From c837b7720429beaf51c2e6eb7ea6f38c9cf46804 Mon Sep 17 00:00:00 2001 From: Aleksandr Pasevin Date: Mon, 23 Mar 2026 18:40:27 +0200 Subject: [PATCH 1/5] feat(rwa): add standalone compliance integration example Transplant the reviewed compliance integration example together with the existing module and example crates it depends on so the PR stands alone. --- Cargo.lock | 64 + Cargo.toml | 15 +- examples/rwa-compliance/Cargo.toml | 28 + examples/rwa-compliance/src/compliance.rs | 85 ++ .../rwa-compliance/src/identity_registry.rs | 129 ++ .../rwa-compliance/src/identity_verifier.rs | 80 ++ examples/rwa-compliance/src/lib.rs | 9 + examples/rwa-compliance/src/test.rs | 1038 +++++++++++++++++ examples/rwa-compliance/src/token.rs | 141 +++ examples/rwa-country-allow/Cargo.toml | 15 + examples/rwa-country-allow/README.md | 50 + examples/rwa-country-allow/src/lib.rs | 88 ++ examples/rwa-country-restrict/Cargo.toml | 15 + examples/rwa-country-restrict/README.md | 50 + examples/rwa-country-restrict/src/lib.rs | 88 ++ examples/rwa-initial-lockup-period/Cargo.toml | 15 + examples/rwa-initial-lockup-period/README.md | 64 + examples/rwa-initial-lockup-period/src/lib.rs | 228 ++++ examples/rwa-max-balance/Cargo.toml | 15 + examples/rwa-max-balance/README.md | 62 + examples/rwa-max-balance/src/lib.rs | 167 +++ examples/rwa-supply-limit/Cargo.toml | 15 + examples/rwa-supply-limit/README.md | 61 + examples/rwa-supply-limit/src/lib.rs | 102 ++ examples/rwa-time-transfers-limits/Cargo.toml | 15 + examples/rwa-time-transfers-limits/README.md | 71 ++ examples/rwa-time-transfers-limits/src/lib.rs | 202 ++++ examples/rwa-transfer-restrict/Cargo.toml | 15 + examples/rwa-transfer-restrict/README.md | 47 + examples/rwa-transfer-restrict/src/lib.rs | 83 ++ .../compliance/modules/country_allow/mod.rs | 455 ++++++++ .../modules/country_allow/storage.rs | 54 + .../modules/country_restrict/mod.rs | 459 ++++++++ .../modules/country_restrict/storage.rs | 54 + .../modules/initial_lockup_period/mod.rs | 274 +++++ .../modules/initial_lockup_period/storage.rs | 152 +++ .../modules/initial_lockup_period/test.rs | 190 +++ .../rwa/compliance/modules/max_balance/mod.rs | 218 ++++ .../compliance/modules/max_balance/storage.rs | 75 ++ .../compliance/modules/max_balance/test.rs | 351 ++++++ .../tokens/src/rwa/compliance/modules/mod.rs | 7 + .../compliance/modules/supply_limit/mod.rs | 116 ++ .../modules/supply_limit/storage.rs | 96 ++ .../compliance/modules/supply_limit/test.rs | 211 ++++ .../modules/time_transfers_limits/mod.rs | 239 ++++ .../modules/time_transfers_limits/storage.rs | 104 ++ .../modules/time_transfers_limits/test.rs | 282 +++++ .../modules/transfer_restrict/mod.rs | 200 ++++ .../modules/transfer_restrict/storage.rs | 54 + .../modules/transfer_restrict/test.rs | 70 ++ 50 files changed, 6717 insertions(+), 1 deletion(-) create mode 100644 examples/rwa-compliance/Cargo.toml create mode 100644 examples/rwa-compliance/src/compliance.rs create mode 100644 examples/rwa-compliance/src/identity_registry.rs create mode 100644 examples/rwa-compliance/src/identity_verifier.rs create mode 100644 examples/rwa-compliance/src/lib.rs create mode 100644 examples/rwa-compliance/src/test.rs create mode 100644 examples/rwa-compliance/src/token.rs create mode 100644 examples/rwa-country-allow/Cargo.toml create mode 100644 examples/rwa-country-allow/README.md create mode 100644 examples/rwa-country-allow/src/lib.rs create mode 100644 examples/rwa-country-restrict/Cargo.toml create mode 100644 examples/rwa-country-restrict/README.md create mode 100644 examples/rwa-country-restrict/src/lib.rs create mode 100644 examples/rwa-initial-lockup-period/Cargo.toml create mode 100644 examples/rwa-initial-lockup-period/README.md create mode 100644 examples/rwa-initial-lockup-period/src/lib.rs create mode 100644 examples/rwa-max-balance/Cargo.toml create mode 100644 examples/rwa-max-balance/README.md create mode 100644 examples/rwa-max-balance/src/lib.rs create mode 100644 examples/rwa-supply-limit/Cargo.toml create mode 100644 examples/rwa-supply-limit/README.md create mode 100644 examples/rwa-supply-limit/src/lib.rs create mode 100644 examples/rwa-time-transfers-limits/Cargo.toml create mode 100644 examples/rwa-time-transfers-limits/README.md create mode 100644 examples/rwa-time-transfers-limits/src/lib.rs create mode 100644 examples/rwa-transfer-restrict/Cargo.toml create mode 100644 examples/rwa-transfer-restrict/README.md create mode 100644 examples/rwa-transfer-restrict/src/lib.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/country_allow/mod.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/country_allow/storage.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/country_restrict/storage.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/initial_lockup_period/mod.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/initial_lockup_period/storage.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/initial_lockup_period/test.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/max_balance/mod.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/max_balance/storage.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/max_balance/test.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/supply_limit/mod.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/supply_limit/storage.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/supply_limit/test.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/time_transfers_limits/mod.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/time_transfers_limits/storage.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/time_transfers_limits/test.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/transfer_restrict/mod.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/transfer_restrict/storage.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/transfer_restrict/test.rs diff --git a/Cargo.lock b/Cargo.lock index 469b78e3b..f4f14a96e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1550,12 +1550,36 @@ dependencies = [ name = "rwa-compliance-example" version = "0.6.0" dependencies = [ + "rwa-country-allow", + "rwa-country-restrict", + "rwa-initial-lockup-period", + "rwa-max-balance", + "rwa-supply-limit", + "rwa-time-transfers-limits", + "rwa-transfer-restrict", "soroban-sdk", "stellar-access", + "stellar-contract-utils", "stellar-macros", "stellar-tokens", ] +[[package]] +name = "rwa-country-allow" +version = "0.6.0" +dependencies = [ + "soroban-sdk", + "stellar-tokens", +] + +[[package]] +name = "rwa-country-restrict" +version = "0.6.0" +dependencies = [ + "soroban-sdk", + "stellar-tokens", +] + [[package]] name = "rwa-identity-example" version = "0.6.0" @@ -1586,6 +1610,38 @@ dependencies = [ "stellar-tokens", ] +[[package]] +name = "rwa-initial-lockup-period" +version = "0.6.0" +dependencies = [ + "soroban-sdk", + "stellar-tokens", +] + +[[package]] +name = "rwa-max-balance" +version = "0.6.0" +dependencies = [ + "soroban-sdk", + "stellar-tokens", +] + +[[package]] +name = "rwa-supply-limit" +version = "0.6.0" +dependencies = [ + "soroban-sdk", + "stellar-tokens", +] + +[[package]] +name = "rwa-time-transfers-limits" +version = "0.6.0" +dependencies = [ + "soroban-sdk", + "stellar-tokens", +] + [[package]] name = "rwa-token-example" version = "0.6.0" @@ -1597,6 +1653,14 @@ dependencies = [ "stellar-tokens", ] +[[package]] +name = "rwa-transfer-restrict" +version = "0.6.0" +dependencies = [ + "soroban-sdk", + "stellar-tokens", +] + [[package]] name = "ryu" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 1fd7fbbfc..c33cdaa51 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,20 @@ members = [ "examples/nft-sequential-minting", "examples/ownable", "examples/pausable", - "examples/rwa/*", + "examples/rwa/claim-issuer", + "examples/rwa/claim-topics-and-issuers", + "examples/rwa/identity", + "examples/rwa/identity-registry", + "examples/rwa/identity-verifier", + "examples/rwa/token", + "examples/rwa-country-allow", + "examples/rwa-country-restrict", + "examples/rwa-max-balance", + "examples/rwa-supply-limit", + "examples/rwa-time-transfers-limits", + "examples/rwa-transfer-restrict", + "examples/rwa-initial-lockup-period", + "examples/rwa-compliance", "examples/sac-admin-generic", "examples/sac-admin-wrapper", "examples/multisig-smart-account/*", diff --git a/examples/rwa-compliance/Cargo.toml b/examples/rwa-compliance/Cargo.toml new file mode 100644 index 000000000..a5b7d4bbd --- /dev/null +++ b/examples/rwa-compliance/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "rwa-compliance-example" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true + +[lib] +crate-type = ["cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-access = { workspace = true } +stellar-contract-utils = { workspace = true } +stellar-macros = { workspace = true } +stellar-tokens = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } +rwa-country-allow = { path = "../rwa-country-allow" } +rwa-country-restrict = { path = "../rwa-country-restrict" } +rwa-initial-lockup-period = { path = "../rwa-initial-lockup-period" } +rwa-max-balance = { path = "../rwa-max-balance" } +rwa-supply-limit = { path = "../rwa-supply-limit" } +rwa-time-transfers-limits = { path = "../rwa-time-transfers-limits" } +rwa-transfer-restrict = { path = "../rwa-transfer-restrict" } diff --git a/examples/rwa-compliance/src/compliance.rs b/examples/rwa-compliance/src/compliance.rs new file mode 100644 index 000000000..973ee3aeb --- /dev/null +++ b/examples/rwa-compliance/src/compliance.rs @@ -0,0 +1,85 @@ +//! Compliance dispatcher contract. +//! +//! Implements the `Compliance` trait (which extends `TokenBinder`), routing +//! hook calls to registered compliance modules. All heavy lifting is delegated +//! to the storage helpers in `stellar_tokens::rwa::compliance::storage`. + +use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, Symbol, Vec}; +use stellar_access::access_control::{self as access_control, AccessControl}; +use stellar_macros::only_role; +use stellar_tokens::rwa::{ + compliance::{storage as compliance_storage, Compliance, ComplianceHook}, + utils::token_binder::{self as binder, TokenBinder}, +}; + +#[contract] +pub struct ComplianceContract; + +#[contractimpl] +impl ComplianceContract { + pub fn __constructor(e: &Env, admin: Address) { + access_control::set_admin(e, &admin); + access_control::grant_role_no_auth(e, &admin, &symbol_short!("admin"), &admin); + } +} + +#[contractimpl] +impl Compliance for ComplianceContract { + #[only_role(operator, "admin")] + fn add_module_to(e: &Env, hook: ComplianceHook, module: Address, operator: Address) { + compliance_storage::add_module_to(e, hook, module); + } + + #[only_role(operator, "admin")] + fn remove_module_from(e: &Env, hook: ComplianceHook, module: Address, operator: Address) { + compliance_storage::remove_module_from(e, hook, module); + } + + fn get_modules_for_hook(e: &Env, hook: ComplianceHook) -> Vec
{ + compliance_storage::get_modules_for_hook(e, hook) + } + + fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool { + compliance_storage::is_module_registered(e, hook, module) + } + + fn transferred(e: &Env, from: Address, to: Address, amount: i128, token: Address) { + compliance_storage::transferred(e, from, to, amount, token); + } + + fn created(e: &Env, to: Address, amount: i128, token: Address) { + compliance_storage::created(e, to, amount, token); + } + + fn destroyed(e: &Env, from: Address, amount: i128, token: Address) { + compliance_storage::destroyed(e, from, amount, token); + } + + fn can_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) -> bool { + compliance_storage::can_transfer(e, from, to, amount, token) + } + + fn can_create(e: &Env, to: Address, amount: i128, token: Address) -> bool { + compliance_storage::can_create(e, to, amount, token) + } +} + +#[contractimpl] +impl TokenBinder for ComplianceContract { + fn linked_tokens(e: &Env) -> Vec
{ + binder::linked_tokens(e) + } + + #[only_role(operator, "admin")] + fn bind_token(e: &Env, token: Address, operator: Address) { + binder::bind_token(e, &token); + } + + #[only_role(operator, "admin")] + fn unbind_token(e: &Env, token: Address, operator: Address) { + binder::unbind_token(e, &token); + } +} + +#[contractimpl(contracttrait)] +impl AccessControl for ComplianceContract {} diff --git a/examples/rwa-compliance/src/identity_registry.rs b/examples/rwa-compliance/src/identity_registry.rs new file mode 100644 index 000000000..cd85c41b4 --- /dev/null +++ b/examples/rwa-compliance/src/identity_registry.rs @@ -0,0 +1,129 @@ +//! Identity Registry Storage contract. +//! +//! Provides identity and country-data storage for the RWA compliance stack. +//! Ported from `examples/rwa` with the constructor bug fixed. + +use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, FromVal, IntoVal, Val, Vec}; +use stellar_access::access_control::{self as access_control}; +use stellar_macros::only_role; +use stellar_tokens::rwa::{ + identity_registry_storage::{ + self as identity_storage, CountryData, CountryDataManager, IdentityRegistryStorage, + IdentityType, + }, + utils::token_binder::{self as binder, TokenBinder}, +}; + +#[contract] +pub struct IdentityRegistryContract; + +#[contractimpl] +impl IdentityRegistryContract { + pub fn __constructor(e: &Env, admin: Address, manager: Address) { + access_control::set_admin(e, &admin); + access_control::grant_role_no_auth(e, &manager, &symbol_short!("manager"), &admin); + } + + #[only_role(operator, "manager")] + pub fn bind_tokens(e: &Env, tokens: Vec
, operator: Address) { + binder::bind_tokens(e, &tokens); + } +} + +#[contractimpl] +impl TokenBinder for IdentityRegistryContract { + fn linked_tokens(e: &Env) -> Vec
{ + binder::linked_tokens(e) + } + + #[only_role(operator, "manager")] + fn bind_token(e: &Env, token: Address, operator: Address) { + binder::bind_token(e, &token); + } + + #[only_role(operator, "manager")] + fn unbind_token(e: &Env, token: Address, operator: Address) { + binder::unbind_token(e, &token); + } +} + +#[contractimpl] +impl IdentityRegistryStorage for IdentityRegistryContract { + #[only_role(operator, "manager")] + fn add_identity( + e: &Env, + account: Address, + identity: Address, + initial_profiles: Vec, + operator: Address, + ) { + let country_data = Vec::from_iter( + e, + initial_profiles.iter().map(|profile| CountryData::from_val(e, &profile)), + ); + identity_storage::add_identity( + e, + &account, + &identity, + IdentityType::Individual, + &country_data, + ); + } + + #[only_role(operator, "manager")] + fn modify_identity(e: &Env, account: Address, new_identity: Address, operator: Address) { + identity_storage::modify_identity(e, &account, &new_identity); + } + + #[only_role(operator, "manager")] + fn remove_identity(e: &Env, account: Address, operator: Address) { + identity_storage::remove_identity(e, &account); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + identity_storage::stored_identity(e, &account) + } + + #[only_role(operator, "manager")] + fn recover_identity(e: &Env, old_account: Address, new_account: Address, operator: Address) { + identity_storage::recover_identity(e, &old_account, &new_account); + } + + fn get_recovered_to(e: &Env, old: Address) -> Option
{ + identity_storage::get_recovered_to(e, &old) + } +} + +#[contractimpl] +impl CountryDataManager for IdentityRegistryContract { + #[only_role(operator, "manager")] + fn add_country_data_entries(e: &Env, account: Address, profiles: Vec, operator: Address) { + let country_data = + Vec::from_iter(e, profiles.iter().map(|profile| CountryData::from_val(e, &profile))); + identity_storage::add_country_data_entries(e, &account, &country_data); + } + + #[only_role(operator, "manager")] + fn modify_country_data(e: &Env, account: Address, index: u32, profile: Val, operator: Address) { + let country_data = CountryData::from_val(e, &profile); + identity_storage::modify_country_data(e, &account, index, &country_data); + } + + #[only_role(operator, "manager")] + fn delete_country_data(e: &Env, account: Address, index: u32, operator: Address) { + identity_storage::delete_country_data(e, &account, index); + } + + fn get_country_data(e: &Env, account: Address, index: u32) -> Val { + identity_storage::get_country_data(e, &account, index).into_val(e) + } + + fn get_country_data_entries(e: &Env, account: Address) -> Vec { + Vec::from_iter( + e, + identity_storage::get_country_data_entries(e, &account) + .iter() + .map(|profile| profile.into_val(e)), + ) + } +} diff --git a/examples/rwa-compliance/src/identity_verifier.rs b/examples/rwa-compliance/src/identity_verifier.rs new file mode 100644 index 000000000..3e88fba18 --- /dev/null +++ b/examples/rwa-compliance/src/identity_verifier.rs @@ -0,0 +1,80 @@ +//! Simplified Identity Verifier contract. +//! +//! Checks that an account has an identity registered in the Identity Registry +//! Storage (IRS). Does **not** perform claim-based verification — suitable for +//! demonstrating the compliance module stack without the full claims pipeline. + +use soroban_sdk::{ + contract, contractimpl, contracttype, panic_with_error, symbol_short, Address, Env, Symbol, Vec, +}; +use stellar_access::access_control::{self as access_control, AccessControl}; +use stellar_macros::only_role; +use stellar_tokens::rwa::{ + emit_claim_topics_and_issuers_set, identity_verifier::IdentityVerifier, RWAError, +}; + +#[contracttype] +#[derive(Clone)] +enum DataKey { + Irs, + ClaimTopicsAndIssuers, +} + +#[soroban_sdk::contractclient(name = "IRSClient")] +#[allow(dead_code)] +trait IRSView { + fn stored_identity(e: &Env, account: Address) -> Address; + fn get_recovered_to(e: &Env, old: Address) -> Option
; +} + +#[contract] +pub struct SimpleIdentityVerifier; + +fn identity_registry_storage(e: &Env) -> Address { + e.storage() + .instance() + .get(&DataKey::Irs) + .unwrap_or_else(|| panic_with_error!(e, RWAError::IdentityRegistryStorageNotSet)) +} + +#[contractimpl] +impl SimpleIdentityVerifier { + pub fn __constructor(e: &Env, admin: Address, irs: Address) { + access_control::set_admin(e, &admin); + access_control::grant_role_no_auth(e, &admin, &symbol_short!("admin"), &admin); + e.storage().instance().set(&DataKey::Irs, &irs); + } +} + +#[contractimpl] +impl IdentityVerifier for SimpleIdentityVerifier { + fn verify_identity(e: &Env, account: &Address) { + let irs = identity_registry_storage(e); + let client = IRSClient::new(e, &irs); + if client.try_stored_identity(account).is_err() { + panic_with_error!(e, RWAError::IdentityVerificationFailed); + } + } + + fn recovery_target(e: &Env, old_account: &Address) -> Option
{ + let irs = identity_registry_storage(e); + let client = IRSClient::new(e, &irs); + client.get_recovered_to(old_account) + } + + #[only_role(operator, "admin")] + fn set_claim_topics_and_issuers(e: &Env, claim_topics_and_issuers: Address, operator: Address) { + e.storage().instance().set(&DataKey::ClaimTopicsAndIssuers, &claim_topics_and_issuers); + emit_claim_topics_and_issuers_set(e, &claim_topics_and_issuers); + } + + fn claim_topics_and_issuers(e: &Env) -> Address { + e.storage() + .instance() + .get(&DataKey::ClaimTopicsAndIssuers) + .unwrap_or_else(|| panic_with_error!(e, RWAError::ClaimTopicsAndIssuersNotSet)) + } +} + +#[contractimpl(contracttrait)] +impl AccessControl for SimpleIdentityVerifier {} diff --git a/examples/rwa-compliance/src/lib.rs b/examples/rwa-compliance/src/lib.rs new file mode 100644 index 000000000..e5d0a6592 --- /dev/null +++ b/examples/rwa-compliance/src/lib.rs @@ -0,0 +1,9 @@ +#![no_std] + +mod compliance; +mod identity_registry; +mod identity_verifier; +mod token; + +#[cfg(test)] +mod test; diff --git a/examples/rwa-compliance/src/test.rs b/examples/rwa-compliance/src/test.rs new file mode 100644 index 000000000..2773300fe --- /dev/null +++ b/examples/rwa-compliance/src/test.rs @@ -0,0 +1,1038 @@ +extern crate std; + +use rwa_country_allow::{CountryAllowContract, CountryAllowContractClient}; +use rwa_country_restrict::{CountryRestrictContract, CountryRestrictContractClient}; +use rwa_initial_lockup_period::{InitialLockupPeriodContract, InitialLockupPeriodContractClient}; +use rwa_max_balance::{MaxBalanceContract, MaxBalanceContractClient}; +use rwa_supply_limit::{SupplyLimitContract, SupplyLimitContractClient}; +use rwa_time_transfers_limits::{TimeTransfersLimitsContract, TimeTransfersLimitsContractClient}; +use rwa_transfer_restrict::{TransferRestrictContract, TransferRestrictContractClient}; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + vec, Address, Env, IntoVal, String, +}; +use stellar_tokens::rwa::{ + compliance::{ + modules::{time_transfers_limits::Limit, ComplianceModuleClient}, + ComplianceHook, + }, + identity_registry_storage::{CountryData, CountryRelation, IndividualCountryRelation}, +}; + +use crate::{ + compliance::{ComplianceContract, ComplianceContractClient}, + identity_registry::{IdentityRegistryContract, IdentityRegistryContractClient}, + identity_verifier::{SimpleIdentityVerifier, SimpleIdentityVerifierClient}, + token::{RWATokenContract, RWATokenContractClient}, +}; + +// --------------------------------------------------------------------------- +// Test setup +// --------------------------------------------------------------------------- + +struct TestSetup<'a> { + env: Env, + admin: Address, + manager: Address, + token: Address, + token_client: RWATokenContractClient<'a>, + compliance: Address, + compliance_client: ComplianceContractClient<'a>, + irs: Address, + irs_client: IdentityRegistryContractClient<'a>, + verifier: Address, +} + +fn us_country_data() -> CountryData { + CountryData { + country: CountryRelation::Individual(IndividualCountryRelation::Residence(840)), + metadata: None, + } +} + +fn de_country_data() -> CountryData { + CountryData { + country: CountryRelation::Individual(IndividualCountryRelation::Residence(276)), + metadata: None, + } +} + +fn setup() -> TestSetup<'static> { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let manager = Address::generate(&env); + + let irs = env.register(IdentityRegistryContract, (&admin, &manager)); + let irs_client = IdentityRegistryContractClient::new(&env, &irs); + + let verifier = env.register(SimpleIdentityVerifier, (&admin, &irs)); + + let compliance = env.register(ComplianceContract, (&admin,)); + let compliance_client = ComplianceContractClient::new(&env, &compliance); + + let name = String::from_str(&env, "Compliance Token"); + let symbol = String::from_str(&env, "CRWA"); + let token = env.register(RWATokenContract, (&name, &symbol, &admin, &compliance, &verifier)); + let token_client = RWATokenContractClient::new(&env, &token); + + compliance_client.bind_token(&token, &admin); + irs_client.bind_tokens(&vec![&env, token.clone()], &manager); + + TestSetup { + env, + admin, + manager, + token, + token_client, + compliance, + compliance_client, + irs, + irs_client, + verifier, + } +} + +fn register_investor(ts: &TestSetup, investor: &Address, identity: &Address, country: CountryData) { + ts.irs_client.add_identity( + investor, + identity, + &vec![&ts.env, country.into_val(&ts.env)], + &ts.manager, + ); +} + +fn wire_module(ts: &TestSetup, module_addr: &Address, hooks: &[ComplianceHook]) { + let cmp_client = ComplianceModuleClient::new(&ts.env, module_addr); + cmp_client.set_compliance_address(&ts.compliance); + + for hook in hooks { + ts.compliance_client.add_module_to(hook, module_addr, &ts.admin); + } +} + +// --------------------------------------------------------------------------- +// Test: CountryAllowModule +// --------------------------------------------------------------------------- + +#[test] +fn test_country_allow() { + let ts = setup(); + + let investor_us = Address::generate(&ts.env); + let investor_de = Address::generate(&ts.env); + let id_us = Address::generate(&ts.env); + let id_de = Address::generate(&ts.env); + + register_investor(&ts, &investor_us, &id_us, us_country_data()); + register_investor(&ts, &investor_de, &id_de, de_country_data()); + + let module = ts.env.register(CountryAllowContract, (&ts.admin,)); + wire_module(&ts, &module, &[ComplianceHook::CanTransfer, ComplianceHook::CanCreate]); + + let mod_client = CountryAllowContractClient::new(&ts.env, &module); + mod_client.set_identity_registry_storage(&ts.token, &ts.irs); + mod_client.add_allowed_country(&ts.token, &840); + + // Mint to US investor passes + ts.token_client.mint(&investor_us, &500, &ts.admin); + assert_eq!(ts.token_client.balance(&investor_us), 500); + + // Mint to DE investor fails (276 not allowed) + let result = ts.token_client.try_mint(&investor_de, &100, &ts.admin); + assert!(result.is_err()); +} + +// --------------------------------------------------------------------------- +// Test: CountryRestrictModule +// --------------------------------------------------------------------------- + +#[test] +fn test_country_restrict() { + let ts = setup(); + + let investor_us = Address::generate(&ts.env); + let investor_de = Address::generate(&ts.env); + let id_us = Address::generate(&ts.env); + let id_de = Address::generate(&ts.env); + + register_investor(&ts, &investor_us, &id_us, us_country_data()); + register_investor(&ts, &investor_de, &id_de, de_country_data()); + + let module = ts.env.register(CountryRestrictContract, (&ts.admin,)); + wire_module(&ts, &module, &[ComplianceHook::CanTransfer, ComplianceHook::CanCreate]); + + let mod_client = CountryRestrictContractClient::new(&ts.env, &module); + mod_client.set_identity_registry_storage(&ts.token, &ts.irs); + mod_client.add_country_restriction(&ts.token, &840); + + // Mint to DE investor passes + ts.token_client.mint(&investor_de, &500, &ts.admin); + assert_eq!(ts.token_client.balance(&investor_de), 500); + + // Mint to US investor fails (840 restricted) + let result = ts.token_client.try_mint(&investor_us, &100, &ts.admin); + assert!(result.is_err()); +} + +// --------------------------------------------------------------------------- +// Test: MaxBalanceModule +// --------------------------------------------------------------------------- + +#[test] +fn test_max_balance() { + let ts = setup(); + + let investor_a = Address::generate(&ts.env); + let investor_b = Address::generate(&ts.env); + let id_a = Address::generate(&ts.env); + let id_b = Address::generate(&ts.env); + + register_investor(&ts, &investor_a, &id_a, us_country_data()); + register_investor(&ts, &investor_b, &id_b, us_country_data()); + + let module = ts.env.register(MaxBalanceContract, (&ts.admin,)); + wire_module( + &ts, + &module, + &[ + ComplianceHook::CanTransfer, + ComplianceHook::CanCreate, + ComplianceHook::Transferred, + ComplianceHook::Created, + ComplianceHook::Destroyed, + ], + ); + + let mod_client = MaxBalanceContractClient::new(&ts.env, &module); + mod_client.set_identity_registry_storage(&ts.token, &ts.irs); + mod_client.set_max_balance(&ts.token, &1000); + mod_client.verify_hook_wiring(); + + // Mint 800 to investor A + ts.token_client.mint(&investor_a, &800, &ts.admin); + assert_eq!(ts.token_client.balance(&investor_a), 800); + assert_eq!(mod_client.get_investor_balance(&ts.token, &id_a), 800); + + // Mint 300 to investor B + ts.token_client.mint(&investor_b, &300, &ts.admin); + assert_eq!(mod_client.get_investor_balance(&ts.token, &id_b), 300); + + // Transfer 250 from A to B pushes B to 550 — passes + ts.token_client.transfer(&investor_a, &investor_b, &250); + assert_eq!(mod_client.get_investor_balance(&ts.token, &id_b), 550); + + // Transfer 500 from A to B would push B to 1050 — exceeds max + let result = ts.token_client.try_transfer(&investor_a, &investor_b, &500); + assert!(result.is_err()); +} + +// --------------------------------------------------------------------------- +// Test: SupplyLimitModule (full stack — internal supply tracking) +// --------------------------------------------------------------------------- + +#[test] +fn test_supply_limit() { + let ts = setup(); + + let investor = Address::generate(&ts.env); + let investor_b = Address::generate(&ts.env); + let id = Address::generate(&ts.env); + let id_b = Address::generate(&ts.env); + register_investor(&ts, &investor, &id, us_country_data()); + register_investor(&ts, &investor_b, &id_b, us_country_data()); + + let module = ts.env.register(SupplyLimitContract, (&ts.admin,)); + wire_module( + &ts, + &module, + &[ComplianceHook::CanCreate, ComplianceHook::Created, ComplianceHook::Destroyed], + ); + + let mod_client = SupplyLimitContractClient::new(&ts.env, &module); + mod_client.set_supply_limit(&ts.token, &1000); + mod_client.verify_hook_wiring(); + + // Mint 800 — internal supply tracks to 800 + ts.token_client.mint(&investor, &800, &ts.admin); + assert_eq!(ts.token_client.total_supply(), 800); + assert_eq!(mod_client.get_internal_supply(&ts.token), 800); + + // Mint 200 more — exactly at limit (1000) + ts.token_client.mint(&investor_b, &200, &ts.admin); + assert_eq!(ts.token_client.total_supply(), 1000); + assert_eq!(mod_client.get_internal_supply(&ts.token), 1000); + + // Mint 1 more — exceeds limit, blocked by can_create + let result = ts.token_client.try_mint(&investor, &1, &ts.admin); + assert!(result.is_err()); + + // Transfer doesn't affect supply — always allowed + ts.token_client.transfer(&investor, &investor_b, &100); + assert_eq!(mod_client.get_internal_supply(&ts.token), 1000); +} + +#[test] +fn test_supply_limit_burn() { + let ts = setup(); + + let investor = Address::generate(&ts.env); + let id = Address::generate(&ts.env); + register_investor(&ts, &investor, &id, us_country_data()); + + let module = ts.env.register(SupplyLimitContract, (&ts.admin,)); + wire_module( + &ts, + &module, + &[ComplianceHook::CanCreate, ComplianceHook::Created, ComplianceHook::Destroyed], + ); + + let mod_client = SupplyLimitContractClient::new(&ts.env, &module); + mod_client.set_supply_limit(&ts.token, &1000); + mod_client.verify_hook_wiring(); + + // Mint to limit + ts.token_client.mint(&investor, &1000, &ts.admin); + assert_eq!(mod_client.get_internal_supply(&ts.token), 1000); + + // Burn 300 — internal supply decrements + ts.token_client.burn(&investor, &300, &ts.admin); + assert_eq!(ts.token_client.total_supply(), 700); + assert_eq!(mod_client.get_internal_supply(&ts.token), 700); + + // Now minting 300 more is possible again + ts.token_client.mint(&investor, &300, &ts.admin); + assert_eq!(mod_client.get_internal_supply(&ts.token), 1000); + + // But 1 more still fails + let result = ts.token_client.try_mint(&investor, &1, &ts.admin); + assert!(result.is_err()); +} + +// --------------------------------------------------------------------------- +// Test: TimeTransfersLimitsModule +// --------------------------------------------------------------------------- + +#[test] +fn test_time_transfer_limits() { + let ts = setup(); + + let investor_a = Address::generate(&ts.env); + let investor_b = Address::generate(&ts.env); + let id_a = Address::generate(&ts.env); + let id_b = Address::generate(&ts.env); + + register_investor(&ts, &investor_a, &id_a, us_country_data()); + register_investor(&ts, &investor_b, &id_b, us_country_data()); + + let module = ts.env.register(TimeTransfersLimitsContract, (&ts.admin,)); + wire_module(&ts, &module, &[ComplianceHook::CanTransfer, ComplianceHook::Transferred]); + + let mod_client = TimeTransfersLimitsContractClient::new(&ts.env, &module); + mod_client.set_identity_registry_storage(&ts.token, &ts.irs); + mod_client.set_time_transfer_limit(&ts.token, &Limit { limit_time: 3_600, limit_value: 100 }); + mod_client.verify_hook_wiring(); + + // Mint enough tokens for transfers + ts.token_client.mint(&investor_a, &500, &ts.admin); + + // Transfer 60 — passes (counter at 60/100) + ts.token_client.transfer(&investor_a, &investor_b, &60); + assert_eq!(ts.token_client.balance(&investor_b), 60); + + // Transfer 50 more — would push counter to 110, exceeds 100/hour + let result = ts.token_client.try_transfer(&investor_a, &investor_b, &50); + assert!(result.is_err()); + + // Transfer 40 — passes (counter at 100, exactly at limit) + ts.token_client.transfer(&investor_a, &investor_b, &40); + assert_eq!(ts.token_client.balance(&investor_b), 100); +} + +// --------------------------------------------------------------------------- +// Test: TransferRestrictModule +// --------------------------------------------------------------------------- + +#[test] +fn test_transfer_restrict() { + let ts = setup(); + + let investor_a = Address::generate(&ts.env); + let investor_b = Address::generate(&ts.env); + let id_a = Address::generate(&ts.env); + let id_b = Address::generate(&ts.env); + + register_investor(&ts, &investor_a, &id_a, us_country_data()); + register_investor(&ts, &investor_b, &id_b, us_country_data()); + + let module = ts.env.register(TransferRestrictContract, (&ts.admin,)); + wire_module(&ts, &module, &[ComplianceHook::CanTransfer]); + + let mod_client = TransferRestrictContractClient::new(&ts.env, &module); + + // Mint tokens (no CanCreate hook, so mints pass) + ts.token_client.mint(&investor_a, &500, &ts.admin); + + // Transfer without allowlist — fails + let result = ts.token_client.try_transfer(&investor_a, &investor_b, &100); + assert!(result.is_err()); + + // Allow investor_a as sender + mod_client.allow_user(&ts.token, &investor_a); + + // Now transfer passes + ts.token_client.transfer(&investor_a, &investor_b, &100); + assert_eq!(ts.token_client.balance(&investor_b), 100); +} + +// --------------------------------------------------------------------------- +// Test: InitialLockupPeriodModule (full stack — internal balance tracking) +// --------------------------------------------------------------------------- + +#[test] +fn test_initial_lockup() { + let ts = setup(); + + let investor = Address::generate(&ts.env); + let recipient = Address::generate(&ts.env); + let id_inv = Address::generate(&ts.env); + let id_rec = Address::generate(&ts.env); + + register_investor(&ts, &investor, &id_inv, us_country_data()); + register_investor(&ts, &recipient, &id_rec, us_country_data()); + + let module = ts.env.register(InitialLockupPeriodContract, (&ts.admin,)); + wire_module( + &ts, + &module, + &[ + ComplianceHook::CanTransfer, + ComplianceHook::Created, + ComplianceHook::Transferred, + ComplianceHook::Destroyed, + ], + ); + + let mod_client = InitialLockupPeriodContractClient::new(&ts.env, &module); + mod_client.set_lockup_period(&ts.token, &1_000); + mod_client.verify_hook_wiring(); + + // Mint 500 — creates lock entry, internal balance tracks to 500 + ts.token_client.mint(&investor, &500, &ts.admin); + assert_eq!(ts.token_client.balance(&investor), 500); + assert_eq!(mod_client.get_total_locked(&ts.token, &investor), 500); + assert_eq!(mod_client.get_internal_balance(&ts.token, &investor), 500); + + // Before lockup expiry: all tokens locked, transfer blocked + let result = ts.token_client.try_transfer(&investor, &recipient, &100); + assert!(result.is_err()); + + // Advance past lockup + ts.env.ledger().with_mut(|li| li.timestamp = 1_001); + + // After lockup expiry: transfer succeeds through the full stack + ts.token_client.transfer(&investor, &recipient, &200); + assert_eq!(ts.token_client.balance(&investor), 300); + assert_eq!(ts.token_client.balance(&recipient), 200); + assert_eq!(mod_client.get_internal_balance(&ts.token, &investor), 300); + assert_eq!(mod_client.get_internal_balance(&ts.token, &recipient), 200); + + // Transfer rest + ts.token_client.transfer(&investor, &recipient, &300); + assert_eq!(mod_client.get_internal_balance(&ts.token, &investor), 0); + assert_eq!(mod_client.get_internal_balance(&ts.token, &recipient), 500); +} + +#[test] +fn test_initial_lockup_partial_unlock() { + let ts = setup(); + + let investor = Address::generate(&ts.env); + let recipient = Address::generate(&ts.env); + let id_inv = Address::generate(&ts.env); + let id_rec = Address::generate(&ts.env); + + register_investor(&ts, &investor, &id_inv, us_country_data()); + register_investor(&ts, &recipient, &id_rec, us_country_data()); + + let module = ts.env.register(InitialLockupPeriodContract, (&ts.admin,)); + wire_module( + &ts, + &module, + &[ + ComplianceHook::CanTransfer, + ComplianceHook::Created, + ComplianceHook::Transferred, + ComplianceHook::Destroyed, + ], + ); + + let mod_client = InitialLockupPeriodContractClient::new(&ts.env, &module); + mod_client.set_lockup_period(&ts.token, &1_000); + mod_client.verify_hook_wiring(); + + // Mint 300 at t=0 (lock until t=1000) + ts.token_client.mint(&investor, &300, &ts.admin); + + // Advance to t=500, mint 200 more (lock until t=1500) + ts.env.ledger().with_mut(|li| li.timestamp = 500); + ts.token_client.mint(&investor, &200, &ts.admin); + assert_eq!(mod_client.get_total_locked(&ts.token, &investor), 500); + assert_eq!(mod_client.get_internal_balance(&ts.token, &investor), 500); + + // At t=1001: first batch unlocked, second still locked + ts.env.ledger().with_mut(|li| li.timestamp = 1_001); + + // Can transfer up to 300 (first batch unlocked) + ts.token_client.transfer(&investor, &recipient, &300); + assert_eq!(mod_client.get_internal_balance(&ts.token, &investor), 200); + + // Can't transfer remaining 200 (still locked) + let result = ts.token_client.try_transfer(&investor, &recipient, &200); + assert!(result.is_err()); + + // At t=1501: second batch also unlocked + ts.env.ledger().with_mut(|li| li.timestamp = 1_501); + ts.token_client.transfer(&investor, &recipient, &200); + assert_eq!(mod_client.get_internal_balance(&ts.token, &investor), 0); +} + +#[test] +fn test_initial_lockup_burn() { + let ts = setup(); + + let investor = Address::generate(&ts.env); + let id_inv = Address::generate(&ts.env); + + register_investor(&ts, &investor, &id_inv, us_country_data()); + + let module = ts.env.register(InitialLockupPeriodContract, (&ts.admin,)); + wire_module( + &ts, + &module, + &[ + ComplianceHook::CanTransfer, + ComplianceHook::Created, + ComplianceHook::Transferred, + ComplianceHook::Destroyed, + ], + ); + + let mod_client = InitialLockupPeriodContractClient::new(&ts.env, &module); + mod_client.set_lockup_period(&ts.token, &1_000); + mod_client.verify_hook_wiring(); + + // Mint 500 (locked until t=1000) + ts.token_client.mint(&investor, &500, &ts.admin); + assert_eq!(mod_client.get_internal_balance(&ts.token, &investor), 500); + + // Advance past lockup + ts.env.ledger().with_mut(|li| li.timestamp = 1_001); + + // Burn 200 — internal balance decrements + ts.token_client.burn(&investor, &200, &ts.admin); + assert_eq!(ts.token_client.balance(&investor), 300); + assert_eq!(mod_client.get_internal_balance(&ts.token, &investor), 300); + + // Burn remaining + ts.token_client.burn(&investor, &300, &ts.admin); + assert_eq!(mod_client.get_internal_balance(&ts.token, &investor), 0); +} + +// --------------------------------------------------------------------------- +// Test: Full stack — multiple modules active simultaneously +// --------------------------------------------------------------------------- + +#[test] +fn test_full_stack() { + let ts = setup(); + + let investor_us = Address::generate(&ts.env); + let investor_de = Address::generate(&ts.env); + let id_us = Address::generate(&ts.env); + let id_de = Address::generate(&ts.env); + + register_investor(&ts, &investor_us, &id_us, us_country_data()); + register_investor(&ts, &investor_de, &id_de, de_country_data()); + + // --- Wire CountryAllowModule (allow US only) --- + let country_mod = ts.env.register(CountryAllowContract, (&ts.admin,)); + wire_module(&ts, &country_mod, &[ComplianceHook::CanTransfer, ComplianceHook::CanCreate]); + let country_client = CountryAllowContractClient::new(&ts.env, &country_mod); + country_client.set_identity_registry_storage(&ts.token, &ts.irs); + country_client.add_allowed_country(&ts.token, &840); + + // --- Wire MaxBalanceModule (max 1000 per identity) --- + let balance_mod = ts.env.register(MaxBalanceContract, (&ts.admin,)); + wire_module( + &ts, + &balance_mod, + &[ + ComplianceHook::CanTransfer, + ComplianceHook::CanCreate, + ComplianceHook::Transferred, + ComplianceHook::Created, + ComplianceHook::Destroyed, + ], + ); + let balance_client = MaxBalanceContractClient::new(&ts.env, &balance_mod); + balance_client.set_identity_registry_storage(&ts.token, &ts.irs); + balance_client.set_max_balance(&ts.token, &1000); + balance_client.verify_hook_wiring(); + + // 1) Mint 800 to US investor — passes all modules + ts.token_client.mint(&investor_us, &800, &ts.admin); + assert_eq!(ts.token_client.balance(&investor_us), 800); + + // 2) Mint to DE investor — fails (country not allowed) + let result = ts.token_client.try_mint(&investor_de, &100, &ts.admin); + assert!(result.is_err()); + + // 3) Allow DE, then mint 300 to DE investor — passes + country_client.add_allowed_country(&ts.token, &276); + ts.token_client.mint(&investor_de, &300, &ts.admin); + + // 4) Mint 200 more to US investor (total 1000) — exactly at max balance + ts.token_client.mint(&investor_us, &200, &ts.admin); + assert_eq!(balance_client.get_investor_balance(&ts.token, &id_us), 1000); + + // 5) Mint 1 more to US investor — exceeds max balance of 1000 + let result = ts.token_client.try_mint(&investor_us, &1, &ts.admin); + assert!(result.is_err()); + + // 6) Transfer 100 from US to DE — passes (DE at 400, under 1000) + ts.token_client.transfer(&investor_us, &investor_de, &100); + assert_eq!(ts.token_client.balance(&investor_us), 900); + assert_eq!(ts.token_client.balance(&investor_de), 400); + + // 7) Transfer 700 to DE would push DE identity to 1100 — exceeds max + let result = ts.token_client.try_transfer(&investor_us, &investor_de, &700); + assert!(result.is_err()); +} + +// --------------------------------------------------------------------------- +// Tests: Hook wiring verification guards +// --------------------------------------------------------------------------- + +#[test] +#[should_panic(expected = "not armed")] +fn guard_supply_limit_without_verification() { + let ts = setup(); + + let investor = Address::generate(&ts.env); + let id = Address::generate(&ts.env); + register_investor(&ts, &investor, &id, us_country_data()); + + let module = ts.env.register(SupplyLimitContract, (&ts.admin,)); + wire_module( + &ts, + &module, + &[ComplianceHook::CanCreate, ComplianceHook::Created, ComplianceHook::Destroyed], + ); + + let mod_client = SupplyLimitContractClient::new(&ts.env, &module); + mod_client.set_supply_limit(&ts.token, &1000); + // Intentionally NOT calling verify_hook_wiring() + + ts.token_client.mint(&investor, &100, &ts.admin); +} + +#[test] +#[should_panic(expected = "Error(Contract, #398)")] +fn guard_supply_limit_missing_hook() { + let ts = setup(); + + let module = ts.env.register(SupplyLimitContract, (&ts.admin,)); + wire_module( + &ts, + &module, + &[ComplianceHook::CanCreate], // missing Created and Destroyed + ); + + let mod_client = SupplyLimitContractClient::new(&ts.env, &module); + mod_client.verify_hook_wiring(); +} + +#[test] +#[should_panic(expected = "not armed")] +fn guard_initial_lockup_without_verification() { + let ts = setup(); + + let investor = Address::generate(&ts.env); + let recipient = Address::generate(&ts.env); + let id_inv = Address::generate(&ts.env); + let id_rec = Address::generate(&ts.env); + + register_investor(&ts, &investor, &id_inv, us_country_data()); + register_investor(&ts, &recipient, &id_rec, us_country_data()); + + let module = ts.env.register(InitialLockupPeriodContract, (&ts.admin,)); + wire_module( + &ts, + &module, + &[ + ComplianceHook::CanTransfer, + ComplianceHook::Created, + ComplianceHook::Transferred, + ComplianceHook::Destroyed, + ], + ); + + let mod_client = InitialLockupPeriodContractClient::new(&ts.env, &module); + mod_client.set_lockup_period(&ts.token, &1_000); + // Intentionally NOT calling verify_hook_wiring() + + ts.token_client.mint(&investor, &500, &ts.admin); + ts.env.ledger().with_mut(|li| li.timestamp = 1_001); + ts.token_client.transfer(&investor, &recipient, &100); +} + +#[test] +#[should_panic(expected = "Error(Contract, #398)")] +fn guard_max_balance_missing_hook() { + let ts = setup(); + + let module = ts.env.register(MaxBalanceContract, (&ts.admin,)); + wire_module( + &ts, + &module, + &[ComplianceHook::CanTransfer, ComplianceHook::CanCreate], /* missing Transferred, + * Created, Destroyed */ + ); + + let mod_client = MaxBalanceContractClient::new(&ts.env, &module); + mod_client.verify_hook_wiring(); +} + +// --------------------------------------------------------------------------- +// Test: Burn during lockup panics (3A) +// --------------------------------------------------------------------------- + +#[test] +#[should_panic(expected = "insufficient unlocked balance for burn")] +fn test_initial_lockup_burn_during_lockup() { + let ts = setup(); + + let investor = Address::generate(&ts.env); + let id_inv = Address::generate(&ts.env); + register_investor(&ts, &investor, &id_inv, us_country_data()); + + let module = ts.env.register(InitialLockupPeriodContract, (&ts.admin,)); + wire_module( + &ts, + &module, + &[ + ComplianceHook::CanTransfer, + ComplianceHook::Created, + ComplianceHook::Transferred, + ComplianceHook::Destroyed, + ], + ); + + let mod_client = InitialLockupPeriodContractClient::new(&ts.env, &module); + mod_client.set_lockup_period(&ts.token, &1_000); + mod_client.verify_hook_wiring(); + + ts.token_client.mint(&investor, &500, &ts.admin); + // All 500 tokens are locked (lockup until t=1000), burn should panic + ts.token_client.burn(&investor, &100, &ts.admin); +} + +// --------------------------------------------------------------------------- +// Test: TimeTransfersLimits window reset (3B) +// --------------------------------------------------------------------------- + +#[test] +fn test_time_transfer_limits_window_reset() { + let ts = setup(); + + let investor_a = Address::generate(&ts.env); + let investor_b = Address::generate(&ts.env); + let id_a = Address::generate(&ts.env); + let id_b = Address::generate(&ts.env); + + register_investor(&ts, &investor_a, &id_a, us_country_data()); + register_investor(&ts, &investor_b, &id_b, us_country_data()); + + let module = ts.env.register(TimeTransfersLimitsContract, (&ts.admin,)); + wire_module(&ts, &module, &[ComplianceHook::CanTransfer, ComplianceHook::Transferred]); + + let mod_client = TimeTransfersLimitsContractClient::new(&ts.env, &module); + mod_client.set_identity_registry_storage(&ts.token, &ts.irs); + mod_client.set_time_transfer_limit(&ts.token, &Limit { limit_time: 3_600, limit_value: 100 }); + mod_client.verify_hook_wiring(); + + ts.token_client.mint(&investor_a, &500, &ts.admin); + + // Transfer up to the limit + ts.token_client.transfer(&investor_a, &investor_b, &100); + assert_eq!(ts.token_client.balance(&investor_b), 100); + + // At the limit — next transfer should fail + let result = ts.token_client.try_transfer(&investor_a, &investor_b, &1); + assert!(result.is_err()); + + // Advance past the 1-hour window + ts.env.ledger().with_mut(|li| li.timestamp = 3_601); + + // Counter reset — transfers succeed again + ts.token_client.transfer(&investor_a, &investor_b, &80); + assert_eq!(ts.token_client.balance(&investor_b), 180); + + // Still within new window, push to limit again + ts.token_client.transfer(&investor_a, &investor_b, &20); + + // Exceeds new window limit + let result = ts.token_client.try_transfer(&investor_a, &investor_b, &1); + assert!(result.is_err()); +} + +// --------------------------------------------------------------------------- +// Tests: TimeTransfersLimits wiring guards (3C) +// --------------------------------------------------------------------------- + +#[test] +#[should_panic(expected = "not armed")] +fn guard_time_transfers_without_verification() { + let ts = setup(); + + let investor_a = Address::generate(&ts.env); + let investor_b = Address::generate(&ts.env); + let id_a = Address::generate(&ts.env); + let id_b = Address::generate(&ts.env); + + register_investor(&ts, &investor_a, &id_a, us_country_data()); + register_investor(&ts, &investor_b, &id_b, us_country_data()); + + let module = ts.env.register(TimeTransfersLimitsContract, (&ts.admin,)); + wire_module(&ts, &module, &[ComplianceHook::CanTransfer, ComplianceHook::Transferred]); + + let mod_client = TimeTransfersLimitsContractClient::new(&ts.env, &module); + mod_client.set_identity_registry_storage(&ts.token, &ts.irs); + mod_client.set_time_transfer_limit(&ts.token, &Limit { limit_time: 3_600, limit_value: 100 }); + // Intentionally NOT calling verify_hook_wiring() + + ts.token_client.mint(&investor_a, &500, &ts.admin); + ts.token_client.transfer(&investor_a, &investor_b, &50); +} + +#[test] +#[should_panic(expected = "Error(Contract, #398)")] +fn guard_time_transfers_missing_hook() { + let ts = setup(); + + let module = ts.env.register(TimeTransfersLimitsContract, (&ts.admin,)); + wire_module( + &ts, + &module, + &[ComplianceHook::CanTransfer], // missing Transferred + ); + + let mod_client = TimeTransfersLimitsContractClient::new(&ts.env, &module); + mod_client.verify_hook_wiring(); +} + +// --------------------------------------------------------------------------- +// Test: CountryAllow/Restrict on can_transfer (not just can_create) (3D) +// --------------------------------------------------------------------------- + +#[test] +fn test_country_allow_blocks_transfer_to_non_allowed() { + let ts = setup(); + + let investor_us = Address::generate(&ts.env); + let investor_de = Address::generate(&ts.env); + let id_us = Address::generate(&ts.env); + let id_de = Address::generate(&ts.env); + + register_investor(&ts, &investor_us, &id_us, us_country_data()); + register_investor(&ts, &investor_de, &id_de, de_country_data()); + + let module = ts.env.register(CountryAllowContract, (&ts.admin,)); + wire_module(&ts, &module, &[ComplianceHook::CanTransfer, ComplianceHook::CanCreate]); + + let mod_client = CountryAllowContractClient::new(&ts.env, &module); + mod_client.set_identity_registry_storage(&ts.token, &ts.irs); + mod_client.add_allowed_country(&ts.token, &840); // US only + + // Mint to US investor passes + ts.token_client.mint(&investor_us, &500, &ts.admin); + + // Transfer to DE investor (276 not allowed) should fail on can_transfer + let result = ts.token_client.try_transfer(&investor_us, &investor_de, &100); + assert!(result.is_err()); + + // Allow DE, then transfer succeeds + mod_client.add_allowed_country(&ts.token, &276); + ts.token_client.transfer(&investor_us, &investor_de, &100); + assert_eq!(ts.token_client.balance(&investor_de), 100); +} + +#[test] +fn test_country_restrict_blocks_transfer_to_restricted() { + let ts = setup(); + + let investor_us = Address::generate(&ts.env); + let investor_de = Address::generate(&ts.env); + let id_us = Address::generate(&ts.env); + let id_de = Address::generate(&ts.env); + + register_investor(&ts, &investor_us, &id_us, us_country_data()); + register_investor(&ts, &investor_de, &id_de, de_country_data()); + + let module = ts.env.register(CountryRestrictContract, (&ts.admin,)); + wire_module(&ts, &module, &[ComplianceHook::CanTransfer, ComplianceHook::CanCreate]); + + let mod_client = CountryRestrictContractClient::new(&ts.env, &module); + mod_client.set_identity_registry_storage(&ts.token, &ts.irs); + mod_client.add_country_restriction(&ts.token, &276); // Restrict DE + + // Mint to US investor passes (840 not restricted) + ts.token_client.mint(&investor_us, &500, &ts.admin); + + // Transfer to DE investor (276 restricted) should fail on can_transfer + let result = ts.token_client.try_transfer(&investor_us, &investor_de, &100); + assert!(result.is_err()); + + // Unrestrict DE, then transfer succeeds + mod_client.remove_country_restriction(&ts.token, &276); + ts.token_client.transfer(&investor_us, &investor_de, &100); + assert_eq!(ts.token_client.balance(&investor_de), 100); +} + +// --------------------------------------------------------------------------- +// Test: TransferRestrict recipient-allowed path (3E) +// --------------------------------------------------------------------------- + +#[test] +fn test_transfer_restrict_recipient_allowed() { + let ts = setup(); + + let investor_a = Address::generate(&ts.env); + let investor_b = Address::generate(&ts.env); + let id_a = Address::generate(&ts.env); + let id_b = Address::generate(&ts.env); + + register_investor(&ts, &investor_a, &id_a, us_country_data()); + register_investor(&ts, &investor_b, &id_b, us_country_data()); + + let module = ts.env.register(TransferRestrictContract, (&ts.admin,)); + wire_module(&ts, &module, &[ComplianceHook::CanTransfer]); + + let mod_client = TransferRestrictContractClient::new(&ts.env, &module); + + ts.token_client.mint(&investor_a, &500, &ts.admin); + + // Neither on allowlist — transfer fails + let result = ts.token_client.try_transfer(&investor_a, &investor_b, &100); + assert!(result.is_err()); + + // Allow ONLY the recipient (investor_b), NOT the sender (investor_a) + mod_client.allow_user(&ts.token, &investor_b); + + // Transfer passes because recipient is allowlisted (T-REX: sender OR recipient) + ts.token_client.transfer(&investor_a, &investor_b, &100); + assert_eq!(ts.token_client.balance(&investor_b), 100); +} + +// --------------------------------------------------------------------------- +// Test: Full stack with burn step (3F) +// --------------------------------------------------------------------------- + +#[test] +fn test_full_stack_with_burn() { + let ts = setup(); + + let investor_us = Address::generate(&ts.env); + let id_us = Address::generate(&ts.env); + + register_investor(&ts, &investor_us, &id_us, us_country_data()); + + // --- Wire CountryAllowModule (allow US) --- + let country_mod = ts.env.register(CountryAllowContract, (&ts.admin,)); + wire_module(&ts, &country_mod, &[ComplianceHook::CanTransfer, ComplianceHook::CanCreate]); + let country_client = CountryAllowContractClient::new(&ts.env, &country_mod); + country_client.set_identity_registry_storage(&ts.token, &ts.irs); + country_client.add_allowed_country(&ts.token, &840); + + // --- Wire MaxBalanceModule (max 1000) --- + let balance_mod = ts.env.register(MaxBalanceContract, (&ts.admin,)); + wire_module( + &ts, + &balance_mod, + &[ + ComplianceHook::CanTransfer, + ComplianceHook::CanCreate, + ComplianceHook::Transferred, + ComplianceHook::Created, + ComplianceHook::Destroyed, + ], + ); + let balance_client = MaxBalanceContractClient::new(&ts.env, &balance_mod); + balance_client.set_identity_registry_storage(&ts.token, &ts.irs); + balance_client.set_max_balance(&ts.token, &1000); + balance_client.verify_hook_wiring(); + + // --- Wire SupplyLimitModule (limit 2000) --- + let supply_mod = ts.env.register(SupplyLimitContract, (&ts.admin,)); + wire_module( + &ts, + &supply_mod, + &[ComplianceHook::CanCreate, ComplianceHook::Created, ComplianceHook::Destroyed], + ); + let supply_client = SupplyLimitContractClient::new(&ts.env, &supply_mod); + supply_client.set_supply_limit(&ts.token, &2000); + supply_client.verify_hook_wiring(); + + // 1) Mint 800 + ts.token_client.mint(&investor_us, &800, &ts.admin); + assert_eq!(ts.token_client.balance(&investor_us), 800); + assert_eq!(balance_client.get_investor_balance(&ts.token, &id_us), 800); + assert_eq!(supply_client.get_internal_supply(&ts.token), 800); + + // 2) Burn 300 — all internal state decrements + ts.token_client.burn(&investor_us, &300, &ts.admin); + assert_eq!(ts.token_client.balance(&investor_us), 500); + assert_eq!(balance_client.get_investor_balance(&ts.token, &id_us), 500); + assert_eq!(supply_client.get_internal_supply(&ts.token), 500); + + // 3) Mint back to 1000 (identity max) — succeeds + ts.token_client.mint(&investor_us, &500, &ts.admin); + assert_eq!(balance_client.get_investor_balance(&ts.token, &id_us), 1000); + assert_eq!(supply_client.get_internal_supply(&ts.token), 1000); + + // 4) Mint 1 more — exceeds max balance + let result = ts.token_client.try_mint(&investor_us, &1, &ts.admin); + assert!(result.is_err()); +} + +#[test] +#[should_panic(expected = "Error(Contract, #304)")] +fn identity_verifier_maps_missing_identity_to_rwa_error() { + let ts = setup(); + let verifier_client = SimpleIdentityVerifierClient::new(&ts.env, &ts.verifier); + let unknown_account = Address::generate(&ts.env); + + verifier_client.verify_identity(&unknown_account); +} + +#[test] +#[should_panic(expected = "Error(Contract, #310)")] +fn identity_verifier_claim_topics_getter_uses_contract_error() { + let ts = setup(); + let verifier_client = SimpleIdentityVerifierClient::new(&ts.env, &ts.verifier); + + verifier_client.claim_topics_and_issuers(); +} + +#[test] +#[should_panic(expected = "Error(Contract, #2000)")] +fn identity_verifier_claim_topics_setter_requires_admin_role() { + let ts = setup(); + let verifier_client = SimpleIdentityVerifierClient::new(&ts.env, &ts.verifier); + let claim_topics_and_issuers = Address::generate(&ts.env); + let unauthorized_operator = Address::generate(&ts.env); + + verifier_client.set_claim_topics_and_issuers(&claim_topics_and_issuers, &unauthorized_operator); +} diff --git a/examples/rwa-compliance/src/token.rs b/examples/rwa-compliance/src/token.rs new file mode 100644 index 000000000..39242ee95 --- /dev/null +++ b/examples/rwa-compliance/src/token.rs @@ -0,0 +1,141 @@ +//! RWA Token contract with full compliance and identity verification wiring. +//! +//! Implements `FungibleToken`, `RWAToken`, `Pausable`, and `AccessControl`. +//! The constructor wires compliance and identity-verifier addresses so that +//! all transfer/mint/burn operations are subject to modular compliance checks. + +use soroban_sdk::{ + contract, contractimpl, symbol_short, Address, Env, MuxedAddress, String, Symbol, Vec, +}; +use stellar_access::access_control::{self as access_control, AccessControl}; +use stellar_contract_utils::pausable::{self as pausable, Pausable}; +use stellar_macros::only_role; +use stellar_tokens::{ + fungible::{Base, FungibleToken}, + rwa::{storage::RWAStorageKey, RWAToken, RWA}, +}; + +#[contract] +pub struct RWATokenContract; + +#[contractimpl] +impl RWATokenContract { + pub fn __constructor( + e: &Env, + name: String, + symbol: String, + admin: Address, + compliance: Address, + identity_verifier: Address, + ) { + Base::set_metadata(e, 18, name, symbol); + access_control::set_admin(e, &admin); + access_control::grant_role_no_auth(e, &admin, &symbol_short!("admin"), &admin); + RWA::set_compliance(e, &compliance); + RWA::set_identity_verifier(e, &identity_verifier); + e.storage().instance().set(&RWAStorageKey::Version, &String::from_str(e, "1.0.0")); + RWA::set_onchain_id(e, &e.current_contract_address()); + } +} + +#[contractimpl(contracttrait)] +impl FungibleToken for RWATokenContract { + type ContractType = RWA; +} + +#[contractimpl] +impl RWAToken for RWATokenContract { + #[only_role(operator, "admin")] + fn forced_transfer(e: &Env, from: Address, to: Address, amount: i128, operator: Address) { + RWA::forced_transfer(e, &from, &to, amount); + } + + #[only_role(operator, "admin")] + fn mint(e: &Env, to: Address, amount: i128, operator: Address) { + RWA::mint(e, &to, amount); + } + + #[only_role(operator, "admin")] + fn burn(e: &Env, user_address: Address, amount: i128, operator: Address) { + RWA::burn(e, &user_address, amount); + } + + #[only_role(operator, "admin")] + fn recover_balance( + e: &Env, + old_account: Address, + new_account: Address, + operator: Address, + ) -> bool { + RWA::recover_balance(e, &old_account, &new_account) + } + + #[only_role(operator, "admin")] + fn set_address_frozen(e: &Env, user_address: Address, freeze: bool, operator: Address) { + RWA::set_address_frozen(e, &user_address, freeze); + } + + #[only_role(operator, "admin")] + fn freeze_partial_tokens(e: &Env, user_address: Address, amount: i128, operator: Address) { + RWA::freeze_partial_tokens(e, &user_address, amount); + } + + #[only_role(operator, "admin")] + fn unfreeze_partial_tokens(e: &Env, user_address: Address, amount: i128, operator: Address) { + RWA::unfreeze_partial_tokens(e, &user_address, amount); + } + + fn is_frozen(e: &Env, user_address: Address) -> bool { + RWA::is_frozen(e, &user_address) + } + + fn get_frozen_tokens(e: &Env, user_address: Address) -> i128 { + RWA::get_frozen_tokens(e, &user_address) + } + + fn version(e: &Env) -> String { + RWA::version(e) + } + + fn onchain_id(e: &Env) -> Address { + RWA::onchain_id(e) + } + + #[only_role(operator, "admin")] + fn set_compliance(e: &Env, compliance: Address, operator: Address) { + RWA::set_compliance(e, &compliance); + } + + fn compliance(e: &Env) -> Address { + RWA::compliance(e) + } + + #[only_role(operator, "admin")] + fn set_identity_verifier(e: &Env, identity_verifier: Address, operator: Address) { + RWA::set_identity_verifier(e, &identity_verifier); + } + + fn identity_verifier(e: &Env) -> Address { + RWA::identity_verifier(e) + } +} + +#[contractimpl] +impl Pausable for RWATokenContract { + fn paused(e: &Env) -> bool { + pausable::paused(e) + } + + #[only_role(caller, "admin")] + fn pause(e: &Env, caller: Address) { + pausable::pause(e); + } + + #[only_role(caller, "admin")] + fn unpause(e: &Env, caller: Address) { + pausable::unpause(e); + } +} + +#[contractimpl(contracttrait)] +impl AccessControl for RWATokenContract {} diff --git a/examples/rwa-country-allow/Cargo.toml b/examples/rwa-country-allow/Cargo.toml new file mode 100644 index 000000000..a60408eab --- /dev/null +++ b/examples/rwa-country-allow/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "rwa-country-allow" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-tokens = { workspace = true } diff --git a/examples/rwa-country-allow/README.md b/examples/rwa-country-allow/README.md new file mode 100644 index 000000000..f0b88aa54 --- /dev/null +++ b/examples/rwa-country-allow/README.md @@ -0,0 +1,50 @@ +# Country Allow Module + +Concrete deployable example of the `CountryAllow` compliance module for Stellar +RWA tokens. + +## What it enforces + +This module allows tokens to be minted or transferred only to recipients whose +registered identity has at least one country code that appears in the module's +per-token allowlist. + +The country lookup is performed through the Identity Registry Storage (IRS), so +the module must be configured with an IRS contract for each token it serves. + +## Authorization model + +This example uses the bootstrap-admin pattern introduced in this port: + +- The constructor stores a one-time `admin` +- Before `set_compliance_address`, privileged configuration calls require that + admin's auth +- After `set_compliance_address`, the same configuration calls require auth + from the bound Compliance contract +- `set_compliance_address` itself remains a one-time admin action + +This lets the module be configured from the CLI before it is locked to the +Compliance contract. + +## Main entrypoints + +- `__constructor(admin)` initializes the bootstrap admin +- `set_identity_registry_storage(token, irs)` stores the IRS address for a + token +- `add_allowed_country(token, country)` adds an ISO 3166-1 numeric code to the + allowlist +- `remove_allowed_country(token, country)` removes a country code +- `batch_allow_countries(token, countries)` updates multiple entries +- `batch_disallow_countries(token, countries)` removes multiple entries +- `is_country_allowed(token, country)` reads the current allowlist state +- `set_compliance_address(compliance)` performs the one-time handoff to the + Compliance contract + +## Notes + +- Storage is token-scoped, so one deployed module can be reused across many + tokens +- This module validates on the compliance read hooks used for transfers and + mints; it does not require extra state-tracking hooks +- In the deploy example, the module is configured before binding and then wired + to the `CanTransfer` and `CanCreate` hooks diff --git a/examples/rwa-country-allow/src/lib.rs b/examples/rwa-country-allow/src/lib.rs new file mode 100644 index 000000000..f2fd11b23 --- /dev/null +++ b/examples/rwa-country-allow/src/lib.rs @@ -0,0 +1,88 @@ +#![no_std] + +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::modules::{ + country_allow::{ + storage::{is_country_allowed, remove_country_allowed, set_country_allowed}, + CountryAllow, CountryAllowed, CountryUnallowed, + }, + storage::{set_compliance_address, set_irs_address, ComplianceModuleStorageKey}, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct CountryAllowContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl CountryAllowContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } +} + +#[contractimpl(contracttrait)] +impl CountryAllow for CountryAllowContract { + fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { + require_module_admin_or_compliance_auth(e); + set_irs_address(e, &token, &irs); + } + + fn add_allowed_country(e: &Env, token: Address, country: u32) { + require_module_admin_or_compliance_auth(e); + set_country_allowed(e, &token, country); + CountryAllowed { token, country }.publish(e); + } + + fn remove_allowed_country(e: &Env, token: Address, country: u32) { + require_module_admin_or_compliance_auth(e); + remove_country_allowed(e, &token, country); + CountryUnallowed { token, country }.publish(e); + } + + fn batch_allow_countries(e: &Env, token: Address, countries: Vec) { + require_module_admin_or_compliance_auth(e); + for country in countries.iter() { + set_country_allowed(e, &token, country); + CountryAllowed { token: token.clone(), country }.publish(e); + } + } + + fn batch_disallow_countries(e: &Env, token: Address, countries: Vec) { + require_module_admin_or_compliance_auth(e); + for country in countries.iter() { + remove_country_allowed(e, &token, country); + CountryUnallowed { token: token.clone(), country }.publish(e); + } + } + + fn is_country_allowed(e: &Env, token: Address, country: u32) -> bool { + is_country_allowed(e, &token, country) + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-country-restrict/Cargo.toml b/examples/rwa-country-restrict/Cargo.toml new file mode 100644 index 000000000..27aabc3bc --- /dev/null +++ b/examples/rwa-country-restrict/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "rwa-country-restrict" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-tokens = { workspace = true } diff --git a/examples/rwa-country-restrict/README.md b/examples/rwa-country-restrict/README.md new file mode 100644 index 000000000..104bf6066 --- /dev/null +++ b/examples/rwa-country-restrict/README.md @@ -0,0 +1,50 @@ +# Country Restrict Module + +Concrete deployable example of the `CountryRestrict` compliance module for +Stellar RWA tokens. + +## What it enforces + +This module blocks tokens from being minted or transferred to recipients whose +registered identity has a country code that appears in the module's per-token +restriction list. + +The country lookup is performed through the Identity Registry Storage (IRS), so +the module must be configured with an IRS contract for each token it serves. + +## Authorization model + +This example uses the bootstrap-admin pattern introduced in this port: + +- The constructor stores a one-time `admin` +- Before `set_compliance_address`, privileged configuration calls require that + admin's auth +- After `set_compliance_address`, the same configuration calls require auth + from the bound Compliance contract +- `set_compliance_address` itself remains a one-time admin action + +This lets the module be configured from the CLI before it is locked to the +Compliance contract. + +## Main entrypoints + +- `__constructor(admin)` initializes the bootstrap admin +- `set_identity_registry_storage(token, irs)` stores the IRS address for a + token +- `add_country_restriction(token, country)` adds an ISO 3166-1 numeric code to + the restriction list +- `remove_country_restriction(token, country)` removes a country code +- `batch_restrict_countries(token, countries)` updates multiple entries +- `batch_unrestrict_countries(token, countries)` removes multiple entries +- `is_country_restricted(token, country)` reads the current restriction state +- `set_compliance_address(compliance)` performs the one-time handoff to the + Compliance contract + +## Notes + +- Storage is token-scoped, so one deployed module can be reused across many + tokens +- This module validates on the compliance read hooks used for transfers and + mints; it does not require extra state-tracking hooks +- In the deploy example, the module is configured before binding and then wired + to the `CanTransfer` and `CanCreate` hooks diff --git a/examples/rwa-country-restrict/src/lib.rs b/examples/rwa-country-restrict/src/lib.rs new file mode 100644 index 000000000..8d8d4c140 --- /dev/null +++ b/examples/rwa-country-restrict/src/lib.rs @@ -0,0 +1,88 @@ +#![no_std] + +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::modules::{ + country_restrict::{ + storage::{is_country_restricted, remove_country_restricted, set_country_restricted}, + CountryRestrict, CountryRestricted, CountryUnrestricted, + }, + storage::{set_compliance_address, set_irs_address, ComplianceModuleStorageKey}, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct CountryRestrictContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl CountryRestrictContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } +} + +#[contractimpl(contracttrait)] +impl CountryRestrict for CountryRestrictContract { + fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { + require_module_admin_or_compliance_auth(e); + set_irs_address(e, &token, &irs); + } + + fn add_country_restriction(e: &Env, token: Address, country: u32) { + require_module_admin_or_compliance_auth(e); + set_country_restricted(e, &token, country); + CountryRestricted { token, country }.publish(e); + } + + fn remove_country_restriction(e: &Env, token: Address, country: u32) { + require_module_admin_or_compliance_auth(e); + remove_country_restricted(e, &token, country); + CountryUnrestricted { token, country }.publish(e); + } + + fn batch_restrict_countries(e: &Env, token: Address, countries: Vec) { + require_module_admin_or_compliance_auth(e); + for country in countries.iter() { + set_country_restricted(e, &token, country); + CountryRestricted { token: token.clone(), country }.publish(e); + } + } + + fn batch_unrestrict_countries(e: &Env, token: Address, countries: Vec) { + require_module_admin_or_compliance_auth(e); + for country in countries.iter() { + remove_country_restricted(e, &token, country); + CountryUnrestricted { token: token.clone(), country }.publish(e); + } + } + + fn is_country_restricted(e: &Env, token: Address, country: u32) -> bool { + is_country_restricted(e, &token, country) + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-initial-lockup-period/Cargo.toml b/examples/rwa-initial-lockup-period/Cargo.toml new file mode 100644 index 000000000..dc0edbff4 --- /dev/null +++ b/examples/rwa-initial-lockup-period/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "rwa-initial-lockup-period" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-tokens = { workspace = true } diff --git a/examples/rwa-initial-lockup-period/README.md b/examples/rwa-initial-lockup-period/README.md new file mode 100644 index 000000000..e795d527d --- /dev/null +++ b/examples/rwa-initial-lockup-period/README.md @@ -0,0 +1,64 @@ +# Initial Lockup Period Module + +Concrete deployable example of the `InitialLockupPeriod` compliance module for +Stellar RWA tokens. + +## What it enforces + +This module applies a lockup period to tokens received through primary +emissions. When tokens are minted, the minted amount is locked until the +configured release timestamp. + +The example follows the library semantics: + +- minted tokens are subject to lockup +- peer-to-peer transfers do not create new lockups for the recipient +- transfers and burns can consume only unlocked balance + +## How it stays in sync + +The module maintains internal balances plus lock records and therefore must be +wired to all of the hooks it depends on: + +- `CanTransfer` +- `Created` +- `Transferred` +- `Destroyed` + +After those hooks are registered, `verify_hook_wiring()` must be called once so +the module marks itself as armed before transfer validation starts. + +## Authorization model + +This example uses the bootstrap-admin pattern introduced in this port: + +- The constructor stores a one-time `admin` +- Before `set_compliance_address`, configuration calls require that admin's + auth +- After `set_compliance_address`, privileged calls require auth from the bound + Compliance contract +- `set_compliance_address` itself remains a one-time admin action + +This allows the module to be configured from the CLI before handing control to +Compliance. + +## Main entrypoints + +- `__constructor(admin)` initializes the bootstrap admin +- `set_lockup_period(token, lockup_seconds)` configures the mint lockup window +- `pre_set_lockup_state(token, wallet, balance, locks)` seeds an existing + holder's mirrored balance and active lock entries +- `required_hooks()` returns the required hook set +- `verify_hook_wiring()` marks the module as armed after registration +- `set_compliance_address(compliance)` performs the one-time handoff to the + Compliance contract + +## Notes + +- Storage is token-scoped, so one deployed module can be reused across many + tokens +- The module stores detailed lock entries plus aggregate locked totals +- If the module is attached after live minting, seed existing balances and any + still-active lock entries before relying on transfer or burn enforcement +- Transfer and burn flows consume unlocked balance first, then matured locks if + needed diff --git a/examples/rwa-initial-lockup-period/src/lib.rs b/examples/rwa-initial-lockup-period/src/lib.rs new file mode 100644 index 000000000..da4fa20d0 --- /dev/null +++ b/examples/rwa-initial-lockup-period/src/lib.rs @@ -0,0 +1,228 @@ +#![no_std] + +use soroban_sdk::{contract, contractimpl, contracttype, vec, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::{ + modules::{ + initial_lockup_period::{ + storage::{ + get_internal_balance, get_locks, get_lockup_period, get_total_locked, + set_internal_balance, set_locks, set_lockup_period, set_total_locked, + }, + InitialLockupPeriod, LockedTokens, LockupPeriodSet, + }, + storage::{ + add_i128_or_panic, set_compliance_address, sub_i128_or_panic, verify_required_hooks, + ComplianceModuleStorageKey, + }, + }, + ComplianceHook, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct InitialLockupPeriodContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +fn calculate_unlocked_amount(e: &Env, locks: &Vec) -> i128 { + let now = e.ledger().timestamp(); + let mut unlocked = 0i128; + for i in 0..locks.len() { + let lock = locks.get(i).unwrap(); + if lock.release_timestamp <= now { + unlocked = add_i128_or_panic(e, unlocked, lock.amount); + } + } + unlocked +} + +fn update_locked_tokens(e: &Env, token: &Address, wallet: &Address, mut amount_to_consume: i128) { + let locks = get_locks(e, token, wallet); + let now = e.ledger().timestamp(); + let mut new_locks = Vec::new(e); + let mut consumed_total = 0i128; + + for i in 0..locks.len() { + let lock = locks.get(i).unwrap(); + if amount_to_consume > 0 && lock.release_timestamp <= now { + if amount_to_consume >= lock.amount { + amount_to_consume = sub_i128_or_panic(e, amount_to_consume, lock.amount); + consumed_total = add_i128_or_panic(e, consumed_total, lock.amount); + } else { + consumed_total = add_i128_or_panic(e, consumed_total, amount_to_consume); + new_locks.push_back(LockedTokens { + amount: sub_i128_or_panic(e, lock.amount, amount_to_consume), + release_timestamp: lock.release_timestamp, + }); + amount_to_consume = 0; + } + } else { + new_locks.push_back(lock); + } + } + + set_locks(e, token, wallet, &new_locks); + + let total_locked = get_total_locked(e, token, wallet); + set_total_locked(e, token, wallet, sub_i128_or_panic(e, total_locked, consumed_total)); +} + +#[contractimpl] +impl InitialLockupPeriodContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } +} + +#[contractimpl(contracttrait)] +impl InitialLockupPeriod for InitialLockupPeriodContract { + fn set_lockup_period(e: &Env, token: Address, lockup_seconds: u64) { + require_module_admin_or_compliance_auth(e); + set_lockup_period(e, &token, lockup_seconds); + LockupPeriodSet { token, lockup_seconds }.publish(e); + } + + fn pre_set_lockup_state( + e: &Env, + token: Address, + wallet: Address, + balance: i128, + locks: Vec, + ) { + require_module_admin_or_compliance_auth(e); + stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, balance); + + let mut total_locked = 0i128; + for i in 0..locks.len() { + let lock = locks.get(i).unwrap(); + stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount( + e, + lock.amount, + ); + total_locked = add_i128_or_panic(e, total_locked, lock.amount); + } + + assert!( + total_locked <= balance, + "InitialLockupPeriodModule: total locked amount cannot exceed balance" + ); + + set_internal_balance(e, &token, &wallet, balance); + set_locks(e, &token, &wallet, &locks); + set_total_locked(e, &token, &wallet, total_locked); + } + + fn required_hooks(e: &Env) -> Vec { + vec![ + e, + ComplianceHook::CanTransfer, + ComplianceHook::Created, + ComplianceHook::Transferred, + ComplianceHook::Destroyed, + ] + } + + fn verify_hook_wiring(e: &Env) { + verify_required_hooks(e, Self::required_hooks(e)); + } + + fn on_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, amount); + + let total_locked = get_total_locked(e, &token, &from); + + if total_locked > 0 { + let pre_balance = get_internal_balance(e, &token, &from); + let pre_free = pre_balance - total_locked; + + if amount > pre_free.max(0) { + let to_consume = amount - pre_free.max(0); + update_locked_tokens(e, &token, &from, to_consume); + } + } + + let from_bal = get_internal_balance(e, &token, &from); + set_internal_balance(e, &token, &from, sub_i128_or_panic(e, from_bal, amount)); + + let to_bal = get_internal_balance(e, &token, &to); + set_internal_balance(e, &token, &to, add_i128_or_panic(e, to_bal, amount)); + } + + fn on_created(e: &Env, to: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, amount); + + let period = get_lockup_period(e, &token); + if period > 0 { + let mut locks = get_locks(e, &token, &to); + locks.push_back(LockedTokens { + amount, + release_timestamp: e.ledger().timestamp().saturating_add(period), + }); + set_locks(e, &token, &to, &locks); + + let total = get_total_locked(e, &token, &to); + set_total_locked(e, &token, &to, add_i128_or_panic(e, total, amount)); + } + + let current = get_internal_balance(e, &token, &to); + set_internal_balance(e, &token, &to, add_i128_or_panic(e, current, amount)); + } + + fn on_destroyed(e: &Env, from: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, amount); + + let total_locked = get_total_locked(e, &token, &from); + + if total_locked > 0 { + let pre_balance = get_internal_balance(e, &token, &from); + let mut free_amount = pre_balance - total_locked; + + if free_amount < amount { + let locks = get_locks(e, &token, &from); + free_amount += calculate_unlocked_amount(e, &locks); + } + + assert!( + free_amount >= amount, + "InitialLockupPeriodModule: insufficient unlocked balance for burn" + ); + + let pre_free = pre_balance - total_locked; + if amount > pre_free.max(0) { + let to_consume = amount - pre_free.max(0); + update_locked_tokens(e, &token, &from, to_consume); + } + } + + let current = get_internal_balance(e, &token, &from); + set_internal_balance(e, &token, &from, sub_i128_or_panic(e, current, amount)); + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-max-balance/Cargo.toml b/examples/rwa-max-balance/Cargo.toml new file mode 100644 index 000000000..1238118f2 --- /dev/null +++ b/examples/rwa-max-balance/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "rwa-max-balance" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-tokens = { workspace = true } diff --git a/examples/rwa-max-balance/README.md b/examples/rwa-max-balance/README.md new file mode 100644 index 000000000..193ebfa64 --- /dev/null +++ b/examples/rwa-max-balance/README.md @@ -0,0 +1,62 @@ +# Max Balance Module + +Concrete deployable example of the `MaxBalance` compliance module for Stellar +RWA tokens. + +## What it enforces + +This module tracks balances per investor identity, not per wallet, and enforces +a maximum balance cap for each token. + +Because the accounting is identity-based, the module must be configured with an +Identity Registry Storage (IRS) contract for each token it serves. + +## How it stays in sync + +The module maintains internal per-identity balances and therefore must be wired +to all of the hooks it depends on: + +- `CanTransfer` +- `CanCreate` +- `Transferred` +- `Created` +- `Destroyed` + +After those hooks are registered, `verify_hook_wiring()` must be called once so +the module marks itself as armed before mint and transfer validation starts. + +## Authorization model + +This example uses the bootstrap-admin pattern introduced in this port: + +- The constructor stores a one-time `admin` +- Before `set_compliance_address`, configuration calls require that admin's + auth +- After `set_compliance_address`, privileged calls require auth from the bound + Compliance contract +- `set_compliance_address` itself remains a one-time admin action + +This allows the module to be seeded and configured from the CLI before handing +control to Compliance. + +## Main entrypoints + +- `__constructor(admin)` initializes the bootstrap admin +- `set_identity_registry_storage(token, irs)` stores the IRS address for a + token +- `set_max_balance(token, max)` configures the per-identity cap +- `pre_set_module_state(token, identity, balance)` seeds an identity balance +- `batch_pre_set_module_state(token, identities, balances)` seeds many + identity balances +- `required_hooks()` returns the required hook set +- `verify_hook_wiring()` marks the module as armed after registration +- `set_compliance_address(compliance)` performs the one-time handoff to the + Compliance contract + +## Notes + +- Storage is token-scoped, so one deployed module can be reused across many + tokens +- Transfers between two wallets that resolve to the same identity do not change + the tracked balance distribution +- A configured max of `0` behaves as "no cap" diff --git a/examples/rwa-max-balance/src/lib.rs b/examples/rwa-max-balance/src/lib.rs new file mode 100644 index 000000000..16352fde6 --- /dev/null +++ b/examples/rwa-max-balance/src/lib.rs @@ -0,0 +1,167 @@ +#![no_std] + +use soroban_sdk::{contract, contractimpl, contracttype, vec, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::{ + modules::{ + max_balance::{ + storage::{get_id_balance, get_max_balance, set_id_balance, set_max_balance}, + IDBalancePreSet, MaxBalance, MaxBalanceSet, + }, + storage::{ + add_i128_or_panic, get_irs_client, set_compliance_address, set_irs_address, + sub_i128_or_panic, verify_required_hooks, ComplianceModuleStorageKey, + }, + }, + ComplianceHook, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct MaxBalanceContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl MaxBalanceContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } +} + +#[contractimpl(contracttrait)] +impl MaxBalance for MaxBalanceContract { + fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { + require_module_admin_or_compliance_auth(e); + set_irs_address(e, &token, &irs); + } + + fn set_max_balance(e: &Env, token: Address, max: i128) { + require_module_admin_or_compliance_auth(e); + stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, max); + set_max_balance(e, &token, max); + MaxBalanceSet { token, max_balance: max }.publish(e); + } + + fn pre_set_module_state(e: &Env, token: Address, identity: Address, balance: i128) { + require_module_admin_or_compliance_auth(e); + stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, balance); + set_id_balance(e, &token, &identity, balance); + IDBalancePreSet { token, identity, balance }.publish(e); + } + + fn batch_pre_set_module_state( + e: &Env, + token: Address, + identities: Vec
, + balances: Vec, + ) { + require_module_admin_or_compliance_auth(e); + assert!( + identities.len() == balances.len(), + "MaxBalanceModule: identities and balances length mismatch" + ); + for i in 0..identities.len() { + let id = identities.get(i).unwrap(); + let bal = balances.get(i).unwrap(); + stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, bal); + set_id_balance(e, &token, &id, bal); + IDBalancePreSet { token: token.clone(), identity: id, balance: bal }.publish(e); + } + } + + fn required_hooks(e: &Env) -> Vec { + vec![ + e, + ComplianceHook::CanTransfer, + ComplianceHook::CanCreate, + ComplianceHook::Transferred, + ComplianceHook::Created, + ComplianceHook::Destroyed, + ] + } + + fn verify_hook_wiring(e: &Env) { + verify_required_hooks(e, Self::required_hooks(e)); + } + + fn on_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, amount); + + let irs = get_irs_client(e, &token); + let from_id = irs.stored_identity(&from); + let to_id = irs.stored_identity(&to); + + if from_id == to_id { + return; + } + + let from_balance = get_id_balance(e, &token, &from_id); + let to_balance = get_id_balance(e, &token, &to_id); + let new_to_balance = add_i128_or_panic(e, to_balance, amount); + + let max = get_max_balance(e, &token); + assert!( + max == 0 || new_to_balance <= max, + "MaxBalanceModule: recipient identity balance exceeds max" + ); + + set_id_balance(e, &token, &from_id, sub_i128_or_panic(e, from_balance, amount)); + set_id_balance(e, &token, &to_id, new_to_balance); + } + + fn on_created(e: &Env, to: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, amount); + + let irs = get_irs_client(e, &token); + let to_id = irs.stored_identity(&to); + + let current = get_id_balance(e, &token, &to_id); + let new_balance = add_i128_or_panic(e, current, amount); + + let max = get_max_balance(e, &token); + assert!( + max == 0 || new_balance <= max, + "MaxBalanceModule: recipient identity balance exceeds max after mint" + ); + + set_id_balance(e, &token, &to_id, new_balance); + } + + fn on_destroyed(e: &Env, from: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, amount); + + let irs = get_irs_client(e, &token); + let from_id = irs.stored_identity(&from); + + let current = get_id_balance(e, &token, &from_id); + set_id_balance(e, &token, &from_id, sub_i128_or_panic(e, current, amount)); + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-supply-limit/Cargo.toml b/examples/rwa-supply-limit/Cargo.toml new file mode 100644 index 000000000..b4d0e313c --- /dev/null +++ b/examples/rwa-supply-limit/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "rwa-supply-limit" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-tokens = { workspace = true } diff --git a/examples/rwa-supply-limit/README.md b/examples/rwa-supply-limit/README.md new file mode 100644 index 000000000..a589cfd70 --- /dev/null +++ b/examples/rwa-supply-limit/README.md @@ -0,0 +1,61 @@ +# Supply Limit Module + +Concrete deployable example of the `SupplyLimit` compliance module for Stellar +RWA tokens. + +## What it enforces + +This module caps the total amount of tokens that may be minted for a given +token contract. + +It keeps an internal supply counter and checks that each mint would stay within +the configured per-token limit. + +## How it stays in sync + +The module maintains internal supply state and therefore must be wired to all +of the hooks it depends on: + +- `CanCreate` +- `Created` +- `Destroyed` + +After those hooks are registered, `verify_hook_wiring()` must be called once so +the module marks itself as armed before mint validation starts. + +## Authorization model + +This example uses the bootstrap-admin pattern introduced in this port: + +- The constructor stores a one-time `admin` +- Before `set_compliance_address`, configuration calls require that admin's + auth +- After `set_compliance_address`, privileged calls require auth from the bound + Compliance contract +- `set_compliance_address` itself remains a one-time admin action + +This allows the module to be configured from the CLI before handing control to +Compliance. + +## Main entrypoints + +- `__constructor(admin)` initializes the bootstrap admin +- `set_supply_limit(token, limit)` sets the per-token cap +- `pre_set_internal_supply(token, supply)` seeds tracked supply when wiring the + module after historical minting +- `get_supply_limit(token)` reads the configured cap +- `get_internal_supply(token)` reads the tracked internal supply +- `required_hooks()` returns the required hook set +- `verify_hook_wiring()` marks the module as armed after registration +- `set_compliance_address(compliance)` performs the one-time handoff to the + Compliance contract + +## Notes + +- Storage is token-scoped, so one deployed module can be reused across many + tokens +- A configured limit of `0` behaves as "no cap" +- If the module is attached after a token already has minted supply, seed the + existing amount with `pre_set_internal_supply` before relying on `can_create` +- The internal supply is updated only through the registered `Created` and + `Destroyed` hooks diff --git a/examples/rwa-supply-limit/src/lib.rs b/examples/rwa-supply-limit/src/lib.rs new file mode 100644 index 000000000..2250b7026 --- /dev/null +++ b/examples/rwa-supply-limit/src/lib.rs @@ -0,0 +1,102 @@ +#![no_std] + +use soroban_sdk::{contract, contractimpl, contracttype, vec, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::{ + modules::{ + storage::{ + add_i128_or_panic, set_compliance_address, sub_i128_or_panic, verify_required_hooks, + ComplianceModuleStorageKey, + }, + supply_limit::{ + storage::{ + get_internal_supply, get_supply_limit, set_internal_supply, set_supply_limit, + }, + SupplyLimit, SupplyLimitSet, + }, + }, + ComplianceHook, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct SupplyLimitContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl SupplyLimitContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } +} + +#[contractimpl(contracttrait)] +impl SupplyLimit for SupplyLimitContract { + fn set_supply_limit(e: &Env, token: Address, limit: i128) { + require_module_admin_or_compliance_auth(e); + stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, limit); + set_supply_limit(e, &token, limit); + SupplyLimitSet { token, limit }.publish(e); + } + + fn pre_set_internal_supply(e: &Env, token: Address, supply: i128) { + require_module_admin_or_compliance_auth(e); + stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, supply); + set_internal_supply(e, &token, supply); + } + + fn get_supply_limit(e: &Env, token: Address) -> i128 { + get_supply_limit(e, &token) + } + + fn get_internal_supply(e: &Env, token: Address) -> i128 { + get_internal_supply(e, &token) + } + + fn required_hooks(e: &Env) -> Vec { + vec![e, ComplianceHook::CanCreate, ComplianceHook::Created, ComplianceHook::Destroyed] + } + + fn verify_hook_wiring(e: &Env) { + verify_required_hooks(e, Self::required_hooks(e)); + } + + fn on_created(e: &Env, _to: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, amount); + let current = get_internal_supply(e, &token); + set_internal_supply(e, &token, add_i128_or_panic(e, current, amount)); + } + + fn on_destroyed(e: &Env, _from: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, amount); + let current = get_internal_supply(e, &token); + set_internal_supply(e, &token, sub_i128_or_panic(e, current, amount)); + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-time-transfers-limits/Cargo.toml b/examples/rwa-time-transfers-limits/Cargo.toml new file mode 100644 index 000000000..6b71f752c --- /dev/null +++ b/examples/rwa-time-transfers-limits/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "rwa-time-transfers-limits" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-tokens = { workspace = true } diff --git a/examples/rwa-time-transfers-limits/README.md b/examples/rwa-time-transfers-limits/README.md new file mode 100644 index 000000000..6377ab122 --- /dev/null +++ b/examples/rwa-time-transfers-limits/README.md @@ -0,0 +1,71 @@ +# Time Transfers Limits Module + +Concrete deployable example of the `TimeTransfersLimits` compliance module for +Stellar RWA tokens. + +## What it enforces + +This module limits the amount an investor identity may transfer within one or +more configured time windows. + +Limits are tracked per identity, not per wallet, so the module must be +configured with an Identity Registry Storage (IRS) contract for each token it +serves. + +Each limit is defined by: + +- `limit_time`: the window size in seconds +- `limit_value`: the maximum transferable amount during that window + +This example allows up to four active limits per token. + +## How it stays in sync + +The module maintains transfer counters and therefore must be wired to all of +the hooks it depends on: + +- `CanTransfer` +- `Transferred` + +After those hooks are registered, `verify_hook_wiring()` must be called once so +the module marks itself as armed before transfer validation starts. + +## Authorization model + +This example uses the bootstrap-admin pattern introduced in this port: + +- The constructor stores a one-time `admin` +- Before `set_compliance_address`, configuration calls require that admin's + auth +- After `set_compliance_address`, privileged calls require auth from the bound + Compliance contract +- `set_compliance_address` itself remains a one-time admin action + +This allows the module to be configured from the CLI before handing control to +Compliance. + +## Main entrypoints + +- `__constructor(admin)` initializes the bootstrap admin +- `set_identity_registry_storage(token, irs)` stores the IRS address for a + token +- `set_time_transfer_limit(token, limit)` adds or replaces a limit window +- `batch_set_time_transfer_limit(token, limits)` updates multiple windows +- `remove_time_transfer_limit(token, limit_time)` removes a window +- `batch_remove_time_transfer_limit(token, limit_times)` removes many windows +- `pre_set_transfer_counter(token, identity, limit_time, counter)` seeds an + in-flight rolling window when attaching the module after recent transfers +- `required_hooks()` returns the required hook set +- `verify_hook_wiring()` marks the module as armed after registration +- `set_compliance_address(compliance)` performs the one-time handoff to the + Compliance contract + +## Notes + +- Storage is token-scoped, so one deployed module can be reused across many + tokens +- Counter resets are driven by ledger timestamps +- If the module is attached after transfers have already occurred inside an + active window, seed the relevant identity counters before relying on + `can_transfer` +- Only outgoing transfer volume is tracked; mint and burn hooks are not used diff --git a/examples/rwa-time-transfers-limits/src/lib.rs b/examples/rwa-time-transfers-limits/src/lib.rs new file mode 100644 index 000000000..88ed4ae88 --- /dev/null +++ b/examples/rwa-time-transfers-limits/src/lib.rs @@ -0,0 +1,202 @@ +#![no_std] + +use soroban_sdk::{ + contract, contractimpl, contracttype, panic_with_error, vec, Address, Env, String, Vec, +}; +use stellar_tokens::rwa::compliance::{ + modules::{ + storage::{ + add_i128_or_panic, get_irs_client, set_compliance_address, set_irs_address, + verify_required_hooks, ComplianceModuleStorageKey, + }, + time_transfers_limits::{ + storage::{get_counter, get_limits, set_counter, set_limits}, + Limit, TimeTransferLimitRemoved, TimeTransferLimitUpdated, TimeTransfersLimits, + TransferCounter, + }, + ComplianceModuleError, + }, + ComplianceHook, +}; + +const MAX_LIMITS_PER_TOKEN: u32 = 4; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct TimeTransfersLimitsContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +fn is_counter_finished(e: &Env, token: &Address, identity: &Address, limit_time: u64) -> bool { + let counter = get_counter(e, token, identity, limit_time); + counter.timer <= e.ledger().timestamp() +} + +fn reset_counter_if_needed(e: &Env, token: &Address, identity: &Address, limit_time: u64) { + if is_counter_finished(e, token, identity, limit_time) { + let counter = + TransferCounter { value: 0, timer: e.ledger().timestamp().saturating_add(limit_time) }; + set_counter(e, token, identity, limit_time, &counter); + } +} + +fn increase_counters(e: &Env, token: &Address, identity: &Address, value: i128) { + let limits = get_limits(e, token); + for limit in limits.iter() { + reset_counter_if_needed(e, token, identity, limit.limit_time); + let mut counter = get_counter(e, token, identity, limit.limit_time); + counter.value = add_i128_or_panic(e, counter.value, value); + set_counter(e, token, identity, limit.limit_time, &counter); + } +} + +#[contractimpl] +impl TimeTransfersLimitsContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } +} + +#[contractimpl(contracttrait)] +impl TimeTransfersLimits for TimeTransfersLimitsContract { + fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { + require_module_admin_or_compliance_auth(e); + set_irs_address(e, &token, &irs); + } + + fn set_time_transfer_limit(e: &Env, token: Address, limit: Limit) { + require_module_admin_or_compliance_auth(e); + assert!(limit.limit_time > 0, "limit_time must be greater than zero"); + stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount( + e, + limit.limit_value, + ); + let mut limits = get_limits(e, &token); + + let mut replaced = false; + for i in 0..limits.len() { + let current = limits.get(i).expect("limit exists"); + if current.limit_time == limit.limit_time { + limits.set(i, limit.clone()); + replaced = true; + break; + } + } + + if !replaced { + if limits.len() >= MAX_LIMITS_PER_TOKEN { + panic_with_error!(e, ComplianceModuleError::TooManyLimits); + } + limits.push_back(limit.clone()); + } + + set_limits(e, &token, &limits); + TimeTransferLimitUpdated { token, limit }.publish(e); + } + + fn batch_set_time_transfer_limit(e: &Env, token: Address, limits: Vec) { + require_module_admin_or_compliance_auth(e); + for limit in limits.iter() { + Self::set_time_transfer_limit(e, token.clone(), limit); + } + } + + fn remove_time_transfer_limit(e: &Env, token: Address, limit_time: u64) { + require_module_admin_or_compliance_auth(e); + let mut limits = get_limits(e, &token); + + let mut found = false; + for i in 0..limits.len() { + let current = limits.get(i).expect("limit exists"); + if current.limit_time == limit_time { + limits.remove(i); + found = true; + break; + } + } + + if !found { + panic_with_error!(e, ComplianceModuleError::MissingLimit); + } + + set_limits(e, &token, &limits); + TimeTransferLimitRemoved { token, limit_time }.publish(e); + } + + fn batch_remove_time_transfer_limit(e: &Env, token: Address, limit_times: Vec) { + require_module_admin_or_compliance_auth(e); + for lt in limit_times.iter() { + Self::remove_time_transfer_limit(e, token.clone(), lt); + } + } + + fn pre_set_transfer_counter( + e: &Env, + token: Address, + identity: Address, + limit_time: u64, + counter: TransferCounter, + ) { + require_module_admin_or_compliance_auth(e); + stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount( + e, + counter.value, + ); + assert!(limit_time > 0, "limit_time must be greater than zero"); + + let mut found = false; + for limit in get_limits(e, &token).iter() { + if limit.limit_time == limit_time { + found = true; + break; + } + } + + if !found { + panic_with_error!(e, ComplianceModuleError::MissingLimit); + } + + set_counter(e, &token, &identity, limit_time, &counter); + } + + fn required_hooks(e: &Env) -> Vec { + vec![e, ComplianceHook::CanTransfer, ComplianceHook::Transferred] + } + + fn verify_hook_wiring(e: &Env) { + verify_required_hooks(e, Self::required_hooks(e)); + } + + fn on_transfer(e: &Env, from: Address, _to: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, amount); + let irs = get_irs_client(e, &token); + let from_id = irs.stored_identity(&from); + increase_counters(e, &token, &from_id, amount); + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-transfer-restrict/Cargo.toml b/examples/rwa-transfer-restrict/Cargo.toml new file mode 100644 index 000000000..9655c300d --- /dev/null +++ b/examples/rwa-transfer-restrict/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "rwa-transfer-restrict" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-tokens = { workspace = true } diff --git a/examples/rwa-transfer-restrict/README.md b/examples/rwa-transfer-restrict/README.md new file mode 100644 index 000000000..a8283c44b --- /dev/null +++ b/examples/rwa-transfer-restrict/README.md @@ -0,0 +1,47 @@ +# Transfer Restrict Module + +Concrete deployable example of the `TransferRestrict` compliance module for +Stellar RWA tokens. + +## What it enforces + +This module maintains a per-token address allowlist for transfers. + +It follows the T-REX semantics implemented by the library trait: + +- if the sender is allowlisted, the transfer passes +- otherwise, the recipient must be allowlisted + +The module is token-scoped, so one deployment can serve many tokens. + +## Authorization model + +This example uses the bootstrap-admin pattern introduced in this port: + +- The constructor stores a one-time `admin` +- Before `set_compliance_address`, allowlist management requires that admin's + auth +- After `set_compliance_address`, the same configuration calls require auth + from the bound Compliance contract +- `set_compliance_address` itself remains a one-time admin action + +This lets the module be configured from the CLI before it is locked to the +Compliance contract. + +## Main entrypoints + +- `__constructor(admin)` initializes the bootstrap admin +- `allow_user(token, user)` adds an address to the transfer allowlist +- `disallow_user(token, user)` removes an address from the transfer allowlist +- `batch_allow_users(token, users)` updates multiple entries +- `batch_disallow_users(token, users)` removes multiple entries +- `is_user_allowed(token, user)` reads the current allowlist state +- `set_compliance_address(compliance)` performs the one-time handoff to the + Compliance contract + +## Notes + +- This module validates transfers through the `CanTransfer` hook +- It does not depend on IRS or other identity infrastructure +- In the deploy example, the admin address is pre-allowlisted before binding so + the happy-path transfer checks can succeed diff --git a/examples/rwa-transfer-restrict/src/lib.rs b/examples/rwa-transfer-restrict/src/lib.rs new file mode 100644 index 000000000..49084164a --- /dev/null +++ b/examples/rwa-transfer-restrict/src/lib.rs @@ -0,0 +1,83 @@ +#![no_std] + +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::modules::{ + storage::{set_compliance_address, ComplianceModuleStorageKey}, + transfer_restrict::{ + storage::{is_user_allowed, remove_user_allowed, set_user_allowed}, + TransferRestrict, UserAllowed, UserDisallowed, + }, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct TransferRestrictContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl TransferRestrictContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } +} + +#[contractimpl(contracttrait)] +impl TransferRestrict for TransferRestrictContract { + fn allow_user(e: &Env, token: Address, user: Address) { + require_module_admin_or_compliance_auth(e); + set_user_allowed(e, &token, &user); + UserAllowed { token, user }.publish(e); + } + + fn disallow_user(e: &Env, token: Address, user: Address) { + require_module_admin_or_compliance_auth(e); + remove_user_allowed(e, &token, &user); + UserDisallowed { token, user }.publish(e); + } + + fn batch_allow_users(e: &Env, token: Address, users: Vec
) { + require_module_admin_or_compliance_auth(e); + for user in users.iter() { + set_user_allowed(e, &token, &user); + UserAllowed { token: token.clone(), user }.publish(e); + } + } + + fn batch_disallow_users(e: &Env, token: Address, users: Vec
) { + require_module_admin_or_compliance_auth(e); + for user in users.iter() { + remove_user_allowed(e, &token, &user); + UserDisallowed { token: token.clone(), user }.publish(e); + } + } + + fn is_user_allowed(e: &Env, token: Address, user: Address) -> bool { + is_user_allowed(e, &token, &user) + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/packages/tokens/src/rwa/compliance/modules/country_allow/mod.rs b/packages/tokens/src/rwa/compliance/modules/country_allow/mod.rs new file mode 100644 index 000000000..fe6cdda69 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/country_allow/mod.rs @@ -0,0 +1,455 @@ +//! Country allowlist compliance module — Stellar port of T-REX +//! [`CountryAllowModule.sol`][trex-src]. +//! +//! Only recipients whose identity has at least one country code in the +//! allowlist may receive tokens. +//! +//! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/CountryAllowModule.sol + +pub mod storage; + +use soroban_sdk::{contractevent, contracttrait, Address, Env, String, Vec}; +use storage::{is_country_allowed, remove_country_allowed, set_country_allowed}; + +use super::storage::{ + country_code, get_compliance_address, get_irs_country_data_entries, module_name, + set_irs_address, +}; + +/// Emitted when a country is added to the allowlist. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CountryAllowed { + #[topic] + pub token: Address, + pub country: u32, +} + +/// Emitted when a country is removed from the allowlist. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CountryUnallowed { + #[topic] + pub token: Address, + pub country: u32, +} + +/// Country allowlist compliance trait. +/// +/// Provides default implementations for maintaining a per-token country +/// allowlist and validating transfers/mints against it via the Identity +/// Registry Storage. +#[contracttrait] +pub trait CountryAllow { + /// Sets the Identity Registry Storage contract address for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token this IRS applies to. + /// * `irs` - The IRS contract address. + /// + /// # Authorization + /// + /// Requires compliance contract authorization. + fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { + get_compliance_address(e).require_auth(); + set_irs_address(e, &token, &irs); + } + + /// Adds a country to the allowlist for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `country` - The ISO 3166-1 numeric country code to allow. + /// + /// # Authorization + /// + /// Requires compliance contract authorization. + /// + /// # Events + /// + /// Emits [`CountryAllowed`]. + fn add_allowed_country(e: &Env, token: Address, country: u32) { + get_compliance_address(e).require_auth(); + set_country_allowed(e, &token, country); + CountryAllowed { token, country }.publish(e); + } + + /// Removes a country from the allowlist for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `country` - The ISO 3166-1 numeric country code to remove. + /// + /// # Authorization + /// + /// Requires compliance contract authorization. + /// + /// # Events + /// + /// Emits [`CountryUnallowed`]. + fn remove_allowed_country(e: &Env, token: Address, country: u32) { + get_compliance_address(e).require_auth(); + remove_country_allowed(e, &token, country); + CountryUnallowed { token, country }.publish(e); + } + + /// Adds multiple countries to the allowlist in a single call. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `countries` - The country codes to allow. + /// + /// # Authorization + /// + /// Requires compliance contract authorization. + /// + /// # Events + /// + /// Emits [`CountryAllowed`] for each country added. + fn batch_allow_countries(e: &Env, token: Address, countries: Vec) { + get_compliance_address(e).require_auth(); + for country in countries.iter() { + set_country_allowed(e, &token, country); + CountryAllowed { token: token.clone(), country }.publish(e); + } + } + + /// Removes multiple countries from the allowlist in a single call. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `countries` - The country codes to remove. + /// + /// # Authorization + /// + /// Requires compliance contract authorization. + /// + /// # Events + /// + /// Emits [`CountryUnallowed`] for each country removed. + fn batch_disallow_countries(e: &Env, token: Address, countries: Vec) { + get_compliance_address(e).require_auth(); + for country in countries.iter() { + remove_country_allowed(e, &token, country); + CountryUnallowed { token: token.clone(), country }.publish(e); + } + } + + /// Returns whether `country` is on the allowlist for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `country` - The ISO 3166-1 numeric country code. + fn is_country_allowed(e: &Env, token: Address, country: u32) -> bool { + is_country_allowed(e, &token, country) + } + + /// No-op — this module does not track transfer state. + fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {} + + /// No-op — this module does not track mint state. + fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {} + + /// No-op — this module does not track burn state. + fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {} + + /// Checks whether `to` has at least one allowed country in the IRS. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `_from` - The sender (unused). + /// * `to` - The recipient whose country data is checked. + /// * `_amount` - The transfer amount (unused). + /// * `token` - The token address. + /// + /// # Returns + /// + /// `true` if the recipient has at least one allowed country, `false` + /// otherwise. + /// + /// # Cross-Contract Calls + /// + /// Calls the IRS to resolve country data for `to`. + fn can_transfer(e: &Env, _from: Address, to: Address, _amount: i128, token: Address) -> bool { + let entries = get_irs_country_data_entries(e, &token, &to); + for entry in entries.iter() { + if is_country_allowed(e, &token, country_code(&entry.country)) { + return true; + } + } + false + } + + /// Delegates to [`can_transfer`](CountryAllow::can_transfer) — same + /// country check applies to mints. + fn can_create(e: &Env, to: Address, amount: i128, token: Address) -> bool { + Self::can_transfer(e, to.clone(), to, amount, token) + } + + /// Returns the module name for identification. + fn name(e: &Env) -> String { + module_name(e, "CountryAllowModule") + } + + /// Returns the compliance contract address. + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + /// Sets the compliance contract address (one-time only). + /// + /// Implementers must gate this entrypoint with bootstrap-admin auth before + /// delegating to + /// [`storage::set_compliance_address`](super::storage::set_compliance_address). + /// + /// + /// # Panics + /// + /// Panics if the compliance address has already been set. + fn set_compliance_address(e: &Env, compliance: Address); +} + +#[cfg(test)] +mod test { + extern crate std; + + use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal, + Val, Vec, + }; + + use super::*; + use crate::rwa::{ + identity_registry_storage::{ + CountryData, CountryDataManager, CountryRelation, IdentityRegistryStorage, + IndividualCountryRelation, OrganizationCountryRelation, + }, + utils::token_binder::TokenBinder, + }; + + #[contract] + struct MockIRSContract; + + #[contracttype] + #[derive(Clone)] + enum MockIRSStorageKey { + Identity(Address), + CountryEntries(Address), + } + + #[contractimpl] + impl TokenBinder for MockIRSContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } + } + + #[contractimpl] + impl IdentityRegistryStorage for MockIRSContract { + fn add_identity( + _e: &Env, + _account: Address, + _identity: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_identity is not used in these tests"); + } + + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { + unreachable!("remove_identity is not used in these tests"); + } + + fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { + unreachable!("modify_identity is not used in these tests"); + } + + fn recover_identity( + _e: &Env, + _old_account: Address, + _new_account: Address, + _operator: Address, + ) { + unreachable!("recover_identity is not used in these tests"); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + e.storage() + .persistent() + .get(&MockIRSStorageKey::Identity(account.clone())) + .unwrap_or(account) + } + } + + #[contractimpl] + impl CountryDataManager for MockIRSContract { + fn add_country_data_entries( + _e: &Env, + _account: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_country_data_entries is not used in these tests"); + } + + fn modify_country_data( + _e: &Env, + _account: Address, + _index: u32, + _country_data: Val, + _operator: Address, + ) { + unreachable!("modify_country_data is not used in these tests"); + } + + fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { + unreachable!("delete_country_data is not used in these tests"); + } + + fn get_country_data_entries(e: &Env, account: Address) -> Vec { + let entries: Vec = e + .storage() + .persistent() + .get(&MockIRSStorageKey::CountryEntries(account)) + .unwrap_or_else(|| Vec::new(e)); + + Vec::from_iter(e, entries.iter().map(|entry| entry.into_val(e))) + } + } + + #[contractimpl] + impl MockIRSContract { + pub fn set_country_data_entries(e: &Env, account: Address, entries: Vec) { + e.storage().persistent().set(&MockIRSStorageKey::CountryEntries(account), &entries); + } + } + + #[contract] + struct TestCountryAllowContract; + + #[contractimpl(contracttrait)] + impl CountryAllow for TestCountryAllowContract { + fn set_compliance_address(_e: &Env, _compliance: Address) { + unreachable!("set_compliance_address is not used in these tests"); + } + } + + fn individual_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Individual(IndividualCountryRelation::Residence(code)), + metadata: None, + } + } + + fn organization_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Organization( + OrganizationCountryRelation::OperatingJurisdiction(code), + ), + metadata: None, + } + } + + #[test] + fn can_transfer_and_create_allow_when_any_country_matches() { + let e = Env::default(); + let module_id = e.register(TestCountryAllowContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let from = Address::generate(&e); + let to = Address::generate(&e); + + irs.set_country_data_entries( + &to, + &vec![&e, individual_country(250), organization_country(276)], + ); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_country_allowed(&e, &token, 276); + + assert!(::can_transfer( + &e, + from.clone(), + to.clone(), + 100, + token.clone(), + )); + assert!(::can_create( + &e, + to.clone(), + 100, + token.clone(), + )); + }); + } + + #[test] + fn can_transfer_and_create_reject_when_no_country_matches() { + let e = Env::default(); + let module_id = e.register(TestCountryAllowContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let from = Address::generate(&e); + let empty_to = Address::generate(&e); + let disallowed_to = Address::generate(&e); + + irs.set_country_data_entries(&disallowed_to, &vec![&e, individual_country(250)]); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_country_allowed(&e, &token, 276); + + assert!(!::can_transfer( + &e, + from.clone(), + empty_to.clone(), + 100, + token.clone(), + )); + assert!(!::can_create( + &e, + empty_to, + 100, + token.clone(), + )); + + assert!(!::can_transfer( + &e, + from.clone(), + disallowed_to.clone(), + 100, + token.clone(), + )); + assert!(!::can_create( + &e, + disallowed_to, + 100, + token.clone(), + )); + }); + } +} diff --git a/packages/tokens/src/rwa/compliance/modules/country_allow/storage.rs b/packages/tokens/src/rwa/compliance/modules/country_allow/storage.rs new file mode 100644 index 000000000..767ca0a14 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/country_allow/storage.rs @@ -0,0 +1,54 @@ +use soroban_sdk::{contracttype, Address, Env}; + +use crate::rwa::compliance::modules::{MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD}; + +#[contracttype] +#[derive(Clone)] +pub enum CountryAllowStorageKey { + /// Per-(token, country) allowlist flag. + AllowedCountry(Address, u32), +} + +/// Returns whether the given country is on the allowlist for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code. +pub fn is_country_allowed(e: &Env, token: &Address, country: u32) -> bool { + let key = CountryAllowStorageKey::AllowedCountry(token.clone(), country); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &bool| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or_default() +} + +/// Adds a country to the allowlist for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code to allow. +pub fn set_country_allowed(e: &Env, token: &Address, country: u32) { + let key = CountryAllowStorageKey::AllowedCountry(token.clone(), country); + e.storage().persistent().set(&key, &true); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} + +/// Removes a country from the allowlist for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code to remove. +pub fn remove_country_allowed(e: &Env, token: &Address, country: u32) { + e.storage() + .persistent() + .remove(&CountryAllowStorageKey::AllowedCountry(token.clone(), country)); +} diff --git a/packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs b/packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs new file mode 100644 index 000000000..09f87a301 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs @@ -0,0 +1,459 @@ +//! Country restriction compliance module — Stellar port of T-REX +//! [`CountryRestrictModule.sol`][trex-src]. +//! +//! Recipients whose identity has a country code on the restriction list are +//! blocked from receiving tokens. +//! +//! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/CountryRestrictModule.sol + +pub mod storage; + +use soroban_sdk::{contractevent, contracttrait, Address, Env, String, Vec}; +use storage::{is_country_restricted, remove_country_restricted, set_country_restricted}; + +use super::storage::{ + country_code, get_compliance_address, get_irs_country_data_entries, module_name, + set_irs_address, +}; + +/// Emitted when a country is added to the restriction list. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CountryRestricted { + #[topic] + pub token: Address, + pub country: u32, +} + +/// Emitted when a country is removed from the restriction list. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CountryUnrestricted { + #[topic] + pub token: Address, + pub country: u32, +} + +/// Country restriction compliance trait. +/// +/// Provides default implementations for maintaining a per-token country +/// restriction list and blocking transfers/mints to recipients from +/// restricted countries via the Identity Registry Storage. +#[contracttrait] +pub trait CountryRestrict { + /// Sets the Identity Registry Storage contract address for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token this IRS applies to. + /// * `irs` - The IRS contract address. + /// + /// # Authorization + /// + /// Requires compliance contract authorization. + fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { + get_compliance_address(e).require_auth(); + set_irs_address(e, &token, &irs); + } + + /// Adds a country to the restriction list for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `country` - The ISO 3166-1 numeric country code to restrict. + /// + /// # Authorization + /// + /// Requires compliance contract authorization. + /// + /// # Events + /// + /// Emits [`CountryRestricted`]. + fn add_country_restriction(e: &Env, token: Address, country: u32) { + get_compliance_address(e).require_auth(); + set_country_restricted(e, &token, country); + CountryRestricted { token, country }.publish(e); + } + + /// Removes a country from the restriction list for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `country` - The ISO 3166-1 numeric country code to unrestrict. + /// + /// # Authorization + /// + /// Requires compliance contract authorization. + /// + /// # Events + /// + /// Emits [`CountryUnrestricted`]. + fn remove_country_restriction(e: &Env, token: Address, country: u32) { + get_compliance_address(e).require_auth(); + remove_country_restricted(e, &token, country); + CountryUnrestricted { token, country }.publish(e); + } + + /// Adds multiple countries to the restriction list in a single call. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `countries` - The country codes to restrict. + /// + /// # Authorization + /// + /// Requires compliance contract authorization. + /// + /// # Events + /// + /// Emits [`CountryRestricted`] for each country added. + fn batch_restrict_countries(e: &Env, token: Address, countries: Vec) { + get_compliance_address(e).require_auth(); + for country in countries.iter() { + set_country_restricted(e, &token, country); + CountryRestricted { token: token.clone(), country }.publish(e); + } + } + + /// Removes multiple countries from the restriction list in a single + /// call. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `countries` - The country codes to unrestrict. + /// + /// # Authorization + /// + /// Requires compliance contract authorization. + /// + /// # Events + /// + /// Emits [`CountryUnrestricted`] for each country removed. + fn batch_unrestrict_countries(e: &Env, token: Address, countries: Vec) { + get_compliance_address(e).require_auth(); + for country in countries.iter() { + remove_country_restricted(e, &token, country); + CountryUnrestricted { token: token.clone(), country }.publish(e); + } + } + + /// Returns whether `country` is on the restriction list for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `country` - The ISO 3166-1 numeric country code. + fn is_country_restricted(e: &Env, token: Address, country: u32) -> bool { + is_country_restricted(e, &token, country) + } + + /// No-op — this module does not track transfer state. + fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {} + + /// No-op — this module does not track mint state. + fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {} + + /// No-op — this module does not track burn state. + fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {} + + /// Checks whether `to` has any restricted country in the IRS. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `_from` - The sender (unused). + /// * `to` - The recipient whose country data is checked. + /// * `_amount` - The transfer amount (unused). + /// * `token` - The token address. + /// + /// # Returns + /// + /// `false` if the recipient has any restricted country, `true` + /// otherwise. + /// + /// # Cross-Contract Calls + /// + /// Calls the IRS to resolve country data for `to`. + fn can_transfer(e: &Env, _from: Address, to: Address, _amount: i128, token: Address) -> bool { + let entries = get_irs_country_data_entries(e, &token, &to); + for entry in entries.iter() { + if is_country_restricted(e, &token, country_code(&entry.country)) { + return false; + } + } + true + } + + /// Delegates to [`can_transfer`](CountryRestrict::can_transfer) — same + /// country check applies to mints. + fn can_create(e: &Env, to: Address, amount: i128, token: Address) -> bool { + Self::can_transfer(e, to.clone(), to, amount, token) + } + + /// Returns the module name for identification. + fn name(e: &Env) -> String { + module_name(e, "CountryRestrictModule") + } + + /// Returns the compliance contract address. + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + /// Sets the compliance contract address (one-time only). + /// + /// Implementers must gate this entrypoint with bootstrap-admin auth before + /// delegating to + /// [`storage::set_compliance_address`](super::storage::set_compliance_address). + /// + /// + /// # Panics + /// + /// Panics if the compliance address has already been set. + fn set_compliance_address(e: &Env, compliance: Address); +} + +#[cfg(test)] +mod test { + extern crate std; + + use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal, + Val, Vec, + }; + + use super::*; + use crate::rwa::{ + identity_registry_storage::{ + CountryData, CountryDataManager, CountryRelation, IdentityRegistryStorage, + IndividualCountryRelation, OrganizationCountryRelation, + }, + utils::token_binder::TokenBinder, + }; + + #[contract] + struct MockIRSContract; + + #[contracttype] + #[derive(Clone)] + enum MockIRSStorageKey { + Identity(Address), + CountryEntries(Address), + } + + #[contractimpl] + impl TokenBinder for MockIRSContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } + } + + #[contractimpl] + impl IdentityRegistryStorage for MockIRSContract { + fn add_identity( + _e: &Env, + _account: Address, + _identity: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_identity is not used in these tests"); + } + + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { + unreachable!("remove_identity is not used in these tests"); + } + + fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { + unreachable!("modify_identity is not used in these tests"); + } + + fn recover_identity( + _e: &Env, + _old_account: Address, + _new_account: Address, + _operator: Address, + ) { + unreachable!("recover_identity is not used in these tests"); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + e.storage() + .persistent() + .get(&MockIRSStorageKey::Identity(account.clone())) + .unwrap_or(account) + } + } + + #[contractimpl] + impl CountryDataManager for MockIRSContract { + fn add_country_data_entries( + _e: &Env, + _account: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_country_data_entries is not used in these tests"); + } + + fn modify_country_data( + _e: &Env, + _account: Address, + _index: u32, + _country_data: Val, + _operator: Address, + ) { + unreachable!("modify_country_data is not used in these tests"); + } + + fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { + unreachable!("delete_country_data is not used in these tests"); + } + + fn get_country_data_entries(e: &Env, account: Address) -> Vec { + let entries: Vec = e + .storage() + .persistent() + .get(&MockIRSStorageKey::CountryEntries(account)) + .unwrap_or_else(|| Vec::new(e)); + + Vec::from_iter(e, entries.iter().map(|entry| entry.into_val(e))) + } + } + + #[contractimpl] + impl MockIRSContract { + pub fn set_country_data_entries(e: &Env, account: Address, entries: Vec) { + e.storage().persistent().set(&MockIRSStorageKey::CountryEntries(account), &entries); + } + } + + #[contract] + struct TestCountryRestrictContract; + + #[contractimpl(contracttrait)] + impl CountryRestrict for TestCountryRestrictContract { + fn set_compliance_address(_e: &Env, _compliance: Address) { + unreachable!("set_compliance_address is not used in these tests"); + } + } + + fn individual_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Individual(IndividualCountryRelation::Residence(code)), + metadata: None, + } + } + + fn organization_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Organization( + OrganizationCountryRelation::OperatingJurisdiction(code), + ), + metadata: None, + } + } + + #[test] + fn can_transfer_and_create_reject_when_any_country_is_restricted() { + let e = Env::default(); + let module_id = e.register(TestCountryRestrictContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let from = Address::generate(&e); + let to = Address::generate(&e); + + irs.set_country_data_entries( + &to, + &vec![&e, individual_country(250), organization_country(408)], + ); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_country_restricted(&e, &token, 408); + + assert!(!::can_transfer( + &e, + from.clone(), + to.clone(), + 100, + token.clone(), + )); + assert!(!::can_create( + &e, + to.clone(), + 100, + token.clone(), + )); + }); + } + + #[test] + fn can_transfer_and_create_allow_when_no_country_is_restricted() { + let e = Env::default(); + let module_id = e.register(TestCountryRestrictContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let from = Address::generate(&e); + let empty_to = Address::generate(&e); + let unrestricted_to = Address::generate(&e); + + irs.set_country_data_entries( + &unrestricted_to, + &vec![&e, individual_country(250), organization_country(276)], + ); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_country_restricted(&e, &token, 408); + + assert!(::can_transfer( + &e, + from.clone(), + empty_to.clone(), + 100, + token.clone(), + )); + assert!(::can_create( + &e, + empty_to, + 100, + token.clone(), + )); + + assert!(::can_transfer( + &e, + from.clone(), + unrestricted_to.clone(), + 100, + token.clone(), + )); + assert!(::can_create( + &e, + unrestricted_to, + 100, + token.clone(), + )); + }); + } +} diff --git a/packages/tokens/src/rwa/compliance/modules/country_restrict/storage.rs b/packages/tokens/src/rwa/compliance/modules/country_restrict/storage.rs new file mode 100644 index 000000000..5d8f13cb2 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/country_restrict/storage.rs @@ -0,0 +1,54 @@ +use soroban_sdk::{contracttype, Address, Env}; + +use crate::rwa::compliance::modules::{MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD}; + +#[contracttype] +#[derive(Clone)] +pub enum CountryRestrictStorageKey { + /// Per-(token, country) restriction flag. + RestrictedCountry(Address, u32), +} + +/// Returns whether the given country is on the restriction list for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code. +pub fn is_country_restricted(e: &Env, token: &Address, country: u32) -> bool { + let key = CountryRestrictStorageKey::RestrictedCountry(token.clone(), country); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &bool| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or_default() +} + +/// Adds a country to the restriction list for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code to restrict. +pub fn set_country_restricted(e: &Env, token: &Address, country: u32) { + let key = CountryRestrictStorageKey::RestrictedCountry(token.clone(), country); + e.storage().persistent().set(&key, &true); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} + +/// Removes a country from the restriction list for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code to unrestrict. +pub fn remove_country_restricted(e: &Env, token: &Address, country: u32) { + e.storage() + .persistent() + .remove(&CountryRestrictStorageKey::RestrictedCountry(token.clone(), country)); +} diff --git a/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/mod.rs b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/mod.rs new file mode 100644 index 000000000..7261fc452 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/mod.rs @@ -0,0 +1,274 @@ +//! Initial lockup period compliance module — Stellar port of T-REX +//! [`TimeExchangeLimitsModule.sol`][trex-src]. +//! +//! Enforces a lockup period for all investors whenever they receive tokens +//! through primary emissions (mints). Tokens received via peer-to-peer +//! transfers are **not** subject to lockup restrictions. +//! +//! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/TimeExchangeLimitsModule.sol + +pub mod storage; +#[cfg(test)] +mod test; + +use soroban_sdk::{contractevent, contracttrait, vec, Address, Env, String, Vec}; +pub use storage::LockedTokens; +use storage::{ + get_internal_balance, get_locks, get_lockup_period, get_total_locked, set_internal_balance, + set_locks, set_lockup_period, set_total_locked, +}; + +use super::storage::{ + add_i128_or_panic, get_compliance_address, hooks_verified, module_name, + require_non_negative_amount, sub_i128_or_panic, verify_required_hooks, +}; +use crate::rwa::compliance::ComplianceHook; + +/// Emitted when a token's lockup duration is configured or changed. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LockupPeriodSet { + #[topic] + pub token: Address, + pub lockup_seconds: u64, +} + +// ################## HELPERS ################## + +fn calculate_unlocked_amount(e: &Env, locks: &Vec) -> i128 { + let now = e.ledger().timestamp(); + let mut unlocked = 0i128; + for i in 0..locks.len() { + let lock = locks.get(i).unwrap(); + if lock.release_timestamp <= now { + unlocked = add_i128_or_panic(e, unlocked, lock.amount); + } + } + unlocked +} + +fn calculate_total_locked_amount(e: &Env, locks: &Vec) -> i128 { + let mut total = 0i128; + for i in 0..locks.len() { + let lock = locks.get(i).unwrap(); + require_non_negative_amount(e, lock.amount); + total = add_i128_or_panic(e, total, lock.amount); + } + total +} + +fn update_locked_tokens(e: &Env, token: &Address, wallet: &Address, mut amount_to_consume: i128) { + let locks = get_locks(e, token, wallet); + let now = e.ledger().timestamp(); + let mut new_locks = Vec::new(e); + let mut consumed_total = 0i128; + + for i in 0..locks.len() { + let lock = locks.get(i).unwrap(); + if amount_to_consume > 0 && lock.release_timestamp <= now { + if amount_to_consume >= lock.amount { + amount_to_consume = sub_i128_or_panic(e, amount_to_consume, lock.amount); + consumed_total = add_i128_or_panic(e, consumed_total, lock.amount); + } else { + consumed_total = add_i128_or_panic(e, consumed_total, amount_to_consume); + new_locks.push_back(LockedTokens { + amount: sub_i128_or_panic(e, lock.amount, amount_to_consume), + release_timestamp: lock.release_timestamp, + }); + amount_to_consume = 0; + } + } else { + new_locks.push_back(lock); + } + } + + set_locks(e, token, wallet, &new_locks); + + let total_locked = get_total_locked(e, token, wallet); + set_total_locked(e, token, wallet, sub_i128_or_panic(e, total_locked, consumed_total)); +} + +#[contracttrait] +pub trait InitialLockupPeriod { + // ################## QUERY STATE ################## + + fn get_lockup_period(e: &Env, token: Address) -> u64 { + get_lockup_period(e, &token) + } + + fn get_total_locked(e: &Env, token: Address, wallet: Address) -> i128 { + get_total_locked(e, &token, &wallet) + } + + fn get_locked_tokens(e: &Env, token: Address, wallet: Address) -> Vec { + get_locks(e, &token, &wallet) + } + + fn get_internal_balance(e: &Env, token: Address, wallet: Address) -> i128 { + get_internal_balance(e, &token, &wallet) + } + + fn can_transfer(e: &Env, from: Address, _to: Address, amount: i128, token: Address) -> bool { + assert!( + hooks_verified(e), + "InitialLockupPeriodModule: not armed — call verify_hook_wiring() after wiring hooks \ + [CanTransfer, Created, Transferred, Destroyed]" + ); + if amount < 0 { + return false; + } + + let total_locked = get_total_locked(e, &token, &from); + if total_locked == 0 { + return true; + } + + let balance = get_internal_balance(e, &token, &from); + let free = balance - total_locked; + + if free >= amount { + return true; + } + + let locks = get_locks(e, &token, &from); + let unlocked = calculate_unlocked_amount(e, &locks); + (free + unlocked) >= amount + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + true + } + + fn name(e: &Env) -> String { + module_name(e, "InitialLockupPeriodModule") + } + + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + // ################## CHANGE STATE ################## + + fn set_lockup_period(e: &Env, token: Address, lockup_seconds: u64) { + get_compliance_address(e).require_auth(); + set_lockup_period(e, &token, lockup_seconds); + LockupPeriodSet { token, lockup_seconds }.publish(e); + } + + fn pre_set_lockup_state( + e: &Env, + token: Address, + wallet: Address, + balance: i128, + locks: Vec, + ) { + get_compliance_address(e).require_auth(); + require_non_negative_amount(e, balance); + + let total_locked = calculate_total_locked_amount(e, &locks); + assert!( + total_locked <= balance, + "InitialLockupPeriodModule: total locked amount cannot exceed balance" + ); + + set_internal_balance(e, &token, &wallet, balance); + set_locks(e, &token, &wallet, &locks); + set_total_locked(e, &token, &wallet, total_locked); + } + + fn verify_hook_wiring(e: &Env) { + verify_required_hooks(e, Self::required_hooks(e)); + } + + fn on_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) { + get_compliance_address(e).require_auth(); + require_non_negative_amount(e, amount); + + let total_locked = get_total_locked(e, &token, &from); + + if total_locked > 0 { + let pre_balance = get_internal_balance(e, &token, &from); + let pre_free = pre_balance - total_locked; + + if amount > pre_free.max(0) { + let to_consume = amount - pre_free.max(0); + update_locked_tokens(e, &token, &from, to_consume); + } + } + + let from_bal = get_internal_balance(e, &token, &from); + set_internal_balance(e, &token, &from, sub_i128_or_panic(e, from_bal, amount)); + + let to_bal = get_internal_balance(e, &token, &to); + set_internal_balance(e, &token, &to, add_i128_or_panic(e, to_bal, amount)); + } + + fn on_created(e: &Env, to: Address, amount: i128, token: Address) { + get_compliance_address(e).require_auth(); + require_non_negative_amount(e, amount); + + let period = get_lockup_period(e, &token); + if period > 0 { + let mut locks = get_locks(e, &token, &to); + locks.push_back(LockedTokens { + amount, + release_timestamp: e.ledger().timestamp().saturating_add(period), + }); + set_locks(e, &token, &to, &locks); + + let total = get_total_locked(e, &token, &to); + set_total_locked(e, &token, &to, add_i128_or_panic(e, total, amount)); + } + + let current = get_internal_balance(e, &token, &to); + set_internal_balance(e, &token, &to, add_i128_or_panic(e, current, amount)); + } + + fn on_destroyed(e: &Env, from: Address, amount: i128, token: Address) { + get_compliance_address(e).require_auth(); + require_non_negative_amount(e, amount); + + let total_locked = get_total_locked(e, &token, &from); + + if total_locked > 0 { + let pre_balance = get_internal_balance(e, &token, &from); + let mut free_amount = pre_balance - total_locked; + + if free_amount < amount { + let locks = get_locks(e, &token, &from); + free_amount += calculate_unlocked_amount(e, &locks); + } + + assert!( + free_amount >= amount, + "InitialLockupPeriodModule: insufficient unlocked balance for burn" + ); + + let pre_free = pre_balance - total_locked; + if amount > pre_free.max(0) { + let to_consume = amount - pre_free.max(0); + update_locked_tokens(e, &token, &from, to_consume); + } + } + + let current = get_internal_balance(e, &token, &from); + set_internal_balance(e, &token, &from, sub_i128_or_panic(e, current, amount)); + } + + /// Implementers must gate this entrypoint with bootstrap-admin auth before + /// delegating to + /// [`storage::set_compliance_address`](super::storage::set_compliance_address). + fn set_compliance_address(e: &Env, compliance: Address); + + // ################## HELPERS ################## + + fn required_hooks(e: &Env) -> Vec { + vec![ + e, + ComplianceHook::CanTransfer, + ComplianceHook::Created, + ComplianceHook::Transferred, + ComplianceHook::Destroyed, + ] + } +} diff --git a/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/storage.rs b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/storage.rs new file mode 100644 index 000000000..2c9788c8d --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/storage.rs @@ -0,0 +1,152 @@ +use soroban_sdk::{contracttype, Address, Env, Vec}; + +use crate::rwa::compliance::modules::{MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD}; + +/// A single mint-created lock entry tracking the locked amount and its +/// release time. Mirrors T-REX `LockedTokens { amount, releaseTimestamp }`. +#[contracttype] +#[derive(Clone)] +pub struct LockedTokens { + pub amount: i128, + pub release_timestamp: u64, +} + +#[contracttype] +#[derive(Clone)] +pub enum InitialLockupStorageKey { + /// Per-token lockup duration in seconds. + LockupPeriod(Address), + /// Per-(token, wallet) ordered list of individual lock entries. + Locks(Address, Address), + /// Per-(token, wallet) aggregate of all locked amounts. + TotalLocked(Address, Address), + /// Per-(token, wallet) balance mirror, updated via hooks to avoid + /// re-entrant `token.balance()` calls. + InternalBalance(Address, Address), +} + +/// Returns the lockup period (in seconds) for `token`, or `0` if not set. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +pub fn get_lockup_period(e: &Env, token: &Address) -> u64 { + let key = InitialLockupStorageKey::LockupPeriod(token.clone()); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &u64| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or_default() +} + +/// Sets the lockup period (in seconds) for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `seconds` - The lockup duration in seconds. +pub fn set_lockup_period(e: &Env, token: &Address, seconds: u64) { + let key = InitialLockupStorageKey::LockupPeriod(token.clone()); + e.storage().persistent().set(&key, &seconds); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} + +/// Returns the lock entries for `wallet` on `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `wallet` - The wallet address. +pub fn get_locks(e: &Env, token: &Address, wallet: &Address) -> Vec { + let key = InitialLockupStorageKey::Locks(token.clone(), wallet.clone()); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &Vec| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or_else(|| Vec::new(e)) +} + +/// Persists the lock entries for `wallet` on `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `wallet` - The wallet address. +/// * `locks` - The updated lock entries. +pub fn set_locks(e: &Env, token: &Address, wallet: &Address, locks: &Vec) { + let key = InitialLockupStorageKey::Locks(token.clone(), wallet.clone()); + e.storage().persistent().set(&key, locks); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} + +/// Returns the total locked amount for `wallet` on `token`, or `0`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `wallet` - The wallet address. +pub fn get_total_locked(e: &Env, token: &Address, wallet: &Address) -> i128 { + let key = InitialLockupStorageKey::TotalLocked(token.clone(), wallet.clone()); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &i128| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or_default() +} + +/// Sets the total locked amount for `wallet` on `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `wallet` - The wallet address. +/// * `amount` - The new total locked amount. +pub fn set_total_locked(e: &Env, token: &Address, wallet: &Address, amount: i128) { + let key = InitialLockupStorageKey::TotalLocked(token.clone(), wallet.clone()); + e.storage().persistent().set(&key, &amount); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} + +/// Returns the internal balance for `wallet` on `token`, or `0`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `wallet` - The wallet address. +pub fn get_internal_balance(e: &Env, token: &Address, wallet: &Address) -> i128 { + let key = InitialLockupStorageKey::InternalBalance(token.clone(), wallet.clone()); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &i128| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or_default() +} + +/// Sets the internal balance for `wallet` on `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `wallet` - The wallet address. +/// * `balance` - The new balance value. +pub fn set_internal_balance(e: &Env, token: &Address, wallet: &Address, balance: i128) { + let key = InitialLockupStorageKey::InternalBalance(token.clone(), wallet.clone()); + e.storage().persistent().set(&key, &balance); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} diff --git a/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/test.rs b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/test.rs new file mode 100644 index 000000000..f758b7a97 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/test.rs @@ -0,0 +1,190 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, +}; + +use super::*; +use crate::rwa::{ + compliance::{ + modules::storage::{hooks_verified, set_compliance_address, ComplianceModuleStorageKey}, + Compliance, ComplianceHook, + }, + utils::token_binder::TokenBinder, +}; + +#[contract] +struct TestInitialLockupPeriodContract; + +#[contractimpl(contracttrait)] +impl InitialLockupPeriod for TestInitialLockupPeriodContract { + fn set_compliance_address(_e: &Env, _compliance: Address) { + unreachable!("set_compliance_address is not used in these tests"); + } +} + +fn arm_hooks(e: &Env) { + e.storage().instance().set(&ComplianceModuleStorageKey::HooksVerified, &true); +} + +#[contract] +struct MockComplianceContract; + +#[derive(Clone)] +#[contracttype] +enum MockComplianceStorageKey { + Registered(ComplianceHook, Address), +} + +#[contractimpl] +impl Compliance for MockComplianceContract { + fn add_module_to(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("add_module_to is not used in these tests"); + } + + fn remove_module_from(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("remove_module_from is not used in these tests"); + } + + fn get_modules_for_hook(_e: &Env, _hook: ComplianceHook) -> soroban_sdk::Vec
{ + unreachable!("get_modules_for_hook is not used in these tests"); + } + + fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool { + e.storage().persistent().has(&MockComplianceStorageKey::Registered(hook, module)) + } + + fn transferred(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) { + unreachable!("transferred is not used in these tests"); + } + + fn created(_e: &Env, _to: Address, _amount: i128, _token: Address) { + unreachable!("created is not used in these tests"); + } + + fn destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) { + unreachable!("destroyed is not used in these tests"); + } + + fn can_transfer( + _e: &Env, + _from: Address, + _to: Address, + _amount: i128, + _token: Address, + ) -> bool { + unreachable!("can_transfer is not used in these tests"); + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + unreachable!("can_create is not used in these tests"); + } +} + +#[contractimpl] +impl TokenBinder for MockComplianceContract { + fn linked_tokens(e: &Env) -> soroban_sdk::Vec
{ + soroban_sdk::Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl MockComplianceContract { + pub fn register_hook(e: &Env, hook: ComplianceHook, module: Address) { + e.storage().persistent().set(&MockComplianceStorageKey::Registered(hook, module), &true); + } +} + +#[test] +fn verify_hook_wiring_sets_cache_when_registered() { + let e = Env::default(); + let module_id = e.register(TestInitialLockupPeriodContract, ()); + let compliance_id = e.register(MockComplianceContract, ()); + let compliance = MockComplianceContractClient::new(&e, &compliance_id); + + for hook in [ + ComplianceHook::CanTransfer, + ComplianceHook::Created, + ComplianceHook::Transferred, + ComplianceHook::Destroyed, + ] { + compliance.register_hook(&hook, &module_id); + } + + e.as_contract(&module_id, || { + set_compliance_address(&e, &compliance_id); + + ::verify_hook_wiring(&e); + + assert!(hooks_verified(&e)); + }); +} + +#[test] +fn pre_set_lockup_state_seeds_existing_locked_balance() { + let e = Env::default(); + e.mock_all_auths(); + + let module_id = e.register(TestInitialLockupPeriodContract, ()); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let wallet = Address::generate(&e); + + e.as_contract(&module_id, || { + set_compliance_address(&e, &compliance); + arm_hooks(&e); + + ::pre_set_lockup_state( + &e, + token.clone(), + wallet.clone(), + 100, + vec![ + &e, + LockedTokens { + amount: 80, + release_timestamp: e.ledger().timestamp().saturating_add(60), + }, + ], + ); + + assert_eq!( + ::get_internal_balance( + &e, + token.clone(), + wallet.clone(), + ), + 100 + ); + assert_eq!( + ::get_total_locked( + &e, + token.clone(), + wallet.clone(), + ), + 80 + ); + assert!(!::can_transfer( + &e, + wallet.clone(), + Address::generate(&e), + 21, + token.clone(), + )); + assert!(::can_transfer( + &e, + wallet, + Address::generate(&e), + 20, + token, + )); + }); +} diff --git a/packages/tokens/src/rwa/compliance/modules/max_balance/mod.rs b/packages/tokens/src/rwa/compliance/modules/max_balance/mod.rs new file mode 100644 index 000000000..aa295329f --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/max_balance/mod.rs @@ -0,0 +1,218 @@ +//! Max balance compliance module — Stellar port of T-REX +//! [`MaxBalanceModule.sol`][trex-src]. +//! +//! Tracks effective balances per **identity** (not per wallet), enforcing a +//! per-token cap. +//! +//! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/MaxBalanceModule.sol + +pub mod storage; +#[cfg(test)] +mod test; + +use soroban_sdk::{contractevent, contracttrait, vec, Address, Env, String, Vec}; +use storage::{get_id_balance, get_max_balance, set_id_balance, set_max_balance}; + +use super::storage::{ + add_i128_or_panic, get_compliance_address, get_irs_client, hooks_verified, module_name, + require_non_negative_amount, set_irs_address, sub_i128_or_panic, verify_required_hooks, +}; +use crate::rwa::compliance::ComplianceHook; + +/// Emitted when a token's per-identity balance cap is configured. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MaxBalanceSet { + #[topic] + pub token: Address, + pub max_balance: i128, +} + +/// Emitted when an identity balance is pre-seeded via `pre_set_module_state`. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct IDBalancePreSet { + #[topic] + pub token: Address, + pub identity: Address, + pub balance: i128, +} + +fn can_increase_identity_balance( + e: &Env, + token: &Address, + identity: &Address, + amount: i128, +) -> bool { + if amount < 0 { + return false; + } + + let max = get_max_balance(e, token); + if max == 0 { + return true; + } + + let current = get_id_balance(e, token, identity); + add_i128_or_panic(e, current, amount) <= max +} + +#[contracttrait] +pub trait MaxBalance { + fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { + get_compliance_address(e).require_auth(); + set_irs_address(e, &token, &irs); + } + + fn set_max_balance(e: &Env, token: Address, max: i128) { + get_compliance_address(e).require_auth(); + require_non_negative_amount(e, max); + set_max_balance(e, &token, max); + MaxBalanceSet { token, max_balance: max }.publish(e); + } + + fn pre_set_module_state(e: &Env, token: Address, identity: Address, balance: i128) { + get_compliance_address(e).require_auth(); + require_non_negative_amount(e, balance); + set_id_balance(e, &token, &identity, balance); + IDBalancePreSet { token, identity, balance }.publish(e); + } + + fn batch_pre_set_module_state( + e: &Env, + token: Address, + identities: Vec
, + balances: Vec, + ) { + get_compliance_address(e).require_auth(); + assert!( + identities.len() == balances.len(), + "MaxBalanceModule: identities and balances length mismatch" + ); + for i in 0..identities.len() { + let id = identities.get(i).unwrap(); + let bal = balances.get(i).unwrap(); + require_non_negative_amount(e, bal); + set_id_balance(e, &token, &id, bal); + IDBalancePreSet { token: token.clone(), identity: id, balance: bal }.publish(e); + } + } + + fn get_investor_balance(e: &Env, token: Address, identity: Address) -> i128 { + get_id_balance(e, &token, &identity) + } + + fn required_hooks(e: &Env) -> Vec { + vec![ + e, + ComplianceHook::CanTransfer, + ComplianceHook::CanCreate, + ComplianceHook::Transferred, + ComplianceHook::Created, + ComplianceHook::Destroyed, + ] + } + + fn verify_hook_wiring(e: &Env) { + verify_required_hooks(e, Self::required_hooks(e)); + } + + fn on_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) { + get_compliance_address(e).require_auth(); + require_non_negative_amount(e, amount); + + let irs = get_irs_client(e, &token); + let from_id = irs.stored_identity(&from); + let to_id = irs.stored_identity(&to); + + if from_id == to_id { + return; + } + + let from_balance = get_id_balance(e, &token, &from_id); + assert!( + can_increase_identity_balance(e, &token, &to_id, amount), + "MaxBalanceModule: recipient identity balance exceeds max" + ); + + let to_balance = get_id_balance(e, &token, &to_id); + let new_to_balance = add_i128_or_panic(e, to_balance, amount); + set_id_balance(e, &token, &from_id, sub_i128_or_panic(e, from_balance, amount)); + set_id_balance(e, &token, &to_id, new_to_balance); + } + + fn on_created(e: &Env, to: Address, amount: i128, token: Address) { + get_compliance_address(e).require_auth(); + require_non_negative_amount(e, amount); + + let irs = get_irs_client(e, &token); + let to_id = irs.stored_identity(&to); + + assert!( + can_increase_identity_balance(e, &token, &to_id, amount), + "MaxBalanceModule: recipient identity balance exceeds max after mint" + ); + + let current = get_id_balance(e, &token, &to_id); + let new_balance = add_i128_or_panic(e, current, amount); + set_id_balance(e, &token, &to_id, new_balance); + } + + fn on_destroyed(e: &Env, from: Address, amount: i128, token: Address) { + get_compliance_address(e).require_auth(); + require_non_negative_amount(e, amount); + + let irs = get_irs_client(e, &token); + let from_id = irs.stored_identity(&from); + + let current = get_id_balance(e, &token, &from_id); + set_id_balance(e, &token, &from_id, sub_i128_or_panic(e, current, amount)); + } + + fn can_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) -> bool { + assert!( + hooks_verified(e), + "MaxBalanceModule: not armed — call verify_hook_wiring() after wiring hooks \ + [CanTransfer, CanCreate, Transferred, Created, Destroyed]" + ); + if amount < 0 { + return false; + } + let irs = get_irs_client(e, &token); + let from_id = irs.stored_identity(&from); + let to_id = irs.stored_identity(&to); + + if from_id == to_id { + return true; + } + + can_increase_identity_balance(e, &token, &to_id, amount) + } + + fn can_create(e: &Env, to: Address, amount: i128, token: Address) -> bool { + assert!( + hooks_verified(e), + "MaxBalanceModule: not armed — call verify_hook_wiring() after wiring hooks \ + [CanTransfer, CanCreate, Transferred, Created, Destroyed]" + ); + if amount < 0 { + return false; + } + let irs = get_irs_client(e, &token); + let to_id = irs.stored_identity(&to); + can_increase_identity_balance(e, &token, &to_id, amount) + } + + fn name(e: &Env) -> String { + module_name(e, "MaxBalanceModule") + } + + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + /// Implementers must gate this entrypoint with bootstrap-admin auth before + /// delegating to + /// [`storage::set_compliance_address`](super::storage::set_compliance_address). + fn set_compliance_address(e: &Env, compliance: Address); +} diff --git a/packages/tokens/src/rwa/compliance/modules/max_balance/storage.rs b/packages/tokens/src/rwa/compliance/modules/max_balance/storage.rs new file mode 100644 index 000000000..60e6cb997 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/max_balance/storage.rs @@ -0,0 +1,75 @@ +use soroban_sdk::{contracttype, Address, Env}; + +use crate::rwa::compliance::modules::{MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD}; + +#[contracttype] +#[derive(Clone)] +pub enum MaxBalanceStorageKey { + /// Per-token maximum allowed identity balance. + MaxBalance(Address), + /// Balance keyed by (token, identity) — not by wallet. + IDBalance(Address, Address), +} + +/// Returns the per-identity balance cap for `token`, or `0` if not set. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +pub fn get_max_balance(e: &Env, token: &Address) -> i128 { + let key = MaxBalanceStorageKey::MaxBalance(token.clone()); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &i128| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or_default() +} + +/// Sets the per-identity balance cap for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `value` - The maximum balance per identity. +pub fn set_max_balance(e: &Env, token: &Address, value: i128) { + let key = MaxBalanceStorageKey::MaxBalance(token.clone()); + e.storage().persistent().set(&key, &value); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} + +/// Returns the tracked balance for `identity` on `token`, or `0` if not +/// set. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `identity` - The on-chain identity address. +pub fn get_id_balance(e: &Env, token: &Address, identity: &Address) -> i128 { + let key = MaxBalanceStorageKey::IDBalance(token.clone(), identity.clone()); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &i128| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or_default() +} + +/// Sets the tracked balance for `identity` on `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `identity` - The on-chain identity address. +/// * `balance` - The new balance value. +pub fn set_id_balance(e: &Env, token: &Address, identity: &Address, balance: i128) { + let key = MaxBalanceStorageKey::IDBalance(token.clone(), identity.clone()); + e.storage().persistent().set(&key, &balance); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} diff --git a/packages/tokens/src/rwa/compliance/modules/max_balance/test.rs b/packages/tokens/src/rwa/compliance/modules/max_balance/test.rs new file mode 100644 index 000000000..53281cadb --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/max_balance/test.rs @@ -0,0 +1,351 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, Address, Env, Val, Vec, +}; + +use super::{ + storage::{set_id_balance, set_max_balance}, + *, +}; +use crate::rwa::{ + compliance::{ + modules::storage::{ + hooks_verified, set_compliance_address, set_irs_address, ComplianceModuleStorageKey, + }, + Compliance, ComplianceHook, + }, + identity_registry_storage::{CountryDataManager, IdentityRegistryStorage}, + utils::token_binder::TokenBinder, +}; + +#[contract] +struct MockIRSContract; + +#[contracttype] +#[derive(Clone)] +enum MockIRSStorageKey { + Identity(Address), +} + +#[contractimpl] +impl TokenBinder for MockIRSContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl IdentityRegistryStorage for MockIRSContract { + fn add_identity( + _e: &Env, + _account: Address, + _identity: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_identity is not used in these tests"); + } + + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { + unreachable!("remove_identity is not used in these tests"); + } + + fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { + unreachable!("modify_identity is not used in these tests"); + } + + fn recover_identity( + _e: &Env, + _old_account: Address, + _new_account: Address, + _operator: Address, + ) { + unreachable!("recover_identity is not used in these tests"); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + e.storage() + .persistent() + .get(&MockIRSStorageKey::Identity(account.clone())) + .unwrap_or(account) + } +} + +#[contractimpl] +impl CountryDataManager for MockIRSContract { + fn add_country_data_entries( + _e: &Env, + _account: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_country_data_entries is not used in these tests"); + } + + fn modify_country_data( + _e: &Env, + _account: Address, + _index: u32, + _country_data: Val, + _operator: Address, + ) { + unreachable!("modify_country_data is not used in these tests"); + } + + fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { + unreachable!("delete_country_data is not used in these tests"); + } + + fn get_country_data_entries(e: &Env, _account: Address) -> Vec { + Vec::new(e) + } +} + +#[contractimpl] +impl MockIRSContract { + pub fn set_identity(e: &Env, account: Address, identity: Address) { + e.storage().persistent().set(&MockIRSStorageKey::Identity(account), &identity); + } +} + +#[contract] +struct MockComplianceContract; + +#[contracttype] +#[derive(Clone)] +enum MockComplianceStorageKey { + Registered(ComplianceHook, Address), +} + +#[contractimpl] +impl Compliance for MockComplianceContract { + fn add_module_to(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("add_module_to is not used in these tests"); + } + + fn remove_module_from(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("remove_module_from is not used in these tests"); + } + + fn get_modules_for_hook(_e: &Env, _hook: ComplianceHook) -> Vec
{ + unreachable!("get_modules_for_hook is not used in these tests"); + } + + fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool { + e.storage().persistent().has(&MockComplianceStorageKey::Registered(hook, module)) + } + + fn transferred(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) { + unreachable!("transferred is not used in these tests"); + } + + fn created(_e: &Env, _to: Address, _amount: i128, _token: Address) { + unreachable!("created is not used in these tests"); + } + + fn destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) { + unreachable!("destroyed is not used in these tests"); + } + + fn can_transfer( + _e: &Env, + _from: Address, + _to: Address, + _amount: i128, + _token: Address, + ) -> bool { + unreachable!("can_transfer is not used in these tests"); + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + unreachable!("can_create is not used in these tests"); + } +} + +#[contractimpl] +impl TokenBinder for MockComplianceContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl MockComplianceContract { + pub fn register_hook(e: &Env, hook: ComplianceHook, module: Address) { + e.storage().persistent().set(&MockComplianceStorageKey::Registered(hook, module), &true); + } +} + +#[contract] +struct TestMaxBalanceContract; + +#[contractimpl(contracttrait)] +impl MaxBalance for TestMaxBalanceContract { + fn set_compliance_address(_e: &Env, _compliance: Address) { + unreachable!("set_compliance_address is not used in these tests"); + } +} + +fn arm_hooks(e: &Env) { + e.storage().instance().set(&ComplianceModuleStorageKey::HooksVerified, &true); +} + +#[test] +fn verify_hook_wiring_sets_cache_when_registered() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let compliance_id = e.register(MockComplianceContract, ()); + let compliance = MockComplianceContractClient::new(&e, &compliance_id); + + for hook in [ + ComplianceHook::CanTransfer, + ComplianceHook::CanCreate, + ComplianceHook::Transferred, + ComplianceHook::Created, + ComplianceHook::Destroyed, + ] { + compliance.register_hook(&hook, &module_id); + } + + e.as_contract(&module_id, || { + set_compliance_address(&e, &compliance_id); + + ::verify_hook_wiring(&e); + + assert!(hooks_verified(&e)); + }); +} + +#[test] +fn can_create_rejects_mint_when_cap_would_be_exceeded() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let recipient = Address::generate(&e); + let recipient_identity = Address::generate(&e); + + irs.set_identity(&recipient, &recipient_identity); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + arm_hooks(&e); + set_max_balance(&e, &token, 100); + set_id_balance(&e, &token, &recipient_identity, 60); + + assert!(!::can_create( + &e, + recipient.clone(), + 50, + token.clone(), + )); + assert!(::can_create( + &e, + recipient, + 40, + token.clone(), + )); + }); +} + +#[test] +fn can_transfer_checks_distinct_recipient_identity_balance() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let recipient = Address::generate(&e); + let sender_identity = Address::generate(&e); + let recipient_identity = Address::generate(&e); + + irs.set_identity(&sender, &sender_identity); + irs.set_identity(&recipient, &recipient_identity); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + arm_hooks(&e); + set_max_balance(&e, &token, 100); + set_id_balance(&e, &token, &recipient_identity, 60); + + assert!(!::can_transfer( + &e, + sender.clone(), + recipient.clone(), + 50, + token.clone(), + )); + assert!(::can_transfer( + &e, + sender, + recipient, + 40, + token.clone(), + )); + }); +} + +#[test] +fn can_create_allows_without_cap_and_rejects_negative_amount() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let recipient = Address::generate(&e); + let recipient_identity = Address::generate(&e); + + irs.set_identity(&recipient, &recipient_identity); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + arm_hooks(&e); + set_id_balance(&e, &token, &recipient_identity, 500); + + assert!(::can_create( + &e, + recipient.clone(), + 1_000, + token.clone(), + )); + assert!(!::can_create( + &e, + recipient, + -1, + token.clone(), + )); + }); +} + +#[test] +fn can_create_rejects_negative_amount_before_requiring_irs() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let token = Address::generate(&e); + let recipient = Address::generate(&e); + + e.as_contract(&module_id, || { + arm_hooks(&e); + + assert!(!::can_create(&e, recipient, -1, token,)); + }); +} diff --git a/packages/tokens/src/rwa/compliance/modules/mod.rs b/packages/tokens/src/rwa/compliance/modules/mod.rs index f4e065161..29995ad41 100644 --- a/packages/tokens/src/rwa/compliance/modules/mod.rs +++ b/packages/tokens/src/rwa/compliance/modules/mod.rs @@ -1,6 +1,13 @@ use soroban_sdk::{contracterror, contracttrait, Address, Env, String}; +pub mod country_allow; +pub mod country_restrict; +pub mod initial_lockup_period; +pub mod max_balance; pub mod storage; +pub mod supply_limit; +pub mod time_transfers_limits; +pub mod transfer_restrict; #[cfg(test)] mod test; diff --git a/packages/tokens/src/rwa/compliance/modules/supply_limit/mod.rs b/packages/tokens/src/rwa/compliance/modules/supply_limit/mod.rs new file mode 100644 index 000000000..d138a002b --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/supply_limit/mod.rs @@ -0,0 +1,116 @@ +//! Supply cap compliance module — Stellar port of T-REX +//! [`SupplyLimitModule.sol`][trex-src]. +//! +//! Caps the total number of tokens that can be minted for a given token. +//! +//! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/SupplyLimitModule.sol + +pub mod storage; +#[cfg(test)] +mod test; + +use soroban_sdk::{contractevent, contracttrait, vec, Address, Env, String, Vec}; +use storage::{get_internal_supply, get_supply_limit, set_internal_supply, set_supply_limit}; + +use super::storage::{ + add_i128_or_panic, get_compliance_address, hooks_verified, module_name, + require_non_negative_amount, sub_i128_or_panic, verify_required_hooks, +}; +use crate::rwa::compliance::ComplianceHook; + +/// Emitted when a token's supply cap is configured or changed. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SupplyLimitSet { + #[topic] + pub token: Address, + pub limit: i128, +} + +#[contracttrait] +pub trait SupplyLimit { + fn set_supply_limit(e: &Env, token: Address, limit: i128) { + get_compliance_address(e).require_auth(); + require_non_negative_amount(e, limit); + set_supply_limit(e, &token, limit); + SupplyLimitSet { token, limit }.publish(e); + } + + fn pre_set_internal_supply(e: &Env, token: Address, supply: i128) { + get_compliance_address(e).require_auth(); + require_non_negative_amount(e, supply); + set_internal_supply(e, &token, supply); + } + + fn get_supply_limit(e: &Env, token: Address) -> i128 { + get_supply_limit(e, &token) + } + + fn get_internal_supply(e: &Env, token: Address) -> i128 { + get_internal_supply(e, &token) + } + + fn required_hooks(e: &Env) -> Vec { + vec![e, ComplianceHook::CanCreate, ComplianceHook::Created, ComplianceHook::Destroyed] + } + + fn verify_hook_wiring(e: &Env) { + verify_required_hooks(e, Self::required_hooks(e)); + } + + fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {} + + fn on_created(e: &Env, _to: Address, amount: i128, token: Address) { + get_compliance_address(e).require_auth(); + require_non_negative_amount(e, amount); + let current = get_internal_supply(e, &token); + set_internal_supply(e, &token, add_i128_or_panic(e, current, amount)); + } + + fn on_destroyed(e: &Env, _from: Address, amount: i128, token: Address) { + get_compliance_address(e).require_auth(); + require_non_negative_amount(e, amount); + let current = get_internal_supply(e, &token); + set_internal_supply(e, &token, sub_i128_or_panic(e, current, amount)); + } + + fn can_transfer( + _e: &Env, + _from: Address, + _to: Address, + _amount: i128, + _token: Address, + ) -> bool { + true + } + + fn can_create(e: &Env, _to: Address, amount: i128, token: Address) -> bool { + assert!( + hooks_verified(e), + "SupplyLimitModule: not armed — call verify_hook_wiring() after wiring hooks \ + [CanCreate, Created, Destroyed]" + ); + if amount < 0 { + return false; + } + let limit = get_supply_limit(e, &token); + if limit == 0 { + return true; + } + let supply = get_internal_supply(e, &token); + add_i128_or_panic(e, supply, amount) <= limit + } + + fn name(e: &Env) -> String { + module_name(e, "SupplyLimitModule") + } + + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + /// Implementers must gate this entrypoint with bootstrap-admin auth before + /// delegating to + /// [`storage::set_compliance_address`](super::storage::set_compliance_address). + fn set_compliance_address(e: &Env, compliance: Address); +} diff --git a/packages/tokens/src/rwa/compliance/modules/supply_limit/storage.rs b/packages/tokens/src/rwa/compliance/modules/supply_limit/storage.rs new file mode 100644 index 000000000..f1ec2f4df --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/supply_limit/storage.rs @@ -0,0 +1,96 @@ +use soroban_sdk::{contracttype, panic_with_error, Address, Env}; + +use crate::rwa::compliance::modules::{ + ComplianceModuleError, MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD, +}; + +#[contracttype] +#[derive(Clone)] +pub enum SupplyLimitStorageKey { + /// Per-token supply cap. + SupplyLimit(Address), + /// Per-token internal supply counter (updated via hooks). + InternalSupply(Address), +} + +/// Returns the supply limit for `token`, or `0` if not set. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +pub fn get_supply_limit(e: &Env, token: &Address) -> i128 { + let key = SupplyLimitStorageKey::SupplyLimit(token.clone()); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &i128| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or_default() +} + +/// Returns the supply limit for `token`, panicking if not configured. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// +/// # Errors +/// +/// * [`ComplianceModuleError::MissingLimit`] - When no supply limit has been +/// configured for this token. +pub fn get_supply_limit_or_panic(e: &Env, token: &Address) -> i128 { + let key = SupplyLimitStorageKey::SupplyLimit(token.clone()); + let limit: i128 = e + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| panic_with_error!(e, ComplianceModuleError::MissingLimit)); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + limit +} + +/// Sets the supply limit for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `limit` - The maximum total supply. +pub fn set_supply_limit(e: &Env, token: &Address, limit: i128) { + let key = SupplyLimitStorageKey::SupplyLimit(token.clone()); + e.storage().persistent().set(&key, &limit); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} + +/// Returns the internal supply counter for `token`, or `0` if not set. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +pub fn get_internal_supply(e: &Env, token: &Address) -> i128 { + let key = SupplyLimitStorageKey::InternalSupply(token.clone()); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &i128| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or_default() +} + +/// Sets the internal supply counter for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `supply` - The new supply value. +pub fn set_internal_supply(e: &Env, token: &Address, supply: i128) { + let key = SupplyLimitStorageKey::InternalSupply(token.clone()); + e.storage().persistent().set(&key, &supply); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} diff --git a/packages/tokens/src/rwa/compliance/modules/supply_limit/test.rs b/packages/tokens/src/rwa/compliance/modules/supply_limit/test.rs new file mode 100644 index 000000000..21018e09e --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/supply_limit/test.rs @@ -0,0 +1,211 @@ +extern crate std; + +use soroban_sdk::{contract, contractimpl, contracttype, testutils::Address as _, Address, Env}; + +use super::*; +use crate::rwa::{ + compliance::{ + modules::storage::{hooks_verified, set_compliance_address, ComplianceModuleStorageKey}, + Compliance, ComplianceHook, + }, + utils::token_binder::TokenBinder, +}; + +#[contract] +struct MockComplianceContract; + +#[contracttype] +#[derive(Clone)] +enum MockComplianceStorageKey { + Registered(ComplianceHook, Address), +} + +#[contractimpl] +impl Compliance for MockComplianceContract { + fn add_module_to(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("add_module_to is not used in these tests"); + } + + fn remove_module_from(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("remove_module_from is not used in these tests"); + } + + fn get_modules_for_hook(_e: &Env, _hook: ComplianceHook) -> soroban_sdk::Vec
{ + unreachable!("get_modules_for_hook is not used in these tests"); + } + + fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool { + e.storage().persistent().has(&MockComplianceStorageKey::Registered(hook, module)) + } + + fn transferred(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) { + unreachable!("transferred is not used in these tests"); + } + + fn created(_e: &Env, _to: Address, _amount: i128, _token: Address) { + unreachable!("created is not used in these tests"); + } + + fn destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) { + unreachable!("destroyed is not used in these tests"); + } + + fn can_transfer( + _e: &Env, + _from: Address, + _to: Address, + _amount: i128, + _token: Address, + ) -> bool { + unreachable!("can_transfer is not used in these tests"); + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + unreachable!("can_create is not used in these tests"); + } +} + +#[contractimpl] +impl TokenBinder for MockComplianceContract { + fn linked_tokens(e: &Env) -> soroban_sdk::Vec
{ + soroban_sdk::Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl MockComplianceContract { + pub fn register_hook(e: &Env, hook: ComplianceHook, module: Address) { + e.storage().persistent().set(&MockComplianceStorageKey::Registered(hook, module), &true); + } +} + +#[contract] +struct TestSupplyLimitContract; + +#[contractimpl(contracttrait)] +impl SupplyLimit for TestSupplyLimitContract { + fn set_compliance_address(_e: &Env, _compliance: Address) { + unreachable!("set_compliance_address is not used in these tests"); + } +} + +fn arm_hooks(e: &Env) { + e.storage().instance().set(&ComplianceModuleStorageKey::HooksVerified, &true); +} + +#[test] +fn verify_hook_wiring_sets_cache_when_registered() { + let e = Env::default(); + let module_id = e.register(TestSupplyLimitContract, ()); + let compliance_id = e.register(MockComplianceContract, ()); + let compliance = MockComplianceContractClient::new(&e, &compliance_id); + + for hook in [ComplianceHook::CanCreate, ComplianceHook::Created, ComplianceHook::Destroyed] { + compliance.register_hook(&hook, &module_id); + } + + e.as_contract(&module_id, || { + set_compliance_address(&e, &compliance_id); + + ::verify_hook_wiring(&e); + + assert!(hooks_verified(&e)); + }); +} + +#[test] +fn get_supply_limit_returns_zero_when_unconfigured() { + let e = Env::default(); + let module_id = e.register(TestSupplyLimitContract, ()); + let token = Address::generate(&e); + + e.as_contract(&module_id, || { + assert_eq!(::get_supply_limit(&e, token), 0); + }); +} + +#[test] +fn can_create_allows_when_limit_is_unset_and_rejects_negative_amount() { + let e = Env::default(); + let module_id = e.register(TestSupplyLimitContract, ()); + let token = Address::generate(&e); + let recipient = Address::generate(&e); + + e.as_contract(&module_id, || { + arm_hooks(&e); + + assert!(::can_create( + &e, + recipient.clone(), + 100, + token.clone(), + )); + assert!(!::can_create( + &e, + recipient, + -1, + token.clone(), + )); + }); +} + +#[test] +fn hooks_update_internal_supply_and_cap_future_mints() { + let e = Env::default(); + e.mock_all_auths(); + + let module_id = e.register(TestSupplyLimitContract, ()); + let compliance_id = e.register(MockComplianceContract, ()); + let token = Address::generate(&e); + let recipient = Address::generate(&e); + let client = TestSupplyLimitContractClient::new(&e, &module_id); + + e.as_contract(&module_id, || { + set_compliance_address(&e, &compliance_id); + arm_hooks(&e); + }); + + client.set_supply_limit(&token, &100); + + assert!(client.can_create(&recipient.clone(), &80, &token)); + client.on_created(&recipient.clone(), &80, &token); + assert_eq!(client.get_internal_supply(&token), 80); + + assert!(!client.can_create(&recipient.clone(), &30, &token)); + + client.on_destroyed(&recipient.clone(), &20, &token); + assert_eq!(client.get_internal_supply(&token), 60); + assert!(client.can_create(&recipient, &40, &token)); +} + +#[test] +fn pre_set_internal_supply_seeds_existing_supply_for_cap_checks() { + let e = Env::default(); + e.mock_all_auths(); + + let module_id = e.register(TestSupplyLimitContract, ()); + let compliance_id = e.register(MockComplianceContract, ()); + let token = Address::generate(&e); + let recipient = Address::generate(&e); + let client = TestSupplyLimitContractClient::new(&e, &module_id); + + e.as_contract(&module_id, || { + set_compliance_address(&e, &compliance_id); + arm_hooks(&e); + }); + + client.set_supply_limit(&token, &100); + client.pre_set_internal_supply(&token, &90); + + assert_eq!(client.get_internal_supply(&token), 90); + assert!(!client.can_create(&recipient.clone(), &11, &token)); + assert!(client.can_create(&recipient, &10, &token)); +} diff --git a/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/mod.rs b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/mod.rs new file mode 100644 index 000000000..5bc76a97f --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/mod.rs @@ -0,0 +1,239 @@ +//! Time-windowed transfer-limits compliance module — Stellar port of T-REX +//! [`TimeTransfersLimitsModule.sol`][trex-src]. +//! +//! Limits transfer volume within configurable time windows, tracking counters +//! per **identity** (not per wallet). +//! +//! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/TimeTransfersLimitsModule.sol + +pub mod storage; +#[cfg(test)] +mod test; + +use soroban_sdk::{contractevent, contracttrait, panic_with_error, vec, Address, Env, String, Vec}; +use storage::{get_counter, get_limits, set_counter, set_limits}; +pub use storage::{Limit, TransferCounter}; + +use super::storage::{ + add_i128_or_panic, get_compliance_address, get_irs_client, hooks_verified, module_name, + require_non_negative_amount, set_irs_address, verify_required_hooks, +}; +use crate::rwa::compliance::{modules::ComplianceModuleError, ComplianceHook}; + +const MAX_LIMITS_PER_TOKEN: u32 = 4; + +/// Emitted when a time-window limit is added or updated. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TimeTransferLimitUpdated { + #[topic] + pub token: Address, + pub limit: Limit, +} + +/// Emitted when a time-window limit is removed. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TimeTransferLimitRemoved { + #[topic] + pub token: Address, + pub limit_time: u64, +} + +// ################## HELPERS ################## + +fn is_counter_finished(e: &Env, token: &Address, identity: &Address, limit_time: u64) -> bool { + let counter = get_counter(e, token, identity, limit_time); + counter.timer <= e.ledger().timestamp() +} + +fn reset_counter_if_needed(e: &Env, token: &Address, identity: &Address, limit_time: u64) { + if is_counter_finished(e, token, identity, limit_time) { + let counter = + TransferCounter { value: 0, timer: e.ledger().timestamp().saturating_add(limit_time) }; + set_counter(e, token, identity, limit_time, &counter); + } +} + +fn increase_counters(e: &Env, token: &Address, identity: &Address, value: i128) { + let limits = get_limits(e, token); + for limit in limits.iter() { + reset_counter_if_needed(e, token, identity, limit.limit_time); + let mut counter = get_counter(e, token, identity, limit.limit_time); + counter.value = add_i128_or_panic(e, counter.value, value); + set_counter(e, token, identity, limit.limit_time, &counter); + } +} + +#[contracttrait] +pub trait TimeTransfersLimits { + // ################## QUERY STATE ################## + + fn get_time_transfer_limits(e: &Env, token: Address) -> Vec { + get_limits(e, &token) + } + + fn can_transfer(e: &Env, from: Address, _to: Address, amount: i128, token: Address) -> bool { + assert!( + hooks_verified(e), + "TimeTransfersLimitsModule: not armed — call verify_hook_wiring() after wiring hooks \ + [CanTransfer, Transferred]" + ); + if amount < 0 { + return false; + } + let irs = get_irs_client(e, &token); + let from_id = irs.stored_identity(&from); + let limits = get_limits(e, &token); + + for limit in limits.iter() { + if amount > limit.limit_value { + return false; + } + + if !is_counter_finished(e, &token, &from_id, limit.limit_time) { + let counter = get_counter(e, &token, &from_id, limit.limit_time); + if add_i128_or_panic(e, counter.value, amount) > limit.limit_value { + return false; + } + } + } + + true + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + true + } + + fn name(e: &Env) -> String { + module_name(e, "TimeTransfersLimitsModule") + } + + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + // ################## CHANGE STATE ################## + + fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { + get_compliance_address(e).require_auth(); + set_irs_address(e, &token, &irs); + } + + fn set_time_transfer_limit(e: &Env, token: Address, limit: Limit) { + get_compliance_address(e).require_auth(); + assert!(limit.limit_time > 0, "limit_time must be greater than zero"); + require_non_negative_amount(e, limit.limit_value); + let mut limits = get_limits(e, &token); + + let mut replaced = false; + for i in 0..limits.len() { + let current = limits.get(i).expect("limit exists"); + if current.limit_time == limit.limit_time { + limits.set(i, limit.clone()); + replaced = true; + break; + } + } + + if !replaced { + if limits.len() >= MAX_LIMITS_PER_TOKEN { + panic_with_error!(e, ComplianceModuleError::TooManyLimits); + } + limits.push_back(limit.clone()); + } + + set_limits(e, &token, &limits); + TimeTransferLimitUpdated { token, limit }.publish(e); + } + + fn batch_set_time_transfer_limit(e: &Env, token: Address, limits: Vec) { + get_compliance_address(e).require_auth(); + for limit in limits.iter() { + Self::set_time_transfer_limit(e, token.clone(), limit); + } + } + + fn remove_time_transfer_limit(e: &Env, token: Address, limit_time: u64) { + get_compliance_address(e).require_auth(); + let mut limits = get_limits(e, &token); + + let mut found = false; + for i in 0..limits.len() { + let current = limits.get(i).expect("limit exists"); + if current.limit_time == limit_time { + limits.remove(i); + found = true; + break; + } + } + + if !found { + panic_with_error!(e, ComplianceModuleError::MissingLimit); + } + + set_limits(e, &token, &limits); + TimeTransferLimitRemoved { token, limit_time }.publish(e); + } + + fn batch_remove_time_transfer_limit(e: &Env, token: Address, limit_times: Vec) { + get_compliance_address(e).require_auth(); + for lt in limit_times.iter() { + Self::remove_time_transfer_limit(e, token.clone(), lt); + } + } + + fn pre_set_transfer_counter( + e: &Env, + token: Address, + identity: Address, + limit_time: u64, + counter: TransferCounter, + ) { + get_compliance_address(e).require_auth(); + require_non_negative_amount(e, counter.value); + assert!(limit_time > 0, "limit_time must be greater than zero"); + + let mut found = false; + for limit in get_limits(e, &token).iter() { + if limit.limit_time == limit_time { + found = true; + break; + } + } + + if !found { + panic_with_error!(e, ComplianceModuleError::MissingLimit); + } + + set_counter(e, &token, &identity, limit_time, &counter); + } + + fn verify_hook_wiring(e: &Env) { + verify_required_hooks(e, Self::required_hooks(e)); + } + + fn on_transfer(e: &Env, from: Address, _to: Address, amount: i128, token: Address) { + get_compliance_address(e).require_auth(); + require_non_negative_amount(e, amount); + let irs = get_irs_client(e, &token); + let from_id = irs.stored_identity(&from); + increase_counters(e, &token, &from_id, amount); + } + + fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {} + + fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {} + + /// Implementers must gate this entrypoint with bootstrap-admin auth before + /// delegating to + /// [`storage::set_compliance_address`](super::storage::set_compliance_address). + fn set_compliance_address(e: &Env, compliance: Address); + + // ################## HELPERS ################## + + fn required_hooks(e: &Env) -> Vec { + vec![e, ComplianceHook::CanTransfer, ComplianceHook::Transferred] + } +} diff --git a/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/storage.rs b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/storage.rs new file mode 100644 index 000000000..8b7e38e5e --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/storage.rs @@ -0,0 +1,104 @@ +use soroban_sdk::{contracttype, Address, Env, Vec}; + +use crate::rwa::compliance::modules::{MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD}; + +/// A single time-window limit: `limit_value` tokens may be transferred +/// within a rolling window of `limit_time` seconds. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Limit { + pub limit_time: u64, + pub limit_value: i128, +} + +/// Tracks cumulative transfer volume for one identity within one window. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransferCounter { + pub value: i128, + pub timer: u64, +} + +#[contracttype] +#[derive(Clone)] +pub enum TimeTransfersLimitsStorageKey { + /// Per-token list of configured time-window limits. + Limits(Address), + /// Counter keyed by (token, identity, window_seconds). + Counter(Address, Address, u64), +} + +/// Returns the list of time-window limits for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +pub fn get_limits(e: &Env, token: &Address) -> Vec { + let key = TimeTransfersLimitsStorageKey::Limits(token.clone()); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &Vec| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or_else(|| Vec::new(e)) +} + +/// Persists the list of time-window limits for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `limits` - The updated limits list. +pub fn set_limits(e: &Env, token: &Address, limits: &Vec) { + let key = TimeTransfersLimitsStorageKey::Limits(token.clone()); + e.storage().persistent().set(&key, limits); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} + +/// Returns the transfer counter for a given identity and time window. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `identity` - The on-chain identity address. +/// * `limit_time` - The time-window duration in seconds. +pub fn get_counter( + e: &Env, + token: &Address, + identity: &Address, + limit_time: u64, +) -> TransferCounter { + let key = TimeTransfersLimitsStorageKey::Counter(token.clone(), identity.clone(), limit_time); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &TransferCounter| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or(TransferCounter { value: 0, timer: 0 }) +} + +/// Persists the transfer counter for a given identity and time window. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `identity` - The on-chain identity address. +/// * `limit_time` - The time-window duration in seconds. +/// * `counter` - The updated counter value. +pub fn set_counter( + e: &Env, + token: &Address, + identity: &Address, + limit_time: u64, + counter: &TransferCounter, +) { + let key = TimeTransfersLimitsStorageKey::Counter(token.clone(), identity.clone(), limit_time); + e.storage().persistent().set(&key, counter); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} diff --git a/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/test.rs b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/test.rs new file mode 100644 index 000000000..aced1114d --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/test.rs @@ -0,0 +1,282 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, Address, Env, Val, Vec, +}; + +use super::*; +use crate::rwa::{ + compliance::{ + modules::storage::{ + hooks_verified, set_compliance_address, set_irs_address, ComplianceModuleStorageKey, + }, + Compliance, ComplianceHook, + }, + identity_registry_storage::{CountryDataManager, IdentityRegistryStorage}, + utils::token_binder::TokenBinder, +}; + +#[contract] +struct MockIRSContract; + +#[contracttype] +#[derive(Clone)] +enum MockIRSStorageKey { + Identity(Address), +} + +#[contractimpl] +impl TokenBinder for MockIRSContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl IdentityRegistryStorage for MockIRSContract { + fn add_identity( + _e: &Env, + _account: Address, + _identity: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_identity is not used in these tests"); + } + + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { + unreachable!("remove_identity is not used in these tests"); + } + + fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { + unreachable!("modify_identity is not used in these tests"); + } + + fn recover_identity( + _e: &Env, + _old_account: Address, + _new_account: Address, + _operator: Address, + ) { + unreachable!("recover_identity is not used in these tests"); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + e.storage() + .persistent() + .get(&MockIRSStorageKey::Identity(account.clone())) + .unwrap_or(account) + } +} + +#[contractimpl] +impl CountryDataManager for MockIRSContract { + fn add_country_data_entries( + _e: &Env, + _account: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_country_data_entries is not used in these tests"); + } + + fn modify_country_data( + _e: &Env, + _account: Address, + _index: u32, + _country_data: Val, + _operator: Address, + ) { + unreachable!("modify_country_data is not used in these tests"); + } + + fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { + unreachable!("delete_country_data is not used in these tests"); + } + + fn get_country_data_entries(e: &Env, _account: Address) -> Vec { + Vec::new(e) + } +} + +#[contractimpl] +impl MockIRSContract { + pub fn set_identity(e: &Env, account: Address, identity: Address) { + e.storage().persistent().set(&MockIRSStorageKey::Identity(account), &identity); + } +} + +#[contract] +struct MockComplianceContract; + +#[contracttype] +#[derive(Clone)] +enum MockComplianceStorageKey { + Registered(ComplianceHook, Address), +} + +#[contractimpl] +impl Compliance for MockComplianceContract { + fn add_module_to(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("add_module_to is not used in these tests"); + } + + fn remove_module_from(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("remove_module_from is not used in these tests"); + } + + fn get_modules_for_hook(_e: &Env, _hook: ComplianceHook) -> Vec
{ + unreachable!("get_modules_for_hook is not used in these tests"); + } + + fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool { + e.storage().persistent().has(&MockComplianceStorageKey::Registered(hook, module)) + } + + fn transferred(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) { + unreachable!("transferred is not used in these tests"); + } + + fn created(_e: &Env, _to: Address, _amount: i128, _token: Address) { + unreachable!("created is not used in these tests"); + } + + fn destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) { + unreachable!("destroyed is not used in these tests"); + } + + fn can_transfer( + _e: &Env, + _from: Address, + _to: Address, + _amount: i128, + _token: Address, + ) -> bool { + unreachable!("can_transfer is not used in these tests"); + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + unreachable!("can_create is not used in these tests"); + } +} + +#[contractimpl] +impl TokenBinder for MockComplianceContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl MockComplianceContract { + pub fn register_hook(e: &Env, hook: ComplianceHook, module: Address) { + e.storage().persistent().set(&MockComplianceStorageKey::Registered(hook, module), &true); + } +} + +#[contract] +struct TestTimeTransfersLimitsContract; + +#[contractimpl(contracttrait)] +impl TimeTransfersLimits for TestTimeTransfersLimitsContract { + fn set_compliance_address(_e: &Env, _compliance: Address) { + unreachable!("set_compliance_address is not used in these tests"); + } +} + +fn arm_hooks(e: &Env) { + e.storage().instance().set(&ComplianceModuleStorageKey::HooksVerified, &true); +} + +#[test] +fn verify_hook_wiring_sets_cache_when_registered() { + let e = Env::default(); + let module_id = e.register(TestTimeTransfersLimitsContract, ()); + let compliance_id = e.register(MockComplianceContract, ()); + let compliance = MockComplianceContractClient::new(&e, &compliance_id); + + for hook in [ComplianceHook::CanTransfer, ComplianceHook::Transferred] { + compliance.register_hook(&hook, &module_id); + } + + e.as_contract(&module_id, || { + set_compliance_address(&e, &compliance_id); + + ::verify_hook_wiring(&e); + + assert!(hooks_verified(&e)); + }); +} + +#[test] +fn pre_set_transfer_counter_blocks_transfers_within_active_window() { + let e = Env::default(); + e.mock_all_auths(); + + let module_id = e.register(TestTimeTransfersLimitsContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let sender_identity = Address::generate(&e); + let recipient = Address::generate(&e); + let client = TestTimeTransfersLimitsContractClient::new(&e, &module_id); + + irs.set_identity(&sender, &sender_identity); + + e.as_contract(&module_id, || { + set_compliance_address(&e, &compliance); + set_irs_address(&e, &token, &irs_id); + arm_hooks(&e); + }); + + client.set_time_transfer_limit(&token, &Limit { limit_time: 60, limit_value: 100 }); + client.pre_set_transfer_counter( + &token, + &sender_identity, + &60, + &TransferCounter { value: 90, timer: e.ledger().timestamp().saturating_add(60) }, + ); + + assert!(!client.can_transfer(&sender.clone(), &recipient.clone(), &11, &token)); + assert!(client.can_transfer(&sender, &recipient, &10, &token)); +} + +#[test] +#[should_panic(expected = "Error(Contract, #400)")] +fn set_time_transfer_limit_rejects_more_than_four_limits() { + let e = Env::default(); + e.mock_all_auths(); + + let module_id = e.register(TestTimeTransfersLimitsContract, ()); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let client = TestTimeTransfersLimitsContractClient::new(&e, &module_id); + + e.as_contract(&module_id, || { + set_compliance_address(&e, &compliance); + }); + + for limit_time in [60_u64, 120, 180, 240] { + client.set_time_transfer_limit(&token, &Limit { limit_time, limit_value: 100 }); + } + + client.set_time_transfer_limit(&token, &Limit { limit_time: 300, limit_value: 100 }); +} diff --git a/packages/tokens/src/rwa/compliance/modules/transfer_restrict/mod.rs b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/mod.rs new file mode 100644 index 000000000..1198a0eb2 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/mod.rs @@ -0,0 +1,200 @@ +//! Transfer restriction (address allowlist) compliance module — Stellar port +//! of T-REX [`TransferRestrictModule.sol`][trex-src]. +//! +//! Maintains a per-token address allowlist. Transfers pass if the sender is +//! on the list; otherwise the recipient must be. +//! +//! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/TransferRestrictModule.sol + +pub mod storage; +#[cfg(test)] +mod test; + +use soroban_sdk::{contractevent, contracttrait, Address, Env, String, Vec}; +use storage::{is_user_allowed, remove_user_allowed, set_user_allowed}; + +use super::storage::{get_compliance_address, module_name}; + +/// Emitted when an address is added to the transfer allowlist. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UserAllowed { + #[topic] + pub token: Address, + pub user: Address, +} + +/// Emitted when an address is removed from the transfer allowlist. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UserDisallowed { + #[topic] + pub token: Address, + pub user: Address, +} + +/// Transfer restriction compliance trait. +/// +/// Provides default implementations for maintaining a per-token address +/// allowlist. Transfers are allowed if the sender is allowlisted; otherwise +/// the recipient must be (T-REX semantics). +#[contracttrait] +pub trait TransferRestrict { + /// Adds `user` to the transfer allowlist for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `user` - The address to allow. + /// + /// # Authorization + /// + /// Requires compliance contract authorization. + /// + /// # Events + /// + /// Emits [`UserAllowed`]. + fn allow_user(e: &Env, token: Address, user: Address) { + get_compliance_address(e).require_auth(); + set_user_allowed(e, &token, &user); + UserAllowed { token, user }.publish(e); + } + + /// Removes `user` from the transfer allowlist for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `user` - The address to disallow. + /// + /// # Authorization + /// + /// Requires compliance contract authorization. + /// + /// # Events + /// + /// Emits [`UserDisallowed`]. + fn disallow_user(e: &Env, token: Address, user: Address) { + get_compliance_address(e).require_auth(); + remove_user_allowed(e, &token, &user); + UserDisallowed { token, user }.publish(e); + } + + /// Adds multiple users to the transfer allowlist in a single call. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `users` - The addresses to allow. + /// + /// # Authorization + /// + /// Requires compliance contract authorization. + /// + /// # Events + /// + /// Emits [`UserAllowed`] for each user added. + fn batch_allow_users(e: &Env, token: Address, users: Vec
) { + get_compliance_address(e).require_auth(); + for user in users.iter() { + set_user_allowed(e, &token, &user); + UserAllowed { token: token.clone(), user }.publish(e); + } + } + + /// Removes multiple users from the transfer allowlist in a single call. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `users` - The addresses to disallow. + /// + /// # Authorization + /// + /// Requires compliance contract authorization. + /// + /// # Events + /// + /// Emits [`UserDisallowed`] for each user removed. + fn batch_disallow_users(e: &Env, token: Address, users: Vec
) { + get_compliance_address(e).require_auth(); + for user in users.iter() { + remove_user_allowed(e, &token, &user); + UserDisallowed { token: token.clone(), user }.publish(e); + } + } + + /// Returns whether `user` is on the transfer allowlist for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `user` - The address to check. + fn is_user_allowed(e: &Env, token: Address, user: Address) -> bool { + is_user_allowed(e, &token, &user) + } + + /// No-op — this module does not track transfer state. + fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {} + + /// No-op — this module does not track mint state. + fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {} + + /// No-op — this module does not track burn state. + fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {} + + /// Checks whether the transfer is allowed by the address allowlist. + /// + /// T-REX semantics: if the sender is allowlisted, the transfer passes; + /// otherwise the recipient must be allowlisted. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `from` - The sender address. + /// * `to` - The recipient address. + /// * `_amount` - The transfer amount (unused). + /// * `token` - The token address. + /// + /// # Returns + /// + /// `true` if the sender or recipient is allowlisted, `false` otherwise. + fn can_transfer(e: &Env, from: Address, to: Address, _amount: i128, token: Address) -> bool { + if is_user_allowed(e, &token, &from) { + return true; + } + is_user_allowed(e, &token, &to) + } + + /// Always returns `true` — mints are not restricted by this module. + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + true + } + + /// Returns the module name for identification. + fn name(e: &Env) -> String { + module_name(e, "TransferRestrictModule") + } + + /// Returns the compliance contract address. + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + /// Sets the compliance contract address (one-time only). + /// + /// Implementers must gate this entrypoint with bootstrap-admin auth before + /// delegating to + /// [`storage::set_compliance_address`](super::storage::set_compliance_address). + /// + /// + /// # Panics + /// + /// Panics if the compliance address has already been set. + fn set_compliance_address(e: &Env, compliance: Address); +} diff --git a/packages/tokens/src/rwa/compliance/modules/transfer_restrict/storage.rs b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/storage.rs new file mode 100644 index 000000000..8fa25912f --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/storage.rs @@ -0,0 +1,54 @@ +use soroban_sdk::{contracttype, Address, Env}; + +use crate::rwa::compliance::modules::{MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD}; + +#[contracttype] +#[derive(Clone)] +pub enum TransferRestrictStorageKey { + /// Per-(token, address) allowlist flag. + AllowedUser(Address, Address), +} + +/// Returns whether `user` is on the transfer allowlist for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `user` - The user address to check. +pub fn is_user_allowed(e: &Env, token: &Address, user: &Address) -> bool { + let key = TransferRestrictStorageKey::AllowedUser(token.clone(), user.clone()); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &bool| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or_default() +} + +/// Adds `user` to the transfer allowlist for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `user` - The user address to allow. +pub fn set_user_allowed(e: &Env, token: &Address, user: &Address) { + let key = TransferRestrictStorageKey::AllowedUser(token.clone(), user.clone()); + e.storage().persistent().set(&key, &true); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} + +/// Removes `user` from the transfer allowlist for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `user` - The user address to disallow. +pub fn remove_user_allowed(e: &Env, token: &Address, user: &Address) { + e.storage() + .persistent() + .remove(&TransferRestrictStorageKey::AllowedUser(token.clone(), user.clone())); +} diff --git a/packages/tokens/src/rwa/compliance/modules/transfer_restrict/test.rs b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/test.rs new file mode 100644 index 000000000..3ae8ba642 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/test.rs @@ -0,0 +1,70 @@ +extern crate std; + +use soroban_sdk::{contract, contractimpl, testutils::Address as _, vec, Address, Env}; + +use super::*; +use crate::rwa::compliance::modules::storage::set_compliance_address; + +#[contract] +struct TestTransferRestrictContract; + +#[contractimpl(contracttrait)] +impl TransferRestrict for TestTransferRestrictContract { + fn set_compliance_address(_e: &Env, _compliance: Address) { + unreachable!("set_compliance_address is not used in these tests"); + } +} + +#[test] +fn can_transfer_allows_sender_or_recipient_when_allowlisted() { + let e = Env::default(); + e.mock_all_auths(); + + let module_id = e.register(TestTransferRestrictContract, ()); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let recipient = Address::generate(&e); + let outsider = Address::generate(&e); + let client = TestTransferRestrictContractClient::new(&e, &module_id); + + e.as_contract(&module_id, || { + set_compliance_address(&e, &compliance); + }); + + assert!(!client.can_transfer(&sender.clone(), &recipient.clone(), &100, &token)); + + client.allow_user(&token, &sender.clone()); + assert!(client.can_transfer(&sender.clone(), &outsider.clone(), &100, &token)); + + client.disallow_user(&token, &sender.clone()); + client.allow_user(&token, &recipient.clone()); + assert!(client.can_transfer(&outsider, &recipient, &100, &token)); +} + +#[test] +fn batch_allow_and_disallow_update_allowlist_entries() { + let e = Env::default(); + e.mock_all_auths(); + + let module_id = e.register(TestTransferRestrictContract, ()); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let user_a = Address::generate(&e); + let user_b = Address::generate(&e); + let client = TestTransferRestrictContractClient::new(&e, &module_id); + + e.as_contract(&module_id, || { + set_compliance_address(&e, &compliance); + }); + + client.batch_allow_users(&token, &vec![&e, user_a.clone(), user_b.clone()]); + + assert!(client.is_user_allowed(&token, &user_a.clone())); + assert!(client.is_user_allowed(&token, &user_b.clone())); + + client.batch_disallow_users(&token, &vec![&e, user_a.clone(), user_b.clone()]); + + assert!(!client.is_user_allowed(&token, &user_a)); + assert!(!client.is_user_allowed(&token, &user_b)); +} From b76909f73687875f743fa31ce999522155db77c1 Mon Sep 17 00:00:00 2001 From: Aleksandr Pasevin Date: Fri, 10 Apr 2026 12:49:56 +0300 Subject: [PATCH 2/5] chore: sync Cargo.lock with workspace manifests --- Cargo.lock | 147 ++++++++++++++++++++++++++--------------------------- 1 file changed, 73 insertions(+), 74 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3d68ac771..7330c33f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -247,9 +247,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.56" +version = "1.2.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" dependencies = [ "find-msvc-tools", "shlex", @@ -393,12 +393,12 @@ dependencies = [ [[package]] name = "darling" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core 0.21.3", - "darling_macro 0.21.3", + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -417,11 +417,10 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" dependencies = [ - "fnv", "ident_case", "proc-macro2", "quote", @@ -442,11 +441,11 @@ dependencies = [ [[package]] name = "darling_macro" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core 0.21.3", + "darling_core 0.23.0", "quote", "syn 2.0.117", ] @@ -634,9 +633,9 @@ checksum = "ca81e6b4777c89fd810c25a4be2b1bd93ea034fbe58e6a75216a34c6b82c539b" [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fee-forwarder-permissioned-example" @@ -900,9 +899,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" [[package]] name = "heapless" @@ -999,12 +998,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.0", "serde", "serde_core", ] @@ -1026,15 +1025,15 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" dependencies = [ "once_cell", "wasm-bindgen", @@ -1071,9 +1070,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.183" +version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" [[package]] name = "libm" @@ -1227,9 +1226,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-derive" @@ -1262,9 +1261,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "ownable-example" @@ -1366,9 +1365,9 @@ dependencies = [ [[package]] name = "proptest" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set", "bit-vec", @@ -1595,7 +1594,7 @@ dependencies = [ [[package]] name = "rwa-country-allow" -version = "0.6.0" +version = "0.7.0" dependencies = [ "soroban-sdk", "stellar-tokens", @@ -1603,7 +1602,7 @@ dependencies = [ [[package]] name = "rwa-country-restrict" -version = "0.6.0" +version = "0.7.0" dependencies = [ "soroban-sdk", "stellar-tokens", @@ -1641,7 +1640,7 @@ dependencies = [ [[package]] name = "rwa-initial-lockup-period" -version = "0.6.0" +version = "0.7.0" dependencies = [ "soroban-sdk", "stellar-tokens", @@ -1649,7 +1648,7 @@ dependencies = [ [[package]] name = "rwa-max-balance" -version = "0.6.0" +version = "0.7.0" dependencies = [ "soroban-sdk", "stellar-tokens", @@ -1657,7 +1656,7 @@ dependencies = [ [[package]] name = "rwa-supply-limit" -version = "0.6.0" +version = "0.7.0" dependencies = [ "soroban-sdk", "stellar-tokens", @@ -1665,7 +1664,7 @@ dependencies = [ [[package]] name = "rwa-time-transfers-limits" -version = "0.6.0" +version = "0.7.0" dependencies = [ "soroban-sdk", "stellar-tokens", @@ -1684,7 +1683,7 @@ dependencies = [ [[package]] name = "rwa-transfer-restrict" -version = "0.6.0" +version = "0.7.0" dependencies = [ "soroban-sdk", "stellar-tokens", @@ -1766,9 +1765,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -1825,15 +1824,15 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.17.0" +version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9" +checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" dependencies = [ "base64", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.13.0", + "indexmap 2.14.0", "schemars 0.8.22", "schemars 0.9.0", "schemars 1.2.1", @@ -1845,11 +1844,11 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.17.0" +version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0" +checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" dependencies = [ - "darling 0.21.3", + "darling 0.23.0", "proc-macro2", "quote", "syn 2.0.117", @@ -1993,9 +1992,9 @@ dependencies = [ [[package]] name = "soroban-ledger-snapshot" -version = "25.3.0" +version = "25.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "760124fb65a2acdea7d241b8efdfab9a39287ae8dc5bf8feb6fd9dfb664c1ad5" +checksum = "2ca06e6c5029d1285e66219cb387a234224e26969ce8ad2bc2d5017e9395d63b" dependencies = [ "serde", "serde_json", @@ -2007,9 +2006,9 @@ dependencies = [ [[package]] name = "soroban-sdk" -version = "25.3.0" +version = "25.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fb27e93f8d3fc3a815d24c60ec11e893c408a36693ec9c823322f954fa096ae" +checksum = "4502f2e018f238a4c5d3212d7d20ea6abcdc6e58babd63b642b693739db30fd1" dependencies = [ "arbitrary", "bytes-lit", @@ -2031,9 +2030,9 @@ dependencies = [ [[package]] name = "soroban-sdk-macros" -version = "25.3.0" +version = "25.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec603a62a90abdef898f8402471a24d8b58a0043b9a998ed6a607a19a5dabe1" +checksum = "ca03e9cf61d241cb9afdd6ddf41f6c25698b3f566a875e7009ea799b89e2bf0a" dependencies = [ "darling 0.20.11", "heck", @@ -2051,9 +2050,9 @@ dependencies = [ [[package]] name = "soroban-spec" -version = "25.3.0" +version = "25.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24718fac3af127fc6910eb6b1d3ccd8403201b6ef0aca73b5acabe4bc3dd42ed" +checksum = "aa02e07f507cc27406ae0834db4dcf309b78c4cc8776eb3b2d662d66e8859d25" dependencies = [ "base64", "sha2", @@ -2064,9 +2063,9 @@ dependencies = [ [[package]] name = "soroban-spec-rust" -version = "25.3.0" +version = "25.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93c558bca7a693ec8ed67d2d8c8f5b300f3772141d619a4a694ad5dd48461256" +checksum = "6835bb510763ef3fa5405e89036e3c8ea6ef5abe55fc52cfe9ac0e38be9d531c" dependencies = [ "prettyplease", "proc-macro2", @@ -2490,9 +2489,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" dependencies = [ "cfg-if", "once_cell", @@ -2503,9 +2502,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2513,9 +2512,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" dependencies = [ "bumpalo", "proc-macro2", @@ -2526,9 +2525,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" dependencies = [ "unicode-ident", ] @@ -2550,7 +2549,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap 2.13.0", + "indexmap 2.14.0", "wasm-encoder", "wasmparser 0.244.0", ] @@ -2579,7 +2578,7 @@ version = "0.116.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a58e28b80dd8340cb07b8242ae654756161f6fc8d0038123d679b7b99964fa50" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.0", "semver", ] @@ -2591,7 +2590,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", "hashbrown 0.15.5", - "indexmap 2.13.0", + "indexmap 2.14.0", "semver", ] @@ -2700,7 +2699,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap 2.13.0", + "indexmap 2.14.0", "prettyplease", "syn 2.0.117", "wasm-metadata", @@ -2731,7 +2730,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", - "indexmap 2.13.0", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -2750,7 +2749,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap 2.13.0", + "indexmap 2.14.0", "log", "semver", "serde", @@ -2762,18 +2761,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.42" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.42" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", From e477a54f68bb5764df2719408655637eb8b72275 Mon Sep 17 00:00:00 2001 From: Aleksandr Pasevin Date: Fri, 10 Apr 2026 15:37:53 +0300 Subject: [PATCH 3/5] refactor: align standalone modules with storage-first pattern and update integration imports Apply the canonical lib.rs / contract.rs layout to all standalone module examples. Move module logic into storage.rs free functions across all seven compliance modules. Update integration test imports to reference contract submodule paths. Delete standalone example READMEs. --- examples/rwa-compliance/src/test.rs | 18 +- examples/rwa-country-allow/README.md | 50 -- examples/rwa-country-allow/src/contract.rs | 101 ++++ examples/rwa-country-allow/src/lib.rs | 87 +--- examples/rwa-country-restrict/README.md | 50 -- examples/rwa-country-restrict/src/contract.rs | 101 ++++ examples/rwa-country-restrict/src/lib.rs | 87 +--- examples/rwa-initial-lockup-period/README.md | 64 --- .../rwa-initial-lockup-period/src/contract.rs | 123 +++++ examples/rwa-initial-lockup-period/src/lib.rs | 227 +-------- examples/rwa-max-balance/README.md | 62 --- examples/rwa-max-balance/src/contract.rs | 125 +++++ examples/rwa-max-balance/src/lib.rs | 166 +------ examples/rwa-supply-limit/README.md | 61 --- examples/rwa-supply-limit/src/contract.rs | 112 +++++ examples/rwa-supply-limit/src/lib.rs | 101 +--- examples/rwa-time-transfers-limits/README.md | 71 --- .../rwa-time-transfers-limits/src/contract.rs | 125 +++++ examples/rwa-time-transfers-limits/src/lib.rs | 201 +------- examples/rwa-transfer-restrict/README.md | 47 -- .../rwa-transfer-restrict/src/contract.rs | 95 ++++ examples/rwa-transfer-restrict/src/lib.rs | 82 +--- .../compliance/modules/country_allow/mod.rs | 430 +---------------- .../modules/country_allow/storage.rs | 105 ++++- .../compliance/modules/country_allow/test.rs | 183 ++++++++ .../modules/country_restrict/mod.rs | 434 +----------------- .../modules/country_restrict/storage.rs | 103 ++++- .../modules/country_restrict/test.rs | 186 ++++++++ .../modules/initial_lockup_period/mod.rs | 252 +--------- .../modules/initial_lockup_period/storage.rs | 272 ++++++++++- .../modules/initial_lockup_period/test.rs | 63 +-- .../rwa/compliance/modules/max_balance/mod.rs | 188 +------- .../compliance/modules/max_balance/storage.rs | 244 +++++++++- .../compliance/modules/max_balance/test.rs | 60 +-- .../compliance/modules/supply_limit/mod.rs | 97 +--- .../modules/supply_limit/storage.rs | 108 ++++- .../compliance/modules/supply_limit/test.rs | 75 +-- .../modules/time_transfers_limits/mod.rs | 209 +-------- .../modules/time_transfers_limits/storage.rs | 248 +++++++++- .../modules/time_transfers_limits/test.rs | 60 ++- .../modules/transfer_restrict/mod.rs | 171 +------ .../modules/transfer_restrict/storage.rs | 87 +++- .../modules/transfer_restrict/test.rs | 52 +-- 43 files changed, 2427 insertions(+), 3356 deletions(-) delete mode 100644 examples/rwa-country-allow/README.md create mode 100644 examples/rwa-country-allow/src/contract.rs delete mode 100644 examples/rwa-country-restrict/README.md create mode 100644 examples/rwa-country-restrict/src/contract.rs delete mode 100644 examples/rwa-initial-lockup-period/README.md create mode 100644 examples/rwa-initial-lockup-period/src/contract.rs delete mode 100644 examples/rwa-max-balance/README.md create mode 100644 examples/rwa-max-balance/src/contract.rs delete mode 100644 examples/rwa-supply-limit/README.md create mode 100644 examples/rwa-supply-limit/src/contract.rs delete mode 100644 examples/rwa-time-transfers-limits/README.md create mode 100644 examples/rwa-time-transfers-limits/src/contract.rs delete mode 100644 examples/rwa-transfer-restrict/README.md create mode 100644 examples/rwa-transfer-restrict/src/contract.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/country_allow/test.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/country_restrict/test.rs diff --git a/examples/rwa-compliance/src/test.rs b/examples/rwa-compliance/src/test.rs index 2773300fe..89a5f2c7e 100644 --- a/examples/rwa-compliance/src/test.rs +++ b/examples/rwa-compliance/src/test.rs @@ -1,12 +1,16 @@ extern crate std; -use rwa_country_allow::{CountryAllowContract, CountryAllowContractClient}; -use rwa_country_restrict::{CountryRestrictContract, CountryRestrictContractClient}; -use rwa_initial_lockup_period::{InitialLockupPeriodContract, InitialLockupPeriodContractClient}; -use rwa_max_balance::{MaxBalanceContract, MaxBalanceContractClient}; -use rwa_supply_limit::{SupplyLimitContract, SupplyLimitContractClient}; -use rwa_time_transfers_limits::{TimeTransfersLimitsContract, TimeTransfersLimitsContractClient}; -use rwa_transfer_restrict::{TransferRestrictContract, TransferRestrictContractClient}; +use rwa_country_allow::contract::{CountryAllowContract, CountryAllowContractClient}; +use rwa_country_restrict::contract::{CountryRestrictContract, CountryRestrictContractClient}; +use rwa_initial_lockup_period::contract::{ + InitialLockupPeriodContract, InitialLockupPeriodContractClient, +}; +use rwa_max_balance::contract::{MaxBalanceContract, MaxBalanceContractClient}; +use rwa_supply_limit::contract::{SupplyLimitContract, SupplyLimitContractClient}; +use rwa_time_transfers_limits::contract::{ + TimeTransfersLimitsContract, TimeTransfersLimitsContractClient, +}; +use rwa_transfer_restrict::contract::{TransferRestrictContract, TransferRestrictContractClient}; use soroban_sdk::{ testutils::{Address as _, Ledger}, vec, Address, Env, IntoVal, String, diff --git a/examples/rwa-country-allow/README.md b/examples/rwa-country-allow/README.md deleted file mode 100644 index f0b88aa54..000000000 --- a/examples/rwa-country-allow/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# Country Allow Module - -Concrete deployable example of the `CountryAllow` compliance module for Stellar -RWA tokens. - -## What it enforces - -This module allows tokens to be minted or transferred only to recipients whose -registered identity has at least one country code that appears in the module's -per-token allowlist. - -The country lookup is performed through the Identity Registry Storage (IRS), so -the module must be configured with an IRS contract for each token it serves. - -## Authorization model - -This example uses the bootstrap-admin pattern introduced in this port: - -- The constructor stores a one-time `admin` -- Before `set_compliance_address`, privileged configuration calls require that - admin's auth -- After `set_compliance_address`, the same configuration calls require auth - from the bound Compliance contract -- `set_compliance_address` itself remains a one-time admin action - -This lets the module be configured from the CLI before it is locked to the -Compliance contract. - -## Main entrypoints - -- `__constructor(admin)` initializes the bootstrap admin -- `set_identity_registry_storage(token, irs)` stores the IRS address for a - token -- `add_allowed_country(token, country)` adds an ISO 3166-1 numeric code to the - allowlist -- `remove_allowed_country(token, country)` removes a country code -- `batch_allow_countries(token, countries)` updates multiple entries -- `batch_disallow_countries(token, countries)` removes multiple entries -- `is_country_allowed(token, country)` reads the current allowlist state -- `set_compliance_address(compliance)` performs the one-time handoff to the - Compliance contract - -## Notes - -- Storage is token-scoped, so one deployed module can be reused across many - tokens -- This module validates on the compliance read hooks used for transfers and - mints; it does not require extra state-tracking hooks -- In the deploy example, the module is configured before binding and then wired - to the `CanTransfer` and `CanCreate` hooks diff --git a/examples/rwa-country-allow/src/contract.rs b/examples/rwa-country-allow/src/contract.rs new file mode 100644 index 000000000..9be520c25 --- /dev/null +++ b/examples/rwa-country-allow/src/contract.rs @@ -0,0 +1,101 @@ +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::modules::{ + country_allow::storage as country_allow, + storage::{ + get_compliance_address, module_name, set_compliance_address, set_irs_address, + ComplianceModuleStorageKey, + }, + ComplianceModule, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct CountryAllowContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl CountryAllowContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } + + pub fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { + require_module_admin_or_compliance_auth(e); + set_irs_address(e, &token, &irs); + } + + pub fn add_allowed_country(e: &Env, token: Address, country: u32) { + require_module_admin_or_compliance_auth(e); + country_allow::add_allowed_country(e, &token, country); + } + + pub fn remove_allowed_country(e: &Env, token: Address, country: u32) { + require_module_admin_or_compliance_auth(e); + country_allow::remove_allowed_country(e, &token, country); + } + + pub fn batch_allow_countries(e: &Env, token: Address, countries: Vec) { + require_module_admin_or_compliance_auth(e); + country_allow::batch_allow_countries(e, &token, &countries); + } + + pub fn batch_disallow_countries(e: &Env, token: Address, countries: Vec) { + require_module_admin_or_compliance_auth(e); + country_allow::batch_disallow_countries(e, &token, &countries); + } + + pub fn is_country_allowed(e: &Env, token: Address, country: u32) -> bool { + country_allow::is_country_allowed(e, &token, country) + } +} + +#[contractimpl(contracttrait)] +impl ComplianceModule for CountryAllowContract { + fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {} + + fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {} + + fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {} + + fn can_transfer(e: &Env, _from: Address, to: Address, _amount: i128, token: Address) -> bool { + country_allow::can_transfer(e, &to, &token) + } + + fn can_create(e: &Env, to: Address, _amount: i128, token: Address) -> bool { + country_allow::can_transfer(e, &to, &token) + } + + fn name(e: &Env) -> String { + module_name(e, "CountryAllowModule") + } + + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-country-allow/src/lib.rs b/examples/rwa-country-allow/src/lib.rs index f2fd11b23..b9aa4d5f0 100644 --- a/examples/rwa-country-allow/src/lib.rs +++ b/examples/rwa-country-allow/src/lib.rs @@ -1,88 +1,3 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; -use stellar_tokens::rwa::compliance::modules::{ - country_allow::{ - storage::{is_country_allowed, remove_country_allowed, set_country_allowed}, - CountryAllow, CountryAllowed, CountryUnallowed, - }, - storage::{set_compliance_address, set_irs_address, ComplianceModuleStorageKey}, -}; - -#[contracttype] -enum DataKey { - Admin, -} - -#[contract] -pub struct CountryAllowContract; - -fn set_admin(e: &Env, admin: &Address) { - e.storage().instance().set(&DataKey::Admin, admin); -} - -fn get_admin(e: &Env) -> Address { - e.storage().instance().get(&DataKey::Admin).expect("admin must be set") -} - -fn require_module_admin_or_compliance_auth(e: &Env) { - if let Some(compliance) = - e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) - { - compliance.require_auth(); - } else { - get_admin(e).require_auth(); - } -} - -#[contractimpl] -impl CountryAllowContract { - pub fn __constructor(e: &Env, admin: Address) { - set_admin(e, &admin); - } -} - -#[contractimpl(contracttrait)] -impl CountryAllow for CountryAllowContract { - fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { - require_module_admin_or_compliance_auth(e); - set_irs_address(e, &token, &irs); - } - - fn add_allowed_country(e: &Env, token: Address, country: u32) { - require_module_admin_or_compliance_auth(e); - set_country_allowed(e, &token, country); - CountryAllowed { token, country }.publish(e); - } - - fn remove_allowed_country(e: &Env, token: Address, country: u32) { - require_module_admin_or_compliance_auth(e); - remove_country_allowed(e, &token, country); - CountryUnallowed { token, country }.publish(e); - } - - fn batch_allow_countries(e: &Env, token: Address, countries: Vec) { - require_module_admin_or_compliance_auth(e); - for country in countries.iter() { - set_country_allowed(e, &token, country); - CountryAllowed { token: token.clone(), country }.publish(e); - } - } - - fn batch_disallow_countries(e: &Env, token: Address, countries: Vec) { - require_module_admin_or_compliance_auth(e); - for country in countries.iter() { - remove_country_allowed(e, &token, country); - CountryUnallowed { token: token.clone(), country }.publish(e); - } - } - - fn is_country_allowed(e: &Env, token: Address, country: u32) -> bool { - is_country_allowed(e, &token, country) - } - - fn set_compliance_address(e: &Env, compliance: Address) { - get_admin(e).require_auth(); - set_compliance_address(e, &compliance); - } -} +pub mod contract; diff --git a/examples/rwa-country-restrict/README.md b/examples/rwa-country-restrict/README.md deleted file mode 100644 index 104bf6066..000000000 --- a/examples/rwa-country-restrict/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# Country Restrict Module - -Concrete deployable example of the `CountryRestrict` compliance module for -Stellar RWA tokens. - -## What it enforces - -This module blocks tokens from being minted or transferred to recipients whose -registered identity has a country code that appears in the module's per-token -restriction list. - -The country lookup is performed through the Identity Registry Storage (IRS), so -the module must be configured with an IRS contract for each token it serves. - -## Authorization model - -This example uses the bootstrap-admin pattern introduced in this port: - -- The constructor stores a one-time `admin` -- Before `set_compliance_address`, privileged configuration calls require that - admin's auth -- After `set_compliance_address`, the same configuration calls require auth - from the bound Compliance contract -- `set_compliance_address` itself remains a one-time admin action - -This lets the module be configured from the CLI before it is locked to the -Compliance contract. - -## Main entrypoints - -- `__constructor(admin)` initializes the bootstrap admin -- `set_identity_registry_storage(token, irs)` stores the IRS address for a - token -- `add_country_restriction(token, country)` adds an ISO 3166-1 numeric code to - the restriction list -- `remove_country_restriction(token, country)` removes a country code -- `batch_restrict_countries(token, countries)` updates multiple entries -- `batch_unrestrict_countries(token, countries)` removes multiple entries -- `is_country_restricted(token, country)` reads the current restriction state -- `set_compliance_address(compliance)` performs the one-time handoff to the - Compliance contract - -## Notes - -- Storage is token-scoped, so one deployed module can be reused across many - tokens -- This module validates on the compliance read hooks used for transfers and - mints; it does not require extra state-tracking hooks -- In the deploy example, the module is configured before binding and then wired - to the `CanTransfer` and `CanCreate` hooks diff --git a/examples/rwa-country-restrict/src/contract.rs b/examples/rwa-country-restrict/src/contract.rs new file mode 100644 index 000000000..aa2c9794c --- /dev/null +++ b/examples/rwa-country-restrict/src/contract.rs @@ -0,0 +1,101 @@ +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::modules::{ + country_restrict::storage as country_restrict, + storage::{ + get_compliance_address, module_name, set_compliance_address, set_irs_address, + ComplianceModuleStorageKey, + }, + ComplianceModule, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct CountryRestrictContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl CountryRestrictContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } + + pub fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { + require_module_admin_or_compliance_auth(e); + set_irs_address(e, &token, &irs); + } + + pub fn add_country_restriction(e: &Env, token: Address, country: u32) { + require_module_admin_or_compliance_auth(e); + country_restrict::add_country_restriction(e, &token, country); + } + + pub fn remove_country_restriction(e: &Env, token: Address, country: u32) { + require_module_admin_or_compliance_auth(e); + country_restrict::remove_country_restriction(e, &token, country); + } + + pub fn batch_restrict_countries(e: &Env, token: Address, countries: Vec) { + require_module_admin_or_compliance_auth(e); + country_restrict::batch_restrict_countries(e, &token, &countries); + } + + pub fn batch_unrestrict_countries(e: &Env, token: Address, countries: Vec) { + require_module_admin_or_compliance_auth(e); + country_restrict::batch_unrestrict_countries(e, &token, &countries); + } + + pub fn is_country_restricted(e: &Env, token: Address, country: u32) -> bool { + country_restrict::is_country_restricted(e, &token, country) + } +} + +#[contractimpl(contracttrait)] +impl ComplianceModule for CountryRestrictContract { + fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {} + + fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {} + + fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {} + + fn can_transfer(e: &Env, _from: Address, to: Address, _amount: i128, token: Address) -> bool { + country_restrict::can_transfer(e, &to, &token) + } + + fn can_create(e: &Env, to: Address, _amount: i128, token: Address) -> bool { + country_restrict::can_transfer(e, &to, &token) + } + + fn name(e: &Env) -> String { + module_name(e, "CountryRestrictModule") + } + + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-country-restrict/src/lib.rs b/examples/rwa-country-restrict/src/lib.rs index 8d8d4c140..b9aa4d5f0 100644 --- a/examples/rwa-country-restrict/src/lib.rs +++ b/examples/rwa-country-restrict/src/lib.rs @@ -1,88 +1,3 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; -use stellar_tokens::rwa::compliance::modules::{ - country_restrict::{ - storage::{is_country_restricted, remove_country_restricted, set_country_restricted}, - CountryRestrict, CountryRestricted, CountryUnrestricted, - }, - storage::{set_compliance_address, set_irs_address, ComplianceModuleStorageKey}, -}; - -#[contracttype] -enum DataKey { - Admin, -} - -#[contract] -pub struct CountryRestrictContract; - -fn set_admin(e: &Env, admin: &Address) { - e.storage().instance().set(&DataKey::Admin, admin); -} - -fn get_admin(e: &Env) -> Address { - e.storage().instance().get(&DataKey::Admin).expect("admin must be set") -} - -fn require_module_admin_or_compliance_auth(e: &Env) { - if let Some(compliance) = - e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) - { - compliance.require_auth(); - } else { - get_admin(e).require_auth(); - } -} - -#[contractimpl] -impl CountryRestrictContract { - pub fn __constructor(e: &Env, admin: Address) { - set_admin(e, &admin); - } -} - -#[contractimpl(contracttrait)] -impl CountryRestrict for CountryRestrictContract { - fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { - require_module_admin_or_compliance_auth(e); - set_irs_address(e, &token, &irs); - } - - fn add_country_restriction(e: &Env, token: Address, country: u32) { - require_module_admin_or_compliance_auth(e); - set_country_restricted(e, &token, country); - CountryRestricted { token, country }.publish(e); - } - - fn remove_country_restriction(e: &Env, token: Address, country: u32) { - require_module_admin_or_compliance_auth(e); - remove_country_restricted(e, &token, country); - CountryUnrestricted { token, country }.publish(e); - } - - fn batch_restrict_countries(e: &Env, token: Address, countries: Vec) { - require_module_admin_or_compliance_auth(e); - for country in countries.iter() { - set_country_restricted(e, &token, country); - CountryRestricted { token: token.clone(), country }.publish(e); - } - } - - fn batch_unrestrict_countries(e: &Env, token: Address, countries: Vec) { - require_module_admin_or_compliance_auth(e); - for country in countries.iter() { - remove_country_restricted(e, &token, country); - CountryUnrestricted { token: token.clone(), country }.publish(e); - } - } - - fn is_country_restricted(e: &Env, token: Address, country: u32) -> bool { - is_country_restricted(e, &token, country) - } - - fn set_compliance_address(e: &Env, compliance: Address) { - get_admin(e).require_auth(); - set_compliance_address(e, &compliance); - } -} +pub mod contract; diff --git a/examples/rwa-initial-lockup-period/README.md b/examples/rwa-initial-lockup-period/README.md deleted file mode 100644 index e795d527d..000000000 --- a/examples/rwa-initial-lockup-period/README.md +++ /dev/null @@ -1,64 +0,0 @@ -# Initial Lockup Period Module - -Concrete deployable example of the `InitialLockupPeriod` compliance module for -Stellar RWA tokens. - -## What it enforces - -This module applies a lockup period to tokens received through primary -emissions. When tokens are minted, the minted amount is locked until the -configured release timestamp. - -The example follows the library semantics: - -- minted tokens are subject to lockup -- peer-to-peer transfers do not create new lockups for the recipient -- transfers and burns can consume only unlocked balance - -## How it stays in sync - -The module maintains internal balances plus lock records and therefore must be -wired to all of the hooks it depends on: - -- `CanTransfer` -- `Created` -- `Transferred` -- `Destroyed` - -After those hooks are registered, `verify_hook_wiring()` must be called once so -the module marks itself as armed before transfer validation starts. - -## Authorization model - -This example uses the bootstrap-admin pattern introduced in this port: - -- The constructor stores a one-time `admin` -- Before `set_compliance_address`, configuration calls require that admin's - auth -- After `set_compliance_address`, privileged calls require auth from the bound - Compliance contract -- `set_compliance_address` itself remains a one-time admin action - -This allows the module to be configured from the CLI before handing control to -Compliance. - -## Main entrypoints - -- `__constructor(admin)` initializes the bootstrap admin -- `set_lockup_period(token, lockup_seconds)` configures the mint lockup window -- `pre_set_lockup_state(token, wallet, balance, locks)` seeds an existing - holder's mirrored balance and active lock entries -- `required_hooks()` returns the required hook set -- `verify_hook_wiring()` marks the module as armed after registration -- `set_compliance_address(compliance)` performs the one-time handoff to the - Compliance contract - -## Notes - -- Storage is token-scoped, so one deployed module can be reused across many - tokens -- The module stores detailed lock entries plus aggregate locked totals -- If the module is attached after live minting, seed existing balances and any - still-active lock entries before relying on transfer or burn enforcement -- Transfer and burn flows consume unlocked balance first, then matured locks if - needed diff --git a/examples/rwa-initial-lockup-period/src/contract.rs b/examples/rwa-initial-lockup-period/src/contract.rs new file mode 100644 index 000000000..330f6741a --- /dev/null +++ b/examples/rwa-initial-lockup-period/src/contract.rs @@ -0,0 +1,123 @@ +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::{ + modules::{ + initial_lockup_period::{storage as lockup, LockedTokens}, + storage::{ + get_compliance_address, module_name, set_compliance_address, ComplianceModuleStorageKey, + }, + ComplianceModule, + }, + ComplianceHook, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct InitialLockupPeriodContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl InitialLockupPeriodContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } + + pub fn set_lockup_period(e: &Env, token: Address, lockup_seconds: u64) { + require_module_admin_or_compliance_auth(e); + lockup::configure_lockup_period(e, &token, lockup_seconds); + } + + pub fn pre_set_lockup_state( + e: &Env, + token: Address, + wallet: Address, + balance: i128, + locks: Vec, + ) { + require_module_admin_or_compliance_auth(e); + lockup::pre_set_lockup_state(e, &token, &wallet, balance, &locks); + } + + pub fn get_lockup_period(e: &Env, token: Address) -> u64 { + lockup::get_lockup_period(e, &token) + } + + pub fn get_total_locked(e: &Env, token: Address, wallet: Address) -> i128 { + lockup::get_total_locked(e, &token, &wallet) + } + + pub fn get_locked_tokens(e: &Env, token: Address, wallet: Address) -> Vec { + lockup::get_locks(e, &token, &wallet) + } + + pub fn get_internal_balance(e: &Env, token: Address, wallet: Address) -> i128 { + lockup::get_internal_balance(e, &token, &wallet) + } + + pub fn required_hooks(e: &Env) -> Vec { + lockup::required_hooks(e) + } + + pub fn verify_hook_wiring(e: &Env) { + lockup::verify_hook_wiring(e); + } +} + +#[contractimpl(contracttrait)] +impl ComplianceModule for InitialLockupPeriodContract { + fn on_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + lockup::on_transfer(e, &from, &to, amount, &token); + } + + fn on_created(e: &Env, to: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + lockup::on_created(e, &to, amount, &token); + } + + fn on_destroyed(e: &Env, from: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + lockup::on_destroyed(e, &from, amount, &token); + } + + fn can_transfer(e: &Env, from: Address, _to: Address, amount: i128, token: Address) -> bool { + lockup::can_transfer(e, &from, amount, &token) + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + true + } + + fn name(e: &Env) -> String { + module_name(e, "InitialLockupPeriodModule") + } + + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-initial-lockup-period/src/lib.rs b/examples/rwa-initial-lockup-period/src/lib.rs index da4fa20d0..b9aa4d5f0 100644 --- a/examples/rwa-initial-lockup-period/src/lib.rs +++ b/examples/rwa-initial-lockup-period/src/lib.rs @@ -1,228 +1,3 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, vec, Address, Env, String, Vec}; -use stellar_tokens::rwa::compliance::{ - modules::{ - initial_lockup_period::{ - storage::{ - get_internal_balance, get_locks, get_lockup_period, get_total_locked, - set_internal_balance, set_locks, set_lockup_period, set_total_locked, - }, - InitialLockupPeriod, LockedTokens, LockupPeriodSet, - }, - storage::{ - add_i128_or_panic, set_compliance_address, sub_i128_or_panic, verify_required_hooks, - ComplianceModuleStorageKey, - }, - }, - ComplianceHook, -}; - -#[contracttype] -enum DataKey { - Admin, -} - -#[contract] -pub struct InitialLockupPeriodContract; - -fn set_admin(e: &Env, admin: &Address) { - e.storage().instance().set(&DataKey::Admin, admin); -} - -fn get_admin(e: &Env) -> Address { - e.storage().instance().get(&DataKey::Admin).expect("admin must be set") -} - -fn require_module_admin_or_compliance_auth(e: &Env) { - if let Some(compliance) = - e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) - { - compliance.require_auth(); - } else { - get_admin(e).require_auth(); - } -} - -fn calculate_unlocked_amount(e: &Env, locks: &Vec) -> i128 { - let now = e.ledger().timestamp(); - let mut unlocked = 0i128; - for i in 0..locks.len() { - let lock = locks.get(i).unwrap(); - if lock.release_timestamp <= now { - unlocked = add_i128_or_panic(e, unlocked, lock.amount); - } - } - unlocked -} - -fn update_locked_tokens(e: &Env, token: &Address, wallet: &Address, mut amount_to_consume: i128) { - let locks = get_locks(e, token, wallet); - let now = e.ledger().timestamp(); - let mut new_locks = Vec::new(e); - let mut consumed_total = 0i128; - - for i in 0..locks.len() { - let lock = locks.get(i).unwrap(); - if amount_to_consume > 0 && lock.release_timestamp <= now { - if amount_to_consume >= lock.amount { - amount_to_consume = sub_i128_or_panic(e, amount_to_consume, lock.amount); - consumed_total = add_i128_or_panic(e, consumed_total, lock.amount); - } else { - consumed_total = add_i128_or_panic(e, consumed_total, amount_to_consume); - new_locks.push_back(LockedTokens { - amount: sub_i128_or_panic(e, lock.amount, amount_to_consume), - release_timestamp: lock.release_timestamp, - }); - amount_to_consume = 0; - } - } else { - new_locks.push_back(lock); - } - } - - set_locks(e, token, wallet, &new_locks); - - let total_locked = get_total_locked(e, token, wallet); - set_total_locked(e, token, wallet, sub_i128_or_panic(e, total_locked, consumed_total)); -} - -#[contractimpl] -impl InitialLockupPeriodContract { - pub fn __constructor(e: &Env, admin: Address) { - set_admin(e, &admin); - } -} - -#[contractimpl(contracttrait)] -impl InitialLockupPeriod for InitialLockupPeriodContract { - fn set_lockup_period(e: &Env, token: Address, lockup_seconds: u64) { - require_module_admin_or_compliance_auth(e); - set_lockup_period(e, &token, lockup_seconds); - LockupPeriodSet { token, lockup_seconds }.publish(e); - } - - fn pre_set_lockup_state( - e: &Env, - token: Address, - wallet: Address, - balance: i128, - locks: Vec, - ) { - require_module_admin_or_compliance_auth(e); - stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, balance); - - let mut total_locked = 0i128; - for i in 0..locks.len() { - let lock = locks.get(i).unwrap(); - stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount( - e, - lock.amount, - ); - total_locked = add_i128_or_panic(e, total_locked, lock.amount); - } - - assert!( - total_locked <= balance, - "InitialLockupPeriodModule: total locked amount cannot exceed balance" - ); - - set_internal_balance(e, &token, &wallet, balance); - set_locks(e, &token, &wallet, &locks); - set_total_locked(e, &token, &wallet, total_locked); - } - - fn required_hooks(e: &Env) -> Vec { - vec![ - e, - ComplianceHook::CanTransfer, - ComplianceHook::Created, - ComplianceHook::Transferred, - ComplianceHook::Destroyed, - ] - } - - fn verify_hook_wiring(e: &Env) { - verify_required_hooks(e, Self::required_hooks(e)); - } - - fn on_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) { - require_module_admin_or_compliance_auth(e); - stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, amount); - - let total_locked = get_total_locked(e, &token, &from); - - if total_locked > 0 { - let pre_balance = get_internal_balance(e, &token, &from); - let pre_free = pre_balance - total_locked; - - if amount > pre_free.max(0) { - let to_consume = amount - pre_free.max(0); - update_locked_tokens(e, &token, &from, to_consume); - } - } - - let from_bal = get_internal_balance(e, &token, &from); - set_internal_balance(e, &token, &from, sub_i128_or_panic(e, from_bal, amount)); - - let to_bal = get_internal_balance(e, &token, &to); - set_internal_balance(e, &token, &to, add_i128_or_panic(e, to_bal, amount)); - } - - fn on_created(e: &Env, to: Address, amount: i128, token: Address) { - require_module_admin_or_compliance_auth(e); - stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, amount); - - let period = get_lockup_period(e, &token); - if period > 0 { - let mut locks = get_locks(e, &token, &to); - locks.push_back(LockedTokens { - amount, - release_timestamp: e.ledger().timestamp().saturating_add(period), - }); - set_locks(e, &token, &to, &locks); - - let total = get_total_locked(e, &token, &to); - set_total_locked(e, &token, &to, add_i128_or_panic(e, total, amount)); - } - - let current = get_internal_balance(e, &token, &to); - set_internal_balance(e, &token, &to, add_i128_or_panic(e, current, amount)); - } - - fn on_destroyed(e: &Env, from: Address, amount: i128, token: Address) { - require_module_admin_or_compliance_auth(e); - stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, amount); - - let total_locked = get_total_locked(e, &token, &from); - - if total_locked > 0 { - let pre_balance = get_internal_balance(e, &token, &from); - let mut free_amount = pre_balance - total_locked; - - if free_amount < amount { - let locks = get_locks(e, &token, &from); - free_amount += calculate_unlocked_amount(e, &locks); - } - - assert!( - free_amount >= amount, - "InitialLockupPeriodModule: insufficient unlocked balance for burn" - ); - - let pre_free = pre_balance - total_locked; - if amount > pre_free.max(0) { - let to_consume = amount - pre_free.max(0); - update_locked_tokens(e, &token, &from, to_consume); - } - } - - let current = get_internal_balance(e, &token, &from); - set_internal_balance(e, &token, &from, sub_i128_or_panic(e, current, amount)); - } - - fn set_compliance_address(e: &Env, compliance: Address) { - get_admin(e).require_auth(); - set_compliance_address(e, &compliance); - } -} +pub mod contract; diff --git a/examples/rwa-max-balance/README.md b/examples/rwa-max-balance/README.md deleted file mode 100644 index 193ebfa64..000000000 --- a/examples/rwa-max-balance/README.md +++ /dev/null @@ -1,62 +0,0 @@ -# Max Balance Module - -Concrete deployable example of the `MaxBalance` compliance module for Stellar -RWA tokens. - -## What it enforces - -This module tracks balances per investor identity, not per wallet, and enforces -a maximum balance cap for each token. - -Because the accounting is identity-based, the module must be configured with an -Identity Registry Storage (IRS) contract for each token it serves. - -## How it stays in sync - -The module maintains internal per-identity balances and therefore must be wired -to all of the hooks it depends on: - -- `CanTransfer` -- `CanCreate` -- `Transferred` -- `Created` -- `Destroyed` - -After those hooks are registered, `verify_hook_wiring()` must be called once so -the module marks itself as armed before mint and transfer validation starts. - -## Authorization model - -This example uses the bootstrap-admin pattern introduced in this port: - -- The constructor stores a one-time `admin` -- Before `set_compliance_address`, configuration calls require that admin's - auth -- After `set_compliance_address`, privileged calls require auth from the bound - Compliance contract -- `set_compliance_address` itself remains a one-time admin action - -This allows the module to be seeded and configured from the CLI before handing -control to Compliance. - -## Main entrypoints - -- `__constructor(admin)` initializes the bootstrap admin -- `set_identity_registry_storage(token, irs)` stores the IRS address for a - token -- `set_max_balance(token, max)` configures the per-identity cap -- `pre_set_module_state(token, identity, balance)` seeds an identity balance -- `batch_pre_set_module_state(token, identities, balances)` seeds many - identity balances -- `required_hooks()` returns the required hook set -- `verify_hook_wiring()` marks the module as armed after registration -- `set_compliance_address(compliance)` performs the one-time handoff to the - Compliance contract - -## Notes - -- Storage is token-scoped, so one deployed module can be reused across many - tokens -- Transfers between two wallets that resolve to the same identity do not change - the tracked balance distribution -- A configured max of `0` behaves as "no cap" diff --git a/examples/rwa-max-balance/src/contract.rs b/examples/rwa-max-balance/src/contract.rs new file mode 100644 index 000000000..32cf25a75 --- /dev/null +++ b/examples/rwa-max-balance/src/contract.rs @@ -0,0 +1,125 @@ +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::{ + modules::{ + max_balance::storage as max_balance, + storage::{ + get_compliance_address, module_name, set_compliance_address, set_irs_address, + ComplianceModuleStorageKey, + }, + ComplianceModule, + }, + ComplianceHook, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct MaxBalanceContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl MaxBalanceContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } + + pub fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { + require_module_admin_or_compliance_auth(e); + set_irs_address(e, &token, &irs); + } + + pub fn set_max_balance(e: &Env, token: Address, max: i128) { + require_module_admin_or_compliance_auth(e); + max_balance::configure_max_balance(e, &token, max); + } + + pub fn pre_set_identity_balance(e: &Env, token: Address, identity: Address, balance: i128) { + require_module_admin_or_compliance_auth(e); + max_balance::pre_set_identity_balance(e, &token, &identity, balance); + } + + pub fn batch_pre_set_identity_balances( + e: &Env, + token: Address, + identities: Vec
, + balances: Vec, + ) { + require_module_admin_or_compliance_auth(e); + max_balance::batch_pre_set_identity_balances(e, &token, &identities, &balances); + } + + pub fn get_max_balance(e: &Env, token: Address) -> i128 { + max_balance::get_max_balance(e, &token) + } + + pub fn get_investor_balance(e: &Env, token: Address, identity: Address) -> i128 { + max_balance::get_id_balance(e, &token, &identity) + } + + pub fn required_hooks(e: &Env) -> Vec { + max_balance::required_hooks(e) + } + + pub fn verify_hook_wiring(e: &Env) { + max_balance::verify_hook_wiring(e); + } +} + +#[contractimpl(contracttrait)] +impl ComplianceModule for MaxBalanceContract { + fn on_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + max_balance::on_transfer(e, &from, &to, amount, &token); + } + + fn on_created(e: &Env, to: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + max_balance::on_created(e, &to, amount, &token); + } + + fn on_destroyed(e: &Env, from: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + max_balance::on_destroyed(e, &from, amount, &token); + } + + fn can_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) -> bool { + max_balance::can_transfer(e, &from, &to, amount, &token) + } + + fn can_create(e: &Env, to: Address, amount: i128, token: Address) -> bool { + max_balance::can_create(e, &to, amount, &token) + } + + fn name(e: &Env) -> String { + module_name(e, "MaxBalanceModule") + } + + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-max-balance/src/lib.rs b/examples/rwa-max-balance/src/lib.rs index 16352fde6..b9aa4d5f0 100644 --- a/examples/rwa-max-balance/src/lib.rs +++ b/examples/rwa-max-balance/src/lib.rs @@ -1,167 +1,3 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, vec, Address, Env, String, Vec}; -use stellar_tokens::rwa::compliance::{ - modules::{ - max_balance::{ - storage::{get_id_balance, get_max_balance, set_id_balance, set_max_balance}, - IDBalancePreSet, MaxBalance, MaxBalanceSet, - }, - storage::{ - add_i128_or_panic, get_irs_client, set_compliance_address, set_irs_address, - sub_i128_or_panic, verify_required_hooks, ComplianceModuleStorageKey, - }, - }, - ComplianceHook, -}; - -#[contracttype] -enum DataKey { - Admin, -} - -#[contract] -pub struct MaxBalanceContract; - -fn set_admin(e: &Env, admin: &Address) { - e.storage().instance().set(&DataKey::Admin, admin); -} - -fn get_admin(e: &Env) -> Address { - e.storage().instance().get(&DataKey::Admin).expect("admin must be set") -} - -fn require_module_admin_or_compliance_auth(e: &Env) { - if let Some(compliance) = - e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) - { - compliance.require_auth(); - } else { - get_admin(e).require_auth(); - } -} - -#[contractimpl] -impl MaxBalanceContract { - pub fn __constructor(e: &Env, admin: Address) { - set_admin(e, &admin); - } -} - -#[contractimpl(contracttrait)] -impl MaxBalance for MaxBalanceContract { - fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { - require_module_admin_or_compliance_auth(e); - set_irs_address(e, &token, &irs); - } - - fn set_max_balance(e: &Env, token: Address, max: i128) { - require_module_admin_or_compliance_auth(e); - stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, max); - set_max_balance(e, &token, max); - MaxBalanceSet { token, max_balance: max }.publish(e); - } - - fn pre_set_module_state(e: &Env, token: Address, identity: Address, balance: i128) { - require_module_admin_or_compliance_auth(e); - stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, balance); - set_id_balance(e, &token, &identity, balance); - IDBalancePreSet { token, identity, balance }.publish(e); - } - - fn batch_pre_set_module_state( - e: &Env, - token: Address, - identities: Vec
, - balances: Vec, - ) { - require_module_admin_or_compliance_auth(e); - assert!( - identities.len() == balances.len(), - "MaxBalanceModule: identities and balances length mismatch" - ); - for i in 0..identities.len() { - let id = identities.get(i).unwrap(); - let bal = balances.get(i).unwrap(); - stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, bal); - set_id_balance(e, &token, &id, bal); - IDBalancePreSet { token: token.clone(), identity: id, balance: bal }.publish(e); - } - } - - fn required_hooks(e: &Env) -> Vec { - vec![ - e, - ComplianceHook::CanTransfer, - ComplianceHook::CanCreate, - ComplianceHook::Transferred, - ComplianceHook::Created, - ComplianceHook::Destroyed, - ] - } - - fn verify_hook_wiring(e: &Env) { - verify_required_hooks(e, Self::required_hooks(e)); - } - - fn on_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) { - require_module_admin_or_compliance_auth(e); - stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, amount); - - let irs = get_irs_client(e, &token); - let from_id = irs.stored_identity(&from); - let to_id = irs.stored_identity(&to); - - if from_id == to_id { - return; - } - - let from_balance = get_id_balance(e, &token, &from_id); - let to_balance = get_id_balance(e, &token, &to_id); - let new_to_balance = add_i128_or_panic(e, to_balance, amount); - - let max = get_max_balance(e, &token); - assert!( - max == 0 || new_to_balance <= max, - "MaxBalanceModule: recipient identity balance exceeds max" - ); - - set_id_balance(e, &token, &from_id, sub_i128_or_panic(e, from_balance, amount)); - set_id_balance(e, &token, &to_id, new_to_balance); - } - - fn on_created(e: &Env, to: Address, amount: i128, token: Address) { - require_module_admin_or_compliance_auth(e); - stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, amount); - - let irs = get_irs_client(e, &token); - let to_id = irs.stored_identity(&to); - - let current = get_id_balance(e, &token, &to_id); - let new_balance = add_i128_or_panic(e, current, amount); - - let max = get_max_balance(e, &token); - assert!( - max == 0 || new_balance <= max, - "MaxBalanceModule: recipient identity balance exceeds max after mint" - ); - - set_id_balance(e, &token, &to_id, new_balance); - } - - fn on_destroyed(e: &Env, from: Address, amount: i128, token: Address) { - require_module_admin_or_compliance_auth(e); - stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, amount); - - let irs = get_irs_client(e, &token); - let from_id = irs.stored_identity(&from); - - let current = get_id_balance(e, &token, &from_id); - set_id_balance(e, &token, &from_id, sub_i128_or_panic(e, current, amount)); - } - - fn set_compliance_address(e: &Env, compliance: Address) { - get_admin(e).require_auth(); - set_compliance_address(e, &compliance); - } -} +pub mod contract; diff --git a/examples/rwa-supply-limit/README.md b/examples/rwa-supply-limit/README.md deleted file mode 100644 index a589cfd70..000000000 --- a/examples/rwa-supply-limit/README.md +++ /dev/null @@ -1,61 +0,0 @@ -# Supply Limit Module - -Concrete deployable example of the `SupplyLimit` compliance module for Stellar -RWA tokens. - -## What it enforces - -This module caps the total amount of tokens that may be minted for a given -token contract. - -It keeps an internal supply counter and checks that each mint would stay within -the configured per-token limit. - -## How it stays in sync - -The module maintains internal supply state and therefore must be wired to all -of the hooks it depends on: - -- `CanCreate` -- `Created` -- `Destroyed` - -After those hooks are registered, `verify_hook_wiring()` must be called once so -the module marks itself as armed before mint validation starts. - -## Authorization model - -This example uses the bootstrap-admin pattern introduced in this port: - -- The constructor stores a one-time `admin` -- Before `set_compliance_address`, configuration calls require that admin's - auth -- After `set_compliance_address`, privileged calls require auth from the bound - Compliance contract -- `set_compliance_address` itself remains a one-time admin action - -This allows the module to be configured from the CLI before handing control to -Compliance. - -## Main entrypoints - -- `__constructor(admin)` initializes the bootstrap admin -- `set_supply_limit(token, limit)` sets the per-token cap -- `pre_set_internal_supply(token, supply)` seeds tracked supply when wiring the - module after historical minting -- `get_supply_limit(token)` reads the configured cap -- `get_internal_supply(token)` reads the tracked internal supply -- `required_hooks()` returns the required hook set -- `verify_hook_wiring()` marks the module as armed after registration -- `set_compliance_address(compliance)` performs the one-time handoff to the - Compliance contract - -## Notes - -- Storage is token-scoped, so one deployed module can be reused across many - tokens -- A configured limit of `0` behaves as "no cap" -- If the module is attached after a token already has minted supply, seed the - existing amount with `pre_set_internal_supply` before relying on `can_create` -- The internal supply is updated only through the registered `Created` and - `Destroyed` hooks diff --git a/examples/rwa-supply-limit/src/contract.rs b/examples/rwa-supply-limit/src/contract.rs new file mode 100644 index 000000000..dbbbba0b9 --- /dev/null +++ b/examples/rwa-supply-limit/src/contract.rs @@ -0,0 +1,112 @@ +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::{ + modules::{ + storage::{ + get_compliance_address, module_name, set_compliance_address, ComplianceModuleStorageKey, + }, + supply_limit::storage as supply_limit, + ComplianceModule, + }, + ComplianceHook, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct SupplyLimitContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl SupplyLimitContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } + + pub fn set_supply_limit(e: &Env, token: Address, limit: i128) { + require_module_admin_or_compliance_auth(e); + supply_limit::configure_supply_limit(e, &token, limit); + } + + pub fn pre_set_supply(e: &Env, token: Address, supply: i128) { + require_module_admin_or_compliance_auth(e); + supply_limit::pre_set_supply(e, &token, supply); + } + + pub fn get_supply_limit(e: &Env, token: Address) -> i128 { + supply_limit::get_supply_limit(e, &token) + } + + pub fn get_internal_supply(e: &Env, token: Address) -> i128 { + supply_limit::get_internal_supply(e, &token) + } + + pub fn required_hooks(e: &Env) -> Vec { + supply_limit::required_hooks(e) + } + + pub fn verify_hook_wiring(e: &Env) { + supply_limit::verify_hook_wiring(e); + } +} + +#[contractimpl(contracttrait)] +impl ComplianceModule for SupplyLimitContract { + fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {} + + fn on_created(e: &Env, _to: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + supply_limit::on_created(e, amount, &token); + } + + fn on_destroyed(e: &Env, _from: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + supply_limit::on_destroyed(e, amount, &token); + } + + fn can_transfer( + _e: &Env, + _from: Address, + _to: Address, + _amount: i128, + _token: Address, + ) -> bool { + true + } + + fn can_create(e: &Env, _to: Address, amount: i128, token: Address) -> bool { + supply_limit::can_create(e, amount, &token) + } + + fn name(e: &Env) -> String { + module_name(e, "SupplyLimitModule") + } + + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-supply-limit/src/lib.rs b/examples/rwa-supply-limit/src/lib.rs index 2250b7026..b9aa4d5f0 100644 --- a/examples/rwa-supply-limit/src/lib.rs +++ b/examples/rwa-supply-limit/src/lib.rs @@ -1,102 +1,3 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, vec, Address, Env, String, Vec}; -use stellar_tokens::rwa::compliance::{ - modules::{ - storage::{ - add_i128_or_panic, set_compliance_address, sub_i128_or_panic, verify_required_hooks, - ComplianceModuleStorageKey, - }, - supply_limit::{ - storage::{ - get_internal_supply, get_supply_limit, set_internal_supply, set_supply_limit, - }, - SupplyLimit, SupplyLimitSet, - }, - }, - ComplianceHook, -}; - -#[contracttype] -enum DataKey { - Admin, -} - -#[contract] -pub struct SupplyLimitContract; - -fn set_admin(e: &Env, admin: &Address) { - e.storage().instance().set(&DataKey::Admin, admin); -} - -fn get_admin(e: &Env) -> Address { - e.storage().instance().get(&DataKey::Admin).expect("admin must be set") -} - -fn require_module_admin_or_compliance_auth(e: &Env) { - if let Some(compliance) = - e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) - { - compliance.require_auth(); - } else { - get_admin(e).require_auth(); - } -} - -#[contractimpl] -impl SupplyLimitContract { - pub fn __constructor(e: &Env, admin: Address) { - set_admin(e, &admin); - } -} - -#[contractimpl(contracttrait)] -impl SupplyLimit for SupplyLimitContract { - fn set_supply_limit(e: &Env, token: Address, limit: i128) { - require_module_admin_or_compliance_auth(e); - stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, limit); - set_supply_limit(e, &token, limit); - SupplyLimitSet { token, limit }.publish(e); - } - - fn pre_set_internal_supply(e: &Env, token: Address, supply: i128) { - require_module_admin_or_compliance_auth(e); - stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, supply); - set_internal_supply(e, &token, supply); - } - - fn get_supply_limit(e: &Env, token: Address) -> i128 { - get_supply_limit(e, &token) - } - - fn get_internal_supply(e: &Env, token: Address) -> i128 { - get_internal_supply(e, &token) - } - - fn required_hooks(e: &Env) -> Vec { - vec![e, ComplianceHook::CanCreate, ComplianceHook::Created, ComplianceHook::Destroyed] - } - - fn verify_hook_wiring(e: &Env) { - verify_required_hooks(e, Self::required_hooks(e)); - } - - fn on_created(e: &Env, _to: Address, amount: i128, token: Address) { - require_module_admin_or_compliance_auth(e); - stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, amount); - let current = get_internal_supply(e, &token); - set_internal_supply(e, &token, add_i128_or_panic(e, current, amount)); - } - - fn on_destroyed(e: &Env, _from: Address, amount: i128, token: Address) { - require_module_admin_or_compliance_auth(e); - stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, amount); - let current = get_internal_supply(e, &token); - set_internal_supply(e, &token, sub_i128_or_panic(e, current, amount)); - } - - fn set_compliance_address(e: &Env, compliance: Address) { - get_admin(e).require_auth(); - set_compliance_address(e, &compliance); - } -} +pub mod contract; diff --git a/examples/rwa-time-transfers-limits/README.md b/examples/rwa-time-transfers-limits/README.md deleted file mode 100644 index 6377ab122..000000000 --- a/examples/rwa-time-transfers-limits/README.md +++ /dev/null @@ -1,71 +0,0 @@ -# Time Transfers Limits Module - -Concrete deployable example of the `TimeTransfersLimits` compliance module for -Stellar RWA tokens. - -## What it enforces - -This module limits the amount an investor identity may transfer within one or -more configured time windows. - -Limits are tracked per identity, not per wallet, so the module must be -configured with an Identity Registry Storage (IRS) contract for each token it -serves. - -Each limit is defined by: - -- `limit_time`: the window size in seconds -- `limit_value`: the maximum transferable amount during that window - -This example allows up to four active limits per token. - -## How it stays in sync - -The module maintains transfer counters and therefore must be wired to all of -the hooks it depends on: - -- `CanTransfer` -- `Transferred` - -After those hooks are registered, `verify_hook_wiring()` must be called once so -the module marks itself as armed before transfer validation starts. - -## Authorization model - -This example uses the bootstrap-admin pattern introduced in this port: - -- The constructor stores a one-time `admin` -- Before `set_compliance_address`, configuration calls require that admin's - auth -- After `set_compliance_address`, privileged calls require auth from the bound - Compliance contract -- `set_compliance_address` itself remains a one-time admin action - -This allows the module to be configured from the CLI before handing control to -Compliance. - -## Main entrypoints - -- `__constructor(admin)` initializes the bootstrap admin -- `set_identity_registry_storage(token, irs)` stores the IRS address for a - token -- `set_time_transfer_limit(token, limit)` adds or replaces a limit window -- `batch_set_time_transfer_limit(token, limits)` updates multiple windows -- `remove_time_transfer_limit(token, limit_time)` removes a window -- `batch_remove_time_transfer_limit(token, limit_times)` removes many windows -- `pre_set_transfer_counter(token, identity, limit_time, counter)` seeds an - in-flight rolling window when attaching the module after recent transfers -- `required_hooks()` returns the required hook set -- `verify_hook_wiring()` marks the module as armed after registration -- `set_compliance_address(compliance)` performs the one-time handoff to the - Compliance contract - -## Notes - -- Storage is token-scoped, so one deployed module can be reused across many - tokens -- Counter resets are driven by ledger timestamps -- If the module is attached after transfers have already occurred inside an - active window, seed the relevant identity counters before relying on - `can_transfer` -- Only outgoing transfer volume is tracked; mint and burn hooks are not used diff --git a/examples/rwa-time-transfers-limits/src/contract.rs b/examples/rwa-time-transfers-limits/src/contract.rs new file mode 100644 index 000000000..d1d559a4b --- /dev/null +++ b/examples/rwa-time-transfers-limits/src/contract.rs @@ -0,0 +1,125 @@ +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::{ + modules::{ + storage::{ + get_compliance_address, module_name, set_compliance_address, ComplianceModuleStorageKey, + }, + time_transfers_limits::{storage as ttl, Limit, TransferCounter}, + ComplianceModule, + }, + ComplianceHook, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct TimeTransfersLimitsContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl TimeTransfersLimitsContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } + + pub fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { + require_module_admin_or_compliance_auth(e); + ttl::configure_irs(e, &token, &irs); + } + + pub fn set_time_transfer_limit(e: &Env, token: Address, limit: Limit) { + require_module_admin_or_compliance_auth(e); + ttl::set_time_transfer_limit(e, &token, &limit); + } + + pub fn batch_set_time_transfer_limit(e: &Env, token: Address, limits: Vec) { + require_module_admin_or_compliance_auth(e); + ttl::batch_set_time_transfer_limit(e, &token, &limits); + } + + pub fn remove_time_transfer_limit(e: &Env, token: Address, limit_time: u64) { + require_module_admin_or_compliance_auth(e); + ttl::remove_time_transfer_limit(e, &token, limit_time); + } + + pub fn batch_remove_time_transfer_limit(e: &Env, token: Address, limit_times: Vec) { + require_module_admin_or_compliance_auth(e); + ttl::batch_remove_time_transfer_limit(e, &token, &limit_times); + } + + pub fn pre_set_transfer_counter( + e: &Env, + token: Address, + identity: Address, + limit_time: u64, + counter: TransferCounter, + ) { + require_module_admin_or_compliance_auth(e); + ttl::pre_set_transfer_counter(e, &token, &identity, limit_time, &counter); + } + + pub fn get_time_transfer_limits(e: &Env, token: Address) -> Vec { + ttl::get_limits(e, &token) + } + + pub fn required_hooks(e: &Env) -> Vec { + ttl::required_hooks(e) + } + + pub fn verify_hook_wiring(e: &Env) { + ttl::verify_hook_wiring(e); + } +} + +#[contractimpl(contracttrait)] +impl ComplianceModule for TimeTransfersLimitsContract { + fn on_transfer(e: &Env, from: Address, _to: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + ttl::on_transfer(e, &from, amount, &token); + } + + fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {} + + fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {} + + fn can_transfer(e: &Env, from: Address, _to: Address, amount: i128, token: Address) -> bool { + ttl::can_transfer(e, &from, amount, &token) + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + true + } + + fn name(e: &Env) -> String { + module_name(e, "TimeTransfersLimitsModule") + } + + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-time-transfers-limits/src/lib.rs b/examples/rwa-time-transfers-limits/src/lib.rs index 88ed4ae88..b9aa4d5f0 100644 --- a/examples/rwa-time-transfers-limits/src/lib.rs +++ b/examples/rwa-time-transfers-limits/src/lib.rs @@ -1,202 +1,3 @@ #![no_std] -use soroban_sdk::{ - contract, contractimpl, contracttype, panic_with_error, vec, Address, Env, String, Vec, -}; -use stellar_tokens::rwa::compliance::{ - modules::{ - storage::{ - add_i128_or_panic, get_irs_client, set_compliance_address, set_irs_address, - verify_required_hooks, ComplianceModuleStorageKey, - }, - time_transfers_limits::{ - storage::{get_counter, get_limits, set_counter, set_limits}, - Limit, TimeTransferLimitRemoved, TimeTransferLimitUpdated, TimeTransfersLimits, - TransferCounter, - }, - ComplianceModuleError, - }, - ComplianceHook, -}; - -const MAX_LIMITS_PER_TOKEN: u32 = 4; - -#[contracttype] -enum DataKey { - Admin, -} - -#[contract] -pub struct TimeTransfersLimitsContract; - -fn set_admin(e: &Env, admin: &Address) { - e.storage().instance().set(&DataKey::Admin, admin); -} - -fn get_admin(e: &Env) -> Address { - e.storage().instance().get(&DataKey::Admin).expect("admin must be set") -} - -fn require_module_admin_or_compliance_auth(e: &Env) { - if let Some(compliance) = - e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) - { - compliance.require_auth(); - } else { - get_admin(e).require_auth(); - } -} - -fn is_counter_finished(e: &Env, token: &Address, identity: &Address, limit_time: u64) -> bool { - let counter = get_counter(e, token, identity, limit_time); - counter.timer <= e.ledger().timestamp() -} - -fn reset_counter_if_needed(e: &Env, token: &Address, identity: &Address, limit_time: u64) { - if is_counter_finished(e, token, identity, limit_time) { - let counter = - TransferCounter { value: 0, timer: e.ledger().timestamp().saturating_add(limit_time) }; - set_counter(e, token, identity, limit_time, &counter); - } -} - -fn increase_counters(e: &Env, token: &Address, identity: &Address, value: i128) { - let limits = get_limits(e, token); - for limit in limits.iter() { - reset_counter_if_needed(e, token, identity, limit.limit_time); - let mut counter = get_counter(e, token, identity, limit.limit_time); - counter.value = add_i128_or_panic(e, counter.value, value); - set_counter(e, token, identity, limit.limit_time, &counter); - } -} - -#[contractimpl] -impl TimeTransfersLimitsContract { - pub fn __constructor(e: &Env, admin: Address) { - set_admin(e, &admin); - } -} - -#[contractimpl(contracttrait)] -impl TimeTransfersLimits for TimeTransfersLimitsContract { - fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { - require_module_admin_or_compliance_auth(e); - set_irs_address(e, &token, &irs); - } - - fn set_time_transfer_limit(e: &Env, token: Address, limit: Limit) { - require_module_admin_or_compliance_auth(e); - assert!(limit.limit_time > 0, "limit_time must be greater than zero"); - stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount( - e, - limit.limit_value, - ); - let mut limits = get_limits(e, &token); - - let mut replaced = false; - for i in 0..limits.len() { - let current = limits.get(i).expect("limit exists"); - if current.limit_time == limit.limit_time { - limits.set(i, limit.clone()); - replaced = true; - break; - } - } - - if !replaced { - if limits.len() >= MAX_LIMITS_PER_TOKEN { - panic_with_error!(e, ComplianceModuleError::TooManyLimits); - } - limits.push_back(limit.clone()); - } - - set_limits(e, &token, &limits); - TimeTransferLimitUpdated { token, limit }.publish(e); - } - - fn batch_set_time_transfer_limit(e: &Env, token: Address, limits: Vec) { - require_module_admin_or_compliance_auth(e); - for limit in limits.iter() { - Self::set_time_transfer_limit(e, token.clone(), limit); - } - } - - fn remove_time_transfer_limit(e: &Env, token: Address, limit_time: u64) { - require_module_admin_or_compliance_auth(e); - let mut limits = get_limits(e, &token); - - let mut found = false; - for i in 0..limits.len() { - let current = limits.get(i).expect("limit exists"); - if current.limit_time == limit_time { - limits.remove(i); - found = true; - break; - } - } - - if !found { - panic_with_error!(e, ComplianceModuleError::MissingLimit); - } - - set_limits(e, &token, &limits); - TimeTransferLimitRemoved { token, limit_time }.publish(e); - } - - fn batch_remove_time_transfer_limit(e: &Env, token: Address, limit_times: Vec) { - require_module_admin_or_compliance_auth(e); - for lt in limit_times.iter() { - Self::remove_time_transfer_limit(e, token.clone(), lt); - } - } - - fn pre_set_transfer_counter( - e: &Env, - token: Address, - identity: Address, - limit_time: u64, - counter: TransferCounter, - ) { - require_module_admin_or_compliance_auth(e); - stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount( - e, - counter.value, - ); - assert!(limit_time > 0, "limit_time must be greater than zero"); - - let mut found = false; - for limit in get_limits(e, &token).iter() { - if limit.limit_time == limit_time { - found = true; - break; - } - } - - if !found { - panic_with_error!(e, ComplianceModuleError::MissingLimit); - } - - set_counter(e, &token, &identity, limit_time, &counter); - } - - fn required_hooks(e: &Env) -> Vec { - vec![e, ComplianceHook::CanTransfer, ComplianceHook::Transferred] - } - - fn verify_hook_wiring(e: &Env) { - verify_required_hooks(e, Self::required_hooks(e)); - } - - fn on_transfer(e: &Env, from: Address, _to: Address, amount: i128, token: Address) { - require_module_admin_or_compliance_auth(e); - stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, amount); - let irs = get_irs_client(e, &token); - let from_id = irs.stored_identity(&from); - increase_counters(e, &token, &from_id, amount); - } - - fn set_compliance_address(e: &Env, compliance: Address) { - get_admin(e).require_auth(); - set_compliance_address(e, &compliance); - } -} +pub mod contract; diff --git a/examples/rwa-transfer-restrict/README.md b/examples/rwa-transfer-restrict/README.md deleted file mode 100644 index a8283c44b..000000000 --- a/examples/rwa-transfer-restrict/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# Transfer Restrict Module - -Concrete deployable example of the `TransferRestrict` compliance module for -Stellar RWA tokens. - -## What it enforces - -This module maintains a per-token address allowlist for transfers. - -It follows the T-REX semantics implemented by the library trait: - -- if the sender is allowlisted, the transfer passes -- otherwise, the recipient must be allowlisted - -The module is token-scoped, so one deployment can serve many tokens. - -## Authorization model - -This example uses the bootstrap-admin pattern introduced in this port: - -- The constructor stores a one-time `admin` -- Before `set_compliance_address`, allowlist management requires that admin's - auth -- After `set_compliance_address`, the same configuration calls require auth - from the bound Compliance contract -- `set_compliance_address` itself remains a one-time admin action - -This lets the module be configured from the CLI before it is locked to the -Compliance contract. - -## Main entrypoints - -- `__constructor(admin)` initializes the bootstrap admin -- `allow_user(token, user)` adds an address to the transfer allowlist -- `disallow_user(token, user)` removes an address from the transfer allowlist -- `batch_allow_users(token, users)` updates multiple entries -- `batch_disallow_users(token, users)` removes multiple entries -- `is_user_allowed(token, user)` reads the current allowlist state -- `set_compliance_address(compliance)` performs the one-time handoff to the - Compliance contract - -## Notes - -- This module validates transfers through the `CanTransfer` hook -- It does not depend on IRS or other identity infrastructure -- In the deploy example, the admin address is pre-allowlisted before binding so - the happy-path transfer checks can succeed diff --git a/examples/rwa-transfer-restrict/src/contract.rs b/examples/rwa-transfer-restrict/src/contract.rs new file mode 100644 index 000000000..b69043c63 --- /dev/null +++ b/examples/rwa-transfer-restrict/src/contract.rs @@ -0,0 +1,95 @@ +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::modules::{ + storage::{ + get_compliance_address, module_name, set_compliance_address, ComplianceModuleStorageKey, + }, + transfer_restrict::storage as transfer_restrict, + ComplianceModule, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct TransferRestrictContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl TransferRestrictContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } + + pub fn allow_user(e: &Env, token: Address, user: Address) { + require_module_admin_or_compliance_auth(e); + transfer_restrict::allow_user(e, &token, &user); + } + + pub fn disallow_user(e: &Env, token: Address, user: Address) { + require_module_admin_or_compliance_auth(e); + transfer_restrict::disallow_user(e, &token, &user); + } + + pub fn batch_allow_users(e: &Env, token: Address, users: Vec
) { + require_module_admin_or_compliance_auth(e); + transfer_restrict::batch_allow_users(e, &token, &users); + } + + pub fn batch_disallow_users(e: &Env, token: Address, users: Vec
) { + require_module_admin_or_compliance_auth(e); + transfer_restrict::batch_disallow_users(e, &token, &users); + } + + pub fn is_user_allowed(e: &Env, token: Address, user: Address) -> bool { + transfer_restrict::is_user_allowed(e, &token, &user) + } +} + +#[contractimpl(contracttrait)] +impl ComplianceModule for TransferRestrictContract { + fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {} + + fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {} + + fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {} + + fn can_transfer(e: &Env, from: Address, to: Address, _amount: i128, token: Address) -> bool { + transfer_restrict::can_transfer(e, &from, &to, &token) + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + true + } + + fn name(e: &Env) -> String { + module_name(e, "TransferRestrictModule") + } + + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-transfer-restrict/src/lib.rs b/examples/rwa-transfer-restrict/src/lib.rs index 49084164a..b9aa4d5f0 100644 --- a/examples/rwa-transfer-restrict/src/lib.rs +++ b/examples/rwa-transfer-restrict/src/lib.rs @@ -1,83 +1,3 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; -use stellar_tokens::rwa::compliance::modules::{ - storage::{set_compliance_address, ComplianceModuleStorageKey}, - transfer_restrict::{ - storage::{is_user_allowed, remove_user_allowed, set_user_allowed}, - TransferRestrict, UserAllowed, UserDisallowed, - }, -}; - -#[contracttype] -enum DataKey { - Admin, -} - -#[contract] -pub struct TransferRestrictContract; - -fn set_admin(e: &Env, admin: &Address) { - e.storage().instance().set(&DataKey::Admin, admin); -} - -fn get_admin(e: &Env) -> Address { - e.storage().instance().get(&DataKey::Admin).expect("admin must be set") -} - -fn require_module_admin_or_compliance_auth(e: &Env) { - if let Some(compliance) = - e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) - { - compliance.require_auth(); - } else { - get_admin(e).require_auth(); - } -} - -#[contractimpl] -impl TransferRestrictContract { - pub fn __constructor(e: &Env, admin: Address) { - set_admin(e, &admin); - } -} - -#[contractimpl(contracttrait)] -impl TransferRestrict for TransferRestrictContract { - fn allow_user(e: &Env, token: Address, user: Address) { - require_module_admin_or_compliance_auth(e); - set_user_allowed(e, &token, &user); - UserAllowed { token, user }.publish(e); - } - - fn disallow_user(e: &Env, token: Address, user: Address) { - require_module_admin_or_compliance_auth(e); - remove_user_allowed(e, &token, &user); - UserDisallowed { token, user }.publish(e); - } - - fn batch_allow_users(e: &Env, token: Address, users: Vec
) { - require_module_admin_or_compliance_auth(e); - for user in users.iter() { - set_user_allowed(e, &token, &user); - UserAllowed { token: token.clone(), user }.publish(e); - } - } - - fn batch_disallow_users(e: &Env, token: Address, users: Vec
) { - require_module_admin_or_compliance_auth(e); - for user in users.iter() { - remove_user_allowed(e, &token, &user); - UserDisallowed { token: token.clone(), user }.publish(e); - } - } - - fn is_user_allowed(e: &Env, token: Address, user: Address) -> bool { - is_user_allowed(e, &token, &user) - } - - fn set_compliance_address(e: &Env, compliance: Address) { - get_admin(e).require_auth(); - set_compliance_address(e, &compliance); - } -} +pub mod contract; diff --git a/packages/tokens/src/rwa/compliance/modules/country_allow/mod.rs b/packages/tokens/src/rwa/compliance/modules/country_allow/mod.rs index fe6cdda69..1da53f7f2 100644 --- a/packages/tokens/src/rwa/compliance/modules/country_allow/mod.rs +++ b/packages/tokens/src/rwa/compliance/modules/country_allow/mod.rs @@ -7,14 +7,10 @@ //! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/CountryAllowModule.sol pub mod storage; +#[cfg(test)] +mod test; -use soroban_sdk::{contractevent, contracttrait, Address, Env, String, Vec}; -use storage::{is_country_allowed, remove_country_allowed, set_country_allowed}; - -use super::storage::{ - country_code, get_compliance_address, get_irs_country_data_entries, module_name, - set_irs_address, -}; +use soroban_sdk::{contractevent, Address}; /// Emitted when a country is added to the allowlist. #[contractevent] @@ -33,423 +29,3 @@ pub struct CountryUnallowed { pub token: Address, pub country: u32, } - -/// Country allowlist compliance trait. -/// -/// Provides default implementations for maintaining a per-token country -/// allowlist and validating transfers/mints against it via the Identity -/// Registry Storage. -#[contracttrait] -pub trait CountryAllow { - /// Sets the Identity Registry Storage contract address for `token`. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - /// * `token` - The token this IRS applies to. - /// * `irs` - The IRS contract address. - /// - /// # Authorization - /// - /// Requires compliance contract authorization. - fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { - get_compliance_address(e).require_auth(); - set_irs_address(e, &token, &irs); - } - - /// Adds a country to the allowlist for `token`. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - /// * `token` - The token address. - /// * `country` - The ISO 3166-1 numeric country code to allow. - /// - /// # Authorization - /// - /// Requires compliance contract authorization. - /// - /// # Events - /// - /// Emits [`CountryAllowed`]. - fn add_allowed_country(e: &Env, token: Address, country: u32) { - get_compliance_address(e).require_auth(); - set_country_allowed(e, &token, country); - CountryAllowed { token, country }.publish(e); - } - - /// Removes a country from the allowlist for `token`. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - /// * `token` - The token address. - /// * `country` - The ISO 3166-1 numeric country code to remove. - /// - /// # Authorization - /// - /// Requires compliance contract authorization. - /// - /// # Events - /// - /// Emits [`CountryUnallowed`]. - fn remove_allowed_country(e: &Env, token: Address, country: u32) { - get_compliance_address(e).require_auth(); - remove_country_allowed(e, &token, country); - CountryUnallowed { token, country }.publish(e); - } - - /// Adds multiple countries to the allowlist in a single call. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - /// * `token` - The token address. - /// * `countries` - The country codes to allow. - /// - /// # Authorization - /// - /// Requires compliance contract authorization. - /// - /// # Events - /// - /// Emits [`CountryAllowed`] for each country added. - fn batch_allow_countries(e: &Env, token: Address, countries: Vec) { - get_compliance_address(e).require_auth(); - for country in countries.iter() { - set_country_allowed(e, &token, country); - CountryAllowed { token: token.clone(), country }.publish(e); - } - } - - /// Removes multiple countries from the allowlist in a single call. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - /// * `token` - The token address. - /// * `countries` - The country codes to remove. - /// - /// # Authorization - /// - /// Requires compliance contract authorization. - /// - /// # Events - /// - /// Emits [`CountryUnallowed`] for each country removed. - fn batch_disallow_countries(e: &Env, token: Address, countries: Vec) { - get_compliance_address(e).require_auth(); - for country in countries.iter() { - remove_country_allowed(e, &token, country); - CountryUnallowed { token: token.clone(), country }.publish(e); - } - } - - /// Returns whether `country` is on the allowlist for `token`. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - /// * `token` - The token address. - /// * `country` - The ISO 3166-1 numeric country code. - fn is_country_allowed(e: &Env, token: Address, country: u32) -> bool { - is_country_allowed(e, &token, country) - } - - /// No-op — this module does not track transfer state. - fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {} - - /// No-op — this module does not track mint state. - fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {} - - /// No-op — this module does not track burn state. - fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {} - - /// Checks whether `to` has at least one allowed country in the IRS. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - /// * `_from` - The sender (unused). - /// * `to` - The recipient whose country data is checked. - /// * `_amount` - The transfer amount (unused). - /// * `token` - The token address. - /// - /// # Returns - /// - /// `true` if the recipient has at least one allowed country, `false` - /// otherwise. - /// - /// # Cross-Contract Calls - /// - /// Calls the IRS to resolve country data for `to`. - fn can_transfer(e: &Env, _from: Address, to: Address, _amount: i128, token: Address) -> bool { - let entries = get_irs_country_data_entries(e, &token, &to); - for entry in entries.iter() { - if is_country_allowed(e, &token, country_code(&entry.country)) { - return true; - } - } - false - } - - /// Delegates to [`can_transfer`](CountryAllow::can_transfer) — same - /// country check applies to mints. - fn can_create(e: &Env, to: Address, amount: i128, token: Address) -> bool { - Self::can_transfer(e, to.clone(), to, amount, token) - } - - /// Returns the module name for identification. - fn name(e: &Env) -> String { - module_name(e, "CountryAllowModule") - } - - /// Returns the compliance contract address. - fn get_compliance_address(e: &Env) -> Address { - get_compliance_address(e) - } - - /// Sets the compliance contract address (one-time only). - /// - /// Implementers must gate this entrypoint with bootstrap-admin auth before - /// delegating to - /// [`storage::set_compliance_address`](super::storage::set_compliance_address). - /// - /// - /// # Panics - /// - /// Panics if the compliance address has already been set. - fn set_compliance_address(e: &Env, compliance: Address); -} - -#[cfg(test)] -mod test { - extern crate std; - - use soroban_sdk::{ - contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal, - Val, Vec, - }; - - use super::*; - use crate::rwa::{ - identity_registry_storage::{ - CountryData, CountryDataManager, CountryRelation, IdentityRegistryStorage, - IndividualCountryRelation, OrganizationCountryRelation, - }, - utils::token_binder::TokenBinder, - }; - - #[contract] - struct MockIRSContract; - - #[contracttype] - #[derive(Clone)] - enum MockIRSStorageKey { - Identity(Address), - CountryEntries(Address), - } - - #[contractimpl] - impl TokenBinder for MockIRSContract { - fn linked_tokens(e: &Env) -> Vec
{ - Vec::new(e) - } - - fn bind_token(_e: &Env, _token: Address, _operator: Address) { - unreachable!("bind_token is not used in these tests"); - } - - fn unbind_token(_e: &Env, _token: Address, _operator: Address) { - unreachable!("unbind_token is not used in these tests"); - } - } - - #[contractimpl] - impl IdentityRegistryStorage for MockIRSContract { - fn add_identity( - _e: &Env, - _account: Address, - _identity: Address, - _country_data_list: Vec, - _operator: Address, - ) { - unreachable!("add_identity is not used in these tests"); - } - - fn remove_identity(_e: &Env, _account: Address, _operator: Address) { - unreachable!("remove_identity is not used in these tests"); - } - - fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { - unreachable!("modify_identity is not used in these tests"); - } - - fn recover_identity( - _e: &Env, - _old_account: Address, - _new_account: Address, - _operator: Address, - ) { - unreachable!("recover_identity is not used in these tests"); - } - - fn stored_identity(e: &Env, account: Address) -> Address { - e.storage() - .persistent() - .get(&MockIRSStorageKey::Identity(account.clone())) - .unwrap_or(account) - } - } - - #[contractimpl] - impl CountryDataManager for MockIRSContract { - fn add_country_data_entries( - _e: &Env, - _account: Address, - _country_data_list: Vec, - _operator: Address, - ) { - unreachable!("add_country_data_entries is not used in these tests"); - } - - fn modify_country_data( - _e: &Env, - _account: Address, - _index: u32, - _country_data: Val, - _operator: Address, - ) { - unreachable!("modify_country_data is not used in these tests"); - } - - fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { - unreachable!("delete_country_data is not used in these tests"); - } - - fn get_country_data_entries(e: &Env, account: Address) -> Vec { - let entries: Vec = e - .storage() - .persistent() - .get(&MockIRSStorageKey::CountryEntries(account)) - .unwrap_or_else(|| Vec::new(e)); - - Vec::from_iter(e, entries.iter().map(|entry| entry.into_val(e))) - } - } - - #[contractimpl] - impl MockIRSContract { - pub fn set_country_data_entries(e: &Env, account: Address, entries: Vec) { - e.storage().persistent().set(&MockIRSStorageKey::CountryEntries(account), &entries); - } - } - - #[contract] - struct TestCountryAllowContract; - - #[contractimpl(contracttrait)] - impl CountryAllow for TestCountryAllowContract { - fn set_compliance_address(_e: &Env, _compliance: Address) { - unreachable!("set_compliance_address is not used in these tests"); - } - } - - fn individual_country(code: u32) -> CountryData { - CountryData { - country: CountryRelation::Individual(IndividualCountryRelation::Residence(code)), - metadata: None, - } - } - - fn organization_country(code: u32) -> CountryData { - CountryData { - country: CountryRelation::Organization( - OrganizationCountryRelation::OperatingJurisdiction(code), - ), - metadata: None, - } - } - - #[test] - fn can_transfer_and_create_allow_when_any_country_matches() { - let e = Env::default(); - let module_id = e.register(TestCountryAllowContract, ()); - let irs_id = e.register(MockIRSContract, ()); - let irs = MockIRSContractClient::new(&e, &irs_id); - let token = Address::generate(&e); - let from = Address::generate(&e); - let to = Address::generate(&e); - - irs.set_country_data_entries( - &to, - &vec![&e, individual_country(250), organization_country(276)], - ); - - e.as_contract(&module_id, || { - set_irs_address(&e, &token, &irs_id); - set_country_allowed(&e, &token, 276); - - assert!(::can_transfer( - &e, - from.clone(), - to.clone(), - 100, - token.clone(), - )); - assert!(::can_create( - &e, - to.clone(), - 100, - token.clone(), - )); - }); - } - - #[test] - fn can_transfer_and_create_reject_when_no_country_matches() { - let e = Env::default(); - let module_id = e.register(TestCountryAllowContract, ()); - let irs_id = e.register(MockIRSContract, ()); - let irs = MockIRSContractClient::new(&e, &irs_id); - let token = Address::generate(&e); - let from = Address::generate(&e); - let empty_to = Address::generate(&e); - let disallowed_to = Address::generate(&e); - - irs.set_country_data_entries(&disallowed_to, &vec![&e, individual_country(250)]); - - e.as_contract(&module_id, || { - set_irs_address(&e, &token, &irs_id); - set_country_allowed(&e, &token, 276); - - assert!(!::can_transfer( - &e, - from.clone(), - empty_to.clone(), - 100, - token.clone(), - )); - assert!(!::can_create( - &e, - empty_to, - 100, - token.clone(), - )); - - assert!(!::can_transfer( - &e, - from.clone(), - disallowed_to.clone(), - 100, - token.clone(), - )); - assert!(!::can_create( - &e, - disallowed_to, - 100, - token.clone(), - )); - }); - } -} diff --git a/packages/tokens/src/rwa/compliance/modules/country_allow/storage.rs b/packages/tokens/src/rwa/compliance/modules/country_allow/storage.rs index 767ca0a14..91b748145 100644 --- a/packages/tokens/src/rwa/compliance/modules/country_allow/storage.rs +++ b/packages/tokens/src/rwa/compliance/modules/country_allow/storage.rs @@ -1,6 +1,10 @@ -use soroban_sdk::{contracttype, Address, Env}; +use soroban_sdk::{contracttype, Address, Env, Vec}; -use crate::rwa::compliance::modules::{MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD}; +use super::{CountryAllowed, CountryUnallowed}; +use crate::rwa::compliance::modules::{ + storage::{country_code, get_irs_country_data_entries}, + MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD, +}; #[contracttype] #[derive(Clone)] @@ -9,6 +13,8 @@ pub enum CountryAllowStorageKey { AllowedCountry(Address, u32), } +// ################## RAW STORAGE ################## + /// Returns whether the given country is on the allowlist for `token`. /// /// # Arguments @@ -27,7 +33,7 @@ pub fn is_country_allowed(e: &Env, token: &Address, country: u32) -> bool { .unwrap_or_default() } -/// Adds a country to the allowlist for `token`. +/// Writes a country's allowed flag to persistent storage. /// /// # Arguments /// @@ -40,7 +46,7 @@ pub fn set_country_allowed(e: &Env, token: &Address, country: u32) { e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); } -/// Removes a country from the allowlist for `token`. +/// Removes a country from the allowlist in persistent storage. /// /// # Arguments /// @@ -52,3 +58,94 @@ pub fn remove_country_allowed(e: &Env, token: &Address, country: u32) { .persistent() .remove(&CountryAllowStorageKey::AllowedCountry(token.clone(), country)); } + +// ################## ACTIONS ################## + +/// Adds a country to the allowlist for `token`. +/// +/// Writes the flag to storage and emits [`CountryAllowed`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code to allow. +pub fn add_allowed_country(e: &Env, token: &Address, country: u32) { + set_country_allowed(e, token, country); + CountryAllowed { token: token.clone(), country }.publish(e); +} + +/// Removes a country from the allowlist for `token`. +/// +/// Deletes the flag from storage and emits [`CountryUnallowed`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code to remove. +pub fn remove_allowed_country(e: &Env, token: &Address, country: u32) { + remove_country_allowed(e, token, country); + CountryUnallowed { token: token.clone(), country }.publish(e); +} + +/// Adds multiple countries to the allowlist in a single call. +/// +/// Emits [`CountryAllowed`] for each country added. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `countries` - The country codes to allow. +pub fn batch_allow_countries(e: &Env, token: &Address, countries: &Vec) { + for country in countries.iter() { + set_country_allowed(e, token, country); + CountryAllowed { token: token.clone(), country }.publish(e); + } +} + +/// Removes multiple countries from the allowlist in a single call. +/// +/// Emits [`CountryUnallowed`] for each country removed. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `countries` - The country codes to remove. +pub fn batch_disallow_countries(e: &Env, token: &Address, countries: &Vec) { + for country in countries.iter() { + remove_country_allowed(e, token, country); + CountryUnallowed { token: token.clone(), country }.publish(e); + } +} + +// ################## COMPLIANCE HOOKS ################## + +/// Checks whether `to` has at least one allowed country in the IRS for +/// `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `to` - The recipient whose country data is checked. +/// * `token` - The token address. +/// +/// # Returns +/// +/// `true` if the recipient has at least one allowed country, `false` +/// otherwise. +/// +/// # Cross-Contract Calls +/// +/// Calls the IRS to resolve country data for `to`. +pub fn can_transfer(e: &Env, to: &Address, token: &Address) -> bool { + let entries = get_irs_country_data_entries(e, token, to); + for entry in entries.iter() { + if is_country_allowed(e, token, country_code(&entry.country)) { + return true; + } + } + false +} diff --git a/packages/tokens/src/rwa/compliance/modules/country_allow/test.rs b/packages/tokens/src/rwa/compliance/modules/country_allow/test.rs new file mode 100644 index 000000000..a8b497283 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/country_allow/test.rs @@ -0,0 +1,183 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal, Val, + Vec, +}; + +use super::storage::{can_transfer, set_country_allowed}; +use crate::rwa::{ + compliance::modules::storage::set_irs_address, + identity_registry_storage::{ + CountryData, CountryDataManager, CountryRelation, IdentityRegistryStorage, + IndividualCountryRelation, OrganizationCountryRelation, + }, + utils::token_binder::TokenBinder, +}; + +#[contract] +struct MockIRSContract; + +#[contracttype] +#[derive(Clone)] +enum MockIRSStorageKey { + Identity(Address), + CountryEntries(Address), +} + +#[contractimpl] +impl TokenBinder for MockIRSContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl IdentityRegistryStorage for MockIRSContract { + fn add_identity( + _e: &Env, + _account: Address, + _identity: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_identity is not used in these tests"); + } + + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { + unreachable!("remove_identity is not used in these tests"); + } + + fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { + unreachable!("modify_identity is not used in these tests"); + } + + fn recover_identity( + _e: &Env, + _old_account: Address, + _new_account: Address, + _operator: Address, + ) { + unreachable!("recover_identity is not used in these tests"); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + e.storage() + .persistent() + .get(&MockIRSStorageKey::Identity(account.clone())) + .unwrap_or(account) + } +} + +#[contractimpl] +impl CountryDataManager for MockIRSContract { + fn add_country_data_entries( + _e: &Env, + _account: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_country_data_entries is not used in these tests"); + } + + fn modify_country_data( + _e: &Env, + _account: Address, + _index: u32, + _country_data: Val, + _operator: Address, + ) { + unreachable!("modify_country_data is not used in these tests"); + } + + fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { + unreachable!("delete_country_data is not used in these tests"); + } + + fn get_country_data_entries(e: &Env, account: Address) -> Vec { + let entries: Vec = e + .storage() + .persistent() + .get(&MockIRSStorageKey::CountryEntries(account)) + .unwrap_or_else(|| Vec::new(e)); + + Vec::from_iter(e, entries.iter().map(|entry| entry.into_val(e))) + } +} + +#[contractimpl] +impl MockIRSContract { + pub fn set_country_data_entries(e: &Env, account: Address, entries: Vec) { + e.storage().persistent().set(&MockIRSStorageKey::CountryEntries(account), &entries); + } +} + +#[contract] +struct TestCountryAllowContract; + +fn individual_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Individual(IndividualCountryRelation::Residence(code)), + metadata: None, + } +} + +fn organization_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Organization(OrganizationCountryRelation::OperatingJurisdiction( + code, + )), + metadata: None, + } +} + +#[test] +fn can_transfer_allows_when_any_country_matches() { + let e = Env::default(); + let module_id = e.register(TestCountryAllowContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let to = Address::generate(&e); + + irs.set_country_data_entries( + &to, + &vec![&e, individual_country(250), organization_country(276)], + ); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_country_allowed(&e, &token, 276); + + assert!(can_transfer(&e, &to, &token)); + }); +} + +#[test] +fn can_transfer_rejects_when_no_country_matches() { + let e = Env::default(); + let module_id = e.register(TestCountryAllowContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let empty_to = Address::generate(&e); + let disallowed_to = Address::generate(&e); + + irs.set_country_data_entries(&disallowed_to, &vec![&e, individual_country(250)]); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_country_allowed(&e, &token, 276); + + assert!(!can_transfer(&e, &empty_to, &token)); + assert!(!can_transfer(&e, &disallowed_to, &token)); + }); +} diff --git a/packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs b/packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs index 09f87a301..2cbd355a1 100644 --- a/packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs +++ b/packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs @@ -7,14 +7,10 @@ //! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/CountryRestrictModule.sol pub mod storage; +#[cfg(test)] +mod test; -use soroban_sdk::{contractevent, contracttrait, Address, Env, String, Vec}; -use storage::{is_country_restricted, remove_country_restricted, set_country_restricted}; - -use super::storage::{ - country_code, get_compliance_address, get_irs_country_data_entries, module_name, - set_irs_address, -}; +use soroban_sdk::{contractevent, Address}; /// Emitted when a country is added to the restriction list. #[contractevent] @@ -33,427 +29,3 @@ pub struct CountryUnrestricted { pub token: Address, pub country: u32, } - -/// Country restriction compliance trait. -/// -/// Provides default implementations for maintaining a per-token country -/// restriction list and blocking transfers/mints to recipients from -/// restricted countries via the Identity Registry Storage. -#[contracttrait] -pub trait CountryRestrict { - /// Sets the Identity Registry Storage contract address for `token`. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - /// * `token` - The token this IRS applies to. - /// * `irs` - The IRS contract address. - /// - /// # Authorization - /// - /// Requires compliance contract authorization. - fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { - get_compliance_address(e).require_auth(); - set_irs_address(e, &token, &irs); - } - - /// Adds a country to the restriction list for `token`. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - /// * `token` - The token address. - /// * `country` - The ISO 3166-1 numeric country code to restrict. - /// - /// # Authorization - /// - /// Requires compliance contract authorization. - /// - /// # Events - /// - /// Emits [`CountryRestricted`]. - fn add_country_restriction(e: &Env, token: Address, country: u32) { - get_compliance_address(e).require_auth(); - set_country_restricted(e, &token, country); - CountryRestricted { token, country }.publish(e); - } - - /// Removes a country from the restriction list for `token`. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - /// * `token` - The token address. - /// * `country` - The ISO 3166-1 numeric country code to unrestrict. - /// - /// # Authorization - /// - /// Requires compliance contract authorization. - /// - /// # Events - /// - /// Emits [`CountryUnrestricted`]. - fn remove_country_restriction(e: &Env, token: Address, country: u32) { - get_compliance_address(e).require_auth(); - remove_country_restricted(e, &token, country); - CountryUnrestricted { token, country }.publish(e); - } - - /// Adds multiple countries to the restriction list in a single call. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - /// * `token` - The token address. - /// * `countries` - The country codes to restrict. - /// - /// # Authorization - /// - /// Requires compliance contract authorization. - /// - /// # Events - /// - /// Emits [`CountryRestricted`] for each country added. - fn batch_restrict_countries(e: &Env, token: Address, countries: Vec) { - get_compliance_address(e).require_auth(); - for country in countries.iter() { - set_country_restricted(e, &token, country); - CountryRestricted { token: token.clone(), country }.publish(e); - } - } - - /// Removes multiple countries from the restriction list in a single - /// call. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - /// * `token` - The token address. - /// * `countries` - The country codes to unrestrict. - /// - /// # Authorization - /// - /// Requires compliance contract authorization. - /// - /// # Events - /// - /// Emits [`CountryUnrestricted`] for each country removed. - fn batch_unrestrict_countries(e: &Env, token: Address, countries: Vec) { - get_compliance_address(e).require_auth(); - for country in countries.iter() { - remove_country_restricted(e, &token, country); - CountryUnrestricted { token: token.clone(), country }.publish(e); - } - } - - /// Returns whether `country` is on the restriction list for `token`. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - /// * `token` - The token address. - /// * `country` - The ISO 3166-1 numeric country code. - fn is_country_restricted(e: &Env, token: Address, country: u32) -> bool { - is_country_restricted(e, &token, country) - } - - /// No-op — this module does not track transfer state. - fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {} - - /// No-op — this module does not track mint state. - fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {} - - /// No-op — this module does not track burn state. - fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {} - - /// Checks whether `to` has any restricted country in the IRS. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - /// * `_from` - The sender (unused). - /// * `to` - The recipient whose country data is checked. - /// * `_amount` - The transfer amount (unused). - /// * `token` - The token address. - /// - /// # Returns - /// - /// `false` if the recipient has any restricted country, `true` - /// otherwise. - /// - /// # Cross-Contract Calls - /// - /// Calls the IRS to resolve country data for `to`. - fn can_transfer(e: &Env, _from: Address, to: Address, _amount: i128, token: Address) -> bool { - let entries = get_irs_country_data_entries(e, &token, &to); - for entry in entries.iter() { - if is_country_restricted(e, &token, country_code(&entry.country)) { - return false; - } - } - true - } - - /// Delegates to [`can_transfer`](CountryRestrict::can_transfer) — same - /// country check applies to mints. - fn can_create(e: &Env, to: Address, amount: i128, token: Address) -> bool { - Self::can_transfer(e, to.clone(), to, amount, token) - } - - /// Returns the module name for identification. - fn name(e: &Env) -> String { - module_name(e, "CountryRestrictModule") - } - - /// Returns the compliance contract address. - fn get_compliance_address(e: &Env) -> Address { - get_compliance_address(e) - } - - /// Sets the compliance contract address (one-time only). - /// - /// Implementers must gate this entrypoint with bootstrap-admin auth before - /// delegating to - /// [`storage::set_compliance_address`](super::storage::set_compliance_address). - /// - /// - /// # Panics - /// - /// Panics if the compliance address has already been set. - fn set_compliance_address(e: &Env, compliance: Address); -} - -#[cfg(test)] -mod test { - extern crate std; - - use soroban_sdk::{ - contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal, - Val, Vec, - }; - - use super::*; - use crate::rwa::{ - identity_registry_storage::{ - CountryData, CountryDataManager, CountryRelation, IdentityRegistryStorage, - IndividualCountryRelation, OrganizationCountryRelation, - }, - utils::token_binder::TokenBinder, - }; - - #[contract] - struct MockIRSContract; - - #[contracttype] - #[derive(Clone)] - enum MockIRSStorageKey { - Identity(Address), - CountryEntries(Address), - } - - #[contractimpl] - impl TokenBinder for MockIRSContract { - fn linked_tokens(e: &Env) -> Vec
{ - Vec::new(e) - } - - fn bind_token(_e: &Env, _token: Address, _operator: Address) { - unreachable!("bind_token is not used in these tests"); - } - - fn unbind_token(_e: &Env, _token: Address, _operator: Address) { - unreachable!("unbind_token is not used in these tests"); - } - } - - #[contractimpl] - impl IdentityRegistryStorage for MockIRSContract { - fn add_identity( - _e: &Env, - _account: Address, - _identity: Address, - _country_data_list: Vec, - _operator: Address, - ) { - unreachable!("add_identity is not used in these tests"); - } - - fn remove_identity(_e: &Env, _account: Address, _operator: Address) { - unreachable!("remove_identity is not used in these tests"); - } - - fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { - unreachable!("modify_identity is not used in these tests"); - } - - fn recover_identity( - _e: &Env, - _old_account: Address, - _new_account: Address, - _operator: Address, - ) { - unreachable!("recover_identity is not used in these tests"); - } - - fn stored_identity(e: &Env, account: Address) -> Address { - e.storage() - .persistent() - .get(&MockIRSStorageKey::Identity(account.clone())) - .unwrap_or(account) - } - } - - #[contractimpl] - impl CountryDataManager for MockIRSContract { - fn add_country_data_entries( - _e: &Env, - _account: Address, - _country_data_list: Vec, - _operator: Address, - ) { - unreachable!("add_country_data_entries is not used in these tests"); - } - - fn modify_country_data( - _e: &Env, - _account: Address, - _index: u32, - _country_data: Val, - _operator: Address, - ) { - unreachable!("modify_country_data is not used in these tests"); - } - - fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { - unreachable!("delete_country_data is not used in these tests"); - } - - fn get_country_data_entries(e: &Env, account: Address) -> Vec { - let entries: Vec = e - .storage() - .persistent() - .get(&MockIRSStorageKey::CountryEntries(account)) - .unwrap_or_else(|| Vec::new(e)); - - Vec::from_iter(e, entries.iter().map(|entry| entry.into_val(e))) - } - } - - #[contractimpl] - impl MockIRSContract { - pub fn set_country_data_entries(e: &Env, account: Address, entries: Vec) { - e.storage().persistent().set(&MockIRSStorageKey::CountryEntries(account), &entries); - } - } - - #[contract] - struct TestCountryRestrictContract; - - #[contractimpl(contracttrait)] - impl CountryRestrict for TestCountryRestrictContract { - fn set_compliance_address(_e: &Env, _compliance: Address) { - unreachable!("set_compliance_address is not used in these tests"); - } - } - - fn individual_country(code: u32) -> CountryData { - CountryData { - country: CountryRelation::Individual(IndividualCountryRelation::Residence(code)), - metadata: None, - } - } - - fn organization_country(code: u32) -> CountryData { - CountryData { - country: CountryRelation::Organization( - OrganizationCountryRelation::OperatingJurisdiction(code), - ), - metadata: None, - } - } - - #[test] - fn can_transfer_and_create_reject_when_any_country_is_restricted() { - let e = Env::default(); - let module_id = e.register(TestCountryRestrictContract, ()); - let irs_id = e.register(MockIRSContract, ()); - let irs = MockIRSContractClient::new(&e, &irs_id); - let token = Address::generate(&e); - let from = Address::generate(&e); - let to = Address::generate(&e); - - irs.set_country_data_entries( - &to, - &vec![&e, individual_country(250), organization_country(408)], - ); - - e.as_contract(&module_id, || { - set_irs_address(&e, &token, &irs_id); - set_country_restricted(&e, &token, 408); - - assert!(!::can_transfer( - &e, - from.clone(), - to.clone(), - 100, - token.clone(), - )); - assert!(!::can_create( - &e, - to.clone(), - 100, - token.clone(), - )); - }); - } - - #[test] - fn can_transfer_and_create_allow_when_no_country_is_restricted() { - let e = Env::default(); - let module_id = e.register(TestCountryRestrictContract, ()); - let irs_id = e.register(MockIRSContract, ()); - let irs = MockIRSContractClient::new(&e, &irs_id); - let token = Address::generate(&e); - let from = Address::generate(&e); - let empty_to = Address::generate(&e); - let unrestricted_to = Address::generate(&e); - - irs.set_country_data_entries( - &unrestricted_to, - &vec![&e, individual_country(250), organization_country(276)], - ); - - e.as_contract(&module_id, || { - set_irs_address(&e, &token, &irs_id); - set_country_restricted(&e, &token, 408); - - assert!(::can_transfer( - &e, - from.clone(), - empty_to.clone(), - 100, - token.clone(), - )); - assert!(::can_create( - &e, - empty_to, - 100, - token.clone(), - )); - - assert!(::can_transfer( - &e, - from.clone(), - unrestricted_to.clone(), - 100, - token.clone(), - )); - assert!(::can_create( - &e, - unrestricted_to, - 100, - token.clone(), - )); - }); - } -} diff --git a/packages/tokens/src/rwa/compliance/modules/country_restrict/storage.rs b/packages/tokens/src/rwa/compliance/modules/country_restrict/storage.rs index 5d8f13cb2..632e2321b 100644 --- a/packages/tokens/src/rwa/compliance/modules/country_restrict/storage.rs +++ b/packages/tokens/src/rwa/compliance/modules/country_restrict/storage.rs @@ -1,6 +1,10 @@ -use soroban_sdk::{contracttype, Address, Env}; +use soroban_sdk::{contracttype, Address, Env, Vec}; -use crate::rwa::compliance::modules::{MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD}; +use super::{CountryRestricted, CountryUnrestricted}; +use crate::rwa::compliance::modules::{ + storage::{country_code, get_irs_country_data_entries}, + MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD, +}; #[contracttype] #[derive(Clone)] @@ -9,6 +13,8 @@ pub enum CountryRestrictStorageKey { RestrictedCountry(Address, u32), } +// ################## RAW STORAGE ################## + /// Returns whether the given country is on the restriction list for `token`. /// /// # Arguments @@ -27,7 +33,7 @@ pub fn is_country_restricted(e: &Env, token: &Address, country: u32) -> bool { .unwrap_or_default() } -/// Adds a country to the restriction list for `token`. +/// Writes a country's restricted flag to persistent storage. /// /// # Arguments /// @@ -40,7 +46,7 @@ pub fn set_country_restricted(e: &Env, token: &Address, country: u32) { e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); } -/// Removes a country from the restriction list for `token`. +/// Removes a country from the restriction list in persistent storage. /// /// # Arguments /// @@ -52,3 +58,92 @@ pub fn remove_country_restricted(e: &Env, token: &Address, country: u32) { .persistent() .remove(&CountryRestrictStorageKey::RestrictedCountry(token.clone(), country)); } + +// ################## ACTIONS ################## + +/// Adds a country to the restriction list for `token`. +/// +/// Writes the flag to storage and emits [`CountryRestricted`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code to restrict. +pub fn add_country_restriction(e: &Env, token: &Address, country: u32) { + set_country_restricted(e, token, country); + CountryRestricted { token: token.clone(), country }.publish(e); +} + +/// Removes a country from the restriction list for `token`. +/// +/// Deletes the flag from storage and emits [`CountryUnrestricted`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code to unrestrict. +pub fn remove_country_restriction(e: &Env, token: &Address, country: u32) { + remove_country_restricted(e, token, country); + CountryUnrestricted { token: token.clone(), country }.publish(e); +} + +/// Adds multiple countries to the restriction list in a single call. +/// +/// Emits [`CountryRestricted`] for each country added. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `countries` - The country codes to restrict. +pub fn batch_restrict_countries(e: &Env, token: &Address, countries: &Vec) { + for country in countries.iter() { + set_country_restricted(e, token, country); + CountryRestricted { token: token.clone(), country }.publish(e); + } +} + +/// Removes multiple countries from the restriction list in a single call. +/// +/// Emits [`CountryUnrestricted`] for each country removed. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `countries` - The country codes to unrestrict. +pub fn batch_unrestrict_countries(e: &Env, token: &Address, countries: &Vec) { + for country in countries.iter() { + remove_country_restricted(e, token, country); + CountryUnrestricted { token: token.clone(), country }.publish(e); + } +} + +// ################## COMPLIANCE HOOKS ################## + +/// Checks whether `to` has any restricted country in the IRS for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `to` - The recipient whose country data is checked. +/// * `token` - The token address. +/// +/// # Returns +/// +/// `false` if the recipient has any restricted country, `true` otherwise. +/// +/// # Cross-Contract Calls +/// +/// Calls the IRS to resolve country data for `to`. +pub fn can_transfer(e: &Env, to: &Address, token: &Address) -> bool { + let entries = get_irs_country_data_entries(e, token, to); + for entry in entries.iter() { + if is_country_restricted(e, token, country_code(&entry.country)) { + return false; + } + } + true +} diff --git a/packages/tokens/src/rwa/compliance/modules/country_restrict/test.rs b/packages/tokens/src/rwa/compliance/modules/country_restrict/test.rs new file mode 100644 index 000000000..f09ed9eab --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/country_restrict/test.rs @@ -0,0 +1,186 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal, Val, + Vec, +}; + +use super::storage::{can_transfer, set_country_restricted}; +use crate::rwa::{ + compliance::modules::storage::set_irs_address, + identity_registry_storage::{ + CountryData, CountryDataManager, CountryRelation, IdentityRegistryStorage, + IndividualCountryRelation, OrganizationCountryRelation, + }, + utils::token_binder::TokenBinder, +}; + +#[contract] +struct MockIRSContract; + +#[contracttype] +#[derive(Clone)] +enum MockIRSStorageKey { + Identity(Address), + CountryEntries(Address), +} + +#[contractimpl] +impl TokenBinder for MockIRSContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl IdentityRegistryStorage for MockIRSContract { + fn add_identity( + _e: &Env, + _account: Address, + _identity: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_identity is not used in these tests"); + } + + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { + unreachable!("remove_identity is not used in these tests"); + } + + fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { + unreachable!("modify_identity is not used in these tests"); + } + + fn recover_identity( + _e: &Env, + _old_account: Address, + _new_account: Address, + _operator: Address, + ) { + unreachable!("recover_identity is not used in these tests"); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + e.storage() + .persistent() + .get(&MockIRSStorageKey::Identity(account.clone())) + .unwrap_or(account) + } +} + +#[contractimpl] +impl CountryDataManager for MockIRSContract { + fn add_country_data_entries( + _e: &Env, + _account: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_country_data_entries is not used in these tests"); + } + + fn modify_country_data( + _e: &Env, + _account: Address, + _index: u32, + _country_data: Val, + _operator: Address, + ) { + unreachable!("modify_country_data is not used in these tests"); + } + + fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { + unreachable!("delete_country_data is not used in these tests"); + } + + fn get_country_data_entries(e: &Env, account: Address) -> Vec { + let entries: Vec = e + .storage() + .persistent() + .get(&MockIRSStorageKey::CountryEntries(account)) + .unwrap_or_else(|| Vec::new(e)); + + Vec::from_iter(e, entries.iter().map(|entry| entry.into_val(e))) + } +} + +#[contractimpl] +impl MockIRSContract { + pub fn set_country_data_entries(e: &Env, account: Address, entries: Vec) { + e.storage().persistent().set(&MockIRSStorageKey::CountryEntries(account), &entries); + } +} + +#[contract] +struct TestCountryRestrictContract; + +fn individual_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Individual(IndividualCountryRelation::Residence(code)), + metadata: None, + } +} + +fn organization_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Organization(OrganizationCountryRelation::OperatingJurisdiction( + code, + )), + metadata: None, + } +} + +#[test] +fn can_transfer_rejects_when_any_country_is_restricted() { + let e = Env::default(); + let module_id = e.register(TestCountryRestrictContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let to = Address::generate(&e); + + irs.set_country_data_entries( + &to, + &vec![&e, individual_country(250), organization_country(408)], + ); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_country_restricted(&e, &token, 408); + + assert!(!can_transfer(&e, &to, &token)); + }); +} + +#[test] +fn can_transfer_allows_when_no_country_is_restricted() { + let e = Env::default(); + let module_id = e.register(TestCountryRestrictContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let empty_to = Address::generate(&e); + let unrestricted_to = Address::generate(&e); + + irs.set_country_data_entries( + &unrestricted_to, + &vec![&e, individual_country(250), organization_country(276)], + ); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_country_restricted(&e, &token, 408); + + assert!(can_transfer(&e, &empty_to, &token)); + assert!(can_transfer(&e, &unrestricted_to, &token)); + }); +} diff --git a/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/mod.rs b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/mod.rs index 7261fc452..ed816abfe 100644 --- a/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/mod.rs +++ b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/mod.rs @@ -11,18 +11,8 @@ pub mod storage; #[cfg(test)] mod test; -use soroban_sdk::{contractevent, contracttrait, vec, Address, Env, String, Vec}; +use soroban_sdk::{contractevent, Address}; pub use storage::LockedTokens; -use storage::{ - get_internal_balance, get_locks, get_lockup_period, get_total_locked, set_internal_balance, - set_locks, set_lockup_period, set_total_locked, -}; - -use super::storage::{ - add_i128_or_panic, get_compliance_address, hooks_verified, module_name, - require_non_negative_amount, sub_i128_or_panic, verify_required_hooks, -}; -use crate::rwa::compliance::ComplianceHook; /// Emitted when a token's lockup duration is configured or changed. #[contractevent] @@ -32,243 +22,3 @@ pub struct LockupPeriodSet { pub token: Address, pub lockup_seconds: u64, } - -// ################## HELPERS ################## - -fn calculate_unlocked_amount(e: &Env, locks: &Vec) -> i128 { - let now = e.ledger().timestamp(); - let mut unlocked = 0i128; - for i in 0..locks.len() { - let lock = locks.get(i).unwrap(); - if lock.release_timestamp <= now { - unlocked = add_i128_or_panic(e, unlocked, lock.amount); - } - } - unlocked -} - -fn calculate_total_locked_amount(e: &Env, locks: &Vec) -> i128 { - let mut total = 0i128; - for i in 0..locks.len() { - let lock = locks.get(i).unwrap(); - require_non_negative_amount(e, lock.amount); - total = add_i128_or_panic(e, total, lock.amount); - } - total -} - -fn update_locked_tokens(e: &Env, token: &Address, wallet: &Address, mut amount_to_consume: i128) { - let locks = get_locks(e, token, wallet); - let now = e.ledger().timestamp(); - let mut new_locks = Vec::new(e); - let mut consumed_total = 0i128; - - for i in 0..locks.len() { - let lock = locks.get(i).unwrap(); - if amount_to_consume > 0 && lock.release_timestamp <= now { - if amount_to_consume >= lock.amount { - amount_to_consume = sub_i128_or_panic(e, amount_to_consume, lock.amount); - consumed_total = add_i128_or_panic(e, consumed_total, lock.amount); - } else { - consumed_total = add_i128_or_panic(e, consumed_total, amount_to_consume); - new_locks.push_back(LockedTokens { - amount: sub_i128_or_panic(e, lock.amount, amount_to_consume), - release_timestamp: lock.release_timestamp, - }); - amount_to_consume = 0; - } - } else { - new_locks.push_back(lock); - } - } - - set_locks(e, token, wallet, &new_locks); - - let total_locked = get_total_locked(e, token, wallet); - set_total_locked(e, token, wallet, sub_i128_or_panic(e, total_locked, consumed_total)); -} - -#[contracttrait] -pub trait InitialLockupPeriod { - // ################## QUERY STATE ################## - - fn get_lockup_period(e: &Env, token: Address) -> u64 { - get_lockup_period(e, &token) - } - - fn get_total_locked(e: &Env, token: Address, wallet: Address) -> i128 { - get_total_locked(e, &token, &wallet) - } - - fn get_locked_tokens(e: &Env, token: Address, wallet: Address) -> Vec { - get_locks(e, &token, &wallet) - } - - fn get_internal_balance(e: &Env, token: Address, wallet: Address) -> i128 { - get_internal_balance(e, &token, &wallet) - } - - fn can_transfer(e: &Env, from: Address, _to: Address, amount: i128, token: Address) -> bool { - assert!( - hooks_verified(e), - "InitialLockupPeriodModule: not armed — call verify_hook_wiring() after wiring hooks \ - [CanTransfer, Created, Transferred, Destroyed]" - ); - if amount < 0 { - return false; - } - - let total_locked = get_total_locked(e, &token, &from); - if total_locked == 0 { - return true; - } - - let balance = get_internal_balance(e, &token, &from); - let free = balance - total_locked; - - if free >= amount { - return true; - } - - let locks = get_locks(e, &token, &from); - let unlocked = calculate_unlocked_amount(e, &locks); - (free + unlocked) >= amount - } - - fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { - true - } - - fn name(e: &Env) -> String { - module_name(e, "InitialLockupPeriodModule") - } - - fn get_compliance_address(e: &Env) -> Address { - get_compliance_address(e) - } - - // ################## CHANGE STATE ################## - - fn set_lockup_period(e: &Env, token: Address, lockup_seconds: u64) { - get_compliance_address(e).require_auth(); - set_lockup_period(e, &token, lockup_seconds); - LockupPeriodSet { token, lockup_seconds }.publish(e); - } - - fn pre_set_lockup_state( - e: &Env, - token: Address, - wallet: Address, - balance: i128, - locks: Vec, - ) { - get_compliance_address(e).require_auth(); - require_non_negative_amount(e, balance); - - let total_locked = calculate_total_locked_amount(e, &locks); - assert!( - total_locked <= balance, - "InitialLockupPeriodModule: total locked amount cannot exceed balance" - ); - - set_internal_balance(e, &token, &wallet, balance); - set_locks(e, &token, &wallet, &locks); - set_total_locked(e, &token, &wallet, total_locked); - } - - fn verify_hook_wiring(e: &Env) { - verify_required_hooks(e, Self::required_hooks(e)); - } - - fn on_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) { - get_compliance_address(e).require_auth(); - require_non_negative_amount(e, amount); - - let total_locked = get_total_locked(e, &token, &from); - - if total_locked > 0 { - let pre_balance = get_internal_balance(e, &token, &from); - let pre_free = pre_balance - total_locked; - - if amount > pre_free.max(0) { - let to_consume = amount - pre_free.max(0); - update_locked_tokens(e, &token, &from, to_consume); - } - } - - let from_bal = get_internal_balance(e, &token, &from); - set_internal_balance(e, &token, &from, sub_i128_or_panic(e, from_bal, amount)); - - let to_bal = get_internal_balance(e, &token, &to); - set_internal_balance(e, &token, &to, add_i128_or_panic(e, to_bal, amount)); - } - - fn on_created(e: &Env, to: Address, amount: i128, token: Address) { - get_compliance_address(e).require_auth(); - require_non_negative_amount(e, amount); - - let period = get_lockup_period(e, &token); - if period > 0 { - let mut locks = get_locks(e, &token, &to); - locks.push_back(LockedTokens { - amount, - release_timestamp: e.ledger().timestamp().saturating_add(period), - }); - set_locks(e, &token, &to, &locks); - - let total = get_total_locked(e, &token, &to); - set_total_locked(e, &token, &to, add_i128_or_panic(e, total, amount)); - } - - let current = get_internal_balance(e, &token, &to); - set_internal_balance(e, &token, &to, add_i128_or_panic(e, current, amount)); - } - - fn on_destroyed(e: &Env, from: Address, amount: i128, token: Address) { - get_compliance_address(e).require_auth(); - require_non_negative_amount(e, amount); - - let total_locked = get_total_locked(e, &token, &from); - - if total_locked > 0 { - let pre_balance = get_internal_balance(e, &token, &from); - let mut free_amount = pre_balance - total_locked; - - if free_amount < amount { - let locks = get_locks(e, &token, &from); - free_amount += calculate_unlocked_amount(e, &locks); - } - - assert!( - free_amount >= amount, - "InitialLockupPeriodModule: insufficient unlocked balance for burn" - ); - - let pre_free = pre_balance - total_locked; - if amount > pre_free.max(0) { - let to_consume = amount - pre_free.max(0); - update_locked_tokens(e, &token, &from, to_consume); - } - } - - let current = get_internal_balance(e, &token, &from); - set_internal_balance(e, &token, &from, sub_i128_or_panic(e, current, amount)); - } - - /// Implementers must gate this entrypoint with bootstrap-admin auth before - /// delegating to - /// [`storage::set_compliance_address`](super::storage::set_compliance_address). - fn set_compliance_address(e: &Env, compliance: Address); - - // ################## HELPERS ################## - - fn required_hooks(e: &Env) -> Vec { - vec![ - e, - ComplianceHook::CanTransfer, - ComplianceHook::Created, - ComplianceHook::Transferred, - ComplianceHook::Destroyed, - ] - } -} diff --git a/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/storage.rs b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/storage.rs index 2c9788c8d..3bff8f5ef 100644 --- a/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/storage.rs +++ b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/storage.rs @@ -1,6 +1,16 @@ -use soroban_sdk::{contracttype, Address, Env, Vec}; +use soroban_sdk::{contracttype, vec, Address, Env, Vec}; -use crate::rwa::compliance::modules::{MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD}; +use super::LockupPeriodSet; +use crate::rwa::compliance::{ + modules::{ + storage::{ + add_i128_or_panic, hooks_verified, require_non_negative_amount, sub_i128_or_panic, + verify_required_hooks, + }, + MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD, + }, + ComplianceHook, +}; /// A single mint-created lock entry tracking the locked amount and its /// release time. Mirrors T-REX `LockedTokens { amount, releaseTimestamp }`. @@ -25,6 +35,8 @@ pub enum InitialLockupStorageKey { InternalBalance(Address, Address), } +// ################## RAW STORAGE ################## + /// Returns the lockup period (in seconds) for `token`, or `0` if not set. /// /// # Arguments @@ -150,3 +162,259 @@ pub fn set_internal_balance(e: &Env, token: &Address, wallet: &Address, balance: e.storage().persistent().set(&key, &balance); e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); } + +// ################## HELPERS ################## + +fn calculate_unlocked_amount(e: &Env, locks: &Vec) -> i128 { + let now = e.ledger().timestamp(); + let mut unlocked = 0i128; + for i in 0..locks.len() { + let lock = locks.get(i).unwrap(); + if lock.release_timestamp <= now { + unlocked = add_i128_or_panic(e, unlocked, lock.amount); + } + } + unlocked +} + +fn calculate_total_locked_amount(e: &Env, locks: &Vec) -> i128 { + let mut total = 0i128; + for i in 0..locks.len() { + let lock = locks.get(i).unwrap(); + require_non_negative_amount(e, lock.amount); + total = add_i128_or_panic(e, total, lock.amount); + } + total +} + +fn update_locked_tokens(e: &Env, token: &Address, wallet: &Address, mut amount_to_consume: i128) { + let locks = get_locks(e, token, wallet); + let now = e.ledger().timestamp(); + let mut new_locks = Vec::new(e); + let mut consumed_total = 0i128; + + for i in 0..locks.len() { + let lock = locks.get(i).unwrap(); + if amount_to_consume > 0 && lock.release_timestamp <= now { + if amount_to_consume >= lock.amount { + amount_to_consume = sub_i128_or_panic(e, amount_to_consume, lock.amount); + consumed_total = add_i128_or_panic(e, consumed_total, lock.amount); + } else { + consumed_total = add_i128_or_panic(e, consumed_total, amount_to_consume); + new_locks.push_back(LockedTokens { + amount: sub_i128_or_panic(e, lock.amount, amount_to_consume), + release_timestamp: lock.release_timestamp, + }); + amount_to_consume = 0; + } + } else { + new_locks.push_back(lock); + } + } + + set_locks(e, token, wallet, &new_locks); + + let total_locked = get_total_locked(e, token, wallet); + set_total_locked(e, token, wallet, sub_i128_or_panic(e, total_locked, consumed_total)); +} + +// ################## ACTIONS ################## + +/// Configures the lockup period for `token` and emits [`LockupPeriodSet`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `lockup_seconds` - The lockup duration in seconds. +pub fn configure_lockup_period(e: &Env, token: &Address, lockup_seconds: u64) { + set_lockup_period(e, token, lockup_seconds); + LockupPeriodSet { token: token.clone(), lockup_seconds }.publish(e); +} + +/// Pre-seeds the lockup state for a wallet. Validates that total locked +/// does not exceed balance. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `wallet` - The wallet address. +/// * `balance` - The wallet balance. +/// * `locks` - The lock entries. +pub fn pre_set_lockup_state( + e: &Env, + token: &Address, + wallet: &Address, + balance: i128, + locks: &Vec, +) { + require_non_negative_amount(e, balance); + + let total_locked = calculate_total_locked_amount(e, locks); + assert!( + total_locked <= balance, + "InitialLockupPeriodModule: total locked amount cannot exceed balance" + ); + + set_internal_balance(e, token, wallet, balance); + set_locks(e, token, wallet, locks); + set_total_locked(e, token, wallet, total_locked); +} + +// ################## HOOK WIRING ################## + +/// Returns the set of compliance hooks this module requires. +pub fn required_hooks(e: &Env) -> Vec { + vec![ + e, + ComplianceHook::CanTransfer, + ComplianceHook::Created, + ComplianceHook::Transferred, + ComplianceHook::Destroyed, + ] +} + +/// Cross-calls the compliance contract to verify that this module is +/// registered on all required hooks. +pub fn verify_hook_wiring(e: &Env) { + verify_required_hooks(e, required_hooks(e)); +} + +// ################## COMPLIANCE HOOKS ################## + +/// Updates internal balances and lock tracking after a transfer. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `from` - The sender address. +/// * `to` - The recipient address. +/// * `amount` - The transfer amount. +/// * `token` - The token address. +pub fn on_transfer(e: &Env, from: &Address, to: &Address, amount: i128, token: &Address) { + require_non_negative_amount(e, amount); + + let total_locked = get_total_locked(e, token, from); + + if total_locked > 0 { + let pre_balance = get_internal_balance(e, token, from); + let pre_free = pre_balance - total_locked; + + if amount > pre_free.max(0) { + let to_consume = amount - pre_free.max(0); + update_locked_tokens(e, token, from, to_consume); + } + } + + let from_bal = get_internal_balance(e, token, from); + set_internal_balance(e, token, from, sub_i128_or_panic(e, from_bal, amount)); + + let to_bal = get_internal_balance(e, token, to); + set_internal_balance(e, token, to, add_i128_or_panic(e, to_bal, amount)); +} + +/// Updates internal balance and creates a lock entry after a mint. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `to` - The recipient address. +/// * `amount` - The minted amount. +/// * `token` - The token address. +pub fn on_created(e: &Env, to: &Address, amount: i128, token: &Address) { + require_non_negative_amount(e, amount); + + let period = get_lockup_period(e, token); + if period > 0 { + let mut locks = get_locks(e, token, to); + locks.push_back(LockedTokens { + amount, + release_timestamp: e.ledger().timestamp().saturating_add(period), + }); + set_locks(e, token, to, &locks); + + let total = get_total_locked(e, token, to); + set_total_locked(e, token, to, add_i128_or_panic(e, total, amount)); + } + + let current = get_internal_balance(e, token, to); + set_internal_balance(e, token, to, add_i128_or_panic(e, current, amount)); +} + +/// Updates internal balance and consumes locks after a burn. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `from` - The burner address. +/// * `amount` - The burned amount. +/// * `token` - The token address. +pub fn on_destroyed(e: &Env, from: &Address, amount: i128, token: &Address) { + require_non_negative_amount(e, amount); + + let total_locked = get_total_locked(e, token, from); + + if total_locked > 0 { + let pre_balance = get_internal_balance(e, token, from); + let mut free_amount = pre_balance - total_locked; + + if free_amount < amount { + let locks = get_locks(e, token, from); + free_amount += calculate_unlocked_amount(e, &locks); + } + + assert!( + free_amount >= amount, + "InitialLockupPeriodModule: insufficient unlocked balance for burn" + ); + + let pre_free = pre_balance - total_locked; + if amount > pre_free.max(0) { + let to_consume = amount - pre_free.max(0); + update_locked_tokens(e, token, from, to_consume); + } + } + + let current = get_internal_balance(e, token, from); + set_internal_balance(e, token, from, sub_i128_or_panic(e, current, amount)); +} + +/// Checks whether a transfer is allowed based on lockup restrictions. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `from` - The sender address. +/// * `amount` - The transfer amount. +/// * `token` - The token address. +/// +/// # Returns +/// +/// `true` if the sender has sufficient unlocked balance. +pub fn can_transfer(e: &Env, from: &Address, amount: i128, token: &Address) -> bool { + assert!( + hooks_verified(e), + "InitialLockupPeriodModule: not armed — call verify_hook_wiring() after wiring hooks \ + [CanTransfer, Created, Transferred, Destroyed]" + ); + if amount < 0 { + return false; + } + + let total_locked = get_total_locked(e, token, from); + if total_locked == 0 { + return true; + } + + let balance = get_internal_balance(e, token, from); + let free = balance - total_locked; + + if free >= amount { + return true; + } + + let locks = get_locks(e, token, from); + let unlocked = calculate_unlocked_amount(e, &locks); + (free + unlocked) >= amount +} diff --git a/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/test.rs b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/test.rs index f758b7a97..ec6951c91 100644 --- a/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/test.rs +++ b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/test.rs @@ -4,7 +4,10 @@ use soroban_sdk::{ contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, }; -use super::*; +use super::storage::{ + can_transfer, get_internal_balance, get_total_locked, pre_set_lockup_state, verify_hook_wiring, + LockedTokens, +}; use crate::rwa::{ compliance::{ modules::storage::{hooks_verified, set_compliance_address, ComplianceModuleStorageKey}, @@ -14,14 +17,7 @@ use crate::rwa::{ }; #[contract] -struct TestInitialLockupPeriodContract; - -#[contractimpl(contracttrait)] -impl InitialLockupPeriod for TestInitialLockupPeriodContract { - fn set_compliance_address(_e: &Env, _compliance: Address) { - unreachable!("set_compliance_address is not used in these tests"); - } -} +struct TestModuleContract; fn arm_hooks(e: &Env) { e.storage().instance().set(&ComplianceModuleStorageKey::HooksVerified, &true); @@ -106,7 +102,7 @@ impl MockComplianceContract { #[test] fn verify_hook_wiring_sets_cache_when_registered() { let e = Env::default(); - let module_id = e.register(TestInitialLockupPeriodContract, ()); + let module_id = e.register(TestModuleContract, ()); let compliance_id = e.register(MockComplianceContract, ()); let compliance = MockComplianceContractClient::new(&e, &compliance_id); @@ -122,7 +118,7 @@ fn verify_hook_wiring_sets_cache_when_registered() { e.as_contract(&module_id, || { set_compliance_address(&e, &compliance_id); - ::verify_hook_wiring(&e); + verify_hook_wiring(&e); assert!(hooks_verified(&e)); }); @@ -131,9 +127,8 @@ fn verify_hook_wiring_sets_cache_when_registered() { #[test] fn pre_set_lockup_state_seeds_existing_locked_balance() { let e = Env::default(); - e.mock_all_auths(); - let module_id = e.register(TestInitialLockupPeriodContract, ()); + let module_id = e.register(TestModuleContract, ()); let compliance = Address::generate(&e); let token = Address::generate(&e); let wallet = Address::generate(&e); @@ -142,12 +137,12 @@ fn pre_set_lockup_state_seeds_existing_locked_balance() { set_compliance_address(&e, &compliance); arm_hooks(&e); - ::pre_set_lockup_state( + pre_set_lockup_state( &e, - token.clone(), - wallet.clone(), + &token, + &wallet, 100, - vec![ + &vec![ &e, LockedTokens { amount: 80, @@ -156,35 +151,9 @@ fn pre_set_lockup_state_seeds_existing_locked_balance() { ], ); - assert_eq!( - ::get_internal_balance( - &e, - token.clone(), - wallet.clone(), - ), - 100 - ); - assert_eq!( - ::get_total_locked( - &e, - token.clone(), - wallet.clone(), - ), - 80 - ); - assert!(!::can_transfer( - &e, - wallet.clone(), - Address::generate(&e), - 21, - token.clone(), - )); - assert!(::can_transfer( - &e, - wallet, - Address::generate(&e), - 20, - token, - )); + assert_eq!(get_internal_balance(&e, &token, &wallet), 100); + assert_eq!(get_total_locked(&e, &token, &wallet), 80); + assert!(!can_transfer(&e, &wallet, 21, &token)); + assert!(can_transfer(&e, &wallet, 20, &token)); }); } diff --git a/packages/tokens/src/rwa/compliance/modules/max_balance/mod.rs b/packages/tokens/src/rwa/compliance/modules/max_balance/mod.rs index aa295329f..a20b71d1b 100644 --- a/packages/tokens/src/rwa/compliance/modules/max_balance/mod.rs +++ b/packages/tokens/src/rwa/compliance/modules/max_balance/mod.rs @@ -10,14 +10,7 @@ pub mod storage; #[cfg(test)] mod test; -use soroban_sdk::{contractevent, contracttrait, vec, Address, Env, String, Vec}; -use storage::{get_id_balance, get_max_balance, set_id_balance, set_max_balance}; - -use super::storage::{ - add_i128_or_panic, get_compliance_address, get_irs_client, hooks_verified, module_name, - require_non_negative_amount, set_irs_address, sub_i128_or_panic, verify_required_hooks, -}; -use crate::rwa::compliance::ComplianceHook; +use soroban_sdk::{contractevent, Address}; /// Emitted when a token's per-identity balance cap is configured. #[contractevent] @@ -37,182 +30,3 @@ pub struct IDBalancePreSet { pub identity: Address, pub balance: i128, } - -fn can_increase_identity_balance( - e: &Env, - token: &Address, - identity: &Address, - amount: i128, -) -> bool { - if amount < 0 { - return false; - } - - let max = get_max_balance(e, token); - if max == 0 { - return true; - } - - let current = get_id_balance(e, token, identity); - add_i128_or_panic(e, current, amount) <= max -} - -#[contracttrait] -pub trait MaxBalance { - fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { - get_compliance_address(e).require_auth(); - set_irs_address(e, &token, &irs); - } - - fn set_max_balance(e: &Env, token: Address, max: i128) { - get_compliance_address(e).require_auth(); - require_non_negative_amount(e, max); - set_max_balance(e, &token, max); - MaxBalanceSet { token, max_balance: max }.publish(e); - } - - fn pre_set_module_state(e: &Env, token: Address, identity: Address, balance: i128) { - get_compliance_address(e).require_auth(); - require_non_negative_amount(e, balance); - set_id_balance(e, &token, &identity, balance); - IDBalancePreSet { token, identity, balance }.publish(e); - } - - fn batch_pre_set_module_state( - e: &Env, - token: Address, - identities: Vec
, - balances: Vec, - ) { - get_compliance_address(e).require_auth(); - assert!( - identities.len() == balances.len(), - "MaxBalanceModule: identities and balances length mismatch" - ); - for i in 0..identities.len() { - let id = identities.get(i).unwrap(); - let bal = balances.get(i).unwrap(); - require_non_negative_amount(e, bal); - set_id_balance(e, &token, &id, bal); - IDBalancePreSet { token: token.clone(), identity: id, balance: bal }.publish(e); - } - } - - fn get_investor_balance(e: &Env, token: Address, identity: Address) -> i128 { - get_id_balance(e, &token, &identity) - } - - fn required_hooks(e: &Env) -> Vec { - vec![ - e, - ComplianceHook::CanTransfer, - ComplianceHook::CanCreate, - ComplianceHook::Transferred, - ComplianceHook::Created, - ComplianceHook::Destroyed, - ] - } - - fn verify_hook_wiring(e: &Env) { - verify_required_hooks(e, Self::required_hooks(e)); - } - - fn on_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) { - get_compliance_address(e).require_auth(); - require_non_negative_amount(e, amount); - - let irs = get_irs_client(e, &token); - let from_id = irs.stored_identity(&from); - let to_id = irs.stored_identity(&to); - - if from_id == to_id { - return; - } - - let from_balance = get_id_balance(e, &token, &from_id); - assert!( - can_increase_identity_balance(e, &token, &to_id, amount), - "MaxBalanceModule: recipient identity balance exceeds max" - ); - - let to_balance = get_id_balance(e, &token, &to_id); - let new_to_balance = add_i128_or_panic(e, to_balance, amount); - set_id_balance(e, &token, &from_id, sub_i128_or_panic(e, from_balance, amount)); - set_id_balance(e, &token, &to_id, new_to_balance); - } - - fn on_created(e: &Env, to: Address, amount: i128, token: Address) { - get_compliance_address(e).require_auth(); - require_non_negative_amount(e, amount); - - let irs = get_irs_client(e, &token); - let to_id = irs.stored_identity(&to); - - assert!( - can_increase_identity_balance(e, &token, &to_id, amount), - "MaxBalanceModule: recipient identity balance exceeds max after mint" - ); - - let current = get_id_balance(e, &token, &to_id); - let new_balance = add_i128_or_panic(e, current, amount); - set_id_balance(e, &token, &to_id, new_balance); - } - - fn on_destroyed(e: &Env, from: Address, amount: i128, token: Address) { - get_compliance_address(e).require_auth(); - require_non_negative_amount(e, amount); - - let irs = get_irs_client(e, &token); - let from_id = irs.stored_identity(&from); - - let current = get_id_balance(e, &token, &from_id); - set_id_balance(e, &token, &from_id, sub_i128_or_panic(e, current, amount)); - } - - fn can_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) -> bool { - assert!( - hooks_verified(e), - "MaxBalanceModule: not armed — call verify_hook_wiring() after wiring hooks \ - [CanTransfer, CanCreate, Transferred, Created, Destroyed]" - ); - if amount < 0 { - return false; - } - let irs = get_irs_client(e, &token); - let from_id = irs.stored_identity(&from); - let to_id = irs.stored_identity(&to); - - if from_id == to_id { - return true; - } - - can_increase_identity_balance(e, &token, &to_id, amount) - } - - fn can_create(e: &Env, to: Address, amount: i128, token: Address) -> bool { - assert!( - hooks_verified(e), - "MaxBalanceModule: not armed — call verify_hook_wiring() after wiring hooks \ - [CanTransfer, CanCreate, Transferred, Created, Destroyed]" - ); - if amount < 0 { - return false; - } - let irs = get_irs_client(e, &token); - let to_id = irs.stored_identity(&to); - can_increase_identity_balance(e, &token, &to_id, amount) - } - - fn name(e: &Env) -> String { - module_name(e, "MaxBalanceModule") - } - - fn get_compliance_address(e: &Env) -> Address { - get_compliance_address(e) - } - - /// Implementers must gate this entrypoint with bootstrap-admin auth before - /// delegating to - /// [`storage::set_compliance_address`](super::storage::set_compliance_address). - fn set_compliance_address(e: &Env, compliance: Address); -} diff --git a/packages/tokens/src/rwa/compliance/modules/max_balance/storage.rs b/packages/tokens/src/rwa/compliance/modules/max_balance/storage.rs index 60e6cb997..6a894523b 100644 --- a/packages/tokens/src/rwa/compliance/modules/max_balance/storage.rs +++ b/packages/tokens/src/rwa/compliance/modules/max_balance/storage.rs @@ -1,6 +1,16 @@ -use soroban_sdk::{contracttype, Address, Env}; +use soroban_sdk::{contracttype, vec, Address, Env, Vec}; -use crate::rwa::compliance::modules::{MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD}; +use super::{IDBalancePreSet, MaxBalanceSet}; +use crate::rwa::compliance::{ + modules::{ + storage::{ + add_i128_or_panic, get_irs_client, hooks_verified, require_non_negative_amount, + sub_i128_or_panic, verify_required_hooks, + }, + MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD, + }, + ComplianceHook, +}; #[contracttype] #[derive(Clone)] @@ -11,6 +21,8 @@ pub enum MaxBalanceStorageKey { IDBalance(Address, Address), } +// ################## RAW STORAGE ################## + /// Returns the per-identity balance cap for `token`, or `0` if not set. /// /// # Arguments @@ -73,3 +85,231 @@ pub fn set_id_balance(e: &Env, token: &Address, identity: &Address, balance: i12 e.storage().persistent().set(&key, &balance); e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); } + +// ################## HELPERS ################## + +fn can_increase_identity_balance( + e: &Env, + token: &Address, + identity: &Address, + amount: i128, +) -> bool { + if amount < 0 { + return false; + } + + let max = get_max_balance(e, token); + if max == 0 { + return true; + } + + let current = get_id_balance(e, token, identity); + add_i128_or_panic(e, current, amount) <= max +} + +// ################## ACTIONS ################## + +/// Validates, stores, and emits [`MaxBalanceSet`] for the given cap. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `max` - The maximum balance per identity. +pub fn configure_max_balance(e: &Env, token: &Address, max: i128) { + require_non_negative_amount(e, max); + set_max_balance(e, token, max); + MaxBalanceSet { token: token.clone(), max_balance: max }.publish(e); +} + +/// Pre-seeds the tracked balance for an identity and emits +/// [`IDBalancePreSet`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `identity` - The on-chain identity address. +/// * `balance` - The pre-seeded balance value. +pub fn pre_set_identity_balance(e: &Env, token: &Address, identity: &Address, balance: i128) { + require_non_negative_amount(e, balance); + set_id_balance(e, token, identity, balance); + IDBalancePreSet { token: token.clone(), identity: identity.clone(), balance }.publish(e); +} + +/// Pre-seeds tracked balances for multiple identities. Emits +/// [`IDBalancePreSet`] for each. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `identities` - Identity addresses. +/// * `balances` - Corresponding balance values. +pub fn batch_pre_set_identity_balances( + e: &Env, + token: &Address, + identities: &Vec
, + balances: &Vec, +) { + assert!( + identities.len() == balances.len(), + "MaxBalanceModule: identities and balances length mismatch" + ); + for i in 0..identities.len() { + let id = identities.get(i).unwrap(); + let bal = balances.get(i).unwrap(); + require_non_negative_amount(e, bal); + set_id_balance(e, token, &id, bal); + IDBalancePreSet { token: token.clone(), identity: id, balance: bal }.publish(e); + } +} + +// ################## HOOK WIRING ################## + +/// Returns the set of compliance hooks this module requires. +pub fn required_hooks(e: &Env) -> Vec { + vec![ + e, + ComplianceHook::CanTransfer, + ComplianceHook::CanCreate, + ComplianceHook::Transferred, + ComplianceHook::Created, + ComplianceHook::Destroyed, + ] +} + +/// Cross-calls the compliance contract to verify that this module is +/// registered on all required hooks. +pub fn verify_hook_wiring(e: &Env) { + verify_required_hooks(e, required_hooks(e)); +} + +// ################## COMPLIANCE HOOKS ################## + +/// Updates identity balances after a transfer. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `from` - The sender address. +/// * `to` - The recipient address. +/// * `amount` - The transfer amount. +/// * `token` - The token address. +pub fn on_transfer(e: &Env, from: &Address, to: &Address, amount: i128, token: &Address) { + require_non_negative_amount(e, amount); + + let irs = get_irs_client(e, token); + let from_id = irs.stored_identity(from); + let to_id = irs.stored_identity(to); + + if from_id == to_id { + return; + } + + let from_balance = get_id_balance(e, token, &from_id); + assert!( + can_increase_identity_balance(e, token, &to_id, amount), + "MaxBalanceModule: recipient identity balance exceeds max" + ); + + let to_balance = get_id_balance(e, token, &to_id); + let new_to_balance = add_i128_or_panic(e, to_balance, amount); + set_id_balance(e, token, &from_id, sub_i128_or_panic(e, from_balance, amount)); + set_id_balance(e, token, &to_id, new_to_balance); +} + +/// Updates identity balance after a mint. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `to` - The recipient address. +/// * `amount` - The minted amount. +/// * `token` - The token address. +pub fn on_created(e: &Env, to: &Address, amount: i128, token: &Address) { + require_non_negative_amount(e, amount); + + let irs = get_irs_client(e, token); + let to_id = irs.stored_identity(to); + + assert!( + can_increase_identity_balance(e, token, &to_id, amount), + "MaxBalanceModule: recipient identity balance exceeds max after mint" + ); + + let current = get_id_balance(e, token, &to_id); + let new_balance = add_i128_or_panic(e, current, amount); + set_id_balance(e, token, &to_id, new_balance); +} + +/// Updates identity balance after a burn. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `from` - The burner address. +/// * `amount` - The burned amount. +/// * `token` - The token address. +pub fn on_destroyed(e: &Env, from: &Address, amount: i128, token: &Address) { + require_non_negative_amount(e, amount); + + let irs = get_irs_client(e, token); + let from_id = irs.stored_identity(from); + + let current = get_id_balance(e, token, &from_id); + set_id_balance(e, token, &from_id, sub_i128_or_panic(e, current, amount)); +} + +/// Checks whether a transfer would exceed the recipient identity's +/// balance cap. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `from` - The sender address. +/// * `to` - The recipient address. +/// * `amount` - The transfer amount. +/// * `token` - The token address. +pub fn can_transfer(e: &Env, from: &Address, to: &Address, amount: i128, token: &Address) -> bool { + assert!( + hooks_verified(e), + "MaxBalanceModule: not armed — call verify_hook_wiring() after wiring hooks [CanTransfer, \ + CanCreate, Transferred, Created, Destroyed]" + ); + if amount < 0 { + return false; + } + let irs = get_irs_client(e, token); + let from_id = irs.stored_identity(from); + let to_id = irs.stored_identity(to); + + if from_id == to_id { + return true; + } + + can_increase_identity_balance(e, token, &to_id, amount) +} + +/// Checks whether a mint would exceed the recipient identity's balance +/// cap. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `to` - The recipient address. +/// * `amount` - The mint amount. +/// * `token` - The token address. +pub fn can_create(e: &Env, to: &Address, amount: i128, token: &Address) -> bool { + assert!( + hooks_verified(e), + "MaxBalanceModule: not armed — call verify_hook_wiring() after wiring hooks [CanTransfer, \ + CanCreate, Transferred, Created, Destroyed]" + ); + if amount < 0 { + return false; + } + let irs = get_irs_client(e, token); + let to_id = irs.stored_identity(to); + can_increase_identity_balance(e, token, &to_id, amount) +} diff --git a/packages/tokens/src/rwa/compliance/modules/max_balance/test.rs b/packages/tokens/src/rwa/compliance/modules/max_balance/test.rs index 53281cadb..bf208fb8d 100644 --- a/packages/tokens/src/rwa/compliance/modules/max_balance/test.rs +++ b/packages/tokens/src/rwa/compliance/modules/max_balance/test.rs @@ -4,9 +4,8 @@ use soroban_sdk::{ contract, contractimpl, contracttype, testutils::Address as _, Address, Env, Val, Vec, }; -use super::{ - storage::{set_id_balance, set_max_balance}, - *, +use super::storage::{ + can_create, can_transfer, set_id_balance, set_max_balance, verify_hook_wiring, }; use crate::rwa::{ compliance::{ @@ -196,13 +195,6 @@ impl MockComplianceContract { #[contract] struct TestMaxBalanceContract; -#[contractimpl(contracttrait)] -impl MaxBalance for TestMaxBalanceContract { - fn set_compliance_address(_e: &Env, _compliance: Address) { - unreachable!("set_compliance_address is not used in these tests"); - } -} - fn arm_hooks(e: &Env) { e.storage().instance().set(&ComplianceModuleStorageKey::HooksVerified, &true); } @@ -227,7 +219,7 @@ fn verify_hook_wiring_sets_cache_when_registered() { e.as_contract(&module_id, || { set_compliance_address(&e, &compliance_id); - ::verify_hook_wiring(&e); + verify_hook_wiring(&e); assert!(hooks_verified(&e)); }); @@ -251,18 +243,8 @@ fn can_create_rejects_mint_when_cap_would_be_exceeded() { set_max_balance(&e, &token, 100); set_id_balance(&e, &token, &recipient_identity, 60); - assert!(!::can_create( - &e, - recipient.clone(), - 50, - token.clone(), - )); - assert!(::can_create( - &e, - recipient, - 40, - token.clone(), - )); + assert!(!can_create(&e, &recipient, 50, &token)); + assert!(can_create(&e, &recipient, 40, &token)); }); } @@ -287,20 +269,8 @@ fn can_transfer_checks_distinct_recipient_identity_balance() { set_max_balance(&e, &token, 100); set_id_balance(&e, &token, &recipient_identity, 60); - assert!(!::can_transfer( - &e, - sender.clone(), - recipient.clone(), - 50, - token.clone(), - )); - assert!(::can_transfer( - &e, - sender, - recipient, - 40, - token.clone(), - )); + assert!(!can_transfer(&e, &sender, &recipient, 50, &token)); + assert!(can_transfer(&e, &sender, &recipient, 40, &token)); }); } @@ -321,18 +291,8 @@ fn can_create_allows_without_cap_and_rejects_negative_amount() { arm_hooks(&e); set_id_balance(&e, &token, &recipient_identity, 500); - assert!(::can_create( - &e, - recipient.clone(), - 1_000, - token.clone(), - )); - assert!(!::can_create( - &e, - recipient, - -1, - token.clone(), - )); + assert!(can_create(&e, &recipient, 1_000, &token)); + assert!(!can_create(&e, &recipient, -1, &token)); }); } @@ -346,6 +306,6 @@ fn can_create_rejects_negative_amount_before_requiring_irs() { e.as_contract(&module_id, || { arm_hooks(&e); - assert!(!::can_create(&e, recipient, -1, token,)); + assert!(!can_create(&e, &recipient, -1, &token)); }); } diff --git a/packages/tokens/src/rwa/compliance/modules/supply_limit/mod.rs b/packages/tokens/src/rwa/compliance/modules/supply_limit/mod.rs index d138a002b..6082d490d 100644 --- a/packages/tokens/src/rwa/compliance/modules/supply_limit/mod.rs +++ b/packages/tokens/src/rwa/compliance/modules/supply_limit/mod.rs @@ -9,14 +9,7 @@ pub mod storage; #[cfg(test)] mod test; -use soroban_sdk::{contractevent, contracttrait, vec, Address, Env, String, Vec}; -use storage::{get_internal_supply, get_supply_limit, set_internal_supply, set_supply_limit}; - -use super::storage::{ - add_i128_or_panic, get_compliance_address, hooks_verified, module_name, - require_non_negative_amount, sub_i128_or_panic, verify_required_hooks, -}; -use crate::rwa::compliance::ComplianceHook; +use soroban_sdk::{contractevent, Address}; /// Emitted when a token's supply cap is configured or changed. #[contractevent] @@ -26,91 +19,3 @@ pub struct SupplyLimitSet { pub token: Address, pub limit: i128, } - -#[contracttrait] -pub trait SupplyLimit { - fn set_supply_limit(e: &Env, token: Address, limit: i128) { - get_compliance_address(e).require_auth(); - require_non_negative_amount(e, limit); - set_supply_limit(e, &token, limit); - SupplyLimitSet { token, limit }.publish(e); - } - - fn pre_set_internal_supply(e: &Env, token: Address, supply: i128) { - get_compliance_address(e).require_auth(); - require_non_negative_amount(e, supply); - set_internal_supply(e, &token, supply); - } - - fn get_supply_limit(e: &Env, token: Address) -> i128 { - get_supply_limit(e, &token) - } - - fn get_internal_supply(e: &Env, token: Address) -> i128 { - get_internal_supply(e, &token) - } - - fn required_hooks(e: &Env) -> Vec { - vec![e, ComplianceHook::CanCreate, ComplianceHook::Created, ComplianceHook::Destroyed] - } - - fn verify_hook_wiring(e: &Env) { - verify_required_hooks(e, Self::required_hooks(e)); - } - - fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {} - - fn on_created(e: &Env, _to: Address, amount: i128, token: Address) { - get_compliance_address(e).require_auth(); - require_non_negative_amount(e, amount); - let current = get_internal_supply(e, &token); - set_internal_supply(e, &token, add_i128_or_panic(e, current, amount)); - } - - fn on_destroyed(e: &Env, _from: Address, amount: i128, token: Address) { - get_compliance_address(e).require_auth(); - require_non_negative_amount(e, amount); - let current = get_internal_supply(e, &token); - set_internal_supply(e, &token, sub_i128_or_panic(e, current, amount)); - } - - fn can_transfer( - _e: &Env, - _from: Address, - _to: Address, - _amount: i128, - _token: Address, - ) -> bool { - true - } - - fn can_create(e: &Env, _to: Address, amount: i128, token: Address) -> bool { - assert!( - hooks_verified(e), - "SupplyLimitModule: not armed — call verify_hook_wiring() after wiring hooks \ - [CanCreate, Created, Destroyed]" - ); - if amount < 0 { - return false; - } - let limit = get_supply_limit(e, &token); - if limit == 0 { - return true; - } - let supply = get_internal_supply(e, &token); - add_i128_or_panic(e, supply, amount) <= limit - } - - fn name(e: &Env) -> String { - module_name(e, "SupplyLimitModule") - } - - fn get_compliance_address(e: &Env) -> Address { - get_compliance_address(e) - } - - /// Implementers must gate this entrypoint with bootstrap-admin auth before - /// delegating to - /// [`storage::set_compliance_address`](super::storage::set_compliance_address). - fn set_compliance_address(e: &Env, compliance: Address); -} diff --git a/packages/tokens/src/rwa/compliance/modules/supply_limit/storage.rs b/packages/tokens/src/rwa/compliance/modules/supply_limit/storage.rs index f1ec2f4df..d337e5a94 100644 --- a/packages/tokens/src/rwa/compliance/modules/supply_limit/storage.rs +++ b/packages/tokens/src/rwa/compliance/modules/supply_limit/storage.rs @@ -1,7 +1,15 @@ -use soroban_sdk::{contracttype, panic_with_error, Address, Env}; +use soroban_sdk::{contracttype, panic_with_error, vec, Address, Env, Vec}; -use crate::rwa::compliance::modules::{ - ComplianceModuleError, MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD, +use super::SupplyLimitSet; +use crate::rwa::compliance::{ + modules::{ + storage::{ + add_i128_or_panic, hooks_verified, require_non_negative_amount, sub_i128_or_panic, + verify_required_hooks, + }, + ComplianceModuleError, MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD, + }, + ComplianceHook, }; #[contracttype] @@ -13,6 +21,8 @@ pub enum SupplyLimitStorageKey { InternalSupply(Address), } +// ################## RAW STORAGE ################## + /// Returns the supply limit for `token`, or `0` if not set. /// /// # Arguments @@ -94,3 +104,95 @@ pub fn set_internal_supply(e: &Env, token: &Address, supply: i128) { e.storage().persistent().set(&key, &supply); e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); } + +// ################## ACTIONS ################## + +/// Validates, stores, and emits [`SupplyLimitSet`] for the given cap. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `limit` - The supply cap. +pub fn configure_supply_limit(e: &Env, token: &Address, limit: i128) { + require_non_negative_amount(e, limit); + set_supply_limit(e, token, limit); + SupplyLimitSet { token: token.clone(), limit }.publish(e); +} + +/// Pre-seeds the internal supply counter for a token. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `supply` - The pre-seeded supply value. +pub fn pre_set_supply(e: &Env, token: &Address, supply: i128) { + require_non_negative_amount(e, supply); + set_internal_supply(e, token, supply); +} + +// ################## HOOK WIRING ################## + +/// Returns the set of compliance hooks this module requires. +pub fn required_hooks(e: &Env) -> Vec { + vec![e, ComplianceHook::CanCreate, ComplianceHook::Created, ComplianceHook::Destroyed] +} + +/// Cross-calls the compliance contract to verify that this module is +/// registered on all required hooks. +pub fn verify_hook_wiring(e: &Env) { + verify_required_hooks(e, required_hooks(e)); +} + +// ################## COMPLIANCE HOOKS ################## + +/// Updates the internal supply counter after a mint. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `amount` - The minted amount. +/// * `token` - The token address. +pub fn on_created(e: &Env, amount: i128, token: &Address) { + require_non_negative_amount(e, amount); + let current = get_internal_supply(e, token); + set_internal_supply(e, token, add_i128_or_panic(e, current, amount)); +} + +/// Updates the internal supply counter after a burn. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `amount` - The burned amount. +/// * `token` - The token address. +pub fn on_destroyed(e: &Env, amount: i128, token: &Address) { + require_non_negative_amount(e, amount); + let current = get_internal_supply(e, token); + set_internal_supply(e, token, sub_i128_or_panic(e, current, amount)); +} + +/// Checks whether a mint would exceed the supply cap. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `amount` - The mint amount. +/// * `token` - The token address. +pub fn can_create(e: &Env, amount: i128, token: &Address) -> bool { + assert!( + hooks_verified(e), + "SupplyLimitModule: not armed — call verify_hook_wiring() after wiring hooks [CanCreate, \ + Created, Destroyed]" + ); + if amount < 0 { + return false; + } + let limit = get_supply_limit(e, token); + if limit == 0 { + return true; + } + let supply = get_internal_supply(e, token); + add_i128_or_panic(e, supply, amount) <= limit +} diff --git a/packages/tokens/src/rwa/compliance/modules/supply_limit/test.rs b/packages/tokens/src/rwa/compliance/modules/supply_limit/test.rs index 21018e09e..7047b8bef 100644 --- a/packages/tokens/src/rwa/compliance/modules/supply_limit/test.rs +++ b/packages/tokens/src/rwa/compliance/modules/supply_limit/test.rs @@ -2,7 +2,10 @@ extern crate std; use soroban_sdk::{contract, contractimpl, contracttype, testutils::Address as _, Address, Env}; -use super::*; +use super::storage::{ + can_create, configure_supply_limit, get_internal_supply, on_created, on_destroyed, + pre_set_supply, verify_hook_wiring, +}; use crate::rwa::{ compliance::{ modules::storage::{hooks_verified, set_compliance_address, ComplianceModuleStorageKey}, @@ -90,13 +93,6 @@ impl MockComplianceContract { #[contract] struct TestSupplyLimitContract; -#[contractimpl(contracttrait)] -impl SupplyLimit for TestSupplyLimitContract { - fn set_compliance_address(_e: &Env, _compliance: Address) { - unreachable!("set_compliance_address is not used in these tests"); - } -} - fn arm_hooks(e: &Env) { e.storage().instance().set(&ComplianceModuleStorageKey::HooksVerified, &true); } @@ -115,7 +111,7 @@ fn verify_hook_wiring_sets_cache_when_registered() { e.as_contract(&module_id, || { set_compliance_address(&e, &compliance_id); - ::verify_hook_wiring(&e); + verify_hook_wiring(&e); assert!(hooks_verified(&e)); }); @@ -128,7 +124,7 @@ fn get_supply_limit_returns_zero_when_unconfigured() { let token = Address::generate(&e); e.as_contract(&module_id, || { - assert_eq!(::get_supply_limit(&e, token), 0); + assert_eq!(super::storage::get_supply_limit(&e, &token), 0); }); } @@ -137,75 +133,50 @@ fn can_create_allows_when_limit_is_unset_and_rejects_negative_amount() { let e = Env::default(); let module_id = e.register(TestSupplyLimitContract, ()); let token = Address::generate(&e); - let recipient = Address::generate(&e); e.as_contract(&module_id, || { arm_hooks(&e); - assert!(::can_create( - &e, - recipient.clone(), - 100, - token.clone(), - )); - assert!(!::can_create( - &e, - recipient, - -1, - token.clone(), - )); + assert!(can_create(&e, 100, &token)); + assert!(!can_create(&e, -1, &token)); }); } #[test] fn hooks_update_internal_supply_and_cap_future_mints() { let e = Env::default(); - e.mock_all_auths(); - let module_id = e.register(TestSupplyLimitContract, ()); - let compliance_id = e.register(MockComplianceContract, ()); let token = Address::generate(&e); - let recipient = Address::generate(&e); - let client = TestSupplyLimitContractClient::new(&e, &module_id); e.as_contract(&module_id, || { - set_compliance_address(&e, &compliance_id); arm_hooks(&e); - }); + configure_supply_limit(&e, &token, 100); - client.set_supply_limit(&token, &100); + assert!(can_create(&e, 80, &token)); + on_created(&e, 80, &token); + assert_eq!(get_internal_supply(&e, &token), 80); - assert!(client.can_create(&recipient.clone(), &80, &token)); - client.on_created(&recipient.clone(), &80, &token); - assert_eq!(client.get_internal_supply(&token), 80); + assert!(!can_create(&e, 30, &token)); - assert!(!client.can_create(&recipient.clone(), &30, &token)); - - client.on_destroyed(&recipient.clone(), &20, &token); - assert_eq!(client.get_internal_supply(&token), 60); - assert!(client.can_create(&recipient, &40, &token)); + on_destroyed(&e, 20, &token); + assert_eq!(get_internal_supply(&e, &token), 60); + assert!(can_create(&e, 40, &token)); + }); } #[test] fn pre_set_internal_supply_seeds_existing_supply_for_cap_checks() { let e = Env::default(); - e.mock_all_auths(); - let module_id = e.register(TestSupplyLimitContract, ()); - let compliance_id = e.register(MockComplianceContract, ()); let token = Address::generate(&e); - let recipient = Address::generate(&e); - let client = TestSupplyLimitContractClient::new(&e, &module_id); e.as_contract(&module_id, || { - set_compliance_address(&e, &compliance_id); arm_hooks(&e); - }); + configure_supply_limit(&e, &token, 100); + pre_set_supply(&e, &token, 90); - client.set_supply_limit(&token, &100); - client.pre_set_internal_supply(&token, &90); - - assert_eq!(client.get_internal_supply(&token), 90); - assert!(!client.can_create(&recipient.clone(), &11, &token)); - assert!(client.can_create(&recipient, &10, &token)); + assert_eq!(get_internal_supply(&e, &token), 90); + assert!(!can_create(&e, 11, &token)); + assert!(can_create(&e, 10, &token)); + }); } diff --git a/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/mod.rs b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/mod.rs index 5bc76a97f..49215ef2d 100644 --- a/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/mod.rs +++ b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/mod.rs @@ -10,17 +10,10 @@ pub mod storage; #[cfg(test)] mod test; -use soroban_sdk::{contractevent, contracttrait, panic_with_error, vec, Address, Env, String, Vec}; -use storage::{get_counter, get_limits, set_counter, set_limits}; +use soroban_sdk::{contractevent, Address}; pub use storage::{Limit, TransferCounter}; -use super::storage::{ - add_i128_or_panic, get_compliance_address, get_irs_client, hooks_verified, module_name, - require_non_negative_amount, set_irs_address, verify_required_hooks, -}; -use crate::rwa::compliance::{modules::ComplianceModuleError, ComplianceHook}; - -const MAX_LIMITS_PER_TOKEN: u32 = 4; +pub const MAX_LIMITS_PER_TOKEN: u32 = 4; /// Emitted when a time-window limit is added or updated. #[contractevent] @@ -39,201 +32,3 @@ pub struct TimeTransferLimitRemoved { pub token: Address, pub limit_time: u64, } - -// ################## HELPERS ################## - -fn is_counter_finished(e: &Env, token: &Address, identity: &Address, limit_time: u64) -> bool { - let counter = get_counter(e, token, identity, limit_time); - counter.timer <= e.ledger().timestamp() -} - -fn reset_counter_if_needed(e: &Env, token: &Address, identity: &Address, limit_time: u64) { - if is_counter_finished(e, token, identity, limit_time) { - let counter = - TransferCounter { value: 0, timer: e.ledger().timestamp().saturating_add(limit_time) }; - set_counter(e, token, identity, limit_time, &counter); - } -} - -fn increase_counters(e: &Env, token: &Address, identity: &Address, value: i128) { - let limits = get_limits(e, token); - for limit in limits.iter() { - reset_counter_if_needed(e, token, identity, limit.limit_time); - let mut counter = get_counter(e, token, identity, limit.limit_time); - counter.value = add_i128_or_panic(e, counter.value, value); - set_counter(e, token, identity, limit.limit_time, &counter); - } -} - -#[contracttrait] -pub trait TimeTransfersLimits { - // ################## QUERY STATE ################## - - fn get_time_transfer_limits(e: &Env, token: Address) -> Vec { - get_limits(e, &token) - } - - fn can_transfer(e: &Env, from: Address, _to: Address, amount: i128, token: Address) -> bool { - assert!( - hooks_verified(e), - "TimeTransfersLimitsModule: not armed — call verify_hook_wiring() after wiring hooks \ - [CanTransfer, Transferred]" - ); - if amount < 0 { - return false; - } - let irs = get_irs_client(e, &token); - let from_id = irs.stored_identity(&from); - let limits = get_limits(e, &token); - - for limit in limits.iter() { - if amount > limit.limit_value { - return false; - } - - if !is_counter_finished(e, &token, &from_id, limit.limit_time) { - let counter = get_counter(e, &token, &from_id, limit.limit_time); - if add_i128_or_panic(e, counter.value, amount) > limit.limit_value { - return false; - } - } - } - - true - } - - fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { - true - } - - fn name(e: &Env) -> String { - module_name(e, "TimeTransfersLimitsModule") - } - - fn get_compliance_address(e: &Env) -> Address { - get_compliance_address(e) - } - - // ################## CHANGE STATE ################## - - fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { - get_compliance_address(e).require_auth(); - set_irs_address(e, &token, &irs); - } - - fn set_time_transfer_limit(e: &Env, token: Address, limit: Limit) { - get_compliance_address(e).require_auth(); - assert!(limit.limit_time > 0, "limit_time must be greater than zero"); - require_non_negative_amount(e, limit.limit_value); - let mut limits = get_limits(e, &token); - - let mut replaced = false; - for i in 0..limits.len() { - let current = limits.get(i).expect("limit exists"); - if current.limit_time == limit.limit_time { - limits.set(i, limit.clone()); - replaced = true; - break; - } - } - - if !replaced { - if limits.len() >= MAX_LIMITS_PER_TOKEN { - panic_with_error!(e, ComplianceModuleError::TooManyLimits); - } - limits.push_back(limit.clone()); - } - - set_limits(e, &token, &limits); - TimeTransferLimitUpdated { token, limit }.publish(e); - } - - fn batch_set_time_transfer_limit(e: &Env, token: Address, limits: Vec) { - get_compliance_address(e).require_auth(); - for limit in limits.iter() { - Self::set_time_transfer_limit(e, token.clone(), limit); - } - } - - fn remove_time_transfer_limit(e: &Env, token: Address, limit_time: u64) { - get_compliance_address(e).require_auth(); - let mut limits = get_limits(e, &token); - - let mut found = false; - for i in 0..limits.len() { - let current = limits.get(i).expect("limit exists"); - if current.limit_time == limit_time { - limits.remove(i); - found = true; - break; - } - } - - if !found { - panic_with_error!(e, ComplianceModuleError::MissingLimit); - } - - set_limits(e, &token, &limits); - TimeTransferLimitRemoved { token, limit_time }.publish(e); - } - - fn batch_remove_time_transfer_limit(e: &Env, token: Address, limit_times: Vec) { - get_compliance_address(e).require_auth(); - for lt in limit_times.iter() { - Self::remove_time_transfer_limit(e, token.clone(), lt); - } - } - - fn pre_set_transfer_counter( - e: &Env, - token: Address, - identity: Address, - limit_time: u64, - counter: TransferCounter, - ) { - get_compliance_address(e).require_auth(); - require_non_negative_amount(e, counter.value); - assert!(limit_time > 0, "limit_time must be greater than zero"); - - let mut found = false; - for limit in get_limits(e, &token).iter() { - if limit.limit_time == limit_time { - found = true; - break; - } - } - - if !found { - panic_with_error!(e, ComplianceModuleError::MissingLimit); - } - - set_counter(e, &token, &identity, limit_time, &counter); - } - - fn verify_hook_wiring(e: &Env) { - verify_required_hooks(e, Self::required_hooks(e)); - } - - fn on_transfer(e: &Env, from: Address, _to: Address, amount: i128, token: Address) { - get_compliance_address(e).require_auth(); - require_non_negative_amount(e, amount); - let irs = get_irs_client(e, &token); - let from_id = irs.stored_identity(&from); - increase_counters(e, &token, &from_id, amount); - } - - fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {} - - fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {} - - /// Implementers must gate this entrypoint with bootstrap-admin auth before - /// delegating to - /// [`storage::set_compliance_address`](super::storage::set_compliance_address). - fn set_compliance_address(e: &Env, compliance: Address); - - // ################## HELPERS ################## - - fn required_hooks(e: &Env) -> Vec { - vec![e, ComplianceHook::CanTransfer, ComplianceHook::Transferred] - } -} diff --git a/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/storage.rs b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/storage.rs index 8b7e38e5e..023f200cd 100644 --- a/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/storage.rs +++ b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/storage.rs @@ -1,6 +1,16 @@ -use soroban_sdk::{contracttype, Address, Env, Vec}; +use soroban_sdk::{contracttype, panic_with_error, vec, Address, Env, Vec}; -use crate::rwa::compliance::modules::{MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD}; +use super::{TimeTransferLimitRemoved, TimeTransferLimitUpdated, MAX_LIMITS_PER_TOKEN}; +use crate::rwa::compliance::{ + modules::{ + storage::{ + add_i128_or_panic, get_irs_client, hooks_verified, require_non_negative_amount, + set_irs_address, verify_required_hooks, + }, + ComplianceModuleError, MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD, + }, + ComplianceHook, +}; /// A single time-window limit: `limit_value` tokens may be transferred /// within a rolling window of `limit_time` seconds. @@ -28,6 +38,8 @@ pub enum TimeTransfersLimitsStorageKey { Counter(Address, Address, u64), } +// ################## RAW STORAGE ################## + /// Returns the list of time-window limits for `token`. /// /// # Arguments @@ -102,3 +114,235 @@ pub fn set_counter( e.storage().persistent().set(&key, counter); e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); } + +// ################## HELPERS ################## + +fn is_counter_finished(e: &Env, token: &Address, identity: &Address, limit_time: u64) -> bool { + let counter = get_counter(e, token, identity, limit_time); + counter.timer <= e.ledger().timestamp() +} + +fn reset_counter_if_needed(e: &Env, token: &Address, identity: &Address, limit_time: u64) { + if is_counter_finished(e, token, identity, limit_time) { + let counter = + TransferCounter { value: 0, timer: e.ledger().timestamp().saturating_add(limit_time) }; + set_counter(e, token, identity, limit_time, &counter); + } +} + +fn increase_counters(e: &Env, token: &Address, identity: &Address, value: i128) { + let limits = get_limits(e, token); + for limit in limits.iter() { + reset_counter_if_needed(e, token, identity, limit.limit_time); + let mut counter = get_counter(e, token, identity, limit.limit_time); + counter.value = add_i128_or_panic(e, counter.value, value); + set_counter(e, token, identity, limit.limit_time, &counter); + } +} + +// ################## ACTIONS ################## + +/// Configures the identity registry storage address for a token. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `irs` - The identity registry storage address. +pub fn configure_irs(e: &Env, token: &Address, irs: &Address) { + set_irs_address(e, token, irs); +} + +/// Sets or updates a time-window transfer limit for `token` and emits +/// [`TimeTransferLimitUpdated`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `limit` - The limit to set. +pub fn set_time_transfer_limit(e: &Env, token: &Address, limit: &Limit) { + assert!(limit.limit_time > 0, "limit_time must be greater than zero"); + require_non_negative_amount(e, limit.limit_value); + let mut limits = get_limits(e, token); + + let mut replaced = false; + for i in 0..limits.len() { + let current = limits.get(i).expect("limit exists"); + if current.limit_time == limit.limit_time { + limits.set(i, limit.clone()); + replaced = true; + break; + } + } + + if !replaced { + if limits.len() >= MAX_LIMITS_PER_TOKEN { + panic_with_error!(e, ComplianceModuleError::TooManyLimits); + } + limits.push_back(limit.clone()); + } + + set_limits(e, token, &limits); + TimeTransferLimitUpdated { token: token.clone(), limit: limit.clone() }.publish(e); +} + +/// Sets or updates multiple time-window transfer limits in a single call. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `limits` - The limits to set. +pub fn batch_set_time_transfer_limit(e: &Env, token: &Address, limits: &Vec) { + for limit in limits.iter() { + set_time_transfer_limit(e, token, &limit); + } +} + +/// Removes a time-window transfer limit and emits +/// [`TimeTransferLimitRemoved`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `limit_time` - The time-window to remove. +pub fn remove_time_transfer_limit(e: &Env, token: &Address, limit_time: u64) { + let mut limits = get_limits(e, token); + + let mut found = false; + for i in 0..limits.len() { + let current = limits.get(i).expect("limit exists"); + if current.limit_time == limit_time { + limits.remove(i); + found = true; + break; + } + } + + if !found { + panic_with_error!(e, ComplianceModuleError::MissingLimit); + } + + set_limits(e, token, &limits); + TimeTransferLimitRemoved { token: token.clone(), limit_time }.publish(e); +} + +/// Removes multiple time-window transfer limits in a single call. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `limit_times` - The time-windows to remove. +pub fn batch_remove_time_transfer_limit(e: &Env, token: &Address, limit_times: &Vec) { + for lt in limit_times.iter() { + remove_time_transfer_limit(e, token, lt); + } +} + +/// Pre-seeds a transfer counter for a given identity and time window. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `identity` - The on-chain identity address. +/// * `limit_time` - The time-window duration in seconds. +/// * `counter` - The counter value to set. +pub fn pre_set_transfer_counter( + e: &Env, + token: &Address, + identity: &Address, + limit_time: u64, + counter: &TransferCounter, +) { + require_non_negative_amount(e, counter.value); + assert!(limit_time > 0, "limit_time must be greater than zero"); + + let mut found = false; + for limit in get_limits(e, token).iter() { + if limit.limit_time == limit_time { + found = true; + break; + } + } + + if !found { + panic_with_error!(e, ComplianceModuleError::MissingLimit); + } + + set_counter(e, token, identity, limit_time, counter); +} + +// ################## HOOK WIRING ################## + +/// Returns the set of compliance hooks this module requires. +pub fn required_hooks(e: &Env) -> Vec { + vec![e, ComplianceHook::CanTransfer, ComplianceHook::Transferred] +} + +/// Cross-calls the compliance contract to verify that this module is +/// registered on all required hooks. +pub fn verify_hook_wiring(e: &Env) { + verify_required_hooks(e, required_hooks(e)); +} + +// ################## COMPLIANCE HOOKS ################## + +/// Resolves the sender's identity and increments transfer counters. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `from` - The sender address. +/// * `amount` - The transfer amount. +/// * `token` - The token address. +pub fn on_transfer(e: &Env, from: &Address, amount: i128, token: &Address) { + require_non_negative_amount(e, amount); + let irs = get_irs_client(e, token); + let from_id = irs.stored_identity(from); + increase_counters(e, token, &from_id, amount); +} + +/// Checks whether a transfer is within the configured time-window limits. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `from` - The sender address. +/// * `amount` - The transfer amount. +/// * `token` - The token address. +/// +/// # Returns +/// +/// `true` if the transfer does not exceed any limit. +pub fn can_transfer(e: &Env, from: &Address, amount: i128, token: &Address) -> bool { + assert!( + hooks_verified(e), + "TimeTransfersLimitsModule: not armed — call verify_hook_wiring() after wiring hooks \ + [CanTransfer, Transferred]" + ); + if amount < 0 { + return false; + } + let irs = get_irs_client(e, token); + let from_id = irs.stored_identity(from); + let limits = get_limits(e, token); + + for limit in limits.iter() { + if amount > limit.limit_value { + return false; + } + + if !is_counter_finished(e, token, &from_id, limit.limit_time) { + let counter = get_counter(e, token, &from_id, limit.limit_time); + if add_i128_or_panic(e, counter.value, amount) > limit.limit_value { + return false; + } + } + } + + true +} diff --git a/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/test.rs b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/test.rs index aced1114d..721336728 100644 --- a/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/test.rs +++ b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/test.rs @@ -4,7 +4,10 @@ use soroban_sdk::{ contract, contractimpl, contracttype, testutils::Address as _, Address, Env, Val, Vec, }; -use super::*; +use super::storage::{ + can_transfer, pre_set_transfer_counter, set_time_transfer_limit, verify_hook_wiring, Limit, + TransferCounter, +}; use crate::rwa::{ compliance::{ modules::storage::{ @@ -191,14 +194,7 @@ impl MockComplianceContract { } #[contract] -struct TestTimeTransfersLimitsContract; - -#[contractimpl(contracttrait)] -impl TimeTransfersLimits for TestTimeTransfersLimitsContract { - fn set_compliance_address(_e: &Env, _compliance: Address) { - unreachable!("set_compliance_address is not used in these tests"); - } -} +struct TestModuleContract; fn arm_hooks(e: &Env) { e.storage().instance().set(&ComplianceModuleStorageKey::HooksVerified, &true); @@ -207,7 +203,7 @@ fn arm_hooks(e: &Env) { #[test] fn verify_hook_wiring_sets_cache_when_registered() { let e = Env::default(); - let module_id = e.register(TestTimeTransfersLimitsContract, ()); + let module_id = e.register(TestModuleContract, ()); let compliance_id = e.register(MockComplianceContract, ()); let compliance = MockComplianceContractClient::new(&e, &compliance_id); @@ -218,7 +214,7 @@ fn verify_hook_wiring_sets_cache_when_registered() { e.as_contract(&module_id, || { set_compliance_address(&e, &compliance_id); - ::verify_hook_wiring(&e); + verify_hook_wiring(&e); assert!(hooks_verified(&e)); }); @@ -227,17 +223,14 @@ fn verify_hook_wiring_sets_cache_when_registered() { #[test] fn pre_set_transfer_counter_blocks_transfers_within_active_window() { let e = Env::default(); - e.mock_all_auths(); - let module_id = e.register(TestTimeTransfersLimitsContract, ()); + let module_id = e.register(TestModuleContract, ()); let irs_id = e.register(MockIRSContract, ()); let irs = MockIRSContractClient::new(&e, &irs_id); let compliance = Address::generate(&e); let token = Address::generate(&e); let sender = Address::generate(&e); let sender_identity = Address::generate(&e); - let recipient = Address::generate(&e); - let client = TestTimeTransfersLimitsContractClient::new(&e, &module_id); irs.set_identity(&sender, &sender_identity); @@ -245,38 +238,37 @@ fn pre_set_transfer_counter_blocks_transfers_within_active_window() { set_compliance_address(&e, &compliance); set_irs_address(&e, &token, &irs_id); arm_hooks(&e); - }); - client.set_time_transfer_limit(&token, &Limit { limit_time: 60, limit_value: 100 }); - client.pre_set_transfer_counter( - &token, - &sender_identity, - &60, - &TransferCounter { value: 90, timer: e.ledger().timestamp().saturating_add(60) }, - ); - - assert!(!client.can_transfer(&sender.clone(), &recipient.clone(), &11, &token)); - assert!(client.can_transfer(&sender, &recipient, &10, &token)); + set_time_transfer_limit(&e, &token, &Limit { limit_time: 60, limit_value: 100 }); + pre_set_transfer_counter( + &e, + &token, + &sender_identity, + 60, + &TransferCounter { value: 90, timer: e.ledger().timestamp().saturating_add(60) }, + ); + + assert!(!can_transfer(&e, &sender, 11, &token)); + assert!(can_transfer(&e, &sender, 10, &token)); + }); } #[test] #[should_panic(expected = "Error(Contract, #400)")] fn set_time_transfer_limit_rejects_more_than_four_limits() { let e = Env::default(); - e.mock_all_auths(); - let module_id = e.register(TestTimeTransfersLimitsContract, ()); + let module_id = e.register(TestModuleContract, ()); let compliance = Address::generate(&e); let token = Address::generate(&e); - let client = TestTimeTransfersLimitsContractClient::new(&e, &module_id); e.as_contract(&module_id, || { set_compliance_address(&e, &compliance); - }); - for limit_time in [60_u64, 120, 180, 240] { - client.set_time_transfer_limit(&token, &Limit { limit_time, limit_value: 100 }); - } + for limit_time in [60_u64, 120, 180, 240] { + set_time_transfer_limit(&e, &token, &Limit { limit_time, limit_value: 100 }); + } - client.set_time_transfer_limit(&token, &Limit { limit_time: 300, limit_value: 100 }); + set_time_transfer_limit(&e, &token, &Limit { limit_time: 300, limit_value: 100 }); + }); } diff --git a/packages/tokens/src/rwa/compliance/modules/transfer_restrict/mod.rs b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/mod.rs index 1198a0eb2..bf87e6117 100644 --- a/packages/tokens/src/rwa/compliance/modules/transfer_restrict/mod.rs +++ b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/mod.rs @@ -10,10 +10,7 @@ pub mod storage; #[cfg(test)] mod test; -use soroban_sdk::{contractevent, contracttrait, Address, Env, String, Vec}; -use storage::{is_user_allowed, remove_user_allowed, set_user_allowed}; - -use super::storage::{get_compliance_address, module_name}; +use soroban_sdk::{contractevent, Address}; /// Emitted when an address is added to the transfer allowlist. #[contractevent] @@ -32,169 +29,3 @@ pub struct UserDisallowed { pub token: Address, pub user: Address, } - -/// Transfer restriction compliance trait. -/// -/// Provides default implementations for maintaining a per-token address -/// allowlist. Transfers are allowed if the sender is allowlisted; otherwise -/// the recipient must be (T-REX semantics). -#[contracttrait] -pub trait TransferRestrict { - /// Adds `user` to the transfer allowlist for `token`. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - /// * `token` - The token address. - /// * `user` - The address to allow. - /// - /// # Authorization - /// - /// Requires compliance contract authorization. - /// - /// # Events - /// - /// Emits [`UserAllowed`]. - fn allow_user(e: &Env, token: Address, user: Address) { - get_compliance_address(e).require_auth(); - set_user_allowed(e, &token, &user); - UserAllowed { token, user }.publish(e); - } - - /// Removes `user` from the transfer allowlist for `token`. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - /// * `token` - The token address. - /// * `user` - The address to disallow. - /// - /// # Authorization - /// - /// Requires compliance contract authorization. - /// - /// # Events - /// - /// Emits [`UserDisallowed`]. - fn disallow_user(e: &Env, token: Address, user: Address) { - get_compliance_address(e).require_auth(); - remove_user_allowed(e, &token, &user); - UserDisallowed { token, user }.publish(e); - } - - /// Adds multiple users to the transfer allowlist in a single call. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - /// * `token` - The token address. - /// * `users` - The addresses to allow. - /// - /// # Authorization - /// - /// Requires compliance contract authorization. - /// - /// # Events - /// - /// Emits [`UserAllowed`] for each user added. - fn batch_allow_users(e: &Env, token: Address, users: Vec
) { - get_compliance_address(e).require_auth(); - for user in users.iter() { - set_user_allowed(e, &token, &user); - UserAllowed { token: token.clone(), user }.publish(e); - } - } - - /// Removes multiple users from the transfer allowlist in a single call. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - /// * `token` - The token address. - /// * `users` - The addresses to disallow. - /// - /// # Authorization - /// - /// Requires compliance contract authorization. - /// - /// # Events - /// - /// Emits [`UserDisallowed`] for each user removed. - fn batch_disallow_users(e: &Env, token: Address, users: Vec
) { - get_compliance_address(e).require_auth(); - for user in users.iter() { - remove_user_allowed(e, &token, &user); - UserDisallowed { token: token.clone(), user }.publish(e); - } - } - - /// Returns whether `user` is on the transfer allowlist for `token`. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - /// * `token` - The token address. - /// * `user` - The address to check. - fn is_user_allowed(e: &Env, token: Address, user: Address) -> bool { - is_user_allowed(e, &token, &user) - } - - /// No-op — this module does not track transfer state. - fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {} - - /// No-op — this module does not track mint state. - fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {} - - /// No-op — this module does not track burn state. - fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {} - - /// Checks whether the transfer is allowed by the address allowlist. - /// - /// T-REX semantics: if the sender is allowlisted, the transfer passes; - /// otherwise the recipient must be allowlisted. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - /// * `from` - The sender address. - /// * `to` - The recipient address. - /// * `_amount` - The transfer amount (unused). - /// * `token` - The token address. - /// - /// # Returns - /// - /// `true` if the sender or recipient is allowlisted, `false` otherwise. - fn can_transfer(e: &Env, from: Address, to: Address, _amount: i128, token: Address) -> bool { - if is_user_allowed(e, &token, &from) { - return true; - } - is_user_allowed(e, &token, &to) - } - - /// Always returns `true` — mints are not restricted by this module. - fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { - true - } - - /// Returns the module name for identification. - fn name(e: &Env) -> String { - module_name(e, "TransferRestrictModule") - } - - /// Returns the compliance contract address. - fn get_compliance_address(e: &Env) -> Address { - get_compliance_address(e) - } - - /// Sets the compliance contract address (one-time only). - /// - /// Implementers must gate this entrypoint with bootstrap-admin auth before - /// delegating to - /// [`storage::set_compliance_address`](super::storage::set_compliance_address). - /// - /// - /// # Panics - /// - /// Panics if the compliance address has already been set. - fn set_compliance_address(e: &Env, compliance: Address); -} diff --git a/packages/tokens/src/rwa/compliance/modules/transfer_restrict/storage.rs b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/storage.rs index 8fa25912f..8a02b799e 100644 --- a/packages/tokens/src/rwa/compliance/modules/transfer_restrict/storage.rs +++ b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/storage.rs @@ -1,5 +1,6 @@ -use soroban_sdk::{contracttype, Address, Env}; +use soroban_sdk::{contracttype, Address, Env, Vec}; +use super::{UserAllowed, UserDisallowed}; use crate::rwa::compliance::modules::{MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD}; #[contracttype] @@ -9,6 +10,8 @@ pub enum TransferRestrictStorageKey { AllowedUser(Address, Address), } +// ################## RAW STORAGE ################## + /// Returns whether `user` is on the transfer allowlist for `token`. /// /// # Arguments @@ -52,3 +55,85 @@ pub fn remove_user_allowed(e: &Env, token: &Address, user: &Address) { .persistent() .remove(&TransferRestrictStorageKey::AllowedUser(token.clone(), user.clone())); } + +// ################## ACTIONS ################## + +/// Adds `user` to the transfer allowlist for `token` and emits +/// [`UserAllowed`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `user` - The address to allow. +pub fn allow_user(e: &Env, token: &Address, user: &Address) { + set_user_allowed(e, token, user); + UserAllowed { token: token.clone(), user: user.clone() }.publish(e); +} + +/// Removes `user` from the transfer allowlist for `token` and emits +/// [`UserDisallowed`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `user` - The address to disallow. +pub fn disallow_user(e: &Env, token: &Address, user: &Address) { + remove_user_allowed(e, token, user); + UserDisallowed { token: token.clone(), user: user.clone() }.publish(e); +} + +/// Adds multiple users to the transfer allowlist in a single call. +/// Emits [`UserAllowed`] for each user added. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `users` - The addresses to allow. +pub fn batch_allow_users(e: &Env, token: &Address, users: &Vec
) { + for user in users.iter() { + set_user_allowed(e, token, &user); + UserAllowed { token: token.clone(), user }.publish(e); + } +} + +/// Removes multiple users from the transfer allowlist in a single call. +/// Emits [`UserDisallowed`] for each user removed. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `users` - The addresses to disallow. +pub fn batch_disallow_users(e: &Env, token: &Address, users: &Vec
) { + for user in users.iter() { + remove_user_allowed(e, token, &user); + UserDisallowed { token: token.clone(), user }.publish(e); + } +} + +// ################## COMPLIANCE HOOKS ################## + +/// Checks whether the transfer is allowed by the address allowlist. +/// +/// T-REX semantics: if the sender is allowlisted, the transfer passes; +/// otherwise the recipient must be allowlisted. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `from` - The sender address. +/// * `to` - The recipient address. +/// * `token` - The token address. +/// +/// # Returns +/// +/// `true` if the sender or recipient is allowlisted, `false` otherwise. +pub fn can_transfer(e: &Env, from: &Address, to: &Address, token: &Address) -> bool { + if is_user_allowed(e, token, from) { + return true; + } + is_user_allowed(e, token, to) +} diff --git a/packages/tokens/src/rwa/compliance/modules/transfer_restrict/test.rs b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/test.rs index 3ae8ba642..d181be4e4 100644 --- a/packages/tokens/src/rwa/compliance/modules/transfer_restrict/test.rs +++ b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/test.rs @@ -1,70 +1,62 @@ extern crate std; -use soroban_sdk::{contract, contractimpl, testutils::Address as _, vec, Address, Env}; +use soroban_sdk::{contract, testutils::Address as _, vec, Address, Env}; -use super::*; +use super::storage::{ + allow_user, batch_allow_users, batch_disallow_users, can_transfer, disallow_user, + is_user_allowed, +}; use crate::rwa::compliance::modules::storage::set_compliance_address; #[contract] -struct TestTransferRestrictContract; - -#[contractimpl(contracttrait)] -impl TransferRestrict for TestTransferRestrictContract { - fn set_compliance_address(_e: &Env, _compliance: Address) { - unreachable!("set_compliance_address is not used in these tests"); - } -} +struct TestModuleContract; #[test] fn can_transfer_allows_sender_or_recipient_when_allowlisted() { let e = Env::default(); - e.mock_all_auths(); - let module_id = e.register(TestTransferRestrictContract, ()); + let module_id = e.register(TestModuleContract, ()); let compliance = Address::generate(&e); let token = Address::generate(&e); let sender = Address::generate(&e); let recipient = Address::generate(&e); let outsider = Address::generate(&e); - let client = TestTransferRestrictContractClient::new(&e, &module_id); e.as_contract(&module_id, || { set_compliance_address(&e, &compliance); - }); - assert!(!client.can_transfer(&sender.clone(), &recipient.clone(), &100, &token)); + assert!(!can_transfer(&e, &sender, &recipient, &token)); - client.allow_user(&token, &sender.clone()); - assert!(client.can_transfer(&sender.clone(), &outsider.clone(), &100, &token)); + allow_user(&e, &token, &sender); + assert!(can_transfer(&e, &sender, &outsider, &token)); - client.disallow_user(&token, &sender.clone()); - client.allow_user(&token, &recipient.clone()); - assert!(client.can_transfer(&outsider, &recipient, &100, &token)); + disallow_user(&e, &token, &sender); + allow_user(&e, &token, &recipient); + assert!(can_transfer(&e, &outsider, &recipient, &token)); + }); } #[test] fn batch_allow_and_disallow_update_allowlist_entries() { let e = Env::default(); - e.mock_all_auths(); - let module_id = e.register(TestTransferRestrictContract, ()); + let module_id = e.register(TestModuleContract, ()); let compliance = Address::generate(&e); let token = Address::generate(&e); let user_a = Address::generate(&e); let user_b = Address::generate(&e); - let client = TestTransferRestrictContractClient::new(&e, &module_id); e.as_contract(&module_id, || { set_compliance_address(&e, &compliance); - }); - client.batch_allow_users(&token, &vec![&e, user_a.clone(), user_b.clone()]); + batch_allow_users(&e, &token, &vec![&e, user_a.clone(), user_b.clone()]); - assert!(client.is_user_allowed(&token, &user_a.clone())); - assert!(client.is_user_allowed(&token, &user_b.clone())); + assert!(is_user_allowed(&e, &token, &user_a)); + assert!(is_user_allowed(&e, &token, &user_b)); - client.batch_disallow_users(&token, &vec![&e, user_a.clone(), user_b.clone()]); + batch_disallow_users(&e, &token, &vec![&e, user_a.clone(), user_b.clone()]); - assert!(!client.is_user_allowed(&token, &user_a)); - assert!(!client.is_user_allowed(&token, &user_b)); + assert!(!is_user_allowed(&e, &token, &user_a)); + assert!(!is_user_allowed(&e, &token, &user_b)); + }); } From 80c5e52f1828b4c3c960c7b025282329313bc59d Mon Sep 17 00:00:00 2001 From: Aleksandr Pasevin Date: Fri, 10 Apr 2026 16:17:33 +0300 Subject: [PATCH 4/5] chore: sync Cargo.lock after merging upstream/main v0.7.1 --- Cargo.lock | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f33dc7d2c..473be0441 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1594,7 +1594,7 @@ dependencies = [ [[package]] name = "rwa-country-allow" -version = "0.7.0" +version = "0.7.1" dependencies = [ "soroban-sdk", "stellar-tokens", @@ -1602,7 +1602,7 @@ dependencies = [ [[package]] name = "rwa-country-restrict" -version = "0.7.0" +version = "0.7.1" dependencies = [ "soroban-sdk", "stellar-tokens", @@ -1640,7 +1640,7 @@ dependencies = [ [[package]] name = "rwa-initial-lockup-period" -version = "0.7.0" +version = "0.7.1" dependencies = [ "soroban-sdk", "stellar-tokens", @@ -1648,7 +1648,7 @@ dependencies = [ [[package]] name = "rwa-max-balance" -version = "0.7.0" +version = "0.7.1" dependencies = [ "soroban-sdk", "stellar-tokens", @@ -1656,7 +1656,7 @@ dependencies = [ [[package]] name = "rwa-supply-limit" -version = "0.7.0" +version = "0.7.1" dependencies = [ "soroban-sdk", "stellar-tokens", @@ -1664,7 +1664,7 @@ dependencies = [ [[package]] name = "rwa-time-transfers-limits" -version = "0.7.0" +version = "0.7.1" dependencies = [ "soroban-sdk", "stellar-tokens", @@ -1683,7 +1683,7 @@ dependencies = [ [[package]] name = "rwa-transfer-restrict" -version = "0.7.0" +version = "0.7.1" dependencies = [ "soroban-sdk", "stellar-tokens", From be4f5f39a71ceba89e287725527820fe5aa19332 Mon Sep 17 00:00:00 2001 From: Aleksandr Pasevin Date: Sat, 11 Apr 2026 12:32:01 +0300 Subject: [PATCH 5/5] refactor: refresh integration branch standalone follow-ups Sync the carried standalone examples and module-side follow-up tests with the latest country, balance/supply, and transfer cleanup pattern. This keeps the integration branch aligned with the reviewed standalone PRs without changing module behavior. --- examples/rwa-country-allow/Cargo.toml | 7 + examples/rwa-country-allow/src/lib.rs | 2 + examples/rwa-country-allow/src/test.rs | 259 ++++++++++++++ examples/rwa-country-restrict/Cargo.toml | 7 + examples/rwa-country-restrict/src/lib.rs | 2 + examples/rwa-country-restrict/src/test.rs | 259 ++++++++++++++ examples/rwa-initial-lockup-period/Cargo.toml | 7 + examples/rwa-initial-lockup-period/src/lib.rs | 2 + .../rwa-initial-lockup-period/src/test.rs | 216 ++++++++++++ examples/rwa-max-balance/Cargo.toml | 7 + examples/rwa-max-balance/src/lib.rs | 2 + examples/rwa-max-balance/src/test.rs | 303 +++++++++++++++++ examples/rwa-supply-limit/Cargo.toml | 7 + examples/rwa-supply-limit/src/lib.rs | 2 + examples/rwa-supply-limit/src/test.rs | 194 +++++++++++ examples/rwa-time-transfers-limits/Cargo.toml | 7 + examples/rwa-time-transfers-limits/src/lib.rs | 2 + .../rwa-time-transfers-limits/src/test.rs | 316 ++++++++++++++++++ examples/rwa-transfer-restrict/Cargo.toml | 7 + examples/rwa-transfer-restrict/src/lib.rs | 2 + examples/rwa-transfer-restrict/src/test.rs | 97 ++++++ .../modules/country_allow/storage.rs | 8 +- .../modules/country_restrict/storage.rs | 8 +- .../modules/initial_lockup_period/storage.rs | 7 +- .../compliance/modules/max_balance/test.rs | 138 +++++++- .../compliance/modules/supply_limit/test.rs | 29 +- .../modules/time_transfers_limits/storage.rs | 8 +- .../modules/transfer_restrict/storage.rs | 6 +- 28 files changed, 1888 insertions(+), 23 deletions(-) create mode 100644 examples/rwa-country-allow/src/test.rs create mode 100644 examples/rwa-country-restrict/src/test.rs create mode 100644 examples/rwa-initial-lockup-period/src/test.rs create mode 100644 examples/rwa-max-balance/src/test.rs create mode 100644 examples/rwa-supply-limit/src/test.rs create mode 100644 examples/rwa-time-transfers-limits/src/test.rs create mode 100644 examples/rwa-transfer-restrict/src/test.rs diff --git a/examples/rwa-country-allow/Cargo.toml b/examples/rwa-country-allow/Cargo.toml index a60408eab..540b26786 100644 --- a/examples/rwa-country-allow/Cargo.toml +++ b/examples/rwa-country-allow/Cargo.toml @@ -5,6 +5,10 @@ license.workspace = true repository.workspace = true publish = false version.workspace = true +authors.workspace = true + +[package.metadata.stellar] +cargo_inherit = true [lib] crate-type = ["cdylib", "rlib"] @@ -13,3 +17,6 @@ doctest = false [dependencies] soroban-sdk = { workspace = true } stellar-tokens = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/examples/rwa-country-allow/src/lib.rs b/examples/rwa-country-allow/src/lib.rs index b9aa4d5f0..a879b6f80 100644 --- a/examples/rwa-country-allow/src/lib.rs +++ b/examples/rwa-country-allow/src/lib.rs @@ -1,3 +1,5 @@ #![no_std] pub mod contract; +#[cfg(test)] +mod test; diff --git a/examples/rwa-country-allow/src/test.rs b/examples/rwa-country-allow/src/test.rs new file mode 100644 index 000000000..06b540ed3 --- /dev/null +++ b/examples/rwa-country-allow/src/test.rs @@ -0,0 +1,259 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal, + String, Val, Vec, +}; +use stellar_tokens::rwa::{ + identity_registry_storage::{ + CountryData, CountryDataManager, CountryRelation, IdentityRegistryStorage, + IndividualCountryRelation, OrganizationCountryRelation, + }, + utils::token_binder::TokenBinder, +}; + +use crate::contract::{CountryAllowContract, CountryAllowContractClient}; + +fn create_client<'a>(e: &Env, admin: &Address) -> CountryAllowContractClient<'a> { + let address = e.register(CountryAllowContract, (admin,)); + CountryAllowContractClient::new(e, &address) +} + +#[contract] +struct MockIRSContract; + +#[contracttype] +#[derive(Clone)] +enum MockIRSStorageKey { + Identity(Address), + CountryEntries(Address), +} + +#[contractimpl] +impl TokenBinder for MockIRSContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl IdentityRegistryStorage for MockIRSContract { + fn add_identity( + _e: &Env, + _account: Address, + _identity: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_identity is not used in these tests"); + } + + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { + unreachable!("remove_identity is not used in these tests"); + } + + fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { + unreachable!("modify_identity is not used in these tests"); + } + + fn recover_identity( + _e: &Env, + _old_account: Address, + _new_account: Address, + _operator: Address, + ) { + unreachable!("recover_identity is not used in these tests"); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + e.storage() + .persistent() + .get(&MockIRSStorageKey::Identity(account.clone())) + .unwrap_or(account) + } +} + +#[contractimpl] +impl CountryDataManager for MockIRSContract { + fn add_country_data_entries( + _e: &Env, + _account: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_country_data_entries is not used in these tests"); + } + + fn modify_country_data( + _e: &Env, + _account: Address, + _index: u32, + _country_data: Val, + _operator: Address, + ) { + unreachable!("modify_country_data is not used in these tests"); + } + + fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { + unreachable!("delete_country_data is not used in these tests"); + } + + fn get_country_data_entries(e: &Env, account: Address) -> Vec { + let entries: Vec = e + .storage() + .persistent() + .get(&MockIRSStorageKey::CountryEntries(account)) + .unwrap_or_else(|| Vec::new(e)); + + Vec::from_iter(e, entries.iter().map(|entry| entry.into_val(e))) + } +} + +#[contractimpl] +impl MockIRSContract { + pub fn set_country_data_entries(e: &Env, account: Address, entries: Vec) { + e.storage().persistent().set(&MockIRSStorageKey::CountryEntries(account), &entries); + } +} + +fn individual_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Individual(IndividualCountryRelation::Residence(code)), + metadata: None, + } +} + +fn organization_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Organization(OrganizationCountryRelation::OperatingJurisdiction( + code, + )), + metadata: None, + } +} + +#[test] +fn add_and_remove_allowed_country_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let client = create_client(&e, &admin); + + assert!(!client.is_country_allowed(&token, &276)); + + client.add_allowed_country(&token, &276); + assert!(client.is_country_allowed(&token, &276)); + + client.remove_allowed_country(&token, &276); + assert!(!client.is_country_allowed(&token, &276)); +} + +#[test] +fn batch_allow_and_disallow_countries_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let client = create_client(&e, &admin); + + client.batch_allow_countries(&token, &vec![&e, 250u32, 276u32]); + assert!(client.is_country_allowed(&token, &250)); + assert!(client.is_country_allowed(&token, &276)); + + client.batch_disallow_countries(&token, &vec![&e, 250u32]); + assert!(!client.is_country_allowed(&token, &250)); + assert!(client.is_country_allowed(&token, &276)); +} + +#[test] +fn name_and_compliance_address_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let client = create_client(&e, &admin); + + assert_eq!(client.name(), String::from_str(&e, "CountryAllowModule")); + + client.set_compliance_address(&compliance); + assert_eq!(client.get_compliance_address(), compliance); +} + +#[test] +fn set_identity_registry_storage_uses_admin_auth_before_compliance_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let irs = Address::generate(&e); + let client = create_client(&e, &admin); + + client.set_identity_registry_storage(&token, &irs); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); +} + +#[test] +fn set_identity_registry_storage_uses_compliance_auth_after_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let irs = Address::generate(&e); + let client = create_client(&e, &admin); + + client.set_compliance_address(&compliance); + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); + + client.set_identity_registry_storage(&token, &irs); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &compliance); +} + +#[test] +fn can_transfer_and_can_create_use_irs_country_entries() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let from = Address::generate(&e); + let token = Address::generate(&e); + let allowed_to = Address::generate(&e); + let disallowed_to = Address::generate(&e); + let amount = 100_i128; + let client = create_client(&e, &admin); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + + irs.set_country_data_entries( + &allowed_to, + &vec![&e, individual_country(250), organization_country(276)], + ); + irs.set_country_data_entries(&disallowed_to, &vec![&e, individual_country(250)]); + + client.set_identity_registry_storage(&token, &irs_id); + client.add_allowed_country(&token, &276); + + assert!(client.can_transfer(&from, &allowed_to, &amount, &token)); + assert!(client.can_create(&allowed_to, &amount, &token)); + assert!(!client.can_transfer(&from, &disallowed_to, &amount, &token)); + assert!(!client.can_create(&disallowed_to, &amount, &token)); +} diff --git a/examples/rwa-country-restrict/Cargo.toml b/examples/rwa-country-restrict/Cargo.toml index 27aabc3bc..d160254e9 100644 --- a/examples/rwa-country-restrict/Cargo.toml +++ b/examples/rwa-country-restrict/Cargo.toml @@ -5,6 +5,10 @@ license.workspace = true repository.workspace = true publish = false version.workspace = true +authors.workspace = true + +[package.metadata.stellar] +cargo_inherit = true [lib] crate-type = ["cdylib", "rlib"] @@ -13,3 +17,6 @@ doctest = false [dependencies] soroban-sdk = { workspace = true } stellar-tokens = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/examples/rwa-country-restrict/src/lib.rs b/examples/rwa-country-restrict/src/lib.rs index b9aa4d5f0..a879b6f80 100644 --- a/examples/rwa-country-restrict/src/lib.rs +++ b/examples/rwa-country-restrict/src/lib.rs @@ -1,3 +1,5 @@ #![no_std] pub mod contract; +#[cfg(test)] +mod test; diff --git a/examples/rwa-country-restrict/src/test.rs b/examples/rwa-country-restrict/src/test.rs new file mode 100644 index 000000000..4d6a10154 --- /dev/null +++ b/examples/rwa-country-restrict/src/test.rs @@ -0,0 +1,259 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal, + String, Val, Vec, +}; +use stellar_tokens::rwa::{ + identity_registry_storage::{ + CountryData, CountryDataManager, CountryRelation, IdentityRegistryStorage, + IndividualCountryRelation, OrganizationCountryRelation, + }, + utils::token_binder::TokenBinder, +}; + +use crate::contract::{CountryRestrictContract, CountryRestrictContractClient}; + +fn create_client<'a>(e: &Env, admin: &Address) -> CountryRestrictContractClient<'a> { + let address = e.register(CountryRestrictContract, (admin,)); + CountryRestrictContractClient::new(e, &address) +} + +#[contract] +struct MockIRSContract; + +#[contracttype] +#[derive(Clone)] +enum MockIRSStorageKey { + Identity(Address), + CountryEntries(Address), +} + +#[contractimpl] +impl TokenBinder for MockIRSContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl IdentityRegistryStorage for MockIRSContract { + fn add_identity( + _e: &Env, + _account: Address, + _identity: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_identity is not used in these tests"); + } + + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { + unreachable!("remove_identity is not used in these tests"); + } + + fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { + unreachable!("modify_identity is not used in these tests"); + } + + fn recover_identity( + _e: &Env, + _old_account: Address, + _new_account: Address, + _operator: Address, + ) { + unreachable!("recover_identity is not used in these tests"); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + e.storage() + .persistent() + .get(&MockIRSStorageKey::Identity(account.clone())) + .unwrap_or(account) + } +} + +#[contractimpl] +impl CountryDataManager for MockIRSContract { + fn add_country_data_entries( + _e: &Env, + _account: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_country_data_entries is not used in these tests"); + } + + fn modify_country_data( + _e: &Env, + _account: Address, + _index: u32, + _country_data: Val, + _operator: Address, + ) { + unreachable!("modify_country_data is not used in these tests"); + } + + fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { + unreachable!("delete_country_data is not used in these tests"); + } + + fn get_country_data_entries(e: &Env, account: Address) -> Vec { + let entries: Vec = e + .storage() + .persistent() + .get(&MockIRSStorageKey::CountryEntries(account)) + .unwrap_or_else(|| Vec::new(e)); + + Vec::from_iter(e, entries.iter().map(|entry| entry.into_val(e))) + } +} + +#[contractimpl] +impl MockIRSContract { + pub fn set_country_data_entries(e: &Env, account: Address, entries: Vec) { + e.storage().persistent().set(&MockIRSStorageKey::CountryEntries(account), &entries); + } +} + +fn individual_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Individual(IndividualCountryRelation::Residence(code)), + metadata: None, + } +} + +fn organization_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Organization(OrganizationCountryRelation::OperatingJurisdiction( + code, + )), + metadata: None, + } +} + +#[test] +fn add_and_remove_country_restriction_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let client = create_client(&e, &admin); + + assert!(!client.is_country_restricted(&token, &276)); + + client.add_country_restriction(&token, &276); + assert!(client.is_country_restricted(&token, &276)); + + client.remove_country_restriction(&token, &276); + assert!(!client.is_country_restricted(&token, &276)); +} + +#[test] +fn batch_restrict_and_unrestrict_countries_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let client = create_client(&e, &admin); + + client.batch_restrict_countries(&token, &vec![&e, 250u32, 276u32]); + assert!(client.is_country_restricted(&token, &250)); + assert!(client.is_country_restricted(&token, &276)); + + client.batch_unrestrict_countries(&token, &vec![&e, 250u32]); + assert!(!client.is_country_restricted(&token, &250)); + assert!(client.is_country_restricted(&token, &276)); +} + +#[test] +fn name_and_compliance_address_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let client = create_client(&e, &admin); + + assert_eq!(client.name(), String::from_str(&e, "CountryRestrictModule")); + + client.set_compliance_address(&compliance); + assert_eq!(client.get_compliance_address(), compliance); +} + +#[test] +fn set_identity_registry_storage_uses_admin_auth_before_compliance_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let irs = Address::generate(&e); + let client = create_client(&e, &admin); + + client.set_identity_registry_storage(&token, &irs); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); +} + +#[test] +fn set_identity_registry_storage_uses_compliance_auth_after_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let irs = Address::generate(&e); + let client = create_client(&e, &admin); + + client.set_compliance_address(&compliance); + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); + + client.set_identity_registry_storage(&token, &irs); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &compliance); +} + +#[test] +fn can_transfer_and_can_create_use_irs_country_entries() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let from = Address::generate(&e); + let token = Address::generate(&e); + let allowed_to = Address::generate(&e); + let restricted_to = Address::generate(&e); + let amount = 100_i128; + let client = create_client(&e, &admin); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + + irs.set_country_data_entries(&allowed_to, &vec![&e, individual_country(250)]); + irs.set_country_data_entries( + &restricted_to, + &vec![&e, individual_country(250), organization_country(276)], + ); + + client.set_identity_registry_storage(&token, &irs_id); + client.add_country_restriction(&token, &276); + + assert!(client.can_transfer(&from, &allowed_to, &amount, &token)); + assert!(client.can_create(&allowed_to, &amount, &token)); + assert!(!client.can_transfer(&from, &restricted_to, &amount, &token)); + assert!(!client.can_create(&restricted_to, &amount, &token)); +} diff --git a/examples/rwa-initial-lockup-period/Cargo.toml b/examples/rwa-initial-lockup-period/Cargo.toml index dc0edbff4..767f3cca1 100644 --- a/examples/rwa-initial-lockup-period/Cargo.toml +++ b/examples/rwa-initial-lockup-period/Cargo.toml @@ -5,6 +5,10 @@ license.workspace = true repository.workspace = true publish = false version.workspace = true +authors.workspace = true + +[package.metadata.stellar] +cargo_inherit = true [lib] crate-type = ["cdylib", "rlib"] @@ -13,3 +17,6 @@ doctest = false [dependencies] soroban-sdk = { workspace = true } stellar-tokens = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/examples/rwa-initial-lockup-period/src/lib.rs b/examples/rwa-initial-lockup-period/src/lib.rs index b9aa4d5f0..a879b6f80 100644 --- a/examples/rwa-initial-lockup-period/src/lib.rs +++ b/examples/rwa-initial-lockup-period/src/lib.rs @@ -1,3 +1,5 @@ #![no_std] pub mod contract; +#[cfg(test)] +mod test; diff --git a/examples/rwa-initial-lockup-period/src/test.rs b/examples/rwa-initial-lockup-period/src/test.rs new file mode 100644 index 000000000..426f71f3f --- /dev/null +++ b/examples/rwa-initial-lockup-period/src/test.rs @@ -0,0 +1,216 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, String, Vec, +}; +use stellar_tokens::rwa::{ + compliance::{modules::initial_lockup_period::LockedTokens, Compliance, ComplianceHook}, + utils::token_binder::TokenBinder, +}; + +use crate::contract::{InitialLockupPeriodContract, InitialLockupPeriodContractClient}; + +fn create_client<'a>(e: &Env, admin: &Address) -> (Address, InitialLockupPeriodContractClient<'a>) { + let address = e.register(InitialLockupPeriodContract, (admin,)); + (address.clone(), InitialLockupPeriodContractClient::new(e, &address)) +} + +#[contract] +struct MockComplianceContract; + +#[contracttype] +#[derive(Clone)] +enum MockComplianceStorageKey { + Registered(ComplianceHook, Address), +} + +#[contractimpl] +impl Compliance for MockComplianceContract { + fn add_module_to(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("add_module_to is not used in these tests"); + } + + fn remove_module_from(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("remove_module_from is not used in these tests"); + } + + fn get_modules_for_hook(_e: &Env, _hook: ComplianceHook) -> Vec
{ + unreachable!("get_modules_for_hook is not used in these tests"); + } + + fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool { + e.storage().persistent().has(&MockComplianceStorageKey::Registered(hook, module)) + } + + fn transferred(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) { + unreachable!("transferred is not used in these tests"); + } + + fn created(_e: &Env, _to: Address, _amount: i128, _token: Address) { + unreachable!("created is not used in these tests"); + } + + fn destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) { + unreachable!("destroyed is not used in these tests"); + } + + fn can_transfer( + _e: &Env, + _from: Address, + _to: Address, + _amount: i128, + _token: Address, + ) -> bool { + unreachable!("can_transfer is not used in these tests"); + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + unreachable!("can_create is not used in these tests"); + } +} + +#[contractimpl] +impl TokenBinder for MockComplianceContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl MockComplianceContract { + pub fn register_hook(e: &Env, hook: ComplianceHook, module: Address) { + e.storage().persistent().set(&MockComplianceStorageKey::Registered(hook, module), &true); + } +} + +#[test] +fn set_and_get_lockup_state_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let wallet = Address::generate(&e); + let release_timestamp = e.ledger().timestamp().saturating_add(60); + let locks = vec![ + &e, + LockedTokens { amount: 80, release_timestamp }, + LockedTokens { amount: 10, release_timestamp: release_timestamp.saturating_add(60) }, + ]; + let (_address, client) = create_client(&e, &admin); + + client.set_lockup_period(&token, &60); + client.pre_set_lockup_state(&token, &wallet, &100, &locks); + + assert_eq!(client.get_lockup_period(&token), 60); + assert_eq!(client.get_total_locked(&token, &wallet), 90); + assert_eq!(client.get_internal_balance(&token, &wallet), 100); + + let stored_locks = client.get_locked_tokens(&token, &wallet); + assert_eq!(stored_locks.len(), 2); + + let first_lock = stored_locks.get(0).unwrap(); + assert_eq!(first_lock.amount, 80); + assert_eq!(first_lock.release_timestamp, release_timestamp); +} + +#[test] +fn name_compliance_address_and_required_hooks_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + assert_eq!(client.name(), String::from_str(&e, "InitialLockupPeriodModule")); + assert_eq!( + client.required_hooks(), + vec![ + &e, + ComplianceHook::CanTransfer, + ComplianceHook::Created, + ComplianceHook::Transferred, + ComplianceHook::Destroyed, + ] + ); + + client.set_compliance_address(&compliance); + assert_eq!(client.get_compliance_address(), compliance); +} + +#[test] +fn set_lockup_period_uses_admin_auth_before_compliance_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_lockup_period(&token, &60); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); +} + +#[test] +fn set_lockup_period_uses_compliance_auth_after_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_compliance_address(&compliance); + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); + + client.set_lockup_period(&token, &60); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &compliance); +} + +#[test] +fn verify_hook_wiring_and_can_transfer_use_public_contract_api() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let wallet = Address::generate(&e); + let recipient = Address::generate(&e); + let release_timestamp = e.ledger().timestamp().saturating_add(60); + let locks = vec![&e, LockedTokens { amount: 80, release_timestamp }]; + let (module_address, client) = create_client(&e, &admin); + let compliance_id = e.register(MockComplianceContract, ()); + let compliance = MockComplianceContractClient::new(&e, &compliance_id); + + client.set_compliance_address(&compliance_id); + for hook in [ + ComplianceHook::CanTransfer, + ComplianceHook::Created, + ComplianceHook::Transferred, + ComplianceHook::Destroyed, + ] { + compliance.register_hook(&hook, &module_address); + } + + client.verify_hook_wiring(); + client.pre_set_lockup_state(&token, &wallet, &100, &locks); + + assert!(!client.can_transfer(&wallet, &recipient, &21, &token)); + assert!(client.can_transfer(&wallet, &recipient, &20, &token)); +} diff --git a/examples/rwa-max-balance/Cargo.toml b/examples/rwa-max-balance/Cargo.toml index 1238118f2..066fdcb4c 100644 --- a/examples/rwa-max-balance/Cargo.toml +++ b/examples/rwa-max-balance/Cargo.toml @@ -5,6 +5,10 @@ license.workspace = true repository.workspace = true publish = false version.workspace = true +authors.workspace = true + +[package.metadata.stellar] +cargo_inherit = true [lib] crate-type = ["cdylib", "rlib"] @@ -13,3 +17,6 @@ doctest = false [dependencies] soroban-sdk = { workspace = true } stellar-tokens = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/examples/rwa-max-balance/src/lib.rs b/examples/rwa-max-balance/src/lib.rs index b9aa4d5f0..a879b6f80 100644 --- a/examples/rwa-max-balance/src/lib.rs +++ b/examples/rwa-max-balance/src/lib.rs @@ -1,3 +1,5 @@ #![no_std] pub mod contract; +#[cfg(test)] +mod test; diff --git a/examples/rwa-max-balance/src/test.rs b/examples/rwa-max-balance/src/test.rs new file mode 100644 index 000000000..3beb493c1 --- /dev/null +++ b/examples/rwa-max-balance/src/test.rs @@ -0,0 +1,303 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, String, Val, + Vec, +}; +use stellar_tokens::rwa::{ + compliance::{Compliance, ComplianceHook}, + identity_registry_storage::IdentityRegistryStorage, + utils::token_binder::TokenBinder, +}; + +use crate::contract::{MaxBalanceContract, MaxBalanceContractClient}; + +fn create_client<'a>(e: &Env, admin: &Address) -> (Address, MaxBalanceContractClient<'a>) { + let address = e.register(MaxBalanceContract, (admin,)); + (address.clone(), MaxBalanceContractClient::new(e, &address)) +} + +#[contract] +struct MockIRSContract; + +#[contracttype] +#[derive(Clone)] +enum MockIRSStorageKey { + Identity(Address), +} + +#[contractimpl] +impl TokenBinder for MockIRSContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl IdentityRegistryStorage for MockIRSContract { + fn add_identity( + _e: &Env, + _account: Address, + _identity: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_identity is not used in these tests"); + } + + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { + unreachable!("remove_identity is not used in these tests"); + } + + fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { + unreachable!("modify_identity is not used in these tests"); + } + + fn recover_identity( + _e: &Env, + _old_account: Address, + _new_account: Address, + _operator: Address, + ) { + unreachable!("recover_identity is not used in these tests"); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + e.storage() + .persistent() + .get(&MockIRSStorageKey::Identity(account.clone())) + .unwrap_or(account) + } +} + +#[contractimpl] +impl MockIRSContract { + pub fn set_identity(e: &Env, account: Address, identity: Address) { + e.storage().persistent().set(&MockIRSStorageKey::Identity(account), &identity); + } +} + +#[contract] +struct MockComplianceContract; + +#[contracttype] +#[derive(Clone)] +enum MockComplianceStorageKey { + Registered(ComplianceHook, Address), +} + +#[contractimpl] +impl Compliance for MockComplianceContract { + fn add_module_to(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("add_module_to is not used in these tests"); + } + + fn remove_module_from(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("remove_module_from is not used in these tests"); + } + + fn get_modules_for_hook(_e: &Env, _hook: ComplianceHook) -> Vec
{ + unreachable!("get_modules_for_hook is not used in these tests"); + } + + fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool { + e.storage().persistent().has(&MockComplianceStorageKey::Registered(hook, module)) + } + + fn transferred(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) { + unreachable!("transferred is not used in these tests"); + } + + fn created(_e: &Env, _to: Address, _amount: i128, _token: Address) { + unreachable!("created is not used in these tests"); + } + + fn destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) { + unreachable!("destroyed is not used in these tests"); + } + + fn can_transfer( + _e: &Env, + _from: Address, + _to: Address, + _amount: i128, + _token: Address, + ) -> bool { + unreachable!("can_transfer is not used in these tests"); + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + unreachable!("can_create is not used in these tests"); + } +} + +#[contractimpl] +impl TokenBinder for MockComplianceContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl MockComplianceContract { + pub fn register_hook(e: &Env, hook: ComplianceHook, module: Address) { + e.storage().persistent().set(&MockComplianceStorageKey::Registered(hook, module), &true); + } +} + +#[test] +fn set_and_get_max_balance_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_max_balance(&token, &100); + + assert_eq!(client.get_max_balance(&token), 100); +} + +#[test] +fn pre_set_identity_balances_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let identity_a = Address::generate(&e); + let identity_b = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.pre_set_identity_balance(&token, &identity_a, &40); + client.batch_pre_set_identity_balances( + &token, + &vec![&e, identity_a.clone(), identity_b.clone()], + &vec![&e, 50_i128, 20_i128], + ); + + assert_eq!(client.get_investor_balance(&token, &identity_a), 50); + assert_eq!(client.get_investor_balance(&token, &identity_b), 20); +} + +#[test] +fn name_compliance_address_and_required_hooks_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + assert_eq!(client.name(), String::from_str(&e, "MaxBalanceModule")); + assert_eq!( + client.required_hooks(), + vec![ + &e, + ComplianceHook::CanTransfer, + ComplianceHook::CanCreate, + ComplianceHook::Transferred, + ComplianceHook::Created, + ComplianceHook::Destroyed, + ] + ); + + client.set_compliance_address(&compliance); + assert_eq!(client.get_compliance_address(), compliance); +} + +#[test] +fn set_identity_registry_storage_uses_admin_auth_before_compliance_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let irs = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_identity_registry_storage(&token, &irs); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); +} + +#[test] +fn set_identity_registry_storage_uses_compliance_auth_after_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let irs = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_compliance_address(&compliance); + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); + + client.set_identity_registry_storage(&token, &irs); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &compliance); +} + +#[test] +fn can_create_and_can_transfer_use_identity_caps() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let recipient = Address::generate(&e); + let sender_identity = Address::generate(&e); + let recipient_identity = Address::generate(&e); + let (module_address, client) = create_client(&e, &admin); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let compliance_id = e.register(MockComplianceContract, ()); + let compliance = MockComplianceContractClient::new(&e, &compliance_id); + + irs.set_identity(&sender, &sender_identity); + irs.set_identity(&recipient, &recipient_identity); + + client.set_compliance_address(&compliance_id); + for hook in [ + ComplianceHook::CanTransfer, + ComplianceHook::CanCreate, + ComplianceHook::Transferred, + ComplianceHook::Created, + ComplianceHook::Destroyed, + ] { + compliance.register_hook(&hook, &module_address); + } + + client.verify_hook_wiring(); + client.set_identity_registry_storage(&token, &irs_id); + client.set_max_balance(&token, &100); + client.pre_set_identity_balance(&token, &recipient_identity, &60); + + assert!(!client.can_create(&recipient, &50, &token)); + assert!(client.can_create(&recipient, &40, &token)); + assert!(!client.can_transfer(&sender, &recipient, &50, &token)); + assert!(client.can_transfer(&sender, &recipient, &40, &token)); +} diff --git a/examples/rwa-supply-limit/Cargo.toml b/examples/rwa-supply-limit/Cargo.toml index b4d0e313c..94977d387 100644 --- a/examples/rwa-supply-limit/Cargo.toml +++ b/examples/rwa-supply-limit/Cargo.toml @@ -5,6 +5,10 @@ license.workspace = true repository.workspace = true publish = false version.workspace = true +authors.workspace = true + +[package.metadata.stellar] +cargo_inherit = true [lib] crate-type = ["cdylib", "rlib"] @@ -13,3 +17,6 @@ doctest = false [dependencies] soroban-sdk = { workspace = true } stellar-tokens = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/examples/rwa-supply-limit/src/lib.rs b/examples/rwa-supply-limit/src/lib.rs index b9aa4d5f0..a879b6f80 100644 --- a/examples/rwa-supply-limit/src/lib.rs +++ b/examples/rwa-supply-limit/src/lib.rs @@ -1,3 +1,5 @@ #![no_std] pub mod contract; +#[cfg(test)] +mod test; diff --git a/examples/rwa-supply-limit/src/test.rs b/examples/rwa-supply-limit/src/test.rs new file mode 100644 index 000000000..728fee1f5 --- /dev/null +++ b/examples/rwa-supply-limit/src/test.rs @@ -0,0 +1,194 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, String, Vec, +}; +use stellar_tokens::rwa::{ + compliance::{Compliance, ComplianceHook}, + utils::token_binder::TokenBinder, +}; + +use crate::contract::{SupplyLimitContract, SupplyLimitContractClient}; + +fn create_client<'a>(e: &Env, admin: &Address) -> (Address, SupplyLimitContractClient<'a>) { + let address = e.register(SupplyLimitContract, (admin,)); + (address.clone(), SupplyLimitContractClient::new(e, &address)) +} + +#[contract] +struct MockComplianceContract; + +#[contracttype] +#[derive(Clone)] +enum MockComplianceStorageKey { + Registered(ComplianceHook, Address), +} + +#[contractimpl] +impl Compliance for MockComplianceContract { + fn add_module_to(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("add_module_to is not used in these tests"); + } + + fn remove_module_from(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("remove_module_from is not used in these tests"); + } + + fn get_modules_for_hook(_e: &Env, _hook: ComplianceHook) -> Vec
{ + unreachable!("get_modules_for_hook is not used in these tests"); + } + + fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool { + e.storage().persistent().has(&MockComplianceStorageKey::Registered(hook, module)) + } + + fn transferred(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) { + unreachable!("transferred is not used in these tests"); + } + + fn created(_e: &Env, _to: Address, _amount: i128, _token: Address) { + unreachable!("created is not used in these tests"); + } + + fn destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) { + unreachable!("destroyed is not used in these tests"); + } + + fn can_transfer( + _e: &Env, + _from: Address, + _to: Address, + _amount: i128, + _token: Address, + ) -> bool { + unreachable!("can_transfer is not used in these tests"); + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + unreachable!("can_create is not used in these tests"); + } +} + +#[contractimpl] +impl TokenBinder for MockComplianceContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl MockComplianceContract { + pub fn register_hook(e: &Env, hook: ComplianceHook, module: Address) { + e.storage().persistent().set(&MockComplianceStorageKey::Registered(hook, module), &true); + } +} + +#[test] +fn set_supply_limit_and_pre_set_supply_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_supply_limit(&token, &100); + client.pre_set_supply(&token, &60); + + assert_eq!(client.get_supply_limit(&token), 100); + assert_eq!(client.get_internal_supply(&token), 60); +} + +#[test] +fn name_compliance_address_and_required_hooks_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + assert_eq!(client.name(), String::from_str(&e, "SupplyLimitModule")); + assert_eq!( + client.required_hooks(), + vec![&e, ComplianceHook::CanCreate, ComplianceHook::Created, ComplianceHook::Destroyed,] + ); + + client.set_compliance_address(&compliance); + assert_eq!(client.get_compliance_address(), compliance); +} + +#[test] +fn set_supply_limit_uses_admin_auth_before_compliance_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_supply_limit(&token, &100); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); +} + +#[test] +fn set_supply_limit_uses_compliance_auth_after_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_compliance_address(&compliance); + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); + + client.set_supply_limit(&token, &100); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &compliance); +} + +#[test] +fn can_create_and_hooks_update_internal_supply() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let account = Address::generate(&e); + let (module_address, client) = create_client(&e, &admin); + let compliance_id = e.register(MockComplianceContract, ()); + let compliance = MockComplianceContractClient::new(&e, &compliance_id); + + client.set_compliance_address(&compliance_id); + for hook in [ComplianceHook::CanCreate, ComplianceHook::Created, ComplianceHook::Destroyed] { + compliance.register_hook(&hook, &module_address); + } + + client.verify_hook_wiring(); + client.set_supply_limit(&token, &100); + + assert!(client.can_create(&account, &80, &token)); + + client.on_created(&account, &80, &token); + assert_eq!(client.get_internal_supply(&token), 80); + assert!(!client.can_create(&account, &30, &token)); + + client.on_destroyed(&account, &20, &token); + assert_eq!(client.get_internal_supply(&token), 60); + assert!(client.can_create(&account, &40, &token)); +} diff --git a/examples/rwa-time-transfers-limits/Cargo.toml b/examples/rwa-time-transfers-limits/Cargo.toml index 6b71f752c..ab02105e5 100644 --- a/examples/rwa-time-transfers-limits/Cargo.toml +++ b/examples/rwa-time-transfers-limits/Cargo.toml @@ -5,6 +5,10 @@ license.workspace = true repository.workspace = true publish = false version.workspace = true +authors.workspace = true + +[package.metadata.stellar] +cargo_inherit = true [lib] crate-type = ["cdylib", "rlib"] @@ -13,3 +17,6 @@ doctest = false [dependencies] soroban-sdk = { workspace = true } stellar-tokens = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/examples/rwa-time-transfers-limits/src/lib.rs b/examples/rwa-time-transfers-limits/src/lib.rs index b9aa4d5f0..a879b6f80 100644 --- a/examples/rwa-time-transfers-limits/src/lib.rs +++ b/examples/rwa-time-transfers-limits/src/lib.rs @@ -1,3 +1,5 @@ #![no_std] pub mod contract; +#[cfg(test)] +mod test; diff --git a/examples/rwa-time-transfers-limits/src/test.rs b/examples/rwa-time-transfers-limits/src/test.rs new file mode 100644 index 000000000..7c75c6f2a --- /dev/null +++ b/examples/rwa-time-transfers-limits/src/test.rs @@ -0,0 +1,316 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, String, Val, + Vec, +}; +use stellar_tokens::rwa::{ + compliance::{ + modules::time_transfers_limits::{Limit, TransferCounter}, + Compliance, ComplianceHook, + }, + identity_registry_storage::{CountryDataManager, IdentityRegistryStorage}, + utils::token_binder::TokenBinder, +}; + +use crate::contract::{TimeTransfersLimitsContract, TimeTransfersLimitsContractClient}; + +fn create_client<'a>(e: &Env, admin: &Address) -> (Address, TimeTransfersLimitsContractClient<'a>) { + let address = e.register(TimeTransfersLimitsContract, (admin,)); + (address.clone(), TimeTransfersLimitsContractClient::new(e, &address)) +} + +#[contract] +struct MockIRSContract; + +#[contracttype] +#[derive(Clone)] +enum MockIRSStorageKey { + Identity(Address), +} + +#[contractimpl] +impl TokenBinder for MockIRSContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl IdentityRegistryStorage for MockIRSContract { + fn add_identity( + _e: &Env, + _account: Address, + _identity: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_identity is not used in these tests"); + } + + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { + unreachable!("remove_identity is not used in these tests"); + } + + fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { + unreachable!("modify_identity is not used in these tests"); + } + + fn recover_identity( + _e: &Env, + _old_account: Address, + _new_account: Address, + _operator: Address, + ) { + unreachable!("recover_identity is not used in these tests"); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + e.storage() + .persistent() + .get(&MockIRSStorageKey::Identity(account.clone())) + .unwrap_or(account) + } +} + +#[contractimpl] +impl CountryDataManager for MockIRSContract { + fn add_country_data_entries( + _e: &Env, + _account: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_country_data_entries is not used in these tests"); + } + + fn modify_country_data( + _e: &Env, + _account: Address, + _index: u32, + _country_data: Val, + _operator: Address, + ) { + unreachable!("modify_country_data is not used in these tests"); + } + + fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { + unreachable!("delete_country_data is not used in these tests"); + } + + fn get_country_data_entries(e: &Env, _account: Address) -> Vec { + Vec::new(e) + } +} + +#[contractimpl] +impl MockIRSContract { + pub fn set_identity(e: &Env, account: Address, identity: Address) { + e.storage().persistent().set(&MockIRSStorageKey::Identity(account), &identity); + } +} + +#[contract] +struct MockComplianceContract; + +#[contracttype] +#[derive(Clone)] +enum MockComplianceStorageKey { + Registered(ComplianceHook, Address), +} + +#[contractimpl] +impl Compliance for MockComplianceContract { + fn add_module_to(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("add_module_to is not used in these tests"); + } + + fn remove_module_from(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("remove_module_from is not used in these tests"); + } + + fn get_modules_for_hook(_e: &Env, _hook: ComplianceHook) -> Vec
{ + unreachable!("get_modules_for_hook is not used in these tests"); + } + + fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool { + e.storage().persistent().has(&MockComplianceStorageKey::Registered(hook, module)) + } + + fn transferred(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) { + unreachable!("transferred is not used in these tests"); + } + + fn created(_e: &Env, _to: Address, _amount: i128, _token: Address) { + unreachable!("created is not used in these tests"); + } + + fn destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) { + unreachable!("destroyed is not used in these tests"); + } + + fn can_transfer( + _e: &Env, + _from: Address, + _to: Address, + _amount: i128, + _token: Address, + ) -> bool { + unreachable!("can_transfer is not used in these tests"); + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + unreachable!("can_create is not used in these tests"); + } +} + +#[contractimpl] +impl TokenBinder for MockComplianceContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl MockComplianceContract { + pub fn register_hook(e: &Env, hook: ComplianceHook, module: Address) { + e.storage().persistent().set(&MockComplianceStorageKey::Registered(hook, module), &true); + } +} + +#[test] +fn set_and_manage_time_transfer_limits_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let limit_a = Limit { limit_time: 60, limit_value: 100 }; + let limit_b = Limit { limit_time: 120, limit_value: 200 }; + let (_address, client) = create_client(&e, &admin); + + client.set_time_transfer_limit(&token, &limit_a); + client.batch_set_time_transfer_limit(&token, &vec![&e, limit_b.clone()]); + + assert_eq!(client.get_time_transfer_limits(&token), vec![&e, limit_a.clone(), limit_b.clone()]); + + client.batch_remove_time_transfer_limit(&token, &vec![&e, 120_u64]); + assert_eq!(client.get_time_transfer_limits(&token), vec![&e, limit_a.clone()]); + + client.remove_time_transfer_limit(&token, &60); + assert_eq!(client.get_time_transfer_limits(&token).len(), 0); +} + +#[test] +fn name_compliance_address_and_required_hooks_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + assert_eq!(client.name(), String::from_str(&e, "TimeTransfersLimitsModule")); + assert_eq!( + client.required_hooks(), + vec![&e, ComplianceHook::CanTransfer, ComplianceHook::Transferred] + ); + + client.set_compliance_address(&compliance); + assert_eq!(client.get_compliance_address(), compliance); +} + +#[test] +fn set_identity_registry_storage_uses_admin_auth_before_compliance_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let irs = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_identity_registry_storage(&token, &irs); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); +} + +#[test] +fn set_identity_registry_storage_uses_compliance_auth_after_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let irs = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_compliance_address(&compliance); + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); + + client.set_identity_registry_storage(&token, &irs); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &compliance); +} + +#[test] +fn verify_hook_wiring_and_counters_affect_public_transfer_checks() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let recipient = Address::generate(&e); + let sender_identity = Address::generate(&e); + let limit = Limit { limit_time: 60, limit_value: 100 }; + let (module_address, client) = create_client(&e, &admin); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let compliance_id = e.register(MockComplianceContract, ()); + let compliance = MockComplianceContractClient::new(&e, &compliance_id); + + irs.set_identity(&sender, &sender_identity); + + client.set_compliance_address(&compliance_id); + for hook in [ComplianceHook::CanTransfer, ComplianceHook::Transferred] { + compliance.register_hook(&hook, &module_address); + } + + client.verify_hook_wiring(); + client.set_identity_registry_storage(&token, &irs_id); + client.set_time_transfer_limit(&token, &limit); + client.pre_set_transfer_counter( + &token, + &sender_identity, + &60, + &TransferCounter { value: 90, timer: e.ledger().timestamp().saturating_add(60) }, + ); + + assert!(!client.can_transfer(&sender, &recipient, &11, &token)); + assert!(client.can_transfer(&sender, &recipient, &10, &token)); + + client.on_transfer(&sender, &recipient, &10, &token); + assert!(!client.can_transfer(&sender, &recipient, &1, &token)); +} diff --git a/examples/rwa-transfer-restrict/Cargo.toml b/examples/rwa-transfer-restrict/Cargo.toml index 9655c300d..e6e333e09 100644 --- a/examples/rwa-transfer-restrict/Cargo.toml +++ b/examples/rwa-transfer-restrict/Cargo.toml @@ -5,6 +5,10 @@ license.workspace = true repository.workspace = true publish = false version.workspace = true +authors.workspace = true + +[package.metadata.stellar] +cargo_inherit = true [lib] crate-type = ["cdylib", "rlib"] @@ -13,3 +17,6 @@ doctest = false [dependencies] soroban-sdk = { workspace = true } stellar-tokens = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/examples/rwa-transfer-restrict/src/lib.rs b/examples/rwa-transfer-restrict/src/lib.rs index b9aa4d5f0..a879b6f80 100644 --- a/examples/rwa-transfer-restrict/src/lib.rs +++ b/examples/rwa-transfer-restrict/src/lib.rs @@ -1,3 +1,5 @@ #![no_std] pub mod contract; +#[cfg(test)] +mod test; diff --git a/examples/rwa-transfer-restrict/src/test.rs b/examples/rwa-transfer-restrict/src/test.rs new file mode 100644 index 000000000..7c5f07871 --- /dev/null +++ b/examples/rwa-transfer-restrict/src/test.rs @@ -0,0 +1,97 @@ +extern crate std; + +use soroban_sdk::{testutils::Address as _, vec, Address, Env, String}; + +use crate::contract::{TransferRestrictContract, TransferRestrictContractClient}; + +fn create_client<'a>(e: &Env, admin: &Address) -> (Address, TransferRestrictContractClient<'a>) { + let address = e.register(TransferRestrictContract, (admin,)); + (address.clone(), TransferRestrictContractClient::new(e, &address)) +} + +#[test] +fn allowlist_methods_and_can_transfer_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let recipient = Address::generate(&e); + let other = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + assert!(!client.is_user_allowed(&token, &sender)); + assert!(!client.can_transfer(&sender, &recipient, &1, &token)); + + client.allow_user(&token, &sender); + assert!(client.is_user_allowed(&token, &sender)); + assert!(client.can_transfer(&sender, &other, &1, &token)); + + client.disallow_user(&token, &sender); + assert!(!client.is_user_allowed(&token, &sender)); + + client.batch_allow_users(&token, &vec![&e, recipient.clone(), other.clone()]); + assert!(client.is_user_allowed(&token, &recipient)); + assert!(client.is_user_allowed(&token, &other)); + assert!(client.can_transfer(&sender, &recipient, &1, &token)); + + client.batch_disallow_users(&token, &vec![&e, recipient.clone(), other.clone()]); + assert!(!client.is_user_allowed(&token, &recipient)); + assert!(!client.is_user_allowed(&token, &other)); + assert!(!client.can_transfer(&sender, &recipient, &1, &token)); +} + +#[test] +fn name_and_compliance_address_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + assert_eq!(client.name(), String::from_str(&e, "TransferRestrictModule")); + + client.set_compliance_address(&compliance); + assert_eq!(client.get_compliance_address(), compliance); +} + +#[test] +fn allow_user_uses_admin_auth_before_compliance_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let user = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.allow_user(&token, &user); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); +} + +#[test] +fn allow_user_uses_compliance_auth_after_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let user = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_compliance_address(&compliance); + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); + + client.allow_user(&token, &user); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &compliance); +} diff --git a/packages/tokens/src/rwa/compliance/modules/country_allow/storage.rs b/packages/tokens/src/rwa/compliance/modules/country_allow/storage.rs index 91b748145..d47da3698 100644 --- a/packages/tokens/src/rwa/compliance/modules/country_allow/storage.rs +++ b/packages/tokens/src/rwa/compliance/modules/country_allow/storage.rs @@ -123,7 +123,7 @@ pub fn batch_disallow_countries(e: &Env, token: &Address, countries: &Vec) // ################## COMPLIANCE HOOKS ################## -/// Checks whether `to` has at least one allowed country in the IRS for +/// Returns `true` if `to` has at least one allowed country in the IRS for /// `token`. /// /// # Arguments @@ -132,10 +132,10 @@ pub fn batch_disallow_countries(e: &Env, token: &Address, countries: &Vec) /// * `to` - The recipient whose country data is checked. /// * `token` - The token address. /// -/// # Returns +/// # Errors /// -/// `true` if the recipient has at least one allowed country, `false` -/// otherwise. +/// * [`crate::rwa::compliance::modules::ComplianceModuleError::IdentityRegistryNotSet`] +/// - When no IRS has been configured for `token`. /// /// # Cross-Contract Calls /// diff --git a/packages/tokens/src/rwa/compliance/modules/country_restrict/storage.rs b/packages/tokens/src/rwa/compliance/modules/country_restrict/storage.rs index 632e2321b..be7c0512e 100644 --- a/packages/tokens/src/rwa/compliance/modules/country_restrict/storage.rs +++ b/packages/tokens/src/rwa/compliance/modules/country_restrict/storage.rs @@ -123,7 +123,8 @@ pub fn batch_unrestrict_countries(e: &Env, token: &Address, countries: &Vec // ################## COMPLIANCE HOOKS ################## -/// Checks whether `to` has any restricted country in the IRS for `token`. +/// Returns `false` if `to` has any restricted country in the IRS for `token`, +/// and `true` otherwise. /// /// # Arguments /// @@ -131,9 +132,10 @@ pub fn batch_unrestrict_countries(e: &Env, token: &Address, countries: &Vec /// * `to` - The recipient whose country data is checked. /// * `token` - The token address. /// -/// # Returns +/// # Errors /// -/// `false` if the recipient has any restricted country, `true` otherwise. +/// * [`crate::rwa::compliance::modules::ComplianceModuleError::IdentityRegistryNotSet`] +/// - When no IRS has been configured for `token`. /// /// # Cross-Contract Calls /// diff --git a/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/storage.rs b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/storage.rs index 3bff8f5ef..8224d7ad1 100644 --- a/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/storage.rs +++ b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/storage.rs @@ -380,7 +380,8 @@ pub fn on_destroyed(e: &Env, from: &Address, amount: i128, token: &Address) { set_internal_balance(e, token, from, sub_i128_or_panic(e, current, amount)); } -/// Checks whether a transfer is allowed based on lockup restrictions. +/// Returns `true` if the sender has sufficient unlocked balance for the +/// transfer. /// /// # Arguments /// @@ -388,10 +389,6 @@ pub fn on_destroyed(e: &Env, from: &Address, amount: i128, token: &Address) { /// * `from` - The sender address. /// * `amount` - The transfer amount. /// * `token` - The token address. -/// -/// # Returns -/// -/// `true` if the sender has sufficient unlocked balance. pub fn can_transfer(e: &Env, from: &Address, amount: i128, token: &Address) -> bool { assert!( hooks_verified(e), diff --git a/packages/tokens/src/rwa/compliance/modules/max_balance/test.rs b/packages/tokens/src/rwa/compliance/modules/max_balance/test.rs index bf208fb8d..c45ae068d 100644 --- a/packages/tokens/src/rwa/compliance/modules/max_balance/test.rs +++ b/packages/tokens/src/rwa/compliance/modules/max_balance/test.rs @@ -5,7 +5,8 @@ use soroban_sdk::{ }; use super::storage::{ - can_create, can_transfer, set_id_balance, set_max_balance, verify_hook_wiring, + can_create, can_transfer, get_id_balance, on_created, on_destroyed, on_transfer, + set_id_balance, set_max_balance, verify_hook_wiring, }; use crate::rwa::{ compliance::{ @@ -309,3 +310,138 @@ fn can_create_rejects_negative_amount_before_requiring_irs() { assert!(!can_create(&e, &recipient, -1, &token)); }); } + +#[test] +fn can_transfer_rejects_negative_amount_before_requiring_irs() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let recipient = Address::generate(&e); + + e.as_contract(&module_id, || { + arm_hooks(&e); + + assert!(!can_transfer(&e, &sender, &recipient, -1, &token)); + }); +} + +#[test] +fn can_transfer_allows_when_sender_and_recipient_share_identity() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let recipient = Address::generate(&e); + let shared_identity = Address::generate(&e); + + irs.set_identity(&sender, &shared_identity); + irs.set_identity(&recipient, &shared_identity); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + arm_hooks(&e); + set_max_balance(&e, &token, 1); + + assert!(can_transfer(&e, &sender, &recipient, 1_000, &token)); + }); +} + +#[test] +fn on_transfer_updates_balances_for_distinct_identities() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let recipient = Address::generate(&e); + let sender_identity = Address::generate(&e); + let recipient_identity = Address::generate(&e); + + irs.set_identity(&sender, &sender_identity); + irs.set_identity(&recipient, &recipient_identity); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_max_balance(&e, &token, 200); + set_id_balance(&e, &token, &sender_identity, 100); + set_id_balance(&e, &token, &recipient_identity, 20); + + on_transfer(&e, &sender, &recipient, 30, &token); + + assert_eq!(get_id_balance(&e, &token, &sender_identity), 70); + assert_eq!(get_id_balance(&e, &token, &recipient_identity), 50); + }); +} + +#[test] +fn on_transfer_is_noop_for_same_identity() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let recipient = Address::generate(&e); + let shared_identity = Address::generate(&e); + + irs.set_identity(&sender, &shared_identity); + irs.set_identity(&recipient, &shared_identity); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_id_balance(&e, &token, &shared_identity, 100); + + on_transfer(&e, &sender, &recipient, 30, &token); + + assert_eq!(get_id_balance(&e, &token, &shared_identity), 100); + }); +} + +#[test] +fn on_created_updates_identity_balance() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let recipient = Address::generate(&e); + let recipient_identity = Address::generate(&e); + + irs.set_identity(&recipient, &recipient_identity); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_max_balance(&e, &token, 200); + set_id_balance(&e, &token, &recipient_identity, 50); + + on_created(&e, &recipient, 30, &token); + + assert_eq!(get_id_balance(&e, &token, &recipient_identity), 80); + }); +} + +#[test] +fn on_destroyed_updates_identity_balance() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let holder = Address::generate(&e); + let holder_identity = Address::generate(&e); + + irs.set_identity(&holder, &holder_identity); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_id_balance(&e, &token, &holder_identity, 90); + + on_destroyed(&e, &holder, 40, &token); + + assert_eq!(get_id_balance(&e, &token, &holder_identity), 50); + }); +} diff --git a/packages/tokens/src/rwa/compliance/modules/supply_limit/test.rs b/packages/tokens/src/rwa/compliance/modules/supply_limit/test.rs index 7047b8bef..18245ba92 100644 --- a/packages/tokens/src/rwa/compliance/modules/supply_limit/test.rs +++ b/packages/tokens/src/rwa/compliance/modules/supply_limit/test.rs @@ -3,8 +3,8 @@ extern crate std; use soroban_sdk::{contract, contractimpl, contracttype, testutils::Address as _, Address, Env}; use super::storage::{ - can_create, configure_supply_limit, get_internal_supply, on_created, on_destroyed, - pre_set_supply, verify_hook_wiring, + can_create, configure_supply_limit, get_internal_supply, get_supply_limit_or_panic, on_created, + on_destroyed, pre_set_supply, verify_hook_wiring, }; use crate::rwa::{ compliance::{ @@ -180,3 +180,28 @@ fn pre_set_internal_supply_seeds_existing_supply_for_cap_checks() { assert!(can_create(&e, 10, &token)); }); } + +#[test] +fn get_supply_limit_or_panic_returns_configured_limit() { + let e = Env::default(); + let module_id = e.register(TestSupplyLimitContract, ()); + let token = Address::generate(&e); + + e.as_contract(&module_id, || { + configure_supply_limit(&e, &token, 100); + + assert_eq!(get_supply_limit_or_panic(&e, &token), 100); + }); +} + +#[test] +#[should_panic] +fn get_supply_limit_or_panic_panics_when_unconfigured() { + let e = Env::default(); + let module_id = e.register(TestSupplyLimitContract, ()); + let token = Address::generate(&e); + + e.as_contract(&module_id, || { + let _ = get_supply_limit_or_panic(&e, &token); + }); +} diff --git a/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/storage.rs b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/storage.rs index 023f200cd..b46cde1a6 100644 --- a/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/storage.rs +++ b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/storage.rs @@ -306,7 +306,8 @@ pub fn on_transfer(e: &Env, from: &Address, amount: i128, token: &Address) { increase_counters(e, token, &from_id, amount); } -/// Checks whether a transfer is within the configured time-window limits. +/// Returns `true` if the transfer does not exceed any configured +/// time-window limit. /// /// # Arguments /// @@ -315,9 +316,10 @@ pub fn on_transfer(e: &Env, from: &Address, amount: i128, token: &Address) { /// * `amount` - The transfer amount. /// * `token` - The token address. /// -/// # Returns +/// # Errors /// -/// `true` if the transfer does not exceed any limit. +/// * [`crate::rwa::compliance::modules::ComplianceModuleError::IdentityRegistryNotSet`] +/// - When no IRS has been configured for `token`. pub fn can_transfer(e: &Env, from: &Address, amount: i128, token: &Address) -> bool { assert!( hooks_verified(e), diff --git a/packages/tokens/src/rwa/compliance/modules/transfer_restrict/storage.rs b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/storage.rs index 8a02b799e..77337ae0c 100644 --- a/packages/tokens/src/rwa/compliance/modules/transfer_restrict/storage.rs +++ b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/storage.rs @@ -116,7 +116,7 @@ pub fn batch_disallow_users(e: &Env, token: &Address, users: &Vec
) { // ################## COMPLIANCE HOOKS ################## -/// Checks whether the transfer is allowed by the address allowlist. +/// Returns `true` if the sender or recipient is allowlisted. /// /// T-REX semantics: if the sender is allowlisted, the transfer passes; /// otherwise the recipient must be allowlisted. @@ -127,10 +127,6 @@ pub fn batch_disallow_users(e: &Env, token: &Address, users: &Vec
) { /// * `from` - The sender address. /// * `to` - The recipient address. /// * `token` - The token address. -/// -/// # Returns -/// -/// `true` if the sender or recipient is allowlisted, `false` otherwise. pub fn can_transfer(e: &Env, from: &Address, to: &Address, token: &Address) -> bool { if is_user_allowed(e, token, from) { return true;