From 668c103f3dae1fca8d31a5bbef915b1864c80720 Mon Sep 17 00:00:00 2001 From: Aleksandr Pasevin Date: Mon, 23 Mar 2026 18:38:48 +0200 Subject: [PATCH 1/8] 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/8] 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/8] 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!( From b85028c9b4fd1118b358833e2d5fd6fb28d66b63 Mon Sep 17 00:00:00 2001 From: Aleksandr Pasevin Date: Fri, 10 Apr 2026 12:49:36 +0300 Subject: [PATCH 4/8] chore: sync Cargo.lock with workspace manifests --- Cargo.lock | 139 ++++++++++++++++++++++++++--------------------------- 1 file changed, 69 insertions(+), 70 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1a1b064e5..77e37d3cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -247,9 +247,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.56" +version = "1.2.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" dependencies = [ "find-msvc-tools", "shlex", @@ -393,12 +393,12 @@ dependencies = [ [[package]] name = "darling" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core 0.21.3", - "darling_macro 0.21.3", + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -417,11 +417,10 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" dependencies = [ - "fnv", "ident_case", "proc-macro2", "quote", @@ -442,11 +441,11 @@ dependencies = [ [[package]] name = "darling_macro" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core 0.21.3", + "darling_core 0.23.0", "quote", "syn 2.0.117", ] @@ -634,9 +633,9 @@ checksum = "ca81e6b4777c89fd810c25a4be2b1bd93ea034fbe58e6a75216a34c6b82c539b" [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fee-forwarder-permissioned-example" @@ -900,9 +899,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" [[package]] name = "heapless" @@ -999,12 +998,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.0", "serde", "serde_core", ] @@ -1026,15 +1025,15 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" dependencies = [ "once_cell", "wasm-bindgen", @@ -1071,9 +1070,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.183" +version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" [[package]] name = "libm" @@ -1227,9 +1226,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-derive" @@ -1262,9 +1261,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "ownable-example" @@ -1366,9 +1365,9 @@ dependencies = [ [[package]] name = "proptest" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set", "bit-vec", @@ -1617,7 +1616,7 @@ dependencies = [ [[package]] name = "rwa-initial-lockup-period" -version = "0.6.0" +version = "0.7.0" dependencies = [ "soroban-sdk", "stellar-tokens", @@ -1625,7 +1624,7 @@ dependencies = [ [[package]] name = "rwa-time-transfers-limits" -version = "0.6.0" +version = "0.7.0" dependencies = [ "soroban-sdk", "stellar-tokens", @@ -1644,7 +1643,7 @@ dependencies = [ [[package]] name = "rwa-transfer-restrict" -version = "0.6.0" +version = "0.7.0" dependencies = [ "soroban-sdk", "stellar-tokens", @@ -1726,9 +1725,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -1785,15 +1784,15 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.17.0" +version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9" +checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" dependencies = [ "base64", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.13.0", + "indexmap 2.14.0", "schemars 0.8.22", "schemars 0.9.0", "schemars 1.2.1", @@ -1805,11 +1804,11 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.17.0" +version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0" +checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" dependencies = [ - "darling 0.21.3", + "darling 0.23.0", "proc-macro2", "quote", "syn 2.0.117", @@ -1953,9 +1952,9 @@ dependencies = [ [[package]] name = "soroban-ledger-snapshot" -version = "25.3.0" +version = "25.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "760124fb65a2acdea7d241b8efdfab9a39287ae8dc5bf8feb6fd9dfb664c1ad5" +checksum = "2ca06e6c5029d1285e66219cb387a234224e26969ce8ad2bc2d5017e9395d63b" dependencies = [ "serde", "serde_json", @@ -1967,9 +1966,9 @@ dependencies = [ [[package]] name = "soroban-sdk" -version = "25.3.0" +version = "25.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fb27e93f8d3fc3a815d24c60ec11e893c408a36693ec9c823322f954fa096ae" +checksum = "4502f2e018f238a4c5d3212d7d20ea6abcdc6e58babd63b642b693739db30fd1" dependencies = [ "arbitrary", "bytes-lit", @@ -1991,9 +1990,9 @@ dependencies = [ [[package]] name = "soroban-sdk-macros" -version = "25.3.0" +version = "25.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec603a62a90abdef898f8402471a24d8b58a0043b9a998ed6a607a19a5dabe1" +checksum = "ca03e9cf61d241cb9afdd6ddf41f6c25698b3f566a875e7009ea799b89e2bf0a" dependencies = [ "darling 0.20.11", "heck", @@ -2011,9 +2010,9 @@ dependencies = [ [[package]] name = "soroban-spec" -version = "25.3.0" +version = "25.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24718fac3af127fc6910eb6b1d3ccd8403201b6ef0aca73b5acabe4bc3dd42ed" +checksum = "aa02e07f507cc27406ae0834db4dcf309b78c4cc8776eb3b2d662d66e8859d25" dependencies = [ "base64", "sha2", @@ -2024,9 +2023,9 @@ dependencies = [ [[package]] name = "soroban-spec-rust" -version = "25.3.0" +version = "25.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93c558bca7a693ec8ed67d2d8c8f5b300f3772141d619a4a694ad5dd48461256" +checksum = "6835bb510763ef3fa5405e89036e3c8ea6ef5abe55fc52cfe9ac0e38be9d531c" dependencies = [ "prettyplease", "proc-macro2", @@ -2450,9 +2449,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" dependencies = [ "cfg-if", "once_cell", @@ -2463,9 +2462,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2473,9 +2472,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" dependencies = [ "bumpalo", "proc-macro2", @@ -2486,9 +2485,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" dependencies = [ "unicode-ident", ] @@ -2510,7 +2509,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap 2.13.0", + "indexmap 2.14.0", "wasm-encoder", "wasmparser 0.244.0", ] @@ -2539,7 +2538,7 @@ version = "0.116.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a58e28b80dd8340cb07b8242ae654756161f6fc8d0038123d679b7b99964fa50" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.0", "semver", ] @@ -2551,7 +2550,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", "hashbrown 0.15.5", - "indexmap 2.13.0", + "indexmap 2.14.0", "semver", ] @@ -2660,7 +2659,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap 2.13.0", + "indexmap 2.14.0", "prettyplease", "syn 2.0.117", "wasm-metadata", @@ -2691,7 +2690,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", - "indexmap 2.13.0", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -2710,7 +2709,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap 2.13.0", + "indexmap 2.14.0", "log", "semver", "serde", @@ -2722,18 +2721,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.42" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.42" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", From fe214af0ca9624d5561198f2f3435d3820ff3759 Mon Sep 17 00:00:00 2001 From: Aleksandr Pasevin Date: Fri, 10 Apr 2026 15:23:39 +0300 Subject: [PATCH 5/8] refactor: apply storage-first pattern to transfer modules Move transfer_restrict, initial_lockup_period, and time_transfers_limits module logic into public free functions in storage.rs. Remove per-module contracttrait definitions from mod.rs. Restructure examples into canonical lib.rs / contract.rs layout. Delete example READMEs. --- examples/rwa-initial-lockup-period/README.md | 64 ----- .../rwa-initial-lockup-period/src/contract.rs | 123 ++++++++ examples/rwa-initial-lockup-period/src/lib.rs | 227 +-------------- examples/rwa-time-transfers-limits/README.md | 71 ----- .../rwa-time-transfers-limits/src/contract.rs | 125 ++++++++ examples/rwa-time-transfers-limits/src/lib.rs | 201 +------------ examples/rwa-transfer-restrict/README.md | 47 --- .../rwa-transfer-restrict/src/contract.rs | 95 ++++++ examples/rwa-transfer-restrict/src/lib.rs | 82 +----- .../modules/initial_lockup_period/mod.rs | 252 +--------------- .../modules/initial_lockup_period/storage.rs | 272 +++++++++++++++++- .../modules/initial_lockup_period/test.rs | 63 ++-- .../modules/time_transfers_limits/mod.rs | 209 +------------- .../modules/time_transfers_limits/storage.rs | 248 +++++++++++++++- .../modules/time_transfers_limits/test.rs | 60 ++-- .../modules/transfer_restrict/mod.rs | 171 +---------- .../modules/transfer_restrict/storage.rs | 87 +++++- .../modules/transfer_restrict/test.rs | 52 ++-- 18 files changed, 1016 insertions(+), 1433 deletions(-) delete mode 100644 examples/rwa-initial-lockup-period/README.md create mode 100644 examples/rwa-initial-lockup-period/src/contract.rs delete mode 100644 examples/rwa-time-transfers-limits/README.md create mode 100644 examples/rwa-time-transfers-limits/src/contract.rs delete mode 100644 examples/rwa-transfer-restrict/README.md create mode 100644 examples/rwa-transfer-restrict/src/contract.rs diff --git a/examples/rwa-initial-lockup-period/README.md b/examples/rwa-initial-lockup-period/README.md deleted file mode 100644 index e795d527d..000000000 --- a/examples/rwa-initial-lockup-period/README.md +++ /dev/null @@ -1,64 +0,0 @@ -# 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/contract.rs b/examples/rwa-initial-lockup-period/src/contract.rs new file mode 100644 index 000000000..330f6741a --- /dev/null +++ b/examples/rwa-initial-lockup-period/src/contract.rs @@ -0,0 +1,123 @@ +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::{ + modules::{ + initial_lockup_period::{storage as lockup, LockedTokens}, + storage::{ + get_compliance_address, module_name, set_compliance_address, ComplianceModuleStorageKey, + }, + ComplianceModule, + }, + ComplianceHook, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct InitialLockupPeriodContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl InitialLockupPeriodContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } + + pub fn set_lockup_period(e: &Env, token: Address, lockup_seconds: u64) { + require_module_admin_or_compliance_auth(e); + lockup::configure_lockup_period(e, &token, lockup_seconds); + } + + pub fn pre_set_lockup_state( + e: &Env, + token: Address, + wallet: Address, + balance: i128, + locks: Vec, + ) { + require_module_admin_or_compliance_auth(e); + lockup::pre_set_lockup_state(e, &token, &wallet, balance, &locks); + } + + pub fn get_lockup_period(e: &Env, token: Address) -> u64 { + lockup::get_lockup_period(e, &token) + } + + pub fn get_total_locked(e: &Env, token: Address, wallet: Address) -> i128 { + lockup::get_total_locked(e, &token, &wallet) + } + + pub fn get_locked_tokens(e: &Env, token: Address, wallet: Address) -> Vec { + lockup::get_locks(e, &token, &wallet) + } + + pub fn get_internal_balance(e: &Env, token: Address, wallet: Address) -> i128 { + lockup::get_internal_balance(e, &token, &wallet) + } + + pub fn required_hooks(e: &Env) -> Vec { + lockup::required_hooks(e) + } + + pub fn verify_hook_wiring(e: &Env) { + lockup::verify_hook_wiring(e); + } +} + +#[contractimpl(contracttrait)] +impl ComplianceModule for InitialLockupPeriodContract { + fn on_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + lockup::on_transfer(e, &from, &to, amount, &token); + } + + fn on_created(e: &Env, to: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + lockup::on_created(e, &to, amount, &token); + } + + fn on_destroyed(e: &Env, from: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + lockup::on_destroyed(e, &from, amount, &token); + } + + fn can_transfer(e: &Env, from: Address, _to: Address, amount: i128, token: Address) -> bool { + lockup::can_transfer(e, &from, amount, &token) + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + true + } + + fn name(e: &Env) -> String { + module_name(e, "InitialLockupPeriodModule") + } + + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-initial-lockup-period/src/lib.rs b/examples/rwa-initial-lockup-period/src/lib.rs index da4fa20d0..b9aa4d5f0 100644 --- a/examples/rwa-initial-lockup-period/src/lib.rs +++ b/examples/rwa-initial-lockup-period/src/lib.rs @@ -1,228 +1,3 @@ #![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); - } -} +pub mod contract; diff --git a/examples/rwa-time-transfers-limits/README.md b/examples/rwa-time-transfers-limits/README.md deleted file mode 100644 index 6377ab122..000000000 --- a/examples/rwa-time-transfers-limits/README.md +++ /dev/null @@ -1,71 +0,0 @@ -# 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/contract.rs b/examples/rwa-time-transfers-limits/src/contract.rs new file mode 100644 index 000000000..d1d559a4b --- /dev/null +++ b/examples/rwa-time-transfers-limits/src/contract.rs @@ -0,0 +1,125 @@ +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::{ + modules::{ + storage::{ + get_compliance_address, module_name, set_compliance_address, ComplianceModuleStorageKey, + }, + time_transfers_limits::{storage as ttl, Limit, TransferCounter}, + ComplianceModule, + }, + ComplianceHook, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct TimeTransfersLimitsContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl TimeTransfersLimitsContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } + + pub fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { + require_module_admin_or_compliance_auth(e); + ttl::configure_irs(e, &token, &irs); + } + + pub fn set_time_transfer_limit(e: &Env, token: Address, limit: Limit) { + require_module_admin_or_compliance_auth(e); + ttl::set_time_transfer_limit(e, &token, &limit); + } + + pub fn batch_set_time_transfer_limit(e: &Env, token: Address, limits: Vec) { + require_module_admin_or_compliance_auth(e); + ttl::batch_set_time_transfer_limit(e, &token, &limits); + } + + pub fn remove_time_transfer_limit(e: &Env, token: Address, limit_time: u64) { + require_module_admin_or_compliance_auth(e); + ttl::remove_time_transfer_limit(e, &token, limit_time); + } + + pub fn batch_remove_time_transfer_limit(e: &Env, token: Address, limit_times: Vec) { + require_module_admin_or_compliance_auth(e); + ttl::batch_remove_time_transfer_limit(e, &token, &limit_times); + } + + pub fn pre_set_transfer_counter( + e: &Env, + token: Address, + identity: Address, + limit_time: u64, + counter: TransferCounter, + ) { + require_module_admin_or_compliance_auth(e); + ttl::pre_set_transfer_counter(e, &token, &identity, limit_time, &counter); + } + + pub fn get_time_transfer_limits(e: &Env, token: Address) -> Vec { + ttl::get_limits(e, &token) + } + + pub fn required_hooks(e: &Env) -> Vec { + ttl::required_hooks(e) + } + + pub fn verify_hook_wiring(e: &Env) { + ttl::verify_hook_wiring(e); + } +} + +#[contractimpl(contracttrait)] +impl ComplianceModule for TimeTransfersLimitsContract { + fn on_transfer(e: &Env, from: Address, _to: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + ttl::on_transfer(e, &from, amount, &token); + } + + fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {} + + fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {} + + fn can_transfer(e: &Env, from: Address, _to: Address, amount: i128, token: Address) -> bool { + ttl::can_transfer(e, &from, amount, &token) + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + true + } + + fn name(e: &Env) -> String { + module_name(e, "TimeTransfersLimitsModule") + } + + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-time-transfers-limits/src/lib.rs b/examples/rwa-time-transfers-limits/src/lib.rs index 88ed4ae88..b9aa4d5f0 100644 --- a/examples/rwa-time-transfers-limits/src/lib.rs +++ b/examples/rwa-time-transfers-limits/src/lib.rs @@ -1,202 +1,3 @@ #![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); - } -} +pub mod contract; diff --git a/examples/rwa-transfer-restrict/README.md b/examples/rwa-transfer-restrict/README.md deleted file mode 100644 index a8283c44b..000000000 --- a/examples/rwa-transfer-restrict/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# 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/contract.rs b/examples/rwa-transfer-restrict/src/contract.rs new file mode 100644 index 000000000..b69043c63 --- /dev/null +++ b/examples/rwa-transfer-restrict/src/contract.rs @@ -0,0 +1,95 @@ +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::modules::{ + storage::{ + get_compliance_address, module_name, set_compliance_address, ComplianceModuleStorageKey, + }, + transfer_restrict::storage as transfer_restrict, + ComplianceModule, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct TransferRestrictContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl TransferRestrictContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } + + pub fn allow_user(e: &Env, token: Address, user: Address) { + require_module_admin_or_compliance_auth(e); + transfer_restrict::allow_user(e, &token, &user); + } + + pub fn disallow_user(e: &Env, token: Address, user: Address) { + require_module_admin_or_compliance_auth(e); + transfer_restrict::disallow_user(e, &token, &user); + } + + pub fn batch_allow_users(e: &Env, token: Address, users: Vec
) { + require_module_admin_or_compliance_auth(e); + transfer_restrict::batch_allow_users(e, &token, &users); + } + + pub fn batch_disallow_users(e: &Env, token: Address, users: Vec
) { + require_module_admin_or_compliance_auth(e); + transfer_restrict::batch_disallow_users(e, &token, &users); + } + + pub fn is_user_allowed(e: &Env, token: Address, user: Address) -> bool { + transfer_restrict::is_user_allowed(e, &token, &user) + } +} + +#[contractimpl(contracttrait)] +impl ComplianceModule for TransferRestrictContract { + fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {} + + fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {} + + fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {} + + fn can_transfer(e: &Env, from: Address, to: Address, _amount: i128, token: Address) -> bool { + transfer_restrict::can_transfer(e, &from, &to, &token) + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + true + } + + fn name(e: &Env) -> String { + module_name(e, "TransferRestrictModule") + } + + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-transfer-restrict/src/lib.rs b/examples/rwa-transfer-restrict/src/lib.rs index 49084164a..b9aa4d5f0 100644 --- a/examples/rwa-transfer-restrict/src/lib.rs +++ b/examples/rwa-transfer-restrict/src/lib.rs @@ -1,83 +1,3 @@ #![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); - } -} +pub mod contract; 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 index 7261fc452..ed816abfe 100644 --- a/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/mod.rs +++ b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/mod.rs @@ -11,18 +11,8 @@ pub mod storage; #[cfg(test)] mod test; -use soroban_sdk::{contractevent, contracttrait, vec, Address, Env, String, Vec}; +use soroban_sdk::{contractevent, Address}; 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] @@ -32,243 +22,3 @@ pub struct LockupPeriodSet { 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 index 2c9788c8d..3bff8f5ef 100644 --- a/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/storage.rs +++ b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/storage.rs @@ -1,6 +1,16 @@ -use soroban_sdk::{contracttype, Address, Env, Vec}; +use soroban_sdk::{contracttype, vec, Address, Env, Vec}; -use crate::rwa::compliance::modules::{MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD}; +use super::LockupPeriodSet; +use crate::rwa::compliance::{ + modules::{ + storage::{ + add_i128_or_panic, hooks_verified, require_non_negative_amount, sub_i128_or_panic, + verify_required_hooks, + }, + MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD, + }, + ComplianceHook, +}; /// A single mint-created lock entry tracking the locked amount and its /// release time. Mirrors T-REX `LockedTokens { amount, releaseTimestamp }`. @@ -25,6 +35,8 @@ pub enum InitialLockupStorageKey { InternalBalance(Address, Address), } +// ################## RAW STORAGE ################## + /// Returns the lockup period (in seconds) for `token`, or `0` if not set. /// /// # Arguments @@ -150,3 +162,259 @@ pub fn set_internal_balance(e: &Env, token: &Address, wallet: &Address, balance: e.storage().persistent().set(&key, &balance); e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); } + +// ################## HELPERS ################## + +fn calculate_unlocked_amount(e: &Env, locks: &Vec) -> i128 { + let now = e.ledger().timestamp(); + let mut unlocked = 0i128; + for i in 0..locks.len() { + let lock = locks.get(i).unwrap(); + if lock.release_timestamp <= now { + unlocked = add_i128_or_panic(e, unlocked, lock.amount); + } + } + unlocked +} + +fn calculate_total_locked_amount(e: &Env, locks: &Vec) -> i128 { + let mut total = 0i128; + for i in 0..locks.len() { + let lock = locks.get(i).unwrap(); + require_non_negative_amount(e, lock.amount); + total = add_i128_or_panic(e, total, lock.amount); + } + total +} + +fn update_locked_tokens(e: &Env, token: &Address, wallet: &Address, mut amount_to_consume: i128) { + let locks = get_locks(e, token, wallet); + let now = e.ledger().timestamp(); + let mut new_locks = Vec::new(e); + let mut consumed_total = 0i128; + + for i in 0..locks.len() { + let lock = locks.get(i).unwrap(); + if amount_to_consume > 0 && lock.release_timestamp <= now { + if amount_to_consume >= lock.amount { + amount_to_consume = sub_i128_or_panic(e, amount_to_consume, lock.amount); + consumed_total = add_i128_or_panic(e, consumed_total, lock.amount); + } else { + consumed_total = add_i128_or_panic(e, consumed_total, amount_to_consume); + new_locks.push_back(LockedTokens { + amount: sub_i128_or_panic(e, lock.amount, amount_to_consume), + release_timestamp: lock.release_timestamp, + }); + amount_to_consume = 0; + } + } else { + new_locks.push_back(lock); + } + } + + set_locks(e, token, wallet, &new_locks); + + let total_locked = get_total_locked(e, token, wallet); + set_total_locked(e, token, wallet, sub_i128_or_panic(e, total_locked, consumed_total)); +} + +// ################## ACTIONS ################## + +/// Configures the lockup period for `token` and emits [`LockupPeriodSet`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `lockup_seconds` - The lockup duration in seconds. +pub fn configure_lockup_period(e: &Env, token: &Address, lockup_seconds: u64) { + set_lockup_period(e, token, lockup_seconds); + LockupPeriodSet { token: token.clone(), lockup_seconds }.publish(e); +} + +/// Pre-seeds the lockup state for a wallet. Validates that total locked +/// does not exceed balance. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `wallet` - The wallet address. +/// * `balance` - The wallet balance. +/// * `locks` - The lock entries. +pub fn pre_set_lockup_state( + e: &Env, + token: &Address, + wallet: &Address, + balance: i128, + locks: &Vec, +) { + require_non_negative_amount(e, balance); + + let total_locked = calculate_total_locked_amount(e, locks); + assert!( + total_locked <= balance, + "InitialLockupPeriodModule: total locked amount cannot exceed balance" + ); + + set_internal_balance(e, token, wallet, balance); + set_locks(e, token, wallet, locks); + set_total_locked(e, token, wallet, total_locked); +} + +// ################## HOOK WIRING ################## + +/// Returns the set of compliance hooks this module requires. +pub fn required_hooks(e: &Env) -> Vec { + vec![ + e, + ComplianceHook::CanTransfer, + ComplianceHook::Created, + ComplianceHook::Transferred, + ComplianceHook::Destroyed, + ] +} + +/// Cross-calls the compliance contract to verify that this module is +/// registered on all required hooks. +pub fn verify_hook_wiring(e: &Env) { + verify_required_hooks(e, required_hooks(e)); +} + +// ################## COMPLIANCE HOOKS ################## + +/// Updates internal balances and lock tracking after a transfer. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `from` - The sender address. +/// * `to` - The recipient address. +/// * `amount` - The transfer amount. +/// * `token` - The token address. +pub fn on_transfer(e: &Env, from: &Address, to: &Address, amount: i128, token: &Address) { + require_non_negative_amount(e, amount); + + let total_locked = get_total_locked(e, token, from); + + if total_locked > 0 { + let pre_balance = get_internal_balance(e, token, from); + let pre_free = pre_balance - total_locked; + + if amount > pre_free.max(0) { + let to_consume = amount - pre_free.max(0); + update_locked_tokens(e, token, from, to_consume); + } + } + + let from_bal = get_internal_balance(e, token, from); + set_internal_balance(e, token, from, sub_i128_or_panic(e, from_bal, amount)); + + let to_bal = get_internal_balance(e, token, to); + set_internal_balance(e, token, to, add_i128_or_panic(e, to_bal, amount)); +} + +/// Updates internal balance and creates a lock entry after a mint. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `to` - The recipient address. +/// * `amount` - The minted amount. +/// * `token` - The token address. +pub fn on_created(e: &Env, to: &Address, amount: i128, token: &Address) { + require_non_negative_amount(e, amount); + + let period = get_lockup_period(e, token); + if period > 0 { + let mut locks = get_locks(e, token, to); + locks.push_back(LockedTokens { + amount, + release_timestamp: e.ledger().timestamp().saturating_add(period), + }); + set_locks(e, token, to, &locks); + + let total = get_total_locked(e, token, to); + set_total_locked(e, token, to, add_i128_or_panic(e, total, amount)); + } + + let current = get_internal_balance(e, token, to); + set_internal_balance(e, token, to, add_i128_or_panic(e, current, amount)); +} + +/// Updates internal balance and consumes locks after a burn. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `from` - The burner address. +/// * `amount` - The burned amount. +/// * `token` - The token address. +pub fn on_destroyed(e: &Env, from: &Address, amount: i128, token: &Address) { + require_non_negative_amount(e, amount); + + let total_locked = get_total_locked(e, token, from); + + if total_locked > 0 { + let pre_balance = get_internal_balance(e, token, from); + let mut free_amount = pre_balance - total_locked; + + if free_amount < amount { + let locks = get_locks(e, token, from); + free_amount += calculate_unlocked_amount(e, &locks); + } + + assert!( + free_amount >= amount, + "InitialLockupPeriodModule: insufficient unlocked balance for burn" + ); + + let pre_free = pre_balance - total_locked; + if amount > pre_free.max(0) { + let to_consume = amount - pre_free.max(0); + update_locked_tokens(e, token, from, to_consume); + } + } + + let current = get_internal_balance(e, token, from); + set_internal_balance(e, token, from, sub_i128_or_panic(e, current, amount)); +} + +/// Checks whether a transfer is allowed based on lockup restrictions. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `from` - The sender address. +/// * `amount` - The transfer amount. +/// * `token` - The token address. +/// +/// # Returns +/// +/// `true` if the sender has sufficient unlocked balance. +pub fn can_transfer(e: &Env, from: &Address, amount: i128, token: &Address) -> bool { + assert!( + hooks_verified(e), + "InitialLockupPeriodModule: not armed — call verify_hook_wiring() after wiring hooks \ + [CanTransfer, Created, Transferred, Destroyed]" + ); + if amount < 0 { + return false; + } + + let total_locked = get_total_locked(e, token, from); + if total_locked == 0 { + return true; + } + + let balance = get_internal_balance(e, token, from); + let free = balance - total_locked; + + if free >= amount { + return true; + } + + let locks = get_locks(e, token, from); + let unlocked = calculate_unlocked_amount(e, &locks); + (free + unlocked) >= amount +} diff --git a/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/test.rs b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/test.rs index f758b7a97..ec6951c91 100644 --- a/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/test.rs +++ b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/test.rs @@ -4,7 +4,10 @@ use soroban_sdk::{ contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, }; -use super::*; +use super::storage::{ + can_transfer, get_internal_balance, get_total_locked, pre_set_lockup_state, verify_hook_wiring, + LockedTokens, +}; use crate::rwa::{ compliance::{ modules::storage::{hooks_verified, set_compliance_address, ComplianceModuleStorageKey}, @@ -14,14 +17,7 @@ use crate::rwa::{ }; #[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"); - } -} +struct TestModuleContract; fn arm_hooks(e: &Env) { e.storage().instance().set(&ComplianceModuleStorageKey::HooksVerified, &true); @@ -106,7 +102,7 @@ impl MockComplianceContract { #[test] fn verify_hook_wiring_sets_cache_when_registered() { let e = Env::default(); - let module_id = e.register(TestInitialLockupPeriodContract, ()); + let module_id = e.register(TestModuleContract, ()); let compliance_id = e.register(MockComplianceContract, ()); let compliance = MockComplianceContractClient::new(&e, &compliance_id); @@ -122,7 +118,7 @@ fn verify_hook_wiring_sets_cache_when_registered() { e.as_contract(&module_id, || { set_compliance_address(&e, &compliance_id); - ::verify_hook_wiring(&e); + verify_hook_wiring(&e); assert!(hooks_verified(&e)); }); @@ -131,9 +127,8 @@ fn verify_hook_wiring_sets_cache_when_registered() { #[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 module_id = e.register(TestModuleContract, ()); let compliance = Address::generate(&e); let token = Address::generate(&e); let wallet = Address::generate(&e); @@ -142,12 +137,12 @@ fn pre_set_lockup_state_seeds_existing_locked_balance() { set_compliance_address(&e, &compliance); arm_hooks(&e); - ::pre_set_lockup_state( + pre_set_lockup_state( &e, - token.clone(), - wallet.clone(), + &token, + &wallet, 100, - vec![ + &vec![ &e, LockedTokens { amount: 80, @@ -156,35 +151,9 @@ fn pre_set_lockup_state_seeds_existing_locked_balance() { ], ); - 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, - )); + assert_eq!(get_internal_balance(&e, &token, &wallet), 100); + assert_eq!(get_total_locked(&e, &token, &wallet), 80); + assert!(!can_transfer(&e, &wallet, 21, &token)); + assert!(can_transfer(&e, &wallet, 20, &token)); }); } diff --git a/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/mod.rs b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/mod.rs index 5bc76a97f..49215ef2d 100644 --- a/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/mod.rs +++ b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/mod.rs @@ -10,17 +10,10 @@ 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}; +use soroban_sdk::{contractevent, Address}; 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; +pub const MAX_LIMITS_PER_TOKEN: u32 = 4; /// Emitted when a time-window limit is added or updated. #[contractevent] @@ -39,201 +32,3 @@ pub struct TimeTransferLimitRemoved { 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 index 8b7e38e5e..023f200cd 100644 --- a/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/storage.rs +++ b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/storage.rs @@ -1,6 +1,16 @@ -use soroban_sdk::{contracttype, Address, Env, Vec}; +use soroban_sdk::{contracttype, panic_with_error, vec, Address, Env, Vec}; -use crate::rwa::compliance::modules::{MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD}; +use super::{TimeTransferLimitRemoved, TimeTransferLimitUpdated, MAX_LIMITS_PER_TOKEN}; +use crate::rwa::compliance::{ + modules::{ + storage::{ + add_i128_or_panic, get_irs_client, hooks_verified, require_non_negative_amount, + set_irs_address, verify_required_hooks, + }, + ComplianceModuleError, MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD, + }, + ComplianceHook, +}; /// A single time-window limit: `limit_value` tokens may be transferred /// within a rolling window of `limit_time` seconds. @@ -28,6 +38,8 @@ pub enum TimeTransfersLimitsStorageKey { Counter(Address, Address, u64), } +// ################## RAW STORAGE ################## + /// Returns the list of time-window limits for `token`. /// /// # Arguments @@ -102,3 +114,235 @@ pub fn set_counter( e.storage().persistent().set(&key, counter); e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); } + +// ################## HELPERS ################## + +fn is_counter_finished(e: &Env, token: &Address, identity: &Address, limit_time: u64) -> bool { + let counter = get_counter(e, token, identity, limit_time); + counter.timer <= e.ledger().timestamp() +} + +fn reset_counter_if_needed(e: &Env, token: &Address, identity: &Address, limit_time: u64) { + if is_counter_finished(e, token, identity, limit_time) { + let counter = + TransferCounter { value: 0, timer: e.ledger().timestamp().saturating_add(limit_time) }; + set_counter(e, token, identity, limit_time, &counter); + } +} + +fn increase_counters(e: &Env, token: &Address, identity: &Address, value: i128) { + let limits = get_limits(e, token); + for limit in limits.iter() { + reset_counter_if_needed(e, token, identity, limit.limit_time); + let mut counter = get_counter(e, token, identity, limit.limit_time); + counter.value = add_i128_or_panic(e, counter.value, value); + set_counter(e, token, identity, limit.limit_time, &counter); + } +} + +// ################## ACTIONS ################## + +/// Configures the identity registry storage address for a token. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `irs` - The identity registry storage address. +pub fn configure_irs(e: &Env, token: &Address, irs: &Address) { + set_irs_address(e, token, irs); +} + +/// Sets or updates a time-window transfer limit for `token` and emits +/// [`TimeTransferLimitUpdated`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `limit` - The limit to set. +pub fn set_time_transfer_limit(e: &Env, token: &Address, limit: &Limit) { + assert!(limit.limit_time > 0, "limit_time must be greater than zero"); + require_non_negative_amount(e, limit.limit_value); + let mut limits = get_limits(e, token); + + let mut replaced = false; + for i in 0..limits.len() { + let current = limits.get(i).expect("limit exists"); + if current.limit_time == limit.limit_time { + limits.set(i, limit.clone()); + replaced = true; + break; + } + } + + if !replaced { + if limits.len() >= MAX_LIMITS_PER_TOKEN { + panic_with_error!(e, ComplianceModuleError::TooManyLimits); + } + limits.push_back(limit.clone()); + } + + set_limits(e, token, &limits); + TimeTransferLimitUpdated { token: token.clone(), limit: limit.clone() }.publish(e); +} + +/// Sets or updates multiple time-window transfer limits in a single call. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `limits` - The limits to set. +pub fn batch_set_time_transfer_limit(e: &Env, token: &Address, limits: &Vec) { + for limit in limits.iter() { + set_time_transfer_limit(e, token, &limit); + } +} + +/// Removes a time-window transfer limit and emits +/// [`TimeTransferLimitRemoved`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `limit_time` - The time-window to remove. +pub fn remove_time_transfer_limit(e: &Env, token: &Address, limit_time: u64) { + let mut limits = get_limits(e, token); + + let mut found = false; + for i in 0..limits.len() { + let current = limits.get(i).expect("limit exists"); + if current.limit_time == limit_time { + limits.remove(i); + found = true; + break; + } + } + + if !found { + panic_with_error!(e, ComplianceModuleError::MissingLimit); + } + + set_limits(e, token, &limits); + TimeTransferLimitRemoved { token: token.clone(), limit_time }.publish(e); +} + +/// Removes multiple time-window transfer limits in a single call. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `limit_times` - The time-windows to remove. +pub fn batch_remove_time_transfer_limit(e: &Env, token: &Address, limit_times: &Vec) { + for lt in limit_times.iter() { + remove_time_transfer_limit(e, token, lt); + } +} + +/// Pre-seeds a transfer counter for a given identity and time window. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `identity` - The on-chain identity address. +/// * `limit_time` - The time-window duration in seconds. +/// * `counter` - The counter value to set. +pub fn pre_set_transfer_counter( + e: &Env, + token: &Address, + identity: &Address, + limit_time: u64, + counter: &TransferCounter, +) { + require_non_negative_amount(e, counter.value); + assert!(limit_time > 0, "limit_time must be greater than zero"); + + let mut found = false; + for limit in get_limits(e, token).iter() { + if limit.limit_time == limit_time { + found = true; + break; + } + } + + if !found { + panic_with_error!(e, ComplianceModuleError::MissingLimit); + } + + set_counter(e, token, identity, limit_time, counter); +} + +// ################## HOOK WIRING ################## + +/// Returns the set of compliance hooks this module requires. +pub fn required_hooks(e: &Env) -> Vec { + vec![e, ComplianceHook::CanTransfer, ComplianceHook::Transferred] +} + +/// Cross-calls the compliance contract to verify that this module is +/// registered on all required hooks. +pub fn verify_hook_wiring(e: &Env) { + verify_required_hooks(e, required_hooks(e)); +} + +// ################## COMPLIANCE HOOKS ################## + +/// Resolves the sender's identity and increments transfer counters. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `from` - The sender address. +/// * `amount` - The transfer amount. +/// * `token` - The token address. +pub fn on_transfer(e: &Env, from: &Address, amount: i128, token: &Address) { + require_non_negative_amount(e, amount); + let irs = get_irs_client(e, token); + let from_id = irs.stored_identity(from); + increase_counters(e, token, &from_id, amount); +} + +/// Checks whether a transfer is within the configured time-window limits. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `from` - The sender address. +/// * `amount` - The transfer amount. +/// * `token` - The token address. +/// +/// # Returns +/// +/// `true` if the transfer does not exceed any limit. +pub fn can_transfer(e: &Env, from: &Address, amount: i128, token: &Address) -> bool { + assert!( + hooks_verified(e), + "TimeTransfersLimitsModule: not armed — call verify_hook_wiring() after wiring hooks \ + [CanTransfer, Transferred]" + ); + if amount < 0 { + return false; + } + let irs = get_irs_client(e, token); + let from_id = irs.stored_identity(from); + let limits = get_limits(e, token); + + for limit in limits.iter() { + if amount > limit.limit_value { + return false; + } + + if !is_counter_finished(e, token, &from_id, limit.limit_time) { + let counter = get_counter(e, token, &from_id, limit.limit_time); + if add_i128_or_panic(e, counter.value, amount) > limit.limit_value { + return false; + } + } + } + + true +} diff --git a/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/test.rs b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/test.rs index aced1114d..721336728 100644 --- a/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/test.rs +++ b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/test.rs @@ -4,7 +4,10 @@ use soroban_sdk::{ contract, contractimpl, contracttype, testutils::Address as _, Address, Env, Val, Vec, }; -use super::*; +use super::storage::{ + can_transfer, pre_set_transfer_counter, set_time_transfer_limit, verify_hook_wiring, Limit, + TransferCounter, +}; use crate::rwa::{ compliance::{ modules::storage::{ @@ -191,14 +194,7 @@ impl MockComplianceContract { } #[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"); - } -} +struct TestModuleContract; fn arm_hooks(e: &Env) { e.storage().instance().set(&ComplianceModuleStorageKey::HooksVerified, &true); @@ -207,7 +203,7 @@ fn arm_hooks(e: &Env) { #[test] fn verify_hook_wiring_sets_cache_when_registered() { let e = Env::default(); - let module_id = e.register(TestTimeTransfersLimitsContract, ()); + let module_id = e.register(TestModuleContract, ()); let compliance_id = e.register(MockComplianceContract, ()); let compliance = MockComplianceContractClient::new(&e, &compliance_id); @@ -218,7 +214,7 @@ fn verify_hook_wiring_sets_cache_when_registered() { e.as_contract(&module_id, || { set_compliance_address(&e, &compliance_id); - ::verify_hook_wiring(&e); + verify_hook_wiring(&e); assert!(hooks_verified(&e)); }); @@ -227,17 +223,14 @@ fn verify_hook_wiring_sets_cache_when_registered() { #[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 module_id = e.register(TestModuleContract, ()); let irs_id = e.register(MockIRSContract, ()); let irs = MockIRSContractClient::new(&e, &irs_id); let compliance = Address::generate(&e); let token = Address::generate(&e); let sender = Address::generate(&e); let sender_identity = Address::generate(&e); - let recipient = Address::generate(&e); - let client = TestTimeTransfersLimitsContractClient::new(&e, &module_id); irs.set_identity(&sender, &sender_identity); @@ -245,38 +238,37 @@ fn pre_set_transfer_counter_blocks_transfers_within_active_window() { 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)); + set_time_transfer_limit(&e, &token, &Limit { limit_time: 60, limit_value: 100 }); + pre_set_transfer_counter( + &e, + &token, + &sender_identity, + 60, + &TransferCounter { value: 90, timer: e.ledger().timestamp().saturating_add(60) }, + ); + + assert!(!can_transfer(&e, &sender, 11, &token)); + assert!(can_transfer(&e, &sender, 10, &token)); + }); } #[test] #[should_panic(expected = "Error(Contract, #400)")] fn set_time_transfer_limit_rejects_more_than_four_limits() { let e = Env::default(); - e.mock_all_auths(); - let module_id = e.register(TestTimeTransfersLimitsContract, ()); + let module_id = e.register(TestModuleContract, ()); 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 }); - } + for limit_time in [60_u64, 120, 180, 240] { + set_time_transfer_limit(&e, &token, &Limit { limit_time, limit_value: 100 }); + } - client.set_time_transfer_limit(&token, &Limit { limit_time: 300, limit_value: 100 }); + set_time_transfer_limit(&e, &token, &Limit { limit_time: 300, limit_value: 100 }); + }); } diff --git a/packages/tokens/src/rwa/compliance/modules/transfer_restrict/mod.rs b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/mod.rs index 1198a0eb2..bf87e6117 100644 --- a/packages/tokens/src/rwa/compliance/modules/transfer_restrict/mod.rs +++ b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/mod.rs @@ -10,10 +10,7 @@ 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}; +use soroban_sdk::{contractevent, Address}; /// Emitted when an address is added to the transfer allowlist. #[contractevent] @@ -32,169 +29,3 @@ pub struct UserDisallowed { 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 index 8fa25912f..8a02b799e 100644 --- a/packages/tokens/src/rwa/compliance/modules/transfer_restrict/storage.rs +++ b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/storage.rs @@ -1,5 +1,6 @@ -use soroban_sdk::{contracttype, Address, Env}; +use soroban_sdk::{contracttype, Address, Env, Vec}; +use super::{UserAllowed, UserDisallowed}; use crate::rwa::compliance::modules::{MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD}; #[contracttype] @@ -9,6 +10,8 @@ pub enum TransferRestrictStorageKey { AllowedUser(Address, Address), } +// ################## RAW STORAGE ################## + /// Returns whether `user` is on the transfer allowlist for `token`. /// /// # Arguments @@ -52,3 +55,85 @@ pub fn remove_user_allowed(e: &Env, token: &Address, user: &Address) { .persistent() .remove(&TransferRestrictStorageKey::AllowedUser(token.clone(), user.clone())); } + +// ################## ACTIONS ################## + +/// Adds `user` to the transfer allowlist for `token` and emits +/// [`UserAllowed`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `user` - The address to allow. +pub fn allow_user(e: &Env, token: &Address, user: &Address) { + set_user_allowed(e, token, user); + UserAllowed { token: token.clone(), user: user.clone() }.publish(e); +} + +/// Removes `user` from the transfer allowlist for `token` and emits +/// [`UserDisallowed`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `user` - The address to disallow. +pub fn disallow_user(e: &Env, token: &Address, user: &Address) { + remove_user_allowed(e, token, user); + UserDisallowed { token: token.clone(), user: user.clone() }.publish(e); +} + +/// Adds multiple users to the transfer allowlist in a single call. +/// Emits [`UserAllowed`] for each user added. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `users` - The addresses to allow. +pub fn batch_allow_users(e: &Env, token: &Address, users: &Vec
) { + for user in users.iter() { + set_user_allowed(e, token, &user); + UserAllowed { token: token.clone(), user }.publish(e); + } +} + +/// Removes multiple users from the transfer allowlist in a single call. +/// Emits [`UserDisallowed`] for each user removed. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `users` - The addresses to disallow. +pub fn batch_disallow_users(e: &Env, token: &Address, users: &Vec
) { + for user in users.iter() { + remove_user_allowed(e, token, &user); + UserDisallowed { token: token.clone(), user }.publish(e); + } +} + +// ################## COMPLIANCE HOOKS ################## + +/// 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. +/// * `token` - The token address. +/// +/// # Returns +/// +/// `true` if the sender or recipient is allowlisted, `false` otherwise. +pub fn can_transfer(e: &Env, from: &Address, to: &Address, token: &Address) -> bool { + if is_user_allowed(e, token, from) { + return true; + } + is_user_allowed(e, token, to) +} diff --git a/packages/tokens/src/rwa/compliance/modules/transfer_restrict/test.rs b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/test.rs index 3ae8ba642..d181be4e4 100644 --- a/packages/tokens/src/rwa/compliance/modules/transfer_restrict/test.rs +++ b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/test.rs @@ -1,70 +1,62 @@ extern crate std; -use soroban_sdk::{contract, contractimpl, testutils::Address as _, vec, Address, Env}; +use soroban_sdk::{contract, testutils::Address as _, vec, Address, Env}; -use super::*; +use super::storage::{ + allow_user, batch_allow_users, batch_disallow_users, can_transfer, disallow_user, + is_user_allowed, +}; use crate::rwa::compliance::modules::storage::set_compliance_address; #[contract] -struct 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"); - } -} +struct TestModuleContract; #[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 module_id = e.register(TestModuleContract, ()); let compliance = Address::generate(&e); let token = Address::generate(&e); let sender = Address::generate(&e); let recipient = Address::generate(&e); let outsider = Address::generate(&e); - 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)); + assert!(!can_transfer(&e, &sender, &recipient, &token)); - client.allow_user(&token, &sender.clone()); - assert!(client.can_transfer(&sender.clone(), &outsider.clone(), &100, &token)); + allow_user(&e, &token, &sender); + assert!(can_transfer(&e, &sender, &outsider, &token)); - client.disallow_user(&token, &sender.clone()); - client.allow_user(&token, &recipient.clone()); - assert!(client.can_transfer(&outsider, &recipient, &100, &token)); + disallow_user(&e, &token, &sender); + allow_user(&e, &token, &recipient); + assert!(can_transfer(&e, &outsider, &recipient, &token)); + }); } #[test] fn batch_allow_and_disallow_update_allowlist_entries() { let e = Env::default(); - e.mock_all_auths(); - let module_id = e.register(TestTransferRestrictContract, ()); + let module_id = e.register(TestModuleContract, ()); let compliance = Address::generate(&e); let token = Address::generate(&e); let user_a = Address::generate(&e); let user_b = Address::generate(&e); - 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()]); + batch_allow_users(&e, &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())); + assert!(is_user_allowed(&e, &token, &user_a)); + assert!(is_user_allowed(&e, &token, &user_b)); - client.batch_disallow_users(&token, &vec![&e, user_a.clone(), user_b.clone()]); + batch_disallow_users(&e, &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)); + assert!(!is_user_allowed(&e, &token, &user_a)); + assert!(!is_user_allowed(&e, &token, &user_b)); + }); } From d827760496f37257ff1a87bb56655f7b30a06363 Mon Sep 17 00:00:00 2001 From: Aleksandr Pasevin Date: Fri, 10 Apr 2026 16:17:14 +0300 Subject: [PATCH 6/8] chore: sync Cargo.lock after merging upstream/main v0.7.1 --- Cargo.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 69cbaedbd..fa4244227 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1616,7 +1616,7 @@ dependencies = [ [[package]] name = "rwa-initial-lockup-period" -version = "0.7.0" +version = "0.7.1" dependencies = [ "soroban-sdk", "stellar-tokens", @@ -1624,7 +1624,7 @@ dependencies = [ [[package]] name = "rwa-time-transfers-limits" -version = "0.7.0" +version = "0.7.1" dependencies = [ "soroban-sdk", "stellar-tokens", @@ -1643,7 +1643,7 @@ dependencies = [ [[package]] name = "rwa-transfer-restrict" -version = "0.7.0" +version = "0.7.1" dependencies = [ "soroban-sdk", "stellar-tokens", From 258925b67602094ffa2700f00fb5e3dcde9d00f5 Mon Sep 17 00:00:00 2001 From: Aleksandr Pasevin Date: Fri, 10 Apr 2026 23:58:17 +0300 Subject: [PATCH 7/8] refactor: align transfer module examples with playbook Finish the standalone transfer-module cleanup pattern by normalizing the example crates, adding contract-facing tests, and cleaning the remaining doc style mismatches without changing module behavior. --- examples/rwa-initial-lockup-period/Cargo.toml | 7 + examples/rwa-initial-lockup-period/src/lib.rs | 2 + .../rwa-initial-lockup-period/src/test.rs | 216 ++++++++++++ examples/rwa-time-transfers-limits/Cargo.toml | 7 + examples/rwa-time-transfers-limits/src/lib.rs | 2 + .../rwa-time-transfers-limits/src/test.rs | 316 ++++++++++++++++++ examples/rwa-transfer-restrict/Cargo.toml | 7 + examples/rwa-transfer-restrict/src/lib.rs | 2 + examples/rwa-transfer-restrict/src/test.rs | 97 ++++++ .../modules/initial_lockup_period/storage.rs | 7 +- .../modules/time_transfers_limits/storage.rs | 8 +- .../modules/transfer_restrict/storage.rs | 6 +- 12 files changed, 664 insertions(+), 13 deletions(-) create mode 100644 examples/rwa-initial-lockup-period/src/test.rs create mode 100644 examples/rwa-time-transfers-limits/src/test.rs create mode 100644 examples/rwa-transfer-restrict/src/test.rs diff --git a/examples/rwa-initial-lockup-period/Cargo.toml b/examples/rwa-initial-lockup-period/Cargo.toml index dc0edbff4..767f3cca1 100644 --- a/examples/rwa-initial-lockup-period/Cargo.toml +++ b/examples/rwa-initial-lockup-period/Cargo.toml @@ -5,6 +5,10 @@ license.workspace = true repository.workspace = true publish = false version.workspace = true +authors.workspace = true + +[package.metadata.stellar] +cargo_inherit = true [lib] crate-type = ["cdylib", "rlib"] @@ -13,3 +17,6 @@ doctest = false [dependencies] soroban-sdk = { workspace = true } stellar-tokens = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/examples/rwa-initial-lockup-period/src/lib.rs b/examples/rwa-initial-lockup-period/src/lib.rs index b9aa4d5f0..a879b6f80 100644 --- a/examples/rwa-initial-lockup-period/src/lib.rs +++ b/examples/rwa-initial-lockup-period/src/lib.rs @@ -1,3 +1,5 @@ #![no_std] pub mod contract; +#[cfg(test)] +mod test; diff --git a/examples/rwa-initial-lockup-period/src/test.rs b/examples/rwa-initial-lockup-period/src/test.rs new file mode 100644 index 000000000..426f71f3f --- /dev/null +++ b/examples/rwa-initial-lockup-period/src/test.rs @@ -0,0 +1,216 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, String, Vec, +}; +use stellar_tokens::rwa::{ + compliance::{modules::initial_lockup_period::LockedTokens, Compliance, ComplianceHook}, + utils::token_binder::TokenBinder, +}; + +use crate::contract::{InitialLockupPeriodContract, InitialLockupPeriodContractClient}; + +fn create_client<'a>(e: &Env, admin: &Address) -> (Address, InitialLockupPeriodContractClient<'a>) { + let address = e.register(InitialLockupPeriodContract, (admin,)); + (address.clone(), InitialLockupPeriodContractClient::new(e, &address)) +} + +#[contract] +struct MockComplianceContract; + +#[contracttype] +#[derive(Clone)] +enum MockComplianceStorageKey { + Registered(ComplianceHook, Address), +} + +#[contractimpl] +impl Compliance for MockComplianceContract { + fn add_module_to(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("add_module_to is not used in these tests"); + } + + fn remove_module_from(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("remove_module_from is not used in these tests"); + } + + fn get_modules_for_hook(_e: &Env, _hook: ComplianceHook) -> Vec
{ + unreachable!("get_modules_for_hook is not used in these tests"); + } + + fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool { + e.storage().persistent().has(&MockComplianceStorageKey::Registered(hook, module)) + } + + fn transferred(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) { + unreachable!("transferred is not used in these tests"); + } + + fn created(_e: &Env, _to: Address, _amount: i128, _token: Address) { + unreachable!("created is not used in these tests"); + } + + fn destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) { + unreachable!("destroyed is not used in these tests"); + } + + fn can_transfer( + _e: &Env, + _from: Address, + _to: Address, + _amount: i128, + _token: Address, + ) -> bool { + unreachable!("can_transfer is not used in these tests"); + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + unreachable!("can_create is not used in these tests"); + } +} + +#[contractimpl] +impl TokenBinder for MockComplianceContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl MockComplianceContract { + pub fn register_hook(e: &Env, hook: ComplianceHook, module: Address) { + e.storage().persistent().set(&MockComplianceStorageKey::Registered(hook, module), &true); + } +} + +#[test] +fn set_and_get_lockup_state_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let wallet = Address::generate(&e); + let release_timestamp = e.ledger().timestamp().saturating_add(60); + let locks = vec![ + &e, + LockedTokens { amount: 80, release_timestamp }, + LockedTokens { amount: 10, release_timestamp: release_timestamp.saturating_add(60) }, + ]; + let (_address, client) = create_client(&e, &admin); + + client.set_lockup_period(&token, &60); + client.pre_set_lockup_state(&token, &wallet, &100, &locks); + + assert_eq!(client.get_lockup_period(&token), 60); + assert_eq!(client.get_total_locked(&token, &wallet), 90); + assert_eq!(client.get_internal_balance(&token, &wallet), 100); + + let stored_locks = client.get_locked_tokens(&token, &wallet); + assert_eq!(stored_locks.len(), 2); + + let first_lock = stored_locks.get(0).unwrap(); + assert_eq!(first_lock.amount, 80); + assert_eq!(first_lock.release_timestamp, release_timestamp); +} + +#[test] +fn name_compliance_address_and_required_hooks_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + assert_eq!(client.name(), String::from_str(&e, "InitialLockupPeriodModule")); + assert_eq!( + client.required_hooks(), + vec![ + &e, + ComplianceHook::CanTransfer, + ComplianceHook::Created, + ComplianceHook::Transferred, + ComplianceHook::Destroyed, + ] + ); + + client.set_compliance_address(&compliance); + assert_eq!(client.get_compliance_address(), compliance); +} + +#[test] +fn set_lockup_period_uses_admin_auth_before_compliance_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_lockup_period(&token, &60); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); +} + +#[test] +fn set_lockup_period_uses_compliance_auth_after_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_compliance_address(&compliance); + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); + + client.set_lockup_period(&token, &60); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &compliance); +} + +#[test] +fn verify_hook_wiring_and_can_transfer_use_public_contract_api() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let wallet = Address::generate(&e); + let recipient = Address::generate(&e); + let release_timestamp = e.ledger().timestamp().saturating_add(60); + let locks = vec![&e, LockedTokens { amount: 80, release_timestamp }]; + let (module_address, client) = create_client(&e, &admin); + let compliance_id = e.register(MockComplianceContract, ()); + let compliance = MockComplianceContractClient::new(&e, &compliance_id); + + client.set_compliance_address(&compliance_id); + for hook in [ + ComplianceHook::CanTransfer, + ComplianceHook::Created, + ComplianceHook::Transferred, + ComplianceHook::Destroyed, + ] { + compliance.register_hook(&hook, &module_address); + } + + client.verify_hook_wiring(); + client.pre_set_lockup_state(&token, &wallet, &100, &locks); + + assert!(!client.can_transfer(&wallet, &recipient, &21, &token)); + assert!(client.can_transfer(&wallet, &recipient, &20, &token)); +} diff --git a/examples/rwa-time-transfers-limits/Cargo.toml b/examples/rwa-time-transfers-limits/Cargo.toml index 6b71f752c..ab02105e5 100644 --- a/examples/rwa-time-transfers-limits/Cargo.toml +++ b/examples/rwa-time-transfers-limits/Cargo.toml @@ -5,6 +5,10 @@ license.workspace = true repository.workspace = true publish = false version.workspace = true +authors.workspace = true + +[package.metadata.stellar] +cargo_inherit = true [lib] crate-type = ["cdylib", "rlib"] @@ -13,3 +17,6 @@ doctest = false [dependencies] soroban-sdk = { workspace = true } stellar-tokens = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/examples/rwa-time-transfers-limits/src/lib.rs b/examples/rwa-time-transfers-limits/src/lib.rs index b9aa4d5f0..a879b6f80 100644 --- a/examples/rwa-time-transfers-limits/src/lib.rs +++ b/examples/rwa-time-transfers-limits/src/lib.rs @@ -1,3 +1,5 @@ #![no_std] pub mod contract; +#[cfg(test)] +mod test; diff --git a/examples/rwa-time-transfers-limits/src/test.rs b/examples/rwa-time-transfers-limits/src/test.rs new file mode 100644 index 000000000..7c75c6f2a --- /dev/null +++ b/examples/rwa-time-transfers-limits/src/test.rs @@ -0,0 +1,316 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, String, Val, + Vec, +}; +use stellar_tokens::rwa::{ + compliance::{ + modules::time_transfers_limits::{Limit, TransferCounter}, + Compliance, ComplianceHook, + }, + identity_registry_storage::{CountryDataManager, IdentityRegistryStorage}, + utils::token_binder::TokenBinder, +}; + +use crate::contract::{TimeTransfersLimitsContract, TimeTransfersLimitsContractClient}; + +fn create_client<'a>(e: &Env, admin: &Address) -> (Address, TimeTransfersLimitsContractClient<'a>) { + let address = e.register(TimeTransfersLimitsContract, (admin,)); + (address.clone(), TimeTransfersLimitsContractClient::new(e, &address)) +} + +#[contract] +struct MockIRSContract; + +#[contracttype] +#[derive(Clone)] +enum MockIRSStorageKey { + Identity(Address), +} + +#[contractimpl] +impl TokenBinder for MockIRSContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl IdentityRegistryStorage for MockIRSContract { + fn add_identity( + _e: &Env, + _account: Address, + _identity: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_identity is not used in these tests"); + } + + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { + unreachable!("remove_identity is not used in these tests"); + } + + fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { + unreachable!("modify_identity is not used in these tests"); + } + + fn recover_identity( + _e: &Env, + _old_account: Address, + _new_account: Address, + _operator: Address, + ) { + unreachable!("recover_identity is not used in these tests"); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + e.storage() + .persistent() + .get(&MockIRSStorageKey::Identity(account.clone())) + .unwrap_or(account) + } +} + +#[contractimpl] +impl CountryDataManager for MockIRSContract { + fn add_country_data_entries( + _e: &Env, + _account: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_country_data_entries is not used in these tests"); + } + + fn modify_country_data( + _e: &Env, + _account: Address, + _index: u32, + _country_data: Val, + _operator: Address, + ) { + unreachable!("modify_country_data is not used in these tests"); + } + + fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { + unreachable!("delete_country_data is not used in these tests"); + } + + fn get_country_data_entries(e: &Env, _account: Address) -> Vec { + Vec::new(e) + } +} + +#[contractimpl] +impl MockIRSContract { + pub fn set_identity(e: &Env, account: Address, identity: Address) { + e.storage().persistent().set(&MockIRSStorageKey::Identity(account), &identity); + } +} + +#[contract] +struct MockComplianceContract; + +#[contracttype] +#[derive(Clone)] +enum MockComplianceStorageKey { + Registered(ComplianceHook, Address), +} + +#[contractimpl] +impl Compliance for MockComplianceContract { + fn add_module_to(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("add_module_to is not used in these tests"); + } + + fn remove_module_from(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("remove_module_from is not used in these tests"); + } + + fn get_modules_for_hook(_e: &Env, _hook: ComplianceHook) -> Vec
{ + unreachable!("get_modules_for_hook is not used in these tests"); + } + + fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool { + e.storage().persistent().has(&MockComplianceStorageKey::Registered(hook, module)) + } + + fn transferred(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) { + unreachable!("transferred is not used in these tests"); + } + + fn created(_e: &Env, _to: Address, _amount: i128, _token: Address) { + unreachable!("created is not used in these tests"); + } + + fn destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) { + unreachable!("destroyed is not used in these tests"); + } + + fn can_transfer( + _e: &Env, + _from: Address, + _to: Address, + _amount: i128, + _token: Address, + ) -> bool { + unreachable!("can_transfer is not used in these tests"); + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + unreachable!("can_create is not used in these tests"); + } +} + +#[contractimpl] +impl TokenBinder for MockComplianceContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl MockComplianceContract { + pub fn register_hook(e: &Env, hook: ComplianceHook, module: Address) { + e.storage().persistent().set(&MockComplianceStorageKey::Registered(hook, module), &true); + } +} + +#[test] +fn set_and_manage_time_transfer_limits_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let limit_a = Limit { limit_time: 60, limit_value: 100 }; + let limit_b = Limit { limit_time: 120, limit_value: 200 }; + let (_address, client) = create_client(&e, &admin); + + client.set_time_transfer_limit(&token, &limit_a); + client.batch_set_time_transfer_limit(&token, &vec![&e, limit_b.clone()]); + + assert_eq!(client.get_time_transfer_limits(&token), vec![&e, limit_a.clone(), limit_b.clone()]); + + client.batch_remove_time_transfer_limit(&token, &vec![&e, 120_u64]); + assert_eq!(client.get_time_transfer_limits(&token), vec![&e, limit_a.clone()]); + + client.remove_time_transfer_limit(&token, &60); + assert_eq!(client.get_time_transfer_limits(&token).len(), 0); +} + +#[test] +fn name_compliance_address_and_required_hooks_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + assert_eq!(client.name(), String::from_str(&e, "TimeTransfersLimitsModule")); + assert_eq!( + client.required_hooks(), + vec![&e, ComplianceHook::CanTransfer, ComplianceHook::Transferred] + ); + + client.set_compliance_address(&compliance); + assert_eq!(client.get_compliance_address(), compliance); +} + +#[test] +fn set_identity_registry_storage_uses_admin_auth_before_compliance_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let irs = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_identity_registry_storage(&token, &irs); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); +} + +#[test] +fn set_identity_registry_storage_uses_compliance_auth_after_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let irs = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_compliance_address(&compliance); + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); + + client.set_identity_registry_storage(&token, &irs); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &compliance); +} + +#[test] +fn verify_hook_wiring_and_counters_affect_public_transfer_checks() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let recipient = Address::generate(&e); + let sender_identity = Address::generate(&e); + let limit = Limit { limit_time: 60, limit_value: 100 }; + let (module_address, client) = create_client(&e, &admin); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let compliance_id = e.register(MockComplianceContract, ()); + let compliance = MockComplianceContractClient::new(&e, &compliance_id); + + irs.set_identity(&sender, &sender_identity); + + client.set_compliance_address(&compliance_id); + for hook in [ComplianceHook::CanTransfer, ComplianceHook::Transferred] { + compliance.register_hook(&hook, &module_address); + } + + client.verify_hook_wiring(); + client.set_identity_registry_storage(&token, &irs_id); + client.set_time_transfer_limit(&token, &limit); + client.pre_set_transfer_counter( + &token, + &sender_identity, + &60, + &TransferCounter { value: 90, timer: e.ledger().timestamp().saturating_add(60) }, + ); + + assert!(!client.can_transfer(&sender, &recipient, &11, &token)); + assert!(client.can_transfer(&sender, &recipient, &10, &token)); + + client.on_transfer(&sender, &recipient, &10, &token); + assert!(!client.can_transfer(&sender, &recipient, &1, &token)); +} diff --git a/examples/rwa-transfer-restrict/Cargo.toml b/examples/rwa-transfer-restrict/Cargo.toml index 9655c300d..e6e333e09 100644 --- a/examples/rwa-transfer-restrict/Cargo.toml +++ b/examples/rwa-transfer-restrict/Cargo.toml @@ -5,6 +5,10 @@ license.workspace = true repository.workspace = true publish = false version.workspace = true +authors.workspace = true + +[package.metadata.stellar] +cargo_inherit = true [lib] crate-type = ["cdylib", "rlib"] @@ -13,3 +17,6 @@ doctest = false [dependencies] soroban-sdk = { workspace = true } stellar-tokens = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/examples/rwa-transfer-restrict/src/lib.rs b/examples/rwa-transfer-restrict/src/lib.rs index b9aa4d5f0..a879b6f80 100644 --- a/examples/rwa-transfer-restrict/src/lib.rs +++ b/examples/rwa-transfer-restrict/src/lib.rs @@ -1,3 +1,5 @@ #![no_std] pub mod contract; +#[cfg(test)] +mod test; diff --git a/examples/rwa-transfer-restrict/src/test.rs b/examples/rwa-transfer-restrict/src/test.rs new file mode 100644 index 000000000..7c5f07871 --- /dev/null +++ b/examples/rwa-transfer-restrict/src/test.rs @@ -0,0 +1,97 @@ +extern crate std; + +use soroban_sdk::{testutils::Address as _, vec, Address, Env, String}; + +use crate::contract::{TransferRestrictContract, TransferRestrictContractClient}; + +fn create_client<'a>(e: &Env, admin: &Address) -> (Address, TransferRestrictContractClient<'a>) { + let address = e.register(TransferRestrictContract, (admin,)); + (address.clone(), TransferRestrictContractClient::new(e, &address)) +} + +#[test] +fn allowlist_methods_and_can_transfer_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let recipient = Address::generate(&e); + let other = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + assert!(!client.is_user_allowed(&token, &sender)); + assert!(!client.can_transfer(&sender, &recipient, &1, &token)); + + client.allow_user(&token, &sender); + assert!(client.is_user_allowed(&token, &sender)); + assert!(client.can_transfer(&sender, &other, &1, &token)); + + client.disallow_user(&token, &sender); + assert!(!client.is_user_allowed(&token, &sender)); + + client.batch_allow_users(&token, &vec![&e, recipient.clone(), other.clone()]); + assert!(client.is_user_allowed(&token, &recipient)); + assert!(client.is_user_allowed(&token, &other)); + assert!(client.can_transfer(&sender, &recipient, &1, &token)); + + client.batch_disallow_users(&token, &vec![&e, recipient.clone(), other.clone()]); + assert!(!client.is_user_allowed(&token, &recipient)); + assert!(!client.is_user_allowed(&token, &other)); + assert!(!client.can_transfer(&sender, &recipient, &1, &token)); +} + +#[test] +fn name_and_compliance_address_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + assert_eq!(client.name(), String::from_str(&e, "TransferRestrictModule")); + + client.set_compliance_address(&compliance); + assert_eq!(client.get_compliance_address(), compliance); +} + +#[test] +fn allow_user_uses_admin_auth_before_compliance_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let user = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.allow_user(&token, &user); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); +} + +#[test] +fn allow_user_uses_compliance_auth_after_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let user = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_compliance_address(&compliance); + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); + + client.allow_user(&token, &user); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &compliance); +} diff --git a/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/storage.rs b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/storage.rs index 3bff8f5ef..8224d7ad1 100644 --- a/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/storage.rs +++ b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/storage.rs @@ -380,7 +380,8 @@ pub fn on_destroyed(e: &Env, from: &Address, amount: i128, token: &Address) { set_internal_balance(e, token, from, sub_i128_or_panic(e, current, amount)); } -/// Checks whether a transfer is allowed based on lockup restrictions. +/// Returns `true` if the sender has sufficient unlocked balance for the +/// transfer. /// /// # Arguments /// @@ -388,10 +389,6 @@ pub fn on_destroyed(e: &Env, from: &Address, amount: i128, token: &Address) { /// * `from` - The sender address. /// * `amount` - The transfer amount. /// * `token` - The token address. -/// -/// # Returns -/// -/// `true` if the sender has sufficient unlocked balance. pub fn can_transfer(e: &Env, from: &Address, amount: i128, token: &Address) -> bool { assert!( hooks_verified(e), 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 index 023f200cd..b46cde1a6 100644 --- a/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/storage.rs +++ b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/storage.rs @@ -306,7 +306,8 @@ pub fn on_transfer(e: &Env, from: &Address, amount: i128, token: &Address) { increase_counters(e, token, &from_id, amount); } -/// Checks whether a transfer is within the configured time-window limits. +/// Returns `true` if the transfer does not exceed any configured +/// time-window limit. /// /// # Arguments /// @@ -315,9 +316,10 @@ pub fn on_transfer(e: &Env, from: &Address, amount: i128, token: &Address) { /// * `amount` - The transfer amount. /// * `token` - The token address. /// -/// # Returns +/// # Errors /// -/// `true` if the transfer does not exceed any limit. +/// * [`crate::rwa::compliance::modules::ComplianceModuleError::IdentityRegistryNotSet`] +/// - When no IRS has been configured for `token`. pub fn can_transfer(e: &Env, from: &Address, amount: i128, token: &Address) -> bool { assert!( hooks_verified(e), diff --git a/packages/tokens/src/rwa/compliance/modules/transfer_restrict/storage.rs b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/storage.rs index 8a02b799e..77337ae0c 100644 --- a/packages/tokens/src/rwa/compliance/modules/transfer_restrict/storage.rs +++ b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/storage.rs @@ -116,7 +116,7 @@ pub fn batch_disallow_users(e: &Env, token: &Address, users: &Vec
) { // ################## COMPLIANCE HOOKS ################## -/// Checks whether the transfer is allowed by the address allowlist. +/// Returns `true` if the sender or recipient is allowlisted. /// /// T-REX semantics: if the sender is allowlisted, the transfer passes; /// otherwise the recipient must be allowlisted. @@ -127,10 +127,6 @@ pub fn batch_disallow_users(e: &Env, token: &Address, users: &Vec
) { /// * `from` - The sender address. /// * `to` - The recipient address. /// * `token` - The token address. -/// -/// # Returns -/// -/// `true` if the sender or recipient is allowlisted, `false` otherwise. pub fn can_transfer(e: &Env, from: &Address, to: &Address, token: &Address) -> bool { if is_user_allowed(e, token, from) { return true; From 2d43f5845f171b3393c67c4e501b642e02934e2a Mon Sep 17 00:00:00 2001 From: Aleksandr Pasevin Date: Tue, 19 May 2026 00:27:42 +0300 Subject: [PATCH 8/8] refactor: align transfer module style Apply the established RWA module conventions across the transfer modules without changing the remaining fail-closed hook behavior. --- Cargo.lock | 6 + examples/rwa-initial-lockup-period/Cargo.toml | 2 + .../rwa-initial-lockup-period/src/contract.rs | 66 ++--- .../rwa-initial-lockup-period/src/test.rs | 4 +- examples/rwa-time-transfers-limits/Cargo.toml | 2 + .../rwa-time-transfers-limits/src/contract.rs | 72 ++---- .../rwa-time-transfers-limits/src/test.rs | 4 +- examples/rwa-transfer-restrict/Cargo.toml | 2 + .../rwa-transfer-restrict/src/contract.rs | 58 ++--- examples/rwa-transfer-restrict/src/test.rs | 4 +- .../modules/initial_lockup_period/mod.rs | 141 ++++++++++- .../modules/initial_lockup_period/storage.rs | 239 ++++++++++++------ .../tokens/src/rwa/compliance/modules/mod.rs | 18 +- .../modules/time_transfers_limits/mod.rs | 207 ++++++++++++++- .../modules/time_transfers_limits/storage.rs | 206 +++++++++++---- .../modules/transfer_restrict/mod.rs | 126 ++++++++- .../modules/transfer_restrict/storage.rs | 136 ++++++---- 17 files changed, 972 insertions(+), 321 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index debb295aa..ff46b86e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1651,6 +1651,8 @@ name = "rwa-initial-lockup-period" version = "0.7.1" dependencies = [ "soroban-sdk", + "stellar-access", + "stellar-macros", "stellar-tokens", ] @@ -1659,6 +1661,8 @@ name = "rwa-time-transfers-limits" version = "0.7.1" dependencies = [ "soroban-sdk", + "stellar-access", + "stellar-macros", "stellar-tokens", ] @@ -1678,6 +1682,8 @@ name = "rwa-transfer-restrict" version = "0.7.1" dependencies = [ "soroban-sdk", + "stellar-access", + "stellar-macros", "stellar-tokens", ] diff --git a/examples/rwa-initial-lockup-period/Cargo.toml b/examples/rwa-initial-lockup-period/Cargo.toml index 767f3cca1..2658397b6 100644 --- a/examples/rwa-initial-lockup-period/Cargo.toml +++ b/examples/rwa-initial-lockup-period/Cargo.toml @@ -16,6 +16,8 @@ doctest = false [dependencies] soroban-sdk = { workspace = true } +stellar-access = { workspace = true } +stellar-macros = { workspace = true } stellar-tokens = { workspace = true } [dev-dependencies] diff --git a/examples/rwa-initial-lockup-period/src/contract.rs b/examples/rwa-initial-lockup-period/src/contract.rs index 330f6741a..bd5ee29dc 100644 --- a/examples/rwa-initial-lockup-period/src/contract.rs +++ b/examples/rwa-initial-lockup-period/src/contract.rs @@ -1,84 +1,68 @@ -use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use soroban_sdk::{contract, contractimpl, Address, Env, String, Vec}; +use stellar_access::access_control; +use stellar_macros::only_admin; use stellar_tokens::rwa::compliance::{ modules::{ - initial_lockup_period::{storage as lockup, LockedTokens}, - storage::{ - get_compliance_address, module_name, set_compliance_address, ComplianceModuleStorageKey, - }, + initial_lockup_period::{storage as lockup, InitialLockupPeriod, LockedTokens}, + storage::{get_compliance_address, module_name, set_compliance_address}, ComplianceModule, }, ComplianceHook, }; -#[contracttype] -enum DataKey { - Admin, -} - #[contract] pub struct InitialLockupPeriodContract; -fn set_admin(e: &Env, admin: &Address) { - e.storage().instance().set(&DataKey::Admin, admin); -} - -fn get_admin(e: &Env) -> Address { - e.storage().instance().get(&DataKey::Admin).expect("admin must be set") -} - -fn require_module_admin_or_compliance_auth(e: &Env) { - if let Some(compliance) = - e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) - { - compliance.require_auth(); - } else { - get_admin(e).require_auth(); - } +fn require_compliance_auth(e: &Env) { + get_compliance_address(e).require_auth(); } #[contractimpl] impl InitialLockupPeriodContract { pub fn __constructor(e: &Env, admin: Address) { - set_admin(e, &admin); + access_control::set_admin(e, &admin); } +} - pub fn set_lockup_period(e: &Env, token: Address, lockup_seconds: u64) { - require_module_admin_or_compliance_auth(e); +#[contractimpl(contracttrait)] +impl InitialLockupPeriod for InitialLockupPeriodContract { + #[only_admin] + fn set_lockup_period(e: &Env, token: Address, lockup_seconds: u64) { lockup::configure_lockup_period(e, &token, lockup_seconds); } - pub fn pre_set_lockup_state( + #[only_admin] + fn pre_set_lockup_state( e: &Env, token: Address, wallet: Address, balance: i128, locks: Vec, ) { - require_module_admin_or_compliance_auth(e); lockup::pre_set_lockup_state(e, &token, &wallet, balance, &locks); } - pub fn get_lockup_period(e: &Env, token: Address) -> u64 { + fn get_lockup_period(e: &Env, token: Address) -> u64 { lockup::get_lockup_period(e, &token) } - pub fn get_total_locked(e: &Env, token: Address, wallet: Address) -> i128 { + fn get_total_locked(e: &Env, token: Address, wallet: Address) -> i128 { lockup::get_total_locked(e, &token, &wallet) } - pub fn get_locked_tokens(e: &Env, token: Address, wallet: Address) -> Vec { + fn get_locked_tokens(e: &Env, token: Address, wallet: Address) -> Vec { lockup::get_locks(e, &token, &wallet) } - pub fn get_internal_balance(e: &Env, token: Address, wallet: Address) -> i128 { + fn get_internal_balance(e: &Env, token: Address, wallet: Address) -> i128 { lockup::get_internal_balance(e, &token, &wallet) } - pub fn required_hooks(e: &Env) -> Vec { + fn required_hooks(e: &Env) -> Vec { lockup::required_hooks(e) } - pub fn verify_hook_wiring(e: &Env) { + fn verify_hook_wiring(e: &Env) { lockup::verify_hook_wiring(e); } } @@ -86,17 +70,17 @@ impl InitialLockupPeriodContract { #[contractimpl(contracttrait)] impl ComplianceModule for InitialLockupPeriodContract { fn on_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) { - require_module_admin_or_compliance_auth(e); + require_compliance_auth(e); lockup::on_transfer(e, &from, &to, amount, &token); } fn on_created(e: &Env, to: Address, amount: i128, token: Address) { - require_module_admin_or_compliance_auth(e); + require_compliance_auth(e); lockup::on_created(e, &to, amount, &token); } fn on_destroyed(e: &Env, from: Address, amount: i128, token: Address) { - require_module_admin_or_compliance_auth(e); + require_compliance_auth(e); lockup::on_destroyed(e, &from, amount, &token); } @@ -116,8 +100,8 @@ impl ComplianceModule for InitialLockupPeriodContract { get_compliance_address(e) } + #[only_admin] fn set_compliance_address(e: &Env, compliance: Address) { - get_admin(e).require_auth(); set_compliance_address(e, &compliance); } } diff --git a/examples/rwa-initial-lockup-period/src/test.rs b/examples/rwa-initial-lockup-period/src/test.rs index 426f71f3f..8e69c7841 100644 --- a/examples/rwa-initial-lockup-period/src/test.rs +++ b/examples/rwa-initial-lockup-period/src/test.rs @@ -162,7 +162,7 @@ fn set_lockup_period_uses_admin_auth_before_compliance_bind() { } #[test] -fn set_lockup_period_uses_compliance_auth_after_bind() { +fn set_lockup_period_uses_admin_auth_after_compliance_bind() { let e = Env::default(); e.mock_all_auths(); let admin = Address::generate(&e); @@ -181,7 +181,7 @@ fn set_lockup_period_uses_compliance_auth_after_bind() { let auths = e.auths(); assert_eq!(auths.len(), 1); let (addr, _) = &auths[0]; - assert_eq!(addr, &compliance); + assert_eq!(addr, &admin); } #[test] diff --git a/examples/rwa-time-transfers-limits/Cargo.toml b/examples/rwa-time-transfers-limits/Cargo.toml index ab02105e5..0142a8b87 100644 --- a/examples/rwa-time-transfers-limits/Cargo.toml +++ b/examples/rwa-time-transfers-limits/Cargo.toml @@ -16,6 +16,8 @@ doctest = false [dependencies] soroban-sdk = { workspace = true } +stellar-access = { workspace = true } +stellar-macros = { workspace = true } stellar-tokens = { workspace = true } [dev-dependencies] diff --git a/examples/rwa-time-transfers-limits/src/contract.rs b/examples/rwa-time-transfers-limits/src/contract.rs index d1d559a4b..a1e9a4da1 100644 --- a/examples/rwa-time-transfers-limits/src/contract.rs +++ b/examples/rwa-time-transfers-limits/src/contract.rs @@ -1,92 +1,76 @@ -use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use soroban_sdk::{contract, contractimpl, Address, Env, String, Vec}; +use stellar_access::access_control; +use stellar_macros::only_admin; use stellar_tokens::rwa::compliance::{ modules::{ - storage::{ - get_compliance_address, module_name, set_compliance_address, ComplianceModuleStorageKey, - }, - time_transfers_limits::{storage as ttl, Limit, TransferCounter}, + storage::{get_compliance_address, module_name, set_compliance_address}, + time_transfers_limits::{storage as ttl, Limit, TimeTransfersLimits, TransferCounter}, ComplianceModule, }, ComplianceHook, }; -#[contracttype] -enum DataKey { - Admin, -} - #[contract] pub struct TimeTransfersLimitsContract; -fn set_admin(e: &Env, admin: &Address) { - e.storage().instance().set(&DataKey::Admin, admin); -} - -fn get_admin(e: &Env) -> Address { - e.storage().instance().get(&DataKey::Admin).expect("admin must be set") -} - -fn require_module_admin_or_compliance_auth(e: &Env) { - if let Some(compliance) = - e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) - { - compliance.require_auth(); - } else { - get_admin(e).require_auth(); - } +fn require_compliance_auth(e: &Env) { + get_compliance_address(e).require_auth(); } #[contractimpl] impl TimeTransfersLimitsContract { pub fn __constructor(e: &Env, admin: Address) { - set_admin(e, &admin); + access_control::set_admin(e, &admin); } +} - pub fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { - require_module_admin_or_compliance_auth(e); +#[contractimpl(contracttrait)] +impl TimeTransfersLimits for TimeTransfersLimitsContract { + #[only_admin] + fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { ttl::configure_irs(e, &token, &irs); } - pub fn set_time_transfer_limit(e: &Env, token: Address, limit: Limit) { - require_module_admin_or_compliance_auth(e); + #[only_admin] + fn set_time_transfer_limit(e: &Env, token: Address, limit: Limit) { ttl::set_time_transfer_limit(e, &token, &limit); } - pub fn batch_set_time_transfer_limit(e: &Env, token: Address, limits: Vec) { - require_module_admin_or_compliance_auth(e); + #[only_admin] + fn batch_set_time_transfer_limit(e: &Env, token: Address, limits: Vec) { ttl::batch_set_time_transfer_limit(e, &token, &limits); } - pub fn remove_time_transfer_limit(e: &Env, token: Address, limit_time: u64) { - require_module_admin_or_compliance_auth(e); + #[only_admin] + fn remove_time_transfer_limit(e: &Env, token: Address, limit_time: u64) { ttl::remove_time_transfer_limit(e, &token, limit_time); } - pub fn batch_remove_time_transfer_limit(e: &Env, token: Address, limit_times: Vec) { - require_module_admin_or_compliance_auth(e); + #[only_admin] + fn batch_remove_time_transfer_limit(e: &Env, token: Address, limit_times: Vec) { ttl::batch_remove_time_transfer_limit(e, &token, &limit_times); } - pub fn pre_set_transfer_counter( + #[only_admin] + fn pre_set_transfer_counter( e: &Env, token: Address, identity: Address, limit_time: u64, counter: TransferCounter, ) { - require_module_admin_or_compliance_auth(e); ttl::pre_set_transfer_counter(e, &token, &identity, limit_time, &counter); } - pub fn get_time_transfer_limits(e: &Env, token: Address) -> Vec { + fn get_time_transfer_limits(e: &Env, token: Address) -> Vec { ttl::get_limits(e, &token) } - pub fn required_hooks(e: &Env) -> Vec { + fn required_hooks(e: &Env) -> Vec { ttl::required_hooks(e) } - pub fn verify_hook_wiring(e: &Env) { + fn verify_hook_wiring(e: &Env) { ttl::verify_hook_wiring(e); } } @@ -94,7 +78,7 @@ impl TimeTransfersLimitsContract { #[contractimpl(contracttrait)] impl ComplianceModule for TimeTransfersLimitsContract { fn on_transfer(e: &Env, from: Address, _to: Address, amount: i128, token: Address) { - require_module_admin_or_compliance_auth(e); + require_compliance_auth(e); ttl::on_transfer(e, &from, amount, &token); } @@ -118,8 +102,8 @@ impl ComplianceModule for TimeTransfersLimitsContract { get_compliance_address(e) } + #[only_admin] fn set_compliance_address(e: &Env, compliance: Address) { - get_admin(e).require_auth(); set_compliance_address(e, &compliance); } } diff --git a/examples/rwa-time-transfers-limits/src/test.rs b/examples/rwa-time-transfers-limits/src/test.rs index 7c75c6f2a..f9f234193 100644 --- a/examples/rwa-time-transfers-limits/src/test.rs +++ b/examples/rwa-time-transfers-limits/src/test.rs @@ -252,7 +252,7 @@ fn set_identity_registry_storage_uses_admin_auth_before_compliance_bind() { } #[test] -fn set_identity_registry_storage_uses_compliance_auth_after_bind() { +fn set_identity_registry_storage_uses_admin_auth_after_compliance_bind() { let e = Env::default(); e.mock_all_auths(); let admin = Address::generate(&e); @@ -272,7 +272,7 @@ fn set_identity_registry_storage_uses_compliance_auth_after_bind() { let auths = e.auths(); assert_eq!(auths.len(), 1); let (addr, _) = &auths[0]; - assert_eq!(addr, &compliance); + assert_eq!(addr, &admin); } #[test] diff --git a/examples/rwa-transfer-restrict/Cargo.toml b/examples/rwa-transfer-restrict/Cargo.toml index e6e333e09..9bb4c7b76 100644 --- a/examples/rwa-transfer-restrict/Cargo.toml +++ b/examples/rwa-transfer-restrict/Cargo.toml @@ -16,6 +16,8 @@ doctest = false [dependencies] soroban-sdk = { workspace = true } +stellar-access = { workspace = true } +stellar-macros = { workspace = true } stellar-tokens = { workspace = true } [dev-dependencies] diff --git a/examples/rwa-transfer-restrict/src/contract.rs b/examples/rwa-transfer-restrict/src/contract.rs index b69043c63..04f696afd 100644 --- a/examples/rwa-transfer-restrict/src/contract.rs +++ b/examples/rwa-transfer-restrict/src/contract.rs @@ -1,65 +1,45 @@ -use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use soroban_sdk::{contract, contractimpl, Address, Env, String, Vec}; +use stellar_access::access_control; +use stellar_macros::only_admin; use stellar_tokens::rwa::compliance::modules::{ - storage::{ - get_compliance_address, module_name, set_compliance_address, ComplianceModuleStorageKey, - }, - transfer_restrict::storage as transfer_restrict, + storage::{get_compliance_address, module_name, set_compliance_address}, + transfer_restrict::{storage as transfer_restrict, TransferRestrict}, ComplianceModule, }; -#[contracttype] -enum DataKey { - Admin, -} - #[contract] pub struct TransferRestrictContract; -fn set_admin(e: &Env, admin: &Address) { - e.storage().instance().set(&DataKey::Admin, admin); -} - -fn get_admin(e: &Env) -> Address { - e.storage().instance().get(&DataKey::Admin).expect("admin must be set") -} - -fn require_module_admin_or_compliance_auth(e: &Env) { - if let Some(compliance) = - e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) - { - compliance.require_auth(); - } else { - get_admin(e).require_auth(); - } -} - #[contractimpl] impl TransferRestrictContract { pub fn __constructor(e: &Env, admin: Address) { - set_admin(e, &admin); + access_control::set_admin(e, &admin); } +} - pub fn allow_user(e: &Env, token: Address, user: Address) { - require_module_admin_or_compliance_auth(e); +#[contractimpl(contracttrait)] +impl TransferRestrict for TransferRestrictContract { + #[only_admin] + fn allow_user(e: &Env, token: Address, user: Address) { transfer_restrict::allow_user(e, &token, &user); } - pub fn disallow_user(e: &Env, token: Address, user: Address) { - require_module_admin_or_compliance_auth(e); + #[only_admin] + fn disallow_user(e: &Env, token: Address, user: Address) { transfer_restrict::disallow_user(e, &token, &user); } - pub fn batch_allow_users(e: &Env, token: Address, users: Vec
) { - require_module_admin_or_compliance_auth(e); + #[only_admin] + fn batch_allow_users(e: &Env, token: Address, users: Vec
) { transfer_restrict::batch_allow_users(e, &token, &users); } - pub fn batch_disallow_users(e: &Env, token: Address, users: Vec
) { - require_module_admin_or_compliance_auth(e); + #[only_admin] + fn batch_disallow_users(e: &Env, token: Address, users: Vec
) { transfer_restrict::batch_disallow_users(e, &token, &users); } - pub fn is_user_allowed(e: &Env, token: Address, user: Address) -> bool { + fn is_user_allowed(e: &Env, token: Address, user: Address) -> bool { transfer_restrict::is_user_allowed(e, &token, &user) } } @@ -88,8 +68,8 @@ impl ComplianceModule for TransferRestrictContract { get_compliance_address(e) } + #[only_admin] fn set_compliance_address(e: &Env, compliance: Address) { - get_admin(e).require_auth(); set_compliance_address(e, &compliance); } } diff --git a/examples/rwa-transfer-restrict/src/test.rs b/examples/rwa-transfer-restrict/src/test.rs index 7c5f07871..d100f2887 100644 --- a/examples/rwa-transfer-restrict/src/test.rs +++ b/examples/rwa-transfer-restrict/src/test.rs @@ -73,7 +73,7 @@ fn allow_user_uses_admin_auth_before_compliance_bind() { } #[test] -fn allow_user_uses_compliance_auth_after_bind() { +fn allow_user_uses_admin_auth_after_compliance_bind() { let e = Env::default(); e.mock_all_auths(); let admin = Address::generate(&e); @@ -93,5 +93,5 @@ fn allow_user_uses_compliance_auth_after_bind() { let auths = e.auths(); assert_eq!(auths.len(), 1); let (addr, _) = &auths[0]; - assert_eq!(addr, &compliance); + assert_eq!(addr, &admin); } 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 index ed816abfe..548645b3f 100644 --- a/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/mod.rs +++ b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/mod.rs @@ -4,6 +4,9 @@ //! 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. +//! The Stellar module keeps internal balance and lock mirrors updated by +//! create, transfer, and destroy hooks so transfer checks can remain local to +//! the module. //! //! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/TimeExchangeLimitsModule.sol @@ -11,8 +14,131 @@ pub mod storage; #[cfg(test)] mod test; -use soroban_sdk::{contractevent, Address}; -pub use storage::LockedTokens; +use soroban_sdk::{contractevent, contracttrait, Address, Env, Vec}; + +pub use crate::rwa::compliance::modules::initial_lockup_period::storage::LockedTokens; +use crate::rwa::compliance::{modules::ComplianceModule, ComplianceHook}; + +/// Initial lockup period compliance module trait. +/// +/// This trait defines the contract-facing API for the initial lockup module. +/// Low-level state changes live in [`storage`]. Privileged methods have no +/// default implementation because each contract must enforce its own access +/// control before delegating to storage helpers. +#[contracttrait] +pub trait InitialLockupPeriod: ComplianceModule { + /// Configures the lockup period for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token whose lockup period is configured. + /// * `lockup_seconds` - The lockup duration in seconds. + /// + /// # Events + /// + /// * topics - `["lockup_period_set", token: Address]` + /// * data - `[lockup_seconds: u64]` + /// + /// # Notes + /// + /// No default implementation is provided because this is a privileged + /// operation that requires custom access control. + fn set_lockup_period(e: &Env, token: Address, lockup_seconds: u64); + + /// Pre-seeds lockup state for `wallet`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token whose lockup state is pre-seeded. + /// * `wallet` - The wallet address. + /// * `balance` - The wallet balance mirror. + /// * `locks` - Existing lock entries. + /// + /// # Errors + /// + /// * refer to [`storage::pre_set_lockup_state`] errors. + /// + /// # Notes + /// + /// No default implementation is provided because this is a privileged + /// operation that requires custom access control. + fn pre_set_lockup_state( + e: &Env, + token: Address, + wallet: Address, + balance: i128, + locks: Vec, + ); + + /// Returns the lockup period for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token whose lockup period is queried. + fn get_lockup_period(e: &Env, token: Address) -> u64 { + storage::get_lockup_period(e, &token) + } + + /// Returns the aggregate locked amount for `wallet` on `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token whose lockup state is queried. + /// * `wallet` - The wallet address. + fn get_total_locked(e: &Env, token: Address, wallet: Address) -> i128 { + storage::get_total_locked(e, &token, &wallet) + } + + /// Returns lock entries for `wallet` on `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token whose lockup state is queried. + /// * `wallet` - The wallet address. + fn get_locked_tokens(e: &Env, token: Address, wallet: Address) -> Vec { + storage::get_locks(e, &token, &wallet) + } + + /// Returns the internal balance mirror for `wallet` on `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token whose balance mirror is queried. + /// * `wallet` - The wallet address. + fn get_internal_balance(e: &Env, token: Address, wallet: Address) -> i128 { + storage::get_internal_balance(e, &token, &wallet) + } + + /// Returns the set of compliance hooks this module requires. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + fn required_hooks(e: &Env) -> Vec { + storage::required_hooks(e) + } + + /// Verifies that this module is registered on every required hook. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// + /// # Errors + /// + /// * refer to [`storage::verify_hook_wiring`] errors. + fn verify_hook_wiring(e: &Env) { + storage::verify_hook_wiring(e); + } +} + +// ################## EVENTS ################## /// Emitted when a token's lockup duration is configured or changed. #[contractevent] @@ -22,3 +148,14 @@ pub struct LockupPeriodSet { pub token: Address, pub lockup_seconds: u64, } + +/// Emits a [`LockupPeriodSet`] event. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token whose lockup period was configured. +/// * `lockup_seconds` - The configured lockup duration in seconds. +pub fn emit_lockup_period_set(e: &Env, token: &Address, lockup_seconds: u64) { + LockupPeriodSet { token: token.clone(), lockup_seconds }.publish(e); +} 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 index 8224d7ad1..3c58f30ee 100644 --- a/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/storage.rs +++ b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/storage.rs @@ -1,13 +1,13 @@ -use soroban_sdk::{contracttype, vec, Address, Env, Vec}; +use soroban_sdk::{contracttype, panic_with_error, vec, Address, Env, Vec}; -use super::LockupPeriodSet; use crate::rwa::compliance::{ modules::{ + initial_lockup_period::emit_lockup_period_set, storage::{ add_i128_or_panic, hooks_verified, require_non_negative_amount, sub_i128_or_panic, verify_required_hooks, }, - MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD, + ComplianceModuleError, MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD, }, ComplianceHook, }; @@ -35,7 +35,7 @@ pub enum InitialLockupStorageKey { InternalBalance(Address, Address), } -// ################## RAW STORAGE ################## +// ################## QUERY STATE ################## /// Returns the lockup period (in seconds) for `token`, or `0` if not set. /// @@ -61,10 +61,13 @@ pub fn get_lockup_period(e: &Env, token: &Address) -> u64 { /// * `e` - Access to the Soroban environment. /// * `token` - The token address. /// * `seconds` - The lockup duration in seconds. +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. 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`. @@ -93,10 +96,13 @@ pub fn get_locks(e: &Env, token: &Address, wallet: &Address) -> 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`. @@ -125,10 +131,13 @@ pub fn get_total_locked(e: &Env, token: &Address, wallet: &Address) -> i128 { /// * `token` - The token address. /// * `wallet` - The wallet address. /// * `amount` - The new total locked amount. +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. 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`. @@ -157,79 +166,36 @@ pub fn get_internal_balance(e: &Env, token: &Address, wallet: &Address) -> i128 /// * `token` - The token address. /// * `wallet` - The wallet address. /// * `balance` - The new balance value. +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. pub fn set_internal_balance(e: &Env, token: &Address, wallet: &Address, balance: i128) { let key = InitialLockupStorageKey::InternalBalance(token.clone(), wallet.clone()); e.storage().persistent().set(&key, &balance); - e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); -} - -// ################## HELPERS ################## - -fn calculate_unlocked_amount(e: &Env, locks: &Vec) -> i128 { - let now = e.ledger().timestamp(); - let mut unlocked = 0i128; - for i in 0..locks.len() { - let lock = locks.get(i).unwrap(); - if lock.release_timestamp <= now { - unlocked = add_i128_or_panic(e, unlocked, lock.amount); - } - } - unlocked } -fn calculate_total_locked_amount(e: &Env, locks: &Vec) -> i128 { - let mut total = 0i128; - for i in 0..locks.len() { - let lock = locks.get(i).unwrap(); - require_non_negative_amount(e, lock.amount); - total = add_i128_or_panic(e, total, lock.amount); - } - total -} +// ################## CHANGE STATE ################## -fn update_locked_tokens(e: &Env, token: &Address, wallet: &Address, mut amount_to_consume: i128) { - let locks = get_locks(e, token, wallet); - let now = e.ledger().timestamp(); - let mut new_locks = Vec::new(e); - let mut consumed_total = 0i128; - - for i in 0..locks.len() { - let lock = locks.get(i).unwrap(); - if amount_to_consume > 0 && lock.release_timestamp <= now { - if amount_to_consume >= lock.amount { - amount_to_consume = sub_i128_or_panic(e, amount_to_consume, lock.amount); - consumed_total = add_i128_or_panic(e, consumed_total, lock.amount); - } else { - consumed_total = add_i128_or_panic(e, consumed_total, amount_to_consume); - new_locks.push_back(LockedTokens { - amount: sub_i128_or_panic(e, lock.amount, amount_to_consume), - release_timestamp: lock.release_timestamp, - }); - amount_to_consume = 0; - } - } else { - new_locks.push_back(lock); - } - } - - set_locks(e, token, wallet, &new_locks); - - let total_locked = get_total_locked(e, token, wallet); - set_total_locked(e, token, wallet, sub_i128_or_panic(e, total_locked, consumed_total)); -} - -// ################## ACTIONS ################## - -/// Configures the lockup period for `token` and emits [`LockupPeriodSet`]. +/// Configures the lockup period for `token`. /// /// # Arguments /// /// * `e` - Access to the Soroban environment. /// * `token` - The token address. /// * `lockup_seconds` - The lockup duration in seconds. +/// +/// # Events +/// +/// * topics - `["lockup_period_set", token: Address]` +/// * data - `[lockup_seconds: u64]` +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. pub fn configure_lockup_period(e: &Env, token: &Address, lockup_seconds: u64) { set_lockup_period(e, token, lockup_seconds); - LockupPeriodSet { token: token.clone(), lockup_seconds }.publish(e); + emit_lockup_period_set(e, token, lockup_seconds); } /// Pre-seeds the lockup state for a wallet. Validates that total locked @@ -242,6 +208,17 @@ pub fn configure_lockup_period(e: &Env, token: &Address, lockup_seconds: u64) { /// * `wallet` - The wallet address. /// * `balance` - The wallet balance. /// * `locks` - The lock entries. +/// +/// # Errors +/// +/// * refer to [`require_non_negative_amount`] errors. +/// * [`ComplianceModuleError::LockupExceedsBalance`] - When total locked amount +/// exceeds the mirrored balance. +/// * refer to [`add_i128_or_panic`] errors. +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. pub fn pre_set_lockup_state( e: &Env, token: &Address, @@ -252,19 +229,20 @@ pub fn pre_set_lockup_state( 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" - ); + if total_locked > balance { + panic_with_error!(e, ComplianceModuleError::LockupExceedsBalance); + } set_internal_balance(e, token, wallet, balance); set_locks(e, token, wallet, locks); set_total_locked(e, token, wallet, total_locked); } -// ################## HOOK WIRING ################## - /// Returns the set of compliance hooks this module requires. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. pub fn required_hooks(e: &Env) -> Vec { vec![ e, @@ -277,12 +255,18 @@ pub fn required_hooks(e: &Env) -> Vec { /// Cross-calls the compliance contract to verify that this module is /// registered on all required hooks. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// +/// # Errors +/// +/// * refer to [`verify_required_hooks`] errors. pub fn verify_hook_wiring(e: &Env) { verify_required_hooks(e, required_hooks(e)); } -// ################## COMPLIANCE HOOKS ################## - /// Updates internal balances and lock tracking after a transfer. /// /// # Arguments @@ -292,6 +276,16 @@ pub fn verify_hook_wiring(e: &Env) { /// * `to` - The recipient address. /// * `amount` - The transfer amount. /// * `token` - The token address. +/// +/// # Errors +/// +/// * refer to [`require_non_negative_amount`] errors. +/// * refer to [`add_i128_or_panic`] errors. +/// * refer to [`sub_i128_or_panic`] errors. +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. pub fn on_transfer(e: &Env, from: &Address, to: &Address, amount: i128, token: &Address) { require_non_negative_amount(e, amount); @@ -322,6 +316,15 @@ pub fn on_transfer(e: &Env, from: &Address, to: &Address, amount: i128, token: & /// * `to` - The recipient address. /// * `amount` - The minted amount. /// * `token` - The token address. +/// +/// # Errors +/// +/// * refer to [`require_non_negative_amount`] errors. +/// * refer to [`add_i128_or_panic`] errors. +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. pub fn on_created(e: &Env, to: &Address, amount: i128, token: &Address) { require_non_negative_amount(e, amount); @@ -350,6 +353,18 @@ pub fn on_created(e: &Env, to: &Address, amount: i128, token: &Address) { /// * `from` - The burner address. /// * `amount` - The burned amount. /// * `token` - The token address. +/// +/// # Errors +/// +/// * refer to [`require_non_negative_amount`] errors. +/// * [`ComplianceModuleError::InsufficientUnlockedBalance`] - When the burn +/// would consume more unlocked balance than available. +/// * refer to [`add_i128_or_panic`] errors. +/// * refer to [`sub_i128_or_panic`] errors. +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. pub fn on_destroyed(e: &Env, from: &Address, amount: i128, token: &Address) { require_non_negative_amount(e, amount); @@ -364,10 +379,9 @@ pub fn on_destroyed(e: &Env, from: &Address, amount: i128, token: &Address) { free_amount += calculate_unlocked_amount(e, &locks); } - assert!( - free_amount >= amount, - "InitialLockupPeriodModule: insufficient unlocked balance for burn" - ); + if free_amount < amount { + panic_with_error!(e, ComplianceModuleError::InsufficientUnlockedBalance); + } let pre_free = pre_balance - total_locked; if amount > pre_free.max(0) { @@ -389,12 +403,16 @@ pub fn on_destroyed(e: &Env, from: &Address, amount: i128, token: &Address) { /// * `from` - The sender address. /// * `amount` - The transfer amount. /// * `token` - The token address. +/// +/// # Errors +/// +/// * [`ComplianceModuleError::HooksNotVerified`] - When required hook wiring +/// has not been verified. +/// * refer to [`add_i128_or_panic`] errors. pub fn can_transfer(e: &Env, from: &Address, amount: i128, token: &Address) -> bool { - assert!( - hooks_verified(e), - "InitialLockupPeriodModule: not armed — call verify_hook_wiring() after wiring hooks \ - [CanTransfer, Created, Transferred, Destroyed]" - ); + if !hooks_verified(e) { + panic_with_error!(e, ComplianceModuleError::HooksNotVerified); + } if amount < 0 { return false; } @@ -415,3 +433,58 @@ pub fn can_transfer(e: &Env, from: &Address, amount: i128, token: &Address) -> b let unlocked = calculate_unlocked_amount(e, &locks); (free + unlocked) >= amount } + +// ################## HELPERS ################## + +fn calculate_unlocked_amount(e: &Env, locks: &Vec) -> i128 { + let now = e.ledger().timestamp(); + let mut unlocked = 0i128; + for i in 0..locks.len() { + let lock = locks.get_unchecked(i); + 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_unchecked(i); + 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_unchecked(i); + 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)); +} diff --git a/packages/tokens/src/rwa/compliance/modules/mod.rs b/packages/tokens/src/rwa/compliance/modules/mod.rs index e580afdf2..061094c52 100644 --- a/packages/tokens/src/rwa/compliance/modules/mod.rs +++ b/packages/tokens/src/rwa/compliance/modules/mod.rs @@ -56,7 +56,7 @@ mod test; /// after the token action /// - CanTransfer/CanCreate: Validation hooks called before the token action /// -/// # Security Note +/// # Security Warning /// /// If a hook modifies state, it should typically only be called by the /// compliance contract. `set_compliance_address` and `get_compliance_address` @@ -82,7 +82,7 @@ pub trait ComplianceModule { /// * `amount` - The amount of tokens transferred. /// * `token` - The address of the token contract that triggered the hook. /// - /// # Security Note + /// # Security Warning /// /// If this function modifies state, it should be called only by the /// compliance contract. To enforce this, add the following at the start of @@ -107,7 +107,7 @@ pub trait ComplianceModule { /// * `amount` - The amount of tokens created. /// * `token` - The address of the token contract that triggered the hook. /// - /// # Security Note + /// # Security Warning /// /// If this function modifies state, it should be called only by the /// compliance contract. To enforce this, add the following at the start of @@ -132,7 +132,7 @@ pub trait ComplianceModule { /// * `amount` - The amount of tokens destroyed. /// * `token` - The address of the token contract that triggered the hook. /// - /// # Security Note + /// # Security Warning /// /// If this function modifies state, it should be called only by the /// compliance contract. To enforce this, add the following at the start of @@ -214,7 +214,7 @@ pub trait ComplianceModule { /// Error codes shared by all compliance modules. /// -/// Compliance module errors occupy the 390–400 range, following the RWA +/// Compliance module errors occupy the 390–406 range, following the RWA /// error numbering convention. #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] @@ -242,6 +242,14 @@ pub enum ComplianceModuleError { ComplianceAlreadySet = 399, /// A token has reached the maximum number of configured limit entries. TooManyLimits = 400, + /// Required hook wiring has not been verified before use. + HooksNotVerified = 403, + /// Locked token state is inconsistent with the mirrored balance. + LockupExceedsBalance = 404, + /// A transfer or burn would consume more unlocked balance than available. + InsufficientUnlockedBalance = 405, + /// A configured time-window limit has an invalid duration. + InvalidLimitTime = 406, } // ################## CONSTANTS ################## 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 index 49215ef2d..98bcbee4e 100644 --- a/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/mod.rs +++ b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/mod.rs @@ -3,6 +3,8 @@ //! //! Limits transfer volume within configurable time windows, tracking counters //! per **identity** (not per wallet). +//! The Stellar module keeps per-identity transfer counters updated by transfer +//! hooks so limit checks do not need to query token transfer history. //! //! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/TimeTransfersLimitsModule.sol @@ -10,11 +12,190 @@ pub mod storage; #[cfg(test)] mod test; -use soroban_sdk::{contractevent, Address}; -pub use storage::{Limit, TransferCounter}; +use soroban_sdk::{contractevent, contracttrait, Address, Env, Vec}; + +pub use crate::rwa::compliance::modules::time_transfers_limits::storage::{Limit, TransferCounter}; +use crate::rwa::compliance::{modules::ComplianceModule, ComplianceHook}; pub const MAX_LIMITS_PER_TOKEN: u32 = 4; +/// Time-window transfer limits compliance module trait. +/// +/// This trait defines the contract-facing API for the time transfer limits +/// module. Low-level state changes live in [`storage`]. Privileged methods have +/// no default implementation because each contract must enforce its own access +/// control before delegating to storage helpers. +#[contracttrait] +pub trait TimeTransfersLimits: ComplianceModule { + /// Configures the Identity Registry Storage contract for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token whose IRS is being configured. + /// * `irs` - The Identity Registry Storage contract address. + /// + /// # Notes + /// + /// No default implementation is provided because this is a privileged + /// operation that requires custom access control. + fn set_identity_registry_storage(e: &Env, token: Address, irs: Address); + + /// Sets or updates a time-window transfer limit for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token whose limit is updated. + /// * `limit` - The time-window limit. + /// + /// # Errors + /// + /// * refer to [`storage::set_time_transfer_limit`] errors. + /// + /// # Events + /// + /// * topics - `["time_transfer_limit_updated", token: Address]` + /// * data - `[limit: Limit]` + /// + /// # Notes + /// + /// No default implementation is provided because this is a privileged + /// operation that requires custom access control. + fn set_time_transfer_limit(e: &Env, token: Address, limit: Limit); + + /// Sets or updates multiple time-window transfer limits for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token whose limits are updated. + /// * `limits` - The time-window limits. + /// + /// # Errors + /// + /// * refer to [`storage::batch_set_time_transfer_limit`] errors. + /// + /// # Events + /// + /// For each configured limit: + /// * topics - `["time_transfer_limit_updated", token: Address]` + /// * data - `[limit: Limit]` + /// + /// # Notes + /// + /// No default implementation is provided because this is a privileged + /// operation that requires custom access control. + fn batch_set_time_transfer_limit(e: &Env, token: Address, limits: Vec); + + /// Removes a time-window transfer limit for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token whose limit is removed. + /// * `limit_time` - The time-window duration to remove. + /// + /// # Errors + /// + /// * refer to [`storage::remove_time_transfer_limit`] errors. + /// + /// # Events + /// + /// * topics - `["time_transfer_limit_removed", token: Address]` + /// * data - `[limit_time: u64]` + /// + /// # Notes + /// + /// No default implementation is provided because this is a privileged + /// operation that requires custom access control. + fn remove_time_transfer_limit(e: &Env, token: Address, limit_time: u64); + + /// Removes multiple time-window transfer limits for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token whose limits are removed. + /// * `limit_times` - The time-window durations to remove. + /// + /// # Errors + /// + /// * refer to [`storage::batch_remove_time_transfer_limit`] errors. + /// + /// # Events + /// + /// For each removed limit: + /// * topics - `["time_transfer_limit_removed", token: Address]` + /// * data - `[limit_time: u64]` + /// + /// # Notes + /// + /// No default implementation is provided because this is a privileged + /// operation that requires custom access control. + fn batch_remove_time_transfer_limit(e: &Env, token: Address, limit_times: Vec); + + /// Pre-seeds a transfer counter for an identity and time window. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token whose counter is pre-seeded. + /// * `identity` - The on-chain identity address. + /// * `limit_time` - The time-window duration in seconds. + /// * `counter` - The pre-seeded counter. + /// + /// # Errors + /// + /// * refer to [`storage::pre_set_transfer_counter`] errors. + /// + /// # Notes + /// + /// No default implementation is provided because this is a privileged + /// operation that requires custom access control. + fn pre_set_transfer_counter( + e: &Env, + token: Address, + identity: Address, + limit_time: u64, + counter: TransferCounter, + ); + + /// Returns configured time-window transfer limits for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token whose limits are queried. + fn get_time_transfer_limits(e: &Env, token: Address) -> Vec { + storage::get_limits(e, &token) + } + + /// Returns the set of compliance hooks this module requires. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + fn required_hooks(e: &Env) -> Vec { + storage::required_hooks(e) + } + + /// Verifies that this module is registered on every required hook. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// + /// # Errors + /// + /// * refer to [`storage::verify_hook_wiring`] errors. + fn verify_hook_wiring(e: &Env) { + storage::verify_hook_wiring(e); + } +} + +// ################## EVENTS ################## + /// Emitted when a time-window limit is added or updated. #[contractevent] #[derive(Clone, Debug, Eq, PartialEq)] @@ -24,6 +205,17 @@ pub struct TimeTransferLimitUpdated { pub limit: Limit, } +/// Emits a [`TimeTransferLimitUpdated`] event. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token whose limit was updated. +/// * `limit` - The configured limit. +pub fn emit_time_transfer_limit_updated(e: &Env, token: &Address, limit: &Limit) { + TimeTransferLimitUpdated { token: token.clone(), limit: limit.clone() }.publish(e); +} + /// Emitted when a time-window limit is removed. #[contractevent] #[derive(Clone, Debug, Eq, PartialEq)] @@ -32,3 +224,14 @@ pub struct TimeTransferLimitRemoved { pub token: Address, pub limit_time: u64, } + +/// Emits a [`TimeTransferLimitRemoved`] event. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token whose limit was removed. +/// * `limit_time` - The removed time-window duration. +pub fn emit_time_transfer_limit_removed(e: &Env, token: &Address, limit_time: u64) { + TimeTransferLimitRemoved { token: token.clone(), limit_time }.publish(e); +} 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 index b46cde1a6..218f51f6a 100644 --- a/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/storage.rs +++ b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/storage.rs @@ -1,12 +1,15 @@ use soroban_sdk::{contracttype, panic_with_error, vec, Address, Env, Vec}; -use super::{TimeTransferLimitRemoved, TimeTransferLimitUpdated, MAX_LIMITS_PER_TOKEN}; use crate::rwa::compliance::{ modules::{ storage::{ add_i128_or_panic, get_irs_client, hooks_verified, require_non_negative_amount, set_irs_address, verify_required_hooks, }, + time_transfers_limits::{ + emit_time_transfer_limit_removed, emit_time_transfer_limit_updated, + MAX_LIMITS_PER_TOKEN, + }, ComplianceModuleError, MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD, }, ComplianceHook, @@ -38,7 +41,7 @@ pub enum TimeTransfersLimitsStorageKey { Counter(Address, Address, u64), } -// ################## RAW STORAGE ################## +// ################## QUERY STATE ################## /// Returns the list of time-window limits for `token`. /// @@ -64,10 +67,13 @@ pub fn get_limits(e: &Env, token: &Address) -> Vec { /// * `e` - Access to the Soroban environment. /// * `token` - The token address. /// * `limits` - The updated limits list. +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. 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. @@ -103,6 +109,10 @@ pub fn get_counter( /// * `identity` - The on-chain identity address. /// * `limit_time` - The time-window duration in seconds. /// * `counter` - The updated counter value. +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. pub fn set_counter( e: &Env, token: &Address, @@ -112,35 +122,9 @@ pub fn set_counter( ) { let key = TimeTransfersLimitsStorageKey::Counter(token.clone(), identity.clone(), limit_time); e.storage().persistent().set(&key, counter); - e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); -} - -// ################## HELPERS ################## - -fn is_counter_finished(e: &Env, token: &Address, identity: &Address, limit_time: u64) -> bool { - let counter = get_counter(e, token, identity, limit_time); - counter.timer <= e.ledger().timestamp() -} - -fn reset_counter_if_needed(e: &Env, token: &Address, identity: &Address, limit_time: u64) { - if is_counter_finished(e, token, identity, limit_time) { - let counter = - TransferCounter { value: 0, timer: e.ledger().timestamp().saturating_add(limit_time) }; - set_counter(e, token, identity, limit_time, &counter); - } -} - -fn increase_counters(e: &Env, token: &Address, identity: &Address, value: i128) { - let limits = get_limits(e, token); - for limit in limits.iter() { - reset_counter_if_needed(e, token, identity, limit.limit_time); - let mut counter = get_counter(e, token, identity, limit.limit_time); - counter.value = add_i128_or_panic(e, counter.value, value); - set_counter(e, token, identity, limit.limit_time, &counter); - } } -// ################## ACTIONS ################## +// ################## CHANGE STATE ################## /// Configures the identity registry storage address for a token. /// @@ -149,26 +133,48 @@ fn increase_counters(e: &Env, token: &Address, identity: &Address, value: i128) /// * `e` - Access to the Soroban environment. /// * `token` - The token address. /// * `irs` - The identity registry storage address. +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. pub fn configure_irs(e: &Env, token: &Address, irs: &Address) { set_irs_address(e, token, irs); } -/// Sets or updates a time-window transfer limit for `token` and emits -/// [`TimeTransferLimitUpdated`]. +/// Sets or updates a time-window transfer limit for `token`. /// /// # Arguments /// /// * `e` - Access to the Soroban environment. /// * `token` - The token address. /// * `limit` - The limit to set. +/// +/// # Errors +/// +/// * [`ComplianceModuleError::InvalidLimitTime`] - When the time-window +/// duration is zero. +/// * refer to [`require_non_negative_amount`] errors. +/// * [`ComplianceModuleError::TooManyLimits`] - When adding a new limit would +/// exceed the maximum number of configured limits. +/// +/// # Events +/// +/// * topics - `["time_transfer_limit_updated", token: Address]` +/// * data - `[limit: Limit]` +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. pub fn set_time_transfer_limit(e: &Env, token: &Address, limit: &Limit) { - assert!(limit.limit_time > 0, "limit_time must be greater than zero"); + if limit.limit_time == 0 { + panic_with_error!(e, ComplianceModuleError::InvalidLimitTime); + } 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"); + let current = limits.get_unchecked(i); if current.limit_time == limit.limit_time { limits.set(i, limit.clone()); replaced = true; @@ -184,7 +190,7 @@ pub fn set_time_transfer_limit(e: &Env, token: &Address, limit: &Limit) { } set_limits(e, token, &limits); - TimeTransferLimitUpdated { token: token.clone(), limit: limit.clone() }.publish(e); + emit_time_transfer_limit_updated(e, token, limit); } /// Sets or updates multiple time-window transfer limits in a single call. @@ -194,26 +200,53 @@ pub fn set_time_transfer_limit(e: &Env, token: &Address, limit: &Limit) { /// * `e` - Access to the Soroban environment. /// * `token` - The token address. /// * `limits` - The limits to set. +/// +/// # Errors +/// +/// * refer to [`set_time_transfer_limit`] errors. +/// +/// # Events +/// +/// For each configured limit: +/// * topics - `["time_transfer_limit_updated", token: Address]` +/// * data - `[limit: Limit]` +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. pub fn batch_set_time_transfer_limit(e: &Env, token: &Address, limits: &Vec) { for limit in limits.iter() { set_time_transfer_limit(e, token, &limit); } } -/// Removes a time-window transfer limit and emits -/// [`TimeTransferLimitRemoved`]. +/// Removes a time-window transfer limit. /// /// # Arguments /// /// * `e` - Access to the Soroban environment. /// * `token` - The token address. /// * `limit_time` - The time-window to remove. +/// +/// # Errors +/// +/// * [`ComplianceModuleError::MissingLimit`] - When no limit exists for +/// `limit_time`. +/// +/// # Events +/// +/// * topics - `["time_transfer_limit_removed", token: Address]` +/// * data - `[limit_time: u64]` +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. pub fn remove_time_transfer_limit(e: &Env, token: &Address, limit_time: u64) { let mut limits = get_limits(e, token); let mut found = false; for i in 0..limits.len() { - let current = limits.get(i).expect("limit exists"); + let current = limits.get_unchecked(i); if current.limit_time == limit_time { limits.remove(i); found = true; @@ -226,7 +259,7 @@ pub fn remove_time_transfer_limit(e: &Env, token: &Address, limit_time: u64) { } set_limits(e, token, &limits); - TimeTransferLimitRemoved { token: token.clone(), limit_time }.publish(e); + emit_time_transfer_limit_removed(e, token, limit_time); } /// Removes multiple time-window transfer limits in a single call. @@ -236,6 +269,20 @@ pub fn remove_time_transfer_limit(e: &Env, token: &Address, limit_time: u64) { /// * `e` - Access to the Soroban environment. /// * `token` - The token address. /// * `limit_times` - The time-windows to remove. +/// +/// # Errors +/// +/// * refer to [`remove_time_transfer_limit`] errors. +/// +/// # Events +/// +/// For each removed limit: +/// * topics - `["time_transfer_limit_removed", token: Address]` +/// * data - `[limit_time: u64]` +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. pub fn batch_remove_time_transfer_limit(e: &Env, token: &Address, limit_times: &Vec) { for lt in limit_times.iter() { remove_time_transfer_limit(e, token, lt); @@ -251,6 +298,18 @@ pub fn batch_remove_time_transfer_limit(e: &Env, token: &Address, limit_times: & /// * `identity` - The on-chain identity address. /// * `limit_time` - The time-window duration in seconds. /// * `counter` - The counter value to set. +/// +/// # Errors +/// +/// * refer to [`require_non_negative_amount`] errors. +/// * [`ComplianceModuleError::InvalidLimitTime`] - When the time-window +/// duration is zero. +/// * [`ComplianceModuleError::MissingLimit`] - When no limit exists for +/// `limit_time`. +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. pub fn pre_set_transfer_counter( e: &Env, token: &Address, @@ -259,7 +318,9 @@ pub fn pre_set_transfer_counter( counter: &TransferCounter, ) { require_non_negative_amount(e, counter.value); - assert!(limit_time > 0, "limit_time must be greater than zero"); + if limit_time == 0 { + panic_with_error!(e, ComplianceModuleError::InvalidLimitTime); + } let mut found = false; for limit in get_limits(e, token).iter() { @@ -276,21 +337,29 @@ pub fn pre_set_transfer_counter( set_counter(e, token, identity, limit_time, counter); } -// ################## HOOK WIRING ################## - /// Returns the set of compliance hooks this module requires. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. pub fn required_hooks(e: &Env) -> Vec { vec![e, ComplianceHook::CanTransfer, ComplianceHook::Transferred] } /// Cross-calls the compliance contract to verify that this module is /// registered on all required hooks. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// +/// # Errors +/// +/// * refer to [`verify_required_hooks`] errors. pub fn verify_hook_wiring(e: &Env) { verify_required_hooks(e, required_hooks(e)); } -// ################## COMPLIANCE HOOKS ################## - /// Resolves the sender's identity and increments transfer counters. /// /// # Arguments @@ -299,6 +368,16 @@ pub fn verify_hook_wiring(e: &Env) { /// * `from` - The sender address. /// * `amount` - The transfer amount. /// * `token` - The token address. +/// +/// # Errors +/// +/// * refer to [`require_non_negative_amount`] errors. +/// * refer to [`get_irs_client`] errors. +/// * refer to [`add_i128_or_panic`] errors. +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. pub fn on_transfer(e: &Env, from: &Address, amount: i128, token: &Address) { require_non_negative_amount(e, amount); let irs = get_irs_client(e, token); @@ -318,14 +397,14 @@ pub fn on_transfer(e: &Env, from: &Address, amount: i128, token: &Address) { /// /// # Errors /// -/// * [`crate::rwa::compliance::modules::ComplianceModuleError::IdentityRegistryNotSet`] -/// - When no IRS has been configured for `token`. +/// * [`ComplianceModuleError::HooksNotVerified`] - When required hook wiring +/// has not been verified. +/// * refer to [`get_irs_client`] errors. +/// * refer to [`add_i128_or_panic`] errors. pub fn can_transfer(e: &Env, from: &Address, amount: i128, token: &Address) -> bool { - assert!( - hooks_verified(e), - "TimeTransfersLimitsModule: not armed — call verify_hook_wiring() after wiring hooks \ - [CanTransfer, Transferred]" - ); + if !hooks_verified(e) { + panic_with_error!(e, ComplianceModuleError::HooksNotVerified); + } if amount < 0 { return false; } @@ -348,3 +427,28 @@ pub fn can_transfer(e: &Env, from: &Address, amount: i128, token: &Address) -> b true } + +// ################## 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); + } +} diff --git a/packages/tokens/src/rwa/compliance/modules/transfer_restrict/mod.rs b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/mod.rs index bf87e6117..6e3f908e9 100644 --- a/packages/tokens/src/rwa/compliance/modules/transfer_restrict/mod.rs +++ b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/mod.rs @@ -10,7 +10,109 @@ pub mod storage; #[cfg(test)] mod test; -use soroban_sdk::{contractevent, Address}; +use soroban_sdk::{contractevent, contracttrait, Address, Env, Vec}; + +use crate::rwa::compliance::modules::ComplianceModule; + +/// Transfer restriction compliance module trait. +/// +/// This trait defines the contract-facing API for the transfer restriction +/// module. Low-level state changes live in [`storage`]. Privileged methods have +/// no default implementation because each contract must enforce its own access +/// control before delegating to storage helpers. +#[contracttrait] +pub trait TransferRestrict: ComplianceModule { + /// Adds `user` to the transfer allowlist for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token whose allowlist is updated. + /// * `user` - The address to allow. + /// + /// # Events + /// + /// * topics - `["user_allowed", token: Address]` + /// * data - `[user: Address]` + /// + /// # Notes + /// + /// No default implementation is provided because this is a privileged + /// operation that requires custom access control. + fn allow_user(e: &Env, token: Address, user: Address); + + /// Removes `user` from the transfer allowlist for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token whose allowlist is updated. + /// * `user` - The address to disallow. + /// + /// # Events + /// + /// * topics - `["user_disallowed", token: Address]` + /// * data - `[user: Address]` + /// + /// # Notes + /// + /// No default implementation is provided because this is a privileged + /// operation that requires custom access control. + fn disallow_user(e: &Env, token: Address, user: Address); + + /// Adds multiple users to the transfer allowlist for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token whose allowlist is updated. + /// * `users` - The addresses to allow. + /// + /// # Events + /// + /// For each user newly added: + /// * topics - `["user_allowed", token: Address]` + /// * data - `[user: Address]` + /// + /// # Notes + /// + /// No default implementation is provided because this is a privileged + /// operation that requires custom access control. + fn batch_allow_users(e: &Env, token: Address, users: Vec
); + + /// Removes multiple users from the transfer allowlist for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token whose allowlist is updated. + /// * `users` - The addresses to disallow. + /// + /// # Events + /// + /// For each user removed: + /// * topics - `["user_disallowed", token: Address]` + /// * data - `[user: Address]` + /// + /// # Notes + /// + /// No default implementation is provided because this is a privileged + /// operation that requires custom access control. + fn batch_disallow_users(e: &Env, token: Address, users: Vec
); + + /// Returns whether `user` is on the transfer allowlist for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token whose allowlist is queried. + /// * `user` - The address to check. + fn is_user_allowed(e: &Env, token: Address, user: Address) -> bool { + storage::is_user_allowed(e, &token, &user) + } +} + +// ################## EVENTS ################## /// Emitted when an address is added to the transfer allowlist. #[contractevent] @@ -21,6 +123,17 @@ pub struct UserAllowed { pub user: Address, } +/// Emits a [`UserAllowed`] event. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token whose allowlist changed. +/// * `user` - The address that was allowed. +pub fn emit_user_allowed(e: &Env, token: &Address, user: &Address) { + UserAllowed { token: token.clone(), user: user.clone() }.publish(e); +} + /// Emitted when an address is removed from the transfer allowlist. #[contractevent] #[derive(Clone, Debug, Eq, PartialEq)] @@ -29,3 +142,14 @@ pub struct UserDisallowed { pub token: Address, pub user: Address, } + +/// Emits a [`UserDisallowed`] event. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token whose allowlist changed. +/// * `user` - The address that was disallowed. +pub fn emit_user_disallowed(e: &Env, token: &Address, user: &Address) { + UserDisallowed { token: token.clone(), user: user.clone() }.publish(e); +} diff --git a/packages/tokens/src/rwa/compliance/modules/transfer_restrict/storage.rs b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/storage.rs index 77337ae0c..d337a679a 100644 --- a/packages/tokens/src/rwa/compliance/modules/transfer_restrict/storage.rs +++ b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/storage.rs @@ -1,7 +1,9 @@ use soroban_sdk::{contracttype, Address, Env, Vec}; -use super::{UserAllowed, UserDisallowed}; -use crate::rwa::compliance::modules::{MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD}; +use crate::rwa::compliance::modules::{ + transfer_restrict::{emit_user_allowed, emit_user_disallowed}, + MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD, +}; #[contracttype] #[derive(Clone)] @@ -10,7 +12,7 @@ pub enum TransferRestrictStorageKey { AllowedUser(Address, Address), } -// ################## RAW STORAGE ################## +// ################## QUERY STATE ################## /// Returns whether `user` is on the transfer allowlist for `token`. /// @@ -21,15 +23,34 @@ pub enum TransferRestrictStorageKey { /// * `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() + if e.storage().persistent().has(&key) { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + true + } else { + false + } +} + +/// Returns `true` if the sender or recipient is allowlisted. +/// +/// T-REX semantics: if the sender is allowlisted, the transfer passes; +/// otherwise the recipient must be allowlisted. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `from` - The sender address. +/// * `to` - The recipient address. +/// * `token` - The token address. +pub fn can_transfer(e: &Env, from: &Address, to: &Address, token: &Address) -> bool { + if is_user_allowed(e, token, from) { + return true; + } + is_user_allowed(e, token, to) } +// ################## CHANGE STATE ################## + /// Adds `user` to the transfer allowlist for `token`. /// /// # Arguments @@ -37,10 +58,13 @@ pub fn is_user_allowed(e: &Env, token: &Address, user: &Address) -> bool { /// * `e` - Access to the Soroban environment. /// * `token` - The token address. /// * `user` - The user address to allow. +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. 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); + e.storage().persistent().set(&key, &()); } /// Removes `user` from the transfer allowlist for `token`. @@ -50,86 +74,104 @@ pub fn set_user_allowed(e: &Env, token: &Address, user: &Address) { /// * `e` - Access to the Soroban environment. /// * `token` - The token address. /// * `user` - The user address to disallow. +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. pub fn remove_user_allowed(e: &Env, token: &Address, user: &Address) { e.storage() .persistent() .remove(&TransferRestrictStorageKey::AllowedUser(token.clone(), user.clone())); } -// ################## ACTIONS ################## - -/// Adds `user` to the transfer allowlist for `token` and emits -/// [`UserAllowed`]. +/// Adds `user` to the transfer allowlist for `token`. /// /// # Arguments /// /// * `e` - Access to the Soroban environment. /// * `token` - The token address. /// * `user` - The address to allow. +/// +/// # Events +/// +/// * topics - `["user_allowed", token: Address]` +/// * data - `[user: Address]` +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. pub fn allow_user(e: &Env, token: &Address, user: &Address) { - set_user_allowed(e, token, user); - UserAllowed { token: token.clone(), user: user.clone() }.publish(e); + if !is_user_allowed(e, token, user) { + set_user_allowed(e, token, user); + emit_user_allowed(e, token, user); + } } -/// Removes `user` from the transfer allowlist for `token` and emits -/// [`UserDisallowed`]. +/// Removes `user` from the transfer allowlist for `token`. /// /// # Arguments /// /// * `e` - Access to the Soroban environment. /// * `token` - The token address. /// * `user` - The address to disallow. +/// +/// # Events +/// +/// * topics - `["user_disallowed", token: Address]` +/// * data - `[user: Address]` +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. pub fn disallow_user(e: &Env, token: &Address, user: &Address) { - remove_user_allowed(e, token, user); - UserDisallowed { token: token.clone(), user: user.clone() }.publish(e); + if is_user_allowed(e, token, user) { + remove_user_allowed(e, token, user); + emit_user_disallowed(e, token, user); + } } /// Adds multiple users to the transfer allowlist in a single call. -/// Emits [`UserAllowed`] for each user added. /// /// # Arguments /// /// * `e` - Access to the Soroban environment. /// * `token` - The token address. /// * `users` - The addresses to allow. +/// +/// # Events +/// +/// For each user newly added: +/// * topics - `["user_allowed", token: Address]` +/// * data - `[user: Address]` +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. pub fn batch_allow_users(e: &Env, token: &Address, users: &Vec
) { for user in users.iter() { - set_user_allowed(e, token, &user); - UserAllowed { token: token.clone(), user }.publish(e); + allow_user(e, token, &user); } } /// Removes multiple users from the transfer allowlist in a single call. -/// Emits [`UserDisallowed`] for each user removed. /// /// # Arguments /// /// * `e` - Access to the Soroban environment. /// * `token` - The token address. /// * `users` - The addresses to disallow. -pub fn batch_disallow_users(e: &Env, token: &Address, users: &Vec
) { - for user in users.iter() { - remove_user_allowed(e, token, &user); - UserDisallowed { token: token.clone(), user }.publish(e); - } -} - -// ################## COMPLIANCE HOOKS ################## - -/// Returns `true` if the sender or recipient is allowlisted. /// -/// T-REX semantics: if the sender is allowlisted, the transfer passes; -/// otherwise the recipient must be allowlisted. +/// # Events /// -/// # Arguments +/// For each user removed: +/// * topics - `["user_disallowed", token: Address]` +/// * data - `[user: Address]` /// -/// * `e` - Access to the Soroban environment. -/// * `from` - The sender address. -/// * `to` - The recipient address. -/// * `token` - The token address. -pub fn can_transfer(e: &Env, from: &Address, to: &Address, token: &Address) -> bool { - if is_user_allowed(e, token, from) { - return true; +/// # Security Warning +/// +/// This helper performs no authorization checks. +pub fn batch_disallow_users(e: &Env, token: &Address, users: &Vec
) { + for user in users.iter() { + disallow_user(e, token, &user); } - is_user_allowed(e, token, to) }