diff --git a/Cargo.lock b/Cargo.lock
index 1432a322d..473be0441 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1578,12 +1578,36 @@ dependencies = [
name = "rwa-compliance-example"
version = "0.7.1"
dependencies = [
+ "rwa-country-allow",
+ "rwa-country-restrict",
+ "rwa-initial-lockup-period",
+ "rwa-max-balance",
+ "rwa-supply-limit",
+ "rwa-time-transfers-limits",
+ "rwa-transfer-restrict",
"soroban-sdk",
"stellar-access",
+ "stellar-contract-utils",
"stellar-macros",
"stellar-tokens",
]
+[[package]]
+name = "rwa-country-allow"
+version = "0.7.1"
+dependencies = [
+ "soroban-sdk",
+ "stellar-tokens",
+]
+
+[[package]]
+name = "rwa-country-restrict"
+version = "0.7.1"
+dependencies = [
+ "soroban-sdk",
+ "stellar-tokens",
+]
+
[[package]]
name = "rwa-identity-example"
version = "0.7.1"
@@ -1614,6 +1638,38 @@ dependencies = [
"stellar-tokens",
]
+[[package]]
+name = "rwa-initial-lockup-period"
+version = "0.7.1"
+dependencies = [
+ "soroban-sdk",
+ "stellar-tokens",
+]
+
+[[package]]
+name = "rwa-max-balance"
+version = "0.7.1"
+dependencies = [
+ "soroban-sdk",
+ "stellar-tokens",
+]
+
+[[package]]
+name = "rwa-supply-limit"
+version = "0.7.1"
+dependencies = [
+ "soroban-sdk",
+ "stellar-tokens",
+]
+
+[[package]]
+name = "rwa-time-transfers-limits"
+version = "0.7.1"
+dependencies = [
+ "soroban-sdk",
+ "stellar-tokens",
+]
+
[[package]]
name = "rwa-token-example"
version = "0.7.1"
@@ -1625,6 +1681,14 @@ dependencies = [
"stellar-tokens",
]
+[[package]]
+name = "rwa-transfer-restrict"
+version = "0.7.1"
+dependencies = [
+ "soroban-sdk",
+ "stellar-tokens",
+]
+
[[package]]
name = "ryu"
version = "1.0.23"
diff --git a/Cargo.toml b/Cargo.toml
index 656eca1fe..75a3f0a0d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -20,7 +20,20 @@ members = [
"examples/nft-sequential-minting",
"examples/ownable",
"examples/pausable",
- "examples/rwa/*",
+ "examples/rwa/claim-issuer",
+ "examples/rwa/claim-topics-and-issuers",
+ "examples/rwa/identity",
+ "examples/rwa/identity-registry",
+ "examples/rwa/identity-verifier",
+ "examples/rwa/token",
+ "examples/rwa-country-allow",
+ "examples/rwa-country-restrict",
+ "examples/rwa-max-balance",
+ "examples/rwa-supply-limit",
+ "examples/rwa-time-transfers-limits",
+ "examples/rwa-transfer-restrict",
+ "examples/rwa-initial-lockup-period",
+ "examples/rwa-compliance",
"examples/sac-admin-generic",
"examples/sac-admin-wrapper",
"examples/multisig-smart-account/*",
diff --git a/examples/rwa-compliance/Cargo.toml b/examples/rwa-compliance/Cargo.toml
new file mode 100644
index 000000000..a5b7d4bbd
--- /dev/null
+++ b/examples/rwa-compliance/Cargo.toml
@@ -0,0 +1,28 @@
+[package]
+name = "rwa-compliance-example"
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+publish = false
+version.workspace = true
+
+[lib]
+crate-type = ["cdylib"]
+doctest = false
+
+[dependencies]
+soroban-sdk = { workspace = true }
+stellar-access = { workspace = true }
+stellar-contract-utils = { workspace = true }
+stellar-macros = { workspace = true }
+stellar-tokens = { workspace = true }
+
+[dev-dependencies]
+soroban-sdk = { workspace = true, features = ["testutils"] }
+rwa-country-allow = { path = "../rwa-country-allow" }
+rwa-country-restrict = { path = "../rwa-country-restrict" }
+rwa-initial-lockup-period = { path = "../rwa-initial-lockup-period" }
+rwa-max-balance = { path = "../rwa-max-balance" }
+rwa-supply-limit = { path = "../rwa-supply-limit" }
+rwa-time-transfers-limits = { path = "../rwa-time-transfers-limits" }
+rwa-transfer-restrict = { path = "../rwa-transfer-restrict" }
diff --git a/examples/rwa-compliance/src/compliance.rs b/examples/rwa-compliance/src/compliance.rs
new file mode 100644
index 000000000..973ee3aeb
--- /dev/null
+++ b/examples/rwa-compliance/src/compliance.rs
@@ -0,0 +1,85 @@
+//! Compliance dispatcher contract.
+//!
+//! Implements the `Compliance` trait (which extends `TokenBinder`), routing
+//! hook calls to registered compliance modules. All heavy lifting is delegated
+//! to the storage helpers in `stellar_tokens::rwa::compliance::storage`.
+
+use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, Symbol, Vec};
+use stellar_access::access_control::{self as access_control, AccessControl};
+use stellar_macros::only_role;
+use stellar_tokens::rwa::{
+ compliance::{storage as compliance_storage, Compliance, ComplianceHook},
+ utils::token_binder::{self as binder, TokenBinder},
+};
+
+#[contract]
+pub struct ComplianceContract;
+
+#[contractimpl]
+impl ComplianceContract {
+ pub fn __constructor(e: &Env, admin: Address) {
+ access_control::set_admin(e, &admin);
+ access_control::grant_role_no_auth(e, &admin, &symbol_short!("admin"), &admin);
+ }
+}
+
+#[contractimpl]
+impl Compliance for ComplianceContract {
+ #[only_role(operator, "admin")]
+ fn add_module_to(e: &Env, hook: ComplianceHook, module: Address, operator: Address) {
+ compliance_storage::add_module_to(e, hook, module);
+ }
+
+ #[only_role(operator, "admin")]
+ fn remove_module_from(e: &Env, hook: ComplianceHook, module: Address, operator: Address) {
+ compliance_storage::remove_module_from(e, hook, module);
+ }
+
+ fn get_modules_for_hook(e: &Env, hook: ComplianceHook) -> Vec
{
+ compliance_storage::get_modules_for_hook(e, hook)
+ }
+
+ fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool {
+ compliance_storage::is_module_registered(e, hook, module)
+ }
+
+ fn transferred(e: &Env, from: Address, to: Address, amount: i128, token: Address) {
+ compliance_storage::transferred(e, from, to, amount, token);
+ }
+
+ fn created(e: &Env, to: Address, amount: i128, token: Address) {
+ compliance_storage::created(e, to, amount, token);
+ }
+
+ fn destroyed(e: &Env, from: Address, amount: i128, token: Address) {
+ compliance_storage::destroyed(e, from, amount, token);
+ }
+
+ fn can_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) -> bool {
+ compliance_storage::can_transfer(e, from, to, amount, token)
+ }
+
+ fn can_create(e: &Env, to: Address, amount: i128, token: Address) -> bool {
+ compliance_storage::can_create(e, to, amount, token)
+ }
+}
+
+#[contractimpl]
+impl TokenBinder for ComplianceContract {
+ fn linked_tokens(e: &Env) -> Vec {
+ binder::linked_tokens(e)
+ }
+
+ #[only_role(operator, "admin")]
+ fn bind_token(e: &Env, token: Address, operator: Address) {
+ binder::bind_token(e, &token);
+ }
+
+ #[only_role(operator, "admin")]
+ fn unbind_token(e: &Env, token: Address, operator: Address) {
+ binder::unbind_token(e, &token);
+ }
+}
+
+#[contractimpl(contracttrait)]
+impl AccessControl for ComplianceContract {}
diff --git a/examples/rwa-compliance/src/identity_registry.rs b/examples/rwa-compliance/src/identity_registry.rs
new file mode 100644
index 000000000..cd85c41b4
--- /dev/null
+++ b/examples/rwa-compliance/src/identity_registry.rs
@@ -0,0 +1,129 @@
+//! Identity Registry Storage contract.
+//!
+//! Provides identity and country-data storage for the RWA compliance stack.
+//! Ported from `examples/rwa` with the constructor bug fixed.
+
+use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, FromVal, IntoVal, Val, Vec};
+use stellar_access::access_control::{self as access_control};
+use stellar_macros::only_role;
+use stellar_tokens::rwa::{
+ identity_registry_storage::{
+ self as identity_storage, CountryData, CountryDataManager, IdentityRegistryStorage,
+ IdentityType,
+ },
+ utils::token_binder::{self as binder, TokenBinder},
+};
+
+#[contract]
+pub struct IdentityRegistryContract;
+
+#[contractimpl]
+impl IdentityRegistryContract {
+ pub fn __constructor(e: &Env, admin: Address, manager: Address) {
+ access_control::set_admin(e, &admin);
+ access_control::grant_role_no_auth(e, &manager, &symbol_short!("manager"), &admin);
+ }
+
+ #[only_role(operator, "manager")]
+ pub fn bind_tokens(e: &Env, tokens: Vec, operator: Address) {
+ binder::bind_tokens(e, &tokens);
+ }
+}
+
+#[contractimpl]
+impl TokenBinder for IdentityRegistryContract {
+ fn linked_tokens(e: &Env) -> Vec {
+ binder::linked_tokens(e)
+ }
+
+ #[only_role(operator, "manager")]
+ fn bind_token(e: &Env, token: Address, operator: Address) {
+ binder::bind_token(e, &token);
+ }
+
+ #[only_role(operator, "manager")]
+ fn unbind_token(e: &Env, token: Address, operator: Address) {
+ binder::unbind_token(e, &token);
+ }
+}
+
+#[contractimpl]
+impl IdentityRegistryStorage for IdentityRegistryContract {
+ #[only_role(operator, "manager")]
+ fn add_identity(
+ e: &Env,
+ account: Address,
+ identity: Address,
+ initial_profiles: Vec,
+ operator: Address,
+ ) {
+ let country_data = Vec::from_iter(
+ e,
+ initial_profiles.iter().map(|profile| CountryData::from_val(e, &profile)),
+ );
+ identity_storage::add_identity(
+ e,
+ &account,
+ &identity,
+ IdentityType::Individual,
+ &country_data,
+ );
+ }
+
+ #[only_role(operator, "manager")]
+ fn modify_identity(e: &Env, account: Address, new_identity: Address, operator: Address) {
+ identity_storage::modify_identity(e, &account, &new_identity);
+ }
+
+ #[only_role(operator, "manager")]
+ fn remove_identity(e: &Env, account: Address, operator: Address) {
+ identity_storage::remove_identity(e, &account);
+ }
+
+ fn stored_identity(e: &Env, account: Address) -> Address {
+ identity_storage::stored_identity(e, &account)
+ }
+
+ #[only_role(operator, "manager")]
+ fn recover_identity(e: &Env, old_account: Address, new_account: Address, operator: Address) {
+ identity_storage::recover_identity(e, &old_account, &new_account);
+ }
+
+ fn get_recovered_to(e: &Env, old: Address) -> Option {
+ identity_storage::get_recovered_to(e, &old)
+ }
+}
+
+#[contractimpl]
+impl CountryDataManager for IdentityRegistryContract {
+ #[only_role(operator, "manager")]
+ fn add_country_data_entries(e: &Env, account: Address, profiles: Vec, operator: Address) {
+ let country_data =
+ Vec::from_iter(e, profiles.iter().map(|profile| CountryData::from_val(e, &profile)));
+ identity_storage::add_country_data_entries(e, &account, &country_data);
+ }
+
+ #[only_role(operator, "manager")]
+ fn modify_country_data(e: &Env, account: Address, index: u32, profile: Val, operator: Address) {
+ let country_data = CountryData::from_val(e, &profile);
+ identity_storage::modify_country_data(e, &account, index, &country_data);
+ }
+
+ #[only_role(operator, "manager")]
+ fn delete_country_data(e: &Env, account: Address, index: u32, operator: Address) {
+ identity_storage::delete_country_data(e, &account, index);
+ }
+
+ fn get_country_data(e: &Env, account: Address, index: u32) -> Val {
+ identity_storage::get_country_data(e, &account, index).into_val(e)
+ }
+
+ fn get_country_data_entries(e: &Env, account: Address) -> Vec {
+ Vec::from_iter(
+ e,
+ identity_storage::get_country_data_entries(e, &account)
+ .iter()
+ .map(|profile| profile.into_val(e)),
+ )
+ }
+}
diff --git a/examples/rwa-compliance/src/identity_verifier.rs b/examples/rwa-compliance/src/identity_verifier.rs
new file mode 100644
index 000000000..3e88fba18
--- /dev/null
+++ b/examples/rwa-compliance/src/identity_verifier.rs
@@ -0,0 +1,80 @@
+//! Simplified Identity Verifier contract.
+//!
+//! Checks that an account has an identity registered in the Identity Registry
+//! Storage (IRS). Does **not** perform claim-based verification — suitable for
+//! demonstrating the compliance module stack without the full claims pipeline.
+
+use soroban_sdk::{
+ contract, contractimpl, contracttype, panic_with_error, symbol_short, Address, Env, Symbol, Vec,
+};
+use stellar_access::access_control::{self as access_control, AccessControl};
+use stellar_macros::only_role;
+use stellar_tokens::rwa::{
+ emit_claim_topics_and_issuers_set, identity_verifier::IdentityVerifier, RWAError,
+};
+
+#[contracttype]
+#[derive(Clone)]
+enum DataKey {
+ Irs,
+ ClaimTopicsAndIssuers,
+}
+
+#[soroban_sdk::contractclient(name = "IRSClient")]
+#[allow(dead_code)]
+trait IRSView {
+ fn stored_identity(e: &Env, account: Address) -> Address;
+ fn get_recovered_to(e: &Env, old: Address) -> Option;
+}
+
+#[contract]
+pub struct SimpleIdentityVerifier;
+
+fn identity_registry_storage(e: &Env) -> Address {
+ e.storage()
+ .instance()
+ .get(&DataKey::Irs)
+ .unwrap_or_else(|| panic_with_error!(e, RWAError::IdentityRegistryStorageNotSet))
+}
+
+#[contractimpl]
+impl SimpleIdentityVerifier {
+ pub fn __constructor(e: &Env, admin: Address, irs: Address) {
+ access_control::set_admin(e, &admin);
+ access_control::grant_role_no_auth(e, &admin, &symbol_short!("admin"), &admin);
+ e.storage().instance().set(&DataKey::Irs, &irs);
+ }
+}
+
+#[contractimpl]
+impl IdentityVerifier for SimpleIdentityVerifier {
+ fn verify_identity(e: &Env, account: &Address) {
+ let irs = identity_registry_storage(e);
+ let client = IRSClient::new(e, &irs);
+ if client.try_stored_identity(account).is_err() {
+ panic_with_error!(e, RWAError::IdentityVerificationFailed);
+ }
+ }
+
+ fn recovery_target(e: &Env, old_account: &Address) -> Option {
+ let irs = identity_registry_storage(e);
+ let client = IRSClient::new(e, &irs);
+ client.get_recovered_to(old_account)
+ }
+
+ #[only_role(operator, "admin")]
+ fn set_claim_topics_and_issuers(e: &Env, claim_topics_and_issuers: Address, operator: Address) {
+ e.storage().instance().set(&DataKey::ClaimTopicsAndIssuers, &claim_topics_and_issuers);
+ emit_claim_topics_and_issuers_set(e, &claim_topics_and_issuers);
+ }
+
+ fn claim_topics_and_issuers(e: &Env) -> Address {
+ e.storage()
+ .instance()
+ .get(&DataKey::ClaimTopicsAndIssuers)
+ .unwrap_or_else(|| panic_with_error!(e, RWAError::ClaimTopicsAndIssuersNotSet))
+ }
+}
+
+#[contractimpl(contracttrait)]
+impl AccessControl for SimpleIdentityVerifier {}
diff --git a/examples/rwa-compliance/src/lib.rs b/examples/rwa-compliance/src/lib.rs
new file mode 100644
index 000000000..e5d0a6592
--- /dev/null
+++ b/examples/rwa-compliance/src/lib.rs
@@ -0,0 +1,9 @@
+#![no_std]
+
+mod compliance;
+mod identity_registry;
+mod identity_verifier;
+mod token;
+
+#[cfg(test)]
+mod test;
diff --git a/examples/rwa-compliance/src/test.rs b/examples/rwa-compliance/src/test.rs
new file mode 100644
index 000000000..89a5f2c7e
--- /dev/null
+++ b/examples/rwa-compliance/src/test.rs
@@ -0,0 +1,1042 @@
+extern crate std;
+
+use rwa_country_allow::contract::{CountryAllowContract, CountryAllowContractClient};
+use rwa_country_restrict::contract::{CountryRestrictContract, CountryRestrictContractClient};
+use rwa_initial_lockup_period::contract::{
+ InitialLockupPeriodContract, InitialLockupPeriodContractClient,
+};
+use rwa_max_balance::contract::{MaxBalanceContract, MaxBalanceContractClient};
+use rwa_supply_limit::contract::{SupplyLimitContract, SupplyLimitContractClient};
+use rwa_time_transfers_limits::contract::{
+ TimeTransfersLimitsContract, TimeTransfersLimitsContractClient,
+};
+use rwa_transfer_restrict::contract::{TransferRestrictContract, TransferRestrictContractClient};
+use soroban_sdk::{
+ testutils::{Address as _, Ledger},
+ vec, Address, Env, IntoVal, String,
+};
+use stellar_tokens::rwa::{
+ compliance::{
+ modules::{time_transfers_limits::Limit, ComplianceModuleClient},
+ ComplianceHook,
+ },
+ identity_registry_storage::{CountryData, CountryRelation, IndividualCountryRelation},
+};
+
+use crate::{
+ compliance::{ComplianceContract, ComplianceContractClient},
+ identity_registry::{IdentityRegistryContract, IdentityRegistryContractClient},
+ identity_verifier::{SimpleIdentityVerifier, SimpleIdentityVerifierClient},
+ token::{RWATokenContract, RWATokenContractClient},
+};
+
+// ---------------------------------------------------------------------------
+// Test setup
+// ---------------------------------------------------------------------------
+
+struct TestSetup<'a> {
+ env: Env,
+ admin: Address,
+ manager: Address,
+ token: Address,
+ token_client: RWATokenContractClient<'a>,
+ compliance: Address,
+ compliance_client: ComplianceContractClient<'a>,
+ irs: Address,
+ irs_client: IdentityRegistryContractClient<'a>,
+ verifier: Address,
+}
+
+fn us_country_data() -> CountryData {
+ CountryData {
+ country: CountryRelation::Individual(IndividualCountryRelation::Residence(840)),
+ metadata: None,
+ }
+}
+
+fn de_country_data() -> CountryData {
+ CountryData {
+ country: CountryRelation::Individual(IndividualCountryRelation::Residence(276)),
+ metadata: None,
+ }
+}
+
+fn setup() -> TestSetup<'static> {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let manager = Address::generate(&env);
+
+ let irs = env.register(IdentityRegistryContract, (&admin, &manager));
+ let irs_client = IdentityRegistryContractClient::new(&env, &irs);
+
+ let verifier = env.register(SimpleIdentityVerifier, (&admin, &irs));
+
+ let compliance = env.register(ComplianceContract, (&admin,));
+ let compliance_client = ComplianceContractClient::new(&env, &compliance);
+
+ let name = String::from_str(&env, "Compliance Token");
+ let symbol = String::from_str(&env, "CRWA");
+ let token = env.register(RWATokenContract, (&name, &symbol, &admin, &compliance, &verifier));
+ let token_client = RWATokenContractClient::new(&env, &token);
+
+ compliance_client.bind_token(&token, &admin);
+ irs_client.bind_tokens(&vec![&env, token.clone()], &manager);
+
+ TestSetup {
+ env,
+ admin,
+ manager,
+ token,
+ token_client,
+ compliance,
+ compliance_client,
+ irs,
+ irs_client,
+ verifier,
+ }
+}
+
+fn register_investor(ts: &TestSetup, investor: &Address, identity: &Address, country: CountryData) {
+ ts.irs_client.add_identity(
+ investor,
+ identity,
+ &vec![&ts.env, country.into_val(&ts.env)],
+ &ts.manager,
+ );
+}
+
+fn wire_module(ts: &TestSetup, module_addr: &Address, hooks: &[ComplianceHook]) {
+ let cmp_client = ComplianceModuleClient::new(&ts.env, module_addr);
+ cmp_client.set_compliance_address(&ts.compliance);
+
+ for hook in hooks {
+ ts.compliance_client.add_module_to(hook, module_addr, &ts.admin);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Test: CountryAllowModule
+// ---------------------------------------------------------------------------
+
+#[test]
+fn test_country_allow() {
+ let ts = setup();
+
+ let investor_us = Address::generate(&ts.env);
+ let investor_de = Address::generate(&ts.env);
+ let id_us = Address::generate(&ts.env);
+ let id_de = Address::generate(&ts.env);
+
+ register_investor(&ts, &investor_us, &id_us, us_country_data());
+ register_investor(&ts, &investor_de, &id_de, de_country_data());
+
+ let module = ts.env.register(CountryAllowContract, (&ts.admin,));
+ wire_module(&ts, &module, &[ComplianceHook::CanTransfer, ComplianceHook::CanCreate]);
+
+ let mod_client = CountryAllowContractClient::new(&ts.env, &module);
+ mod_client.set_identity_registry_storage(&ts.token, &ts.irs);
+ mod_client.add_allowed_country(&ts.token, &840);
+
+ // Mint to US investor passes
+ ts.token_client.mint(&investor_us, &500, &ts.admin);
+ assert_eq!(ts.token_client.balance(&investor_us), 500);
+
+ // Mint to DE investor fails (276 not allowed)
+ let result = ts.token_client.try_mint(&investor_de, &100, &ts.admin);
+ assert!(result.is_err());
+}
+
+// ---------------------------------------------------------------------------
+// Test: CountryRestrictModule
+// ---------------------------------------------------------------------------
+
+#[test]
+fn test_country_restrict() {
+ let ts = setup();
+
+ let investor_us = Address::generate(&ts.env);
+ let investor_de = Address::generate(&ts.env);
+ let id_us = Address::generate(&ts.env);
+ let id_de = Address::generate(&ts.env);
+
+ register_investor(&ts, &investor_us, &id_us, us_country_data());
+ register_investor(&ts, &investor_de, &id_de, de_country_data());
+
+ let module = ts.env.register(CountryRestrictContract, (&ts.admin,));
+ wire_module(&ts, &module, &[ComplianceHook::CanTransfer, ComplianceHook::CanCreate]);
+
+ let mod_client = CountryRestrictContractClient::new(&ts.env, &module);
+ mod_client.set_identity_registry_storage(&ts.token, &ts.irs);
+ mod_client.add_country_restriction(&ts.token, &840);
+
+ // Mint to DE investor passes
+ ts.token_client.mint(&investor_de, &500, &ts.admin);
+ assert_eq!(ts.token_client.balance(&investor_de), 500);
+
+ // Mint to US investor fails (840 restricted)
+ let result = ts.token_client.try_mint(&investor_us, &100, &ts.admin);
+ assert!(result.is_err());
+}
+
+// ---------------------------------------------------------------------------
+// Test: MaxBalanceModule
+// ---------------------------------------------------------------------------
+
+#[test]
+fn test_max_balance() {
+ let ts = setup();
+
+ let investor_a = Address::generate(&ts.env);
+ let investor_b = Address::generate(&ts.env);
+ let id_a = Address::generate(&ts.env);
+ let id_b = Address::generate(&ts.env);
+
+ register_investor(&ts, &investor_a, &id_a, us_country_data());
+ register_investor(&ts, &investor_b, &id_b, us_country_data());
+
+ let module = ts.env.register(MaxBalanceContract, (&ts.admin,));
+ wire_module(
+ &ts,
+ &module,
+ &[
+ ComplianceHook::CanTransfer,
+ ComplianceHook::CanCreate,
+ ComplianceHook::Transferred,
+ ComplianceHook::Created,
+ ComplianceHook::Destroyed,
+ ],
+ );
+
+ let mod_client = MaxBalanceContractClient::new(&ts.env, &module);
+ mod_client.set_identity_registry_storage(&ts.token, &ts.irs);
+ mod_client.set_max_balance(&ts.token, &1000);
+ mod_client.verify_hook_wiring();
+
+ // Mint 800 to investor A
+ ts.token_client.mint(&investor_a, &800, &ts.admin);
+ assert_eq!(ts.token_client.balance(&investor_a), 800);
+ assert_eq!(mod_client.get_investor_balance(&ts.token, &id_a), 800);
+
+ // Mint 300 to investor B
+ ts.token_client.mint(&investor_b, &300, &ts.admin);
+ assert_eq!(mod_client.get_investor_balance(&ts.token, &id_b), 300);
+
+ // Transfer 250 from A to B pushes B to 550 — passes
+ ts.token_client.transfer(&investor_a, &investor_b, &250);
+ assert_eq!(mod_client.get_investor_balance(&ts.token, &id_b), 550);
+
+ // Transfer 500 from A to B would push B to 1050 — exceeds max
+ let result = ts.token_client.try_transfer(&investor_a, &investor_b, &500);
+ assert!(result.is_err());
+}
+
+// ---------------------------------------------------------------------------
+// Test: SupplyLimitModule (full stack — internal supply tracking)
+// ---------------------------------------------------------------------------
+
+#[test]
+fn test_supply_limit() {
+ let ts = setup();
+
+ let investor = Address::generate(&ts.env);
+ let investor_b = Address::generate(&ts.env);
+ let id = Address::generate(&ts.env);
+ let id_b = Address::generate(&ts.env);
+ register_investor(&ts, &investor, &id, us_country_data());
+ register_investor(&ts, &investor_b, &id_b, us_country_data());
+
+ let module = ts.env.register(SupplyLimitContract, (&ts.admin,));
+ wire_module(
+ &ts,
+ &module,
+ &[ComplianceHook::CanCreate, ComplianceHook::Created, ComplianceHook::Destroyed],
+ );
+
+ let mod_client = SupplyLimitContractClient::new(&ts.env, &module);
+ mod_client.set_supply_limit(&ts.token, &1000);
+ mod_client.verify_hook_wiring();
+
+ // Mint 800 — internal supply tracks to 800
+ ts.token_client.mint(&investor, &800, &ts.admin);
+ assert_eq!(ts.token_client.total_supply(), 800);
+ assert_eq!(mod_client.get_internal_supply(&ts.token), 800);
+
+ // Mint 200 more — exactly at limit (1000)
+ ts.token_client.mint(&investor_b, &200, &ts.admin);
+ assert_eq!(ts.token_client.total_supply(), 1000);
+ assert_eq!(mod_client.get_internal_supply(&ts.token), 1000);
+
+ // Mint 1 more — exceeds limit, blocked by can_create
+ let result = ts.token_client.try_mint(&investor, &1, &ts.admin);
+ assert!(result.is_err());
+
+ // Transfer doesn't affect supply — always allowed
+ ts.token_client.transfer(&investor, &investor_b, &100);
+ assert_eq!(mod_client.get_internal_supply(&ts.token), 1000);
+}
+
+#[test]
+fn test_supply_limit_burn() {
+ let ts = setup();
+
+ let investor = Address::generate(&ts.env);
+ let id = Address::generate(&ts.env);
+ register_investor(&ts, &investor, &id, us_country_data());
+
+ let module = ts.env.register(SupplyLimitContract, (&ts.admin,));
+ wire_module(
+ &ts,
+ &module,
+ &[ComplianceHook::CanCreate, ComplianceHook::Created, ComplianceHook::Destroyed],
+ );
+
+ let mod_client = SupplyLimitContractClient::new(&ts.env, &module);
+ mod_client.set_supply_limit(&ts.token, &1000);
+ mod_client.verify_hook_wiring();
+
+ // Mint to limit
+ ts.token_client.mint(&investor, &1000, &ts.admin);
+ assert_eq!(mod_client.get_internal_supply(&ts.token), 1000);
+
+ // Burn 300 — internal supply decrements
+ ts.token_client.burn(&investor, &300, &ts.admin);
+ assert_eq!(ts.token_client.total_supply(), 700);
+ assert_eq!(mod_client.get_internal_supply(&ts.token), 700);
+
+ // Now minting 300 more is possible again
+ ts.token_client.mint(&investor, &300, &ts.admin);
+ assert_eq!(mod_client.get_internal_supply(&ts.token), 1000);
+
+ // But 1 more still fails
+ let result = ts.token_client.try_mint(&investor, &1, &ts.admin);
+ assert!(result.is_err());
+}
+
+// ---------------------------------------------------------------------------
+// Test: TimeTransfersLimitsModule
+// ---------------------------------------------------------------------------
+
+#[test]
+fn test_time_transfer_limits() {
+ let ts = setup();
+
+ let investor_a = Address::generate(&ts.env);
+ let investor_b = Address::generate(&ts.env);
+ let id_a = Address::generate(&ts.env);
+ let id_b = Address::generate(&ts.env);
+
+ register_investor(&ts, &investor_a, &id_a, us_country_data());
+ register_investor(&ts, &investor_b, &id_b, us_country_data());
+
+ let module = ts.env.register(TimeTransfersLimitsContract, (&ts.admin,));
+ wire_module(&ts, &module, &[ComplianceHook::CanTransfer, ComplianceHook::Transferred]);
+
+ let mod_client = TimeTransfersLimitsContractClient::new(&ts.env, &module);
+ mod_client.set_identity_registry_storage(&ts.token, &ts.irs);
+ mod_client.set_time_transfer_limit(&ts.token, &Limit { limit_time: 3_600, limit_value: 100 });
+ mod_client.verify_hook_wiring();
+
+ // Mint enough tokens for transfers
+ ts.token_client.mint(&investor_a, &500, &ts.admin);
+
+ // Transfer 60 — passes (counter at 60/100)
+ ts.token_client.transfer(&investor_a, &investor_b, &60);
+ assert_eq!(ts.token_client.balance(&investor_b), 60);
+
+ // Transfer 50 more — would push counter to 110, exceeds 100/hour
+ let result = ts.token_client.try_transfer(&investor_a, &investor_b, &50);
+ assert!(result.is_err());
+
+ // Transfer 40 — passes (counter at 100, exactly at limit)
+ ts.token_client.transfer(&investor_a, &investor_b, &40);
+ assert_eq!(ts.token_client.balance(&investor_b), 100);
+}
+
+// ---------------------------------------------------------------------------
+// Test: TransferRestrictModule
+// ---------------------------------------------------------------------------
+
+#[test]
+fn test_transfer_restrict() {
+ let ts = setup();
+
+ let investor_a = Address::generate(&ts.env);
+ let investor_b = Address::generate(&ts.env);
+ let id_a = Address::generate(&ts.env);
+ let id_b = Address::generate(&ts.env);
+
+ register_investor(&ts, &investor_a, &id_a, us_country_data());
+ register_investor(&ts, &investor_b, &id_b, us_country_data());
+
+ let module = ts.env.register(TransferRestrictContract, (&ts.admin,));
+ wire_module(&ts, &module, &[ComplianceHook::CanTransfer]);
+
+ let mod_client = TransferRestrictContractClient::new(&ts.env, &module);
+
+ // Mint tokens (no CanCreate hook, so mints pass)
+ ts.token_client.mint(&investor_a, &500, &ts.admin);
+
+ // Transfer without allowlist — fails
+ let result = ts.token_client.try_transfer(&investor_a, &investor_b, &100);
+ assert!(result.is_err());
+
+ // Allow investor_a as sender
+ mod_client.allow_user(&ts.token, &investor_a);
+
+ // Now transfer passes
+ ts.token_client.transfer(&investor_a, &investor_b, &100);
+ assert_eq!(ts.token_client.balance(&investor_b), 100);
+}
+
+// ---------------------------------------------------------------------------
+// Test: InitialLockupPeriodModule (full stack — internal balance tracking)
+// ---------------------------------------------------------------------------
+
+#[test]
+fn test_initial_lockup() {
+ let ts = setup();
+
+ let investor = Address::generate(&ts.env);
+ let recipient = Address::generate(&ts.env);
+ let id_inv = Address::generate(&ts.env);
+ let id_rec = Address::generate(&ts.env);
+
+ register_investor(&ts, &investor, &id_inv, us_country_data());
+ register_investor(&ts, &recipient, &id_rec, us_country_data());
+
+ let module = ts.env.register(InitialLockupPeriodContract, (&ts.admin,));
+ wire_module(
+ &ts,
+ &module,
+ &[
+ ComplianceHook::CanTransfer,
+ ComplianceHook::Created,
+ ComplianceHook::Transferred,
+ ComplianceHook::Destroyed,
+ ],
+ );
+
+ let mod_client = InitialLockupPeriodContractClient::new(&ts.env, &module);
+ mod_client.set_lockup_period(&ts.token, &1_000);
+ mod_client.verify_hook_wiring();
+
+ // Mint 500 — creates lock entry, internal balance tracks to 500
+ ts.token_client.mint(&investor, &500, &ts.admin);
+ assert_eq!(ts.token_client.balance(&investor), 500);
+ assert_eq!(mod_client.get_total_locked(&ts.token, &investor), 500);
+ assert_eq!(mod_client.get_internal_balance(&ts.token, &investor), 500);
+
+ // Before lockup expiry: all tokens locked, transfer blocked
+ let result = ts.token_client.try_transfer(&investor, &recipient, &100);
+ assert!(result.is_err());
+
+ // Advance past lockup
+ ts.env.ledger().with_mut(|li| li.timestamp = 1_001);
+
+ // After lockup expiry: transfer succeeds through the full stack
+ ts.token_client.transfer(&investor, &recipient, &200);
+ assert_eq!(ts.token_client.balance(&investor), 300);
+ assert_eq!(ts.token_client.balance(&recipient), 200);
+ assert_eq!(mod_client.get_internal_balance(&ts.token, &investor), 300);
+ assert_eq!(mod_client.get_internal_balance(&ts.token, &recipient), 200);
+
+ // Transfer rest
+ ts.token_client.transfer(&investor, &recipient, &300);
+ assert_eq!(mod_client.get_internal_balance(&ts.token, &investor), 0);
+ assert_eq!(mod_client.get_internal_balance(&ts.token, &recipient), 500);
+}
+
+#[test]
+fn test_initial_lockup_partial_unlock() {
+ let ts = setup();
+
+ let investor = Address::generate(&ts.env);
+ let recipient = Address::generate(&ts.env);
+ let id_inv = Address::generate(&ts.env);
+ let id_rec = Address::generate(&ts.env);
+
+ register_investor(&ts, &investor, &id_inv, us_country_data());
+ register_investor(&ts, &recipient, &id_rec, us_country_data());
+
+ let module = ts.env.register(InitialLockupPeriodContract, (&ts.admin,));
+ wire_module(
+ &ts,
+ &module,
+ &[
+ ComplianceHook::CanTransfer,
+ ComplianceHook::Created,
+ ComplianceHook::Transferred,
+ ComplianceHook::Destroyed,
+ ],
+ );
+
+ let mod_client = InitialLockupPeriodContractClient::new(&ts.env, &module);
+ mod_client.set_lockup_period(&ts.token, &1_000);
+ mod_client.verify_hook_wiring();
+
+ // Mint 300 at t=0 (lock until t=1000)
+ ts.token_client.mint(&investor, &300, &ts.admin);
+
+ // Advance to t=500, mint 200 more (lock until t=1500)
+ ts.env.ledger().with_mut(|li| li.timestamp = 500);
+ ts.token_client.mint(&investor, &200, &ts.admin);
+ assert_eq!(mod_client.get_total_locked(&ts.token, &investor), 500);
+ assert_eq!(mod_client.get_internal_balance(&ts.token, &investor), 500);
+
+ // At t=1001: first batch unlocked, second still locked
+ ts.env.ledger().with_mut(|li| li.timestamp = 1_001);
+
+ // Can transfer up to 300 (first batch unlocked)
+ ts.token_client.transfer(&investor, &recipient, &300);
+ assert_eq!(mod_client.get_internal_balance(&ts.token, &investor), 200);
+
+ // Can't transfer remaining 200 (still locked)
+ let result = ts.token_client.try_transfer(&investor, &recipient, &200);
+ assert!(result.is_err());
+
+ // At t=1501: second batch also unlocked
+ ts.env.ledger().with_mut(|li| li.timestamp = 1_501);
+ ts.token_client.transfer(&investor, &recipient, &200);
+ assert_eq!(mod_client.get_internal_balance(&ts.token, &investor), 0);
+}
+
+#[test]
+fn test_initial_lockup_burn() {
+ let ts = setup();
+
+ let investor = Address::generate(&ts.env);
+ let id_inv = Address::generate(&ts.env);
+
+ register_investor(&ts, &investor, &id_inv, us_country_data());
+
+ let module = ts.env.register(InitialLockupPeriodContract, (&ts.admin,));
+ wire_module(
+ &ts,
+ &module,
+ &[
+ ComplianceHook::CanTransfer,
+ ComplianceHook::Created,
+ ComplianceHook::Transferred,
+ ComplianceHook::Destroyed,
+ ],
+ );
+
+ let mod_client = InitialLockupPeriodContractClient::new(&ts.env, &module);
+ mod_client.set_lockup_period(&ts.token, &1_000);
+ mod_client.verify_hook_wiring();
+
+ // Mint 500 (locked until t=1000)
+ ts.token_client.mint(&investor, &500, &ts.admin);
+ assert_eq!(mod_client.get_internal_balance(&ts.token, &investor), 500);
+
+ // Advance past lockup
+ ts.env.ledger().with_mut(|li| li.timestamp = 1_001);
+
+ // Burn 200 — internal balance decrements
+ ts.token_client.burn(&investor, &200, &ts.admin);
+ assert_eq!(ts.token_client.balance(&investor), 300);
+ assert_eq!(mod_client.get_internal_balance(&ts.token, &investor), 300);
+
+ // Burn remaining
+ ts.token_client.burn(&investor, &300, &ts.admin);
+ assert_eq!(mod_client.get_internal_balance(&ts.token, &investor), 0);
+}
+
+// ---------------------------------------------------------------------------
+// Test: Full stack — multiple modules active simultaneously
+// ---------------------------------------------------------------------------
+
+#[test]
+fn test_full_stack() {
+ let ts = setup();
+
+ let investor_us = Address::generate(&ts.env);
+ let investor_de = Address::generate(&ts.env);
+ let id_us = Address::generate(&ts.env);
+ let id_de = Address::generate(&ts.env);
+
+ register_investor(&ts, &investor_us, &id_us, us_country_data());
+ register_investor(&ts, &investor_de, &id_de, de_country_data());
+
+ // --- Wire CountryAllowModule (allow US only) ---
+ let country_mod = ts.env.register(CountryAllowContract, (&ts.admin,));
+ wire_module(&ts, &country_mod, &[ComplianceHook::CanTransfer, ComplianceHook::CanCreate]);
+ let country_client = CountryAllowContractClient::new(&ts.env, &country_mod);
+ country_client.set_identity_registry_storage(&ts.token, &ts.irs);
+ country_client.add_allowed_country(&ts.token, &840);
+
+ // --- Wire MaxBalanceModule (max 1000 per identity) ---
+ let balance_mod = ts.env.register(MaxBalanceContract, (&ts.admin,));
+ wire_module(
+ &ts,
+ &balance_mod,
+ &[
+ ComplianceHook::CanTransfer,
+ ComplianceHook::CanCreate,
+ ComplianceHook::Transferred,
+ ComplianceHook::Created,
+ ComplianceHook::Destroyed,
+ ],
+ );
+ let balance_client = MaxBalanceContractClient::new(&ts.env, &balance_mod);
+ balance_client.set_identity_registry_storage(&ts.token, &ts.irs);
+ balance_client.set_max_balance(&ts.token, &1000);
+ balance_client.verify_hook_wiring();
+
+ // 1) Mint 800 to US investor — passes all modules
+ ts.token_client.mint(&investor_us, &800, &ts.admin);
+ assert_eq!(ts.token_client.balance(&investor_us), 800);
+
+ // 2) Mint to DE investor — fails (country not allowed)
+ let result = ts.token_client.try_mint(&investor_de, &100, &ts.admin);
+ assert!(result.is_err());
+
+ // 3) Allow DE, then mint 300 to DE investor — passes
+ country_client.add_allowed_country(&ts.token, &276);
+ ts.token_client.mint(&investor_de, &300, &ts.admin);
+
+ // 4) Mint 200 more to US investor (total 1000) — exactly at max balance
+ ts.token_client.mint(&investor_us, &200, &ts.admin);
+ assert_eq!(balance_client.get_investor_balance(&ts.token, &id_us), 1000);
+
+ // 5) Mint 1 more to US investor — exceeds max balance of 1000
+ let result = ts.token_client.try_mint(&investor_us, &1, &ts.admin);
+ assert!(result.is_err());
+
+ // 6) Transfer 100 from US to DE — passes (DE at 400, under 1000)
+ ts.token_client.transfer(&investor_us, &investor_de, &100);
+ assert_eq!(ts.token_client.balance(&investor_us), 900);
+ assert_eq!(ts.token_client.balance(&investor_de), 400);
+
+ // 7) Transfer 700 to DE would push DE identity to 1100 — exceeds max
+ let result = ts.token_client.try_transfer(&investor_us, &investor_de, &700);
+ assert!(result.is_err());
+}
+
+// ---------------------------------------------------------------------------
+// Tests: Hook wiring verification guards
+// ---------------------------------------------------------------------------
+
+#[test]
+#[should_panic(expected = "not armed")]
+fn guard_supply_limit_without_verification() {
+ let ts = setup();
+
+ let investor = Address::generate(&ts.env);
+ let id = Address::generate(&ts.env);
+ register_investor(&ts, &investor, &id, us_country_data());
+
+ let module = ts.env.register(SupplyLimitContract, (&ts.admin,));
+ wire_module(
+ &ts,
+ &module,
+ &[ComplianceHook::CanCreate, ComplianceHook::Created, ComplianceHook::Destroyed],
+ );
+
+ let mod_client = SupplyLimitContractClient::new(&ts.env, &module);
+ mod_client.set_supply_limit(&ts.token, &1000);
+ // Intentionally NOT calling verify_hook_wiring()
+
+ ts.token_client.mint(&investor, &100, &ts.admin);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #398)")]
+fn guard_supply_limit_missing_hook() {
+ let ts = setup();
+
+ let module = ts.env.register(SupplyLimitContract, (&ts.admin,));
+ wire_module(
+ &ts,
+ &module,
+ &[ComplianceHook::CanCreate], // missing Created and Destroyed
+ );
+
+ let mod_client = SupplyLimitContractClient::new(&ts.env, &module);
+ mod_client.verify_hook_wiring();
+}
+
+#[test]
+#[should_panic(expected = "not armed")]
+fn guard_initial_lockup_without_verification() {
+ let ts = setup();
+
+ let investor = Address::generate(&ts.env);
+ let recipient = Address::generate(&ts.env);
+ let id_inv = Address::generate(&ts.env);
+ let id_rec = Address::generate(&ts.env);
+
+ register_investor(&ts, &investor, &id_inv, us_country_data());
+ register_investor(&ts, &recipient, &id_rec, us_country_data());
+
+ let module = ts.env.register(InitialLockupPeriodContract, (&ts.admin,));
+ wire_module(
+ &ts,
+ &module,
+ &[
+ ComplianceHook::CanTransfer,
+ ComplianceHook::Created,
+ ComplianceHook::Transferred,
+ ComplianceHook::Destroyed,
+ ],
+ );
+
+ let mod_client = InitialLockupPeriodContractClient::new(&ts.env, &module);
+ mod_client.set_lockup_period(&ts.token, &1_000);
+ // Intentionally NOT calling verify_hook_wiring()
+
+ ts.token_client.mint(&investor, &500, &ts.admin);
+ ts.env.ledger().with_mut(|li| li.timestamp = 1_001);
+ ts.token_client.transfer(&investor, &recipient, &100);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #398)")]
+fn guard_max_balance_missing_hook() {
+ let ts = setup();
+
+ let module = ts.env.register(MaxBalanceContract, (&ts.admin,));
+ wire_module(
+ &ts,
+ &module,
+ &[ComplianceHook::CanTransfer, ComplianceHook::CanCreate], /* missing Transferred,
+ * Created, Destroyed */
+ );
+
+ let mod_client = MaxBalanceContractClient::new(&ts.env, &module);
+ mod_client.verify_hook_wiring();
+}
+
+// ---------------------------------------------------------------------------
+// Test: Burn during lockup panics (3A)
+// ---------------------------------------------------------------------------
+
+#[test]
+#[should_panic(expected = "insufficient unlocked balance for burn")]
+fn test_initial_lockup_burn_during_lockup() {
+ let ts = setup();
+
+ let investor = Address::generate(&ts.env);
+ let id_inv = Address::generate(&ts.env);
+ register_investor(&ts, &investor, &id_inv, us_country_data());
+
+ let module = ts.env.register(InitialLockupPeriodContract, (&ts.admin,));
+ wire_module(
+ &ts,
+ &module,
+ &[
+ ComplianceHook::CanTransfer,
+ ComplianceHook::Created,
+ ComplianceHook::Transferred,
+ ComplianceHook::Destroyed,
+ ],
+ );
+
+ let mod_client = InitialLockupPeriodContractClient::new(&ts.env, &module);
+ mod_client.set_lockup_period(&ts.token, &1_000);
+ mod_client.verify_hook_wiring();
+
+ ts.token_client.mint(&investor, &500, &ts.admin);
+ // All 500 tokens are locked (lockup until t=1000), burn should panic
+ ts.token_client.burn(&investor, &100, &ts.admin);
+}
+
+// ---------------------------------------------------------------------------
+// Test: TimeTransfersLimits window reset (3B)
+// ---------------------------------------------------------------------------
+
+#[test]
+fn test_time_transfer_limits_window_reset() {
+ let ts = setup();
+
+ let investor_a = Address::generate(&ts.env);
+ let investor_b = Address::generate(&ts.env);
+ let id_a = Address::generate(&ts.env);
+ let id_b = Address::generate(&ts.env);
+
+ register_investor(&ts, &investor_a, &id_a, us_country_data());
+ register_investor(&ts, &investor_b, &id_b, us_country_data());
+
+ let module = ts.env.register(TimeTransfersLimitsContract, (&ts.admin,));
+ wire_module(&ts, &module, &[ComplianceHook::CanTransfer, ComplianceHook::Transferred]);
+
+ let mod_client = TimeTransfersLimitsContractClient::new(&ts.env, &module);
+ mod_client.set_identity_registry_storage(&ts.token, &ts.irs);
+ mod_client.set_time_transfer_limit(&ts.token, &Limit { limit_time: 3_600, limit_value: 100 });
+ mod_client.verify_hook_wiring();
+
+ ts.token_client.mint(&investor_a, &500, &ts.admin);
+
+ // Transfer up to the limit
+ ts.token_client.transfer(&investor_a, &investor_b, &100);
+ assert_eq!(ts.token_client.balance(&investor_b), 100);
+
+ // At the limit — next transfer should fail
+ let result = ts.token_client.try_transfer(&investor_a, &investor_b, &1);
+ assert!(result.is_err());
+
+ // Advance past the 1-hour window
+ ts.env.ledger().with_mut(|li| li.timestamp = 3_601);
+
+ // Counter reset — transfers succeed again
+ ts.token_client.transfer(&investor_a, &investor_b, &80);
+ assert_eq!(ts.token_client.balance(&investor_b), 180);
+
+ // Still within new window, push to limit again
+ ts.token_client.transfer(&investor_a, &investor_b, &20);
+
+ // Exceeds new window limit
+ let result = ts.token_client.try_transfer(&investor_a, &investor_b, &1);
+ assert!(result.is_err());
+}
+
+// ---------------------------------------------------------------------------
+// Tests: TimeTransfersLimits wiring guards (3C)
+// ---------------------------------------------------------------------------
+
+#[test]
+#[should_panic(expected = "not armed")]
+fn guard_time_transfers_without_verification() {
+ let ts = setup();
+
+ let investor_a = Address::generate(&ts.env);
+ let investor_b = Address::generate(&ts.env);
+ let id_a = Address::generate(&ts.env);
+ let id_b = Address::generate(&ts.env);
+
+ register_investor(&ts, &investor_a, &id_a, us_country_data());
+ register_investor(&ts, &investor_b, &id_b, us_country_data());
+
+ let module = ts.env.register(TimeTransfersLimitsContract, (&ts.admin,));
+ wire_module(&ts, &module, &[ComplianceHook::CanTransfer, ComplianceHook::Transferred]);
+
+ let mod_client = TimeTransfersLimitsContractClient::new(&ts.env, &module);
+ mod_client.set_identity_registry_storage(&ts.token, &ts.irs);
+ mod_client.set_time_transfer_limit(&ts.token, &Limit { limit_time: 3_600, limit_value: 100 });
+ // Intentionally NOT calling verify_hook_wiring()
+
+ ts.token_client.mint(&investor_a, &500, &ts.admin);
+ ts.token_client.transfer(&investor_a, &investor_b, &50);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #398)")]
+fn guard_time_transfers_missing_hook() {
+ let ts = setup();
+
+ let module = ts.env.register(TimeTransfersLimitsContract, (&ts.admin,));
+ wire_module(
+ &ts,
+ &module,
+ &[ComplianceHook::CanTransfer], // missing Transferred
+ );
+
+ let mod_client = TimeTransfersLimitsContractClient::new(&ts.env, &module);
+ mod_client.verify_hook_wiring();
+}
+
+// ---------------------------------------------------------------------------
+// Test: CountryAllow/Restrict on can_transfer (not just can_create) (3D)
+// ---------------------------------------------------------------------------
+
+#[test]
+fn test_country_allow_blocks_transfer_to_non_allowed() {
+ let ts = setup();
+
+ let investor_us = Address::generate(&ts.env);
+ let investor_de = Address::generate(&ts.env);
+ let id_us = Address::generate(&ts.env);
+ let id_de = Address::generate(&ts.env);
+
+ register_investor(&ts, &investor_us, &id_us, us_country_data());
+ register_investor(&ts, &investor_de, &id_de, de_country_data());
+
+ let module = ts.env.register(CountryAllowContract, (&ts.admin,));
+ wire_module(&ts, &module, &[ComplianceHook::CanTransfer, ComplianceHook::CanCreate]);
+
+ let mod_client = CountryAllowContractClient::new(&ts.env, &module);
+ mod_client.set_identity_registry_storage(&ts.token, &ts.irs);
+ mod_client.add_allowed_country(&ts.token, &840); // US only
+
+ // Mint to US investor passes
+ ts.token_client.mint(&investor_us, &500, &ts.admin);
+
+ // Transfer to DE investor (276 not allowed) should fail on can_transfer
+ let result = ts.token_client.try_transfer(&investor_us, &investor_de, &100);
+ assert!(result.is_err());
+
+ // Allow DE, then transfer succeeds
+ mod_client.add_allowed_country(&ts.token, &276);
+ ts.token_client.transfer(&investor_us, &investor_de, &100);
+ assert_eq!(ts.token_client.balance(&investor_de), 100);
+}
+
+#[test]
+fn test_country_restrict_blocks_transfer_to_restricted() {
+ let ts = setup();
+
+ let investor_us = Address::generate(&ts.env);
+ let investor_de = Address::generate(&ts.env);
+ let id_us = Address::generate(&ts.env);
+ let id_de = Address::generate(&ts.env);
+
+ register_investor(&ts, &investor_us, &id_us, us_country_data());
+ register_investor(&ts, &investor_de, &id_de, de_country_data());
+
+ let module = ts.env.register(CountryRestrictContract, (&ts.admin,));
+ wire_module(&ts, &module, &[ComplianceHook::CanTransfer, ComplianceHook::CanCreate]);
+
+ let mod_client = CountryRestrictContractClient::new(&ts.env, &module);
+ mod_client.set_identity_registry_storage(&ts.token, &ts.irs);
+ mod_client.add_country_restriction(&ts.token, &276); // Restrict DE
+
+ // Mint to US investor passes (840 not restricted)
+ ts.token_client.mint(&investor_us, &500, &ts.admin);
+
+ // Transfer to DE investor (276 restricted) should fail on can_transfer
+ let result = ts.token_client.try_transfer(&investor_us, &investor_de, &100);
+ assert!(result.is_err());
+
+ // Unrestrict DE, then transfer succeeds
+ mod_client.remove_country_restriction(&ts.token, &276);
+ ts.token_client.transfer(&investor_us, &investor_de, &100);
+ assert_eq!(ts.token_client.balance(&investor_de), 100);
+}
+
+// ---------------------------------------------------------------------------
+// Test: TransferRestrict recipient-allowed path (3E)
+// ---------------------------------------------------------------------------
+
+#[test]
+fn test_transfer_restrict_recipient_allowed() {
+ let ts = setup();
+
+ let investor_a = Address::generate(&ts.env);
+ let investor_b = Address::generate(&ts.env);
+ let id_a = Address::generate(&ts.env);
+ let id_b = Address::generate(&ts.env);
+
+ register_investor(&ts, &investor_a, &id_a, us_country_data());
+ register_investor(&ts, &investor_b, &id_b, us_country_data());
+
+ let module = ts.env.register(TransferRestrictContract, (&ts.admin,));
+ wire_module(&ts, &module, &[ComplianceHook::CanTransfer]);
+
+ let mod_client = TransferRestrictContractClient::new(&ts.env, &module);
+
+ ts.token_client.mint(&investor_a, &500, &ts.admin);
+
+ // Neither on allowlist — transfer fails
+ let result = ts.token_client.try_transfer(&investor_a, &investor_b, &100);
+ assert!(result.is_err());
+
+ // Allow ONLY the recipient (investor_b), NOT the sender (investor_a)
+ mod_client.allow_user(&ts.token, &investor_b);
+
+ // Transfer passes because recipient is allowlisted (T-REX: sender OR recipient)
+ ts.token_client.transfer(&investor_a, &investor_b, &100);
+ assert_eq!(ts.token_client.balance(&investor_b), 100);
+}
+
+// ---------------------------------------------------------------------------
+// Test: Full stack with burn step (3F)
+// ---------------------------------------------------------------------------
+
+#[test]
+fn test_full_stack_with_burn() {
+ let ts = setup();
+
+ let investor_us = Address::generate(&ts.env);
+ let id_us = Address::generate(&ts.env);
+
+ register_investor(&ts, &investor_us, &id_us, us_country_data());
+
+ // --- Wire CountryAllowModule (allow US) ---
+ let country_mod = ts.env.register(CountryAllowContract, (&ts.admin,));
+ wire_module(&ts, &country_mod, &[ComplianceHook::CanTransfer, ComplianceHook::CanCreate]);
+ let country_client = CountryAllowContractClient::new(&ts.env, &country_mod);
+ country_client.set_identity_registry_storage(&ts.token, &ts.irs);
+ country_client.add_allowed_country(&ts.token, &840);
+
+ // --- Wire MaxBalanceModule (max 1000) ---
+ let balance_mod = ts.env.register(MaxBalanceContract, (&ts.admin,));
+ wire_module(
+ &ts,
+ &balance_mod,
+ &[
+ ComplianceHook::CanTransfer,
+ ComplianceHook::CanCreate,
+ ComplianceHook::Transferred,
+ ComplianceHook::Created,
+ ComplianceHook::Destroyed,
+ ],
+ );
+ let balance_client = MaxBalanceContractClient::new(&ts.env, &balance_mod);
+ balance_client.set_identity_registry_storage(&ts.token, &ts.irs);
+ balance_client.set_max_balance(&ts.token, &1000);
+ balance_client.verify_hook_wiring();
+
+ // --- Wire SupplyLimitModule (limit 2000) ---
+ let supply_mod = ts.env.register(SupplyLimitContract, (&ts.admin,));
+ wire_module(
+ &ts,
+ &supply_mod,
+ &[ComplianceHook::CanCreate, ComplianceHook::Created, ComplianceHook::Destroyed],
+ );
+ let supply_client = SupplyLimitContractClient::new(&ts.env, &supply_mod);
+ supply_client.set_supply_limit(&ts.token, &2000);
+ supply_client.verify_hook_wiring();
+
+ // 1) Mint 800
+ ts.token_client.mint(&investor_us, &800, &ts.admin);
+ assert_eq!(ts.token_client.balance(&investor_us), 800);
+ assert_eq!(balance_client.get_investor_balance(&ts.token, &id_us), 800);
+ assert_eq!(supply_client.get_internal_supply(&ts.token), 800);
+
+ // 2) Burn 300 — all internal state decrements
+ ts.token_client.burn(&investor_us, &300, &ts.admin);
+ assert_eq!(ts.token_client.balance(&investor_us), 500);
+ assert_eq!(balance_client.get_investor_balance(&ts.token, &id_us), 500);
+ assert_eq!(supply_client.get_internal_supply(&ts.token), 500);
+
+ // 3) Mint back to 1000 (identity max) — succeeds
+ ts.token_client.mint(&investor_us, &500, &ts.admin);
+ assert_eq!(balance_client.get_investor_balance(&ts.token, &id_us), 1000);
+ assert_eq!(supply_client.get_internal_supply(&ts.token), 1000);
+
+ // 4) Mint 1 more — exceeds max balance
+ let result = ts.token_client.try_mint(&investor_us, &1, &ts.admin);
+ assert!(result.is_err());
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #304)")]
+fn identity_verifier_maps_missing_identity_to_rwa_error() {
+ let ts = setup();
+ let verifier_client = SimpleIdentityVerifierClient::new(&ts.env, &ts.verifier);
+ let unknown_account = Address::generate(&ts.env);
+
+ verifier_client.verify_identity(&unknown_account);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #310)")]
+fn identity_verifier_claim_topics_getter_uses_contract_error() {
+ let ts = setup();
+ let verifier_client = SimpleIdentityVerifierClient::new(&ts.env, &ts.verifier);
+
+ verifier_client.claim_topics_and_issuers();
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #2000)")]
+fn identity_verifier_claim_topics_setter_requires_admin_role() {
+ let ts = setup();
+ let verifier_client = SimpleIdentityVerifierClient::new(&ts.env, &ts.verifier);
+ let claim_topics_and_issuers = Address::generate(&ts.env);
+ let unauthorized_operator = Address::generate(&ts.env);
+
+ verifier_client.set_claim_topics_and_issuers(&claim_topics_and_issuers, &unauthorized_operator);
+}
diff --git a/examples/rwa-compliance/src/token.rs b/examples/rwa-compliance/src/token.rs
new file mode 100644
index 000000000..39242ee95
--- /dev/null
+++ b/examples/rwa-compliance/src/token.rs
@@ -0,0 +1,141 @@
+//! RWA Token contract with full compliance and identity verification wiring.
+//!
+//! Implements `FungibleToken`, `RWAToken`, `Pausable`, and `AccessControl`.
+//! The constructor wires compliance and identity-verifier addresses so that
+//! all transfer/mint/burn operations are subject to modular compliance checks.
+
+use soroban_sdk::{
+ contract, contractimpl, symbol_short, Address, Env, MuxedAddress, String, Symbol, Vec,
+};
+use stellar_access::access_control::{self as access_control, AccessControl};
+use stellar_contract_utils::pausable::{self as pausable, Pausable};
+use stellar_macros::only_role;
+use stellar_tokens::{
+ fungible::{Base, FungibleToken},
+ rwa::{storage::RWAStorageKey, RWAToken, RWA},
+};
+
+#[contract]
+pub struct RWATokenContract;
+
+#[contractimpl]
+impl RWATokenContract {
+ pub fn __constructor(
+ e: &Env,
+ name: String,
+ symbol: String,
+ admin: Address,
+ compliance: Address,
+ identity_verifier: Address,
+ ) {
+ Base::set_metadata(e, 18, name, symbol);
+ access_control::set_admin(e, &admin);
+ access_control::grant_role_no_auth(e, &admin, &symbol_short!("admin"), &admin);
+ RWA::set_compliance(e, &compliance);
+ RWA::set_identity_verifier(e, &identity_verifier);
+ e.storage().instance().set(&RWAStorageKey::Version, &String::from_str(e, "1.0.0"));
+ RWA::set_onchain_id(e, &e.current_contract_address());
+ }
+}
+
+#[contractimpl(contracttrait)]
+impl FungibleToken for RWATokenContract {
+ type ContractType = RWA;
+}
+
+#[contractimpl]
+impl RWAToken for RWATokenContract {
+ #[only_role(operator, "admin")]
+ fn forced_transfer(e: &Env, from: Address, to: Address, amount: i128, operator: Address) {
+ RWA::forced_transfer(e, &from, &to, amount);
+ }
+
+ #[only_role(operator, "admin")]
+ fn mint(e: &Env, to: Address, amount: i128, operator: Address) {
+ RWA::mint(e, &to, amount);
+ }
+
+ #[only_role(operator, "admin")]
+ fn burn(e: &Env, user_address: Address, amount: i128, operator: Address) {
+ RWA::burn(e, &user_address, amount);
+ }
+
+ #[only_role(operator, "admin")]
+ fn recover_balance(
+ e: &Env,
+ old_account: Address,
+ new_account: Address,
+ operator: Address,
+ ) -> bool {
+ RWA::recover_balance(e, &old_account, &new_account)
+ }
+
+ #[only_role(operator, "admin")]
+ fn set_address_frozen(e: &Env, user_address: Address, freeze: bool, operator: Address) {
+ RWA::set_address_frozen(e, &user_address, freeze);
+ }
+
+ #[only_role(operator, "admin")]
+ fn freeze_partial_tokens(e: &Env, user_address: Address, amount: i128, operator: Address) {
+ RWA::freeze_partial_tokens(e, &user_address, amount);
+ }
+
+ #[only_role(operator, "admin")]
+ fn unfreeze_partial_tokens(e: &Env, user_address: Address, amount: i128, operator: Address) {
+ RWA::unfreeze_partial_tokens(e, &user_address, amount);
+ }
+
+ fn is_frozen(e: &Env, user_address: Address) -> bool {
+ RWA::is_frozen(e, &user_address)
+ }
+
+ fn get_frozen_tokens(e: &Env, user_address: Address) -> i128 {
+ RWA::get_frozen_tokens(e, &user_address)
+ }
+
+ fn version(e: &Env) -> String {
+ RWA::version(e)
+ }
+
+ fn onchain_id(e: &Env) -> Address {
+ RWA::onchain_id(e)
+ }
+
+ #[only_role(operator, "admin")]
+ fn set_compliance(e: &Env, compliance: Address, operator: Address) {
+ RWA::set_compliance(e, &compliance);
+ }
+
+ fn compliance(e: &Env) -> Address {
+ RWA::compliance(e)
+ }
+
+ #[only_role(operator, "admin")]
+ fn set_identity_verifier(e: &Env, identity_verifier: Address, operator: Address) {
+ RWA::set_identity_verifier(e, &identity_verifier);
+ }
+
+ fn identity_verifier(e: &Env) -> Address {
+ RWA::identity_verifier(e)
+ }
+}
+
+#[contractimpl]
+impl Pausable for RWATokenContract {
+ fn paused(e: &Env) -> bool {
+ pausable::paused(e)
+ }
+
+ #[only_role(caller, "admin")]
+ fn pause(e: &Env, caller: Address) {
+ pausable::pause(e);
+ }
+
+ #[only_role(caller, "admin")]
+ fn unpause(e: &Env, caller: Address) {
+ pausable::unpause(e);
+ }
+}
+
+#[contractimpl(contracttrait)]
+impl AccessControl for RWATokenContract {}
diff --git a/examples/rwa-country-allow/Cargo.toml b/examples/rwa-country-allow/Cargo.toml
new file mode 100644
index 000000000..540b26786
--- /dev/null
+++ b/examples/rwa-country-allow/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "rwa-country-allow"
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+publish = false
+version.workspace = true
+authors.workspace = true
+
+[package.metadata.stellar]
+cargo_inherit = true
+
+[lib]
+crate-type = ["cdylib", "rlib"]
+doctest = false
+
+[dependencies]
+soroban-sdk = { workspace = true }
+stellar-tokens = { workspace = true }
+
+[dev-dependencies]
+soroban-sdk = { workspace = true, features = ["testutils"] }
diff --git a/examples/rwa-country-allow/src/contract.rs b/examples/rwa-country-allow/src/contract.rs
new file mode 100644
index 000000000..9be520c25
--- /dev/null
+++ b/examples/rwa-country-allow/src/contract.rs
@@ -0,0 +1,101 @@
+use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec};
+use stellar_tokens::rwa::compliance::modules::{
+ country_allow::storage as country_allow,
+ storage::{
+ get_compliance_address, module_name, set_compliance_address, set_irs_address,
+ ComplianceModuleStorageKey,
+ },
+ ComplianceModule,
+};
+
+#[contracttype]
+enum DataKey {
+ Admin,
+}
+
+#[contract]
+pub struct CountryAllowContract;
+
+fn set_admin(e: &Env, admin: &Address) {
+ e.storage().instance().set(&DataKey::Admin, admin);
+}
+
+fn get_admin(e: &Env) -> Address {
+ e.storage().instance().get(&DataKey::Admin).expect("admin must be set")
+}
+
+fn require_module_admin_or_compliance_auth(e: &Env) {
+ if let Some(compliance) =
+ e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance)
+ {
+ compliance.require_auth();
+ } else {
+ get_admin(e).require_auth();
+ }
+}
+
+#[contractimpl]
+impl CountryAllowContract {
+ pub fn __constructor(e: &Env, admin: Address) {
+ set_admin(e, &admin);
+ }
+
+ pub fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) {
+ require_module_admin_or_compliance_auth(e);
+ set_irs_address(e, &token, &irs);
+ }
+
+ pub fn add_allowed_country(e: &Env, token: Address, country: u32) {
+ require_module_admin_or_compliance_auth(e);
+ country_allow::add_allowed_country(e, &token, country);
+ }
+
+ pub fn remove_allowed_country(e: &Env, token: Address, country: u32) {
+ require_module_admin_or_compliance_auth(e);
+ country_allow::remove_allowed_country(e, &token, country);
+ }
+
+ pub fn batch_allow_countries(e: &Env, token: Address, countries: Vec) {
+ require_module_admin_or_compliance_auth(e);
+ country_allow::batch_allow_countries(e, &token, &countries);
+ }
+
+ pub fn batch_disallow_countries(e: &Env, token: Address, countries: Vec) {
+ require_module_admin_or_compliance_auth(e);
+ country_allow::batch_disallow_countries(e, &token, &countries);
+ }
+
+ pub fn is_country_allowed(e: &Env, token: Address, country: u32) -> bool {
+ country_allow::is_country_allowed(e, &token, country)
+ }
+}
+
+#[contractimpl(contracttrait)]
+impl ComplianceModule for CountryAllowContract {
+ fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {}
+
+ fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {}
+
+ fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {}
+
+ fn can_transfer(e: &Env, _from: Address, to: Address, _amount: i128, token: Address) -> bool {
+ country_allow::can_transfer(e, &to, &token)
+ }
+
+ fn can_create(e: &Env, to: Address, _amount: i128, token: Address) -> bool {
+ country_allow::can_transfer(e, &to, &token)
+ }
+
+ fn name(e: &Env) -> String {
+ module_name(e, "CountryAllowModule")
+ }
+
+ fn get_compliance_address(e: &Env) -> Address {
+ get_compliance_address(e)
+ }
+
+ fn set_compliance_address(e: &Env, compliance: Address) {
+ get_admin(e).require_auth();
+ set_compliance_address(e, &compliance);
+ }
+}
diff --git a/examples/rwa-country-allow/src/lib.rs b/examples/rwa-country-allow/src/lib.rs
new file mode 100644
index 000000000..a879b6f80
--- /dev/null
+++ b/examples/rwa-country-allow/src/lib.rs
@@ -0,0 +1,5 @@
+#![no_std]
+
+pub mod contract;
+#[cfg(test)]
+mod test;
diff --git a/examples/rwa-country-allow/src/test.rs b/examples/rwa-country-allow/src/test.rs
new file mode 100644
index 000000000..06b540ed3
--- /dev/null
+++ b/examples/rwa-country-allow/src/test.rs
@@ -0,0 +1,259 @@
+extern crate std;
+
+use soroban_sdk::{
+ contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal,
+ String, Val, Vec,
+};
+use stellar_tokens::rwa::{
+ identity_registry_storage::{
+ CountryData, CountryDataManager, CountryRelation, IdentityRegistryStorage,
+ IndividualCountryRelation, OrganizationCountryRelation,
+ },
+ utils::token_binder::TokenBinder,
+};
+
+use crate::contract::{CountryAllowContract, CountryAllowContractClient};
+
+fn create_client<'a>(e: &Env, admin: &Address) -> CountryAllowContractClient<'a> {
+ let address = e.register(CountryAllowContract, (admin,));
+ CountryAllowContractClient::new(e, &address)
+}
+
+#[contract]
+struct MockIRSContract;
+
+#[contracttype]
+#[derive(Clone)]
+enum MockIRSStorageKey {
+ Identity(Address),
+ CountryEntries(Address),
+}
+
+#[contractimpl]
+impl TokenBinder for MockIRSContract {
+ fn linked_tokens(e: &Env) -> Vec {
+ Vec::new(e)
+ }
+
+ fn bind_token(_e: &Env, _token: Address, _operator: Address) {
+ unreachable!("bind_token is not used in these tests");
+ }
+
+ fn unbind_token(_e: &Env, _token: Address, _operator: Address) {
+ unreachable!("unbind_token is not used in these tests");
+ }
+}
+
+#[contractimpl]
+impl IdentityRegistryStorage for MockIRSContract {
+ fn add_identity(
+ _e: &Env,
+ _account: Address,
+ _identity: Address,
+ _country_data_list: Vec,
+ _operator: Address,
+ ) {
+ unreachable!("add_identity is not used in these tests");
+ }
+
+ fn remove_identity(_e: &Env, _account: Address, _operator: Address) {
+ unreachable!("remove_identity is not used in these tests");
+ }
+
+ fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) {
+ unreachable!("modify_identity is not used in these tests");
+ }
+
+ fn recover_identity(
+ _e: &Env,
+ _old_account: Address,
+ _new_account: Address,
+ _operator: Address,
+ ) {
+ unreachable!("recover_identity is not used in these tests");
+ }
+
+ fn stored_identity(e: &Env, account: Address) -> Address {
+ e.storage()
+ .persistent()
+ .get(&MockIRSStorageKey::Identity(account.clone()))
+ .unwrap_or(account)
+ }
+}
+
+#[contractimpl]
+impl CountryDataManager for MockIRSContract {
+ fn add_country_data_entries(
+ _e: &Env,
+ _account: Address,
+ _country_data_list: Vec,
+ _operator: Address,
+ ) {
+ unreachable!("add_country_data_entries is not used in these tests");
+ }
+
+ fn modify_country_data(
+ _e: &Env,
+ _account: Address,
+ _index: u32,
+ _country_data: Val,
+ _operator: Address,
+ ) {
+ unreachable!("modify_country_data is not used in these tests");
+ }
+
+ fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) {
+ unreachable!("delete_country_data is not used in these tests");
+ }
+
+ fn get_country_data_entries(e: &Env, account: Address) -> Vec {
+ let entries: Vec = e
+ .storage()
+ .persistent()
+ .get(&MockIRSStorageKey::CountryEntries(account))
+ .unwrap_or_else(|| Vec::new(e));
+
+ Vec::from_iter(e, entries.iter().map(|entry| entry.into_val(e)))
+ }
+}
+
+#[contractimpl]
+impl MockIRSContract {
+ pub fn set_country_data_entries(e: &Env, account: Address, entries: Vec) {
+ e.storage().persistent().set(&MockIRSStorageKey::CountryEntries(account), &entries);
+ }
+}
+
+fn individual_country(code: u32) -> CountryData {
+ CountryData {
+ country: CountryRelation::Individual(IndividualCountryRelation::Residence(code)),
+ metadata: None,
+ }
+}
+
+fn organization_country(code: u32) -> CountryData {
+ CountryData {
+ country: CountryRelation::Organization(OrganizationCountryRelation::OperatingJurisdiction(
+ code,
+ )),
+ metadata: None,
+ }
+}
+
+#[test]
+fn add_and_remove_allowed_country_work() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let admin = Address::generate(&e);
+ let token = Address::generate(&e);
+ let client = create_client(&e, &admin);
+
+ assert!(!client.is_country_allowed(&token, &276));
+
+ client.add_allowed_country(&token, &276);
+ assert!(client.is_country_allowed(&token, &276));
+
+ client.remove_allowed_country(&token, &276);
+ assert!(!client.is_country_allowed(&token, &276));
+}
+
+#[test]
+fn batch_allow_and_disallow_countries_work() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let admin = Address::generate(&e);
+ let token = Address::generate(&e);
+ let client = create_client(&e, &admin);
+
+ client.batch_allow_countries(&token, &vec![&e, 250u32, 276u32]);
+ assert!(client.is_country_allowed(&token, &250));
+ assert!(client.is_country_allowed(&token, &276));
+
+ client.batch_disallow_countries(&token, &vec![&e, 250u32]);
+ assert!(!client.is_country_allowed(&token, &250));
+ assert!(client.is_country_allowed(&token, &276));
+}
+
+#[test]
+fn name_and_compliance_address_work() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let admin = Address::generate(&e);
+ let compliance = Address::generate(&e);
+ let client = create_client(&e, &admin);
+
+ assert_eq!(client.name(), String::from_str(&e, "CountryAllowModule"));
+
+ client.set_compliance_address(&compliance);
+ assert_eq!(client.get_compliance_address(), compliance);
+}
+
+#[test]
+fn set_identity_registry_storage_uses_admin_auth_before_compliance_bind() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let admin = Address::generate(&e);
+ let token = Address::generate(&e);
+ let irs = Address::generate(&e);
+ let client = create_client(&e, &admin);
+
+ client.set_identity_registry_storage(&token, &irs);
+
+ let auths = e.auths();
+ assert_eq!(auths.len(), 1);
+ let (addr, _) = &auths[0];
+ assert_eq!(addr, &admin);
+}
+
+#[test]
+fn set_identity_registry_storage_uses_compliance_auth_after_bind() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let admin = Address::generate(&e);
+ let compliance = Address::generate(&e);
+ let token = Address::generate(&e);
+ let irs = Address::generate(&e);
+ let client = create_client(&e, &admin);
+
+ client.set_compliance_address(&compliance);
+ let auths = e.auths();
+ assert_eq!(auths.len(), 1);
+ let (addr, _) = &auths[0];
+ assert_eq!(addr, &admin);
+
+ client.set_identity_registry_storage(&token, &irs);
+
+ let auths = e.auths();
+ assert_eq!(auths.len(), 1);
+ let (addr, _) = &auths[0];
+ assert_eq!(addr, &compliance);
+}
+
+#[test]
+fn can_transfer_and_can_create_use_irs_country_entries() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let admin = Address::generate(&e);
+ let from = Address::generate(&e);
+ let token = Address::generate(&e);
+ let allowed_to = Address::generate(&e);
+ let disallowed_to = Address::generate(&e);
+ let amount = 100_i128;
+ let client = create_client(&e, &admin);
+ let irs_id = e.register(MockIRSContract, ());
+ let irs = MockIRSContractClient::new(&e, &irs_id);
+
+ irs.set_country_data_entries(
+ &allowed_to,
+ &vec![&e, individual_country(250), organization_country(276)],
+ );
+ irs.set_country_data_entries(&disallowed_to, &vec![&e, individual_country(250)]);
+
+ client.set_identity_registry_storage(&token, &irs_id);
+ client.add_allowed_country(&token, &276);
+
+ assert!(client.can_transfer(&from, &allowed_to, &amount, &token));
+ assert!(client.can_create(&allowed_to, &amount, &token));
+ assert!(!client.can_transfer(&from, &disallowed_to, &amount, &token));
+ assert!(!client.can_create(&disallowed_to, &amount, &token));
+}
diff --git a/examples/rwa-country-restrict/Cargo.toml b/examples/rwa-country-restrict/Cargo.toml
new file mode 100644
index 000000000..d160254e9
--- /dev/null
+++ b/examples/rwa-country-restrict/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "rwa-country-restrict"
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+publish = false
+version.workspace = true
+authors.workspace = true
+
+[package.metadata.stellar]
+cargo_inherit = true
+
+[lib]
+crate-type = ["cdylib", "rlib"]
+doctest = false
+
+[dependencies]
+soroban-sdk = { workspace = true }
+stellar-tokens = { workspace = true }
+
+[dev-dependencies]
+soroban-sdk = { workspace = true, features = ["testutils"] }
diff --git a/examples/rwa-country-restrict/src/contract.rs b/examples/rwa-country-restrict/src/contract.rs
new file mode 100644
index 000000000..aa2c9794c
--- /dev/null
+++ b/examples/rwa-country-restrict/src/contract.rs
@@ -0,0 +1,101 @@
+use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec};
+use stellar_tokens::rwa::compliance::modules::{
+ country_restrict::storage as country_restrict,
+ storage::{
+ get_compliance_address, module_name, set_compliance_address, set_irs_address,
+ ComplianceModuleStorageKey,
+ },
+ ComplianceModule,
+};
+
+#[contracttype]
+enum DataKey {
+ Admin,
+}
+
+#[contract]
+pub struct CountryRestrictContract;
+
+fn set_admin(e: &Env, admin: &Address) {
+ e.storage().instance().set(&DataKey::Admin, admin);
+}
+
+fn get_admin(e: &Env) -> Address {
+ e.storage().instance().get(&DataKey::Admin).expect("admin must be set")
+}
+
+fn require_module_admin_or_compliance_auth(e: &Env) {
+ if let Some(compliance) =
+ e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance)
+ {
+ compliance.require_auth();
+ } else {
+ get_admin(e).require_auth();
+ }
+}
+
+#[contractimpl]
+impl CountryRestrictContract {
+ pub fn __constructor(e: &Env, admin: Address) {
+ set_admin(e, &admin);
+ }
+
+ pub fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) {
+ require_module_admin_or_compliance_auth(e);
+ set_irs_address(e, &token, &irs);
+ }
+
+ pub fn add_country_restriction(e: &Env, token: Address, country: u32) {
+ require_module_admin_or_compliance_auth(e);
+ country_restrict::add_country_restriction(e, &token, country);
+ }
+
+ pub fn remove_country_restriction(e: &Env, token: Address, country: u32) {
+ require_module_admin_or_compliance_auth(e);
+ country_restrict::remove_country_restriction(e, &token, country);
+ }
+
+ pub fn batch_restrict_countries(e: &Env, token: Address, countries: Vec) {
+ require_module_admin_or_compliance_auth(e);
+ country_restrict::batch_restrict_countries(e, &token, &countries);
+ }
+
+ pub fn batch_unrestrict_countries(e: &Env, token: Address, countries: Vec) {
+ require_module_admin_or_compliance_auth(e);
+ country_restrict::batch_unrestrict_countries(e, &token, &countries);
+ }
+
+ pub fn is_country_restricted(e: &Env, token: Address, country: u32) -> bool {
+ country_restrict::is_country_restricted(e, &token, country)
+ }
+}
+
+#[contractimpl(contracttrait)]
+impl ComplianceModule for CountryRestrictContract {
+ fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {}
+
+ fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {}
+
+ fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {}
+
+ fn can_transfer(e: &Env, _from: Address, to: Address, _amount: i128, token: Address) -> bool {
+ country_restrict::can_transfer(e, &to, &token)
+ }
+
+ fn can_create(e: &Env, to: Address, _amount: i128, token: Address) -> bool {
+ country_restrict::can_transfer(e, &to, &token)
+ }
+
+ fn name(e: &Env) -> String {
+ module_name(e, "CountryRestrictModule")
+ }
+
+ fn get_compliance_address(e: &Env) -> Address {
+ get_compliance_address(e)
+ }
+
+ fn set_compliance_address(e: &Env, compliance: Address) {
+ get_admin(e).require_auth();
+ set_compliance_address(e, &compliance);
+ }
+}
diff --git a/examples/rwa-country-restrict/src/lib.rs b/examples/rwa-country-restrict/src/lib.rs
new file mode 100644
index 000000000..a879b6f80
--- /dev/null
+++ b/examples/rwa-country-restrict/src/lib.rs
@@ -0,0 +1,5 @@
+#![no_std]
+
+pub mod contract;
+#[cfg(test)]
+mod test;
diff --git a/examples/rwa-country-restrict/src/test.rs b/examples/rwa-country-restrict/src/test.rs
new file mode 100644
index 000000000..4d6a10154
--- /dev/null
+++ b/examples/rwa-country-restrict/src/test.rs
@@ -0,0 +1,259 @@
+extern crate std;
+
+use soroban_sdk::{
+ contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal,
+ String, Val, Vec,
+};
+use stellar_tokens::rwa::{
+ identity_registry_storage::{
+ CountryData, CountryDataManager, CountryRelation, IdentityRegistryStorage,
+ IndividualCountryRelation, OrganizationCountryRelation,
+ },
+ utils::token_binder::TokenBinder,
+};
+
+use crate::contract::{CountryRestrictContract, CountryRestrictContractClient};
+
+fn create_client<'a>(e: &Env, admin: &Address) -> CountryRestrictContractClient<'a> {
+ let address = e.register(CountryRestrictContract, (admin,));
+ CountryRestrictContractClient::new(e, &address)
+}
+
+#[contract]
+struct MockIRSContract;
+
+#[contracttype]
+#[derive(Clone)]
+enum MockIRSStorageKey {
+ Identity(Address),
+ CountryEntries(Address),
+}
+
+#[contractimpl]
+impl TokenBinder for MockIRSContract {
+ fn linked_tokens(e: &Env) -> Vec {
+ Vec::new(e)
+ }
+
+ fn bind_token(_e: &Env, _token: Address, _operator: Address) {
+ unreachable!("bind_token is not used in these tests");
+ }
+
+ fn unbind_token(_e: &Env, _token: Address, _operator: Address) {
+ unreachable!("unbind_token is not used in these tests");
+ }
+}
+
+#[contractimpl]
+impl IdentityRegistryStorage for MockIRSContract {
+ fn add_identity(
+ _e: &Env,
+ _account: Address,
+ _identity: Address,
+ _country_data_list: Vec,
+ _operator: Address,
+ ) {
+ unreachable!("add_identity is not used in these tests");
+ }
+
+ fn remove_identity(_e: &Env, _account: Address, _operator: Address) {
+ unreachable!("remove_identity is not used in these tests");
+ }
+
+ fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) {
+ unreachable!("modify_identity is not used in these tests");
+ }
+
+ fn recover_identity(
+ _e: &Env,
+ _old_account: Address,
+ _new_account: Address,
+ _operator: Address,
+ ) {
+ unreachable!("recover_identity is not used in these tests");
+ }
+
+ fn stored_identity(e: &Env, account: Address) -> Address {
+ e.storage()
+ .persistent()
+ .get(&MockIRSStorageKey::Identity(account.clone()))
+ .unwrap_or(account)
+ }
+}
+
+#[contractimpl]
+impl CountryDataManager for MockIRSContract {
+ fn add_country_data_entries(
+ _e: &Env,
+ _account: Address,
+ _country_data_list: Vec,
+ _operator: Address,
+ ) {
+ unreachable!("add_country_data_entries is not used in these tests");
+ }
+
+ fn modify_country_data(
+ _e: &Env,
+ _account: Address,
+ _index: u32,
+ _country_data: Val,
+ _operator: Address,
+ ) {
+ unreachable!("modify_country_data is not used in these tests");
+ }
+
+ fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) {
+ unreachable!("delete_country_data is not used in these tests");
+ }
+
+ fn get_country_data_entries(e: &Env, account: Address) -> Vec {
+ let entries: Vec = e
+ .storage()
+ .persistent()
+ .get(&MockIRSStorageKey::CountryEntries(account))
+ .unwrap_or_else(|| Vec::new(e));
+
+ Vec::from_iter(e, entries.iter().map(|entry| entry.into_val(e)))
+ }
+}
+
+#[contractimpl]
+impl MockIRSContract {
+ pub fn set_country_data_entries(e: &Env, account: Address, entries: Vec) {
+ e.storage().persistent().set(&MockIRSStorageKey::CountryEntries(account), &entries);
+ }
+}
+
+fn individual_country(code: u32) -> CountryData {
+ CountryData {
+ country: CountryRelation::Individual(IndividualCountryRelation::Residence(code)),
+ metadata: None,
+ }
+}
+
+fn organization_country(code: u32) -> CountryData {
+ CountryData {
+ country: CountryRelation::Organization(OrganizationCountryRelation::OperatingJurisdiction(
+ code,
+ )),
+ metadata: None,
+ }
+}
+
+#[test]
+fn add_and_remove_country_restriction_work() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let admin = Address::generate(&e);
+ let token = Address::generate(&e);
+ let client = create_client(&e, &admin);
+
+ assert!(!client.is_country_restricted(&token, &276));
+
+ client.add_country_restriction(&token, &276);
+ assert!(client.is_country_restricted(&token, &276));
+
+ client.remove_country_restriction(&token, &276);
+ assert!(!client.is_country_restricted(&token, &276));
+}
+
+#[test]
+fn batch_restrict_and_unrestrict_countries_work() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let admin = Address::generate(&e);
+ let token = Address::generate(&e);
+ let client = create_client(&e, &admin);
+
+ client.batch_restrict_countries(&token, &vec![&e, 250u32, 276u32]);
+ assert!(client.is_country_restricted(&token, &250));
+ assert!(client.is_country_restricted(&token, &276));
+
+ client.batch_unrestrict_countries(&token, &vec![&e, 250u32]);
+ assert!(!client.is_country_restricted(&token, &250));
+ assert!(client.is_country_restricted(&token, &276));
+}
+
+#[test]
+fn name_and_compliance_address_work() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let admin = Address::generate(&e);
+ let compliance = Address::generate(&e);
+ let client = create_client(&e, &admin);
+
+ assert_eq!(client.name(), String::from_str(&e, "CountryRestrictModule"));
+
+ client.set_compliance_address(&compliance);
+ assert_eq!(client.get_compliance_address(), compliance);
+}
+
+#[test]
+fn set_identity_registry_storage_uses_admin_auth_before_compliance_bind() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let admin = Address::generate(&e);
+ let token = Address::generate(&e);
+ let irs = Address::generate(&e);
+ let client = create_client(&e, &admin);
+
+ client.set_identity_registry_storage(&token, &irs);
+
+ let auths = e.auths();
+ assert_eq!(auths.len(), 1);
+ let (addr, _) = &auths[0];
+ assert_eq!(addr, &admin);
+}
+
+#[test]
+fn set_identity_registry_storage_uses_compliance_auth_after_bind() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let admin = Address::generate(&e);
+ let compliance = Address::generate(&e);
+ let token = Address::generate(&e);
+ let irs = Address::generate(&e);
+ let client = create_client(&e, &admin);
+
+ client.set_compliance_address(&compliance);
+ let auths = e.auths();
+ assert_eq!(auths.len(), 1);
+ let (addr, _) = &auths[0];
+ assert_eq!(addr, &admin);
+
+ client.set_identity_registry_storage(&token, &irs);
+
+ let auths = e.auths();
+ assert_eq!(auths.len(), 1);
+ let (addr, _) = &auths[0];
+ assert_eq!(addr, &compliance);
+}
+
+#[test]
+fn can_transfer_and_can_create_use_irs_country_entries() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let admin = Address::generate(&e);
+ let from = Address::generate(&e);
+ let token = Address::generate(&e);
+ let allowed_to = Address::generate(&e);
+ let restricted_to = Address::generate(&e);
+ let amount = 100_i128;
+ let client = create_client(&e, &admin);
+ let irs_id = e.register(MockIRSContract, ());
+ let irs = MockIRSContractClient::new(&e, &irs_id);
+
+ irs.set_country_data_entries(&allowed_to, &vec![&e, individual_country(250)]);
+ irs.set_country_data_entries(
+ &restricted_to,
+ &vec![&e, individual_country(250), organization_country(276)],
+ );
+
+ client.set_identity_registry_storage(&token, &irs_id);
+ client.add_country_restriction(&token, &276);
+
+ assert!(client.can_transfer(&from, &allowed_to, &amount, &token));
+ assert!(client.can_create(&allowed_to, &amount, &token));
+ assert!(!client.can_transfer(&from, &restricted_to, &amount, &token));
+ assert!(!client.can_create(&restricted_to, &amount, &token));
+}
diff --git a/examples/rwa-initial-lockup-period/Cargo.toml b/examples/rwa-initial-lockup-period/Cargo.toml
new file mode 100644
index 000000000..767f3cca1
--- /dev/null
+++ b/examples/rwa-initial-lockup-period/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "rwa-initial-lockup-period"
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+publish = false
+version.workspace = true
+authors.workspace = true
+
+[package.metadata.stellar]
+cargo_inherit = true
+
+[lib]
+crate-type = ["cdylib", "rlib"]
+doctest = false
+
+[dependencies]
+soroban-sdk = { workspace = true }
+stellar-tokens = { workspace = true }
+
+[dev-dependencies]
+soroban-sdk = { workspace = true, features = ["testutils"] }
diff --git a/examples/rwa-initial-lockup-period/src/contract.rs b/examples/rwa-initial-lockup-period/src/contract.rs
new file mode 100644
index 000000000..330f6741a
--- /dev/null
+++ b/examples/rwa-initial-lockup-period/src/contract.rs
@@ -0,0 +1,123 @@
+use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec};
+use stellar_tokens::rwa::compliance::{
+ modules::{
+ initial_lockup_period::{storage as lockup, LockedTokens},
+ storage::{
+ get_compliance_address, module_name, set_compliance_address, ComplianceModuleStorageKey,
+ },
+ ComplianceModule,
+ },
+ ComplianceHook,
+};
+
+#[contracttype]
+enum DataKey {
+ Admin,
+}
+
+#[contract]
+pub struct InitialLockupPeriodContract;
+
+fn set_admin(e: &Env, admin: &Address) {
+ e.storage().instance().set(&DataKey::Admin, admin);
+}
+
+fn get_admin(e: &Env) -> Address {
+ e.storage().instance().get(&DataKey::Admin).expect("admin must be set")
+}
+
+fn require_module_admin_or_compliance_auth(e: &Env) {
+ if let Some(compliance) =
+ e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance)
+ {
+ compliance.require_auth();
+ } else {
+ get_admin(e).require_auth();
+ }
+}
+
+#[contractimpl]
+impl InitialLockupPeriodContract {
+ pub fn __constructor(e: &Env, admin: Address) {
+ set_admin(e, &admin);
+ }
+
+ pub fn set_lockup_period(e: &Env, token: Address, lockup_seconds: u64) {
+ require_module_admin_or_compliance_auth(e);
+ lockup::configure_lockup_period(e, &token, lockup_seconds);
+ }
+
+ pub fn pre_set_lockup_state(
+ e: &Env,
+ token: Address,
+ wallet: Address,
+ balance: i128,
+ locks: Vec,
+ ) {
+ require_module_admin_or_compliance_auth(e);
+ lockup::pre_set_lockup_state(e, &token, &wallet, balance, &locks);
+ }
+
+ pub fn get_lockup_period(e: &Env, token: Address) -> u64 {
+ lockup::get_lockup_period(e, &token)
+ }
+
+ pub fn get_total_locked(e: &Env, token: Address, wallet: Address) -> i128 {
+ lockup::get_total_locked(e, &token, &wallet)
+ }
+
+ pub fn get_locked_tokens(e: &Env, token: Address, wallet: Address) -> Vec {
+ lockup::get_locks(e, &token, &wallet)
+ }
+
+ pub fn get_internal_balance(e: &Env, token: Address, wallet: Address) -> i128 {
+ lockup::get_internal_balance(e, &token, &wallet)
+ }
+
+ pub fn required_hooks(e: &Env) -> Vec {
+ lockup::required_hooks(e)
+ }
+
+ pub fn verify_hook_wiring(e: &Env) {
+ lockup::verify_hook_wiring(e);
+ }
+}
+
+#[contractimpl(contracttrait)]
+impl ComplianceModule for InitialLockupPeriodContract {
+ fn on_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) {
+ require_module_admin_or_compliance_auth(e);
+ lockup::on_transfer(e, &from, &to, amount, &token);
+ }
+
+ fn on_created(e: &Env, to: Address, amount: i128, token: Address) {
+ require_module_admin_or_compliance_auth(e);
+ lockup::on_created(e, &to, amount, &token);
+ }
+
+ fn on_destroyed(e: &Env, from: Address, amount: i128, token: Address) {
+ require_module_admin_or_compliance_auth(e);
+ lockup::on_destroyed(e, &from, amount, &token);
+ }
+
+ fn can_transfer(e: &Env, from: Address, _to: Address, amount: i128, token: Address) -> bool {
+ lockup::can_transfer(e, &from, amount, &token)
+ }
+
+ fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool {
+ true
+ }
+
+ fn name(e: &Env) -> String {
+ module_name(e, "InitialLockupPeriodModule")
+ }
+
+ fn get_compliance_address(e: &Env) -> Address {
+ get_compliance_address(e)
+ }
+
+ fn set_compliance_address(e: &Env, compliance: Address) {
+ get_admin(e).require_auth();
+ set_compliance_address(e, &compliance);
+ }
+}
diff --git a/examples/rwa-initial-lockup-period/src/lib.rs b/examples/rwa-initial-lockup-period/src/lib.rs
new file mode 100644
index 000000000..a879b6f80
--- /dev/null
+++ b/examples/rwa-initial-lockup-period/src/lib.rs
@@ -0,0 +1,5 @@
+#![no_std]
+
+pub mod contract;
+#[cfg(test)]
+mod test;
diff --git a/examples/rwa-initial-lockup-period/src/test.rs b/examples/rwa-initial-lockup-period/src/test.rs
new file mode 100644
index 000000000..426f71f3f
--- /dev/null
+++ b/examples/rwa-initial-lockup-period/src/test.rs
@@ -0,0 +1,216 @@
+extern crate std;
+
+use soroban_sdk::{
+ contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, String, Vec,
+};
+use stellar_tokens::rwa::{
+ compliance::{modules::initial_lockup_period::LockedTokens, Compliance, ComplianceHook},
+ utils::token_binder::TokenBinder,
+};
+
+use crate::contract::{InitialLockupPeriodContract, InitialLockupPeriodContractClient};
+
+fn create_client<'a>(e: &Env, admin: &Address) -> (Address, InitialLockupPeriodContractClient<'a>) {
+ let address = e.register(InitialLockupPeriodContract, (admin,));
+ (address.clone(), InitialLockupPeriodContractClient::new(e, &address))
+}
+
+#[contract]
+struct MockComplianceContract;
+
+#[contracttype]
+#[derive(Clone)]
+enum MockComplianceStorageKey {
+ Registered(ComplianceHook, Address),
+}
+
+#[contractimpl]
+impl Compliance for MockComplianceContract {
+ fn add_module_to(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) {
+ unreachable!("add_module_to is not used in these tests");
+ }
+
+ fn remove_module_from(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) {
+ unreachable!("remove_module_from is not used in these tests");
+ }
+
+ fn get_modules_for_hook(_e: &Env, _hook: ComplianceHook) -> Vec {
+ unreachable!("get_modules_for_hook is not used in these tests");
+ }
+
+ fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool {
+ e.storage().persistent().has(&MockComplianceStorageKey::Registered(hook, module))
+ }
+
+ fn transferred(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {
+ unreachable!("transferred is not used in these tests");
+ }
+
+ fn created(_e: &Env, _to: Address, _amount: i128, _token: Address) {
+ unreachable!("created is not used in these tests");
+ }
+
+ fn destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {
+ unreachable!("destroyed is not used in these tests");
+ }
+
+ fn can_transfer(
+ _e: &Env,
+ _from: Address,
+ _to: Address,
+ _amount: i128,
+ _token: Address,
+ ) -> bool {
+ unreachable!("can_transfer is not used in these tests");
+ }
+
+ fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool {
+ unreachable!("can_create is not used in these tests");
+ }
+}
+
+#[contractimpl]
+impl TokenBinder for MockComplianceContract {
+ fn linked_tokens(e: &Env) -> Vec {
+ Vec::new(e)
+ }
+
+ fn bind_token(_e: &Env, _token: Address, _operator: Address) {
+ unreachable!("bind_token is not used in these tests");
+ }
+
+ fn unbind_token(_e: &Env, _token: Address, _operator: Address) {
+ unreachable!("unbind_token is not used in these tests");
+ }
+}
+
+#[contractimpl]
+impl MockComplianceContract {
+ pub fn register_hook(e: &Env, hook: ComplianceHook, module: Address) {
+ e.storage().persistent().set(&MockComplianceStorageKey::Registered(hook, module), &true);
+ }
+}
+
+#[test]
+fn set_and_get_lockup_state_work() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let admin = Address::generate(&e);
+ let token = Address::generate(&e);
+ let wallet = Address::generate(&e);
+ let release_timestamp = e.ledger().timestamp().saturating_add(60);
+ let locks = vec![
+ &e,
+ LockedTokens { amount: 80, release_timestamp },
+ LockedTokens { amount: 10, release_timestamp: release_timestamp.saturating_add(60) },
+ ];
+ let (_address, client) = create_client(&e, &admin);
+
+ client.set_lockup_period(&token, &60);
+ client.pre_set_lockup_state(&token, &wallet, &100, &locks);
+
+ assert_eq!(client.get_lockup_period(&token), 60);
+ assert_eq!(client.get_total_locked(&token, &wallet), 90);
+ assert_eq!(client.get_internal_balance(&token, &wallet), 100);
+
+ let stored_locks = client.get_locked_tokens(&token, &wallet);
+ assert_eq!(stored_locks.len(), 2);
+
+ let first_lock = stored_locks.get(0).unwrap();
+ assert_eq!(first_lock.amount, 80);
+ assert_eq!(first_lock.release_timestamp, release_timestamp);
+}
+
+#[test]
+fn name_compliance_address_and_required_hooks_work() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let admin = Address::generate(&e);
+ let compliance = Address::generate(&e);
+ let (_address, client) = create_client(&e, &admin);
+
+ assert_eq!(client.name(), String::from_str(&e, "InitialLockupPeriodModule"));
+ assert_eq!(
+ client.required_hooks(),
+ vec![
+ &e,
+ ComplianceHook::CanTransfer,
+ ComplianceHook::Created,
+ ComplianceHook::Transferred,
+ ComplianceHook::Destroyed,
+ ]
+ );
+
+ client.set_compliance_address(&compliance);
+ assert_eq!(client.get_compliance_address(), compliance);
+}
+
+#[test]
+fn set_lockup_period_uses_admin_auth_before_compliance_bind() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let admin = Address::generate(&e);
+ let token = Address::generate(&e);
+ let (_address, client) = create_client(&e, &admin);
+
+ client.set_lockup_period(&token, &60);
+
+ let auths = e.auths();
+ assert_eq!(auths.len(), 1);
+ let (addr, _) = &auths[0];
+ assert_eq!(addr, &admin);
+}
+
+#[test]
+fn set_lockup_period_uses_compliance_auth_after_bind() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let admin = Address::generate(&e);
+ let compliance = Address::generate(&e);
+ let token = Address::generate(&e);
+ let (_address, client) = create_client(&e, &admin);
+
+ client.set_compliance_address(&compliance);
+ let auths = e.auths();
+ assert_eq!(auths.len(), 1);
+ let (addr, _) = &auths[0];
+ assert_eq!(addr, &admin);
+
+ client.set_lockup_period(&token, &60);
+
+ let auths = e.auths();
+ assert_eq!(auths.len(), 1);
+ let (addr, _) = &auths[0];
+ assert_eq!(addr, &compliance);
+}
+
+#[test]
+fn verify_hook_wiring_and_can_transfer_use_public_contract_api() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let admin = Address::generate(&e);
+ let token = Address::generate(&e);
+ let wallet = Address::generate(&e);
+ let recipient = Address::generate(&e);
+ let release_timestamp = e.ledger().timestamp().saturating_add(60);
+ let locks = vec![&e, LockedTokens { amount: 80, release_timestamp }];
+ let (module_address, client) = create_client(&e, &admin);
+ let compliance_id = e.register(MockComplianceContract, ());
+ let compliance = MockComplianceContractClient::new(&e, &compliance_id);
+
+ client.set_compliance_address(&compliance_id);
+ for hook in [
+ ComplianceHook::CanTransfer,
+ ComplianceHook::Created,
+ ComplianceHook::Transferred,
+ ComplianceHook::Destroyed,
+ ] {
+ compliance.register_hook(&hook, &module_address);
+ }
+
+ client.verify_hook_wiring();
+ client.pre_set_lockup_state(&token, &wallet, &100, &locks);
+
+ assert!(!client.can_transfer(&wallet, &recipient, &21, &token));
+ assert!(client.can_transfer(&wallet, &recipient, &20, &token));
+}
diff --git a/examples/rwa-max-balance/Cargo.toml b/examples/rwa-max-balance/Cargo.toml
new file mode 100644
index 000000000..066fdcb4c
--- /dev/null
+++ b/examples/rwa-max-balance/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "rwa-max-balance"
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+publish = false
+version.workspace = true
+authors.workspace = true
+
+[package.metadata.stellar]
+cargo_inherit = true
+
+[lib]
+crate-type = ["cdylib", "rlib"]
+doctest = false
+
+[dependencies]
+soroban-sdk = { workspace = true }
+stellar-tokens = { workspace = true }
+
+[dev-dependencies]
+soroban-sdk = { workspace = true, features = ["testutils"] }
diff --git a/examples/rwa-max-balance/src/contract.rs b/examples/rwa-max-balance/src/contract.rs
new file mode 100644
index 000000000..32cf25a75
--- /dev/null
+++ b/examples/rwa-max-balance/src/contract.rs
@@ -0,0 +1,125 @@
+use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec};
+use stellar_tokens::rwa::compliance::{
+ modules::{
+ max_balance::storage as max_balance,
+ storage::{
+ get_compliance_address, module_name, set_compliance_address, set_irs_address,
+ ComplianceModuleStorageKey,
+ },
+ ComplianceModule,
+ },
+ ComplianceHook,
+};
+
+#[contracttype]
+enum DataKey {
+ Admin,
+}
+
+#[contract]
+pub struct MaxBalanceContract;
+
+fn set_admin(e: &Env, admin: &Address) {
+ e.storage().instance().set(&DataKey::Admin, admin);
+}
+
+fn get_admin(e: &Env) -> Address {
+ e.storage().instance().get(&DataKey::Admin).expect("admin must be set")
+}
+
+fn require_module_admin_or_compliance_auth(e: &Env) {
+ if let Some(compliance) =
+ e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance)
+ {
+ compliance.require_auth();
+ } else {
+ get_admin(e).require_auth();
+ }
+}
+
+#[contractimpl]
+impl MaxBalanceContract {
+ pub fn __constructor(e: &Env, admin: Address) {
+ set_admin(e, &admin);
+ }
+
+ pub fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) {
+ require_module_admin_or_compliance_auth(e);
+ set_irs_address(e, &token, &irs);
+ }
+
+ pub fn set_max_balance(e: &Env, token: Address, max: i128) {
+ require_module_admin_or_compliance_auth(e);
+ max_balance::configure_max_balance(e, &token, max);
+ }
+
+ pub fn pre_set_identity_balance(e: &Env, token: Address, identity: Address, balance: i128) {
+ require_module_admin_or_compliance_auth(e);
+ max_balance::pre_set_identity_balance(e, &token, &identity, balance);
+ }
+
+ pub fn batch_pre_set_identity_balances(
+ e: &Env,
+ token: Address,
+ identities: Vec,
+ balances: Vec,
+ ) {
+ require_module_admin_or_compliance_auth(e);
+ max_balance::batch_pre_set_identity_balances(e, &token, &identities, &balances);
+ }
+
+ pub fn get_max_balance(e: &Env, token: Address) -> i128 {
+ max_balance::get_max_balance(e, &token)
+ }
+
+ pub fn get_investor_balance(e: &Env, token: Address, identity: Address) -> i128 {
+ max_balance::get_id_balance(e, &token, &identity)
+ }
+
+ pub fn required_hooks(e: &Env) -> Vec {
+ max_balance::required_hooks(e)
+ }
+
+ pub fn verify_hook_wiring(e: &Env) {
+ max_balance::verify_hook_wiring(e);
+ }
+}
+
+#[contractimpl(contracttrait)]
+impl ComplianceModule for MaxBalanceContract {
+ fn on_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) {
+ require_module_admin_or_compliance_auth(e);
+ max_balance::on_transfer(e, &from, &to, amount, &token);
+ }
+
+ fn on_created(e: &Env, to: Address, amount: i128, token: Address) {
+ require_module_admin_or_compliance_auth(e);
+ max_balance::on_created(e, &to, amount, &token);
+ }
+
+ fn on_destroyed(e: &Env, from: Address, amount: i128, token: Address) {
+ require_module_admin_or_compliance_auth(e);
+ max_balance::on_destroyed(e, &from, amount, &token);
+ }
+
+ fn can_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) -> bool {
+ max_balance::can_transfer(e, &from, &to, amount, &token)
+ }
+
+ fn can_create(e: &Env, to: Address, amount: i128, token: Address) -> bool {
+ max_balance::can_create(e, &to, amount, &token)
+ }
+
+ fn name(e: &Env) -> String {
+ module_name(e, "MaxBalanceModule")
+ }
+
+ fn get_compliance_address(e: &Env) -> Address {
+ get_compliance_address(e)
+ }
+
+ fn set_compliance_address(e: &Env, compliance: Address) {
+ get_admin(e).require_auth();
+ set_compliance_address(e, &compliance);
+ }
+}
diff --git a/examples/rwa-max-balance/src/lib.rs b/examples/rwa-max-balance/src/lib.rs
new file mode 100644
index 000000000..a879b6f80
--- /dev/null
+++ b/examples/rwa-max-balance/src/lib.rs
@@ -0,0 +1,5 @@
+#![no_std]
+
+pub mod contract;
+#[cfg(test)]
+mod test;
diff --git a/examples/rwa-max-balance/src/test.rs b/examples/rwa-max-balance/src/test.rs
new file mode 100644
index 000000000..3beb493c1
--- /dev/null
+++ b/examples/rwa-max-balance/src/test.rs
@@ -0,0 +1,303 @@
+extern crate std;
+
+use soroban_sdk::{
+ contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, String, Val,
+ Vec,
+};
+use stellar_tokens::rwa::{
+ compliance::{Compliance, ComplianceHook},
+ identity_registry_storage::IdentityRegistryStorage,
+ utils::token_binder::TokenBinder,
+};
+
+use crate::contract::{MaxBalanceContract, MaxBalanceContractClient};
+
+fn create_client<'a>(e: &Env, admin: &Address) -> (Address, MaxBalanceContractClient<'a>) {
+ let address = e.register(MaxBalanceContract, (admin,));
+ (address.clone(), MaxBalanceContractClient::new(e, &address))
+}
+
+#[contract]
+struct MockIRSContract;
+
+#[contracttype]
+#[derive(Clone)]
+enum MockIRSStorageKey {
+ Identity(Address),
+}
+
+#[contractimpl]
+impl TokenBinder for MockIRSContract {
+ fn linked_tokens(e: &Env) -> Vec {
+ Vec::new(e)
+ }
+
+ fn bind_token(_e: &Env, _token: Address, _operator: Address) {
+ unreachable!("bind_token is not used in these tests");
+ }
+
+ fn unbind_token(_e: &Env, _token: Address, _operator: Address) {
+ unreachable!("unbind_token is not used in these tests");
+ }
+}
+
+#[contractimpl]
+impl IdentityRegistryStorage for MockIRSContract {
+ fn add_identity(
+ _e: &Env,
+ _account: Address,
+ _identity: Address,
+ _country_data_list: Vec,
+ _operator: Address,
+ ) {
+ unreachable!("add_identity is not used in these tests");
+ }
+
+ fn remove_identity(_e: &Env, _account: Address, _operator: Address) {
+ unreachable!("remove_identity is not used in these tests");
+ }
+
+ fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) {
+ unreachable!("modify_identity is not used in these tests");
+ }
+
+ fn recover_identity(
+ _e: &Env,
+ _old_account: Address,
+ _new_account: Address,
+ _operator: Address,
+ ) {
+ unreachable!("recover_identity is not used in these tests");
+ }
+
+ fn stored_identity(e: &Env, account: Address) -> Address {
+ e.storage()
+ .persistent()
+ .get(&MockIRSStorageKey::Identity(account.clone()))
+ .unwrap_or(account)
+ }
+}
+
+#[contractimpl]
+impl MockIRSContract {
+ pub fn set_identity(e: &Env, account: Address, identity: Address) {
+ e.storage().persistent().set(&MockIRSStorageKey::Identity(account), &identity);
+ }
+}
+
+#[contract]
+struct MockComplianceContract;
+
+#[contracttype]
+#[derive(Clone)]
+enum MockComplianceStorageKey {
+ Registered(ComplianceHook, Address),
+}
+
+#[contractimpl]
+impl Compliance for MockComplianceContract {
+ fn add_module_to(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) {
+ unreachable!("add_module_to is not used in these tests");
+ }
+
+ fn remove_module_from(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) {
+ unreachable!("remove_module_from is not used in these tests");
+ }
+
+ fn get_modules_for_hook(_e: &Env, _hook: ComplianceHook) -> Vec {
+ unreachable!("get_modules_for_hook is not used in these tests");
+ }
+
+ fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool {
+ e.storage().persistent().has(&MockComplianceStorageKey::Registered(hook, module))
+ }
+
+ fn transferred(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {
+ unreachable!("transferred is not used in these tests");
+ }
+
+ fn created(_e: &Env, _to: Address, _amount: i128, _token: Address) {
+ unreachable!("created is not used in these tests");
+ }
+
+ fn destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {
+ unreachable!("destroyed is not used in these tests");
+ }
+
+ fn can_transfer(
+ _e: &Env,
+ _from: Address,
+ _to: Address,
+ _amount: i128,
+ _token: Address,
+ ) -> bool {
+ unreachable!("can_transfer is not used in these tests");
+ }
+
+ fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool {
+ unreachable!("can_create is not used in these tests");
+ }
+}
+
+#[contractimpl]
+impl TokenBinder for MockComplianceContract {
+ fn linked_tokens(e: &Env) -> Vec {
+ Vec::new(e)
+ }
+
+ fn bind_token(_e: &Env, _token: Address, _operator: Address) {
+ unreachable!("bind_token is not used in these tests");
+ }
+
+ fn unbind_token(_e: &Env, _token: Address, _operator: Address) {
+ unreachable!("unbind_token is not used in these tests");
+ }
+}
+
+#[contractimpl]
+impl MockComplianceContract {
+ pub fn register_hook(e: &Env, hook: ComplianceHook, module: Address) {
+ e.storage().persistent().set(&MockComplianceStorageKey::Registered(hook, module), &true);
+ }
+}
+
+#[test]
+fn set_and_get_max_balance_work() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let admin = Address::generate(&e);
+ let token = Address::generate(&e);
+ let (_address, client) = create_client(&e, &admin);
+
+ client.set_max_balance(&token, &100);
+
+ assert_eq!(client.get_max_balance(&token), 100);
+}
+
+#[test]
+fn pre_set_identity_balances_work() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let admin = Address::generate(&e);
+ let token = Address::generate(&e);
+ let identity_a = Address::generate(&e);
+ let identity_b = Address::generate(&e);
+ let (_address, client) = create_client(&e, &admin);
+
+ client.pre_set_identity_balance(&token, &identity_a, &40);
+ client.batch_pre_set_identity_balances(
+ &token,
+ &vec![&e, identity_a.clone(), identity_b.clone()],
+ &vec![&e, 50_i128, 20_i128],
+ );
+
+ assert_eq!(client.get_investor_balance(&token, &identity_a), 50);
+ assert_eq!(client.get_investor_balance(&token, &identity_b), 20);
+}
+
+#[test]
+fn name_compliance_address_and_required_hooks_work() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let admin = Address::generate(&e);
+ let compliance = Address::generate(&e);
+ let (_address, client) = create_client(&e, &admin);
+
+ assert_eq!(client.name(), String::from_str(&e, "MaxBalanceModule"));
+ assert_eq!(
+ client.required_hooks(),
+ vec![
+ &e,
+ ComplianceHook::CanTransfer,
+ ComplianceHook::CanCreate,
+ ComplianceHook::Transferred,
+ ComplianceHook::Created,
+ ComplianceHook::Destroyed,
+ ]
+ );
+
+ client.set_compliance_address(&compliance);
+ assert_eq!(client.get_compliance_address(), compliance);
+}
+
+#[test]
+fn set_identity_registry_storage_uses_admin_auth_before_compliance_bind() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let admin = Address::generate(&e);
+ let token = Address::generate(&e);
+ let irs = Address::generate(&e);
+ let (_address, client) = create_client(&e, &admin);
+
+ client.set_identity_registry_storage(&token, &irs);
+
+ let auths = e.auths();
+ assert_eq!(auths.len(), 1);
+ let (addr, _) = &auths[0];
+ assert_eq!(addr, &admin);
+}
+
+#[test]
+fn set_identity_registry_storage_uses_compliance_auth_after_bind() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let admin = Address::generate(&e);
+ let compliance = Address::generate(&e);
+ let token = Address::generate(&e);
+ let irs = Address::generate(&e);
+ let (_address, client) = create_client(&e, &admin);
+
+ client.set_compliance_address(&compliance);
+ let auths = e.auths();
+ assert_eq!(auths.len(), 1);
+ let (addr, _) = &auths[0];
+ assert_eq!(addr, &admin);
+
+ client.set_identity_registry_storage(&token, &irs);
+
+ let auths = e.auths();
+ assert_eq!(auths.len(), 1);
+ let (addr, _) = &auths[0];
+ assert_eq!(addr, &compliance);
+}
+
+#[test]
+fn can_create_and_can_transfer_use_identity_caps() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let admin = Address::generate(&e);
+ let token = Address::generate(&e);
+ let sender = Address::generate(&e);
+ let recipient = Address::generate(&e);
+ let sender_identity = Address::generate(&e);
+ let recipient_identity = Address::generate(&e);
+ let (module_address, client) = create_client(&e, &admin);
+ let irs_id = e.register(MockIRSContract, ());
+ let irs = MockIRSContractClient::new(&e, &irs_id);
+ let compliance_id = e.register(MockComplianceContract, ());
+ let compliance = MockComplianceContractClient::new(&e, &compliance_id);
+
+ irs.set_identity(&sender, &sender_identity);
+ irs.set_identity(&recipient, &recipient_identity);
+
+ client.set_compliance_address(&compliance_id);
+ for hook in [
+ ComplianceHook::CanTransfer,
+ ComplianceHook::CanCreate,
+ ComplianceHook::Transferred,
+ ComplianceHook::Created,
+ ComplianceHook::Destroyed,
+ ] {
+ compliance.register_hook(&hook, &module_address);
+ }
+
+ client.verify_hook_wiring();
+ client.set_identity_registry_storage(&token, &irs_id);
+ client.set_max_balance(&token, &100);
+ client.pre_set_identity_balance(&token, &recipient_identity, &60);
+
+ assert!(!client.can_create(&recipient, &50, &token));
+ assert!(client.can_create(&recipient, &40, &token));
+ assert!(!client.can_transfer(&sender, &recipient, &50, &token));
+ assert!(client.can_transfer(&sender, &recipient, &40, &token));
+}
diff --git a/examples/rwa-supply-limit/Cargo.toml b/examples/rwa-supply-limit/Cargo.toml
new file mode 100644
index 000000000..94977d387
--- /dev/null
+++ b/examples/rwa-supply-limit/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "rwa-supply-limit"
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+publish = false
+version.workspace = true
+authors.workspace = true
+
+[package.metadata.stellar]
+cargo_inherit = true
+
+[lib]
+crate-type = ["cdylib", "rlib"]
+doctest = false
+
+[dependencies]
+soroban-sdk = { workspace = true }
+stellar-tokens = { workspace = true }
+
+[dev-dependencies]
+soroban-sdk = { workspace = true, features = ["testutils"] }
diff --git a/examples/rwa-supply-limit/src/contract.rs b/examples/rwa-supply-limit/src/contract.rs
new file mode 100644
index 000000000..dbbbba0b9
--- /dev/null
+++ b/examples/rwa-supply-limit/src/contract.rs
@@ -0,0 +1,112 @@
+use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec};
+use stellar_tokens::rwa::compliance::{
+ modules::{
+ storage::{
+ get_compliance_address, module_name, set_compliance_address, ComplianceModuleStorageKey,
+ },
+ supply_limit::storage as supply_limit,
+ ComplianceModule,
+ },
+ ComplianceHook,
+};
+
+#[contracttype]
+enum DataKey {
+ Admin,
+}
+
+#[contract]
+pub struct SupplyLimitContract;
+
+fn set_admin(e: &Env, admin: &Address) {
+ e.storage().instance().set(&DataKey::Admin, admin);
+}
+
+fn get_admin(e: &Env) -> Address {
+ e.storage().instance().get(&DataKey::Admin).expect("admin must be set")
+}
+
+fn require_module_admin_or_compliance_auth(e: &Env) {
+ if let Some(compliance) =
+ e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance)
+ {
+ compliance.require_auth();
+ } else {
+ get_admin(e).require_auth();
+ }
+}
+
+#[contractimpl]
+impl SupplyLimitContract {
+ pub fn __constructor(e: &Env, admin: Address) {
+ set_admin(e, &admin);
+ }
+
+ pub fn set_supply_limit(e: &Env, token: Address, limit: i128) {
+ require_module_admin_or_compliance_auth(e);
+ supply_limit::configure_supply_limit(e, &token, limit);
+ }
+
+ pub fn pre_set_supply(e: &Env, token: Address, supply: i128) {
+ require_module_admin_or_compliance_auth(e);
+ supply_limit::pre_set_supply(e, &token, supply);
+ }
+
+ pub fn get_supply_limit(e: &Env, token: Address) -> i128 {
+ supply_limit::get_supply_limit(e, &token)
+ }
+
+ pub fn get_internal_supply(e: &Env, token: Address) -> i128 {
+ supply_limit::get_internal_supply(e, &token)
+ }
+
+ pub fn required_hooks(e: &Env) -> Vec {
+ supply_limit::required_hooks(e)
+ }
+
+ pub fn verify_hook_wiring(e: &Env) {
+ supply_limit::verify_hook_wiring(e);
+ }
+}
+
+#[contractimpl(contracttrait)]
+impl ComplianceModule for SupplyLimitContract {
+ fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {}
+
+ fn on_created(e: &Env, _to: Address, amount: i128, token: Address) {
+ require_module_admin_or_compliance_auth(e);
+ supply_limit::on_created(e, amount, &token);
+ }
+
+ fn on_destroyed(e: &Env, _from: Address, amount: i128, token: Address) {
+ require_module_admin_or_compliance_auth(e);
+ supply_limit::on_destroyed(e, amount, &token);
+ }
+
+ fn can_transfer(
+ _e: &Env,
+ _from: Address,
+ _to: Address,
+ _amount: i128,
+ _token: Address,
+ ) -> bool {
+ true
+ }
+
+ fn can_create(e: &Env, _to: Address, amount: i128, token: Address) -> bool {
+ supply_limit::can_create(e, amount, &token)
+ }
+
+ fn name(e: &Env) -> String {
+ module_name(e, "SupplyLimitModule")
+ }
+
+ fn get_compliance_address(e: &Env) -> Address {
+ get_compliance_address(e)
+ }
+
+ fn set_compliance_address(e: &Env, compliance: Address) {
+ get_admin(e).require_auth();
+ set_compliance_address(e, &compliance);
+ }
+}
diff --git a/examples/rwa-supply-limit/src/lib.rs b/examples/rwa-supply-limit/src/lib.rs
new file mode 100644
index 000000000..a879b6f80
--- /dev/null
+++ b/examples/rwa-supply-limit/src/lib.rs
@@ -0,0 +1,5 @@
+#![no_std]
+
+pub mod contract;
+#[cfg(test)]
+mod test;
diff --git a/examples/rwa-supply-limit/src/test.rs b/examples/rwa-supply-limit/src/test.rs
new file mode 100644
index 000000000..728fee1f5
--- /dev/null
+++ b/examples/rwa-supply-limit/src/test.rs
@@ -0,0 +1,194 @@
+extern crate std;
+
+use soroban_sdk::{
+ contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, String, Vec,
+};
+use stellar_tokens::rwa::{
+ compliance::{Compliance, ComplianceHook},
+ utils::token_binder::TokenBinder,
+};
+
+use crate::contract::{SupplyLimitContract, SupplyLimitContractClient};
+
+fn create_client<'a>(e: &Env, admin: &Address) -> (Address, SupplyLimitContractClient<'a>) {
+ let address = e.register(SupplyLimitContract, (admin,));
+ (address.clone(), SupplyLimitContractClient::new(e, &address))
+}
+
+#[contract]
+struct MockComplianceContract;
+
+#[contracttype]
+#[derive(Clone)]
+enum MockComplianceStorageKey {
+ Registered(ComplianceHook, Address),
+}
+
+#[contractimpl]
+impl Compliance for MockComplianceContract {
+ fn add_module_to(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) {
+ unreachable!("add_module_to is not used in these tests");
+ }
+
+ fn remove_module_from(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) {
+ unreachable!("remove_module_from is not used in these tests");
+ }
+
+ fn get_modules_for_hook(_e: &Env, _hook: ComplianceHook) -> Vec {
+ unreachable!("get_modules_for_hook is not used in these tests");
+ }
+
+ fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool {
+ e.storage().persistent().has(&MockComplianceStorageKey::Registered(hook, module))
+ }
+
+ fn transferred(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {
+ unreachable!("transferred is not used in these tests");
+ }
+
+ fn created(_e: &Env, _to: Address, _amount: i128, _token: Address) {
+ unreachable!("created is not used in these tests");
+ }
+
+ fn destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {
+ unreachable!("destroyed is not used in these tests");
+ }
+
+ fn can_transfer(
+ _e: &Env,
+ _from: Address,
+ _to: Address,
+ _amount: i128,
+ _token: Address,
+ ) -> bool {
+ unreachable!("can_transfer is not used in these tests");
+ }
+
+ fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool {
+ unreachable!("can_create is not used in these tests");
+ }
+}
+
+#[contractimpl]
+impl TokenBinder for MockComplianceContract {
+ fn linked_tokens(e: &Env) -> Vec {
+ Vec::new(e)
+ }
+
+ fn bind_token(_e: &Env, _token: Address, _operator: Address) {
+ unreachable!("bind_token is not used in these tests");
+ }
+
+ fn unbind_token(_e: &Env, _token: Address, _operator: Address) {
+ unreachable!("unbind_token is not used in these tests");
+ }
+}
+
+#[contractimpl]
+impl MockComplianceContract {
+ pub fn register_hook(e: &Env, hook: ComplianceHook, module: Address) {
+ e.storage().persistent().set(&MockComplianceStorageKey::Registered(hook, module), &true);
+ }
+}
+
+#[test]
+fn set_supply_limit_and_pre_set_supply_work() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let admin = Address::generate(&e);
+ let token = Address::generate(&e);
+ let (_address, client) = create_client(&e, &admin);
+
+ client.set_supply_limit(&token, &100);
+ client.pre_set_supply(&token, &60);
+
+ assert_eq!(client.get_supply_limit(&token), 100);
+ assert_eq!(client.get_internal_supply(&token), 60);
+}
+
+#[test]
+fn name_compliance_address_and_required_hooks_work() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let admin = Address::generate(&e);
+ let compliance = Address::generate(&e);
+ let (_address, client) = create_client(&e, &admin);
+
+ assert_eq!(client.name(), String::from_str(&e, "SupplyLimitModule"));
+ assert_eq!(
+ client.required_hooks(),
+ vec![&e, ComplianceHook::CanCreate, ComplianceHook::Created, ComplianceHook::Destroyed,]
+ );
+
+ client.set_compliance_address(&compliance);
+ assert_eq!(client.get_compliance_address(), compliance);
+}
+
+#[test]
+fn set_supply_limit_uses_admin_auth_before_compliance_bind() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let admin = Address::generate(&e);
+ let token = Address::generate(&e);
+ let (_address, client) = create_client(&e, &admin);
+
+ client.set_supply_limit(&token, &100);
+
+ let auths = e.auths();
+ assert_eq!(auths.len(), 1);
+ let (addr, _) = &auths[0];
+ assert_eq!(addr, &admin);
+}
+
+#[test]
+fn set_supply_limit_uses_compliance_auth_after_bind() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let admin = Address::generate(&e);
+ let compliance = Address::generate(&e);
+ let token = Address::generate(&e);
+ let (_address, client) = create_client(&e, &admin);
+
+ client.set_compliance_address(&compliance);
+ let auths = e.auths();
+ assert_eq!(auths.len(), 1);
+ let (addr, _) = &auths[0];
+ assert_eq!(addr, &admin);
+
+ client.set_supply_limit(&token, &100);
+
+ let auths = e.auths();
+ assert_eq!(auths.len(), 1);
+ let (addr, _) = &auths[0];
+ assert_eq!(addr, &compliance);
+}
+
+#[test]
+fn can_create_and_hooks_update_internal_supply() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let admin = Address::generate(&e);
+ let token = Address::generate(&e);
+ let account = Address::generate(&e);
+ let (module_address, client) = create_client(&e, &admin);
+ let compliance_id = e.register(MockComplianceContract, ());
+ let compliance = MockComplianceContractClient::new(&e, &compliance_id);
+
+ client.set_compliance_address(&compliance_id);
+ for hook in [ComplianceHook::CanCreate, ComplianceHook::Created, ComplianceHook::Destroyed] {
+ compliance.register_hook(&hook, &module_address);
+ }
+
+ client.verify_hook_wiring();
+ client.set_supply_limit(&token, &100);
+
+ assert!(client.can_create(&account, &80, &token));
+
+ client.on_created(&account, &80, &token);
+ assert_eq!(client.get_internal_supply(&token), 80);
+ assert!(!client.can_create(&account, &30, &token));
+
+ client.on_destroyed(&account, &20, &token);
+ assert_eq!(client.get_internal_supply(&token), 60);
+ assert!(client.can_create(&account, &40, &token));
+}
diff --git a/examples/rwa-time-transfers-limits/Cargo.toml b/examples/rwa-time-transfers-limits/Cargo.toml
new file mode 100644
index 000000000..ab02105e5
--- /dev/null
+++ b/examples/rwa-time-transfers-limits/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "rwa-time-transfers-limits"
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+publish = false
+version.workspace = true
+authors.workspace = true
+
+[package.metadata.stellar]
+cargo_inherit = true
+
+[lib]
+crate-type = ["cdylib", "rlib"]
+doctest = false
+
+[dependencies]
+soroban-sdk = { workspace = true }
+stellar-tokens = { workspace = true }
+
+[dev-dependencies]
+soroban-sdk = { workspace = true, features = ["testutils"] }
diff --git a/examples/rwa-time-transfers-limits/src/contract.rs b/examples/rwa-time-transfers-limits/src/contract.rs
new file mode 100644
index 000000000..d1d559a4b
--- /dev/null
+++ b/examples/rwa-time-transfers-limits/src/contract.rs
@@ -0,0 +1,125 @@
+use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec};
+use stellar_tokens::rwa::compliance::{
+ modules::{
+ storage::{
+ get_compliance_address, module_name, set_compliance_address, ComplianceModuleStorageKey,
+ },
+ time_transfers_limits::{storage as ttl, Limit, TransferCounter},
+ ComplianceModule,
+ },
+ ComplianceHook,
+};
+
+#[contracttype]
+enum DataKey {
+ Admin,
+}
+
+#[contract]
+pub struct TimeTransfersLimitsContract;
+
+fn set_admin(e: &Env, admin: &Address) {
+ e.storage().instance().set(&DataKey::Admin, admin);
+}
+
+fn get_admin(e: &Env) -> Address {
+ e.storage().instance().get(&DataKey::Admin).expect("admin must be set")
+}
+
+fn require_module_admin_or_compliance_auth(e: &Env) {
+ if let Some(compliance) =
+ e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance)
+ {
+ compliance.require_auth();
+ } else {
+ get_admin(e).require_auth();
+ }
+}
+
+#[contractimpl]
+impl TimeTransfersLimitsContract {
+ pub fn __constructor(e: &Env, admin: Address) {
+ set_admin(e, &admin);
+ }
+
+ pub fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) {
+ require_module_admin_or_compliance_auth(e);
+ ttl::configure_irs(e, &token, &irs);
+ }
+
+ pub fn set_time_transfer_limit(e: &Env, token: Address, limit: Limit) {
+ require_module_admin_or_compliance_auth(e);
+ ttl::set_time_transfer_limit(e, &token, &limit);
+ }
+
+ pub fn batch_set_time_transfer_limit(e: &Env, token: Address, limits: Vec) {
+ require_module_admin_or_compliance_auth(e);
+ ttl::batch_set_time_transfer_limit(e, &token, &limits);
+ }
+
+ pub fn remove_time_transfer_limit(e: &Env, token: Address, limit_time: u64) {
+ require_module_admin_or_compliance_auth(e);
+ ttl::remove_time_transfer_limit(e, &token, limit_time);
+ }
+
+ pub fn batch_remove_time_transfer_limit(e: &Env, token: Address, limit_times: Vec) {
+ require_module_admin_or_compliance_auth(e);
+ ttl::batch_remove_time_transfer_limit(e, &token, &limit_times);
+ }
+
+ pub fn pre_set_transfer_counter(
+ e: &Env,
+ token: Address,
+ identity: Address,
+ limit_time: u64,
+ counter: TransferCounter,
+ ) {
+ require_module_admin_or_compliance_auth(e);
+ ttl::pre_set_transfer_counter(e, &token, &identity, limit_time, &counter);
+ }
+
+ pub fn get_time_transfer_limits(e: &Env, token: Address) -> Vec {
+ ttl::get_limits(e, &token)
+ }
+
+ pub fn required_hooks(e: &Env) -> Vec {
+ ttl::required_hooks(e)
+ }
+
+ pub fn verify_hook_wiring(e: &Env) {
+ ttl::verify_hook_wiring(e);
+ }
+}
+
+#[contractimpl(contracttrait)]
+impl ComplianceModule for TimeTransfersLimitsContract {
+ fn on_transfer(e: &Env, from: Address, _to: Address, amount: i128, token: Address) {
+ require_module_admin_or_compliance_auth(e);
+ ttl::on_transfer(e, &from, amount, &token);
+ }
+
+ fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {}
+
+ fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {}
+
+ fn can_transfer(e: &Env, from: Address, _to: Address, amount: i128, token: Address) -> bool {
+ ttl::can_transfer(e, &from, amount, &token)
+ }
+
+ fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool {
+ true
+ }
+
+ fn name(e: &Env) -> String {
+ module_name(e, "TimeTransfersLimitsModule")
+ }
+
+ fn get_compliance_address(e: &Env) -> Address {
+ get_compliance_address(e)
+ }
+
+ fn set_compliance_address(e: &Env, compliance: Address) {
+ get_admin(e).require_auth();
+ set_compliance_address(e, &compliance);
+ }
+}
diff --git a/examples/rwa-time-transfers-limits/src/lib.rs b/examples/rwa-time-transfers-limits/src/lib.rs
new file mode 100644
index 000000000..a879b6f80
--- /dev/null
+++ b/examples/rwa-time-transfers-limits/src/lib.rs
@@ -0,0 +1,5 @@
+#![no_std]
+
+pub mod contract;
+#[cfg(test)]
+mod test;
diff --git a/examples/rwa-time-transfers-limits/src/test.rs b/examples/rwa-time-transfers-limits/src/test.rs
new file mode 100644
index 000000000..7c75c6f2a
--- /dev/null
+++ b/examples/rwa-time-transfers-limits/src/test.rs
@@ -0,0 +1,316 @@
+extern crate std;
+
+use soroban_sdk::{
+ contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, String, Val,
+ Vec,
+};
+use stellar_tokens::rwa::{
+ compliance::{
+ modules::time_transfers_limits::{Limit, TransferCounter},
+ Compliance, ComplianceHook,
+ },
+ identity_registry_storage::{CountryDataManager, IdentityRegistryStorage},
+ utils::token_binder::TokenBinder,
+};
+
+use crate::contract::{TimeTransfersLimitsContract, TimeTransfersLimitsContractClient};
+
+fn create_client<'a>(e: &Env, admin: &Address) -> (Address, TimeTransfersLimitsContractClient<'a>) {
+ let address = e.register(TimeTransfersLimitsContract, (admin,));
+ (address.clone(), TimeTransfersLimitsContractClient::new(e, &address))
+}
+
+#[contract]
+struct MockIRSContract;
+
+#[contracttype]
+#[derive(Clone)]
+enum MockIRSStorageKey {
+ Identity(Address),
+}
+
+#[contractimpl]
+impl TokenBinder for MockIRSContract {
+ fn linked_tokens(e: &Env) -> Vec {
+ Vec::new(e)
+ }
+
+ fn bind_token(_e: &Env, _token: Address, _operator: Address) {
+ unreachable!("bind_token is not used in these tests");
+ }
+
+ fn unbind_token(_e: &Env, _token: Address, _operator: Address) {
+ unreachable!("unbind_token is not used in these tests");
+ }
+}
+
+#[contractimpl]
+impl IdentityRegistryStorage for MockIRSContract {
+ fn add_identity(
+ _e: &Env,
+ _account: Address,
+ _identity: Address,
+ _country_data_list: Vec,
+ _operator: Address,
+ ) {
+ unreachable!("add_identity is not used in these tests");
+ }
+
+ fn remove_identity(_e: &Env, _account: Address, _operator: Address) {
+ unreachable!("remove_identity is not used in these tests");
+ }
+
+ fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) {
+ unreachable!("modify_identity is not used in these tests");
+ }
+
+ fn recover_identity(
+ _e: &Env,
+ _old_account: Address,
+ _new_account: Address,
+ _operator: Address,
+ ) {
+ unreachable!("recover_identity is not used in these tests");
+ }
+
+ fn stored_identity(e: &Env, account: Address) -> Address {
+ e.storage()
+ .persistent()
+ .get(&MockIRSStorageKey::Identity(account.clone()))
+ .unwrap_or(account)
+ }
+}
+
+#[contractimpl]
+impl CountryDataManager for MockIRSContract {
+ fn add_country_data_entries(
+ _e: &Env,
+ _account: Address,
+ _country_data_list: Vec,
+ _operator: Address,
+ ) {
+ unreachable!("add_country_data_entries is not used in these tests");
+ }
+
+ fn modify_country_data(
+ _e: &Env,
+ _account: Address,
+ _index: u32,
+ _country_data: Val,
+ _operator: Address,
+ ) {
+ unreachable!("modify_country_data is not used in these tests");
+ }
+
+ fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) {
+ unreachable!("delete_country_data is not used in these tests");
+ }
+
+ fn get_country_data_entries(e: &Env, _account: Address) -> Vec {
+ Vec::new(e)
+ }
+}
+
+#[contractimpl]
+impl MockIRSContract {
+ pub fn set_identity(e: &Env, account: Address, identity: Address) {
+ e.storage().persistent().set(&MockIRSStorageKey::Identity(account), &identity);
+ }
+}
+
+#[contract]
+struct MockComplianceContract;
+
+#[contracttype]
+#[derive(Clone)]
+enum MockComplianceStorageKey {
+ Registered(ComplianceHook, Address),
+}
+
+#[contractimpl]
+impl Compliance for MockComplianceContract {
+ fn add_module_to(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) {
+ unreachable!("add_module_to is not used in these tests");
+ }
+
+ fn remove_module_from(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) {
+ unreachable!("remove_module_from is not used in these tests");
+ }
+
+ fn get_modules_for_hook(_e: &Env, _hook: ComplianceHook) -> Vec {
+ unreachable!("get_modules_for_hook is not used in these tests");
+ }
+
+ fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool {
+ e.storage().persistent().has(&MockComplianceStorageKey::Registered(hook, module))
+ }
+
+ fn transferred(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {
+ unreachable!("transferred is not used in these tests");
+ }
+
+ fn created(_e: &Env, _to: Address, _amount: i128, _token: Address) {
+ unreachable!("created is not used in these tests");
+ }
+
+ fn destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {
+ unreachable!("destroyed is not used in these tests");
+ }
+
+ fn can_transfer(
+ _e: &Env,
+ _from: Address,
+ _to: Address,
+ _amount: i128,
+ _token: Address,
+ ) -> bool {
+ unreachable!("can_transfer is not used in these tests");
+ }
+
+ fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool {
+ unreachable!("can_create is not used in these tests");
+ }
+}
+
+#[contractimpl]
+impl TokenBinder for MockComplianceContract {
+ fn linked_tokens(e: &Env) -> Vec {
+ Vec::new(e)
+ }
+
+ fn bind_token(_e: &Env, _token: Address, _operator: Address) {
+ unreachable!("bind_token is not used in these tests");
+ }
+
+ fn unbind_token(_e: &Env, _token: Address, _operator: Address) {
+ unreachable!("unbind_token is not used in these tests");
+ }
+}
+
+#[contractimpl]
+impl MockComplianceContract {
+ pub fn register_hook(e: &Env, hook: ComplianceHook, module: Address) {
+ e.storage().persistent().set(&MockComplianceStorageKey::Registered(hook, module), &true);
+ }
+}
+
+#[test]
+fn set_and_manage_time_transfer_limits_work() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let admin = Address::generate(&e);
+ let token = Address::generate(&e);
+ let limit_a = Limit { limit_time: 60, limit_value: 100 };
+ let limit_b = Limit { limit_time: 120, limit_value: 200 };
+ let (_address, client) = create_client(&e, &admin);
+
+ client.set_time_transfer_limit(&token, &limit_a);
+ client.batch_set_time_transfer_limit(&token, &vec![&e, limit_b.clone()]);
+
+ assert_eq!(client.get_time_transfer_limits(&token), vec![&e, limit_a.clone(), limit_b.clone()]);
+
+ client.batch_remove_time_transfer_limit(&token, &vec![&e, 120_u64]);
+ assert_eq!(client.get_time_transfer_limits(&token), vec![&e, limit_a.clone()]);
+
+ client.remove_time_transfer_limit(&token, &60);
+ assert_eq!(client.get_time_transfer_limits(&token).len(), 0);
+}
+
+#[test]
+fn name_compliance_address_and_required_hooks_work() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let admin = Address::generate(&e);
+ let compliance = Address::generate(&e);
+ let (_address, client) = create_client(&e, &admin);
+
+ assert_eq!(client.name(), String::from_str(&e, "TimeTransfersLimitsModule"));
+ assert_eq!(
+ client.required_hooks(),
+ vec![&e, ComplianceHook::CanTransfer, ComplianceHook::Transferred]
+ );
+
+ client.set_compliance_address(&compliance);
+ assert_eq!(client.get_compliance_address(), compliance);
+}
+
+#[test]
+fn set_identity_registry_storage_uses_admin_auth_before_compliance_bind() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let admin = Address::generate(&e);
+ let token = Address::generate(&e);
+ let irs = Address::generate(&e);
+ let (_address, client) = create_client(&e, &admin);
+
+ client.set_identity_registry_storage(&token, &irs);
+
+ let auths = e.auths();
+ assert_eq!(auths.len(), 1);
+ let (addr, _) = &auths[0];
+ assert_eq!(addr, &admin);
+}
+
+#[test]
+fn set_identity_registry_storage_uses_compliance_auth_after_bind() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let admin = Address::generate(&e);
+ let compliance = Address::generate(&e);
+ let token = Address::generate(&e);
+ let irs = Address::generate(&e);
+ let (_address, client) = create_client(&e, &admin);
+
+ client.set_compliance_address(&compliance);
+ let auths = e.auths();
+ assert_eq!(auths.len(), 1);
+ let (addr, _) = &auths[0];
+ assert_eq!(addr, &admin);
+
+ client.set_identity_registry_storage(&token, &irs);
+
+ let auths = e.auths();
+ assert_eq!(auths.len(), 1);
+ let (addr, _) = &auths[0];
+ assert_eq!(addr, &compliance);
+}
+
+#[test]
+fn verify_hook_wiring_and_counters_affect_public_transfer_checks() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let admin = Address::generate(&e);
+ let token = Address::generate(&e);
+ let sender = Address::generate(&e);
+ let recipient = Address::generate(&e);
+ let sender_identity = Address::generate(&e);
+ let limit = Limit { limit_time: 60, limit_value: 100 };
+ let (module_address, client) = create_client(&e, &admin);
+ let irs_id = e.register(MockIRSContract, ());
+ let irs = MockIRSContractClient::new(&e, &irs_id);
+ let compliance_id = e.register(MockComplianceContract, ());
+ let compliance = MockComplianceContractClient::new(&e, &compliance_id);
+
+ irs.set_identity(&sender, &sender_identity);
+
+ client.set_compliance_address(&compliance_id);
+ for hook in [ComplianceHook::CanTransfer, ComplianceHook::Transferred] {
+ compliance.register_hook(&hook, &module_address);
+ }
+
+ client.verify_hook_wiring();
+ client.set_identity_registry_storage(&token, &irs_id);
+ client.set_time_transfer_limit(&token, &limit);
+ client.pre_set_transfer_counter(
+ &token,
+ &sender_identity,
+ &60,
+ &TransferCounter { value: 90, timer: e.ledger().timestamp().saturating_add(60) },
+ );
+
+ assert!(!client.can_transfer(&sender, &recipient, &11, &token));
+ assert!(client.can_transfer(&sender, &recipient, &10, &token));
+
+ client.on_transfer(&sender, &recipient, &10, &token);
+ assert!(!client.can_transfer(&sender, &recipient, &1, &token));
+}
diff --git a/examples/rwa-transfer-restrict/Cargo.toml b/examples/rwa-transfer-restrict/Cargo.toml
new file mode 100644
index 000000000..e6e333e09
--- /dev/null
+++ b/examples/rwa-transfer-restrict/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "rwa-transfer-restrict"
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+publish = false
+version.workspace = true
+authors.workspace = true
+
+[package.metadata.stellar]
+cargo_inherit = true
+
+[lib]
+crate-type = ["cdylib", "rlib"]
+doctest = false
+
+[dependencies]
+soroban-sdk = { workspace = true }
+stellar-tokens = { workspace = true }
+
+[dev-dependencies]
+soroban-sdk = { workspace = true, features = ["testutils"] }
diff --git a/examples/rwa-transfer-restrict/src/contract.rs b/examples/rwa-transfer-restrict/src/contract.rs
new file mode 100644
index 000000000..b69043c63
--- /dev/null
+++ b/examples/rwa-transfer-restrict/src/contract.rs
@@ -0,0 +1,95 @@
+use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec};
+use stellar_tokens::rwa::compliance::modules::{
+ storage::{
+ get_compliance_address, module_name, set_compliance_address, ComplianceModuleStorageKey,
+ },
+ transfer_restrict::storage as transfer_restrict,
+ ComplianceModule,
+};
+
+#[contracttype]
+enum DataKey {
+ Admin,
+}
+
+#[contract]
+pub struct TransferRestrictContract;
+
+fn set_admin(e: &Env, admin: &Address) {
+ e.storage().instance().set(&DataKey::Admin, admin);
+}
+
+fn get_admin(e: &Env) -> Address {
+ e.storage().instance().get(&DataKey::Admin).expect("admin must be set")
+}
+
+fn require_module_admin_or_compliance_auth(e: &Env) {
+ if let Some(compliance) =
+ e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance)
+ {
+ compliance.require_auth();
+ } else {
+ get_admin(e).require_auth();
+ }
+}
+
+#[contractimpl]
+impl TransferRestrictContract {
+ pub fn __constructor(e: &Env, admin: Address) {
+ set_admin(e, &admin);
+ }
+
+ pub fn allow_user(e: &Env, token: Address, user: Address) {
+ require_module_admin_or_compliance_auth(e);
+ transfer_restrict::allow_user(e, &token, &user);
+ }
+
+ pub fn disallow_user(e: &Env, token: Address, user: Address) {
+ require_module_admin_or_compliance_auth(e);
+ transfer_restrict::disallow_user(e, &token, &user);
+ }
+
+ pub fn batch_allow_users(e: &Env, token: Address, users: Vec) {
+ require_module_admin_or_compliance_auth(e);
+ transfer_restrict::batch_allow_users(e, &token, &users);
+ }
+
+ pub fn batch_disallow_users(e: &Env, token: Address, users: Vec) {
+ require_module_admin_or_compliance_auth(e);
+ transfer_restrict::batch_disallow_users(e, &token, &users);
+ }
+
+ pub fn is_user_allowed(e: &Env, token: Address, user: Address) -> bool {
+ transfer_restrict::is_user_allowed(e, &token, &user)
+ }
+}
+
+#[contractimpl(contracttrait)]
+impl ComplianceModule for TransferRestrictContract {
+ fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {}
+
+ fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {}
+
+ fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {}
+
+ fn can_transfer(e: &Env, from: Address, to: Address, _amount: i128, token: Address) -> bool {
+ transfer_restrict::can_transfer(e, &from, &to, &token)
+ }
+
+ fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool {
+ true
+ }
+
+ fn name(e: &Env) -> String {
+ module_name(e, "TransferRestrictModule")
+ }
+
+ fn get_compliance_address(e: &Env) -> Address {
+ get_compliance_address(e)
+ }
+
+ fn set_compliance_address(e: &Env, compliance: Address) {
+ get_admin(e).require_auth();
+ set_compliance_address(e, &compliance);
+ }
+}
diff --git a/examples/rwa-transfer-restrict/src/lib.rs b/examples/rwa-transfer-restrict/src/lib.rs
new file mode 100644
index 000000000..a879b6f80
--- /dev/null
+++ b/examples/rwa-transfer-restrict/src/lib.rs
@@ -0,0 +1,5 @@
+#![no_std]
+
+pub mod contract;
+#[cfg(test)]
+mod test;
diff --git a/examples/rwa-transfer-restrict/src/test.rs b/examples/rwa-transfer-restrict/src/test.rs
new file mode 100644
index 000000000..7c5f07871
--- /dev/null
+++ b/examples/rwa-transfer-restrict/src/test.rs
@@ -0,0 +1,97 @@
+extern crate std;
+
+use soroban_sdk::{testutils::Address as _, vec, Address, Env, String};
+
+use crate::contract::{TransferRestrictContract, TransferRestrictContractClient};
+
+fn create_client<'a>(e: &Env, admin: &Address) -> (Address, TransferRestrictContractClient<'a>) {
+ let address = e.register(TransferRestrictContract, (admin,));
+ (address.clone(), TransferRestrictContractClient::new(e, &address))
+}
+
+#[test]
+fn allowlist_methods_and_can_transfer_work() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let admin = Address::generate(&e);
+ let token = Address::generate(&e);
+ let sender = Address::generate(&e);
+ let recipient = Address::generate(&e);
+ let other = Address::generate(&e);
+ let (_address, client) = create_client(&e, &admin);
+
+ assert!(!client.is_user_allowed(&token, &sender));
+ assert!(!client.can_transfer(&sender, &recipient, &1, &token));
+
+ client.allow_user(&token, &sender);
+ assert!(client.is_user_allowed(&token, &sender));
+ assert!(client.can_transfer(&sender, &other, &1, &token));
+
+ client.disallow_user(&token, &sender);
+ assert!(!client.is_user_allowed(&token, &sender));
+
+ client.batch_allow_users(&token, &vec![&e, recipient.clone(), other.clone()]);
+ assert!(client.is_user_allowed(&token, &recipient));
+ assert!(client.is_user_allowed(&token, &other));
+ assert!(client.can_transfer(&sender, &recipient, &1, &token));
+
+ client.batch_disallow_users(&token, &vec![&e, recipient.clone(), other.clone()]);
+ assert!(!client.is_user_allowed(&token, &recipient));
+ assert!(!client.is_user_allowed(&token, &other));
+ assert!(!client.can_transfer(&sender, &recipient, &1, &token));
+}
+
+#[test]
+fn name_and_compliance_address_work() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let admin = Address::generate(&e);
+ let compliance = Address::generate(&e);
+ let (_address, client) = create_client(&e, &admin);
+
+ assert_eq!(client.name(), String::from_str(&e, "TransferRestrictModule"));
+
+ client.set_compliance_address(&compliance);
+ assert_eq!(client.get_compliance_address(), compliance);
+}
+
+#[test]
+fn allow_user_uses_admin_auth_before_compliance_bind() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let admin = Address::generate(&e);
+ let token = Address::generate(&e);
+ let user = Address::generate(&e);
+ let (_address, client) = create_client(&e, &admin);
+
+ client.allow_user(&token, &user);
+
+ let auths = e.auths();
+ assert_eq!(auths.len(), 1);
+ let (addr, _) = &auths[0];
+ assert_eq!(addr, &admin);
+}
+
+#[test]
+fn allow_user_uses_compliance_auth_after_bind() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let admin = Address::generate(&e);
+ let compliance = Address::generate(&e);
+ let token = Address::generate(&e);
+ let user = Address::generate(&e);
+ let (_address, client) = create_client(&e, &admin);
+
+ client.set_compliance_address(&compliance);
+ let auths = e.auths();
+ assert_eq!(auths.len(), 1);
+ let (addr, _) = &auths[0];
+ assert_eq!(addr, &admin);
+
+ client.allow_user(&token, &user);
+
+ let auths = e.auths();
+ assert_eq!(auths.len(), 1);
+ let (addr, _) = &auths[0];
+ assert_eq!(addr, &compliance);
+}
diff --git a/packages/tokens/src/rwa/compliance/modules/country_allow/mod.rs b/packages/tokens/src/rwa/compliance/modules/country_allow/mod.rs
new file mode 100644
index 000000000..1da53f7f2
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/country_allow/mod.rs
@@ -0,0 +1,31 @@
+//! Country allowlist compliance module — Stellar port of T-REX
+//! [`CountryAllowModule.sol`][trex-src].
+//!
+//! Only recipients whose identity has at least one country code in the
+//! allowlist may receive tokens.
+//!
+//! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/CountryAllowModule.sol
+
+pub mod storage;
+#[cfg(test)]
+mod test;
+
+use soroban_sdk::{contractevent, Address};
+
+/// Emitted when a country is added to the allowlist.
+#[contractevent]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct CountryAllowed {
+ #[topic]
+ pub token: Address,
+ pub country: u32,
+}
+
+/// Emitted when a country is removed from the allowlist.
+#[contractevent]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct CountryUnallowed {
+ #[topic]
+ pub token: Address,
+ pub country: u32,
+}
diff --git a/packages/tokens/src/rwa/compliance/modules/country_allow/storage.rs b/packages/tokens/src/rwa/compliance/modules/country_allow/storage.rs
new file mode 100644
index 000000000..d47da3698
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/country_allow/storage.rs
@@ -0,0 +1,151 @@
+use soroban_sdk::{contracttype, Address, Env, Vec};
+
+use super::{CountryAllowed, CountryUnallowed};
+use crate::rwa::compliance::modules::{
+ storage::{country_code, get_irs_country_data_entries},
+ MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD,
+};
+
+#[contracttype]
+#[derive(Clone)]
+pub enum CountryAllowStorageKey {
+ /// Per-(token, country) allowlist flag.
+ AllowedCountry(Address, u32),
+}
+
+// ################## RAW STORAGE ##################
+
+/// Returns whether the given country is on the allowlist for `token`.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `country` - The ISO 3166-1 numeric country code.
+pub fn is_country_allowed(e: &Env, token: &Address, country: u32) -> bool {
+ let key = CountryAllowStorageKey::AllowedCountry(token.clone(), country);
+ e.storage()
+ .persistent()
+ .get(&key)
+ .inspect(|_: &bool| {
+ e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT);
+ })
+ .unwrap_or_default()
+}
+
+/// Writes a country's allowed flag to persistent storage.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `country` - The ISO 3166-1 numeric country code to allow.
+pub fn set_country_allowed(e: &Env, token: &Address, country: u32) {
+ let key = CountryAllowStorageKey::AllowedCountry(token.clone(), country);
+ e.storage().persistent().set(&key, &true);
+ e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT);
+}
+
+/// Removes a country from the allowlist in persistent storage.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `country` - The ISO 3166-1 numeric country code to remove.
+pub fn remove_country_allowed(e: &Env, token: &Address, country: u32) {
+ e.storage()
+ .persistent()
+ .remove(&CountryAllowStorageKey::AllowedCountry(token.clone(), country));
+}
+
+// ################## ACTIONS ##################
+
+/// Adds a country to the allowlist for `token`.
+///
+/// Writes the flag to storage and emits [`CountryAllowed`].
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `country` - The ISO 3166-1 numeric country code to allow.
+pub fn add_allowed_country(e: &Env, token: &Address, country: u32) {
+ set_country_allowed(e, token, country);
+ CountryAllowed { token: token.clone(), country }.publish(e);
+}
+
+/// Removes a country from the allowlist for `token`.
+///
+/// Deletes the flag from storage and emits [`CountryUnallowed`].
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `country` - The ISO 3166-1 numeric country code to remove.
+pub fn remove_allowed_country(e: &Env, token: &Address, country: u32) {
+ remove_country_allowed(e, token, country);
+ CountryUnallowed { token: token.clone(), country }.publish(e);
+}
+
+/// Adds multiple countries to the allowlist in a single call.
+///
+/// Emits [`CountryAllowed`] for each country added.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `countries` - The country codes to allow.
+pub fn batch_allow_countries(e: &Env, token: &Address, countries: &Vec) {
+ for country in countries.iter() {
+ set_country_allowed(e, token, country);
+ CountryAllowed { token: token.clone(), country }.publish(e);
+ }
+}
+
+/// Removes multiple countries from the allowlist in a single call.
+///
+/// Emits [`CountryUnallowed`] for each country removed.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `countries` - The country codes to remove.
+pub fn batch_disallow_countries(e: &Env, token: &Address, countries: &Vec) {
+ for country in countries.iter() {
+ remove_country_allowed(e, token, country);
+ CountryUnallowed { token: token.clone(), country }.publish(e);
+ }
+}
+
+// ################## COMPLIANCE HOOKS ##################
+
+/// Returns `true` if `to` has at least one allowed country in the IRS for
+/// `token`.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `to` - The recipient whose country data is checked.
+/// * `token` - The token address.
+///
+/// # Errors
+///
+/// * [`crate::rwa::compliance::modules::ComplianceModuleError::IdentityRegistryNotSet`]
+/// - When no IRS has been configured for `token`.
+///
+/// # Cross-Contract Calls
+///
+/// Calls the IRS to resolve country data for `to`.
+pub fn can_transfer(e: &Env, to: &Address, token: &Address) -> bool {
+ let entries = get_irs_country_data_entries(e, token, to);
+ for entry in entries.iter() {
+ if is_country_allowed(e, token, country_code(&entry.country)) {
+ return true;
+ }
+ }
+ false
+}
diff --git a/packages/tokens/src/rwa/compliance/modules/country_allow/test.rs b/packages/tokens/src/rwa/compliance/modules/country_allow/test.rs
new file mode 100644
index 000000000..a8b497283
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/country_allow/test.rs
@@ -0,0 +1,183 @@
+extern crate std;
+
+use soroban_sdk::{
+ contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal, Val,
+ Vec,
+};
+
+use super::storage::{can_transfer, set_country_allowed};
+use crate::rwa::{
+ compliance::modules::storage::set_irs_address,
+ identity_registry_storage::{
+ CountryData, CountryDataManager, CountryRelation, IdentityRegistryStorage,
+ IndividualCountryRelation, OrganizationCountryRelation,
+ },
+ utils::token_binder::TokenBinder,
+};
+
+#[contract]
+struct MockIRSContract;
+
+#[contracttype]
+#[derive(Clone)]
+enum MockIRSStorageKey {
+ Identity(Address),
+ CountryEntries(Address),
+}
+
+#[contractimpl]
+impl TokenBinder for MockIRSContract {
+ fn linked_tokens(e: &Env) -> Vec {
+ Vec::new(e)
+ }
+
+ fn bind_token(_e: &Env, _token: Address, _operator: Address) {
+ unreachable!("bind_token is not used in these tests");
+ }
+
+ fn unbind_token(_e: &Env, _token: Address, _operator: Address) {
+ unreachable!("unbind_token is not used in these tests");
+ }
+}
+
+#[contractimpl]
+impl IdentityRegistryStorage for MockIRSContract {
+ fn add_identity(
+ _e: &Env,
+ _account: Address,
+ _identity: Address,
+ _country_data_list: Vec,
+ _operator: Address,
+ ) {
+ unreachable!("add_identity is not used in these tests");
+ }
+
+ fn remove_identity(_e: &Env, _account: Address, _operator: Address) {
+ unreachable!("remove_identity is not used in these tests");
+ }
+
+ fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) {
+ unreachable!("modify_identity is not used in these tests");
+ }
+
+ fn recover_identity(
+ _e: &Env,
+ _old_account: Address,
+ _new_account: Address,
+ _operator: Address,
+ ) {
+ unreachable!("recover_identity is not used in these tests");
+ }
+
+ fn stored_identity(e: &Env, account: Address) -> Address {
+ e.storage()
+ .persistent()
+ .get(&MockIRSStorageKey::Identity(account.clone()))
+ .unwrap_or(account)
+ }
+}
+
+#[contractimpl]
+impl CountryDataManager for MockIRSContract {
+ fn add_country_data_entries(
+ _e: &Env,
+ _account: Address,
+ _country_data_list: Vec,
+ _operator: Address,
+ ) {
+ unreachable!("add_country_data_entries is not used in these tests");
+ }
+
+ fn modify_country_data(
+ _e: &Env,
+ _account: Address,
+ _index: u32,
+ _country_data: Val,
+ _operator: Address,
+ ) {
+ unreachable!("modify_country_data is not used in these tests");
+ }
+
+ fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) {
+ unreachable!("delete_country_data is not used in these tests");
+ }
+
+ fn get_country_data_entries(e: &Env, account: Address) -> Vec {
+ let entries: Vec = e
+ .storage()
+ .persistent()
+ .get(&MockIRSStorageKey::CountryEntries(account))
+ .unwrap_or_else(|| Vec::new(e));
+
+ Vec::from_iter(e, entries.iter().map(|entry| entry.into_val(e)))
+ }
+}
+
+#[contractimpl]
+impl MockIRSContract {
+ pub fn set_country_data_entries(e: &Env, account: Address, entries: Vec) {
+ e.storage().persistent().set(&MockIRSStorageKey::CountryEntries(account), &entries);
+ }
+}
+
+#[contract]
+struct TestCountryAllowContract;
+
+fn individual_country(code: u32) -> CountryData {
+ CountryData {
+ country: CountryRelation::Individual(IndividualCountryRelation::Residence(code)),
+ metadata: None,
+ }
+}
+
+fn organization_country(code: u32) -> CountryData {
+ CountryData {
+ country: CountryRelation::Organization(OrganizationCountryRelation::OperatingJurisdiction(
+ code,
+ )),
+ metadata: None,
+ }
+}
+
+#[test]
+fn can_transfer_allows_when_any_country_matches() {
+ let e = Env::default();
+ let module_id = e.register(TestCountryAllowContract, ());
+ let irs_id = e.register(MockIRSContract, ());
+ let irs = MockIRSContractClient::new(&e, &irs_id);
+ let token = Address::generate(&e);
+ let to = Address::generate(&e);
+
+ irs.set_country_data_entries(
+ &to,
+ &vec![&e, individual_country(250), organization_country(276)],
+ );
+
+ e.as_contract(&module_id, || {
+ set_irs_address(&e, &token, &irs_id);
+ set_country_allowed(&e, &token, 276);
+
+ assert!(can_transfer(&e, &to, &token));
+ });
+}
+
+#[test]
+fn can_transfer_rejects_when_no_country_matches() {
+ let e = Env::default();
+ let module_id = e.register(TestCountryAllowContract, ());
+ let irs_id = e.register(MockIRSContract, ());
+ let irs = MockIRSContractClient::new(&e, &irs_id);
+ let token = Address::generate(&e);
+ let empty_to = Address::generate(&e);
+ let disallowed_to = Address::generate(&e);
+
+ irs.set_country_data_entries(&disallowed_to, &vec![&e, individual_country(250)]);
+
+ e.as_contract(&module_id, || {
+ set_irs_address(&e, &token, &irs_id);
+ set_country_allowed(&e, &token, 276);
+
+ assert!(!can_transfer(&e, &empty_to, &token));
+ assert!(!can_transfer(&e, &disallowed_to, &token));
+ });
+}
diff --git a/packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs b/packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs
new file mode 100644
index 000000000..2cbd355a1
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs
@@ -0,0 +1,31 @@
+//! Country restriction compliance module — Stellar port of T-REX
+//! [`CountryRestrictModule.sol`][trex-src].
+//!
+//! Recipients whose identity has a country code on the restriction list are
+//! blocked from receiving tokens.
+//!
+//! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/CountryRestrictModule.sol
+
+pub mod storage;
+#[cfg(test)]
+mod test;
+
+use soroban_sdk::{contractevent, Address};
+
+/// Emitted when a country is added to the restriction list.
+#[contractevent]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct CountryRestricted {
+ #[topic]
+ pub token: Address,
+ pub country: u32,
+}
+
+/// Emitted when a country is removed from the restriction list.
+#[contractevent]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct CountryUnrestricted {
+ #[topic]
+ pub token: Address,
+ pub country: u32,
+}
diff --git a/packages/tokens/src/rwa/compliance/modules/country_restrict/storage.rs b/packages/tokens/src/rwa/compliance/modules/country_restrict/storage.rs
new file mode 100644
index 000000000..be7c0512e
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/country_restrict/storage.rs
@@ -0,0 +1,151 @@
+use soroban_sdk::{contracttype, Address, Env, Vec};
+
+use super::{CountryRestricted, CountryUnrestricted};
+use crate::rwa::compliance::modules::{
+ storage::{country_code, get_irs_country_data_entries},
+ MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD,
+};
+
+#[contracttype]
+#[derive(Clone)]
+pub enum CountryRestrictStorageKey {
+ /// Per-(token, country) restriction flag.
+ RestrictedCountry(Address, u32),
+}
+
+// ################## RAW STORAGE ##################
+
+/// Returns whether the given country is on the restriction list for `token`.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `country` - The ISO 3166-1 numeric country code.
+pub fn is_country_restricted(e: &Env, token: &Address, country: u32) -> bool {
+ let key = CountryRestrictStorageKey::RestrictedCountry(token.clone(), country);
+ e.storage()
+ .persistent()
+ .get(&key)
+ .inspect(|_: &bool| {
+ e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT);
+ })
+ .unwrap_or_default()
+}
+
+/// Writes a country's restricted flag to persistent storage.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `country` - The ISO 3166-1 numeric country code to restrict.
+pub fn set_country_restricted(e: &Env, token: &Address, country: u32) {
+ let key = CountryRestrictStorageKey::RestrictedCountry(token.clone(), country);
+ e.storage().persistent().set(&key, &true);
+ e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT);
+}
+
+/// Removes a country from the restriction list in persistent storage.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `country` - The ISO 3166-1 numeric country code to unrestrict.
+pub fn remove_country_restricted(e: &Env, token: &Address, country: u32) {
+ e.storage()
+ .persistent()
+ .remove(&CountryRestrictStorageKey::RestrictedCountry(token.clone(), country));
+}
+
+// ################## ACTIONS ##################
+
+/// Adds a country to the restriction list for `token`.
+///
+/// Writes the flag to storage and emits [`CountryRestricted`].
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `country` - The ISO 3166-1 numeric country code to restrict.
+pub fn add_country_restriction(e: &Env, token: &Address, country: u32) {
+ set_country_restricted(e, token, country);
+ CountryRestricted { token: token.clone(), country }.publish(e);
+}
+
+/// Removes a country from the restriction list for `token`.
+///
+/// Deletes the flag from storage and emits [`CountryUnrestricted`].
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `country` - The ISO 3166-1 numeric country code to unrestrict.
+pub fn remove_country_restriction(e: &Env, token: &Address, country: u32) {
+ remove_country_restricted(e, token, country);
+ CountryUnrestricted { token: token.clone(), country }.publish(e);
+}
+
+/// Adds multiple countries to the restriction list in a single call.
+///
+/// Emits [`CountryRestricted`] for each country added.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `countries` - The country codes to restrict.
+pub fn batch_restrict_countries(e: &Env, token: &Address, countries: &Vec) {
+ for country in countries.iter() {
+ set_country_restricted(e, token, country);
+ CountryRestricted { token: token.clone(), country }.publish(e);
+ }
+}
+
+/// Removes multiple countries from the restriction list in a single call.
+///
+/// Emits [`CountryUnrestricted`] for each country removed.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `countries` - The country codes to unrestrict.
+pub fn batch_unrestrict_countries(e: &Env, token: &Address, countries: &Vec) {
+ for country in countries.iter() {
+ remove_country_restricted(e, token, country);
+ CountryUnrestricted { token: token.clone(), country }.publish(e);
+ }
+}
+
+// ################## COMPLIANCE HOOKS ##################
+
+/// Returns `false` if `to` has any restricted country in the IRS for `token`,
+/// and `true` otherwise.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `to` - The recipient whose country data is checked.
+/// * `token` - The token address.
+///
+/// # Errors
+///
+/// * [`crate::rwa::compliance::modules::ComplianceModuleError::IdentityRegistryNotSet`]
+/// - When no IRS has been configured for `token`.
+///
+/// # Cross-Contract Calls
+///
+/// Calls the IRS to resolve country data for `to`.
+pub fn can_transfer(e: &Env, to: &Address, token: &Address) -> bool {
+ let entries = get_irs_country_data_entries(e, token, to);
+ for entry in entries.iter() {
+ if is_country_restricted(e, token, country_code(&entry.country)) {
+ return false;
+ }
+ }
+ true
+}
diff --git a/packages/tokens/src/rwa/compliance/modules/country_restrict/test.rs b/packages/tokens/src/rwa/compliance/modules/country_restrict/test.rs
new file mode 100644
index 000000000..f09ed9eab
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/country_restrict/test.rs
@@ -0,0 +1,186 @@
+extern crate std;
+
+use soroban_sdk::{
+ contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal, Val,
+ Vec,
+};
+
+use super::storage::{can_transfer, set_country_restricted};
+use crate::rwa::{
+ compliance::modules::storage::set_irs_address,
+ identity_registry_storage::{
+ CountryData, CountryDataManager, CountryRelation, IdentityRegistryStorage,
+ IndividualCountryRelation, OrganizationCountryRelation,
+ },
+ utils::token_binder::TokenBinder,
+};
+
+#[contract]
+struct MockIRSContract;
+
+#[contracttype]
+#[derive(Clone)]
+enum MockIRSStorageKey {
+ Identity(Address),
+ CountryEntries(Address),
+}
+
+#[contractimpl]
+impl TokenBinder for MockIRSContract {
+ fn linked_tokens(e: &Env) -> Vec {
+ Vec::new(e)
+ }
+
+ fn bind_token(_e: &Env, _token: Address, _operator: Address) {
+ unreachable!("bind_token is not used in these tests");
+ }
+
+ fn unbind_token(_e: &Env, _token: Address, _operator: Address) {
+ unreachable!("unbind_token is not used in these tests");
+ }
+}
+
+#[contractimpl]
+impl IdentityRegistryStorage for MockIRSContract {
+ fn add_identity(
+ _e: &Env,
+ _account: Address,
+ _identity: Address,
+ _country_data_list: Vec,
+ _operator: Address,
+ ) {
+ unreachable!("add_identity is not used in these tests");
+ }
+
+ fn remove_identity(_e: &Env, _account: Address, _operator: Address) {
+ unreachable!("remove_identity is not used in these tests");
+ }
+
+ fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) {
+ unreachable!("modify_identity is not used in these tests");
+ }
+
+ fn recover_identity(
+ _e: &Env,
+ _old_account: Address,
+ _new_account: Address,
+ _operator: Address,
+ ) {
+ unreachable!("recover_identity is not used in these tests");
+ }
+
+ fn stored_identity(e: &Env, account: Address) -> Address {
+ e.storage()
+ .persistent()
+ .get(&MockIRSStorageKey::Identity(account.clone()))
+ .unwrap_or(account)
+ }
+}
+
+#[contractimpl]
+impl CountryDataManager for MockIRSContract {
+ fn add_country_data_entries(
+ _e: &Env,
+ _account: Address,
+ _country_data_list: Vec,
+ _operator: Address,
+ ) {
+ unreachable!("add_country_data_entries is not used in these tests");
+ }
+
+ fn modify_country_data(
+ _e: &Env,
+ _account: Address,
+ _index: u32,
+ _country_data: Val,
+ _operator: Address,
+ ) {
+ unreachable!("modify_country_data is not used in these tests");
+ }
+
+ fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) {
+ unreachable!("delete_country_data is not used in these tests");
+ }
+
+ fn get_country_data_entries(e: &Env, account: Address) -> Vec {
+ let entries: Vec = e
+ .storage()
+ .persistent()
+ .get(&MockIRSStorageKey::CountryEntries(account))
+ .unwrap_or_else(|| Vec::new(e));
+
+ Vec::from_iter(e, entries.iter().map(|entry| entry.into_val(e)))
+ }
+}
+
+#[contractimpl]
+impl MockIRSContract {
+ pub fn set_country_data_entries(e: &Env, account: Address, entries: Vec) {
+ e.storage().persistent().set(&MockIRSStorageKey::CountryEntries(account), &entries);
+ }
+}
+
+#[contract]
+struct TestCountryRestrictContract;
+
+fn individual_country(code: u32) -> CountryData {
+ CountryData {
+ country: CountryRelation::Individual(IndividualCountryRelation::Residence(code)),
+ metadata: None,
+ }
+}
+
+fn organization_country(code: u32) -> CountryData {
+ CountryData {
+ country: CountryRelation::Organization(OrganizationCountryRelation::OperatingJurisdiction(
+ code,
+ )),
+ metadata: None,
+ }
+}
+
+#[test]
+fn can_transfer_rejects_when_any_country_is_restricted() {
+ let e = Env::default();
+ let module_id = e.register(TestCountryRestrictContract, ());
+ let irs_id = e.register(MockIRSContract, ());
+ let irs = MockIRSContractClient::new(&e, &irs_id);
+ let token = Address::generate(&e);
+ let to = Address::generate(&e);
+
+ irs.set_country_data_entries(
+ &to,
+ &vec![&e, individual_country(250), organization_country(408)],
+ );
+
+ e.as_contract(&module_id, || {
+ set_irs_address(&e, &token, &irs_id);
+ set_country_restricted(&e, &token, 408);
+
+ assert!(!can_transfer(&e, &to, &token));
+ });
+}
+
+#[test]
+fn can_transfer_allows_when_no_country_is_restricted() {
+ let e = Env::default();
+ let module_id = e.register(TestCountryRestrictContract, ());
+ let irs_id = e.register(MockIRSContract, ());
+ let irs = MockIRSContractClient::new(&e, &irs_id);
+ let token = Address::generate(&e);
+ let empty_to = Address::generate(&e);
+ let unrestricted_to = Address::generate(&e);
+
+ irs.set_country_data_entries(
+ &unrestricted_to,
+ &vec![&e, individual_country(250), organization_country(276)],
+ );
+
+ e.as_contract(&module_id, || {
+ set_irs_address(&e, &token, &irs_id);
+ set_country_restricted(&e, &token, 408);
+
+ assert!(can_transfer(&e, &empty_to, &token));
+ assert!(can_transfer(&e, &unrestricted_to, &token));
+ });
+}
diff --git a/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/mod.rs b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/mod.rs
new file mode 100644
index 000000000..ed816abfe
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/mod.rs
@@ -0,0 +1,24 @@
+//! Initial lockup period compliance module — Stellar port of T-REX
+//! [`TimeExchangeLimitsModule.sol`][trex-src].
+//!
+//! Enforces a lockup period for all investors whenever they receive tokens
+//! through primary emissions (mints). Tokens received via peer-to-peer
+//! transfers are **not** subject to lockup restrictions.
+//!
+//! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/TimeExchangeLimitsModule.sol
+
+pub mod storage;
+#[cfg(test)]
+mod test;
+
+use soroban_sdk::{contractevent, Address};
+pub use storage::LockedTokens;
+
+/// Emitted when a token's lockup duration is configured or changed.
+#[contractevent]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct LockupPeriodSet {
+ #[topic]
+ pub token: Address,
+ pub lockup_seconds: u64,
+}
diff --git a/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/storage.rs b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/storage.rs
new file mode 100644
index 000000000..8224d7ad1
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/storage.rs
@@ -0,0 +1,417 @@
+use soroban_sdk::{contracttype, vec, Address, Env, Vec};
+
+use super::LockupPeriodSet;
+use crate::rwa::compliance::{
+ modules::{
+ storage::{
+ add_i128_or_panic, hooks_verified, require_non_negative_amount, sub_i128_or_panic,
+ verify_required_hooks,
+ },
+ MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD,
+ },
+ ComplianceHook,
+};
+
+/// A single mint-created lock entry tracking the locked amount and its
+/// release time. Mirrors T-REX `LockedTokens { amount, releaseTimestamp }`.
+#[contracttype]
+#[derive(Clone)]
+pub struct LockedTokens {
+ pub amount: i128,
+ pub release_timestamp: u64,
+}
+
+#[contracttype]
+#[derive(Clone)]
+pub enum InitialLockupStorageKey {
+ /// Per-token lockup duration in seconds.
+ LockupPeriod(Address),
+ /// Per-(token, wallet) ordered list of individual lock entries.
+ Locks(Address, Address),
+ /// Per-(token, wallet) aggregate of all locked amounts.
+ TotalLocked(Address, Address),
+ /// Per-(token, wallet) balance mirror, updated via hooks to avoid
+ /// re-entrant `token.balance()` calls.
+ InternalBalance(Address, Address),
+}
+
+// ################## RAW STORAGE ##################
+
+/// Returns the lockup period (in seconds) for `token`, or `0` if not set.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+pub fn get_lockup_period(e: &Env, token: &Address) -> u64 {
+ let key = InitialLockupStorageKey::LockupPeriod(token.clone());
+ e.storage()
+ .persistent()
+ .get(&key)
+ .inspect(|_: &u64| {
+ e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT);
+ })
+ .unwrap_or_default()
+}
+
+/// Sets the lockup period (in seconds) for `token`.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `seconds` - The lockup duration in seconds.
+pub fn set_lockup_period(e: &Env, token: &Address, seconds: u64) {
+ let key = InitialLockupStorageKey::LockupPeriod(token.clone());
+ e.storage().persistent().set(&key, &seconds);
+ e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT);
+}
+
+/// Returns the lock entries for `wallet` on `token`.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `wallet` - The wallet address.
+pub fn get_locks(e: &Env, token: &Address, wallet: &Address) -> Vec {
+ let key = InitialLockupStorageKey::Locks(token.clone(), wallet.clone());
+ e.storage()
+ .persistent()
+ .get(&key)
+ .inspect(|_: &Vec| {
+ e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT);
+ })
+ .unwrap_or_else(|| Vec::new(e))
+}
+
+/// Persists the lock entries for `wallet` on `token`.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `wallet` - The wallet address.
+/// * `locks` - The updated lock entries.
+pub fn set_locks(e: &Env, token: &Address, wallet: &Address, locks: &Vec) {
+ let key = InitialLockupStorageKey::Locks(token.clone(), wallet.clone());
+ e.storage().persistent().set(&key, locks);
+ e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT);
+}
+
+/// Returns the total locked amount for `wallet` on `token`, or `0`.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `wallet` - The wallet address.
+pub fn get_total_locked(e: &Env, token: &Address, wallet: &Address) -> i128 {
+ let key = InitialLockupStorageKey::TotalLocked(token.clone(), wallet.clone());
+ e.storage()
+ .persistent()
+ .get(&key)
+ .inspect(|_: &i128| {
+ e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT);
+ })
+ .unwrap_or_default()
+}
+
+/// Sets the total locked amount for `wallet` on `token`.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `wallet` - The wallet address.
+/// * `amount` - The new total locked amount.
+pub fn set_total_locked(e: &Env, token: &Address, wallet: &Address, amount: i128) {
+ let key = InitialLockupStorageKey::TotalLocked(token.clone(), wallet.clone());
+ e.storage().persistent().set(&key, &amount);
+ e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT);
+}
+
+/// Returns the internal balance for `wallet` on `token`, or `0`.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `wallet` - The wallet address.
+pub fn get_internal_balance(e: &Env, token: &Address, wallet: &Address) -> i128 {
+ let key = InitialLockupStorageKey::InternalBalance(token.clone(), wallet.clone());
+ e.storage()
+ .persistent()
+ .get(&key)
+ .inspect(|_: &i128| {
+ e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT);
+ })
+ .unwrap_or_default()
+}
+
+/// Sets the internal balance for `wallet` on `token`.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `wallet` - The wallet address.
+/// * `balance` - The new balance value.
+pub fn set_internal_balance(e: &Env, token: &Address, wallet: &Address, balance: i128) {
+ let key = InitialLockupStorageKey::InternalBalance(token.clone(), wallet.clone());
+ e.storage().persistent().set(&key, &balance);
+ e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT);
+}
+
+// ################## HELPERS ##################
+
+fn calculate_unlocked_amount(e: &Env, locks: &Vec) -> i128 {
+ let now = e.ledger().timestamp();
+ let mut unlocked = 0i128;
+ for i in 0..locks.len() {
+ let lock = locks.get(i).unwrap();
+ if lock.release_timestamp <= now {
+ unlocked = add_i128_or_panic(e, unlocked, lock.amount);
+ }
+ }
+ unlocked
+}
+
+fn calculate_total_locked_amount(e: &Env, locks: &Vec) -> i128 {
+ let mut total = 0i128;
+ for i in 0..locks.len() {
+ let lock = locks.get(i).unwrap();
+ require_non_negative_amount(e, lock.amount);
+ total = add_i128_or_panic(e, total, lock.amount);
+ }
+ total
+}
+
+fn update_locked_tokens(e: &Env, token: &Address, wallet: &Address, mut amount_to_consume: i128) {
+ let locks = get_locks(e, token, wallet);
+ let now = e.ledger().timestamp();
+ let mut new_locks = Vec::new(e);
+ let mut consumed_total = 0i128;
+
+ for i in 0..locks.len() {
+ let lock = locks.get(i).unwrap();
+ if amount_to_consume > 0 && lock.release_timestamp <= now {
+ if amount_to_consume >= lock.amount {
+ amount_to_consume = sub_i128_or_panic(e, amount_to_consume, lock.amount);
+ consumed_total = add_i128_or_panic(e, consumed_total, lock.amount);
+ } else {
+ consumed_total = add_i128_or_panic(e, consumed_total, amount_to_consume);
+ new_locks.push_back(LockedTokens {
+ amount: sub_i128_or_panic(e, lock.amount, amount_to_consume),
+ release_timestamp: lock.release_timestamp,
+ });
+ amount_to_consume = 0;
+ }
+ } else {
+ new_locks.push_back(lock);
+ }
+ }
+
+ set_locks(e, token, wallet, &new_locks);
+
+ let total_locked = get_total_locked(e, token, wallet);
+ set_total_locked(e, token, wallet, sub_i128_or_panic(e, total_locked, consumed_total));
+}
+
+// ################## ACTIONS ##################
+
+/// Configures the lockup period for `token` and emits [`LockupPeriodSet`].
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `lockup_seconds` - The lockup duration in seconds.
+pub fn configure_lockup_period(e: &Env, token: &Address, lockup_seconds: u64) {
+ set_lockup_period(e, token, lockup_seconds);
+ LockupPeriodSet { token: token.clone(), lockup_seconds }.publish(e);
+}
+
+/// Pre-seeds the lockup state for a wallet. Validates that total locked
+/// does not exceed balance.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `wallet` - The wallet address.
+/// * `balance` - The wallet balance.
+/// * `locks` - The lock entries.
+pub fn pre_set_lockup_state(
+ e: &Env,
+ token: &Address,
+ wallet: &Address,
+ balance: i128,
+ locks: &Vec,
+) {
+ require_non_negative_amount(e, balance);
+
+ let total_locked = calculate_total_locked_amount(e, locks);
+ assert!(
+ total_locked <= balance,
+ "InitialLockupPeriodModule: total locked amount cannot exceed balance"
+ );
+
+ set_internal_balance(e, token, wallet, balance);
+ set_locks(e, token, wallet, locks);
+ set_total_locked(e, token, wallet, total_locked);
+}
+
+// ################## HOOK WIRING ##################
+
+/// Returns the set of compliance hooks this module requires.
+pub fn required_hooks(e: &Env) -> Vec {
+ vec![
+ e,
+ ComplianceHook::CanTransfer,
+ ComplianceHook::Created,
+ ComplianceHook::Transferred,
+ ComplianceHook::Destroyed,
+ ]
+}
+
+/// Cross-calls the compliance contract to verify that this module is
+/// registered on all required hooks.
+pub fn verify_hook_wiring(e: &Env) {
+ verify_required_hooks(e, required_hooks(e));
+}
+
+// ################## COMPLIANCE HOOKS ##################
+
+/// Updates internal balances and lock tracking after a transfer.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `from` - The sender address.
+/// * `to` - The recipient address.
+/// * `amount` - The transfer amount.
+/// * `token` - The token address.
+pub fn on_transfer(e: &Env, from: &Address, to: &Address, amount: i128, token: &Address) {
+ require_non_negative_amount(e, amount);
+
+ let total_locked = get_total_locked(e, token, from);
+
+ if total_locked > 0 {
+ let pre_balance = get_internal_balance(e, token, from);
+ let pre_free = pre_balance - total_locked;
+
+ if amount > pre_free.max(0) {
+ let to_consume = amount - pre_free.max(0);
+ update_locked_tokens(e, token, from, to_consume);
+ }
+ }
+
+ let from_bal = get_internal_balance(e, token, from);
+ set_internal_balance(e, token, from, sub_i128_or_panic(e, from_bal, amount));
+
+ let to_bal = get_internal_balance(e, token, to);
+ set_internal_balance(e, token, to, add_i128_or_panic(e, to_bal, amount));
+}
+
+/// Updates internal balance and creates a lock entry after a mint.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `to` - The recipient address.
+/// * `amount` - The minted amount.
+/// * `token` - The token address.
+pub fn on_created(e: &Env, to: &Address, amount: i128, token: &Address) {
+ require_non_negative_amount(e, amount);
+
+ let period = get_lockup_period(e, token);
+ if period > 0 {
+ let mut locks = get_locks(e, token, to);
+ locks.push_back(LockedTokens {
+ amount,
+ release_timestamp: e.ledger().timestamp().saturating_add(period),
+ });
+ set_locks(e, token, to, &locks);
+
+ let total = get_total_locked(e, token, to);
+ set_total_locked(e, token, to, add_i128_or_panic(e, total, amount));
+ }
+
+ let current = get_internal_balance(e, token, to);
+ set_internal_balance(e, token, to, add_i128_or_panic(e, current, amount));
+}
+
+/// Updates internal balance and consumes locks after a burn.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `from` - The burner address.
+/// * `amount` - The burned amount.
+/// * `token` - The token address.
+pub fn on_destroyed(e: &Env, from: &Address, amount: i128, token: &Address) {
+ require_non_negative_amount(e, amount);
+
+ let total_locked = get_total_locked(e, token, from);
+
+ if total_locked > 0 {
+ let pre_balance = get_internal_balance(e, token, from);
+ let mut free_amount = pre_balance - total_locked;
+
+ if free_amount < amount {
+ let locks = get_locks(e, token, from);
+ free_amount += calculate_unlocked_amount(e, &locks);
+ }
+
+ assert!(
+ free_amount >= amount,
+ "InitialLockupPeriodModule: insufficient unlocked balance for burn"
+ );
+
+ let pre_free = pre_balance - total_locked;
+ if amount > pre_free.max(0) {
+ let to_consume = amount - pre_free.max(0);
+ update_locked_tokens(e, token, from, to_consume);
+ }
+ }
+
+ let current = get_internal_balance(e, token, from);
+ set_internal_balance(e, token, from, sub_i128_or_panic(e, current, amount));
+}
+
+/// Returns `true` if the sender has sufficient unlocked balance for the
+/// transfer.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `from` - The sender address.
+/// * `amount` - The transfer amount.
+/// * `token` - The token address.
+pub fn can_transfer(e: &Env, from: &Address, amount: i128, token: &Address) -> bool {
+ assert!(
+ hooks_verified(e),
+ "InitialLockupPeriodModule: not armed — call verify_hook_wiring() after wiring hooks \
+ [CanTransfer, Created, Transferred, Destroyed]"
+ );
+ if amount < 0 {
+ return false;
+ }
+
+ let total_locked = get_total_locked(e, token, from);
+ if total_locked == 0 {
+ return true;
+ }
+
+ let balance = get_internal_balance(e, token, from);
+ let free = balance - total_locked;
+
+ if free >= amount {
+ return true;
+ }
+
+ let locks = get_locks(e, token, from);
+ let unlocked = calculate_unlocked_amount(e, &locks);
+ (free + unlocked) >= amount
+}
diff --git a/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/test.rs b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/test.rs
new file mode 100644
index 000000000..ec6951c91
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/test.rs
@@ -0,0 +1,159 @@
+extern crate std;
+
+use soroban_sdk::{
+ contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env,
+};
+
+use super::storage::{
+ can_transfer, get_internal_balance, get_total_locked, pre_set_lockup_state, verify_hook_wiring,
+ LockedTokens,
+};
+use crate::rwa::{
+ compliance::{
+ modules::storage::{hooks_verified, set_compliance_address, ComplianceModuleStorageKey},
+ Compliance, ComplianceHook,
+ },
+ utils::token_binder::TokenBinder,
+};
+
+#[contract]
+struct TestModuleContract;
+
+fn arm_hooks(e: &Env) {
+ e.storage().instance().set(&ComplianceModuleStorageKey::HooksVerified, &true);
+}
+
+#[contract]
+struct MockComplianceContract;
+
+#[derive(Clone)]
+#[contracttype]
+enum MockComplianceStorageKey {
+ Registered(ComplianceHook, Address),
+}
+
+#[contractimpl]
+impl Compliance for MockComplianceContract {
+ fn add_module_to(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) {
+ unreachable!("add_module_to is not used in these tests");
+ }
+
+ fn remove_module_from(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) {
+ unreachable!("remove_module_from is not used in these tests");
+ }
+
+ fn get_modules_for_hook(_e: &Env, _hook: ComplianceHook) -> soroban_sdk::Vec {
+ unreachable!("get_modules_for_hook is not used in these tests");
+ }
+
+ fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool {
+ e.storage().persistent().has(&MockComplianceStorageKey::Registered(hook, module))
+ }
+
+ fn transferred(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {
+ unreachable!("transferred is not used in these tests");
+ }
+
+ fn created(_e: &Env, _to: Address, _amount: i128, _token: Address) {
+ unreachable!("created is not used in these tests");
+ }
+
+ fn destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {
+ unreachable!("destroyed is not used in these tests");
+ }
+
+ fn can_transfer(
+ _e: &Env,
+ _from: Address,
+ _to: Address,
+ _amount: i128,
+ _token: Address,
+ ) -> bool {
+ unreachable!("can_transfer is not used in these tests");
+ }
+
+ fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool {
+ unreachable!("can_create is not used in these tests");
+ }
+}
+
+#[contractimpl]
+impl TokenBinder for MockComplianceContract {
+ fn linked_tokens(e: &Env) -> soroban_sdk::Vec {
+ soroban_sdk::Vec::new(e)
+ }
+
+ fn bind_token(_e: &Env, _token: Address, _operator: Address) {
+ unreachable!("bind_token is not used in these tests");
+ }
+
+ fn unbind_token(_e: &Env, _token: Address, _operator: Address) {
+ unreachable!("unbind_token is not used in these tests");
+ }
+}
+
+#[contractimpl]
+impl MockComplianceContract {
+ pub fn register_hook(e: &Env, hook: ComplianceHook, module: Address) {
+ e.storage().persistent().set(&MockComplianceStorageKey::Registered(hook, module), &true);
+ }
+}
+
+#[test]
+fn verify_hook_wiring_sets_cache_when_registered() {
+ let e = Env::default();
+ let module_id = e.register(TestModuleContract, ());
+ let compliance_id = e.register(MockComplianceContract, ());
+ let compliance = MockComplianceContractClient::new(&e, &compliance_id);
+
+ for hook in [
+ ComplianceHook::CanTransfer,
+ ComplianceHook::Created,
+ ComplianceHook::Transferred,
+ ComplianceHook::Destroyed,
+ ] {
+ compliance.register_hook(&hook, &module_id);
+ }
+
+ e.as_contract(&module_id, || {
+ set_compliance_address(&e, &compliance_id);
+
+ verify_hook_wiring(&e);
+
+ assert!(hooks_verified(&e));
+ });
+}
+
+#[test]
+fn pre_set_lockup_state_seeds_existing_locked_balance() {
+ let e = Env::default();
+
+ let module_id = e.register(TestModuleContract, ());
+ let compliance = Address::generate(&e);
+ let token = Address::generate(&e);
+ let wallet = Address::generate(&e);
+
+ e.as_contract(&module_id, || {
+ set_compliance_address(&e, &compliance);
+ arm_hooks(&e);
+
+ pre_set_lockup_state(
+ &e,
+ &token,
+ &wallet,
+ 100,
+ &vec![
+ &e,
+ LockedTokens {
+ amount: 80,
+ release_timestamp: e.ledger().timestamp().saturating_add(60),
+ },
+ ],
+ );
+
+ assert_eq!(get_internal_balance(&e, &token, &wallet), 100);
+ assert_eq!(get_total_locked(&e, &token, &wallet), 80);
+ assert!(!can_transfer(&e, &wallet, 21, &token));
+ assert!(can_transfer(&e, &wallet, 20, &token));
+ });
+}
diff --git a/packages/tokens/src/rwa/compliance/modules/max_balance/mod.rs b/packages/tokens/src/rwa/compliance/modules/max_balance/mod.rs
new file mode 100644
index 000000000..a20b71d1b
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/max_balance/mod.rs
@@ -0,0 +1,32 @@
+//! Max balance compliance module — Stellar port of T-REX
+//! [`MaxBalanceModule.sol`][trex-src].
+//!
+//! Tracks effective balances per **identity** (not per wallet), enforcing a
+//! per-token cap.
+//!
+//! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/MaxBalanceModule.sol
+
+pub mod storage;
+#[cfg(test)]
+mod test;
+
+use soroban_sdk::{contractevent, Address};
+
+/// Emitted when a token's per-identity balance cap is configured.
+#[contractevent]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct MaxBalanceSet {
+ #[topic]
+ pub token: Address,
+ pub max_balance: i128,
+}
+
+/// Emitted when an identity balance is pre-seeded via `pre_set_module_state`.
+#[contractevent]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct IDBalancePreSet {
+ #[topic]
+ pub token: Address,
+ pub identity: Address,
+ pub balance: i128,
+}
diff --git a/packages/tokens/src/rwa/compliance/modules/max_balance/storage.rs b/packages/tokens/src/rwa/compliance/modules/max_balance/storage.rs
new file mode 100644
index 000000000..6a894523b
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/max_balance/storage.rs
@@ -0,0 +1,315 @@
+use soroban_sdk::{contracttype, vec, Address, Env, Vec};
+
+use super::{IDBalancePreSet, MaxBalanceSet};
+use crate::rwa::compliance::{
+ modules::{
+ storage::{
+ add_i128_or_panic, get_irs_client, hooks_verified, require_non_negative_amount,
+ sub_i128_or_panic, verify_required_hooks,
+ },
+ MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD,
+ },
+ ComplianceHook,
+};
+
+#[contracttype]
+#[derive(Clone)]
+pub enum MaxBalanceStorageKey {
+ /// Per-token maximum allowed identity balance.
+ MaxBalance(Address),
+ /// Balance keyed by (token, identity) — not by wallet.
+ IDBalance(Address, Address),
+}
+
+// ################## RAW STORAGE ##################
+
+/// Returns the per-identity balance cap for `token`, or `0` if not set.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+pub fn get_max_balance(e: &Env, token: &Address) -> i128 {
+ let key = MaxBalanceStorageKey::MaxBalance(token.clone());
+ e.storage()
+ .persistent()
+ .get(&key)
+ .inspect(|_: &i128| {
+ e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT);
+ })
+ .unwrap_or_default()
+}
+
+/// Sets the per-identity balance cap for `token`.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `value` - The maximum balance per identity.
+pub fn set_max_balance(e: &Env, token: &Address, value: i128) {
+ let key = MaxBalanceStorageKey::MaxBalance(token.clone());
+ e.storage().persistent().set(&key, &value);
+ e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT);
+}
+
+/// Returns the tracked balance for `identity` on `token`, or `0` if not
+/// set.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `identity` - The on-chain identity address.
+pub fn get_id_balance(e: &Env, token: &Address, identity: &Address) -> i128 {
+ let key = MaxBalanceStorageKey::IDBalance(token.clone(), identity.clone());
+ e.storage()
+ .persistent()
+ .get(&key)
+ .inspect(|_: &i128| {
+ e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT);
+ })
+ .unwrap_or_default()
+}
+
+/// Sets the tracked balance for `identity` on `token`.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `identity` - The on-chain identity address.
+/// * `balance` - The new balance value.
+pub fn set_id_balance(e: &Env, token: &Address, identity: &Address, balance: i128) {
+ let key = MaxBalanceStorageKey::IDBalance(token.clone(), identity.clone());
+ e.storage().persistent().set(&key, &balance);
+ e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT);
+}
+
+// ################## HELPERS ##################
+
+fn can_increase_identity_balance(
+ e: &Env,
+ token: &Address,
+ identity: &Address,
+ amount: i128,
+) -> bool {
+ if amount < 0 {
+ return false;
+ }
+
+ let max = get_max_balance(e, token);
+ if max == 0 {
+ return true;
+ }
+
+ let current = get_id_balance(e, token, identity);
+ add_i128_or_panic(e, current, amount) <= max
+}
+
+// ################## ACTIONS ##################
+
+/// Validates, stores, and emits [`MaxBalanceSet`] for the given cap.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `max` - The maximum balance per identity.
+pub fn configure_max_balance(e: &Env, token: &Address, max: i128) {
+ require_non_negative_amount(e, max);
+ set_max_balance(e, token, max);
+ MaxBalanceSet { token: token.clone(), max_balance: max }.publish(e);
+}
+
+/// Pre-seeds the tracked balance for an identity and emits
+/// [`IDBalancePreSet`].
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `identity` - The on-chain identity address.
+/// * `balance` - The pre-seeded balance value.
+pub fn pre_set_identity_balance(e: &Env, token: &Address, identity: &Address, balance: i128) {
+ require_non_negative_amount(e, balance);
+ set_id_balance(e, token, identity, balance);
+ IDBalancePreSet { token: token.clone(), identity: identity.clone(), balance }.publish(e);
+}
+
+/// Pre-seeds tracked balances for multiple identities. Emits
+/// [`IDBalancePreSet`] for each.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `identities` - Identity addresses.
+/// * `balances` - Corresponding balance values.
+pub fn batch_pre_set_identity_balances(
+ e: &Env,
+ token: &Address,
+ identities: &Vec,
+ balances: &Vec,
+) {
+ assert!(
+ identities.len() == balances.len(),
+ "MaxBalanceModule: identities and balances length mismatch"
+ );
+ for i in 0..identities.len() {
+ let id = identities.get(i).unwrap();
+ let bal = balances.get(i).unwrap();
+ require_non_negative_amount(e, bal);
+ set_id_balance(e, token, &id, bal);
+ IDBalancePreSet { token: token.clone(), identity: id, balance: bal }.publish(e);
+ }
+}
+
+// ################## HOOK WIRING ##################
+
+/// Returns the set of compliance hooks this module requires.
+pub fn required_hooks(e: &Env) -> Vec {
+ vec![
+ e,
+ ComplianceHook::CanTransfer,
+ ComplianceHook::CanCreate,
+ ComplianceHook::Transferred,
+ ComplianceHook::Created,
+ ComplianceHook::Destroyed,
+ ]
+}
+
+/// Cross-calls the compliance contract to verify that this module is
+/// registered on all required hooks.
+pub fn verify_hook_wiring(e: &Env) {
+ verify_required_hooks(e, required_hooks(e));
+}
+
+// ################## COMPLIANCE HOOKS ##################
+
+/// Updates identity balances after a transfer.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `from` - The sender address.
+/// * `to` - The recipient address.
+/// * `amount` - The transfer amount.
+/// * `token` - The token address.
+pub fn on_transfer(e: &Env, from: &Address, to: &Address, amount: i128, token: &Address) {
+ require_non_negative_amount(e, amount);
+
+ let irs = get_irs_client(e, token);
+ let from_id = irs.stored_identity(from);
+ let to_id = irs.stored_identity(to);
+
+ if from_id == to_id {
+ return;
+ }
+
+ let from_balance = get_id_balance(e, token, &from_id);
+ assert!(
+ can_increase_identity_balance(e, token, &to_id, amount),
+ "MaxBalanceModule: recipient identity balance exceeds max"
+ );
+
+ let to_balance = get_id_balance(e, token, &to_id);
+ let new_to_balance = add_i128_or_panic(e, to_balance, amount);
+ set_id_balance(e, token, &from_id, sub_i128_or_panic(e, from_balance, amount));
+ set_id_balance(e, token, &to_id, new_to_balance);
+}
+
+/// Updates identity balance after a mint.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `to` - The recipient address.
+/// * `amount` - The minted amount.
+/// * `token` - The token address.
+pub fn on_created(e: &Env, to: &Address, amount: i128, token: &Address) {
+ require_non_negative_amount(e, amount);
+
+ let irs = get_irs_client(e, token);
+ let to_id = irs.stored_identity(to);
+
+ assert!(
+ can_increase_identity_balance(e, token, &to_id, amount),
+ "MaxBalanceModule: recipient identity balance exceeds max after mint"
+ );
+
+ let current = get_id_balance(e, token, &to_id);
+ let new_balance = add_i128_or_panic(e, current, amount);
+ set_id_balance(e, token, &to_id, new_balance);
+}
+
+/// Updates identity balance after a burn.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `from` - The burner address.
+/// * `amount` - The burned amount.
+/// * `token` - The token address.
+pub fn on_destroyed(e: &Env, from: &Address, amount: i128, token: &Address) {
+ require_non_negative_amount(e, amount);
+
+ let irs = get_irs_client(e, token);
+ let from_id = irs.stored_identity(from);
+
+ let current = get_id_balance(e, token, &from_id);
+ set_id_balance(e, token, &from_id, sub_i128_or_panic(e, current, amount));
+}
+
+/// Checks whether a transfer would exceed the recipient identity's
+/// balance cap.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `from` - The sender address.
+/// * `to` - The recipient address.
+/// * `amount` - The transfer amount.
+/// * `token` - The token address.
+pub fn can_transfer(e: &Env, from: &Address, to: &Address, amount: i128, token: &Address) -> bool {
+ assert!(
+ hooks_verified(e),
+ "MaxBalanceModule: not armed — call verify_hook_wiring() after wiring hooks [CanTransfer, \
+ CanCreate, Transferred, Created, Destroyed]"
+ );
+ if amount < 0 {
+ return false;
+ }
+ let irs = get_irs_client(e, token);
+ let from_id = irs.stored_identity(from);
+ let to_id = irs.stored_identity(to);
+
+ if from_id == to_id {
+ return true;
+ }
+
+ can_increase_identity_balance(e, token, &to_id, amount)
+}
+
+/// Checks whether a mint would exceed the recipient identity's balance
+/// cap.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `to` - The recipient address.
+/// * `amount` - The mint amount.
+/// * `token` - The token address.
+pub fn can_create(e: &Env, to: &Address, amount: i128, token: &Address) -> bool {
+ assert!(
+ hooks_verified(e),
+ "MaxBalanceModule: not armed — call verify_hook_wiring() after wiring hooks [CanTransfer, \
+ CanCreate, Transferred, Created, Destroyed]"
+ );
+ if amount < 0 {
+ return false;
+ }
+ let irs = get_irs_client(e, token);
+ let to_id = irs.stored_identity(to);
+ can_increase_identity_balance(e, token, &to_id, amount)
+}
diff --git a/packages/tokens/src/rwa/compliance/modules/max_balance/test.rs b/packages/tokens/src/rwa/compliance/modules/max_balance/test.rs
new file mode 100644
index 000000000..c45ae068d
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/max_balance/test.rs
@@ -0,0 +1,447 @@
+extern crate std;
+
+use soroban_sdk::{
+ contract, contractimpl, contracttype, testutils::Address as _, Address, Env, Val, Vec,
+};
+
+use super::storage::{
+ can_create, can_transfer, get_id_balance, on_created, on_destroyed, on_transfer,
+ set_id_balance, set_max_balance, verify_hook_wiring,
+};
+use crate::rwa::{
+ compliance::{
+ modules::storage::{
+ hooks_verified, set_compliance_address, set_irs_address, ComplianceModuleStorageKey,
+ },
+ Compliance, ComplianceHook,
+ },
+ identity_registry_storage::{CountryDataManager, IdentityRegistryStorage},
+ utils::token_binder::TokenBinder,
+};
+
+#[contract]
+struct MockIRSContract;
+
+#[contracttype]
+#[derive(Clone)]
+enum MockIRSStorageKey {
+ Identity(Address),
+}
+
+#[contractimpl]
+impl TokenBinder for MockIRSContract {
+ fn linked_tokens(e: &Env) -> Vec {
+ Vec::new(e)
+ }
+
+ fn bind_token(_e: &Env, _token: Address, _operator: Address) {
+ unreachable!("bind_token is not used in these tests");
+ }
+
+ fn unbind_token(_e: &Env, _token: Address, _operator: Address) {
+ unreachable!("unbind_token is not used in these tests");
+ }
+}
+
+#[contractimpl]
+impl IdentityRegistryStorage for MockIRSContract {
+ fn add_identity(
+ _e: &Env,
+ _account: Address,
+ _identity: Address,
+ _country_data_list: Vec,
+ _operator: Address,
+ ) {
+ unreachable!("add_identity is not used in these tests");
+ }
+
+ fn remove_identity(_e: &Env, _account: Address, _operator: Address) {
+ unreachable!("remove_identity is not used in these tests");
+ }
+
+ fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) {
+ unreachable!("modify_identity is not used in these tests");
+ }
+
+ fn recover_identity(
+ _e: &Env,
+ _old_account: Address,
+ _new_account: Address,
+ _operator: Address,
+ ) {
+ unreachable!("recover_identity is not used in these tests");
+ }
+
+ fn stored_identity(e: &Env, account: Address) -> Address {
+ e.storage()
+ .persistent()
+ .get(&MockIRSStorageKey::Identity(account.clone()))
+ .unwrap_or(account)
+ }
+}
+
+#[contractimpl]
+impl CountryDataManager for MockIRSContract {
+ fn add_country_data_entries(
+ _e: &Env,
+ _account: Address,
+ _country_data_list: Vec,
+ _operator: Address,
+ ) {
+ unreachable!("add_country_data_entries is not used in these tests");
+ }
+
+ fn modify_country_data(
+ _e: &Env,
+ _account: Address,
+ _index: u32,
+ _country_data: Val,
+ _operator: Address,
+ ) {
+ unreachable!("modify_country_data is not used in these tests");
+ }
+
+ fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) {
+ unreachable!("delete_country_data is not used in these tests");
+ }
+
+ fn get_country_data_entries(e: &Env, _account: Address) -> Vec {
+ Vec::new(e)
+ }
+}
+
+#[contractimpl]
+impl MockIRSContract {
+ pub fn set_identity(e: &Env, account: Address, identity: Address) {
+ e.storage().persistent().set(&MockIRSStorageKey::Identity(account), &identity);
+ }
+}
+
+#[contract]
+struct MockComplianceContract;
+
+#[contracttype]
+#[derive(Clone)]
+enum MockComplianceStorageKey {
+ Registered(ComplianceHook, Address),
+}
+
+#[contractimpl]
+impl Compliance for MockComplianceContract {
+ fn add_module_to(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) {
+ unreachable!("add_module_to is not used in these tests");
+ }
+
+ fn remove_module_from(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) {
+ unreachable!("remove_module_from is not used in these tests");
+ }
+
+ fn get_modules_for_hook(_e: &Env, _hook: ComplianceHook) -> Vec {
+ unreachable!("get_modules_for_hook is not used in these tests");
+ }
+
+ fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool {
+ e.storage().persistent().has(&MockComplianceStorageKey::Registered(hook, module))
+ }
+
+ fn transferred(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {
+ unreachable!("transferred is not used in these tests");
+ }
+
+ fn created(_e: &Env, _to: Address, _amount: i128, _token: Address) {
+ unreachable!("created is not used in these tests");
+ }
+
+ fn destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {
+ unreachable!("destroyed is not used in these tests");
+ }
+
+ fn can_transfer(
+ _e: &Env,
+ _from: Address,
+ _to: Address,
+ _amount: i128,
+ _token: Address,
+ ) -> bool {
+ unreachable!("can_transfer is not used in these tests");
+ }
+
+ fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool {
+ unreachable!("can_create is not used in these tests");
+ }
+}
+
+#[contractimpl]
+impl TokenBinder for MockComplianceContract {
+ fn linked_tokens(e: &Env) -> Vec {
+ Vec::new(e)
+ }
+
+ fn bind_token(_e: &Env, _token: Address, _operator: Address) {
+ unreachable!("bind_token is not used in these tests");
+ }
+
+ fn unbind_token(_e: &Env, _token: Address, _operator: Address) {
+ unreachable!("unbind_token is not used in these tests");
+ }
+}
+
+#[contractimpl]
+impl MockComplianceContract {
+ pub fn register_hook(e: &Env, hook: ComplianceHook, module: Address) {
+ e.storage().persistent().set(&MockComplianceStorageKey::Registered(hook, module), &true);
+ }
+}
+
+#[contract]
+struct TestMaxBalanceContract;
+
+fn arm_hooks(e: &Env) {
+ e.storage().instance().set(&ComplianceModuleStorageKey::HooksVerified, &true);
+}
+
+#[test]
+fn verify_hook_wiring_sets_cache_when_registered() {
+ let e = Env::default();
+ let module_id = e.register(TestMaxBalanceContract, ());
+ let compliance_id = e.register(MockComplianceContract, ());
+ let compliance = MockComplianceContractClient::new(&e, &compliance_id);
+
+ for hook in [
+ ComplianceHook::CanTransfer,
+ ComplianceHook::CanCreate,
+ ComplianceHook::Transferred,
+ ComplianceHook::Created,
+ ComplianceHook::Destroyed,
+ ] {
+ compliance.register_hook(&hook, &module_id);
+ }
+
+ e.as_contract(&module_id, || {
+ set_compliance_address(&e, &compliance_id);
+
+ verify_hook_wiring(&e);
+
+ assert!(hooks_verified(&e));
+ });
+}
+
+#[test]
+fn can_create_rejects_mint_when_cap_would_be_exceeded() {
+ let e = Env::default();
+ let module_id = e.register(TestMaxBalanceContract, ());
+ let irs_id = e.register(MockIRSContract, ());
+ let irs = MockIRSContractClient::new(&e, &irs_id);
+ let token = Address::generate(&e);
+ let recipient = Address::generate(&e);
+ let recipient_identity = Address::generate(&e);
+
+ irs.set_identity(&recipient, &recipient_identity);
+
+ e.as_contract(&module_id, || {
+ set_irs_address(&e, &token, &irs_id);
+ arm_hooks(&e);
+ set_max_balance(&e, &token, 100);
+ set_id_balance(&e, &token, &recipient_identity, 60);
+
+ assert!(!can_create(&e, &recipient, 50, &token));
+ assert!(can_create(&e, &recipient, 40, &token));
+ });
+}
+
+#[test]
+fn can_transfer_checks_distinct_recipient_identity_balance() {
+ let e = Env::default();
+ let module_id = e.register(TestMaxBalanceContract, ());
+ let irs_id = e.register(MockIRSContract, ());
+ let irs = MockIRSContractClient::new(&e, &irs_id);
+ let token = Address::generate(&e);
+ let sender = Address::generate(&e);
+ let recipient = Address::generate(&e);
+ let sender_identity = Address::generate(&e);
+ let recipient_identity = Address::generate(&e);
+
+ irs.set_identity(&sender, &sender_identity);
+ irs.set_identity(&recipient, &recipient_identity);
+
+ e.as_contract(&module_id, || {
+ set_irs_address(&e, &token, &irs_id);
+ arm_hooks(&e);
+ set_max_balance(&e, &token, 100);
+ set_id_balance(&e, &token, &recipient_identity, 60);
+
+ assert!(!can_transfer(&e, &sender, &recipient, 50, &token));
+ assert!(can_transfer(&e, &sender, &recipient, 40, &token));
+ });
+}
+
+#[test]
+fn can_create_allows_without_cap_and_rejects_negative_amount() {
+ let e = Env::default();
+ let module_id = e.register(TestMaxBalanceContract, ());
+ let irs_id = e.register(MockIRSContract, ());
+ let irs = MockIRSContractClient::new(&e, &irs_id);
+ let token = Address::generate(&e);
+ let recipient = Address::generate(&e);
+ let recipient_identity = Address::generate(&e);
+
+ irs.set_identity(&recipient, &recipient_identity);
+
+ e.as_contract(&module_id, || {
+ set_irs_address(&e, &token, &irs_id);
+ arm_hooks(&e);
+ set_id_balance(&e, &token, &recipient_identity, 500);
+
+ assert!(can_create(&e, &recipient, 1_000, &token));
+ assert!(!can_create(&e, &recipient, -1, &token));
+ });
+}
+
+#[test]
+fn can_create_rejects_negative_amount_before_requiring_irs() {
+ let e = Env::default();
+ let module_id = e.register(TestMaxBalanceContract, ());
+ let token = Address::generate(&e);
+ let recipient = Address::generate(&e);
+
+ e.as_contract(&module_id, || {
+ arm_hooks(&e);
+
+ assert!(!can_create(&e, &recipient, -1, &token));
+ });
+}
+
+#[test]
+fn can_transfer_rejects_negative_amount_before_requiring_irs() {
+ let e = Env::default();
+ let module_id = e.register(TestMaxBalanceContract, ());
+ let token = Address::generate(&e);
+ let sender = Address::generate(&e);
+ let recipient = Address::generate(&e);
+
+ e.as_contract(&module_id, || {
+ arm_hooks(&e);
+
+ assert!(!can_transfer(&e, &sender, &recipient, -1, &token));
+ });
+}
+
+#[test]
+fn can_transfer_allows_when_sender_and_recipient_share_identity() {
+ let e = Env::default();
+ let module_id = e.register(TestMaxBalanceContract, ());
+ let irs_id = e.register(MockIRSContract, ());
+ let irs = MockIRSContractClient::new(&e, &irs_id);
+ let token = Address::generate(&e);
+ let sender = Address::generate(&e);
+ let recipient = Address::generate(&e);
+ let shared_identity = Address::generate(&e);
+
+ irs.set_identity(&sender, &shared_identity);
+ irs.set_identity(&recipient, &shared_identity);
+
+ e.as_contract(&module_id, || {
+ set_irs_address(&e, &token, &irs_id);
+ arm_hooks(&e);
+ set_max_balance(&e, &token, 1);
+
+ assert!(can_transfer(&e, &sender, &recipient, 1_000, &token));
+ });
+}
+
+#[test]
+fn on_transfer_updates_balances_for_distinct_identities() {
+ let e = Env::default();
+ let module_id = e.register(TestMaxBalanceContract, ());
+ let irs_id = e.register(MockIRSContract, ());
+ let irs = MockIRSContractClient::new(&e, &irs_id);
+ let token = Address::generate(&e);
+ let sender = Address::generate(&e);
+ let recipient = Address::generate(&e);
+ let sender_identity = Address::generate(&e);
+ let recipient_identity = Address::generate(&e);
+
+ irs.set_identity(&sender, &sender_identity);
+ irs.set_identity(&recipient, &recipient_identity);
+
+ e.as_contract(&module_id, || {
+ set_irs_address(&e, &token, &irs_id);
+ set_max_balance(&e, &token, 200);
+ set_id_balance(&e, &token, &sender_identity, 100);
+ set_id_balance(&e, &token, &recipient_identity, 20);
+
+ on_transfer(&e, &sender, &recipient, 30, &token);
+
+ assert_eq!(get_id_balance(&e, &token, &sender_identity), 70);
+ assert_eq!(get_id_balance(&e, &token, &recipient_identity), 50);
+ });
+}
+
+#[test]
+fn on_transfer_is_noop_for_same_identity() {
+ let e = Env::default();
+ let module_id = e.register(TestMaxBalanceContract, ());
+ let irs_id = e.register(MockIRSContract, ());
+ let irs = MockIRSContractClient::new(&e, &irs_id);
+ let token = Address::generate(&e);
+ let sender = Address::generate(&e);
+ let recipient = Address::generate(&e);
+ let shared_identity = Address::generate(&e);
+
+ irs.set_identity(&sender, &shared_identity);
+ irs.set_identity(&recipient, &shared_identity);
+
+ e.as_contract(&module_id, || {
+ set_irs_address(&e, &token, &irs_id);
+ set_id_balance(&e, &token, &shared_identity, 100);
+
+ on_transfer(&e, &sender, &recipient, 30, &token);
+
+ assert_eq!(get_id_balance(&e, &token, &shared_identity), 100);
+ });
+}
+
+#[test]
+fn on_created_updates_identity_balance() {
+ let e = Env::default();
+ let module_id = e.register(TestMaxBalanceContract, ());
+ let irs_id = e.register(MockIRSContract, ());
+ let irs = MockIRSContractClient::new(&e, &irs_id);
+ let token = Address::generate(&e);
+ let recipient = Address::generate(&e);
+ let recipient_identity = Address::generate(&e);
+
+ irs.set_identity(&recipient, &recipient_identity);
+
+ e.as_contract(&module_id, || {
+ set_irs_address(&e, &token, &irs_id);
+ set_max_balance(&e, &token, 200);
+ set_id_balance(&e, &token, &recipient_identity, 50);
+
+ on_created(&e, &recipient, 30, &token);
+
+ assert_eq!(get_id_balance(&e, &token, &recipient_identity), 80);
+ });
+}
+
+#[test]
+fn on_destroyed_updates_identity_balance() {
+ let e = Env::default();
+ let module_id = e.register(TestMaxBalanceContract, ());
+ let irs_id = e.register(MockIRSContract, ());
+ let irs = MockIRSContractClient::new(&e, &irs_id);
+ let token = Address::generate(&e);
+ let holder = Address::generate(&e);
+ let holder_identity = Address::generate(&e);
+
+ irs.set_identity(&holder, &holder_identity);
+
+ e.as_contract(&module_id, || {
+ set_irs_address(&e, &token, &irs_id);
+ set_id_balance(&e, &token, &holder_identity, 90);
+
+ on_destroyed(&e, &holder, 40, &token);
+
+ assert_eq!(get_id_balance(&e, &token, &holder_identity), 50);
+ });
+}
diff --git a/packages/tokens/src/rwa/compliance/modules/mod.rs b/packages/tokens/src/rwa/compliance/modules/mod.rs
index f4e065161..29995ad41 100644
--- a/packages/tokens/src/rwa/compliance/modules/mod.rs
+++ b/packages/tokens/src/rwa/compliance/modules/mod.rs
@@ -1,6 +1,13 @@
use soroban_sdk::{contracterror, contracttrait, Address, Env, String};
+pub mod country_allow;
+pub mod country_restrict;
+pub mod initial_lockup_period;
+pub mod max_balance;
pub mod storage;
+pub mod supply_limit;
+pub mod time_transfers_limits;
+pub mod transfer_restrict;
#[cfg(test)]
mod test;
diff --git a/packages/tokens/src/rwa/compliance/modules/supply_limit/mod.rs b/packages/tokens/src/rwa/compliance/modules/supply_limit/mod.rs
new file mode 100644
index 000000000..6082d490d
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/supply_limit/mod.rs
@@ -0,0 +1,21 @@
+//! Supply cap compliance module — Stellar port of T-REX
+//! [`SupplyLimitModule.sol`][trex-src].
+//!
+//! Caps the total number of tokens that can be minted for a given token.
+//!
+//! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/SupplyLimitModule.sol
+
+pub mod storage;
+#[cfg(test)]
+mod test;
+
+use soroban_sdk::{contractevent, Address};
+
+/// Emitted when a token's supply cap is configured or changed.
+#[contractevent]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct SupplyLimitSet {
+ #[topic]
+ pub token: Address,
+ pub limit: i128,
+}
diff --git a/packages/tokens/src/rwa/compliance/modules/supply_limit/storage.rs b/packages/tokens/src/rwa/compliance/modules/supply_limit/storage.rs
new file mode 100644
index 000000000..d337e5a94
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/supply_limit/storage.rs
@@ -0,0 +1,198 @@
+use soroban_sdk::{contracttype, panic_with_error, vec, Address, Env, Vec};
+
+use super::SupplyLimitSet;
+use crate::rwa::compliance::{
+ modules::{
+ storage::{
+ add_i128_or_panic, hooks_verified, require_non_negative_amount, sub_i128_or_panic,
+ verify_required_hooks,
+ },
+ ComplianceModuleError, MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD,
+ },
+ ComplianceHook,
+};
+
+#[contracttype]
+#[derive(Clone)]
+pub enum SupplyLimitStorageKey {
+ /// Per-token supply cap.
+ SupplyLimit(Address),
+ /// Per-token internal supply counter (updated via hooks).
+ InternalSupply(Address),
+}
+
+// ################## RAW STORAGE ##################
+
+/// Returns the supply limit for `token`, or `0` if not set.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+pub fn get_supply_limit(e: &Env, token: &Address) -> i128 {
+ let key = SupplyLimitStorageKey::SupplyLimit(token.clone());
+ e.storage()
+ .persistent()
+ .get(&key)
+ .inspect(|_: &i128| {
+ e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT);
+ })
+ .unwrap_or_default()
+}
+
+/// Returns the supply limit for `token`, panicking if not configured.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+///
+/// # Errors
+///
+/// * [`ComplianceModuleError::MissingLimit`] - When no supply limit has been
+/// configured for this token.
+pub fn get_supply_limit_or_panic(e: &Env, token: &Address) -> i128 {
+ let key = SupplyLimitStorageKey::SupplyLimit(token.clone());
+ let limit: i128 = e
+ .storage()
+ .persistent()
+ .get(&key)
+ .unwrap_or_else(|| panic_with_error!(e, ComplianceModuleError::MissingLimit));
+ e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT);
+ limit
+}
+
+/// Sets the supply limit for `token`.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `limit` - The maximum total supply.
+pub fn set_supply_limit(e: &Env, token: &Address, limit: i128) {
+ let key = SupplyLimitStorageKey::SupplyLimit(token.clone());
+ e.storage().persistent().set(&key, &limit);
+ e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT);
+}
+
+/// Returns the internal supply counter for `token`, or `0` if not set.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+pub fn get_internal_supply(e: &Env, token: &Address) -> i128 {
+ let key = SupplyLimitStorageKey::InternalSupply(token.clone());
+ e.storage()
+ .persistent()
+ .get(&key)
+ .inspect(|_: &i128| {
+ e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT);
+ })
+ .unwrap_or_default()
+}
+
+/// Sets the internal supply counter for `token`.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `supply` - The new supply value.
+pub fn set_internal_supply(e: &Env, token: &Address, supply: i128) {
+ let key = SupplyLimitStorageKey::InternalSupply(token.clone());
+ e.storage().persistent().set(&key, &supply);
+ e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT);
+}
+
+// ################## ACTIONS ##################
+
+/// Validates, stores, and emits [`SupplyLimitSet`] for the given cap.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `limit` - The supply cap.
+pub fn configure_supply_limit(e: &Env, token: &Address, limit: i128) {
+ require_non_negative_amount(e, limit);
+ set_supply_limit(e, token, limit);
+ SupplyLimitSet { token: token.clone(), limit }.publish(e);
+}
+
+/// Pre-seeds the internal supply counter for a token.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `supply` - The pre-seeded supply value.
+pub fn pre_set_supply(e: &Env, token: &Address, supply: i128) {
+ require_non_negative_amount(e, supply);
+ set_internal_supply(e, token, supply);
+}
+
+// ################## HOOK WIRING ##################
+
+/// Returns the set of compliance hooks this module requires.
+pub fn required_hooks(e: &Env) -> Vec {
+ vec![e, ComplianceHook::CanCreate, ComplianceHook::Created, ComplianceHook::Destroyed]
+}
+
+/// Cross-calls the compliance contract to verify that this module is
+/// registered on all required hooks.
+pub fn verify_hook_wiring(e: &Env) {
+ verify_required_hooks(e, required_hooks(e));
+}
+
+// ################## COMPLIANCE HOOKS ##################
+
+/// Updates the internal supply counter after a mint.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `amount` - The minted amount.
+/// * `token` - The token address.
+pub fn on_created(e: &Env, amount: i128, token: &Address) {
+ require_non_negative_amount(e, amount);
+ let current = get_internal_supply(e, token);
+ set_internal_supply(e, token, add_i128_or_panic(e, current, amount));
+}
+
+/// Updates the internal supply counter after a burn.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `amount` - The burned amount.
+/// * `token` - The token address.
+pub fn on_destroyed(e: &Env, amount: i128, token: &Address) {
+ require_non_negative_amount(e, amount);
+ let current = get_internal_supply(e, token);
+ set_internal_supply(e, token, sub_i128_or_panic(e, current, amount));
+}
+
+/// Checks whether a mint would exceed the supply cap.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `amount` - The mint amount.
+/// * `token` - The token address.
+pub fn can_create(e: &Env, amount: i128, token: &Address) -> bool {
+ assert!(
+ hooks_verified(e),
+ "SupplyLimitModule: not armed — call verify_hook_wiring() after wiring hooks [CanCreate, \
+ Created, Destroyed]"
+ );
+ if amount < 0 {
+ return false;
+ }
+ let limit = get_supply_limit(e, token);
+ if limit == 0 {
+ return true;
+ }
+ let supply = get_internal_supply(e, token);
+ add_i128_or_panic(e, supply, amount) <= limit
+}
diff --git a/packages/tokens/src/rwa/compliance/modules/supply_limit/test.rs b/packages/tokens/src/rwa/compliance/modules/supply_limit/test.rs
new file mode 100644
index 000000000..18245ba92
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/supply_limit/test.rs
@@ -0,0 +1,207 @@
+extern crate std;
+
+use soroban_sdk::{contract, contractimpl, contracttype, testutils::Address as _, Address, Env};
+
+use super::storage::{
+ can_create, configure_supply_limit, get_internal_supply, get_supply_limit_or_panic, on_created,
+ on_destroyed, pre_set_supply, verify_hook_wiring,
+};
+use crate::rwa::{
+ compliance::{
+ modules::storage::{hooks_verified, set_compliance_address, ComplianceModuleStorageKey},
+ Compliance, ComplianceHook,
+ },
+ utils::token_binder::TokenBinder,
+};
+
+#[contract]
+struct MockComplianceContract;
+
+#[contracttype]
+#[derive(Clone)]
+enum MockComplianceStorageKey {
+ Registered(ComplianceHook, Address),
+}
+
+#[contractimpl]
+impl Compliance for MockComplianceContract {
+ fn add_module_to(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) {
+ unreachable!("add_module_to is not used in these tests");
+ }
+
+ fn remove_module_from(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) {
+ unreachable!("remove_module_from is not used in these tests");
+ }
+
+ fn get_modules_for_hook(_e: &Env, _hook: ComplianceHook) -> soroban_sdk::Vec {
+ unreachable!("get_modules_for_hook is not used in these tests");
+ }
+
+ fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool {
+ e.storage().persistent().has(&MockComplianceStorageKey::Registered(hook, module))
+ }
+
+ fn transferred(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {
+ unreachable!("transferred is not used in these tests");
+ }
+
+ fn created(_e: &Env, _to: Address, _amount: i128, _token: Address) {
+ unreachable!("created is not used in these tests");
+ }
+
+ fn destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {
+ unreachable!("destroyed is not used in these tests");
+ }
+
+ fn can_transfer(
+ _e: &Env,
+ _from: Address,
+ _to: Address,
+ _amount: i128,
+ _token: Address,
+ ) -> bool {
+ unreachable!("can_transfer is not used in these tests");
+ }
+
+ fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool {
+ unreachable!("can_create is not used in these tests");
+ }
+}
+
+#[contractimpl]
+impl TokenBinder for MockComplianceContract {
+ fn linked_tokens(e: &Env) -> soroban_sdk::Vec {
+ soroban_sdk::Vec::new(e)
+ }
+
+ fn bind_token(_e: &Env, _token: Address, _operator: Address) {
+ unreachable!("bind_token is not used in these tests");
+ }
+
+ fn unbind_token(_e: &Env, _token: Address, _operator: Address) {
+ unreachable!("unbind_token is not used in these tests");
+ }
+}
+
+#[contractimpl]
+impl MockComplianceContract {
+ pub fn register_hook(e: &Env, hook: ComplianceHook, module: Address) {
+ e.storage().persistent().set(&MockComplianceStorageKey::Registered(hook, module), &true);
+ }
+}
+
+#[contract]
+struct TestSupplyLimitContract;
+
+fn arm_hooks(e: &Env) {
+ e.storage().instance().set(&ComplianceModuleStorageKey::HooksVerified, &true);
+}
+
+#[test]
+fn verify_hook_wiring_sets_cache_when_registered() {
+ let e = Env::default();
+ let module_id = e.register(TestSupplyLimitContract, ());
+ let compliance_id = e.register(MockComplianceContract, ());
+ let compliance = MockComplianceContractClient::new(&e, &compliance_id);
+
+ for hook in [ComplianceHook::CanCreate, ComplianceHook::Created, ComplianceHook::Destroyed] {
+ compliance.register_hook(&hook, &module_id);
+ }
+
+ e.as_contract(&module_id, || {
+ set_compliance_address(&e, &compliance_id);
+
+ verify_hook_wiring(&e);
+
+ assert!(hooks_verified(&e));
+ });
+}
+
+#[test]
+fn get_supply_limit_returns_zero_when_unconfigured() {
+ let e = Env::default();
+ let module_id = e.register(TestSupplyLimitContract, ());
+ let token = Address::generate(&e);
+
+ e.as_contract(&module_id, || {
+ assert_eq!(super::storage::get_supply_limit(&e, &token), 0);
+ });
+}
+
+#[test]
+fn can_create_allows_when_limit_is_unset_and_rejects_negative_amount() {
+ let e = Env::default();
+ let module_id = e.register(TestSupplyLimitContract, ());
+ let token = Address::generate(&e);
+
+ e.as_contract(&module_id, || {
+ arm_hooks(&e);
+
+ assert!(can_create(&e, 100, &token));
+ assert!(!can_create(&e, -1, &token));
+ });
+}
+
+#[test]
+fn hooks_update_internal_supply_and_cap_future_mints() {
+ let e = Env::default();
+ let module_id = e.register(TestSupplyLimitContract, ());
+ let token = Address::generate(&e);
+
+ e.as_contract(&module_id, || {
+ arm_hooks(&e);
+ configure_supply_limit(&e, &token, 100);
+
+ assert!(can_create(&e, 80, &token));
+ on_created(&e, 80, &token);
+ assert_eq!(get_internal_supply(&e, &token), 80);
+
+ assert!(!can_create(&e, 30, &token));
+
+ on_destroyed(&e, 20, &token);
+ assert_eq!(get_internal_supply(&e, &token), 60);
+ assert!(can_create(&e, 40, &token));
+ });
+}
+
+#[test]
+fn pre_set_internal_supply_seeds_existing_supply_for_cap_checks() {
+ let e = Env::default();
+ let module_id = e.register(TestSupplyLimitContract, ());
+ let token = Address::generate(&e);
+
+ e.as_contract(&module_id, || {
+ arm_hooks(&e);
+ configure_supply_limit(&e, &token, 100);
+ pre_set_supply(&e, &token, 90);
+
+ assert_eq!(get_internal_supply(&e, &token), 90);
+ assert!(!can_create(&e, 11, &token));
+ assert!(can_create(&e, 10, &token));
+ });
+}
+
+#[test]
+fn get_supply_limit_or_panic_returns_configured_limit() {
+ let e = Env::default();
+ let module_id = e.register(TestSupplyLimitContract, ());
+ let token = Address::generate(&e);
+
+ e.as_contract(&module_id, || {
+ configure_supply_limit(&e, &token, 100);
+
+ assert_eq!(get_supply_limit_or_panic(&e, &token), 100);
+ });
+}
+
+#[test]
+#[should_panic]
+fn get_supply_limit_or_panic_panics_when_unconfigured() {
+ let e = Env::default();
+ let module_id = e.register(TestSupplyLimitContract, ());
+ let token = Address::generate(&e);
+
+ e.as_contract(&module_id, || {
+ let _ = get_supply_limit_or_panic(&e, &token);
+ });
+}
diff --git a/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/mod.rs b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/mod.rs
new file mode 100644
index 000000000..49215ef2d
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/mod.rs
@@ -0,0 +1,34 @@
+//! Time-windowed transfer-limits compliance module — Stellar port of T-REX
+//! [`TimeTransfersLimitsModule.sol`][trex-src].
+//!
+//! Limits transfer volume within configurable time windows, tracking counters
+//! per **identity** (not per wallet).
+//!
+//! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/TimeTransfersLimitsModule.sol
+
+pub mod storage;
+#[cfg(test)]
+mod test;
+
+use soroban_sdk::{contractevent, Address};
+pub use storage::{Limit, TransferCounter};
+
+pub const MAX_LIMITS_PER_TOKEN: u32 = 4;
+
+/// Emitted when a time-window limit is added or updated.
+#[contractevent]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct TimeTransferLimitUpdated {
+ #[topic]
+ pub token: Address,
+ pub limit: Limit,
+}
+
+/// Emitted when a time-window limit is removed.
+#[contractevent]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct TimeTransferLimitRemoved {
+ #[topic]
+ pub token: Address,
+ pub limit_time: u64,
+}
diff --git a/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/storage.rs b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/storage.rs
new file mode 100644
index 000000000..b46cde1a6
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/storage.rs
@@ -0,0 +1,350 @@
+use soroban_sdk::{contracttype, panic_with_error, vec, Address, Env, Vec};
+
+use super::{TimeTransferLimitRemoved, TimeTransferLimitUpdated, MAX_LIMITS_PER_TOKEN};
+use crate::rwa::compliance::{
+ modules::{
+ storage::{
+ add_i128_or_panic, get_irs_client, hooks_verified, require_non_negative_amount,
+ set_irs_address, verify_required_hooks,
+ },
+ ComplianceModuleError, MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD,
+ },
+ ComplianceHook,
+};
+
+/// A single time-window limit: `limit_value` tokens may be transferred
+/// within a rolling window of `limit_time` seconds.
+#[contracttype]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Limit {
+ pub limit_time: u64,
+ pub limit_value: i128,
+}
+
+/// Tracks cumulative transfer volume for one identity within one window.
+#[contracttype]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct TransferCounter {
+ pub value: i128,
+ pub timer: u64,
+}
+
+#[contracttype]
+#[derive(Clone)]
+pub enum TimeTransfersLimitsStorageKey {
+ /// Per-token list of configured time-window limits.
+ Limits(Address),
+ /// Counter keyed by (token, identity, window_seconds).
+ Counter(Address, Address, u64),
+}
+
+// ################## RAW STORAGE ##################
+
+/// Returns the list of time-window limits for `token`.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+pub fn get_limits(e: &Env, token: &Address) -> Vec {
+ let key = TimeTransfersLimitsStorageKey::Limits(token.clone());
+ e.storage()
+ .persistent()
+ .get(&key)
+ .inspect(|_: &Vec| {
+ e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT);
+ })
+ .unwrap_or_else(|| Vec::new(e))
+}
+
+/// Persists the list of time-window limits for `token`.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `limits` - The updated limits list.
+pub fn set_limits(e: &Env, token: &Address, limits: &Vec) {
+ let key = TimeTransfersLimitsStorageKey::Limits(token.clone());
+ e.storage().persistent().set(&key, limits);
+ e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT);
+}
+
+/// Returns the transfer counter for a given identity and time window.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `identity` - The on-chain identity address.
+/// * `limit_time` - The time-window duration in seconds.
+pub fn get_counter(
+ e: &Env,
+ token: &Address,
+ identity: &Address,
+ limit_time: u64,
+) -> TransferCounter {
+ let key = TimeTransfersLimitsStorageKey::Counter(token.clone(), identity.clone(), limit_time);
+ e.storage()
+ .persistent()
+ .get(&key)
+ .inspect(|_: &TransferCounter| {
+ e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT);
+ })
+ .unwrap_or(TransferCounter { value: 0, timer: 0 })
+}
+
+/// Persists the transfer counter for a given identity and time window.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `identity` - The on-chain identity address.
+/// * `limit_time` - The time-window duration in seconds.
+/// * `counter` - The updated counter value.
+pub fn set_counter(
+ e: &Env,
+ token: &Address,
+ identity: &Address,
+ limit_time: u64,
+ counter: &TransferCounter,
+) {
+ let key = TimeTransfersLimitsStorageKey::Counter(token.clone(), identity.clone(), limit_time);
+ e.storage().persistent().set(&key, counter);
+ e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT);
+}
+
+// ################## HELPERS ##################
+
+fn is_counter_finished(e: &Env, token: &Address, identity: &Address, limit_time: u64) -> bool {
+ let counter = get_counter(e, token, identity, limit_time);
+ counter.timer <= e.ledger().timestamp()
+}
+
+fn reset_counter_if_needed(e: &Env, token: &Address, identity: &Address, limit_time: u64) {
+ if is_counter_finished(e, token, identity, limit_time) {
+ let counter =
+ TransferCounter { value: 0, timer: e.ledger().timestamp().saturating_add(limit_time) };
+ set_counter(e, token, identity, limit_time, &counter);
+ }
+}
+
+fn increase_counters(e: &Env, token: &Address, identity: &Address, value: i128) {
+ let limits = get_limits(e, token);
+ for limit in limits.iter() {
+ reset_counter_if_needed(e, token, identity, limit.limit_time);
+ let mut counter = get_counter(e, token, identity, limit.limit_time);
+ counter.value = add_i128_or_panic(e, counter.value, value);
+ set_counter(e, token, identity, limit.limit_time, &counter);
+ }
+}
+
+// ################## ACTIONS ##################
+
+/// Configures the identity registry storage address for a token.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `irs` - The identity registry storage address.
+pub fn configure_irs(e: &Env, token: &Address, irs: &Address) {
+ set_irs_address(e, token, irs);
+}
+
+/// Sets or updates a time-window transfer limit for `token` and emits
+/// [`TimeTransferLimitUpdated`].
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `limit` - The limit to set.
+pub fn set_time_transfer_limit(e: &Env, token: &Address, limit: &Limit) {
+ assert!(limit.limit_time > 0, "limit_time must be greater than zero");
+ require_non_negative_amount(e, limit.limit_value);
+ let mut limits = get_limits(e, token);
+
+ let mut replaced = false;
+ for i in 0..limits.len() {
+ let current = limits.get(i).expect("limit exists");
+ if current.limit_time == limit.limit_time {
+ limits.set(i, limit.clone());
+ replaced = true;
+ break;
+ }
+ }
+
+ if !replaced {
+ if limits.len() >= MAX_LIMITS_PER_TOKEN {
+ panic_with_error!(e, ComplianceModuleError::TooManyLimits);
+ }
+ limits.push_back(limit.clone());
+ }
+
+ set_limits(e, token, &limits);
+ TimeTransferLimitUpdated { token: token.clone(), limit: limit.clone() }.publish(e);
+}
+
+/// Sets or updates multiple time-window transfer limits in a single call.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `limits` - The limits to set.
+pub fn batch_set_time_transfer_limit(e: &Env, token: &Address, limits: &Vec) {
+ for limit in limits.iter() {
+ set_time_transfer_limit(e, token, &limit);
+ }
+}
+
+/// Removes a time-window transfer limit and emits
+/// [`TimeTransferLimitRemoved`].
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `limit_time` - The time-window to remove.
+pub fn remove_time_transfer_limit(e: &Env, token: &Address, limit_time: u64) {
+ let mut limits = get_limits(e, token);
+
+ let mut found = false;
+ for i in 0..limits.len() {
+ let current = limits.get(i).expect("limit exists");
+ if current.limit_time == limit_time {
+ limits.remove(i);
+ found = true;
+ break;
+ }
+ }
+
+ if !found {
+ panic_with_error!(e, ComplianceModuleError::MissingLimit);
+ }
+
+ set_limits(e, token, &limits);
+ TimeTransferLimitRemoved { token: token.clone(), limit_time }.publish(e);
+}
+
+/// Removes multiple time-window transfer limits in a single call.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `limit_times` - The time-windows to remove.
+pub fn batch_remove_time_transfer_limit(e: &Env, token: &Address, limit_times: &Vec) {
+ for lt in limit_times.iter() {
+ remove_time_transfer_limit(e, token, lt);
+ }
+}
+
+/// Pre-seeds a transfer counter for a given identity and time window.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `identity` - The on-chain identity address.
+/// * `limit_time` - The time-window duration in seconds.
+/// * `counter` - The counter value to set.
+pub fn pre_set_transfer_counter(
+ e: &Env,
+ token: &Address,
+ identity: &Address,
+ limit_time: u64,
+ counter: &TransferCounter,
+) {
+ require_non_negative_amount(e, counter.value);
+ assert!(limit_time > 0, "limit_time must be greater than zero");
+
+ let mut found = false;
+ for limit in get_limits(e, token).iter() {
+ if limit.limit_time == limit_time {
+ found = true;
+ break;
+ }
+ }
+
+ if !found {
+ panic_with_error!(e, ComplianceModuleError::MissingLimit);
+ }
+
+ set_counter(e, token, identity, limit_time, counter);
+}
+
+// ################## HOOK WIRING ##################
+
+/// Returns the set of compliance hooks this module requires.
+pub fn required_hooks(e: &Env) -> Vec {
+ vec![e, ComplianceHook::CanTransfer, ComplianceHook::Transferred]
+}
+
+/// Cross-calls the compliance contract to verify that this module is
+/// registered on all required hooks.
+pub fn verify_hook_wiring(e: &Env) {
+ verify_required_hooks(e, required_hooks(e));
+}
+
+// ################## COMPLIANCE HOOKS ##################
+
+/// Resolves the sender's identity and increments transfer counters.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `from` - The sender address.
+/// * `amount` - The transfer amount.
+/// * `token` - The token address.
+pub fn on_transfer(e: &Env, from: &Address, amount: i128, token: &Address) {
+ require_non_negative_amount(e, amount);
+ let irs = get_irs_client(e, token);
+ let from_id = irs.stored_identity(from);
+ increase_counters(e, token, &from_id, amount);
+}
+
+/// Returns `true` if the transfer does not exceed any configured
+/// time-window limit.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `from` - The sender address.
+/// * `amount` - The transfer amount.
+/// * `token` - The token address.
+///
+/// # Errors
+///
+/// * [`crate::rwa::compliance::modules::ComplianceModuleError::IdentityRegistryNotSet`]
+/// - When no IRS has been configured for `token`.
+pub fn can_transfer(e: &Env, from: &Address, amount: i128, token: &Address) -> bool {
+ assert!(
+ hooks_verified(e),
+ "TimeTransfersLimitsModule: not armed — call verify_hook_wiring() after wiring hooks \
+ [CanTransfer, Transferred]"
+ );
+ if amount < 0 {
+ return false;
+ }
+ let irs = get_irs_client(e, token);
+ let from_id = irs.stored_identity(from);
+ let limits = get_limits(e, token);
+
+ for limit in limits.iter() {
+ if amount > limit.limit_value {
+ return false;
+ }
+
+ if !is_counter_finished(e, token, &from_id, limit.limit_time) {
+ let counter = get_counter(e, token, &from_id, limit.limit_time);
+ if add_i128_or_panic(e, counter.value, amount) > limit.limit_value {
+ return false;
+ }
+ }
+ }
+
+ true
+}
diff --git a/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/test.rs b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/test.rs
new file mode 100644
index 000000000..721336728
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/test.rs
@@ -0,0 +1,274 @@
+extern crate std;
+
+use soroban_sdk::{
+ contract, contractimpl, contracttype, testutils::Address as _, Address, Env, Val, Vec,
+};
+
+use super::storage::{
+ can_transfer, pre_set_transfer_counter, set_time_transfer_limit, verify_hook_wiring, Limit,
+ TransferCounter,
+};
+use crate::rwa::{
+ compliance::{
+ modules::storage::{
+ hooks_verified, set_compliance_address, set_irs_address, ComplianceModuleStorageKey,
+ },
+ Compliance, ComplianceHook,
+ },
+ identity_registry_storage::{CountryDataManager, IdentityRegistryStorage},
+ utils::token_binder::TokenBinder,
+};
+
+#[contract]
+struct MockIRSContract;
+
+#[contracttype]
+#[derive(Clone)]
+enum MockIRSStorageKey {
+ Identity(Address),
+}
+
+#[contractimpl]
+impl TokenBinder for MockIRSContract {
+ fn linked_tokens(e: &Env) -> Vec {
+ Vec::new(e)
+ }
+
+ fn bind_token(_e: &Env, _token: Address, _operator: Address) {
+ unreachable!("bind_token is not used in these tests");
+ }
+
+ fn unbind_token(_e: &Env, _token: Address, _operator: Address) {
+ unreachable!("unbind_token is not used in these tests");
+ }
+}
+
+#[contractimpl]
+impl IdentityRegistryStorage for MockIRSContract {
+ fn add_identity(
+ _e: &Env,
+ _account: Address,
+ _identity: Address,
+ _country_data_list: Vec,
+ _operator: Address,
+ ) {
+ unreachable!("add_identity is not used in these tests");
+ }
+
+ fn remove_identity(_e: &Env, _account: Address, _operator: Address) {
+ unreachable!("remove_identity is not used in these tests");
+ }
+
+ fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) {
+ unreachable!("modify_identity is not used in these tests");
+ }
+
+ fn recover_identity(
+ _e: &Env,
+ _old_account: Address,
+ _new_account: Address,
+ _operator: Address,
+ ) {
+ unreachable!("recover_identity is not used in these tests");
+ }
+
+ fn stored_identity(e: &Env, account: Address) -> Address {
+ e.storage()
+ .persistent()
+ .get(&MockIRSStorageKey::Identity(account.clone()))
+ .unwrap_or(account)
+ }
+}
+
+#[contractimpl]
+impl CountryDataManager for MockIRSContract {
+ fn add_country_data_entries(
+ _e: &Env,
+ _account: Address,
+ _country_data_list: Vec,
+ _operator: Address,
+ ) {
+ unreachable!("add_country_data_entries is not used in these tests");
+ }
+
+ fn modify_country_data(
+ _e: &Env,
+ _account: Address,
+ _index: u32,
+ _country_data: Val,
+ _operator: Address,
+ ) {
+ unreachable!("modify_country_data is not used in these tests");
+ }
+
+ fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) {
+ unreachable!("delete_country_data is not used in these tests");
+ }
+
+ fn get_country_data_entries(e: &Env, _account: Address) -> Vec {
+ Vec::new(e)
+ }
+}
+
+#[contractimpl]
+impl MockIRSContract {
+ pub fn set_identity(e: &Env, account: Address, identity: Address) {
+ e.storage().persistent().set(&MockIRSStorageKey::Identity(account), &identity);
+ }
+}
+
+#[contract]
+struct MockComplianceContract;
+
+#[contracttype]
+#[derive(Clone)]
+enum MockComplianceStorageKey {
+ Registered(ComplianceHook, Address),
+}
+
+#[contractimpl]
+impl Compliance for MockComplianceContract {
+ fn add_module_to(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) {
+ unreachable!("add_module_to is not used in these tests");
+ }
+
+ fn remove_module_from(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) {
+ unreachable!("remove_module_from is not used in these tests");
+ }
+
+ fn get_modules_for_hook(_e: &Env, _hook: ComplianceHook) -> Vec {
+ unreachable!("get_modules_for_hook is not used in these tests");
+ }
+
+ fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool {
+ e.storage().persistent().has(&MockComplianceStorageKey::Registered(hook, module))
+ }
+
+ fn transferred(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {
+ unreachable!("transferred is not used in these tests");
+ }
+
+ fn created(_e: &Env, _to: Address, _amount: i128, _token: Address) {
+ unreachable!("created is not used in these tests");
+ }
+
+ fn destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {
+ unreachable!("destroyed is not used in these tests");
+ }
+
+ fn can_transfer(
+ _e: &Env,
+ _from: Address,
+ _to: Address,
+ _amount: i128,
+ _token: Address,
+ ) -> bool {
+ unreachable!("can_transfer is not used in these tests");
+ }
+
+ fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool {
+ unreachable!("can_create is not used in these tests");
+ }
+}
+
+#[contractimpl]
+impl TokenBinder for MockComplianceContract {
+ fn linked_tokens(e: &Env) -> Vec {
+ Vec::new(e)
+ }
+
+ fn bind_token(_e: &Env, _token: Address, _operator: Address) {
+ unreachable!("bind_token is not used in these tests");
+ }
+
+ fn unbind_token(_e: &Env, _token: Address, _operator: Address) {
+ unreachable!("unbind_token is not used in these tests");
+ }
+}
+
+#[contractimpl]
+impl MockComplianceContract {
+ pub fn register_hook(e: &Env, hook: ComplianceHook, module: Address) {
+ e.storage().persistent().set(&MockComplianceStorageKey::Registered(hook, module), &true);
+ }
+}
+
+#[contract]
+struct TestModuleContract;
+
+fn arm_hooks(e: &Env) {
+ e.storage().instance().set(&ComplianceModuleStorageKey::HooksVerified, &true);
+}
+
+#[test]
+fn verify_hook_wiring_sets_cache_when_registered() {
+ let e = Env::default();
+ let module_id = e.register(TestModuleContract, ());
+ let compliance_id = e.register(MockComplianceContract, ());
+ let compliance = MockComplianceContractClient::new(&e, &compliance_id);
+
+ for hook in [ComplianceHook::CanTransfer, ComplianceHook::Transferred] {
+ compliance.register_hook(&hook, &module_id);
+ }
+
+ e.as_contract(&module_id, || {
+ set_compliance_address(&e, &compliance_id);
+
+ verify_hook_wiring(&e);
+
+ assert!(hooks_verified(&e));
+ });
+}
+
+#[test]
+fn pre_set_transfer_counter_blocks_transfers_within_active_window() {
+ let e = Env::default();
+
+ let module_id = e.register(TestModuleContract, ());
+ let irs_id = e.register(MockIRSContract, ());
+ let irs = MockIRSContractClient::new(&e, &irs_id);
+ let compliance = Address::generate(&e);
+ let token = Address::generate(&e);
+ let sender = Address::generate(&e);
+ let sender_identity = Address::generate(&e);
+
+ irs.set_identity(&sender, &sender_identity);
+
+ e.as_contract(&module_id, || {
+ set_compliance_address(&e, &compliance);
+ set_irs_address(&e, &token, &irs_id);
+ arm_hooks(&e);
+
+ set_time_transfer_limit(&e, &token, &Limit { limit_time: 60, limit_value: 100 });
+ pre_set_transfer_counter(
+ &e,
+ &token,
+ &sender_identity,
+ 60,
+ &TransferCounter { value: 90, timer: e.ledger().timestamp().saturating_add(60) },
+ );
+
+ assert!(!can_transfer(&e, &sender, 11, &token));
+ assert!(can_transfer(&e, &sender, 10, &token));
+ });
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #400)")]
+fn set_time_transfer_limit_rejects_more_than_four_limits() {
+ let e = Env::default();
+
+ let module_id = e.register(TestModuleContract, ());
+ let compliance = Address::generate(&e);
+ let token = Address::generate(&e);
+
+ e.as_contract(&module_id, || {
+ set_compliance_address(&e, &compliance);
+
+ for limit_time in [60_u64, 120, 180, 240] {
+ set_time_transfer_limit(&e, &token, &Limit { limit_time, limit_value: 100 });
+ }
+
+ set_time_transfer_limit(&e, &token, &Limit { limit_time: 300, limit_value: 100 });
+ });
+}
diff --git a/packages/tokens/src/rwa/compliance/modules/transfer_restrict/mod.rs b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/mod.rs
new file mode 100644
index 000000000..bf87e6117
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/mod.rs
@@ -0,0 +1,31 @@
+//! Transfer restriction (address allowlist) compliance module — Stellar port
+//! of T-REX [`TransferRestrictModule.sol`][trex-src].
+//!
+//! Maintains a per-token address allowlist. Transfers pass if the sender is
+//! on the list; otherwise the recipient must be.
+//!
+//! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/TransferRestrictModule.sol
+
+pub mod storage;
+#[cfg(test)]
+mod test;
+
+use soroban_sdk::{contractevent, Address};
+
+/// Emitted when an address is added to the transfer allowlist.
+#[contractevent]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct UserAllowed {
+ #[topic]
+ pub token: Address,
+ pub user: Address,
+}
+
+/// Emitted when an address is removed from the transfer allowlist.
+#[contractevent]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct UserDisallowed {
+ #[topic]
+ pub token: Address,
+ pub user: Address,
+}
diff --git a/packages/tokens/src/rwa/compliance/modules/transfer_restrict/storage.rs b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/storage.rs
new file mode 100644
index 000000000..77337ae0c
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/storage.rs
@@ -0,0 +1,135 @@
+use soroban_sdk::{contracttype, Address, Env, Vec};
+
+use super::{UserAllowed, UserDisallowed};
+use crate::rwa::compliance::modules::{MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD};
+
+#[contracttype]
+#[derive(Clone)]
+pub enum TransferRestrictStorageKey {
+ /// Per-(token, address) allowlist flag.
+ AllowedUser(Address, Address),
+}
+
+// ################## RAW STORAGE ##################
+
+/// Returns whether `user` is on the transfer allowlist for `token`.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `user` - The user address to check.
+pub fn is_user_allowed(e: &Env, token: &Address, user: &Address) -> bool {
+ let key = TransferRestrictStorageKey::AllowedUser(token.clone(), user.clone());
+ e.storage()
+ .persistent()
+ .get(&key)
+ .inspect(|_: &bool| {
+ e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT);
+ })
+ .unwrap_or_default()
+}
+
+/// Adds `user` to the transfer allowlist for `token`.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `user` - The user address to allow.
+pub fn set_user_allowed(e: &Env, token: &Address, user: &Address) {
+ let key = TransferRestrictStorageKey::AllowedUser(token.clone(), user.clone());
+ e.storage().persistent().set(&key, &true);
+ e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT);
+}
+
+/// Removes `user` from the transfer allowlist for `token`.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `user` - The user address to disallow.
+pub fn remove_user_allowed(e: &Env, token: &Address, user: &Address) {
+ e.storage()
+ .persistent()
+ .remove(&TransferRestrictStorageKey::AllowedUser(token.clone(), user.clone()));
+}
+
+// ################## ACTIONS ##################
+
+/// Adds `user` to the transfer allowlist for `token` and emits
+/// [`UserAllowed`].
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `user` - The address to allow.
+pub fn allow_user(e: &Env, token: &Address, user: &Address) {
+ set_user_allowed(e, token, user);
+ UserAllowed { token: token.clone(), user: user.clone() }.publish(e);
+}
+
+/// Removes `user` from the transfer allowlist for `token` and emits
+/// [`UserDisallowed`].
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `user` - The address to disallow.
+pub fn disallow_user(e: &Env, token: &Address, user: &Address) {
+ remove_user_allowed(e, token, user);
+ UserDisallowed { token: token.clone(), user: user.clone() }.publish(e);
+}
+
+/// Adds multiple users to the transfer allowlist in a single call.
+/// Emits [`UserAllowed`] for each user added.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `users` - The addresses to allow.
+pub fn batch_allow_users(e: &Env, token: &Address, users: &Vec) {
+ for user in users.iter() {
+ set_user_allowed(e, token, &user);
+ UserAllowed { token: token.clone(), user }.publish(e);
+ }
+}
+
+/// Removes multiple users from the transfer allowlist in a single call.
+/// Emits [`UserDisallowed`] for each user removed.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `users` - The addresses to disallow.
+pub fn batch_disallow_users(e: &Env, token: &Address, users: &Vec) {
+ for user in users.iter() {
+ remove_user_allowed(e, token, &user);
+ UserDisallowed { token: token.clone(), user }.publish(e);
+ }
+}
+
+// ################## COMPLIANCE HOOKS ##################
+
+/// Returns `true` if the sender or recipient is allowlisted.
+///
+/// T-REX semantics: if the sender is allowlisted, the transfer passes;
+/// otherwise the recipient must be allowlisted.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `from` - The sender address.
+/// * `to` - The recipient address.
+/// * `token` - The token address.
+pub fn can_transfer(e: &Env, from: &Address, to: &Address, token: &Address) -> bool {
+ if is_user_allowed(e, token, from) {
+ return true;
+ }
+ is_user_allowed(e, token, to)
+}
diff --git a/packages/tokens/src/rwa/compliance/modules/transfer_restrict/test.rs b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/test.rs
new file mode 100644
index 000000000..d181be4e4
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/test.rs
@@ -0,0 +1,62 @@
+extern crate std;
+
+use soroban_sdk::{contract, testutils::Address as _, vec, Address, Env};
+
+use super::storage::{
+ allow_user, batch_allow_users, batch_disallow_users, can_transfer, disallow_user,
+ is_user_allowed,
+};
+use crate::rwa::compliance::modules::storage::set_compliance_address;
+
+#[contract]
+struct TestModuleContract;
+
+#[test]
+fn can_transfer_allows_sender_or_recipient_when_allowlisted() {
+ let e = Env::default();
+
+ let module_id = e.register(TestModuleContract, ());
+ let compliance = Address::generate(&e);
+ let token = Address::generate(&e);
+ let sender = Address::generate(&e);
+ let recipient = Address::generate(&e);
+ let outsider = Address::generate(&e);
+
+ e.as_contract(&module_id, || {
+ set_compliance_address(&e, &compliance);
+
+ assert!(!can_transfer(&e, &sender, &recipient, &token));
+
+ allow_user(&e, &token, &sender);
+ assert!(can_transfer(&e, &sender, &outsider, &token));
+
+ disallow_user(&e, &token, &sender);
+ allow_user(&e, &token, &recipient);
+ assert!(can_transfer(&e, &outsider, &recipient, &token));
+ });
+}
+
+#[test]
+fn batch_allow_and_disallow_update_allowlist_entries() {
+ let e = Env::default();
+
+ let module_id = e.register(TestModuleContract, ());
+ let compliance = Address::generate(&e);
+ let token = Address::generate(&e);
+ let user_a = Address::generate(&e);
+ let user_b = Address::generate(&e);
+
+ e.as_contract(&module_id, || {
+ set_compliance_address(&e, &compliance);
+
+ batch_allow_users(&e, &token, &vec![&e, user_a.clone(), user_b.clone()]);
+
+ assert!(is_user_allowed(&e, &token, &user_a));
+ assert!(is_user_allowed(&e, &token, &user_b));
+
+ batch_disallow_users(&e, &token, &vec![&e, user_a.clone(), user_b.clone()]);
+
+ assert!(!is_user_allowed(&e, &token, &user_a));
+ assert!(!is_user_allowed(&e, &token, &user_b));
+ });
+}