Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions contracts/compliance/src/extend.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//! OpenZeppelin extends an entry when it reads it, not when it creates it.

use soroban_sdk::Env;
use stellar_tokens::rwa::{compliance, compliance::ComplianceHook, utils::token_binder};

/// Reads the token count and every bucket, the one a binding wrote included.
pub(crate) fn bound_tokens(e: &Env) {
token_binder::linked_tokens(e);
}

pub(crate) fn modules(e: &Env, hook: &ComplianceHook) {
compliance::storage::get_modules_for_hook(e, hook.clone());
}
9 changes: 7 additions & 2 deletions contracts/compliance/src/governance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use stellar_access::{
use stellar_tokens::rwa::{compliance, compliance::ComplianceHook, utils::token_binder};
use strata_timelock::{queue, setup, Clock, Refusal, TimelockHost};

use crate::extend;
use crate::guards;
use crate::{
ChangeApplied, ChangeCancelled, ChangeProposed, Compliance, ComplianceContractError,
Expand Down Expand Up @@ -103,12 +104,16 @@ pub(crate) fn apply(e: &Env, id: u32) {
queue::take_ready::<Compliance, _>(e, id).unwrap_or_else(|r| refuse(e, r));
match &change {
ComplianceChange::AddModule(hook, module) => {
compliance::storage::add_module_to(e, hook.clone(), module.clone())
compliance::storage::add_module_to(e, hook.clone(), module.clone());
extend::modules(e, hook);
}
ComplianceChange::RemoveModule(hook, module) => {
compliance::storage::remove_module_from(e, hook.clone(), module.clone())
}
ComplianceChange::BindToken(token) => token_binder::bind_token(e, token),
ComplianceChange::BindToken(token) => {
token_binder::bind_token(e, token);
extend::bound_tokens(e);
}
ComplianceChange::UnbindToken(token) => token_binder::unbind_token(e, token),
ComplianceChange::Admin(next) => offer_admin(e, next),
}
Expand Down
5 changes: 4 additions & 1 deletion contracts/compliance/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

#![no_std]

mod extend;
mod governance;
mod guards;

Expand Down Expand Up @@ -365,6 +366,7 @@ impl token_binder::TokenBinder for Compliance {
guards::direct_change_allowed(e);
strata_storage::bump_instance(e);
token_binder::bind_token(e, &token);
extend::bound_tokens(e);
}

/// Unlinks `token`, after which its moves fail the hooks. Instant during
Expand Down Expand Up @@ -426,7 +428,8 @@ impl compliance::Compliance for Compliance {
fn add_module_to(e: &Env, hook: ComplianceHook, module: Address, _operator: Address) {
guards::direct_change_allowed(e);
strata_storage::bump_instance(e);
compliance::storage::add_module_to(e, hook, module);
compliance::storage::add_module_to(e, hook.clone(), module);
extend::modules(e, &hook);
}

/// Removes `module` from `hook`. Instant during setup; after it, propose a
Expand Down
1 change: 1 addition & 0 deletions contracts/compliance/src/test.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
mod changes;
mod guards;
mod ttl;

use soroban_sdk::{
testutils::{storage::Instance as _, Address as _, Ledger as _},
Expand Down
157 changes: 157 additions & 0 deletions contracts/compliance/src/test/ttl.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
//! A new bound-token or module entry gets its full TTL in the call that creates it.

use soroban_sdk::{
contract, contractimpl, contracttype,
testutils::{storage::Persistent as _, Address as _, Ledger as _},
Address, Env, IntoVal, Val,
};
use stellar_tokens::rwa::{
compliance::{storage::ComplianceDataKey, ComplianceHook, COMPLIANCE_EXTEND_AMOUNT},
utils::token_binder::{BUCKET_SIZE, TOKEN_BINDER_EXTEND_AMOUNT},
};
use strata_timelock::{Clock, MIN_DELAY};

use crate::{Compliance, ComplianceChange, ComplianceClient};

/// Testnet's minimum lifetime of a new persistent entry, in ledgers.
const TESTNET_MIN_PERSISTENT_TTL: u32 = 120_960;

/// Plays the vault: its clock lets a timelocked change land.
#[contract]
struct VaultStub;

#[contractimpl(contracttrait)]
impl strata_timelock::ClockSource for VaultStub {
fn timelock_clock(_e: &Env) -> Clock {
Clock {
delay: MIN_DELAY,
exit_window: 0,
paused_secs: 0,
paused: false,
winding_down: false,
}
}
}

/// Mirrors OpenZeppelin's private token-binder key: same variants, same encoding.
#[contracttype]
enum TokenBinderStorageKey {
TokenBucket(u32),
TotalCount,
}

struct Fixture<'a> {
e: Env,
rules: ComplianceClient<'a>,
admin: Address,
}

impl Fixture<'_> {
fn persistent_ttl<K: IntoVal<Env, Val>>(&self, key: &K) -> u32 {
self.e.as_contract(&self.rules.address, || {
self.e.storage().persistent().get_ttl(key)
})
}

/// Every entry a first binding writes: the token count and the first bucket.
fn bound_token_ttls(&self) -> [u32; 2] {
[
self.persistent_ttl(&TokenBinderStorageKey::TotalCount),
self.persistent_ttl(&TokenBinderStorageKey::TokenBucket(0)),
]
}

fn modules_ttl(&self, hook: ComplianceHook) -> u32 {
self.persistent_ttl(&ComplianceDataKey::HookModules(hook))
}

fn land(&self, change: &ComplianceChange) {
let id = self.rules.propose_change(change);
self.e.ledger().with_mut(|l| {
l.timestamp += MIN_DELAY;
l.sequence_number += (MIN_DELAY / 5) as u32;
});
self.rules.apply_change(&id);
}
}

/// A compliance contract in setup on a ledger with testnet's minimum lifetime.
fn setup<'a>() -> Fixture<'a> {
let e = Env::default();
e.mock_all_auths();
e.ledger()
.set_min_persistent_entry_ttl(TESTNET_MIN_PERSISTENT_TTL);
let admin = Address::generate(&e);
let rules = ComplianceClient::new(&e, &e.register(Compliance, (admin.clone(),)));
Fixture { e, rules, admin }
}

fn finished<'a>() -> Fixture<'a> {
let f = setup();
f.rules.bind_vault(&f.e.register(VaultStub, ()));
f.rules.finish_setup();
f
}

#[test]
fn a_binding_during_setup_writes_the_bound_tokens_with_the_full_ttl() {
let f = setup();

f.rules.bind_token(&Address::generate(&f.e), &f.admin);

assert_eq!(f.bound_token_ttls(), [TOKEN_BINDER_EXTEND_AMOUNT; 2]);
}

#[test]
fn a_binding_through_the_timelock_writes_the_bound_tokens_with_the_full_ttl() {
let f = finished();

f.land(&ComplianceChange::BindToken(Address::generate(&f.e)));

assert_eq!(f.bound_token_ttls(), [TOKEN_BINDER_EXTEND_AMOUNT; 2]);
}

#[test]
fn the_binding_that_opens_a_second_bucket_writes_it_with_the_full_ttl() {
let f = setup();

for _ in 0..=BUCKET_SIZE {
f.rules.bind_token(&Address::generate(&f.e), &f.admin);
}

assert_eq!(
f.persistent_ttl(&TokenBinderStorageKey::TokenBucket(1)),
TOKEN_BINDER_EXTEND_AMOUNT
);
}

#[test]
fn a_module_added_during_setup_writes_the_hooks_modules_with_the_full_ttl() {
let f = setup();

f.rules.add_module_to(
&ComplianceHook::CanTransfer,
&Address::generate(&f.e),
&f.admin,
);

assert_eq!(
f.modules_ttl(ComplianceHook::CanTransfer),
COMPLIANCE_EXTEND_AMOUNT
);
}

#[test]
fn a_module_added_through_the_timelock_writes_the_hooks_modules_with_the_full_ttl() {
let f = finished();

f.land(&ComplianceChange::AddModule(
ComplianceHook::Created,
Address::generate(&f.e),
));

assert_eq!(
f.modules_ttl(ComplianceHook::Created),
COMPLIANCE_EXTEND_AMOUNT
);
}
18 changes: 10 additions & 8 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,21 +55,23 @@ contract extends, who pays, and how to restore an archived one.
| The oracle's setup, grant, hand-over and change calls extend its instance | [`contracts/nav-oracle/src/test_governance.rs`](../contracts/nav-oracle/src/test_governance.rs) | `setup_grants_hand_overs_and_changes_restore_the_instance_ttl` |
| Every call that writes a share balance or a freeze gives a new one its full TTL, under testnet's minimum lifetime; so do a deposit claim and the first redemption, through the vault | [`contracts/share-token/src/test_ttl.rs`](../contracts/share-token/src/test_ttl.rs), [`contracts/async-vault/src/test/ttl.rs`](../contracts/async-vault/src/test/ttl.rs) | `a_mint_writes_the_new_balance_with_the_full_ttl`, `an_escrow_writes_the_receiving_balance_with_the_full_ttl`, `a_recovery_writes_the_new_balance_with_the_full_ttl`, `sending_nothing_from_an_empty_account_writes_its_balance_with_the_full_ttl`, `a_partial_freeze_writes_its_entry_with_the_full_ttl`, `the_first_redemption_writes_the_vaults_share_balance_with_the_full_ttl` |
| Every role grant in the vault, the token and the oracle gives the role's entries their full TTL: in the constructor, during setup and through the timelock | [`contracts/share-token/src/test_ttl.rs`](../contracts/share-token/src/test_ttl.rs), [`contracts/async-vault/src/test/ttl.rs`](../contracts/async-vault/src/test/ttl.rs), [`contracts/nav-oracle/src/test_governance.rs`](../contracts/nav-oracle/src/test_governance.rs) | `the_constructor_writes_each_role_with_the_full_ttl`, `a_grant_during_setup_writes_the_role_with_the_full_ttl`, `a_grant_through_the_timelock_writes_the_role_with_the_full_ttl` |
| Binding a token or adding a module gives each new compliance entry its full TTL, under testnet's minimum lifetime: during setup and through the timelock | [`contracts/compliance/src/test/ttl.rs`](../contracts/compliance/src/test/ttl.rs) | `a_binding_during_setup_writes_the_bound_tokens_with_the_full_ttl`, `a_binding_through_the_timelock_writes_the_bound_tokens_with_the_full_ttl`, `the_binding_that_opens_a_second_bucket_writes_it_with_the_full_ttl`, `a_module_added_during_setup_writes_the_hooks_modules_with_the_full_ttl`, `a_module_added_through_the_timelock_writes_the_hooks_modules_with_the_full_ttl` |
| Under mainnet's longer minimum a new entry starts at that minimum; a balance is first renewed on day 91, a role on day 31 | [`contracts/share-token/src/test_ttl.rs`](../contracts/share-token/src/test_ttl.rs) | `under_mainnets_minimum_a_new_balance_is_first_renewed_on_day_91`, `under_mainnets_minimum_a_new_role_is_first_renewed_on_day_31` |
| An epoch that was never written reads as nothing | [`contracts/async-vault/src/test/constructor.rs`](../contracts/async-vault/src/test/constructor.rs) | `unwritten_epoch_reads_as_none`, `epoch_zero_is_the_absent_id` |

The test `Env` does not archive an entry whose TTL runs out; it still reads it.
So these tests check the TTL numbers the contracts set, not what the network
does once an entry is archived.

The share token's balances and freezes, and the roles of the vault, the token
and the oracle, are stored by the OpenZeppelin Stellar library. It extends an
entry when it reads it, not when it creates it, so the contracts read back each
one they create. Without that, a new entry would start at the network's minimum
lifetime. The tests for it set the ledger's minimum to testnet's 120,960
ledgers; the test `Env` defaults to 4,096. OpenZeppelin never extends the
instance, so the token, the identity verifier and the compliance contract
extend their own on every call that does work.
The share token's balances and freezes, the compliance stub's modules and bound
tokens, and the roles of the vault, the token and the oracle, are stored by the
OpenZeppelin Stellar library. It extends an entry when it reads it, not when it
creates it, so the contracts read back each one they create. Without that, a
new entry would start at the network's minimum lifetime. The tests for it set
the ledger's minimum to testnet's 120,960 ledgers; the test `Env` defaults to
4,096. OpenZeppelin never extends the instance, so the token, the identity
verifier and the compliance contract extend their own on every call that does
work.

## Authorization

Expand Down
32 changes: 15 additions & 17 deletions docs/kit-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,17 +349,16 @@ threshold. The kit's storage helpers ask on every read and every write of the
kit's own entries. OpenZeppelin asks when it reads an entry that already
exists, never when it writes one, so the kit reads back each entry it creates
through OpenZeppelin. The transaction that creates an entry therefore extends
it to its target at once; the compliance stub's modules and bound tokens are
the one exception (see **What OpenZeppelin extends**). The network decides only
how long a new entry lives before any extension (see **Network settings**).
it to its target at once. The network decides only how long a new entry lives
before any extension (see **Network settings**).

| Contract | What it extends | When | To how long |
|---|---|---|---|
| `async_vault` | Its instance, and every persistent entry it reads or writes: epochs, requests, settlements, wind-down positions, pending governance changes | Every call that reads or writes the entry | 30 days (518,400 ledgers), once 29 days or less remain |
| `nav_oracle` | Its instance, the latest report, and each pending governance change it reads or writes | `attest` extends the instance and the report; `bind_vault`, `finish_setup`, `grant_role`, `transfer_admin_role`, `propose_change`, `cancel_change` and `apply_change` extend the instance; every other call extends whichever entry it reads or writes | Same |
| `share_token` | Its instance, each balance and freeze entry it writes, and each pending governance change it reads or writes | Every call that moves shares or changes token settings: `transfer`, `transfer_from`, `approve`, `mint`, `burn`, `forced_transfer`, `legal_transfer`, freezes, recovery, `set_compliance`, `set_identity_verifier`, `pause`, `unpause`, `grant_role`, `revoke_role`, `transfer_admin_role`, `bind_vault`, `finish_setup`, `propose_change`, `cancel_change`, `apply_change`; also the reads `balance` and `total_supply`, which may be the only token calls during a wind-down | Same |
| `identity_verifier` | Its instance and each allowlist entry | `allow`, `is_allowed`, `verify_identity` | Same |
| `compliance` | Its instance, and each pending governance change it reads or writes | Every hook the token calls (`can_create`, `can_transfer`, `created`, `transferred`, `destroyed`) and its admin calls | Same |
| `compliance` | Its instance, the modules and bound tokens it writes, and each pending governance change it reads or writes | Every hook the token calls (`can_create`, `can_transfer`, `created`, `transferred`, `destroyed`) and its admin calls | Same |
| `async_vault`, `share_token`, `nav_oracle` | The four entries a grant writes: the account's role, its member-list slot, the member count and the role list | Each grant (in the constructor, `grant_role` and an applied `GrantRole` change) extends all four. After that, a role check extends only that account's role entry; the slot, the count and the list are extended only by grants and by calls that list role members (such as the vault's `manager()` or the token's `get_role_member_count`) | 90 days (1,555,200 ledgers), once 89 days or less remain |

The kit's own lengths are the constants in `crates/strata-storage`
Expand Down Expand Up @@ -396,19 +395,18 @@ own reads, in the same call:
- the vault, the token and the oracle read back every entry a grant writes (the
account's role, its place in the member list, the member count and the list
of roles) after each grant in their constructors, in `grant_role`, and in an
applied `GrantRole` change.

So a new balance, freeze or role entry reaches its target in the transaction
that creates it, and the kit keeps OpenZeppelin's thresholds without copying
them. A revoke creates nothing: it rewrites the member count and moves the last
member into the freed slot with plain writes, and extends none of the entries
it moves. The compliance stub does not read back its modules and bound tokens
when it writes them. The vault's `finish_setup` reads the bound tokens, which
extends them, and every hook the token calls reads its modules; the hooks after
a mint, transfer or burn read the bound tokens too. Allowances are temporary
entries that live until their `live_until_ledger`. OpenZeppelin never extends
an instance; the kit does, as above. The token's total supply and metadata live
in its instance.
applied `GrantRole` change;
- the compliance stub reads back the hook's modules after `add_module_to` and
an applied `AddModule` change, and the bound tokens after `bind_token` and an
applied `BindToken` change.

So a new balance, freeze, role, module or bound-token entry reaches its target
in the transaction that creates it, and the kit keeps OpenZeppelin's thresholds
without copying them. A revoke creates nothing: it rewrites the member count and
moves the last member into the freed slot with plain writes, and extends none
of the entries it moves. Allowances are temporary entries that live until their
`live_until_ledger`. OpenZeppelin never extends an instance; the kit does, as
above. The token's total supply and metadata live in its instance.

**Network settings.** Each network sets how long a new persistent entry lives
and how far one extension can reach. Validators can change these settings by
Expand Down
Loading