diff --git a/Cargo.lock b/Cargo.lock
index 469b78e3b..f4f14a96e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1550,12 +1550,36 @@ dependencies = [
name = "rwa-compliance-example"
version = "0.6.0"
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.6.0"
+dependencies = [
+ "soroban-sdk",
+ "stellar-tokens",
+]
+
+[[package]]
+name = "rwa-country-restrict"
+version = "0.6.0"
+dependencies = [
+ "soroban-sdk",
+ "stellar-tokens",
+]
+
[[package]]
name = "rwa-identity-example"
version = "0.6.0"
@@ -1586,6 +1610,38 @@ dependencies = [
"stellar-tokens",
]
+[[package]]
+name = "rwa-initial-lockup-period"
+version = "0.6.0"
+dependencies = [
+ "soroban-sdk",
+ "stellar-tokens",
+]
+
+[[package]]
+name = "rwa-max-balance"
+version = "0.6.0"
+dependencies = [
+ "soroban-sdk",
+ "stellar-tokens",
+]
+
+[[package]]
+name = "rwa-supply-limit"
+version = "0.6.0"
+dependencies = [
+ "soroban-sdk",
+ "stellar-tokens",
+]
+
+[[package]]
+name = "rwa-time-transfers-limits"
+version = "0.6.0"
+dependencies = [
+ "soroban-sdk",
+ "stellar-tokens",
+]
+
[[package]]
name = "rwa-token-example"
version = "0.6.0"
@@ -1597,6 +1653,14 @@ dependencies = [
"stellar-tokens",
]
+[[package]]
+name = "rwa-transfer-restrict"
+version = "0.6.0"
+dependencies = [
+ "soroban-sdk",
+ "stellar-tokens",
+]
+
[[package]]
name = "ryu"
version = "1.0.23"
diff --git a/Cargo.toml b/Cargo.toml
index 1fd7fbbfc..c33cdaa51 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -19,7 +19,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..2773300fe
--- /dev/null
+++ b/examples/rwa-compliance/src/test.rs
@@ -0,0 +1,1038 @@
+extern crate std;
+
+use rwa_country_allow::{CountryAllowContract, CountryAllowContractClient};
+use rwa_country_restrict::{CountryRestrictContract, CountryRestrictContractClient};
+use rwa_initial_lockup_period::{InitialLockupPeriodContract, InitialLockupPeriodContractClient};
+use rwa_max_balance::{MaxBalanceContract, MaxBalanceContractClient};
+use rwa_supply_limit::{SupplyLimitContract, SupplyLimitContractClient};
+use rwa_time_transfers_limits::{TimeTransfersLimitsContract, TimeTransfersLimitsContractClient};
+use rwa_transfer_restrict::{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..a60408eab
--- /dev/null
+++ b/examples/rwa-country-allow/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "rwa-country-allow"
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+publish = false
+version.workspace = true
+
+[lib]
+crate-type = ["cdylib", "rlib"]
+doctest = false
+
+[dependencies]
+soroban-sdk = { workspace = true }
+stellar-tokens = { workspace = true }
diff --git a/examples/rwa-country-allow/README.md b/examples/rwa-country-allow/README.md
new file mode 100644
index 000000000..f0b88aa54
--- /dev/null
+++ b/examples/rwa-country-allow/README.md
@@ -0,0 +1,50 @@
+# Country Allow Module
+
+Concrete deployable example of the `CountryAllow` compliance module for Stellar
+RWA tokens.
+
+## What it enforces
+
+This module allows tokens to be minted or transferred only to recipients whose
+registered identity has at least one country code that appears in the module's
+per-token allowlist.
+
+The country lookup is performed through the Identity Registry Storage (IRS), so
+the module must be configured with an IRS contract for each token it serves.
+
+## Authorization model
+
+This example uses the bootstrap-admin pattern introduced in this port:
+
+- The constructor stores a one-time `admin`
+- Before `set_compliance_address`, privileged configuration calls require that
+ admin's auth
+- After `set_compliance_address`, the same configuration calls require auth
+ from the bound Compliance contract
+- `set_compliance_address` itself remains a one-time admin action
+
+This lets the module be configured from the CLI before it is locked to the
+Compliance contract.
+
+## Main entrypoints
+
+- `__constructor(admin)` initializes the bootstrap admin
+- `set_identity_registry_storage(token, irs)` stores the IRS address for a
+ token
+- `add_allowed_country(token, country)` adds an ISO 3166-1 numeric code to the
+ allowlist
+- `remove_allowed_country(token, country)` removes a country code
+- `batch_allow_countries(token, countries)` updates multiple entries
+- `batch_disallow_countries(token, countries)` removes multiple entries
+- `is_country_allowed(token, country)` reads the current allowlist state
+- `set_compliance_address(compliance)` performs the one-time handoff to the
+ Compliance contract
+
+## Notes
+
+- Storage is token-scoped, so one deployed module can be reused across many
+ tokens
+- This module validates on the compliance read hooks used for transfers and
+ mints; it does not require extra state-tracking hooks
+- In the deploy example, the module is configured before binding and then wired
+ to the `CanTransfer` and `CanCreate` hooks
diff --git a/examples/rwa-country-allow/src/lib.rs b/examples/rwa-country-allow/src/lib.rs
new file mode 100644
index 000000000..f2fd11b23
--- /dev/null
+++ b/examples/rwa-country-allow/src/lib.rs
@@ -0,0 +1,88 @@
+#![no_std]
+
+use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec};
+use stellar_tokens::rwa::compliance::modules::{
+ country_allow::{
+ storage::{is_country_allowed, remove_country_allowed, set_country_allowed},
+ CountryAllow, CountryAllowed, CountryUnallowed,
+ },
+ storage::{set_compliance_address, set_irs_address, ComplianceModuleStorageKey},
+};
+
+#[contracttype]
+enum DataKey {
+ Admin,
+}
+
+#[contract]
+pub struct CountryAllowContract;
+
+fn set_admin(e: &Env, admin: &Address) {
+ e.storage().instance().set(&DataKey::Admin, admin);
+}
+
+fn get_admin(e: &Env) -> Address {
+ e.storage().instance().get(&DataKey::Admin).expect("admin must be set")
+}
+
+fn require_module_admin_or_compliance_auth(e: &Env) {
+ if let Some(compliance) =
+ e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance)
+ {
+ compliance.require_auth();
+ } else {
+ get_admin(e).require_auth();
+ }
+}
+
+#[contractimpl]
+impl CountryAllowContract {
+ pub fn __constructor(e: &Env, admin: Address) {
+ set_admin(e, &admin);
+ }
+}
+
+#[contractimpl(contracttrait)]
+impl CountryAllow for CountryAllowContract {
+ fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) {
+ require_module_admin_or_compliance_auth(e);
+ set_irs_address(e, &token, &irs);
+ }
+
+ fn add_allowed_country(e: &Env, token: Address, country: u32) {
+ require_module_admin_or_compliance_auth(e);
+ set_country_allowed(e, &token, country);
+ CountryAllowed { token, country }.publish(e);
+ }
+
+ fn remove_allowed_country(e: &Env, token: Address, country: u32) {
+ require_module_admin_or_compliance_auth(e);
+ remove_country_allowed(e, &token, country);
+ CountryUnallowed { token, country }.publish(e);
+ }
+
+ fn batch_allow_countries(e: &Env, token: Address, countries: Vec) {
+ require_module_admin_or_compliance_auth(e);
+ for country in countries.iter() {
+ set_country_allowed(e, &token, country);
+ CountryAllowed { token: token.clone(), country }.publish(e);
+ }
+ }
+
+ fn batch_disallow_countries(e: &Env, token: Address, countries: Vec) {
+ require_module_admin_or_compliance_auth(e);
+ for country in countries.iter() {
+ remove_country_allowed(e, &token, country);
+ CountryUnallowed { token: token.clone(), country }.publish(e);
+ }
+ }
+
+ fn is_country_allowed(e: &Env, token: Address, country: u32) -> bool {
+ is_country_allowed(e, &token, country)
+ }
+
+ fn set_compliance_address(e: &Env, compliance: Address) {
+ get_admin(e).require_auth();
+ set_compliance_address(e, &compliance);
+ }
+}
diff --git a/examples/rwa-country-restrict/Cargo.toml b/examples/rwa-country-restrict/Cargo.toml
new file mode 100644
index 000000000..27aabc3bc
--- /dev/null
+++ b/examples/rwa-country-restrict/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "rwa-country-restrict"
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+publish = false
+version.workspace = true
+
+[lib]
+crate-type = ["cdylib", "rlib"]
+doctest = false
+
+[dependencies]
+soroban-sdk = { workspace = true }
+stellar-tokens = { workspace = true }
diff --git a/examples/rwa-country-restrict/README.md b/examples/rwa-country-restrict/README.md
new file mode 100644
index 000000000..104bf6066
--- /dev/null
+++ b/examples/rwa-country-restrict/README.md
@@ -0,0 +1,50 @@
+# Country Restrict Module
+
+Concrete deployable example of the `CountryRestrict` compliance module for
+Stellar RWA tokens.
+
+## What it enforces
+
+This module blocks tokens from being minted or transferred to recipients whose
+registered identity has a country code that appears in the module's per-token
+restriction list.
+
+The country lookup is performed through the Identity Registry Storage (IRS), so
+the module must be configured with an IRS contract for each token it serves.
+
+## Authorization model
+
+This example uses the bootstrap-admin pattern introduced in this port:
+
+- The constructor stores a one-time `admin`
+- Before `set_compliance_address`, privileged configuration calls require that
+ admin's auth
+- After `set_compliance_address`, the same configuration calls require auth
+ from the bound Compliance contract
+- `set_compliance_address` itself remains a one-time admin action
+
+This lets the module be configured from the CLI before it is locked to the
+Compliance contract.
+
+## Main entrypoints
+
+- `__constructor(admin)` initializes the bootstrap admin
+- `set_identity_registry_storage(token, irs)` stores the IRS address for a
+ token
+- `add_country_restriction(token, country)` adds an ISO 3166-1 numeric code to
+ the restriction list
+- `remove_country_restriction(token, country)` removes a country code
+- `batch_restrict_countries(token, countries)` updates multiple entries
+- `batch_unrestrict_countries(token, countries)` removes multiple entries
+- `is_country_restricted(token, country)` reads the current restriction state
+- `set_compliance_address(compliance)` performs the one-time handoff to the
+ Compliance contract
+
+## Notes
+
+- Storage is token-scoped, so one deployed module can be reused across many
+ tokens
+- This module validates on the compliance read hooks used for transfers and
+ mints; it does not require extra state-tracking hooks
+- In the deploy example, the module is configured before binding and then wired
+ to the `CanTransfer` and `CanCreate` hooks
diff --git a/examples/rwa-country-restrict/src/lib.rs b/examples/rwa-country-restrict/src/lib.rs
new file mode 100644
index 000000000..8d8d4c140
--- /dev/null
+++ b/examples/rwa-country-restrict/src/lib.rs
@@ -0,0 +1,88 @@
+#![no_std]
+
+use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec};
+use stellar_tokens::rwa::compliance::modules::{
+ country_restrict::{
+ storage::{is_country_restricted, remove_country_restricted, set_country_restricted},
+ CountryRestrict, CountryRestricted, CountryUnrestricted,
+ },
+ storage::{set_compliance_address, set_irs_address, ComplianceModuleStorageKey},
+};
+
+#[contracttype]
+enum DataKey {
+ Admin,
+}
+
+#[contract]
+pub struct CountryRestrictContract;
+
+fn set_admin(e: &Env, admin: &Address) {
+ e.storage().instance().set(&DataKey::Admin, admin);
+}
+
+fn get_admin(e: &Env) -> Address {
+ e.storage().instance().get(&DataKey::Admin).expect("admin must be set")
+}
+
+fn require_module_admin_or_compliance_auth(e: &Env) {
+ if let Some(compliance) =
+ e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance)
+ {
+ compliance.require_auth();
+ } else {
+ get_admin(e).require_auth();
+ }
+}
+
+#[contractimpl]
+impl CountryRestrictContract {
+ pub fn __constructor(e: &Env, admin: Address) {
+ set_admin(e, &admin);
+ }
+}
+
+#[contractimpl(contracttrait)]
+impl CountryRestrict for CountryRestrictContract {
+ fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) {
+ require_module_admin_or_compliance_auth(e);
+ set_irs_address(e, &token, &irs);
+ }
+
+ fn add_country_restriction(e: &Env, token: Address, country: u32) {
+ require_module_admin_or_compliance_auth(e);
+ set_country_restricted(e, &token, country);
+ CountryRestricted { token, country }.publish(e);
+ }
+
+ fn remove_country_restriction(e: &Env, token: Address, country: u32) {
+ require_module_admin_or_compliance_auth(e);
+ remove_country_restricted(e, &token, country);
+ CountryUnrestricted { token, country }.publish(e);
+ }
+
+ fn batch_restrict_countries(e: &Env, token: Address, countries: Vec) {
+ require_module_admin_or_compliance_auth(e);
+ for country in countries.iter() {
+ set_country_restricted(e, &token, country);
+ CountryRestricted { token: token.clone(), country }.publish(e);
+ }
+ }
+
+ fn batch_unrestrict_countries(e: &Env, token: Address, countries: Vec) {
+ require_module_admin_or_compliance_auth(e);
+ for country in countries.iter() {
+ remove_country_restricted(e, &token, country);
+ CountryUnrestricted { token: token.clone(), country }.publish(e);
+ }
+ }
+
+ fn is_country_restricted(e: &Env, token: Address, country: u32) -> bool {
+ is_country_restricted(e, &token, country)
+ }
+
+ fn set_compliance_address(e: &Env, compliance: Address) {
+ get_admin(e).require_auth();
+ set_compliance_address(e, &compliance);
+ }
+}
diff --git a/examples/rwa-initial-lockup-period/Cargo.toml b/examples/rwa-initial-lockup-period/Cargo.toml
new file mode 100644
index 000000000..dc0edbff4
--- /dev/null
+++ b/examples/rwa-initial-lockup-period/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "rwa-initial-lockup-period"
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+publish = false
+version.workspace = true
+
+[lib]
+crate-type = ["cdylib", "rlib"]
+doctest = false
+
+[dependencies]
+soroban-sdk = { workspace = true }
+stellar-tokens = { workspace = true }
diff --git a/examples/rwa-initial-lockup-period/README.md b/examples/rwa-initial-lockup-period/README.md
new file mode 100644
index 000000000..e795d527d
--- /dev/null
+++ b/examples/rwa-initial-lockup-period/README.md
@@ -0,0 +1,64 @@
+# Initial Lockup Period Module
+
+Concrete deployable example of the `InitialLockupPeriod` compliance module for
+Stellar RWA tokens.
+
+## What it enforces
+
+This module applies a lockup period to tokens received through primary
+emissions. When tokens are minted, the minted amount is locked until the
+configured release timestamp.
+
+The example follows the library semantics:
+
+- minted tokens are subject to lockup
+- peer-to-peer transfers do not create new lockups for the recipient
+- transfers and burns can consume only unlocked balance
+
+## How it stays in sync
+
+The module maintains internal balances plus lock records and therefore must be
+wired to all of the hooks it depends on:
+
+- `CanTransfer`
+- `Created`
+- `Transferred`
+- `Destroyed`
+
+After those hooks are registered, `verify_hook_wiring()` must be called once so
+the module marks itself as armed before transfer validation starts.
+
+## Authorization model
+
+This example uses the bootstrap-admin pattern introduced in this port:
+
+- The constructor stores a one-time `admin`
+- Before `set_compliance_address`, configuration calls require that admin's
+ auth
+- After `set_compliance_address`, privileged calls require auth from the bound
+ Compliance contract
+- `set_compliance_address` itself remains a one-time admin action
+
+This allows the module to be configured from the CLI before handing control to
+Compliance.
+
+## Main entrypoints
+
+- `__constructor(admin)` initializes the bootstrap admin
+- `set_lockup_period(token, lockup_seconds)` configures the mint lockup window
+- `pre_set_lockup_state(token, wallet, balance, locks)` seeds an existing
+ holder's mirrored balance and active lock entries
+- `required_hooks()` returns the required hook set
+- `verify_hook_wiring()` marks the module as armed after registration
+- `set_compliance_address(compliance)` performs the one-time handoff to the
+ Compliance contract
+
+## Notes
+
+- Storage is token-scoped, so one deployed module can be reused across many
+ tokens
+- The module stores detailed lock entries plus aggregate locked totals
+- If the module is attached after live minting, seed existing balances and any
+ still-active lock entries before relying on transfer or burn enforcement
+- Transfer and burn flows consume unlocked balance first, then matured locks if
+ needed
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..da4fa20d0
--- /dev/null
+++ b/examples/rwa-initial-lockup-period/src/lib.rs
@@ -0,0 +1,228 @@
+#![no_std]
+
+use soroban_sdk::{contract, contractimpl, contracttype, vec, Address, Env, String, Vec};
+use stellar_tokens::rwa::compliance::{
+ modules::{
+ initial_lockup_period::{
+ storage::{
+ get_internal_balance, get_locks, get_lockup_period, get_total_locked,
+ set_internal_balance, set_locks, set_lockup_period, set_total_locked,
+ },
+ InitialLockupPeriod, LockedTokens, LockupPeriodSet,
+ },
+ storage::{
+ add_i128_or_panic, set_compliance_address, sub_i128_or_panic, verify_required_hooks,
+ ComplianceModuleStorageKey,
+ },
+ },
+ 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();
+ }
+}
+
+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 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));
+}
+
+#[contractimpl]
+impl InitialLockupPeriodContract {
+ pub fn __constructor(e: &Env, admin: Address) {
+ set_admin(e, &admin);
+ }
+}
+
+#[contractimpl(contracttrait)]
+impl InitialLockupPeriod for InitialLockupPeriodContract {
+ fn set_lockup_period(e: &Env, token: Address, lockup_seconds: u64) {
+ require_module_admin_or_compliance_auth(e);
+ set_lockup_period(e, &token, lockup_seconds);
+ LockupPeriodSet { token, lockup_seconds }.publish(e);
+ }
+
+ fn pre_set_lockup_state(
+ e: &Env,
+ token: Address,
+ wallet: Address,
+ balance: i128,
+ locks: Vec,
+ ) {
+ require_module_admin_or_compliance_auth(e);
+ stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, balance);
+
+ let mut total_locked = 0i128;
+ for i in 0..locks.len() {
+ let lock = locks.get(i).unwrap();
+ stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(
+ e,
+ lock.amount,
+ );
+ total_locked = add_i128_or_panic(e, total_locked, lock.amount);
+ }
+
+ 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);
+ }
+
+ fn required_hooks(e: &Env) -> Vec {
+ vec![
+ e,
+ ComplianceHook::CanTransfer,
+ ComplianceHook::Created,
+ ComplianceHook::Transferred,
+ ComplianceHook::Destroyed,
+ ]
+ }
+
+ fn verify_hook_wiring(e: &Env) {
+ verify_required_hooks(e, Self::required_hooks(e));
+ }
+
+ fn on_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) {
+ require_module_admin_or_compliance_auth(e);
+ stellar_tokens::rwa::compliance::modules::storage::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));
+ }
+
+ fn on_created(e: &Env, to: Address, amount: i128, token: Address) {
+ require_module_admin_or_compliance_auth(e);
+ stellar_tokens::rwa::compliance::modules::storage::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));
+ }
+
+ fn on_destroyed(e: &Env, from: Address, amount: i128, token: Address) {
+ require_module_admin_or_compliance_auth(e);
+ stellar_tokens::rwa::compliance::modules::storage::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));
+ }
+
+ 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/Cargo.toml b/examples/rwa-max-balance/Cargo.toml
new file mode 100644
index 000000000..1238118f2
--- /dev/null
+++ b/examples/rwa-max-balance/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "rwa-max-balance"
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+publish = false
+version.workspace = true
+
+[lib]
+crate-type = ["cdylib", "rlib"]
+doctest = false
+
+[dependencies]
+soroban-sdk = { workspace = true }
+stellar-tokens = { workspace = true }
diff --git a/examples/rwa-max-balance/README.md b/examples/rwa-max-balance/README.md
new file mode 100644
index 000000000..193ebfa64
--- /dev/null
+++ b/examples/rwa-max-balance/README.md
@@ -0,0 +1,62 @@
+# Max Balance Module
+
+Concrete deployable example of the `MaxBalance` compliance module for Stellar
+RWA tokens.
+
+## What it enforces
+
+This module tracks balances per investor identity, not per wallet, and enforces
+a maximum balance cap for each token.
+
+Because the accounting is identity-based, the module must be configured with an
+Identity Registry Storage (IRS) contract for each token it serves.
+
+## How it stays in sync
+
+The module maintains internal per-identity balances and therefore must be wired
+to all of the hooks it depends on:
+
+- `CanTransfer`
+- `CanCreate`
+- `Transferred`
+- `Created`
+- `Destroyed`
+
+After those hooks are registered, `verify_hook_wiring()` must be called once so
+the module marks itself as armed before mint and transfer validation starts.
+
+## Authorization model
+
+This example uses the bootstrap-admin pattern introduced in this port:
+
+- The constructor stores a one-time `admin`
+- Before `set_compliance_address`, configuration calls require that admin's
+ auth
+- After `set_compliance_address`, privileged calls require auth from the bound
+ Compliance contract
+- `set_compliance_address` itself remains a one-time admin action
+
+This allows the module to be seeded and configured from the CLI before handing
+control to Compliance.
+
+## Main entrypoints
+
+- `__constructor(admin)` initializes the bootstrap admin
+- `set_identity_registry_storage(token, irs)` stores the IRS address for a
+ token
+- `set_max_balance(token, max)` configures the per-identity cap
+- `pre_set_module_state(token, identity, balance)` seeds an identity balance
+- `batch_pre_set_module_state(token, identities, balances)` seeds many
+ identity balances
+- `required_hooks()` returns the required hook set
+- `verify_hook_wiring()` marks the module as armed after registration
+- `set_compliance_address(compliance)` performs the one-time handoff to the
+ Compliance contract
+
+## Notes
+
+- Storage is token-scoped, so one deployed module can be reused across many
+ tokens
+- Transfers between two wallets that resolve to the same identity do not change
+ the tracked balance distribution
+- A configured max of `0` behaves as "no cap"
diff --git a/examples/rwa-max-balance/src/lib.rs b/examples/rwa-max-balance/src/lib.rs
new file mode 100644
index 000000000..16352fde6
--- /dev/null
+++ b/examples/rwa-max-balance/src/lib.rs
@@ -0,0 +1,167 @@
+#![no_std]
+
+use soroban_sdk::{contract, contractimpl, contracttype, vec, Address, Env, String, Vec};
+use stellar_tokens::rwa::compliance::{
+ modules::{
+ max_balance::{
+ storage::{get_id_balance, get_max_balance, set_id_balance, set_max_balance},
+ IDBalancePreSet, MaxBalance, MaxBalanceSet,
+ },
+ storage::{
+ add_i128_or_panic, get_irs_client, set_compliance_address, set_irs_address,
+ sub_i128_or_panic, verify_required_hooks, ComplianceModuleStorageKey,
+ },
+ },
+ 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);
+ }
+}
+
+#[contractimpl(contracttrait)]
+impl MaxBalance for MaxBalanceContract {
+ fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) {
+ require_module_admin_or_compliance_auth(e);
+ set_irs_address(e, &token, &irs);
+ }
+
+ fn set_max_balance(e: &Env, token: Address, max: i128) {
+ require_module_admin_or_compliance_auth(e);
+ stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, max);
+ set_max_balance(e, &token, max);
+ MaxBalanceSet { token, max_balance: max }.publish(e);
+ }
+
+ fn pre_set_module_state(e: &Env, token: Address, identity: Address, balance: i128) {
+ require_module_admin_or_compliance_auth(e);
+ stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, balance);
+ set_id_balance(e, &token, &identity, balance);
+ IDBalancePreSet { token, identity, balance }.publish(e);
+ }
+
+ fn batch_pre_set_module_state(
+ e: &Env,
+ token: Address,
+ identities: Vec,
+ balances: Vec,
+ ) {
+ require_module_admin_or_compliance_auth(e);
+ 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();
+ stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, bal);
+ set_id_balance(e, &token, &id, bal);
+ IDBalancePreSet { token: token.clone(), identity: id, balance: bal }.publish(e);
+ }
+ }
+
+ fn required_hooks(e: &Env) -> Vec {
+ vec![
+ e,
+ ComplianceHook::CanTransfer,
+ ComplianceHook::CanCreate,
+ ComplianceHook::Transferred,
+ ComplianceHook::Created,
+ ComplianceHook::Destroyed,
+ ]
+ }
+
+ fn verify_hook_wiring(e: &Env) {
+ verify_required_hooks(e, Self::required_hooks(e));
+ }
+
+ fn on_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) {
+ require_module_admin_or_compliance_auth(e);
+ stellar_tokens::rwa::compliance::modules::storage::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);
+ let to_balance = get_id_balance(e, &token, &to_id);
+ let new_to_balance = add_i128_or_panic(e, to_balance, amount);
+
+ let max = get_max_balance(e, &token);
+ assert!(
+ max == 0 || new_to_balance <= max,
+ "MaxBalanceModule: recipient identity balance exceeds max"
+ );
+
+ 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);
+ }
+
+ fn on_created(e: &Env, to: Address, amount: i128, token: Address) {
+ require_module_admin_or_compliance_auth(e);
+ stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, amount);
+
+ let irs = get_irs_client(e, &token);
+ let to_id = irs.stored_identity(&to);
+
+ let current = get_id_balance(e, &token, &to_id);
+ let new_balance = add_i128_or_panic(e, current, amount);
+
+ let max = get_max_balance(e, &token);
+ assert!(
+ max == 0 || new_balance <= max,
+ "MaxBalanceModule: recipient identity balance exceeds max after mint"
+ );
+
+ set_id_balance(e, &token, &to_id, new_balance);
+ }
+
+ fn on_destroyed(e: &Env, from: Address, amount: i128, token: Address) {
+ require_module_admin_or_compliance_auth(e);
+ stellar_tokens::rwa::compliance::modules::storage::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));
+ }
+
+ 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/Cargo.toml b/examples/rwa-supply-limit/Cargo.toml
new file mode 100644
index 000000000..b4d0e313c
--- /dev/null
+++ b/examples/rwa-supply-limit/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "rwa-supply-limit"
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+publish = false
+version.workspace = true
+
+[lib]
+crate-type = ["cdylib", "rlib"]
+doctest = false
+
+[dependencies]
+soroban-sdk = { workspace = true }
+stellar-tokens = { workspace = true }
diff --git a/examples/rwa-supply-limit/README.md b/examples/rwa-supply-limit/README.md
new file mode 100644
index 000000000..a589cfd70
--- /dev/null
+++ b/examples/rwa-supply-limit/README.md
@@ -0,0 +1,61 @@
+# Supply Limit Module
+
+Concrete deployable example of the `SupplyLimit` compliance module for Stellar
+RWA tokens.
+
+## What it enforces
+
+This module caps the total amount of tokens that may be minted for a given
+token contract.
+
+It keeps an internal supply counter and checks that each mint would stay within
+the configured per-token limit.
+
+## How it stays in sync
+
+The module maintains internal supply state and therefore must be wired to all
+of the hooks it depends on:
+
+- `CanCreate`
+- `Created`
+- `Destroyed`
+
+After those hooks are registered, `verify_hook_wiring()` must be called once so
+the module marks itself as armed before mint validation starts.
+
+## Authorization model
+
+This example uses the bootstrap-admin pattern introduced in this port:
+
+- The constructor stores a one-time `admin`
+- Before `set_compliance_address`, configuration calls require that admin's
+ auth
+- After `set_compliance_address`, privileged calls require auth from the bound
+ Compliance contract
+- `set_compliance_address` itself remains a one-time admin action
+
+This allows the module to be configured from the CLI before handing control to
+Compliance.
+
+## Main entrypoints
+
+- `__constructor(admin)` initializes the bootstrap admin
+- `set_supply_limit(token, limit)` sets the per-token cap
+- `pre_set_internal_supply(token, supply)` seeds tracked supply when wiring the
+ module after historical minting
+- `get_supply_limit(token)` reads the configured cap
+- `get_internal_supply(token)` reads the tracked internal supply
+- `required_hooks()` returns the required hook set
+- `verify_hook_wiring()` marks the module as armed after registration
+- `set_compliance_address(compliance)` performs the one-time handoff to the
+ Compliance contract
+
+## Notes
+
+- Storage is token-scoped, so one deployed module can be reused across many
+ tokens
+- A configured limit of `0` behaves as "no cap"
+- If the module is attached after a token already has minted supply, seed the
+ existing amount with `pre_set_internal_supply` before relying on `can_create`
+- The internal supply is updated only through the registered `Created` and
+ `Destroyed` hooks
diff --git a/examples/rwa-supply-limit/src/lib.rs b/examples/rwa-supply-limit/src/lib.rs
new file mode 100644
index 000000000..2250b7026
--- /dev/null
+++ b/examples/rwa-supply-limit/src/lib.rs
@@ -0,0 +1,102 @@
+#![no_std]
+
+use soroban_sdk::{contract, contractimpl, contracttype, vec, Address, Env, String, Vec};
+use stellar_tokens::rwa::compliance::{
+ modules::{
+ storage::{
+ add_i128_or_panic, set_compliance_address, sub_i128_or_panic, verify_required_hooks,
+ ComplianceModuleStorageKey,
+ },
+ supply_limit::{
+ storage::{
+ get_internal_supply, get_supply_limit, set_internal_supply, set_supply_limit,
+ },
+ SupplyLimit, SupplyLimitSet,
+ },
+ },
+ 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);
+ }
+}
+
+#[contractimpl(contracttrait)]
+impl SupplyLimit for SupplyLimitContract {
+ fn set_supply_limit(e: &Env, token: Address, limit: i128) {
+ require_module_admin_or_compliance_auth(e);
+ stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, limit);
+ set_supply_limit(e, &token, limit);
+ SupplyLimitSet { token, limit }.publish(e);
+ }
+
+ fn pre_set_internal_supply(e: &Env, token: Address, supply: i128) {
+ require_module_admin_or_compliance_auth(e);
+ stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, supply);
+ set_internal_supply(e, &token, supply);
+ }
+
+ fn get_supply_limit(e: &Env, token: Address) -> i128 {
+ get_supply_limit(e, &token)
+ }
+
+ fn get_internal_supply(e: &Env, token: Address) -> i128 {
+ get_internal_supply(e, &token)
+ }
+
+ fn required_hooks(e: &Env) -> Vec {
+ vec![e, ComplianceHook::CanCreate, ComplianceHook::Created, ComplianceHook::Destroyed]
+ }
+
+ fn verify_hook_wiring(e: &Env) {
+ verify_required_hooks(e, Self::required_hooks(e));
+ }
+
+ fn on_created(e: &Env, _to: Address, amount: i128, token: Address) {
+ require_module_admin_or_compliance_auth(e);
+ stellar_tokens::rwa::compliance::modules::storage::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));
+ }
+
+ fn on_destroyed(e: &Env, _from: Address, amount: i128, token: Address) {
+ require_module_admin_or_compliance_auth(e);
+ stellar_tokens::rwa::compliance::modules::storage::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));
+ }
+
+ 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/Cargo.toml b/examples/rwa-time-transfers-limits/Cargo.toml
new file mode 100644
index 000000000..6b71f752c
--- /dev/null
+++ b/examples/rwa-time-transfers-limits/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "rwa-time-transfers-limits"
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+publish = false
+version.workspace = true
+
+[lib]
+crate-type = ["cdylib", "rlib"]
+doctest = false
+
+[dependencies]
+soroban-sdk = { workspace = true }
+stellar-tokens = { workspace = true }
diff --git a/examples/rwa-time-transfers-limits/README.md b/examples/rwa-time-transfers-limits/README.md
new file mode 100644
index 000000000..6377ab122
--- /dev/null
+++ b/examples/rwa-time-transfers-limits/README.md
@@ -0,0 +1,71 @@
+# Time Transfers Limits Module
+
+Concrete deployable example of the `TimeTransfersLimits` compliance module for
+Stellar RWA tokens.
+
+## What it enforces
+
+This module limits the amount an investor identity may transfer within one or
+more configured time windows.
+
+Limits are tracked per identity, not per wallet, so the module must be
+configured with an Identity Registry Storage (IRS) contract for each token it
+serves.
+
+Each limit is defined by:
+
+- `limit_time`: the window size in seconds
+- `limit_value`: the maximum transferable amount during that window
+
+This example allows up to four active limits per token.
+
+## How it stays in sync
+
+The module maintains transfer counters and therefore must be wired to all of
+the hooks it depends on:
+
+- `CanTransfer`
+- `Transferred`
+
+After those hooks are registered, `verify_hook_wiring()` must be called once so
+the module marks itself as armed before transfer validation starts.
+
+## Authorization model
+
+This example uses the bootstrap-admin pattern introduced in this port:
+
+- The constructor stores a one-time `admin`
+- Before `set_compliance_address`, configuration calls require that admin's
+ auth
+- After `set_compliance_address`, privileged calls require auth from the bound
+ Compliance contract
+- `set_compliance_address` itself remains a one-time admin action
+
+This allows the module to be configured from the CLI before handing control to
+Compliance.
+
+## Main entrypoints
+
+- `__constructor(admin)` initializes the bootstrap admin
+- `set_identity_registry_storage(token, irs)` stores the IRS address for a
+ token
+- `set_time_transfer_limit(token, limit)` adds or replaces a limit window
+- `batch_set_time_transfer_limit(token, limits)` updates multiple windows
+- `remove_time_transfer_limit(token, limit_time)` removes a window
+- `batch_remove_time_transfer_limit(token, limit_times)` removes many windows
+- `pre_set_transfer_counter(token, identity, limit_time, counter)` seeds an
+ in-flight rolling window when attaching the module after recent transfers
+- `required_hooks()` returns the required hook set
+- `verify_hook_wiring()` marks the module as armed after registration
+- `set_compliance_address(compliance)` performs the one-time handoff to the
+ Compliance contract
+
+## Notes
+
+- Storage is token-scoped, so one deployed module can be reused across many
+ tokens
+- Counter resets are driven by ledger timestamps
+- If the module is attached after transfers have already occurred inside an
+ active window, seed the relevant identity counters before relying on
+ `can_transfer`
+- Only outgoing transfer volume is tracked; mint and burn hooks are not used
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..88ed4ae88
--- /dev/null
+++ b/examples/rwa-time-transfers-limits/src/lib.rs
@@ -0,0 +1,202 @@
+#![no_std]
+
+use soroban_sdk::{
+ contract, contractimpl, contracttype, panic_with_error, vec, Address, Env, String, Vec,
+};
+use stellar_tokens::rwa::compliance::{
+ modules::{
+ storage::{
+ add_i128_or_panic, get_irs_client, set_compliance_address, set_irs_address,
+ verify_required_hooks, ComplianceModuleStorageKey,
+ },
+ time_transfers_limits::{
+ storage::{get_counter, get_limits, set_counter, set_limits},
+ Limit, TimeTransferLimitRemoved, TimeTransferLimitUpdated, TimeTransfersLimits,
+ TransferCounter,
+ },
+ ComplianceModuleError,
+ },
+ ComplianceHook,
+};
+
+const MAX_LIMITS_PER_TOKEN: u32 = 4;
+
+#[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();
+ }
+}
+
+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);
+ }
+}
+
+#[contractimpl]
+impl TimeTransfersLimitsContract {
+ pub fn __constructor(e: &Env, admin: Address) {
+ set_admin(e, &admin);
+ }
+}
+
+#[contractimpl(contracttrait)]
+impl TimeTransfersLimits for TimeTransfersLimitsContract {
+ fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) {
+ require_module_admin_or_compliance_auth(e);
+ set_irs_address(e, &token, &irs);
+ }
+
+ fn set_time_transfer_limit(e: &Env, token: Address, limit: Limit) {
+ require_module_admin_or_compliance_auth(e);
+ assert!(limit.limit_time > 0, "limit_time must be greater than zero");
+ stellar_tokens::rwa::compliance::modules::storage::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, limit }.publish(e);
+ }
+
+ fn batch_set_time_transfer_limit(e: &Env, token: Address, limits: Vec) {
+ require_module_admin_or_compliance_auth(e);
+ for limit in limits.iter() {
+ Self::set_time_transfer_limit(e, token.clone(), limit);
+ }
+ }
+
+ fn remove_time_transfer_limit(e: &Env, token: Address, limit_time: u64) {
+ require_module_admin_or_compliance_auth(e);
+ 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, limit_time }.publish(e);
+ }
+
+ fn batch_remove_time_transfer_limit(e: &Env, token: Address, limit_times: Vec) {
+ require_module_admin_or_compliance_auth(e);
+ for lt in limit_times.iter() {
+ Self::remove_time_transfer_limit(e, token.clone(), lt);
+ }
+ }
+
+ fn pre_set_transfer_counter(
+ e: &Env,
+ token: Address,
+ identity: Address,
+ limit_time: u64,
+ counter: TransferCounter,
+ ) {
+ require_module_admin_or_compliance_auth(e);
+ stellar_tokens::rwa::compliance::modules::storage::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);
+ }
+
+ fn required_hooks(e: &Env) -> Vec {
+ vec![e, ComplianceHook::CanTransfer, ComplianceHook::Transferred]
+ }
+
+ fn verify_hook_wiring(e: &Env) {
+ verify_required_hooks(e, Self::required_hooks(e));
+ }
+
+ fn on_transfer(e: &Env, from: Address, _to: Address, amount: i128, token: Address) {
+ require_module_admin_or_compliance_auth(e);
+ stellar_tokens::rwa::compliance::modules::storage::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);
+ }
+
+ 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/Cargo.toml b/examples/rwa-transfer-restrict/Cargo.toml
new file mode 100644
index 000000000..9655c300d
--- /dev/null
+++ b/examples/rwa-transfer-restrict/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "rwa-transfer-restrict"
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+publish = false
+version.workspace = true
+
+[lib]
+crate-type = ["cdylib", "rlib"]
+doctest = false
+
+[dependencies]
+soroban-sdk = { workspace = true }
+stellar-tokens = { workspace = true }
diff --git a/examples/rwa-transfer-restrict/README.md b/examples/rwa-transfer-restrict/README.md
new file mode 100644
index 000000000..a8283c44b
--- /dev/null
+++ b/examples/rwa-transfer-restrict/README.md
@@ -0,0 +1,47 @@
+# Transfer Restrict Module
+
+Concrete deployable example of the `TransferRestrict` compliance module for
+Stellar RWA tokens.
+
+## What it enforces
+
+This module maintains a per-token address allowlist for transfers.
+
+It follows the T-REX semantics implemented by the library trait:
+
+- if the sender is allowlisted, the transfer passes
+- otherwise, the recipient must be allowlisted
+
+The module is token-scoped, so one deployment can serve many tokens.
+
+## Authorization model
+
+This example uses the bootstrap-admin pattern introduced in this port:
+
+- The constructor stores a one-time `admin`
+- Before `set_compliance_address`, allowlist management requires that admin's
+ auth
+- After `set_compliance_address`, the same configuration calls require auth
+ from the bound Compliance contract
+- `set_compliance_address` itself remains a one-time admin action
+
+This lets the module be configured from the CLI before it is locked to the
+Compliance contract.
+
+## Main entrypoints
+
+- `__constructor(admin)` initializes the bootstrap admin
+- `allow_user(token, user)` adds an address to the transfer allowlist
+- `disallow_user(token, user)` removes an address from the transfer allowlist
+- `batch_allow_users(token, users)` updates multiple entries
+- `batch_disallow_users(token, users)` removes multiple entries
+- `is_user_allowed(token, user)` reads the current allowlist state
+- `set_compliance_address(compliance)` performs the one-time handoff to the
+ Compliance contract
+
+## Notes
+
+- This module validates transfers through the `CanTransfer` hook
+- It does not depend on IRS or other identity infrastructure
+- In the deploy example, the admin address is pre-allowlisted before binding so
+ the happy-path transfer checks can succeed
diff --git a/examples/rwa-transfer-restrict/src/lib.rs b/examples/rwa-transfer-restrict/src/lib.rs
new file mode 100644
index 000000000..49084164a
--- /dev/null
+++ b/examples/rwa-transfer-restrict/src/lib.rs
@@ -0,0 +1,83 @@
+#![no_std]
+
+use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec};
+use stellar_tokens::rwa::compliance::modules::{
+ storage::{set_compliance_address, ComplianceModuleStorageKey},
+ transfer_restrict::{
+ storage::{is_user_allowed, remove_user_allowed, set_user_allowed},
+ TransferRestrict, UserAllowed, UserDisallowed,
+ },
+};
+
+#[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);
+ }
+}
+
+#[contractimpl(contracttrait)]
+impl TransferRestrict for TransferRestrictContract {
+ fn allow_user(e: &Env, token: Address, user: Address) {
+ require_module_admin_or_compliance_auth(e);
+ set_user_allowed(e, &token, &user);
+ UserAllowed { token, user }.publish(e);
+ }
+
+ fn disallow_user(e: &Env, token: Address, user: Address) {
+ require_module_admin_or_compliance_auth(e);
+ remove_user_allowed(e, &token, &user);
+ UserDisallowed { token, user }.publish(e);
+ }
+
+ fn batch_allow_users(e: &Env, token: Address, users: Vec) {
+ require_module_admin_or_compliance_auth(e);
+ for user in users.iter() {
+ set_user_allowed(e, &token, &user);
+ UserAllowed { token: token.clone(), user }.publish(e);
+ }
+ }
+
+ fn batch_disallow_users(e: &Env, token: Address, users: Vec) {
+ require_module_admin_or_compliance_auth(e);
+ for user in users.iter() {
+ remove_user_allowed(e, &token, &user);
+ UserDisallowed { token: token.clone(), user }.publish(e);
+ }
+ }
+
+ fn is_user_allowed(e: &Env, token: Address, user: Address) -> bool {
+ is_user_allowed(e, &token, &user)
+ }
+
+ fn set_compliance_address(e: &Env, compliance: Address) {
+ get_admin(e).require_auth();
+ set_compliance_address(e, &compliance);
+ }
+}
diff --git a/packages/tokens/src/rwa/compliance/modules/country_allow/mod.rs b/packages/tokens/src/rwa/compliance/modules/country_allow/mod.rs
new file mode 100644
index 000000000..fe6cdda69
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/country_allow/mod.rs
@@ -0,0 +1,455 @@
+//! Country allowlist compliance module — Stellar port of T-REX
+//! [`CountryAllowModule.sol`][trex-src].
+//!
+//! Only recipients whose identity has at least one country code in the
+//! allowlist may receive tokens.
+//!
+//! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/CountryAllowModule.sol
+
+pub mod storage;
+
+use soroban_sdk::{contractevent, contracttrait, Address, Env, String, Vec};
+use storage::{is_country_allowed, remove_country_allowed, set_country_allowed};
+
+use super::storage::{
+ country_code, get_compliance_address, get_irs_country_data_entries, module_name,
+ set_irs_address,
+};
+
+/// Emitted when a country is added to the allowlist.
+#[contractevent]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct CountryAllowed {
+ #[topic]
+ pub token: Address,
+ pub country: u32,
+}
+
+/// Emitted when a country is removed from the allowlist.
+#[contractevent]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct CountryUnallowed {
+ #[topic]
+ pub token: Address,
+ pub country: u32,
+}
+
+/// Country allowlist compliance trait.
+///
+/// Provides default implementations for maintaining a per-token country
+/// allowlist and validating transfers/mints against it via the Identity
+/// Registry Storage.
+#[contracttrait]
+pub trait CountryAllow {
+ /// Sets the Identity Registry Storage contract address for `token`.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `token` - The token this IRS applies to.
+ /// * `irs` - The IRS contract address.
+ ///
+ /// # Authorization
+ ///
+ /// Requires compliance contract authorization.
+ fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) {
+ get_compliance_address(e).require_auth();
+ set_irs_address(e, &token, &irs);
+ }
+
+ /// Adds a country to the allowlist for `token`.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `token` - The token address.
+ /// * `country` - The ISO 3166-1 numeric country code to allow.
+ ///
+ /// # Authorization
+ ///
+ /// Requires compliance contract authorization.
+ ///
+ /// # Events
+ ///
+ /// Emits [`CountryAllowed`].
+ fn add_allowed_country(e: &Env, token: Address, country: u32) {
+ get_compliance_address(e).require_auth();
+ set_country_allowed(e, &token, country);
+ CountryAllowed { token, country }.publish(e);
+ }
+
+ /// Removes a country from the allowlist for `token`.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `token` - The token address.
+ /// * `country` - The ISO 3166-1 numeric country code to remove.
+ ///
+ /// # Authorization
+ ///
+ /// Requires compliance contract authorization.
+ ///
+ /// # Events
+ ///
+ /// Emits [`CountryUnallowed`].
+ fn remove_allowed_country(e: &Env, token: Address, country: u32) {
+ get_compliance_address(e).require_auth();
+ remove_country_allowed(e, &token, country);
+ CountryUnallowed { token, country }.publish(e);
+ }
+
+ /// Adds multiple countries to the allowlist in a single call.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `token` - The token address.
+ /// * `countries` - The country codes to allow.
+ ///
+ /// # Authorization
+ ///
+ /// Requires compliance contract authorization.
+ ///
+ /// # Events
+ ///
+ /// Emits [`CountryAllowed`] for each country added.
+ fn batch_allow_countries(e: &Env, token: Address, countries: Vec) {
+ get_compliance_address(e).require_auth();
+ for country in countries.iter() {
+ set_country_allowed(e, &token, country);
+ CountryAllowed { token: token.clone(), country }.publish(e);
+ }
+ }
+
+ /// Removes multiple countries from the allowlist in a single call.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `token` - The token address.
+ /// * `countries` - The country codes to remove.
+ ///
+ /// # Authorization
+ ///
+ /// Requires compliance contract authorization.
+ ///
+ /// # Events
+ ///
+ /// Emits [`CountryUnallowed`] for each country removed.
+ fn batch_disallow_countries(e: &Env, token: Address, countries: Vec) {
+ get_compliance_address(e).require_auth();
+ for country in countries.iter() {
+ remove_country_allowed(e, &token, country);
+ CountryUnallowed { token: token.clone(), country }.publish(e);
+ }
+ }
+
+ /// Returns whether `country` is on the allowlist for `token`.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `token` - The token address.
+ /// * `country` - The ISO 3166-1 numeric country code.
+ fn is_country_allowed(e: &Env, token: Address, country: u32) -> bool {
+ is_country_allowed(e, &token, country)
+ }
+
+ /// No-op — this module does not track transfer state.
+ fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {}
+
+ /// No-op — this module does not track mint state.
+ fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {}
+
+ /// No-op — this module does not track burn state.
+ fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {}
+
+ /// Checks whether `to` has at least one allowed country in the IRS.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `_from` - The sender (unused).
+ /// * `to` - The recipient whose country data is checked.
+ /// * `_amount` - The transfer amount (unused).
+ /// * `token` - The token address.
+ ///
+ /// # Returns
+ ///
+ /// `true` if the recipient has at least one allowed country, `false`
+ /// otherwise.
+ ///
+ /// # Cross-Contract Calls
+ ///
+ /// Calls the IRS to resolve country data for `to`.
+ fn can_transfer(e: &Env, _from: Address, to: Address, _amount: i128, token: Address) -> bool {
+ let entries = get_irs_country_data_entries(e, &token, &to);
+ for entry in entries.iter() {
+ if is_country_allowed(e, &token, country_code(&entry.country)) {
+ return true;
+ }
+ }
+ false
+ }
+
+ /// Delegates to [`can_transfer`](CountryAllow::can_transfer) — same
+ /// country check applies to mints.
+ fn can_create(e: &Env, to: Address, amount: i128, token: Address) -> bool {
+ Self::can_transfer(e, to.clone(), to, amount, token)
+ }
+
+ /// Returns the module name for identification.
+ fn name(e: &Env) -> String {
+ module_name(e, "CountryAllowModule")
+ }
+
+ /// Returns the compliance contract address.
+ fn get_compliance_address(e: &Env) -> Address {
+ get_compliance_address(e)
+ }
+
+ /// Sets the compliance contract address (one-time only).
+ ///
+ /// Implementers must gate this entrypoint with bootstrap-admin auth before
+ /// delegating to
+ /// [`storage::set_compliance_address`](super::storage::set_compliance_address).
+ ///
+ ///
+ /// # Panics
+ ///
+ /// Panics if the compliance address has already been set.
+ fn set_compliance_address(e: &Env, compliance: Address);
+}
+
+#[cfg(test)]
+mod test {
+ extern crate std;
+
+ use soroban_sdk::{
+ contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal,
+ Val, Vec,
+ };
+
+ use super::*;
+ use crate::rwa::{
+ identity_registry_storage::{
+ CountryData, CountryDataManager, CountryRelation, IdentityRegistryStorage,
+ IndividualCountryRelation, OrganizationCountryRelation,
+ },
+ utils::token_binder::TokenBinder,
+ };
+
+ #[contract]
+ struct MockIRSContract;
+
+ #[contracttype]
+ #[derive(Clone)]
+ enum MockIRSStorageKey {
+ Identity(Address),
+ CountryEntries(Address),
+ }
+
+ #[contractimpl]
+ impl TokenBinder for MockIRSContract {
+ fn linked_tokens(e: &Env) -> Vec {
+ Vec::new(e)
+ }
+
+ fn bind_token(_e: &Env, _token: Address, _operator: Address) {
+ unreachable!("bind_token is not used in these tests");
+ }
+
+ fn unbind_token(_e: &Env, _token: Address, _operator: Address) {
+ unreachable!("unbind_token is not used in these tests");
+ }
+ }
+
+ #[contractimpl]
+ impl IdentityRegistryStorage for MockIRSContract {
+ fn add_identity(
+ _e: &Env,
+ _account: Address,
+ _identity: Address,
+ _country_data_list: Vec,
+ _operator: Address,
+ ) {
+ unreachable!("add_identity is not used in these tests");
+ }
+
+ fn remove_identity(_e: &Env, _account: Address, _operator: Address) {
+ unreachable!("remove_identity is not used in these tests");
+ }
+
+ fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) {
+ unreachable!("modify_identity is not used in these tests");
+ }
+
+ fn recover_identity(
+ _e: &Env,
+ _old_account: Address,
+ _new_account: Address,
+ _operator: Address,
+ ) {
+ unreachable!("recover_identity is not used in these tests");
+ }
+
+ fn stored_identity(e: &Env, account: Address) -> Address {
+ e.storage()
+ .persistent()
+ .get(&MockIRSStorageKey::Identity(account.clone()))
+ .unwrap_or(account)
+ }
+ }
+
+ #[contractimpl]
+ impl CountryDataManager for MockIRSContract {
+ fn add_country_data_entries(
+ _e: &Env,
+ _account: Address,
+ _country_data_list: Vec,
+ _operator: Address,
+ ) {
+ unreachable!("add_country_data_entries is not used in these tests");
+ }
+
+ fn modify_country_data(
+ _e: &Env,
+ _account: Address,
+ _index: u32,
+ _country_data: Val,
+ _operator: Address,
+ ) {
+ unreachable!("modify_country_data is not used in these tests");
+ }
+
+ fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) {
+ unreachable!("delete_country_data is not used in these tests");
+ }
+
+ fn get_country_data_entries(e: &Env, account: Address) -> Vec {
+ let entries: Vec = e
+ .storage()
+ .persistent()
+ .get(&MockIRSStorageKey::CountryEntries(account))
+ .unwrap_or_else(|| Vec::new(e));
+
+ Vec::from_iter(e, entries.iter().map(|entry| entry.into_val(e)))
+ }
+ }
+
+ #[contractimpl]
+ impl MockIRSContract {
+ pub fn set_country_data_entries(e: &Env, account: Address, entries: Vec) {
+ e.storage().persistent().set(&MockIRSStorageKey::CountryEntries(account), &entries);
+ }
+ }
+
+ #[contract]
+ struct TestCountryAllowContract;
+
+ #[contractimpl(contracttrait)]
+ impl CountryAllow for TestCountryAllowContract {
+ fn set_compliance_address(_e: &Env, _compliance: Address) {
+ unreachable!("set_compliance_address is not used in these tests");
+ }
+ }
+
+ fn individual_country(code: u32) -> CountryData {
+ CountryData {
+ country: CountryRelation::Individual(IndividualCountryRelation::Residence(code)),
+ metadata: None,
+ }
+ }
+
+ fn organization_country(code: u32) -> CountryData {
+ CountryData {
+ country: CountryRelation::Organization(
+ OrganizationCountryRelation::OperatingJurisdiction(code),
+ ),
+ metadata: None,
+ }
+ }
+
+ #[test]
+ fn can_transfer_and_create_allow_when_any_country_matches() {
+ let e = Env::default();
+ let module_id = e.register(TestCountryAllowContract, ());
+ let irs_id = e.register(MockIRSContract, ());
+ let irs = MockIRSContractClient::new(&e, &irs_id);
+ let token = Address::generate(&e);
+ let from = Address::generate(&e);
+ let to = Address::generate(&e);
+
+ irs.set_country_data_entries(
+ &to,
+ &vec![&e, individual_country(250), organization_country(276)],
+ );
+
+ e.as_contract(&module_id, || {
+ set_irs_address(&e, &token, &irs_id);
+ set_country_allowed(&e, &token, 276);
+
+ assert!(::can_transfer(
+ &e,
+ from.clone(),
+ to.clone(),
+ 100,
+ token.clone(),
+ ));
+ assert!(::can_create(
+ &e,
+ to.clone(),
+ 100,
+ token.clone(),
+ ));
+ });
+ }
+
+ #[test]
+ fn can_transfer_and_create_reject_when_no_country_matches() {
+ let e = Env::default();
+ let module_id = e.register(TestCountryAllowContract, ());
+ let irs_id = e.register(MockIRSContract, ());
+ let irs = MockIRSContractClient::new(&e, &irs_id);
+ let token = Address::generate(&e);
+ let from = Address::generate(&e);
+ let empty_to = Address::generate(&e);
+ let disallowed_to = Address::generate(&e);
+
+ irs.set_country_data_entries(&disallowed_to, &vec![&e, individual_country(250)]);
+
+ e.as_contract(&module_id, || {
+ set_irs_address(&e, &token, &irs_id);
+ set_country_allowed(&e, &token, 276);
+
+ assert!(!::can_transfer(
+ &e,
+ from.clone(),
+ empty_to.clone(),
+ 100,
+ token.clone(),
+ ));
+ assert!(!::can_create(
+ &e,
+ empty_to,
+ 100,
+ token.clone(),
+ ));
+
+ assert!(!::can_transfer(
+ &e,
+ from.clone(),
+ disallowed_to.clone(),
+ 100,
+ token.clone(),
+ ));
+ assert!(!::can_create(
+ &e,
+ disallowed_to,
+ 100,
+ token.clone(),
+ ));
+ });
+ }
+}
diff --git a/packages/tokens/src/rwa/compliance/modules/country_allow/storage.rs b/packages/tokens/src/rwa/compliance/modules/country_allow/storage.rs
new file mode 100644
index 000000000..767ca0a14
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/country_allow/storage.rs
@@ -0,0 +1,54 @@
+use soroban_sdk::{contracttype, Address, Env};
+
+use crate::rwa::compliance::modules::{MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD};
+
+#[contracttype]
+#[derive(Clone)]
+pub enum CountryAllowStorageKey {
+ /// Per-(token, country) allowlist flag.
+ AllowedCountry(Address, u32),
+}
+
+/// Returns whether the given country is on the allowlist for `token`.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `country` - The ISO 3166-1 numeric country code.
+pub fn is_country_allowed(e: &Env, token: &Address, country: u32) -> bool {
+ let key = CountryAllowStorageKey::AllowedCountry(token.clone(), country);
+ e.storage()
+ .persistent()
+ .get(&key)
+ .inspect(|_: &bool| {
+ e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT);
+ })
+ .unwrap_or_default()
+}
+
+/// Adds a country to the allowlist for `token`.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `country` - The ISO 3166-1 numeric country code to allow.
+pub fn set_country_allowed(e: &Env, token: &Address, country: u32) {
+ let key = CountryAllowStorageKey::AllowedCountry(token.clone(), country);
+ e.storage().persistent().set(&key, &true);
+ e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT);
+}
+
+/// Removes a country from the allowlist for `token`.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `country` - The ISO 3166-1 numeric country code to remove.
+pub fn remove_country_allowed(e: &Env, token: &Address, country: u32) {
+ e.storage()
+ .persistent()
+ .remove(&CountryAllowStorageKey::AllowedCountry(token.clone(), country));
+}
diff --git a/packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs b/packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs
new file mode 100644
index 000000000..09f87a301
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs
@@ -0,0 +1,459 @@
+//! Country restriction compliance module — Stellar port of T-REX
+//! [`CountryRestrictModule.sol`][trex-src].
+//!
+//! Recipients whose identity has a country code on the restriction list are
+//! blocked from receiving tokens.
+//!
+//! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/CountryRestrictModule.sol
+
+pub mod storage;
+
+use soroban_sdk::{contractevent, contracttrait, Address, Env, String, Vec};
+use storage::{is_country_restricted, remove_country_restricted, set_country_restricted};
+
+use super::storage::{
+ country_code, get_compliance_address, get_irs_country_data_entries, module_name,
+ set_irs_address,
+};
+
+/// Emitted when a country is added to the restriction list.
+#[contractevent]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct CountryRestricted {
+ #[topic]
+ pub token: Address,
+ pub country: u32,
+}
+
+/// Emitted when a country is removed from the restriction list.
+#[contractevent]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct CountryUnrestricted {
+ #[topic]
+ pub token: Address,
+ pub country: u32,
+}
+
+/// Country restriction compliance trait.
+///
+/// Provides default implementations for maintaining a per-token country
+/// restriction list and blocking transfers/mints to recipients from
+/// restricted countries via the Identity Registry Storage.
+#[contracttrait]
+pub trait CountryRestrict {
+ /// Sets the Identity Registry Storage contract address for `token`.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `token` - The token this IRS applies to.
+ /// * `irs` - The IRS contract address.
+ ///
+ /// # Authorization
+ ///
+ /// Requires compliance contract authorization.
+ fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) {
+ get_compliance_address(e).require_auth();
+ set_irs_address(e, &token, &irs);
+ }
+
+ /// Adds a country to the restriction list for `token`.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `token` - The token address.
+ /// * `country` - The ISO 3166-1 numeric country code to restrict.
+ ///
+ /// # Authorization
+ ///
+ /// Requires compliance contract authorization.
+ ///
+ /// # Events
+ ///
+ /// Emits [`CountryRestricted`].
+ fn add_country_restriction(e: &Env, token: Address, country: u32) {
+ get_compliance_address(e).require_auth();
+ set_country_restricted(e, &token, country);
+ CountryRestricted { token, country }.publish(e);
+ }
+
+ /// Removes a country from the restriction list for `token`.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `token` - The token address.
+ /// * `country` - The ISO 3166-1 numeric country code to unrestrict.
+ ///
+ /// # Authorization
+ ///
+ /// Requires compliance contract authorization.
+ ///
+ /// # Events
+ ///
+ /// Emits [`CountryUnrestricted`].
+ fn remove_country_restriction(e: &Env, token: Address, country: u32) {
+ get_compliance_address(e).require_auth();
+ remove_country_restricted(e, &token, country);
+ CountryUnrestricted { token, country }.publish(e);
+ }
+
+ /// Adds multiple countries to the restriction list in a single call.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `token` - The token address.
+ /// * `countries` - The country codes to restrict.
+ ///
+ /// # Authorization
+ ///
+ /// Requires compliance contract authorization.
+ ///
+ /// # Events
+ ///
+ /// Emits [`CountryRestricted`] for each country added.
+ fn batch_restrict_countries(e: &Env, token: Address, countries: Vec) {
+ get_compliance_address(e).require_auth();
+ for country in countries.iter() {
+ set_country_restricted(e, &token, country);
+ CountryRestricted { token: token.clone(), country }.publish(e);
+ }
+ }
+
+ /// Removes multiple countries from the restriction list in a single
+ /// call.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `token` - The token address.
+ /// * `countries` - The country codes to unrestrict.
+ ///
+ /// # Authorization
+ ///
+ /// Requires compliance contract authorization.
+ ///
+ /// # Events
+ ///
+ /// Emits [`CountryUnrestricted`] for each country removed.
+ fn batch_unrestrict_countries(e: &Env, token: Address, countries: Vec) {
+ get_compliance_address(e).require_auth();
+ for country in countries.iter() {
+ remove_country_restricted(e, &token, country);
+ CountryUnrestricted { token: token.clone(), country }.publish(e);
+ }
+ }
+
+ /// Returns whether `country` is on the restriction list for `token`.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `token` - The token address.
+ /// * `country` - The ISO 3166-1 numeric country code.
+ fn is_country_restricted(e: &Env, token: Address, country: u32) -> bool {
+ is_country_restricted(e, &token, country)
+ }
+
+ /// No-op — this module does not track transfer state.
+ fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {}
+
+ /// No-op — this module does not track mint state.
+ fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {}
+
+ /// No-op — this module does not track burn state.
+ fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {}
+
+ /// Checks whether `to` has any restricted country in the IRS.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `_from` - The sender (unused).
+ /// * `to` - The recipient whose country data is checked.
+ /// * `_amount` - The transfer amount (unused).
+ /// * `token` - The token address.
+ ///
+ /// # Returns
+ ///
+ /// `false` if the recipient has any restricted country, `true`
+ /// otherwise.
+ ///
+ /// # Cross-Contract Calls
+ ///
+ /// Calls the IRS to resolve country data for `to`.
+ fn can_transfer(e: &Env, _from: Address, to: Address, _amount: i128, token: Address) -> bool {
+ let entries = get_irs_country_data_entries(e, &token, &to);
+ for entry in entries.iter() {
+ if is_country_restricted(e, &token, country_code(&entry.country)) {
+ return false;
+ }
+ }
+ true
+ }
+
+ /// Delegates to [`can_transfer`](CountryRestrict::can_transfer) — same
+ /// country check applies to mints.
+ fn can_create(e: &Env, to: Address, amount: i128, token: Address) -> bool {
+ Self::can_transfer(e, to.clone(), to, amount, token)
+ }
+
+ /// Returns the module name for identification.
+ fn name(e: &Env) -> String {
+ module_name(e, "CountryRestrictModule")
+ }
+
+ /// Returns the compliance contract address.
+ fn get_compliance_address(e: &Env) -> Address {
+ get_compliance_address(e)
+ }
+
+ /// Sets the compliance contract address (one-time only).
+ ///
+ /// Implementers must gate this entrypoint with bootstrap-admin auth before
+ /// delegating to
+ /// [`storage::set_compliance_address`](super::storage::set_compliance_address).
+ ///
+ ///
+ /// # Panics
+ ///
+ /// Panics if the compliance address has already been set.
+ fn set_compliance_address(e: &Env, compliance: Address);
+}
+
+#[cfg(test)]
+mod test {
+ extern crate std;
+
+ use soroban_sdk::{
+ contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal,
+ Val, Vec,
+ };
+
+ use super::*;
+ use crate::rwa::{
+ identity_registry_storage::{
+ CountryData, CountryDataManager, CountryRelation, IdentityRegistryStorage,
+ IndividualCountryRelation, OrganizationCountryRelation,
+ },
+ utils::token_binder::TokenBinder,
+ };
+
+ #[contract]
+ struct MockIRSContract;
+
+ #[contracttype]
+ #[derive(Clone)]
+ enum MockIRSStorageKey {
+ Identity(Address),
+ CountryEntries(Address),
+ }
+
+ #[contractimpl]
+ impl TokenBinder for MockIRSContract {
+ fn linked_tokens(e: &Env) -> Vec {
+ Vec::new(e)
+ }
+
+ fn bind_token(_e: &Env, _token: Address, _operator: Address) {
+ unreachable!("bind_token is not used in these tests");
+ }
+
+ fn unbind_token(_e: &Env, _token: Address, _operator: Address) {
+ unreachable!("unbind_token is not used in these tests");
+ }
+ }
+
+ #[contractimpl]
+ impl IdentityRegistryStorage for MockIRSContract {
+ fn add_identity(
+ _e: &Env,
+ _account: Address,
+ _identity: Address,
+ _country_data_list: Vec,
+ _operator: Address,
+ ) {
+ unreachable!("add_identity is not used in these tests");
+ }
+
+ fn remove_identity(_e: &Env, _account: Address, _operator: Address) {
+ unreachable!("remove_identity is not used in these tests");
+ }
+
+ fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) {
+ unreachable!("modify_identity is not used in these tests");
+ }
+
+ fn recover_identity(
+ _e: &Env,
+ _old_account: Address,
+ _new_account: Address,
+ _operator: Address,
+ ) {
+ unreachable!("recover_identity is not used in these tests");
+ }
+
+ fn stored_identity(e: &Env, account: Address) -> Address {
+ e.storage()
+ .persistent()
+ .get(&MockIRSStorageKey::Identity(account.clone()))
+ .unwrap_or(account)
+ }
+ }
+
+ #[contractimpl]
+ impl CountryDataManager for MockIRSContract {
+ fn add_country_data_entries(
+ _e: &Env,
+ _account: Address,
+ _country_data_list: Vec,
+ _operator: Address,
+ ) {
+ unreachable!("add_country_data_entries is not used in these tests");
+ }
+
+ fn modify_country_data(
+ _e: &Env,
+ _account: Address,
+ _index: u32,
+ _country_data: Val,
+ _operator: Address,
+ ) {
+ unreachable!("modify_country_data is not used in these tests");
+ }
+
+ fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) {
+ unreachable!("delete_country_data is not used in these tests");
+ }
+
+ fn get_country_data_entries(e: &Env, account: Address) -> Vec {
+ let entries: Vec = e
+ .storage()
+ .persistent()
+ .get(&MockIRSStorageKey::CountryEntries(account))
+ .unwrap_or_else(|| Vec::new(e));
+
+ Vec::from_iter(e, entries.iter().map(|entry| entry.into_val(e)))
+ }
+ }
+
+ #[contractimpl]
+ impl MockIRSContract {
+ pub fn set_country_data_entries(e: &Env, account: Address, entries: Vec) {
+ e.storage().persistent().set(&MockIRSStorageKey::CountryEntries(account), &entries);
+ }
+ }
+
+ #[contract]
+ struct TestCountryRestrictContract;
+
+ #[contractimpl(contracttrait)]
+ impl CountryRestrict for TestCountryRestrictContract {
+ fn set_compliance_address(_e: &Env, _compliance: Address) {
+ unreachable!("set_compliance_address is not used in these tests");
+ }
+ }
+
+ fn individual_country(code: u32) -> CountryData {
+ CountryData {
+ country: CountryRelation::Individual(IndividualCountryRelation::Residence(code)),
+ metadata: None,
+ }
+ }
+
+ fn organization_country(code: u32) -> CountryData {
+ CountryData {
+ country: CountryRelation::Organization(
+ OrganizationCountryRelation::OperatingJurisdiction(code),
+ ),
+ metadata: None,
+ }
+ }
+
+ #[test]
+ fn can_transfer_and_create_reject_when_any_country_is_restricted() {
+ let e = Env::default();
+ let module_id = e.register(TestCountryRestrictContract, ());
+ let irs_id = e.register(MockIRSContract, ());
+ let irs = MockIRSContractClient::new(&e, &irs_id);
+ let token = Address::generate(&e);
+ let from = Address::generate(&e);
+ let to = Address::generate(&e);
+
+ irs.set_country_data_entries(
+ &to,
+ &vec![&e, individual_country(250), organization_country(408)],
+ );
+
+ e.as_contract(&module_id, || {
+ set_irs_address(&e, &token, &irs_id);
+ set_country_restricted(&e, &token, 408);
+
+ assert!(!::can_transfer(
+ &e,
+ from.clone(),
+ to.clone(),
+ 100,
+ token.clone(),
+ ));
+ assert!(!::can_create(
+ &e,
+ to.clone(),
+ 100,
+ token.clone(),
+ ));
+ });
+ }
+
+ #[test]
+ fn can_transfer_and_create_allow_when_no_country_is_restricted() {
+ let e = Env::default();
+ let module_id = e.register(TestCountryRestrictContract, ());
+ let irs_id = e.register(MockIRSContract, ());
+ let irs = MockIRSContractClient::new(&e, &irs_id);
+ let token = Address::generate(&e);
+ let from = Address::generate(&e);
+ let empty_to = Address::generate(&e);
+ let unrestricted_to = Address::generate(&e);
+
+ irs.set_country_data_entries(
+ &unrestricted_to,
+ &vec![&e, individual_country(250), organization_country(276)],
+ );
+
+ e.as_contract(&module_id, || {
+ set_irs_address(&e, &token, &irs_id);
+ set_country_restricted(&e, &token, 408);
+
+ assert!(::can_transfer(
+ &e,
+ from.clone(),
+ empty_to.clone(),
+ 100,
+ token.clone(),
+ ));
+ assert!(::can_create(
+ &e,
+ empty_to,
+ 100,
+ token.clone(),
+ ));
+
+ assert!(::can_transfer(
+ &e,
+ from.clone(),
+ unrestricted_to.clone(),
+ 100,
+ token.clone(),
+ ));
+ assert!(::can_create(
+ &e,
+ unrestricted_to,
+ 100,
+ token.clone(),
+ ));
+ });
+ }
+}
diff --git a/packages/tokens/src/rwa/compliance/modules/country_restrict/storage.rs b/packages/tokens/src/rwa/compliance/modules/country_restrict/storage.rs
new file mode 100644
index 000000000..5d8f13cb2
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/country_restrict/storage.rs
@@ -0,0 +1,54 @@
+use soroban_sdk::{contracttype, Address, Env};
+
+use crate::rwa::compliance::modules::{MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD};
+
+#[contracttype]
+#[derive(Clone)]
+pub enum CountryRestrictStorageKey {
+ /// Per-(token, country) restriction flag.
+ RestrictedCountry(Address, u32),
+}
+
+/// Returns whether the given country is on the restriction list for `token`.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `country` - The ISO 3166-1 numeric country code.
+pub fn is_country_restricted(e: &Env, token: &Address, country: u32) -> bool {
+ let key = CountryRestrictStorageKey::RestrictedCountry(token.clone(), country);
+ e.storage()
+ .persistent()
+ .get(&key)
+ .inspect(|_: &bool| {
+ e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT);
+ })
+ .unwrap_or_default()
+}
+
+/// Adds a country to the restriction list for `token`.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `country` - The ISO 3166-1 numeric country code to restrict.
+pub fn set_country_restricted(e: &Env, token: &Address, country: u32) {
+ let key = CountryRestrictStorageKey::RestrictedCountry(token.clone(), country);
+ e.storage().persistent().set(&key, &true);
+ e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT);
+}
+
+/// Removes a country from the restriction list for `token`.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `token` - The token address.
+/// * `country` - The ISO 3166-1 numeric country code to unrestrict.
+pub fn remove_country_restricted(e: &Env, token: &Address, country: u32) {
+ e.storage()
+ .persistent()
+ .remove(&CountryRestrictStorageKey::RestrictedCountry(token.clone(), country));
+}
diff --git a/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/mod.rs b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/mod.rs
new file mode 100644
index 000000000..7261fc452
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/mod.rs
@@ -0,0 +1,274 @@
+//! 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, contracttrait, vec, Address, Env, String, Vec};
+pub use storage::LockedTokens;
+use storage::{
+ get_internal_balance, get_locks, get_lockup_period, get_total_locked, set_internal_balance,
+ set_locks, set_lockup_period, set_total_locked,
+};
+
+use super::storage::{
+ add_i128_or_panic, get_compliance_address, hooks_verified, module_name,
+ require_non_negative_amount, sub_i128_or_panic, verify_required_hooks,
+};
+use crate::rwa::compliance::ComplianceHook;
+
+/// 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,
+}
+
+// ################## 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));
+}
+
+#[contracttrait]
+pub trait InitialLockupPeriod {
+ // ################## QUERY STATE ##################
+
+ fn get_lockup_period(e: &Env, token: Address) -> u64 {
+ get_lockup_period(e, &token)
+ }
+
+ fn get_total_locked(e: &Env, token: Address, wallet: Address) -> i128 {
+ get_total_locked(e, &token, &wallet)
+ }
+
+ fn get_locked_tokens(e: &Env, token: Address, wallet: Address) -> Vec {
+ get_locks(e, &token, &wallet)
+ }
+
+ fn get_internal_balance(e: &Env, token: Address, wallet: Address) -> i128 {
+ get_internal_balance(e, &token, &wallet)
+ }
+
+ fn can_transfer(e: &Env, from: Address, _to: 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
+ }
+
+ 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)
+ }
+
+ // ################## CHANGE STATE ##################
+
+ fn set_lockup_period(e: &Env, token: Address, lockup_seconds: u64) {
+ get_compliance_address(e).require_auth();
+ set_lockup_period(e, &token, lockup_seconds);
+ LockupPeriodSet { token, lockup_seconds }.publish(e);
+ }
+
+ fn pre_set_lockup_state(
+ e: &Env,
+ token: Address,
+ wallet: Address,
+ balance: i128,
+ locks: Vec,
+ ) {
+ get_compliance_address(e).require_auth();
+ 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);
+ }
+
+ fn verify_hook_wiring(e: &Env) {
+ verify_required_hooks(e, Self::required_hooks(e));
+ }
+
+ fn on_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) {
+ get_compliance_address(e).require_auth();
+ 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));
+ }
+
+ fn on_created(e: &Env, to: Address, amount: i128, token: Address) {
+ get_compliance_address(e).require_auth();
+ 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));
+ }
+
+ fn on_destroyed(e: &Env, from: Address, amount: i128, token: Address) {
+ get_compliance_address(e).require_auth();
+ 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));
+ }
+
+ /// Implementers must gate this entrypoint with bootstrap-admin auth before
+ /// delegating to
+ /// [`storage::set_compliance_address`](super::storage::set_compliance_address).
+ fn set_compliance_address(e: &Env, compliance: Address);
+
+ // ################## HELPERS ##################
+
+ fn required_hooks(e: &Env) -> Vec {
+ vec![
+ e,
+ ComplianceHook::CanTransfer,
+ ComplianceHook::Created,
+ ComplianceHook::Transferred,
+ ComplianceHook::Destroyed,
+ ]
+ }
+}
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..2c9788c8d
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/storage.rs
@@ -0,0 +1,152 @@
+use soroban_sdk::{contracttype, Address, Env, Vec};
+
+use crate::rwa::compliance::modules::{MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD};
+
+/// 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),
+}
+
+/// 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);
+}
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..f758b7a97
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/test.rs
@@ -0,0 +1,190 @@
+extern crate std;
+
+use soroban_sdk::{
+ contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env,
+};
+
+use super::*;
+use crate::rwa::{
+ compliance::{
+ modules::storage::{hooks_verified, set_compliance_address, ComplianceModuleStorageKey},
+ Compliance, ComplianceHook,
+ },
+ utils::token_binder::TokenBinder,
+};
+
+#[contract]
+struct TestInitialLockupPeriodContract;
+
+#[contractimpl(contracttrait)]
+impl InitialLockupPeriod for TestInitialLockupPeriodContract {
+ fn set_compliance_address(_e: &Env, _compliance: Address) {
+ unreachable!("set_compliance_address is not used in these tests");
+ }
+}
+
+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(TestInitialLockupPeriodContract, ());
+ 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();
+ e.mock_all_auths();
+
+ let module_id = e.register(TestInitialLockupPeriodContract, ());
+ 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.clone(),
+ wallet.clone(),
+ 100,
+ vec![
+ &e,
+ LockedTokens {
+ amount: 80,
+ release_timestamp: e.ledger().timestamp().saturating_add(60),
+ },
+ ],
+ );
+
+ assert_eq!(
+ ::get_internal_balance(
+ &e,
+ token.clone(),
+ wallet.clone(),
+ ),
+ 100
+ );
+ assert_eq!(
+ ::get_total_locked(
+ &e,
+ token.clone(),
+ wallet.clone(),
+ ),
+ 80
+ );
+ assert!(!::can_transfer(
+ &e,
+ wallet.clone(),
+ Address::generate(&e),
+ 21,
+ token.clone(),
+ ));
+ assert!(::can_transfer(
+ &e,
+ wallet,
+ Address::generate(&e),
+ 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..aa295329f
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/max_balance/mod.rs
@@ -0,0 +1,218 @@
+//! 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, contracttrait, vec, Address, Env, String, Vec};
+use storage::{get_id_balance, get_max_balance, set_id_balance, set_max_balance};
+
+use super::storage::{
+ add_i128_or_panic, get_compliance_address, get_irs_client, hooks_verified, module_name,
+ require_non_negative_amount, set_irs_address, sub_i128_or_panic, verify_required_hooks,
+};
+use crate::rwa::compliance::ComplianceHook;
+
+/// 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,
+}
+
+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
+}
+
+#[contracttrait]
+pub trait MaxBalance {
+ fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) {
+ get_compliance_address(e).require_auth();
+ set_irs_address(e, &token, &irs);
+ }
+
+ fn set_max_balance(e: &Env, token: Address, max: i128) {
+ get_compliance_address(e).require_auth();
+ require_non_negative_amount(e, max);
+ set_max_balance(e, &token, max);
+ MaxBalanceSet { token, max_balance: max }.publish(e);
+ }
+
+ fn pre_set_module_state(e: &Env, token: Address, identity: Address, balance: i128) {
+ get_compliance_address(e).require_auth();
+ require_non_negative_amount(e, balance);
+ set_id_balance(e, &token, &identity, balance);
+ IDBalancePreSet { token, identity, balance }.publish(e);
+ }
+
+ fn batch_pre_set_module_state(
+ e: &Env,
+ token: Address,
+ identities: Vec,
+ balances: Vec,
+ ) {
+ get_compliance_address(e).require_auth();
+ 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);
+ }
+ }
+
+ fn get_investor_balance(e: &Env, token: Address, identity: Address) -> i128 {
+ get_id_balance(e, &token, &identity)
+ }
+
+ fn required_hooks(e: &Env) -> Vec {
+ vec![
+ e,
+ ComplianceHook::CanTransfer,
+ ComplianceHook::CanCreate,
+ ComplianceHook::Transferred,
+ ComplianceHook::Created,
+ ComplianceHook::Destroyed,
+ ]
+ }
+
+ fn verify_hook_wiring(e: &Env) {
+ verify_required_hooks(e, Self::required_hooks(e));
+ }
+
+ fn on_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) {
+ get_compliance_address(e).require_auth();
+ 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);
+ }
+
+ fn on_created(e: &Env, to: Address, amount: i128, token: Address) {
+ get_compliance_address(e).require_auth();
+ 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);
+ }
+
+ fn on_destroyed(e: &Env, from: Address, amount: i128, token: Address) {
+ get_compliance_address(e).require_auth();
+ 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));
+ }
+
+ 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)
+ }
+
+ 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)
+ }
+
+ fn name(e: &Env) -> String {
+ module_name(e, "MaxBalanceModule")
+ }
+
+ fn get_compliance_address(e: &Env) -> Address {
+ get_compliance_address(e)
+ }
+
+ /// Implementers must gate this entrypoint with bootstrap-admin auth before
+ /// delegating to
+ /// [`storage::set_compliance_address`](super::storage::set_compliance_address).
+ fn set_compliance_address(e: &Env, compliance: Address);
+}
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..60e6cb997
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/max_balance/storage.rs
@@ -0,0 +1,75 @@
+use soroban_sdk::{contracttype, Address, Env};
+
+use crate::rwa::compliance::modules::{MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD};
+
+#[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),
+}
+
+/// 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);
+}
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..53281cadb
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/max_balance/test.rs
@@ -0,0 +1,351 @@
+extern crate std;
+
+use soroban_sdk::{
+ contract, contractimpl, contracttype, testutils::Address as _, Address, Env, Val, Vec,
+};
+
+use super::{
+ storage::{set_id_balance, set_max_balance},
+ *,
+};
+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;
+
+#[contractimpl(contracttrait)]
+impl MaxBalance for TestMaxBalanceContract {
+ fn set_compliance_address(_e: &Env, _compliance: Address) {
+ unreachable!("set_compliance_address is not used in these tests");
+ }
+}
+
+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.clone(),
+ 50,
+ token.clone(),
+ ));
+ assert!(::can_create(
+ &e,
+ recipient,
+ 40,
+ token.clone(),
+ ));
+ });
+}
+
+#[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.clone(),
+ recipient.clone(),
+ 50,
+ token.clone(),
+ ));
+ assert!(::can_transfer(
+ &e,
+ sender,
+ recipient,
+ 40,
+ token.clone(),
+ ));
+ });
+}
+
+#[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.clone(),
+ 1_000,
+ token.clone(),
+ ));
+ assert!(!::can_create(
+ &e,
+ recipient,
+ -1,
+ token.clone(),
+ ));
+ });
+}
+
+#[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,));
+ });
+}
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..d138a002b
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/supply_limit/mod.rs
@@ -0,0 +1,116 @@
+//! 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, contracttrait, vec, Address, Env, String, Vec};
+use storage::{get_internal_supply, get_supply_limit, set_internal_supply, set_supply_limit};
+
+use super::storage::{
+ add_i128_or_panic, get_compliance_address, hooks_verified, module_name,
+ require_non_negative_amount, sub_i128_or_panic, verify_required_hooks,
+};
+use crate::rwa::compliance::ComplianceHook;
+
+/// 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,
+}
+
+#[contracttrait]
+pub trait SupplyLimit {
+ fn set_supply_limit(e: &Env, token: Address, limit: i128) {
+ get_compliance_address(e).require_auth();
+ require_non_negative_amount(e, limit);
+ set_supply_limit(e, &token, limit);
+ SupplyLimitSet { token, limit }.publish(e);
+ }
+
+ fn pre_set_internal_supply(e: &Env, token: Address, supply: i128) {
+ get_compliance_address(e).require_auth();
+ require_non_negative_amount(e, supply);
+ set_internal_supply(e, &token, supply);
+ }
+
+ fn get_supply_limit(e: &Env, token: Address) -> i128 {
+ get_supply_limit(e, &token)
+ }
+
+ fn get_internal_supply(e: &Env, token: Address) -> i128 {
+ get_internal_supply(e, &token)
+ }
+
+ fn required_hooks(e: &Env) -> Vec {
+ vec![e, ComplianceHook::CanCreate, ComplianceHook::Created, ComplianceHook::Destroyed]
+ }
+
+ fn verify_hook_wiring(e: &Env) {
+ verify_required_hooks(e, Self::required_hooks(e));
+ }
+
+ fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {}
+
+ fn on_created(e: &Env, _to: Address, amount: i128, token: Address) {
+ get_compliance_address(e).require_auth();
+ 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));
+ }
+
+ fn on_destroyed(e: &Env, _from: Address, amount: i128, token: Address) {
+ get_compliance_address(e).require_auth();
+ 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));
+ }
+
+ 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 {
+ 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
+ }
+
+ fn name(e: &Env) -> String {
+ module_name(e, "SupplyLimitModule")
+ }
+
+ fn get_compliance_address(e: &Env) -> Address {
+ get_compliance_address(e)
+ }
+
+ /// Implementers must gate this entrypoint with bootstrap-admin auth before
+ /// delegating to
+ /// [`storage::set_compliance_address`](super::storage::set_compliance_address).
+ fn set_compliance_address(e: &Env, compliance: Address);
+}
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..f1ec2f4df
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/supply_limit/storage.rs
@@ -0,0 +1,96 @@
+use soroban_sdk::{contracttype, panic_with_error, Address, Env};
+
+use crate::rwa::compliance::modules::{
+ ComplianceModuleError, MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD,
+};
+
+#[contracttype]
+#[derive(Clone)]
+pub enum SupplyLimitStorageKey {
+ /// Per-token supply cap.
+ SupplyLimit(Address),
+ /// Per-token internal supply counter (updated via hooks).
+ InternalSupply(Address),
+}
+
+/// 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);
+}
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..21018e09e
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/supply_limit/test.rs
@@ -0,0 +1,211 @@
+extern crate std;
+
+use soroban_sdk::{contract, contractimpl, contracttype, testutils::Address as _, Address, Env};
+
+use super::*;
+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;
+
+#[contractimpl(contracttrait)]
+impl SupplyLimit for TestSupplyLimitContract {
+ fn set_compliance_address(_e: &Env, _compliance: Address) {
+ unreachable!("set_compliance_address is not used in these tests");
+ }
+}
+
+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!(::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);
+ let recipient = Address::generate(&e);
+
+ e.as_contract(&module_id, || {
+ arm_hooks(&e);
+
+ assert!(::can_create(
+ &e,
+ recipient.clone(),
+ 100,
+ token.clone(),
+ ));
+ assert!(!::can_create(
+ &e,
+ recipient,
+ -1,
+ token.clone(),
+ ));
+ });
+}
+
+#[test]
+fn hooks_update_internal_supply_and_cap_future_mints() {
+ let e = Env::default();
+ e.mock_all_auths();
+
+ let module_id = e.register(TestSupplyLimitContract, ());
+ let compliance_id = e.register(MockComplianceContract, ());
+ let token = Address::generate(&e);
+ let recipient = Address::generate(&e);
+ let client = TestSupplyLimitContractClient::new(&e, &module_id);
+
+ e.as_contract(&module_id, || {
+ set_compliance_address(&e, &compliance_id);
+ arm_hooks(&e);
+ });
+
+ client.set_supply_limit(&token, &100);
+
+ assert!(client.can_create(&recipient.clone(), &80, &token));
+ client.on_created(&recipient.clone(), &80, &token);
+ assert_eq!(client.get_internal_supply(&token), 80);
+
+ assert!(!client.can_create(&recipient.clone(), &30, &token));
+
+ client.on_destroyed(&recipient.clone(), &20, &token);
+ assert_eq!(client.get_internal_supply(&token), 60);
+ assert!(client.can_create(&recipient, &40, &token));
+}
+
+#[test]
+fn pre_set_internal_supply_seeds_existing_supply_for_cap_checks() {
+ let e = Env::default();
+ e.mock_all_auths();
+
+ let module_id = e.register(TestSupplyLimitContract, ());
+ let compliance_id = e.register(MockComplianceContract, ());
+ let token = Address::generate(&e);
+ let recipient = Address::generate(&e);
+ let client = TestSupplyLimitContractClient::new(&e, &module_id);
+
+ e.as_contract(&module_id, || {
+ set_compliance_address(&e, &compliance_id);
+ arm_hooks(&e);
+ });
+
+ client.set_supply_limit(&token, &100);
+ client.pre_set_internal_supply(&token, &90);
+
+ assert_eq!(client.get_internal_supply(&token), 90);
+ assert!(!client.can_create(&recipient.clone(), &11, &token));
+ assert!(client.can_create(&recipient, &10, &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..5bc76a97f
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/mod.rs
@@ -0,0 +1,239 @@
+//! 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, contracttrait, panic_with_error, vec, Address, Env, String, Vec};
+use storage::{get_counter, get_limits, set_counter, set_limits};
+pub use storage::{Limit, TransferCounter};
+
+use super::storage::{
+ add_i128_or_panic, get_compliance_address, get_irs_client, hooks_verified, module_name,
+ require_non_negative_amount, set_irs_address, verify_required_hooks,
+};
+use crate::rwa::compliance::{modules::ComplianceModuleError, ComplianceHook};
+
+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,
+}
+
+// ################## 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);
+ }
+}
+
+#[contracttrait]
+pub trait TimeTransfersLimits {
+ // ################## QUERY STATE ##################
+
+ fn get_time_transfer_limits(e: &Env, token: Address) -> Vec {
+ get_limits(e, &token)
+ }
+
+ fn can_transfer(e: &Env, from: Address, _to: 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
+ }
+
+ 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)
+ }
+
+ // ################## CHANGE STATE ##################
+
+ fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) {
+ get_compliance_address(e).require_auth();
+ set_irs_address(e, &token, &irs);
+ }
+
+ fn set_time_transfer_limit(e: &Env, token: Address, limit: Limit) {
+ get_compliance_address(e).require_auth();
+ 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, limit }.publish(e);
+ }
+
+ fn batch_set_time_transfer_limit(e: &Env, token: Address, limits: Vec) {
+ get_compliance_address(e).require_auth();
+ for limit in limits.iter() {
+ Self::set_time_transfer_limit(e, token.clone(), limit);
+ }
+ }
+
+ fn remove_time_transfer_limit(e: &Env, token: Address, limit_time: u64) {
+ get_compliance_address(e).require_auth();
+ 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, limit_time }.publish(e);
+ }
+
+ fn batch_remove_time_transfer_limit(e: &Env, token: Address, limit_times: Vec) {
+ get_compliance_address(e).require_auth();
+ for lt in limit_times.iter() {
+ Self::remove_time_transfer_limit(e, token.clone(), lt);
+ }
+ }
+
+ fn pre_set_transfer_counter(
+ e: &Env,
+ token: Address,
+ identity: Address,
+ limit_time: u64,
+ counter: TransferCounter,
+ ) {
+ get_compliance_address(e).require_auth();
+ 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);
+ }
+
+ fn verify_hook_wiring(e: &Env) {
+ verify_required_hooks(e, Self::required_hooks(e));
+ }
+
+ fn on_transfer(e: &Env, from: Address, _to: Address, amount: i128, token: Address) {
+ get_compliance_address(e).require_auth();
+ 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);
+ }
+
+ fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {}
+
+ fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {}
+
+ /// Implementers must gate this entrypoint with bootstrap-admin auth before
+ /// delegating to
+ /// [`storage::set_compliance_address`](super::storage::set_compliance_address).
+ fn set_compliance_address(e: &Env, compliance: Address);
+
+ // ################## HELPERS ##################
+
+ fn required_hooks(e: &Env) -> Vec {
+ vec![e, ComplianceHook::CanTransfer, ComplianceHook::Transferred]
+ }
+}
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..8b7e38e5e
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/storage.rs
@@ -0,0 +1,104 @@
+use soroban_sdk::{contracttype, Address, Env, Vec};
+
+use crate::rwa::compliance::modules::{MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD};
+
+/// 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),
+}
+
+/// 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);
+}
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..aced1114d
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/test.rs
@@ -0,0 +1,282 @@
+extern crate std;
+
+use soroban_sdk::{
+ contract, contractimpl, contracttype, testutils::Address as _, Address, Env, Val, Vec,
+};
+
+use super::*;
+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 TestTimeTransfersLimitsContract;
+
+#[contractimpl(contracttrait)]
+impl TimeTransfersLimits for TestTimeTransfersLimitsContract {
+ fn set_compliance_address(_e: &Env, _compliance: Address) {
+ unreachable!("set_compliance_address is not used in these tests");
+ }
+}
+
+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(TestTimeTransfersLimitsContract, ());
+ 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();
+ e.mock_all_auths();
+
+ let module_id = e.register(TestTimeTransfersLimitsContract, ());
+ 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);
+ let recipient = Address::generate(&e);
+ let client = TestTimeTransfersLimitsContractClient::new(&e, &module_id);
+
+ 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);
+ });
+
+ client.set_time_transfer_limit(&token, &Limit { limit_time: 60, limit_value: 100 });
+ client.pre_set_transfer_counter(
+ &token,
+ &sender_identity,
+ &60,
+ &TransferCounter { value: 90, timer: e.ledger().timestamp().saturating_add(60) },
+ );
+
+ assert!(!client.can_transfer(&sender.clone(), &recipient.clone(), &11, &token));
+ assert!(client.can_transfer(&sender, &recipient, &10, &token));
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #400)")]
+fn set_time_transfer_limit_rejects_more_than_four_limits() {
+ let e = Env::default();
+ e.mock_all_auths();
+
+ let module_id = e.register(TestTimeTransfersLimitsContract, ());
+ let compliance = Address::generate(&e);
+ let token = Address::generate(&e);
+ let client = TestTimeTransfersLimitsContractClient::new(&e, &module_id);
+
+ e.as_contract(&module_id, || {
+ set_compliance_address(&e, &compliance);
+ });
+
+ for limit_time in [60_u64, 120, 180, 240] {
+ client.set_time_transfer_limit(&token, &Limit { limit_time, limit_value: 100 });
+ }
+
+ client.set_time_transfer_limit(&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..1198a0eb2
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/mod.rs
@@ -0,0 +1,200 @@
+//! Transfer restriction (address allowlist) compliance module — Stellar port
+//! of T-REX [`TransferRestrictModule.sol`][trex-src].
+//!
+//! Maintains a per-token address allowlist. Transfers pass if the sender is
+//! on the list; otherwise the recipient must be.
+//!
+//! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/TransferRestrictModule.sol
+
+pub mod storage;
+#[cfg(test)]
+mod test;
+
+use soroban_sdk::{contractevent, contracttrait, Address, Env, String, Vec};
+use storage::{is_user_allowed, remove_user_allowed, set_user_allowed};
+
+use super::storage::{get_compliance_address, module_name};
+
+/// 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,
+}
+
+/// Transfer restriction compliance trait.
+///
+/// Provides default implementations for maintaining a per-token address
+/// allowlist. Transfers are allowed if the sender is allowlisted; otherwise
+/// the recipient must be (T-REX semantics).
+#[contracttrait]
+pub trait TransferRestrict {
+ /// Adds `user` to the transfer allowlist for `token`.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `token` - The token address.
+ /// * `user` - The address to allow.
+ ///
+ /// # Authorization
+ ///
+ /// Requires compliance contract authorization.
+ ///
+ /// # Events
+ ///
+ /// Emits [`UserAllowed`].
+ fn allow_user(e: &Env, token: Address, user: Address) {
+ get_compliance_address(e).require_auth();
+ set_user_allowed(e, &token, &user);
+ UserAllowed { token, user }.publish(e);
+ }
+
+ /// Removes `user` from the transfer allowlist for `token`.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `token` - The token address.
+ /// * `user` - The address to disallow.
+ ///
+ /// # Authorization
+ ///
+ /// Requires compliance contract authorization.
+ ///
+ /// # Events
+ ///
+ /// Emits [`UserDisallowed`].
+ fn disallow_user(e: &Env, token: Address, user: Address) {
+ get_compliance_address(e).require_auth();
+ remove_user_allowed(e, &token, &user);
+ UserDisallowed { token, user }.publish(e);
+ }
+
+ /// Adds multiple users to the transfer allowlist in a single call.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `token` - The token address.
+ /// * `users` - The addresses to allow.
+ ///
+ /// # Authorization
+ ///
+ /// Requires compliance contract authorization.
+ ///
+ /// # Events
+ ///
+ /// Emits [`UserAllowed`] for each user added.
+ fn batch_allow_users(e: &Env, token: Address, users: Vec) {
+ get_compliance_address(e).require_auth();
+ 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.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `token` - The token address.
+ /// * `users` - The addresses to disallow.
+ ///
+ /// # Authorization
+ ///
+ /// Requires compliance contract authorization.
+ ///
+ /// # Events
+ ///
+ /// Emits [`UserDisallowed`] for each user removed.
+ fn batch_disallow_users(e: &Env, token: Address, users: Vec) {
+ get_compliance_address(e).require_auth();
+ for user in users.iter() {
+ remove_user_allowed(e, &token, &user);
+ UserDisallowed { token: token.clone(), user }.publish(e);
+ }
+ }
+
+ /// Returns whether `user` is on the transfer allowlist for `token`.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `token` - The token address.
+ /// * `user` - The address to check.
+ fn is_user_allowed(e: &Env, token: Address, user: Address) -> bool {
+ is_user_allowed(e, &token, &user)
+ }
+
+ /// No-op — this module does not track transfer state.
+ fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {}
+
+ /// No-op — this module does not track mint state.
+ fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {}
+
+ /// No-op — this module does not track burn state.
+ fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {}
+
+ /// Checks whether the transfer is allowed by the address allowlist.
+ ///
+ /// 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.
+ /// * `_amount` - The transfer amount (unused).
+ /// * `token` - The token address.
+ ///
+ /// # Returns
+ ///
+ /// `true` if the sender or recipient is allowlisted, `false` otherwise.
+ fn can_transfer(e: &Env, from: Address, to: Address, _amount: i128, token: Address) -> bool {
+ if is_user_allowed(e, &token, &from) {
+ return true;
+ }
+ is_user_allowed(e, &token, &to)
+ }
+
+ /// Always returns `true` — mints are not restricted by this module.
+ fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool {
+ true
+ }
+
+ /// Returns the module name for identification.
+ fn name(e: &Env) -> String {
+ module_name(e, "TransferRestrictModule")
+ }
+
+ /// Returns the compliance contract address.
+ fn get_compliance_address(e: &Env) -> Address {
+ get_compliance_address(e)
+ }
+
+ /// Sets the compliance contract address (one-time only).
+ ///
+ /// Implementers must gate this entrypoint with bootstrap-admin auth before
+ /// delegating to
+ /// [`storage::set_compliance_address`](super::storage::set_compliance_address).
+ ///
+ ///
+ /// # Panics
+ ///
+ /// Panics if the compliance address has already been set.
+ fn set_compliance_address(e: &Env, compliance: Address);
+}
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..8fa25912f
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/storage.rs
@@ -0,0 +1,54 @@
+use soroban_sdk::{contracttype, Address, Env};
+
+use crate::rwa::compliance::modules::{MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD};
+
+#[contracttype]
+#[derive(Clone)]
+pub enum TransferRestrictStorageKey {
+ /// Per-(token, address) allowlist flag.
+ AllowedUser(Address, Address),
+}
+
+/// 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()));
+}
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..3ae8ba642
--- /dev/null
+++ b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/test.rs
@@ -0,0 +1,70 @@
+extern crate std;
+
+use soroban_sdk::{contract, contractimpl, testutils::Address as _, vec, Address, Env};
+
+use super::*;
+use crate::rwa::compliance::modules::storage::set_compliance_address;
+
+#[contract]
+struct TestTransferRestrictContract;
+
+#[contractimpl(contracttrait)]
+impl TransferRestrict for TestTransferRestrictContract {
+ fn set_compliance_address(_e: &Env, _compliance: Address) {
+ unreachable!("set_compliance_address is not used in these tests");
+ }
+}
+
+#[test]
+fn can_transfer_allows_sender_or_recipient_when_allowlisted() {
+ let e = Env::default();
+ e.mock_all_auths();
+
+ let module_id = e.register(TestTransferRestrictContract, ());
+ 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);
+ let client = TestTransferRestrictContractClient::new(&e, &module_id);
+
+ e.as_contract(&module_id, || {
+ set_compliance_address(&e, &compliance);
+ });
+
+ assert!(!client.can_transfer(&sender.clone(), &recipient.clone(), &100, &token));
+
+ client.allow_user(&token, &sender.clone());
+ assert!(client.can_transfer(&sender.clone(), &outsider.clone(), &100, &token));
+
+ client.disallow_user(&token, &sender.clone());
+ client.allow_user(&token, &recipient.clone());
+ assert!(client.can_transfer(&outsider, &recipient, &100, &token));
+}
+
+#[test]
+fn batch_allow_and_disallow_update_allowlist_entries() {
+ let e = Env::default();
+ e.mock_all_auths();
+
+ let module_id = e.register(TestTransferRestrictContract, ());
+ let compliance = Address::generate(&e);
+ let token = Address::generate(&e);
+ let user_a = Address::generate(&e);
+ let user_b = Address::generate(&e);
+ let client = TestTransferRestrictContractClient::new(&e, &module_id);
+
+ e.as_contract(&module_id, || {
+ set_compliance_address(&e, &compliance);
+ });
+
+ client.batch_allow_users(&token, &vec![&e, user_a.clone(), user_b.clone()]);
+
+ assert!(client.is_user_allowed(&token, &user_a.clone()));
+ assert!(client.is_user_allowed(&token, &user_b.clone()));
+
+ client.batch_disallow_users(&token, &vec![&e, user_a.clone(), user_b.clone()]);
+
+ assert!(!client.is_user_allowed(&token, &user_a));
+ assert!(!client.is_user_allowed(&token, &user_b));
+}