From 668c103f3dae1fca8d31a5bbef915b1864c80720 Mon Sep 17 00:00:00 2001 From: Aleksandr Pasevin Date: Mon, 23 Mar 2026 18:38:48 +0200 Subject: [PATCH 1/3] feat(rwa): add standalone transfer compliance modules Transplant the reviewed transfer restriction, time transfer, and lockup modules plus their example crates onto upstream/main as an independent PR. --- Cargo.lock | 24 ++ Cargo.toml | 3 + examples/rwa-initial-lockup-period/Cargo.toml | 15 + examples/rwa-initial-lockup-period/README.md | 64 ++++ examples/rwa-initial-lockup-period/src/lib.rs | 228 ++++++++++++++ examples/rwa-time-transfers-limits/Cargo.toml | 15 + examples/rwa-time-transfers-limits/README.md | 71 +++++ examples/rwa-time-transfers-limits/src/lib.rs | 202 +++++++++++++ examples/rwa-transfer-restrict/Cargo.toml | 15 + examples/rwa-transfer-restrict/README.md | 47 +++ examples/rwa-transfer-restrict/src/lib.rs | 83 ++++++ .../modules/initial_lockup_period/mod.rs | 274 +++++++++++++++++ .../modules/initial_lockup_period/storage.rs | 152 ++++++++++ .../modules/initial_lockup_period/test.rs | 190 ++++++++++++ .../tokens/src/rwa/compliance/modules/mod.rs | 3 + .../modules/time_transfers_limits/mod.rs | 239 +++++++++++++++ .../modules/time_transfers_limits/storage.rs | 104 +++++++ .../modules/time_transfers_limits/test.rs | 282 ++++++++++++++++++ .../modules/transfer_restrict/mod.rs | 200 +++++++++++++ .../modules/transfer_restrict/storage.rs | 54 ++++ .../modules/transfer_restrict/test.rs | 70 +++++ 21 files changed, 2335 insertions(+) create mode 100644 examples/rwa-initial-lockup-period/Cargo.toml create mode 100644 examples/rwa-initial-lockup-period/README.md create mode 100644 examples/rwa-initial-lockup-period/src/lib.rs create mode 100644 examples/rwa-time-transfers-limits/Cargo.toml create mode 100644 examples/rwa-time-transfers-limits/README.md create mode 100644 examples/rwa-time-transfers-limits/src/lib.rs create mode 100644 examples/rwa-transfer-restrict/Cargo.toml create mode 100644 examples/rwa-transfer-restrict/README.md create mode 100644 examples/rwa-transfer-restrict/src/lib.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/initial_lockup_period/mod.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/initial_lockup_period/storage.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/initial_lockup_period/test.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/time_transfers_limits/mod.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/time_transfers_limits/storage.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/time_transfers_limits/test.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/transfer_restrict/mod.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/transfer_restrict/storage.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/transfer_restrict/test.rs diff --git a/Cargo.lock b/Cargo.lock index 469b78e3b..9ef80730c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1586,6 +1586,22 @@ dependencies = [ "stellar-tokens", ] +[[package]] +name = "rwa-initial-lockup-period" +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 +1613,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..97cd8bd22 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,9 @@ members = [ "examples/ownable", "examples/pausable", "examples/rwa/*", + "examples/rwa-time-transfers-limits", + "examples/rwa-transfer-restrict", + "examples/rwa-initial-lockup-period", "examples/sac-admin-generic", "examples/sac-admin-wrapper", "examples/multisig-smart-account/*", diff --git a/examples/rwa-initial-lockup-period/Cargo.toml b/examples/rwa-initial-lockup-period/Cargo.toml new file mode 100644 index 000000000..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-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/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/mod.rs b/packages/tokens/src/rwa/compliance/modules/mod.rs index f4e065161..e580afdf2 100644 --- a/packages/tokens/src/rwa/compliance/modules/mod.rs +++ b/packages/tokens/src/rwa/compliance/modules/mod.rs @@ -1,6 +1,9 @@ use soroban_sdk::{contracterror, contracttrait, Address, Env, String}; +pub mod initial_lockup_period; pub mod storage; +pub mod time_transfers_limits; +pub mod transfer_restrict; #[cfg(test)] mod test; 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)); +} From 272888584500f720239fde1cf1ecc910830abd3a Mon Sep 17 00:00:00 2001 From: Aleksandr Pasevin Date: Tue, 24 Mar 2026 17:49:41 +0200 Subject: [PATCH 2/3] test(rwa): add coverage for shared compliance helpers Cover the shared compliance storage helpers directly so Codecov reflects the real exercised behavior without touching production logic. --- .../tokens/src/rwa/compliance/modules/test.rs | 90 ++++++++++++++++++- 1 file changed, 87 insertions(+), 3 deletions(-) diff --git a/packages/tokens/src/rwa/compliance/modules/test.rs b/packages/tokens/src/rwa/compliance/modules/test.rs index 6d6659231..72d866ce9 100644 --- a/packages/tokens/src/rwa/compliance/modules/test.rs +++ b/packages/tokens/src/rwa/compliance/modules/test.rs @@ -1,8 +1,8 @@ extern crate std; use soroban_sdk::{ - contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal, Val, - Vec, + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal, + Symbol, Val, Vec, }; use super::storage::*; @@ -10,7 +10,7 @@ use crate::rwa::{ compliance::{Compliance, ComplianceHook}, identity_registry_storage::{ CountryData, CountryDataManager, CountryRelation, IdentityRegistryStorage, - IndividualCountryRelation, + IndividualCountryRelation, OrganizationCountryRelation, }, utils::token_binder::TokenBinder, }; @@ -354,6 +354,29 @@ fn panicking_math_helpers_return_expected_values() { assert_eq!(sub_i128_or_panic(&e, 7, 4), 3); } +#[test] +fn require_non_negative_amount_accepts_zero_and_positive_values() { + let e = Env::default(); + + require_non_negative_amount(&e, 0); + require_non_negative_amount(&e, 42); +} + +#[test] +#[should_panic(expected = "Error(Contract, #391)")] +fn require_non_negative_amount_panics_on_negative() { + let e = Env::default(); + + require_non_negative_amount(&e, -1); +} + +#[test] +fn module_name_returns_soroban_string() { + let e = Env::default(); + + assert_eq!(module_name(&e, "ExampleModule"), soroban_sdk::String::from_str(&e, "ExampleModule")); +} + #[test] #[should_panic(expected = "Error(Contract, #392)")] fn add_i128_or_panic_panics_on_overflow() { @@ -369,3 +392,64 @@ fn sub_i128_or_panic_panics_on_underflow() { let _ = sub_i128_or_panic(&e, i128::MIN, 1); } + +#[test] +fn country_code_extracts_all_relation_variants() { + let e = Env::default(); + + assert_eq!( + country_code(&CountryRelation::Individual(IndividualCountryRelation::Residence(276))), + 276 + ); + assert_eq!( + country_code(&CountryRelation::Individual(IndividualCountryRelation::Citizenship(724))), + 724 + ); + assert_eq!( + country_code(&CountryRelation::Individual(IndividualCountryRelation::SourceOfFunds(840))), + 840 + ); + assert_eq!( + country_code(&CountryRelation::Individual(IndividualCountryRelation::TaxResidency(250))), + 250 + ); + assert_eq!( + country_code(&CountryRelation::Individual(IndividualCountryRelation::Custom( + Symbol::new(&e, "custom"), + 826, + ))), + 826 + ); + + assert_eq!( + country_code(&CountryRelation::Organization(OrganizationCountryRelation::Incorporation( + 528 + ))), + 528 + ); + assert_eq!( + country_code(&CountryRelation::Organization( + OrganizationCountryRelation::OperatingJurisdiction(756) + )), + 756 + ); + assert_eq!( + country_code(&CountryRelation::Organization( + OrganizationCountryRelation::TaxJurisdiction(208) + )), + 208 + ); + assert_eq!( + country_code(&CountryRelation::Organization(OrganizationCountryRelation::SourceOfFunds( + 484 + ))), + 484 + ); + assert_eq!( + country_code(&CountryRelation::Organization(OrganizationCountryRelation::Custom( + Symbol::new(&e, "branch"), + 392, + ))), + 392 + ); +} From 83c14a648d730b1a223c71ada62d0676b513dda4 Mon Sep 17 00:00:00 2001 From: Aleksandr Pasevin Date: Tue, 24 Mar 2026 18:01:07 +0200 Subject: [PATCH 3/3] style(rwa): format compliance helper coverage tests Apply rustfmt to the new shared-helper coverage assertions so the transfer PR passes the workspace formatting check. --- packages/tokens/src/rwa/compliance/modules/test.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/tokens/src/rwa/compliance/modules/test.rs b/packages/tokens/src/rwa/compliance/modules/test.rs index 72d866ce9..074c44f2b 100644 --- a/packages/tokens/src/rwa/compliance/modules/test.rs +++ b/packages/tokens/src/rwa/compliance/modules/test.rs @@ -374,7 +374,10 @@ fn require_non_negative_amount_panics_on_negative() { fn module_name_returns_soroban_string() { let e = Env::default(); - assert_eq!(module_name(&e, "ExampleModule"), soroban_sdk::String::from_str(&e, "ExampleModule")); + assert_eq!( + module_name(&e, "ExampleModule"), + soroban_sdk::String::from_str(&e, "ExampleModule") + ); } #[test] @@ -434,9 +437,9 @@ fn country_code_extracts_all_relation_variants() { 756 ); assert_eq!( - country_code(&CountryRelation::Organization( - OrganizationCountryRelation::TaxJurisdiction(208) - )), + country_code(&CountryRelation::Organization(OrganizationCountryRelation::TaxJurisdiction( + 208 + ))), 208 ); assert_eq!(