diff --git a/contracts/async-vault/src/extend.rs b/contracts/async-vault/src/extend.rs new file mode 100644 index 00000000..2de355a3 --- /dev/null +++ b/contracts/async-vault/src/extend.rs @@ -0,0 +1,13 @@ +//! OpenZeppelin extends a role entry when it reads it, not when it creates it. + +use soroban_sdk::{Address, Env, Symbol}; +use stellar_access::access_control; + +/// The role, its member-list slot, the member count and the role list: all a grant writes. +pub(crate) fn role_entries(e: &Env, account: &Address, role: &Symbol) { + if let Some(index) = access_control::has_role(e, account, role) { + access_control::get_role_member(e, role, index); + } + access_control::get_role_member_count(e, role); + access_control::get_existing_roles(e); +} diff --git a/contracts/async-vault/src/governance.rs b/contracts/async-vault/src/governance.rs index 47909e2e..f0904ced 100644 --- a/contracts/async-vault/src/governance.rs +++ b/contracts/async-vault/src/governance.rs @@ -22,6 +22,7 @@ use strata_timelock::{queue, setup, Clock, Refusal}; use crate::error::VaultError; use crate::event::{ChangeApplied, ChangeCancelled, ChangeProposed, NoticeSet, SetupFinished}; +use crate::extend; use crate::guards; use crate::keys::DataKey; use crate::state; @@ -247,7 +248,8 @@ pub(crate) fn apply(e: &Env, id: u32) { VaultChange::Notice(secs) => set_notice(e, *secs), VaultChange::WindDownDelay(secs) => wind_down::set_delay(e, *secs), VaultChange::GrantRole(role, account) => { - access_control::grant_role_no_auth(e, account, role, &admin) + access_control::grant_role_no_auth(e, account, role, &admin); + extend::role_entries(e, account, role); } VaultChange::RevokeRole(role, account) => { access_control::revoke_role_no_auth(e, account, role, &admin) diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 1dcb990a..72fb288c 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -4,6 +4,7 @@ mod deposit; mod epoch; mod error; mod event; +mod extend; mod governance; mod guards; mod health; @@ -86,8 +87,11 @@ impl AsyncVault { access_control::set_admin(e, &roles.governance); access_control::grant_role_no_auth(e, &roles.manager, &MANAGER_ROLE, &roles.governance); + extend::role_entries(e, &roles.manager, &MANAGER_ROLE); access_control::grant_role_no_auth(e, &roles.treasury, &TREASURY_ROLE, &roles.governance); + extend::role_entries(e, &roles.treasury, &TREASURY_ROLE); access_control::grant_role_no_auth(e, &roles.guardian, &GUARDIAN_ROLE, &roles.governance); + extend::role_entries(e, &roles.guardian, &GUARDIAN_ROLE); state::set_addr(e, &DataKey::Asset, &asset); state::set_addr(e, &DataKey::ShareToken, &share_token); @@ -404,6 +408,7 @@ impl AccessControl for AsyncVault { fn grant_role(e: &Env, account: Address, role: Symbol, caller: Address) { guards::direct_change_allowed(e); access_control::grant_role(e, &account, &role, &caller); + extend::role_entries(e, &account, &role); } /// Instant for every role but `manager`: taking `treasury` or `guardian` diff --git a/contracts/async-vault/src/test/ttl.rs b/contracts/async-vault/src/test/ttl.rs index 9bc2ab65..2a7a7485 100644 --- a/contracts/async-vault/src/test/ttl.rs +++ b/contracts/async-vault/src/test/ttl.rs @@ -1,15 +1,76 @@ use soroban_sdk::testutils::storage::{Instance as _, Persistent as _}; +use soroban_sdk::{contracttype, IntoVal, Symbol, Val}; +use stellar_access::access_control::{AccessControlStorageKey, ROLE_EXTEND_AMOUNT}; +use stellar_tokens::fungible::{FungibleStorageKey, BALANCE_EXTEND_AMOUNT}; use strata_storage::{INSTANCE_EXTEND_AMOUNT, PERSISTENT_EXTEND_AMOUNT}; use super::*; use crate::keys::DataKey; +use crate::roles::{GUARDIAN_ROLE, MANAGER_ROLE, TREASURY_ROLE}; const TWO_DAYS: u64 = 2 * DAY_DURATION; +/// Testnet's minimum lifetime of a new persistent entry, in ledgers. +const TESTNET_MIN_PERSISTENT_TTL: u32 = 120_960; + fn persistent_ttl(f: &Fixture, key: &DataKey) -> u32 { f.e.as_contract(&f.vault.address, || f.e.storage().persistent().get_ttl(key)) } +fn ttl_in>(e: &Env, contract: &Address, key: &K) -> u32 { + e.as_contract(contract, || e.storage().persistent().get_ttl(key)) +} + +/// Mirrors OpenZeppelin's private member-list key: same variant and fields, same encoding. +#[contracttype] +struct RoleAccountKey { + role: Symbol, + index: u32, +} + +#[contracttype] +enum RoleMemberKey { + RoleAccounts(RoleAccountKey), +} + +/// Every entry a grant writes: the role, its member-list slot, the member count, the role list. +fn role_entry_ttls(e: &Env, contract: &Address, account: &Address, role: &Symbol) -> [u32; 4] { + let has_role = AccessControlStorageKey::HasRole(account.clone(), role.clone()); + let index: u32 = e.as_contract(contract, || { + e.storage().persistent().get(&has_role).unwrap() + }); + [ + ttl_in(e, contract, &has_role), + ttl_in( + e, + contract, + &RoleMemberKey::RoleAccounts(RoleAccountKey { + role: role.clone(), + index, + }), + ), + ttl_in( + e, + contract, + &AccessControlStorageKey::RoleAccountsCount(role.clone()), + ), + ttl_in(e, contract, &AccessControlStorageKey::ExistingRoles), + ] +} + +fn share_balance_ttl(f: &Fixture, account: &Address) -> u32 { + ttl_in( + &f.e, + &f.share.address, + &FungibleStorageKey::Balance(account.clone()), + ) +} + +fn testnet_minimum(e: &Env) { + e.ledger() + .set_min_persistent_entry_ttl(TESTNET_MIN_PERSISTENT_TTL); +} + fn instance_ttl(f: &Fixture) -> u32 { f.e.as_contract(&f.vault.address, || f.e.storage().instance().get_ttl()) } @@ -106,3 +167,82 @@ fn a_pending_request_still_claims_after_most_of_a_month() { assert_eq!(f.vault.claim_deposit(&user, &epoch), 200); } + +#[test] +fn a_deposit_claim_writes_the_new_share_balance_with_the_full_ttl() { + let f = setup(); + let user = f.investor(1_000); + let epoch = f.vault.request_deposit(&user, &400); + f.fulfill_epoch(wad(2)); + testnet_minimum(&f.e); + + f.vault.claim_deposit(&user, &epoch); + + assert_eq!(share_balance_ttl(&f, &user), BALANCE_EXTEND_AMOUNT); +} + +#[test] +fn the_first_redemption_writes_the_vaults_share_balance_with_the_full_ttl() { + let f = setup(); + let holder = f.holder(100); + testnet_minimum(&f.e); + + f.vault.request_redeem(&holder, &100); + + assert_eq!( + share_balance_ttl(&f, &f.vault.address), + BALANCE_EXTEND_AMOUNT + ); +} + +#[test] +fn the_constructor_writes_each_role_with_the_full_ttl() { + let e = Env::default(); + testnet_minimum(&e); + let roles = distinct_roles(&e); + + let vault = register_with(&e, roles.clone()); + + for (account, role) in [ + (&roles.manager, MANAGER_ROLE), + (&roles.treasury, TREASURY_ROLE), + (&roles.guardian, GUARDIAN_ROLE), + ] { + assert_eq!( + role_entry_ttls(&e, &vault, account, &role), + [ROLE_EXTEND_AMOUNT; 4] + ); + } +} + +#[test] +fn a_grant_during_setup_writes_the_role_with_the_full_ttl() { + let f = setup_unfinished(); + let next = Address::generate(&f.e); + testnet_minimum(&f.e); + + f.vault.grant_role(&next, &GUARDIAN_ROLE, &f.admin); + + assert_eq!( + role_entry_ttls(&f.e, &f.vault.address, &next, &GUARDIAN_ROLE), + [ROLE_EXTEND_AMOUNT; 4] + ); +} + +#[test] +fn a_grant_through_the_timelock_writes_the_role_with_the_full_ttl() { + let f = setup(); + let next = Address::generate(&f.e); + let id = f + .vault + .propose_change(&VaultChange::GrantRole(GUARDIAN_ROLE, next.clone())); + f.advance(MIN_UPGRADE_DELAY); + testnet_minimum(&f.e); + + f.vault.apply_change(&id); + + assert_eq!( + role_entry_ttls(&f.e, &f.vault.address, &next, &GUARDIAN_ROLE), + [ROLE_EXTEND_AMOUNT; 4] + ); +} diff --git a/contracts/nav-oracle/src/extend.rs b/contracts/nav-oracle/src/extend.rs new file mode 100644 index 00000000..2de355a3 --- /dev/null +++ b/contracts/nav-oracle/src/extend.rs @@ -0,0 +1,13 @@ +//! OpenZeppelin extends a role entry when it reads it, not when it creates it. + +use soroban_sdk::{Address, Env, Symbol}; +use stellar_access::access_control; + +/// The role, its member-list slot, the member count and the role list: all a grant writes. +pub(crate) fn role_entries(e: &Env, account: &Address, role: &Symbol) { + if let Some(index) = access_control::has_role(e, account, role) { + access_control::get_role_member(e, role, index); + } + access_control::get_role_member_count(e, role); + access_control::get_existing_roles(e); +} diff --git a/contracts/nav-oracle/src/governance.rs b/contracts/nav-oracle/src/governance.rs index 153bc649..2ceab364 100644 --- a/contracts/nav-oracle/src/governance.rs +++ b/contracts/nav-oracle/src/governance.rs @@ -6,6 +6,7 @@ use stellar_access::{ use stellar_contract_utils::upgradeable; use strata_timelock::{queue, setup, Clock, Refusal, TimelockHost}; +use crate::extend; use crate::guards; use crate::state::OracleConfig; use crate::{ @@ -121,7 +122,8 @@ pub(crate) fn apply(e: &Env, id: u32) { match &change { OracleChange::Upgrade(hash) => upgradeable::upgrade(e, hash), OracleChange::GrantRole(role, account) => { - access_control::grant_role_no_auth(e, account, role, &admin) + access_control::grant_role_no_auth(e, account, role, &admin); + extend::role_entries(e, account, role); } OracleChange::RevokeRole(role, account) => { access_control::revoke_role_no_auth(e, account, role, &admin) diff --git a/contracts/nav-oracle/src/lib.rs b/contracts/nav-oracle/src/lib.rs index d7c7d4e3..f94deabb 100644 --- a/contracts/nav-oracle/src/lib.rs +++ b/contracts/nav-oracle/src/lib.rs @@ -1,5 +1,6 @@ #![no_std] +mod extend; mod governance; mod guards; mod state; @@ -189,6 +190,7 @@ impl AccessControl for NavOracleContract { guards::direct_change_allowed(e); strata_storage::bump_instance(e); access_control::grant_role(e, &account, &role, &caller); + extend::role_entries(e, &account, &role); } /// Cancelling a pending hand-over (`live_until_ledger == 0`) keeps the @@ -236,7 +238,9 @@ impl NavOracleContract { guards::valid_config(e, &config); access_control::set_admin(e, &admin); access_control::grant_role_no_auth(e, &attester, &ATTESTER_ROLE, &admin); + extend::role_entries(e, &attester, &ATTESTER_ROLE); access_control::grant_role_no_auth(e, &guardian, &GUARDIAN_ROLE, &admin); + extend::role_entries(e, &guardian, &GUARDIAN_ROLE); state::set_config(e, &config); state::set_nav_paused(e, false); diff --git a/contracts/nav-oracle/src/test_governance.rs b/contracts/nav-oracle/src/test_governance.rs index 6a64dec0..abdf1d10 100644 --- a/contracts/nav-oracle/src/test_governance.rs +++ b/contracts/nav-oracle/src/test_governance.rs @@ -1,13 +1,15 @@ extern crate std; use soroban_sdk::{ - contract, contractimpl, symbol_short, + contract, contractimpl, contracttype, symbol_short, testutils::{ - storage::Instance as _, Address as _, AuthorizedFunction, AuthorizedInvocation, - Events as _, Ledger as _, MockAuth, MockAuthInvoke, + storage::{Instance as _, Persistent as _}, + Address as _, AuthorizedFunction, AuthorizedInvocation, Events as _, Ledger as _, MockAuth, + MockAuthInvoke, }, - Address, BytesN, Env, Event as _, IntoVal, Symbol, + Address, BytesN, Env, Event as _, IntoVal, Symbol, Val, }; +use stellar_access::access_control::{AccessControlStorageKey, ROLE_EXTEND_AMOUNT}; use stellar_access::role_transfer::RoleTransferError; use strata_storage::INSTANCE_EXTEND_AMOUNT; use strata_timelock::{Clock, DAY_DURATION, MIN_DELAY}; @@ -21,6 +23,8 @@ use crate::{ const SCALE: i128 = 1_000_000_000_000_000_000; const TWO_DAYS_IN_LEDGERS: u32 = 2 * 17_280; +/// Testnet's minimum lifetime of a new persistent entry, in ledgers. +const TESTNET_MIN_PERSISTENT_TTL: u32 = 120_960; /// Plays the vault: reports whatever clock a test sets, and counts the reads. #[contract] @@ -937,6 +941,96 @@ fn setup_grants_hand_overs_and_changes_restore_the_instance_ttl() { f.restores_the_instance(|| f.oracle.apply_change(&id)); } +/// Mirrors OpenZeppelin's private member-list key: same variant and fields, same encoding. +#[contracttype] +struct RoleAccountKey { + role: Symbol, + index: u32, +} + +#[contracttype] +enum RoleMemberKey { + RoleAccounts(RoleAccountKey), +} + +impl Fixture<'_> { + fn persistent_ttl>(&self, key: &K) -> u32 { + self.e.as_contract(&self.oracle.address, || { + self.e.storage().persistent().get_ttl(key) + }) + } + + /// Every entry a grant writes: the role, its member-list slot, the member count, the role list. + fn role_entry_ttls(&self, account: &Address, role: &Symbol) -> [u32; 4] { + let has_role = AccessControlStorageKey::HasRole(account.clone(), role.clone()); + let index: u32 = self.e.as_contract(&self.oracle.address, || { + self.e.storage().persistent().get(&has_role).unwrap() + }); + [ + self.persistent_ttl(&has_role), + self.persistent_ttl(&RoleMemberKey::RoleAccounts(RoleAccountKey { + role: role.clone(), + index, + })), + self.persistent_ttl(&AccessControlStorageKey::RoleAccountsCount(role.clone())), + self.persistent_ttl(&AccessControlStorageKey::ExistingRoles), + ] + } +} + +fn testnet_minimum(e: &Env) { + e.ledger() + .set_min_persistent_entry_ttl(TESTNET_MIN_PERSISTENT_TTL); +} + +#[test] +fn the_constructor_writes_each_role_with_the_full_ttl() { + let f = setup_with(|e, args| { + testnet_minimum(e); + e.register(NavOracleContract, args) + }); + + assert_eq!( + f.role_entry_ttls(&f.attester, &ATTESTER_ROLE), + [ROLE_EXTEND_AMOUNT; 4] + ); + assert_eq!( + f.role_entry_ttls(&f.guardian, &GUARDIAN_ROLE), + [ROLE_EXTEND_AMOUNT; 4] + ); +} + +#[test] +fn a_grant_during_setup_writes_the_role_with_the_full_ttl() { + let f = setup(); + let next = Address::generate(&f.e); + testnet_minimum(&f.e); + + f.oracle.grant_role(&next, &GUARDIAN_ROLE, &f.admin); + + assert_eq!( + f.role_entry_ttls(&next, &GUARDIAN_ROLE), + [ROLE_EXTEND_AMOUNT; 4] + ); +} + +#[test] +fn a_grant_through_the_timelock_writes_the_role_with_the_full_ttl() { + let f = setup(); + f.finish(); + let next = Address::generate(&f.e); + let id = f.propose(OracleChange::GrantRole(GUARDIAN_ROLE, next.clone())); + f.advance(MIN_DELAY); + testnet_minimum(&f.e); + + f.oracle.apply_change(&id); + + assert_eq!( + f.role_entry_ttls(&next, &GUARDIAN_ROLE), + [ROLE_EXTEND_AMOUNT; 4] + ); +} + #[test] fn each_admin_call_asks_the_admin_for_one_signature() { let f = setup(); diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index 28f98927..6188531e 100644 --- a/contracts/share-token/src/contract.rs +++ b/contracts/share-token/src/contract.rs @@ -1,5 +1,6 @@ use crate::error::ShareTokenError; use crate::event::LegalTransfer; +use crate::extend; use crate::governance::{self, TokenChange, TokenPendingChange}; use crate::guards; use crate::roles::COMPLIANCE_ROLE; @@ -36,12 +37,9 @@ impl ShareToken { ) { Base::set_metadata(e, 7, name, symbol); access_control::set_admin(e, &admin); - access_control::grant_role_no_auth( - e, - &compliance_authority, - &Symbol::new(e, COMPLIANCE_ROLE), - &admin, - ); + let compliance_role = Symbol::new(e, COMPLIANCE_ROLE); + access_control::grant_role_no_auth(e, &compliance_authority, &compliance_role, &admin); + extend::role_entries(e, &compliance_authority, &compliance_role); RWA::set_compliance(e, &compliance); RWA::set_identity_verifier(e, &identity_verifier); upgradeable::set_schema_version(e, 1); @@ -111,6 +109,7 @@ impl ShareToken { guards::identity_verified(e, &to); strata_storage::bump_instance(e); RWA::forced_transfer(e, &from, &to, amount); + extend::balances(e, &[&from, &to]); LegalTransfer { from, to, @@ -149,7 +148,8 @@ impl Pausable for ShareToken { } /// OpenZeppelin leaves the instance's lifetime to the token, so every call -/// that moves shares extends it before handing over to the library. +/// that moves shares extends it before handing over to the library. So do +/// `balance` and `total_supply`, which may be the only calls during a wind-down. #[contractimpl(contracttrait)] impl FungibleToken for ShareToken { type ContractType = RWA; @@ -157,11 +157,13 @@ impl FungibleToken for ShareToken { fn transfer(e: &Env, from: Address, to: MuxedAddress, amount: i128) { strata_storage::bump_instance(e); ::transfer(e, &from, &to, amount); + extend::balances(e, &[&from, &to.address()]); } fn transfer_from(e: &Env, spender: Address, from: Address, to: Address, amount: i128) { strata_storage::bump_instance(e); ::transfer_from(e, &spender, &from, &to, amount); + extend::balances(e, &[&from, &to]); } fn approve(e: &Env, owner: Address, spender: Address, amount: i128, live_until_ledger: u32) { @@ -174,6 +176,24 @@ impl FungibleToken for ShareToken { live_until_ledger, ); } + + /// All shares in circulation, in share base units. Reading it extends + /// the token's instance. + fn total_supply(e: &Env) -> i128 { + strata_storage::bump_instance(e); + ::total_supply(e) + } + + /// The shares `account` holds, in share base units. Reading it extends + /// the token's instance. + /// + /// # Arguments + /// + /// * `account` - The holder. + fn balance(e: &Env, account: Address) -> i128 { + strata_storage::bump_instance(e); + ::balance(e, &account) + } } #[contractimpl(contracttrait)] @@ -183,6 +203,7 @@ impl AccessControl for ShareToken { guards::direct_change_allowed(e); strata_storage::bump_instance(e); access_control::grant_role(e, &account, &role, &caller); + extend::role_entries(e, &account, &role); } /// Instant for `compliance`. Revoking `manager` waits like a grant, @@ -221,11 +242,13 @@ impl RWAToken for ShareToken { fn mint(e: &Env, to: Address, amount: i128, operator: Address) { strata_storage::bump_instance(e); RWA::mint(e, &to, amount); + extend::balances(e, &[&to]); } #[only_role(operator, "manager")] fn burn(e: &Env, user_address: Address, amount: i128, operator: Address) { strata_storage::bump_instance(e); RWA::burn(e, &user_address, amount); + extend::balances(e, &[&user_address]); } /// The vault escrows redemptions through it. Compliance moves shares with /// `legal_transfer` instead. @@ -233,6 +256,7 @@ impl RWAToken for ShareToken { fn forced_transfer(e: &Env, from: Address, to: Address, amount: i128, operator: Address) { strata_storage::bump_instance(e); RWA::forced_transfer(e, &from, &to, amount); + extend::balances(e, &[&from, &to]); } /// Moves a whole balance to the account the identity verifier names, so /// it needs the admin's co-signature like a legal transfer, and never @@ -248,24 +272,31 @@ impl RWAToken for ShareToken { guards::not_vault_address(e, &old_account); guards::not_vault_address(e, &new_account); strata_storage::bump_instance(e); - RWA::recover_balance(e, &old_account, &new_account) + let recovered = RWA::recover_balance(e, &old_account, &new_account); + extend::balances(e, &[&new_account]); + extend::address_frozen(e, &new_account); + extend::frozen_tokens(e, &new_account); + recovered } #[only_role(operator, "compliance")] fn set_address_frozen(e: &Env, user_address: Address, freeze: bool, operator: Address) { guards::not_freezing_vault(e, &user_address, freeze); strata_storage::bump_instance(e); RWA::set_address_frozen(e, &user_address, freeze); + extend::address_frozen(e, &user_address); } #[only_role(operator, "compliance")] fn freeze_partial_tokens(e: &Env, user_address: Address, amount: i128, operator: Address) { guards::not_vault_address(e, &user_address); strata_storage::bump_instance(e); RWA::freeze_partial_tokens(e, &user_address, amount); + extend::frozen_tokens(e, &user_address); } #[only_role(operator, "compliance")] fn unfreeze_partial_tokens(e: &Env, user_address: Address, amount: i128, operator: Address) { strata_storage::bump_instance(e); RWA::unfreeze_partial_tokens(e, &user_address, amount); + extend::frozen_tokens(e, &user_address); } fn set_compliance(e: &Env, compliance: Address, operator: Address) { guards::caller_is_admin(e, &operator); diff --git a/contracts/share-token/src/extend.rs b/contracts/share-token/src/extend.rs new file mode 100644 index 00000000..352dbaa2 --- /dev/null +++ b/contracts/share-token/src/extend.rs @@ -0,0 +1,28 @@ +//! OpenZeppelin extends an entry when it reads it, not when it creates it. + +use soroban_sdk::{Address, Env, Symbol}; +use stellar_access::access_control; +use stellar_tokens::{fungible::Base, rwa::RWA}; + +pub(crate) fn balances(e: &Env, accounts: &[&Address]) { + for account in accounts { + Base::balance(e, account); + } +} + +pub(crate) fn address_frozen(e: &Env, account: &Address) { + RWA::is_frozen(e, account); +} + +pub(crate) fn frozen_tokens(e: &Env, account: &Address) { + RWA::get_frozen_tokens(e, account); +} + +/// The role, its member-list slot, the member count and the role list: all a grant writes. +pub(crate) fn role_entries(e: &Env, account: &Address, role: &Symbol) { + if let Some(index) = access_control::has_role(e, account, role) { + access_control::get_role_member(e, role, index); + } + access_control::get_role_member_count(e, role); + access_control::get_existing_roles(e); +} diff --git a/contracts/share-token/src/governance.rs b/contracts/share-token/src/governance.rs index bc4aa2ca..970d073a 100644 --- a/contracts/share-token/src/governance.rs +++ b/contracts/share-token/src/governance.rs @@ -9,6 +9,7 @@ use strata_timelock::{queue, setup, Clock, Refusal, TimelockHost}; use crate::error::ShareTokenError; use crate::event::{ChangeApplied, ChangeCancelled, ChangeProposed, SetupFinished, VaultBound}; +use crate::extend; use crate::guards; use crate::ShareToken; @@ -106,7 +107,8 @@ pub(crate) fn apply(e: &Env, id: u32) { match &change { TokenChange::Upgrade(hash) => upgradeable::upgrade(e, hash), TokenChange::GrantRole(role, account) => { - access_control::grant_role_no_auth(e, account, role, &admin) + access_control::grant_role_no_auth(e, account, role, &admin); + extend::role_entries(e, account, role); } TokenChange::RevokeRole(role, account) => { access_control::revoke_role_no_auth(e, account, role, &admin) diff --git a/contracts/share-token/src/lib.rs b/contracts/share-token/src/lib.rs index 5dbd9b0e..a9f55cdd 100644 --- a/contracts/share-token/src/lib.rs +++ b/contracts/share-token/src/lib.rs @@ -2,6 +2,7 @@ mod contract; mod error; mod event; +mod extend; mod governance; mod guards; mod roles; @@ -18,3 +19,5 @@ mod test; mod test_governance; #[cfg(test)] mod test_guards; +#[cfg(test)] +mod test_ttl; diff --git a/contracts/share-token/src/test.rs b/contracts/share-token/src/test.rs index 52137224..8052833e 100644 --- a/contracts/share-token/src/test.rs +++ b/contracts/share-token/src/test.rs @@ -556,6 +556,19 @@ fn a_legal_transfer_restores_the_instance_ttl() { assert_eq!(instance_ttl(&e, &f.token.address), INSTANCE_EXTEND_AMOUNT); } +#[test] +fn reading_a_balance_or_the_supply_restores_the_instance_ttl() { + let e = Env::default(); + let (f, holder, _) = decayed_live_token(&e); + + f.token.balance(&holder); + assert_eq!(instance_ttl(&e, &f.token.address), INSTANCE_EXTEND_AMOUNT); + + age(&e, TWO_DAYS_IN_LEDGERS); + f.token.total_supply(); + assert_eq!(instance_ttl(&e, &f.token.address), INSTANCE_EXTEND_AMOUNT); +} + #[test] fn each_role_call_asks_its_holder_for_one_signature() { let e = Env::default(); diff --git a/contracts/share-token/src/test_ttl.rs b/contracts/share-token/src/test_ttl.rs new file mode 100644 index 00000000..cbbee7c4 --- /dev/null +++ b/contracts/share-token/src/test_ttl.rs @@ -0,0 +1,413 @@ +//! A new balance, freeze or role entry gets its full TTL in the call that creates it. + +use soroban_sdk::{ + contract, contractimpl, contracttype, symbol_short, + testutils::{storage::Persistent as _, Address as _, Ledger as _}, + Address, Env, IntoVal, String, Symbol, Val, +}; +use stellar_access::access_control::{AccessControlStorageKey, ROLE_EXTEND_AMOUNT}; +use stellar_tokens::{ + fungible::{FungibleStorageKey, BALANCE_EXTEND_AMOUNT}, + rwa::{identity_verification, RWAStorageKey, FROZEN_EXTEND_AMOUNT}, +}; +use strata_timelock::{Clock, MIN_DELAY}; + +use crate::{ShareToken, ShareTokenClient, TokenChange}; +use compliance::{Compliance, ComplianceClient}; + +/// Testnet's minimum lifetime of a new persistent entry, in ledgers. +const TESTNET_MIN_PERSISTENT_TTL: u32 = 120_960; +/// Mainnet's, in ledgers: longer than any extend target. +const MAINNET_MIN_PERSISTENT_TTL: u32 = 2_073_600; +const DAY_IN_LEDGERS: u32 = 17_280; + +/// Accepts every account and names the recovery target a test sets. +#[contract] +struct OpenVerifier; + +#[contractimpl] +impl OpenVerifier { + pub fn set_recovery_target(e: Env, target: Address) { + e.storage() + .instance() + .set(&symbol_short!("target"), &target); + } +} + +#[contractimpl(contracttrait)] +impl identity_verification::IdentityVerifier for OpenVerifier { + fn verify_identity(_e: &Env, _account: &Address) {} + + fn recovery_target(e: &Env, _old_account: &Address) -> Option
{ + e.storage().instance().get(&symbol_short!("target")) + } + + fn set_claim_topics_and_issuers( + _e: &Env, + _claim_topics_and_issuers: Address, + _operator: Address, + ) { + } +} + +/// 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 member-list key: same variant and fields, same encoding. +#[contracttype] +struct RoleAccountKey { + role: Symbol, + index: u32, +} + +#[contracttype] +enum RoleMemberKey { + RoleAccounts(RoleAccountKey), +} + +struct Fixture<'a> { + e: Env, + token: ShareTokenClient<'a>, + verifier: OpenVerifierClient<'a>, + admin: Address, + compliance_authority: Address, +} + +impl Fixture<'_> { + fn persistent_ttl>(&self, key: &K) -> u32 { + self.e.as_contract(&self.token.address, || { + self.e.storage().persistent().get_ttl(key) + }) + } + + fn balance_ttl(&self, account: &Address) -> u32 { + self.persistent_ttl(&FungibleStorageKey::Balance(account.clone())) + } + + /// Every entry a grant writes: the role, its member-list slot, the member count, the role list. + fn role_entry_ttls(&self, account: &Address, role: &Symbol) -> [u32; 4] { + let has_role = AccessControlStorageKey::HasRole(account.clone(), role.clone()); + let index: u32 = self.e.as_contract(&self.token.address, || { + self.e.storage().persistent().get(&has_role).unwrap() + }); + [ + self.persistent_ttl(&has_role), + self.persistent_ttl(&RoleMemberKey::RoleAccounts(RoleAccountKey { + role: role.clone(), + index, + })), + self.persistent_ttl(&AccessControlStorageKey::RoleAccountsCount(role.clone())), + self.persistent_ttl(&AccessControlStorageKey::ExistingRoles), + ] + } + + /// A manager to mint with, and a holder of `shares`. + fn holder(&self, shares: i128) -> (Address, Address) { + let manager = Address::generate(&self.e); + self.token + .grant_role(&manager, &manager_role(), &self.admin); + let holder = Address::generate(&self.e); + self.token.mint(&holder, &shares, &manager); + (manager, holder) + } + + fn order(&self) -> String { + String::from_str(&self.e, "court-order-2026-117") + } + + fn age_days(&self, days: u32) { + self.e + .ledger() + .with_mut(|l| l.sequence_number += days * DAY_IN_LEDGERS); + } +} + +fn manager_role() -> Symbol { + symbol_short!("manager") +} + +/// A token in setup on a ledger with testnet's minimum lifetime; its verifier accepts everyone. +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 compliance_authority = Address::generate(&e); + let rules = ComplianceClient::new(&e, &e.register(Compliance, (admin.clone(),))); + let verifier = OpenVerifierClient::new(&e, &e.register(OpenVerifier, ())); + let token = ShareTokenClient::new( + &e, + &e.register( + ShareToken, + ( + String::from_str(&e, "Strata Vault USDC"), + String::from_str(&e, "bvUSDC"), + admin.clone(), + compliance_authority.clone(), + rules.address.clone(), + verifier.address.clone(), + ), + ), + ); + rules.bind_token(&token.address, &admin); + Fixture { + e, + token, + verifier, + admin, + compliance_authority, + } +} + +#[test] +fn a_mint_writes_the_new_balance_with_the_full_ttl() { + let f = setup(); + + let (_, holder) = f.holder(100); + + assert_eq!(f.balance_ttl(&holder), BALANCE_EXTEND_AMOUNT); +} + +#[test] +fn a_transfer_writes_the_new_balance_with_the_full_ttl() { + let f = setup(); + let (_, holder) = f.holder(100); + let to = Address::generate(&f.e); + + f.token.transfer(&holder, &to, &40); + + assert_eq!(f.balance_ttl(&to), BALANCE_EXTEND_AMOUNT); +} + +#[test] +fn a_transfer_from_writes_the_new_balance_with_the_full_ttl() { + let f = setup(); + let (_, holder) = f.holder(100); + let spender = Address::generate(&f.e); + let to = Address::generate(&f.e); + f.token + .approve(&holder, &spender, &40, &(f.e.ledger().sequence() + 100)); + + f.token.transfer_from(&spender, &holder, &to, &40); + + assert_eq!(f.balance_ttl(&to), BALANCE_EXTEND_AMOUNT); +} + +#[test] +fn an_escrow_writes_the_receiving_balance_with_the_full_ttl() { + let f = setup(); + let (manager, holder) = f.holder(100); + let vault = Address::generate(&f.e); + + f.token.forced_transfer(&holder, &vault, &40, &manager); + + assert_eq!(f.balance_ttl(&vault), BALANCE_EXTEND_AMOUNT); +} + +#[test] +fn a_legal_transfer_writes_the_new_balance_with_the_full_ttl() { + let f = setup(); + let (_, holder) = f.holder(100); + let to = Address::generate(&f.e); + + f.token + .legal_transfer(&holder, &to, &40, &f.order(), &f.compliance_authority); + + assert_eq!(f.balance_ttl(&to), BALANCE_EXTEND_AMOUNT); +} + +#[test] +fn a_recovery_writes_the_new_balance_with_the_full_ttl() { + let f = setup(); + let (_, holder) = f.holder(100); + let new_account = Address::generate(&f.e); + f.verifier.set_recovery_target(&new_account); + + assert!(f + .token + .recover_balance(&holder, &new_account, &f.compliance_authority)); + + assert_eq!(f.balance_ttl(&new_account), BALANCE_EXTEND_AMOUNT); +} + +#[test] +fn a_recovery_writes_the_freezes_it_carries_with_the_full_ttl() { + let f = setup(); + let (_, holder) = f.holder(100); + f.token + .freeze_partial_tokens(&holder, &30, &f.compliance_authority); + f.token + .set_address_frozen(&holder, &true, &f.compliance_authority); + let new_account = Address::generate(&f.e); + f.verifier.set_recovery_target(&new_account); + + assert!(f + .token + .recover_balance(&holder, &new_account, &f.compliance_authority)); + + assert_eq!( + f.persistent_ttl(&RWAStorageKey::FrozenTokens(new_account.clone())), + FROZEN_EXTEND_AMOUNT + ); + assert_eq!( + f.persistent_ttl(&RWAStorageKey::AddressFrozen(new_account)), + FROZEN_EXTEND_AMOUNT + ); +} + +/// OpenZeppelin writes a zero balance for an empty account that sends nothing. +#[test] +fn sending_nothing_from_an_empty_account_writes_its_balance_with_the_full_ttl() { + let f = setup(); + let (manager, _) = f.holder(100); + let to = Address::generate(&f.e); + let empty = || Address::generate(&f.e); + let (by_transfer, by_transfer_from, by_escrow, by_order, by_burn) = + (empty(), empty(), empty(), empty(), empty()); + + f.token.transfer(&by_transfer, &to, &0); + f.token.transfer_from(&to, &by_transfer_from, &to, &0); + f.token.forced_transfer(&by_escrow, &to, &0, &manager); + f.token + .legal_transfer(&by_order, &to, &0, &f.order(), &f.compliance_authority); + f.token.burn(&by_burn, &0, &manager); + + for account in [by_transfer, by_transfer_from, by_escrow, by_order, by_burn] { + assert_eq!(f.balance_ttl(&account), BALANCE_EXTEND_AMOUNT); + } +} + +#[test] +fn a_freeze_writes_its_entry_with_the_full_ttl() { + let f = setup(); + let account = Address::generate(&f.e); + + f.token + .set_address_frozen(&account, &true, &f.compliance_authority); + + assert_eq!( + f.persistent_ttl(&RWAStorageKey::AddressFrozen(account)), + FROZEN_EXTEND_AMOUNT + ); +} + +#[test] +fn a_partial_freeze_writes_its_entry_with_the_full_ttl() { + let f = setup(); + let (_, holder) = f.holder(100); + let empty = Address::generate(&f.e); + + f.token + .freeze_partial_tokens(&holder, &30, &f.compliance_authority); + f.token + .unfreeze_partial_tokens(&empty, &0, &f.compliance_authority); + + for account in [holder, empty] { + assert_eq!( + f.persistent_ttl(&RWAStorageKey::FrozenTokens(account)), + FROZEN_EXTEND_AMOUNT + ); + } +} + +#[test] +fn the_constructor_writes_the_compliance_role_with_the_full_ttl() { + let f = setup(); + + assert_eq!( + f.role_entry_ttls(&f.compliance_authority, &Symbol::new(&f.e, "compliance")), + [ROLE_EXTEND_AMOUNT; 4] + ); +} + +#[test] +fn a_grant_during_setup_writes_the_role_with_the_full_ttl() { + let f = setup(); + let vault = Address::generate(&f.e); + + f.token.grant_role(&vault, &manager_role(), &f.admin); + + assert_eq!( + f.role_entry_ttls(&vault, &manager_role()), + [ROLE_EXTEND_AMOUNT; 4] + ); +} + +#[test] +fn a_grant_through_the_timelock_writes_the_role_with_the_full_ttl() { + let f = setup(); + f.token.bind_vault(&f.e.register(VaultStub, ())); + f.token.finish_setup(); + let next = Address::generate(&f.e); + let id = f + .token + .propose_change(&TokenChange::GrantRole(manager_role(), next.clone())); + f.e.ledger().with_mut(|l| { + l.timestamp += MIN_DELAY; + l.sequence_number += (MIN_DELAY / 5) as u32; + }); + + f.token.apply_change(&id); + + assert_eq!( + f.role_entry_ttls(&next, &manager_role()), + [ROLE_EXTEND_AMOUNT; 4] + ); +} + +#[test] +fn under_mainnets_minimum_a_new_balance_is_first_renewed_on_day_91() { + let f = setup(); + f.e.ledger() + .set_min_persistent_entry_ttl(MAINNET_MIN_PERSISTENT_TTL); + let (_, holder) = f.holder(100); + assert_eq!(f.balance_ttl(&holder), MAINNET_MIN_PERSISTENT_TTL - 1); + + f.age_days(90); + f.token.balance(&holder); + assert_eq!( + f.balance_ttl(&holder), + MAINNET_MIN_PERSISTENT_TTL - 1 - 90 * DAY_IN_LEDGERS + ); + + f.age_days(1); + f.token.balance(&holder); + assert_eq!(f.balance_ttl(&holder), BALANCE_EXTEND_AMOUNT); +} + +#[test] +fn under_mainnets_minimum_a_new_role_is_first_renewed_on_day_31() { + let f = setup(); + f.e.ledger() + .set_min_persistent_entry_ttl(MAINNET_MIN_PERSISTENT_TTL); + let vault = Address::generate(&f.e); + f.token.grant_role(&vault, &manager_role(), &f.admin); + let has_role = AccessControlStorageKey::HasRole(vault.clone(), manager_role()); + assert_eq!(f.persistent_ttl(&has_role), MAINNET_MIN_PERSISTENT_TTL - 1); + + f.age_days(30); + f.token.has_role(&vault, &manager_role()); + assert_eq!( + f.persistent_ttl(&has_role), + MAINNET_MIN_PERSISTENT_TTL - 1 - 30 * DAY_IN_LEDGERS + ); + + f.age_days(1); + f.token.has_role(&vault, &manager_role()); + assert_eq!(f.persistent_ttl(&has_role), ROLE_EXTEND_AMOUNT); +} diff --git a/docs/TESTING.md b/docs/TESTING.md index b9c61278..ef67d718 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -53,14 +53,21 @@ contract extends, who pays, and how to restore an archived one. | A request still claims, and a wind-down position still pays, after most of a month | [`contracts/async-vault/src/test/ttl.rs`](../contracts/async-vault/src/test/ttl.rs), [`contracts/async-vault/src/test/wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs) | `a_pending_request_still_claims_after_most_of_a_month`, `a_position_survives_the_ledger_advancing` | | The oracle's latest valuation keeps a full TTL while it is read | [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs) | `the_latest_valuation_keeps_a_full_ttl_while_it_is_read` | | 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` | +| 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 roles are stored by the OpenZeppelin Stellar -library, which extends them when they are read or written. It never extends the +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. diff --git a/docs/kit-spec.md b/docs/kit-spec.md index ee946928..1af200f4 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -317,18 +317,32 @@ Soroban archives a ledger entry when its time to live (TTL) runs out. An archived entry cannot be read until it is restored, so a contract whose instance is archived refuses every call. +The kit runs the same code on every network. Each persistent entry, the +contract instance included, has a target lifetime and a threshold one day +shorter. Writing an entry does not extend it on its own: an entry goes back to +its target only when a call asks for that and its TTL has fallen to the +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**). + | 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 less than 29 days remain | +| `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, 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` | 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 | +| `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 lengths are the constants in `crates/strata-storage` +The kit's own lengths are the constants in `crates/strata-storage` (`PERSISTENT_EXTEND_AMOUNT`, `INSTANCE_EXTEND_AMOUNT`, and thresholds one day -shorter). They are fixed in code: a governance setting would add an admin power -for little gain. +shorter). The entries OpenZeppelin stores keep OpenZeppelin's own lengths. All +of them are fixed in code: a governance setting would add an admin power for +little gain. **Pending governance changes.** A change waiting in the timelock of `async_vault`, `share_token`, `nav_oracle` or `compliance` lives as a persistent entry, @@ -341,37 +355,92 @@ its `pending_changes()` view, builds each one's storage key the way the timelock crate does, and extends it the same 30 days as every instance and Wasm. -**What OpenZeppelin extends.** `stellar-tokens` 0.7.2 stores the share token's -balances and frozen amounts, and the compliance contract's modules and bound -tokens. `stellar-access` 0.7.2 stores every contract's roles. OpenZeppelin -extends an existing entry when it reads it: balances, frozen amounts, modules -and bound tokens to 30 days, roles to 90. It does not extend an entry when it -writes one, so a new entry starts at the network's minimum lifetime (see -**Network settings**) and waits for a later caller to read it. 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 a restored entry comes back with the same lifetime: 120,960 ledgers (about -7 days) on testnet, 2,073,600 ledgers (about 120 days) on mainnet. Validators -can change these values by vote, so the kit does not hardcode them; check +**What OpenZeppelin extends, and what the kit adds.** `stellar-tokens` 0.7.2 +stores the share token's balances and freeze entries, and the compliance +contract's modules and bound tokens. `stellar-access` 0.7.2 stores the roles of +the vault, the token and the oracle. OpenZeppelin extends an existing entry +when it reads it: balances, freezes, modules and bound tokens to 30 days, roles +to 90. It does not extend an entry when it writes one, so a new entry would +start at the network's minimum lifetime and wait for a later caller to read it. +The kit closes that gap by reading back what it writes, through OpenZeppelin's +own reads, in the same call: + +- the share token reads back each balance and freeze entry after `mint`, + `burn`, `transfer`, `transfer_from`, `forced_transfer`, `legal_transfer`, + `recover_balance`, `set_address_frozen`, `freeze_partial_tokens` and + `unfreeze_partial_tokens`; +- 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. + +**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 +vote; the kit hardcodes neither. Check [Stellar Lab's network limits](https://lab.stellar.org/network-limits) for current values. +| Setting | Testnet | Mainnet | +|---|---|---| +| Minimum lifetime of a new persistent entry | 120,960 ledgers (about 7 days) | 2,073,600 ledgers (about 120 days) | +| Longest lifetime one extension can reach | 3,110,400 ledgers (about 180 days) | 3,110,400 ledgers (about 180 days) | + +Read over RPC on 2026-10-20. + +**What this means on each network.** On testnet a new entry would live about 7 +days, less than any threshold, so renewals start in its first week: the +transaction that creates it extends it to its target, and from the next day any +call that reads it renews it. On mainnet a new entry already lives 120 +days, more than any target, so the extension in the creating transaction +changes nothing. No call renews a 30-day entry there until about the fourth +month (day 91), or a role until day 31. A restored entry comes back with the +network's minimum lifetime, so a mainnet restore pays at least 120 days of +rent. + +**Why the same values on every network.** The protocol already applies each +network's minimum, so mainnet entries start at 120 days without the kit knowing +it. The same Wasm runs on testnet and on mainnet, so what is tested on testnet +is what ships; a build-time network profile would mean two Wasm files to +review. The threshold one day under the target means an entry in daily use is +renewed at most once a day, for about a day of rent. Capping each extension +(`extend_ttl_with_limits`) would let rarely used entries archive sooner, and +each of those restores would pay at least 120 days of rent on mainnet. + **Who pays.** The account that sends a transaction pays the rent for every -extension that transaction triggers. A call pays only to bring an entry back +extension that transaction triggers. The transaction that creates an entry pays +for its target, or for the network's minimum when that is longer: 30 or 90 days +on testnet, 120 days on mainnet. A later call pays only to bring an entry back to its target once it has fallen to its threshold: about a day of rent when -the entry is read daily, up to a whole target (30 days, or 90 for a role) after -a long quiet period. Creating or restoring an entry pays for at least the -network's minimum lifetime, 120 days on mainnet. The kit does not cap each -extension with `extend_ttl_with_limits`: a cap would let rarely used entries -archive sooner, and each of those restores pays at least 120 days of rent on -mainnet. +the entry is read daily, up to a whole target after a long quiet period. A +restore pays for the network's minimum, as above. + +**What frontends should do.** + +- Expect uneven renewal fees. The first call after a quiet period pays to renew + every entry it reads that has fallen to its threshold, so the same call can + cost more after a quiet week than on a busy day. +- Simulate again just before submitting. The fee comes from the simulation. If + an entry falls to its threshold between simulation and submission, the call + pays a renewal the fee does not cover, and the transaction fails. +- Let RPC add a restore when simulation asks for one. When a call needs an + archived entry, simulation adds its restore to the transaction (see + **Restoring**). Submit the transaction as simulated, restore included. **Idle deployments.** An entry nobody reads archives when its lifetime runs -out: 30 days after it was last extended (90 for a role), or the network's -minimum lifetime after it was created, whichever is later. Run +out: 30 days after its last renewal (90 for a role), and on mainnet never +sooner than 120 days after it was created. Run `STELLAR_ACCOUNT= npm run extend -w scripts/harness` after every deploy and before a long quiet period. It restores, when archived, and extends by 30 days three kinds of entry only: each contract instance, the Wasm @@ -392,8 +461,9 @@ Wasm. After a restore, simulate the call again before sending it. **Production modules.** The compliance and identity contracts in this repository are testnet stubs. A production compliance module or identity -registry must extend its own instance and entries the same way, or the share -token stops working when they archive. +registry must extend its own instance and entries the same way, each entry in +the transaction that creates it, or the share token stops working when they +archive. ---