diff --git a/Cargo.lock b/Cargo.lock index 1432a322d..473be0441 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1578,12 +1578,36 @@ dependencies = [ name = "rwa-compliance-example" version = "0.7.1" 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.7.1" +dependencies = [ + "soroban-sdk", + "stellar-tokens", +] + +[[package]] +name = "rwa-country-restrict" +version = "0.7.1" +dependencies = [ + "soroban-sdk", + "stellar-tokens", +] + [[package]] name = "rwa-identity-example" version = "0.7.1" @@ -1614,6 +1638,38 @@ dependencies = [ "stellar-tokens", ] +[[package]] +name = "rwa-initial-lockup-period" +version = "0.7.1" +dependencies = [ + "soroban-sdk", + "stellar-tokens", +] + +[[package]] +name = "rwa-max-balance" +version = "0.7.1" +dependencies = [ + "soroban-sdk", + "stellar-tokens", +] + +[[package]] +name = "rwa-supply-limit" +version = "0.7.1" +dependencies = [ + "soroban-sdk", + "stellar-tokens", +] + +[[package]] +name = "rwa-time-transfers-limits" +version = "0.7.1" +dependencies = [ + "soroban-sdk", + "stellar-tokens", +] + [[package]] name = "rwa-token-example" version = "0.7.1" @@ -1625,6 +1681,14 @@ dependencies = [ "stellar-tokens", ] +[[package]] +name = "rwa-transfer-restrict" +version = "0.7.1" +dependencies = [ + "soroban-sdk", + "stellar-tokens", +] + [[package]] name = "ryu" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 656eca1fe..75a3f0a0d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,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..89a5f2c7e --- /dev/null +++ b/examples/rwa-compliance/src/test.rs @@ -0,0 +1,1042 @@ +extern crate std; + +use rwa_country_allow::contract::{CountryAllowContract, CountryAllowContractClient}; +use rwa_country_restrict::contract::{CountryRestrictContract, CountryRestrictContractClient}; +use rwa_initial_lockup_period::contract::{ + InitialLockupPeriodContract, InitialLockupPeriodContractClient, +}; +use rwa_max_balance::contract::{MaxBalanceContract, MaxBalanceContractClient}; +use rwa_supply_limit::contract::{SupplyLimitContract, SupplyLimitContractClient}; +use rwa_time_transfers_limits::contract::{ + TimeTransfersLimitsContract, TimeTransfersLimitsContractClient, +}; +use rwa_transfer_restrict::contract::{TransferRestrictContract, TransferRestrictContractClient}; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + vec, Address, Env, IntoVal, String, +}; +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..540b26786 --- /dev/null +++ b/examples/rwa-country-allow/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "rwa-country-allow" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true +authors.workspace = true + +[package.metadata.stellar] +cargo_inherit = true + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-tokens = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/examples/rwa-country-allow/src/contract.rs b/examples/rwa-country-allow/src/contract.rs new file mode 100644 index 000000000..9be520c25 --- /dev/null +++ b/examples/rwa-country-allow/src/contract.rs @@ -0,0 +1,101 @@ +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::modules::{ + country_allow::storage as country_allow, + storage::{ + get_compliance_address, module_name, set_compliance_address, set_irs_address, + ComplianceModuleStorageKey, + }, + ComplianceModule, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct CountryAllowContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl CountryAllowContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } + + pub fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { + require_module_admin_or_compliance_auth(e); + set_irs_address(e, &token, &irs); + } + + pub fn add_allowed_country(e: &Env, token: Address, country: u32) { + require_module_admin_or_compliance_auth(e); + country_allow::add_allowed_country(e, &token, country); + } + + pub fn remove_allowed_country(e: &Env, token: Address, country: u32) { + require_module_admin_or_compliance_auth(e); + country_allow::remove_allowed_country(e, &token, country); + } + + pub fn batch_allow_countries(e: &Env, token: Address, countries: Vec) { + require_module_admin_or_compliance_auth(e); + country_allow::batch_allow_countries(e, &token, &countries); + } + + pub fn batch_disallow_countries(e: &Env, token: Address, countries: Vec) { + require_module_admin_or_compliance_auth(e); + country_allow::batch_disallow_countries(e, &token, &countries); + } + + pub fn is_country_allowed(e: &Env, token: Address, country: u32) -> bool { + country_allow::is_country_allowed(e, &token, country) + } +} + +#[contractimpl(contracttrait)] +impl ComplianceModule for CountryAllowContract { + fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {} + + fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {} + + fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {} + + fn can_transfer(e: &Env, _from: Address, to: Address, _amount: i128, token: Address) -> bool { + country_allow::can_transfer(e, &to, &token) + } + + fn can_create(e: &Env, to: Address, _amount: i128, token: Address) -> bool { + country_allow::can_transfer(e, &to, &token) + } + + fn name(e: &Env) -> String { + module_name(e, "CountryAllowModule") + } + + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-country-allow/src/lib.rs b/examples/rwa-country-allow/src/lib.rs new file mode 100644 index 000000000..a879b6f80 --- /dev/null +++ b/examples/rwa-country-allow/src/lib.rs @@ -0,0 +1,5 @@ +#![no_std] + +pub mod contract; +#[cfg(test)] +mod test; diff --git a/examples/rwa-country-allow/src/test.rs b/examples/rwa-country-allow/src/test.rs new file mode 100644 index 000000000..06b540ed3 --- /dev/null +++ b/examples/rwa-country-allow/src/test.rs @@ -0,0 +1,259 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal, + String, Val, Vec, +}; +use stellar_tokens::rwa::{ + identity_registry_storage::{ + CountryData, CountryDataManager, CountryRelation, IdentityRegistryStorage, + IndividualCountryRelation, OrganizationCountryRelation, + }, + utils::token_binder::TokenBinder, +}; + +use crate::contract::{CountryAllowContract, CountryAllowContractClient}; + +fn create_client<'a>(e: &Env, admin: &Address) -> CountryAllowContractClient<'a> { + let address = e.register(CountryAllowContract, (admin,)); + CountryAllowContractClient::new(e, &address) +} + +#[contract] +struct MockIRSContract; + +#[contracttype] +#[derive(Clone)] +enum MockIRSStorageKey { + Identity(Address), + CountryEntries(Address), +} + +#[contractimpl] +impl TokenBinder for MockIRSContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl IdentityRegistryStorage for MockIRSContract { + fn add_identity( + _e: &Env, + _account: Address, + _identity: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_identity is not used in these tests"); + } + + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { + unreachable!("remove_identity is not used in these tests"); + } + + fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { + unreachable!("modify_identity is not used in these tests"); + } + + fn recover_identity( + _e: &Env, + _old_account: Address, + _new_account: Address, + _operator: Address, + ) { + unreachable!("recover_identity is not used in these tests"); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + e.storage() + .persistent() + .get(&MockIRSStorageKey::Identity(account.clone())) + .unwrap_or(account) + } +} + +#[contractimpl] +impl CountryDataManager for MockIRSContract { + fn add_country_data_entries( + _e: &Env, + _account: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_country_data_entries is not used in these tests"); + } + + fn modify_country_data( + _e: &Env, + _account: Address, + _index: u32, + _country_data: Val, + _operator: Address, + ) { + unreachable!("modify_country_data is not used in these tests"); + } + + fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { + unreachable!("delete_country_data is not used in these tests"); + } + + fn get_country_data_entries(e: &Env, account: Address) -> Vec { + let entries: Vec = e + .storage() + .persistent() + .get(&MockIRSStorageKey::CountryEntries(account)) + .unwrap_or_else(|| Vec::new(e)); + + Vec::from_iter(e, entries.iter().map(|entry| entry.into_val(e))) + } +} + +#[contractimpl] +impl MockIRSContract { + pub fn set_country_data_entries(e: &Env, account: Address, entries: Vec) { + e.storage().persistent().set(&MockIRSStorageKey::CountryEntries(account), &entries); + } +} + +fn individual_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Individual(IndividualCountryRelation::Residence(code)), + metadata: None, + } +} + +fn organization_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Organization(OrganizationCountryRelation::OperatingJurisdiction( + code, + )), + metadata: None, + } +} + +#[test] +fn add_and_remove_allowed_country_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let client = create_client(&e, &admin); + + assert!(!client.is_country_allowed(&token, &276)); + + client.add_allowed_country(&token, &276); + assert!(client.is_country_allowed(&token, &276)); + + client.remove_allowed_country(&token, &276); + assert!(!client.is_country_allowed(&token, &276)); +} + +#[test] +fn batch_allow_and_disallow_countries_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let client = create_client(&e, &admin); + + client.batch_allow_countries(&token, &vec![&e, 250u32, 276u32]); + assert!(client.is_country_allowed(&token, &250)); + assert!(client.is_country_allowed(&token, &276)); + + client.batch_disallow_countries(&token, &vec![&e, 250u32]); + assert!(!client.is_country_allowed(&token, &250)); + assert!(client.is_country_allowed(&token, &276)); +} + +#[test] +fn name_and_compliance_address_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let client = create_client(&e, &admin); + + assert_eq!(client.name(), String::from_str(&e, "CountryAllowModule")); + + client.set_compliance_address(&compliance); + assert_eq!(client.get_compliance_address(), compliance); +} + +#[test] +fn set_identity_registry_storage_uses_admin_auth_before_compliance_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let irs = Address::generate(&e); + let client = create_client(&e, &admin); + + client.set_identity_registry_storage(&token, &irs); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); +} + +#[test] +fn set_identity_registry_storage_uses_compliance_auth_after_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let irs = Address::generate(&e); + let client = create_client(&e, &admin); + + client.set_compliance_address(&compliance); + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); + + client.set_identity_registry_storage(&token, &irs); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &compliance); +} + +#[test] +fn can_transfer_and_can_create_use_irs_country_entries() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let from = Address::generate(&e); + let token = Address::generate(&e); + let allowed_to = Address::generate(&e); + let disallowed_to = Address::generate(&e); + let amount = 100_i128; + let client = create_client(&e, &admin); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + + irs.set_country_data_entries( + &allowed_to, + &vec![&e, individual_country(250), organization_country(276)], + ); + irs.set_country_data_entries(&disallowed_to, &vec![&e, individual_country(250)]); + + client.set_identity_registry_storage(&token, &irs_id); + client.add_allowed_country(&token, &276); + + assert!(client.can_transfer(&from, &allowed_to, &amount, &token)); + assert!(client.can_create(&allowed_to, &amount, &token)); + assert!(!client.can_transfer(&from, &disallowed_to, &amount, &token)); + assert!(!client.can_create(&disallowed_to, &amount, &token)); +} diff --git a/examples/rwa-country-restrict/Cargo.toml b/examples/rwa-country-restrict/Cargo.toml new file mode 100644 index 000000000..d160254e9 --- /dev/null +++ b/examples/rwa-country-restrict/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "rwa-country-restrict" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true +authors.workspace = true + +[package.metadata.stellar] +cargo_inherit = true + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-tokens = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/examples/rwa-country-restrict/src/contract.rs b/examples/rwa-country-restrict/src/contract.rs new file mode 100644 index 000000000..aa2c9794c --- /dev/null +++ b/examples/rwa-country-restrict/src/contract.rs @@ -0,0 +1,101 @@ +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::modules::{ + country_restrict::storage as country_restrict, + storage::{ + get_compliance_address, module_name, set_compliance_address, set_irs_address, + ComplianceModuleStorageKey, + }, + ComplianceModule, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct CountryRestrictContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl CountryRestrictContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } + + pub fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { + require_module_admin_or_compliance_auth(e); + set_irs_address(e, &token, &irs); + } + + pub fn add_country_restriction(e: &Env, token: Address, country: u32) { + require_module_admin_or_compliance_auth(e); + country_restrict::add_country_restriction(e, &token, country); + } + + pub fn remove_country_restriction(e: &Env, token: Address, country: u32) { + require_module_admin_or_compliance_auth(e); + country_restrict::remove_country_restriction(e, &token, country); + } + + pub fn batch_restrict_countries(e: &Env, token: Address, countries: Vec) { + require_module_admin_or_compliance_auth(e); + country_restrict::batch_restrict_countries(e, &token, &countries); + } + + pub fn batch_unrestrict_countries(e: &Env, token: Address, countries: Vec) { + require_module_admin_or_compliance_auth(e); + country_restrict::batch_unrestrict_countries(e, &token, &countries); + } + + pub fn is_country_restricted(e: &Env, token: Address, country: u32) -> bool { + country_restrict::is_country_restricted(e, &token, country) + } +} + +#[contractimpl(contracttrait)] +impl ComplianceModule for CountryRestrictContract { + fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {} + + fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {} + + fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {} + + fn can_transfer(e: &Env, _from: Address, to: Address, _amount: i128, token: Address) -> bool { + country_restrict::can_transfer(e, &to, &token) + } + + fn can_create(e: &Env, to: Address, _amount: i128, token: Address) -> bool { + country_restrict::can_transfer(e, &to, &token) + } + + fn name(e: &Env) -> String { + module_name(e, "CountryRestrictModule") + } + + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-country-restrict/src/lib.rs b/examples/rwa-country-restrict/src/lib.rs new file mode 100644 index 000000000..a879b6f80 --- /dev/null +++ b/examples/rwa-country-restrict/src/lib.rs @@ -0,0 +1,5 @@ +#![no_std] + +pub mod contract; +#[cfg(test)] +mod test; diff --git a/examples/rwa-country-restrict/src/test.rs b/examples/rwa-country-restrict/src/test.rs new file mode 100644 index 000000000..4d6a10154 --- /dev/null +++ b/examples/rwa-country-restrict/src/test.rs @@ -0,0 +1,259 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal, + String, Val, Vec, +}; +use stellar_tokens::rwa::{ + identity_registry_storage::{ + CountryData, CountryDataManager, CountryRelation, IdentityRegistryStorage, + IndividualCountryRelation, OrganizationCountryRelation, + }, + utils::token_binder::TokenBinder, +}; + +use crate::contract::{CountryRestrictContract, CountryRestrictContractClient}; + +fn create_client<'a>(e: &Env, admin: &Address) -> CountryRestrictContractClient<'a> { + let address = e.register(CountryRestrictContract, (admin,)); + CountryRestrictContractClient::new(e, &address) +} + +#[contract] +struct MockIRSContract; + +#[contracttype] +#[derive(Clone)] +enum MockIRSStorageKey { + Identity(Address), + CountryEntries(Address), +} + +#[contractimpl] +impl TokenBinder for MockIRSContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl IdentityRegistryStorage for MockIRSContract { + fn add_identity( + _e: &Env, + _account: Address, + _identity: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_identity is not used in these tests"); + } + + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { + unreachable!("remove_identity is not used in these tests"); + } + + fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { + unreachable!("modify_identity is not used in these tests"); + } + + fn recover_identity( + _e: &Env, + _old_account: Address, + _new_account: Address, + _operator: Address, + ) { + unreachable!("recover_identity is not used in these tests"); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + e.storage() + .persistent() + .get(&MockIRSStorageKey::Identity(account.clone())) + .unwrap_or(account) + } +} + +#[contractimpl] +impl CountryDataManager for MockIRSContract { + fn add_country_data_entries( + _e: &Env, + _account: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_country_data_entries is not used in these tests"); + } + + fn modify_country_data( + _e: &Env, + _account: Address, + _index: u32, + _country_data: Val, + _operator: Address, + ) { + unreachable!("modify_country_data is not used in these tests"); + } + + fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { + unreachable!("delete_country_data is not used in these tests"); + } + + fn get_country_data_entries(e: &Env, account: Address) -> Vec { + let entries: Vec = e + .storage() + .persistent() + .get(&MockIRSStorageKey::CountryEntries(account)) + .unwrap_or_else(|| Vec::new(e)); + + Vec::from_iter(e, entries.iter().map(|entry| entry.into_val(e))) + } +} + +#[contractimpl] +impl MockIRSContract { + pub fn set_country_data_entries(e: &Env, account: Address, entries: Vec) { + e.storage().persistent().set(&MockIRSStorageKey::CountryEntries(account), &entries); + } +} + +fn individual_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Individual(IndividualCountryRelation::Residence(code)), + metadata: None, + } +} + +fn organization_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Organization(OrganizationCountryRelation::OperatingJurisdiction( + code, + )), + metadata: None, + } +} + +#[test] +fn add_and_remove_country_restriction_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let client = create_client(&e, &admin); + + assert!(!client.is_country_restricted(&token, &276)); + + client.add_country_restriction(&token, &276); + assert!(client.is_country_restricted(&token, &276)); + + client.remove_country_restriction(&token, &276); + assert!(!client.is_country_restricted(&token, &276)); +} + +#[test] +fn batch_restrict_and_unrestrict_countries_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let client = create_client(&e, &admin); + + client.batch_restrict_countries(&token, &vec![&e, 250u32, 276u32]); + assert!(client.is_country_restricted(&token, &250)); + assert!(client.is_country_restricted(&token, &276)); + + client.batch_unrestrict_countries(&token, &vec![&e, 250u32]); + assert!(!client.is_country_restricted(&token, &250)); + assert!(client.is_country_restricted(&token, &276)); +} + +#[test] +fn name_and_compliance_address_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let client = create_client(&e, &admin); + + assert_eq!(client.name(), String::from_str(&e, "CountryRestrictModule")); + + client.set_compliance_address(&compliance); + assert_eq!(client.get_compliance_address(), compliance); +} + +#[test] +fn set_identity_registry_storage_uses_admin_auth_before_compliance_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let irs = Address::generate(&e); + let client = create_client(&e, &admin); + + client.set_identity_registry_storage(&token, &irs); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); +} + +#[test] +fn set_identity_registry_storage_uses_compliance_auth_after_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let irs = Address::generate(&e); + let client = create_client(&e, &admin); + + client.set_compliance_address(&compliance); + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); + + client.set_identity_registry_storage(&token, &irs); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &compliance); +} + +#[test] +fn can_transfer_and_can_create_use_irs_country_entries() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let from = Address::generate(&e); + let token = Address::generate(&e); + let allowed_to = Address::generate(&e); + let restricted_to = Address::generate(&e); + let amount = 100_i128; + let client = create_client(&e, &admin); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + + irs.set_country_data_entries(&allowed_to, &vec![&e, individual_country(250)]); + irs.set_country_data_entries( + &restricted_to, + &vec![&e, individual_country(250), organization_country(276)], + ); + + client.set_identity_registry_storage(&token, &irs_id); + client.add_country_restriction(&token, &276); + + assert!(client.can_transfer(&from, &allowed_to, &amount, &token)); + assert!(client.can_create(&allowed_to, &amount, &token)); + assert!(!client.can_transfer(&from, &restricted_to, &amount, &token)); + assert!(!client.can_create(&restricted_to, &amount, &token)); +} diff --git a/examples/rwa-initial-lockup-period/Cargo.toml b/examples/rwa-initial-lockup-period/Cargo.toml new file mode 100644 index 000000000..767f3cca1 --- /dev/null +++ b/examples/rwa-initial-lockup-period/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "rwa-initial-lockup-period" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true +authors.workspace = true + +[package.metadata.stellar] +cargo_inherit = true + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-tokens = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/examples/rwa-initial-lockup-period/src/contract.rs b/examples/rwa-initial-lockup-period/src/contract.rs new file mode 100644 index 000000000..330f6741a --- /dev/null +++ b/examples/rwa-initial-lockup-period/src/contract.rs @@ -0,0 +1,123 @@ +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::{ + modules::{ + initial_lockup_period::{storage as lockup, LockedTokens}, + storage::{ + get_compliance_address, module_name, set_compliance_address, ComplianceModuleStorageKey, + }, + ComplianceModule, + }, + ComplianceHook, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct InitialLockupPeriodContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl InitialLockupPeriodContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } + + pub fn set_lockup_period(e: &Env, token: Address, lockup_seconds: u64) { + require_module_admin_or_compliance_auth(e); + lockup::configure_lockup_period(e, &token, lockup_seconds); + } + + pub fn pre_set_lockup_state( + e: &Env, + token: Address, + wallet: Address, + balance: i128, + locks: Vec, + ) { + require_module_admin_or_compliance_auth(e); + lockup::pre_set_lockup_state(e, &token, &wallet, balance, &locks); + } + + pub fn get_lockup_period(e: &Env, token: Address) -> u64 { + lockup::get_lockup_period(e, &token) + } + + pub fn get_total_locked(e: &Env, token: Address, wallet: Address) -> i128 { + lockup::get_total_locked(e, &token, &wallet) + } + + pub fn get_locked_tokens(e: &Env, token: Address, wallet: Address) -> Vec { + lockup::get_locks(e, &token, &wallet) + } + + pub fn get_internal_balance(e: &Env, token: Address, wallet: Address) -> i128 { + lockup::get_internal_balance(e, &token, &wallet) + } + + pub fn required_hooks(e: &Env) -> Vec { + lockup::required_hooks(e) + } + + pub fn verify_hook_wiring(e: &Env) { + lockup::verify_hook_wiring(e); + } +} + +#[contractimpl(contracttrait)] +impl ComplianceModule for InitialLockupPeriodContract { + fn on_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + lockup::on_transfer(e, &from, &to, amount, &token); + } + + fn on_created(e: &Env, to: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + lockup::on_created(e, &to, amount, &token); + } + + fn on_destroyed(e: &Env, from: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + lockup::on_destroyed(e, &from, amount, &token); + } + + fn can_transfer(e: &Env, from: Address, _to: Address, amount: i128, token: Address) -> bool { + lockup::can_transfer(e, &from, amount, &token) + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + true + } + + fn name(e: &Env) -> String { + module_name(e, "InitialLockupPeriodModule") + } + + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-initial-lockup-period/src/lib.rs b/examples/rwa-initial-lockup-period/src/lib.rs new file mode 100644 index 000000000..a879b6f80 --- /dev/null +++ b/examples/rwa-initial-lockup-period/src/lib.rs @@ -0,0 +1,5 @@ +#![no_std] + +pub mod contract; +#[cfg(test)] +mod test; diff --git a/examples/rwa-initial-lockup-period/src/test.rs b/examples/rwa-initial-lockup-period/src/test.rs new file mode 100644 index 000000000..426f71f3f --- /dev/null +++ b/examples/rwa-initial-lockup-period/src/test.rs @@ -0,0 +1,216 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, String, Vec, +}; +use stellar_tokens::rwa::{ + compliance::{modules::initial_lockup_period::LockedTokens, Compliance, ComplianceHook}, + utils::token_binder::TokenBinder, +}; + +use crate::contract::{InitialLockupPeriodContract, InitialLockupPeriodContractClient}; + +fn create_client<'a>(e: &Env, admin: &Address) -> (Address, InitialLockupPeriodContractClient<'a>) { + let address = e.register(InitialLockupPeriodContract, (admin,)); + (address.clone(), InitialLockupPeriodContractClient::new(e, &address)) +} + +#[contract] +struct MockComplianceContract; + +#[contracttype] +#[derive(Clone)] +enum MockComplianceStorageKey { + Registered(ComplianceHook, Address), +} + +#[contractimpl] +impl Compliance for MockComplianceContract { + fn add_module_to(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("add_module_to is not used in these tests"); + } + + fn remove_module_from(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("remove_module_from is not used in these tests"); + } + + fn get_modules_for_hook(_e: &Env, _hook: ComplianceHook) -> Vec
{ + unreachable!("get_modules_for_hook is not used in these tests"); + } + + fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool { + e.storage().persistent().has(&MockComplianceStorageKey::Registered(hook, module)) + } + + fn transferred(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) { + unreachable!("transferred is not used in these tests"); + } + + fn created(_e: &Env, _to: Address, _amount: i128, _token: Address) { + unreachable!("created is not used in these tests"); + } + + fn destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) { + unreachable!("destroyed is not used in these tests"); + } + + fn can_transfer( + _e: &Env, + _from: Address, + _to: Address, + _amount: i128, + _token: Address, + ) -> bool { + unreachable!("can_transfer is not used in these tests"); + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + unreachable!("can_create is not used in these tests"); + } +} + +#[contractimpl] +impl TokenBinder for MockComplianceContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl MockComplianceContract { + pub fn register_hook(e: &Env, hook: ComplianceHook, module: Address) { + e.storage().persistent().set(&MockComplianceStorageKey::Registered(hook, module), &true); + } +} + +#[test] +fn set_and_get_lockup_state_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let wallet = Address::generate(&e); + let release_timestamp = e.ledger().timestamp().saturating_add(60); + let locks = vec![ + &e, + LockedTokens { amount: 80, release_timestamp }, + LockedTokens { amount: 10, release_timestamp: release_timestamp.saturating_add(60) }, + ]; + let (_address, client) = create_client(&e, &admin); + + client.set_lockup_period(&token, &60); + client.pre_set_lockup_state(&token, &wallet, &100, &locks); + + assert_eq!(client.get_lockup_period(&token), 60); + assert_eq!(client.get_total_locked(&token, &wallet), 90); + assert_eq!(client.get_internal_balance(&token, &wallet), 100); + + let stored_locks = client.get_locked_tokens(&token, &wallet); + assert_eq!(stored_locks.len(), 2); + + let first_lock = stored_locks.get(0).unwrap(); + assert_eq!(first_lock.amount, 80); + assert_eq!(first_lock.release_timestamp, release_timestamp); +} + +#[test] +fn name_compliance_address_and_required_hooks_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + assert_eq!(client.name(), String::from_str(&e, "InitialLockupPeriodModule")); + assert_eq!( + client.required_hooks(), + vec![ + &e, + ComplianceHook::CanTransfer, + ComplianceHook::Created, + ComplianceHook::Transferred, + ComplianceHook::Destroyed, + ] + ); + + client.set_compliance_address(&compliance); + assert_eq!(client.get_compliance_address(), compliance); +} + +#[test] +fn set_lockup_period_uses_admin_auth_before_compliance_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_lockup_period(&token, &60); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); +} + +#[test] +fn set_lockup_period_uses_compliance_auth_after_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_compliance_address(&compliance); + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); + + client.set_lockup_period(&token, &60); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &compliance); +} + +#[test] +fn verify_hook_wiring_and_can_transfer_use_public_contract_api() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let wallet = Address::generate(&e); + let recipient = Address::generate(&e); + let release_timestamp = e.ledger().timestamp().saturating_add(60); + let locks = vec![&e, LockedTokens { amount: 80, release_timestamp }]; + let (module_address, client) = create_client(&e, &admin); + let compliance_id = e.register(MockComplianceContract, ()); + let compliance = MockComplianceContractClient::new(&e, &compliance_id); + + client.set_compliance_address(&compliance_id); + for hook in [ + ComplianceHook::CanTransfer, + ComplianceHook::Created, + ComplianceHook::Transferred, + ComplianceHook::Destroyed, + ] { + compliance.register_hook(&hook, &module_address); + } + + client.verify_hook_wiring(); + client.pre_set_lockup_state(&token, &wallet, &100, &locks); + + assert!(!client.can_transfer(&wallet, &recipient, &21, &token)); + assert!(client.can_transfer(&wallet, &recipient, &20, &token)); +} diff --git a/examples/rwa-max-balance/Cargo.toml b/examples/rwa-max-balance/Cargo.toml new file mode 100644 index 000000000..066fdcb4c --- /dev/null +++ b/examples/rwa-max-balance/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "rwa-max-balance" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true +authors.workspace = true + +[package.metadata.stellar] +cargo_inherit = true + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-tokens = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/examples/rwa-max-balance/src/contract.rs b/examples/rwa-max-balance/src/contract.rs new file mode 100644 index 000000000..32cf25a75 --- /dev/null +++ b/examples/rwa-max-balance/src/contract.rs @@ -0,0 +1,125 @@ +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::{ + modules::{ + max_balance::storage as max_balance, + storage::{ + get_compliance_address, module_name, set_compliance_address, set_irs_address, + ComplianceModuleStorageKey, + }, + ComplianceModule, + }, + ComplianceHook, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct MaxBalanceContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl MaxBalanceContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } + + pub fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { + require_module_admin_or_compliance_auth(e); + set_irs_address(e, &token, &irs); + } + + pub fn set_max_balance(e: &Env, token: Address, max: i128) { + require_module_admin_or_compliance_auth(e); + max_balance::configure_max_balance(e, &token, max); + } + + pub fn pre_set_identity_balance(e: &Env, token: Address, identity: Address, balance: i128) { + require_module_admin_or_compliance_auth(e); + max_balance::pre_set_identity_balance(e, &token, &identity, balance); + } + + pub fn batch_pre_set_identity_balances( + e: &Env, + token: Address, + identities: Vec
, + balances: Vec, + ) { + require_module_admin_or_compliance_auth(e); + max_balance::batch_pre_set_identity_balances(e, &token, &identities, &balances); + } + + pub fn get_max_balance(e: &Env, token: Address) -> i128 { + max_balance::get_max_balance(e, &token) + } + + pub fn get_investor_balance(e: &Env, token: Address, identity: Address) -> i128 { + max_balance::get_id_balance(e, &token, &identity) + } + + pub fn required_hooks(e: &Env) -> Vec { + max_balance::required_hooks(e) + } + + pub fn verify_hook_wiring(e: &Env) { + max_balance::verify_hook_wiring(e); + } +} + +#[contractimpl(contracttrait)] +impl ComplianceModule for MaxBalanceContract { + fn on_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + max_balance::on_transfer(e, &from, &to, amount, &token); + } + + fn on_created(e: &Env, to: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + max_balance::on_created(e, &to, amount, &token); + } + + fn on_destroyed(e: &Env, from: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + max_balance::on_destroyed(e, &from, amount, &token); + } + + fn can_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) -> bool { + max_balance::can_transfer(e, &from, &to, amount, &token) + } + + fn can_create(e: &Env, to: Address, amount: i128, token: Address) -> bool { + max_balance::can_create(e, &to, amount, &token) + } + + fn name(e: &Env) -> String { + module_name(e, "MaxBalanceModule") + } + + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-max-balance/src/lib.rs b/examples/rwa-max-balance/src/lib.rs new file mode 100644 index 000000000..a879b6f80 --- /dev/null +++ b/examples/rwa-max-balance/src/lib.rs @@ -0,0 +1,5 @@ +#![no_std] + +pub mod contract; +#[cfg(test)] +mod test; diff --git a/examples/rwa-max-balance/src/test.rs b/examples/rwa-max-balance/src/test.rs new file mode 100644 index 000000000..3beb493c1 --- /dev/null +++ b/examples/rwa-max-balance/src/test.rs @@ -0,0 +1,303 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, String, Val, + Vec, +}; +use stellar_tokens::rwa::{ + compliance::{Compliance, ComplianceHook}, + identity_registry_storage::IdentityRegistryStorage, + utils::token_binder::TokenBinder, +}; + +use crate::contract::{MaxBalanceContract, MaxBalanceContractClient}; + +fn create_client<'a>(e: &Env, admin: &Address) -> (Address, MaxBalanceContractClient<'a>) { + let address = e.register(MaxBalanceContract, (admin,)); + (address.clone(), MaxBalanceContractClient::new(e, &address)) +} + +#[contract] +struct MockIRSContract; + +#[contracttype] +#[derive(Clone)] +enum MockIRSStorageKey { + Identity(Address), +} + +#[contractimpl] +impl TokenBinder for MockIRSContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl IdentityRegistryStorage for MockIRSContract { + fn add_identity( + _e: &Env, + _account: Address, + _identity: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_identity is not used in these tests"); + } + + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { + unreachable!("remove_identity is not used in these tests"); + } + + fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { + unreachable!("modify_identity is not used in these tests"); + } + + fn recover_identity( + _e: &Env, + _old_account: Address, + _new_account: Address, + _operator: Address, + ) { + unreachable!("recover_identity is not used in these tests"); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + e.storage() + .persistent() + .get(&MockIRSStorageKey::Identity(account.clone())) + .unwrap_or(account) + } +} + +#[contractimpl] +impl MockIRSContract { + pub fn set_identity(e: &Env, account: Address, identity: Address) { + e.storage().persistent().set(&MockIRSStorageKey::Identity(account), &identity); + } +} + +#[contract] +struct MockComplianceContract; + +#[contracttype] +#[derive(Clone)] +enum MockComplianceStorageKey { + Registered(ComplianceHook, Address), +} + +#[contractimpl] +impl Compliance for MockComplianceContract { + fn add_module_to(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("add_module_to is not used in these tests"); + } + + fn remove_module_from(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("remove_module_from is not used in these tests"); + } + + fn get_modules_for_hook(_e: &Env, _hook: ComplianceHook) -> Vec
{ + unreachable!("get_modules_for_hook is not used in these tests"); + } + + fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool { + e.storage().persistent().has(&MockComplianceStorageKey::Registered(hook, module)) + } + + fn transferred(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) { + unreachable!("transferred is not used in these tests"); + } + + fn created(_e: &Env, _to: Address, _amount: i128, _token: Address) { + unreachable!("created is not used in these tests"); + } + + fn destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) { + unreachable!("destroyed is not used in these tests"); + } + + fn can_transfer( + _e: &Env, + _from: Address, + _to: Address, + _amount: i128, + _token: Address, + ) -> bool { + unreachable!("can_transfer is not used in these tests"); + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + unreachable!("can_create is not used in these tests"); + } +} + +#[contractimpl] +impl TokenBinder for MockComplianceContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl MockComplianceContract { + pub fn register_hook(e: &Env, hook: ComplianceHook, module: Address) { + e.storage().persistent().set(&MockComplianceStorageKey::Registered(hook, module), &true); + } +} + +#[test] +fn set_and_get_max_balance_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_max_balance(&token, &100); + + assert_eq!(client.get_max_balance(&token), 100); +} + +#[test] +fn pre_set_identity_balances_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let identity_a = Address::generate(&e); + let identity_b = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.pre_set_identity_balance(&token, &identity_a, &40); + client.batch_pre_set_identity_balances( + &token, + &vec![&e, identity_a.clone(), identity_b.clone()], + &vec![&e, 50_i128, 20_i128], + ); + + assert_eq!(client.get_investor_balance(&token, &identity_a), 50); + assert_eq!(client.get_investor_balance(&token, &identity_b), 20); +} + +#[test] +fn name_compliance_address_and_required_hooks_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + assert_eq!(client.name(), String::from_str(&e, "MaxBalanceModule")); + assert_eq!( + client.required_hooks(), + vec![ + &e, + ComplianceHook::CanTransfer, + ComplianceHook::CanCreate, + ComplianceHook::Transferred, + ComplianceHook::Created, + ComplianceHook::Destroyed, + ] + ); + + client.set_compliance_address(&compliance); + assert_eq!(client.get_compliance_address(), compliance); +} + +#[test] +fn set_identity_registry_storage_uses_admin_auth_before_compliance_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let irs = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_identity_registry_storage(&token, &irs); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); +} + +#[test] +fn set_identity_registry_storage_uses_compliance_auth_after_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let irs = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_compliance_address(&compliance); + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); + + client.set_identity_registry_storage(&token, &irs); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &compliance); +} + +#[test] +fn can_create_and_can_transfer_use_identity_caps() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let recipient = Address::generate(&e); + let sender_identity = Address::generate(&e); + let recipient_identity = Address::generate(&e); + let (module_address, client) = create_client(&e, &admin); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let compliance_id = e.register(MockComplianceContract, ()); + let compliance = MockComplianceContractClient::new(&e, &compliance_id); + + irs.set_identity(&sender, &sender_identity); + irs.set_identity(&recipient, &recipient_identity); + + client.set_compliance_address(&compliance_id); + for hook in [ + ComplianceHook::CanTransfer, + ComplianceHook::CanCreate, + ComplianceHook::Transferred, + ComplianceHook::Created, + ComplianceHook::Destroyed, + ] { + compliance.register_hook(&hook, &module_address); + } + + client.verify_hook_wiring(); + client.set_identity_registry_storage(&token, &irs_id); + client.set_max_balance(&token, &100); + client.pre_set_identity_balance(&token, &recipient_identity, &60); + + assert!(!client.can_create(&recipient, &50, &token)); + assert!(client.can_create(&recipient, &40, &token)); + assert!(!client.can_transfer(&sender, &recipient, &50, &token)); + assert!(client.can_transfer(&sender, &recipient, &40, &token)); +} diff --git a/examples/rwa-supply-limit/Cargo.toml b/examples/rwa-supply-limit/Cargo.toml new file mode 100644 index 000000000..94977d387 --- /dev/null +++ b/examples/rwa-supply-limit/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "rwa-supply-limit" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true +authors.workspace = true + +[package.metadata.stellar] +cargo_inherit = true + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-tokens = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/examples/rwa-supply-limit/src/contract.rs b/examples/rwa-supply-limit/src/contract.rs new file mode 100644 index 000000000..dbbbba0b9 --- /dev/null +++ b/examples/rwa-supply-limit/src/contract.rs @@ -0,0 +1,112 @@ +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::{ + modules::{ + storage::{ + get_compliance_address, module_name, set_compliance_address, ComplianceModuleStorageKey, + }, + supply_limit::storage as supply_limit, + ComplianceModule, + }, + ComplianceHook, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct SupplyLimitContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl SupplyLimitContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } + + pub fn set_supply_limit(e: &Env, token: Address, limit: i128) { + require_module_admin_or_compliance_auth(e); + supply_limit::configure_supply_limit(e, &token, limit); + } + + pub fn pre_set_supply(e: &Env, token: Address, supply: i128) { + require_module_admin_or_compliance_auth(e); + supply_limit::pre_set_supply(e, &token, supply); + } + + pub fn get_supply_limit(e: &Env, token: Address) -> i128 { + supply_limit::get_supply_limit(e, &token) + } + + pub fn get_internal_supply(e: &Env, token: Address) -> i128 { + supply_limit::get_internal_supply(e, &token) + } + + pub fn required_hooks(e: &Env) -> Vec { + supply_limit::required_hooks(e) + } + + pub fn verify_hook_wiring(e: &Env) { + supply_limit::verify_hook_wiring(e); + } +} + +#[contractimpl(contracttrait)] +impl ComplianceModule for SupplyLimitContract { + fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {} + + fn on_created(e: &Env, _to: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + supply_limit::on_created(e, amount, &token); + } + + fn on_destroyed(e: &Env, _from: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + supply_limit::on_destroyed(e, amount, &token); + } + + fn can_transfer( + _e: &Env, + _from: Address, + _to: Address, + _amount: i128, + _token: Address, + ) -> bool { + true + } + + fn can_create(e: &Env, _to: Address, amount: i128, token: Address) -> bool { + supply_limit::can_create(e, amount, &token) + } + + fn name(e: &Env) -> String { + module_name(e, "SupplyLimitModule") + } + + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-supply-limit/src/lib.rs b/examples/rwa-supply-limit/src/lib.rs new file mode 100644 index 000000000..a879b6f80 --- /dev/null +++ b/examples/rwa-supply-limit/src/lib.rs @@ -0,0 +1,5 @@ +#![no_std] + +pub mod contract; +#[cfg(test)] +mod test; diff --git a/examples/rwa-supply-limit/src/test.rs b/examples/rwa-supply-limit/src/test.rs new file mode 100644 index 000000000..728fee1f5 --- /dev/null +++ b/examples/rwa-supply-limit/src/test.rs @@ -0,0 +1,194 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, String, Vec, +}; +use stellar_tokens::rwa::{ + compliance::{Compliance, ComplianceHook}, + utils::token_binder::TokenBinder, +}; + +use crate::contract::{SupplyLimitContract, SupplyLimitContractClient}; + +fn create_client<'a>(e: &Env, admin: &Address) -> (Address, SupplyLimitContractClient<'a>) { + let address = e.register(SupplyLimitContract, (admin,)); + (address.clone(), SupplyLimitContractClient::new(e, &address)) +} + +#[contract] +struct MockComplianceContract; + +#[contracttype] +#[derive(Clone)] +enum MockComplianceStorageKey { + Registered(ComplianceHook, Address), +} + +#[contractimpl] +impl Compliance for MockComplianceContract { + fn add_module_to(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("add_module_to is not used in these tests"); + } + + fn remove_module_from(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("remove_module_from is not used in these tests"); + } + + fn get_modules_for_hook(_e: &Env, _hook: ComplianceHook) -> Vec
{ + unreachable!("get_modules_for_hook is not used in these tests"); + } + + fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool { + e.storage().persistent().has(&MockComplianceStorageKey::Registered(hook, module)) + } + + fn transferred(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) { + unreachable!("transferred is not used in these tests"); + } + + fn created(_e: &Env, _to: Address, _amount: i128, _token: Address) { + unreachable!("created is not used in these tests"); + } + + fn destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) { + unreachable!("destroyed is not used in these tests"); + } + + fn can_transfer( + _e: &Env, + _from: Address, + _to: Address, + _amount: i128, + _token: Address, + ) -> bool { + unreachable!("can_transfer is not used in these tests"); + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + unreachable!("can_create is not used in these tests"); + } +} + +#[contractimpl] +impl TokenBinder for MockComplianceContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl MockComplianceContract { + pub fn register_hook(e: &Env, hook: ComplianceHook, module: Address) { + e.storage().persistent().set(&MockComplianceStorageKey::Registered(hook, module), &true); + } +} + +#[test] +fn set_supply_limit_and_pre_set_supply_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_supply_limit(&token, &100); + client.pre_set_supply(&token, &60); + + assert_eq!(client.get_supply_limit(&token), 100); + assert_eq!(client.get_internal_supply(&token), 60); +} + +#[test] +fn name_compliance_address_and_required_hooks_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + assert_eq!(client.name(), String::from_str(&e, "SupplyLimitModule")); + assert_eq!( + client.required_hooks(), + vec![&e, ComplianceHook::CanCreate, ComplianceHook::Created, ComplianceHook::Destroyed,] + ); + + client.set_compliance_address(&compliance); + assert_eq!(client.get_compliance_address(), compliance); +} + +#[test] +fn set_supply_limit_uses_admin_auth_before_compliance_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_supply_limit(&token, &100); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); +} + +#[test] +fn set_supply_limit_uses_compliance_auth_after_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_compliance_address(&compliance); + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); + + client.set_supply_limit(&token, &100); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &compliance); +} + +#[test] +fn can_create_and_hooks_update_internal_supply() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let account = Address::generate(&e); + let (module_address, client) = create_client(&e, &admin); + let compliance_id = e.register(MockComplianceContract, ()); + let compliance = MockComplianceContractClient::new(&e, &compliance_id); + + client.set_compliance_address(&compliance_id); + for hook in [ComplianceHook::CanCreate, ComplianceHook::Created, ComplianceHook::Destroyed] { + compliance.register_hook(&hook, &module_address); + } + + client.verify_hook_wiring(); + client.set_supply_limit(&token, &100); + + assert!(client.can_create(&account, &80, &token)); + + client.on_created(&account, &80, &token); + assert_eq!(client.get_internal_supply(&token), 80); + assert!(!client.can_create(&account, &30, &token)); + + client.on_destroyed(&account, &20, &token); + assert_eq!(client.get_internal_supply(&token), 60); + assert!(client.can_create(&account, &40, &token)); +} diff --git a/examples/rwa-time-transfers-limits/Cargo.toml b/examples/rwa-time-transfers-limits/Cargo.toml new file mode 100644 index 000000000..ab02105e5 --- /dev/null +++ b/examples/rwa-time-transfers-limits/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "rwa-time-transfers-limits" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true +authors.workspace = true + +[package.metadata.stellar] +cargo_inherit = true + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-tokens = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/examples/rwa-time-transfers-limits/src/contract.rs b/examples/rwa-time-transfers-limits/src/contract.rs new file mode 100644 index 000000000..d1d559a4b --- /dev/null +++ b/examples/rwa-time-transfers-limits/src/contract.rs @@ -0,0 +1,125 @@ +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::{ + modules::{ + storage::{ + get_compliance_address, module_name, set_compliance_address, ComplianceModuleStorageKey, + }, + time_transfers_limits::{storage as ttl, Limit, TransferCounter}, + ComplianceModule, + }, + ComplianceHook, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct TimeTransfersLimitsContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl TimeTransfersLimitsContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } + + pub fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { + require_module_admin_or_compliance_auth(e); + ttl::configure_irs(e, &token, &irs); + } + + pub fn set_time_transfer_limit(e: &Env, token: Address, limit: Limit) { + require_module_admin_or_compliance_auth(e); + ttl::set_time_transfer_limit(e, &token, &limit); + } + + pub fn batch_set_time_transfer_limit(e: &Env, token: Address, limits: Vec) { + require_module_admin_or_compliance_auth(e); + ttl::batch_set_time_transfer_limit(e, &token, &limits); + } + + pub fn remove_time_transfer_limit(e: &Env, token: Address, limit_time: u64) { + require_module_admin_or_compliance_auth(e); + ttl::remove_time_transfer_limit(e, &token, limit_time); + } + + pub fn batch_remove_time_transfer_limit(e: &Env, token: Address, limit_times: Vec) { + require_module_admin_or_compliance_auth(e); + ttl::batch_remove_time_transfer_limit(e, &token, &limit_times); + } + + pub fn pre_set_transfer_counter( + e: &Env, + token: Address, + identity: Address, + limit_time: u64, + counter: TransferCounter, + ) { + require_module_admin_or_compliance_auth(e); + ttl::pre_set_transfer_counter(e, &token, &identity, limit_time, &counter); + } + + pub fn get_time_transfer_limits(e: &Env, token: Address) -> Vec { + ttl::get_limits(e, &token) + } + + pub fn required_hooks(e: &Env) -> Vec { + ttl::required_hooks(e) + } + + pub fn verify_hook_wiring(e: &Env) { + ttl::verify_hook_wiring(e); + } +} + +#[contractimpl(contracttrait)] +impl ComplianceModule for TimeTransfersLimitsContract { + fn on_transfer(e: &Env, from: Address, _to: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + ttl::on_transfer(e, &from, amount, &token); + } + + fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {} + + fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {} + + fn can_transfer(e: &Env, from: Address, _to: Address, amount: i128, token: Address) -> bool { + ttl::can_transfer(e, &from, amount, &token) + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + true + } + + fn name(e: &Env) -> String { + module_name(e, "TimeTransfersLimitsModule") + } + + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-time-transfers-limits/src/lib.rs b/examples/rwa-time-transfers-limits/src/lib.rs new file mode 100644 index 000000000..a879b6f80 --- /dev/null +++ b/examples/rwa-time-transfers-limits/src/lib.rs @@ -0,0 +1,5 @@ +#![no_std] + +pub mod contract; +#[cfg(test)] +mod test; diff --git a/examples/rwa-time-transfers-limits/src/test.rs b/examples/rwa-time-transfers-limits/src/test.rs new file mode 100644 index 000000000..7c75c6f2a --- /dev/null +++ b/examples/rwa-time-transfers-limits/src/test.rs @@ -0,0 +1,316 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, String, Val, + Vec, +}; +use stellar_tokens::rwa::{ + compliance::{ + modules::time_transfers_limits::{Limit, TransferCounter}, + Compliance, ComplianceHook, + }, + identity_registry_storage::{CountryDataManager, IdentityRegistryStorage}, + utils::token_binder::TokenBinder, +}; + +use crate::contract::{TimeTransfersLimitsContract, TimeTransfersLimitsContractClient}; + +fn create_client<'a>(e: &Env, admin: &Address) -> (Address, TimeTransfersLimitsContractClient<'a>) { + let address = e.register(TimeTransfersLimitsContract, (admin,)); + (address.clone(), TimeTransfersLimitsContractClient::new(e, &address)) +} + +#[contract] +struct MockIRSContract; + +#[contracttype] +#[derive(Clone)] +enum MockIRSStorageKey { + Identity(Address), +} + +#[contractimpl] +impl TokenBinder for MockIRSContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl IdentityRegistryStorage for MockIRSContract { + fn add_identity( + _e: &Env, + _account: Address, + _identity: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_identity is not used in these tests"); + } + + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { + unreachable!("remove_identity is not used in these tests"); + } + + fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { + unreachable!("modify_identity is not used in these tests"); + } + + fn recover_identity( + _e: &Env, + _old_account: Address, + _new_account: Address, + _operator: Address, + ) { + unreachable!("recover_identity is not used in these tests"); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + e.storage() + .persistent() + .get(&MockIRSStorageKey::Identity(account.clone())) + .unwrap_or(account) + } +} + +#[contractimpl] +impl CountryDataManager for MockIRSContract { + fn add_country_data_entries( + _e: &Env, + _account: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_country_data_entries is not used in these tests"); + } + + fn modify_country_data( + _e: &Env, + _account: Address, + _index: u32, + _country_data: Val, + _operator: Address, + ) { + unreachable!("modify_country_data is not used in these tests"); + } + + fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { + unreachable!("delete_country_data is not used in these tests"); + } + + fn get_country_data_entries(e: &Env, _account: Address) -> Vec { + Vec::new(e) + } +} + +#[contractimpl] +impl MockIRSContract { + pub fn set_identity(e: &Env, account: Address, identity: Address) { + e.storage().persistent().set(&MockIRSStorageKey::Identity(account), &identity); + } +} + +#[contract] +struct MockComplianceContract; + +#[contracttype] +#[derive(Clone)] +enum MockComplianceStorageKey { + Registered(ComplianceHook, Address), +} + +#[contractimpl] +impl Compliance for MockComplianceContract { + fn add_module_to(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("add_module_to is not used in these tests"); + } + + fn remove_module_from(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("remove_module_from is not used in these tests"); + } + + fn get_modules_for_hook(_e: &Env, _hook: ComplianceHook) -> Vec
{ + unreachable!("get_modules_for_hook is not used in these tests"); + } + + fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool { + e.storage().persistent().has(&MockComplianceStorageKey::Registered(hook, module)) + } + + fn transferred(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) { + unreachable!("transferred is not used in these tests"); + } + + fn created(_e: &Env, _to: Address, _amount: i128, _token: Address) { + unreachable!("created is not used in these tests"); + } + + fn destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) { + unreachable!("destroyed is not used in these tests"); + } + + fn can_transfer( + _e: &Env, + _from: Address, + _to: Address, + _amount: i128, + _token: Address, + ) -> bool { + unreachable!("can_transfer is not used in these tests"); + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + unreachable!("can_create is not used in these tests"); + } +} + +#[contractimpl] +impl TokenBinder for MockComplianceContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl MockComplianceContract { + pub fn register_hook(e: &Env, hook: ComplianceHook, module: Address) { + e.storage().persistent().set(&MockComplianceStorageKey::Registered(hook, module), &true); + } +} + +#[test] +fn set_and_manage_time_transfer_limits_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let limit_a = Limit { limit_time: 60, limit_value: 100 }; + let limit_b = Limit { limit_time: 120, limit_value: 200 }; + let (_address, client) = create_client(&e, &admin); + + client.set_time_transfer_limit(&token, &limit_a); + client.batch_set_time_transfer_limit(&token, &vec![&e, limit_b.clone()]); + + assert_eq!(client.get_time_transfer_limits(&token), vec![&e, limit_a.clone(), limit_b.clone()]); + + client.batch_remove_time_transfer_limit(&token, &vec![&e, 120_u64]); + assert_eq!(client.get_time_transfer_limits(&token), vec![&e, limit_a.clone()]); + + client.remove_time_transfer_limit(&token, &60); + assert_eq!(client.get_time_transfer_limits(&token).len(), 0); +} + +#[test] +fn name_compliance_address_and_required_hooks_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + assert_eq!(client.name(), String::from_str(&e, "TimeTransfersLimitsModule")); + assert_eq!( + client.required_hooks(), + vec![&e, ComplianceHook::CanTransfer, ComplianceHook::Transferred] + ); + + client.set_compliance_address(&compliance); + assert_eq!(client.get_compliance_address(), compliance); +} + +#[test] +fn set_identity_registry_storage_uses_admin_auth_before_compliance_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let irs = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_identity_registry_storage(&token, &irs); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); +} + +#[test] +fn set_identity_registry_storage_uses_compliance_auth_after_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let irs = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_compliance_address(&compliance); + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); + + client.set_identity_registry_storage(&token, &irs); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &compliance); +} + +#[test] +fn verify_hook_wiring_and_counters_affect_public_transfer_checks() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let recipient = Address::generate(&e); + let sender_identity = Address::generate(&e); + let limit = Limit { limit_time: 60, limit_value: 100 }; + let (module_address, client) = create_client(&e, &admin); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let compliance_id = e.register(MockComplianceContract, ()); + let compliance = MockComplianceContractClient::new(&e, &compliance_id); + + irs.set_identity(&sender, &sender_identity); + + client.set_compliance_address(&compliance_id); + for hook in [ComplianceHook::CanTransfer, ComplianceHook::Transferred] { + compliance.register_hook(&hook, &module_address); + } + + client.verify_hook_wiring(); + client.set_identity_registry_storage(&token, &irs_id); + client.set_time_transfer_limit(&token, &limit); + client.pre_set_transfer_counter( + &token, + &sender_identity, + &60, + &TransferCounter { value: 90, timer: e.ledger().timestamp().saturating_add(60) }, + ); + + assert!(!client.can_transfer(&sender, &recipient, &11, &token)); + assert!(client.can_transfer(&sender, &recipient, &10, &token)); + + client.on_transfer(&sender, &recipient, &10, &token); + assert!(!client.can_transfer(&sender, &recipient, &1, &token)); +} diff --git a/examples/rwa-transfer-restrict/Cargo.toml b/examples/rwa-transfer-restrict/Cargo.toml new file mode 100644 index 000000000..e6e333e09 --- /dev/null +++ b/examples/rwa-transfer-restrict/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "rwa-transfer-restrict" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true +authors.workspace = true + +[package.metadata.stellar] +cargo_inherit = true + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-tokens = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/examples/rwa-transfer-restrict/src/contract.rs b/examples/rwa-transfer-restrict/src/contract.rs new file mode 100644 index 000000000..b69043c63 --- /dev/null +++ b/examples/rwa-transfer-restrict/src/contract.rs @@ -0,0 +1,95 @@ +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::modules::{ + storage::{ + get_compliance_address, module_name, set_compliance_address, ComplianceModuleStorageKey, + }, + transfer_restrict::storage as transfer_restrict, + ComplianceModule, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct TransferRestrictContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl TransferRestrictContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } + + pub fn allow_user(e: &Env, token: Address, user: Address) { + require_module_admin_or_compliance_auth(e); + transfer_restrict::allow_user(e, &token, &user); + } + + pub fn disallow_user(e: &Env, token: Address, user: Address) { + require_module_admin_or_compliance_auth(e); + transfer_restrict::disallow_user(e, &token, &user); + } + + pub fn batch_allow_users(e: &Env, token: Address, users: Vec
) { + require_module_admin_or_compliance_auth(e); + transfer_restrict::batch_allow_users(e, &token, &users); + } + + pub fn batch_disallow_users(e: &Env, token: Address, users: Vec
) { + require_module_admin_or_compliance_auth(e); + transfer_restrict::batch_disallow_users(e, &token, &users); + } + + pub fn is_user_allowed(e: &Env, token: Address, user: Address) -> bool { + transfer_restrict::is_user_allowed(e, &token, &user) + } +} + +#[contractimpl(contracttrait)] +impl ComplianceModule for TransferRestrictContract { + fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {} + + fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {} + + fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {} + + fn can_transfer(e: &Env, from: Address, to: Address, _amount: i128, token: Address) -> bool { + transfer_restrict::can_transfer(e, &from, &to, &token) + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + true + } + + fn name(e: &Env) -> String { + module_name(e, "TransferRestrictModule") + } + + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-transfer-restrict/src/lib.rs b/examples/rwa-transfer-restrict/src/lib.rs new file mode 100644 index 000000000..a879b6f80 --- /dev/null +++ b/examples/rwa-transfer-restrict/src/lib.rs @@ -0,0 +1,5 @@ +#![no_std] + +pub mod contract; +#[cfg(test)] +mod test; diff --git a/examples/rwa-transfer-restrict/src/test.rs b/examples/rwa-transfer-restrict/src/test.rs new file mode 100644 index 000000000..7c5f07871 --- /dev/null +++ b/examples/rwa-transfer-restrict/src/test.rs @@ -0,0 +1,97 @@ +extern crate std; + +use soroban_sdk::{testutils::Address as _, vec, Address, Env, String}; + +use crate::contract::{TransferRestrictContract, TransferRestrictContractClient}; + +fn create_client<'a>(e: &Env, admin: &Address) -> (Address, TransferRestrictContractClient<'a>) { + let address = e.register(TransferRestrictContract, (admin,)); + (address.clone(), TransferRestrictContractClient::new(e, &address)) +} + +#[test] +fn allowlist_methods_and_can_transfer_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let recipient = Address::generate(&e); + let other = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + assert!(!client.is_user_allowed(&token, &sender)); + assert!(!client.can_transfer(&sender, &recipient, &1, &token)); + + client.allow_user(&token, &sender); + assert!(client.is_user_allowed(&token, &sender)); + assert!(client.can_transfer(&sender, &other, &1, &token)); + + client.disallow_user(&token, &sender); + assert!(!client.is_user_allowed(&token, &sender)); + + client.batch_allow_users(&token, &vec![&e, recipient.clone(), other.clone()]); + assert!(client.is_user_allowed(&token, &recipient)); + assert!(client.is_user_allowed(&token, &other)); + assert!(client.can_transfer(&sender, &recipient, &1, &token)); + + client.batch_disallow_users(&token, &vec![&e, recipient.clone(), other.clone()]); + assert!(!client.is_user_allowed(&token, &recipient)); + assert!(!client.is_user_allowed(&token, &other)); + assert!(!client.can_transfer(&sender, &recipient, &1, &token)); +} + +#[test] +fn name_and_compliance_address_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + assert_eq!(client.name(), String::from_str(&e, "TransferRestrictModule")); + + client.set_compliance_address(&compliance); + assert_eq!(client.get_compliance_address(), compliance); +} + +#[test] +fn allow_user_uses_admin_auth_before_compliance_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let user = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.allow_user(&token, &user); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); +} + +#[test] +fn allow_user_uses_compliance_auth_after_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let user = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_compliance_address(&compliance); + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); + + client.allow_user(&token, &user); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &compliance); +} diff --git a/packages/tokens/src/rwa/compliance/modules/country_allow/mod.rs b/packages/tokens/src/rwa/compliance/modules/country_allow/mod.rs new file mode 100644 index 000000000..1da53f7f2 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/country_allow/mod.rs @@ -0,0 +1,31 @@ +//! 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; +#[cfg(test)] +mod test; + +use soroban_sdk::{contractevent, 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, +} 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..d47da3698 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/country_allow/storage.rs @@ -0,0 +1,151 @@ +use soroban_sdk::{contracttype, Address, Env, Vec}; + +use super::{CountryAllowed, CountryUnallowed}; +use crate::rwa::compliance::modules::{ + storage::{country_code, get_irs_country_data_entries}, + MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD, +}; + +#[contracttype] +#[derive(Clone)] +pub enum CountryAllowStorageKey { + /// Per-(token, country) allowlist flag. + AllowedCountry(Address, u32), +} + +// ################## RAW STORAGE ################## + +/// 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() +} + +/// Writes a country's allowed flag to persistent storage. +/// +/// # 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 in persistent storage. +/// +/// # 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)); +} + +// ################## ACTIONS ################## + +/// Adds a country to the allowlist for `token`. +/// +/// Writes the flag to storage and emits [`CountryAllowed`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code to allow. +pub fn add_allowed_country(e: &Env, token: &Address, country: u32) { + set_country_allowed(e, token, country); + CountryAllowed { token: token.clone(), country }.publish(e); +} + +/// Removes a country from the allowlist for `token`. +/// +/// Deletes the flag from storage and emits [`CountryUnallowed`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code to remove. +pub fn remove_allowed_country(e: &Env, token: &Address, country: u32) { + remove_country_allowed(e, token, country); + CountryUnallowed { token: token.clone(), country }.publish(e); +} + +/// Adds multiple countries to the allowlist in a single call. +/// +/// Emits [`CountryAllowed`] for each country added. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `countries` - The country codes to allow. +pub fn batch_allow_countries(e: &Env, token: &Address, countries: &Vec) { + for country in countries.iter() { + set_country_allowed(e, token, country); + CountryAllowed { token: token.clone(), country }.publish(e); + } +} + +/// Removes multiple countries from the allowlist in a single call. +/// +/// Emits [`CountryUnallowed`] for each country removed. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `countries` - The country codes to remove. +pub fn batch_disallow_countries(e: &Env, token: &Address, countries: &Vec) { + for country in countries.iter() { + remove_country_allowed(e, token, country); + CountryUnallowed { token: token.clone(), country }.publish(e); + } +} + +// ################## COMPLIANCE HOOKS ################## + +/// Returns `true` if `to` has at least one allowed country in the IRS for +/// `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `to` - The recipient whose country data is checked. +/// * `token` - The token address. +/// +/// # Errors +/// +/// * [`crate::rwa::compliance::modules::ComplianceModuleError::IdentityRegistryNotSet`] +/// - When no IRS has been configured for `token`. +/// +/// # Cross-Contract Calls +/// +/// Calls the IRS to resolve country data for `to`. +pub fn can_transfer(e: &Env, to: &Address, token: &Address) -> bool { + let entries = get_irs_country_data_entries(e, token, to); + for entry in entries.iter() { + if is_country_allowed(e, token, country_code(&entry.country)) { + return true; + } + } + false +} diff --git a/packages/tokens/src/rwa/compliance/modules/country_allow/test.rs b/packages/tokens/src/rwa/compliance/modules/country_allow/test.rs new file mode 100644 index 000000000..a8b497283 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/country_allow/test.rs @@ -0,0 +1,183 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal, Val, + Vec, +}; + +use super::storage::{can_transfer, set_country_allowed}; +use crate::rwa::{ + compliance::modules::storage::set_irs_address, + identity_registry_storage::{ + CountryData, CountryDataManager, CountryRelation, IdentityRegistryStorage, + IndividualCountryRelation, OrganizationCountryRelation, + }, + utils::token_binder::TokenBinder, +}; + +#[contract] +struct MockIRSContract; + +#[contracttype] +#[derive(Clone)] +enum MockIRSStorageKey { + Identity(Address), + CountryEntries(Address), +} + +#[contractimpl] +impl TokenBinder for MockIRSContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl IdentityRegistryStorage for MockIRSContract { + fn add_identity( + _e: &Env, + _account: Address, + _identity: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_identity is not used in these tests"); + } + + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { + unreachable!("remove_identity is not used in these tests"); + } + + fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { + unreachable!("modify_identity is not used in these tests"); + } + + fn recover_identity( + _e: &Env, + _old_account: Address, + _new_account: Address, + _operator: Address, + ) { + unreachable!("recover_identity is not used in these tests"); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + e.storage() + .persistent() + .get(&MockIRSStorageKey::Identity(account.clone())) + .unwrap_or(account) + } +} + +#[contractimpl] +impl CountryDataManager for MockIRSContract { + fn add_country_data_entries( + _e: &Env, + _account: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_country_data_entries is not used in these tests"); + } + + fn modify_country_data( + _e: &Env, + _account: Address, + _index: u32, + _country_data: Val, + _operator: Address, + ) { + unreachable!("modify_country_data is not used in these tests"); + } + + fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { + unreachable!("delete_country_data is not used in these tests"); + } + + fn get_country_data_entries(e: &Env, account: Address) -> Vec { + let entries: Vec = e + .storage() + .persistent() + .get(&MockIRSStorageKey::CountryEntries(account)) + .unwrap_or_else(|| Vec::new(e)); + + Vec::from_iter(e, entries.iter().map(|entry| entry.into_val(e))) + } +} + +#[contractimpl] +impl MockIRSContract { + pub fn set_country_data_entries(e: &Env, account: Address, entries: Vec) { + e.storage().persistent().set(&MockIRSStorageKey::CountryEntries(account), &entries); + } +} + +#[contract] +struct TestCountryAllowContract; + +fn individual_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Individual(IndividualCountryRelation::Residence(code)), + metadata: None, + } +} + +fn organization_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Organization(OrganizationCountryRelation::OperatingJurisdiction( + code, + )), + metadata: None, + } +} + +#[test] +fn can_transfer_allows_when_any_country_matches() { + let e = Env::default(); + let module_id = e.register(TestCountryAllowContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let to = Address::generate(&e); + + irs.set_country_data_entries( + &to, + &vec![&e, individual_country(250), organization_country(276)], + ); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_country_allowed(&e, &token, 276); + + assert!(can_transfer(&e, &to, &token)); + }); +} + +#[test] +fn can_transfer_rejects_when_no_country_matches() { + let e = Env::default(); + let module_id = e.register(TestCountryAllowContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let empty_to = Address::generate(&e); + let disallowed_to = Address::generate(&e); + + irs.set_country_data_entries(&disallowed_to, &vec![&e, individual_country(250)]); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_country_allowed(&e, &token, 276); + + assert!(!can_transfer(&e, &empty_to, &token)); + assert!(!can_transfer(&e, &disallowed_to, &token)); + }); +} diff --git a/packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs b/packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs new file mode 100644 index 000000000..2cbd355a1 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs @@ -0,0 +1,31 @@ +//! 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; +#[cfg(test)] +mod test; + +use soroban_sdk::{contractevent, 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, +} 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..be7c0512e --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/country_restrict/storage.rs @@ -0,0 +1,151 @@ +use soroban_sdk::{contracttype, Address, Env, Vec}; + +use super::{CountryRestricted, CountryUnrestricted}; +use crate::rwa::compliance::modules::{ + storage::{country_code, get_irs_country_data_entries}, + MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD, +}; + +#[contracttype] +#[derive(Clone)] +pub enum CountryRestrictStorageKey { + /// Per-(token, country) restriction flag. + RestrictedCountry(Address, u32), +} + +// ################## RAW STORAGE ################## + +/// 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() +} + +/// Writes a country's restricted flag to persistent storage. +/// +/// # 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 in persistent storage. +/// +/// # 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)); +} + +// ################## ACTIONS ################## + +/// Adds a country to the restriction list for `token`. +/// +/// Writes the flag to storage and emits [`CountryRestricted`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code to restrict. +pub fn add_country_restriction(e: &Env, token: &Address, country: u32) { + set_country_restricted(e, token, country); + CountryRestricted { token: token.clone(), country }.publish(e); +} + +/// Removes a country from the restriction list for `token`. +/// +/// Deletes the flag from storage and emits [`CountryUnrestricted`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code to unrestrict. +pub fn remove_country_restriction(e: &Env, token: &Address, country: u32) { + remove_country_restricted(e, token, country); + CountryUnrestricted { token: token.clone(), country }.publish(e); +} + +/// Adds multiple countries to the restriction list in a single call. +/// +/// Emits [`CountryRestricted`] for each country added. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `countries` - The country codes to restrict. +pub fn batch_restrict_countries(e: &Env, token: &Address, countries: &Vec) { + for country in countries.iter() { + set_country_restricted(e, token, country); + CountryRestricted { token: token.clone(), country }.publish(e); + } +} + +/// Removes multiple countries from the restriction list in a single call. +/// +/// Emits [`CountryUnrestricted`] for each country removed. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `countries` - The country codes to unrestrict. +pub fn batch_unrestrict_countries(e: &Env, token: &Address, countries: &Vec) { + for country in countries.iter() { + remove_country_restricted(e, token, country); + CountryUnrestricted { token: token.clone(), country }.publish(e); + } +} + +// ################## COMPLIANCE HOOKS ################## + +/// Returns `false` if `to` has any restricted country in the IRS for `token`, +/// and `true` otherwise. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `to` - The recipient whose country data is checked. +/// * `token` - The token address. +/// +/// # Errors +/// +/// * [`crate::rwa::compliance::modules::ComplianceModuleError::IdentityRegistryNotSet`] +/// - When no IRS has been configured for `token`. +/// +/// # Cross-Contract Calls +/// +/// Calls the IRS to resolve country data for `to`. +pub fn can_transfer(e: &Env, to: &Address, token: &Address) -> bool { + let entries = get_irs_country_data_entries(e, token, to); + for entry in entries.iter() { + if is_country_restricted(e, token, country_code(&entry.country)) { + return false; + } + } + true +} diff --git a/packages/tokens/src/rwa/compliance/modules/country_restrict/test.rs b/packages/tokens/src/rwa/compliance/modules/country_restrict/test.rs new file mode 100644 index 000000000..f09ed9eab --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/country_restrict/test.rs @@ -0,0 +1,186 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal, Val, + Vec, +}; + +use super::storage::{can_transfer, set_country_restricted}; +use crate::rwa::{ + compliance::modules::storage::set_irs_address, + identity_registry_storage::{ + CountryData, CountryDataManager, CountryRelation, IdentityRegistryStorage, + IndividualCountryRelation, OrganizationCountryRelation, + }, + utils::token_binder::TokenBinder, +}; + +#[contract] +struct MockIRSContract; + +#[contracttype] +#[derive(Clone)] +enum MockIRSStorageKey { + Identity(Address), + CountryEntries(Address), +} + +#[contractimpl] +impl TokenBinder for MockIRSContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl IdentityRegistryStorage for MockIRSContract { + fn add_identity( + _e: &Env, + _account: Address, + _identity: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_identity is not used in these tests"); + } + + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { + unreachable!("remove_identity is not used in these tests"); + } + + fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { + unreachable!("modify_identity is not used in these tests"); + } + + fn recover_identity( + _e: &Env, + _old_account: Address, + _new_account: Address, + _operator: Address, + ) { + unreachable!("recover_identity is not used in these tests"); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + e.storage() + .persistent() + .get(&MockIRSStorageKey::Identity(account.clone())) + .unwrap_or(account) + } +} + +#[contractimpl] +impl CountryDataManager for MockIRSContract { + fn add_country_data_entries( + _e: &Env, + _account: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_country_data_entries is not used in these tests"); + } + + fn modify_country_data( + _e: &Env, + _account: Address, + _index: u32, + _country_data: Val, + _operator: Address, + ) { + unreachable!("modify_country_data is not used in these tests"); + } + + fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { + unreachable!("delete_country_data is not used in these tests"); + } + + fn get_country_data_entries(e: &Env, account: Address) -> Vec { + let entries: Vec = e + .storage() + .persistent() + .get(&MockIRSStorageKey::CountryEntries(account)) + .unwrap_or_else(|| Vec::new(e)); + + Vec::from_iter(e, entries.iter().map(|entry| entry.into_val(e))) + } +} + +#[contractimpl] +impl MockIRSContract { + pub fn set_country_data_entries(e: &Env, account: Address, entries: Vec) { + e.storage().persistent().set(&MockIRSStorageKey::CountryEntries(account), &entries); + } +} + +#[contract] +struct TestCountryRestrictContract; + +fn individual_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Individual(IndividualCountryRelation::Residence(code)), + metadata: None, + } +} + +fn organization_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Organization(OrganizationCountryRelation::OperatingJurisdiction( + code, + )), + metadata: None, + } +} + +#[test] +fn can_transfer_rejects_when_any_country_is_restricted() { + let e = Env::default(); + let module_id = e.register(TestCountryRestrictContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let to = Address::generate(&e); + + irs.set_country_data_entries( + &to, + &vec![&e, individual_country(250), organization_country(408)], + ); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_country_restricted(&e, &token, 408); + + assert!(!can_transfer(&e, &to, &token)); + }); +} + +#[test] +fn can_transfer_allows_when_no_country_is_restricted() { + let e = Env::default(); + let module_id = e.register(TestCountryRestrictContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let empty_to = Address::generate(&e); + let unrestricted_to = Address::generate(&e); + + irs.set_country_data_entries( + &unrestricted_to, + &vec![&e, individual_country(250), organization_country(276)], + ); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_country_restricted(&e, &token, 408); + + assert!(can_transfer(&e, &empty_to, &token)); + assert!(can_transfer(&e, &unrestricted_to, &token)); + }); +} diff --git a/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/mod.rs b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/mod.rs new file mode 100644 index 000000000..ed816abfe --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/mod.rs @@ -0,0 +1,24 @@ +//! 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, Address}; +pub use storage::LockedTokens; + +/// 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, +} 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..8224d7ad1 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/storage.rs @@ -0,0 +1,417 @@ +use soroban_sdk::{contracttype, vec, Address, Env, Vec}; + +use super::LockupPeriodSet; +use crate::rwa::compliance::{ + modules::{ + storage::{ + add_i128_or_panic, hooks_verified, require_non_negative_amount, sub_i128_or_panic, + verify_required_hooks, + }, + MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD, + }, + ComplianceHook, +}; + +/// A single mint-created lock entry tracking the locked amount and its +/// release time. Mirrors T-REX `LockedTokens { amount, releaseTimestamp }`. +#[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), +} + +// ################## RAW STORAGE ################## + +/// 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); +} + +// ################## HELPERS ################## + +fn calculate_unlocked_amount(e: &Env, locks: &Vec) -> i128 { + let now = e.ledger().timestamp(); + let mut unlocked = 0i128; + for i in 0..locks.len() { + let lock = locks.get(i).unwrap(); + if lock.release_timestamp <= now { + unlocked = add_i128_or_panic(e, unlocked, lock.amount); + } + } + unlocked +} + +fn calculate_total_locked_amount(e: &Env, locks: &Vec) -> i128 { + let mut total = 0i128; + for i in 0..locks.len() { + let lock = locks.get(i).unwrap(); + require_non_negative_amount(e, lock.amount); + total = add_i128_or_panic(e, total, lock.amount); + } + total +} + +fn update_locked_tokens(e: &Env, token: &Address, wallet: &Address, mut amount_to_consume: i128) { + let locks = get_locks(e, token, wallet); + let now = e.ledger().timestamp(); + let mut new_locks = Vec::new(e); + let mut consumed_total = 0i128; + + for i in 0..locks.len() { + let lock = locks.get(i).unwrap(); + if amount_to_consume > 0 && lock.release_timestamp <= now { + if amount_to_consume >= lock.amount { + amount_to_consume = sub_i128_or_panic(e, amount_to_consume, lock.amount); + consumed_total = add_i128_or_panic(e, consumed_total, lock.amount); + } else { + consumed_total = add_i128_or_panic(e, consumed_total, amount_to_consume); + new_locks.push_back(LockedTokens { + amount: sub_i128_or_panic(e, lock.amount, amount_to_consume), + release_timestamp: lock.release_timestamp, + }); + amount_to_consume = 0; + } + } else { + new_locks.push_back(lock); + } + } + + set_locks(e, token, wallet, &new_locks); + + let total_locked = get_total_locked(e, token, wallet); + set_total_locked(e, token, wallet, sub_i128_or_panic(e, total_locked, consumed_total)); +} + +// ################## ACTIONS ################## + +/// Configures the lockup period for `token` and emits [`LockupPeriodSet`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `lockup_seconds` - The lockup duration in seconds. +pub fn configure_lockup_period(e: &Env, token: &Address, lockup_seconds: u64) { + set_lockup_period(e, token, lockup_seconds); + LockupPeriodSet { token: token.clone(), lockup_seconds }.publish(e); +} + +/// Pre-seeds the lockup state for a wallet. Validates that total locked +/// does not exceed balance. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `wallet` - The wallet address. +/// * `balance` - The wallet balance. +/// * `locks` - The lock entries. +pub fn pre_set_lockup_state( + e: &Env, + token: &Address, + wallet: &Address, + balance: i128, + locks: &Vec, +) { + require_non_negative_amount(e, balance); + + let total_locked = calculate_total_locked_amount(e, locks); + assert!( + total_locked <= balance, + "InitialLockupPeriodModule: total locked amount cannot exceed balance" + ); + + set_internal_balance(e, token, wallet, balance); + set_locks(e, token, wallet, locks); + set_total_locked(e, token, wallet, total_locked); +} + +// ################## HOOK WIRING ################## + +/// Returns the set of compliance hooks this module requires. +pub fn required_hooks(e: &Env) -> Vec { + vec![ + e, + ComplianceHook::CanTransfer, + ComplianceHook::Created, + ComplianceHook::Transferred, + ComplianceHook::Destroyed, + ] +} + +/// Cross-calls the compliance contract to verify that this module is +/// registered on all required hooks. +pub fn verify_hook_wiring(e: &Env) { + verify_required_hooks(e, required_hooks(e)); +} + +// ################## COMPLIANCE HOOKS ################## + +/// Updates internal balances and lock tracking after a transfer. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `from` - The sender address. +/// * `to` - The recipient address. +/// * `amount` - The transfer amount. +/// * `token` - The token address. +pub fn on_transfer(e: &Env, from: &Address, to: &Address, amount: i128, token: &Address) { + require_non_negative_amount(e, amount); + + let total_locked = get_total_locked(e, token, from); + + if total_locked > 0 { + let pre_balance = get_internal_balance(e, token, from); + let pre_free = pre_balance - total_locked; + + if amount > pre_free.max(0) { + let to_consume = amount - pre_free.max(0); + update_locked_tokens(e, token, from, to_consume); + } + } + + let from_bal = get_internal_balance(e, token, from); + set_internal_balance(e, token, from, sub_i128_or_panic(e, from_bal, amount)); + + let to_bal = get_internal_balance(e, token, to); + set_internal_balance(e, token, to, add_i128_or_panic(e, to_bal, amount)); +} + +/// Updates internal balance and creates a lock entry after a mint. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `to` - The recipient address. +/// * `amount` - The minted amount. +/// * `token` - The token address. +pub fn on_created(e: &Env, to: &Address, amount: i128, token: &Address) { + require_non_negative_amount(e, amount); + + let period = get_lockup_period(e, token); + if period > 0 { + let mut locks = get_locks(e, token, to); + locks.push_back(LockedTokens { + amount, + release_timestamp: e.ledger().timestamp().saturating_add(period), + }); + set_locks(e, token, to, &locks); + + let total = get_total_locked(e, token, to); + set_total_locked(e, token, to, add_i128_or_panic(e, total, amount)); + } + + let current = get_internal_balance(e, token, to); + set_internal_balance(e, token, to, add_i128_or_panic(e, current, amount)); +} + +/// Updates internal balance and consumes locks after a burn. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `from` - The burner address. +/// * `amount` - The burned amount. +/// * `token` - The token address. +pub fn on_destroyed(e: &Env, from: &Address, amount: i128, token: &Address) { + require_non_negative_amount(e, amount); + + let total_locked = get_total_locked(e, token, from); + + if total_locked > 0 { + let pre_balance = get_internal_balance(e, token, from); + let mut free_amount = pre_balance - total_locked; + + if free_amount < amount { + let locks = get_locks(e, token, from); + free_amount += calculate_unlocked_amount(e, &locks); + } + + assert!( + free_amount >= amount, + "InitialLockupPeriodModule: insufficient unlocked balance for burn" + ); + + let pre_free = pre_balance - total_locked; + if amount > pre_free.max(0) { + let to_consume = amount - pre_free.max(0); + update_locked_tokens(e, token, from, to_consume); + } + } + + let current = get_internal_balance(e, token, from); + set_internal_balance(e, token, from, sub_i128_or_panic(e, current, amount)); +} + +/// Returns `true` if the sender has sufficient unlocked balance for the +/// transfer. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `from` - The sender address. +/// * `amount` - The transfer amount. +/// * `token` - The token address. +pub fn can_transfer(e: &Env, from: &Address, amount: i128, token: &Address) -> bool { + assert!( + hooks_verified(e), + "InitialLockupPeriodModule: not armed — call verify_hook_wiring() after wiring hooks \ + [CanTransfer, Created, Transferred, Destroyed]" + ); + if amount < 0 { + return false; + } + + let total_locked = get_total_locked(e, token, from); + if total_locked == 0 { + return true; + } + + let balance = get_internal_balance(e, token, from); + let free = balance - total_locked; + + if free >= amount { + return true; + } + + let locks = get_locks(e, token, from); + let unlocked = calculate_unlocked_amount(e, &locks); + (free + unlocked) >= amount +} diff --git a/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/test.rs b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/test.rs new file mode 100644 index 000000000..ec6951c91 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/test.rs @@ -0,0 +1,159 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, +}; + +use super::storage::{ + can_transfer, get_internal_balance, get_total_locked, pre_set_lockup_state, verify_hook_wiring, + LockedTokens, +}; +use crate::rwa::{ + compliance::{ + modules::storage::{hooks_verified, set_compliance_address, ComplianceModuleStorageKey}, + Compliance, ComplianceHook, + }, + utils::token_binder::TokenBinder, +}; + +#[contract] +struct TestModuleContract; + +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(TestModuleContract, ()); + 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(); + + let module_id = e.register(TestModuleContract, ()); + 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, + &wallet, + 100, + &vec![ + &e, + LockedTokens { + amount: 80, + release_timestamp: e.ledger().timestamp().saturating_add(60), + }, + ], + ); + + assert_eq!(get_internal_balance(&e, &token, &wallet), 100); + assert_eq!(get_total_locked(&e, &token, &wallet), 80); + assert!(!can_transfer(&e, &wallet, 21, &token)); + assert!(can_transfer(&e, &wallet, 20, &token)); + }); +} diff --git a/packages/tokens/src/rwa/compliance/modules/max_balance/mod.rs b/packages/tokens/src/rwa/compliance/modules/max_balance/mod.rs new file mode 100644 index 000000000..a20b71d1b --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/max_balance/mod.rs @@ -0,0 +1,32 @@ +//! 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, Address}; + +/// 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, +} 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..6a894523b --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/max_balance/storage.rs @@ -0,0 +1,315 @@ +use soroban_sdk::{contracttype, vec, Address, Env, Vec}; + +use super::{IDBalancePreSet, MaxBalanceSet}; +use crate::rwa::compliance::{ + modules::{ + storage::{ + add_i128_or_panic, get_irs_client, hooks_verified, require_non_negative_amount, + sub_i128_or_panic, verify_required_hooks, + }, + MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD, + }, + ComplianceHook, +}; + +#[contracttype] +#[derive(Clone)] +pub enum MaxBalanceStorageKey { + /// Per-token maximum allowed identity balance. + MaxBalance(Address), + /// Balance keyed by (token, identity) — not by wallet. + IDBalance(Address, Address), +} + +// ################## RAW STORAGE ################## + +/// 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); +} + +// ################## HELPERS ################## + +fn can_increase_identity_balance( + e: &Env, + token: &Address, + identity: &Address, + amount: i128, +) -> bool { + if amount < 0 { + return false; + } + + let max = get_max_balance(e, token); + if max == 0 { + return true; + } + + let current = get_id_balance(e, token, identity); + add_i128_or_panic(e, current, amount) <= max +} + +// ################## ACTIONS ################## + +/// Validates, stores, and emits [`MaxBalanceSet`] for the given cap. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `max` - The maximum balance per identity. +pub fn configure_max_balance(e: &Env, token: &Address, max: i128) { + require_non_negative_amount(e, max); + set_max_balance(e, token, max); + MaxBalanceSet { token: token.clone(), max_balance: max }.publish(e); +} + +/// Pre-seeds the tracked balance for an identity and emits +/// [`IDBalancePreSet`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `identity` - The on-chain identity address. +/// * `balance` - The pre-seeded balance value. +pub fn pre_set_identity_balance(e: &Env, token: &Address, identity: &Address, balance: i128) { + require_non_negative_amount(e, balance); + set_id_balance(e, token, identity, balance); + IDBalancePreSet { token: token.clone(), identity: identity.clone(), balance }.publish(e); +} + +/// Pre-seeds tracked balances for multiple identities. Emits +/// [`IDBalancePreSet`] for each. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `identities` - Identity addresses. +/// * `balances` - Corresponding balance values. +pub fn batch_pre_set_identity_balances( + e: &Env, + token: &Address, + identities: &Vec
, + balances: &Vec, +) { + assert!( + identities.len() == balances.len(), + "MaxBalanceModule: identities and balances length mismatch" + ); + for i in 0..identities.len() { + let id = identities.get(i).unwrap(); + let bal = balances.get(i).unwrap(); + require_non_negative_amount(e, bal); + set_id_balance(e, token, &id, bal); + IDBalancePreSet { token: token.clone(), identity: id, balance: bal }.publish(e); + } +} + +// ################## HOOK WIRING ################## + +/// Returns the set of compliance hooks this module requires. +pub fn required_hooks(e: &Env) -> Vec { + vec![ + e, + ComplianceHook::CanTransfer, + ComplianceHook::CanCreate, + ComplianceHook::Transferred, + ComplianceHook::Created, + ComplianceHook::Destroyed, + ] +} + +/// Cross-calls the compliance contract to verify that this module is +/// registered on all required hooks. +pub fn verify_hook_wiring(e: &Env) { + verify_required_hooks(e, required_hooks(e)); +} + +// ################## COMPLIANCE HOOKS ################## + +/// Updates identity balances after a transfer. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `from` - The sender address. +/// * `to` - The recipient address. +/// * `amount` - The transfer amount. +/// * `token` - The token address. +pub fn on_transfer(e: &Env, from: &Address, to: &Address, amount: i128, token: &Address) { + require_non_negative_amount(e, amount); + + let irs = get_irs_client(e, token); + let from_id = irs.stored_identity(from); + let to_id = irs.stored_identity(to); + + if from_id == to_id { + return; + } + + let from_balance = get_id_balance(e, token, &from_id); + assert!( + can_increase_identity_balance(e, token, &to_id, amount), + "MaxBalanceModule: recipient identity balance exceeds max" + ); + + let to_balance = get_id_balance(e, token, &to_id); + let new_to_balance = add_i128_or_panic(e, to_balance, amount); + set_id_balance(e, token, &from_id, sub_i128_or_panic(e, from_balance, amount)); + set_id_balance(e, token, &to_id, new_to_balance); +} + +/// Updates identity balance after a mint. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `to` - The recipient address. +/// * `amount` - The minted amount. +/// * `token` - The token address. +pub fn on_created(e: &Env, to: &Address, amount: i128, token: &Address) { + require_non_negative_amount(e, amount); + + let irs = get_irs_client(e, token); + let to_id = irs.stored_identity(to); + + assert!( + can_increase_identity_balance(e, token, &to_id, amount), + "MaxBalanceModule: recipient identity balance exceeds max after mint" + ); + + let current = get_id_balance(e, token, &to_id); + let new_balance = add_i128_or_panic(e, current, amount); + set_id_balance(e, token, &to_id, new_balance); +} + +/// Updates identity balance after a burn. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `from` - The burner address. +/// * `amount` - The burned amount. +/// * `token` - The token address. +pub fn on_destroyed(e: &Env, from: &Address, amount: i128, token: &Address) { + require_non_negative_amount(e, amount); + + let irs = get_irs_client(e, token); + let from_id = irs.stored_identity(from); + + let current = get_id_balance(e, token, &from_id); + set_id_balance(e, token, &from_id, sub_i128_or_panic(e, current, amount)); +} + +/// Checks whether a transfer would exceed the recipient identity's +/// balance cap. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `from` - The sender address. +/// * `to` - The recipient address. +/// * `amount` - The transfer amount. +/// * `token` - The token address. +pub fn can_transfer(e: &Env, from: &Address, to: &Address, amount: i128, token: &Address) -> bool { + assert!( + hooks_verified(e), + "MaxBalanceModule: not armed — call verify_hook_wiring() after wiring hooks [CanTransfer, \ + CanCreate, Transferred, Created, Destroyed]" + ); + if amount < 0 { + return false; + } + let irs = get_irs_client(e, token); + let from_id = irs.stored_identity(from); + let to_id = irs.stored_identity(to); + + if from_id == to_id { + return true; + } + + can_increase_identity_balance(e, token, &to_id, amount) +} + +/// Checks whether a mint would exceed the recipient identity's balance +/// cap. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `to` - The recipient address. +/// * `amount` - The mint amount. +/// * `token` - The token address. +pub fn can_create(e: &Env, to: &Address, amount: i128, token: &Address) -> bool { + assert!( + hooks_verified(e), + "MaxBalanceModule: not armed — call verify_hook_wiring() after wiring hooks [CanTransfer, \ + CanCreate, Transferred, Created, Destroyed]" + ); + if amount < 0 { + return false; + } + let irs = get_irs_client(e, token); + let to_id = irs.stored_identity(to); + can_increase_identity_balance(e, token, &to_id, amount) +} diff --git a/packages/tokens/src/rwa/compliance/modules/max_balance/test.rs b/packages/tokens/src/rwa/compliance/modules/max_balance/test.rs new file mode 100644 index 000000000..c45ae068d --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/max_balance/test.rs @@ -0,0 +1,447 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, Address, Env, Val, Vec, +}; + +use super::storage::{ + can_create, can_transfer, get_id_balance, on_created, on_destroyed, on_transfer, + set_id_balance, set_max_balance, verify_hook_wiring, +}; +use crate::rwa::{ + compliance::{ + 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; + +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, 50, &token)); + assert!(can_create(&e, &recipient, 40, &token)); + }); +} + +#[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, &recipient, 50, &token)); + assert!(can_transfer(&e, &sender, &recipient, 40, &token)); + }); +} + +#[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, 1_000, &token)); + assert!(!can_create(&e, &recipient, -1, &token)); + }); +} + +#[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)); + }); +} + +#[test] +fn can_transfer_rejects_negative_amount_before_requiring_irs() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let recipient = Address::generate(&e); + + e.as_contract(&module_id, || { + arm_hooks(&e); + + assert!(!can_transfer(&e, &sender, &recipient, -1, &token)); + }); +} + +#[test] +fn can_transfer_allows_when_sender_and_recipient_share_identity() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let recipient = Address::generate(&e); + let shared_identity = Address::generate(&e); + + irs.set_identity(&sender, &shared_identity); + irs.set_identity(&recipient, &shared_identity); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + arm_hooks(&e); + set_max_balance(&e, &token, 1); + + assert!(can_transfer(&e, &sender, &recipient, 1_000, &token)); + }); +} + +#[test] +fn on_transfer_updates_balances_for_distinct_identities() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let recipient = Address::generate(&e); + let sender_identity = Address::generate(&e); + let recipient_identity = Address::generate(&e); + + irs.set_identity(&sender, &sender_identity); + irs.set_identity(&recipient, &recipient_identity); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_max_balance(&e, &token, 200); + set_id_balance(&e, &token, &sender_identity, 100); + set_id_balance(&e, &token, &recipient_identity, 20); + + on_transfer(&e, &sender, &recipient, 30, &token); + + assert_eq!(get_id_balance(&e, &token, &sender_identity), 70); + assert_eq!(get_id_balance(&e, &token, &recipient_identity), 50); + }); +} + +#[test] +fn on_transfer_is_noop_for_same_identity() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let recipient = Address::generate(&e); + let shared_identity = Address::generate(&e); + + irs.set_identity(&sender, &shared_identity); + irs.set_identity(&recipient, &shared_identity); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_id_balance(&e, &token, &shared_identity, 100); + + on_transfer(&e, &sender, &recipient, 30, &token); + + assert_eq!(get_id_balance(&e, &token, &shared_identity), 100); + }); +} + +#[test] +fn on_created_updates_identity_balance() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let recipient = Address::generate(&e); + let recipient_identity = Address::generate(&e); + + irs.set_identity(&recipient, &recipient_identity); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_max_balance(&e, &token, 200); + set_id_balance(&e, &token, &recipient_identity, 50); + + on_created(&e, &recipient, 30, &token); + + assert_eq!(get_id_balance(&e, &token, &recipient_identity), 80); + }); +} + +#[test] +fn on_destroyed_updates_identity_balance() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let holder = Address::generate(&e); + let holder_identity = Address::generate(&e); + + irs.set_identity(&holder, &holder_identity); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_id_balance(&e, &token, &holder_identity, 90); + + on_destroyed(&e, &holder, 40, &token); + + assert_eq!(get_id_balance(&e, &token, &holder_identity), 50); + }); +} diff --git a/packages/tokens/src/rwa/compliance/modules/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..6082d490d --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/supply_limit/mod.rs @@ -0,0 +1,21 @@ +//! 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, Address}; + +/// 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, +} 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..d337e5a94 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/supply_limit/storage.rs @@ -0,0 +1,198 @@ +use soroban_sdk::{contracttype, panic_with_error, vec, Address, Env, Vec}; + +use super::SupplyLimitSet; +use crate::rwa::compliance::{ + modules::{ + storage::{ + add_i128_or_panic, hooks_verified, require_non_negative_amount, sub_i128_or_panic, + verify_required_hooks, + }, + ComplianceModuleError, MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD, + }, + ComplianceHook, +}; + +#[contracttype] +#[derive(Clone)] +pub enum SupplyLimitStorageKey { + /// Per-token supply cap. + SupplyLimit(Address), + /// Per-token internal supply counter (updated via hooks). + InternalSupply(Address), +} + +// ################## RAW STORAGE ################## + +/// 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); +} + +// ################## ACTIONS ################## + +/// Validates, stores, and emits [`SupplyLimitSet`] for the given cap. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `limit` - The supply cap. +pub fn configure_supply_limit(e: &Env, token: &Address, limit: i128) { + require_non_negative_amount(e, limit); + set_supply_limit(e, token, limit); + SupplyLimitSet { token: token.clone(), limit }.publish(e); +} + +/// Pre-seeds the internal supply counter for a token. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `supply` - The pre-seeded supply value. +pub fn pre_set_supply(e: &Env, token: &Address, supply: i128) { + require_non_negative_amount(e, supply); + set_internal_supply(e, token, supply); +} + +// ################## HOOK WIRING ################## + +/// Returns the set of compliance hooks this module requires. +pub fn required_hooks(e: &Env) -> Vec { + vec![e, ComplianceHook::CanCreate, ComplianceHook::Created, ComplianceHook::Destroyed] +} + +/// Cross-calls the compliance contract to verify that this module is +/// registered on all required hooks. +pub fn verify_hook_wiring(e: &Env) { + verify_required_hooks(e, required_hooks(e)); +} + +// ################## COMPLIANCE HOOKS ################## + +/// Updates the internal supply counter after a mint. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `amount` - The minted amount. +/// * `token` - The token address. +pub fn on_created(e: &Env, amount: i128, token: &Address) { + require_non_negative_amount(e, amount); + let current = get_internal_supply(e, token); + set_internal_supply(e, token, add_i128_or_panic(e, current, amount)); +} + +/// Updates the internal supply counter after a burn. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `amount` - The burned amount. +/// * `token` - The token address. +pub fn on_destroyed(e: &Env, amount: i128, token: &Address) { + require_non_negative_amount(e, amount); + let current = get_internal_supply(e, token); + set_internal_supply(e, token, sub_i128_or_panic(e, current, amount)); +} + +/// Checks whether a mint would exceed the supply cap. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `amount` - The mint amount. +/// * `token` - The token address. +pub fn can_create(e: &Env, amount: i128, token: &Address) -> bool { + assert!( + hooks_verified(e), + "SupplyLimitModule: not armed — call verify_hook_wiring() after wiring hooks [CanCreate, \ + Created, Destroyed]" + ); + if amount < 0 { + return false; + } + let limit = get_supply_limit(e, token); + if limit == 0 { + return true; + } + let supply = get_internal_supply(e, token); + add_i128_or_panic(e, supply, amount) <= limit +} diff --git a/packages/tokens/src/rwa/compliance/modules/supply_limit/test.rs b/packages/tokens/src/rwa/compliance/modules/supply_limit/test.rs new file mode 100644 index 000000000..18245ba92 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/supply_limit/test.rs @@ -0,0 +1,207 @@ +extern crate std; + +use soroban_sdk::{contract, contractimpl, contracttype, testutils::Address as _, Address, Env}; + +use super::storage::{ + can_create, configure_supply_limit, get_internal_supply, get_supply_limit_or_panic, on_created, + on_destroyed, pre_set_supply, verify_hook_wiring, +}; +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; + +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!(super::storage::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); + + e.as_contract(&module_id, || { + arm_hooks(&e); + + assert!(can_create(&e, 100, &token)); + assert!(!can_create(&e, -1, &token)); + }); +} + +#[test] +fn hooks_update_internal_supply_and_cap_future_mints() { + let e = Env::default(); + let module_id = e.register(TestSupplyLimitContract, ()); + let token = Address::generate(&e); + + e.as_contract(&module_id, || { + arm_hooks(&e); + configure_supply_limit(&e, &token, 100); + + assert!(can_create(&e, 80, &token)); + on_created(&e, 80, &token); + assert_eq!(get_internal_supply(&e, &token), 80); + + assert!(!can_create(&e, 30, &token)); + + on_destroyed(&e, 20, &token); + assert_eq!(get_internal_supply(&e, &token), 60); + assert!(can_create(&e, 40, &token)); + }); +} + +#[test] +fn pre_set_internal_supply_seeds_existing_supply_for_cap_checks() { + let e = Env::default(); + let module_id = e.register(TestSupplyLimitContract, ()); + let token = Address::generate(&e); + + e.as_contract(&module_id, || { + arm_hooks(&e); + configure_supply_limit(&e, &token, 100); + pre_set_supply(&e, &token, 90); + + assert_eq!(get_internal_supply(&e, &token), 90); + assert!(!can_create(&e, 11, &token)); + assert!(can_create(&e, 10, &token)); + }); +} + +#[test] +fn get_supply_limit_or_panic_returns_configured_limit() { + let e = Env::default(); + let module_id = e.register(TestSupplyLimitContract, ()); + let token = Address::generate(&e); + + e.as_contract(&module_id, || { + configure_supply_limit(&e, &token, 100); + + assert_eq!(get_supply_limit_or_panic(&e, &token), 100); + }); +} + +#[test] +#[should_panic] +fn get_supply_limit_or_panic_panics_when_unconfigured() { + let e = Env::default(); + let module_id = e.register(TestSupplyLimitContract, ()); + let token = Address::generate(&e); + + e.as_contract(&module_id, || { + let _ = get_supply_limit_or_panic(&e, &token); + }); +} diff --git a/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/mod.rs b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/mod.rs new file mode 100644 index 000000000..49215ef2d --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/mod.rs @@ -0,0 +1,34 @@ +//! 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, Address}; +pub use storage::{Limit, TransferCounter}; + +pub 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, +} 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..b46cde1a6 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/storage.rs @@ -0,0 +1,350 @@ +use soroban_sdk::{contracttype, panic_with_error, vec, Address, Env, Vec}; + +use super::{TimeTransferLimitRemoved, TimeTransferLimitUpdated, MAX_LIMITS_PER_TOKEN}; +use crate::rwa::compliance::{ + modules::{ + storage::{ + add_i128_or_panic, get_irs_client, hooks_verified, require_non_negative_amount, + set_irs_address, verify_required_hooks, + }, + ComplianceModuleError, MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD, + }, + ComplianceHook, +}; + +/// A single time-window limit: `limit_value` tokens may be transferred +/// within a rolling window of `limit_time` seconds. +#[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), +} + +// ################## RAW STORAGE ################## + +/// 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); +} + +// ################## HELPERS ################## + +fn is_counter_finished(e: &Env, token: &Address, identity: &Address, limit_time: u64) -> bool { + let counter = get_counter(e, token, identity, limit_time); + counter.timer <= e.ledger().timestamp() +} + +fn reset_counter_if_needed(e: &Env, token: &Address, identity: &Address, limit_time: u64) { + if is_counter_finished(e, token, identity, limit_time) { + let counter = + TransferCounter { value: 0, timer: e.ledger().timestamp().saturating_add(limit_time) }; + set_counter(e, token, identity, limit_time, &counter); + } +} + +fn increase_counters(e: &Env, token: &Address, identity: &Address, value: i128) { + let limits = get_limits(e, token); + for limit in limits.iter() { + reset_counter_if_needed(e, token, identity, limit.limit_time); + let mut counter = get_counter(e, token, identity, limit.limit_time); + counter.value = add_i128_or_panic(e, counter.value, value); + set_counter(e, token, identity, limit.limit_time, &counter); + } +} + +// ################## ACTIONS ################## + +/// Configures the identity registry storage address for a token. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `irs` - The identity registry storage address. +pub fn configure_irs(e: &Env, token: &Address, irs: &Address) { + set_irs_address(e, token, irs); +} + +/// Sets or updates a time-window transfer limit for `token` and emits +/// [`TimeTransferLimitUpdated`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `limit` - The limit to set. +pub fn set_time_transfer_limit(e: &Env, token: &Address, limit: &Limit) { + assert!(limit.limit_time > 0, "limit_time must be greater than zero"); + require_non_negative_amount(e, limit.limit_value); + let mut limits = get_limits(e, token); + + let mut replaced = false; + for i in 0..limits.len() { + let current = limits.get(i).expect("limit exists"); + if current.limit_time == limit.limit_time { + limits.set(i, limit.clone()); + replaced = true; + break; + } + } + + if !replaced { + if limits.len() >= MAX_LIMITS_PER_TOKEN { + panic_with_error!(e, ComplianceModuleError::TooManyLimits); + } + limits.push_back(limit.clone()); + } + + set_limits(e, token, &limits); + TimeTransferLimitUpdated { token: token.clone(), limit: limit.clone() }.publish(e); +} + +/// Sets or updates multiple time-window transfer limits in a single call. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `limits` - The limits to set. +pub fn batch_set_time_transfer_limit(e: &Env, token: &Address, limits: &Vec) { + for limit in limits.iter() { + set_time_transfer_limit(e, token, &limit); + } +} + +/// Removes a time-window transfer limit and emits +/// [`TimeTransferLimitRemoved`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `limit_time` - The time-window to remove. +pub fn remove_time_transfer_limit(e: &Env, token: &Address, limit_time: u64) { + let mut limits = get_limits(e, token); + + let mut found = false; + for i in 0..limits.len() { + let current = limits.get(i).expect("limit exists"); + if current.limit_time == limit_time { + limits.remove(i); + found = true; + break; + } + } + + if !found { + panic_with_error!(e, ComplianceModuleError::MissingLimit); + } + + set_limits(e, token, &limits); + TimeTransferLimitRemoved { token: token.clone(), limit_time }.publish(e); +} + +/// Removes multiple time-window transfer limits in a single call. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `limit_times` - The time-windows to remove. +pub fn batch_remove_time_transfer_limit(e: &Env, token: &Address, limit_times: &Vec) { + for lt in limit_times.iter() { + remove_time_transfer_limit(e, token, lt); + } +} + +/// Pre-seeds a transfer counter for a given identity and time window. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `identity` - The on-chain identity address. +/// * `limit_time` - The time-window duration in seconds. +/// * `counter` - The counter value to set. +pub fn pre_set_transfer_counter( + e: &Env, + token: &Address, + identity: &Address, + limit_time: u64, + counter: &TransferCounter, +) { + require_non_negative_amount(e, counter.value); + assert!(limit_time > 0, "limit_time must be greater than zero"); + + let mut found = false; + for limit in get_limits(e, token).iter() { + if limit.limit_time == limit_time { + found = true; + break; + } + } + + if !found { + panic_with_error!(e, ComplianceModuleError::MissingLimit); + } + + set_counter(e, token, identity, limit_time, counter); +} + +// ################## HOOK WIRING ################## + +/// Returns the set of compliance hooks this module requires. +pub fn required_hooks(e: &Env) -> Vec { + vec![e, ComplianceHook::CanTransfer, ComplianceHook::Transferred] +} + +/// Cross-calls the compliance contract to verify that this module is +/// registered on all required hooks. +pub fn verify_hook_wiring(e: &Env) { + verify_required_hooks(e, required_hooks(e)); +} + +// ################## COMPLIANCE HOOKS ################## + +/// Resolves the sender's identity and increments transfer counters. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `from` - The sender address. +/// * `amount` - The transfer amount. +/// * `token` - The token address. +pub fn on_transfer(e: &Env, from: &Address, amount: i128, token: &Address) { + require_non_negative_amount(e, amount); + let irs = get_irs_client(e, token); + let from_id = irs.stored_identity(from); + increase_counters(e, token, &from_id, amount); +} + +/// Returns `true` if the transfer does not exceed any configured +/// time-window limit. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `from` - The sender address. +/// * `amount` - The transfer amount. +/// * `token` - The token address. +/// +/// # Errors +/// +/// * [`crate::rwa::compliance::modules::ComplianceModuleError::IdentityRegistryNotSet`] +/// - When no IRS has been configured for `token`. +pub fn can_transfer(e: &Env, from: &Address, amount: i128, token: &Address) -> bool { + assert!( + hooks_verified(e), + "TimeTransfersLimitsModule: not armed — call verify_hook_wiring() after wiring hooks \ + [CanTransfer, Transferred]" + ); + if amount < 0 { + return false; + } + let irs = get_irs_client(e, token); + let from_id = irs.stored_identity(from); + let limits = get_limits(e, token); + + for limit in limits.iter() { + if amount > limit.limit_value { + return false; + } + + if !is_counter_finished(e, token, &from_id, limit.limit_time) { + let counter = get_counter(e, token, &from_id, limit.limit_time); + if add_i128_or_panic(e, counter.value, amount) > limit.limit_value { + return false; + } + } + } + + true +} diff --git a/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/test.rs b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/test.rs new file mode 100644 index 000000000..721336728 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/test.rs @@ -0,0 +1,274 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, Address, Env, Val, Vec, +}; + +use super::storage::{ + can_transfer, pre_set_transfer_counter, set_time_transfer_limit, verify_hook_wiring, Limit, + TransferCounter, +}; +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 TestModuleContract; + +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(TestModuleContract, ()); + 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(); + + let module_id = e.register(TestModuleContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let sender_identity = Address::generate(&e); + + 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); + + set_time_transfer_limit(&e, &token, &Limit { limit_time: 60, limit_value: 100 }); + pre_set_transfer_counter( + &e, + &token, + &sender_identity, + 60, + &TransferCounter { value: 90, timer: e.ledger().timestamp().saturating_add(60) }, + ); + + assert!(!can_transfer(&e, &sender, 11, &token)); + assert!(can_transfer(&e, &sender, 10, &token)); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #400)")] +fn set_time_transfer_limit_rejects_more_than_four_limits() { + let e = Env::default(); + + let module_id = e.register(TestModuleContract, ()); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + + e.as_contract(&module_id, || { + set_compliance_address(&e, &compliance); + + for limit_time in [60_u64, 120, 180, 240] { + set_time_transfer_limit(&e, &token, &Limit { limit_time, limit_value: 100 }); + } + + set_time_transfer_limit(&e, &token, &Limit { limit_time: 300, limit_value: 100 }); + }); +} diff --git a/packages/tokens/src/rwa/compliance/modules/transfer_restrict/mod.rs b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/mod.rs new file mode 100644 index 000000000..bf87e6117 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/mod.rs @@ -0,0 +1,31 @@ +//! 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, Address}; + +/// 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, +} 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..77337ae0c --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/storage.rs @@ -0,0 +1,135 @@ +use soroban_sdk::{contracttype, Address, Env, Vec}; + +use super::{UserAllowed, UserDisallowed}; +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), +} + +// ################## RAW STORAGE ################## + +/// 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())); +} + +// ################## ACTIONS ################## + +/// Adds `user` to the transfer allowlist for `token` and emits +/// [`UserAllowed`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `user` - The address to allow. +pub fn allow_user(e: &Env, token: &Address, user: &Address) { + set_user_allowed(e, token, user); + UserAllowed { token: token.clone(), user: user.clone() }.publish(e); +} + +/// Removes `user` from the transfer allowlist for `token` and emits +/// [`UserDisallowed`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `user` - The address to disallow. +pub fn disallow_user(e: &Env, token: &Address, user: &Address) { + remove_user_allowed(e, token, user); + UserDisallowed { token: token.clone(), user: user.clone() }.publish(e); +} + +/// Adds multiple users to the transfer allowlist in a single call. +/// Emits [`UserAllowed`] for each user added. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `users` - The addresses to allow. +pub fn batch_allow_users(e: &Env, token: &Address, users: &Vec
) { + for user in users.iter() { + set_user_allowed(e, token, &user); + UserAllowed { token: token.clone(), user }.publish(e); + } +} + +/// Removes multiple users from the transfer allowlist in a single call. +/// Emits [`UserDisallowed`] for each user removed. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `users` - The addresses to disallow. +pub fn batch_disallow_users(e: &Env, token: &Address, users: &Vec
) { + for user in users.iter() { + remove_user_allowed(e, token, &user); + UserDisallowed { token: token.clone(), user }.publish(e); + } +} + +// ################## COMPLIANCE HOOKS ################## + +/// Returns `true` if the sender or recipient is allowlisted. +/// +/// T-REX semantics: if the sender is allowlisted, the transfer passes; +/// otherwise the recipient must be allowlisted. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `from` - The sender address. +/// * `to` - The recipient address. +/// * `token` - The token address. +pub fn can_transfer(e: &Env, from: &Address, to: &Address, token: &Address) -> bool { + if is_user_allowed(e, token, from) { + return true; + } + is_user_allowed(e, token, to) +} diff --git a/packages/tokens/src/rwa/compliance/modules/transfer_restrict/test.rs b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/test.rs new file mode 100644 index 000000000..d181be4e4 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/test.rs @@ -0,0 +1,62 @@ +extern crate std; + +use soroban_sdk::{contract, testutils::Address as _, vec, Address, Env}; + +use super::storage::{ + allow_user, batch_allow_users, batch_disallow_users, can_transfer, disallow_user, + is_user_allowed, +}; +use crate::rwa::compliance::modules::storage::set_compliance_address; + +#[contract] +struct TestModuleContract; + +#[test] +fn can_transfer_allows_sender_or_recipient_when_allowlisted() { + let e = Env::default(); + + let module_id = e.register(TestModuleContract, ()); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let recipient = Address::generate(&e); + let outsider = Address::generate(&e); + + e.as_contract(&module_id, || { + set_compliance_address(&e, &compliance); + + assert!(!can_transfer(&e, &sender, &recipient, &token)); + + allow_user(&e, &token, &sender); + assert!(can_transfer(&e, &sender, &outsider, &token)); + + disallow_user(&e, &token, &sender); + allow_user(&e, &token, &recipient); + assert!(can_transfer(&e, &outsider, &recipient, &token)); + }); +} + +#[test] +fn batch_allow_and_disallow_update_allowlist_entries() { + let e = Env::default(); + + let module_id = e.register(TestModuleContract, ()); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let user_a = Address::generate(&e); + let user_b = Address::generate(&e); + + e.as_contract(&module_id, || { + set_compliance_address(&e, &compliance); + + batch_allow_users(&e, &token, &vec![&e, user_a.clone(), user_b.clone()]); + + assert!(is_user_allowed(&e, &token, &user_a)); + assert!(is_user_allowed(&e, &token, &user_b)); + + batch_disallow_users(&e, &token, &vec![&e, user_a.clone(), user_b.clone()]); + + assert!(!is_user_allowed(&e, &token, &user_a)); + assert!(!is_user_allowed(&e, &token, &user_b)); + }); +}