From 30c56ef6750bf9b3f78421e9dc50503afb68df67 Mon Sep 17 00:00:00 2001 From: Aleksandr Pasevin Date: Mon, 23 Mar 2026 18:36:44 +0200 Subject: [PATCH 1/2] feat(rwa): add standalone country compliance modules Transplant the reviewed country compliance modules and example crates onto upstream/main so they can ship as an independent PR without the old stack. --- Cargo.lock | 16 + Cargo.toml | 2 + examples/rwa-country-allow/Cargo.toml | 15 + examples/rwa-country-allow/README.md | 50 ++ examples/rwa-country-allow/src/lib.rs | 88 ++++ examples/rwa-country-restrict/Cargo.toml | 15 + examples/rwa-country-restrict/README.md | 50 ++ examples/rwa-country-restrict/src/lib.rs | 88 ++++ .../compliance/modules/country_allow/mod.rs | 455 +++++++++++++++++ .../modules/country_allow/storage.rs | 54 +++ .../modules/country_restrict/mod.rs | 459 ++++++++++++++++++ .../modules/country_restrict/storage.rs | 54 +++ .../tokens/src/rwa/compliance/modules/mod.rs | 2 + 13 files changed, 1348 insertions(+) create mode 100644 examples/rwa-country-allow/Cargo.toml create mode 100644 examples/rwa-country-allow/README.md create mode 100644 examples/rwa-country-allow/src/lib.rs create mode 100644 examples/rwa-country-restrict/Cargo.toml create mode 100644 examples/rwa-country-restrict/README.md create mode 100644 examples/rwa-country-restrict/src/lib.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/country_allow/mod.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/country_allow/storage.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/country_restrict/storage.rs diff --git a/Cargo.lock b/Cargo.lock index 469b78e3b..66d730416 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1556,6 +1556,22 @@ dependencies = [ "stellar-tokens", ] +[[package]] +name = "rwa-country-allow" +version = "0.6.0" +dependencies = [ + "soroban-sdk", + "stellar-tokens", +] + +[[package]] +name = "rwa-country-restrict" +version = "0.6.0" +dependencies = [ + "soroban-sdk", + "stellar-tokens", +] + [[package]] name = "rwa-identity-example" version = "0.6.0" diff --git a/Cargo.toml b/Cargo.toml index 1fd7fbbfc..a7e97bfcb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,8 @@ members = [ "examples/ownable", "examples/pausable", "examples/rwa/*", + "examples/rwa-country-allow", + "examples/rwa-country-restrict", "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..a60408eab --- /dev/null +++ b/examples/rwa-country-allow/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "rwa-country-allow" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-tokens = { workspace = true } diff --git a/examples/rwa-country-allow/README.md b/examples/rwa-country-allow/README.md new file mode 100644 index 000000000..f0b88aa54 --- /dev/null +++ b/examples/rwa-country-allow/README.md @@ -0,0 +1,50 @@ +# Country Allow Module + +Concrete deployable example of the `CountryAllow` compliance module for Stellar +RWA tokens. + +## What it enforces + +This module allows tokens to be minted or transferred only to recipients whose +registered identity has at least one country code that appears in the module's +per-token allowlist. + +The country lookup is performed through the Identity Registry Storage (IRS), so +the module must be configured with an IRS contract for each token it serves. + +## Authorization model + +This example uses the bootstrap-admin pattern introduced in this port: + +- The constructor stores a one-time `admin` +- Before `set_compliance_address`, privileged configuration calls require that + admin's auth +- After `set_compliance_address`, the same configuration calls require auth + from the bound Compliance contract +- `set_compliance_address` itself remains a one-time admin action + +This lets the module be configured from the CLI before it is locked to the +Compliance contract. + +## Main entrypoints + +- `__constructor(admin)` initializes the bootstrap admin +- `set_identity_registry_storage(token, irs)` stores the IRS address for a + token +- `add_allowed_country(token, country)` adds an ISO 3166-1 numeric code to the + allowlist +- `remove_allowed_country(token, country)` removes a country code +- `batch_allow_countries(token, countries)` updates multiple entries +- `batch_disallow_countries(token, countries)` removes multiple entries +- `is_country_allowed(token, country)` reads the current allowlist state +- `set_compliance_address(compliance)` performs the one-time handoff to the + Compliance contract + +## Notes + +- Storage is token-scoped, so one deployed module can be reused across many + tokens +- This module validates on the compliance read hooks used for transfers and + mints; it does not require extra state-tracking hooks +- In the deploy example, the module is configured before binding and then wired + to the `CanTransfer` and `CanCreate` hooks diff --git a/examples/rwa-country-allow/src/lib.rs b/examples/rwa-country-allow/src/lib.rs new file mode 100644 index 000000000..f2fd11b23 --- /dev/null +++ b/examples/rwa-country-allow/src/lib.rs @@ -0,0 +1,88 @@ +#![no_std] + +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::modules::{ + country_allow::{ + storage::{is_country_allowed, remove_country_allowed, set_country_allowed}, + CountryAllow, CountryAllowed, CountryUnallowed, + }, + storage::{set_compliance_address, set_irs_address, ComplianceModuleStorageKey}, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct CountryAllowContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl CountryAllowContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } +} + +#[contractimpl(contracttrait)] +impl CountryAllow for CountryAllowContract { + fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { + require_module_admin_or_compliance_auth(e); + set_irs_address(e, &token, &irs); + } + + fn add_allowed_country(e: &Env, token: Address, country: u32) { + require_module_admin_or_compliance_auth(e); + set_country_allowed(e, &token, country); + CountryAllowed { token, country }.publish(e); + } + + fn remove_allowed_country(e: &Env, token: Address, country: u32) { + require_module_admin_or_compliance_auth(e); + remove_country_allowed(e, &token, country); + CountryUnallowed { token, country }.publish(e); + } + + fn batch_allow_countries(e: &Env, token: Address, countries: Vec) { + require_module_admin_or_compliance_auth(e); + for country in countries.iter() { + set_country_allowed(e, &token, country); + CountryAllowed { token: token.clone(), country }.publish(e); + } + } + + fn batch_disallow_countries(e: &Env, token: Address, countries: Vec) { + require_module_admin_or_compliance_auth(e); + for country in countries.iter() { + remove_country_allowed(e, &token, country); + CountryUnallowed { token: token.clone(), country }.publish(e); + } + } + + fn is_country_allowed(e: &Env, token: Address, country: u32) -> bool { + is_country_allowed(e, &token, country) + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-country-restrict/Cargo.toml b/examples/rwa-country-restrict/Cargo.toml new file mode 100644 index 000000000..27aabc3bc --- /dev/null +++ b/examples/rwa-country-restrict/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "rwa-country-restrict" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-tokens = { workspace = true } diff --git a/examples/rwa-country-restrict/README.md b/examples/rwa-country-restrict/README.md new file mode 100644 index 000000000..104bf6066 --- /dev/null +++ b/examples/rwa-country-restrict/README.md @@ -0,0 +1,50 @@ +# Country Restrict Module + +Concrete deployable example of the `CountryRestrict` compliance module for +Stellar RWA tokens. + +## What it enforces + +This module blocks tokens from being minted or transferred to recipients whose +registered identity has a country code that appears in the module's per-token +restriction list. + +The country lookup is performed through the Identity Registry Storage (IRS), so +the module must be configured with an IRS contract for each token it serves. + +## Authorization model + +This example uses the bootstrap-admin pattern introduced in this port: + +- The constructor stores a one-time `admin` +- Before `set_compliance_address`, privileged configuration calls require that + admin's auth +- After `set_compliance_address`, the same configuration calls require auth + from the bound Compliance contract +- `set_compliance_address` itself remains a one-time admin action + +This lets the module be configured from the CLI before it is locked to the +Compliance contract. + +## Main entrypoints + +- `__constructor(admin)` initializes the bootstrap admin +- `set_identity_registry_storage(token, irs)` stores the IRS address for a + token +- `add_country_restriction(token, country)` adds an ISO 3166-1 numeric code to + the restriction list +- `remove_country_restriction(token, country)` removes a country code +- `batch_restrict_countries(token, countries)` updates multiple entries +- `batch_unrestrict_countries(token, countries)` removes multiple entries +- `is_country_restricted(token, country)` reads the current restriction state +- `set_compliance_address(compliance)` performs the one-time handoff to the + Compliance contract + +## Notes + +- Storage is token-scoped, so one deployed module can be reused across many + tokens +- This module validates on the compliance read hooks used for transfers and + mints; it does not require extra state-tracking hooks +- In the deploy example, the module is configured before binding and then wired + to the `CanTransfer` and `CanCreate` hooks diff --git a/examples/rwa-country-restrict/src/lib.rs b/examples/rwa-country-restrict/src/lib.rs new file mode 100644 index 000000000..8d8d4c140 --- /dev/null +++ b/examples/rwa-country-restrict/src/lib.rs @@ -0,0 +1,88 @@ +#![no_std] + +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::modules::{ + country_restrict::{ + storage::{is_country_restricted, remove_country_restricted, set_country_restricted}, + CountryRestrict, CountryRestricted, CountryUnrestricted, + }, + storage::{set_compliance_address, set_irs_address, ComplianceModuleStorageKey}, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct CountryRestrictContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl CountryRestrictContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } +} + +#[contractimpl(contracttrait)] +impl CountryRestrict for CountryRestrictContract { + fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { + require_module_admin_or_compliance_auth(e); + set_irs_address(e, &token, &irs); + } + + fn add_country_restriction(e: &Env, token: Address, country: u32) { + require_module_admin_or_compliance_auth(e); + set_country_restricted(e, &token, country); + CountryRestricted { token, country }.publish(e); + } + + fn remove_country_restriction(e: &Env, token: Address, country: u32) { + require_module_admin_or_compliance_auth(e); + remove_country_restricted(e, &token, country); + CountryUnrestricted { token, country }.publish(e); + } + + fn batch_restrict_countries(e: &Env, token: Address, countries: Vec) { + require_module_admin_or_compliance_auth(e); + for country in countries.iter() { + set_country_restricted(e, &token, country); + CountryRestricted { token: token.clone(), country }.publish(e); + } + } + + fn batch_unrestrict_countries(e: &Env, token: Address, countries: Vec) { + require_module_admin_or_compliance_auth(e); + for country in countries.iter() { + remove_country_restricted(e, &token, country); + CountryUnrestricted { token: token.clone(), country }.publish(e); + } + } + + fn is_country_restricted(e: &Env, token: Address, country: u32) -> bool { + is_country_restricted(e, &token, country) + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/packages/tokens/src/rwa/compliance/modules/country_allow/mod.rs b/packages/tokens/src/rwa/compliance/modules/country_allow/mod.rs new file mode 100644 index 000000000..fe6cdda69 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/country_allow/mod.rs @@ -0,0 +1,455 @@ +//! Country allowlist compliance module — Stellar port of T-REX +//! [`CountryAllowModule.sol`][trex-src]. +//! +//! Only recipients whose identity has at least one country code in the +//! allowlist may receive tokens. +//! +//! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/CountryAllowModule.sol + +pub mod storage; + +use soroban_sdk::{contractevent, contracttrait, Address, Env, String, Vec}; +use storage::{is_country_allowed, remove_country_allowed, set_country_allowed}; + +use super::storage::{ + country_code, get_compliance_address, get_irs_country_data_entries, module_name, + set_irs_address, +}; + +/// Emitted when a country is added to the allowlist. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CountryAllowed { + #[topic] + pub token: Address, + pub country: u32, +} + +/// Emitted when a country is removed from the allowlist. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CountryUnallowed { + #[topic] + pub token: Address, + pub country: u32, +} + +/// Country allowlist compliance trait. +/// +/// Provides default implementations for maintaining a per-token country +/// allowlist and validating transfers/mints against it via the Identity +/// Registry Storage. +#[contracttrait] +pub trait CountryAllow { + /// Sets the Identity Registry Storage contract address for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token this IRS applies to. + /// * `irs` - The IRS contract address. + /// + /// # Authorization + /// + /// Requires compliance contract authorization. + fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { + get_compliance_address(e).require_auth(); + set_irs_address(e, &token, &irs); + } + + /// Adds a country to the allowlist for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `country` - The ISO 3166-1 numeric country code to allow. + /// + /// # Authorization + /// + /// Requires compliance contract authorization. + /// + /// # Events + /// + /// Emits [`CountryAllowed`]. + fn add_allowed_country(e: &Env, token: Address, country: u32) { + get_compliance_address(e).require_auth(); + set_country_allowed(e, &token, country); + CountryAllowed { token, country }.publish(e); + } + + /// Removes a country from the allowlist for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `country` - The ISO 3166-1 numeric country code to remove. + /// + /// # Authorization + /// + /// Requires compliance contract authorization. + /// + /// # Events + /// + /// Emits [`CountryUnallowed`]. + fn remove_allowed_country(e: &Env, token: Address, country: u32) { + get_compliance_address(e).require_auth(); + remove_country_allowed(e, &token, country); + CountryUnallowed { token, country }.publish(e); + } + + /// Adds multiple countries to the allowlist in a single call. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `countries` - The country codes to allow. + /// + /// # Authorization + /// + /// Requires compliance contract authorization. + /// + /// # Events + /// + /// Emits [`CountryAllowed`] for each country added. + fn batch_allow_countries(e: &Env, token: Address, countries: Vec) { + get_compliance_address(e).require_auth(); + for country in countries.iter() { + set_country_allowed(e, &token, country); + CountryAllowed { token: token.clone(), country }.publish(e); + } + } + + /// Removes multiple countries from the allowlist in a single call. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `countries` - The country codes to remove. + /// + /// # Authorization + /// + /// Requires compliance contract authorization. + /// + /// # Events + /// + /// Emits [`CountryUnallowed`] for each country removed. + fn batch_disallow_countries(e: &Env, token: Address, countries: Vec) { + get_compliance_address(e).require_auth(); + for country in countries.iter() { + remove_country_allowed(e, &token, country); + CountryUnallowed { token: token.clone(), country }.publish(e); + } + } + + /// Returns whether `country` is on the allowlist for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `country` - The ISO 3166-1 numeric country code. + fn is_country_allowed(e: &Env, token: Address, country: u32) -> bool { + is_country_allowed(e, &token, country) + } + + /// No-op — this module does not track transfer state. + fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {} + + /// No-op — this module does not track mint state. + fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {} + + /// No-op — this module does not track burn state. + fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {} + + /// Checks whether `to` has at least one allowed country in the IRS. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `_from` - The sender (unused). + /// * `to` - The recipient whose country data is checked. + /// * `_amount` - The transfer amount (unused). + /// * `token` - The token address. + /// + /// # Returns + /// + /// `true` if the recipient has at least one allowed country, `false` + /// otherwise. + /// + /// # Cross-Contract Calls + /// + /// Calls the IRS to resolve country data for `to`. + fn can_transfer(e: &Env, _from: Address, to: Address, _amount: i128, token: Address) -> bool { + let entries = get_irs_country_data_entries(e, &token, &to); + for entry in entries.iter() { + if is_country_allowed(e, &token, country_code(&entry.country)) { + return true; + } + } + false + } + + /// Delegates to [`can_transfer`](CountryAllow::can_transfer) — same + /// country check applies to mints. + fn can_create(e: &Env, to: Address, amount: i128, token: Address) -> bool { + Self::can_transfer(e, to.clone(), to, amount, token) + } + + /// Returns the module name for identification. + fn name(e: &Env) -> String { + module_name(e, "CountryAllowModule") + } + + /// Returns the compliance contract address. + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + /// Sets the compliance contract address (one-time only). + /// + /// Implementers must gate this entrypoint with bootstrap-admin auth before + /// delegating to + /// [`storage::set_compliance_address`](super::storage::set_compliance_address). + /// + /// + /// # Panics + /// + /// Panics if the compliance address has already been set. + fn set_compliance_address(e: &Env, compliance: Address); +} + +#[cfg(test)] +mod test { + extern crate std; + + use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal, + Val, Vec, + }; + + use super::*; + use crate::rwa::{ + identity_registry_storage::{ + CountryData, CountryDataManager, CountryRelation, IdentityRegistryStorage, + IndividualCountryRelation, OrganizationCountryRelation, + }, + utils::token_binder::TokenBinder, + }; + + #[contract] + struct MockIRSContract; + + #[contracttype] + #[derive(Clone)] + enum MockIRSStorageKey { + Identity(Address), + CountryEntries(Address), + } + + #[contractimpl] + impl TokenBinder for MockIRSContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } + } + + #[contractimpl] + impl IdentityRegistryStorage for MockIRSContract { + fn add_identity( + _e: &Env, + _account: Address, + _identity: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_identity is not used in these tests"); + } + + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { + unreachable!("remove_identity is not used in these tests"); + } + + fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { + unreachable!("modify_identity is not used in these tests"); + } + + fn recover_identity( + _e: &Env, + _old_account: Address, + _new_account: Address, + _operator: Address, + ) { + unreachable!("recover_identity is not used in these tests"); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + e.storage() + .persistent() + .get(&MockIRSStorageKey::Identity(account.clone())) + .unwrap_or(account) + } + } + + #[contractimpl] + impl CountryDataManager for MockIRSContract { + fn add_country_data_entries( + _e: &Env, + _account: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_country_data_entries is not used in these tests"); + } + + fn modify_country_data( + _e: &Env, + _account: Address, + _index: u32, + _country_data: Val, + _operator: Address, + ) { + unreachable!("modify_country_data is not used in these tests"); + } + + fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { + unreachable!("delete_country_data is not used in these tests"); + } + + fn get_country_data_entries(e: &Env, account: Address) -> Vec { + let entries: Vec = e + .storage() + .persistent() + .get(&MockIRSStorageKey::CountryEntries(account)) + .unwrap_or_else(|| Vec::new(e)); + + Vec::from_iter(e, entries.iter().map(|entry| entry.into_val(e))) + } + } + + #[contractimpl] + impl MockIRSContract { + pub fn set_country_data_entries(e: &Env, account: Address, entries: Vec) { + e.storage().persistent().set(&MockIRSStorageKey::CountryEntries(account), &entries); + } + } + + #[contract] + struct TestCountryAllowContract; + + #[contractimpl(contracttrait)] + impl CountryAllow for TestCountryAllowContract { + fn set_compliance_address(_e: &Env, _compliance: Address) { + unreachable!("set_compliance_address is not used in these tests"); + } + } + + fn individual_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Individual(IndividualCountryRelation::Residence(code)), + metadata: None, + } + } + + fn organization_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Organization( + OrganizationCountryRelation::OperatingJurisdiction(code), + ), + metadata: None, + } + } + + #[test] + fn can_transfer_and_create_allow_when_any_country_matches() { + let e = Env::default(); + let module_id = e.register(TestCountryAllowContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let from = Address::generate(&e); + let to = Address::generate(&e); + + irs.set_country_data_entries( + &to, + &vec![&e, individual_country(250), organization_country(276)], + ); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_country_allowed(&e, &token, 276); + + assert!(::can_transfer( + &e, + from.clone(), + to.clone(), + 100, + token.clone(), + )); + assert!(::can_create( + &e, + to.clone(), + 100, + token.clone(), + )); + }); + } + + #[test] + fn can_transfer_and_create_reject_when_no_country_matches() { + let e = Env::default(); + let module_id = e.register(TestCountryAllowContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let from = Address::generate(&e); + let empty_to = Address::generate(&e); + let disallowed_to = Address::generate(&e); + + irs.set_country_data_entries(&disallowed_to, &vec![&e, individual_country(250)]); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_country_allowed(&e, &token, 276); + + assert!(!::can_transfer( + &e, + from.clone(), + empty_to.clone(), + 100, + token.clone(), + )); + assert!(!::can_create( + &e, + empty_to, + 100, + token.clone(), + )); + + assert!(!::can_transfer( + &e, + from.clone(), + disallowed_to.clone(), + 100, + token.clone(), + )); + assert!(!::can_create( + &e, + disallowed_to, + 100, + token.clone(), + )); + }); + } +} diff --git a/packages/tokens/src/rwa/compliance/modules/country_allow/storage.rs b/packages/tokens/src/rwa/compliance/modules/country_allow/storage.rs new file mode 100644 index 000000000..767ca0a14 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/country_allow/storage.rs @@ -0,0 +1,54 @@ +use soroban_sdk::{contracttype, Address, Env}; + +use crate::rwa::compliance::modules::{MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD}; + +#[contracttype] +#[derive(Clone)] +pub enum CountryAllowStorageKey { + /// Per-(token, country) allowlist flag. + AllowedCountry(Address, u32), +} + +/// Returns whether the given country is on the allowlist for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code. +pub fn is_country_allowed(e: &Env, token: &Address, country: u32) -> bool { + let key = CountryAllowStorageKey::AllowedCountry(token.clone(), country); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &bool| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or_default() +} + +/// Adds a country to the allowlist for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code to allow. +pub fn set_country_allowed(e: &Env, token: &Address, country: u32) { + let key = CountryAllowStorageKey::AllowedCountry(token.clone(), country); + e.storage().persistent().set(&key, &true); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} + +/// Removes a country from the allowlist for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code to remove. +pub fn remove_country_allowed(e: &Env, token: &Address, country: u32) { + e.storage() + .persistent() + .remove(&CountryAllowStorageKey::AllowedCountry(token.clone(), country)); +} diff --git a/packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs b/packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs new file mode 100644 index 000000000..09f87a301 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs @@ -0,0 +1,459 @@ +//! Country restriction compliance module — Stellar port of T-REX +//! [`CountryRestrictModule.sol`][trex-src]. +//! +//! Recipients whose identity has a country code on the restriction list are +//! blocked from receiving tokens. +//! +//! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/CountryRestrictModule.sol + +pub mod storage; + +use soroban_sdk::{contractevent, contracttrait, Address, Env, String, Vec}; +use storage::{is_country_restricted, remove_country_restricted, set_country_restricted}; + +use super::storage::{ + country_code, get_compliance_address, get_irs_country_data_entries, module_name, + set_irs_address, +}; + +/// Emitted when a country is added to the restriction list. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CountryRestricted { + #[topic] + pub token: Address, + pub country: u32, +} + +/// Emitted when a country is removed from the restriction list. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CountryUnrestricted { + #[topic] + pub token: Address, + pub country: u32, +} + +/// Country restriction compliance trait. +/// +/// Provides default implementations for maintaining a per-token country +/// restriction list and blocking transfers/mints to recipients from +/// restricted countries via the Identity Registry Storage. +#[contracttrait] +pub trait CountryRestrict { + /// Sets the Identity Registry Storage contract address for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token this IRS applies to. + /// * `irs` - The IRS contract address. + /// + /// # Authorization + /// + /// Requires compliance contract authorization. + fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { + get_compliance_address(e).require_auth(); + set_irs_address(e, &token, &irs); + } + + /// Adds a country to the restriction list for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `country` - The ISO 3166-1 numeric country code to restrict. + /// + /// # Authorization + /// + /// Requires compliance contract authorization. + /// + /// # Events + /// + /// Emits [`CountryRestricted`]. + fn add_country_restriction(e: &Env, token: Address, country: u32) { + get_compliance_address(e).require_auth(); + set_country_restricted(e, &token, country); + CountryRestricted { token, country }.publish(e); + } + + /// Removes a country from the restriction list for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `country` - The ISO 3166-1 numeric country code to unrestrict. + /// + /// # Authorization + /// + /// Requires compliance contract authorization. + /// + /// # Events + /// + /// Emits [`CountryUnrestricted`]. + fn remove_country_restriction(e: &Env, token: Address, country: u32) { + get_compliance_address(e).require_auth(); + remove_country_restricted(e, &token, country); + CountryUnrestricted { token, country }.publish(e); + } + + /// Adds multiple countries to the restriction list in a single call. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `countries` - The country codes to restrict. + /// + /// # Authorization + /// + /// Requires compliance contract authorization. + /// + /// # Events + /// + /// Emits [`CountryRestricted`] for each country added. + fn batch_restrict_countries(e: &Env, token: Address, countries: Vec) { + get_compliance_address(e).require_auth(); + for country in countries.iter() { + set_country_restricted(e, &token, country); + CountryRestricted { token: token.clone(), country }.publish(e); + } + } + + /// Removes multiple countries from the restriction list in a single + /// call. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `countries` - The country codes to unrestrict. + /// + /// # Authorization + /// + /// Requires compliance contract authorization. + /// + /// # Events + /// + /// Emits [`CountryUnrestricted`] for each country removed. + fn batch_unrestrict_countries(e: &Env, token: Address, countries: Vec) { + get_compliance_address(e).require_auth(); + for country in countries.iter() { + remove_country_restricted(e, &token, country); + CountryUnrestricted { token: token.clone(), country }.publish(e); + } + } + + /// Returns whether `country` is on the restriction list for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `country` - The ISO 3166-1 numeric country code. + fn is_country_restricted(e: &Env, token: Address, country: u32) -> bool { + is_country_restricted(e, &token, country) + } + + /// No-op — this module does not track transfer state. + fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {} + + /// No-op — this module does not track mint state. + fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {} + + /// No-op — this module does not track burn state. + fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {} + + /// Checks whether `to` has any restricted country in the IRS. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `_from` - The sender (unused). + /// * `to` - The recipient whose country data is checked. + /// * `_amount` - The transfer amount (unused). + /// * `token` - The token address. + /// + /// # Returns + /// + /// `false` if the recipient has any restricted country, `true` + /// otherwise. + /// + /// # Cross-Contract Calls + /// + /// Calls the IRS to resolve country data for `to`. + fn can_transfer(e: &Env, _from: Address, to: Address, _amount: i128, token: Address) -> bool { + let entries = get_irs_country_data_entries(e, &token, &to); + for entry in entries.iter() { + if is_country_restricted(e, &token, country_code(&entry.country)) { + return false; + } + } + true + } + + /// Delegates to [`can_transfer`](CountryRestrict::can_transfer) — same + /// country check applies to mints. + fn can_create(e: &Env, to: Address, amount: i128, token: Address) -> bool { + Self::can_transfer(e, to.clone(), to, amount, token) + } + + /// Returns the module name for identification. + fn name(e: &Env) -> String { + module_name(e, "CountryRestrictModule") + } + + /// Returns the compliance contract address. + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + /// Sets the compliance contract address (one-time only). + /// + /// Implementers must gate this entrypoint with bootstrap-admin auth before + /// delegating to + /// [`storage::set_compliance_address`](super::storage::set_compliance_address). + /// + /// + /// # Panics + /// + /// Panics if the compliance address has already been set. + fn set_compliance_address(e: &Env, compliance: Address); +} + +#[cfg(test)] +mod test { + extern crate std; + + use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal, + Val, Vec, + }; + + use super::*; + use crate::rwa::{ + identity_registry_storage::{ + CountryData, CountryDataManager, CountryRelation, IdentityRegistryStorage, + IndividualCountryRelation, OrganizationCountryRelation, + }, + utils::token_binder::TokenBinder, + }; + + #[contract] + struct MockIRSContract; + + #[contracttype] + #[derive(Clone)] + enum MockIRSStorageKey { + Identity(Address), + CountryEntries(Address), + } + + #[contractimpl] + impl TokenBinder for MockIRSContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } + } + + #[contractimpl] + impl IdentityRegistryStorage for MockIRSContract { + fn add_identity( + _e: &Env, + _account: Address, + _identity: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_identity is not used in these tests"); + } + + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { + unreachable!("remove_identity is not used in these tests"); + } + + fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { + unreachable!("modify_identity is not used in these tests"); + } + + fn recover_identity( + _e: &Env, + _old_account: Address, + _new_account: Address, + _operator: Address, + ) { + unreachable!("recover_identity is not used in these tests"); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + e.storage() + .persistent() + .get(&MockIRSStorageKey::Identity(account.clone())) + .unwrap_or(account) + } + } + + #[contractimpl] + impl CountryDataManager for MockIRSContract { + fn add_country_data_entries( + _e: &Env, + _account: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_country_data_entries is not used in these tests"); + } + + fn modify_country_data( + _e: &Env, + _account: Address, + _index: u32, + _country_data: Val, + _operator: Address, + ) { + unreachable!("modify_country_data is not used in these tests"); + } + + fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { + unreachable!("delete_country_data is not used in these tests"); + } + + fn get_country_data_entries(e: &Env, account: Address) -> Vec { + let entries: Vec = e + .storage() + .persistent() + .get(&MockIRSStorageKey::CountryEntries(account)) + .unwrap_or_else(|| Vec::new(e)); + + Vec::from_iter(e, entries.iter().map(|entry| entry.into_val(e))) + } + } + + #[contractimpl] + impl MockIRSContract { + pub fn set_country_data_entries(e: &Env, account: Address, entries: Vec) { + e.storage().persistent().set(&MockIRSStorageKey::CountryEntries(account), &entries); + } + } + + #[contract] + struct TestCountryRestrictContract; + + #[contractimpl(contracttrait)] + impl CountryRestrict for TestCountryRestrictContract { + fn set_compliance_address(_e: &Env, _compliance: Address) { + unreachable!("set_compliance_address is not used in these tests"); + } + } + + fn individual_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Individual(IndividualCountryRelation::Residence(code)), + metadata: None, + } + } + + fn organization_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Organization( + OrganizationCountryRelation::OperatingJurisdiction(code), + ), + metadata: None, + } + } + + #[test] + fn can_transfer_and_create_reject_when_any_country_is_restricted() { + let e = Env::default(); + let module_id = e.register(TestCountryRestrictContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let from = Address::generate(&e); + let to = Address::generate(&e); + + irs.set_country_data_entries( + &to, + &vec![&e, individual_country(250), organization_country(408)], + ); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_country_restricted(&e, &token, 408); + + assert!(!::can_transfer( + &e, + from.clone(), + to.clone(), + 100, + token.clone(), + )); + assert!(!::can_create( + &e, + to.clone(), + 100, + token.clone(), + )); + }); + } + + #[test] + fn can_transfer_and_create_allow_when_no_country_is_restricted() { + let e = Env::default(); + let module_id = e.register(TestCountryRestrictContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let from = Address::generate(&e); + let empty_to = Address::generate(&e); + let unrestricted_to = Address::generate(&e); + + irs.set_country_data_entries( + &unrestricted_to, + &vec![&e, individual_country(250), organization_country(276)], + ); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_country_restricted(&e, &token, 408); + + assert!(::can_transfer( + &e, + from.clone(), + empty_to.clone(), + 100, + token.clone(), + )); + assert!(::can_create( + &e, + empty_to, + 100, + token.clone(), + )); + + assert!(::can_transfer( + &e, + from.clone(), + unrestricted_to.clone(), + 100, + token.clone(), + )); + assert!(::can_create( + &e, + unrestricted_to, + 100, + token.clone(), + )); + }); + } +} diff --git a/packages/tokens/src/rwa/compliance/modules/country_restrict/storage.rs b/packages/tokens/src/rwa/compliance/modules/country_restrict/storage.rs new file mode 100644 index 000000000..5d8f13cb2 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/country_restrict/storage.rs @@ -0,0 +1,54 @@ +use soroban_sdk::{contracttype, Address, Env}; + +use crate::rwa::compliance::modules::{MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD}; + +#[contracttype] +#[derive(Clone)] +pub enum CountryRestrictStorageKey { + /// Per-(token, country) restriction flag. + RestrictedCountry(Address, u32), +} + +/// Returns whether the given country is on the restriction list for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code. +pub fn is_country_restricted(e: &Env, token: &Address, country: u32) -> bool { + let key = CountryRestrictStorageKey::RestrictedCountry(token.clone(), country); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &bool| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or_default() +} + +/// Adds a country to the restriction list for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code to restrict. +pub fn set_country_restricted(e: &Env, token: &Address, country: u32) { + let key = CountryRestrictStorageKey::RestrictedCountry(token.clone(), country); + e.storage().persistent().set(&key, &true); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} + +/// Removes a country from the restriction list for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code to unrestrict. +pub fn remove_country_restricted(e: &Env, token: &Address, country: u32) { + e.storage() + .persistent() + .remove(&CountryRestrictStorageKey::RestrictedCountry(token.clone(), country)); +} diff --git a/packages/tokens/src/rwa/compliance/modules/mod.rs b/packages/tokens/src/rwa/compliance/modules/mod.rs index f4e065161..3af719f4a 100644 --- a/packages/tokens/src/rwa/compliance/modules/mod.rs +++ b/packages/tokens/src/rwa/compliance/modules/mod.rs @@ -1,5 +1,7 @@ use soroban_sdk::{contracterror, contracttrait, Address, Env, String}; +pub mod country_allow; +pub mod country_restrict; pub mod storage; #[cfg(test)] From da529e5658dec2e6b0d529b7734db241726a49cc Mon Sep 17 00:00:00 2001 From: Aleksandr Pasevin Date: Tue, 24 Mar 2026 18:46:32 +0200 Subject: [PATCH 2/2] refactor(rwa): move country module tests into sibling files Align the country compliance modules with the repository's preferred test layout by extracting inline test modules into dedicated test.rs files. --- .../compliance/modules/country_allow/mod.rs | 234 +---------------- .../compliance/modules/country_allow/test.rs | 228 +++++++++++++++++ .../modules/country_restrict/mod.rs | 237 +----------------- .../modules/country_restrict/test.rs | 231 +++++++++++++++++ 4 files changed, 463 insertions(+), 467 deletions(-) create mode 100644 packages/tokens/src/rwa/compliance/modules/country_allow/test.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/country_restrict/test.rs diff --git a/packages/tokens/src/rwa/compliance/modules/country_allow/mod.rs b/packages/tokens/src/rwa/compliance/modules/country_allow/mod.rs index fe6cdda69..c2c50a3b3 100644 --- a/packages/tokens/src/rwa/compliance/modules/country_allow/mod.rs +++ b/packages/tokens/src/rwa/compliance/modules/country_allow/mod.rs @@ -7,6 +7,8 @@ //! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/CountryAllowModule.sol pub mod storage; +#[cfg(test)] +mod test; use soroban_sdk::{contractevent, contracttrait, Address, Env, String, Vec}; use storage::{is_country_allowed, remove_country_allowed, set_country_allowed}; @@ -221,235 +223,3 @@ pub trait CountryAllow { /// Panics if the compliance address has already been set. fn set_compliance_address(e: &Env, compliance: Address); } - -#[cfg(test)] -mod test { - extern crate std; - - use soroban_sdk::{ - contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal, - Val, Vec, - }; - - use super::*; - use crate::rwa::{ - identity_registry_storage::{ - CountryData, CountryDataManager, CountryRelation, IdentityRegistryStorage, - IndividualCountryRelation, OrganizationCountryRelation, - }, - utils::token_binder::TokenBinder, - }; - - #[contract] - struct MockIRSContract; - - #[contracttype] - #[derive(Clone)] - enum MockIRSStorageKey { - Identity(Address), - CountryEntries(Address), - } - - #[contractimpl] - impl TokenBinder for MockIRSContract { - fn linked_tokens(e: &Env) -> Vec
{ - Vec::new(e) - } - - fn bind_token(_e: &Env, _token: Address, _operator: Address) { - unreachable!("bind_token is not used in these tests"); - } - - fn unbind_token(_e: &Env, _token: Address, _operator: Address) { - unreachable!("unbind_token is not used in these tests"); - } - } - - #[contractimpl] - impl IdentityRegistryStorage for MockIRSContract { - fn add_identity( - _e: &Env, - _account: Address, - _identity: Address, - _country_data_list: Vec, - _operator: Address, - ) { - unreachable!("add_identity is not used in these tests"); - } - - fn remove_identity(_e: &Env, _account: Address, _operator: Address) { - unreachable!("remove_identity is not used in these tests"); - } - - fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { - unreachable!("modify_identity is not used in these tests"); - } - - fn recover_identity( - _e: &Env, - _old_account: Address, - _new_account: Address, - _operator: Address, - ) { - unreachable!("recover_identity is not used in these tests"); - } - - fn stored_identity(e: &Env, account: Address) -> Address { - e.storage() - .persistent() - .get(&MockIRSStorageKey::Identity(account.clone())) - .unwrap_or(account) - } - } - - #[contractimpl] - impl CountryDataManager for MockIRSContract { - fn add_country_data_entries( - _e: &Env, - _account: Address, - _country_data_list: Vec, - _operator: Address, - ) { - unreachable!("add_country_data_entries is not used in these tests"); - } - - fn modify_country_data( - _e: &Env, - _account: Address, - _index: u32, - _country_data: Val, - _operator: Address, - ) { - unreachable!("modify_country_data is not used in these tests"); - } - - fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { - unreachable!("delete_country_data is not used in these tests"); - } - - fn get_country_data_entries(e: &Env, account: Address) -> Vec { - let entries: Vec = e - .storage() - .persistent() - .get(&MockIRSStorageKey::CountryEntries(account)) - .unwrap_or_else(|| Vec::new(e)); - - Vec::from_iter(e, entries.iter().map(|entry| entry.into_val(e))) - } - } - - #[contractimpl] - impl MockIRSContract { - pub fn set_country_data_entries(e: &Env, account: Address, entries: Vec) { - e.storage().persistent().set(&MockIRSStorageKey::CountryEntries(account), &entries); - } - } - - #[contract] - struct TestCountryAllowContract; - - #[contractimpl(contracttrait)] - impl CountryAllow for TestCountryAllowContract { - fn set_compliance_address(_e: &Env, _compliance: Address) { - unreachable!("set_compliance_address is not used in these tests"); - } - } - - fn individual_country(code: u32) -> CountryData { - CountryData { - country: CountryRelation::Individual(IndividualCountryRelation::Residence(code)), - metadata: None, - } - } - - fn organization_country(code: u32) -> CountryData { - CountryData { - country: CountryRelation::Organization( - OrganizationCountryRelation::OperatingJurisdiction(code), - ), - metadata: None, - } - } - - #[test] - fn can_transfer_and_create_allow_when_any_country_matches() { - let e = Env::default(); - let module_id = e.register(TestCountryAllowContract, ()); - let irs_id = e.register(MockIRSContract, ()); - let irs = MockIRSContractClient::new(&e, &irs_id); - let token = Address::generate(&e); - let from = Address::generate(&e); - let to = Address::generate(&e); - - irs.set_country_data_entries( - &to, - &vec![&e, individual_country(250), organization_country(276)], - ); - - e.as_contract(&module_id, || { - set_irs_address(&e, &token, &irs_id); - set_country_allowed(&e, &token, 276); - - assert!(::can_transfer( - &e, - from.clone(), - to.clone(), - 100, - token.clone(), - )); - assert!(::can_create( - &e, - to.clone(), - 100, - token.clone(), - )); - }); - } - - #[test] - fn can_transfer_and_create_reject_when_no_country_matches() { - let e = Env::default(); - let module_id = e.register(TestCountryAllowContract, ()); - let irs_id = e.register(MockIRSContract, ()); - let irs = MockIRSContractClient::new(&e, &irs_id); - let token = Address::generate(&e); - let from = Address::generate(&e); - let empty_to = Address::generate(&e); - let disallowed_to = Address::generate(&e); - - irs.set_country_data_entries(&disallowed_to, &vec![&e, individual_country(250)]); - - e.as_contract(&module_id, || { - set_irs_address(&e, &token, &irs_id); - set_country_allowed(&e, &token, 276); - - assert!(!::can_transfer( - &e, - from.clone(), - empty_to.clone(), - 100, - token.clone(), - )); - assert!(!::can_create( - &e, - empty_to, - 100, - token.clone(), - )); - - assert!(!::can_transfer( - &e, - from.clone(), - disallowed_to.clone(), - 100, - token.clone(), - )); - assert!(!::can_create( - &e, - disallowed_to, - 100, - token.clone(), - )); - }); - } -} diff --git a/packages/tokens/src/rwa/compliance/modules/country_allow/test.rs b/packages/tokens/src/rwa/compliance/modules/country_allow/test.rs new file mode 100644 index 000000000..d09f9168d --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/country_allow/test.rs @@ -0,0 +1,228 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal, Val, + Vec, +}; + +use super::*; +use crate::rwa::{ + identity_registry_storage::{ + CountryData, CountryDataManager, CountryRelation, IdentityRegistryStorage, + IndividualCountryRelation, OrganizationCountryRelation, + }, + utils::token_binder::TokenBinder, +}; + +#[contract] +struct MockIRSContract; + +#[contracttype] +#[derive(Clone)] +enum MockIRSStorageKey { + Identity(Address), + CountryEntries(Address), +} + +#[contractimpl] +impl TokenBinder for MockIRSContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl IdentityRegistryStorage for MockIRSContract { + fn add_identity( + _e: &Env, + _account: Address, + _identity: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_identity is not used in these tests"); + } + + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { + unreachable!("remove_identity is not used in these tests"); + } + + fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { + unreachable!("modify_identity is not used in these tests"); + } + + fn recover_identity( + _e: &Env, + _old_account: Address, + _new_account: Address, + _operator: Address, + ) { + unreachable!("recover_identity is not used in these tests"); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + e.storage() + .persistent() + .get(&MockIRSStorageKey::Identity(account.clone())) + .unwrap_or(account) + } +} + +#[contractimpl] +impl CountryDataManager for MockIRSContract { + fn add_country_data_entries( + _e: &Env, + _account: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_country_data_entries is not used in these tests"); + } + + fn modify_country_data( + _e: &Env, + _account: Address, + _index: u32, + _country_data: Val, + _operator: Address, + ) { + unreachable!("modify_country_data is not used in these tests"); + } + + fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { + unreachable!("delete_country_data is not used in these tests"); + } + + fn get_country_data_entries(e: &Env, account: Address) -> Vec { + let entries: Vec = e + .storage() + .persistent() + .get(&MockIRSStorageKey::CountryEntries(account)) + .unwrap_or_else(|| Vec::new(e)); + + Vec::from_iter(e, entries.iter().map(|entry| entry.into_val(e))) + } +} + +#[contractimpl] +impl MockIRSContract { + pub fn set_country_data_entries(e: &Env, account: Address, entries: Vec) { + e.storage().persistent().set(&MockIRSStorageKey::CountryEntries(account), &entries); + } +} + +#[contract] +struct TestCountryAllowContract; + +#[contractimpl(contracttrait)] +impl CountryAllow for TestCountryAllowContract { + fn set_compliance_address(_e: &Env, _compliance: Address) { + unreachable!("set_compliance_address is not used in these tests"); + } +} + +fn individual_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Individual(IndividualCountryRelation::Residence(code)), + metadata: None, + } +} + +fn organization_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Organization(OrganizationCountryRelation::OperatingJurisdiction( + code, + )), + metadata: None, + } +} + +#[test] +fn can_transfer_and_create_allow_when_any_country_matches() { + let e = Env::default(); + let module_id = e.register(TestCountryAllowContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let from = Address::generate(&e); + let to = Address::generate(&e); + + irs.set_country_data_entries( + &to, + &vec![&e, individual_country(250), organization_country(276)], + ); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_country_allowed(&e, &token, 276); + + assert!(::can_transfer( + &e, + from.clone(), + to.clone(), + 100, + token.clone(), + )); + assert!(::can_create( + &e, + to.clone(), + 100, + token.clone(), + )); + }); +} + +#[test] +fn can_transfer_and_create_reject_when_no_country_matches() { + let e = Env::default(); + let module_id = e.register(TestCountryAllowContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let from = Address::generate(&e); + let empty_to = Address::generate(&e); + let disallowed_to = Address::generate(&e); + + irs.set_country_data_entries(&disallowed_to, &vec![&e, individual_country(250)]); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_country_allowed(&e, &token, 276); + + assert!(!::can_transfer( + &e, + from.clone(), + empty_to.clone(), + 100, + token.clone(), + )); + assert!(!::can_create( + &e, + empty_to, + 100, + token.clone(), + )); + + assert!(!::can_transfer( + &e, + from.clone(), + disallowed_to.clone(), + 100, + token.clone(), + )); + assert!(!::can_create( + &e, + disallowed_to, + 100, + token.clone(), + )); + }); +} diff --git a/packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs b/packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs index 09f87a301..0c5d85785 100644 --- a/packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs +++ b/packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs @@ -7,6 +7,8 @@ //! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/CountryRestrictModule.sol pub mod storage; +#[cfg(test)] +mod test; use soroban_sdk::{contractevent, contracttrait, Address, Env, String, Vec}; use storage::{is_country_restricted, remove_country_restricted, set_country_restricted}; @@ -222,238 +224,3 @@ pub trait CountryRestrict { /// Panics if the compliance address has already been set. fn set_compliance_address(e: &Env, compliance: Address); } - -#[cfg(test)] -mod test { - extern crate std; - - use soroban_sdk::{ - contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal, - Val, Vec, - }; - - use super::*; - use crate::rwa::{ - identity_registry_storage::{ - CountryData, CountryDataManager, CountryRelation, IdentityRegistryStorage, - IndividualCountryRelation, OrganizationCountryRelation, - }, - utils::token_binder::TokenBinder, - }; - - #[contract] - struct MockIRSContract; - - #[contracttype] - #[derive(Clone)] - enum MockIRSStorageKey { - Identity(Address), - CountryEntries(Address), - } - - #[contractimpl] - impl TokenBinder for MockIRSContract { - fn linked_tokens(e: &Env) -> Vec
{ - Vec::new(e) - } - - fn bind_token(_e: &Env, _token: Address, _operator: Address) { - unreachable!("bind_token is not used in these tests"); - } - - fn unbind_token(_e: &Env, _token: Address, _operator: Address) { - unreachable!("unbind_token is not used in these tests"); - } - } - - #[contractimpl] - impl IdentityRegistryStorage for MockIRSContract { - fn add_identity( - _e: &Env, - _account: Address, - _identity: Address, - _country_data_list: Vec, - _operator: Address, - ) { - unreachable!("add_identity is not used in these tests"); - } - - fn remove_identity(_e: &Env, _account: Address, _operator: Address) { - unreachable!("remove_identity is not used in these tests"); - } - - fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { - unreachable!("modify_identity is not used in these tests"); - } - - fn recover_identity( - _e: &Env, - _old_account: Address, - _new_account: Address, - _operator: Address, - ) { - unreachable!("recover_identity is not used in these tests"); - } - - fn stored_identity(e: &Env, account: Address) -> Address { - e.storage() - .persistent() - .get(&MockIRSStorageKey::Identity(account.clone())) - .unwrap_or(account) - } - } - - #[contractimpl] - impl CountryDataManager for MockIRSContract { - fn add_country_data_entries( - _e: &Env, - _account: Address, - _country_data_list: Vec, - _operator: Address, - ) { - unreachable!("add_country_data_entries is not used in these tests"); - } - - fn modify_country_data( - _e: &Env, - _account: Address, - _index: u32, - _country_data: Val, - _operator: Address, - ) { - unreachable!("modify_country_data is not used in these tests"); - } - - fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { - unreachable!("delete_country_data is not used in these tests"); - } - - fn get_country_data_entries(e: &Env, account: Address) -> Vec { - let entries: Vec = e - .storage() - .persistent() - .get(&MockIRSStorageKey::CountryEntries(account)) - .unwrap_or_else(|| Vec::new(e)); - - Vec::from_iter(e, entries.iter().map(|entry| entry.into_val(e))) - } - } - - #[contractimpl] - impl MockIRSContract { - pub fn set_country_data_entries(e: &Env, account: Address, entries: Vec) { - e.storage().persistent().set(&MockIRSStorageKey::CountryEntries(account), &entries); - } - } - - #[contract] - struct TestCountryRestrictContract; - - #[contractimpl(contracttrait)] - impl CountryRestrict for TestCountryRestrictContract { - fn set_compliance_address(_e: &Env, _compliance: Address) { - unreachable!("set_compliance_address is not used in these tests"); - } - } - - fn individual_country(code: u32) -> CountryData { - CountryData { - country: CountryRelation::Individual(IndividualCountryRelation::Residence(code)), - metadata: None, - } - } - - fn organization_country(code: u32) -> CountryData { - CountryData { - country: CountryRelation::Organization( - OrganizationCountryRelation::OperatingJurisdiction(code), - ), - metadata: None, - } - } - - #[test] - fn can_transfer_and_create_reject_when_any_country_is_restricted() { - let e = Env::default(); - let module_id = e.register(TestCountryRestrictContract, ()); - let irs_id = e.register(MockIRSContract, ()); - let irs = MockIRSContractClient::new(&e, &irs_id); - let token = Address::generate(&e); - let from = Address::generate(&e); - let to = Address::generate(&e); - - irs.set_country_data_entries( - &to, - &vec![&e, individual_country(250), organization_country(408)], - ); - - e.as_contract(&module_id, || { - set_irs_address(&e, &token, &irs_id); - set_country_restricted(&e, &token, 408); - - assert!(!::can_transfer( - &e, - from.clone(), - to.clone(), - 100, - token.clone(), - )); - assert!(!::can_create( - &e, - to.clone(), - 100, - token.clone(), - )); - }); - } - - #[test] - fn can_transfer_and_create_allow_when_no_country_is_restricted() { - let e = Env::default(); - let module_id = e.register(TestCountryRestrictContract, ()); - let irs_id = e.register(MockIRSContract, ()); - let irs = MockIRSContractClient::new(&e, &irs_id); - let token = Address::generate(&e); - let from = Address::generate(&e); - let empty_to = Address::generate(&e); - let unrestricted_to = Address::generate(&e); - - irs.set_country_data_entries( - &unrestricted_to, - &vec![&e, individual_country(250), organization_country(276)], - ); - - e.as_contract(&module_id, || { - set_irs_address(&e, &token, &irs_id); - set_country_restricted(&e, &token, 408); - - assert!(::can_transfer( - &e, - from.clone(), - empty_to.clone(), - 100, - token.clone(), - )); - assert!(::can_create( - &e, - empty_to, - 100, - token.clone(), - )); - - assert!(::can_transfer( - &e, - from.clone(), - unrestricted_to.clone(), - 100, - token.clone(), - )); - assert!(::can_create( - &e, - unrestricted_to, - 100, - token.clone(), - )); - }); - } -} diff --git a/packages/tokens/src/rwa/compliance/modules/country_restrict/test.rs b/packages/tokens/src/rwa/compliance/modules/country_restrict/test.rs new file mode 100644 index 000000000..f94ddd9d1 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/country_restrict/test.rs @@ -0,0 +1,231 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal, Val, + Vec, +}; + +use super::*; +use crate::rwa::{ + identity_registry_storage::{ + CountryData, CountryDataManager, CountryRelation, IdentityRegistryStorage, + IndividualCountryRelation, OrganizationCountryRelation, + }, + utils::token_binder::TokenBinder, +}; + +#[contract] +struct MockIRSContract; + +#[contracttype] +#[derive(Clone)] +enum MockIRSStorageKey { + Identity(Address), + CountryEntries(Address), +} + +#[contractimpl] +impl TokenBinder for MockIRSContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl IdentityRegistryStorage for MockIRSContract { + fn add_identity( + _e: &Env, + _account: Address, + _identity: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_identity is not used in these tests"); + } + + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { + unreachable!("remove_identity is not used in these tests"); + } + + fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { + unreachable!("modify_identity is not used in these tests"); + } + + fn recover_identity( + _e: &Env, + _old_account: Address, + _new_account: Address, + _operator: Address, + ) { + unreachable!("recover_identity is not used in these tests"); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + e.storage() + .persistent() + .get(&MockIRSStorageKey::Identity(account.clone())) + .unwrap_or(account) + } +} + +#[contractimpl] +impl CountryDataManager for MockIRSContract { + fn add_country_data_entries( + _e: &Env, + _account: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_country_data_entries is not used in these tests"); + } + + fn modify_country_data( + _e: &Env, + _account: Address, + _index: u32, + _country_data: Val, + _operator: Address, + ) { + unreachable!("modify_country_data is not used in these tests"); + } + + fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { + unreachable!("delete_country_data is not used in these tests"); + } + + fn get_country_data_entries(e: &Env, account: Address) -> Vec { + let entries: Vec = e + .storage() + .persistent() + .get(&MockIRSStorageKey::CountryEntries(account)) + .unwrap_or_else(|| Vec::new(e)); + + Vec::from_iter(e, entries.iter().map(|entry| entry.into_val(e))) + } +} + +#[contractimpl] +impl MockIRSContract { + pub fn set_country_data_entries(e: &Env, account: Address, entries: Vec) { + e.storage().persistent().set(&MockIRSStorageKey::CountryEntries(account), &entries); + } +} + +#[contract] +struct TestCountryRestrictContract; + +#[contractimpl(contracttrait)] +impl CountryRestrict for TestCountryRestrictContract { + fn set_compliance_address(_e: &Env, _compliance: Address) { + unreachable!("set_compliance_address is not used in these tests"); + } +} + +fn individual_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Individual(IndividualCountryRelation::Residence(code)), + metadata: None, + } +} + +fn organization_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Organization(OrganizationCountryRelation::OperatingJurisdiction( + code, + )), + metadata: None, + } +} + +#[test] +fn can_transfer_and_create_reject_when_any_country_is_restricted() { + let e = Env::default(); + let module_id = e.register(TestCountryRestrictContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let from = Address::generate(&e); + let to = Address::generate(&e); + + irs.set_country_data_entries( + &to, + &vec![&e, individual_country(250), organization_country(408)], + ); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_country_restricted(&e, &token, 408); + + assert!(!::can_transfer( + &e, + from.clone(), + to.clone(), + 100, + token.clone(), + )); + assert!(!::can_create( + &e, + to.clone(), + 100, + token.clone(), + )); + }); +} + +#[test] +fn can_transfer_and_create_allow_when_no_country_is_restricted() { + let e = Env::default(); + let module_id = e.register(TestCountryRestrictContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let from = Address::generate(&e); + let empty_to = Address::generate(&e); + let unrestricted_to = Address::generate(&e); + + irs.set_country_data_entries( + &unrestricted_to, + &vec![&e, individual_country(250), organization_country(276)], + ); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_country_restricted(&e, &token, 408); + + assert!(::can_transfer( + &e, + from.clone(), + empty_to.clone(), + 100, + token.clone(), + )); + assert!(::can_create( + &e, + empty_to, + 100, + token.clone(), + )); + + assert!(::can_transfer( + &e, + from.clone(), + unrestricted_to.clone(), + 100, + token.clone(), + )); + assert!(::can_create( + &e, + unrestricted_to, + 100, + token.clone(), + )); + }); +}