diff --git a/contracts/compliance/src/extend.rs b/contracts/compliance/src/extend.rs new file mode 100644 index 00000000..c12778d7 --- /dev/null +++ b/contracts/compliance/src/extend.rs @@ -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()); +} diff --git a/contracts/compliance/src/governance.rs b/contracts/compliance/src/governance.rs index b158b049..85e48f70 100644 --- a/contracts/compliance/src/governance.rs +++ b/contracts/compliance/src/governance.rs @@ -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, @@ -103,12 +104,16 @@ pub(crate) fn apply(e: &Env, id: u32) { queue::take_ready::(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), } diff --git a/contracts/compliance/src/lib.rs b/contracts/compliance/src/lib.rs index a56add4e..36fe8d6c 100644 --- a/contracts/compliance/src/lib.rs +++ b/contracts/compliance/src/lib.rs @@ -21,6 +21,7 @@ #![no_std] +mod extend; mod governance; mod guards; @@ -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 @@ -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 diff --git a/contracts/compliance/src/test.rs b/contracts/compliance/src/test.rs index 35d5d75d..12ce17b8 100644 --- a/contracts/compliance/src/test.rs +++ b/contracts/compliance/src/test.rs @@ -1,5 +1,6 @@ mod changes; mod guards; +mod ttl; use soroban_sdk::{ testutils::{storage::Instance as _, Address as _, Ledger as _}, diff --git a/contracts/compliance/src/test/ttl.rs b/contracts/compliance/src/test/ttl.rs new file mode 100644 index 00000000..704d73d2 --- /dev/null +++ b/contracts/compliance/src/test/ttl.rs @@ -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>(&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 + ); +} diff --git a/docs/TESTING.md b/docs/TESTING.md index 3ebfb0f1..f96d9f8f 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -55,6 +55,7 @@ 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` | @@ -62,14 +63,15 @@ 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 diff --git a/docs/kit-spec.md b/docs/kit-spec.md index b0a6504e..15b09583 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -354,9 +354,8 @@ 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 | |---|---|---|---| @@ -364,7 +363,7 @@ how long a new entry lives before any extension (see **Network settings**). | `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` @@ -401,19 +400,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