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)); +}