From 381146c4f2a10839ac21f75930b1400b7ebf9883 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:56:04 -0300 Subject: [PATCH 1/7] fix: extend new share balances in the same call OpenZeppelin extends a balance or freeze entry when it reads it, not when it creates it, so a new one started at the network's minimum lifetime and the next caller paid to extend it. On testnet that minimum is about seven days, and a transaction simulated before the entry existed then failed for too low a fee; the first redemption, which creates the vault's own share balance, hit this. Every token call that writes a balance or a freeze entry now reads it back through OpenZeppelin's own reads, so a new entry reaches its extend target in the call that creates it. The tests set the ledger's minimum lifetime to testnet's. --- contracts/async-vault/src/test/ttl.rs | 49 +++++ contracts/share-token/src/contract.rs | 16 +- contracts/share-token/src/extend.rs | 18 ++ contracts/share-token/src/lib.rs | 3 + contracts/share-token/src/test_ttl.rs | 284 ++++++++++++++++++++++++++ 5 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 contracts/share-token/src/extend.rs create mode 100644 contracts/share-token/src/test_ttl.rs diff --git a/contracts/async-vault/src/test/ttl.rs b/contracts/async-vault/src/test/ttl.rs index 9bc2ab65..234774a6 100644 --- a/contracts/async-vault/src/test/ttl.rs +++ b/contracts/async-vault/src/test/ttl.rs @@ -1,4 +1,6 @@ use soroban_sdk::testutils::storage::{Instance as _, Persistent as _}; +use soroban_sdk::{IntoVal, Val}; +use stellar_tokens::fungible::{FungibleStorageKey, BALANCE_EXTEND_AMOUNT}; use strata_storage::{INSTANCE_EXTEND_AMOUNT, PERSISTENT_EXTEND_AMOUNT}; use super::*; @@ -6,10 +8,30 @@ use crate::keys::DataKey; 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)) +} + +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 +128,30 @@ 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 + ); +} diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index 28f98927..4617da68 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; @@ -111,6 +112,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, @@ -157,11 +159,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) { @@ -221,11 +225,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 +239,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 +255,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..bc5c65fa --- /dev/null +++ b/contracts/share-token/src/extend.rs @@ -0,0 +1,18 @@ +//! OpenZeppelin extends an entry when it reads it, not when it creates it. + +use soroban_sdk::{Address, Env}; +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); +} 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_ttl.rs b/contracts/share-token/src/test_ttl.rs new file mode 100644 index 00000000..21e16624 --- /dev/null +++ b/contracts/share-token/src/test_ttl.rs @@ -0,0 +1,284 @@ +//! A balance or freeze the token creates reaches its extend target in the call that +//! creates it, even where the network's minimum lifetime is shorter. + +use soroban_sdk::{ + contract, contractimpl, symbol_short, + testutils::{storage::Persistent as _, Address as _, Ledger as _}, + Address, Env, IntoVal, String, Symbol, Val, +}; +use stellar_tokens::{ + fungible::{FungibleStorageKey, BALANCE_EXTEND_AMOUNT}, + rwa::{identity_verification, RWAStorageKey, FROZEN_EXTEND_AMOUNT}, +}; + +use crate::{ShareToken, ShareTokenClient}; +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; + +/// 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, + ) { + } +} + +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())) + } + + /// 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 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 account that sends nothing and +/// held 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 a_network_minimum_longer_than_the_extend_target_is_kept() { + 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); +} From 469031df02b4b568fc33f994ebce2bd7c1aafaae Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:58:29 -0300 Subject: [PATCH 2/7] fix: extend role entries in the granting call A role grant writes up to four persistent entries: the account's role, its place in the member list, the member count and the list of roles. OpenZeppelin extends them when it reads them, not when it creates them, so the first keeper or investor whose call checked the role paid to extend it from the network's minimum. Every grant the vault, the share token and the oracle make (in their constructors, in grant_role and in an applied GrantRole change) now reads those entries back through OpenZeppelin's own reads, so the granter pays and each entry reaches the role lifetime in the same call. --- contracts/async-vault/src/extend.rs | 13 +++ contracts/async-vault/src/governance.rs | 4 +- contracts/async-vault/src/lib.rs | 5 + contracts/async-vault/src/test/ttl.rs | 94 +++++++++++++++++- contracts/nav-oracle/src/extend.rs | 13 +++ contracts/nav-oracle/src/governance.rs | 4 +- contracts/nav-oracle/src/lib.rs | 4 + contracts/nav-oracle/src/test_governance.rs | 103 +++++++++++++++++++- contracts/share-token/src/contract.rs | 10 +- contracts/share-token/src/extend.rs | 12 ++- contracts/share-token/src/governance.rs | 4 +- contracts/share-token/src/test_ttl.rs | 100 ++++++++++++++++++- 12 files changed, 348 insertions(+), 18 deletions(-) create mode 100644 contracts/async-vault/src/extend.rs create mode 100644 contracts/nav-oracle/src/extend.rs 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 234774a6..bab85502 100644 --- a/contracts/async-vault/src/test/ttl.rs +++ b/contracts/async-vault/src/test/ttl.rs @@ -1,10 +1,12 @@ use soroban_sdk::testutils::storage::{Instance as _, Persistent as _}; -use soroban_sdk::{IntoVal, Val}; +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; @@ -19,6 +21,44 @@ fn ttl_in>(e: &Env, contract: &Address, key: &K) -> u32 { e.as_contract(contract, || e.storage().persistent().get_ttl(key)) } +/// OpenZeppelin keeps the type of a role's member-list key private. A type +/// with the same variant name and fields encodes the same key. +#[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, @@ -155,3 +195,55 @@ fn the_first_redemption_writes_the_vaults_share_balance_with_the_full_ttl() { 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..27a675b7 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,97 @@ fn setup_grants_hand_overs_and_changes_restore_the_instance_ttl() { f.restores_the_instance(|| f.oracle.apply_change(&id)); } +/// OpenZeppelin keeps the type of a role's member-list key private. A type +/// with the same variant name and fields encodes the same key. +#[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 4617da68..09abb995 100644 --- a/contracts/share-token/src/contract.rs +++ b/contracts/share-token/src/contract.rs @@ -37,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); @@ -187,6 +184,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, diff --git a/contracts/share-token/src/extend.rs b/contracts/share-token/src/extend.rs index bc5c65fa..352dbaa2 100644 --- a/contracts/share-token/src/extend.rs +++ b/contracts/share-token/src/extend.rs @@ -1,6 +1,7 @@ //! OpenZeppelin extends an entry when it reads it, not when it creates it. -use soroban_sdk::{Address, Env}; +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]) { @@ -16,3 +17,12 @@ pub(crate) fn address_frozen(e: &Env, account: &Address) { 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/test_ttl.rs b/contracts/share-token/src/test_ttl.rs index 21e16624..0d415d07 100644 --- a/contracts/share-token/src/test_ttl.rs +++ b/contracts/share-token/src/test_ttl.rs @@ -1,17 +1,19 @@ -//! A balance or freeze the token creates reaches its extend target in the call that +//! An entry the token creates reaches its extend target in the call that //! creates it, even where the network's minimum lifetime is shorter. use soroban_sdk::{ - contract, contractimpl, symbol_short, + 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}; +use crate::{ShareToken, ShareTokenClient, TokenChange}; use compliance::{Compliance, ComplianceClient}; /// Testnet's minimum lifetime of a new persistent entry, in ledgers. @@ -48,6 +50,36 @@ impl identity_verification::IdentityVerifier for OpenVerifier { } } +/// 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, + } + } +} + +/// OpenZeppelin keeps the type of a role's member-list key private. A type +/// with the same variant name and fields encodes the same key. +#[contracttype] +struct RoleAccountKey { + role: Symbol, + index: u32, +} + +#[contracttype] +enum RoleMemberKey { + RoleAccounts(RoleAccountKey), +} + struct Fixture<'a> { e: Env, token: ShareTokenClient<'a>, @@ -67,6 +99,23 @@ impl Fixture<'_> { 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); @@ -272,6 +321,51 @@ fn a_partial_freeze_writes_its_entry_with_the_full_ttl() { } } +#[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 a_network_minimum_longer_than_the_extend_target_is_kept() { let f = setup(); From df5e9978d10ba873b4f7cdb3c9b56847bc7196cd Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 02:00:16 -0300 Subject: [PATCH 3/7] docs: rewrite the ttl policy for each network The kit spec said OpenZeppelin extends balances when it writes them; it extends them only when it reads them. The TTL policy now says the contracts read back each balance, freeze and role entry they write, and lists each network's minimum lifetime and longest extension (testnet about 7 days, mainnet about 120, read over RPC), what that means on each network, why the kit keeps one set of values everywhere, who pays, and what frontends should do. The testing guide lists the new tests. --- docs/TESTING.md | 11 ++++- docs/kit-spec.md | 119 ++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 110 insertions(+), 20 deletions(-) diff --git a/docs/TESTING.md b/docs/TESTING.md index 68070a7c..a4176f49 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` | +| A network minimum longer than the target, as on mainnet, is kept | [`contracts/share-token/src/test_ttl.rs`](../contracts/share-token/src/test_ttl.rs) | `a_network_minimum_longer_than_the_extend_target_is_kept` | | 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 write. 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 c6a45e72..e0a2a62c 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -312,18 +312,29 @@ 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. A call that reads or writes an entry extends it back to its target +once its TTL has fallen to the threshold. The transaction that creates an entry +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` | 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 role entries each grant writes | Each grant (in the constructor, `grant_role` and an applied `GrantRole` change), then every role check | 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, @@ -336,19 +347,90 @@ 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, and extends each to 30 days when it is read or written. `stellar-access` -0.7.2 stores every contract's roles and extends them to 90 days the same way. -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. +**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 creates 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. 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 touches 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. One call extends each entry it touches to -at most 30 days, so no caller pays for more than 30 days of any entry. - -**Idle deployments.** A deployment nobody calls for 30 days archives. Run +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 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 touches 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 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 @@ -366,8 +448,9 @@ one instance at a time. **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. --- From 151c25334988f219e2f84188abdcbb073b1e1834 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 02:46:00 -0300 Subject: [PATCH 4/7] test: check when mainnet first renews an entry The mainnet test only checked that a new balance starts at the network's 120-day minimum. It now also checks the spec's claim about renewals: a read on day 90 leaves the balance alone, and a read on day 91 renews it to 30 days. A second test does the same for a role, which a role check renews on day 31 and not on day 30. Test comments are one line each. --- contracts/async-vault/src/test/ttl.rs | 3 +- contracts/nav-oracle/src/test_governance.rs | 3 +- contracts/share-token/src/test_ttl.rs | 53 +++++++++++++++++---- docs/TESTING.md | 2 +- 4 files changed, 47 insertions(+), 14 deletions(-) diff --git a/contracts/async-vault/src/test/ttl.rs b/contracts/async-vault/src/test/ttl.rs index bab85502..2a7a7485 100644 --- a/contracts/async-vault/src/test/ttl.rs +++ b/contracts/async-vault/src/test/ttl.rs @@ -21,8 +21,7 @@ fn ttl_in>(e: &Env, contract: &Address, key: &K) -> u32 { e.as_contract(contract, || e.storage().persistent().get_ttl(key)) } -/// OpenZeppelin keeps the type of a role's member-list key private. A type -/// with the same variant name and fields encodes the same key. +/// Mirrors OpenZeppelin's private member-list key: same variant and fields, same encoding. #[contracttype] struct RoleAccountKey { role: Symbol, diff --git a/contracts/nav-oracle/src/test_governance.rs b/contracts/nav-oracle/src/test_governance.rs index 27a675b7..abdf1d10 100644 --- a/contracts/nav-oracle/src/test_governance.rs +++ b/contracts/nav-oracle/src/test_governance.rs @@ -941,8 +941,7 @@ fn setup_grants_hand_overs_and_changes_restore_the_instance_ttl() { f.restores_the_instance(|| f.oracle.apply_change(&id)); } -/// OpenZeppelin keeps the type of a role's member-list key private. A type -/// with the same variant name and fields encodes the same key. +/// Mirrors OpenZeppelin's private member-list key: same variant and fields, same encoding. #[contracttype] struct RoleAccountKey { role: Symbol, diff --git a/contracts/share-token/src/test_ttl.rs b/contracts/share-token/src/test_ttl.rs index 0d415d07..cbbee7c4 100644 --- a/contracts/share-token/src/test_ttl.rs +++ b/contracts/share-token/src/test_ttl.rs @@ -1,5 +1,4 @@ -//! An entry the token creates reaches its extend target in the call that -//! creates it, even where the network's minimum lifetime is shorter. +//! 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, @@ -20,6 +19,7 @@ use compliance::{Compliance, ComplianceClient}; 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] @@ -67,8 +67,7 @@ impl strata_timelock::ClockSource for VaultStub { } } -/// OpenZeppelin keeps the type of a role's member-list key private. A type -/// with the same variant name and fields encodes the same key. +/// Mirrors OpenZeppelin's private member-list key: same variant and fields, same encoding. #[contracttype] struct RoleAccountKey { role: Symbol, @@ -129,6 +128,12 @@ impl Fixture<'_> { 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 { @@ -265,8 +270,7 @@ fn a_recovery_writes_the_freezes_it_carries_with_the_full_ttl() { ); } -/// OpenZeppelin writes a zero balance for an account that sends nothing and -/// held nothing. +/// 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(); @@ -367,12 +371,43 @@ fn a_grant_through_the_timelock_writes_the_role_with_the_full_ttl() { } #[test] -fn a_network_minimum_longer_than_the_extend_target_is_kept() { +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 a4176f49..6c9a596a 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -55,7 +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` | -| A network minimum longer than the target, as on mainnet, is kept | [`contracts/share-token/src/test_ttl.rs`](../contracts/share-token/src/test_ttl.rs) | `a_network_minimum_longer_than_the_extend_target_is_kept` | +| 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. From 891fc79414912e97e8348537eafd00d2700fc1b1 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 02:46:14 -0300 Subject: [PATCH 5/7] docs: say which calls renew each role entry The TTL policy said every role check renews a role's entries and that any read or write extends an entry. A role check extends only that account's role entry; the member-list slot, the member count and the role list are extended only by grants and by calls that list role members. Writing an entry never extends it on its own: the kit's storage helpers extend on every read and write, OpenZeppelin only when it reads an existing entry, so the kit reads back what it creates. A revoke moves entries with plain writes and extends none of them. --- docs/TESTING.md | 2 +- docs/kit-spec.md | 35 ++++++++++++++++++++--------------- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/docs/TESTING.md b/docs/TESTING.md index 6c9a596a..8a23b76d 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -65,7 +65,7 @@ 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 write. Without that, a new entry would start at the network's minimum +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 diff --git a/docs/kit-spec.md b/docs/kit-spec.md index e0a2a62c..5856c667 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -314,12 +314,15 @@ 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. A call that reads or writes an entry extends it back to its target -once its TTL has fallen to the threshold. The transaction that creates an entry -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**). +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 | |---|---|---|---| @@ -328,7 +331,7 @@ settings**). | `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` | 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 role entries each grant writes | Each grant (in the constructor, `grant_role` and an applied `GrantRole` change), then every role check | 90 days (1,555,200 ledgers), once 89 days or less remain | +| `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` (`PERSISTENT_EXTEND_AMOUNT`, `INSTANCE_EXTEND_AMOUNT`, and thresholds one day @@ -352,7 +355,7 @@ 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 creates one, so a new entry would +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: @@ -368,13 +371,15 @@ own reads, in the same call: 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. 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 +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. +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 @@ -392,7 +397,7 @@ 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 touches it renews it. On mainnet a new entry already lives 120 +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 @@ -419,7 +424,7 @@ 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 touches that has fallen to its threshold, so the same call can + 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 From 9516c958bb51de7ef965e80addc06cd19700405c Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:19:04 -0300 Subject: [PATCH 6/7] fix: extend the token instance on balance reads balance and total_supply now extend the share token's instance before they read, like every call that moves shares. A keeper's wind-down round, or a claim that burns nothing, only reads the token, so a quiet wind-down could otherwise let the token's instance archive. --- contracts/share-token/src/contract.rs | 29 ++++++++++++++++++++++++++- contracts/share-token/src/test.rs | 13 ++++++++++++ docs/kit-spec.md | 2 +- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index 09abb995..4090d945 100644 --- a/contracts/share-token/src/contract.rs +++ b/contracts/share-token/src/contract.rs @@ -148,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; @@ -175,6 +176,32 @@ impl FungibleToken for ShareToken { live_until_ledger, ); } + + /// All shares in circulation, in share base units. Reading it extends + /// the token's instance. + /// + /// # Authorization + /// + /// None: anyone may call. + 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. + /// + /// # Authorization + /// + /// None: anyone may call. + fn balance(e: &Env, account: Address) -> i128 { + strata_storage::bump_instance(e); + ::balance(e, &account) + } } #[contractimpl(contracttrait)] 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/docs/kit-spec.md b/docs/kit-spec.md index 5856c667..3a179287 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -328,7 +328,7 @@ how long a new entry lives before any extension (see **Network settings**). |---|---|---|---| | `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` | 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 | From 2dd3ad4bbcae0bcc79e0f7eb03c3fe2e156de804 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:35:08 -0300 Subject: [PATCH 7/7] docs(token): leave the authorization section out of balance and total_supply Both are read-only views, and a view's doc carries no authorization section, like the oracle's views. --- contracts/share-token/src/contract.rs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index 4090d945..6188531e 100644 --- a/contracts/share-token/src/contract.rs +++ b/contracts/share-token/src/contract.rs @@ -179,10 +179,6 @@ impl FungibleToken for ShareToken { /// All shares in circulation, in share base units. Reading it extends /// the token's instance. - /// - /// # Authorization - /// - /// None: anyone may call. fn total_supply(e: &Env) -> i128 { strata_storage::bump_instance(e); ::total_supply(e) @@ -194,10 +190,6 @@ impl FungibleToken for ShareToken { /// # Arguments /// /// * `account` - The holder. - /// - /// # Authorization - /// - /// None: anyone may call. fn balance(e: &Env, account: Address) -> i128 { strata_storage::bump_instance(e); ::balance(e, &account)