diff --git a/Cargo.lock b/Cargo.lock index 67427429..21750d9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -354,6 +354,7 @@ dependencies = [ "stellar-access", "stellar-macros", "stellar-tokens", + "storage", ] [[package]] @@ -1712,6 +1713,7 @@ dependencies = [ "stellar-contract-utils", "stellar-macros", "stellar-tokens", + "storage", ] [[package]] diff --git a/contracts/compliance/Cargo.toml b/contracts/compliance/Cargo.toml index 203d3b86..dbc70e3f 100644 --- a/contracts/compliance/Cargo.toml +++ b/contracts/compliance/Cargo.toml @@ -14,3 +14,7 @@ soroban-sdk = { workspace = true } stellar-access = { workspace = true } stellar-macros = { workspace = true } stellar-tokens = { workspace = true } +storage = { path = "../../crates/storage" } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/compliance/src/lib.rs b/contracts/compliance/src/lib.rs index 5946ddf3..7f59fdad 100644 --- a/contracts/compliance/src/lib.rs +++ b/contracts/compliance/src/lib.rs @@ -19,6 +19,7 @@ pub struct Compliance; impl Compliance { pub fn __constructor(e: &Env, admin: Address) { access_control::set_admin(e, &admin); + storage::bump_instance(e); } } @@ -26,24 +27,58 @@ impl Compliance { impl token_binder::TokenBinder for Compliance { #[only_admin] fn bind_token(e: &Env, token: Address, _operator: Address) { + storage::bump_instance(e); token_binder::bind_token(e, &token); } #[only_admin] fn unbind_token(e: &Env, token: Address, _operator: Address) { + storage::bump_instance(e); token_binder::unbind_token(e, &token); } } +/// The token calls the check before and the hook after every mint, burn and +/// transfer, which is what keeps this instance alive. #[contractimpl(contracttrait)] impl compliance::Compliance for Compliance { #[only_admin] fn add_module_to(e: &Env, hook: ComplianceHook, module: Address, _operator: Address) { + storage::bump_instance(e); compliance::storage::add_module_to(e, hook, module); } #[only_admin] fn remove_module_from(e: &Env, hook: ComplianceHook, module: Address, _operator: Address) { + storage::bump_instance(e); compliance::storage::remove_module_from(e, hook, module); } + + fn transferred(e: &Env, from: Address, to: Address, amount: i128, token: Address) { + storage::bump_instance(e); + compliance::storage::transferred(e, from, to, amount, token); + } + + fn created(e: &Env, to: Address, amount: i128, token: Address) { + storage::bump_instance(e); + compliance::storage::created(e, to, amount, token); + } + + fn destroyed(e: &Env, from: Address, amount: i128, token: Address) { + storage::bump_instance(e); + compliance::storage::destroyed(e, from, amount, token); + } + + fn can_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) -> bool { + storage::bump_instance(e); + compliance::storage::can_transfer(e, from, to, amount, token) + } + + fn can_create(e: &Env, to: Address, amount: i128, token: Address) -> bool { + storage::bump_instance(e); + compliance::storage::can_create(e, to, amount, token) + } } + +#[cfg(test)] +mod test; diff --git a/contracts/compliance/src/test.rs b/contracts/compliance/src/test.rs new file mode 100644 index 00000000..8e479e6c --- /dev/null +++ b/contracts/compliance/src/test.rs @@ -0,0 +1,73 @@ +use soroban_sdk::{ + testutils::{storage::Instance as _, Address as _, Ledger as _}, + Address, Env, +}; +use storage::INSTANCE_EXTEND_AMOUNT; + +use crate::{Compliance, ComplianceClient}; + +const TWO_DAYS_IN_LEDGERS: u32 = 2 * 17_280; + +fn instance_ttl(e: &Env, id: &Address) -> u32 { + e.as_contract(id, || e.storage().instance().get_ttl()) +} + +fn age(e: &Env) { + e.ledger() + .with_mut(|l| l.sequence_number += TWO_DAYS_IN_LEDGERS); +} + +/// A compliance contract with one bound token, its instance decayed past the +/// extend threshold. +fn decayed(e: &Env) -> (ComplianceClient<'_>, Address) { + e.mock_all_auths(); + let admin = Address::generate(e); + let token = Address::generate(e); + let client = ComplianceClient::new(e, &e.register(Compliance, (admin.clone(),))); + client.bind_token(&token, &admin); + age(e); + assert!(instance_ttl(e, &client.address) < INSTANCE_EXTEND_AMOUNT); + (client, token) +} + +#[test] +fn construction_writes_the_instance_with_the_full_ttl() { + let e = Env::default(); + let id = e.register(Compliance, (Address::generate(&e),)); + + assert_eq!(instance_ttl(&e, &id), INSTANCE_EXTEND_AMOUNT); +} + +#[test] +fn the_checks_a_token_runs_before_moving_shares_restore_the_instance_ttl() { + let e = Env::default(); + let (client, token) = decayed(&e); + let holder = Address::generate(&e); + let other = Address::generate(&e); + + assert!(client.can_create(&holder, &1, &token)); + assert_eq!(instance_ttl(&e, &client.address), INSTANCE_EXTEND_AMOUNT); + + age(&e); + assert!(client.can_transfer(&holder, &other, &1, &token)); + assert_eq!(instance_ttl(&e, &client.address), INSTANCE_EXTEND_AMOUNT); +} + +#[test] +fn the_hooks_a_token_calls_after_moving_shares_restore_the_instance_ttl() { + let e = Env::default(); + let (client, token) = decayed(&e); + let holder = Address::generate(&e); + let other = Address::generate(&e); + + client.created(&holder, &1, &token); + assert_eq!(instance_ttl(&e, &client.address), INSTANCE_EXTEND_AMOUNT); + + age(&e); + client.transferred(&holder, &other, &1, &token); + assert_eq!(instance_ttl(&e, &client.address), INSTANCE_EXTEND_AMOUNT); + + age(&e); + client.destroyed(&other, &1, &token); + assert_eq!(instance_ttl(&e, &client.address), INSTANCE_EXTEND_AMOUNT); +} diff --git a/contracts/identity-verifier/src/lib.rs b/contracts/identity-verifier/src/lib.rs index 5c3ba014..4206c639 100644 --- a/contracts/identity-verifier/src/lib.rs +++ b/contracts/identity-verifier/src/lib.rs @@ -26,23 +26,29 @@ pub struct IdentityVerifier; impl IdentityVerifier { pub fn __constructor(e: &Env, admin: Address) { access_control::set_admin(e, &admin); + storage::bump_instance(e); } /// Adds or removes an account from the allowlist. #[only_admin] pub fn allow(e: &Env, account: Address, allowed: bool, caller: Address) { caller.require_auth(); + storage::bump_instance(e); storage::set_persistent(e, &DataKey::Allowed(account), &allowed); } pub fn is_allowed(e: &Env, account: Address) -> bool { + storage::bump_instance(e); storage::get_persistent(e, &DataKey::Allowed(account)).unwrap_or(false) } } #[contractimpl(contracttrait)] impl identity_verification::IdentityVerifier for IdentityVerifier { + /// The share token calls this on every mint and transfer, so it is what + /// keeps the instance alive. fn verify_identity(e: &Env, account: &Address) { + storage::bump_instance(e); let allowed: bool = storage::get_persistent(e, &DataKey::Allowed(account.clone())).unwrap_or(false); if !allowed { diff --git a/contracts/identity-verifier/src/test.rs b/contracts/identity-verifier/src/test.rs index de865bfe..221bf836 100644 --- a/contracts/identity-verifier/src/test.rs +++ b/contracts/identity-verifier/src/test.rs @@ -1,9 +1,9 @@ use super::*; use soroban_sdk::{ - testutils::{storage::Persistent as _, Address as _, Ledger as _}, + testutils::{storage::Instance as _, storage::Persistent as _, Address as _, Ledger as _}, Address, Env, }; -use storage::PERSISTENT_EXTEND_AMOUNT; +use storage::{INSTANCE_EXTEND_AMOUNT, PERSISTENT_EXTEND_AMOUNT}; const DAY_IN_LEDGERS: u32 = 17_280; use stellar_tokens::rwa::identity_verification::IdentityVerifierClient as IdClient; @@ -103,3 +103,38 @@ fn checking_an_allowed_account_restores_the_full_ttl() { IdClient::new(&e, &id).verify_identity(&investor); assert_eq!(allowed_ttl(&e, &id, &investor), PERSISTENT_EXTEND_AMOUNT); } + +fn instance_ttl(e: &Env, id: &Address) -> u32 { + e.as_contract(id, || e.storage().instance().get_ttl()) +} + +#[test] +fn construction_writes_the_instance_with_the_full_ttl() { + let e = Env::default(); + let id = e.register(IdentityVerifier, (Address::generate(&e),)); + + assert_eq!(instance_ttl(&e, &id), INSTANCE_EXTEND_AMOUNT); +} + +#[test] +fn allowing_and_checking_restore_the_instance_ttl() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let investor = Address::generate(&e); + let id = e.register(IdentityVerifier, (admin.clone(),)); + let client = IdentityVerifierClient::new(&e, &id); + + age(&e, 2 * DAY_IN_LEDGERS); + assert!(instance_ttl(&e, &id) < INSTANCE_EXTEND_AMOUNT); + client.allow(&investor, &true, &admin); + assert_eq!(instance_ttl(&e, &id), INSTANCE_EXTEND_AMOUNT); + + age(&e, 2 * DAY_IN_LEDGERS); + IdClient::new(&e, &id).verify_identity(&investor); + assert_eq!(instance_ttl(&e, &id), INSTANCE_EXTEND_AMOUNT); + + age(&e, 2 * DAY_IN_LEDGERS); + client.is_allowed(&investor); + assert_eq!(instance_ttl(&e, &id), INSTANCE_EXTEND_AMOUNT); +} diff --git a/contracts/share-token/Cargo.toml b/contracts/share-token/Cargo.toml index 3aecab2c..d06350fc 100644 --- a/contracts/share-token/Cargo.toml +++ b/contracts/share-token/Cargo.toml @@ -15,6 +15,7 @@ stellar-access = { workspace = true } stellar-macros = { workspace = true } stellar-tokens = { workspace = true } stellar-contract-utils = { workspace = true } +storage = { path = "../../crates/storage" } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index e69d419a..7cd1c853 100644 --- a/contracts/share-token/src/contract.rs +++ b/contracts/share-token/src/contract.rs @@ -8,7 +8,7 @@ use stellar_access::access_control::{self as access_control, AccessControl}; use stellar_contract_utils::pausable::{self as pausable, Pausable}; use stellar_macros::{only_admin, only_any_role, only_role}; use stellar_tokens::{ - fungible::{Base, FungibleToken}, + fungible::{Base, ContractOverrides, FungibleToken}, rwa::{RWAToken, RWA}, }; @@ -36,6 +36,7 @@ impl ShareToken { ); RWA::set_compliance(e, &compliance); RWA::set_identity_verifier(e, &identity_verifier); + storage::bump_instance(e); } } @@ -44,18 +45,43 @@ impl Pausable for ShareToken { #[only_admin] fn pause(e: &Env, caller: Address) { caller.require_auth(); + storage::bump_instance(e); pausable::pause(e); } #[only_admin] fn unpause(e: &Env, caller: Address) { caller.require_auth(); + storage::bump_instance(e); pausable::unpause(e); } } +/// OpenZeppelin leaves the instance's lifetime to the token, so every call +/// that moves shares extends it before handing over to the library. #[contractimpl(contracttrait)] impl FungibleToken for ShareToken { type ContractType = RWA; + + fn transfer(e: &Env, from: Address, to: MuxedAddress, amount: i128) { + storage::bump_instance(e); + ::transfer(e, &from, &to, amount); + } + + fn transfer_from(e: &Env, spender: Address, from: Address, to: Address, amount: i128) { + storage::bump_instance(e); + ::transfer_from(e, &spender, &from, &to, amount); + } + + fn approve(e: &Env, owner: Address, spender: Address, amount: i128, live_until_ledger: u32) { + storage::bump_instance(e); + ::approve( + e, + &owner, + &spender, + amount, + live_until_ledger, + ); + } } #[contractimpl(contracttrait)] @@ -72,15 +98,18 @@ impl AccessControl for ShareToken { impl RWAToken for ShareToken { #[only_role(operator, "manager")] fn mint(e: &Env, to: Address, amount: i128, operator: Address) { + storage::bump_instance(e); RWA::mint(e, &to, amount); } #[only_role(operator, "manager")] fn burn(e: &Env, user_address: Address, amount: i128, operator: Address) { + storage::bump_instance(e); RWA::burn(e, &user_address, amount); } /// The vault escrows redemptions through it; compliance intervenes with it. #[only_any_role(operator, ["manager", "compliance"])] fn forced_transfer(e: &Env, from: Address, to: Address, amount: i128, operator: Address) { + storage::bump_instance(e); RWA::forced_transfer(e, &from, &to, amount); } #[only_role(operator, "compliance")] @@ -90,28 +119,34 @@ impl RWAToken for ShareToken { new_account: Address, operator: Address, ) -> bool { + storage::bump_instance(e); RWA::recover_balance(e, &old_account, &new_account) } #[only_role(operator, "compliance")] fn set_address_frozen(e: &Env, user_address: Address, freeze: bool, operator: Address) { + storage::bump_instance(e); RWA::set_address_frozen(e, &user_address, freeze); } #[only_role(operator, "compliance")] fn freeze_partial_tokens(e: &Env, user_address: Address, amount: i128, operator: Address) { + storage::bump_instance(e); RWA::freeze_partial_tokens(e, &user_address, amount); } #[only_role(operator, "compliance")] fn unfreeze_partial_tokens(e: &Env, user_address: Address, amount: i128, operator: Address) { + storage::bump_instance(e); RWA::unfreeze_partial_tokens(e, &user_address, amount); } #[only_admin] fn set_compliance(e: &Env, compliance: Address, operator: Address) { operator.require_auth(); + storage::bump_instance(e); RWA::set_compliance(e, &compliance); } #[only_admin] fn set_identity_verifier(e: &Env, identity_verifier: Address, operator: Address) { operator.require_auth(); + storage::bump_instance(e); RWA::set_identity_verifier(e, &identity_verifier); } } diff --git a/contracts/share-token/src/test.rs b/contracts/share-token/src/test.rs index 8c45c7a6..947798ef 100644 --- a/contracts/share-token/src/test.rs +++ b/contracts/share-token/src/test.rs @@ -1,10 +1,11 @@ use soroban_sdk::{ symbol_short, - testutils::{Address as _, MockAuth, MockAuthInvoke}, + testutils::{storage::Instance as _, Address as _, Ledger as _, MockAuth, MockAuthInvoke}, Address, Env, IntoVal, String, Symbol, }; use stellar_access::access_control::{self as access_control, AccessControlError}; use stellar_tokens::rwa::RWAError; +use storage::INSTANCE_EXTEND_AMOUNT; use crate::{ShareToken, ShareTokenClient}; use compliance::{Compliance, ComplianceClient}; @@ -413,3 +414,99 @@ fn the_constructor_grants_the_compliance_role_and_no_manager() { .has_role(&f.admin, &symbol_short!("manager")) .is_none()); } + +const TWO_DAYS_IN_LEDGERS: u32 = 2 * 17_280; + +fn instance_ttl(e: &Env, id: &Address) -> u32 { + e.as_contract(id, || e.storage().instance().get_ttl()) +} + +fn age(e: &Env, ledgers: u32) { + e.ledger().with_mut(|l| l.sequence_number += ledgers); +} + +/// A live token whose instance has decayed past the extend threshold, with +/// two allowlisted holders, the first holding 100 shares. +fn decayed_live_token(e: &Env) -> (Fixture<'_>, Address, Address) { + let (f, identity) = setup_live(e); + let holder = Address::generate(e); + let other = Address::generate(e); + identity.allow(&holder, &true, &f.admin); + identity.allow(&other, &true, &f.admin); + f.token.mint(&holder, &100, &f.manager); + + age(e, TWO_DAYS_IN_LEDGERS); + assert!(instance_ttl(e, &f.token.address) < INSTANCE_EXTEND_AMOUNT); + (f, holder, other) +} + +#[test] +fn construction_writes_the_instance_with_the_full_ttl() { + let e = Env::default(); + let f = setup_fixture(&e); + + assert_eq!(instance_ttl(&e, &f.token.address), INSTANCE_EXTEND_AMOUNT); +} + +#[test] +fn a_transfer_restores_the_instance_ttl() { + let e = Env::default(); + let (f, holder, other) = decayed_live_token(&e); + + f.token.transfer(&holder, &other, &10); + + assert_eq!(instance_ttl(&e, &f.token.address), INSTANCE_EXTEND_AMOUNT); +} + +#[test] +fn a_transfer_from_restores_the_instance_ttl() { + let e = Env::default(); + let (f, holder, other) = decayed_live_token(&e); + let live_until = e.ledger().sequence() + 3 * 17_280; + f.token.approve(&holder, &other, &10, &live_until); + age(&e, TWO_DAYS_IN_LEDGERS); + assert!(instance_ttl(&e, &f.token.address) < INSTANCE_EXTEND_AMOUNT); + + f.token.transfer_from(&other, &holder, &other, &10); + + assert_eq!(instance_ttl(&e, &f.token.address), INSTANCE_EXTEND_AMOUNT); +} + +#[test] +fn an_approval_restores_the_instance_ttl() { + let e = Env::default(); + let (f, holder, other) = decayed_live_token(&e); + + let live_until = e.ledger().sequence() + 100; + f.token.approve(&holder, &other, &10, &live_until); + + assert_eq!(instance_ttl(&e, &f.token.address), INSTANCE_EXTEND_AMOUNT); +} + +#[test] +fn the_vaults_mint_burn_and_escrow_restore_the_instance_ttl() { + let e = Env::default(); + let (f, holder, other) = decayed_live_token(&e); + + f.token.mint(&other, &5, &f.manager); + assert_eq!(instance_ttl(&e, &f.token.address), INSTANCE_EXTEND_AMOUNT); + + age(&e, TWO_DAYS_IN_LEDGERS); + f.token.forced_transfer(&holder, &other, &5, &f.manager); + assert_eq!(instance_ttl(&e, &f.token.address), INSTANCE_EXTEND_AMOUNT); + + age(&e, TWO_DAYS_IN_LEDGERS); + f.token.burn(&other, &5, &f.manager); + assert_eq!(instance_ttl(&e, &f.token.address), INSTANCE_EXTEND_AMOUNT); +} + +#[test] +fn compliance_interventions_restore_the_instance_ttl() { + let e = Env::default(); + let (f, holder, _) = decayed_live_token(&e); + + f.token + .set_address_frozen(&holder, &true, &f.compliance_authority); + + assert_eq!(instance_ttl(&e, &f.token.address), INSTANCE_EXTEND_AMOUNT); +} diff --git a/docs/TESTING.md b/docs/TESTING.md index 477899c5..deb3a060 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -27,6 +27,7 @@ than a day short. The instance follows the same rule. | Persistent entries round-trip and get the full TTL on write | [`crates/storage/src/test.rs`](../crates/storage/src/test.rs) | `persistent_round_trips`, `writing_sets_the_full_ttl` | | A read restores a decayed entry; a missing entry is not created | [`crates/storage/src/test.rs`](../crates/storage/src/test.rs) | `reading_a_decayed_entry_restores_the_full_ttl`, `reading_a_missing_entry_creates_nothing` | | Instance writes and reads extend the contract instance | [`crates/storage/src/test.rs`](../crates/storage/src/test.rs) | `instance_writes_bump_the_instance`, `reading_the_instance_restores_its_ttl` | +| The share token, identity verifier and compliance contract extend their own instance on every call that does work | [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs), [`contracts/identity-verifier/src/test.rs`](../contracts/identity-verifier/src/test.rs), [`contracts/compliance/src/test.rs`](../contracts/compliance/src/test.rs) | `a_transfer_restores_the_instance_ttl`, `the_vaults_mint_burn_and_escrow_restore_the_instance_ttl`, `allowing_and_checking_restore_the_instance_ttl`, `the_hooks_a_token_calls_after_moving_shares_restore_the_instance_ttl` | | Vault requests and epochs are written with the full TTL | [`contracts/async-vault/src/test/ttl.rs`](../contracts/async-vault/src/test/ttl.rs) | `a_request_and_its_epoch_are_written_with_the_full_ttl`, `a_redemption_request_is_written_with_the_full_ttl` | | Reading a vault request, epoch or the instance restores its TTL | [`contracts/async-vault/src/test/ttl.rs`](../contracts/async-vault/src/test/ttl.rs) | `reading_a_decayed_request_restores_its_full_ttl`, `reading_a_decayed_epoch_restores_its_full_ttl`, `any_vault_read_restores_the_instance_ttl` | | 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` | @@ -38,7 +39,9 @@ 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 manages their TTL itself. +library, which extends them when they are read or written. It never extends the +instance, so the token, the identity verifier and the compliance contract +extend their own on every call that does work. ## Authorization