diff --git a/.gitignore b/.gitignore index df869b315..cccded53a 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,7 @@ lcov.info coverage/ **/test_snapshots/ + +# Compiled WASM artifacts and testnet state +examples/rwa-deploy/wasm/ +examples/rwa-deploy/testnet-addresses.json diff --git a/Cargo.lock b/Cargo.lock index 1432a322d..ba3456b82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -456,6 +456,47 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "deploy-compliance" +version = "0.7.1" +dependencies = [ + "soroban-sdk", + "stellar-access", + "stellar-macros", + "stellar-tokens", +] + +[[package]] +name = "deploy-irs" +version = "0.7.1" +dependencies = [ + "soroban-sdk", + "stellar-access", + "stellar-macros", + "stellar-tokens", +] + +[[package]] +name = "deploy-token" +version = "0.7.1" +dependencies = [ + "soroban-sdk", + "stellar-access", + "stellar-contract-utils", + "stellar-macros", + "stellar-tokens", +] + +[[package]] +name = "deploy-verifier" +version = "0.7.1" +dependencies = [ + "soroban-sdk", + "stellar-access", + "stellar-macros", + "stellar-tokens", +] + [[package]] name = "der" version = "0.7.10" @@ -1575,12 +1616,18 @@ dependencies = [ ] [[package]] -name = "rwa-compliance-example" +name = "rwa-country-allow" +version = "0.7.1" +dependencies = [ + "soroban-sdk", + "stellar-tokens", +] + +[[package]] +name = "rwa-country-restrict" version = "0.7.1" dependencies = [ "soroban-sdk", - "stellar-access", - "stellar-macros", "stellar-tokens", ] @@ -1614,6 +1661,38 @@ dependencies = [ "stellar-tokens", ] +[[package]] +name = "rwa-initial-lockup-period" +version = "0.7.1" +dependencies = [ + "soroban-sdk", + "stellar-tokens", +] + +[[package]] +name = "rwa-max-balance" +version = "0.7.1" +dependencies = [ + "soroban-sdk", + "stellar-tokens", +] + +[[package]] +name = "rwa-supply-limit" +version = "0.7.1" +dependencies = [ + "soroban-sdk", + "stellar-tokens", +] + +[[package]] +name = "rwa-time-transfers-limits" +version = "0.7.1" +dependencies = [ + "soroban-sdk", + "stellar-tokens", +] + [[package]] name = "rwa-token-example" version = "0.7.1" @@ -1625,6 +1704,14 @@ dependencies = [ "stellar-tokens", ] +[[package]] +name = "rwa-transfer-restrict" +version = "0.7.1" +dependencies = [ + "soroban-sdk", + "stellar-tokens", +] + [[package]] name = "ryu" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 656eca1fe..f1a678a7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,23 @@ members = [ "examples/nft-sequential-minting", "examples/ownable", "examples/pausable", - "examples/rwa/*", + "examples/rwa/claim-issuer", + "examples/rwa/claim-topics-and-issuers", + "examples/rwa/identity", + "examples/rwa/identity-registry", + "examples/rwa/identity-verifier", + "examples/rwa/token", + "examples/rwa-country-allow", + "examples/rwa-country-restrict", + "examples/rwa-max-balance", + "examples/rwa-supply-limit", + "examples/rwa-time-transfers-limits", + "examples/rwa-transfer-restrict", + "examples/rwa-initial-lockup-period", + "examples/rwa-deploy/irs", + "examples/rwa-deploy/verifier", + "examples/rwa-deploy/compliance", + "examples/rwa-deploy/token", "examples/sac-admin-generic", "examples/sac-admin-wrapper", "examples/multisig-smart-account/*", diff --git a/examples/rwa-country-allow/Cargo.toml b/examples/rwa-country-allow/Cargo.toml new file mode 100644 index 000000000..540b26786 --- /dev/null +++ b/examples/rwa-country-allow/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "rwa-country-allow" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true +authors.workspace = true + +[package.metadata.stellar] +cargo_inherit = true + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-tokens = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/examples/rwa-country-allow/src/contract.rs b/examples/rwa-country-allow/src/contract.rs new file mode 100644 index 000000000..9be520c25 --- /dev/null +++ b/examples/rwa-country-allow/src/contract.rs @@ -0,0 +1,101 @@ +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::modules::{ + country_allow::storage as country_allow, + storage::{ + get_compliance_address, module_name, set_compliance_address, set_irs_address, + ComplianceModuleStorageKey, + }, + ComplianceModule, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct CountryAllowContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl CountryAllowContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } + + pub fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { + require_module_admin_or_compliance_auth(e); + set_irs_address(e, &token, &irs); + } + + pub fn add_allowed_country(e: &Env, token: Address, country: u32) { + require_module_admin_or_compliance_auth(e); + country_allow::add_allowed_country(e, &token, country); + } + + pub fn remove_allowed_country(e: &Env, token: Address, country: u32) { + require_module_admin_or_compliance_auth(e); + country_allow::remove_allowed_country(e, &token, country); + } + + pub fn batch_allow_countries(e: &Env, token: Address, countries: Vec) { + require_module_admin_or_compliance_auth(e); + country_allow::batch_allow_countries(e, &token, &countries); + } + + pub fn batch_disallow_countries(e: &Env, token: Address, countries: Vec) { + require_module_admin_or_compliance_auth(e); + country_allow::batch_disallow_countries(e, &token, &countries); + } + + pub fn is_country_allowed(e: &Env, token: Address, country: u32) -> bool { + country_allow::is_country_allowed(e, &token, country) + } +} + +#[contractimpl(contracttrait)] +impl ComplianceModule for CountryAllowContract { + fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {} + + fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {} + + fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {} + + fn can_transfer(e: &Env, _from: Address, to: Address, _amount: i128, token: Address) -> bool { + country_allow::can_transfer(e, &to, &token) + } + + fn can_create(e: &Env, to: Address, _amount: i128, token: Address) -> bool { + country_allow::can_transfer(e, &to, &token) + } + + fn name(e: &Env) -> String { + module_name(e, "CountryAllowModule") + } + + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-country-allow/src/lib.rs b/examples/rwa-country-allow/src/lib.rs new file mode 100644 index 000000000..a879b6f80 --- /dev/null +++ b/examples/rwa-country-allow/src/lib.rs @@ -0,0 +1,5 @@ +#![no_std] + +pub mod contract; +#[cfg(test)] +mod test; diff --git a/examples/rwa-country-allow/src/test.rs b/examples/rwa-country-allow/src/test.rs new file mode 100644 index 000000000..06b540ed3 --- /dev/null +++ b/examples/rwa-country-allow/src/test.rs @@ -0,0 +1,259 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal, + String, Val, Vec, +}; +use stellar_tokens::rwa::{ + identity_registry_storage::{ + CountryData, CountryDataManager, CountryRelation, IdentityRegistryStorage, + IndividualCountryRelation, OrganizationCountryRelation, + }, + utils::token_binder::TokenBinder, +}; + +use crate::contract::{CountryAllowContract, CountryAllowContractClient}; + +fn create_client<'a>(e: &Env, admin: &Address) -> CountryAllowContractClient<'a> { + let address = e.register(CountryAllowContract, (admin,)); + CountryAllowContractClient::new(e, &address) +} + +#[contract] +struct MockIRSContract; + +#[contracttype] +#[derive(Clone)] +enum MockIRSStorageKey { + Identity(Address), + CountryEntries(Address), +} + +#[contractimpl] +impl TokenBinder for MockIRSContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl IdentityRegistryStorage for MockIRSContract { + fn add_identity( + _e: &Env, + _account: Address, + _identity: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_identity is not used in these tests"); + } + + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { + unreachable!("remove_identity is not used in these tests"); + } + + fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { + unreachable!("modify_identity is not used in these tests"); + } + + fn recover_identity( + _e: &Env, + _old_account: Address, + _new_account: Address, + _operator: Address, + ) { + unreachable!("recover_identity is not used in these tests"); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + e.storage() + .persistent() + .get(&MockIRSStorageKey::Identity(account.clone())) + .unwrap_or(account) + } +} + +#[contractimpl] +impl CountryDataManager for MockIRSContract { + fn add_country_data_entries( + _e: &Env, + _account: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_country_data_entries is not used in these tests"); + } + + fn modify_country_data( + _e: &Env, + _account: Address, + _index: u32, + _country_data: Val, + _operator: Address, + ) { + unreachable!("modify_country_data is not used in these tests"); + } + + fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { + unreachable!("delete_country_data is not used in these tests"); + } + + fn get_country_data_entries(e: &Env, account: Address) -> Vec { + let entries: Vec = e + .storage() + .persistent() + .get(&MockIRSStorageKey::CountryEntries(account)) + .unwrap_or_else(|| Vec::new(e)); + + Vec::from_iter(e, entries.iter().map(|entry| entry.into_val(e))) + } +} + +#[contractimpl] +impl MockIRSContract { + pub fn set_country_data_entries(e: &Env, account: Address, entries: Vec) { + e.storage().persistent().set(&MockIRSStorageKey::CountryEntries(account), &entries); + } +} + +fn individual_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Individual(IndividualCountryRelation::Residence(code)), + metadata: None, + } +} + +fn organization_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Organization(OrganizationCountryRelation::OperatingJurisdiction( + code, + )), + metadata: None, + } +} + +#[test] +fn add_and_remove_allowed_country_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let client = create_client(&e, &admin); + + assert!(!client.is_country_allowed(&token, &276)); + + client.add_allowed_country(&token, &276); + assert!(client.is_country_allowed(&token, &276)); + + client.remove_allowed_country(&token, &276); + assert!(!client.is_country_allowed(&token, &276)); +} + +#[test] +fn batch_allow_and_disallow_countries_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let client = create_client(&e, &admin); + + client.batch_allow_countries(&token, &vec![&e, 250u32, 276u32]); + assert!(client.is_country_allowed(&token, &250)); + assert!(client.is_country_allowed(&token, &276)); + + client.batch_disallow_countries(&token, &vec![&e, 250u32]); + assert!(!client.is_country_allowed(&token, &250)); + assert!(client.is_country_allowed(&token, &276)); +} + +#[test] +fn name_and_compliance_address_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let client = create_client(&e, &admin); + + assert_eq!(client.name(), String::from_str(&e, "CountryAllowModule")); + + client.set_compliance_address(&compliance); + assert_eq!(client.get_compliance_address(), compliance); +} + +#[test] +fn set_identity_registry_storage_uses_admin_auth_before_compliance_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let irs = Address::generate(&e); + let client = create_client(&e, &admin); + + client.set_identity_registry_storage(&token, &irs); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); +} + +#[test] +fn set_identity_registry_storage_uses_compliance_auth_after_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let irs = Address::generate(&e); + let client = create_client(&e, &admin); + + client.set_compliance_address(&compliance); + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); + + client.set_identity_registry_storage(&token, &irs); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &compliance); +} + +#[test] +fn can_transfer_and_can_create_use_irs_country_entries() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let from = Address::generate(&e); + let token = Address::generate(&e); + let allowed_to = Address::generate(&e); + let disallowed_to = Address::generate(&e); + let amount = 100_i128; + let client = create_client(&e, &admin); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + + irs.set_country_data_entries( + &allowed_to, + &vec![&e, individual_country(250), organization_country(276)], + ); + irs.set_country_data_entries(&disallowed_to, &vec![&e, individual_country(250)]); + + client.set_identity_registry_storage(&token, &irs_id); + client.add_allowed_country(&token, &276); + + assert!(client.can_transfer(&from, &allowed_to, &amount, &token)); + assert!(client.can_create(&allowed_to, &amount, &token)); + assert!(!client.can_transfer(&from, &disallowed_to, &amount, &token)); + assert!(!client.can_create(&disallowed_to, &amount, &token)); +} diff --git a/examples/rwa-country-restrict/Cargo.toml b/examples/rwa-country-restrict/Cargo.toml new file mode 100644 index 000000000..d160254e9 --- /dev/null +++ b/examples/rwa-country-restrict/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "rwa-country-restrict" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true +authors.workspace = true + +[package.metadata.stellar] +cargo_inherit = true + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-tokens = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/examples/rwa-country-restrict/src/contract.rs b/examples/rwa-country-restrict/src/contract.rs new file mode 100644 index 000000000..aa2c9794c --- /dev/null +++ b/examples/rwa-country-restrict/src/contract.rs @@ -0,0 +1,101 @@ +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::modules::{ + country_restrict::storage as country_restrict, + storage::{ + get_compliance_address, module_name, set_compliance_address, set_irs_address, + ComplianceModuleStorageKey, + }, + ComplianceModule, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct CountryRestrictContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl CountryRestrictContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } + + pub fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { + require_module_admin_or_compliance_auth(e); + set_irs_address(e, &token, &irs); + } + + pub fn add_country_restriction(e: &Env, token: Address, country: u32) { + require_module_admin_or_compliance_auth(e); + country_restrict::add_country_restriction(e, &token, country); + } + + pub fn remove_country_restriction(e: &Env, token: Address, country: u32) { + require_module_admin_or_compliance_auth(e); + country_restrict::remove_country_restriction(e, &token, country); + } + + pub fn batch_restrict_countries(e: &Env, token: Address, countries: Vec) { + require_module_admin_or_compliance_auth(e); + country_restrict::batch_restrict_countries(e, &token, &countries); + } + + pub fn batch_unrestrict_countries(e: &Env, token: Address, countries: Vec) { + require_module_admin_or_compliance_auth(e); + country_restrict::batch_unrestrict_countries(e, &token, &countries); + } + + pub fn is_country_restricted(e: &Env, token: Address, country: u32) -> bool { + country_restrict::is_country_restricted(e, &token, country) + } +} + +#[contractimpl(contracttrait)] +impl ComplianceModule for CountryRestrictContract { + fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {} + + fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {} + + fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {} + + fn can_transfer(e: &Env, _from: Address, to: Address, _amount: i128, token: Address) -> bool { + country_restrict::can_transfer(e, &to, &token) + } + + fn can_create(e: &Env, to: Address, _amount: i128, token: Address) -> bool { + country_restrict::can_transfer(e, &to, &token) + } + + fn name(e: &Env) -> String { + module_name(e, "CountryRestrictModule") + } + + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-country-restrict/src/lib.rs b/examples/rwa-country-restrict/src/lib.rs new file mode 100644 index 000000000..a879b6f80 --- /dev/null +++ b/examples/rwa-country-restrict/src/lib.rs @@ -0,0 +1,5 @@ +#![no_std] + +pub mod contract; +#[cfg(test)] +mod test; diff --git a/examples/rwa-country-restrict/src/test.rs b/examples/rwa-country-restrict/src/test.rs new file mode 100644 index 000000000..4d6a10154 --- /dev/null +++ b/examples/rwa-country-restrict/src/test.rs @@ -0,0 +1,259 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal, + String, Val, Vec, +}; +use stellar_tokens::rwa::{ + identity_registry_storage::{ + CountryData, CountryDataManager, CountryRelation, IdentityRegistryStorage, + IndividualCountryRelation, OrganizationCountryRelation, + }, + utils::token_binder::TokenBinder, +}; + +use crate::contract::{CountryRestrictContract, CountryRestrictContractClient}; + +fn create_client<'a>(e: &Env, admin: &Address) -> CountryRestrictContractClient<'a> { + let address = e.register(CountryRestrictContract, (admin,)); + CountryRestrictContractClient::new(e, &address) +} + +#[contract] +struct MockIRSContract; + +#[contracttype] +#[derive(Clone)] +enum MockIRSStorageKey { + Identity(Address), + CountryEntries(Address), +} + +#[contractimpl] +impl TokenBinder for MockIRSContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl IdentityRegistryStorage for MockIRSContract { + fn add_identity( + _e: &Env, + _account: Address, + _identity: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_identity is not used in these tests"); + } + + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { + unreachable!("remove_identity is not used in these tests"); + } + + fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { + unreachable!("modify_identity is not used in these tests"); + } + + fn recover_identity( + _e: &Env, + _old_account: Address, + _new_account: Address, + _operator: Address, + ) { + unreachable!("recover_identity is not used in these tests"); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + e.storage() + .persistent() + .get(&MockIRSStorageKey::Identity(account.clone())) + .unwrap_or(account) + } +} + +#[contractimpl] +impl CountryDataManager for MockIRSContract { + fn add_country_data_entries( + _e: &Env, + _account: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_country_data_entries is not used in these tests"); + } + + fn modify_country_data( + _e: &Env, + _account: Address, + _index: u32, + _country_data: Val, + _operator: Address, + ) { + unreachable!("modify_country_data is not used in these tests"); + } + + fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { + unreachable!("delete_country_data is not used in these tests"); + } + + fn get_country_data_entries(e: &Env, account: Address) -> Vec { + let entries: Vec = e + .storage() + .persistent() + .get(&MockIRSStorageKey::CountryEntries(account)) + .unwrap_or_else(|| Vec::new(e)); + + Vec::from_iter(e, entries.iter().map(|entry| entry.into_val(e))) + } +} + +#[contractimpl] +impl MockIRSContract { + pub fn set_country_data_entries(e: &Env, account: Address, entries: Vec) { + e.storage().persistent().set(&MockIRSStorageKey::CountryEntries(account), &entries); + } +} + +fn individual_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Individual(IndividualCountryRelation::Residence(code)), + metadata: None, + } +} + +fn organization_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Organization(OrganizationCountryRelation::OperatingJurisdiction( + code, + )), + metadata: None, + } +} + +#[test] +fn add_and_remove_country_restriction_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let client = create_client(&e, &admin); + + assert!(!client.is_country_restricted(&token, &276)); + + client.add_country_restriction(&token, &276); + assert!(client.is_country_restricted(&token, &276)); + + client.remove_country_restriction(&token, &276); + assert!(!client.is_country_restricted(&token, &276)); +} + +#[test] +fn batch_restrict_and_unrestrict_countries_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let client = create_client(&e, &admin); + + client.batch_restrict_countries(&token, &vec![&e, 250u32, 276u32]); + assert!(client.is_country_restricted(&token, &250)); + assert!(client.is_country_restricted(&token, &276)); + + client.batch_unrestrict_countries(&token, &vec![&e, 250u32]); + assert!(!client.is_country_restricted(&token, &250)); + assert!(client.is_country_restricted(&token, &276)); +} + +#[test] +fn name_and_compliance_address_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let client = create_client(&e, &admin); + + assert_eq!(client.name(), String::from_str(&e, "CountryRestrictModule")); + + client.set_compliance_address(&compliance); + assert_eq!(client.get_compliance_address(), compliance); +} + +#[test] +fn set_identity_registry_storage_uses_admin_auth_before_compliance_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let irs = Address::generate(&e); + let client = create_client(&e, &admin); + + client.set_identity_registry_storage(&token, &irs); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); +} + +#[test] +fn set_identity_registry_storage_uses_compliance_auth_after_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let irs = Address::generate(&e); + let client = create_client(&e, &admin); + + client.set_compliance_address(&compliance); + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); + + client.set_identity_registry_storage(&token, &irs); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &compliance); +} + +#[test] +fn can_transfer_and_can_create_use_irs_country_entries() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let from = Address::generate(&e); + let token = Address::generate(&e); + let allowed_to = Address::generate(&e); + let restricted_to = Address::generate(&e); + let amount = 100_i128; + let client = create_client(&e, &admin); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + + irs.set_country_data_entries(&allowed_to, &vec![&e, individual_country(250)]); + irs.set_country_data_entries( + &restricted_to, + &vec![&e, individual_country(250), organization_country(276)], + ); + + client.set_identity_registry_storage(&token, &irs_id); + client.add_country_restriction(&token, &276); + + assert!(client.can_transfer(&from, &allowed_to, &amount, &token)); + assert!(client.can_create(&allowed_to, &amount, &token)); + assert!(!client.can_transfer(&from, &restricted_to, &amount, &token)); + assert!(!client.can_create(&restricted_to, &amount, &token)); +} diff --git a/examples/rwa-deploy/README.md b/examples/rwa-deploy/README.md new file mode 100644 index 000000000..15659b05a --- /dev/null +++ b/examples/rwa-deploy/README.md @@ -0,0 +1,57 @@ +# RWA Deploy Crates + +Minimal deployable crates used by the end-to-end RWA deployment flow. + +These crates primarily wire OpenZeppelin Stellar library traits into concrete +WASMs. They stay intentionally thin, but they do contain the deployment-specific +wiring needed for constructors, access control, token binding, and hook-based +compliance orchestration. + +## Crates + +| Crate | Purpose | Key traits / modules composed | +| ------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| `irs/` | Identity Registry Storage — stores investor identities and country data | `IdentityRegistryStorage`, `CountryDataManager`, `TokenBinder`, `AccessControl` | +| `verifier/` | Identity Verifier — validates that an account has a registered identity | `IdentityVerifier`, `AccessControl` | +| `compliance/` | Compliance contract — orchestrates hook dispatch across registered modules | `Compliance`, `TokenBinder`, `AccessControl` | +| `token/` | RWA Token — compliant fungible token with freeze, forced transfer, and pause | `FungibleToken`, `RWAToken`, `Pausable`, `AccessControl` | + +## Why separate crates? + +Soroban requires each deployable contract to live in its own crate (one +`cdylib` per WASM). These crates are thin wrappers: the actual implementation +lives in `stellar-tokens`, `stellar-access`, and `stellar-contract-utils`. + +## Build + +`scripts/build.sh` builds the full deployable stack: + +- 4 infrastructure WASMs from this directory: `irs/`, `verifier/`, + `compliance/`, and `token/` +- 7 compliance-module WASMs from the example module crates: + `rwa-country-allow`, `rwa-country-restrict`, `rwa-initial-lockup-period`, + `rwa-max-balance`, `rwa-supply-limit`, `rwa-time-transfers-limits`, and + `rwa-transfer-restrict` + +The script calls `stellar contract build` for each package and writes the +optimized artifacts to `examples/rwa-deploy/wasm/`. + +## Deploy flow + +`scripts/deploy.sh` follows the current bootstrap-admin flow introduced by the +RWA module examples: + +1. Deploy IRS, verifier, compliance, and token +2. Bind the token to Compliance and IRS +3. Deploy all 7 compliance modules with a bootstrap admin +4. Configure every module while bootstrap admin auth is still active +5. Call `set_compliance_address` on each module to hand control to Compliance + +After deployment, `scripts/wire.sh` registers the modules on their required +hooks, and `scripts/e2e.sh` runs the full testnet flow. + +## Artifacts (git-ignored) + +- `wasm/` — compiled WASM binaries produced by `scripts/build.sh` +- `testnet-addresses.json` — contract addresses from the last deployment + (produced by `scripts/deploy.sh`) diff --git a/examples/rwa-deploy/compliance/Cargo.toml b/examples/rwa-deploy/compliance/Cargo.toml new file mode 100644 index 000000000..dbd10ffdb --- /dev/null +++ b/examples/rwa-deploy/compliance/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "deploy-compliance" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true + +[lib] +crate-type = ["cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-access = { workspace = true } +stellar-macros = { workspace = true } +stellar-tokens = { workspace = true } diff --git a/examples/rwa-deploy/compliance/src/lib.rs b/examples/rwa-deploy/compliance/src/lib.rs new file mode 100644 index 000000000..c1fc5e34c --- /dev/null +++ b/examples/rwa-deploy/compliance/src/lib.rs @@ -0,0 +1,81 @@ +#![no_std] + +use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, Symbol, Vec}; +use stellar_access::access_control::{self as access_control, AccessControl}; +use stellar_macros::only_role; +use stellar_tokens::rwa::{ + compliance::{storage as compliance_storage, Compliance, ComplianceHook}, + utils::token_binder::{self as binder, TokenBinder}, +}; + +#[contract] +pub struct ComplianceContract; + +#[contractimpl] +impl ComplianceContract { + pub fn __constructor(e: &Env, admin: Address) { + access_control::set_admin(e, &admin); + access_control::grant_role_no_auth(e, &admin, &symbol_short!("admin"), &admin); + } +} + +#[contractimpl] +impl Compliance for ComplianceContract { + #[only_role(operator, "admin")] + fn add_module_to(e: &Env, hook: ComplianceHook, module: Address, operator: Address) { + compliance_storage::add_module_to(e, hook, module); + } + + #[only_role(operator, "admin")] + fn remove_module_from(e: &Env, hook: ComplianceHook, module: Address, operator: Address) { + compliance_storage::remove_module_from(e, hook, module); + } + + fn get_modules_for_hook(e: &Env, hook: ComplianceHook) -> Vec
{ + compliance_storage::get_modules_for_hook(e, hook) + } + + fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool { + compliance_storage::is_module_registered(e, hook, module) + } + + fn transferred(e: &Env, from: Address, to: Address, amount: i128, token: Address) { + compliance_storage::transferred(e, from, to, amount, token); + } + + fn created(e: &Env, to: Address, amount: i128, token: Address) { + compliance_storage::created(e, to, amount, token); + } + + fn destroyed(e: &Env, from: Address, amount: i128, token: Address) { + compliance_storage::destroyed(e, from, amount, token); + } + + fn can_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) -> bool { + compliance_storage::can_transfer(e, from, to, amount, token) + } + + fn can_create(e: &Env, to: Address, amount: i128, token: Address) -> bool { + compliance_storage::can_create(e, to, amount, token) + } +} + +#[contractimpl] +impl TokenBinder for ComplianceContract { + fn linked_tokens(e: &Env) -> Vec
{ + binder::linked_tokens(e) + } + + #[only_role(operator, "admin")] + fn bind_token(e: &Env, token: Address, operator: Address) { + binder::bind_token(e, &token); + } + + #[only_role(operator, "admin")] + fn unbind_token(e: &Env, token: Address, operator: Address) { + binder::unbind_token(e, &token); + } +} + +#[contractimpl(contracttrait)] +impl AccessControl for ComplianceContract {} diff --git a/examples/rwa-deploy/irs/Cargo.toml b/examples/rwa-deploy/irs/Cargo.toml new file mode 100644 index 000000000..298c9e4c6 --- /dev/null +++ b/examples/rwa-deploy/irs/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "deploy-irs" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true + +[lib] +crate-type = ["cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-access = { workspace = true } +stellar-macros = { workspace = true } +stellar-tokens = { workspace = true } diff --git a/examples/rwa-deploy/irs/src/lib.rs b/examples/rwa-deploy/irs/src/lib.rs new file mode 100644 index 000000000..66f3a1354 --- /dev/null +++ b/examples/rwa-deploy/irs/src/lib.rs @@ -0,0 +1,131 @@ +#![no_std] + +use soroban_sdk::{ + contract, contractimpl, symbol_short, Address, Env, FromVal, IntoVal, Symbol, Val, Vec, +}; +use stellar_access::access_control::{self as access_control, AccessControl}; +use stellar_macros::only_role; +use stellar_tokens::rwa::{ + identity_registry_storage::{ + self as identity_storage, CountryData, CountryDataManager, IdentityRegistryStorage, + IdentityType, + }, + utils::token_binder::{self as binder, TokenBinder}, +}; + +#[contract] +pub struct IdentityRegistryContract; + +#[contractimpl] +impl IdentityRegistryContract { + pub fn __constructor(e: &Env, admin: Address, manager: Address) { + access_control::set_admin(e, &admin); + access_control::grant_role_no_auth(e, &manager, &symbol_short!("manager"), &admin); + } + + #[only_role(operator, "manager")] + pub fn bind_tokens(e: &Env, tokens: Vec
, operator: Address) { + binder::bind_tokens(e, &tokens); + } +} + +#[contractimpl] +impl TokenBinder for IdentityRegistryContract { + fn linked_tokens(e: &Env) -> Vec
{ + binder::linked_tokens(e) + } + + #[only_role(operator, "manager")] + fn bind_token(e: &Env, token: Address, operator: Address) { + binder::bind_token(e, &token); + } + + #[only_role(operator, "manager")] + fn unbind_token(e: &Env, token: Address, operator: Address) { + binder::unbind_token(e, &token); + } +} + +#[contractimpl] +impl IdentityRegistryStorage for IdentityRegistryContract { + #[only_role(operator, "manager")] + fn add_identity( + e: &Env, + account: Address, + identity: Address, + initial_profiles: Vec, + operator: Address, + ) { + let country_data = Vec::from_iter( + e, + initial_profiles.iter().map(|profile| CountryData::from_val(e, &profile)), + ); + identity_storage::add_identity( + e, + &account, + &identity, + IdentityType::Individual, + &country_data, + ); + } + + #[only_role(operator, "manager")] + fn modify_identity(e: &Env, account: Address, new_identity: Address, operator: Address) { + identity_storage::modify_identity(e, &account, &new_identity); + } + + #[only_role(operator, "manager")] + fn remove_identity(e: &Env, account: Address, operator: Address) { + identity_storage::remove_identity(e, &account); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + identity_storage::stored_identity(e, &account) + } + + #[only_role(operator, "manager")] + fn recover_identity(e: &Env, old_account: Address, new_account: Address, operator: Address) { + identity_storage::recover_identity(e, &old_account, &new_account); + } + + fn get_recovered_to(e: &Env, old: Address) -> Option
{ + identity_storage::get_recovered_to(e, &old) + } +} + +#[contractimpl] +impl CountryDataManager for IdentityRegistryContract { + #[only_role(operator, "manager")] + fn add_country_data_entries(e: &Env, account: Address, profiles: Vec, operator: Address) { + let country_data = + Vec::from_iter(e, profiles.iter().map(|profile| CountryData::from_val(e, &profile))); + identity_storage::add_country_data_entries(e, &account, &country_data); + } + + #[only_role(operator, "manager")] + fn modify_country_data(e: &Env, account: Address, index: u32, profile: Val, operator: Address) { + let country_data = CountryData::from_val(e, &profile); + identity_storage::modify_country_data(e, &account, index, &country_data); + } + + #[only_role(operator, "manager")] + fn delete_country_data(e: &Env, account: Address, index: u32, operator: Address) { + identity_storage::delete_country_data(e, &account, index); + } + + fn get_country_data(e: &Env, account: Address, index: u32) -> Val { + identity_storage::get_country_data(e, &account, index).into_val(e) + } + + fn get_country_data_entries(e: &Env, account: Address) -> Vec { + Vec::from_iter( + e, + identity_storage::get_country_data_entries(e, &account) + .iter() + .map(|profile| profile.into_val(e)), + ) + } +} + +#[contractimpl(contracttrait)] +impl AccessControl for IdentityRegistryContract {} diff --git a/examples/rwa-deploy/scripts/build-module.sh b/examples/rwa-deploy/scripts/build-module.sh new file mode 100755 index 000000000..ff2805bd0 --- /dev/null +++ b/examples/rwa-deploy/scripts/build-module.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Build a single compliance module WASM by short name. +# Usage: ./build-module.sh +# Example: ./build-module.sh supply-limit +set -euo pipefail + +if [ $# -lt 1 ]; then + echo "Usage: $0 " + echo "" + echo "Available modules:" + echo " country-allow" + echo " country-restrict" + echo " initial-lockup-period" + echo " max-balance" + echo " supply-limit" + echo " time-transfers-limits" + echo " transfer-restrict" + exit 1 +fi + +MODULE="$1" +PKG="rwa-$MODULE" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +WASM_DIR="$ROOT_DIR/examples/rwa-deploy/wasm" + +mkdir -p "$WASM_DIR" + +echo "=== Building $PKG ===" +cd "$ROOT_DIR" +stellar contract build --package "$PKG" --out-dir "$WASM_DIR" + +WASM_NAME="${PKG//-/_}.wasm" +if [ -f "$WASM_DIR/$WASM_NAME" ]; then + SIZE=$(wc -c < "$WASM_DIR/$WASM_NAME" | tr -d ' ') + echo " $WASM_NAME (${SIZE} bytes) -> examples/rwa-deploy/wasm/" +else + echo "ERROR: $WASM_NAME not found!" >&2 + exit 1 +fi diff --git a/examples/rwa-deploy/scripts/build.sh b/examples/rwa-deploy/scripts/build.sh new file mode 100755 index 000000000..530368b90 --- /dev/null +++ b/examples/rwa-deploy/scripts/build.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Build all 11 RWA contract WASMs (7 compliance modules + 4 infrastructure). +# Uses `stellar contract build` which handles WASM feature stripping properly, +# unlike raw `cargo build` + deprecated `stellar contract optimize`. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +WASM_DIR="$ROOT_DIR/examples/rwa-deploy/wasm" + +mkdir -p "$WASM_DIR" + +MODULES=( + rwa-country-allow + rwa-country-restrict + rwa-initial-lockup-period + rwa-max-balance + rwa-supply-limit + rwa-time-transfers-limits + rwa-transfer-restrict +) + +INFRA=( + deploy-irs + deploy-verifier + deploy-compliance + deploy-token +) + +ALL=("${MODULES[@]}" "${INFRA[@]}") + +echo "=== Building ${#ALL[@]} WASMs ===" + +cd "$ROOT_DIR" +for pkg in "${ALL[@]}"; do + echo " Building $pkg..." + if ! output=$(stellar contract build --package "$pkg" --out-dir "$WASM_DIR" 2>&1); then + printf '%s\n' "$output" | sed '/^$/d' + echo "ERROR: Failed to build $pkg" >&2 + exit 1 + fi + printf '%s\n' "$output" | sed '/^$/d' +done + +echo "" +echo "=== WASM sizes ===" +for pkg in "${ALL[@]}"; do + WASM_NAME="${pkg//-/_}.wasm" + if [ -f "$WASM_DIR/$WASM_NAME" ]; then + SIZE=$(wc -c < "$WASM_DIR/$WASM_NAME" | tr -d ' ') + echo " $WASM_NAME (${SIZE} bytes)" + else + echo " WARNING: $WASM_NAME not found!" + fi +done + +echo "" +echo "=== All WASMs built to examples/rwa-deploy/wasm/ ===" diff --git a/examples/rwa-deploy/scripts/common.sh b/examples/rwa-deploy/scripts/common.sh new file mode 100644 index 000000000..4416657d2 --- /dev/null +++ b/examples/rwa-deploy/scripts/common.sh @@ -0,0 +1,304 @@ +#!/usr/bin/env bash + +retryable_invoke_error() { + local output=$1 + case "$output" in + *"transaction submission timeout"* | \ + *"could not load platform certs"* | \ + *"connection reset"* | \ + *"temporarily unavailable"* | \ + *"deadline has elapsed"* | \ + *"timed out"*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +is_contract_id() { + case "$1" in + C[A-Z0-9]*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +require_contract_id() { + local label=$1 + local contract_id=$2 + + if ! is_contract_id "$contract_id"; then + echo "ERROR: Missing or invalid $label contract id: '$contract_id'" >&2 + return 1 + fi +} + +invoke() { + local contract_id=$1 + require_contract_id "invoke target" "$contract_id" || return 1 + + stellar contract invoke --id "$contract_id" \ + --source "$SOURCE" --network "$NETWORK" \ + -- "${@:2}" +} + +invoke_readonly() { + local contract_id=$1 + require_contract_id "readonly invoke target" "$contract_id" || return 1 + + stellar contract invoke --id "$contract_id" \ + --source-account "$ADMIN" --network "$NETWORK" \ + -- "${@:2}" +} + +invoke_with_retry() { + local attempts=${STELLAR_INVOKE_RETRIES:-4} + local delay=${STELLAR_INVOKE_RETRY_DELAY_SECONDS:-3} + local attempt output status + + for attempt in $(seq 1 "$attempts"); do + if output=$(invoke "$@" 2>&1); then + printf '%s\n' "$output" + return 0 + fi + status=$? + + if ! retryable_invoke_error "$output"; then + printf '%s\n' "$output" >&2 + return "$status" + fi + + if [ "$attempt" -eq "$attempts" ]; then + printf '%s\n' "$output" >&2 + return "$status" + fi + + echo "Retrying invoke after transient Stellar CLI failure..." >&2 + sleep $((delay * attempt)) + done +} + +read_addr() { + python3 -c "import json; d=json.load(open('$ADDR_FILE')); print(d$1)" +} + +hook_modules() { + invoke_readonly "$COMPLIANCE" get_modules_for_hook --hook "\"$1\"" +} + +is_module_registered_for_hook() { + local hook=$1 + local module=$2 + local output + + if ! output=$(hook_modules "$hook" 2>&1); then + return 1 + fi + + MODULE_TO_FIND="$module" python3 -c ' +import json, os, sys + +module = os.environ["MODULE_TO_FIND"] +lines = [line.strip() for line in sys.stdin.read().splitlines() if line.strip()] +payload = lines[-1] if lines else "[]" +modules = json.loads(payload) +sys.exit(0 if module in modules else 1) +' <<<"$output" +} + +ensure_hook_registration() { + local hook=$1 + local module_addr=$2 + local name=$3 + local attempts=${STELLAR_INVOKE_RETRIES:-4} + local delay=${STELLAR_INVOKE_RETRY_DELAY_SECONDS:-3} + local attempt output status + + echo " $name -> $hook" + + for attempt in $(seq 1 "$attempts"); do + if is_module_registered_for_hook "$hook" "$module_addr"; then + echo " already registered" + return 0 + fi + + if output=$(invoke "$COMPLIANCE" add_module_to --hook "\"$hook\"" --module "$module_addr" --operator "$ADMIN" 2>&1); then + printf '%s\n' "$output" + return 0 + fi + status=$? + + if is_module_registered_for_hook "$hook" "$module_addr"; then + echo " registered after retryable failure" + return 0 + fi + + case "$output" in + *"Error(Contract, #360)"* | *"ModuleAlreadyRegistered"*) + echo " already registered" + return 0 + ;; + esac + + if ! retryable_invoke_error "$output"; then + printf '%s\n' "$output" >&2 + return "$status" + fi + + if [ "$attempt" -eq "$attempts" ]; then + printf '%s\n' "$output" >&2 + return "$status" + fi + + echo " retrying after transient Stellar CLI failure..." >&2 + sleep $((delay * attempt)) + done +} + +verify_hook_wiring_with_retry() { + local module_addr=$1 + local name=$2 + local attempts=${STELLAR_INVOKE_RETRIES:-4} + local delay=${STELLAR_INVOKE_RETRY_DELAY_SECONDS:-3} + local attempt output status + + echo " Verifying $name..." + + for attempt in $(seq 1 "$attempts"); do + if output=$(invoke "$module_addr" verify_hook_wiring 2>&1); then + printf '%s\n' "$output" + return 0 + fi + status=$? + + if ! retryable_invoke_error "$output"; then + printf '%s\n' "$output" >&2 + return "$status" + fi + + if [ "$attempt" -eq "$attempts" ]; then + printf '%s\n' "$output" >&2 + return "$status" + fi + + echo " retrying verification after transient Stellar CLI failure..." >&2 + sleep $((delay * attempt)) + done +} + +identity_matches() { + local contract_addr=$1 + local account=$2 + local expected_identity=$3 + local output + + if ! output=$(invoke_readonly "$contract_addr" stored_identity --account "$account" 2>&1); then + return 1 + fi + + EXPECTED_IDENTITY="$expected_identity" python3 -c ' +import os, sys + +expected = os.environ["EXPECTED_IDENTITY"] +lines = [line.strip() for line in sys.stdin.read().splitlines() if line.strip()] +payload = lines[-1].strip("\"") if lines else "" +sys.exit(0 if payload == expected else 1) +' <<<"$output" +} + +country_profiles_to_scval_json() { + python3 - "$1" <<'PY' +import json +import sys + +profiles = json.loads(sys.argv[1]) + +def sc_symbol(value): + return {"symbol": value} + +def sc_string(value): + return {"string": value} + +def sc_u32(value): + return {"u32": value} + +def metadata_to_scval(metadata): + if metadata is None: + return "void" + + return { + "map": [ + {"key": sc_string(key), "val": sc_string(value)} + for key, value in sorted(metadata.items()) + ] + } + +def enum_to_scval(enum_value): + [(outer_name, outer_payload)] = enum_value.items() + [(inner_name, inner_payload)] = outer_payload.items() + return {"vec": [sc_symbol(outer_name), {"vec": [sc_symbol(inner_name), sc_u32(inner_payload)]}]} + +def country_data_to_scval(profile): + return { + "map": [ + {"key": sc_symbol("country"), "val": enum_to_scval(profile["country"])}, + {"key": sc_symbol("metadata"), "val": metadata_to_scval(profile.get("metadata"))}, + ] + } + +print(json.dumps([country_data_to_scval(profile) for profile in profiles], separators=(",", ":"))) +PY +} + +ensure_identity_registered() { + local contract_addr=$1 + local account=$2 + local identity=$3 + local profiles_json=$4 + local profiles_scval_json + local attempts=${STELLAR_INVOKE_RETRIES:-4} + local delay=${STELLAR_INVOKE_RETRY_DELAY_SECONDS:-3} + local attempt output status + + profiles_scval_json=$(country_profiles_to_scval_json "$profiles_json") + + if identity_matches "$contract_addr" "$account" "$identity"; then + echo " Identity already registered for $account." + return 0 + fi + + for attempt in $(seq 1 "$attempts"); do + if output=$(invoke "$contract_addr" add_identity \ + --account "$account" \ + --identity "$identity" \ + --initial_profiles "$profiles_scval_json" \ + --operator "$ADMIN" 2>&1); then + printf '%s\n' "$output" + return 0 + fi + status=$? + + if identity_matches "$contract_addr" "$account" "$identity"; then + echo " Identity registration confirmed after retryable failure for $account." + return 0 + fi + + if ! retryable_invoke_error "$output"; then + printf '%s\n' "$output" >&2 + return "$status" + fi + + if [ "$attempt" -eq "$attempts" ]; then + printf '%s\n' "$output" >&2 + return "$status" + fi + + echo " Retrying identity registration after transient Stellar CLI failure..." >&2 + sleep $((delay * attempt)) + done +} diff --git a/examples/rwa-deploy/scripts/deploy-module.sh b/examples/rwa-deploy/scripts/deploy-module.sh new file mode 100755 index 000000000..c37d35d2d --- /dev/null +++ b/examples/rwa-deploy/scripts/deploy-module.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Deploy a single compliance module, apply shared pre-bind setup, then bind + wire. +# Usage: ./deploy-module.sh [hook1 hook2 ...] +# Example: ./deploy-module.sh country-allow CanTransfer CanCreate +# +# This script handles the correct ordering: +# 1. Deploy the module with bootstrap admin +# 2. Apply shared pre-bind setup (IRS on identity-aware modules only) +# 3. Set compliance address (hands off to compliance) +# 4. Register on hooks (optional) +# +# This helper intentionally does not apply module-specific business config +# (allowlists, limits, lockup periods, etc.). Use `deploy.sh` for the full +# stack configuration flow. +# +# Prerequisites: deploy.sh must have been run (needs addresses file for infra). +set -euo pipefail + +if [ $# -lt 1 ]; then + echo "Usage: $0 [hook1 hook2 ...]" + echo "" + echo "Available modules: country-allow, country-restrict, initial-lockup-period," + echo " max-balance, supply-limit, time-transfers-limits, transfer-restrict" + echo "" + echo "Available hooks: CanTransfer, CanCreate, Transferred, Created, Destroyed" + exit 1 +fi + +MODULE="$1"; shift +HOOKS=("$@") + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +WASM_DIR="$ROOT_DIR/examples/rwa-deploy/wasm" +ADDR_FILE="$ROOT_DIR/examples/rwa-deploy/testnet-addresses.json" + +SOURCE="${STELLAR_SOURCE:-alice}" +NETWORK="${STELLAR_NETWORK:-testnet}" + +. "$SCRIPT_DIR/common.sh" + +WASM_NAME="rwa_${MODULE//-/_}.wasm" +WASM_PATH="$WASM_DIR/$WASM_NAME" + +if [ ! -f "$WASM_PATH" ]; then + echo "ERROR: $WASM_NAME not found. Run build.sh or build-module.sh first." >&2 + exit 1 +fi + +if [ ! -f "$ADDR_FILE" ]; then + echo "ERROR: testnet-addresses.json not found. Run deploy.sh first." >&2 + exit 1 +fi + +ADMIN=$(read_addr "['admin']") +TOKEN=$(read_addr "['contracts']['token']") +IRS=$(read_addr "['contracts']['irs']") +COMPLIANCE=$(read_addr "['contracts']['compliance']") + +require_contract_id "token" "$TOKEN" +require_contract_id "irs" "$IRS" +require_contract_id "compliance" "$COMPLIANCE" + +# ── Step 1: Deploy ── +echo "=== Deploying $MODULE ===" +MODULE_ADDR=$(stellar contract deploy \ + --wasm "$WASM_PATH" \ + --source "$SOURCE" --network "$NETWORK" \ + -- --admin "$ADMIN") +require_contract_id "$MODULE" "$MODULE_ADDR" +echo " Address: $MODULE_ADDR" + +# ── Step 2: Configure (before compliance bind) ── +IRS_MODULES=("country-allow" "country-restrict" "max-balance" "time-transfers-limits") +for irs_mod in "${IRS_MODULES[@]}"; do + if [ "$MODULE" = "$irs_mod" ]; then + echo " Setting IRS..." + invoke "$MODULE_ADDR" set_identity_registry_storage --token "$TOKEN" --irs "$IRS" + break + fi +done + +# ── Step 3: Set compliance address (hands off to compliance) ── +echo " Binding to compliance..." +invoke "$MODULE_ADDR" set_compliance_address --compliance "$COMPLIANCE" + +# ── Step 4: Register on hooks ── +for HOOK in "${HOOKS[@]}"; do + ensure_hook_registration "$HOOK" "$MODULE_ADDR" "$MODULE" +done + +echo "" +echo "=== $MODULE deployed and bound ===" +echo "Address: $MODULE_ADDR" diff --git a/examples/rwa-deploy/scripts/deploy.sh b/examples/rwa-deploy/scripts/deploy.sh new file mode 100755 index 000000000..7e460d401 --- /dev/null +++ b/examples/rwa-deploy/scripts/deploy.sh @@ -0,0 +1,249 @@ +#!/usr/bin/env bash +# Master deploy script: deploys ALL contracts, configures every module, then locks. +# +# CRITICAL ordering: module constructors store a bootstrap admin. Before +# `set_compliance_address`, privileged module actions require that admin; after +# the bind step they require the Compliance contract's auth instead. Since the +# CLI can't authorize as the Compliance contract, ALL configuration must happen +# BEFORE calling `set_compliance_address`. +# +# Flow: +# 1. Deploy infrastructure (IRS, Verifier, Compliance, Token) +# 2. Bind token to compliance + IRS +# 3. Deploy all 7 compliance modules with bootstrap admin +# 4. Configure every module (IRS, rules, limits, allowlists) +# 5. Set compliance address on all modules (transfers control to compliance) +# +# After this: run wire.sh, then test scripts. +# +# Prerequisites: build.sh must have been run first. +# Env vars: +# STELLAR_SOURCE - signing key alias (default: alice) +# STELLAR_NETWORK - network passphrase (default: testnet) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +WASM_DIR="$ROOT_DIR/examples/rwa-deploy/wasm" +ADDR_FILE="$ROOT_DIR/examples/rwa-deploy/testnet-addresses.json" + +SOURCE="${STELLAR_SOURCE:-alice}" +NETWORK="${STELLAR_NETWORK:-testnet}" + +. "$SCRIPT_DIR/common.sh" + +if [ ! -d "$WASM_DIR" ] || [ -z "$(ls -A "$WASM_DIR" 2>/dev/null)" ]; then + echo "ERROR: No WASMs found. Run build.sh first." >&2 + exit 1 +fi + +echo "=== Deploying RWA Stack ===" +echo "Source: $SOURCE | Network: $NETWORK" +echo "" + +ADMIN=$(stellar keys address "$SOURCE") + +deploy_contract() { + local LABEL=$1; shift + local attempts=${STELLAR_DEPLOY_RETRIES:-4} + local delay=${STELLAR_DEPLOY_RETRY_DELAY_SECONDS:-3} + local attempt output status addr + + echo "--- Deploying $LABEL ---" >&2 + + for attempt in $(seq 1 "$attempts"); do + if output=$(stellar contract deploy "$@" 2>&1); then + printf '%s\n' "$output" >&2 + addr=$(printf '%s\n' "$output" | awk 'NF { line = $0 } END { print line }') + + if require_contract_id "$LABEL" "$addr"; then + echo " $LABEL: $addr" >&2 + echo "$addr" + return 0 + fi + + output="${output} +ERROR: deploy returned an empty or invalid contract id for $LABEL" + status=1 + else + status=$? + fi + + if ! retryable_invoke_error "$output"; then + printf '%s\n' "$output" >&2 + return "$status" + fi + + if [ "$attempt" -eq "$attempts" ]; then + printf '%s\n' "$output" >&2 + return "$status" + fi + + echo "Retrying $LABEL deploy after transient Stellar CLI failure..." >&2 + sleep $((delay * attempt)) + done +} + +write_addresses() { + cat > "$ADDR_FILE" <&2 + return 1 + fi + + deploy_contract "$NAME" \ + --wasm "$WASM_DIR/$WASM_NAME" \ + --source "$SOURCE" --network "$NETWORK" \ + -- --admin "$ADMIN" +} + +COUNTRY_ALLOW=$(deploy_module country-allow) +COUNTRY_RESTRICT=$(deploy_module country-restrict) +INITIAL_LOCKUP=$(deploy_module initial-lockup-period) +MAX_BALANCE=$(deploy_module max-balance) +SUPPLY_LIMIT=$(deploy_module supply-limit) +TIME_TRANSFERS=$(deploy_module time-transfers-limits) +TRANSFER_RESTRICT=$(deploy_module transfer-restrict) + +# Persist deployed addresses early so `wire.sh` and manual recovery can resume +# if a later configuration or bind step is interrupted. +write_addresses + +# ── Step 4: Configure ALL modules (before compliance bind) ── +# +# After set_compliance_address, module admin calls require Compliance contract +# auth which is impossible from the CLI. So ALL config goes here. + +echo "" +echo "=== Step 4/5: Configuring modules (before compliance bind) ===" + +# 4a. Set IRS on identity-aware modules +echo " Setting IRS on identity-aware modules..." +invoke_with_retry "$COUNTRY_ALLOW" set_identity_registry_storage --token "$TOKEN" --irs "$IRS" +invoke_with_retry "$COUNTRY_RESTRICT" set_identity_registry_storage --token "$TOKEN" --irs "$IRS" +invoke_with_retry "$MAX_BALANCE" set_identity_registry_storage --token "$TOKEN" --irs "$IRS" +invoke_with_retry "$TIME_TRANSFERS" set_identity_registry_storage --token "$TOKEN" --irs "$IRS" + +# 4b. CountryAllow: allow US (840), GB (826), DE (276) +echo " CountryAllow: adding US, GB, DE..." +invoke_with_retry "$COUNTRY_ALLOW" add_allowed_country --token "$TOKEN" --country 840 +invoke_with_retry "$COUNTRY_ALLOW" add_allowed_country --token "$TOKEN" --country 826 +invoke_with_retry "$COUNTRY_ALLOW" add_allowed_country --token "$TOKEN" --country 276 + +# 4c. CountryRestrict: restrict North Korea (408), Iran (364) +echo " CountryRestrict: blocking DPRK, IRN..." +invoke_with_retry "$COUNTRY_RESTRICT" add_country_restriction --token "$TOKEN" --country 408 +invoke_with_retry "$COUNTRY_RESTRICT" add_country_restriction --token "$TOKEN" --country 364 + +# 4d. MaxBalance: set limit of 1,000,000 tokens +echo " MaxBalance: setting limit 1000000..." +invoke_with_retry "$MAX_BALANCE" set_max_balance --token "$TOKEN" --max 1000000 + +# 4e. SupplyLimit: set total supply limit of 10,000,000 +echo " SupplyLimit: setting limit 10000000..." +invoke_with_retry "$SUPPLY_LIMIT" set_supply_limit --token "$TOKEN" --limit 10000000 + +# 4f. TimeTransfersLimits: set daily limit of 100,000 +echo " TimeTransfersLimits: setting daily limit 100000..." +invoke_with_retry "$TIME_TRANSFERS" set_time_transfer_limit \ + --token "$TOKEN" \ + --limit '{"limit_time":86400,"limit_value":"100000"}' + +# 4g. InitialLockupPeriod: set lockup to 300 seconds (5 min for testing) +echo " InitialLockupPeriod: setting lockup 300s..." +invoke_with_retry "$INITIAL_LOCKUP" set_lockup_period --token "$TOKEN" --lockup_seconds 300 + +# 4h. TransferRestrict: allow the admin address +echo " TransferRestrict: allowing admin..." +invoke_with_retry "$TRANSFER_RESTRICT" allow_user --token "$TOKEN" --user "$ADMIN" + +echo " All modules configured." + +# ── Step 5: Set compliance address on ALL modules (hands off to compliance) ── + +echo "" +echo "=== Step 5/5: Locking all modules to compliance ===" +for MODULE_ADDR in "$COUNTRY_ALLOW" "$COUNTRY_RESTRICT" "$INITIAL_LOCKUP" \ + "$MAX_BALANCE" "$SUPPLY_LIMIT" "$TIME_TRANSFERS" "$TRANSFER_RESTRICT"; do + invoke_with_retry "$MODULE_ADDR" set_compliance_address --compliance "$COMPLIANCE" +done +echo " All 7 modules bound. Admin functions now require Compliance contract auth." + +# ── Save addresses ── + +write_addresses + +echo "" +echo "=== Deployment Complete ===" +echo " Infrastructure: IRS, Verifier, Compliance, Token" +echo " Modules: 7/7 deployed and configured" +echo " Status: All modules bound to Compliance" +echo " Addresses: $ADDR_FILE" +echo "" +echo "Next steps:" +echo " 1. ./wire.sh — Register modules on compliance hooks" +echo " 2. ./test-happy-path.sh — Mint tokens and verify compliance" diff --git a/examples/rwa-deploy/scripts/e2e.sh b/examples/rwa-deploy/scripts/e2e.sh new file mode 100755 index 000000000..c18a6a191 --- /dev/null +++ b/examples/rwa-deploy/scripts/e2e.sh @@ -0,0 +1,353 @@ +#!/usr/bin/env bash +# End-to-end: build -> deploy -> wire -> test. +# +# This is the single script that does everything in the correct order: +# Phase 1: Build all 11 WASMs (7 modules + 4 infra) +# Phase 2: Deploy infra + all 7 modules (with ALL config before compliance lock) +# Phase 3: Wire all 7 modules to compliance hooks +# Phase 4: Register investor identity in IRS +# Phase 5: Mint tokens and verify balance (happy path) +# +# Usage: ./e2e.sh [--skip-build] +# Env vars: +# STELLAR_SOURCE - signing key alias (default: alice) +# STELLAR_NETWORK - network passphrase (default: testnet) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +ADDR_FILE="$ROOT_DIR/examples/rwa-deploy/testnet-addresses.json" + +SOURCE="${STELLAR_SOURCE:-alice}" +NETWORK="${STELLAR_NETWORK:-testnet}" + +. "$SCRIPT_DIR/common.sh" + +SKIP_BUILD=false +if [ "${1:-}" = "--skip-build" ]; then + SKIP_BUILD=true +fi + +PASS=0 +FAIL=0 + +phase_header() { + echo "╔══════════════════════════════════════════╗" + printf "║ %-40s ║\n" "$1" + echo "╚══════════════════════════════════════════╝" + echo "" +} + +test_header() { + echo "--- $1 ---" +} + +extract_first_number() { + grep -oE '"[0-9]+"' | head -1 | tr -d '"' || \ + grep -oE '[0-9]+' | head -1 || echo "0" +} + +load_addresses() { + ADMIN=$(read_addr "['admin']") + TOKEN=$(read_addr "['contracts']['token']") + IRS=$(read_addr "['contracts']['irs']") + COUNTRY_ALLOW=$(read_addr "['modules']['country_allow']") + COUNTRY_RESTRICT=$(read_addr "['modules']['country_restrict']") + INITIAL_LOCKUP=$(read_addr "['modules']['initial_lockup_period']") + MAX_BALANCE=$(read_addr "['modules']['max_balance']") + SUPPLY_LIMIT=$(read_addr "['modules']['supply_limit']") + TIME_TRANSFERS=$(read_addr "['modules']['time_transfers_limits']") + TRANSFER_RESTRICT=$(read_addr "['modules']['transfer_restrict']") + + require_contract_id "token" "$TOKEN" + require_contract_id "irs" "$IRS" + require_contract_id "country_allow" "$COUNTRY_ALLOW" + require_contract_id "country_restrict" "$COUNTRY_RESTRICT" + require_contract_id "initial_lockup_period" "$INITIAL_LOCKUP" + require_contract_id "max_balance" "$MAX_BALANCE" + require_contract_id "supply_limit" "$SUPPLY_LIMIT" + require_contract_id "time_transfers_limits" "$TIME_TRANSFERS" + require_contract_id "transfer_restrict" "$TRANSFER_RESTRICT" +} + +register_test_identity() { + local alias_name=$1 + local country_code=$2 + local description=$3 + local address + + if [ "$alias_name" = "$SOURCE" ]; then + address="$ADMIN" + else + stellar keys generate "$alias_name" 2>/dev/null || true + address=$(stellar keys address "$alias_name") + fi + + echo "Registering $description ($address)..." + ensure_identity_registered \ + "$IRS" \ + "$address" \ + "$address" \ + "[{\"country\":{\"Individual\":{\"Citizenship\":$country_code}},\"metadata\":null}]" + echo " Identity registered." + REGISTERED_IDENTITY_ADDRESS=$address +} + +# ═══════════════════════════════════════════════════════ +# Phase 1: Build +# ═══════════════════════════════════════════════════════ +if [ "$SKIP_BUILD" = false ]; then + phase_header "Phase 1/5: Building all WASMs" + bash "$SCRIPT_DIR/build.sh" + echo "" +else + echo "Phase 1/5: Build SKIPPED (--skip-build)" + echo "" +fi + +# ═══════════════════════════════════════════════════════ +# Phase 2: Deploy (includes ALL module configuration) +# ═══════════════════════════════════════════════════════ +phase_header "Phase 2/5: Deploying full stack" +bash "$SCRIPT_DIR/deploy.sh" +echo "" + +# ═══════════════════════════════════════════════════════ +# Phase 3: Wire modules to hooks +# ═══════════════════════════════════════════════════════ +phase_header "Phase 3/5: Wiring modules to hooks" +bash "$SCRIPT_DIR/wire.sh" +echo "" + +# ═══════════════════════════════════════════════════════ +# Phase 4: Register investor identity +# ═══════════════════════════════════════════════════════ +phase_header "Phase 4/5: Registering investor identity" + +load_addresses + +register_test_identity "$SOURCE" 840 "investor" +INVESTOR=$REGISTERED_IDENTITY_ADDRESS + +# Generate test-only keypairs for country enforcement tests (no funding needed). +register_test_identity "e2e-investor-2" 392 "investor-2" +INVESTOR2=$REGISTERED_IDENTITY_ADDRESS +register_test_identity "e2e-investor-3" 408 "investor-3" +INVESTOR3=$REGISTERED_IDENTITY_ADDRESS +echo "" + +# ═══════════════════════════════════════════════════════ +# Phase 5: Comprehensive compliance tests +# ═══════════════════════════════════════════════════════ +phase_header "Phase 5/5: Compliance module tests" + +assert_pass() { + local DESC=$1; shift + local OUTPUT + echo " [$DESC]" + if OUTPUT=$("$@" 2>&1); then + echo " PASS" + PASS=$((PASS + 1)) + elif retryable_invoke_error "$OUTPUT"; then + echo " FAIL (transient Stellar CLI error)" + FAIL=$((FAIL + 1)) + else + echo " FAIL (expected success)" + FAIL=$((FAIL + 1)) + fi +} + +assert_fail() { + local DESC=$1; shift + echo " [$DESC]" + local OUTPUT + if OUTPUT=$("$@" 2>&1); then + echo " FAIL (expected rejection but succeeded)" + FAIL=$((FAIL + 1)) + elif retryable_invoke_error "$OUTPUT"; then + echo " FAIL (transient Stellar CLI error)" + FAIL=$((FAIL + 1)) + else + echo " PASS (correctly rejected)" + PASS=$((PASS + 1)) + fi +} + +assert_eq() { + local DESC=$1 EXPECTED=$2 ACTUAL=$3 + echo " [$DESC]" + if [ "$ACTUAL" = "$EXPECTED" ]; then + echo " PASS ($ACTUAL)" + PASS=$((PASS + 1)) + else + echo " FAIL (expected $EXPECTED, got $ACTUAL)" + FAIL=$((FAIL + 1)) + fi +} + +get_balance() { + local OUT + OUT=$(invoke_readonly "$TOKEN" balance --account "$1" 2>&1) + echo "$OUT" | extract_first_number +} + +get_internal_supply() { + local OUT + OUT=$(invoke_readonly "$SUPPLY_LIMIT" get_internal_supply --token "$TOKEN" 2>&1) + echo "$OUT" | extract_first_number +} + +run_auth_handoff_suite() { + # deploy.sh already proves the pre-bind bootstrap-admin path because all + # module configuration succeeds before calling `set_compliance_address`. + # These checks prove the post-bind handoff with real testnet transactions by + # asserting that the same externally owned admin can no longer call + # privileged module config. + local description contract method + local arg1 arg2 arg3 arg4 arg5 arg6 + local args + + test_header "Auth handoff: admin config blocked after compliance bind" + while IFS='|' read -r description contract method arg1 arg2 arg3 arg4 arg5 arg6; do + [ -n "$description" ] || continue + + args=() + for arg in "$arg1" "$arg2" "$arg3" "$arg4" "$arg5" "$arg6"; do + if [ -n "$arg" ]; then + args+=("$arg") + fi + done + + assert_fail "$description" invoke "$contract" "$method" "${args[@]}" + done <= amount. + # All 1000 tokens are locked so burn should be rejected. + test_header "Test 4: Lockup blocks burn of locked tokens" + assert_fail "burn 500 during lockup" invoke "$TOKEN" burn \ + --user_address "$INVESTOR" --amount 500 --operator "$ADMIN" + + BAL=$(get_balance "$INVESTOR") + assert_eq "balance unchanged = 1000" "1000" "$BAL" + echo "" +} + +run_balance_tests() { + # Supply is 1000 from Test 1. Mint 1000 more -> internal supply = 2000. + test_header "Test 5: Mint more (supply counter tracks)" + assert_pass "mint 1000 more" invoke_with_retry "$TOKEN" mint --to "$INVESTOR" --amount 1000 --operator "$ADMIN" + + BAL=$(get_balance "$INVESTOR") + assert_eq "balance = 2000" "2000" "$BAL" + + SUPPLY=$(get_internal_supply) + assert_eq "internal supply = 2000" "2000" "$SUPPLY" + echo "" + + # MaxBalance is 1,000,000 per identity. Investor has 2000 already. + # Mint 998,000 more to hit the identity cap exactly. + test_header "Test 6: Mint to max-balance ceiling" + assert_pass "mint 998000 (fill to 1M)" invoke_with_retry "$TOKEN" mint --to "$INVESTOR" --amount 998000 --operator "$ADMIN" + + BAL=$(get_balance "$INVESTOR") + assert_eq "balance = 1000000" "1000000" "$BAL" + + SUPPLY=$(get_internal_supply) + assert_eq "internal supply = 1000000" "1000000" "$SUPPLY" + + assert_fail "mint 1 more (over max-balance)" invoke "$TOKEN" mint --to "$INVESTOR" --amount 1 --operator "$ADMIN" + echo "" +} + +run_country_tests() { + # Japan (392) is NOT in the allowed list (US/GB/DE = 840/826/276). + # Mint to investor-2 should be rejected by CountryAllowModule.can_create. + test_header "Test 7: CountryAllow blocks mint to non-allowed country" + assert_fail "mint to Japan investor (392 not allowed)" invoke "$TOKEN" mint --to "$INVESTOR2" --amount 100 --operator "$ADMIN" + echo "" + + # DPRK (408) IS on the restricted list. + # Mint to investor-3 should be rejected by CountryRestrictModule.can_create. + test_header "Test 8: CountryRestrict blocks mint to restricted country" + assert_fail "mint to DPRK investor (408 restricted)" invoke "$TOKEN" mint --to "$INVESTOR3" --amount 100 --operator "$ADMIN" + echo "" +} + +run_auth_handoff_suite +run_supply_limit_tests +run_lockup_tests +run_balance_tests +run_country_tests + +# ═══════════════════════════════════════════════════════ +# Summary +# ═══════════════════════════════════════════════════════ +TOTAL=$((PASS + FAIL)) +echo "╔════════════════════════════════════════════════════╗" +echo "║ E2E RESULTS ║" +echo "╠════════════════════════════════════════════════════╣" +echo "║ Build: 11 WASMs compiled ║" +echo "║ Deploy: 4 infra + 7 modules configured ║" +echo "║ Wire: 7 modules on 19 hooks (4 verified) ║" +echo "║ Identity: 3 investors registered ║" +echo "╠════════════════════════════════════════════════════╣" +echo "║ Module tests: ║" +echo "║ - Auth handoff: admin blocked after bind ║" +echo "║ - Supply limit: mint, over-mint rejection ║" +echo "║ - Max balance: identity cap enforcement ║" +echo "║ - Initial lockup: transfer + burn blocked ║" +echo "║ - Country allow: non-allowed country rejected ║" +echo "║ - Country restrict: restricted country rejected ║" +echo "║ - Internal state: supply counter across mints ║" +echo "╠════════════════════════════════════════════════════╣" +printf "║ Tests: %d passed, %d failed (of %d) ║\n" "$PASS" "$FAIL" "$TOTAL" +echo "╚════════════════════════════════════════════════════╝" +echo "" +echo "Addresses: $ADDR_FILE" + +if [ $FAIL -gt 0 ]; then + exit 1 +fi diff --git a/examples/rwa-deploy/scripts/test-happy-path.sh b/examples/rwa-deploy/scripts/test-happy-path.sh new file mode 100755 index 000000000..60f897e4c --- /dev/null +++ b/examples/rwa-deploy/scripts/test-happy-path.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Test happy path: register investor identity, mint tokens, verify balance. +# +# Prerequisites: +# - deploy.sh has been run (modules configured + bound) +# - wire.sh has been run (modules registered on hooks) +# +# This script does NOT try to configure modules (add_allowed_country, etc.) +# because those functions require compliance auth after deploy.sh +# binds each module to the Compliance contract. All module config happens in deploy.sh. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +ADDR_FILE="$ROOT_DIR/examples/rwa-deploy/testnet-addresses.json" + +SOURCE="${STELLAR_SOURCE:-alice}" +NETWORK="${STELLAR_NETWORK:-testnet}" + +. "$SCRIPT_DIR/common.sh" + +if [ ! -f "$ADDR_FILE" ]; then + echo "ERROR: testnet-addresses.json not found. Run deploy.sh first." >&2 + exit 1 +fi + +ADMIN=$(read_addr "['admin']") +TOKEN=$(read_addr "['contracts']['token']") +IRS=$(read_addr "['contracts']['irs']") + +require_contract_id "token" "$TOKEN" +require_contract_id "irs" "$IRS" + +INVESTOR="$ADMIN" + +echo "=== Happy Path Test ===" +echo "Token: $TOKEN" +echo "Investor: $INVESTOR" +echo "" + +# Step 1: Register investor identity in IRS (IRS uses its own admin auth, not compliance) +echo "1. Registering investor identity..." +ensure_identity_registered \ + "$IRS" \ + "$INVESTOR" \ + "$INVESTOR" \ + '[{"country":{"Individual":{"Citizenship":840}},"metadata":null}]' + +# Step 2: Mint tokens (this triggers compliance hooks: CanCreate -> Created) +echo "" +echo "2. Minting 1000 tokens to investor..." +invoke_with_retry "$TOKEN" mint --to "$INVESTOR" --amount 1000 --operator "$ADMIN" + +# Step 3: Check balance +echo "" +echo "3. Checking balance..." +BALANCE_OUTPUT=$(invoke_readonly "$TOKEN" balance --account "$INVESTOR" 2>&1) +BALANCE=$(echo "$BALANCE_OUTPUT" | grep -oE '[0-9]+' | head -1) + +echo "" +echo "=== Result ===" +echo "Balance: $BALANCE" +if [ -n "$BALANCE" ] && [ "$BALANCE" -ge 1000 ] 2>/dev/null; then + echo "PASS: Happy path succeeded!" +else + echo "FAIL: Unexpected balance: '$BALANCE_OUTPUT'" + exit 1 +fi diff --git a/examples/rwa-deploy/scripts/wire.sh b/examples/rwa-deploy/scripts/wire.sh new file mode 100755 index 000000000..04ce9df51 --- /dev/null +++ b/examples/rwa-deploy/scripts/wire.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Wire all 7 compliance modules to their required hooks. +# Reads addresses from deploy/testnet-addresses.json (written by deploy.sh). +# +# Hook registrations per module: +# - CountryAllow: CanTransfer, CanCreate +# - CountryRestrict: CanTransfer, CanCreate +# - MaxBalance: CanTransfer, CanCreate, Transferred, Created, Destroyed +# - TransferRestrict: CanTransfer +# - TimeTransfersLimits: CanTransfer, Transferred +# - SupplyLimit: CanCreate, Created, Destroyed +# - InitialLockupPeriod: CanTransfer, Created, Transferred, Destroyed +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +ADDR_FILE="$ROOT_DIR/examples/rwa-deploy/testnet-addresses.json" + +SOURCE="${STELLAR_SOURCE:-alice}" +NETWORK="${STELLAR_NETWORK:-testnet}" + +if [ ! -f "$ADDR_FILE" ]; then + echo "ERROR: testnet-addresses.json not found. Run deploy.sh first." >&2 + exit 1 +fi + +. "$SCRIPT_DIR/common.sh" + +ADMIN=$(read_addr "['admin']") +COMPLIANCE=$(read_addr "['contracts']['compliance']") + +COUNTRY_ALLOW=$(read_addr "['modules']['country_allow']") +COUNTRY_RESTRICT=$(read_addr "['modules']['country_restrict']") +MAX_BALANCE=$(read_addr "['modules']['max_balance']") +TRANSFER_RESTRICT=$(read_addr "['modules']['transfer_restrict']") +TIME_TRANSFERS=$(read_addr "['modules']['time_transfers_limits']") +SUPPLY_LIMIT=$(read_addr "['modules']['supply_limit']") +INITIAL_LOCKUP=$(read_addr "['modules']['initial_lockup_period']") + +require_contract_id "compliance" "$COMPLIANCE" +require_contract_id "country_allow" "$COUNTRY_ALLOW" +require_contract_id "country_restrict" "$COUNTRY_RESTRICT" +require_contract_id "max_balance" "$MAX_BALANCE" +require_contract_id "transfer_restrict" "$TRANSFER_RESTRICT" +require_contract_id "time_transfers_limits" "$TIME_TRANSFERS" +require_contract_id "supply_limit" "$SUPPLY_LIMIT" +require_contract_id "initial_lockup_period" "$INITIAL_LOCKUP" + +echo "=== Wiring Modules to Compliance Hooks ===" +echo "" + +ensure_hook_registration "CanTransfer" "$COUNTRY_ALLOW" "CountryAllowModule" +ensure_hook_registration "CanCreate" "$COUNTRY_ALLOW" "CountryAllowModule" + +ensure_hook_registration "CanTransfer" "$COUNTRY_RESTRICT" "CountryRestrictModule" +ensure_hook_registration "CanCreate" "$COUNTRY_RESTRICT" "CountryRestrictModule" + +ensure_hook_registration "CanTransfer" "$MAX_BALANCE" "MaxBalanceModule" +ensure_hook_registration "CanCreate" "$MAX_BALANCE" "MaxBalanceModule" +ensure_hook_registration "Transferred" "$MAX_BALANCE" "MaxBalanceModule" +ensure_hook_registration "Created" "$MAX_BALANCE" "MaxBalanceModule" +ensure_hook_registration "Destroyed" "$MAX_BALANCE" "MaxBalanceModule" + +ensure_hook_registration "CanTransfer" "$TRANSFER_RESTRICT" "TransferRestrictModule" + +ensure_hook_registration "CanTransfer" "$TIME_TRANSFERS" "TimeTransfersLimitsModule" +ensure_hook_registration "Transferred" "$TIME_TRANSFERS" "TimeTransfersLimitsModule" + +ensure_hook_registration "CanCreate" "$SUPPLY_LIMIT" "SupplyLimitModule" +ensure_hook_registration "Created" "$SUPPLY_LIMIT" "SupplyLimitModule" +ensure_hook_registration "Destroyed" "$SUPPLY_LIMIT" "SupplyLimitModule" + +ensure_hook_registration "CanTransfer" "$INITIAL_LOCKUP" "InitialLockupPeriodModule" +ensure_hook_registration "Created" "$INITIAL_LOCKUP" "InitialLockupPeriodModule" +ensure_hook_registration "Transferred" "$INITIAL_LOCKUP" "InitialLockupPeriodModule" +ensure_hook_registration "Destroyed" "$INITIAL_LOCKUP" "InitialLockupPeriodModule" + +echo "" +echo "=== Verifying hook wiring for stateful modules ===" +echo "" + +verify_hook_wiring_with_retry "$SUPPLY_LIMIT" "SupplyLimitModule" +verify_hook_wiring_with_retry "$INITIAL_LOCKUP" "InitialLockupPeriodModule" +verify_hook_wiring_with_retry "$MAX_BALANCE" "MaxBalanceModule" +verify_hook_wiring_with_retry "$TIME_TRANSFERS" "TimeTransfersLimitsModule" + +echo "" +echo "=== Wiring Complete (19 hooks registered, 4 modules verified) ===" diff --git a/examples/rwa-deploy/token/Cargo.toml b/examples/rwa-deploy/token/Cargo.toml new file mode 100644 index 000000000..a5b498234 --- /dev/null +++ b/examples/rwa-deploy/token/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "deploy-token" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true + +[lib] +crate-type = ["cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-access = { workspace = true } +stellar-contract-utils = { workspace = true } +stellar-macros = { workspace = true } +stellar-tokens = { workspace = true } diff --git a/examples/rwa-deploy/token/src/lib.rs b/examples/rwa-deploy/token/src/lib.rs new file mode 100644 index 000000000..92013b5f5 --- /dev/null +++ b/examples/rwa-deploy/token/src/lib.rs @@ -0,0 +1,137 @@ +#![no_std] + +use soroban_sdk::{ + contract, contractimpl, symbol_short, Address, Env, MuxedAddress, String, Symbol, Vec, +}; +use stellar_access::access_control::{self as access_control, AccessControl}; +use stellar_contract_utils::pausable::{self as pausable, Pausable}; +use stellar_macros::only_role; +use stellar_tokens::{ + fungible::{Base, FungibleToken}, + rwa::{storage::RWAStorageKey, RWAToken, RWA}, +}; + +#[contract] +pub struct RWATokenContract; + +#[contractimpl] +impl RWATokenContract { + pub fn __constructor( + e: &Env, + name: String, + symbol: String, + admin: Address, + compliance: Address, + identity_verifier: Address, + ) { + Base::set_metadata(e, 18, name, symbol); + access_control::set_admin(e, &admin); + access_control::grant_role_no_auth(e, &admin, &symbol_short!("admin"), &admin); + RWA::set_compliance(e, &compliance); + RWA::set_identity_verifier(e, &identity_verifier); + e.storage().instance().set(&RWAStorageKey::Version, &String::from_str(e, "1.0.0")); + RWA::set_onchain_id(e, &e.current_contract_address()); + } +} + +#[contractimpl(contracttrait)] +impl FungibleToken for RWATokenContract { + type ContractType = RWA; +} + +#[contractimpl] +impl RWAToken for RWATokenContract { + #[only_role(operator, "admin")] + fn forced_transfer(e: &Env, from: Address, to: Address, amount: i128, operator: Address) { + RWA::forced_transfer(e, &from, &to, amount); + } + + #[only_role(operator, "admin")] + fn mint(e: &Env, to: Address, amount: i128, operator: Address) { + RWA::mint(e, &to, amount); + } + + #[only_role(operator, "admin")] + fn burn(e: &Env, user_address: Address, amount: i128, operator: Address) { + RWA::burn(e, &user_address, amount); + } + + #[only_role(operator, "admin")] + fn recover_balance( + e: &Env, + old_account: Address, + new_account: Address, + operator: Address, + ) -> bool { + RWA::recover_balance(e, &old_account, &new_account) + } + + #[only_role(operator, "admin")] + fn set_address_frozen(e: &Env, user_address: Address, freeze: bool, operator: Address) { + RWA::set_address_frozen(e, &user_address, freeze); + } + + #[only_role(operator, "admin")] + fn freeze_partial_tokens(e: &Env, user_address: Address, amount: i128, operator: Address) { + RWA::freeze_partial_tokens(e, &user_address, amount); + } + + #[only_role(operator, "admin")] + fn unfreeze_partial_tokens(e: &Env, user_address: Address, amount: i128, operator: Address) { + RWA::unfreeze_partial_tokens(e, &user_address, amount); + } + + fn is_frozen(e: &Env, user_address: Address) -> bool { + RWA::is_frozen(e, &user_address) + } + + fn get_frozen_tokens(e: &Env, user_address: Address) -> i128 { + RWA::get_frozen_tokens(e, &user_address) + } + + fn version(e: &Env) -> String { + RWA::version(e) + } + + fn onchain_id(e: &Env) -> Address { + RWA::onchain_id(e) + } + + #[only_role(operator, "admin")] + fn set_compliance(e: &Env, compliance: Address, operator: Address) { + RWA::set_compliance(e, &compliance); + } + + fn compliance(e: &Env) -> Address { + RWA::compliance(e) + } + + #[only_role(operator, "admin")] + fn set_identity_verifier(e: &Env, identity_verifier: Address, operator: Address) { + RWA::set_identity_verifier(e, &identity_verifier); + } + + fn identity_verifier(e: &Env) -> Address { + RWA::identity_verifier(e) + } +} + +#[contractimpl] +impl Pausable for RWATokenContract { + fn paused(e: &Env) -> bool { + pausable::paused(e) + } + + #[only_role(caller, "admin")] + fn pause(e: &Env, caller: Address) { + pausable::pause(e); + } + + #[only_role(caller, "admin")] + fn unpause(e: &Env, caller: Address) { + pausable::unpause(e); + } +} + +#[contractimpl(contracttrait)] +impl AccessControl for RWATokenContract {} diff --git a/examples/rwa-deploy/verifier/Cargo.toml b/examples/rwa-deploy/verifier/Cargo.toml new file mode 100644 index 000000000..82cf29365 --- /dev/null +++ b/examples/rwa-deploy/verifier/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "deploy-verifier" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true + +[lib] +crate-type = ["cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-access = { workspace = true } +stellar-macros = { workspace = true } +stellar-tokens = { workspace = true } diff --git a/examples/rwa-deploy/verifier/src/lib.rs b/examples/rwa-deploy/verifier/src/lib.rs new file mode 100644 index 000000000..c8ba550ac --- /dev/null +++ b/examples/rwa-deploy/verifier/src/lib.rs @@ -0,0 +1,70 @@ +#![no_std] + +use soroban_sdk::{ + contract, contractimpl, contracttype, panic_with_error, symbol_short, Address, Env, Symbol, Vec, +}; +use stellar_access::access_control::{self as access_control, AccessControl}; +use stellar_macros::only_role; +use stellar_tokens::rwa::{ + emit_claim_topics_and_issuers_set, identity_registry_storage::IdentityRegistryStorageClient, + identity_verifier::IdentityVerifier, RWAError, +}; + +#[contracttype] +#[derive(Clone)] +enum DataKey { + Irs, + ClaimTopicsAndIssuers, +} + +#[contract] +pub struct SimpleIdentityVerifier; + +fn identity_registry_storage(e: &Env) -> Address { + e.storage() + .instance() + .get(&DataKey::Irs) + .unwrap_or_else(|| panic_with_error!(e, RWAError::IdentityRegistryStorageNotSet)) +} + +#[contractimpl] +impl SimpleIdentityVerifier { + pub fn __constructor(e: &Env, admin: Address, irs: Address) { + access_control::set_admin(e, &admin); + access_control::grant_role_no_auth(e, &admin, &symbol_short!("admin"), &admin); + e.storage().instance().set(&DataKey::Irs, &irs); + } +} + +#[contractimpl] +impl IdentityVerifier for SimpleIdentityVerifier { + fn verify_identity(e: &Env, account: &Address) { + let irs = identity_registry_storage(e); + let client = IdentityRegistryStorageClient::new(e, &irs); + if client.try_stored_identity(account).is_err() { + panic_with_error!(e, RWAError::IdentityVerificationFailed); + } + } + + fn recovery_target(e: &Env, old_account: &Address) -> Option
{ + let irs = identity_registry_storage(e); + let client = IdentityRegistryStorageClient::new(e, &irs); + client.get_recovered_to(old_account) + } + + #[only_role(operator, "admin")] + fn set_claim_topics_and_issuers(e: &Env, claim_topics_and_issuers: Address, operator: Address) { + e.storage().instance().set(&DataKey::ClaimTopicsAndIssuers, &claim_topics_and_issuers); + emit_claim_topics_and_issuers_set(e, &claim_topics_and_issuers); + } + + fn claim_topics_and_issuers(e: &Env) -> Address { + e.storage() + .instance() + .get(&DataKey::ClaimTopicsAndIssuers) + .unwrap_or_else(|| panic_with_error!(e, RWAError::ClaimTopicsAndIssuersNotSet)) + } +} + +#[contractimpl(contracttrait)] +impl AccessControl for SimpleIdentityVerifier {} diff --git a/examples/rwa-initial-lockup-period/Cargo.toml b/examples/rwa-initial-lockup-period/Cargo.toml new file mode 100644 index 000000000..767f3cca1 --- /dev/null +++ b/examples/rwa-initial-lockup-period/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "rwa-initial-lockup-period" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true +authors.workspace = true + +[package.metadata.stellar] +cargo_inherit = true + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-tokens = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/examples/rwa-initial-lockup-period/src/contract.rs b/examples/rwa-initial-lockup-period/src/contract.rs new file mode 100644 index 000000000..330f6741a --- /dev/null +++ b/examples/rwa-initial-lockup-period/src/contract.rs @@ -0,0 +1,123 @@ +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::{ + modules::{ + initial_lockup_period::{storage as lockup, LockedTokens}, + storage::{ + get_compliance_address, module_name, set_compliance_address, ComplianceModuleStorageKey, + }, + ComplianceModule, + }, + ComplianceHook, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct InitialLockupPeriodContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl InitialLockupPeriodContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } + + pub fn set_lockup_period(e: &Env, token: Address, lockup_seconds: u64) { + require_module_admin_or_compliance_auth(e); + lockup::configure_lockup_period(e, &token, lockup_seconds); + } + + pub fn pre_set_lockup_state( + e: &Env, + token: Address, + wallet: Address, + balance: i128, + locks: Vec, + ) { + require_module_admin_or_compliance_auth(e); + lockup::pre_set_lockup_state(e, &token, &wallet, balance, &locks); + } + + pub fn get_lockup_period(e: &Env, token: Address) -> u64 { + lockup::get_lockup_period(e, &token) + } + + pub fn get_total_locked(e: &Env, token: Address, wallet: Address) -> i128 { + lockup::get_total_locked(e, &token, &wallet) + } + + pub fn get_locked_tokens(e: &Env, token: Address, wallet: Address) -> Vec { + lockup::get_locks(e, &token, &wallet) + } + + pub fn get_internal_balance(e: &Env, token: Address, wallet: Address) -> i128 { + lockup::get_internal_balance(e, &token, &wallet) + } + + pub fn required_hooks(e: &Env) -> Vec { + lockup::required_hooks(e) + } + + pub fn verify_hook_wiring(e: &Env) { + lockup::verify_hook_wiring(e); + } +} + +#[contractimpl(contracttrait)] +impl ComplianceModule for InitialLockupPeriodContract { + fn on_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + lockup::on_transfer(e, &from, &to, amount, &token); + } + + fn on_created(e: &Env, to: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + lockup::on_created(e, &to, amount, &token); + } + + fn on_destroyed(e: &Env, from: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + lockup::on_destroyed(e, &from, amount, &token); + } + + fn can_transfer(e: &Env, from: Address, _to: Address, amount: i128, token: Address) -> bool { + lockup::can_transfer(e, &from, amount, &token) + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + true + } + + fn name(e: &Env) -> String { + module_name(e, "InitialLockupPeriodModule") + } + + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-initial-lockup-period/src/lib.rs b/examples/rwa-initial-lockup-period/src/lib.rs new file mode 100644 index 000000000..a879b6f80 --- /dev/null +++ b/examples/rwa-initial-lockup-period/src/lib.rs @@ -0,0 +1,5 @@ +#![no_std] + +pub mod contract; +#[cfg(test)] +mod test; diff --git a/examples/rwa-initial-lockup-period/src/test.rs b/examples/rwa-initial-lockup-period/src/test.rs new file mode 100644 index 000000000..426f71f3f --- /dev/null +++ b/examples/rwa-initial-lockup-period/src/test.rs @@ -0,0 +1,216 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, String, Vec, +}; +use stellar_tokens::rwa::{ + compliance::{modules::initial_lockup_period::LockedTokens, Compliance, ComplianceHook}, + utils::token_binder::TokenBinder, +}; + +use crate::contract::{InitialLockupPeriodContract, InitialLockupPeriodContractClient}; + +fn create_client<'a>(e: &Env, admin: &Address) -> (Address, InitialLockupPeriodContractClient<'a>) { + let address = e.register(InitialLockupPeriodContract, (admin,)); + (address.clone(), InitialLockupPeriodContractClient::new(e, &address)) +} + +#[contract] +struct MockComplianceContract; + +#[contracttype] +#[derive(Clone)] +enum MockComplianceStorageKey { + Registered(ComplianceHook, Address), +} + +#[contractimpl] +impl Compliance for MockComplianceContract { + fn add_module_to(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("add_module_to is not used in these tests"); + } + + fn remove_module_from(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("remove_module_from is not used in these tests"); + } + + fn get_modules_for_hook(_e: &Env, _hook: ComplianceHook) -> Vec
{ + unreachable!("get_modules_for_hook is not used in these tests"); + } + + fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool { + e.storage().persistent().has(&MockComplianceStorageKey::Registered(hook, module)) + } + + fn transferred(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) { + unreachable!("transferred is not used in these tests"); + } + + fn created(_e: &Env, _to: Address, _amount: i128, _token: Address) { + unreachable!("created is not used in these tests"); + } + + fn destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) { + unreachable!("destroyed is not used in these tests"); + } + + fn can_transfer( + _e: &Env, + _from: Address, + _to: Address, + _amount: i128, + _token: Address, + ) -> bool { + unreachable!("can_transfer is not used in these tests"); + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + unreachable!("can_create is not used in these tests"); + } +} + +#[contractimpl] +impl TokenBinder for MockComplianceContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl MockComplianceContract { + pub fn register_hook(e: &Env, hook: ComplianceHook, module: Address) { + e.storage().persistent().set(&MockComplianceStorageKey::Registered(hook, module), &true); + } +} + +#[test] +fn set_and_get_lockup_state_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let wallet = Address::generate(&e); + let release_timestamp = e.ledger().timestamp().saturating_add(60); + let locks = vec![ + &e, + LockedTokens { amount: 80, release_timestamp }, + LockedTokens { amount: 10, release_timestamp: release_timestamp.saturating_add(60) }, + ]; + let (_address, client) = create_client(&e, &admin); + + client.set_lockup_period(&token, &60); + client.pre_set_lockup_state(&token, &wallet, &100, &locks); + + assert_eq!(client.get_lockup_period(&token), 60); + assert_eq!(client.get_total_locked(&token, &wallet), 90); + assert_eq!(client.get_internal_balance(&token, &wallet), 100); + + let stored_locks = client.get_locked_tokens(&token, &wallet); + assert_eq!(stored_locks.len(), 2); + + let first_lock = stored_locks.get(0).unwrap(); + assert_eq!(first_lock.amount, 80); + assert_eq!(first_lock.release_timestamp, release_timestamp); +} + +#[test] +fn name_compliance_address_and_required_hooks_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + assert_eq!(client.name(), String::from_str(&e, "InitialLockupPeriodModule")); + assert_eq!( + client.required_hooks(), + vec![ + &e, + ComplianceHook::CanTransfer, + ComplianceHook::Created, + ComplianceHook::Transferred, + ComplianceHook::Destroyed, + ] + ); + + client.set_compliance_address(&compliance); + assert_eq!(client.get_compliance_address(), compliance); +} + +#[test] +fn set_lockup_period_uses_admin_auth_before_compliance_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_lockup_period(&token, &60); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); +} + +#[test] +fn set_lockup_period_uses_compliance_auth_after_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_compliance_address(&compliance); + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); + + client.set_lockup_period(&token, &60); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &compliance); +} + +#[test] +fn verify_hook_wiring_and_can_transfer_use_public_contract_api() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let wallet = Address::generate(&e); + let recipient = Address::generate(&e); + let release_timestamp = e.ledger().timestamp().saturating_add(60); + let locks = vec![&e, LockedTokens { amount: 80, release_timestamp }]; + let (module_address, client) = create_client(&e, &admin); + let compliance_id = e.register(MockComplianceContract, ()); + let compliance = MockComplianceContractClient::new(&e, &compliance_id); + + client.set_compliance_address(&compliance_id); + for hook in [ + ComplianceHook::CanTransfer, + ComplianceHook::Created, + ComplianceHook::Transferred, + ComplianceHook::Destroyed, + ] { + compliance.register_hook(&hook, &module_address); + } + + client.verify_hook_wiring(); + client.pre_set_lockup_state(&token, &wallet, &100, &locks); + + assert!(!client.can_transfer(&wallet, &recipient, &21, &token)); + assert!(client.can_transfer(&wallet, &recipient, &20, &token)); +} diff --git a/examples/rwa-max-balance/Cargo.toml b/examples/rwa-max-balance/Cargo.toml new file mode 100644 index 000000000..066fdcb4c --- /dev/null +++ b/examples/rwa-max-balance/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "rwa-max-balance" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true +authors.workspace = true + +[package.metadata.stellar] +cargo_inherit = true + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-tokens = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/examples/rwa-max-balance/src/contract.rs b/examples/rwa-max-balance/src/contract.rs new file mode 100644 index 000000000..32cf25a75 --- /dev/null +++ b/examples/rwa-max-balance/src/contract.rs @@ -0,0 +1,125 @@ +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::{ + modules::{ + max_balance::storage as max_balance, + storage::{ + get_compliance_address, module_name, set_compliance_address, set_irs_address, + ComplianceModuleStorageKey, + }, + ComplianceModule, + }, + ComplianceHook, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct MaxBalanceContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl MaxBalanceContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } + + pub fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { + require_module_admin_or_compliance_auth(e); + set_irs_address(e, &token, &irs); + } + + pub fn set_max_balance(e: &Env, token: Address, max: i128) { + require_module_admin_or_compliance_auth(e); + max_balance::configure_max_balance(e, &token, max); + } + + pub fn pre_set_identity_balance(e: &Env, token: Address, identity: Address, balance: i128) { + require_module_admin_or_compliance_auth(e); + max_balance::pre_set_identity_balance(e, &token, &identity, balance); + } + + pub fn batch_pre_set_identity_balances( + e: &Env, + token: Address, + identities: Vec
, + balances: Vec, + ) { + require_module_admin_or_compliance_auth(e); + max_balance::batch_pre_set_identity_balances(e, &token, &identities, &balances); + } + + pub fn get_max_balance(e: &Env, token: Address) -> i128 { + max_balance::get_max_balance(e, &token) + } + + pub fn get_investor_balance(e: &Env, token: Address, identity: Address) -> i128 { + max_balance::get_id_balance(e, &token, &identity) + } + + pub fn required_hooks(e: &Env) -> Vec { + max_balance::required_hooks(e) + } + + pub fn verify_hook_wiring(e: &Env) { + max_balance::verify_hook_wiring(e); + } +} + +#[contractimpl(contracttrait)] +impl ComplianceModule for MaxBalanceContract { + fn on_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + max_balance::on_transfer(e, &from, &to, amount, &token); + } + + fn on_created(e: &Env, to: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + max_balance::on_created(e, &to, amount, &token); + } + + fn on_destroyed(e: &Env, from: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + max_balance::on_destroyed(e, &from, amount, &token); + } + + fn can_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) -> bool { + max_balance::can_transfer(e, &from, &to, amount, &token) + } + + fn can_create(e: &Env, to: Address, amount: i128, token: Address) -> bool { + max_balance::can_create(e, &to, amount, &token) + } + + fn name(e: &Env) -> String { + module_name(e, "MaxBalanceModule") + } + + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-max-balance/src/lib.rs b/examples/rwa-max-balance/src/lib.rs new file mode 100644 index 000000000..a879b6f80 --- /dev/null +++ b/examples/rwa-max-balance/src/lib.rs @@ -0,0 +1,5 @@ +#![no_std] + +pub mod contract; +#[cfg(test)] +mod test; diff --git a/examples/rwa-max-balance/src/test.rs b/examples/rwa-max-balance/src/test.rs new file mode 100644 index 000000000..3beb493c1 --- /dev/null +++ b/examples/rwa-max-balance/src/test.rs @@ -0,0 +1,303 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, String, Val, + Vec, +}; +use stellar_tokens::rwa::{ + compliance::{Compliance, ComplianceHook}, + identity_registry_storage::IdentityRegistryStorage, + utils::token_binder::TokenBinder, +}; + +use crate::contract::{MaxBalanceContract, MaxBalanceContractClient}; + +fn create_client<'a>(e: &Env, admin: &Address) -> (Address, MaxBalanceContractClient<'a>) { + let address = e.register(MaxBalanceContract, (admin,)); + (address.clone(), MaxBalanceContractClient::new(e, &address)) +} + +#[contract] +struct MockIRSContract; + +#[contracttype] +#[derive(Clone)] +enum MockIRSStorageKey { + Identity(Address), +} + +#[contractimpl] +impl TokenBinder for MockIRSContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl IdentityRegistryStorage for MockIRSContract { + fn add_identity( + _e: &Env, + _account: Address, + _identity: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_identity is not used in these tests"); + } + + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { + unreachable!("remove_identity is not used in these tests"); + } + + fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { + unreachable!("modify_identity is not used in these tests"); + } + + fn recover_identity( + _e: &Env, + _old_account: Address, + _new_account: Address, + _operator: Address, + ) { + unreachable!("recover_identity is not used in these tests"); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + e.storage() + .persistent() + .get(&MockIRSStorageKey::Identity(account.clone())) + .unwrap_or(account) + } +} + +#[contractimpl] +impl MockIRSContract { + pub fn set_identity(e: &Env, account: Address, identity: Address) { + e.storage().persistent().set(&MockIRSStorageKey::Identity(account), &identity); + } +} + +#[contract] +struct MockComplianceContract; + +#[contracttype] +#[derive(Clone)] +enum MockComplianceStorageKey { + Registered(ComplianceHook, Address), +} + +#[contractimpl] +impl Compliance for MockComplianceContract { + fn add_module_to(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("add_module_to is not used in these tests"); + } + + fn remove_module_from(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("remove_module_from is not used in these tests"); + } + + fn get_modules_for_hook(_e: &Env, _hook: ComplianceHook) -> Vec
{ + unreachable!("get_modules_for_hook is not used in these tests"); + } + + fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool { + e.storage().persistent().has(&MockComplianceStorageKey::Registered(hook, module)) + } + + fn transferred(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) { + unreachable!("transferred is not used in these tests"); + } + + fn created(_e: &Env, _to: Address, _amount: i128, _token: Address) { + unreachable!("created is not used in these tests"); + } + + fn destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) { + unreachable!("destroyed is not used in these tests"); + } + + fn can_transfer( + _e: &Env, + _from: Address, + _to: Address, + _amount: i128, + _token: Address, + ) -> bool { + unreachable!("can_transfer is not used in these tests"); + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + unreachable!("can_create is not used in these tests"); + } +} + +#[contractimpl] +impl TokenBinder for MockComplianceContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl MockComplianceContract { + pub fn register_hook(e: &Env, hook: ComplianceHook, module: Address) { + e.storage().persistent().set(&MockComplianceStorageKey::Registered(hook, module), &true); + } +} + +#[test] +fn set_and_get_max_balance_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_max_balance(&token, &100); + + assert_eq!(client.get_max_balance(&token), 100); +} + +#[test] +fn pre_set_identity_balances_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let identity_a = Address::generate(&e); + let identity_b = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.pre_set_identity_balance(&token, &identity_a, &40); + client.batch_pre_set_identity_balances( + &token, + &vec![&e, identity_a.clone(), identity_b.clone()], + &vec![&e, 50_i128, 20_i128], + ); + + assert_eq!(client.get_investor_balance(&token, &identity_a), 50); + assert_eq!(client.get_investor_balance(&token, &identity_b), 20); +} + +#[test] +fn name_compliance_address_and_required_hooks_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + assert_eq!(client.name(), String::from_str(&e, "MaxBalanceModule")); + assert_eq!( + client.required_hooks(), + vec![ + &e, + ComplianceHook::CanTransfer, + ComplianceHook::CanCreate, + ComplianceHook::Transferred, + ComplianceHook::Created, + ComplianceHook::Destroyed, + ] + ); + + client.set_compliance_address(&compliance); + assert_eq!(client.get_compliance_address(), compliance); +} + +#[test] +fn set_identity_registry_storage_uses_admin_auth_before_compliance_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let irs = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_identity_registry_storage(&token, &irs); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); +} + +#[test] +fn set_identity_registry_storage_uses_compliance_auth_after_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let irs = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_compliance_address(&compliance); + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); + + client.set_identity_registry_storage(&token, &irs); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &compliance); +} + +#[test] +fn can_create_and_can_transfer_use_identity_caps() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let recipient = Address::generate(&e); + let sender_identity = Address::generate(&e); + let recipient_identity = Address::generate(&e); + let (module_address, client) = create_client(&e, &admin); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let compliance_id = e.register(MockComplianceContract, ()); + let compliance = MockComplianceContractClient::new(&e, &compliance_id); + + irs.set_identity(&sender, &sender_identity); + irs.set_identity(&recipient, &recipient_identity); + + client.set_compliance_address(&compliance_id); + for hook in [ + ComplianceHook::CanTransfer, + ComplianceHook::CanCreate, + ComplianceHook::Transferred, + ComplianceHook::Created, + ComplianceHook::Destroyed, + ] { + compliance.register_hook(&hook, &module_address); + } + + client.verify_hook_wiring(); + client.set_identity_registry_storage(&token, &irs_id); + client.set_max_balance(&token, &100); + client.pre_set_identity_balance(&token, &recipient_identity, &60); + + assert!(!client.can_create(&recipient, &50, &token)); + assert!(client.can_create(&recipient, &40, &token)); + assert!(!client.can_transfer(&sender, &recipient, &50, &token)); + assert!(client.can_transfer(&sender, &recipient, &40, &token)); +} diff --git a/examples/rwa-supply-limit/Cargo.toml b/examples/rwa-supply-limit/Cargo.toml new file mode 100644 index 000000000..94977d387 --- /dev/null +++ b/examples/rwa-supply-limit/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "rwa-supply-limit" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true +authors.workspace = true + +[package.metadata.stellar] +cargo_inherit = true + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-tokens = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/examples/rwa-supply-limit/src/contract.rs b/examples/rwa-supply-limit/src/contract.rs new file mode 100644 index 000000000..dbbbba0b9 --- /dev/null +++ b/examples/rwa-supply-limit/src/contract.rs @@ -0,0 +1,112 @@ +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::{ + modules::{ + storage::{ + get_compliance_address, module_name, set_compliance_address, ComplianceModuleStorageKey, + }, + supply_limit::storage as supply_limit, + ComplianceModule, + }, + ComplianceHook, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct SupplyLimitContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl SupplyLimitContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } + + pub fn set_supply_limit(e: &Env, token: Address, limit: i128) { + require_module_admin_or_compliance_auth(e); + supply_limit::configure_supply_limit(e, &token, limit); + } + + pub fn pre_set_supply(e: &Env, token: Address, supply: i128) { + require_module_admin_or_compliance_auth(e); + supply_limit::pre_set_supply(e, &token, supply); + } + + pub fn get_supply_limit(e: &Env, token: Address) -> i128 { + supply_limit::get_supply_limit(e, &token) + } + + pub fn get_internal_supply(e: &Env, token: Address) -> i128 { + supply_limit::get_internal_supply(e, &token) + } + + pub fn required_hooks(e: &Env) -> Vec { + supply_limit::required_hooks(e) + } + + pub fn verify_hook_wiring(e: &Env) { + supply_limit::verify_hook_wiring(e); + } +} + +#[contractimpl(contracttrait)] +impl ComplianceModule for SupplyLimitContract { + fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {} + + fn on_created(e: &Env, _to: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + supply_limit::on_created(e, amount, &token); + } + + fn on_destroyed(e: &Env, _from: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + supply_limit::on_destroyed(e, amount, &token); + } + + fn can_transfer( + _e: &Env, + _from: Address, + _to: Address, + _amount: i128, + _token: Address, + ) -> bool { + true + } + + fn can_create(e: &Env, _to: Address, amount: i128, token: Address) -> bool { + supply_limit::can_create(e, amount, &token) + } + + fn name(e: &Env) -> String { + module_name(e, "SupplyLimitModule") + } + + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-supply-limit/src/lib.rs b/examples/rwa-supply-limit/src/lib.rs new file mode 100644 index 000000000..a879b6f80 --- /dev/null +++ b/examples/rwa-supply-limit/src/lib.rs @@ -0,0 +1,5 @@ +#![no_std] + +pub mod contract; +#[cfg(test)] +mod test; diff --git a/examples/rwa-supply-limit/src/test.rs b/examples/rwa-supply-limit/src/test.rs new file mode 100644 index 000000000..728fee1f5 --- /dev/null +++ b/examples/rwa-supply-limit/src/test.rs @@ -0,0 +1,194 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, String, Vec, +}; +use stellar_tokens::rwa::{ + compliance::{Compliance, ComplianceHook}, + utils::token_binder::TokenBinder, +}; + +use crate::contract::{SupplyLimitContract, SupplyLimitContractClient}; + +fn create_client<'a>(e: &Env, admin: &Address) -> (Address, SupplyLimitContractClient<'a>) { + let address = e.register(SupplyLimitContract, (admin,)); + (address.clone(), SupplyLimitContractClient::new(e, &address)) +} + +#[contract] +struct MockComplianceContract; + +#[contracttype] +#[derive(Clone)] +enum MockComplianceStorageKey { + Registered(ComplianceHook, Address), +} + +#[contractimpl] +impl Compliance for MockComplianceContract { + fn add_module_to(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("add_module_to is not used in these tests"); + } + + fn remove_module_from(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("remove_module_from is not used in these tests"); + } + + fn get_modules_for_hook(_e: &Env, _hook: ComplianceHook) -> Vec
{ + unreachable!("get_modules_for_hook is not used in these tests"); + } + + fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool { + e.storage().persistent().has(&MockComplianceStorageKey::Registered(hook, module)) + } + + fn transferred(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) { + unreachable!("transferred is not used in these tests"); + } + + fn created(_e: &Env, _to: Address, _amount: i128, _token: Address) { + unreachable!("created is not used in these tests"); + } + + fn destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) { + unreachable!("destroyed is not used in these tests"); + } + + fn can_transfer( + _e: &Env, + _from: Address, + _to: Address, + _amount: i128, + _token: Address, + ) -> bool { + unreachable!("can_transfer is not used in these tests"); + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + unreachable!("can_create is not used in these tests"); + } +} + +#[contractimpl] +impl TokenBinder for MockComplianceContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl MockComplianceContract { + pub fn register_hook(e: &Env, hook: ComplianceHook, module: Address) { + e.storage().persistent().set(&MockComplianceStorageKey::Registered(hook, module), &true); + } +} + +#[test] +fn set_supply_limit_and_pre_set_supply_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_supply_limit(&token, &100); + client.pre_set_supply(&token, &60); + + assert_eq!(client.get_supply_limit(&token), 100); + assert_eq!(client.get_internal_supply(&token), 60); +} + +#[test] +fn name_compliance_address_and_required_hooks_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + assert_eq!(client.name(), String::from_str(&e, "SupplyLimitModule")); + assert_eq!( + client.required_hooks(), + vec![&e, ComplianceHook::CanCreate, ComplianceHook::Created, ComplianceHook::Destroyed,] + ); + + client.set_compliance_address(&compliance); + assert_eq!(client.get_compliance_address(), compliance); +} + +#[test] +fn set_supply_limit_uses_admin_auth_before_compliance_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_supply_limit(&token, &100); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); +} + +#[test] +fn set_supply_limit_uses_compliance_auth_after_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_compliance_address(&compliance); + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); + + client.set_supply_limit(&token, &100); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &compliance); +} + +#[test] +fn can_create_and_hooks_update_internal_supply() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let account = Address::generate(&e); + let (module_address, client) = create_client(&e, &admin); + let compliance_id = e.register(MockComplianceContract, ()); + let compliance = MockComplianceContractClient::new(&e, &compliance_id); + + client.set_compliance_address(&compliance_id); + for hook in [ComplianceHook::CanCreate, ComplianceHook::Created, ComplianceHook::Destroyed] { + compliance.register_hook(&hook, &module_address); + } + + client.verify_hook_wiring(); + client.set_supply_limit(&token, &100); + + assert!(client.can_create(&account, &80, &token)); + + client.on_created(&account, &80, &token); + assert_eq!(client.get_internal_supply(&token), 80); + assert!(!client.can_create(&account, &30, &token)); + + client.on_destroyed(&account, &20, &token); + assert_eq!(client.get_internal_supply(&token), 60); + assert!(client.can_create(&account, &40, &token)); +} diff --git a/examples/rwa-time-transfers-limits/Cargo.toml b/examples/rwa-time-transfers-limits/Cargo.toml new file mode 100644 index 000000000..ab02105e5 --- /dev/null +++ b/examples/rwa-time-transfers-limits/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "rwa-time-transfers-limits" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true +authors.workspace = true + +[package.metadata.stellar] +cargo_inherit = true + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-tokens = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/examples/rwa-time-transfers-limits/src/contract.rs b/examples/rwa-time-transfers-limits/src/contract.rs new file mode 100644 index 000000000..d1d559a4b --- /dev/null +++ b/examples/rwa-time-transfers-limits/src/contract.rs @@ -0,0 +1,125 @@ +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::{ + modules::{ + storage::{ + get_compliance_address, module_name, set_compliance_address, ComplianceModuleStorageKey, + }, + time_transfers_limits::{storage as ttl, Limit, TransferCounter}, + ComplianceModule, + }, + ComplianceHook, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct TimeTransfersLimitsContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl TimeTransfersLimitsContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } + + pub fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { + require_module_admin_or_compliance_auth(e); + ttl::configure_irs(e, &token, &irs); + } + + pub fn set_time_transfer_limit(e: &Env, token: Address, limit: Limit) { + require_module_admin_or_compliance_auth(e); + ttl::set_time_transfer_limit(e, &token, &limit); + } + + pub fn batch_set_time_transfer_limit(e: &Env, token: Address, limits: Vec) { + require_module_admin_or_compliance_auth(e); + ttl::batch_set_time_transfer_limit(e, &token, &limits); + } + + pub fn remove_time_transfer_limit(e: &Env, token: Address, limit_time: u64) { + require_module_admin_or_compliance_auth(e); + ttl::remove_time_transfer_limit(e, &token, limit_time); + } + + pub fn batch_remove_time_transfer_limit(e: &Env, token: Address, limit_times: Vec) { + require_module_admin_or_compliance_auth(e); + ttl::batch_remove_time_transfer_limit(e, &token, &limit_times); + } + + pub fn pre_set_transfer_counter( + e: &Env, + token: Address, + identity: Address, + limit_time: u64, + counter: TransferCounter, + ) { + require_module_admin_or_compliance_auth(e); + ttl::pre_set_transfer_counter(e, &token, &identity, limit_time, &counter); + } + + pub fn get_time_transfer_limits(e: &Env, token: Address) -> Vec { + ttl::get_limits(e, &token) + } + + pub fn required_hooks(e: &Env) -> Vec { + ttl::required_hooks(e) + } + + pub fn verify_hook_wiring(e: &Env) { + ttl::verify_hook_wiring(e); + } +} + +#[contractimpl(contracttrait)] +impl ComplianceModule for TimeTransfersLimitsContract { + fn on_transfer(e: &Env, from: Address, _to: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + ttl::on_transfer(e, &from, amount, &token); + } + + fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {} + + fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {} + + fn can_transfer(e: &Env, from: Address, _to: Address, amount: i128, token: Address) -> bool { + ttl::can_transfer(e, &from, amount, &token) + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + true + } + + fn name(e: &Env) -> String { + module_name(e, "TimeTransfersLimitsModule") + } + + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-time-transfers-limits/src/lib.rs b/examples/rwa-time-transfers-limits/src/lib.rs new file mode 100644 index 000000000..a879b6f80 --- /dev/null +++ b/examples/rwa-time-transfers-limits/src/lib.rs @@ -0,0 +1,5 @@ +#![no_std] + +pub mod contract; +#[cfg(test)] +mod test; diff --git a/examples/rwa-time-transfers-limits/src/test.rs b/examples/rwa-time-transfers-limits/src/test.rs new file mode 100644 index 000000000..7c75c6f2a --- /dev/null +++ b/examples/rwa-time-transfers-limits/src/test.rs @@ -0,0 +1,316 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, String, Val, + Vec, +}; +use stellar_tokens::rwa::{ + compliance::{ + modules::time_transfers_limits::{Limit, TransferCounter}, + Compliance, ComplianceHook, + }, + identity_registry_storage::{CountryDataManager, IdentityRegistryStorage}, + utils::token_binder::TokenBinder, +}; + +use crate::contract::{TimeTransfersLimitsContract, TimeTransfersLimitsContractClient}; + +fn create_client<'a>(e: &Env, admin: &Address) -> (Address, TimeTransfersLimitsContractClient<'a>) { + let address = e.register(TimeTransfersLimitsContract, (admin,)); + (address.clone(), TimeTransfersLimitsContractClient::new(e, &address)) +} + +#[contract] +struct MockIRSContract; + +#[contracttype] +#[derive(Clone)] +enum MockIRSStorageKey { + Identity(Address), +} + +#[contractimpl] +impl TokenBinder for MockIRSContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl IdentityRegistryStorage for MockIRSContract { + fn add_identity( + _e: &Env, + _account: Address, + _identity: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_identity is not used in these tests"); + } + + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { + unreachable!("remove_identity is not used in these tests"); + } + + fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { + unreachable!("modify_identity is not used in these tests"); + } + + fn recover_identity( + _e: &Env, + _old_account: Address, + _new_account: Address, + _operator: Address, + ) { + unreachable!("recover_identity is not used in these tests"); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + e.storage() + .persistent() + .get(&MockIRSStorageKey::Identity(account.clone())) + .unwrap_or(account) + } +} + +#[contractimpl] +impl CountryDataManager for MockIRSContract { + fn add_country_data_entries( + _e: &Env, + _account: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_country_data_entries is not used in these tests"); + } + + fn modify_country_data( + _e: &Env, + _account: Address, + _index: u32, + _country_data: Val, + _operator: Address, + ) { + unreachable!("modify_country_data is not used in these tests"); + } + + fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { + unreachable!("delete_country_data is not used in these tests"); + } + + fn get_country_data_entries(e: &Env, _account: Address) -> Vec { + Vec::new(e) + } +} + +#[contractimpl] +impl MockIRSContract { + pub fn set_identity(e: &Env, account: Address, identity: Address) { + e.storage().persistent().set(&MockIRSStorageKey::Identity(account), &identity); + } +} + +#[contract] +struct MockComplianceContract; + +#[contracttype] +#[derive(Clone)] +enum MockComplianceStorageKey { + Registered(ComplianceHook, Address), +} + +#[contractimpl] +impl Compliance for MockComplianceContract { + fn add_module_to(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("add_module_to is not used in these tests"); + } + + fn remove_module_from(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("remove_module_from is not used in these tests"); + } + + fn get_modules_for_hook(_e: &Env, _hook: ComplianceHook) -> Vec
{ + unreachable!("get_modules_for_hook is not used in these tests"); + } + + fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool { + e.storage().persistent().has(&MockComplianceStorageKey::Registered(hook, module)) + } + + fn transferred(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) { + unreachable!("transferred is not used in these tests"); + } + + fn created(_e: &Env, _to: Address, _amount: i128, _token: Address) { + unreachable!("created is not used in these tests"); + } + + fn destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) { + unreachable!("destroyed is not used in these tests"); + } + + fn can_transfer( + _e: &Env, + _from: Address, + _to: Address, + _amount: i128, + _token: Address, + ) -> bool { + unreachable!("can_transfer is not used in these tests"); + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + unreachable!("can_create is not used in these tests"); + } +} + +#[contractimpl] +impl TokenBinder for MockComplianceContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl MockComplianceContract { + pub fn register_hook(e: &Env, hook: ComplianceHook, module: Address) { + e.storage().persistent().set(&MockComplianceStorageKey::Registered(hook, module), &true); + } +} + +#[test] +fn set_and_manage_time_transfer_limits_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let limit_a = Limit { limit_time: 60, limit_value: 100 }; + let limit_b = Limit { limit_time: 120, limit_value: 200 }; + let (_address, client) = create_client(&e, &admin); + + client.set_time_transfer_limit(&token, &limit_a); + client.batch_set_time_transfer_limit(&token, &vec![&e, limit_b.clone()]); + + assert_eq!(client.get_time_transfer_limits(&token), vec![&e, limit_a.clone(), limit_b.clone()]); + + client.batch_remove_time_transfer_limit(&token, &vec![&e, 120_u64]); + assert_eq!(client.get_time_transfer_limits(&token), vec![&e, limit_a.clone()]); + + client.remove_time_transfer_limit(&token, &60); + assert_eq!(client.get_time_transfer_limits(&token).len(), 0); +} + +#[test] +fn name_compliance_address_and_required_hooks_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + assert_eq!(client.name(), String::from_str(&e, "TimeTransfersLimitsModule")); + assert_eq!( + client.required_hooks(), + vec![&e, ComplianceHook::CanTransfer, ComplianceHook::Transferred] + ); + + client.set_compliance_address(&compliance); + assert_eq!(client.get_compliance_address(), compliance); +} + +#[test] +fn set_identity_registry_storage_uses_admin_auth_before_compliance_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let irs = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_identity_registry_storage(&token, &irs); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); +} + +#[test] +fn set_identity_registry_storage_uses_compliance_auth_after_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let irs = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_compliance_address(&compliance); + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); + + client.set_identity_registry_storage(&token, &irs); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &compliance); +} + +#[test] +fn verify_hook_wiring_and_counters_affect_public_transfer_checks() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let recipient = Address::generate(&e); + let sender_identity = Address::generate(&e); + let limit = Limit { limit_time: 60, limit_value: 100 }; + let (module_address, client) = create_client(&e, &admin); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let compliance_id = e.register(MockComplianceContract, ()); + let compliance = MockComplianceContractClient::new(&e, &compliance_id); + + irs.set_identity(&sender, &sender_identity); + + client.set_compliance_address(&compliance_id); + for hook in [ComplianceHook::CanTransfer, ComplianceHook::Transferred] { + compliance.register_hook(&hook, &module_address); + } + + client.verify_hook_wiring(); + client.set_identity_registry_storage(&token, &irs_id); + client.set_time_transfer_limit(&token, &limit); + client.pre_set_transfer_counter( + &token, + &sender_identity, + &60, + &TransferCounter { value: 90, timer: e.ledger().timestamp().saturating_add(60) }, + ); + + assert!(!client.can_transfer(&sender, &recipient, &11, &token)); + assert!(client.can_transfer(&sender, &recipient, &10, &token)); + + client.on_transfer(&sender, &recipient, &10, &token); + assert!(!client.can_transfer(&sender, &recipient, &1, &token)); +} diff --git a/examples/rwa-transfer-restrict/Cargo.toml b/examples/rwa-transfer-restrict/Cargo.toml new file mode 100644 index 000000000..e6e333e09 --- /dev/null +++ b/examples/rwa-transfer-restrict/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "rwa-transfer-restrict" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true +authors.workspace = true + +[package.metadata.stellar] +cargo_inherit = true + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-tokens = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/examples/rwa-transfer-restrict/src/contract.rs b/examples/rwa-transfer-restrict/src/contract.rs new file mode 100644 index 000000000..b69043c63 --- /dev/null +++ b/examples/rwa-transfer-restrict/src/contract.rs @@ -0,0 +1,95 @@ +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::modules::{ + storage::{ + get_compliance_address, module_name, set_compliance_address, ComplianceModuleStorageKey, + }, + transfer_restrict::storage as transfer_restrict, + ComplianceModule, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct TransferRestrictContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl TransferRestrictContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } + + pub fn allow_user(e: &Env, token: Address, user: Address) { + require_module_admin_or_compliance_auth(e); + transfer_restrict::allow_user(e, &token, &user); + } + + pub fn disallow_user(e: &Env, token: Address, user: Address) { + require_module_admin_or_compliance_auth(e); + transfer_restrict::disallow_user(e, &token, &user); + } + + pub fn batch_allow_users(e: &Env, token: Address, users: Vec
) { + require_module_admin_or_compliance_auth(e); + transfer_restrict::batch_allow_users(e, &token, &users); + } + + pub fn batch_disallow_users(e: &Env, token: Address, users: Vec
) { + require_module_admin_or_compliance_auth(e); + transfer_restrict::batch_disallow_users(e, &token, &users); + } + + pub fn is_user_allowed(e: &Env, token: Address, user: Address) -> bool { + transfer_restrict::is_user_allowed(e, &token, &user) + } +} + +#[contractimpl(contracttrait)] +impl ComplianceModule for TransferRestrictContract { + fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {} + + fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {} + + fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {} + + fn can_transfer(e: &Env, from: Address, to: Address, _amount: i128, token: Address) -> bool { + transfer_restrict::can_transfer(e, &from, &to, &token) + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + true + } + + fn name(e: &Env) -> String { + module_name(e, "TransferRestrictModule") + } + + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-transfer-restrict/src/lib.rs b/examples/rwa-transfer-restrict/src/lib.rs new file mode 100644 index 000000000..a879b6f80 --- /dev/null +++ b/examples/rwa-transfer-restrict/src/lib.rs @@ -0,0 +1,5 @@ +#![no_std] + +pub mod contract; +#[cfg(test)] +mod test; diff --git a/examples/rwa-transfer-restrict/src/test.rs b/examples/rwa-transfer-restrict/src/test.rs new file mode 100644 index 000000000..7c5f07871 --- /dev/null +++ b/examples/rwa-transfer-restrict/src/test.rs @@ -0,0 +1,97 @@ +extern crate std; + +use soroban_sdk::{testutils::Address as _, vec, Address, Env, String}; + +use crate::contract::{TransferRestrictContract, TransferRestrictContractClient}; + +fn create_client<'a>(e: &Env, admin: &Address) -> (Address, TransferRestrictContractClient<'a>) { + let address = e.register(TransferRestrictContract, (admin,)); + (address.clone(), TransferRestrictContractClient::new(e, &address)) +} + +#[test] +fn allowlist_methods_and_can_transfer_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let recipient = Address::generate(&e); + let other = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + assert!(!client.is_user_allowed(&token, &sender)); + assert!(!client.can_transfer(&sender, &recipient, &1, &token)); + + client.allow_user(&token, &sender); + assert!(client.is_user_allowed(&token, &sender)); + assert!(client.can_transfer(&sender, &other, &1, &token)); + + client.disallow_user(&token, &sender); + assert!(!client.is_user_allowed(&token, &sender)); + + client.batch_allow_users(&token, &vec![&e, recipient.clone(), other.clone()]); + assert!(client.is_user_allowed(&token, &recipient)); + assert!(client.is_user_allowed(&token, &other)); + assert!(client.can_transfer(&sender, &recipient, &1, &token)); + + client.batch_disallow_users(&token, &vec![&e, recipient.clone(), other.clone()]); + assert!(!client.is_user_allowed(&token, &recipient)); + assert!(!client.is_user_allowed(&token, &other)); + assert!(!client.can_transfer(&sender, &recipient, &1, &token)); +} + +#[test] +fn name_and_compliance_address_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + assert_eq!(client.name(), String::from_str(&e, "TransferRestrictModule")); + + client.set_compliance_address(&compliance); + assert_eq!(client.get_compliance_address(), compliance); +} + +#[test] +fn allow_user_uses_admin_auth_before_compliance_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let user = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.allow_user(&token, &user); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); +} + +#[test] +fn allow_user_uses_compliance_auth_after_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let user = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_compliance_address(&compliance); + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); + + client.allow_user(&token, &user); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &compliance); +} diff --git a/packages/tokens/src/rwa/compliance/modules/country_allow/mod.rs b/packages/tokens/src/rwa/compliance/modules/country_allow/mod.rs new file mode 100644 index 000000000..1da53f7f2 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/country_allow/mod.rs @@ -0,0 +1,31 @@ +//! Country allowlist compliance module — Stellar port of T-REX +//! [`CountryAllowModule.sol`][trex-src]. +//! +//! Only recipients whose identity has at least one country code in the +//! allowlist may receive tokens. +//! +//! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/CountryAllowModule.sol + +pub mod storage; +#[cfg(test)] +mod test; + +use soroban_sdk::{contractevent, Address}; + +/// Emitted when a country is added to the allowlist. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CountryAllowed { + #[topic] + pub token: Address, + pub country: u32, +} + +/// Emitted when a country is removed from the allowlist. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CountryUnallowed { + #[topic] + pub token: Address, + pub country: u32, +} diff --git a/packages/tokens/src/rwa/compliance/modules/country_allow/storage.rs b/packages/tokens/src/rwa/compliance/modules/country_allow/storage.rs new file mode 100644 index 000000000..d47da3698 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/country_allow/storage.rs @@ -0,0 +1,151 @@ +use soroban_sdk::{contracttype, Address, Env, Vec}; + +use super::{CountryAllowed, CountryUnallowed}; +use crate::rwa::compliance::modules::{ + storage::{country_code, get_irs_country_data_entries}, + MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD, +}; + +#[contracttype] +#[derive(Clone)] +pub enum CountryAllowStorageKey { + /// Per-(token, country) allowlist flag. + AllowedCountry(Address, u32), +} + +// ################## RAW STORAGE ################## + +/// Returns whether the given country is on the allowlist for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code. +pub fn is_country_allowed(e: &Env, token: &Address, country: u32) -> bool { + let key = CountryAllowStorageKey::AllowedCountry(token.clone(), country); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &bool| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or_default() +} + +/// Writes a country's allowed flag to persistent storage. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code to allow. +pub fn set_country_allowed(e: &Env, token: &Address, country: u32) { + let key = CountryAllowStorageKey::AllowedCountry(token.clone(), country); + e.storage().persistent().set(&key, &true); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} + +/// Removes a country from the allowlist in persistent storage. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code to remove. +pub fn remove_country_allowed(e: &Env, token: &Address, country: u32) { + e.storage() + .persistent() + .remove(&CountryAllowStorageKey::AllowedCountry(token.clone(), country)); +} + +// ################## ACTIONS ################## + +/// Adds a country to the allowlist for `token`. +/// +/// Writes the flag to storage and emits [`CountryAllowed`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code to allow. +pub fn add_allowed_country(e: &Env, token: &Address, country: u32) { + set_country_allowed(e, token, country); + CountryAllowed { token: token.clone(), country }.publish(e); +} + +/// Removes a country from the allowlist for `token`. +/// +/// Deletes the flag from storage and emits [`CountryUnallowed`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code to remove. +pub fn remove_allowed_country(e: &Env, token: &Address, country: u32) { + remove_country_allowed(e, token, country); + CountryUnallowed { token: token.clone(), country }.publish(e); +} + +/// Adds multiple countries to the allowlist in a single call. +/// +/// Emits [`CountryAllowed`] for each country added. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `countries` - The country codes to allow. +pub fn batch_allow_countries(e: &Env, token: &Address, countries: &Vec) { + for country in countries.iter() { + set_country_allowed(e, token, country); + CountryAllowed { token: token.clone(), country }.publish(e); + } +} + +/// Removes multiple countries from the allowlist in a single call. +/// +/// Emits [`CountryUnallowed`] for each country removed. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `countries` - The country codes to remove. +pub fn batch_disallow_countries(e: &Env, token: &Address, countries: &Vec) { + for country in countries.iter() { + remove_country_allowed(e, token, country); + CountryUnallowed { token: token.clone(), country }.publish(e); + } +} + +// ################## COMPLIANCE HOOKS ################## + +/// Returns `true` if `to` has at least one allowed country in the IRS for +/// `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `to` - The recipient whose country data is checked. +/// * `token` - The token address. +/// +/// # Errors +/// +/// * [`crate::rwa::compliance::modules::ComplianceModuleError::IdentityRegistryNotSet`] +/// - When no IRS has been configured for `token`. +/// +/// # Cross-Contract Calls +/// +/// Calls the IRS to resolve country data for `to`. +pub fn can_transfer(e: &Env, to: &Address, token: &Address) -> bool { + let entries = get_irs_country_data_entries(e, token, to); + for entry in entries.iter() { + if is_country_allowed(e, token, country_code(&entry.country)) { + return true; + } + } + false +} diff --git a/packages/tokens/src/rwa/compliance/modules/country_allow/test.rs b/packages/tokens/src/rwa/compliance/modules/country_allow/test.rs new file mode 100644 index 000000000..a8b497283 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/country_allow/test.rs @@ -0,0 +1,183 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal, Val, + Vec, +}; + +use super::storage::{can_transfer, set_country_allowed}; +use crate::rwa::{ + compliance::modules::storage::set_irs_address, + identity_registry_storage::{ + CountryData, CountryDataManager, CountryRelation, IdentityRegistryStorage, + IndividualCountryRelation, OrganizationCountryRelation, + }, + utils::token_binder::TokenBinder, +}; + +#[contract] +struct MockIRSContract; + +#[contracttype] +#[derive(Clone)] +enum MockIRSStorageKey { + Identity(Address), + CountryEntries(Address), +} + +#[contractimpl] +impl TokenBinder for MockIRSContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl IdentityRegistryStorage for MockIRSContract { + fn add_identity( + _e: &Env, + _account: Address, + _identity: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_identity is not used in these tests"); + } + + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { + unreachable!("remove_identity is not used in these tests"); + } + + fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { + unreachable!("modify_identity is not used in these tests"); + } + + fn recover_identity( + _e: &Env, + _old_account: Address, + _new_account: Address, + _operator: Address, + ) { + unreachable!("recover_identity is not used in these tests"); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + e.storage() + .persistent() + .get(&MockIRSStorageKey::Identity(account.clone())) + .unwrap_or(account) + } +} + +#[contractimpl] +impl CountryDataManager for MockIRSContract { + fn add_country_data_entries( + _e: &Env, + _account: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_country_data_entries is not used in these tests"); + } + + fn modify_country_data( + _e: &Env, + _account: Address, + _index: u32, + _country_data: Val, + _operator: Address, + ) { + unreachable!("modify_country_data is not used in these tests"); + } + + fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { + unreachable!("delete_country_data is not used in these tests"); + } + + fn get_country_data_entries(e: &Env, account: Address) -> Vec { + let entries: Vec = e + .storage() + .persistent() + .get(&MockIRSStorageKey::CountryEntries(account)) + .unwrap_or_else(|| Vec::new(e)); + + Vec::from_iter(e, entries.iter().map(|entry| entry.into_val(e))) + } +} + +#[contractimpl] +impl MockIRSContract { + pub fn set_country_data_entries(e: &Env, account: Address, entries: Vec) { + e.storage().persistent().set(&MockIRSStorageKey::CountryEntries(account), &entries); + } +} + +#[contract] +struct TestCountryAllowContract; + +fn individual_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Individual(IndividualCountryRelation::Residence(code)), + metadata: None, + } +} + +fn organization_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Organization(OrganizationCountryRelation::OperatingJurisdiction( + code, + )), + metadata: None, + } +} + +#[test] +fn can_transfer_allows_when_any_country_matches() { + let e = Env::default(); + let module_id = e.register(TestCountryAllowContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let to = Address::generate(&e); + + irs.set_country_data_entries( + &to, + &vec![&e, individual_country(250), organization_country(276)], + ); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_country_allowed(&e, &token, 276); + + assert!(can_transfer(&e, &to, &token)); + }); +} + +#[test] +fn can_transfer_rejects_when_no_country_matches() { + let e = Env::default(); + let module_id = e.register(TestCountryAllowContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let empty_to = Address::generate(&e); + let disallowed_to = Address::generate(&e); + + irs.set_country_data_entries(&disallowed_to, &vec![&e, individual_country(250)]); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_country_allowed(&e, &token, 276); + + assert!(!can_transfer(&e, &empty_to, &token)); + assert!(!can_transfer(&e, &disallowed_to, &token)); + }); +} diff --git a/packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs b/packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs new file mode 100644 index 000000000..2cbd355a1 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs @@ -0,0 +1,31 @@ +//! Country restriction compliance module — Stellar port of T-REX +//! [`CountryRestrictModule.sol`][trex-src]. +//! +//! Recipients whose identity has a country code on the restriction list are +//! blocked from receiving tokens. +//! +//! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/CountryRestrictModule.sol + +pub mod storage; +#[cfg(test)] +mod test; + +use soroban_sdk::{contractevent, Address}; + +/// Emitted when a country is added to the restriction list. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CountryRestricted { + #[topic] + pub token: Address, + pub country: u32, +} + +/// Emitted when a country is removed from the restriction list. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CountryUnrestricted { + #[topic] + pub token: Address, + pub country: u32, +} diff --git a/packages/tokens/src/rwa/compliance/modules/country_restrict/storage.rs b/packages/tokens/src/rwa/compliance/modules/country_restrict/storage.rs new file mode 100644 index 000000000..be7c0512e --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/country_restrict/storage.rs @@ -0,0 +1,151 @@ +use soroban_sdk::{contracttype, Address, Env, Vec}; + +use super::{CountryRestricted, CountryUnrestricted}; +use crate::rwa::compliance::modules::{ + storage::{country_code, get_irs_country_data_entries}, + MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD, +}; + +#[contracttype] +#[derive(Clone)] +pub enum CountryRestrictStorageKey { + /// Per-(token, country) restriction flag. + RestrictedCountry(Address, u32), +} + +// ################## RAW STORAGE ################## + +/// Returns whether the given country is on the restriction list for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code. +pub fn is_country_restricted(e: &Env, token: &Address, country: u32) -> bool { + let key = CountryRestrictStorageKey::RestrictedCountry(token.clone(), country); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &bool| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or_default() +} + +/// Writes a country's restricted flag to persistent storage. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code to restrict. +pub fn set_country_restricted(e: &Env, token: &Address, country: u32) { + let key = CountryRestrictStorageKey::RestrictedCountry(token.clone(), country); + e.storage().persistent().set(&key, &true); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} + +/// Removes a country from the restriction list in persistent storage. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code to unrestrict. +pub fn remove_country_restricted(e: &Env, token: &Address, country: u32) { + e.storage() + .persistent() + .remove(&CountryRestrictStorageKey::RestrictedCountry(token.clone(), country)); +} + +// ################## ACTIONS ################## + +/// Adds a country to the restriction list for `token`. +/// +/// Writes the flag to storage and emits [`CountryRestricted`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code to restrict. +pub fn add_country_restriction(e: &Env, token: &Address, country: u32) { + set_country_restricted(e, token, country); + CountryRestricted { token: token.clone(), country }.publish(e); +} + +/// Removes a country from the restriction list for `token`. +/// +/// Deletes the flag from storage and emits [`CountryUnrestricted`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code to unrestrict. +pub fn remove_country_restriction(e: &Env, token: &Address, country: u32) { + remove_country_restricted(e, token, country); + CountryUnrestricted { token: token.clone(), country }.publish(e); +} + +/// Adds multiple countries to the restriction list in a single call. +/// +/// Emits [`CountryRestricted`] for each country added. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `countries` - The country codes to restrict. +pub fn batch_restrict_countries(e: &Env, token: &Address, countries: &Vec) { + for country in countries.iter() { + set_country_restricted(e, token, country); + CountryRestricted { token: token.clone(), country }.publish(e); + } +} + +/// Removes multiple countries from the restriction list in a single call. +/// +/// Emits [`CountryUnrestricted`] for each country removed. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `countries` - The country codes to unrestrict. +pub fn batch_unrestrict_countries(e: &Env, token: &Address, countries: &Vec) { + for country in countries.iter() { + remove_country_restricted(e, token, country); + CountryUnrestricted { token: token.clone(), country }.publish(e); + } +} + +// ################## COMPLIANCE HOOKS ################## + +/// Returns `false` if `to` has any restricted country in the IRS for `token`, +/// and `true` otherwise. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `to` - The recipient whose country data is checked. +/// * `token` - The token address. +/// +/// # Errors +/// +/// * [`crate::rwa::compliance::modules::ComplianceModuleError::IdentityRegistryNotSet`] +/// - When no IRS has been configured for `token`. +/// +/// # Cross-Contract Calls +/// +/// Calls the IRS to resolve country data for `to`. +pub fn can_transfer(e: &Env, to: &Address, token: &Address) -> bool { + let entries = get_irs_country_data_entries(e, token, to); + for entry in entries.iter() { + if is_country_restricted(e, token, country_code(&entry.country)) { + return false; + } + } + true +} diff --git a/packages/tokens/src/rwa/compliance/modules/country_restrict/test.rs b/packages/tokens/src/rwa/compliance/modules/country_restrict/test.rs new file mode 100644 index 000000000..f09ed9eab --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/country_restrict/test.rs @@ -0,0 +1,186 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal, Val, + Vec, +}; + +use super::storage::{can_transfer, set_country_restricted}; +use crate::rwa::{ + compliance::modules::storage::set_irs_address, + identity_registry_storage::{ + CountryData, CountryDataManager, CountryRelation, IdentityRegistryStorage, + IndividualCountryRelation, OrganizationCountryRelation, + }, + utils::token_binder::TokenBinder, +}; + +#[contract] +struct MockIRSContract; + +#[contracttype] +#[derive(Clone)] +enum MockIRSStorageKey { + Identity(Address), + CountryEntries(Address), +} + +#[contractimpl] +impl TokenBinder for MockIRSContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl IdentityRegistryStorage for MockIRSContract { + fn add_identity( + _e: &Env, + _account: Address, + _identity: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_identity is not used in these tests"); + } + + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { + unreachable!("remove_identity is not used in these tests"); + } + + fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { + unreachable!("modify_identity is not used in these tests"); + } + + fn recover_identity( + _e: &Env, + _old_account: Address, + _new_account: Address, + _operator: Address, + ) { + unreachable!("recover_identity is not used in these tests"); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + e.storage() + .persistent() + .get(&MockIRSStorageKey::Identity(account.clone())) + .unwrap_or(account) + } +} + +#[contractimpl] +impl CountryDataManager for MockIRSContract { + fn add_country_data_entries( + _e: &Env, + _account: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_country_data_entries is not used in these tests"); + } + + fn modify_country_data( + _e: &Env, + _account: Address, + _index: u32, + _country_data: Val, + _operator: Address, + ) { + unreachable!("modify_country_data is not used in these tests"); + } + + fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { + unreachable!("delete_country_data is not used in these tests"); + } + + fn get_country_data_entries(e: &Env, account: Address) -> Vec { + let entries: Vec = e + .storage() + .persistent() + .get(&MockIRSStorageKey::CountryEntries(account)) + .unwrap_or_else(|| Vec::new(e)); + + Vec::from_iter(e, entries.iter().map(|entry| entry.into_val(e))) + } +} + +#[contractimpl] +impl MockIRSContract { + pub fn set_country_data_entries(e: &Env, account: Address, entries: Vec) { + e.storage().persistent().set(&MockIRSStorageKey::CountryEntries(account), &entries); + } +} + +#[contract] +struct TestCountryRestrictContract; + +fn individual_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Individual(IndividualCountryRelation::Residence(code)), + metadata: None, + } +} + +fn organization_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Organization(OrganizationCountryRelation::OperatingJurisdiction( + code, + )), + metadata: None, + } +} + +#[test] +fn can_transfer_rejects_when_any_country_is_restricted() { + let e = Env::default(); + let module_id = e.register(TestCountryRestrictContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let to = Address::generate(&e); + + irs.set_country_data_entries( + &to, + &vec![&e, individual_country(250), organization_country(408)], + ); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_country_restricted(&e, &token, 408); + + assert!(!can_transfer(&e, &to, &token)); + }); +} + +#[test] +fn can_transfer_allows_when_no_country_is_restricted() { + let e = Env::default(); + let module_id = e.register(TestCountryRestrictContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let empty_to = Address::generate(&e); + let unrestricted_to = Address::generate(&e); + + irs.set_country_data_entries( + &unrestricted_to, + &vec![&e, individual_country(250), organization_country(276)], + ); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_country_restricted(&e, &token, 408); + + assert!(can_transfer(&e, &empty_to, &token)); + assert!(can_transfer(&e, &unrestricted_to, &token)); + }); +} diff --git a/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/mod.rs b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/mod.rs new file mode 100644 index 000000000..ed816abfe --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/mod.rs @@ -0,0 +1,24 @@ +//! Initial lockup period compliance module — Stellar port of T-REX +//! [`TimeExchangeLimitsModule.sol`][trex-src]. +//! +//! Enforces a lockup period for all investors whenever they receive tokens +//! through primary emissions (mints). Tokens received via peer-to-peer +//! transfers are **not** subject to lockup restrictions. +//! +//! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/TimeExchangeLimitsModule.sol + +pub mod storage; +#[cfg(test)] +mod test; + +use soroban_sdk::{contractevent, Address}; +pub use storage::LockedTokens; + +/// Emitted when a token's lockup duration is configured or changed. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LockupPeriodSet { + #[topic] + pub token: Address, + pub lockup_seconds: u64, +} diff --git a/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/storage.rs b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/storage.rs new file mode 100644 index 000000000..8224d7ad1 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/storage.rs @@ -0,0 +1,417 @@ +use soroban_sdk::{contracttype, vec, Address, Env, Vec}; + +use super::LockupPeriodSet; +use crate::rwa::compliance::{ + modules::{ + storage::{ + add_i128_or_panic, hooks_verified, require_non_negative_amount, sub_i128_or_panic, + verify_required_hooks, + }, + MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD, + }, + ComplianceHook, +}; + +/// A single mint-created lock entry tracking the locked amount and its +/// release time. Mirrors T-REX `LockedTokens { amount, releaseTimestamp }`. +#[contracttype] +#[derive(Clone)] +pub struct LockedTokens { + pub amount: i128, + pub release_timestamp: u64, +} + +#[contracttype] +#[derive(Clone)] +pub enum InitialLockupStorageKey { + /// Per-token lockup duration in seconds. + LockupPeriod(Address), + /// Per-(token, wallet) ordered list of individual lock entries. + Locks(Address, Address), + /// Per-(token, wallet) aggregate of all locked amounts. + TotalLocked(Address, Address), + /// Per-(token, wallet) balance mirror, updated via hooks to avoid + /// re-entrant `token.balance()` calls. + InternalBalance(Address, Address), +} + +// ################## RAW STORAGE ################## + +/// Returns the lockup period (in seconds) for `token`, or `0` if not set. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +pub fn get_lockup_period(e: &Env, token: &Address) -> u64 { + let key = InitialLockupStorageKey::LockupPeriod(token.clone()); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &u64| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or_default() +} + +/// Sets the lockup period (in seconds) for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `seconds` - The lockup duration in seconds. +pub fn set_lockup_period(e: &Env, token: &Address, seconds: u64) { + let key = InitialLockupStorageKey::LockupPeriod(token.clone()); + e.storage().persistent().set(&key, &seconds); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} + +/// Returns the lock entries for `wallet` on `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `wallet` - The wallet address. +pub fn get_locks(e: &Env, token: &Address, wallet: &Address) -> Vec { + let key = InitialLockupStorageKey::Locks(token.clone(), wallet.clone()); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &Vec| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or_else(|| Vec::new(e)) +} + +/// Persists the lock entries for `wallet` on `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `wallet` - The wallet address. +/// * `locks` - The updated lock entries. +pub fn set_locks(e: &Env, token: &Address, wallet: &Address, locks: &Vec) { + let key = InitialLockupStorageKey::Locks(token.clone(), wallet.clone()); + e.storage().persistent().set(&key, locks); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} + +/// Returns the total locked amount for `wallet` on `token`, or `0`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `wallet` - The wallet address. +pub fn get_total_locked(e: &Env, token: &Address, wallet: &Address) -> i128 { + let key = InitialLockupStorageKey::TotalLocked(token.clone(), wallet.clone()); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &i128| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or_default() +} + +/// Sets the total locked amount for `wallet` on `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `wallet` - The wallet address. +/// * `amount` - The new total locked amount. +pub fn set_total_locked(e: &Env, token: &Address, wallet: &Address, amount: i128) { + let key = InitialLockupStorageKey::TotalLocked(token.clone(), wallet.clone()); + e.storage().persistent().set(&key, &amount); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} + +/// Returns the internal balance for `wallet` on `token`, or `0`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `wallet` - The wallet address. +pub fn get_internal_balance(e: &Env, token: &Address, wallet: &Address) -> i128 { + let key = InitialLockupStorageKey::InternalBalance(token.clone(), wallet.clone()); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &i128| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or_default() +} + +/// Sets the internal balance for `wallet` on `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `wallet` - The wallet address. +/// * `balance` - The new balance value. +pub fn set_internal_balance(e: &Env, token: &Address, wallet: &Address, balance: i128) { + let key = InitialLockupStorageKey::InternalBalance(token.clone(), wallet.clone()); + e.storage().persistent().set(&key, &balance); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} + +// ################## HELPERS ################## + +fn calculate_unlocked_amount(e: &Env, locks: &Vec) -> i128 { + let now = e.ledger().timestamp(); + let mut unlocked = 0i128; + for i in 0..locks.len() { + let lock = locks.get(i).unwrap(); + if lock.release_timestamp <= now { + unlocked = add_i128_or_panic(e, unlocked, lock.amount); + } + } + unlocked +} + +fn calculate_total_locked_amount(e: &Env, locks: &Vec) -> i128 { + let mut total = 0i128; + for i in 0..locks.len() { + let lock = locks.get(i).unwrap(); + require_non_negative_amount(e, lock.amount); + total = add_i128_or_panic(e, total, lock.amount); + } + total +} + +fn update_locked_tokens(e: &Env, token: &Address, wallet: &Address, mut amount_to_consume: i128) { + let locks = get_locks(e, token, wallet); + let now = e.ledger().timestamp(); + let mut new_locks = Vec::new(e); + let mut consumed_total = 0i128; + + for i in 0..locks.len() { + let lock = locks.get(i).unwrap(); + if amount_to_consume > 0 && lock.release_timestamp <= now { + if amount_to_consume >= lock.amount { + amount_to_consume = sub_i128_or_panic(e, amount_to_consume, lock.amount); + consumed_total = add_i128_or_panic(e, consumed_total, lock.amount); + } else { + consumed_total = add_i128_or_panic(e, consumed_total, amount_to_consume); + new_locks.push_back(LockedTokens { + amount: sub_i128_or_panic(e, lock.amount, amount_to_consume), + release_timestamp: lock.release_timestamp, + }); + amount_to_consume = 0; + } + } else { + new_locks.push_back(lock); + } + } + + set_locks(e, token, wallet, &new_locks); + + let total_locked = get_total_locked(e, token, wallet); + set_total_locked(e, token, wallet, sub_i128_or_panic(e, total_locked, consumed_total)); +} + +// ################## ACTIONS ################## + +/// Configures the lockup period for `token` and emits [`LockupPeriodSet`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `lockup_seconds` - The lockup duration in seconds. +pub fn configure_lockup_period(e: &Env, token: &Address, lockup_seconds: u64) { + set_lockup_period(e, token, lockup_seconds); + LockupPeriodSet { token: token.clone(), lockup_seconds }.publish(e); +} + +/// Pre-seeds the lockup state for a wallet. Validates that total locked +/// does not exceed balance. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `wallet` - The wallet address. +/// * `balance` - The wallet balance. +/// * `locks` - The lock entries. +pub fn pre_set_lockup_state( + e: &Env, + token: &Address, + wallet: &Address, + balance: i128, + locks: &Vec, +) { + require_non_negative_amount(e, balance); + + let total_locked = calculate_total_locked_amount(e, locks); + assert!( + total_locked <= balance, + "InitialLockupPeriodModule: total locked amount cannot exceed balance" + ); + + set_internal_balance(e, token, wallet, balance); + set_locks(e, token, wallet, locks); + set_total_locked(e, token, wallet, total_locked); +} + +// ################## HOOK WIRING ################## + +/// Returns the set of compliance hooks this module requires. +pub fn required_hooks(e: &Env) -> Vec { + vec![ + e, + ComplianceHook::CanTransfer, + ComplianceHook::Created, + ComplianceHook::Transferred, + ComplianceHook::Destroyed, + ] +} + +/// Cross-calls the compliance contract to verify that this module is +/// registered on all required hooks. +pub fn verify_hook_wiring(e: &Env) { + verify_required_hooks(e, required_hooks(e)); +} + +// ################## COMPLIANCE HOOKS ################## + +/// Updates internal balances and lock tracking after a transfer. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `from` - The sender address. +/// * `to` - The recipient address. +/// * `amount` - The transfer amount. +/// * `token` - The token address. +pub fn on_transfer(e: &Env, from: &Address, to: &Address, amount: i128, token: &Address) { + require_non_negative_amount(e, amount); + + let total_locked = get_total_locked(e, token, from); + + if total_locked > 0 { + let pre_balance = get_internal_balance(e, token, from); + let pre_free = pre_balance - total_locked; + + if amount > pre_free.max(0) { + let to_consume = amount - pre_free.max(0); + update_locked_tokens(e, token, from, to_consume); + } + } + + let from_bal = get_internal_balance(e, token, from); + set_internal_balance(e, token, from, sub_i128_or_panic(e, from_bal, amount)); + + let to_bal = get_internal_balance(e, token, to); + set_internal_balance(e, token, to, add_i128_or_panic(e, to_bal, amount)); +} + +/// Updates internal balance and creates a lock entry after a mint. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `to` - The recipient address. +/// * `amount` - The minted amount. +/// * `token` - The token address. +pub fn on_created(e: &Env, to: &Address, amount: i128, token: &Address) { + require_non_negative_amount(e, amount); + + let period = get_lockup_period(e, token); + if period > 0 { + let mut locks = get_locks(e, token, to); + locks.push_back(LockedTokens { + amount, + release_timestamp: e.ledger().timestamp().saturating_add(period), + }); + set_locks(e, token, to, &locks); + + let total = get_total_locked(e, token, to); + set_total_locked(e, token, to, add_i128_or_panic(e, total, amount)); + } + + let current = get_internal_balance(e, token, to); + set_internal_balance(e, token, to, add_i128_or_panic(e, current, amount)); +} + +/// Updates internal balance and consumes locks after a burn. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `from` - The burner address. +/// * `amount` - The burned amount. +/// * `token` - The token address. +pub fn on_destroyed(e: &Env, from: &Address, amount: i128, token: &Address) { + require_non_negative_amount(e, amount); + + let total_locked = get_total_locked(e, token, from); + + if total_locked > 0 { + let pre_balance = get_internal_balance(e, token, from); + let mut free_amount = pre_balance - total_locked; + + if free_amount < amount { + let locks = get_locks(e, token, from); + free_amount += calculate_unlocked_amount(e, &locks); + } + + assert!( + free_amount >= amount, + "InitialLockupPeriodModule: insufficient unlocked balance for burn" + ); + + let pre_free = pre_balance - total_locked; + if amount > pre_free.max(0) { + let to_consume = amount - pre_free.max(0); + update_locked_tokens(e, token, from, to_consume); + } + } + + let current = get_internal_balance(e, token, from); + set_internal_balance(e, token, from, sub_i128_or_panic(e, current, amount)); +} + +/// Returns `true` if the sender has sufficient unlocked balance for the +/// transfer. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `from` - The sender address. +/// * `amount` - The transfer amount. +/// * `token` - The token address. +pub fn can_transfer(e: &Env, from: &Address, amount: i128, token: &Address) -> bool { + assert!( + hooks_verified(e), + "InitialLockupPeriodModule: not armed — call verify_hook_wiring() after wiring hooks \ + [CanTransfer, Created, Transferred, Destroyed]" + ); + if amount < 0 { + return false; + } + + let total_locked = get_total_locked(e, token, from); + if total_locked == 0 { + return true; + } + + let balance = get_internal_balance(e, token, from); + let free = balance - total_locked; + + if free >= amount { + return true; + } + + let locks = get_locks(e, token, from); + let unlocked = calculate_unlocked_amount(e, &locks); + (free + unlocked) >= amount +} diff --git a/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/test.rs b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/test.rs new file mode 100644 index 000000000..35090f6d7 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/test.rs @@ -0,0 +1,308 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, + testutils::{Address as _, Ledger as _}, + vec, Address, Env, +}; + +use super::storage::{ + can_transfer, configure_lockup_period, get_internal_balance, get_locks, get_total_locked, + on_created, on_destroyed, on_transfer, pre_set_lockup_state, set_internal_balance, set_locks, + set_total_locked, 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)); + }); +} + +#[test] +fn can_transfer_returns_true_without_locks_and_false_for_negative_amount() { + let e = Env::default(); + let module_id = e.register(TestModuleContract, ()); + let token = Address::generate(&e); + let wallet = Address::generate(&e); + + e.as_contract(&module_id, || { + arm_hooks(&e); + + assert!(!can_transfer(&e, &wallet, -1, &token)); + assert!(can_transfer(&e, &wallet, 1_000, &token)); + }); +} + +#[test] +fn on_created_locks_minted_amount_when_period_is_configured() { + let e = Env::default(); + e.ledger().set_timestamp(100); + + let module_id = e.register(TestModuleContract, ()); + let token = Address::generate(&e); + let wallet = Address::generate(&e); + + e.as_contract(&module_id, || { + configure_lockup_period(&e, &token, 60); + + on_created(&e, &wallet, 40, &token); + + let locks = get_locks(&e, &token, &wallet); + assert_eq!(locks.len(), 1); + let lock = locks.get(0).unwrap(); + assert_eq!(lock.amount, 40); + assert_eq!(lock.release_timestamp, 160); + assert_eq!(get_total_locked(&e, &token, &wallet), 40); + assert_eq!(get_internal_balance(&e, &token, &wallet), 40); + }); +} + +#[test] +fn on_transfer_consumes_unlocked_locks_before_updating_balances() { + let e = Env::default(); + e.ledger().set_timestamp(100); + + let module_id = e.register(TestModuleContract, ()); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let recipient = Address::generate(&e); + + e.as_contract(&module_id, || { + set_internal_balance(&e, &token, &sender, 100); + set_internal_balance(&e, &token, &recipient, 10); + set_locks( + &e, + &token, + &sender, + &vec![ + &e, + LockedTokens { amount: 30, release_timestamp: 90 }, + LockedTokens { amount: 40, release_timestamp: 200 }, + ], + ); + set_total_locked(&e, &token, &sender, 70); + + on_transfer(&e, &sender, &recipient, 50, &token); + + let locks = get_locks(&e, &token, &sender); + assert_eq!(locks.len(), 2); + let first_lock = locks.get(0).unwrap(); + assert_eq!(first_lock.amount, 10); + assert_eq!(first_lock.release_timestamp, 90); + let second_lock = locks.get(1).unwrap(); + assert_eq!(second_lock.amount, 40); + assert_eq!(second_lock.release_timestamp, 200); + assert_eq!(get_total_locked(&e, &token, &sender), 50); + assert_eq!(get_internal_balance(&e, &token, &sender), 50); + assert_eq!(get_internal_balance(&e, &token, &recipient), 60); + }); +} + +#[test] +fn on_destroyed_consumes_unlocked_locks_before_burning() { + let e = Env::default(); + e.ledger().set_timestamp(100); + + let module_id = e.register(TestModuleContract, ()); + let token = Address::generate(&e); + let wallet = Address::generate(&e); + + e.as_contract(&module_id, || { + set_internal_balance(&e, &token, &wallet, 100); + set_locks( + &e, + &token, + &wallet, + &vec![ + &e, + LockedTokens { amount: 30, release_timestamp: 90 }, + LockedTokens { amount: 40, release_timestamp: 200 }, + ], + ); + set_total_locked(&e, &token, &wallet, 70); + + on_destroyed(&e, &wallet, 50, &token); + + let locks = get_locks(&e, &token, &wallet); + assert_eq!(locks.len(), 2); + let first_lock = locks.get(0).unwrap(); + assert_eq!(first_lock.amount, 10); + assert_eq!(first_lock.release_timestamp, 90); + let second_lock = locks.get(1).unwrap(); + assert_eq!(second_lock.amount, 40); + assert_eq!(second_lock.release_timestamp, 200); + assert_eq!(get_total_locked(&e, &token, &wallet), 50); + assert_eq!(get_internal_balance(&e, &token, &wallet), 50); + }); +} + +#[test] +#[should_panic] +fn on_destroyed_panics_when_burn_exceeds_unlocked_balance() { + let e = Env::default(); + e.ledger().set_timestamp(100); + + let module_id = e.register(TestModuleContract, ()); + let token = Address::generate(&e); + let wallet = Address::generate(&e); + + e.as_contract(&module_id, || { + set_internal_balance(&e, &token, &wallet, 100); + set_locks( + &e, + &token, + &wallet, + &vec![ + &e, + LockedTokens { amount: 10, release_timestamp: 90 }, + LockedTokens { amount: 70, release_timestamp: 200 }, + ], + ); + set_total_locked(&e, &token, &wallet, 80); + + on_destroyed(&e, &wallet, 40, &token); + }); +} diff --git a/packages/tokens/src/rwa/compliance/modules/max_balance/mod.rs b/packages/tokens/src/rwa/compliance/modules/max_balance/mod.rs new file mode 100644 index 000000000..a20b71d1b --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/max_balance/mod.rs @@ -0,0 +1,32 @@ +//! Max balance compliance module — Stellar port of T-REX +//! [`MaxBalanceModule.sol`][trex-src]. +//! +//! Tracks effective balances per **identity** (not per wallet), enforcing a +//! per-token cap. +//! +//! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/MaxBalanceModule.sol + +pub mod storage; +#[cfg(test)] +mod test; + +use soroban_sdk::{contractevent, Address}; + +/// Emitted when a token's per-identity balance cap is configured. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MaxBalanceSet { + #[topic] + pub token: Address, + pub max_balance: i128, +} + +/// Emitted when an identity balance is pre-seeded via `pre_set_module_state`. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct IDBalancePreSet { + #[topic] + pub token: Address, + pub identity: Address, + pub balance: i128, +} diff --git a/packages/tokens/src/rwa/compliance/modules/max_balance/storage.rs b/packages/tokens/src/rwa/compliance/modules/max_balance/storage.rs new file mode 100644 index 000000000..6a894523b --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/max_balance/storage.rs @@ -0,0 +1,315 @@ +use soroban_sdk::{contracttype, vec, Address, Env, Vec}; + +use super::{IDBalancePreSet, MaxBalanceSet}; +use crate::rwa::compliance::{ + modules::{ + storage::{ + add_i128_or_panic, get_irs_client, hooks_verified, require_non_negative_amount, + sub_i128_or_panic, verify_required_hooks, + }, + MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD, + }, + ComplianceHook, +}; + +#[contracttype] +#[derive(Clone)] +pub enum MaxBalanceStorageKey { + /// Per-token maximum allowed identity balance. + MaxBalance(Address), + /// Balance keyed by (token, identity) — not by wallet. + IDBalance(Address, Address), +} + +// ################## RAW STORAGE ################## + +/// Returns the per-identity balance cap for `token`, or `0` if not set. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +pub fn get_max_balance(e: &Env, token: &Address) -> i128 { + let key = MaxBalanceStorageKey::MaxBalance(token.clone()); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &i128| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or_default() +} + +/// Sets the per-identity balance cap for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `value` - The maximum balance per identity. +pub fn set_max_balance(e: &Env, token: &Address, value: i128) { + let key = MaxBalanceStorageKey::MaxBalance(token.clone()); + e.storage().persistent().set(&key, &value); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} + +/// Returns the tracked balance for `identity` on `token`, or `0` if not +/// set. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `identity` - The on-chain identity address. +pub fn get_id_balance(e: &Env, token: &Address, identity: &Address) -> i128 { + let key = MaxBalanceStorageKey::IDBalance(token.clone(), identity.clone()); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &i128| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or_default() +} + +/// Sets the tracked balance for `identity` on `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `identity` - The on-chain identity address. +/// * `balance` - The new balance value. +pub fn set_id_balance(e: &Env, token: &Address, identity: &Address, balance: i128) { + let key = MaxBalanceStorageKey::IDBalance(token.clone(), identity.clone()); + e.storage().persistent().set(&key, &balance); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} + +// ################## HELPERS ################## + +fn can_increase_identity_balance( + e: &Env, + token: &Address, + identity: &Address, + amount: i128, +) -> bool { + if amount < 0 { + return false; + } + + let max = get_max_balance(e, token); + if max == 0 { + return true; + } + + let current = get_id_balance(e, token, identity); + add_i128_or_panic(e, current, amount) <= max +} + +// ################## ACTIONS ################## + +/// Validates, stores, and emits [`MaxBalanceSet`] for the given cap. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `max` - The maximum balance per identity. +pub fn configure_max_balance(e: &Env, token: &Address, max: i128) { + require_non_negative_amount(e, max); + set_max_balance(e, token, max); + MaxBalanceSet { token: token.clone(), max_balance: max }.publish(e); +} + +/// Pre-seeds the tracked balance for an identity and emits +/// [`IDBalancePreSet`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `identity` - The on-chain identity address. +/// * `balance` - The pre-seeded balance value. +pub fn pre_set_identity_balance(e: &Env, token: &Address, identity: &Address, balance: i128) { + require_non_negative_amount(e, balance); + set_id_balance(e, token, identity, balance); + IDBalancePreSet { token: token.clone(), identity: identity.clone(), balance }.publish(e); +} + +/// Pre-seeds tracked balances for multiple identities. Emits +/// [`IDBalancePreSet`] for each. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `identities` - Identity addresses. +/// * `balances` - Corresponding balance values. +pub fn batch_pre_set_identity_balances( + e: &Env, + token: &Address, + identities: &Vec
, + balances: &Vec, +) { + assert!( + identities.len() == balances.len(), + "MaxBalanceModule: identities and balances length mismatch" + ); + for i in 0..identities.len() { + let id = identities.get(i).unwrap(); + let bal = balances.get(i).unwrap(); + require_non_negative_amount(e, bal); + set_id_balance(e, token, &id, bal); + IDBalancePreSet { token: token.clone(), identity: id, balance: bal }.publish(e); + } +} + +// ################## HOOK WIRING ################## + +/// Returns the set of compliance hooks this module requires. +pub fn required_hooks(e: &Env) -> Vec { + vec![ + e, + ComplianceHook::CanTransfer, + ComplianceHook::CanCreate, + ComplianceHook::Transferred, + ComplianceHook::Created, + ComplianceHook::Destroyed, + ] +} + +/// Cross-calls the compliance contract to verify that this module is +/// registered on all required hooks. +pub fn verify_hook_wiring(e: &Env) { + verify_required_hooks(e, required_hooks(e)); +} + +// ################## COMPLIANCE HOOKS ################## + +/// Updates identity balances after a transfer. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `from` - The sender address. +/// * `to` - The recipient address. +/// * `amount` - The transfer amount. +/// * `token` - The token address. +pub fn on_transfer(e: &Env, from: &Address, to: &Address, amount: i128, token: &Address) { + require_non_negative_amount(e, amount); + + let irs = get_irs_client(e, token); + let from_id = irs.stored_identity(from); + let to_id = irs.stored_identity(to); + + if from_id == to_id { + return; + } + + let from_balance = get_id_balance(e, token, &from_id); + assert!( + can_increase_identity_balance(e, token, &to_id, amount), + "MaxBalanceModule: recipient identity balance exceeds max" + ); + + let to_balance = get_id_balance(e, token, &to_id); + let new_to_balance = add_i128_or_panic(e, to_balance, amount); + set_id_balance(e, token, &from_id, sub_i128_or_panic(e, from_balance, amount)); + set_id_balance(e, token, &to_id, new_to_balance); +} + +/// Updates identity balance after a mint. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `to` - The recipient address. +/// * `amount` - The minted amount. +/// * `token` - The token address. +pub fn on_created(e: &Env, to: &Address, amount: i128, token: &Address) { + require_non_negative_amount(e, amount); + + let irs = get_irs_client(e, token); + let to_id = irs.stored_identity(to); + + assert!( + can_increase_identity_balance(e, token, &to_id, amount), + "MaxBalanceModule: recipient identity balance exceeds max after mint" + ); + + let current = get_id_balance(e, token, &to_id); + let new_balance = add_i128_or_panic(e, current, amount); + set_id_balance(e, token, &to_id, new_balance); +} + +/// Updates identity balance after a burn. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `from` - The burner address. +/// * `amount` - The burned amount. +/// * `token` - The token address. +pub fn on_destroyed(e: &Env, from: &Address, amount: i128, token: &Address) { + require_non_negative_amount(e, amount); + + let irs = get_irs_client(e, token); + let from_id = irs.stored_identity(from); + + let current = get_id_balance(e, token, &from_id); + set_id_balance(e, token, &from_id, sub_i128_or_panic(e, current, amount)); +} + +/// Checks whether a transfer would exceed the recipient identity's +/// balance cap. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `from` - The sender address. +/// * `to` - The recipient address. +/// * `amount` - The transfer amount. +/// * `token` - The token address. +pub fn can_transfer(e: &Env, from: &Address, to: &Address, amount: i128, token: &Address) -> bool { + assert!( + hooks_verified(e), + "MaxBalanceModule: not armed — call verify_hook_wiring() after wiring hooks [CanTransfer, \ + CanCreate, Transferred, Created, Destroyed]" + ); + if amount < 0 { + return false; + } + let irs = get_irs_client(e, token); + let from_id = irs.stored_identity(from); + let to_id = irs.stored_identity(to); + + if from_id == to_id { + return true; + } + + can_increase_identity_balance(e, token, &to_id, amount) +} + +/// Checks whether a mint would exceed the recipient identity's balance +/// cap. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `to` - The recipient address. +/// * `amount` - The mint amount. +/// * `token` - The token address. +pub fn can_create(e: &Env, to: &Address, amount: i128, token: &Address) -> bool { + assert!( + hooks_verified(e), + "MaxBalanceModule: not armed — call verify_hook_wiring() after wiring hooks [CanTransfer, \ + CanCreate, Transferred, Created, Destroyed]" + ); + if amount < 0 { + return false; + } + let irs = get_irs_client(e, token); + let to_id = irs.stored_identity(to); + can_increase_identity_balance(e, token, &to_id, amount) +} diff --git a/packages/tokens/src/rwa/compliance/modules/max_balance/test.rs b/packages/tokens/src/rwa/compliance/modules/max_balance/test.rs new file mode 100644 index 000000000..c45ae068d --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/max_balance/test.rs @@ -0,0 +1,447 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, Address, Env, Val, Vec, +}; + +use super::storage::{ + can_create, can_transfer, get_id_balance, on_created, on_destroyed, on_transfer, + set_id_balance, set_max_balance, verify_hook_wiring, +}; +use crate::rwa::{ + compliance::{ + modules::storage::{ + hooks_verified, set_compliance_address, set_irs_address, ComplianceModuleStorageKey, + }, + Compliance, ComplianceHook, + }, + identity_registry_storage::{CountryDataManager, IdentityRegistryStorage}, + utils::token_binder::TokenBinder, +}; + +#[contract] +struct MockIRSContract; + +#[contracttype] +#[derive(Clone)] +enum MockIRSStorageKey { + Identity(Address), +} + +#[contractimpl] +impl TokenBinder for MockIRSContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl IdentityRegistryStorage for MockIRSContract { + fn add_identity( + _e: &Env, + _account: Address, + _identity: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_identity is not used in these tests"); + } + + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { + unreachable!("remove_identity is not used in these tests"); + } + + fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { + unreachable!("modify_identity is not used in these tests"); + } + + fn recover_identity( + _e: &Env, + _old_account: Address, + _new_account: Address, + _operator: Address, + ) { + unreachable!("recover_identity is not used in these tests"); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + e.storage() + .persistent() + .get(&MockIRSStorageKey::Identity(account.clone())) + .unwrap_or(account) + } +} + +#[contractimpl] +impl CountryDataManager for MockIRSContract { + fn add_country_data_entries( + _e: &Env, + _account: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_country_data_entries is not used in these tests"); + } + + fn modify_country_data( + _e: &Env, + _account: Address, + _index: u32, + _country_data: Val, + _operator: Address, + ) { + unreachable!("modify_country_data is not used in these tests"); + } + + fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { + unreachable!("delete_country_data is not used in these tests"); + } + + fn get_country_data_entries(e: &Env, _account: Address) -> Vec { + Vec::new(e) + } +} + +#[contractimpl] +impl MockIRSContract { + pub fn set_identity(e: &Env, account: Address, identity: Address) { + e.storage().persistent().set(&MockIRSStorageKey::Identity(account), &identity); + } +} + +#[contract] +struct MockComplianceContract; + +#[contracttype] +#[derive(Clone)] +enum MockComplianceStorageKey { + Registered(ComplianceHook, Address), +} + +#[contractimpl] +impl Compliance for MockComplianceContract { + fn add_module_to(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("add_module_to is not used in these tests"); + } + + fn remove_module_from(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("remove_module_from is not used in these tests"); + } + + fn get_modules_for_hook(_e: &Env, _hook: ComplianceHook) -> Vec
{ + unreachable!("get_modules_for_hook is not used in these tests"); + } + + fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool { + e.storage().persistent().has(&MockComplianceStorageKey::Registered(hook, module)) + } + + fn transferred(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) { + unreachable!("transferred is not used in these tests"); + } + + fn created(_e: &Env, _to: Address, _amount: i128, _token: Address) { + unreachable!("created is not used in these tests"); + } + + fn destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) { + unreachable!("destroyed is not used in these tests"); + } + + fn can_transfer( + _e: &Env, + _from: Address, + _to: Address, + _amount: i128, + _token: Address, + ) -> bool { + unreachable!("can_transfer is not used in these tests"); + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + unreachable!("can_create is not used in these tests"); + } +} + +#[contractimpl] +impl TokenBinder for MockComplianceContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl MockComplianceContract { + pub fn register_hook(e: &Env, hook: ComplianceHook, module: Address) { + e.storage().persistent().set(&MockComplianceStorageKey::Registered(hook, module), &true); + } +} + +#[contract] +struct TestMaxBalanceContract; + +fn arm_hooks(e: &Env) { + e.storage().instance().set(&ComplianceModuleStorageKey::HooksVerified, &true); +} + +#[test] +fn verify_hook_wiring_sets_cache_when_registered() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let compliance_id = e.register(MockComplianceContract, ()); + let compliance = MockComplianceContractClient::new(&e, &compliance_id); + + for hook in [ + ComplianceHook::CanTransfer, + ComplianceHook::CanCreate, + ComplianceHook::Transferred, + ComplianceHook::Created, + ComplianceHook::Destroyed, + ] { + compliance.register_hook(&hook, &module_id); + } + + e.as_contract(&module_id, || { + set_compliance_address(&e, &compliance_id); + + verify_hook_wiring(&e); + + assert!(hooks_verified(&e)); + }); +} + +#[test] +fn can_create_rejects_mint_when_cap_would_be_exceeded() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let recipient = Address::generate(&e); + let recipient_identity = Address::generate(&e); + + irs.set_identity(&recipient, &recipient_identity); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + arm_hooks(&e); + set_max_balance(&e, &token, 100); + set_id_balance(&e, &token, &recipient_identity, 60); + + assert!(!can_create(&e, &recipient, 50, &token)); + assert!(can_create(&e, &recipient, 40, &token)); + }); +} + +#[test] +fn can_transfer_checks_distinct_recipient_identity_balance() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let recipient = Address::generate(&e); + let sender_identity = Address::generate(&e); + let recipient_identity = Address::generate(&e); + + irs.set_identity(&sender, &sender_identity); + irs.set_identity(&recipient, &recipient_identity); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + arm_hooks(&e); + set_max_balance(&e, &token, 100); + set_id_balance(&e, &token, &recipient_identity, 60); + + assert!(!can_transfer(&e, &sender, &recipient, 50, &token)); + assert!(can_transfer(&e, &sender, &recipient, 40, &token)); + }); +} + +#[test] +fn can_create_allows_without_cap_and_rejects_negative_amount() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let recipient = Address::generate(&e); + let recipient_identity = Address::generate(&e); + + irs.set_identity(&recipient, &recipient_identity); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + arm_hooks(&e); + set_id_balance(&e, &token, &recipient_identity, 500); + + assert!(can_create(&e, &recipient, 1_000, &token)); + assert!(!can_create(&e, &recipient, -1, &token)); + }); +} + +#[test] +fn can_create_rejects_negative_amount_before_requiring_irs() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let token = Address::generate(&e); + let recipient = Address::generate(&e); + + e.as_contract(&module_id, || { + arm_hooks(&e); + + assert!(!can_create(&e, &recipient, -1, &token)); + }); +} + +#[test] +fn can_transfer_rejects_negative_amount_before_requiring_irs() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let recipient = Address::generate(&e); + + e.as_contract(&module_id, || { + arm_hooks(&e); + + assert!(!can_transfer(&e, &sender, &recipient, -1, &token)); + }); +} + +#[test] +fn can_transfer_allows_when_sender_and_recipient_share_identity() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let recipient = Address::generate(&e); + let shared_identity = Address::generate(&e); + + irs.set_identity(&sender, &shared_identity); + irs.set_identity(&recipient, &shared_identity); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + arm_hooks(&e); + set_max_balance(&e, &token, 1); + + assert!(can_transfer(&e, &sender, &recipient, 1_000, &token)); + }); +} + +#[test] +fn on_transfer_updates_balances_for_distinct_identities() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let recipient = Address::generate(&e); + let sender_identity = Address::generate(&e); + let recipient_identity = Address::generate(&e); + + irs.set_identity(&sender, &sender_identity); + irs.set_identity(&recipient, &recipient_identity); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_max_balance(&e, &token, 200); + set_id_balance(&e, &token, &sender_identity, 100); + set_id_balance(&e, &token, &recipient_identity, 20); + + on_transfer(&e, &sender, &recipient, 30, &token); + + assert_eq!(get_id_balance(&e, &token, &sender_identity), 70); + assert_eq!(get_id_balance(&e, &token, &recipient_identity), 50); + }); +} + +#[test] +fn on_transfer_is_noop_for_same_identity() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let recipient = Address::generate(&e); + let shared_identity = Address::generate(&e); + + irs.set_identity(&sender, &shared_identity); + irs.set_identity(&recipient, &shared_identity); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_id_balance(&e, &token, &shared_identity, 100); + + on_transfer(&e, &sender, &recipient, 30, &token); + + assert_eq!(get_id_balance(&e, &token, &shared_identity), 100); + }); +} + +#[test] +fn on_created_updates_identity_balance() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let recipient = Address::generate(&e); + let recipient_identity = Address::generate(&e); + + irs.set_identity(&recipient, &recipient_identity); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_max_balance(&e, &token, 200); + set_id_balance(&e, &token, &recipient_identity, 50); + + on_created(&e, &recipient, 30, &token); + + assert_eq!(get_id_balance(&e, &token, &recipient_identity), 80); + }); +} + +#[test] +fn on_destroyed_updates_identity_balance() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let holder = Address::generate(&e); + let holder_identity = Address::generate(&e); + + irs.set_identity(&holder, &holder_identity); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_id_balance(&e, &token, &holder_identity, 90); + + on_destroyed(&e, &holder, 40, &token); + + assert_eq!(get_id_balance(&e, &token, &holder_identity), 50); + }); +} diff --git a/packages/tokens/src/rwa/compliance/modules/mod.rs b/packages/tokens/src/rwa/compliance/modules/mod.rs index f4e065161..29995ad41 100644 --- a/packages/tokens/src/rwa/compliance/modules/mod.rs +++ b/packages/tokens/src/rwa/compliance/modules/mod.rs @@ -1,6 +1,13 @@ use soroban_sdk::{contracterror, contracttrait, Address, Env, String}; +pub mod country_allow; +pub mod country_restrict; +pub mod initial_lockup_period; +pub mod max_balance; pub mod storage; +pub mod supply_limit; +pub mod time_transfers_limits; +pub mod transfer_restrict; #[cfg(test)] mod test; diff --git a/packages/tokens/src/rwa/compliance/modules/supply_limit/mod.rs b/packages/tokens/src/rwa/compliance/modules/supply_limit/mod.rs new file mode 100644 index 000000000..6082d490d --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/supply_limit/mod.rs @@ -0,0 +1,21 @@ +//! Supply cap compliance module — Stellar port of T-REX +//! [`SupplyLimitModule.sol`][trex-src]. +//! +//! Caps the total number of tokens that can be minted for a given token. +//! +//! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/SupplyLimitModule.sol + +pub mod storage; +#[cfg(test)] +mod test; + +use soroban_sdk::{contractevent, Address}; + +/// Emitted when a token's supply cap is configured or changed. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SupplyLimitSet { + #[topic] + pub token: Address, + pub limit: i128, +} diff --git a/packages/tokens/src/rwa/compliance/modules/supply_limit/storage.rs b/packages/tokens/src/rwa/compliance/modules/supply_limit/storage.rs new file mode 100644 index 000000000..d337e5a94 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/supply_limit/storage.rs @@ -0,0 +1,198 @@ +use soroban_sdk::{contracttype, panic_with_error, vec, Address, Env, Vec}; + +use super::SupplyLimitSet; +use crate::rwa::compliance::{ + modules::{ + storage::{ + add_i128_or_panic, hooks_verified, require_non_negative_amount, sub_i128_or_panic, + verify_required_hooks, + }, + ComplianceModuleError, MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD, + }, + ComplianceHook, +}; + +#[contracttype] +#[derive(Clone)] +pub enum SupplyLimitStorageKey { + /// Per-token supply cap. + SupplyLimit(Address), + /// Per-token internal supply counter (updated via hooks). + InternalSupply(Address), +} + +// ################## RAW STORAGE ################## + +/// Returns the supply limit for `token`, or `0` if not set. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +pub fn get_supply_limit(e: &Env, token: &Address) -> i128 { + let key = SupplyLimitStorageKey::SupplyLimit(token.clone()); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &i128| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or_default() +} + +/// Returns the supply limit for `token`, panicking if not configured. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// +/// # Errors +/// +/// * [`ComplianceModuleError::MissingLimit`] - When no supply limit has been +/// configured for this token. +pub fn get_supply_limit_or_panic(e: &Env, token: &Address) -> i128 { + let key = SupplyLimitStorageKey::SupplyLimit(token.clone()); + let limit: i128 = e + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| panic_with_error!(e, ComplianceModuleError::MissingLimit)); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + limit +} + +/// Sets the supply limit for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `limit` - The maximum total supply. +pub fn set_supply_limit(e: &Env, token: &Address, limit: i128) { + let key = SupplyLimitStorageKey::SupplyLimit(token.clone()); + e.storage().persistent().set(&key, &limit); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} + +/// Returns the internal supply counter for `token`, or `0` if not set. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +pub fn get_internal_supply(e: &Env, token: &Address) -> i128 { + let key = SupplyLimitStorageKey::InternalSupply(token.clone()); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &i128| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or_default() +} + +/// Sets the internal supply counter for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `supply` - The new supply value. +pub fn set_internal_supply(e: &Env, token: &Address, supply: i128) { + let key = SupplyLimitStorageKey::InternalSupply(token.clone()); + e.storage().persistent().set(&key, &supply); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} + +// ################## ACTIONS ################## + +/// Validates, stores, and emits [`SupplyLimitSet`] for the given cap. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `limit` - The supply cap. +pub fn configure_supply_limit(e: &Env, token: &Address, limit: i128) { + require_non_negative_amount(e, limit); + set_supply_limit(e, token, limit); + SupplyLimitSet { token: token.clone(), limit }.publish(e); +} + +/// Pre-seeds the internal supply counter for a token. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `supply` - The pre-seeded supply value. +pub fn pre_set_supply(e: &Env, token: &Address, supply: i128) { + require_non_negative_amount(e, supply); + set_internal_supply(e, token, supply); +} + +// ################## HOOK WIRING ################## + +/// Returns the set of compliance hooks this module requires. +pub fn required_hooks(e: &Env) -> Vec { + vec![e, ComplianceHook::CanCreate, ComplianceHook::Created, ComplianceHook::Destroyed] +} + +/// Cross-calls the compliance contract to verify that this module is +/// registered on all required hooks. +pub fn verify_hook_wiring(e: &Env) { + verify_required_hooks(e, required_hooks(e)); +} + +// ################## COMPLIANCE HOOKS ################## + +/// Updates the internal supply counter after a mint. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `amount` - The minted amount. +/// * `token` - The token address. +pub fn on_created(e: &Env, amount: i128, token: &Address) { + require_non_negative_amount(e, amount); + let current = get_internal_supply(e, token); + set_internal_supply(e, token, add_i128_or_panic(e, current, amount)); +} + +/// Updates the internal supply counter after a burn. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `amount` - The burned amount. +/// * `token` - The token address. +pub fn on_destroyed(e: &Env, amount: i128, token: &Address) { + require_non_negative_amount(e, amount); + let current = get_internal_supply(e, token); + set_internal_supply(e, token, sub_i128_or_panic(e, current, amount)); +} + +/// Checks whether a mint would exceed the supply cap. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `amount` - The mint amount. +/// * `token` - The token address. +pub fn can_create(e: &Env, amount: i128, token: &Address) -> bool { + assert!( + hooks_verified(e), + "SupplyLimitModule: not armed — call verify_hook_wiring() after wiring hooks [CanCreate, \ + Created, Destroyed]" + ); + if amount < 0 { + return false; + } + let limit = get_supply_limit(e, token); + if limit == 0 { + return true; + } + let supply = get_internal_supply(e, token); + add_i128_or_panic(e, supply, amount) <= limit +} diff --git a/packages/tokens/src/rwa/compliance/modules/supply_limit/test.rs b/packages/tokens/src/rwa/compliance/modules/supply_limit/test.rs new file mode 100644 index 000000000..18245ba92 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/supply_limit/test.rs @@ -0,0 +1,207 @@ +extern crate std; + +use soroban_sdk::{contract, contractimpl, contracttype, testutils::Address as _, Address, Env}; + +use super::storage::{ + can_create, configure_supply_limit, get_internal_supply, get_supply_limit_or_panic, on_created, + on_destroyed, pre_set_supply, verify_hook_wiring, +}; +use crate::rwa::{ + compliance::{ + modules::storage::{hooks_verified, set_compliance_address, ComplianceModuleStorageKey}, + Compliance, ComplianceHook, + }, + utils::token_binder::TokenBinder, +}; + +#[contract] +struct MockComplianceContract; + +#[contracttype] +#[derive(Clone)] +enum MockComplianceStorageKey { + Registered(ComplianceHook, Address), +} + +#[contractimpl] +impl Compliance for MockComplianceContract { + fn add_module_to(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("add_module_to is not used in these tests"); + } + + fn remove_module_from(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("remove_module_from is not used in these tests"); + } + + fn get_modules_for_hook(_e: &Env, _hook: ComplianceHook) -> soroban_sdk::Vec
{ + unreachable!("get_modules_for_hook is not used in these tests"); + } + + fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool { + e.storage().persistent().has(&MockComplianceStorageKey::Registered(hook, module)) + } + + fn transferred(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) { + unreachable!("transferred is not used in these tests"); + } + + fn created(_e: &Env, _to: Address, _amount: i128, _token: Address) { + unreachable!("created is not used in these tests"); + } + + fn destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) { + unreachable!("destroyed is not used in these tests"); + } + + fn can_transfer( + _e: &Env, + _from: Address, + _to: Address, + _amount: i128, + _token: Address, + ) -> bool { + unreachable!("can_transfer is not used in these tests"); + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + unreachable!("can_create is not used in these tests"); + } +} + +#[contractimpl] +impl TokenBinder for MockComplianceContract { + fn linked_tokens(e: &Env) -> soroban_sdk::Vec
{ + soroban_sdk::Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl MockComplianceContract { + pub fn register_hook(e: &Env, hook: ComplianceHook, module: Address) { + e.storage().persistent().set(&MockComplianceStorageKey::Registered(hook, module), &true); + } +} + +#[contract] +struct TestSupplyLimitContract; + +fn arm_hooks(e: &Env) { + e.storage().instance().set(&ComplianceModuleStorageKey::HooksVerified, &true); +} + +#[test] +fn verify_hook_wiring_sets_cache_when_registered() { + let e = Env::default(); + let module_id = e.register(TestSupplyLimitContract, ()); + let compliance_id = e.register(MockComplianceContract, ()); + let compliance = MockComplianceContractClient::new(&e, &compliance_id); + + for hook in [ComplianceHook::CanCreate, ComplianceHook::Created, ComplianceHook::Destroyed] { + compliance.register_hook(&hook, &module_id); + } + + e.as_contract(&module_id, || { + set_compliance_address(&e, &compliance_id); + + verify_hook_wiring(&e); + + assert!(hooks_verified(&e)); + }); +} + +#[test] +fn get_supply_limit_returns_zero_when_unconfigured() { + let e = Env::default(); + let module_id = e.register(TestSupplyLimitContract, ()); + let token = Address::generate(&e); + + e.as_contract(&module_id, || { + assert_eq!(super::storage::get_supply_limit(&e, &token), 0); + }); +} + +#[test] +fn can_create_allows_when_limit_is_unset_and_rejects_negative_amount() { + let e = Env::default(); + let module_id = e.register(TestSupplyLimitContract, ()); + let token = Address::generate(&e); + + e.as_contract(&module_id, || { + arm_hooks(&e); + + assert!(can_create(&e, 100, &token)); + assert!(!can_create(&e, -1, &token)); + }); +} + +#[test] +fn hooks_update_internal_supply_and_cap_future_mints() { + let e = Env::default(); + let module_id = e.register(TestSupplyLimitContract, ()); + let token = Address::generate(&e); + + e.as_contract(&module_id, || { + arm_hooks(&e); + configure_supply_limit(&e, &token, 100); + + assert!(can_create(&e, 80, &token)); + on_created(&e, 80, &token); + assert_eq!(get_internal_supply(&e, &token), 80); + + assert!(!can_create(&e, 30, &token)); + + on_destroyed(&e, 20, &token); + assert_eq!(get_internal_supply(&e, &token), 60); + assert!(can_create(&e, 40, &token)); + }); +} + +#[test] +fn pre_set_internal_supply_seeds_existing_supply_for_cap_checks() { + let e = Env::default(); + let module_id = e.register(TestSupplyLimitContract, ()); + let token = Address::generate(&e); + + e.as_contract(&module_id, || { + arm_hooks(&e); + configure_supply_limit(&e, &token, 100); + pre_set_supply(&e, &token, 90); + + assert_eq!(get_internal_supply(&e, &token), 90); + assert!(!can_create(&e, 11, &token)); + assert!(can_create(&e, 10, &token)); + }); +} + +#[test] +fn get_supply_limit_or_panic_returns_configured_limit() { + let e = Env::default(); + let module_id = e.register(TestSupplyLimitContract, ()); + let token = Address::generate(&e); + + e.as_contract(&module_id, || { + configure_supply_limit(&e, &token, 100); + + assert_eq!(get_supply_limit_or_panic(&e, &token), 100); + }); +} + +#[test] +#[should_panic] +fn get_supply_limit_or_panic_panics_when_unconfigured() { + let e = Env::default(); + let module_id = e.register(TestSupplyLimitContract, ()); + let token = Address::generate(&e); + + e.as_contract(&module_id, || { + let _ = get_supply_limit_or_panic(&e, &token); + }); +} diff --git a/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/mod.rs b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/mod.rs new file mode 100644 index 000000000..49215ef2d --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/mod.rs @@ -0,0 +1,34 @@ +//! Time-windowed transfer-limits compliance module — Stellar port of T-REX +//! [`TimeTransfersLimitsModule.sol`][trex-src]. +//! +//! Limits transfer volume within configurable time windows, tracking counters +//! per **identity** (not per wallet). +//! +//! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/TimeTransfersLimitsModule.sol + +pub mod storage; +#[cfg(test)] +mod test; + +use soroban_sdk::{contractevent, Address}; +pub use storage::{Limit, TransferCounter}; + +pub const MAX_LIMITS_PER_TOKEN: u32 = 4; + +/// Emitted when a time-window limit is added or updated. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TimeTransferLimitUpdated { + #[topic] + pub token: Address, + pub limit: Limit, +} + +/// Emitted when a time-window limit is removed. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TimeTransferLimitRemoved { + #[topic] + pub token: Address, + pub limit_time: u64, +} diff --git a/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/storage.rs b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/storage.rs new file mode 100644 index 000000000..b46cde1a6 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/storage.rs @@ -0,0 +1,350 @@ +use soroban_sdk::{contracttype, panic_with_error, vec, Address, Env, Vec}; + +use super::{TimeTransferLimitRemoved, TimeTransferLimitUpdated, MAX_LIMITS_PER_TOKEN}; +use crate::rwa::compliance::{ + modules::{ + storage::{ + add_i128_or_panic, get_irs_client, hooks_verified, require_non_negative_amount, + set_irs_address, verify_required_hooks, + }, + ComplianceModuleError, MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD, + }, + ComplianceHook, +}; + +/// A single time-window limit: `limit_value` tokens may be transferred +/// within a rolling window of `limit_time` seconds. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Limit { + pub limit_time: u64, + pub limit_value: i128, +} + +/// Tracks cumulative transfer volume for one identity within one window. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransferCounter { + pub value: i128, + pub timer: u64, +} + +#[contracttype] +#[derive(Clone)] +pub enum TimeTransfersLimitsStorageKey { + /// Per-token list of configured time-window limits. + Limits(Address), + /// Counter keyed by (token, identity, window_seconds). + Counter(Address, Address, u64), +} + +// ################## RAW STORAGE ################## + +/// Returns the list of time-window limits for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +pub fn get_limits(e: &Env, token: &Address) -> Vec { + let key = TimeTransfersLimitsStorageKey::Limits(token.clone()); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &Vec| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or_else(|| Vec::new(e)) +} + +/// Persists the list of time-window limits for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `limits` - The updated limits list. +pub fn set_limits(e: &Env, token: &Address, limits: &Vec) { + let key = TimeTransfersLimitsStorageKey::Limits(token.clone()); + e.storage().persistent().set(&key, limits); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} + +/// Returns the transfer counter for a given identity and time window. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `identity` - The on-chain identity address. +/// * `limit_time` - The time-window duration in seconds. +pub fn get_counter( + e: &Env, + token: &Address, + identity: &Address, + limit_time: u64, +) -> TransferCounter { + let key = TimeTransfersLimitsStorageKey::Counter(token.clone(), identity.clone(), limit_time); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &TransferCounter| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or(TransferCounter { value: 0, timer: 0 }) +} + +/// Persists the transfer counter for a given identity and time window. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `identity` - The on-chain identity address. +/// * `limit_time` - The time-window duration in seconds. +/// * `counter` - The updated counter value. +pub fn set_counter( + e: &Env, + token: &Address, + identity: &Address, + limit_time: u64, + counter: &TransferCounter, +) { + let key = TimeTransfersLimitsStorageKey::Counter(token.clone(), identity.clone(), limit_time); + e.storage().persistent().set(&key, counter); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} + +// ################## HELPERS ################## + +fn is_counter_finished(e: &Env, token: &Address, identity: &Address, limit_time: u64) -> bool { + let counter = get_counter(e, token, identity, limit_time); + counter.timer <= e.ledger().timestamp() +} + +fn reset_counter_if_needed(e: &Env, token: &Address, identity: &Address, limit_time: u64) { + if is_counter_finished(e, token, identity, limit_time) { + let counter = + TransferCounter { value: 0, timer: e.ledger().timestamp().saturating_add(limit_time) }; + set_counter(e, token, identity, limit_time, &counter); + } +} + +fn increase_counters(e: &Env, token: &Address, identity: &Address, value: i128) { + let limits = get_limits(e, token); + for limit in limits.iter() { + reset_counter_if_needed(e, token, identity, limit.limit_time); + let mut counter = get_counter(e, token, identity, limit.limit_time); + counter.value = add_i128_or_panic(e, counter.value, value); + set_counter(e, token, identity, limit.limit_time, &counter); + } +} + +// ################## ACTIONS ################## + +/// Configures the identity registry storage address for a token. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `irs` - The identity registry storage address. +pub fn configure_irs(e: &Env, token: &Address, irs: &Address) { + set_irs_address(e, token, irs); +} + +/// Sets or updates a time-window transfer limit for `token` and emits +/// [`TimeTransferLimitUpdated`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `limit` - The limit to set. +pub fn set_time_transfer_limit(e: &Env, token: &Address, limit: &Limit) { + assert!(limit.limit_time > 0, "limit_time must be greater than zero"); + require_non_negative_amount(e, limit.limit_value); + let mut limits = get_limits(e, token); + + let mut replaced = false; + for i in 0..limits.len() { + let current = limits.get(i).expect("limit exists"); + if current.limit_time == limit.limit_time { + limits.set(i, limit.clone()); + replaced = true; + break; + } + } + + if !replaced { + if limits.len() >= MAX_LIMITS_PER_TOKEN { + panic_with_error!(e, ComplianceModuleError::TooManyLimits); + } + limits.push_back(limit.clone()); + } + + set_limits(e, token, &limits); + TimeTransferLimitUpdated { token: token.clone(), limit: limit.clone() }.publish(e); +} + +/// Sets or updates multiple time-window transfer limits in a single call. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `limits` - The limits to set. +pub fn batch_set_time_transfer_limit(e: &Env, token: &Address, limits: &Vec) { + for limit in limits.iter() { + set_time_transfer_limit(e, token, &limit); + } +} + +/// Removes a time-window transfer limit and emits +/// [`TimeTransferLimitRemoved`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `limit_time` - The time-window to remove. +pub fn remove_time_transfer_limit(e: &Env, token: &Address, limit_time: u64) { + let mut limits = get_limits(e, token); + + let mut found = false; + for i in 0..limits.len() { + let current = limits.get(i).expect("limit exists"); + if current.limit_time == limit_time { + limits.remove(i); + found = true; + break; + } + } + + if !found { + panic_with_error!(e, ComplianceModuleError::MissingLimit); + } + + set_limits(e, token, &limits); + TimeTransferLimitRemoved { token: token.clone(), limit_time }.publish(e); +} + +/// Removes multiple time-window transfer limits in a single call. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `limit_times` - The time-windows to remove. +pub fn batch_remove_time_transfer_limit(e: &Env, token: &Address, limit_times: &Vec) { + for lt in limit_times.iter() { + remove_time_transfer_limit(e, token, lt); + } +} + +/// Pre-seeds a transfer counter for a given identity and time window. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `identity` - The on-chain identity address. +/// * `limit_time` - The time-window duration in seconds. +/// * `counter` - The counter value to set. +pub fn pre_set_transfer_counter( + e: &Env, + token: &Address, + identity: &Address, + limit_time: u64, + counter: &TransferCounter, +) { + require_non_negative_amount(e, counter.value); + assert!(limit_time > 0, "limit_time must be greater than zero"); + + let mut found = false; + for limit in get_limits(e, token).iter() { + if limit.limit_time == limit_time { + found = true; + break; + } + } + + if !found { + panic_with_error!(e, ComplianceModuleError::MissingLimit); + } + + set_counter(e, token, identity, limit_time, counter); +} + +// ################## HOOK WIRING ################## + +/// Returns the set of compliance hooks this module requires. +pub fn required_hooks(e: &Env) -> Vec { + vec![e, ComplianceHook::CanTransfer, ComplianceHook::Transferred] +} + +/// Cross-calls the compliance contract to verify that this module is +/// registered on all required hooks. +pub fn verify_hook_wiring(e: &Env) { + verify_required_hooks(e, required_hooks(e)); +} + +// ################## COMPLIANCE HOOKS ################## + +/// Resolves the sender's identity and increments transfer counters. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `from` - The sender address. +/// * `amount` - The transfer amount. +/// * `token` - The token address. +pub fn on_transfer(e: &Env, from: &Address, amount: i128, token: &Address) { + require_non_negative_amount(e, amount); + let irs = get_irs_client(e, token); + let from_id = irs.stored_identity(from); + increase_counters(e, token, &from_id, amount); +} + +/// Returns `true` if the transfer does not exceed any configured +/// time-window limit. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `from` - The sender address. +/// * `amount` - The transfer amount. +/// * `token` - The token address. +/// +/// # Errors +/// +/// * [`crate::rwa::compliance::modules::ComplianceModuleError::IdentityRegistryNotSet`] +/// - When no IRS has been configured for `token`. +pub fn can_transfer(e: &Env, from: &Address, amount: i128, token: &Address) -> bool { + assert!( + hooks_verified(e), + "TimeTransfersLimitsModule: not armed — call verify_hook_wiring() after wiring hooks \ + [CanTransfer, Transferred]" + ); + if amount < 0 { + return false; + } + let irs = get_irs_client(e, token); + let from_id = irs.stored_identity(from); + let limits = get_limits(e, token); + + for limit in limits.iter() { + if amount > limit.limit_value { + return false; + } + + if !is_counter_finished(e, token, &from_id, limit.limit_time) { + let counter = get_counter(e, token, &from_id, limit.limit_time); + if add_i128_or_panic(e, counter.value, amount) > limit.limit_value { + return false; + } + } + } + + true +} diff --git a/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/test.rs b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/test.rs new file mode 100644 index 000000000..65aca552f --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/test.rs @@ -0,0 +1,381 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, + testutils::{Address as _, Ledger as _}, + Address, Env, Val, Vec, +}; + +use super::storage::{ + can_transfer, get_counter, get_limits, on_transfer, pre_set_transfer_counter, + remove_time_transfer_limit, 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 }); + }); +} + +#[test] +fn set_time_transfer_limit_replaces_existing_window() { + let e = Env::default(); + let module_id = e.register(TestModuleContract, ()); + let token = Address::generate(&e); + + e.as_contract(&module_id, || { + set_time_transfer_limit(&e, &token, &Limit { limit_time: 60, limit_value: 100 }); + set_time_transfer_limit(&e, &token, &Limit { limit_time: 60, limit_value: 150 }); + + let limits = get_limits(&e, &token); + assert_eq!(limits.len(), 1); + assert_eq!(limits.get(0).unwrap(), Limit { limit_time: 60, limit_value: 150 }); + }); +} + +#[test] +fn on_transfer_resets_finished_counter_before_incrementing() { + let e = Env::default(); + e.ledger().set_timestamp(100); + + let module_id = e.register(TestModuleContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + 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_irs_address(&e, &token, &irs_id); + 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: 100 }, + ); + + on_transfer(&e, &sender, 20, &token); + + assert_eq!( + get_counter(&e, &token, &sender_identity, 60), + TransferCounter { value: 20, timer: 160 } + ); + }); +} + +#[test] +#[should_panic] +fn remove_time_transfer_limit_panics_when_limit_is_missing() { + let e = Env::default(); + let module_id = e.register(TestModuleContract, ()); + let token = Address::generate(&e); + + e.as_contract(&module_id, || { + remove_time_transfer_limit(&e, &token, 60); + }); +} + +#[test] +#[should_panic] +fn pre_set_transfer_counter_panics_when_limit_is_missing() { + let e = Env::default(); + let module_id = e.register(TestModuleContract, ()); + let token = Address::generate(&e); + let identity = Address::generate(&e); + + e.as_contract(&module_id, || { + pre_set_transfer_counter( + &e, + &token, + &identity, + 60, + &TransferCounter { value: 10, timer: 100 }, + ); + }); +} + +#[test] +fn can_transfer_rejects_negative_amount_and_amounts_above_limit() { + let e = Env::default(); + let module_id = e.register(TestModuleContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + 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, || { + arm_hooks(&e); + assert!(!can_transfer(&e, &sender, -1, &token)); + + set_irs_address(&e, &token, &irs_id); + set_time_transfer_limit(&e, &token, &Limit { limit_time: 60, limit_value: 100 }); + + assert!(!can_transfer(&e, &sender, 101, &token)); + }); +} diff --git a/packages/tokens/src/rwa/compliance/modules/transfer_restrict/mod.rs b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/mod.rs new file mode 100644 index 000000000..bf87e6117 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/mod.rs @@ -0,0 +1,31 @@ +//! Transfer restriction (address allowlist) compliance module — Stellar port +//! of T-REX [`TransferRestrictModule.sol`][trex-src]. +//! +//! Maintains a per-token address allowlist. Transfers pass if the sender is +//! on the list; otherwise the recipient must be. +//! +//! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/TransferRestrictModule.sol + +pub mod storage; +#[cfg(test)] +mod test; + +use soroban_sdk::{contractevent, Address}; + +/// Emitted when an address is added to the transfer allowlist. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UserAllowed { + #[topic] + pub token: Address, + pub user: Address, +} + +/// Emitted when an address is removed from the transfer allowlist. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UserDisallowed { + #[topic] + pub token: Address, + pub user: Address, +} diff --git a/packages/tokens/src/rwa/compliance/modules/transfer_restrict/storage.rs b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/storage.rs new file mode 100644 index 000000000..77337ae0c --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/storage.rs @@ -0,0 +1,135 @@ +use soroban_sdk::{contracttype, Address, Env, Vec}; + +use super::{UserAllowed, UserDisallowed}; +use crate::rwa::compliance::modules::{MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD}; + +#[contracttype] +#[derive(Clone)] +pub enum TransferRestrictStorageKey { + /// Per-(token, address) allowlist flag. + AllowedUser(Address, Address), +} + +// ################## RAW STORAGE ################## + +/// Returns whether `user` is on the transfer allowlist for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `user` - The user address to check. +pub fn is_user_allowed(e: &Env, token: &Address, user: &Address) -> bool { + let key = TransferRestrictStorageKey::AllowedUser(token.clone(), user.clone()); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &bool| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or_default() +} + +/// Adds `user` to the transfer allowlist for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `user` - The user address to allow. +pub fn set_user_allowed(e: &Env, token: &Address, user: &Address) { + let key = TransferRestrictStorageKey::AllowedUser(token.clone(), user.clone()); + e.storage().persistent().set(&key, &true); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} + +/// Removes `user` from the transfer allowlist for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `user` - The user address to disallow. +pub fn remove_user_allowed(e: &Env, token: &Address, user: &Address) { + e.storage() + .persistent() + .remove(&TransferRestrictStorageKey::AllowedUser(token.clone(), user.clone())); +} + +// ################## ACTIONS ################## + +/// Adds `user` to the transfer allowlist for `token` and emits +/// [`UserAllowed`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `user` - The address to allow. +pub fn allow_user(e: &Env, token: &Address, user: &Address) { + set_user_allowed(e, token, user); + UserAllowed { token: token.clone(), user: user.clone() }.publish(e); +} + +/// Removes `user` from the transfer allowlist for `token` and emits +/// [`UserDisallowed`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `user` - The address to disallow. +pub fn disallow_user(e: &Env, token: &Address, user: &Address) { + remove_user_allowed(e, token, user); + UserDisallowed { token: token.clone(), user: user.clone() }.publish(e); +} + +/// Adds multiple users to the transfer allowlist in a single call. +/// Emits [`UserAllowed`] for each user added. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `users` - The addresses to allow. +pub fn batch_allow_users(e: &Env, token: &Address, users: &Vec
) { + for user in users.iter() { + set_user_allowed(e, token, &user); + UserAllowed { token: token.clone(), user }.publish(e); + } +} + +/// Removes multiple users from the transfer allowlist in a single call. +/// Emits [`UserDisallowed`] for each user removed. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `users` - The addresses to disallow. +pub fn batch_disallow_users(e: &Env, token: &Address, users: &Vec
) { + for user in users.iter() { + remove_user_allowed(e, token, &user); + UserDisallowed { token: token.clone(), user }.publish(e); + } +} + +// ################## COMPLIANCE HOOKS ################## + +/// Returns `true` if the sender or recipient is allowlisted. +/// +/// T-REX semantics: if the sender is allowlisted, the transfer passes; +/// otherwise the recipient must be allowlisted. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `from` - The sender address. +/// * `to` - The recipient address. +/// * `token` - The token address. +pub fn can_transfer(e: &Env, from: &Address, to: &Address, token: &Address) -> bool { + if is_user_allowed(e, token, from) { + return true; + } + is_user_allowed(e, token, to) +} diff --git a/packages/tokens/src/rwa/compliance/modules/transfer_restrict/test.rs b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/test.rs new file mode 100644 index 000000000..d181be4e4 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/test.rs @@ -0,0 +1,62 @@ +extern crate std; + +use soroban_sdk::{contract, testutils::Address as _, vec, Address, Env}; + +use super::storage::{ + allow_user, batch_allow_users, batch_disallow_users, can_transfer, disallow_user, + is_user_allowed, +}; +use crate::rwa::compliance::modules::storage::set_compliance_address; + +#[contract] +struct TestModuleContract; + +#[test] +fn can_transfer_allows_sender_or_recipient_when_allowlisted() { + let e = Env::default(); + + let module_id = e.register(TestModuleContract, ()); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let recipient = Address::generate(&e); + let outsider = Address::generate(&e); + + e.as_contract(&module_id, || { + set_compliance_address(&e, &compliance); + + assert!(!can_transfer(&e, &sender, &recipient, &token)); + + allow_user(&e, &token, &sender); + assert!(can_transfer(&e, &sender, &outsider, &token)); + + disallow_user(&e, &token, &sender); + allow_user(&e, &token, &recipient); + assert!(can_transfer(&e, &outsider, &recipient, &token)); + }); +} + +#[test] +fn batch_allow_and_disallow_update_allowlist_entries() { + let e = Env::default(); + + let module_id = e.register(TestModuleContract, ()); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let user_a = Address::generate(&e); + let user_b = Address::generate(&e); + + e.as_contract(&module_id, || { + set_compliance_address(&e, &compliance); + + batch_allow_users(&e, &token, &vec![&e, user_a.clone(), user_b.clone()]); + + assert!(is_user_allowed(&e, &token, &user_a)); + assert!(is_user_allowed(&e, &token, &user_b)); + + batch_disallow_users(&e, &token, &vec![&e, user_a.clone(), user_b.clone()]); + + assert!(!is_user_allowed(&e, &token, &user_a)); + assert!(!is_user_allowed(&e, &token, &user_b)); + }); +}