diff --git a/Cargo.lock b/Cargo.lock index 518a47c30..ff46b86e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1646,6 +1646,26 @@ dependencies = [ "stellar-tokens", ] +[[package]] +name = "rwa-initial-lockup-period" +version = "0.7.1" +dependencies = [ + "soroban-sdk", + "stellar-access", + "stellar-macros", + "stellar-tokens", +] + +[[package]] +name = "rwa-time-transfers-limits" +version = "0.7.1" +dependencies = [ + "soroban-sdk", + "stellar-access", + "stellar-macros", + "stellar-tokens", +] + [[package]] name = "rwa-token-example" version = "0.7.1" @@ -1657,6 +1677,16 @@ dependencies = [ "stellar-tokens", ] +[[package]] +name = "rwa-transfer-restrict" +version = "0.7.1" +dependencies = [ + "soroban-sdk", + "stellar-access", + "stellar-macros", + "stellar-tokens", +] + [[package]] name = "ryu" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 656eca1fe..9d3cc9b8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,9 @@ members = [ "examples/ownable", "examples/pausable", "examples/rwa/*", + "examples/rwa-time-transfers-limits", + "examples/rwa-transfer-restrict", + "examples/rwa-initial-lockup-period", "examples/sac-admin-generic", "examples/sac-admin-wrapper", "examples/multisig-smart-account/*", diff --git a/examples/rwa-initial-lockup-period/Cargo.toml b/examples/rwa-initial-lockup-period/Cargo.toml new file mode 100644 index 000000000..2658397b6 --- /dev/null +++ b/examples/rwa-initial-lockup-period/Cargo.toml @@ -0,0 +1,24 @@ +[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-access = { workspace = true } +stellar-macros = { 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..bd5ee29dc --- /dev/null +++ b/examples/rwa-initial-lockup-period/src/contract.rs @@ -0,0 +1,107 @@ +use soroban_sdk::{contract, contractimpl, Address, Env, String, Vec}; +use stellar_access::access_control; +use stellar_macros::only_admin; +use stellar_tokens::rwa::compliance::{ + modules::{ + initial_lockup_period::{storage as lockup, InitialLockupPeriod, LockedTokens}, + storage::{get_compliance_address, module_name, set_compliance_address}, + ComplianceModule, + }, + ComplianceHook, +}; + +#[contract] +pub struct InitialLockupPeriodContract; + +fn require_compliance_auth(e: &Env) { + get_compliance_address(e).require_auth(); +} + +#[contractimpl] +impl InitialLockupPeriodContract { + pub fn __constructor(e: &Env, admin: Address) { + access_control::set_admin(e, &admin); + } +} + +#[contractimpl(contracttrait)] +impl InitialLockupPeriod for InitialLockupPeriodContract { + #[only_admin] + fn set_lockup_period(e: &Env, token: Address, lockup_seconds: u64) { + lockup::configure_lockup_period(e, &token, lockup_seconds); + } + + #[only_admin] + fn pre_set_lockup_state( + e: &Env, + token: Address, + wallet: Address, + balance: i128, + locks: Vec, + ) { + lockup::pre_set_lockup_state(e, &token, &wallet, balance, &locks); + } + + fn get_lockup_period(e: &Env, token: Address) -> u64 { + lockup::get_lockup_period(e, &token) + } + + fn get_total_locked(e: &Env, token: Address, wallet: Address) -> i128 { + lockup::get_total_locked(e, &token, &wallet) + } + + fn get_locked_tokens(e: &Env, token: Address, wallet: Address) -> Vec { + lockup::get_locks(e, &token, &wallet) + } + + fn get_internal_balance(e: &Env, token: Address, wallet: Address) -> i128 { + lockup::get_internal_balance(e, &token, &wallet) + } + + fn required_hooks(e: &Env) -> Vec { + lockup::required_hooks(e) + } + + 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_compliance_auth(e); + lockup::on_transfer(e, &from, &to, amount, &token); + } + + fn on_created(e: &Env, to: Address, amount: i128, token: Address) { + require_compliance_auth(e); + lockup::on_created(e, &to, amount, &token); + } + + fn on_destroyed(e: &Env, from: Address, amount: i128, token: Address) { + require_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) + } + + #[only_admin] + fn set_compliance_address(e: &Env, compliance: Address) { + 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..8e69c7841 --- /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_admin_auth_after_compliance_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, &admin); +} + +#[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-time-transfers-limits/Cargo.toml b/examples/rwa-time-transfers-limits/Cargo.toml new file mode 100644 index 000000000..0142a8b87 --- /dev/null +++ b/examples/rwa-time-transfers-limits/Cargo.toml @@ -0,0 +1,24 @@ +[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-access = { workspace = true } +stellar-macros = { 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..a1e9a4da1 --- /dev/null +++ b/examples/rwa-time-transfers-limits/src/contract.rs @@ -0,0 +1,109 @@ +use soroban_sdk::{contract, contractimpl, Address, Env, String, Vec}; +use stellar_access::access_control; +use stellar_macros::only_admin; +use stellar_tokens::rwa::compliance::{ + modules::{ + storage::{get_compliance_address, module_name, set_compliance_address}, + time_transfers_limits::{storage as ttl, Limit, TimeTransfersLimits, TransferCounter}, + ComplianceModule, + }, + ComplianceHook, +}; + +#[contract] +pub struct TimeTransfersLimitsContract; + +fn require_compliance_auth(e: &Env) { + get_compliance_address(e).require_auth(); +} + +#[contractimpl] +impl TimeTransfersLimitsContract { + pub fn __constructor(e: &Env, admin: Address) { + access_control::set_admin(e, &admin); + } +} + +#[contractimpl(contracttrait)] +impl TimeTransfersLimits for TimeTransfersLimitsContract { + #[only_admin] + fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { + ttl::configure_irs(e, &token, &irs); + } + + #[only_admin] + fn set_time_transfer_limit(e: &Env, token: Address, limit: Limit) { + ttl::set_time_transfer_limit(e, &token, &limit); + } + + #[only_admin] + fn batch_set_time_transfer_limit(e: &Env, token: Address, limits: Vec) { + ttl::batch_set_time_transfer_limit(e, &token, &limits); + } + + #[only_admin] + fn remove_time_transfer_limit(e: &Env, token: Address, limit_time: u64) { + ttl::remove_time_transfer_limit(e, &token, limit_time); + } + + #[only_admin] + fn batch_remove_time_transfer_limit(e: &Env, token: Address, limit_times: Vec) { + ttl::batch_remove_time_transfer_limit(e, &token, &limit_times); + } + + #[only_admin] + fn pre_set_transfer_counter( + e: &Env, + token: Address, + identity: Address, + limit_time: u64, + counter: TransferCounter, + ) { + ttl::pre_set_transfer_counter(e, &token, &identity, limit_time, &counter); + } + + fn get_time_transfer_limits(e: &Env, token: Address) -> Vec { + ttl::get_limits(e, &token) + } + + fn required_hooks(e: &Env) -> Vec { + ttl::required_hooks(e) + } + + 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_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) + } + + #[only_admin] + fn set_compliance_address(e: &Env, compliance: Address) { + 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..f9f234193 --- /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_admin_auth_after_compliance_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, &admin); +} + +#[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..9bb4c7b76 --- /dev/null +++ b/examples/rwa-transfer-restrict/Cargo.toml @@ -0,0 +1,24 @@ +[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-access = { workspace = true } +stellar-macros = { 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..04f696afd --- /dev/null +++ b/examples/rwa-transfer-restrict/src/contract.rs @@ -0,0 +1,75 @@ +use soroban_sdk::{contract, contractimpl, Address, Env, String, Vec}; +use stellar_access::access_control; +use stellar_macros::only_admin; +use stellar_tokens::rwa::compliance::modules::{ + storage::{get_compliance_address, module_name, set_compliance_address}, + transfer_restrict::{storage as transfer_restrict, TransferRestrict}, + ComplianceModule, +}; + +#[contract] +pub struct TransferRestrictContract; + +#[contractimpl] +impl TransferRestrictContract { + pub fn __constructor(e: &Env, admin: Address) { + access_control::set_admin(e, &admin); + } +} + +#[contractimpl(contracttrait)] +impl TransferRestrict for TransferRestrictContract { + #[only_admin] + fn allow_user(e: &Env, token: Address, user: Address) { + transfer_restrict::allow_user(e, &token, &user); + } + + #[only_admin] + fn disallow_user(e: &Env, token: Address, user: Address) { + transfer_restrict::disallow_user(e, &token, &user); + } + + #[only_admin] + fn batch_allow_users(e: &Env, token: Address, users: Vec
) { + transfer_restrict::batch_allow_users(e, &token, &users); + } + + #[only_admin] + fn batch_disallow_users(e: &Env, token: Address, users: Vec
) { + transfer_restrict::batch_disallow_users(e, &token, &users); + } + + 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) + } + + #[only_admin] + fn set_compliance_address(e: &Env, compliance: Address) { + 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..d100f2887 --- /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_admin_auth_after_compliance_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, &admin); +} 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..548645b3f --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/mod.rs @@ -0,0 +1,161 @@ +//! 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. +//! The Stellar module keeps internal balance and lock mirrors updated by +//! create, transfer, and destroy hooks so transfer checks can remain local to +//! the module. +//! +//! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/TimeExchangeLimitsModule.sol + +pub mod storage; +#[cfg(test)] +mod test; + +use soroban_sdk::{contractevent, contracttrait, Address, Env, Vec}; + +pub use crate::rwa::compliance::modules::initial_lockup_period::storage::LockedTokens; +use crate::rwa::compliance::{modules::ComplianceModule, ComplianceHook}; + +/// Initial lockup period compliance module trait. +/// +/// This trait defines the contract-facing API for the initial lockup module. +/// Low-level state changes live in [`storage`]. Privileged methods have no +/// default implementation because each contract must enforce its own access +/// control before delegating to storage helpers. +#[contracttrait] +pub trait InitialLockupPeriod: ComplianceModule { + /// Configures the lockup period for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token whose lockup period is configured. + /// * `lockup_seconds` - The lockup duration in seconds. + /// + /// # Events + /// + /// * topics - `["lockup_period_set", token: Address]` + /// * data - `[lockup_seconds: u64]` + /// + /// # Notes + /// + /// No default implementation is provided because this is a privileged + /// operation that requires custom access control. + fn set_lockup_period(e: &Env, token: Address, lockup_seconds: u64); + + /// Pre-seeds lockup state for `wallet`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token whose lockup state is pre-seeded. + /// * `wallet` - The wallet address. + /// * `balance` - The wallet balance mirror. + /// * `locks` - Existing lock entries. + /// + /// # Errors + /// + /// * refer to [`storage::pre_set_lockup_state`] errors. + /// + /// # Notes + /// + /// No default implementation is provided because this is a privileged + /// operation that requires custom access control. + fn pre_set_lockup_state( + e: &Env, + token: Address, + wallet: Address, + balance: i128, + locks: Vec, + ); + + /// Returns the lockup period for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token whose lockup period is queried. + fn get_lockup_period(e: &Env, token: Address) -> u64 { + storage::get_lockup_period(e, &token) + } + + /// Returns the aggregate locked amount for `wallet` on `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token whose lockup state is queried. + /// * `wallet` - The wallet address. + fn get_total_locked(e: &Env, token: Address, wallet: Address) -> i128 { + storage::get_total_locked(e, &token, &wallet) + } + + /// Returns lock entries for `wallet` on `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token whose lockup state is queried. + /// * `wallet` - The wallet address. + fn get_locked_tokens(e: &Env, token: Address, wallet: Address) -> Vec { + storage::get_locks(e, &token, &wallet) + } + + /// Returns the internal balance mirror for `wallet` on `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token whose balance mirror is queried. + /// * `wallet` - The wallet address. + fn get_internal_balance(e: &Env, token: Address, wallet: Address) -> i128 { + storage::get_internal_balance(e, &token, &wallet) + } + + /// Returns the set of compliance hooks this module requires. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + fn required_hooks(e: &Env) -> Vec { + storage::required_hooks(e) + } + + /// Verifies that this module is registered on every required hook. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// + /// # Errors + /// + /// * refer to [`storage::verify_hook_wiring`] errors. + fn verify_hook_wiring(e: &Env) { + storage::verify_hook_wiring(e); + } +} + +// ################## EVENTS ################## + +/// 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, +} + +/// Emits a [`LockupPeriodSet`] event. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token whose lockup period was configured. +/// * `lockup_seconds` - The configured lockup duration in seconds. +pub fn emit_lockup_period_set(e: &Env, token: &Address, lockup_seconds: u64) { + LockupPeriodSet { token: token.clone(), lockup_seconds }.publish(e); +} 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..3c58f30ee --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/storage.rs @@ -0,0 +1,490 @@ +use soroban_sdk::{contracttype, panic_with_error, vec, Address, Env, Vec}; + +use crate::rwa::compliance::{ + modules::{ + initial_lockup_period::emit_lockup_period_set, + 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, +}; + +/// 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), +} + +// ################## QUERY STATE ################## + +/// 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. +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. +pub fn set_lockup_period(e: &Env, token: &Address, seconds: u64) { + let key = InitialLockupStorageKey::LockupPeriod(token.clone()); + e.storage().persistent().set(&key, &seconds); +} + +/// 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. +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. +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); +} + +/// 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. +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. +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); +} + +/// 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. +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. +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); +} + +// ################## CHANGE STATE ################## + +/// Configures the lockup period for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `lockup_seconds` - The lockup duration in seconds. +/// +/// # Events +/// +/// * topics - `["lockup_period_set", token: Address]` +/// * data - `[lockup_seconds: u64]` +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. +pub fn configure_lockup_period(e: &Env, token: &Address, lockup_seconds: u64) { + set_lockup_period(e, token, lockup_seconds); + emit_lockup_period_set(e, token, lockup_seconds); +} + +/// 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. +/// +/// # Errors +/// +/// * refer to [`require_non_negative_amount`] errors. +/// * [`ComplianceModuleError::LockupExceedsBalance`] - When total locked amount +/// exceeds the mirrored balance. +/// * refer to [`add_i128_or_panic`] errors. +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. +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); + if total_locked > balance { + panic_with_error!(e, ComplianceModuleError::LockupExceedsBalance); + } + + set_internal_balance(e, token, wallet, balance); + set_locks(e, token, wallet, locks); + set_total_locked(e, token, wallet, total_locked); +} + +/// Returns the set of compliance hooks this module requires. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +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. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// +/// # Errors +/// +/// * refer to [`verify_required_hooks`] errors. +pub fn verify_hook_wiring(e: &Env) { + verify_required_hooks(e, required_hooks(e)); +} + +/// 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. +/// +/// # Errors +/// +/// * refer to [`require_non_negative_amount`] errors. +/// * refer to [`add_i128_or_panic`] errors. +/// * refer to [`sub_i128_or_panic`] errors. +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. +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. +/// +/// # Errors +/// +/// * refer to [`require_non_negative_amount`] errors. +/// * refer to [`add_i128_or_panic`] errors. +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. +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. +/// +/// # Errors +/// +/// * refer to [`require_non_negative_amount`] errors. +/// * [`ComplianceModuleError::InsufficientUnlockedBalance`] - When the burn +/// would consume more unlocked balance than available. +/// * refer to [`add_i128_or_panic`] errors. +/// * refer to [`sub_i128_or_panic`] errors. +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. +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); + } + + if free_amount < amount { + panic_with_error!(e, ComplianceModuleError::InsufficientUnlockedBalance); + } + + 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. +/// +/// # Errors +/// +/// * [`ComplianceModuleError::HooksNotVerified`] - When required hook wiring +/// has not been verified. +/// * refer to [`add_i128_or_panic`] errors. +pub fn can_transfer(e: &Env, from: &Address, amount: i128, token: &Address) -> bool { + if !hooks_verified(e) { + panic_with_error!(e, ComplianceModuleError::HooksNotVerified); + } + 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 +} + +// ################## 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_unchecked(i); + 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_unchecked(i); + 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_unchecked(i); + 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)); +} 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/mod.rs b/packages/tokens/src/rwa/compliance/modules/mod.rs index f4e065161..061094c52 100644 --- a/packages/tokens/src/rwa/compliance/modules/mod.rs +++ b/packages/tokens/src/rwa/compliance/modules/mod.rs @@ -1,6 +1,9 @@ use soroban_sdk::{contracterror, contracttrait, Address, Env, String}; +pub mod initial_lockup_period; pub mod storage; +pub mod time_transfers_limits; +pub mod transfer_restrict; #[cfg(test)] mod test; @@ -53,7 +56,7 @@ mod test; /// after the token action /// - CanTransfer/CanCreate: Validation hooks called before the token action /// -/// # Security Note +/// # Security Warning /// /// If a hook modifies state, it should typically only be called by the /// compliance contract. `set_compliance_address` and `get_compliance_address` @@ -79,7 +82,7 @@ pub trait ComplianceModule { /// * `amount` - The amount of tokens transferred. /// * `token` - The address of the token contract that triggered the hook. /// - /// # Security Note + /// # Security Warning /// /// If this function modifies state, it should be called only by the /// compliance contract. To enforce this, add the following at the start of @@ -104,7 +107,7 @@ pub trait ComplianceModule { /// * `amount` - The amount of tokens created. /// * `token` - The address of the token contract that triggered the hook. /// - /// # Security Note + /// # Security Warning /// /// If this function modifies state, it should be called only by the /// compliance contract. To enforce this, add the following at the start of @@ -129,7 +132,7 @@ pub trait ComplianceModule { /// * `amount` - The amount of tokens destroyed. /// * `token` - The address of the token contract that triggered the hook. /// - /// # Security Note + /// # Security Warning /// /// If this function modifies state, it should be called only by the /// compliance contract. To enforce this, add the following at the start of @@ -211,7 +214,7 @@ pub trait ComplianceModule { /// Error codes shared by all compliance modules. /// -/// Compliance module errors occupy the 390–400 range, following the RWA +/// Compliance module errors occupy the 390–406 range, following the RWA /// error numbering convention. #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] @@ -239,6 +242,14 @@ pub enum ComplianceModuleError { ComplianceAlreadySet = 399, /// A token has reached the maximum number of configured limit entries. TooManyLimits = 400, + /// Required hook wiring has not been verified before use. + HooksNotVerified = 403, + /// Locked token state is inconsistent with the mirrored balance. + LockupExceedsBalance = 404, + /// A transfer or burn would consume more unlocked balance than available. + InsufficientUnlockedBalance = 405, + /// A configured time-window limit has an invalid duration. + InvalidLimitTime = 406, } // ################## CONSTANTS ################## diff --git a/packages/tokens/src/rwa/compliance/modules/test.rs b/packages/tokens/src/rwa/compliance/modules/test.rs index 6d6659231..074c44f2b 100644 --- a/packages/tokens/src/rwa/compliance/modules/test.rs +++ b/packages/tokens/src/rwa/compliance/modules/test.rs @@ -1,8 +1,8 @@ extern crate std; use soroban_sdk::{ - contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal, Val, - Vec, + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal, + Symbol, Val, Vec, }; use super::storage::*; @@ -10,7 +10,7 @@ use crate::rwa::{ compliance::{Compliance, ComplianceHook}, identity_registry_storage::{ CountryData, CountryDataManager, CountryRelation, IdentityRegistryStorage, - IndividualCountryRelation, + IndividualCountryRelation, OrganizationCountryRelation, }, utils::token_binder::TokenBinder, }; @@ -354,6 +354,32 @@ fn panicking_math_helpers_return_expected_values() { assert_eq!(sub_i128_or_panic(&e, 7, 4), 3); } +#[test] +fn require_non_negative_amount_accepts_zero_and_positive_values() { + let e = Env::default(); + + require_non_negative_amount(&e, 0); + require_non_negative_amount(&e, 42); +} + +#[test] +#[should_panic(expected = "Error(Contract, #391)")] +fn require_non_negative_amount_panics_on_negative() { + let e = Env::default(); + + require_non_negative_amount(&e, -1); +} + +#[test] +fn module_name_returns_soroban_string() { + let e = Env::default(); + + assert_eq!( + module_name(&e, "ExampleModule"), + soroban_sdk::String::from_str(&e, "ExampleModule") + ); +} + #[test] #[should_panic(expected = "Error(Contract, #392)")] fn add_i128_or_panic_panics_on_overflow() { @@ -369,3 +395,64 @@ fn sub_i128_or_panic_panics_on_underflow() { let _ = sub_i128_or_panic(&e, i128::MIN, 1); } + +#[test] +fn country_code_extracts_all_relation_variants() { + let e = Env::default(); + + assert_eq!( + country_code(&CountryRelation::Individual(IndividualCountryRelation::Residence(276))), + 276 + ); + assert_eq!( + country_code(&CountryRelation::Individual(IndividualCountryRelation::Citizenship(724))), + 724 + ); + assert_eq!( + country_code(&CountryRelation::Individual(IndividualCountryRelation::SourceOfFunds(840))), + 840 + ); + assert_eq!( + country_code(&CountryRelation::Individual(IndividualCountryRelation::TaxResidency(250))), + 250 + ); + assert_eq!( + country_code(&CountryRelation::Individual(IndividualCountryRelation::Custom( + Symbol::new(&e, "custom"), + 826, + ))), + 826 + ); + + assert_eq!( + country_code(&CountryRelation::Organization(OrganizationCountryRelation::Incorporation( + 528 + ))), + 528 + ); + assert_eq!( + country_code(&CountryRelation::Organization( + OrganizationCountryRelation::OperatingJurisdiction(756) + )), + 756 + ); + assert_eq!( + country_code(&CountryRelation::Organization(OrganizationCountryRelation::TaxJurisdiction( + 208 + ))), + 208 + ); + assert_eq!( + country_code(&CountryRelation::Organization(OrganizationCountryRelation::SourceOfFunds( + 484 + ))), + 484 + ); + assert_eq!( + country_code(&CountryRelation::Organization(OrganizationCountryRelation::Custom( + Symbol::new(&e, "branch"), + 392, + ))), + 392 + ); +} 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..98bcbee4e --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/mod.rs @@ -0,0 +1,237 @@ +//! 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). +//! The Stellar module keeps per-identity transfer counters updated by transfer +//! hooks so limit checks do not need to query token transfer history. +//! +//! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/TimeTransfersLimitsModule.sol + +pub mod storage; +#[cfg(test)] +mod test; + +use soroban_sdk::{contractevent, contracttrait, Address, Env, Vec}; + +pub use crate::rwa::compliance::modules::time_transfers_limits::storage::{Limit, TransferCounter}; +use crate::rwa::compliance::{modules::ComplianceModule, ComplianceHook}; + +pub const MAX_LIMITS_PER_TOKEN: u32 = 4; + +/// Time-window transfer limits compliance module trait. +/// +/// This trait defines the contract-facing API for the time transfer limits +/// module. Low-level state changes live in [`storage`]. Privileged methods have +/// no default implementation because each contract must enforce its own access +/// control before delegating to storage helpers. +#[contracttrait] +pub trait TimeTransfersLimits: ComplianceModule { + /// Configures the Identity Registry Storage contract for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token whose IRS is being configured. + /// * `irs` - The Identity Registry Storage contract address. + /// + /// # Notes + /// + /// No default implementation is provided because this is a privileged + /// operation that requires custom access control. + fn set_identity_registry_storage(e: &Env, token: Address, irs: Address); + + /// Sets or updates a time-window transfer limit for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token whose limit is updated. + /// * `limit` - The time-window limit. + /// + /// # Errors + /// + /// * refer to [`storage::set_time_transfer_limit`] errors. + /// + /// # Events + /// + /// * topics - `["time_transfer_limit_updated", token: Address]` + /// * data - `[limit: Limit]` + /// + /// # Notes + /// + /// No default implementation is provided because this is a privileged + /// operation that requires custom access control. + fn set_time_transfer_limit(e: &Env, token: Address, limit: Limit); + + /// Sets or updates multiple time-window transfer limits for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token whose limits are updated. + /// * `limits` - The time-window limits. + /// + /// # Errors + /// + /// * refer to [`storage::batch_set_time_transfer_limit`] errors. + /// + /// # Events + /// + /// For each configured limit: + /// * topics - `["time_transfer_limit_updated", token: Address]` + /// * data - `[limit: Limit]` + /// + /// # Notes + /// + /// No default implementation is provided because this is a privileged + /// operation that requires custom access control. + fn batch_set_time_transfer_limit(e: &Env, token: Address, limits: Vec); + + /// Removes a time-window transfer limit for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token whose limit is removed. + /// * `limit_time` - The time-window duration to remove. + /// + /// # Errors + /// + /// * refer to [`storage::remove_time_transfer_limit`] errors. + /// + /// # Events + /// + /// * topics - `["time_transfer_limit_removed", token: Address]` + /// * data - `[limit_time: u64]` + /// + /// # Notes + /// + /// No default implementation is provided because this is a privileged + /// operation that requires custom access control. + fn remove_time_transfer_limit(e: &Env, token: Address, limit_time: u64); + + /// Removes multiple time-window transfer limits for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token whose limits are removed. + /// * `limit_times` - The time-window durations to remove. + /// + /// # Errors + /// + /// * refer to [`storage::batch_remove_time_transfer_limit`] errors. + /// + /// # Events + /// + /// For each removed limit: + /// * topics - `["time_transfer_limit_removed", token: Address]` + /// * data - `[limit_time: u64]` + /// + /// # Notes + /// + /// No default implementation is provided because this is a privileged + /// operation that requires custom access control. + fn batch_remove_time_transfer_limit(e: &Env, token: Address, limit_times: Vec); + + /// Pre-seeds a transfer counter for an identity and time window. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token whose counter is pre-seeded. + /// * `identity` - The on-chain identity address. + /// * `limit_time` - The time-window duration in seconds. + /// * `counter` - The pre-seeded counter. + /// + /// # Errors + /// + /// * refer to [`storage::pre_set_transfer_counter`] errors. + /// + /// # Notes + /// + /// No default implementation is provided because this is a privileged + /// operation that requires custom access control. + fn pre_set_transfer_counter( + e: &Env, + token: Address, + identity: Address, + limit_time: u64, + counter: TransferCounter, + ); + + /// Returns configured time-window transfer limits for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token whose limits are queried. + fn get_time_transfer_limits(e: &Env, token: Address) -> Vec { + storage::get_limits(e, &token) + } + + /// Returns the set of compliance hooks this module requires. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + fn required_hooks(e: &Env) -> Vec { + storage::required_hooks(e) + } + + /// Verifies that this module is registered on every required hook. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// + /// # Errors + /// + /// * refer to [`storage::verify_hook_wiring`] errors. + fn verify_hook_wiring(e: &Env) { + storage::verify_hook_wiring(e); + } +} + +// ################## EVENTS ################## + +/// 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, +} + +/// Emits a [`TimeTransferLimitUpdated`] event. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token whose limit was updated. +/// * `limit` - The configured limit. +pub fn emit_time_transfer_limit_updated(e: &Env, token: &Address, limit: &Limit) { + TimeTransferLimitUpdated { token: token.clone(), limit: limit.clone() }.publish(e); +} + +/// 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, +} + +/// Emits a [`TimeTransferLimitRemoved`] event. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token whose limit was removed. +/// * `limit_time` - The removed time-window duration. +pub fn emit_time_transfer_limit_removed(e: &Env, token: &Address, limit_time: u64) { + TimeTransferLimitRemoved { token: token.clone(), limit_time }.publish(e); +} 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..218f51f6a --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/storage.rs @@ -0,0 +1,454 @@ +use soroban_sdk::{contracttype, panic_with_error, vec, Address, Env, Vec}; + +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, + }, + time_transfers_limits::{ + emit_time_transfer_limit_removed, emit_time_transfer_limit_updated, + MAX_LIMITS_PER_TOKEN, + }, + 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), +} + +// ################## QUERY STATE ################## + +/// 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. +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. +pub fn set_limits(e: &Env, token: &Address, limits: &Vec) { + let key = TimeTransfersLimitsStorageKey::Limits(token.clone()); + e.storage().persistent().set(&key, limits); +} + +/// 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. +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. +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); +} + +// ################## CHANGE STATE ################## + +/// 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. +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. +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`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `limit` - The limit to set. +/// +/// # Errors +/// +/// * [`ComplianceModuleError::InvalidLimitTime`] - When the time-window +/// duration is zero. +/// * refer to [`require_non_negative_amount`] errors. +/// * [`ComplianceModuleError::TooManyLimits`] - When adding a new limit would +/// exceed the maximum number of configured limits. +/// +/// # Events +/// +/// * topics - `["time_transfer_limit_updated", token: Address]` +/// * data - `[limit: Limit]` +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. +pub fn set_time_transfer_limit(e: &Env, token: &Address, limit: &Limit) { + if limit.limit_time == 0 { + panic_with_error!(e, ComplianceModuleError::InvalidLimitTime); + } + 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_unchecked(i); + 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); + emit_time_transfer_limit_updated(e, token, limit); +} + +/// 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. +/// +/// # Errors +/// +/// * refer to [`set_time_transfer_limit`] errors. +/// +/// # Events +/// +/// For each configured limit: +/// * topics - `["time_transfer_limit_updated", token: Address]` +/// * data - `[limit: Limit]` +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. +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. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `limit_time` - The time-window to remove. +/// +/// # Errors +/// +/// * [`ComplianceModuleError::MissingLimit`] - When no limit exists for +/// `limit_time`. +/// +/// # Events +/// +/// * topics - `["time_transfer_limit_removed", token: Address]` +/// * data - `[limit_time: u64]` +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. +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_unchecked(i); + 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); + emit_time_transfer_limit_removed(e, token, limit_time); +} + +/// 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. +/// +/// # Errors +/// +/// * refer to [`remove_time_transfer_limit`] errors. +/// +/// # Events +/// +/// For each removed limit: +/// * topics - `["time_transfer_limit_removed", token: Address]` +/// * data - `[limit_time: u64]` +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. +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. +/// +/// # Errors +/// +/// * refer to [`require_non_negative_amount`] errors. +/// * [`ComplianceModuleError::InvalidLimitTime`] - When the time-window +/// duration is zero. +/// * [`ComplianceModuleError::MissingLimit`] - When no limit exists for +/// `limit_time`. +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. +pub fn pre_set_transfer_counter( + e: &Env, + token: &Address, + identity: &Address, + limit_time: u64, + counter: &TransferCounter, +) { + require_non_negative_amount(e, counter.value); + if limit_time == 0 { + panic_with_error!(e, ComplianceModuleError::InvalidLimitTime); + } + + 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); +} + +/// Returns the set of compliance hooks this module requires. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +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. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// +/// # Errors +/// +/// * refer to [`verify_required_hooks`] errors. +pub fn verify_hook_wiring(e: &Env) { + verify_required_hooks(e, required_hooks(e)); +} + +/// 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. +/// +/// # Errors +/// +/// * refer to [`require_non_negative_amount`] errors. +/// * refer to [`get_irs_client`] errors. +/// * refer to [`add_i128_or_panic`] errors. +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. +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 +/// +/// * [`ComplianceModuleError::HooksNotVerified`] - When required hook wiring +/// has not been verified. +/// * refer to [`get_irs_client`] errors. +/// * refer to [`add_i128_or_panic`] errors. +pub fn can_transfer(e: &Env, from: &Address, amount: i128, token: &Address) -> bool { + if !hooks_verified(e) { + panic_with_error!(e, ComplianceModuleError::HooksNotVerified); + } + 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 +} + +// ################## 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); + } +} 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..6e3f908e9 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/mod.rs @@ -0,0 +1,155 @@ +//! Transfer restriction (address allowlist) compliance module — Stellar port +//! of T-REX [`TransferRestrictModule.sol`][trex-src]. +//! +//! Maintains a per-token address allowlist. Transfers pass if the sender is +//! on the list; otherwise the recipient must be. +//! +//! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/TransferRestrictModule.sol + +pub mod storage; +#[cfg(test)] +mod test; + +use soroban_sdk::{contractevent, contracttrait, Address, Env, Vec}; + +use crate::rwa::compliance::modules::ComplianceModule; + +/// Transfer restriction compliance module trait. +/// +/// This trait defines the contract-facing API for the transfer restriction +/// module. Low-level state changes live in [`storage`]. Privileged methods have +/// no default implementation because each contract must enforce its own access +/// control before delegating to storage helpers. +#[contracttrait] +pub trait TransferRestrict: ComplianceModule { + /// Adds `user` to the transfer allowlist for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token whose allowlist is updated. + /// * `user` - The address to allow. + /// + /// # Events + /// + /// * topics - `["user_allowed", token: Address]` + /// * data - `[user: Address]` + /// + /// # Notes + /// + /// No default implementation is provided because this is a privileged + /// operation that requires custom access control. + fn allow_user(e: &Env, token: Address, user: Address); + + /// Removes `user` from the transfer allowlist for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token whose allowlist is updated. + /// * `user` - The address to disallow. + /// + /// # Events + /// + /// * topics - `["user_disallowed", token: Address]` + /// * data - `[user: Address]` + /// + /// # Notes + /// + /// No default implementation is provided because this is a privileged + /// operation that requires custom access control. + fn disallow_user(e: &Env, token: Address, user: Address); + + /// Adds multiple users to the transfer allowlist for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token whose allowlist is updated. + /// * `users` - The addresses to allow. + /// + /// # Events + /// + /// For each user newly added: + /// * topics - `["user_allowed", token: Address]` + /// * data - `[user: Address]` + /// + /// # Notes + /// + /// No default implementation is provided because this is a privileged + /// operation that requires custom access control. + fn batch_allow_users(e: &Env, token: Address, users: Vec
); + + /// Removes multiple users from the transfer allowlist for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token whose allowlist is updated. + /// * `users` - The addresses to disallow. + /// + /// # Events + /// + /// For each user removed: + /// * topics - `["user_disallowed", token: Address]` + /// * data - `[user: Address]` + /// + /// # Notes + /// + /// No default implementation is provided because this is a privileged + /// operation that requires custom access control. + fn batch_disallow_users(e: &Env, token: Address, users: Vec
); + + /// Returns whether `user` is on the transfer allowlist for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token whose allowlist is queried. + /// * `user` - The address to check. + fn is_user_allowed(e: &Env, token: Address, user: Address) -> bool { + storage::is_user_allowed(e, &token, &user) + } +} + +// ################## EVENTS ################## + +/// 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, +} + +/// Emits a [`UserAllowed`] event. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token whose allowlist changed. +/// * `user` - The address that was allowed. +pub fn emit_user_allowed(e: &Env, token: &Address, user: &Address) { + UserAllowed { token: token.clone(), user: user.clone() }.publish(e); +} + +/// 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, +} + +/// Emits a [`UserDisallowed`] event. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token whose allowlist changed. +/// * `user` - The address that was disallowed. +pub fn emit_user_disallowed(e: &Env, token: &Address, user: &Address) { + UserDisallowed { token: token.clone(), user: user.clone() }.publish(e); +} diff --git a/packages/tokens/src/rwa/compliance/modules/transfer_restrict/storage.rs b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/storage.rs new file mode 100644 index 000000000..d337a679a --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/storage.rs @@ -0,0 +1,177 @@ +use soroban_sdk::{contracttype, Address, Env, Vec}; + +use crate::rwa::compliance::modules::{ + transfer_restrict::{emit_user_allowed, emit_user_disallowed}, + MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD, +}; + +#[contracttype] +#[derive(Clone)] +pub enum TransferRestrictStorageKey { + /// Per-(token, address) allowlist flag. + AllowedUser(Address, Address), +} + +// ################## QUERY STATE ################## + +/// 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()); + if e.storage().persistent().has(&key) { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + true + } else { + false + } +} + +/// 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) +} + +// ################## CHANGE STATE ################## + +/// 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. +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. +pub fn set_user_allowed(e: &Env, token: &Address, user: &Address) { + let key = TransferRestrictStorageKey::AllowedUser(token.clone(), user.clone()); + e.storage().persistent().set(&key, &()); +} + +/// 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. +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. +pub fn remove_user_allowed(e: &Env, token: &Address, user: &Address) { + e.storage() + .persistent() + .remove(&TransferRestrictStorageKey::AllowedUser(token.clone(), user.clone())); +} + +/// Adds `user` to the transfer allowlist for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `user` - The address to allow. +/// +/// # Events +/// +/// * topics - `["user_allowed", token: Address]` +/// * data - `[user: Address]` +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. +pub fn allow_user(e: &Env, token: &Address, user: &Address) { + if !is_user_allowed(e, token, user) { + set_user_allowed(e, token, user); + emit_user_allowed(e, token, user); + } +} + +/// Removes `user` from the transfer allowlist for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `user` - The address to disallow. +/// +/// # Events +/// +/// * topics - `["user_disallowed", token: Address]` +/// * data - `[user: Address]` +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. +pub fn disallow_user(e: &Env, token: &Address, user: &Address) { + if is_user_allowed(e, token, user) { + remove_user_allowed(e, token, user); + emit_user_disallowed(e, token, user); + } +} + +/// Adds multiple users to the transfer allowlist in a single call. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `users` - The addresses to allow. +/// +/// # Events +/// +/// For each user newly added: +/// * topics - `["user_allowed", token: Address]` +/// * data - `[user: Address]` +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. +pub fn batch_allow_users(e: &Env, token: &Address, users: &Vec
) { + for user in users.iter() { + allow_user(e, token, &user); + } +} + +/// Removes multiple users from the transfer allowlist in a single call. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `users` - The addresses to disallow. +/// +/// # Events +/// +/// For each user removed: +/// * topics - `["user_disallowed", token: Address]` +/// * data - `[user: Address]` +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. +pub fn batch_disallow_users(e: &Env, token: &Address, users: &Vec
) { + for user in users.iter() { + disallow_user(e, token, &user); + } +} 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)); + }); +}