From b7814a8dc09fd43e287e2528b25e8da6b87d9c1f Mon Sep 17 00:00:00 2001 From: Aleksandr Pasevin Date: Mon, 23 Mar 2026 18:41:34 +0200 Subject: [PATCH 1/7] feat(rwa): add standalone deploy example and e2e flow Transplant the reviewed deploy crates, scripts, and required module assets onto upstream/main so the deploy and testnet e2e PR can stand on its own. --- .gitignore | 4 + Cargo.lock | 93 +++- Cargo.toml | 18 +- examples/rwa-country-allow/Cargo.toml | 15 + examples/rwa-country-allow/README.md | 50 ++ examples/rwa-country-allow/src/lib.rs | 88 ++++ examples/rwa-country-restrict/Cargo.toml | 15 + examples/rwa-country-restrict/README.md | 50 ++ examples/rwa-country-restrict/src/lib.rs | 88 ++++ examples/rwa-deploy/README.md | 57 +++ examples/rwa-deploy/compliance/Cargo.toml | 17 + examples/rwa-deploy/compliance/src/lib.rs | 81 ++++ examples/rwa-deploy/irs/Cargo.toml | 17 + examples/rwa-deploy/irs/src/lib.rs | 131 +++++ examples/rwa-deploy/scripts/build-module.sh | 41 ++ examples/rwa-deploy/scripts/build.sh | 58 +++ examples/rwa-deploy/scripts/common.sh | 230 +++++++++ examples/rwa-deploy/scripts/deploy-module.sh | 94 ++++ examples/rwa-deploy/scripts/deploy.sh | 276 +++++++++++ examples/rwa-deploy/scripts/e2e.sh | 353 ++++++++++++++ .../rwa-deploy/scripts/test-happy-path.sh | 95 ++++ examples/rwa-deploy/scripts/wire.sh | 88 ++++ examples/rwa-deploy/token/Cargo.toml | 18 + examples/rwa-deploy/token/src/lib.rs | 137 ++++++ examples/rwa-deploy/verifier/Cargo.toml | 17 + examples/rwa-deploy/verifier/src/lib.rs | 76 +++ 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-max-balance/Cargo.toml | 15 + examples/rwa-max-balance/README.md | 62 +++ examples/rwa-max-balance/src/lib.rs | 167 +++++++ examples/rwa-supply-limit/Cargo.toml | 15 + examples/rwa-supply-limit/README.md | 61 +++ examples/rwa-supply-limit/src/lib.rs | 102 ++++ 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 ++++ .../compliance/modules/country_allow/mod.rs | 455 +++++++++++++++++ .../modules/country_allow/storage.rs | 54 +++ .../modules/country_restrict/mod.rs | 459 ++++++++++++++++++ .../modules/country_restrict/storage.rs | 54 +++ .../modules/initial_lockup_period/mod.rs | 274 +++++++++++ .../modules/initial_lockup_period/storage.rs | 152 ++++++ .../modules/initial_lockup_period/test.rs | 190 ++++++++ .../rwa/compliance/modules/max_balance/mod.rs | 218 +++++++++ .../compliance/modules/max_balance/storage.rs | 75 +++ .../compliance/modules/max_balance/test.rs | 351 ++++++++++++++ .../tokens/src/rwa/compliance/modules/mod.rs | 7 + .../compliance/modules/supply_limit/mod.rs | 116 +++++ .../modules/supply_limit/storage.rs | 96 ++++ .../compliance/modules/supply_limit/test.rs | 211 ++++++++ .../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 +++ 61 files changed, 7026 insertions(+), 4 deletions(-) create mode 100644 examples/rwa-country-allow/Cargo.toml create mode 100644 examples/rwa-country-allow/README.md create mode 100644 examples/rwa-country-allow/src/lib.rs create mode 100644 examples/rwa-country-restrict/Cargo.toml create mode 100644 examples/rwa-country-restrict/README.md create mode 100644 examples/rwa-country-restrict/src/lib.rs create mode 100644 examples/rwa-deploy/README.md create mode 100644 examples/rwa-deploy/compliance/Cargo.toml create mode 100644 examples/rwa-deploy/compliance/src/lib.rs create mode 100644 examples/rwa-deploy/irs/Cargo.toml create mode 100644 examples/rwa-deploy/irs/src/lib.rs create mode 100755 examples/rwa-deploy/scripts/build-module.sh create mode 100755 examples/rwa-deploy/scripts/build.sh create mode 100644 examples/rwa-deploy/scripts/common.sh create mode 100755 examples/rwa-deploy/scripts/deploy-module.sh create mode 100755 examples/rwa-deploy/scripts/deploy.sh create mode 100755 examples/rwa-deploy/scripts/e2e.sh create mode 100755 examples/rwa-deploy/scripts/test-happy-path.sh create mode 100755 examples/rwa-deploy/scripts/wire.sh create mode 100644 examples/rwa-deploy/token/Cargo.toml create mode 100644 examples/rwa-deploy/token/src/lib.rs create mode 100644 examples/rwa-deploy/verifier/Cargo.toml create mode 100644 examples/rwa-deploy/verifier/src/lib.rs 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-max-balance/Cargo.toml create mode 100644 examples/rwa-max-balance/README.md create mode 100644 examples/rwa-max-balance/src/lib.rs create mode 100644 examples/rwa-supply-limit/Cargo.toml create mode 100644 examples/rwa-supply-limit/README.md create mode 100644 examples/rwa-supply-limit/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/country_allow/mod.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/country_allow/storage.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/country_restrict/storage.rs 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/max_balance/mod.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/max_balance/storage.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/max_balance/test.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/supply_limit/mod.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/supply_limit/storage.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/supply_limit/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/.gitignore b/.gitignore index df869b315..cccded53a 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,7 @@ lcov.info coverage/ **/test_snapshots/ + +# Compiled WASM artifacts and testnet state +examples/rwa-deploy/wasm/ +examples/rwa-deploy/testnet-addresses.json diff --git a/Cargo.lock b/Cargo.lock index 469b78e3b..bc390eac4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -457,6 +457,47 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "deploy-compliance" +version = "0.6.0" +dependencies = [ + "soroban-sdk", + "stellar-access", + "stellar-macros", + "stellar-tokens", +] + +[[package]] +name = "deploy-irs" +version = "0.6.0" +dependencies = [ + "soroban-sdk", + "stellar-access", + "stellar-macros", + "stellar-tokens", +] + +[[package]] +name = "deploy-token" +version = "0.6.0" +dependencies = [ + "soroban-sdk", + "stellar-access", + "stellar-contract-utils", + "stellar-macros", + "stellar-tokens", +] + +[[package]] +name = "deploy-verifier" +version = "0.6.0" +dependencies = [ + "soroban-sdk", + "stellar-access", + "stellar-macros", + "stellar-tokens", +] + [[package]] name = "der" version = "0.7.10" @@ -1547,12 +1588,18 @@ dependencies = [ ] [[package]] -name = "rwa-compliance-example" +name = "rwa-country-allow" +version = "0.6.0" +dependencies = [ + "soroban-sdk", + "stellar-tokens", +] + +[[package]] +name = "rwa-country-restrict" version = "0.6.0" dependencies = [ "soroban-sdk", - "stellar-access", - "stellar-macros", "stellar-tokens", ] @@ -1586,6 +1633,38 @@ dependencies = [ "stellar-tokens", ] +[[package]] +name = "rwa-initial-lockup-period" +version = "0.6.0" +dependencies = [ + "soroban-sdk", + "stellar-tokens", +] + +[[package]] +name = "rwa-max-balance" +version = "0.6.0" +dependencies = [ + "soroban-sdk", + "stellar-tokens", +] + +[[package]] +name = "rwa-supply-limit" +version = "0.6.0" +dependencies = [ + "soroban-sdk", + "stellar-tokens", +] + +[[package]] +name = "rwa-time-transfers-limits" +version = "0.6.0" +dependencies = [ + "soroban-sdk", + "stellar-tokens", +] + [[package]] name = "rwa-token-example" version = "0.6.0" @@ -1597,6 +1676,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..80700c305 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,23 @@ members = [ "examples/nft-sequential-minting", "examples/ownable", "examples/pausable", - "examples/rwa/*", + "examples/rwa/claim-issuer", + "examples/rwa/claim-topics-and-issuers", + "examples/rwa/identity", + "examples/rwa/identity-registry", + "examples/rwa/identity-verifier", + "examples/rwa/token", + "examples/rwa-country-allow", + "examples/rwa-country-restrict", + "examples/rwa-max-balance", + "examples/rwa-supply-limit", + "examples/rwa-time-transfers-limits", + "examples/rwa-transfer-restrict", + "examples/rwa-initial-lockup-period", + "examples/rwa-deploy/irs", + "examples/rwa-deploy/verifier", + "examples/rwa-deploy/compliance", + "examples/rwa-deploy/token", "examples/sac-admin-generic", "examples/sac-admin-wrapper", "examples/multisig-smart-account/*", diff --git a/examples/rwa-country-allow/Cargo.toml b/examples/rwa-country-allow/Cargo.toml new file mode 100644 index 000000000..a60408eab --- /dev/null +++ b/examples/rwa-country-allow/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "rwa-country-allow" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-tokens = { workspace = true } diff --git a/examples/rwa-country-allow/README.md b/examples/rwa-country-allow/README.md new file mode 100644 index 000000000..f0b88aa54 --- /dev/null +++ b/examples/rwa-country-allow/README.md @@ -0,0 +1,50 @@ +# Country Allow Module + +Concrete deployable example of the `CountryAllow` compliance module for Stellar +RWA tokens. + +## What it enforces + +This module allows tokens to be minted or transferred only to recipients whose +registered identity has at least one country code that appears in the module's +per-token allowlist. + +The country lookup is performed through the Identity Registry Storage (IRS), so +the module must be configured with an IRS contract for each token it serves. + +## Authorization model + +This example uses the bootstrap-admin pattern introduced in this port: + +- The constructor stores a one-time `admin` +- Before `set_compliance_address`, privileged configuration calls require that + admin's auth +- After `set_compliance_address`, the same configuration calls require auth + from the bound Compliance contract +- `set_compliance_address` itself remains a one-time admin action + +This lets the module be configured from the CLI before it is locked to the +Compliance contract. + +## Main entrypoints + +- `__constructor(admin)` initializes the bootstrap admin +- `set_identity_registry_storage(token, irs)` stores the IRS address for a + token +- `add_allowed_country(token, country)` adds an ISO 3166-1 numeric code to the + allowlist +- `remove_allowed_country(token, country)` removes a country code +- `batch_allow_countries(token, countries)` updates multiple entries +- `batch_disallow_countries(token, countries)` removes multiple entries +- `is_country_allowed(token, country)` reads the current allowlist state +- `set_compliance_address(compliance)` performs the one-time handoff to the + Compliance contract + +## Notes + +- Storage is token-scoped, so one deployed module can be reused across many + tokens +- This module validates on the compliance read hooks used for transfers and + mints; it does not require extra state-tracking hooks +- In the deploy example, the module is configured before binding and then wired + to the `CanTransfer` and `CanCreate` hooks diff --git a/examples/rwa-country-allow/src/lib.rs b/examples/rwa-country-allow/src/lib.rs new file mode 100644 index 000000000..f2fd11b23 --- /dev/null +++ b/examples/rwa-country-allow/src/lib.rs @@ -0,0 +1,88 @@ +#![no_std] + +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::modules::{ + country_allow::{ + storage::{is_country_allowed, remove_country_allowed, set_country_allowed}, + CountryAllow, CountryAllowed, CountryUnallowed, + }, + storage::{set_compliance_address, set_irs_address, ComplianceModuleStorageKey}, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct CountryAllowContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl CountryAllowContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } +} + +#[contractimpl(contracttrait)] +impl CountryAllow for CountryAllowContract { + fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { + require_module_admin_or_compliance_auth(e); + set_irs_address(e, &token, &irs); + } + + fn add_allowed_country(e: &Env, token: Address, country: u32) { + require_module_admin_or_compliance_auth(e); + set_country_allowed(e, &token, country); + CountryAllowed { token, country }.publish(e); + } + + fn remove_allowed_country(e: &Env, token: Address, country: u32) { + require_module_admin_or_compliance_auth(e); + remove_country_allowed(e, &token, country); + CountryUnallowed { token, country }.publish(e); + } + + fn batch_allow_countries(e: &Env, token: Address, countries: Vec) { + require_module_admin_or_compliance_auth(e); + for country in countries.iter() { + set_country_allowed(e, &token, country); + CountryAllowed { token: token.clone(), country }.publish(e); + } + } + + fn batch_disallow_countries(e: &Env, token: Address, countries: Vec) { + require_module_admin_or_compliance_auth(e); + for country in countries.iter() { + remove_country_allowed(e, &token, country); + CountryUnallowed { token: token.clone(), country }.publish(e); + } + } + + fn is_country_allowed(e: &Env, token: Address, country: u32) -> bool { + is_country_allowed(e, &token, country) + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-country-restrict/Cargo.toml b/examples/rwa-country-restrict/Cargo.toml new file mode 100644 index 000000000..27aabc3bc --- /dev/null +++ b/examples/rwa-country-restrict/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "rwa-country-restrict" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-tokens = { workspace = true } diff --git a/examples/rwa-country-restrict/README.md b/examples/rwa-country-restrict/README.md new file mode 100644 index 000000000..104bf6066 --- /dev/null +++ b/examples/rwa-country-restrict/README.md @@ -0,0 +1,50 @@ +# Country Restrict Module + +Concrete deployable example of the `CountryRestrict` compliance module for +Stellar RWA tokens. + +## What it enforces + +This module blocks tokens from being minted or transferred to recipients whose +registered identity has a country code that appears in the module's per-token +restriction list. + +The country lookup is performed through the Identity Registry Storage (IRS), so +the module must be configured with an IRS contract for each token it serves. + +## Authorization model + +This example uses the bootstrap-admin pattern introduced in this port: + +- The constructor stores a one-time `admin` +- Before `set_compliance_address`, privileged configuration calls require that + admin's auth +- After `set_compliance_address`, the same configuration calls require auth + from the bound Compliance contract +- `set_compliance_address` itself remains a one-time admin action + +This lets the module be configured from the CLI before it is locked to the +Compliance contract. + +## Main entrypoints + +- `__constructor(admin)` initializes the bootstrap admin +- `set_identity_registry_storage(token, irs)` stores the IRS address for a + token +- `add_country_restriction(token, country)` adds an ISO 3166-1 numeric code to + the restriction list +- `remove_country_restriction(token, country)` removes a country code +- `batch_restrict_countries(token, countries)` updates multiple entries +- `batch_unrestrict_countries(token, countries)` removes multiple entries +- `is_country_restricted(token, country)` reads the current restriction state +- `set_compliance_address(compliance)` performs the one-time handoff to the + Compliance contract + +## Notes + +- Storage is token-scoped, so one deployed module can be reused across many + tokens +- This module validates on the compliance read hooks used for transfers and + mints; it does not require extra state-tracking hooks +- In the deploy example, the module is configured before binding and then wired + to the `CanTransfer` and `CanCreate` hooks diff --git a/examples/rwa-country-restrict/src/lib.rs b/examples/rwa-country-restrict/src/lib.rs new file mode 100644 index 000000000..8d8d4c140 --- /dev/null +++ b/examples/rwa-country-restrict/src/lib.rs @@ -0,0 +1,88 @@ +#![no_std] + +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::modules::{ + country_restrict::{ + storage::{is_country_restricted, remove_country_restricted, set_country_restricted}, + CountryRestrict, CountryRestricted, CountryUnrestricted, + }, + storage::{set_compliance_address, set_irs_address, ComplianceModuleStorageKey}, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct CountryRestrictContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl CountryRestrictContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } +} + +#[contractimpl(contracttrait)] +impl CountryRestrict for CountryRestrictContract { + fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { + require_module_admin_or_compliance_auth(e); + set_irs_address(e, &token, &irs); + } + + fn add_country_restriction(e: &Env, token: Address, country: u32) { + require_module_admin_or_compliance_auth(e); + set_country_restricted(e, &token, country); + CountryRestricted { token, country }.publish(e); + } + + fn remove_country_restriction(e: &Env, token: Address, country: u32) { + require_module_admin_or_compliance_auth(e); + remove_country_restricted(e, &token, country); + CountryUnrestricted { token, country }.publish(e); + } + + fn batch_restrict_countries(e: &Env, token: Address, countries: Vec) { + require_module_admin_or_compliance_auth(e); + for country in countries.iter() { + set_country_restricted(e, &token, country); + CountryRestricted { token: token.clone(), country }.publish(e); + } + } + + fn batch_unrestrict_countries(e: &Env, token: Address, countries: Vec) { + require_module_admin_or_compliance_auth(e); + for country in countries.iter() { + remove_country_restricted(e, &token, country); + CountryUnrestricted { token: token.clone(), country }.publish(e); + } + } + + fn is_country_restricted(e: &Env, token: Address, country: u32) -> bool { + is_country_restricted(e, &token, country) + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-deploy/README.md b/examples/rwa-deploy/README.md new file mode 100644 index 000000000..15659b05a --- /dev/null +++ b/examples/rwa-deploy/README.md @@ -0,0 +1,57 @@ +# RWA Deploy Crates + +Minimal deployable crates used by the end-to-end RWA deployment flow. + +These crates primarily wire OpenZeppelin Stellar library traits into concrete +WASMs. They stay intentionally thin, but they do contain the deployment-specific +wiring needed for constructors, access control, token binding, and hook-based +compliance orchestration. + +## Crates + +| Crate | Purpose | Key traits / modules composed | +| ------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| `irs/` | Identity Registry Storage — stores investor identities and country data | `IdentityRegistryStorage`, `CountryDataManager`, `TokenBinder`, `AccessControl` | +| `verifier/` | Identity Verifier — validates that an account has a registered identity | `IdentityVerifier`, `AccessControl` | +| `compliance/` | Compliance contract — orchestrates hook dispatch across registered modules | `Compliance`, `TokenBinder`, `AccessControl` | +| `token/` | RWA Token — compliant fungible token with freeze, forced transfer, and pause | `FungibleToken`, `RWAToken`, `Pausable`, `AccessControl` | + +## Why separate crates? + +Soroban requires each deployable contract to live in its own crate (one +`cdylib` per WASM). These crates are thin wrappers: the actual implementation +lives in `stellar-tokens`, `stellar-access`, and `stellar-contract-utils`. + +## Build + +`scripts/build.sh` builds the full deployable stack: + +- 4 infrastructure WASMs from this directory: `irs/`, `verifier/`, + `compliance/`, and `token/` +- 7 compliance-module WASMs from the example module crates: + `rwa-country-allow`, `rwa-country-restrict`, `rwa-initial-lockup-period`, + `rwa-max-balance`, `rwa-supply-limit`, `rwa-time-transfers-limits`, and + `rwa-transfer-restrict` + +The script calls `stellar contract build` for each package and writes the +optimized artifacts to `examples/rwa-deploy/wasm/`. + +## Deploy flow + +`scripts/deploy.sh` follows the current bootstrap-admin flow introduced by the +RWA module examples: + +1. Deploy IRS, verifier, compliance, and token +2. Bind the token to Compliance and IRS +3. Deploy all 7 compliance modules with a bootstrap admin +4. Configure every module while bootstrap admin auth is still active +5. Call `set_compliance_address` on each module to hand control to Compliance + +After deployment, `scripts/wire.sh` registers the modules on their required +hooks, and `scripts/e2e.sh` runs the full testnet flow. + +## Artifacts (git-ignored) + +- `wasm/` — compiled WASM binaries produced by `scripts/build.sh` +- `testnet-addresses.json` — contract addresses from the last deployment + (produced by `scripts/deploy.sh`) diff --git a/examples/rwa-deploy/compliance/Cargo.toml b/examples/rwa-deploy/compliance/Cargo.toml new file mode 100644 index 000000000..dbd10ffdb --- /dev/null +++ b/examples/rwa-deploy/compliance/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "deploy-compliance" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true + +[lib] +crate-type = ["cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-access = { workspace = true } +stellar-macros = { workspace = true } +stellar-tokens = { workspace = true } diff --git a/examples/rwa-deploy/compliance/src/lib.rs b/examples/rwa-deploy/compliance/src/lib.rs new file mode 100644 index 000000000..c1fc5e34c --- /dev/null +++ b/examples/rwa-deploy/compliance/src/lib.rs @@ -0,0 +1,81 @@ +#![no_std] + +use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, Symbol, Vec}; +use stellar_access::access_control::{self as access_control, AccessControl}; +use stellar_macros::only_role; +use stellar_tokens::rwa::{ + compliance::{storage as compliance_storage, Compliance, ComplianceHook}, + utils::token_binder::{self as binder, TokenBinder}, +}; + +#[contract] +pub struct ComplianceContract; + +#[contractimpl] +impl ComplianceContract { + pub fn __constructor(e: &Env, admin: Address) { + access_control::set_admin(e, &admin); + access_control::grant_role_no_auth(e, &admin, &symbol_short!("admin"), &admin); + } +} + +#[contractimpl] +impl Compliance for ComplianceContract { + #[only_role(operator, "admin")] + fn add_module_to(e: &Env, hook: ComplianceHook, module: Address, operator: Address) { + compliance_storage::add_module_to(e, hook, module); + } + + #[only_role(operator, "admin")] + fn remove_module_from(e: &Env, hook: ComplianceHook, module: Address, operator: Address) { + compliance_storage::remove_module_from(e, hook, module); + } + + fn get_modules_for_hook(e: &Env, hook: ComplianceHook) -> Vec
{ + compliance_storage::get_modules_for_hook(e, hook) + } + + fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool { + compliance_storage::is_module_registered(e, hook, module) + } + + fn transferred(e: &Env, from: Address, to: Address, amount: i128, token: Address) { + compliance_storage::transferred(e, from, to, amount, token); + } + + fn created(e: &Env, to: Address, amount: i128, token: Address) { + compliance_storage::created(e, to, amount, token); + } + + fn destroyed(e: &Env, from: Address, amount: i128, token: Address) { + compliance_storage::destroyed(e, from, amount, token); + } + + fn can_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) -> bool { + compliance_storage::can_transfer(e, from, to, amount, token) + } + + fn can_create(e: &Env, to: Address, amount: i128, token: Address) -> bool { + compliance_storage::can_create(e, to, amount, token) + } +} + +#[contractimpl] +impl TokenBinder for ComplianceContract { + fn linked_tokens(e: &Env) -> Vec
{ + binder::linked_tokens(e) + } + + #[only_role(operator, "admin")] + fn bind_token(e: &Env, token: Address, operator: Address) { + binder::bind_token(e, &token); + } + + #[only_role(operator, "admin")] + fn unbind_token(e: &Env, token: Address, operator: Address) { + binder::unbind_token(e, &token); + } +} + +#[contractimpl(contracttrait)] +impl AccessControl for ComplianceContract {} diff --git a/examples/rwa-deploy/irs/Cargo.toml b/examples/rwa-deploy/irs/Cargo.toml new file mode 100644 index 000000000..298c9e4c6 --- /dev/null +++ b/examples/rwa-deploy/irs/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "deploy-irs" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true + +[lib] +crate-type = ["cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-access = { workspace = true } +stellar-macros = { workspace = true } +stellar-tokens = { workspace = true } diff --git a/examples/rwa-deploy/irs/src/lib.rs b/examples/rwa-deploy/irs/src/lib.rs new file mode 100644 index 000000000..66f3a1354 --- /dev/null +++ b/examples/rwa-deploy/irs/src/lib.rs @@ -0,0 +1,131 @@ +#![no_std] + +use soroban_sdk::{ + contract, contractimpl, symbol_short, Address, Env, FromVal, IntoVal, Symbol, Val, Vec, +}; +use stellar_access::access_control::{self as access_control, AccessControl}; +use stellar_macros::only_role; +use stellar_tokens::rwa::{ + identity_registry_storage::{ + self as identity_storage, CountryData, CountryDataManager, IdentityRegistryStorage, + IdentityType, + }, + utils::token_binder::{self as binder, TokenBinder}, +}; + +#[contract] +pub struct IdentityRegistryContract; + +#[contractimpl] +impl IdentityRegistryContract { + pub fn __constructor(e: &Env, admin: Address, manager: Address) { + access_control::set_admin(e, &admin); + access_control::grant_role_no_auth(e, &manager, &symbol_short!("manager"), &admin); + } + + #[only_role(operator, "manager")] + pub fn bind_tokens(e: &Env, tokens: Vec
, operator: Address) { + binder::bind_tokens(e, &tokens); + } +} + +#[contractimpl] +impl TokenBinder for IdentityRegistryContract { + fn linked_tokens(e: &Env) -> Vec
{ + binder::linked_tokens(e) + } + + #[only_role(operator, "manager")] + fn bind_token(e: &Env, token: Address, operator: Address) { + binder::bind_token(e, &token); + } + + #[only_role(operator, "manager")] + fn unbind_token(e: &Env, token: Address, operator: Address) { + binder::unbind_token(e, &token); + } +} + +#[contractimpl] +impl IdentityRegistryStorage for IdentityRegistryContract { + #[only_role(operator, "manager")] + fn add_identity( + e: &Env, + account: Address, + identity: Address, + initial_profiles: Vec, + operator: Address, + ) { + let country_data = Vec::from_iter( + e, + initial_profiles.iter().map(|profile| CountryData::from_val(e, &profile)), + ); + identity_storage::add_identity( + e, + &account, + &identity, + IdentityType::Individual, + &country_data, + ); + } + + #[only_role(operator, "manager")] + fn modify_identity(e: &Env, account: Address, new_identity: Address, operator: Address) { + identity_storage::modify_identity(e, &account, &new_identity); + } + + #[only_role(operator, "manager")] + fn remove_identity(e: &Env, account: Address, operator: Address) { + identity_storage::remove_identity(e, &account); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + identity_storage::stored_identity(e, &account) + } + + #[only_role(operator, "manager")] + fn recover_identity(e: &Env, old_account: Address, new_account: Address, operator: Address) { + identity_storage::recover_identity(e, &old_account, &new_account); + } + + fn get_recovered_to(e: &Env, old: Address) -> Option
{ + identity_storage::get_recovered_to(e, &old) + } +} + +#[contractimpl] +impl CountryDataManager for IdentityRegistryContract { + #[only_role(operator, "manager")] + fn add_country_data_entries(e: &Env, account: Address, profiles: Vec, operator: Address) { + let country_data = + Vec::from_iter(e, profiles.iter().map(|profile| CountryData::from_val(e, &profile))); + identity_storage::add_country_data_entries(e, &account, &country_data); + } + + #[only_role(operator, "manager")] + fn modify_country_data(e: &Env, account: Address, index: u32, profile: Val, operator: Address) { + let country_data = CountryData::from_val(e, &profile); + identity_storage::modify_country_data(e, &account, index, &country_data); + } + + #[only_role(operator, "manager")] + fn delete_country_data(e: &Env, account: Address, index: u32, operator: Address) { + identity_storage::delete_country_data(e, &account, index); + } + + fn get_country_data(e: &Env, account: Address, index: u32) -> Val { + identity_storage::get_country_data(e, &account, index).into_val(e) + } + + fn get_country_data_entries(e: &Env, account: Address) -> Vec { + Vec::from_iter( + e, + identity_storage::get_country_data_entries(e, &account) + .iter() + .map(|profile| profile.into_val(e)), + ) + } +} + +#[contractimpl(contracttrait)] +impl AccessControl for IdentityRegistryContract {} diff --git a/examples/rwa-deploy/scripts/build-module.sh b/examples/rwa-deploy/scripts/build-module.sh new file mode 100755 index 000000000..ff2805bd0 --- /dev/null +++ b/examples/rwa-deploy/scripts/build-module.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Build a single compliance module WASM by short name. +# Usage: ./build-module.sh +# Example: ./build-module.sh supply-limit +set -euo pipefail + +if [ $# -lt 1 ]; then + echo "Usage: $0 " + echo "" + echo "Available modules:" + echo " country-allow" + echo " country-restrict" + echo " initial-lockup-period" + echo " max-balance" + echo " supply-limit" + echo " time-transfers-limits" + echo " transfer-restrict" + exit 1 +fi + +MODULE="$1" +PKG="rwa-$MODULE" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +WASM_DIR="$ROOT_DIR/examples/rwa-deploy/wasm" + +mkdir -p "$WASM_DIR" + +echo "=== Building $PKG ===" +cd "$ROOT_DIR" +stellar contract build --package "$PKG" --out-dir "$WASM_DIR" + +WASM_NAME="${PKG//-/_}.wasm" +if [ -f "$WASM_DIR/$WASM_NAME" ]; then + SIZE=$(wc -c < "$WASM_DIR/$WASM_NAME" | tr -d ' ') + echo " $WASM_NAME (${SIZE} bytes) -> examples/rwa-deploy/wasm/" +else + echo "ERROR: $WASM_NAME not found!" >&2 + exit 1 +fi diff --git a/examples/rwa-deploy/scripts/build.sh b/examples/rwa-deploy/scripts/build.sh new file mode 100755 index 000000000..530368b90 --- /dev/null +++ b/examples/rwa-deploy/scripts/build.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Build all 11 RWA contract WASMs (7 compliance modules + 4 infrastructure). +# Uses `stellar contract build` which handles WASM feature stripping properly, +# unlike raw `cargo build` + deprecated `stellar contract optimize`. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +WASM_DIR="$ROOT_DIR/examples/rwa-deploy/wasm" + +mkdir -p "$WASM_DIR" + +MODULES=( + rwa-country-allow + rwa-country-restrict + rwa-initial-lockup-period + rwa-max-balance + rwa-supply-limit + rwa-time-transfers-limits + rwa-transfer-restrict +) + +INFRA=( + deploy-irs + deploy-verifier + deploy-compliance + deploy-token +) + +ALL=("${MODULES[@]}" "${INFRA[@]}") + +echo "=== Building ${#ALL[@]} WASMs ===" + +cd "$ROOT_DIR" +for pkg in "${ALL[@]}"; do + echo " Building $pkg..." + if ! output=$(stellar contract build --package "$pkg" --out-dir "$WASM_DIR" 2>&1); then + printf '%s\n' "$output" | sed '/^$/d' + echo "ERROR: Failed to build $pkg" >&2 + exit 1 + fi + printf '%s\n' "$output" | sed '/^$/d' +done + +echo "" +echo "=== WASM sizes ===" +for pkg in "${ALL[@]}"; do + WASM_NAME="${pkg//-/_}.wasm" + if [ -f "$WASM_DIR/$WASM_NAME" ]; then + SIZE=$(wc -c < "$WASM_DIR/$WASM_NAME" | tr -d ' ') + echo " $WASM_NAME (${SIZE} bytes)" + else + echo " WARNING: $WASM_NAME not found!" + fi +done + +echo "" +echo "=== All WASMs built to examples/rwa-deploy/wasm/ ===" diff --git a/examples/rwa-deploy/scripts/common.sh b/examples/rwa-deploy/scripts/common.sh new file mode 100644 index 000000000..cfd8e4043 --- /dev/null +++ b/examples/rwa-deploy/scripts/common.sh @@ -0,0 +1,230 @@ +#!/usr/bin/env bash + +retryable_invoke_error() { + local output=$1 + case "$output" in + *"transaction submission timeout"* | \ + *"could not load platform certs"* | \ + *"connection reset"* | \ + *"temporarily unavailable"* | \ + *"deadline has elapsed"* | \ + *"timed out"*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +is_contract_id() { + case "$1" in + C[A-Z0-9]*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +require_contract_id() { + local label=$1 + local contract_id=$2 + + if ! is_contract_id "$contract_id"; then + echo "ERROR: Missing or invalid $label contract id: '$contract_id'" >&2 + return 1 + fi +} + +invoke() { + local contract_id=$1 + require_contract_id "invoke target" "$contract_id" || return 1 + + stellar contract invoke --id "$contract_id" \ + --source "$SOURCE" --network "$NETWORK" \ + -- "${@:2}" +} + +invoke_readonly() { + local contract_id=$1 + require_contract_id "readonly invoke target" "$contract_id" || return 1 + + stellar contract invoke --id "$contract_id" \ + --source-account "$ADMIN" --network "$NETWORK" \ + -- "${@:2}" +} + +read_addr() { + python3 -c "import json; d=json.load(open('$ADDR_FILE')); print(d$1)" +} + +hook_modules() { + invoke_readonly "$COMPLIANCE" get_modules_for_hook --hook "\"$1\"" +} + +is_module_registered_for_hook() { + local hook=$1 + local module=$2 + local output + + if ! output=$(hook_modules "$hook" 2>&1); then + return 1 + fi + + MODULE_TO_FIND="$module" python3 -c ' +import json, os, sys + +module = os.environ["MODULE_TO_FIND"] +lines = [line.strip() for line in sys.stdin.read().splitlines() if line.strip()] +payload = lines[-1] if lines else "[]" +modules = json.loads(payload) +sys.exit(0 if module in modules else 1) +' <<<"$output" +} + +ensure_hook_registration() { + local hook=$1 + local module_addr=$2 + local name=$3 + local attempts=${STELLAR_INVOKE_RETRIES:-4} + local delay=${STELLAR_INVOKE_RETRY_DELAY_SECONDS:-3} + local attempt output status + + echo " $name -> $hook" + + for attempt in $(seq 1 "$attempts"); do + if is_module_registered_for_hook "$hook" "$module_addr"; then + echo " already registered" + return 0 + fi + + if output=$(invoke "$COMPLIANCE" add_module_to --hook "\"$hook\"" --module "$module_addr" --operator "$ADMIN" 2>&1); then + printf '%s\n' "$output" + return 0 + fi + status=$? + + if is_module_registered_for_hook "$hook" "$module_addr"; then + echo " registered after retryable failure" + return 0 + fi + + case "$output" in + *"Error(Contract, #360)"* | *"ModuleAlreadyRegistered"*) + echo " already registered" + return 0 + ;; + esac + + if ! retryable_invoke_error "$output"; then + printf '%s\n' "$output" >&2 + return "$status" + fi + + if [ "$attempt" -eq "$attempts" ]; then + printf '%s\n' "$output" >&2 + return "$status" + fi + + echo " retrying after transient Stellar CLI failure..." >&2 + sleep $((delay * attempt)) + done +} + +verify_hook_wiring_with_retry() { + local module_addr=$1 + local name=$2 + local attempts=${STELLAR_INVOKE_RETRIES:-4} + local delay=${STELLAR_INVOKE_RETRY_DELAY_SECONDS:-3} + local attempt output status + + echo " Verifying $name..." + + for attempt in $(seq 1 "$attempts"); do + if output=$(invoke "$module_addr" verify_hook_wiring 2>&1); then + printf '%s\n' "$output" + return 0 + fi + status=$? + + if ! retryable_invoke_error "$output"; then + printf '%s\n' "$output" >&2 + return "$status" + fi + + if [ "$attempt" -eq "$attempts" ]; then + printf '%s\n' "$output" >&2 + return "$status" + fi + + echo " retrying verification after transient Stellar CLI failure..." >&2 + sleep $((delay * attempt)) + done +} + +identity_matches() { + local contract_addr=$1 + local account=$2 + local expected_identity=$3 + local output + + if ! output=$(invoke_readonly "$contract_addr" stored_identity --account "$account" 2>&1); then + return 1 + fi + + EXPECTED_IDENTITY="$expected_identity" python3 -c ' +import os, sys + +expected = os.environ["EXPECTED_IDENTITY"] +lines = [line.strip() for line in sys.stdin.read().splitlines() if line.strip()] +payload = lines[-1].strip("\"") if lines else "" +sys.exit(0 if payload == expected else 1) +' <<<"$output" +} + +ensure_identity_registered() { + local contract_addr=$1 + local account=$2 + local identity=$3 + local profiles_json=$4 + local attempts=${STELLAR_INVOKE_RETRIES:-4} + local delay=${STELLAR_INVOKE_RETRY_DELAY_SECONDS:-3} + local attempt output status + + if identity_matches "$contract_addr" "$account" "$identity"; then + echo " Identity already registered for $account." + return 0 + fi + + for attempt in $(seq 1 "$attempts"); do + if output=$(invoke "$contract_addr" add_identity \ + --account "$account" \ + --identity "$identity" \ + --initial_profiles "$profiles_json" \ + --operator "$ADMIN" 2>&1); then + printf '%s\n' "$output" + return 0 + fi + status=$? + + if identity_matches "$contract_addr" "$account" "$identity"; then + echo " Identity registration confirmed after retryable failure for $account." + return 0 + fi + + if ! retryable_invoke_error "$output"; then + printf '%s\n' "$output" >&2 + return "$status" + fi + + if [ "$attempt" -eq "$attempts" ]; then + printf '%s\n' "$output" >&2 + return "$status" + fi + + echo " Retrying identity registration after transient Stellar CLI failure..." >&2 + sleep $((delay * attempt)) + done +} diff --git a/examples/rwa-deploy/scripts/deploy-module.sh b/examples/rwa-deploy/scripts/deploy-module.sh new file mode 100755 index 000000000..c37d35d2d --- /dev/null +++ b/examples/rwa-deploy/scripts/deploy-module.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Deploy a single compliance module, apply shared pre-bind setup, then bind + wire. +# Usage: ./deploy-module.sh [hook1 hook2 ...] +# Example: ./deploy-module.sh country-allow CanTransfer CanCreate +# +# This script handles the correct ordering: +# 1. Deploy the module with bootstrap admin +# 2. Apply shared pre-bind setup (IRS on identity-aware modules only) +# 3. Set compliance address (hands off to compliance) +# 4. Register on hooks (optional) +# +# This helper intentionally does not apply module-specific business config +# (allowlists, limits, lockup periods, etc.). Use `deploy.sh` for the full +# stack configuration flow. +# +# Prerequisites: deploy.sh must have been run (needs addresses file for infra). +set -euo pipefail + +if [ $# -lt 1 ]; then + echo "Usage: $0 [hook1 hook2 ...]" + echo "" + echo "Available modules: country-allow, country-restrict, initial-lockup-period," + echo " max-balance, supply-limit, time-transfers-limits, transfer-restrict" + echo "" + echo "Available hooks: CanTransfer, CanCreate, Transferred, Created, Destroyed" + exit 1 +fi + +MODULE="$1"; shift +HOOKS=("$@") + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +WASM_DIR="$ROOT_DIR/examples/rwa-deploy/wasm" +ADDR_FILE="$ROOT_DIR/examples/rwa-deploy/testnet-addresses.json" + +SOURCE="${STELLAR_SOURCE:-alice}" +NETWORK="${STELLAR_NETWORK:-testnet}" + +. "$SCRIPT_DIR/common.sh" + +WASM_NAME="rwa_${MODULE//-/_}.wasm" +WASM_PATH="$WASM_DIR/$WASM_NAME" + +if [ ! -f "$WASM_PATH" ]; then + echo "ERROR: $WASM_NAME not found. Run build.sh or build-module.sh first." >&2 + exit 1 +fi + +if [ ! -f "$ADDR_FILE" ]; then + echo "ERROR: testnet-addresses.json not found. Run deploy.sh first." >&2 + exit 1 +fi + +ADMIN=$(read_addr "['admin']") +TOKEN=$(read_addr "['contracts']['token']") +IRS=$(read_addr "['contracts']['irs']") +COMPLIANCE=$(read_addr "['contracts']['compliance']") + +require_contract_id "token" "$TOKEN" +require_contract_id "irs" "$IRS" +require_contract_id "compliance" "$COMPLIANCE" + +# ── Step 1: Deploy ── +echo "=== Deploying $MODULE ===" +MODULE_ADDR=$(stellar contract deploy \ + --wasm "$WASM_PATH" \ + --source "$SOURCE" --network "$NETWORK" \ + -- --admin "$ADMIN") +require_contract_id "$MODULE" "$MODULE_ADDR" +echo " Address: $MODULE_ADDR" + +# ── Step 2: Configure (before compliance bind) ── +IRS_MODULES=("country-allow" "country-restrict" "max-balance" "time-transfers-limits") +for irs_mod in "${IRS_MODULES[@]}"; do + if [ "$MODULE" = "$irs_mod" ]; then + echo " Setting IRS..." + invoke "$MODULE_ADDR" set_identity_registry_storage --token "$TOKEN" --irs "$IRS" + break + fi +done + +# ── Step 3: Set compliance address (hands off to compliance) ── +echo " Binding to compliance..." +invoke "$MODULE_ADDR" set_compliance_address --compliance "$COMPLIANCE" + +# ── Step 4: Register on hooks ── +for HOOK in "${HOOKS[@]}"; do + ensure_hook_registration "$HOOK" "$MODULE_ADDR" "$MODULE" +done + +echo "" +echo "=== $MODULE deployed and bound ===" +echo "Address: $MODULE_ADDR" diff --git a/examples/rwa-deploy/scripts/deploy.sh b/examples/rwa-deploy/scripts/deploy.sh new file mode 100755 index 000000000..a3cf4a869 --- /dev/null +++ b/examples/rwa-deploy/scripts/deploy.sh @@ -0,0 +1,276 @@ +#!/usr/bin/env bash +# Master deploy script: deploys ALL contracts, configures every module, then locks. +# +# CRITICAL ordering: module constructors store a bootstrap admin. Before +# `set_compliance_address`, privileged module actions require that admin; after +# the bind step they require the Compliance contract's auth instead. Since the +# CLI can't authorize as the Compliance contract, ALL configuration must happen +# BEFORE calling `set_compliance_address`. +# +# Flow: +# 1. Deploy infrastructure (IRS, Verifier, Compliance, Token) +# 2. Bind token to compliance + IRS +# 3. Deploy all 7 compliance modules with bootstrap admin +# 4. Configure every module (IRS, rules, limits, allowlists) +# 5. Set compliance address on all modules (transfers control to compliance) +# +# After this: run wire.sh, then test scripts. +# +# Prerequisites: build.sh must have been run first. +# Env vars: +# STELLAR_SOURCE - signing key alias (default: alice) +# STELLAR_NETWORK - network passphrase (default: testnet) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +WASM_DIR="$ROOT_DIR/examples/rwa-deploy/wasm" +ADDR_FILE="$ROOT_DIR/examples/rwa-deploy/testnet-addresses.json" + +SOURCE="${STELLAR_SOURCE:-alice}" +NETWORK="${STELLAR_NETWORK:-testnet}" + +. "$SCRIPT_DIR/common.sh" + +if [ ! -d "$WASM_DIR" ] || [ -z "$(ls -A "$WASM_DIR" 2>/dev/null)" ]; then + echo "ERROR: No WASMs found. Run build.sh first." >&2 + exit 1 +fi + +echo "=== Deploying RWA Stack ===" +echo "Source: $SOURCE | Network: $NETWORK" +echo "" + +ADMIN=$(stellar keys address "$SOURCE") + +deploy_contract() { + local LABEL=$1; shift + local attempts=${STELLAR_DEPLOY_RETRIES:-4} + local delay=${STELLAR_DEPLOY_RETRY_DELAY_SECONDS:-3} + local attempt output status addr + + echo "--- Deploying $LABEL ---" >&2 + + for attempt in $(seq 1 "$attempts"); do + if output=$(stellar contract deploy "$@" 2>&1); then + printf '%s\n' "$output" >&2 + addr=$(printf '%s\n' "$output" | awk 'NF { line = $0 } END { print line }') + + if require_contract_id "$LABEL" "$addr"; then + echo " $LABEL: $addr" >&2 + echo "$addr" + return 0 + fi + + output="${output} +ERROR: deploy returned an empty or invalid contract id for $LABEL" + status=1 + else + status=$? + fi + + if ! retryable_invoke_error "$output"; then + printf '%s\n' "$output" >&2 + return "$status" + fi + + if [ "$attempt" -eq "$attempts" ]; then + printf '%s\n' "$output" >&2 + return "$status" + fi + + echo "Retrying $LABEL deploy after transient Stellar CLI failure..." >&2 + sleep $((delay * attempt)) + done +} + +invoke_with_retry() { + local attempts=${STELLAR_INVOKE_RETRIES:-4} + local delay=${STELLAR_INVOKE_RETRY_DELAY_SECONDS:-3} + local attempt output status + + for attempt in $(seq 1 "$attempts"); do + if output=$(invoke "$@" 2>&1); then + printf '%s\n' "$output" + return 0 + fi + status=$? + + if ! retryable_invoke_error "$output"; then + printf '%s\n' "$output" >&2 + return "$status" + fi + + if [ "$attempt" -eq "$attempts" ]; then + printf '%s\n' "$output" >&2 + return "$status" + fi + + echo "Retrying deploy invoke after transient Stellar CLI failure..." >&2 + sleep $((delay * attempt)) + done +} + +write_addresses() { + cat > "$ADDR_FILE" <&2 + return 1 + fi + + deploy_contract "$NAME" \ + --wasm "$WASM_DIR/$WASM_NAME" \ + --source "$SOURCE" --network "$NETWORK" \ + -- --admin "$ADMIN" +} + +COUNTRY_ALLOW=$(deploy_module country-allow) +COUNTRY_RESTRICT=$(deploy_module country-restrict) +INITIAL_LOCKUP=$(deploy_module initial-lockup-period) +MAX_BALANCE=$(deploy_module max-balance) +SUPPLY_LIMIT=$(deploy_module supply-limit) +TIME_TRANSFERS=$(deploy_module time-transfers-limits) +TRANSFER_RESTRICT=$(deploy_module transfer-restrict) + +# Persist deployed addresses early so `wire.sh` and manual recovery can resume +# if a later configuration or bind step is interrupted. +write_addresses + +# ── Step 4: Configure ALL modules (before compliance bind) ── +# +# After set_compliance_address, module admin calls require Compliance contract +# auth which is impossible from the CLI. So ALL config goes here. + +echo "" +echo "=== Step 4/5: Configuring modules (before compliance bind) ===" + +# 4a. Set IRS on identity-aware modules +echo " Setting IRS on identity-aware modules..." +invoke_with_retry "$COUNTRY_ALLOW" set_identity_registry_storage --token "$TOKEN" --irs "$IRS" +invoke_with_retry "$COUNTRY_RESTRICT" set_identity_registry_storage --token "$TOKEN" --irs "$IRS" +invoke_with_retry "$MAX_BALANCE" set_identity_registry_storage --token "$TOKEN" --irs "$IRS" +invoke_with_retry "$TIME_TRANSFERS" set_identity_registry_storage --token "$TOKEN" --irs "$IRS" + +# 4b. CountryAllow: allow US (840), GB (826), DE (276) +echo " CountryAllow: adding US, GB, DE..." +invoke_with_retry "$COUNTRY_ALLOW" add_allowed_country --token "$TOKEN" --country 840 +invoke_with_retry "$COUNTRY_ALLOW" add_allowed_country --token "$TOKEN" --country 826 +invoke_with_retry "$COUNTRY_ALLOW" add_allowed_country --token "$TOKEN" --country 276 + +# 4c. CountryRestrict: restrict North Korea (408), Iran (364) +echo " CountryRestrict: blocking DPRK, IRN..." +invoke_with_retry "$COUNTRY_RESTRICT" add_country_restriction --token "$TOKEN" --country 408 +invoke_with_retry "$COUNTRY_RESTRICT" add_country_restriction --token "$TOKEN" --country 364 + +# 4d. MaxBalance: set limit of 1,000,000 tokens +echo " MaxBalance: setting limit 1000000..." +invoke_with_retry "$MAX_BALANCE" set_max_balance --token "$TOKEN" --max 1000000 + +# 4e. SupplyLimit: set total supply limit of 10,000,000 +echo " SupplyLimit: setting limit 10000000..." +invoke_with_retry "$SUPPLY_LIMIT" set_supply_limit --token "$TOKEN" --limit 10000000 + +# 4f. TimeTransfersLimits: set daily limit of 100,000 +echo " TimeTransfersLimits: setting daily limit 100000..." +invoke_with_retry "$TIME_TRANSFERS" set_time_transfer_limit \ + --token "$TOKEN" \ + --limit '{"limit_time":86400,"limit_value":"100000"}' + +# 4g. InitialLockupPeriod: set lockup to 300 seconds (5 min for testing) +echo " InitialLockupPeriod: setting lockup 300s..." +invoke_with_retry "$INITIAL_LOCKUP" set_lockup_period --token "$TOKEN" --lockup_seconds 300 + +# 4h. TransferRestrict: allow the admin address +echo " TransferRestrict: allowing admin..." +invoke_with_retry "$TRANSFER_RESTRICT" allow_user --token "$TOKEN" --user "$ADMIN" + +echo " All modules configured." + +# ── Step 5: Set compliance address on ALL modules (hands off to compliance) ── + +echo "" +echo "=== Step 5/5: Locking all modules to compliance ===" +for MODULE_ADDR in "$COUNTRY_ALLOW" "$COUNTRY_RESTRICT" "$INITIAL_LOCKUP" \ + "$MAX_BALANCE" "$SUPPLY_LIMIT" "$TIME_TRANSFERS" "$TRANSFER_RESTRICT"; do + invoke_with_retry "$MODULE_ADDR" set_compliance_address --compliance "$COMPLIANCE" +done +echo " All 7 modules bound. Admin functions now require Compliance contract auth." + +# ── Save addresses ── + +write_addresses + +echo "" +echo "=== Deployment Complete ===" +echo " Infrastructure: IRS, Verifier, Compliance, Token" +echo " Modules: 7/7 deployed and configured" +echo " Status: All modules bound to Compliance" +echo " Addresses: $ADDR_FILE" +echo "" +echo "Next steps:" +echo " 1. ./wire.sh — Register modules on compliance hooks" +echo " 2. ./test-happy-path.sh — Mint tokens and verify compliance" diff --git a/examples/rwa-deploy/scripts/e2e.sh b/examples/rwa-deploy/scripts/e2e.sh new file mode 100755 index 000000000..9bbedacb0 --- /dev/null +++ b/examples/rwa-deploy/scripts/e2e.sh @@ -0,0 +1,353 @@ +#!/usr/bin/env bash +# End-to-end: build -> deploy -> wire -> test. +# +# This is the single script that does everything in the correct order: +# Phase 1: Build all 11 WASMs (7 modules + 4 infra) +# Phase 2: Deploy infra + all 7 modules (with ALL config before compliance lock) +# Phase 3: Wire all 7 modules to compliance hooks +# Phase 4: Register investor identity in IRS +# Phase 5: Mint tokens and verify balance (happy path) +# +# Usage: ./e2e.sh [--skip-build] +# Env vars: +# STELLAR_SOURCE - signing key alias (default: alice) +# STELLAR_NETWORK - network passphrase (default: testnet) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +ADDR_FILE="$ROOT_DIR/examples/rwa-deploy/testnet-addresses.json" + +SOURCE="${STELLAR_SOURCE:-alice}" +NETWORK="${STELLAR_NETWORK:-testnet}" + +. "$SCRIPT_DIR/common.sh" + +SKIP_BUILD=false +if [ "${1:-}" = "--skip-build" ]; then + SKIP_BUILD=true +fi + +PASS=0 +FAIL=0 + +phase_header() { + echo "╔══════════════════════════════════════════╗" + printf "║ %-40s ║\n" "$1" + echo "╚══════════════════════════════════════════╝" + echo "" +} + +test_header() { + echo "--- $1 ---" +} + +extract_first_number() { + grep -oE '"[0-9]+"' | head -1 | tr -d '"' || \ + grep -oE '[0-9]+' | head -1 || echo "0" +} + +load_addresses() { + ADMIN=$(read_addr "['admin']") + TOKEN=$(read_addr "['contracts']['token']") + IRS=$(read_addr "['contracts']['irs']") + COUNTRY_ALLOW=$(read_addr "['modules']['country_allow']") + COUNTRY_RESTRICT=$(read_addr "['modules']['country_restrict']") + INITIAL_LOCKUP=$(read_addr "['modules']['initial_lockup_period']") + MAX_BALANCE=$(read_addr "['modules']['max_balance']") + SUPPLY_LIMIT=$(read_addr "['modules']['supply_limit']") + TIME_TRANSFERS=$(read_addr "['modules']['time_transfers_limits']") + TRANSFER_RESTRICT=$(read_addr "['modules']['transfer_restrict']") + + require_contract_id "token" "$TOKEN" + require_contract_id "irs" "$IRS" + require_contract_id "country_allow" "$COUNTRY_ALLOW" + require_contract_id "country_restrict" "$COUNTRY_RESTRICT" + require_contract_id "initial_lockup_period" "$INITIAL_LOCKUP" + require_contract_id "max_balance" "$MAX_BALANCE" + require_contract_id "supply_limit" "$SUPPLY_LIMIT" + require_contract_id "time_transfers_limits" "$TIME_TRANSFERS" + require_contract_id "transfer_restrict" "$TRANSFER_RESTRICT" +} + +register_test_identity() { + local alias_name=$1 + local country_code=$2 + local description=$3 + local address + + if [ "$alias_name" = "$SOURCE" ]; then + address="$ADMIN" + else + stellar keys generate "$alias_name" 2>/dev/null || true + address=$(stellar keys address "$alias_name") + fi + + echo "Registering $description ($address)..." + ensure_identity_registered \ + "$IRS" \ + "$address" \ + "$address" \ + "[{\"country\":{\"Individual\":{\"Citizenship\":$country_code}},\"metadata\":null}]" + echo " Identity registered." + REGISTERED_IDENTITY_ADDRESS=$address +} + +# ═══════════════════════════════════════════════════════ +# Phase 1: Build +# ═══════════════════════════════════════════════════════ +if [ "$SKIP_BUILD" = false ]; then + phase_header "Phase 1/5: Building all WASMs" + bash "$SCRIPT_DIR/build.sh" + echo "" +else + echo "Phase 1/5: Build SKIPPED (--skip-build)" + echo "" +fi + +# ═══════════════════════════════════════════════════════ +# Phase 2: Deploy (includes ALL module configuration) +# ═══════════════════════════════════════════════════════ +phase_header "Phase 2/5: Deploying full stack" +bash "$SCRIPT_DIR/deploy.sh" +echo "" + +# ═══════════════════════════════════════════════════════ +# Phase 3: Wire modules to hooks +# ═══════════════════════════════════════════════════════ +phase_header "Phase 3/5: Wiring modules to hooks" +bash "$SCRIPT_DIR/wire.sh" +echo "" + +# ═══════════════════════════════════════════════════════ +# Phase 4: Register investor identity +# ═══════════════════════════════════════════════════════ +phase_header "Phase 4/5: Registering investor identity" + +load_addresses + +register_test_identity "$SOURCE" 840 "investor" +INVESTOR=$REGISTERED_IDENTITY_ADDRESS + +# Generate test-only keypairs for country enforcement tests (no funding needed). +register_test_identity "e2e-investor-2" 392 "investor-2" +INVESTOR2=$REGISTERED_IDENTITY_ADDRESS +register_test_identity "e2e-investor-3" 408 "investor-3" +INVESTOR3=$REGISTERED_IDENTITY_ADDRESS +echo "" + +# ═══════════════════════════════════════════════════════ +# Phase 5: Comprehensive compliance tests +# ═══════════════════════════════════════════════════════ +phase_header "Phase 5/5: Compliance module tests" + +assert_pass() { + local DESC=$1; shift + local OUTPUT + echo " [$DESC]" + if OUTPUT=$("$@" 2>&1); then + echo " PASS" + PASS=$((PASS + 1)) + elif retryable_invoke_error "$OUTPUT"; then + echo " FAIL (transient Stellar CLI error)" + FAIL=$((FAIL + 1)) + else + echo " FAIL (expected success)" + FAIL=$((FAIL + 1)) + fi +} + +assert_fail() { + local DESC=$1; shift + echo " [$DESC]" + local OUTPUT + if OUTPUT=$("$@" 2>&1); then + echo " FAIL (expected rejection but succeeded)" + FAIL=$((FAIL + 1)) + elif retryable_invoke_error "$OUTPUT"; then + echo " FAIL (transient Stellar CLI error)" + FAIL=$((FAIL + 1)) + else + echo " PASS (correctly rejected)" + PASS=$((PASS + 1)) + fi +} + +assert_eq() { + local DESC=$1 EXPECTED=$2 ACTUAL=$3 + echo " [$DESC]" + if [ "$ACTUAL" = "$EXPECTED" ]; then + echo " PASS ($ACTUAL)" + PASS=$((PASS + 1)) + else + echo " FAIL (expected $EXPECTED, got $ACTUAL)" + FAIL=$((FAIL + 1)) + fi +} + +get_balance() { + local OUT + OUT=$(invoke_readonly "$TOKEN" balance --account "$1" 2>&1) + echo "$OUT" | extract_first_number +} + +get_internal_supply() { + local OUT + OUT=$(invoke_readonly "$SUPPLY_LIMIT" get_internal_supply --token "$TOKEN" 2>&1) + echo "$OUT" | extract_first_number +} + +run_auth_handoff_suite() { + # deploy.sh already proves the pre-bind bootstrap-admin path because all + # module configuration succeeds before calling `set_compliance_address`. + # These checks prove the post-bind handoff with real testnet transactions by + # asserting that the same externally owned admin can no longer call + # privileged module config. + local description contract method + local arg1 arg2 arg3 arg4 arg5 arg6 + local args + + test_header "Auth handoff: admin config blocked after compliance bind" + while IFS='|' read -r description contract method arg1 arg2 arg3 arg4 arg5 arg6; do + [ -n "$description" ] || continue + + args=() + for arg in "$arg1" "$arg2" "$arg3" "$arg4" "$arg5" "$arg6"; do + if [ -n "$arg" ]; then + args+=("$arg") + fi + done + + assert_fail "$description" invoke "$contract" "$method" "${args[@]}" + done <= amount. + # All 1000 tokens are locked so burn should be rejected. + test_header "Test 4: Lockup blocks burn of locked tokens" + assert_fail "burn 500 during lockup" invoke "$TOKEN" burn \ + --user_address "$INVESTOR" --amount 500 --operator "$ADMIN" + + BAL=$(get_balance "$INVESTOR") + assert_eq "balance unchanged = 1000" "1000" "$BAL" + echo "" +} + +run_balance_tests() { + # Supply is 1000 from Test 1. Mint 1000 more -> internal supply = 2000. + test_header "Test 5: Mint more (supply counter tracks)" + assert_pass "mint 1000 more" invoke "$TOKEN" mint --to "$INVESTOR" --amount 1000 --operator "$ADMIN" + + BAL=$(get_balance "$INVESTOR") + assert_eq "balance = 2000" "2000" "$BAL" + + SUPPLY=$(get_internal_supply) + assert_eq "internal supply = 2000" "2000" "$SUPPLY" + echo "" + + # MaxBalance is 1,000,000 per identity. Investor has 2000 already. + # Mint 998,000 more to hit the identity cap exactly. + test_header "Test 6: Mint to max-balance ceiling" + assert_pass "mint 998000 (fill to 1M)" invoke "$TOKEN" mint --to "$INVESTOR" --amount 998000 --operator "$ADMIN" + + BAL=$(get_balance "$INVESTOR") + assert_eq "balance = 1000000" "1000000" "$BAL" + + SUPPLY=$(get_internal_supply) + assert_eq "internal supply = 1000000" "1000000" "$SUPPLY" + + assert_fail "mint 1 more (over max-balance)" invoke "$TOKEN" mint --to "$INVESTOR" --amount 1 --operator "$ADMIN" + echo "" +} + +run_country_tests() { + # Japan (392) is NOT in the allowed list (US/GB/DE = 840/826/276). + # Mint to investor-2 should be rejected by CountryAllowModule.can_create. + test_header "Test 7: CountryAllow blocks mint to non-allowed country" + assert_fail "mint to Japan investor (392 not allowed)" invoke "$TOKEN" mint --to "$INVESTOR2" --amount 100 --operator "$ADMIN" + echo "" + + # DPRK (408) IS on the restricted list. + # Mint to investor-3 should be rejected by CountryRestrictModule.can_create. + test_header "Test 8: CountryRestrict blocks mint to restricted country" + assert_fail "mint to DPRK investor (408 restricted)" invoke "$TOKEN" mint --to "$INVESTOR3" --amount 100 --operator "$ADMIN" + echo "" +} + +run_auth_handoff_suite +run_supply_limit_tests +run_lockup_tests +run_balance_tests +run_country_tests + +# ═══════════════════════════════════════════════════════ +# Summary +# ═══════════════════════════════════════════════════════ +TOTAL=$((PASS + FAIL)) +echo "╔════════════════════════════════════════════════════╗" +echo "║ E2E RESULTS ║" +echo "╠════════════════════════════════════════════════════╣" +echo "║ Build: 11 WASMs compiled ║" +echo "║ Deploy: 4 infra + 7 modules configured ║" +echo "║ Wire: 7 modules on 19 hooks (4 verified) ║" +echo "║ Identity: 3 investors registered ║" +echo "╠════════════════════════════════════════════════════╣" +echo "║ Module tests: ║" +echo "║ - Auth handoff: admin blocked after bind ║" +echo "║ - Supply limit: mint, over-mint rejection ║" +echo "║ - Max balance: identity cap enforcement ║" +echo "║ - Initial lockup: transfer + burn blocked ║" +echo "║ - Country allow: non-allowed country rejected ║" +echo "║ - Country restrict: restricted country rejected ║" +echo "║ - Internal state: supply counter across mints ║" +echo "╠════════════════════════════════════════════════════╣" +printf "║ Tests: %d passed, %d failed (of %d) ║\n" "$PASS" "$FAIL" "$TOTAL" +echo "╚════════════════════════════════════════════════════╝" +echo "" +echo "Addresses: $ADDR_FILE" + +if [ $FAIL -gt 0 ]; then + exit 1 +fi diff --git a/examples/rwa-deploy/scripts/test-happy-path.sh b/examples/rwa-deploy/scripts/test-happy-path.sh new file mode 100755 index 000000000..5471bebe4 --- /dev/null +++ b/examples/rwa-deploy/scripts/test-happy-path.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Test happy path: register investor identity, mint tokens, verify balance. +# +# Prerequisites: +# - deploy.sh has been run (modules configured + bound) +# - wire.sh has been run (modules registered on hooks) +# +# This script does NOT try to configure modules (add_allowed_country, etc.) +# because those functions require compliance auth after deploy.sh +# binds each module to the Compliance contract. All module config happens in deploy.sh. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +ADDR_FILE="$ROOT_DIR/examples/rwa-deploy/testnet-addresses.json" + +SOURCE="${STELLAR_SOURCE:-alice}" +NETWORK="${STELLAR_NETWORK:-testnet}" + +. "$SCRIPT_DIR/common.sh" + +if [ ! -f "$ADDR_FILE" ]; then + echo "ERROR: testnet-addresses.json not found. Run deploy.sh first." >&2 + exit 1 +fi + +ADMIN=$(read_addr "['admin']") +TOKEN=$(read_addr "['contracts']['token']") +IRS=$(read_addr "['contracts']['irs']") + +require_contract_id "token" "$TOKEN" +require_contract_id "irs" "$IRS" + +INVESTOR="$ADMIN" + +invoke_with_retry() { + local attempts=${STELLAR_INVOKE_RETRIES:-4} + local delay=${STELLAR_INVOKE_RETRY_DELAY_SECONDS:-3} + local attempt output status + + for attempt in $(seq 1 "$attempts"); do + if output=$(invoke "$@" 2>&1); then + printf '%s\n' "$output" + return 0 + fi + status=$? + + if ! retryable_invoke_error "$output"; then + printf '%s\n' "$output" >&2 + return "$status" + fi + + if [ "$attempt" -eq "$attempts" ]; then + printf '%s\n' "$output" >&2 + return "$status" + fi + + echo " Retrying after transient Stellar CLI failure..." >&2 + sleep $((delay * attempt)) + done +} + +echo "=== Happy Path Test ===" +echo "Token: $TOKEN" +echo "Investor: $INVESTOR" +echo "" + +# Step 1: Register investor identity in IRS (IRS uses its own admin auth, not compliance) +echo "1. Registering investor identity..." +ensure_identity_registered \ + "$IRS" \ + "$INVESTOR" \ + "$INVESTOR" \ + '[{"country":{"Individual":{"Citizenship":840}},"metadata":null}]' + +# Step 2: Mint tokens (this triggers compliance hooks: CanCreate -> Created) +echo "" +echo "2. Minting 1000 tokens to investor..." +invoke_with_retry "$TOKEN" mint --to "$INVESTOR" --amount 1000 --operator "$ADMIN" + +# Step 3: Check balance +echo "" +echo "3. Checking balance..." +BALANCE_OUTPUT=$(invoke_readonly "$TOKEN" balance --account "$INVESTOR" 2>&1) +BALANCE=$(echo "$BALANCE_OUTPUT" | grep -oE '[0-9]+' | head -1) + +echo "" +echo "=== Result ===" +echo "Balance: $BALANCE" +if [ -n "$BALANCE" ] && [ "$BALANCE" -ge 1000 ] 2>/dev/null; then + echo "PASS: Happy path succeeded!" +else + echo "FAIL: Unexpected balance: '$BALANCE_OUTPUT'" + exit 1 +fi diff --git a/examples/rwa-deploy/scripts/wire.sh b/examples/rwa-deploy/scripts/wire.sh new file mode 100755 index 000000000..04ce9df51 --- /dev/null +++ b/examples/rwa-deploy/scripts/wire.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Wire all 7 compliance modules to their required hooks. +# Reads addresses from deploy/testnet-addresses.json (written by deploy.sh). +# +# Hook registrations per module: +# - CountryAllow: CanTransfer, CanCreate +# - CountryRestrict: CanTransfer, CanCreate +# - MaxBalance: CanTransfer, CanCreate, Transferred, Created, Destroyed +# - TransferRestrict: CanTransfer +# - TimeTransfersLimits: CanTransfer, Transferred +# - SupplyLimit: CanCreate, Created, Destroyed +# - InitialLockupPeriod: CanTransfer, Created, Transferred, Destroyed +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +ADDR_FILE="$ROOT_DIR/examples/rwa-deploy/testnet-addresses.json" + +SOURCE="${STELLAR_SOURCE:-alice}" +NETWORK="${STELLAR_NETWORK:-testnet}" + +if [ ! -f "$ADDR_FILE" ]; then + echo "ERROR: testnet-addresses.json not found. Run deploy.sh first." >&2 + exit 1 +fi + +. "$SCRIPT_DIR/common.sh" + +ADMIN=$(read_addr "['admin']") +COMPLIANCE=$(read_addr "['contracts']['compliance']") + +COUNTRY_ALLOW=$(read_addr "['modules']['country_allow']") +COUNTRY_RESTRICT=$(read_addr "['modules']['country_restrict']") +MAX_BALANCE=$(read_addr "['modules']['max_balance']") +TRANSFER_RESTRICT=$(read_addr "['modules']['transfer_restrict']") +TIME_TRANSFERS=$(read_addr "['modules']['time_transfers_limits']") +SUPPLY_LIMIT=$(read_addr "['modules']['supply_limit']") +INITIAL_LOCKUP=$(read_addr "['modules']['initial_lockup_period']") + +require_contract_id "compliance" "$COMPLIANCE" +require_contract_id "country_allow" "$COUNTRY_ALLOW" +require_contract_id "country_restrict" "$COUNTRY_RESTRICT" +require_contract_id "max_balance" "$MAX_BALANCE" +require_contract_id "transfer_restrict" "$TRANSFER_RESTRICT" +require_contract_id "time_transfers_limits" "$TIME_TRANSFERS" +require_contract_id "supply_limit" "$SUPPLY_LIMIT" +require_contract_id "initial_lockup_period" "$INITIAL_LOCKUP" + +echo "=== Wiring Modules to Compliance Hooks ===" +echo "" + +ensure_hook_registration "CanTransfer" "$COUNTRY_ALLOW" "CountryAllowModule" +ensure_hook_registration "CanCreate" "$COUNTRY_ALLOW" "CountryAllowModule" + +ensure_hook_registration "CanTransfer" "$COUNTRY_RESTRICT" "CountryRestrictModule" +ensure_hook_registration "CanCreate" "$COUNTRY_RESTRICT" "CountryRestrictModule" + +ensure_hook_registration "CanTransfer" "$MAX_BALANCE" "MaxBalanceModule" +ensure_hook_registration "CanCreate" "$MAX_BALANCE" "MaxBalanceModule" +ensure_hook_registration "Transferred" "$MAX_BALANCE" "MaxBalanceModule" +ensure_hook_registration "Created" "$MAX_BALANCE" "MaxBalanceModule" +ensure_hook_registration "Destroyed" "$MAX_BALANCE" "MaxBalanceModule" + +ensure_hook_registration "CanTransfer" "$TRANSFER_RESTRICT" "TransferRestrictModule" + +ensure_hook_registration "CanTransfer" "$TIME_TRANSFERS" "TimeTransfersLimitsModule" +ensure_hook_registration "Transferred" "$TIME_TRANSFERS" "TimeTransfersLimitsModule" + +ensure_hook_registration "CanCreate" "$SUPPLY_LIMIT" "SupplyLimitModule" +ensure_hook_registration "Created" "$SUPPLY_LIMIT" "SupplyLimitModule" +ensure_hook_registration "Destroyed" "$SUPPLY_LIMIT" "SupplyLimitModule" + +ensure_hook_registration "CanTransfer" "$INITIAL_LOCKUP" "InitialLockupPeriodModule" +ensure_hook_registration "Created" "$INITIAL_LOCKUP" "InitialLockupPeriodModule" +ensure_hook_registration "Transferred" "$INITIAL_LOCKUP" "InitialLockupPeriodModule" +ensure_hook_registration "Destroyed" "$INITIAL_LOCKUP" "InitialLockupPeriodModule" + +echo "" +echo "=== Verifying hook wiring for stateful modules ===" +echo "" + +verify_hook_wiring_with_retry "$SUPPLY_LIMIT" "SupplyLimitModule" +verify_hook_wiring_with_retry "$INITIAL_LOCKUP" "InitialLockupPeriodModule" +verify_hook_wiring_with_retry "$MAX_BALANCE" "MaxBalanceModule" +verify_hook_wiring_with_retry "$TIME_TRANSFERS" "TimeTransfersLimitsModule" + +echo "" +echo "=== Wiring Complete (19 hooks registered, 4 modules verified) ===" diff --git a/examples/rwa-deploy/token/Cargo.toml b/examples/rwa-deploy/token/Cargo.toml new file mode 100644 index 000000000..a5b498234 --- /dev/null +++ b/examples/rwa-deploy/token/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "deploy-token" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true + +[lib] +crate-type = ["cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-access = { workspace = true } +stellar-contract-utils = { workspace = true } +stellar-macros = { workspace = true } +stellar-tokens = { workspace = true } diff --git a/examples/rwa-deploy/token/src/lib.rs b/examples/rwa-deploy/token/src/lib.rs new file mode 100644 index 000000000..92013b5f5 --- /dev/null +++ b/examples/rwa-deploy/token/src/lib.rs @@ -0,0 +1,137 @@ +#![no_std] + +use soroban_sdk::{ + contract, contractimpl, symbol_short, Address, Env, MuxedAddress, String, Symbol, Vec, +}; +use stellar_access::access_control::{self as access_control, AccessControl}; +use stellar_contract_utils::pausable::{self as pausable, Pausable}; +use stellar_macros::only_role; +use stellar_tokens::{ + fungible::{Base, FungibleToken}, + rwa::{storage::RWAStorageKey, RWAToken, RWA}, +}; + +#[contract] +pub struct RWATokenContract; + +#[contractimpl] +impl RWATokenContract { + pub fn __constructor( + e: &Env, + name: String, + symbol: String, + admin: Address, + compliance: Address, + identity_verifier: Address, + ) { + Base::set_metadata(e, 18, name, symbol); + access_control::set_admin(e, &admin); + access_control::grant_role_no_auth(e, &admin, &symbol_short!("admin"), &admin); + RWA::set_compliance(e, &compliance); + RWA::set_identity_verifier(e, &identity_verifier); + e.storage().instance().set(&RWAStorageKey::Version, &String::from_str(e, "1.0.0")); + RWA::set_onchain_id(e, &e.current_contract_address()); + } +} + +#[contractimpl(contracttrait)] +impl FungibleToken for RWATokenContract { + type ContractType = RWA; +} + +#[contractimpl] +impl RWAToken for RWATokenContract { + #[only_role(operator, "admin")] + fn forced_transfer(e: &Env, from: Address, to: Address, amount: i128, operator: Address) { + RWA::forced_transfer(e, &from, &to, amount); + } + + #[only_role(operator, "admin")] + fn mint(e: &Env, to: Address, amount: i128, operator: Address) { + RWA::mint(e, &to, amount); + } + + #[only_role(operator, "admin")] + fn burn(e: &Env, user_address: Address, amount: i128, operator: Address) { + RWA::burn(e, &user_address, amount); + } + + #[only_role(operator, "admin")] + fn recover_balance( + e: &Env, + old_account: Address, + new_account: Address, + operator: Address, + ) -> bool { + RWA::recover_balance(e, &old_account, &new_account) + } + + #[only_role(operator, "admin")] + fn set_address_frozen(e: &Env, user_address: Address, freeze: bool, operator: Address) { + RWA::set_address_frozen(e, &user_address, freeze); + } + + #[only_role(operator, "admin")] + fn freeze_partial_tokens(e: &Env, user_address: Address, amount: i128, operator: Address) { + RWA::freeze_partial_tokens(e, &user_address, amount); + } + + #[only_role(operator, "admin")] + fn unfreeze_partial_tokens(e: &Env, user_address: Address, amount: i128, operator: Address) { + RWA::unfreeze_partial_tokens(e, &user_address, amount); + } + + fn is_frozen(e: &Env, user_address: Address) -> bool { + RWA::is_frozen(e, &user_address) + } + + fn get_frozen_tokens(e: &Env, user_address: Address) -> i128 { + RWA::get_frozen_tokens(e, &user_address) + } + + fn version(e: &Env) -> String { + RWA::version(e) + } + + fn onchain_id(e: &Env) -> Address { + RWA::onchain_id(e) + } + + #[only_role(operator, "admin")] + fn set_compliance(e: &Env, compliance: Address, operator: Address) { + RWA::set_compliance(e, &compliance); + } + + fn compliance(e: &Env) -> Address { + RWA::compliance(e) + } + + #[only_role(operator, "admin")] + fn set_identity_verifier(e: &Env, identity_verifier: Address, operator: Address) { + RWA::set_identity_verifier(e, &identity_verifier); + } + + fn identity_verifier(e: &Env) -> Address { + RWA::identity_verifier(e) + } +} + +#[contractimpl] +impl Pausable for RWATokenContract { + fn paused(e: &Env) -> bool { + pausable::paused(e) + } + + #[only_role(caller, "admin")] + fn pause(e: &Env, caller: Address) { + pausable::pause(e); + } + + #[only_role(caller, "admin")] + fn unpause(e: &Env, caller: Address) { + pausable::unpause(e); + } +} + +#[contractimpl(contracttrait)] +impl AccessControl for RWATokenContract {} diff --git a/examples/rwa-deploy/verifier/Cargo.toml b/examples/rwa-deploy/verifier/Cargo.toml new file mode 100644 index 000000000..82cf29365 --- /dev/null +++ b/examples/rwa-deploy/verifier/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "deploy-verifier" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true + +[lib] +crate-type = ["cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-access = { workspace = true } +stellar-macros = { workspace = true } +stellar-tokens = { workspace = true } diff --git a/examples/rwa-deploy/verifier/src/lib.rs b/examples/rwa-deploy/verifier/src/lib.rs new file mode 100644 index 000000000..bf77abc79 --- /dev/null +++ b/examples/rwa-deploy/verifier/src/lib.rs @@ -0,0 +1,76 @@ +#![no_std] + +use soroban_sdk::{ + contract, contractimpl, contracttype, panic_with_error, symbol_short, Address, Env, Symbol, Vec, +}; +use stellar_access::access_control::{self as access_control, AccessControl}; +use stellar_macros::only_role; +use stellar_tokens::rwa::{ + emit_claim_topics_and_issuers_set, identity_verifier::IdentityVerifier, RWAError, +}; + +#[contracttype] +#[derive(Clone)] +enum DataKey { + Irs, + ClaimTopicsAndIssuers, +} + +#[soroban_sdk::contractclient(name = "IRSClient")] +#[allow(dead_code)] +trait IRSView { + fn stored_identity(e: &Env, account: Address) -> Address; + fn get_recovered_to(e: &Env, old: Address) -> Option
; +} + +#[contract] +pub struct SimpleIdentityVerifier; + +fn identity_registry_storage(e: &Env) -> Address { + e.storage() + .instance() + .get(&DataKey::Irs) + .unwrap_or_else(|| panic_with_error!(e, RWAError::IdentityRegistryStorageNotSet)) +} + +#[contractimpl] +impl SimpleIdentityVerifier { + pub fn __constructor(e: &Env, admin: Address, irs: Address) { + access_control::set_admin(e, &admin); + access_control::grant_role_no_auth(e, &admin, &symbol_short!("admin"), &admin); + e.storage().instance().set(&DataKey::Irs, &irs); + } +} + +#[contractimpl] +impl IdentityVerifier for SimpleIdentityVerifier { + fn verify_identity(e: &Env, account: &Address) { + let irs = identity_registry_storage(e); + let client = IRSClient::new(e, &irs); + if client.try_stored_identity(account).is_err() { + panic_with_error!(e, RWAError::IdentityVerificationFailed); + } + } + + fn recovery_target(e: &Env, old_account: &Address) -> Option
{ + let irs = identity_registry_storage(e); + let client = IRSClient::new(e, &irs); + client.get_recovered_to(old_account) + } + + #[only_role(operator, "admin")] + fn set_claim_topics_and_issuers(e: &Env, claim_topics_and_issuers: Address, operator: Address) { + e.storage().instance().set(&DataKey::ClaimTopicsAndIssuers, &claim_topics_and_issuers); + emit_claim_topics_and_issuers_set(e, &claim_topics_and_issuers); + } + + fn claim_topics_and_issuers(e: &Env) -> Address { + e.storage() + .instance() + .get(&DataKey::ClaimTopicsAndIssuers) + .unwrap_or_else(|| panic_with_error!(e, RWAError::ClaimTopicsAndIssuersNotSet)) + } +} + +#[contractimpl(contracttrait)] +impl AccessControl for SimpleIdentityVerifier {} diff --git a/examples/rwa-initial-lockup-period/Cargo.toml b/examples/rwa-initial-lockup-period/Cargo.toml new file mode 100644 index 000000000..dc0edbff4 --- /dev/null +++ b/examples/rwa-initial-lockup-period/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "rwa-initial-lockup-period" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-tokens = { workspace = true } diff --git a/examples/rwa-initial-lockup-period/README.md b/examples/rwa-initial-lockup-period/README.md new file mode 100644 index 000000000..e795d527d --- /dev/null +++ b/examples/rwa-initial-lockup-period/README.md @@ -0,0 +1,64 @@ +# Initial Lockup Period Module + +Concrete deployable example of the `InitialLockupPeriod` compliance module for +Stellar RWA tokens. + +## What it enforces + +This module applies a lockup period to tokens received through primary +emissions. When tokens are minted, the minted amount is locked until the +configured release timestamp. + +The example follows the library semantics: + +- minted tokens are subject to lockup +- peer-to-peer transfers do not create new lockups for the recipient +- transfers and burns can consume only unlocked balance + +## How it stays in sync + +The module maintains internal balances plus lock records and therefore must be +wired to all of the hooks it depends on: + +- `CanTransfer` +- `Created` +- `Transferred` +- `Destroyed` + +After those hooks are registered, `verify_hook_wiring()` must be called once so +the module marks itself as armed before transfer validation starts. + +## Authorization model + +This example uses the bootstrap-admin pattern introduced in this port: + +- The constructor stores a one-time `admin` +- Before `set_compliance_address`, configuration calls require that admin's + auth +- After `set_compliance_address`, privileged calls require auth from the bound + Compliance contract +- `set_compliance_address` itself remains a one-time admin action + +This allows the module to be configured from the CLI before handing control to +Compliance. + +## Main entrypoints + +- `__constructor(admin)` initializes the bootstrap admin +- `set_lockup_period(token, lockup_seconds)` configures the mint lockup window +- `pre_set_lockup_state(token, wallet, balance, locks)` seeds an existing + holder's mirrored balance and active lock entries +- `required_hooks()` returns the required hook set +- `verify_hook_wiring()` marks the module as armed after registration +- `set_compliance_address(compliance)` performs the one-time handoff to the + Compliance contract + +## Notes + +- Storage is token-scoped, so one deployed module can be reused across many + tokens +- The module stores detailed lock entries plus aggregate locked totals +- If the module is attached after live minting, seed existing balances and any + still-active lock entries before relying on transfer or burn enforcement +- Transfer and burn flows consume unlocked balance first, then matured locks if + needed diff --git a/examples/rwa-initial-lockup-period/src/lib.rs b/examples/rwa-initial-lockup-period/src/lib.rs new file mode 100644 index 000000000..da4fa20d0 --- /dev/null +++ b/examples/rwa-initial-lockup-period/src/lib.rs @@ -0,0 +1,228 @@ +#![no_std] + +use soroban_sdk::{contract, contractimpl, contracttype, vec, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::{ + modules::{ + initial_lockup_period::{ + storage::{ + get_internal_balance, get_locks, get_lockup_period, get_total_locked, + set_internal_balance, set_locks, set_lockup_period, set_total_locked, + }, + InitialLockupPeriod, LockedTokens, LockupPeriodSet, + }, + storage::{ + add_i128_or_panic, set_compliance_address, sub_i128_or_panic, verify_required_hooks, + ComplianceModuleStorageKey, + }, + }, + ComplianceHook, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct InitialLockupPeriodContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +fn calculate_unlocked_amount(e: &Env, locks: &Vec) -> i128 { + let now = e.ledger().timestamp(); + let mut unlocked = 0i128; + for i in 0..locks.len() { + let lock = locks.get(i).unwrap(); + if lock.release_timestamp <= now { + unlocked = add_i128_or_panic(e, unlocked, lock.amount); + } + } + unlocked +} + +fn update_locked_tokens(e: &Env, token: &Address, wallet: &Address, mut amount_to_consume: i128) { + let locks = get_locks(e, token, wallet); + let now = e.ledger().timestamp(); + let mut new_locks = Vec::new(e); + let mut consumed_total = 0i128; + + for i in 0..locks.len() { + let lock = locks.get(i).unwrap(); + if amount_to_consume > 0 && lock.release_timestamp <= now { + if amount_to_consume >= lock.amount { + amount_to_consume = sub_i128_or_panic(e, amount_to_consume, lock.amount); + consumed_total = add_i128_or_panic(e, consumed_total, lock.amount); + } else { + consumed_total = add_i128_or_panic(e, consumed_total, amount_to_consume); + new_locks.push_back(LockedTokens { + amount: sub_i128_or_panic(e, lock.amount, amount_to_consume), + release_timestamp: lock.release_timestamp, + }); + amount_to_consume = 0; + } + } else { + new_locks.push_back(lock); + } + } + + set_locks(e, token, wallet, &new_locks); + + let total_locked = get_total_locked(e, token, wallet); + set_total_locked(e, token, wallet, sub_i128_or_panic(e, total_locked, consumed_total)); +} + +#[contractimpl] +impl InitialLockupPeriodContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } +} + +#[contractimpl(contracttrait)] +impl InitialLockupPeriod for InitialLockupPeriodContract { + fn set_lockup_period(e: &Env, token: Address, lockup_seconds: u64) { + require_module_admin_or_compliance_auth(e); + set_lockup_period(e, &token, lockup_seconds); + LockupPeriodSet { token, lockup_seconds }.publish(e); + } + + fn pre_set_lockup_state( + e: &Env, + token: Address, + wallet: Address, + balance: i128, + locks: Vec, + ) { + require_module_admin_or_compliance_auth(e); + stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, balance); + + let mut total_locked = 0i128; + for i in 0..locks.len() { + let lock = locks.get(i).unwrap(); + stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount( + e, + lock.amount, + ); + total_locked = add_i128_or_panic(e, total_locked, lock.amount); + } + + assert!( + total_locked <= balance, + "InitialLockupPeriodModule: total locked amount cannot exceed balance" + ); + + set_internal_balance(e, &token, &wallet, balance); + set_locks(e, &token, &wallet, &locks); + set_total_locked(e, &token, &wallet, total_locked); + } + + fn required_hooks(e: &Env) -> Vec { + vec![ + e, + ComplianceHook::CanTransfer, + ComplianceHook::Created, + ComplianceHook::Transferred, + ComplianceHook::Destroyed, + ] + } + + fn verify_hook_wiring(e: &Env) { + verify_required_hooks(e, Self::required_hooks(e)); + } + + fn on_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, amount); + + let total_locked = get_total_locked(e, &token, &from); + + if total_locked > 0 { + let pre_balance = get_internal_balance(e, &token, &from); + let pre_free = pre_balance - total_locked; + + if amount > pre_free.max(0) { + let to_consume = amount - pre_free.max(0); + update_locked_tokens(e, &token, &from, to_consume); + } + } + + let from_bal = get_internal_balance(e, &token, &from); + set_internal_balance(e, &token, &from, sub_i128_or_panic(e, from_bal, amount)); + + let to_bal = get_internal_balance(e, &token, &to); + set_internal_balance(e, &token, &to, add_i128_or_panic(e, to_bal, amount)); + } + + fn on_created(e: &Env, to: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, amount); + + let period = get_lockup_period(e, &token); + if period > 0 { + let mut locks = get_locks(e, &token, &to); + locks.push_back(LockedTokens { + amount, + release_timestamp: e.ledger().timestamp().saturating_add(period), + }); + set_locks(e, &token, &to, &locks); + + let total = get_total_locked(e, &token, &to); + set_total_locked(e, &token, &to, add_i128_or_panic(e, total, amount)); + } + + let current = get_internal_balance(e, &token, &to); + set_internal_balance(e, &token, &to, add_i128_or_panic(e, current, amount)); + } + + fn on_destroyed(e: &Env, from: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, amount); + + let total_locked = get_total_locked(e, &token, &from); + + if total_locked > 0 { + let pre_balance = get_internal_balance(e, &token, &from); + let mut free_amount = pre_balance - total_locked; + + if free_amount < amount { + let locks = get_locks(e, &token, &from); + free_amount += calculate_unlocked_amount(e, &locks); + } + + assert!( + free_amount >= amount, + "InitialLockupPeriodModule: insufficient unlocked balance for burn" + ); + + let pre_free = pre_balance - total_locked; + if amount > pre_free.max(0) { + let to_consume = amount - pre_free.max(0); + update_locked_tokens(e, &token, &from, to_consume); + } + } + + let current = get_internal_balance(e, &token, &from); + set_internal_balance(e, &token, &from, sub_i128_or_panic(e, current, amount)); + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-max-balance/Cargo.toml b/examples/rwa-max-balance/Cargo.toml new file mode 100644 index 000000000..1238118f2 --- /dev/null +++ b/examples/rwa-max-balance/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "rwa-max-balance" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-tokens = { workspace = true } diff --git a/examples/rwa-max-balance/README.md b/examples/rwa-max-balance/README.md new file mode 100644 index 000000000..193ebfa64 --- /dev/null +++ b/examples/rwa-max-balance/README.md @@ -0,0 +1,62 @@ +# Max Balance Module + +Concrete deployable example of the `MaxBalance` compliance module for Stellar +RWA tokens. + +## What it enforces + +This module tracks balances per investor identity, not per wallet, and enforces +a maximum balance cap for each token. + +Because the accounting is identity-based, the module must be configured with an +Identity Registry Storage (IRS) contract for each token it serves. + +## How it stays in sync + +The module maintains internal per-identity balances and therefore must be wired +to all of the hooks it depends on: + +- `CanTransfer` +- `CanCreate` +- `Transferred` +- `Created` +- `Destroyed` + +After those hooks are registered, `verify_hook_wiring()` must be called once so +the module marks itself as armed before mint and transfer validation starts. + +## Authorization model + +This example uses the bootstrap-admin pattern introduced in this port: + +- The constructor stores a one-time `admin` +- Before `set_compliance_address`, configuration calls require that admin's + auth +- After `set_compliance_address`, privileged calls require auth from the bound + Compliance contract +- `set_compliance_address` itself remains a one-time admin action + +This allows the module to be seeded and configured from the CLI before handing +control to Compliance. + +## Main entrypoints + +- `__constructor(admin)` initializes the bootstrap admin +- `set_identity_registry_storage(token, irs)` stores the IRS address for a + token +- `set_max_balance(token, max)` configures the per-identity cap +- `pre_set_module_state(token, identity, balance)` seeds an identity balance +- `batch_pre_set_module_state(token, identities, balances)` seeds many + identity balances +- `required_hooks()` returns the required hook set +- `verify_hook_wiring()` marks the module as armed after registration +- `set_compliance_address(compliance)` performs the one-time handoff to the + Compliance contract + +## Notes + +- Storage is token-scoped, so one deployed module can be reused across many + tokens +- Transfers between two wallets that resolve to the same identity do not change + the tracked balance distribution +- A configured max of `0` behaves as "no cap" diff --git a/examples/rwa-max-balance/src/lib.rs b/examples/rwa-max-balance/src/lib.rs new file mode 100644 index 000000000..16352fde6 --- /dev/null +++ b/examples/rwa-max-balance/src/lib.rs @@ -0,0 +1,167 @@ +#![no_std] + +use soroban_sdk::{contract, contractimpl, contracttype, vec, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::{ + modules::{ + max_balance::{ + storage::{get_id_balance, get_max_balance, set_id_balance, set_max_balance}, + IDBalancePreSet, MaxBalance, MaxBalanceSet, + }, + storage::{ + add_i128_or_panic, get_irs_client, set_compliance_address, set_irs_address, + sub_i128_or_panic, verify_required_hooks, ComplianceModuleStorageKey, + }, + }, + ComplianceHook, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct MaxBalanceContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl MaxBalanceContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } +} + +#[contractimpl(contracttrait)] +impl MaxBalance for MaxBalanceContract { + fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { + require_module_admin_or_compliance_auth(e); + set_irs_address(e, &token, &irs); + } + + fn set_max_balance(e: &Env, token: Address, max: i128) { + require_module_admin_or_compliance_auth(e); + stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, max); + set_max_balance(e, &token, max); + MaxBalanceSet { token, max_balance: max }.publish(e); + } + + fn pre_set_module_state(e: &Env, token: Address, identity: Address, balance: i128) { + require_module_admin_or_compliance_auth(e); + stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, balance); + set_id_balance(e, &token, &identity, balance); + IDBalancePreSet { token, identity, balance }.publish(e); + } + + fn batch_pre_set_module_state( + e: &Env, + token: Address, + identities: Vec
, + balances: Vec, + ) { + require_module_admin_or_compliance_auth(e); + assert!( + identities.len() == balances.len(), + "MaxBalanceModule: identities and balances length mismatch" + ); + for i in 0..identities.len() { + let id = identities.get(i).unwrap(); + let bal = balances.get(i).unwrap(); + stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, bal); + set_id_balance(e, &token, &id, bal); + IDBalancePreSet { token: token.clone(), identity: id, balance: bal }.publish(e); + } + } + + fn required_hooks(e: &Env) -> Vec { + vec![ + e, + ComplianceHook::CanTransfer, + ComplianceHook::CanCreate, + ComplianceHook::Transferred, + ComplianceHook::Created, + ComplianceHook::Destroyed, + ] + } + + fn verify_hook_wiring(e: &Env) { + verify_required_hooks(e, Self::required_hooks(e)); + } + + fn on_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, amount); + + let irs = get_irs_client(e, &token); + let from_id = irs.stored_identity(&from); + let to_id = irs.stored_identity(&to); + + if from_id == to_id { + return; + } + + let from_balance = get_id_balance(e, &token, &from_id); + let to_balance = get_id_balance(e, &token, &to_id); + let new_to_balance = add_i128_or_panic(e, to_balance, amount); + + let max = get_max_balance(e, &token); + assert!( + max == 0 || new_to_balance <= max, + "MaxBalanceModule: recipient identity balance exceeds max" + ); + + set_id_balance(e, &token, &from_id, sub_i128_or_panic(e, from_balance, amount)); + set_id_balance(e, &token, &to_id, new_to_balance); + } + + fn on_created(e: &Env, to: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, amount); + + let irs = get_irs_client(e, &token); + let to_id = irs.stored_identity(&to); + + let current = get_id_balance(e, &token, &to_id); + let new_balance = add_i128_or_panic(e, current, amount); + + let max = get_max_balance(e, &token); + assert!( + max == 0 || new_balance <= max, + "MaxBalanceModule: recipient identity balance exceeds max after mint" + ); + + set_id_balance(e, &token, &to_id, new_balance); + } + + fn on_destroyed(e: &Env, from: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, amount); + + let irs = get_irs_client(e, &token); + let from_id = irs.stored_identity(&from); + + let current = get_id_balance(e, &token, &from_id); + set_id_balance(e, &token, &from_id, sub_i128_or_panic(e, current, amount)); + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-supply-limit/Cargo.toml b/examples/rwa-supply-limit/Cargo.toml new file mode 100644 index 000000000..b4d0e313c --- /dev/null +++ b/examples/rwa-supply-limit/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "rwa-supply-limit" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-tokens = { workspace = true } diff --git a/examples/rwa-supply-limit/README.md b/examples/rwa-supply-limit/README.md new file mode 100644 index 000000000..a589cfd70 --- /dev/null +++ b/examples/rwa-supply-limit/README.md @@ -0,0 +1,61 @@ +# Supply Limit Module + +Concrete deployable example of the `SupplyLimit` compliance module for Stellar +RWA tokens. + +## What it enforces + +This module caps the total amount of tokens that may be minted for a given +token contract. + +It keeps an internal supply counter and checks that each mint would stay within +the configured per-token limit. + +## How it stays in sync + +The module maintains internal supply state and therefore must be wired to all +of the hooks it depends on: + +- `CanCreate` +- `Created` +- `Destroyed` + +After those hooks are registered, `verify_hook_wiring()` must be called once so +the module marks itself as armed before mint validation starts. + +## Authorization model + +This example uses the bootstrap-admin pattern introduced in this port: + +- The constructor stores a one-time `admin` +- Before `set_compliance_address`, configuration calls require that admin's + auth +- After `set_compliance_address`, privileged calls require auth from the bound + Compliance contract +- `set_compliance_address` itself remains a one-time admin action + +This allows the module to be configured from the CLI before handing control to +Compliance. + +## Main entrypoints + +- `__constructor(admin)` initializes the bootstrap admin +- `set_supply_limit(token, limit)` sets the per-token cap +- `pre_set_internal_supply(token, supply)` seeds tracked supply when wiring the + module after historical minting +- `get_supply_limit(token)` reads the configured cap +- `get_internal_supply(token)` reads the tracked internal supply +- `required_hooks()` returns the required hook set +- `verify_hook_wiring()` marks the module as armed after registration +- `set_compliance_address(compliance)` performs the one-time handoff to the + Compliance contract + +## Notes + +- Storage is token-scoped, so one deployed module can be reused across many + tokens +- A configured limit of `0` behaves as "no cap" +- If the module is attached after a token already has minted supply, seed the + existing amount with `pre_set_internal_supply` before relying on `can_create` +- The internal supply is updated only through the registered `Created` and + `Destroyed` hooks diff --git a/examples/rwa-supply-limit/src/lib.rs b/examples/rwa-supply-limit/src/lib.rs new file mode 100644 index 000000000..2250b7026 --- /dev/null +++ b/examples/rwa-supply-limit/src/lib.rs @@ -0,0 +1,102 @@ +#![no_std] + +use soroban_sdk::{contract, contractimpl, contracttype, vec, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::{ + modules::{ + storage::{ + add_i128_or_panic, set_compliance_address, sub_i128_or_panic, verify_required_hooks, + ComplianceModuleStorageKey, + }, + supply_limit::{ + storage::{ + get_internal_supply, get_supply_limit, set_internal_supply, set_supply_limit, + }, + SupplyLimit, SupplyLimitSet, + }, + }, + ComplianceHook, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct SupplyLimitContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl SupplyLimitContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } +} + +#[contractimpl(contracttrait)] +impl SupplyLimit for SupplyLimitContract { + fn set_supply_limit(e: &Env, token: Address, limit: i128) { + require_module_admin_or_compliance_auth(e); + stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, limit); + set_supply_limit(e, &token, limit); + SupplyLimitSet { token, limit }.publish(e); + } + + fn pre_set_internal_supply(e: &Env, token: Address, supply: i128) { + require_module_admin_or_compliance_auth(e); + stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, supply); + set_internal_supply(e, &token, supply); + } + + fn get_supply_limit(e: &Env, token: Address) -> i128 { + get_supply_limit(e, &token) + } + + fn get_internal_supply(e: &Env, token: Address) -> i128 { + get_internal_supply(e, &token) + } + + fn required_hooks(e: &Env) -> Vec { + vec![e, ComplianceHook::CanCreate, ComplianceHook::Created, ComplianceHook::Destroyed] + } + + fn verify_hook_wiring(e: &Env) { + verify_required_hooks(e, Self::required_hooks(e)); + } + + fn on_created(e: &Env, _to: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, amount); + let current = get_internal_supply(e, &token); + set_internal_supply(e, &token, add_i128_or_panic(e, current, amount)); + } + + fn on_destroyed(e: &Env, _from: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, amount); + let current = get_internal_supply(e, &token); + set_internal_supply(e, &token, sub_i128_or_panic(e, current, amount)); + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-time-transfers-limits/Cargo.toml b/examples/rwa-time-transfers-limits/Cargo.toml new file mode 100644 index 000000000..6b71f752c --- /dev/null +++ b/examples/rwa-time-transfers-limits/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "rwa-time-transfers-limits" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-tokens = { workspace = true } diff --git a/examples/rwa-time-transfers-limits/README.md b/examples/rwa-time-transfers-limits/README.md new file mode 100644 index 000000000..6377ab122 --- /dev/null +++ b/examples/rwa-time-transfers-limits/README.md @@ -0,0 +1,71 @@ +# Time Transfers Limits Module + +Concrete deployable example of the `TimeTransfersLimits` compliance module for +Stellar RWA tokens. + +## What it enforces + +This module limits the amount an investor identity may transfer within one or +more configured time windows. + +Limits are tracked per identity, not per wallet, so the module must be +configured with an Identity Registry Storage (IRS) contract for each token it +serves. + +Each limit is defined by: + +- `limit_time`: the window size in seconds +- `limit_value`: the maximum transferable amount during that window + +This example allows up to four active limits per token. + +## How it stays in sync + +The module maintains transfer counters and therefore must be wired to all of +the hooks it depends on: + +- `CanTransfer` +- `Transferred` + +After those hooks are registered, `verify_hook_wiring()` must be called once so +the module marks itself as armed before transfer validation starts. + +## Authorization model + +This example uses the bootstrap-admin pattern introduced in this port: + +- The constructor stores a one-time `admin` +- Before `set_compliance_address`, configuration calls require that admin's + auth +- After `set_compliance_address`, privileged calls require auth from the bound + Compliance contract +- `set_compliance_address` itself remains a one-time admin action + +This allows the module to be configured from the CLI before handing control to +Compliance. + +## Main entrypoints + +- `__constructor(admin)` initializes the bootstrap admin +- `set_identity_registry_storage(token, irs)` stores the IRS address for a + token +- `set_time_transfer_limit(token, limit)` adds or replaces a limit window +- `batch_set_time_transfer_limit(token, limits)` updates multiple windows +- `remove_time_transfer_limit(token, limit_time)` removes a window +- `batch_remove_time_transfer_limit(token, limit_times)` removes many windows +- `pre_set_transfer_counter(token, identity, limit_time, counter)` seeds an + in-flight rolling window when attaching the module after recent transfers +- `required_hooks()` returns the required hook set +- `verify_hook_wiring()` marks the module as armed after registration +- `set_compliance_address(compliance)` performs the one-time handoff to the + Compliance contract + +## Notes + +- Storage is token-scoped, so one deployed module can be reused across many + tokens +- Counter resets are driven by ledger timestamps +- If the module is attached after transfers have already occurred inside an + active window, seed the relevant identity counters before relying on + `can_transfer` +- Only outgoing transfer volume is tracked; mint and burn hooks are not used diff --git a/examples/rwa-time-transfers-limits/src/lib.rs b/examples/rwa-time-transfers-limits/src/lib.rs new file mode 100644 index 000000000..88ed4ae88 --- /dev/null +++ b/examples/rwa-time-transfers-limits/src/lib.rs @@ -0,0 +1,202 @@ +#![no_std] + +use soroban_sdk::{ + contract, contractimpl, contracttype, panic_with_error, vec, Address, Env, String, Vec, +}; +use stellar_tokens::rwa::compliance::{ + modules::{ + storage::{ + add_i128_or_panic, get_irs_client, set_compliance_address, set_irs_address, + verify_required_hooks, ComplianceModuleStorageKey, + }, + time_transfers_limits::{ + storage::{get_counter, get_limits, set_counter, set_limits}, + Limit, TimeTransferLimitRemoved, TimeTransferLimitUpdated, TimeTransfersLimits, + TransferCounter, + }, + ComplianceModuleError, + }, + ComplianceHook, +}; + +const MAX_LIMITS_PER_TOKEN: u32 = 4; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct TimeTransfersLimitsContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +fn is_counter_finished(e: &Env, token: &Address, identity: &Address, limit_time: u64) -> bool { + let counter = get_counter(e, token, identity, limit_time); + counter.timer <= e.ledger().timestamp() +} + +fn reset_counter_if_needed(e: &Env, token: &Address, identity: &Address, limit_time: u64) { + if is_counter_finished(e, token, identity, limit_time) { + let counter = + TransferCounter { value: 0, timer: e.ledger().timestamp().saturating_add(limit_time) }; + set_counter(e, token, identity, limit_time, &counter); + } +} + +fn increase_counters(e: &Env, token: &Address, identity: &Address, value: i128) { + let limits = get_limits(e, token); + for limit in limits.iter() { + reset_counter_if_needed(e, token, identity, limit.limit_time); + let mut counter = get_counter(e, token, identity, limit.limit_time); + counter.value = add_i128_or_panic(e, counter.value, value); + set_counter(e, token, identity, limit.limit_time, &counter); + } +} + +#[contractimpl] +impl TimeTransfersLimitsContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } +} + +#[contractimpl(contracttrait)] +impl TimeTransfersLimits for TimeTransfersLimitsContract { + fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { + require_module_admin_or_compliance_auth(e); + set_irs_address(e, &token, &irs); + } + + fn set_time_transfer_limit(e: &Env, token: Address, limit: Limit) { + require_module_admin_or_compliance_auth(e); + assert!(limit.limit_time > 0, "limit_time must be greater than zero"); + stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount( + e, + limit.limit_value, + ); + let mut limits = get_limits(e, &token); + + let mut replaced = false; + for i in 0..limits.len() { + let current = limits.get(i).expect("limit exists"); + if current.limit_time == limit.limit_time { + limits.set(i, limit.clone()); + replaced = true; + break; + } + } + + if !replaced { + if limits.len() >= MAX_LIMITS_PER_TOKEN { + panic_with_error!(e, ComplianceModuleError::TooManyLimits); + } + limits.push_back(limit.clone()); + } + + set_limits(e, &token, &limits); + TimeTransferLimitUpdated { token, limit }.publish(e); + } + + fn batch_set_time_transfer_limit(e: &Env, token: Address, limits: Vec) { + require_module_admin_or_compliance_auth(e); + for limit in limits.iter() { + Self::set_time_transfer_limit(e, token.clone(), limit); + } + } + + fn remove_time_transfer_limit(e: &Env, token: Address, limit_time: u64) { + require_module_admin_or_compliance_auth(e); + let mut limits = get_limits(e, &token); + + let mut found = false; + for i in 0..limits.len() { + let current = limits.get(i).expect("limit exists"); + if current.limit_time == limit_time { + limits.remove(i); + found = true; + break; + } + } + + if !found { + panic_with_error!(e, ComplianceModuleError::MissingLimit); + } + + set_limits(e, &token, &limits); + TimeTransferLimitRemoved { token, limit_time }.publish(e); + } + + fn batch_remove_time_transfer_limit(e: &Env, token: Address, limit_times: Vec) { + require_module_admin_or_compliance_auth(e); + for lt in limit_times.iter() { + Self::remove_time_transfer_limit(e, token.clone(), lt); + } + } + + fn pre_set_transfer_counter( + e: &Env, + token: Address, + identity: Address, + limit_time: u64, + counter: TransferCounter, + ) { + require_module_admin_or_compliance_auth(e); + stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount( + e, + counter.value, + ); + assert!(limit_time > 0, "limit_time must be greater than zero"); + + let mut found = false; + for limit in get_limits(e, &token).iter() { + if limit.limit_time == limit_time { + found = true; + break; + } + } + + if !found { + panic_with_error!(e, ComplianceModuleError::MissingLimit); + } + + set_counter(e, &token, &identity, limit_time, &counter); + } + + fn required_hooks(e: &Env) -> Vec { + vec![e, ComplianceHook::CanTransfer, ComplianceHook::Transferred] + } + + fn verify_hook_wiring(e: &Env) { + verify_required_hooks(e, Self::required_hooks(e)); + } + + fn on_transfer(e: &Env, from: Address, _to: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, amount); + let irs = get_irs_client(e, &token); + let from_id = irs.stored_identity(&from); + increase_counters(e, &token, &from_id, amount); + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-transfer-restrict/Cargo.toml b/examples/rwa-transfer-restrict/Cargo.toml new file mode 100644 index 000000000..9655c300d --- /dev/null +++ b/examples/rwa-transfer-restrict/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "rwa-transfer-restrict" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-tokens = { workspace = true } diff --git a/examples/rwa-transfer-restrict/README.md b/examples/rwa-transfer-restrict/README.md new file mode 100644 index 000000000..a8283c44b --- /dev/null +++ b/examples/rwa-transfer-restrict/README.md @@ -0,0 +1,47 @@ +# Transfer Restrict Module + +Concrete deployable example of the `TransferRestrict` compliance module for +Stellar RWA tokens. + +## What it enforces + +This module maintains a per-token address allowlist for transfers. + +It follows the T-REX semantics implemented by the library trait: + +- if the sender is allowlisted, the transfer passes +- otherwise, the recipient must be allowlisted + +The module is token-scoped, so one deployment can serve many tokens. + +## Authorization model + +This example uses the bootstrap-admin pattern introduced in this port: + +- The constructor stores a one-time `admin` +- Before `set_compliance_address`, allowlist management requires that admin's + auth +- After `set_compliance_address`, the same configuration calls require auth + from the bound Compliance contract +- `set_compliance_address` itself remains a one-time admin action + +This lets the module be configured from the CLI before it is locked to the +Compliance contract. + +## Main entrypoints + +- `__constructor(admin)` initializes the bootstrap admin +- `allow_user(token, user)` adds an address to the transfer allowlist +- `disallow_user(token, user)` removes an address from the transfer allowlist +- `batch_allow_users(token, users)` updates multiple entries +- `batch_disallow_users(token, users)` removes multiple entries +- `is_user_allowed(token, user)` reads the current allowlist state +- `set_compliance_address(compliance)` performs the one-time handoff to the + Compliance contract + +## Notes + +- This module validates transfers through the `CanTransfer` hook +- It does not depend on IRS or other identity infrastructure +- In the deploy example, the admin address is pre-allowlisted before binding so + the happy-path transfer checks can succeed diff --git a/examples/rwa-transfer-restrict/src/lib.rs b/examples/rwa-transfer-restrict/src/lib.rs new file mode 100644 index 000000000..49084164a --- /dev/null +++ b/examples/rwa-transfer-restrict/src/lib.rs @@ -0,0 +1,83 @@ +#![no_std] + +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::modules::{ + storage::{set_compliance_address, ComplianceModuleStorageKey}, + transfer_restrict::{ + storage::{is_user_allowed, remove_user_allowed, set_user_allowed}, + TransferRestrict, UserAllowed, UserDisallowed, + }, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct TransferRestrictContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl TransferRestrictContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } +} + +#[contractimpl(contracttrait)] +impl TransferRestrict for TransferRestrictContract { + fn allow_user(e: &Env, token: Address, user: Address) { + require_module_admin_or_compliance_auth(e); + set_user_allowed(e, &token, &user); + UserAllowed { token, user }.publish(e); + } + + fn disallow_user(e: &Env, token: Address, user: Address) { + require_module_admin_or_compliance_auth(e); + remove_user_allowed(e, &token, &user); + UserDisallowed { token, user }.publish(e); + } + + fn batch_allow_users(e: &Env, token: Address, users: Vec
) { + require_module_admin_or_compliance_auth(e); + for user in users.iter() { + set_user_allowed(e, &token, &user); + UserAllowed { token: token.clone(), user }.publish(e); + } + } + + fn batch_disallow_users(e: &Env, token: Address, users: Vec
) { + require_module_admin_or_compliance_auth(e); + for user in users.iter() { + remove_user_allowed(e, &token, &user); + UserDisallowed { token: token.clone(), user }.publish(e); + } + } + + fn is_user_allowed(e: &Env, token: Address, user: Address) -> bool { + is_user_allowed(e, &token, &user) + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/packages/tokens/src/rwa/compliance/modules/country_allow/mod.rs b/packages/tokens/src/rwa/compliance/modules/country_allow/mod.rs new file mode 100644 index 000000000..fe6cdda69 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/country_allow/mod.rs @@ -0,0 +1,455 @@ +//! Country allowlist compliance module — Stellar port of T-REX +//! [`CountryAllowModule.sol`][trex-src]. +//! +//! Only recipients whose identity has at least one country code in the +//! allowlist may receive tokens. +//! +//! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/CountryAllowModule.sol + +pub mod storage; + +use soroban_sdk::{contractevent, contracttrait, Address, Env, String, Vec}; +use storage::{is_country_allowed, remove_country_allowed, set_country_allowed}; + +use super::storage::{ + country_code, get_compliance_address, get_irs_country_data_entries, module_name, + set_irs_address, +}; + +/// Emitted when a country is added to the allowlist. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CountryAllowed { + #[topic] + pub token: Address, + pub country: u32, +} + +/// Emitted when a country is removed from the allowlist. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CountryUnallowed { + #[topic] + pub token: Address, + pub country: u32, +} + +/// Country allowlist compliance trait. +/// +/// Provides default implementations for maintaining a per-token country +/// allowlist and validating transfers/mints against it via the Identity +/// Registry Storage. +#[contracttrait] +pub trait CountryAllow { + /// Sets the Identity Registry Storage contract address for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token this IRS applies to. + /// * `irs` - The IRS contract address. + /// + /// # Authorization + /// + /// Requires compliance contract authorization. + fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { + get_compliance_address(e).require_auth(); + set_irs_address(e, &token, &irs); + } + + /// Adds a country to the allowlist for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `country` - The ISO 3166-1 numeric country code to allow. + /// + /// # Authorization + /// + /// Requires compliance contract authorization. + /// + /// # Events + /// + /// Emits [`CountryAllowed`]. + fn add_allowed_country(e: &Env, token: Address, country: u32) { + get_compliance_address(e).require_auth(); + set_country_allowed(e, &token, country); + CountryAllowed { token, country }.publish(e); + } + + /// Removes a country from the allowlist for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `country` - The ISO 3166-1 numeric country code to remove. + /// + /// # Authorization + /// + /// Requires compliance contract authorization. + /// + /// # Events + /// + /// Emits [`CountryUnallowed`]. + fn remove_allowed_country(e: &Env, token: Address, country: u32) { + get_compliance_address(e).require_auth(); + remove_country_allowed(e, &token, country); + CountryUnallowed { token, country }.publish(e); + } + + /// Adds multiple countries to the allowlist in a single call. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `countries` - The country codes to allow. + /// + /// # Authorization + /// + /// Requires compliance contract authorization. + /// + /// # Events + /// + /// Emits [`CountryAllowed`] for each country added. + fn batch_allow_countries(e: &Env, token: Address, countries: Vec) { + get_compliance_address(e).require_auth(); + for country in countries.iter() { + set_country_allowed(e, &token, country); + CountryAllowed { token: token.clone(), country }.publish(e); + } + } + + /// Removes multiple countries from the allowlist in a single call. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `countries` - The country codes to remove. + /// + /// # Authorization + /// + /// Requires compliance contract authorization. + /// + /// # Events + /// + /// Emits [`CountryUnallowed`] for each country removed. + fn batch_disallow_countries(e: &Env, token: Address, countries: Vec) { + get_compliance_address(e).require_auth(); + for country in countries.iter() { + remove_country_allowed(e, &token, country); + CountryUnallowed { token: token.clone(), country }.publish(e); + } + } + + /// Returns whether `country` is on the allowlist for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `country` - The ISO 3166-1 numeric country code. + fn is_country_allowed(e: &Env, token: Address, country: u32) -> bool { + is_country_allowed(e, &token, country) + } + + /// No-op — this module does not track transfer state. + fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {} + + /// No-op — this module does not track mint state. + fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {} + + /// No-op — this module does not track burn state. + fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {} + + /// Checks whether `to` has at least one allowed country in the IRS. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `_from` - The sender (unused). + /// * `to` - The recipient whose country data is checked. + /// * `_amount` - The transfer amount (unused). + /// * `token` - The token address. + /// + /// # Returns + /// + /// `true` if the recipient has at least one allowed country, `false` + /// otherwise. + /// + /// # Cross-Contract Calls + /// + /// Calls the IRS to resolve country data for `to`. + fn can_transfer(e: &Env, _from: Address, to: Address, _amount: i128, token: Address) -> bool { + let entries = get_irs_country_data_entries(e, &token, &to); + for entry in entries.iter() { + if is_country_allowed(e, &token, country_code(&entry.country)) { + return true; + } + } + false + } + + /// Delegates to [`can_transfer`](CountryAllow::can_transfer) — same + /// country check applies to mints. + fn can_create(e: &Env, to: Address, amount: i128, token: Address) -> bool { + Self::can_transfer(e, to.clone(), to, amount, token) + } + + /// Returns the module name for identification. + fn name(e: &Env) -> String { + module_name(e, "CountryAllowModule") + } + + /// Returns the compliance contract address. + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + /// Sets the compliance contract address (one-time only). + /// + /// Implementers must gate this entrypoint with bootstrap-admin auth before + /// delegating to + /// [`storage::set_compliance_address`](super::storage::set_compliance_address). + /// + /// + /// # Panics + /// + /// Panics if the compliance address has already been set. + fn set_compliance_address(e: &Env, compliance: Address); +} + +#[cfg(test)] +mod test { + extern crate std; + + use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal, + Val, Vec, + }; + + use super::*; + use crate::rwa::{ + identity_registry_storage::{ + CountryData, CountryDataManager, CountryRelation, IdentityRegistryStorage, + IndividualCountryRelation, OrganizationCountryRelation, + }, + utils::token_binder::TokenBinder, + }; + + #[contract] + struct MockIRSContract; + + #[contracttype] + #[derive(Clone)] + enum MockIRSStorageKey { + Identity(Address), + CountryEntries(Address), + } + + #[contractimpl] + impl TokenBinder for MockIRSContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } + } + + #[contractimpl] + impl IdentityRegistryStorage for MockIRSContract { + fn add_identity( + _e: &Env, + _account: Address, + _identity: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_identity is not used in these tests"); + } + + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { + unreachable!("remove_identity is not used in these tests"); + } + + fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { + unreachable!("modify_identity is not used in these tests"); + } + + fn recover_identity( + _e: &Env, + _old_account: Address, + _new_account: Address, + _operator: Address, + ) { + unreachable!("recover_identity is not used in these tests"); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + e.storage() + .persistent() + .get(&MockIRSStorageKey::Identity(account.clone())) + .unwrap_or(account) + } + } + + #[contractimpl] + impl CountryDataManager for MockIRSContract { + fn add_country_data_entries( + _e: &Env, + _account: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_country_data_entries is not used in these tests"); + } + + fn modify_country_data( + _e: &Env, + _account: Address, + _index: u32, + _country_data: Val, + _operator: Address, + ) { + unreachable!("modify_country_data is not used in these tests"); + } + + fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { + unreachable!("delete_country_data is not used in these tests"); + } + + fn get_country_data_entries(e: &Env, account: Address) -> Vec { + let entries: Vec = e + .storage() + .persistent() + .get(&MockIRSStorageKey::CountryEntries(account)) + .unwrap_or_else(|| Vec::new(e)); + + Vec::from_iter(e, entries.iter().map(|entry| entry.into_val(e))) + } + } + + #[contractimpl] + impl MockIRSContract { + pub fn set_country_data_entries(e: &Env, account: Address, entries: Vec) { + e.storage().persistent().set(&MockIRSStorageKey::CountryEntries(account), &entries); + } + } + + #[contract] + struct TestCountryAllowContract; + + #[contractimpl(contracttrait)] + impl CountryAllow for TestCountryAllowContract { + fn set_compliance_address(_e: &Env, _compliance: Address) { + unreachable!("set_compliance_address is not used in these tests"); + } + } + + fn individual_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Individual(IndividualCountryRelation::Residence(code)), + metadata: None, + } + } + + fn organization_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Organization( + OrganizationCountryRelation::OperatingJurisdiction(code), + ), + metadata: None, + } + } + + #[test] + fn can_transfer_and_create_allow_when_any_country_matches() { + let e = Env::default(); + let module_id = e.register(TestCountryAllowContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let from = Address::generate(&e); + let to = Address::generate(&e); + + irs.set_country_data_entries( + &to, + &vec![&e, individual_country(250), organization_country(276)], + ); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_country_allowed(&e, &token, 276); + + assert!(::can_transfer( + &e, + from.clone(), + to.clone(), + 100, + token.clone(), + )); + assert!(::can_create( + &e, + to.clone(), + 100, + token.clone(), + )); + }); + } + + #[test] + fn can_transfer_and_create_reject_when_no_country_matches() { + let e = Env::default(); + let module_id = e.register(TestCountryAllowContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let from = Address::generate(&e); + let empty_to = Address::generate(&e); + let disallowed_to = Address::generate(&e); + + irs.set_country_data_entries(&disallowed_to, &vec![&e, individual_country(250)]); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_country_allowed(&e, &token, 276); + + assert!(!::can_transfer( + &e, + from.clone(), + empty_to.clone(), + 100, + token.clone(), + )); + assert!(!::can_create( + &e, + empty_to, + 100, + token.clone(), + )); + + assert!(!::can_transfer( + &e, + from.clone(), + disallowed_to.clone(), + 100, + token.clone(), + )); + assert!(!::can_create( + &e, + disallowed_to, + 100, + token.clone(), + )); + }); + } +} diff --git a/packages/tokens/src/rwa/compliance/modules/country_allow/storage.rs b/packages/tokens/src/rwa/compliance/modules/country_allow/storage.rs new file mode 100644 index 000000000..767ca0a14 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/country_allow/storage.rs @@ -0,0 +1,54 @@ +use soroban_sdk::{contracttype, Address, Env}; + +use crate::rwa::compliance::modules::{MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD}; + +#[contracttype] +#[derive(Clone)] +pub enum CountryAllowStorageKey { + /// Per-(token, country) allowlist flag. + AllowedCountry(Address, u32), +} + +/// Returns whether the given country is on the allowlist for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code. +pub fn is_country_allowed(e: &Env, token: &Address, country: u32) -> bool { + let key = CountryAllowStorageKey::AllowedCountry(token.clone(), country); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &bool| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or_default() +} + +/// Adds a country to the allowlist for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code to allow. +pub fn set_country_allowed(e: &Env, token: &Address, country: u32) { + let key = CountryAllowStorageKey::AllowedCountry(token.clone(), country); + e.storage().persistent().set(&key, &true); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} + +/// Removes a country from the allowlist for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code to remove. +pub fn remove_country_allowed(e: &Env, token: &Address, country: u32) { + e.storage() + .persistent() + .remove(&CountryAllowStorageKey::AllowedCountry(token.clone(), country)); +} diff --git a/packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs b/packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs new file mode 100644 index 000000000..09f87a301 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs @@ -0,0 +1,459 @@ +//! Country restriction compliance module — Stellar port of T-REX +//! [`CountryRestrictModule.sol`][trex-src]. +//! +//! Recipients whose identity has a country code on the restriction list are +//! blocked from receiving tokens. +//! +//! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/CountryRestrictModule.sol + +pub mod storage; + +use soroban_sdk::{contractevent, contracttrait, Address, Env, String, Vec}; +use storage::{is_country_restricted, remove_country_restricted, set_country_restricted}; + +use super::storage::{ + country_code, get_compliance_address, get_irs_country_data_entries, module_name, + set_irs_address, +}; + +/// Emitted when a country is added to the restriction list. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CountryRestricted { + #[topic] + pub token: Address, + pub country: u32, +} + +/// Emitted when a country is removed from the restriction list. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CountryUnrestricted { + #[topic] + pub token: Address, + pub country: u32, +} + +/// Country restriction compliance trait. +/// +/// Provides default implementations for maintaining a per-token country +/// restriction list and blocking transfers/mints to recipients from +/// restricted countries via the Identity Registry Storage. +#[contracttrait] +pub trait CountryRestrict { + /// Sets the Identity Registry Storage contract address for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token this IRS applies to. + /// * `irs` - The IRS contract address. + /// + /// # Authorization + /// + /// Requires compliance contract authorization. + fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { + get_compliance_address(e).require_auth(); + set_irs_address(e, &token, &irs); + } + + /// Adds a country to the restriction list for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `country` - The ISO 3166-1 numeric country code to restrict. + /// + /// # Authorization + /// + /// Requires compliance contract authorization. + /// + /// # Events + /// + /// Emits [`CountryRestricted`]. + fn add_country_restriction(e: &Env, token: Address, country: u32) { + get_compliance_address(e).require_auth(); + set_country_restricted(e, &token, country); + CountryRestricted { token, country }.publish(e); + } + + /// Removes a country from the restriction list for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `country` - The ISO 3166-1 numeric country code to unrestrict. + /// + /// # Authorization + /// + /// Requires compliance contract authorization. + /// + /// # Events + /// + /// Emits [`CountryUnrestricted`]. + fn remove_country_restriction(e: &Env, token: Address, country: u32) { + get_compliance_address(e).require_auth(); + remove_country_restricted(e, &token, country); + CountryUnrestricted { token, country }.publish(e); + } + + /// Adds multiple countries to the restriction list in a single call. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `countries` - The country codes to restrict. + /// + /// # Authorization + /// + /// Requires compliance contract authorization. + /// + /// # Events + /// + /// Emits [`CountryRestricted`] for each country added. + fn batch_restrict_countries(e: &Env, token: Address, countries: Vec) { + get_compliance_address(e).require_auth(); + for country in countries.iter() { + set_country_restricted(e, &token, country); + CountryRestricted { token: token.clone(), country }.publish(e); + } + } + + /// Removes multiple countries from the restriction list in a single + /// call. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `countries` - The country codes to unrestrict. + /// + /// # Authorization + /// + /// Requires compliance contract authorization. + /// + /// # Events + /// + /// Emits [`CountryUnrestricted`] for each country removed. + fn batch_unrestrict_countries(e: &Env, token: Address, countries: Vec) { + get_compliance_address(e).require_auth(); + for country in countries.iter() { + remove_country_restricted(e, &token, country); + CountryUnrestricted { token: token.clone(), country }.publish(e); + } + } + + /// Returns whether `country` is on the restriction list for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `country` - The ISO 3166-1 numeric country code. + fn is_country_restricted(e: &Env, token: Address, country: u32) -> bool { + is_country_restricted(e, &token, country) + } + + /// No-op — this module does not track transfer state. + fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {} + + /// No-op — this module does not track mint state. + fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {} + + /// No-op — this module does not track burn state. + fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {} + + /// Checks whether `to` has any restricted country in the IRS. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `_from` - The sender (unused). + /// * `to` - The recipient whose country data is checked. + /// * `_amount` - The transfer amount (unused). + /// * `token` - The token address. + /// + /// # Returns + /// + /// `false` if the recipient has any restricted country, `true` + /// otherwise. + /// + /// # Cross-Contract Calls + /// + /// Calls the IRS to resolve country data for `to`. + fn can_transfer(e: &Env, _from: Address, to: Address, _amount: i128, token: Address) -> bool { + let entries = get_irs_country_data_entries(e, &token, &to); + for entry in entries.iter() { + if is_country_restricted(e, &token, country_code(&entry.country)) { + return false; + } + } + true + } + + /// Delegates to [`can_transfer`](CountryRestrict::can_transfer) — same + /// country check applies to mints. + fn can_create(e: &Env, to: Address, amount: i128, token: Address) -> bool { + Self::can_transfer(e, to.clone(), to, amount, token) + } + + /// Returns the module name for identification. + fn name(e: &Env) -> String { + module_name(e, "CountryRestrictModule") + } + + /// Returns the compliance contract address. + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + /// Sets the compliance contract address (one-time only). + /// + /// Implementers must gate this entrypoint with bootstrap-admin auth before + /// delegating to + /// [`storage::set_compliance_address`](super::storage::set_compliance_address). + /// + /// + /// # Panics + /// + /// Panics if the compliance address has already been set. + fn set_compliance_address(e: &Env, compliance: Address); +} + +#[cfg(test)] +mod test { + extern crate std; + + use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal, + Val, Vec, + }; + + use super::*; + use crate::rwa::{ + identity_registry_storage::{ + CountryData, CountryDataManager, CountryRelation, IdentityRegistryStorage, + IndividualCountryRelation, OrganizationCountryRelation, + }, + utils::token_binder::TokenBinder, + }; + + #[contract] + struct MockIRSContract; + + #[contracttype] + #[derive(Clone)] + enum MockIRSStorageKey { + Identity(Address), + CountryEntries(Address), + } + + #[contractimpl] + impl TokenBinder for MockIRSContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } + } + + #[contractimpl] + impl IdentityRegistryStorage for MockIRSContract { + fn add_identity( + _e: &Env, + _account: Address, + _identity: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_identity is not used in these tests"); + } + + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { + unreachable!("remove_identity is not used in these tests"); + } + + fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { + unreachable!("modify_identity is not used in these tests"); + } + + fn recover_identity( + _e: &Env, + _old_account: Address, + _new_account: Address, + _operator: Address, + ) { + unreachable!("recover_identity is not used in these tests"); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + e.storage() + .persistent() + .get(&MockIRSStorageKey::Identity(account.clone())) + .unwrap_or(account) + } + } + + #[contractimpl] + impl CountryDataManager for MockIRSContract { + fn add_country_data_entries( + _e: &Env, + _account: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_country_data_entries is not used in these tests"); + } + + fn modify_country_data( + _e: &Env, + _account: Address, + _index: u32, + _country_data: Val, + _operator: Address, + ) { + unreachable!("modify_country_data is not used in these tests"); + } + + fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { + unreachable!("delete_country_data is not used in these tests"); + } + + fn get_country_data_entries(e: &Env, account: Address) -> Vec { + let entries: Vec = e + .storage() + .persistent() + .get(&MockIRSStorageKey::CountryEntries(account)) + .unwrap_or_else(|| Vec::new(e)); + + Vec::from_iter(e, entries.iter().map(|entry| entry.into_val(e))) + } + } + + #[contractimpl] + impl MockIRSContract { + pub fn set_country_data_entries(e: &Env, account: Address, entries: Vec) { + e.storage().persistent().set(&MockIRSStorageKey::CountryEntries(account), &entries); + } + } + + #[contract] + struct TestCountryRestrictContract; + + #[contractimpl(contracttrait)] + impl CountryRestrict for TestCountryRestrictContract { + fn set_compliance_address(_e: &Env, _compliance: Address) { + unreachable!("set_compliance_address is not used in these tests"); + } + } + + fn individual_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Individual(IndividualCountryRelation::Residence(code)), + metadata: None, + } + } + + fn organization_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Organization( + OrganizationCountryRelation::OperatingJurisdiction(code), + ), + metadata: None, + } + } + + #[test] + fn can_transfer_and_create_reject_when_any_country_is_restricted() { + let e = Env::default(); + let module_id = e.register(TestCountryRestrictContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let from = Address::generate(&e); + let to = Address::generate(&e); + + irs.set_country_data_entries( + &to, + &vec![&e, individual_country(250), organization_country(408)], + ); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_country_restricted(&e, &token, 408); + + assert!(!::can_transfer( + &e, + from.clone(), + to.clone(), + 100, + token.clone(), + )); + assert!(!::can_create( + &e, + to.clone(), + 100, + token.clone(), + )); + }); + } + + #[test] + fn can_transfer_and_create_allow_when_no_country_is_restricted() { + let e = Env::default(); + let module_id = e.register(TestCountryRestrictContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let from = Address::generate(&e); + let empty_to = Address::generate(&e); + let unrestricted_to = Address::generate(&e); + + irs.set_country_data_entries( + &unrestricted_to, + &vec![&e, individual_country(250), organization_country(276)], + ); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_country_restricted(&e, &token, 408); + + assert!(::can_transfer( + &e, + from.clone(), + empty_to.clone(), + 100, + token.clone(), + )); + assert!(::can_create( + &e, + empty_to, + 100, + token.clone(), + )); + + assert!(::can_transfer( + &e, + from.clone(), + unrestricted_to.clone(), + 100, + token.clone(), + )); + assert!(::can_create( + &e, + unrestricted_to, + 100, + token.clone(), + )); + }); + } +} diff --git a/packages/tokens/src/rwa/compliance/modules/country_restrict/storage.rs b/packages/tokens/src/rwa/compliance/modules/country_restrict/storage.rs new file mode 100644 index 000000000..5d8f13cb2 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/country_restrict/storage.rs @@ -0,0 +1,54 @@ +use soroban_sdk::{contracttype, Address, Env}; + +use crate::rwa::compliance::modules::{MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD}; + +#[contracttype] +#[derive(Clone)] +pub enum CountryRestrictStorageKey { + /// Per-(token, country) restriction flag. + RestrictedCountry(Address, u32), +} + +/// Returns whether the given country is on the restriction list for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code. +pub fn is_country_restricted(e: &Env, token: &Address, country: u32) -> bool { + let key = CountryRestrictStorageKey::RestrictedCountry(token.clone(), country); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &bool| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or_default() +} + +/// Adds a country to the restriction list for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code to restrict. +pub fn set_country_restricted(e: &Env, token: &Address, country: u32) { + let key = CountryRestrictStorageKey::RestrictedCountry(token.clone(), country); + e.storage().persistent().set(&key, &true); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} + +/// Removes a country from the restriction list for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code to unrestrict. +pub fn remove_country_restricted(e: &Env, token: &Address, country: u32) { + e.storage() + .persistent() + .remove(&CountryRestrictStorageKey::RestrictedCountry(token.clone(), country)); +} diff --git a/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/mod.rs b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/mod.rs new file mode 100644 index 000000000..7261fc452 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/mod.rs @@ -0,0 +1,274 @@ +//! Initial lockup period compliance module — Stellar port of T-REX +//! [`TimeExchangeLimitsModule.sol`][trex-src]. +//! +//! Enforces a lockup period for all investors whenever they receive tokens +//! through primary emissions (mints). Tokens received via peer-to-peer +//! transfers are **not** subject to lockup restrictions. +//! +//! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/TimeExchangeLimitsModule.sol + +pub mod storage; +#[cfg(test)] +mod test; + +use soroban_sdk::{contractevent, contracttrait, vec, Address, Env, String, Vec}; +pub use storage::LockedTokens; +use storage::{ + get_internal_balance, get_locks, get_lockup_period, get_total_locked, set_internal_balance, + set_locks, set_lockup_period, set_total_locked, +}; + +use super::storage::{ + add_i128_or_panic, get_compliance_address, hooks_verified, module_name, + require_non_negative_amount, sub_i128_or_panic, verify_required_hooks, +}; +use crate::rwa::compliance::ComplianceHook; + +/// Emitted when a token's lockup duration is configured or changed. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LockupPeriodSet { + #[topic] + pub token: Address, + pub lockup_seconds: u64, +} + +// ################## HELPERS ################## + +fn calculate_unlocked_amount(e: &Env, locks: &Vec) -> i128 { + let now = e.ledger().timestamp(); + let mut unlocked = 0i128; + for i in 0..locks.len() { + let lock = locks.get(i).unwrap(); + if lock.release_timestamp <= now { + unlocked = add_i128_or_panic(e, unlocked, lock.amount); + } + } + unlocked +} + +fn calculate_total_locked_amount(e: &Env, locks: &Vec) -> i128 { + let mut total = 0i128; + for i in 0..locks.len() { + let lock = locks.get(i).unwrap(); + require_non_negative_amount(e, lock.amount); + total = add_i128_or_panic(e, total, lock.amount); + } + total +} + +fn update_locked_tokens(e: &Env, token: &Address, wallet: &Address, mut amount_to_consume: i128) { + let locks = get_locks(e, token, wallet); + let now = e.ledger().timestamp(); + let mut new_locks = Vec::new(e); + let mut consumed_total = 0i128; + + for i in 0..locks.len() { + let lock = locks.get(i).unwrap(); + if amount_to_consume > 0 && lock.release_timestamp <= now { + if amount_to_consume >= lock.amount { + amount_to_consume = sub_i128_or_panic(e, amount_to_consume, lock.amount); + consumed_total = add_i128_or_panic(e, consumed_total, lock.amount); + } else { + consumed_total = add_i128_or_panic(e, consumed_total, amount_to_consume); + new_locks.push_back(LockedTokens { + amount: sub_i128_or_panic(e, lock.amount, amount_to_consume), + release_timestamp: lock.release_timestamp, + }); + amount_to_consume = 0; + } + } else { + new_locks.push_back(lock); + } + } + + set_locks(e, token, wallet, &new_locks); + + let total_locked = get_total_locked(e, token, wallet); + set_total_locked(e, token, wallet, sub_i128_or_panic(e, total_locked, consumed_total)); +} + +#[contracttrait] +pub trait InitialLockupPeriod { + // ################## QUERY STATE ################## + + fn get_lockup_period(e: &Env, token: Address) -> u64 { + get_lockup_period(e, &token) + } + + fn get_total_locked(e: &Env, token: Address, wallet: Address) -> i128 { + get_total_locked(e, &token, &wallet) + } + + fn get_locked_tokens(e: &Env, token: Address, wallet: Address) -> Vec { + get_locks(e, &token, &wallet) + } + + fn get_internal_balance(e: &Env, token: Address, wallet: Address) -> i128 { + get_internal_balance(e, &token, &wallet) + } + + fn can_transfer(e: &Env, from: Address, _to: Address, amount: i128, token: Address) -> bool { + assert!( + hooks_verified(e), + "InitialLockupPeriodModule: not armed — call verify_hook_wiring() after wiring hooks \ + [CanTransfer, Created, Transferred, Destroyed]" + ); + if amount < 0 { + return false; + } + + let total_locked = get_total_locked(e, &token, &from); + if total_locked == 0 { + return true; + } + + let balance = get_internal_balance(e, &token, &from); + let free = balance - total_locked; + + if free >= amount { + return true; + } + + let locks = get_locks(e, &token, &from); + let unlocked = calculate_unlocked_amount(e, &locks); + (free + unlocked) >= amount + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + true + } + + fn name(e: &Env) -> String { + module_name(e, "InitialLockupPeriodModule") + } + + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + // ################## CHANGE STATE ################## + + fn set_lockup_period(e: &Env, token: Address, lockup_seconds: u64) { + get_compliance_address(e).require_auth(); + set_lockup_period(e, &token, lockup_seconds); + LockupPeriodSet { token, lockup_seconds }.publish(e); + } + + fn pre_set_lockup_state( + e: &Env, + token: Address, + wallet: Address, + balance: i128, + locks: Vec, + ) { + get_compliance_address(e).require_auth(); + require_non_negative_amount(e, balance); + + let total_locked = calculate_total_locked_amount(e, &locks); + assert!( + total_locked <= balance, + "InitialLockupPeriodModule: total locked amount cannot exceed balance" + ); + + set_internal_balance(e, &token, &wallet, balance); + set_locks(e, &token, &wallet, &locks); + set_total_locked(e, &token, &wallet, total_locked); + } + + fn verify_hook_wiring(e: &Env) { + verify_required_hooks(e, Self::required_hooks(e)); + } + + fn on_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) { + get_compliance_address(e).require_auth(); + require_non_negative_amount(e, amount); + + let total_locked = get_total_locked(e, &token, &from); + + if total_locked > 0 { + let pre_balance = get_internal_balance(e, &token, &from); + let pre_free = pre_balance - total_locked; + + if amount > pre_free.max(0) { + let to_consume = amount - pre_free.max(0); + update_locked_tokens(e, &token, &from, to_consume); + } + } + + let from_bal = get_internal_balance(e, &token, &from); + set_internal_balance(e, &token, &from, sub_i128_or_panic(e, from_bal, amount)); + + let to_bal = get_internal_balance(e, &token, &to); + set_internal_balance(e, &token, &to, add_i128_or_panic(e, to_bal, amount)); + } + + fn on_created(e: &Env, to: Address, amount: i128, token: Address) { + get_compliance_address(e).require_auth(); + require_non_negative_amount(e, amount); + + let period = get_lockup_period(e, &token); + if period > 0 { + let mut locks = get_locks(e, &token, &to); + locks.push_back(LockedTokens { + amount, + release_timestamp: e.ledger().timestamp().saturating_add(period), + }); + set_locks(e, &token, &to, &locks); + + let total = get_total_locked(e, &token, &to); + set_total_locked(e, &token, &to, add_i128_or_panic(e, total, amount)); + } + + let current = get_internal_balance(e, &token, &to); + set_internal_balance(e, &token, &to, add_i128_or_panic(e, current, amount)); + } + + fn on_destroyed(e: &Env, from: Address, amount: i128, token: Address) { + get_compliance_address(e).require_auth(); + require_non_negative_amount(e, amount); + + let total_locked = get_total_locked(e, &token, &from); + + if total_locked > 0 { + let pre_balance = get_internal_balance(e, &token, &from); + let mut free_amount = pre_balance - total_locked; + + if free_amount < amount { + let locks = get_locks(e, &token, &from); + free_amount += calculate_unlocked_amount(e, &locks); + } + + assert!( + free_amount >= amount, + "InitialLockupPeriodModule: insufficient unlocked balance for burn" + ); + + let pre_free = pre_balance - total_locked; + if amount > pre_free.max(0) { + let to_consume = amount - pre_free.max(0); + update_locked_tokens(e, &token, &from, to_consume); + } + } + + let current = get_internal_balance(e, &token, &from); + set_internal_balance(e, &token, &from, sub_i128_or_panic(e, current, amount)); + } + + /// Implementers must gate this entrypoint with bootstrap-admin auth before + /// delegating to + /// [`storage::set_compliance_address`](super::storage::set_compliance_address). + fn set_compliance_address(e: &Env, compliance: Address); + + // ################## HELPERS ################## + + fn required_hooks(e: &Env) -> Vec { + vec![ + e, + ComplianceHook::CanTransfer, + ComplianceHook::Created, + ComplianceHook::Transferred, + ComplianceHook::Destroyed, + ] + } +} diff --git a/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/storage.rs b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/storage.rs new file mode 100644 index 000000000..2c9788c8d --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/storage.rs @@ -0,0 +1,152 @@ +use soroban_sdk::{contracttype, Address, Env, Vec}; + +use crate::rwa::compliance::modules::{MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD}; + +/// A single mint-created lock entry tracking the locked amount and its +/// release time. Mirrors T-REX `LockedTokens { amount, releaseTimestamp }`. +#[contracttype] +#[derive(Clone)] +pub struct LockedTokens { + pub amount: i128, + pub release_timestamp: u64, +} + +#[contracttype] +#[derive(Clone)] +pub enum InitialLockupStorageKey { + /// Per-token lockup duration in seconds. + LockupPeriod(Address), + /// Per-(token, wallet) ordered list of individual lock entries. + Locks(Address, Address), + /// Per-(token, wallet) aggregate of all locked amounts. + TotalLocked(Address, Address), + /// Per-(token, wallet) balance mirror, updated via hooks to avoid + /// re-entrant `token.balance()` calls. + InternalBalance(Address, Address), +} + +/// Returns the lockup period (in seconds) for `token`, or `0` if not set. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +pub fn get_lockup_period(e: &Env, token: &Address) -> u64 { + let key = InitialLockupStorageKey::LockupPeriod(token.clone()); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &u64| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or_default() +} + +/// Sets the lockup period (in seconds) for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `seconds` - The lockup duration in seconds. +pub fn set_lockup_period(e: &Env, token: &Address, seconds: u64) { + let key = InitialLockupStorageKey::LockupPeriod(token.clone()); + e.storage().persistent().set(&key, &seconds); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} + +/// Returns the lock entries for `wallet` on `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `wallet` - The wallet address. +pub fn get_locks(e: &Env, token: &Address, wallet: &Address) -> Vec { + let key = InitialLockupStorageKey::Locks(token.clone(), wallet.clone()); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &Vec| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or_else(|| Vec::new(e)) +} + +/// Persists the lock entries for `wallet` on `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `wallet` - The wallet address. +/// * `locks` - The updated lock entries. +pub fn set_locks(e: &Env, token: &Address, wallet: &Address, locks: &Vec) { + let key = InitialLockupStorageKey::Locks(token.clone(), wallet.clone()); + e.storage().persistent().set(&key, locks); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} + +/// Returns the total locked amount for `wallet` on `token`, or `0`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `wallet` - The wallet address. +pub fn get_total_locked(e: &Env, token: &Address, wallet: &Address) -> i128 { + let key = InitialLockupStorageKey::TotalLocked(token.clone(), wallet.clone()); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &i128| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or_default() +} + +/// Sets the total locked amount for `wallet` on `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `wallet` - The wallet address. +/// * `amount` - The new total locked amount. +pub fn set_total_locked(e: &Env, token: &Address, wallet: &Address, amount: i128) { + let key = InitialLockupStorageKey::TotalLocked(token.clone(), wallet.clone()); + e.storage().persistent().set(&key, &amount); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} + +/// Returns the internal balance for `wallet` on `token`, or `0`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `wallet` - The wallet address. +pub fn get_internal_balance(e: &Env, token: &Address, wallet: &Address) -> i128 { + let key = InitialLockupStorageKey::InternalBalance(token.clone(), wallet.clone()); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &i128| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or_default() +} + +/// Sets the internal balance for `wallet` on `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `wallet` - The wallet address. +/// * `balance` - The new balance value. +pub fn set_internal_balance(e: &Env, token: &Address, wallet: &Address, balance: i128) { + let key = InitialLockupStorageKey::InternalBalance(token.clone(), wallet.clone()); + e.storage().persistent().set(&key, &balance); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} diff --git a/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/test.rs b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/test.rs new file mode 100644 index 000000000..f758b7a97 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/test.rs @@ -0,0 +1,190 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, +}; + +use super::*; +use crate::rwa::{ + compliance::{ + modules::storage::{hooks_verified, set_compliance_address, ComplianceModuleStorageKey}, + Compliance, ComplianceHook, + }, + utils::token_binder::TokenBinder, +}; + +#[contract] +struct TestInitialLockupPeriodContract; + +#[contractimpl(contracttrait)] +impl InitialLockupPeriod for TestInitialLockupPeriodContract { + fn set_compliance_address(_e: &Env, _compliance: Address) { + unreachable!("set_compliance_address is not used in these tests"); + } +} + +fn arm_hooks(e: &Env) { + e.storage().instance().set(&ComplianceModuleStorageKey::HooksVerified, &true); +} + +#[contract] +struct MockComplianceContract; + +#[derive(Clone)] +#[contracttype] +enum MockComplianceStorageKey { + Registered(ComplianceHook, Address), +} + +#[contractimpl] +impl Compliance for MockComplianceContract { + fn add_module_to(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("add_module_to is not used in these tests"); + } + + fn remove_module_from(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("remove_module_from is not used in these tests"); + } + + fn get_modules_for_hook(_e: &Env, _hook: ComplianceHook) -> soroban_sdk::Vec
{ + unreachable!("get_modules_for_hook is not used in these tests"); + } + + fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool { + e.storage().persistent().has(&MockComplianceStorageKey::Registered(hook, module)) + } + + fn transferred(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) { + unreachable!("transferred is not used in these tests"); + } + + fn created(_e: &Env, _to: Address, _amount: i128, _token: Address) { + unreachable!("created is not used in these tests"); + } + + fn destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) { + unreachable!("destroyed is not used in these tests"); + } + + fn can_transfer( + _e: &Env, + _from: Address, + _to: Address, + _amount: i128, + _token: Address, + ) -> bool { + unreachable!("can_transfer is not used in these tests"); + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + unreachable!("can_create is not used in these tests"); + } +} + +#[contractimpl] +impl TokenBinder for MockComplianceContract { + fn linked_tokens(e: &Env) -> soroban_sdk::Vec
{ + soroban_sdk::Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl MockComplianceContract { + pub fn register_hook(e: &Env, hook: ComplianceHook, module: Address) { + e.storage().persistent().set(&MockComplianceStorageKey::Registered(hook, module), &true); + } +} + +#[test] +fn verify_hook_wiring_sets_cache_when_registered() { + let e = Env::default(); + let module_id = e.register(TestInitialLockupPeriodContract, ()); + let compliance_id = e.register(MockComplianceContract, ()); + let compliance = MockComplianceContractClient::new(&e, &compliance_id); + + for hook in [ + ComplianceHook::CanTransfer, + ComplianceHook::Created, + ComplianceHook::Transferred, + ComplianceHook::Destroyed, + ] { + compliance.register_hook(&hook, &module_id); + } + + e.as_contract(&module_id, || { + set_compliance_address(&e, &compliance_id); + + ::verify_hook_wiring(&e); + + assert!(hooks_verified(&e)); + }); +} + +#[test] +fn pre_set_lockup_state_seeds_existing_locked_balance() { + let e = Env::default(); + e.mock_all_auths(); + + let module_id = e.register(TestInitialLockupPeriodContract, ()); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let wallet = Address::generate(&e); + + e.as_contract(&module_id, || { + set_compliance_address(&e, &compliance); + arm_hooks(&e); + + ::pre_set_lockup_state( + &e, + token.clone(), + wallet.clone(), + 100, + vec![ + &e, + LockedTokens { + amount: 80, + release_timestamp: e.ledger().timestamp().saturating_add(60), + }, + ], + ); + + assert_eq!( + ::get_internal_balance( + &e, + token.clone(), + wallet.clone(), + ), + 100 + ); + assert_eq!( + ::get_total_locked( + &e, + token.clone(), + wallet.clone(), + ), + 80 + ); + assert!(!::can_transfer( + &e, + wallet.clone(), + Address::generate(&e), + 21, + token.clone(), + )); + assert!(::can_transfer( + &e, + wallet, + Address::generate(&e), + 20, + token, + )); + }); +} diff --git a/packages/tokens/src/rwa/compliance/modules/max_balance/mod.rs b/packages/tokens/src/rwa/compliance/modules/max_balance/mod.rs new file mode 100644 index 000000000..aa295329f --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/max_balance/mod.rs @@ -0,0 +1,218 @@ +//! Max balance compliance module — Stellar port of T-REX +//! [`MaxBalanceModule.sol`][trex-src]. +//! +//! Tracks effective balances per **identity** (not per wallet), enforcing a +//! per-token cap. +//! +//! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/MaxBalanceModule.sol + +pub mod storage; +#[cfg(test)] +mod test; + +use soroban_sdk::{contractevent, contracttrait, vec, Address, Env, String, Vec}; +use storage::{get_id_balance, get_max_balance, set_id_balance, set_max_balance}; + +use super::storage::{ + add_i128_or_panic, get_compliance_address, get_irs_client, hooks_verified, module_name, + require_non_negative_amount, set_irs_address, sub_i128_or_panic, verify_required_hooks, +}; +use crate::rwa::compliance::ComplianceHook; + +/// Emitted when a token's per-identity balance cap is configured. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MaxBalanceSet { + #[topic] + pub token: Address, + pub max_balance: i128, +} + +/// Emitted when an identity balance is pre-seeded via `pre_set_module_state`. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct IDBalancePreSet { + #[topic] + pub token: Address, + pub identity: Address, + pub balance: i128, +} + +fn can_increase_identity_balance( + e: &Env, + token: &Address, + identity: &Address, + amount: i128, +) -> bool { + if amount < 0 { + return false; + } + + let max = get_max_balance(e, token); + if max == 0 { + return true; + } + + let current = get_id_balance(e, token, identity); + add_i128_or_panic(e, current, amount) <= max +} + +#[contracttrait] +pub trait MaxBalance { + fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { + get_compliance_address(e).require_auth(); + set_irs_address(e, &token, &irs); + } + + fn set_max_balance(e: &Env, token: Address, max: i128) { + get_compliance_address(e).require_auth(); + require_non_negative_amount(e, max); + set_max_balance(e, &token, max); + MaxBalanceSet { token, max_balance: max }.publish(e); + } + + fn pre_set_module_state(e: &Env, token: Address, identity: Address, balance: i128) { + get_compliance_address(e).require_auth(); + require_non_negative_amount(e, balance); + set_id_balance(e, &token, &identity, balance); + IDBalancePreSet { token, identity, balance }.publish(e); + } + + fn batch_pre_set_module_state( + e: &Env, + token: Address, + identities: Vec
, + balances: Vec, + ) { + get_compliance_address(e).require_auth(); + assert!( + identities.len() == balances.len(), + "MaxBalanceModule: identities and balances length mismatch" + ); + for i in 0..identities.len() { + let id = identities.get(i).unwrap(); + let bal = balances.get(i).unwrap(); + require_non_negative_amount(e, bal); + set_id_balance(e, &token, &id, bal); + IDBalancePreSet { token: token.clone(), identity: id, balance: bal }.publish(e); + } + } + + fn get_investor_balance(e: &Env, token: Address, identity: Address) -> i128 { + get_id_balance(e, &token, &identity) + } + + fn required_hooks(e: &Env) -> Vec { + vec![ + e, + ComplianceHook::CanTransfer, + ComplianceHook::CanCreate, + ComplianceHook::Transferred, + ComplianceHook::Created, + ComplianceHook::Destroyed, + ] + } + + fn verify_hook_wiring(e: &Env) { + verify_required_hooks(e, Self::required_hooks(e)); + } + + fn on_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) { + get_compliance_address(e).require_auth(); + require_non_negative_amount(e, amount); + + let irs = get_irs_client(e, &token); + let from_id = irs.stored_identity(&from); + let to_id = irs.stored_identity(&to); + + if from_id == to_id { + return; + } + + let from_balance = get_id_balance(e, &token, &from_id); + assert!( + can_increase_identity_balance(e, &token, &to_id, amount), + "MaxBalanceModule: recipient identity balance exceeds max" + ); + + let to_balance = get_id_balance(e, &token, &to_id); + let new_to_balance = add_i128_or_panic(e, to_balance, amount); + set_id_balance(e, &token, &from_id, sub_i128_or_panic(e, from_balance, amount)); + set_id_balance(e, &token, &to_id, new_to_balance); + } + + fn on_created(e: &Env, to: Address, amount: i128, token: Address) { + get_compliance_address(e).require_auth(); + require_non_negative_amount(e, amount); + + let irs = get_irs_client(e, &token); + let to_id = irs.stored_identity(&to); + + assert!( + can_increase_identity_balance(e, &token, &to_id, amount), + "MaxBalanceModule: recipient identity balance exceeds max after mint" + ); + + let current = get_id_balance(e, &token, &to_id); + let new_balance = add_i128_or_panic(e, current, amount); + set_id_balance(e, &token, &to_id, new_balance); + } + + fn on_destroyed(e: &Env, from: Address, amount: i128, token: Address) { + get_compliance_address(e).require_auth(); + require_non_negative_amount(e, amount); + + let irs = get_irs_client(e, &token); + let from_id = irs.stored_identity(&from); + + let current = get_id_balance(e, &token, &from_id); + set_id_balance(e, &token, &from_id, sub_i128_or_panic(e, current, amount)); + } + + fn can_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) -> bool { + assert!( + hooks_verified(e), + "MaxBalanceModule: not armed — call verify_hook_wiring() after wiring hooks \ + [CanTransfer, CanCreate, Transferred, Created, Destroyed]" + ); + if amount < 0 { + return false; + } + let irs = get_irs_client(e, &token); + let from_id = irs.stored_identity(&from); + let to_id = irs.stored_identity(&to); + + if from_id == to_id { + return true; + } + + can_increase_identity_balance(e, &token, &to_id, amount) + } + + fn can_create(e: &Env, to: Address, amount: i128, token: Address) -> bool { + assert!( + hooks_verified(e), + "MaxBalanceModule: not armed — call verify_hook_wiring() after wiring hooks \ + [CanTransfer, CanCreate, Transferred, Created, Destroyed]" + ); + if amount < 0 { + return false; + } + let irs = get_irs_client(e, &token); + let to_id = irs.stored_identity(&to); + can_increase_identity_balance(e, &token, &to_id, amount) + } + + fn name(e: &Env) -> String { + module_name(e, "MaxBalanceModule") + } + + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + /// Implementers must gate this entrypoint with bootstrap-admin auth before + /// delegating to + /// [`storage::set_compliance_address`](super::storage::set_compliance_address). + fn set_compliance_address(e: &Env, compliance: Address); +} diff --git a/packages/tokens/src/rwa/compliance/modules/max_balance/storage.rs b/packages/tokens/src/rwa/compliance/modules/max_balance/storage.rs new file mode 100644 index 000000000..60e6cb997 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/max_balance/storage.rs @@ -0,0 +1,75 @@ +use soroban_sdk::{contracttype, Address, Env}; + +use crate::rwa::compliance::modules::{MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD}; + +#[contracttype] +#[derive(Clone)] +pub enum MaxBalanceStorageKey { + /// Per-token maximum allowed identity balance. + MaxBalance(Address), + /// Balance keyed by (token, identity) — not by wallet. + IDBalance(Address, Address), +} + +/// Returns the per-identity balance cap for `token`, or `0` if not set. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +pub fn get_max_balance(e: &Env, token: &Address) -> i128 { + let key = MaxBalanceStorageKey::MaxBalance(token.clone()); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &i128| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or_default() +} + +/// Sets the per-identity balance cap for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `value` - The maximum balance per identity. +pub fn set_max_balance(e: &Env, token: &Address, value: i128) { + let key = MaxBalanceStorageKey::MaxBalance(token.clone()); + e.storage().persistent().set(&key, &value); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} + +/// Returns the tracked balance for `identity` on `token`, or `0` if not +/// set. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `identity` - The on-chain identity address. +pub fn get_id_balance(e: &Env, token: &Address, identity: &Address) -> i128 { + let key = MaxBalanceStorageKey::IDBalance(token.clone(), identity.clone()); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &i128| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or_default() +} + +/// Sets the tracked balance for `identity` on `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `identity` - The on-chain identity address. +/// * `balance` - The new balance value. +pub fn set_id_balance(e: &Env, token: &Address, identity: &Address, balance: i128) { + let key = MaxBalanceStorageKey::IDBalance(token.clone(), identity.clone()); + e.storage().persistent().set(&key, &balance); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} diff --git a/packages/tokens/src/rwa/compliance/modules/max_balance/test.rs b/packages/tokens/src/rwa/compliance/modules/max_balance/test.rs new file mode 100644 index 000000000..53281cadb --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/max_balance/test.rs @@ -0,0 +1,351 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, Address, Env, Val, Vec, +}; + +use super::{ + storage::{set_id_balance, set_max_balance}, + *, +}; +use crate::rwa::{ + compliance::{ + modules::storage::{ + hooks_verified, set_compliance_address, set_irs_address, ComplianceModuleStorageKey, + }, + Compliance, ComplianceHook, + }, + identity_registry_storage::{CountryDataManager, IdentityRegistryStorage}, + utils::token_binder::TokenBinder, +}; + +#[contract] +struct MockIRSContract; + +#[contracttype] +#[derive(Clone)] +enum MockIRSStorageKey { + Identity(Address), +} + +#[contractimpl] +impl TokenBinder for MockIRSContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl IdentityRegistryStorage for MockIRSContract { + fn add_identity( + _e: &Env, + _account: Address, + _identity: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_identity is not used in these tests"); + } + + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { + unreachable!("remove_identity is not used in these tests"); + } + + fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { + unreachable!("modify_identity is not used in these tests"); + } + + fn recover_identity( + _e: &Env, + _old_account: Address, + _new_account: Address, + _operator: Address, + ) { + unreachable!("recover_identity is not used in these tests"); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + e.storage() + .persistent() + .get(&MockIRSStorageKey::Identity(account.clone())) + .unwrap_or(account) + } +} + +#[contractimpl] +impl CountryDataManager for MockIRSContract { + fn add_country_data_entries( + _e: &Env, + _account: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_country_data_entries is not used in these tests"); + } + + fn modify_country_data( + _e: &Env, + _account: Address, + _index: u32, + _country_data: Val, + _operator: Address, + ) { + unreachable!("modify_country_data is not used in these tests"); + } + + fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { + unreachable!("delete_country_data is not used in these tests"); + } + + fn get_country_data_entries(e: &Env, _account: Address) -> Vec { + Vec::new(e) + } +} + +#[contractimpl] +impl MockIRSContract { + pub fn set_identity(e: &Env, account: Address, identity: Address) { + e.storage().persistent().set(&MockIRSStorageKey::Identity(account), &identity); + } +} + +#[contract] +struct MockComplianceContract; + +#[contracttype] +#[derive(Clone)] +enum MockComplianceStorageKey { + Registered(ComplianceHook, Address), +} + +#[contractimpl] +impl Compliance for MockComplianceContract { + fn add_module_to(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("add_module_to is not used in these tests"); + } + + fn remove_module_from(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("remove_module_from is not used in these tests"); + } + + fn get_modules_for_hook(_e: &Env, _hook: ComplianceHook) -> Vec
{ + unreachable!("get_modules_for_hook is not used in these tests"); + } + + fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool { + e.storage().persistent().has(&MockComplianceStorageKey::Registered(hook, module)) + } + + fn transferred(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) { + unreachable!("transferred is not used in these tests"); + } + + fn created(_e: &Env, _to: Address, _amount: i128, _token: Address) { + unreachable!("created is not used in these tests"); + } + + fn destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) { + unreachable!("destroyed is not used in these tests"); + } + + fn can_transfer( + _e: &Env, + _from: Address, + _to: Address, + _amount: i128, + _token: Address, + ) -> bool { + unreachable!("can_transfer is not used in these tests"); + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + unreachable!("can_create is not used in these tests"); + } +} + +#[contractimpl] +impl TokenBinder for MockComplianceContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl MockComplianceContract { + pub fn register_hook(e: &Env, hook: ComplianceHook, module: Address) { + e.storage().persistent().set(&MockComplianceStorageKey::Registered(hook, module), &true); + } +} + +#[contract] +struct TestMaxBalanceContract; + +#[contractimpl(contracttrait)] +impl MaxBalance for TestMaxBalanceContract { + fn set_compliance_address(_e: &Env, _compliance: Address) { + unreachable!("set_compliance_address is not used in these tests"); + } +} + +fn arm_hooks(e: &Env) { + e.storage().instance().set(&ComplianceModuleStorageKey::HooksVerified, &true); +} + +#[test] +fn verify_hook_wiring_sets_cache_when_registered() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let compliance_id = e.register(MockComplianceContract, ()); + let compliance = MockComplianceContractClient::new(&e, &compliance_id); + + for hook in [ + ComplianceHook::CanTransfer, + ComplianceHook::CanCreate, + ComplianceHook::Transferred, + ComplianceHook::Created, + ComplianceHook::Destroyed, + ] { + compliance.register_hook(&hook, &module_id); + } + + e.as_contract(&module_id, || { + set_compliance_address(&e, &compliance_id); + + ::verify_hook_wiring(&e); + + assert!(hooks_verified(&e)); + }); +} + +#[test] +fn can_create_rejects_mint_when_cap_would_be_exceeded() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let recipient = Address::generate(&e); + let recipient_identity = Address::generate(&e); + + irs.set_identity(&recipient, &recipient_identity); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + arm_hooks(&e); + set_max_balance(&e, &token, 100); + set_id_balance(&e, &token, &recipient_identity, 60); + + assert!(!::can_create( + &e, + recipient.clone(), + 50, + token.clone(), + )); + assert!(::can_create( + &e, + recipient, + 40, + token.clone(), + )); + }); +} + +#[test] +fn can_transfer_checks_distinct_recipient_identity_balance() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let recipient = Address::generate(&e); + let sender_identity = Address::generate(&e); + let recipient_identity = Address::generate(&e); + + irs.set_identity(&sender, &sender_identity); + irs.set_identity(&recipient, &recipient_identity); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + arm_hooks(&e); + set_max_balance(&e, &token, 100); + set_id_balance(&e, &token, &recipient_identity, 60); + + assert!(!::can_transfer( + &e, + sender.clone(), + recipient.clone(), + 50, + token.clone(), + )); + assert!(::can_transfer( + &e, + sender, + recipient, + 40, + token.clone(), + )); + }); +} + +#[test] +fn can_create_allows_without_cap_and_rejects_negative_amount() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let recipient = Address::generate(&e); + let recipient_identity = Address::generate(&e); + + irs.set_identity(&recipient, &recipient_identity); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + arm_hooks(&e); + set_id_balance(&e, &token, &recipient_identity, 500); + + assert!(::can_create( + &e, + recipient.clone(), + 1_000, + token.clone(), + )); + assert!(!::can_create( + &e, + recipient, + -1, + token.clone(), + )); + }); +} + +#[test] +fn can_create_rejects_negative_amount_before_requiring_irs() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let token = Address::generate(&e); + let recipient = Address::generate(&e); + + e.as_contract(&module_id, || { + arm_hooks(&e); + + assert!(!::can_create(&e, recipient, -1, token,)); + }); +} diff --git a/packages/tokens/src/rwa/compliance/modules/mod.rs b/packages/tokens/src/rwa/compliance/modules/mod.rs index f4e065161..29995ad41 100644 --- a/packages/tokens/src/rwa/compliance/modules/mod.rs +++ b/packages/tokens/src/rwa/compliance/modules/mod.rs @@ -1,6 +1,13 @@ use soroban_sdk::{contracterror, contracttrait, Address, Env, String}; +pub mod country_allow; +pub mod country_restrict; +pub mod initial_lockup_period; +pub mod max_balance; pub mod storage; +pub mod supply_limit; +pub mod time_transfers_limits; +pub mod transfer_restrict; #[cfg(test)] mod test; diff --git a/packages/tokens/src/rwa/compliance/modules/supply_limit/mod.rs b/packages/tokens/src/rwa/compliance/modules/supply_limit/mod.rs new file mode 100644 index 000000000..d138a002b --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/supply_limit/mod.rs @@ -0,0 +1,116 @@ +//! Supply cap compliance module — Stellar port of T-REX +//! [`SupplyLimitModule.sol`][trex-src]. +//! +//! Caps the total number of tokens that can be minted for a given token. +//! +//! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/SupplyLimitModule.sol + +pub mod storage; +#[cfg(test)] +mod test; + +use soroban_sdk::{contractevent, contracttrait, vec, Address, Env, String, Vec}; +use storage::{get_internal_supply, get_supply_limit, set_internal_supply, set_supply_limit}; + +use super::storage::{ + add_i128_or_panic, get_compliance_address, hooks_verified, module_name, + require_non_negative_amount, sub_i128_or_panic, verify_required_hooks, +}; +use crate::rwa::compliance::ComplianceHook; + +/// Emitted when a token's supply cap is configured or changed. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SupplyLimitSet { + #[topic] + pub token: Address, + pub limit: i128, +} + +#[contracttrait] +pub trait SupplyLimit { + fn set_supply_limit(e: &Env, token: Address, limit: i128) { + get_compliance_address(e).require_auth(); + require_non_negative_amount(e, limit); + set_supply_limit(e, &token, limit); + SupplyLimitSet { token, limit }.publish(e); + } + + fn pre_set_internal_supply(e: &Env, token: Address, supply: i128) { + get_compliance_address(e).require_auth(); + require_non_negative_amount(e, supply); + set_internal_supply(e, &token, supply); + } + + fn get_supply_limit(e: &Env, token: Address) -> i128 { + get_supply_limit(e, &token) + } + + fn get_internal_supply(e: &Env, token: Address) -> i128 { + get_internal_supply(e, &token) + } + + fn required_hooks(e: &Env) -> Vec { + vec![e, ComplianceHook::CanCreate, ComplianceHook::Created, ComplianceHook::Destroyed] + } + + fn verify_hook_wiring(e: &Env) { + verify_required_hooks(e, Self::required_hooks(e)); + } + + fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {} + + fn on_created(e: &Env, _to: Address, amount: i128, token: Address) { + get_compliance_address(e).require_auth(); + require_non_negative_amount(e, amount); + let current = get_internal_supply(e, &token); + set_internal_supply(e, &token, add_i128_or_panic(e, current, amount)); + } + + fn on_destroyed(e: &Env, _from: Address, amount: i128, token: Address) { + get_compliance_address(e).require_auth(); + require_non_negative_amount(e, amount); + let current = get_internal_supply(e, &token); + set_internal_supply(e, &token, sub_i128_or_panic(e, current, amount)); + } + + fn can_transfer( + _e: &Env, + _from: Address, + _to: Address, + _amount: i128, + _token: Address, + ) -> bool { + true + } + + fn can_create(e: &Env, _to: Address, amount: i128, token: Address) -> bool { + assert!( + hooks_verified(e), + "SupplyLimitModule: not armed — call verify_hook_wiring() after wiring hooks \ + [CanCreate, Created, Destroyed]" + ); + if amount < 0 { + return false; + } + let limit = get_supply_limit(e, &token); + if limit == 0 { + return true; + } + let supply = get_internal_supply(e, &token); + add_i128_or_panic(e, supply, amount) <= limit + } + + fn name(e: &Env) -> String { + module_name(e, "SupplyLimitModule") + } + + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + /// Implementers must gate this entrypoint with bootstrap-admin auth before + /// delegating to + /// [`storage::set_compliance_address`](super::storage::set_compliance_address). + fn set_compliance_address(e: &Env, compliance: Address); +} diff --git a/packages/tokens/src/rwa/compliance/modules/supply_limit/storage.rs b/packages/tokens/src/rwa/compliance/modules/supply_limit/storage.rs new file mode 100644 index 000000000..f1ec2f4df --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/supply_limit/storage.rs @@ -0,0 +1,96 @@ +use soroban_sdk::{contracttype, panic_with_error, Address, Env}; + +use crate::rwa::compliance::modules::{ + ComplianceModuleError, MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD, +}; + +#[contracttype] +#[derive(Clone)] +pub enum SupplyLimitStorageKey { + /// Per-token supply cap. + SupplyLimit(Address), + /// Per-token internal supply counter (updated via hooks). + InternalSupply(Address), +} + +/// Returns the supply limit for `token`, or `0` if not set. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +pub fn get_supply_limit(e: &Env, token: &Address) -> i128 { + let key = SupplyLimitStorageKey::SupplyLimit(token.clone()); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &i128| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or_default() +} + +/// Returns the supply limit for `token`, panicking if not configured. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// +/// # Errors +/// +/// * [`ComplianceModuleError::MissingLimit`] - When no supply limit has been +/// configured for this token. +pub fn get_supply_limit_or_panic(e: &Env, token: &Address) -> i128 { + let key = SupplyLimitStorageKey::SupplyLimit(token.clone()); + let limit: i128 = e + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| panic_with_error!(e, ComplianceModuleError::MissingLimit)); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + limit +} + +/// Sets the supply limit for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `limit` - The maximum total supply. +pub fn set_supply_limit(e: &Env, token: &Address, limit: i128) { + let key = SupplyLimitStorageKey::SupplyLimit(token.clone()); + e.storage().persistent().set(&key, &limit); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} + +/// Returns the internal supply counter for `token`, or `0` if not set. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +pub fn get_internal_supply(e: &Env, token: &Address) -> i128 { + let key = SupplyLimitStorageKey::InternalSupply(token.clone()); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &i128| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or_default() +} + +/// Sets the internal supply counter for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `supply` - The new supply value. +pub fn set_internal_supply(e: &Env, token: &Address, supply: i128) { + let key = SupplyLimitStorageKey::InternalSupply(token.clone()); + e.storage().persistent().set(&key, &supply); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} diff --git a/packages/tokens/src/rwa/compliance/modules/supply_limit/test.rs b/packages/tokens/src/rwa/compliance/modules/supply_limit/test.rs new file mode 100644 index 000000000..21018e09e --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/supply_limit/test.rs @@ -0,0 +1,211 @@ +extern crate std; + +use soroban_sdk::{contract, contractimpl, contracttype, testutils::Address as _, Address, Env}; + +use super::*; +use crate::rwa::{ + compliance::{ + modules::storage::{hooks_verified, set_compliance_address, ComplianceModuleStorageKey}, + Compliance, ComplianceHook, + }, + utils::token_binder::TokenBinder, +}; + +#[contract] +struct MockComplianceContract; + +#[contracttype] +#[derive(Clone)] +enum MockComplianceStorageKey { + Registered(ComplianceHook, Address), +} + +#[contractimpl] +impl Compliance for MockComplianceContract { + fn add_module_to(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("add_module_to is not used in these tests"); + } + + fn remove_module_from(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("remove_module_from is not used in these tests"); + } + + fn get_modules_for_hook(_e: &Env, _hook: ComplianceHook) -> soroban_sdk::Vec
{ + unreachable!("get_modules_for_hook is not used in these tests"); + } + + fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool { + e.storage().persistent().has(&MockComplianceStorageKey::Registered(hook, module)) + } + + fn transferred(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) { + unreachable!("transferred is not used in these tests"); + } + + fn created(_e: &Env, _to: Address, _amount: i128, _token: Address) { + unreachable!("created is not used in these tests"); + } + + fn destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) { + unreachable!("destroyed is not used in these tests"); + } + + fn can_transfer( + _e: &Env, + _from: Address, + _to: Address, + _amount: i128, + _token: Address, + ) -> bool { + unreachable!("can_transfer is not used in these tests"); + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + unreachable!("can_create is not used in these tests"); + } +} + +#[contractimpl] +impl TokenBinder for MockComplianceContract { + fn linked_tokens(e: &Env) -> soroban_sdk::Vec
{ + soroban_sdk::Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl MockComplianceContract { + pub fn register_hook(e: &Env, hook: ComplianceHook, module: Address) { + e.storage().persistent().set(&MockComplianceStorageKey::Registered(hook, module), &true); + } +} + +#[contract] +struct TestSupplyLimitContract; + +#[contractimpl(contracttrait)] +impl SupplyLimit for TestSupplyLimitContract { + fn set_compliance_address(_e: &Env, _compliance: Address) { + unreachable!("set_compliance_address is not used in these tests"); + } +} + +fn arm_hooks(e: &Env) { + e.storage().instance().set(&ComplianceModuleStorageKey::HooksVerified, &true); +} + +#[test] +fn verify_hook_wiring_sets_cache_when_registered() { + let e = Env::default(); + let module_id = e.register(TestSupplyLimitContract, ()); + let compliance_id = e.register(MockComplianceContract, ()); + let compliance = MockComplianceContractClient::new(&e, &compliance_id); + + for hook in [ComplianceHook::CanCreate, ComplianceHook::Created, ComplianceHook::Destroyed] { + compliance.register_hook(&hook, &module_id); + } + + e.as_contract(&module_id, || { + set_compliance_address(&e, &compliance_id); + + ::verify_hook_wiring(&e); + + assert!(hooks_verified(&e)); + }); +} + +#[test] +fn get_supply_limit_returns_zero_when_unconfigured() { + let e = Env::default(); + let module_id = e.register(TestSupplyLimitContract, ()); + let token = Address::generate(&e); + + e.as_contract(&module_id, || { + assert_eq!(::get_supply_limit(&e, token), 0); + }); +} + +#[test] +fn can_create_allows_when_limit_is_unset_and_rejects_negative_amount() { + let e = Env::default(); + let module_id = e.register(TestSupplyLimitContract, ()); + let token = Address::generate(&e); + let recipient = Address::generate(&e); + + e.as_contract(&module_id, || { + arm_hooks(&e); + + assert!(::can_create( + &e, + recipient.clone(), + 100, + token.clone(), + )); + assert!(!::can_create( + &e, + recipient, + -1, + token.clone(), + )); + }); +} + +#[test] +fn hooks_update_internal_supply_and_cap_future_mints() { + let e = Env::default(); + e.mock_all_auths(); + + let module_id = e.register(TestSupplyLimitContract, ()); + let compliance_id = e.register(MockComplianceContract, ()); + let token = Address::generate(&e); + let recipient = Address::generate(&e); + let client = TestSupplyLimitContractClient::new(&e, &module_id); + + e.as_contract(&module_id, || { + set_compliance_address(&e, &compliance_id); + arm_hooks(&e); + }); + + client.set_supply_limit(&token, &100); + + assert!(client.can_create(&recipient.clone(), &80, &token)); + client.on_created(&recipient.clone(), &80, &token); + assert_eq!(client.get_internal_supply(&token), 80); + + assert!(!client.can_create(&recipient.clone(), &30, &token)); + + client.on_destroyed(&recipient.clone(), &20, &token); + assert_eq!(client.get_internal_supply(&token), 60); + assert!(client.can_create(&recipient, &40, &token)); +} + +#[test] +fn pre_set_internal_supply_seeds_existing_supply_for_cap_checks() { + let e = Env::default(); + e.mock_all_auths(); + + let module_id = e.register(TestSupplyLimitContract, ()); + let compliance_id = e.register(MockComplianceContract, ()); + let token = Address::generate(&e); + let recipient = Address::generate(&e); + let client = TestSupplyLimitContractClient::new(&e, &module_id); + + e.as_contract(&module_id, || { + set_compliance_address(&e, &compliance_id); + arm_hooks(&e); + }); + + client.set_supply_limit(&token, &100); + client.pre_set_internal_supply(&token, &90); + + assert_eq!(client.get_internal_supply(&token), 90); + assert!(!client.can_create(&recipient.clone(), &11, &token)); + assert!(client.can_create(&recipient, &10, &token)); +} diff --git a/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/mod.rs b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/mod.rs new file mode 100644 index 000000000..5bc76a97f --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/mod.rs @@ -0,0 +1,239 @@ +//! Time-windowed transfer-limits compliance module — Stellar port of T-REX +//! [`TimeTransfersLimitsModule.sol`][trex-src]. +//! +//! Limits transfer volume within configurable time windows, tracking counters +//! per **identity** (not per wallet). +//! +//! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/TimeTransfersLimitsModule.sol + +pub mod storage; +#[cfg(test)] +mod test; + +use soroban_sdk::{contractevent, contracttrait, panic_with_error, vec, Address, Env, String, Vec}; +use storage::{get_counter, get_limits, set_counter, set_limits}; +pub use storage::{Limit, TransferCounter}; + +use super::storage::{ + add_i128_or_panic, get_compliance_address, get_irs_client, hooks_verified, module_name, + require_non_negative_amount, set_irs_address, verify_required_hooks, +}; +use crate::rwa::compliance::{modules::ComplianceModuleError, ComplianceHook}; + +const MAX_LIMITS_PER_TOKEN: u32 = 4; + +/// Emitted when a time-window limit is added or updated. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TimeTransferLimitUpdated { + #[topic] + pub token: Address, + pub limit: Limit, +} + +/// Emitted when a time-window limit is removed. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TimeTransferLimitRemoved { + #[topic] + pub token: Address, + pub limit_time: u64, +} + +// ################## HELPERS ################## + +fn is_counter_finished(e: &Env, token: &Address, identity: &Address, limit_time: u64) -> bool { + let counter = get_counter(e, token, identity, limit_time); + counter.timer <= e.ledger().timestamp() +} + +fn reset_counter_if_needed(e: &Env, token: &Address, identity: &Address, limit_time: u64) { + if is_counter_finished(e, token, identity, limit_time) { + let counter = + TransferCounter { value: 0, timer: e.ledger().timestamp().saturating_add(limit_time) }; + set_counter(e, token, identity, limit_time, &counter); + } +} + +fn increase_counters(e: &Env, token: &Address, identity: &Address, value: i128) { + let limits = get_limits(e, token); + for limit in limits.iter() { + reset_counter_if_needed(e, token, identity, limit.limit_time); + let mut counter = get_counter(e, token, identity, limit.limit_time); + counter.value = add_i128_or_panic(e, counter.value, value); + set_counter(e, token, identity, limit.limit_time, &counter); + } +} + +#[contracttrait] +pub trait TimeTransfersLimits { + // ################## QUERY STATE ################## + + fn get_time_transfer_limits(e: &Env, token: Address) -> Vec { + get_limits(e, &token) + } + + fn can_transfer(e: &Env, from: Address, _to: Address, amount: i128, token: Address) -> bool { + assert!( + hooks_verified(e), + "TimeTransfersLimitsModule: not armed — call verify_hook_wiring() after wiring hooks \ + [CanTransfer, Transferred]" + ); + if amount < 0 { + return false; + } + let irs = get_irs_client(e, &token); + let from_id = irs.stored_identity(&from); + let limits = get_limits(e, &token); + + for limit in limits.iter() { + if amount > limit.limit_value { + return false; + } + + if !is_counter_finished(e, &token, &from_id, limit.limit_time) { + let counter = get_counter(e, &token, &from_id, limit.limit_time); + if add_i128_or_panic(e, counter.value, amount) > limit.limit_value { + return false; + } + } + } + + true + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + true + } + + fn name(e: &Env) -> String { + module_name(e, "TimeTransfersLimitsModule") + } + + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + // ################## CHANGE STATE ################## + + fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { + get_compliance_address(e).require_auth(); + set_irs_address(e, &token, &irs); + } + + fn set_time_transfer_limit(e: &Env, token: Address, limit: Limit) { + get_compliance_address(e).require_auth(); + assert!(limit.limit_time > 0, "limit_time must be greater than zero"); + require_non_negative_amount(e, limit.limit_value); + let mut limits = get_limits(e, &token); + + let mut replaced = false; + for i in 0..limits.len() { + let current = limits.get(i).expect("limit exists"); + if current.limit_time == limit.limit_time { + limits.set(i, limit.clone()); + replaced = true; + break; + } + } + + if !replaced { + if limits.len() >= MAX_LIMITS_PER_TOKEN { + panic_with_error!(e, ComplianceModuleError::TooManyLimits); + } + limits.push_back(limit.clone()); + } + + set_limits(e, &token, &limits); + TimeTransferLimitUpdated { token, limit }.publish(e); + } + + fn batch_set_time_transfer_limit(e: &Env, token: Address, limits: Vec) { + get_compliance_address(e).require_auth(); + for limit in limits.iter() { + Self::set_time_transfer_limit(e, token.clone(), limit); + } + } + + fn remove_time_transfer_limit(e: &Env, token: Address, limit_time: u64) { + get_compliance_address(e).require_auth(); + let mut limits = get_limits(e, &token); + + let mut found = false; + for i in 0..limits.len() { + let current = limits.get(i).expect("limit exists"); + if current.limit_time == limit_time { + limits.remove(i); + found = true; + break; + } + } + + if !found { + panic_with_error!(e, ComplianceModuleError::MissingLimit); + } + + set_limits(e, &token, &limits); + TimeTransferLimitRemoved { token, limit_time }.publish(e); + } + + fn batch_remove_time_transfer_limit(e: &Env, token: Address, limit_times: Vec) { + get_compliance_address(e).require_auth(); + for lt in limit_times.iter() { + Self::remove_time_transfer_limit(e, token.clone(), lt); + } + } + + fn pre_set_transfer_counter( + e: &Env, + token: Address, + identity: Address, + limit_time: u64, + counter: TransferCounter, + ) { + get_compliance_address(e).require_auth(); + require_non_negative_amount(e, counter.value); + assert!(limit_time > 0, "limit_time must be greater than zero"); + + let mut found = false; + for limit in get_limits(e, &token).iter() { + if limit.limit_time == limit_time { + found = true; + break; + } + } + + if !found { + panic_with_error!(e, ComplianceModuleError::MissingLimit); + } + + set_counter(e, &token, &identity, limit_time, &counter); + } + + fn verify_hook_wiring(e: &Env) { + verify_required_hooks(e, Self::required_hooks(e)); + } + + fn on_transfer(e: &Env, from: Address, _to: Address, amount: i128, token: Address) { + get_compliance_address(e).require_auth(); + require_non_negative_amount(e, amount); + let irs = get_irs_client(e, &token); + let from_id = irs.stored_identity(&from); + increase_counters(e, &token, &from_id, amount); + } + + fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {} + + fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {} + + /// Implementers must gate this entrypoint with bootstrap-admin auth before + /// delegating to + /// [`storage::set_compliance_address`](super::storage::set_compliance_address). + fn set_compliance_address(e: &Env, compliance: Address); + + // ################## HELPERS ################## + + fn required_hooks(e: &Env) -> Vec { + vec![e, ComplianceHook::CanTransfer, ComplianceHook::Transferred] + } +} diff --git a/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/storage.rs b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/storage.rs new file mode 100644 index 000000000..8b7e38e5e --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/storage.rs @@ -0,0 +1,104 @@ +use soroban_sdk::{contracttype, Address, Env, Vec}; + +use crate::rwa::compliance::modules::{MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD}; + +/// A single time-window limit: `limit_value` tokens may be transferred +/// within a rolling window of `limit_time` seconds. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Limit { + pub limit_time: u64, + pub limit_value: i128, +} + +/// Tracks cumulative transfer volume for one identity within one window. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransferCounter { + pub value: i128, + pub timer: u64, +} + +#[contracttype] +#[derive(Clone)] +pub enum TimeTransfersLimitsStorageKey { + /// Per-token list of configured time-window limits. + Limits(Address), + /// Counter keyed by (token, identity, window_seconds). + Counter(Address, Address, u64), +} + +/// Returns the list of time-window limits for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +pub fn get_limits(e: &Env, token: &Address) -> Vec { + let key = TimeTransfersLimitsStorageKey::Limits(token.clone()); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &Vec| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or_else(|| Vec::new(e)) +} + +/// Persists the list of time-window limits for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `limits` - The updated limits list. +pub fn set_limits(e: &Env, token: &Address, limits: &Vec) { + let key = TimeTransfersLimitsStorageKey::Limits(token.clone()); + e.storage().persistent().set(&key, limits); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} + +/// Returns the transfer counter for a given identity and time window. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `identity` - The on-chain identity address. +/// * `limit_time` - The time-window duration in seconds. +pub fn get_counter( + e: &Env, + token: &Address, + identity: &Address, + limit_time: u64, +) -> TransferCounter { + let key = TimeTransfersLimitsStorageKey::Counter(token.clone(), identity.clone(), limit_time); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &TransferCounter| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or(TransferCounter { value: 0, timer: 0 }) +} + +/// Persists the transfer counter for a given identity and time window. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `identity` - The on-chain identity address. +/// * `limit_time` - The time-window duration in seconds. +/// * `counter` - The updated counter value. +pub fn set_counter( + e: &Env, + token: &Address, + identity: &Address, + limit_time: u64, + counter: &TransferCounter, +) { + let key = TimeTransfersLimitsStorageKey::Counter(token.clone(), identity.clone(), limit_time); + e.storage().persistent().set(&key, counter); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} diff --git a/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/test.rs b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/test.rs new file mode 100644 index 000000000..aced1114d --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/test.rs @@ -0,0 +1,282 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, Address, Env, Val, Vec, +}; + +use super::*; +use crate::rwa::{ + compliance::{ + modules::storage::{ + hooks_verified, set_compliance_address, set_irs_address, ComplianceModuleStorageKey, + }, + Compliance, ComplianceHook, + }, + identity_registry_storage::{CountryDataManager, IdentityRegistryStorage}, + utils::token_binder::TokenBinder, +}; + +#[contract] +struct MockIRSContract; + +#[contracttype] +#[derive(Clone)] +enum MockIRSStorageKey { + Identity(Address), +} + +#[contractimpl] +impl TokenBinder for MockIRSContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl IdentityRegistryStorage for MockIRSContract { + fn add_identity( + _e: &Env, + _account: Address, + _identity: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_identity is not used in these tests"); + } + + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { + unreachable!("remove_identity is not used in these tests"); + } + + fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { + unreachable!("modify_identity is not used in these tests"); + } + + fn recover_identity( + _e: &Env, + _old_account: Address, + _new_account: Address, + _operator: Address, + ) { + unreachable!("recover_identity is not used in these tests"); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + e.storage() + .persistent() + .get(&MockIRSStorageKey::Identity(account.clone())) + .unwrap_or(account) + } +} + +#[contractimpl] +impl CountryDataManager for MockIRSContract { + fn add_country_data_entries( + _e: &Env, + _account: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_country_data_entries is not used in these tests"); + } + + fn modify_country_data( + _e: &Env, + _account: Address, + _index: u32, + _country_data: Val, + _operator: Address, + ) { + unreachable!("modify_country_data is not used in these tests"); + } + + fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { + unreachable!("delete_country_data is not used in these tests"); + } + + fn get_country_data_entries(e: &Env, _account: Address) -> Vec { + Vec::new(e) + } +} + +#[contractimpl] +impl MockIRSContract { + pub fn set_identity(e: &Env, account: Address, identity: Address) { + e.storage().persistent().set(&MockIRSStorageKey::Identity(account), &identity); + } +} + +#[contract] +struct MockComplianceContract; + +#[contracttype] +#[derive(Clone)] +enum MockComplianceStorageKey { + Registered(ComplianceHook, Address), +} + +#[contractimpl] +impl Compliance for MockComplianceContract { + fn add_module_to(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("add_module_to is not used in these tests"); + } + + fn remove_module_from(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("remove_module_from is not used in these tests"); + } + + fn get_modules_for_hook(_e: &Env, _hook: ComplianceHook) -> Vec
{ + unreachable!("get_modules_for_hook is not used in these tests"); + } + + fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool { + e.storage().persistent().has(&MockComplianceStorageKey::Registered(hook, module)) + } + + fn transferred(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) { + unreachable!("transferred is not used in these tests"); + } + + fn created(_e: &Env, _to: Address, _amount: i128, _token: Address) { + unreachable!("created is not used in these tests"); + } + + fn destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) { + unreachable!("destroyed is not used in these tests"); + } + + fn can_transfer( + _e: &Env, + _from: Address, + _to: Address, + _amount: i128, + _token: Address, + ) -> bool { + unreachable!("can_transfer is not used in these tests"); + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + unreachable!("can_create is not used in these tests"); + } +} + +#[contractimpl] +impl TokenBinder for MockComplianceContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl MockComplianceContract { + pub fn register_hook(e: &Env, hook: ComplianceHook, module: Address) { + e.storage().persistent().set(&MockComplianceStorageKey::Registered(hook, module), &true); + } +} + +#[contract] +struct TestTimeTransfersLimitsContract; + +#[contractimpl(contracttrait)] +impl TimeTransfersLimits for TestTimeTransfersLimitsContract { + fn set_compliance_address(_e: &Env, _compliance: Address) { + unreachable!("set_compliance_address is not used in these tests"); + } +} + +fn arm_hooks(e: &Env) { + e.storage().instance().set(&ComplianceModuleStorageKey::HooksVerified, &true); +} + +#[test] +fn verify_hook_wiring_sets_cache_when_registered() { + let e = Env::default(); + let module_id = e.register(TestTimeTransfersLimitsContract, ()); + let compliance_id = e.register(MockComplianceContract, ()); + let compliance = MockComplianceContractClient::new(&e, &compliance_id); + + for hook in [ComplianceHook::CanTransfer, ComplianceHook::Transferred] { + compliance.register_hook(&hook, &module_id); + } + + e.as_contract(&module_id, || { + set_compliance_address(&e, &compliance_id); + + ::verify_hook_wiring(&e); + + assert!(hooks_verified(&e)); + }); +} + +#[test] +fn pre_set_transfer_counter_blocks_transfers_within_active_window() { + let e = Env::default(); + e.mock_all_auths(); + + let module_id = e.register(TestTimeTransfersLimitsContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let sender_identity = Address::generate(&e); + let recipient = Address::generate(&e); + let client = TestTimeTransfersLimitsContractClient::new(&e, &module_id); + + irs.set_identity(&sender, &sender_identity); + + e.as_contract(&module_id, || { + set_compliance_address(&e, &compliance); + set_irs_address(&e, &token, &irs_id); + arm_hooks(&e); + }); + + client.set_time_transfer_limit(&token, &Limit { limit_time: 60, limit_value: 100 }); + client.pre_set_transfer_counter( + &token, + &sender_identity, + &60, + &TransferCounter { value: 90, timer: e.ledger().timestamp().saturating_add(60) }, + ); + + assert!(!client.can_transfer(&sender.clone(), &recipient.clone(), &11, &token)); + assert!(client.can_transfer(&sender, &recipient, &10, &token)); +} + +#[test] +#[should_panic(expected = "Error(Contract, #400)")] +fn set_time_transfer_limit_rejects_more_than_four_limits() { + let e = Env::default(); + e.mock_all_auths(); + + let module_id = e.register(TestTimeTransfersLimitsContract, ()); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let client = TestTimeTransfersLimitsContractClient::new(&e, &module_id); + + e.as_contract(&module_id, || { + set_compliance_address(&e, &compliance); + }); + + for limit_time in [60_u64, 120, 180, 240] { + client.set_time_transfer_limit(&token, &Limit { limit_time, limit_value: 100 }); + } + + client.set_time_transfer_limit(&token, &Limit { limit_time: 300, limit_value: 100 }); +} diff --git a/packages/tokens/src/rwa/compliance/modules/transfer_restrict/mod.rs b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/mod.rs new file mode 100644 index 000000000..1198a0eb2 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/mod.rs @@ -0,0 +1,200 @@ +//! Transfer restriction (address allowlist) compliance module — Stellar port +//! of T-REX [`TransferRestrictModule.sol`][trex-src]. +//! +//! Maintains a per-token address allowlist. Transfers pass if the sender is +//! on the list; otherwise the recipient must be. +//! +//! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/TransferRestrictModule.sol + +pub mod storage; +#[cfg(test)] +mod test; + +use soroban_sdk::{contractevent, contracttrait, Address, Env, String, Vec}; +use storage::{is_user_allowed, remove_user_allowed, set_user_allowed}; + +use super::storage::{get_compliance_address, module_name}; + +/// Emitted when an address is added to the transfer allowlist. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UserAllowed { + #[topic] + pub token: Address, + pub user: Address, +} + +/// Emitted when an address is removed from the transfer allowlist. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UserDisallowed { + #[topic] + pub token: Address, + pub user: Address, +} + +/// Transfer restriction compliance trait. +/// +/// Provides default implementations for maintaining a per-token address +/// allowlist. Transfers are allowed if the sender is allowlisted; otherwise +/// the recipient must be (T-REX semantics). +#[contracttrait] +pub trait TransferRestrict { + /// Adds `user` to the transfer allowlist for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `user` - The address to allow. + /// + /// # Authorization + /// + /// Requires compliance contract authorization. + /// + /// # Events + /// + /// Emits [`UserAllowed`]. + fn allow_user(e: &Env, token: Address, user: Address) { + get_compliance_address(e).require_auth(); + set_user_allowed(e, &token, &user); + UserAllowed { token, user }.publish(e); + } + + /// Removes `user` from the transfer allowlist for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `user` - The address to disallow. + /// + /// # Authorization + /// + /// Requires compliance contract authorization. + /// + /// # Events + /// + /// Emits [`UserDisallowed`]. + fn disallow_user(e: &Env, token: Address, user: Address) { + get_compliance_address(e).require_auth(); + remove_user_allowed(e, &token, &user); + UserDisallowed { token, user }.publish(e); + } + + /// Adds multiple users to the transfer allowlist in a single call. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `users` - The addresses to allow. + /// + /// # Authorization + /// + /// Requires compliance contract authorization. + /// + /// # Events + /// + /// Emits [`UserAllowed`] for each user added. + fn batch_allow_users(e: &Env, token: Address, users: Vec
) { + get_compliance_address(e).require_auth(); + for user in users.iter() { + set_user_allowed(e, &token, &user); + UserAllowed { token: token.clone(), user }.publish(e); + } + } + + /// Removes multiple users from the transfer allowlist in a single call. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `users` - The addresses to disallow. + /// + /// # Authorization + /// + /// Requires compliance contract authorization. + /// + /// # Events + /// + /// Emits [`UserDisallowed`] for each user removed. + fn batch_disallow_users(e: &Env, token: Address, users: Vec
) { + get_compliance_address(e).require_auth(); + for user in users.iter() { + remove_user_allowed(e, &token, &user); + UserDisallowed { token: token.clone(), user }.publish(e); + } + } + + /// Returns whether `user` is on the transfer allowlist for `token`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `token` - The token address. + /// * `user` - The address to check. + fn is_user_allowed(e: &Env, token: Address, user: Address) -> bool { + is_user_allowed(e, &token, &user) + } + + /// No-op — this module does not track transfer state. + fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {} + + /// No-op — this module does not track mint state. + fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {} + + /// No-op — this module does not track burn state. + fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {} + + /// Checks whether the transfer is allowed by the address allowlist. + /// + /// T-REX semantics: if the sender is allowlisted, the transfer passes; + /// otherwise the recipient must be allowlisted. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `from` - The sender address. + /// * `to` - The recipient address. + /// * `_amount` - The transfer amount (unused). + /// * `token` - The token address. + /// + /// # Returns + /// + /// `true` if the sender or recipient is allowlisted, `false` otherwise. + fn can_transfer(e: &Env, from: Address, to: Address, _amount: i128, token: Address) -> bool { + if is_user_allowed(e, &token, &from) { + return true; + } + is_user_allowed(e, &token, &to) + } + + /// Always returns `true` — mints are not restricted by this module. + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + true + } + + /// Returns the module name for identification. + fn name(e: &Env) -> String { + module_name(e, "TransferRestrictModule") + } + + /// Returns the compliance contract address. + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + /// Sets the compliance contract address (one-time only). + /// + /// Implementers must gate this entrypoint with bootstrap-admin auth before + /// delegating to + /// [`storage::set_compliance_address`](super::storage::set_compliance_address). + /// + /// + /// # Panics + /// + /// Panics if the compliance address has already been set. + fn set_compliance_address(e: &Env, compliance: Address); +} diff --git a/packages/tokens/src/rwa/compliance/modules/transfer_restrict/storage.rs b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/storage.rs new file mode 100644 index 000000000..8fa25912f --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/storage.rs @@ -0,0 +1,54 @@ +use soroban_sdk::{contracttype, Address, Env}; + +use crate::rwa::compliance::modules::{MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD}; + +#[contracttype] +#[derive(Clone)] +pub enum TransferRestrictStorageKey { + /// Per-(token, address) allowlist flag. + AllowedUser(Address, Address), +} + +/// Returns whether `user` is on the transfer allowlist for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `user` - The user address to check. +pub fn is_user_allowed(e: &Env, token: &Address, user: &Address) -> bool { + let key = TransferRestrictStorageKey::AllowedUser(token.clone(), user.clone()); + e.storage() + .persistent() + .get(&key) + .inspect(|_: &bool| { + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); + }) + .unwrap_or_default() +} + +/// Adds `user` to the transfer allowlist for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `user` - The user address to allow. +pub fn set_user_allowed(e: &Env, token: &Address, user: &Address) { + let key = TransferRestrictStorageKey::AllowedUser(token.clone(), user.clone()); + e.storage().persistent().set(&key, &true); + e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); +} + +/// Removes `user` from the transfer allowlist for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `user` - The user address to disallow. +pub fn remove_user_allowed(e: &Env, token: &Address, user: &Address) { + e.storage() + .persistent() + .remove(&TransferRestrictStorageKey::AllowedUser(token.clone(), user.clone())); +} diff --git a/packages/tokens/src/rwa/compliance/modules/transfer_restrict/test.rs b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/test.rs new file mode 100644 index 000000000..3ae8ba642 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/transfer_restrict/test.rs @@ -0,0 +1,70 @@ +extern crate std; + +use soroban_sdk::{contract, contractimpl, testutils::Address as _, vec, Address, Env}; + +use super::*; +use crate::rwa::compliance::modules::storage::set_compliance_address; + +#[contract] +struct TestTransferRestrictContract; + +#[contractimpl(contracttrait)] +impl TransferRestrict for TestTransferRestrictContract { + fn set_compliance_address(_e: &Env, _compliance: Address) { + unreachable!("set_compliance_address is not used in these tests"); + } +} + +#[test] +fn can_transfer_allows_sender_or_recipient_when_allowlisted() { + let e = Env::default(); + e.mock_all_auths(); + + let module_id = e.register(TestTransferRestrictContract, ()); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let recipient = Address::generate(&e); + let outsider = Address::generate(&e); + let client = TestTransferRestrictContractClient::new(&e, &module_id); + + e.as_contract(&module_id, || { + set_compliance_address(&e, &compliance); + }); + + assert!(!client.can_transfer(&sender.clone(), &recipient.clone(), &100, &token)); + + client.allow_user(&token, &sender.clone()); + assert!(client.can_transfer(&sender.clone(), &outsider.clone(), &100, &token)); + + client.disallow_user(&token, &sender.clone()); + client.allow_user(&token, &recipient.clone()); + assert!(client.can_transfer(&outsider, &recipient, &100, &token)); +} + +#[test] +fn batch_allow_and_disallow_update_allowlist_entries() { + let e = Env::default(); + e.mock_all_auths(); + + let module_id = e.register(TestTransferRestrictContract, ()); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let user_a = Address::generate(&e); + let user_b = Address::generate(&e); + let client = TestTransferRestrictContractClient::new(&e, &module_id); + + e.as_contract(&module_id, || { + set_compliance_address(&e, &compliance); + }); + + client.batch_allow_users(&token, &vec![&e, user_a.clone(), user_b.clone()]); + + assert!(client.is_user_allowed(&token, &user_a.clone())); + assert!(client.is_user_allowed(&token, &user_b.clone())); + + client.batch_disallow_users(&token, &vec![&e, user_a.clone(), user_b.clone()]); + + assert!(!client.is_user_allowed(&token, &user_a)); + assert!(!client.is_user_allowed(&token, &user_b)); +} From d131291a760496d8dd506234c09fa83bca425935 Mon Sep 17 00:00:00 2001 From: Aleksandr Pasevin Date: Mon, 23 Mar 2026 19:27:47 +0200 Subject: [PATCH 2/7] fix(rwa): align standalone deploy e2e with current irs cli encoding Encode IRS country profiles as explicit ScVal payloads and reuse the existing invoke retry helper so the standalone deploy branch passes testnet e2e. --- examples/rwa-deploy/scripts/common.sh | 76 ++++++++++++++++++++++++++- examples/rwa-deploy/scripts/e2e.sh | 6 +-- 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/examples/rwa-deploy/scripts/common.sh b/examples/rwa-deploy/scripts/common.sh index cfd8e4043..cf5af346a 100644 --- a/examples/rwa-deploy/scripts/common.sh +++ b/examples/rwa-deploy/scripts/common.sh @@ -56,6 +56,33 @@ invoke_readonly() { -- "${@:2}" } +invoke_with_retry() { + local attempts=${STELLAR_INVOKE_RETRIES:-4} + local delay=${STELLAR_INVOKE_RETRY_DELAY_SECONDS:-3} + local attempt output status + + for attempt in $(seq 1 "$attempts"); do + if output=$(invoke "$@" 2>&1); then + printf '%s\n' "$output" + return 0 + fi + status=$? + + if ! retryable_invoke_error "$output"; then + printf '%s\n' "$output" >&2 + return "$status" + fi + + if [ "$attempt" -eq "$attempts" ]; then + printf '%s\n' "$output" >&2 + return "$status" + fi + + echo "Retrying deploy invoke after transient Stellar CLI failure..." >&2 + sleep $((delay * attempt)) + done +} + read_addr() { python3 -c "import json; d=json.load(open('$ADDR_FILE')); print(d$1)" } @@ -184,15 +211,62 @@ sys.exit(0 if payload == expected else 1) ' <<<"$output" } +country_profiles_to_scval_json() { + python3 - "$1" <<'PY' +import json +import sys + +profiles = json.loads(sys.argv[1]) + +def sc_symbol(value): + return {"symbol": value} + +def sc_string(value): + return {"string": value} + +def sc_u32(value): + return {"u32": value} + +def metadata_to_scval(metadata): + if metadata is None: + return "void" + + return { + "map": [ + {"key": sc_string(key), "val": sc_string(value)} + for key, value in sorted(metadata.items()) + ] + } + +def enum_to_scval(enum_value): + [(outer_name, outer_payload)] = enum_value.items() + [(inner_name, inner_payload)] = outer_payload.items() + return {"vec": [sc_symbol(outer_name), {"vec": [sc_symbol(inner_name), sc_u32(inner_payload)]}]} + +def country_data_to_scval(profile): + return { + "map": [ + {"key": sc_symbol("country"), "val": enum_to_scval(profile["country"])}, + {"key": sc_symbol("metadata"), "val": metadata_to_scval(profile.get("metadata"))}, + ] + } + +print(json.dumps([country_data_to_scval(profile) for profile in profiles], separators=(",", ":"))) +PY +} + ensure_identity_registered() { local contract_addr=$1 local account=$2 local identity=$3 local profiles_json=$4 + local profiles_scval_json local attempts=${STELLAR_INVOKE_RETRIES:-4} local delay=${STELLAR_INVOKE_RETRY_DELAY_SECONDS:-3} local attempt output status + profiles_scval_json=$(country_profiles_to_scval_json "$profiles_json") + if identity_matches "$contract_addr" "$account" "$identity"; then echo " Identity already registered for $account." return 0 @@ -202,7 +276,7 @@ ensure_identity_registered() { if output=$(invoke "$contract_addr" add_identity \ --account "$account" \ --identity "$identity" \ - --initial_profiles "$profiles_json" \ + --initial_profiles "$profiles_scval_json" \ --operator "$ADMIN" 2>&1); then printf '%s\n' "$output" return 0 diff --git a/examples/rwa-deploy/scripts/e2e.sh b/examples/rwa-deploy/scripts/e2e.sh index 9bbedacb0..c18a6a191 100755 --- a/examples/rwa-deploy/scripts/e2e.sh +++ b/examples/rwa-deploy/scripts/e2e.sh @@ -233,7 +233,7 @@ EOF run_supply_limit_tests() { test_header "Test 1: Mint 1000 tokens" - assert_pass "mint 1000" invoke "$TOKEN" mint --to "$INVESTOR" --amount 1000 --operator "$ADMIN" + assert_pass "mint 1000" invoke_with_retry "$TOKEN" mint --to "$INVESTOR" --amount 1000 --operator "$ADMIN" BAL=$(get_balance "$INVESTOR") assert_eq "balance = 1000" "1000" "$BAL" @@ -278,7 +278,7 @@ run_lockup_tests() { run_balance_tests() { # Supply is 1000 from Test 1. Mint 1000 more -> internal supply = 2000. test_header "Test 5: Mint more (supply counter tracks)" - assert_pass "mint 1000 more" invoke "$TOKEN" mint --to "$INVESTOR" --amount 1000 --operator "$ADMIN" + assert_pass "mint 1000 more" invoke_with_retry "$TOKEN" mint --to "$INVESTOR" --amount 1000 --operator "$ADMIN" BAL=$(get_balance "$INVESTOR") assert_eq "balance = 2000" "2000" "$BAL" @@ -290,7 +290,7 @@ run_balance_tests() { # MaxBalance is 1,000,000 per identity. Investor has 2000 already. # Mint 998,000 more to hit the identity cap exactly. test_header "Test 6: Mint to max-balance ceiling" - assert_pass "mint 998000 (fill to 1M)" invoke "$TOKEN" mint --to "$INVESTOR" --amount 998000 --operator "$ADMIN" + assert_pass "mint 998000 (fill to 1M)" invoke_with_retry "$TOKEN" mint --to "$INVESTOR" --amount 998000 --operator "$ADMIN" BAL=$(get_balance "$INVESTOR") assert_eq "balance = 1000000" "1000000" "$BAL" From 66aac411d38f9573d51c77d9e5936673b116d32e Mon Sep 17 00:00:00 2001 From: Aleksandr Pasevin Date: Mon, 23 Mar 2026 20:44:45 +0200 Subject: [PATCH 3/7] refactor(rwa): deduplicate deploy retry helpers Keep the retrying invoke helper in common.sh, remove script-local duplicates, and use a neutral retry message across the deploy scripts. --- examples/rwa-deploy/scripts/common.sh | 2 +- examples/rwa-deploy/scripts/deploy.sh | 27 ------------------- .../rwa-deploy/scripts/test-happy-path.sh | 27 ------------------- 3 files changed, 1 insertion(+), 55 deletions(-) diff --git a/examples/rwa-deploy/scripts/common.sh b/examples/rwa-deploy/scripts/common.sh index cf5af346a..4416657d2 100644 --- a/examples/rwa-deploy/scripts/common.sh +++ b/examples/rwa-deploy/scripts/common.sh @@ -78,7 +78,7 @@ invoke_with_retry() { return "$status" fi - echo "Retrying deploy invoke after transient Stellar CLI failure..." >&2 + echo "Retrying invoke after transient Stellar CLI failure..." >&2 sleep $((delay * attempt)) done } diff --git a/examples/rwa-deploy/scripts/deploy.sh b/examples/rwa-deploy/scripts/deploy.sh index a3cf4a869..7e460d401 100755 --- a/examples/rwa-deploy/scripts/deploy.sh +++ b/examples/rwa-deploy/scripts/deploy.sh @@ -84,33 +84,6 @@ ERROR: deploy returned an empty or invalid contract id for $LABEL" done } -invoke_with_retry() { - local attempts=${STELLAR_INVOKE_RETRIES:-4} - local delay=${STELLAR_INVOKE_RETRY_DELAY_SECONDS:-3} - local attempt output status - - for attempt in $(seq 1 "$attempts"); do - if output=$(invoke "$@" 2>&1); then - printf '%s\n' "$output" - return 0 - fi - status=$? - - if ! retryable_invoke_error "$output"; then - printf '%s\n' "$output" >&2 - return "$status" - fi - - if [ "$attempt" -eq "$attempts" ]; then - printf '%s\n' "$output" >&2 - return "$status" - fi - - echo "Retrying deploy invoke after transient Stellar CLI failure..." >&2 - sleep $((delay * attempt)) - done -} - write_addresses() { cat > "$ADDR_FILE" <&1); then - printf '%s\n' "$output" - return 0 - fi - status=$? - - if ! retryable_invoke_error "$output"; then - printf '%s\n' "$output" >&2 - return "$status" - fi - - if [ "$attempt" -eq "$attempts" ]; then - printf '%s\n' "$output" >&2 - return "$status" - fi - - echo " Retrying after transient Stellar CLI failure..." >&2 - sleep $((delay * attempt)) - done -} - echo "=== Happy Path Test ===" echo "Token: $TOKEN" echo "Investor: $INVESTOR" From db39c91844df44928798eba5afe480c4b2de4d28 Mon Sep 17 00:00:00 2001 From: Aleksandr Pasevin Date: Fri, 10 Apr 2026 12:40:42 +0300 Subject: [PATCH 4/7] chore(cargo): regenerate lockfile Refresh transitive dependency resolutions after merge conflict resolution. --- Cargo.lock | 133 ++++++++++++++++++++++++++--------------------------- 1 file changed, 66 insertions(+), 67 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index de34cc488..f1e8c68fe 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", ] @@ -675,9 +674,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" @@ -941,9 +940,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" @@ -1040,12 +1039,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", ] @@ -1067,15 +1066,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", @@ -1112,9 +1111,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" @@ -1268,9 +1267,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" @@ -1303,9 +1302,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" @@ -1407,9 +1406,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", @@ -1789,9 +1788,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" @@ -1848,15 +1847,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", @@ -1868,11 +1867,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", @@ -2016,9 +2015,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", @@ -2030,9 +2029,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", @@ -2054,9 +2053,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", @@ -2074,9 +2073,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", @@ -2087,9 +2086,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", @@ -2513,9 +2512,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", @@ -2526,9 +2525,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", @@ -2536,9 +2535,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", @@ -2549,9 +2548,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", ] @@ -2573,7 +2572,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", ] @@ -2602,7 +2601,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", ] @@ -2614,7 +2613,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", "hashbrown 0.15.5", - "indexmap 2.13.0", + "indexmap 2.14.0", "semver", ] @@ -2723,7 +2722,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap 2.13.0", + "indexmap 2.14.0", "prettyplease", "syn 2.0.117", "wasm-metadata", @@ -2754,7 +2753,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", - "indexmap 2.13.0", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -2773,7 +2772,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap 2.13.0", + "indexmap 2.14.0", "log", "semver", "serde", @@ -2785,18 +2784,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 65480fa38ef8077139f297123040cde665333b24 Mon Sep 17 00:00:00 2001 From: Aleksandr Pasevin Date: Fri, 10 Apr 2026 15:40:59 +0300 Subject: [PATCH 5/7] refactor: align standalone modules with storage-first pattern for deploy branch Apply the canonical lib.rs / contract.rs layout to all standalone module examples. Move module logic into storage.rs free functions across all seven compliance modules. Delete standalone example READMEs. --- examples/rwa-country-allow/README.md | 50 -- examples/rwa-country-allow/src/contract.rs | 101 ++++ examples/rwa-country-allow/src/lib.rs | 87 +--- examples/rwa-country-restrict/README.md | 50 -- examples/rwa-country-restrict/src/contract.rs | 101 ++++ examples/rwa-country-restrict/src/lib.rs | 87 +--- 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-max-balance/README.md | 62 --- examples/rwa-max-balance/src/contract.rs | 125 +++++ examples/rwa-max-balance/src/lib.rs | 166 +------ examples/rwa-supply-limit/README.md | 61 --- examples/rwa-supply-limit/src/contract.rs | 112 +++++ examples/rwa-supply-limit/src/lib.rs | 101 +--- 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 +--- .../compliance/modules/country_allow/mod.rs | 430 +---------------- .../modules/country_allow/storage.rs | 105 ++++- .../compliance/modules/country_allow/test.rs | 183 ++++++++ .../modules/country_restrict/mod.rs | 434 +----------------- .../modules/country_restrict/storage.rs | 103 ++++- .../modules/country_restrict/test.rs | 186 ++++++++ .../modules/initial_lockup_period/mod.rs | 252 +--------- .../modules/initial_lockup_period/storage.rs | 272 ++++++++++- .../modules/initial_lockup_period/test.rs | 63 +-- .../rwa/compliance/modules/max_balance/mod.rs | 188 +------- .../compliance/modules/max_balance/storage.rs | 244 +++++++++- .../compliance/modules/max_balance/test.rs | 60 +-- .../compliance/modules/supply_limit/mod.rs | 97 +--- .../modules/supply_limit/storage.rs | 108 ++++- .../compliance/modules/supply_limit/test.rs | 75 +-- .../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 +-- 42 files changed, 2416 insertions(+), 3349 deletions(-) delete mode 100644 examples/rwa-country-allow/README.md create mode 100644 examples/rwa-country-allow/src/contract.rs delete mode 100644 examples/rwa-country-restrict/README.md create mode 100644 examples/rwa-country-restrict/src/contract.rs 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-max-balance/README.md create mode 100644 examples/rwa-max-balance/src/contract.rs delete mode 100644 examples/rwa-supply-limit/README.md create mode 100644 examples/rwa-supply-limit/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 create mode 100644 packages/tokens/src/rwa/compliance/modules/country_allow/test.rs create mode 100644 packages/tokens/src/rwa/compliance/modules/country_restrict/test.rs diff --git a/examples/rwa-country-allow/README.md b/examples/rwa-country-allow/README.md deleted file mode 100644 index f0b88aa54..000000000 --- a/examples/rwa-country-allow/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# Country Allow Module - -Concrete deployable example of the `CountryAllow` compliance module for Stellar -RWA tokens. - -## What it enforces - -This module allows tokens to be minted or transferred only to recipients whose -registered identity has at least one country code that appears in the module's -per-token allowlist. - -The country lookup is performed through the Identity Registry Storage (IRS), so -the module must be configured with an IRS contract for each token it serves. - -## Authorization model - -This example uses the bootstrap-admin pattern introduced in this port: - -- The constructor stores a one-time `admin` -- Before `set_compliance_address`, privileged configuration calls require that - admin's auth -- After `set_compliance_address`, the same configuration calls require auth - from the bound Compliance contract -- `set_compliance_address` itself remains a one-time admin action - -This lets the module be configured from the CLI before it is locked to the -Compliance contract. - -## Main entrypoints - -- `__constructor(admin)` initializes the bootstrap admin -- `set_identity_registry_storage(token, irs)` stores the IRS address for a - token -- `add_allowed_country(token, country)` adds an ISO 3166-1 numeric code to the - allowlist -- `remove_allowed_country(token, country)` removes a country code -- `batch_allow_countries(token, countries)` updates multiple entries -- `batch_disallow_countries(token, countries)` removes multiple entries -- `is_country_allowed(token, country)` reads the current allowlist state -- `set_compliance_address(compliance)` performs the one-time handoff to the - Compliance contract - -## Notes - -- Storage is token-scoped, so one deployed module can be reused across many - tokens -- This module validates on the compliance read hooks used for transfers and - mints; it does not require extra state-tracking hooks -- In the deploy example, the module is configured before binding and then wired - to the `CanTransfer` and `CanCreate` hooks diff --git a/examples/rwa-country-allow/src/contract.rs b/examples/rwa-country-allow/src/contract.rs new file mode 100644 index 000000000..9be520c25 --- /dev/null +++ b/examples/rwa-country-allow/src/contract.rs @@ -0,0 +1,101 @@ +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::modules::{ + country_allow::storage as country_allow, + storage::{ + get_compliance_address, module_name, set_compliance_address, set_irs_address, + ComplianceModuleStorageKey, + }, + ComplianceModule, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct CountryAllowContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl CountryAllowContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } + + pub fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { + require_module_admin_or_compliance_auth(e); + set_irs_address(e, &token, &irs); + } + + pub fn add_allowed_country(e: &Env, token: Address, country: u32) { + require_module_admin_or_compliance_auth(e); + country_allow::add_allowed_country(e, &token, country); + } + + pub fn remove_allowed_country(e: &Env, token: Address, country: u32) { + require_module_admin_or_compliance_auth(e); + country_allow::remove_allowed_country(e, &token, country); + } + + pub fn batch_allow_countries(e: &Env, token: Address, countries: Vec) { + require_module_admin_or_compliance_auth(e); + country_allow::batch_allow_countries(e, &token, &countries); + } + + pub fn batch_disallow_countries(e: &Env, token: Address, countries: Vec) { + require_module_admin_or_compliance_auth(e); + country_allow::batch_disallow_countries(e, &token, &countries); + } + + pub fn is_country_allowed(e: &Env, token: Address, country: u32) -> bool { + country_allow::is_country_allowed(e, &token, country) + } +} + +#[contractimpl(contracttrait)] +impl ComplianceModule for CountryAllowContract { + fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {} + + fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {} + + fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {} + + fn can_transfer(e: &Env, _from: Address, to: Address, _amount: i128, token: Address) -> bool { + country_allow::can_transfer(e, &to, &token) + } + + fn can_create(e: &Env, to: Address, _amount: i128, token: Address) -> bool { + country_allow::can_transfer(e, &to, &token) + } + + fn name(e: &Env) -> String { + module_name(e, "CountryAllowModule") + } + + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-country-allow/src/lib.rs b/examples/rwa-country-allow/src/lib.rs index f2fd11b23..b9aa4d5f0 100644 --- a/examples/rwa-country-allow/src/lib.rs +++ b/examples/rwa-country-allow/src/lib.rs @@ -1,88 +1,3 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; -use stellar_tokens::rwa::compliance::modules::{ - country_allow::{ - storage::{is_country_allowed, remove_country_allowed, set_country_allowed}, - CountryAllow, CountryAllowed, CountryUnallowed, - }, - storage::{set_compliance_address, set_irs_address, ComplianceModuleStorageKey}, -}; - -#[contracttype] -enum DataKey { - Admin, -} - -#[contract] -pub struct CountryAllowContract; - -fn set_admin(e: &Env, admin: &Address) { - e.storage().instance().set(&DataKey::Admin, admin); -} - -fn get_admin(e: &Env) -> Address { - e.storage().instance().get(&DataKey::Admin).expect("admin must be set") -} - -fn require_module_admin_or_compliance_auth(e: &Env) { - if let Some(compliance) = - e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) - { - compliance.require_auth(); - } else { - get_admin(e).require_auth(); - } -} - -#[contractimpl] -impl CountryAllowContract { - pub fn __constructor(e: &Env, admin: Address) { - set_admin(e, &admin); - } -} - -#[contractimpl(contracttrait)] -impl CountryAllow for CountryAllowContract { - fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { - require_module_admin_or_compliance_auth(e); - set_irs_address(e, &token, &irs); - } - - fn add_allowed_country(e: &Env, token: Address, country: u32) { - require_module_admin_or_compliance_auth(e); - set_country_allowed(e, &token, country); - CountryAllowed { token, country }.publish(e); - } - - fn remove_allowed_country(e: &Env, token: Address, country: u32) { - require_module_admin_or_compliance_auth(e); - remove_country_allowed(e, &token, country); - CountryUnallowed { token, country }.publish(e); - } - - fn batch_allow_countries(e: &Env, token: Address, countries: Vec) { - require_module_admin_or_compliance_auth(e); - for country in countries.iter() { - set_country_allowed(e, &token, country); - CountryAllowed { token: token.clone(), country }.publish(e); - } - } - - fn batch_disallow_countries(e: &Env, token: Address, countries: Vec) { - require_module_admin_or_compliance_auth(e); - for country in countries.iter() { - remove_country_allowed(e, &token, country); - CountryUnallowed { token: token.clone(), country }.publish(e); - } - } - - fn is_country_allowed(e: &Env, token: Address, country: u32) -> bool { - is_country_allowed(e, &token, country) - } - - fn set_compliance_address(e: &Env, compliance: Address) { - get_admin(e).require_auth(); - set_compliance_address(e, &compliance); - } -} +pub mod contract; diff --git a/examples/rwa-country-restrict/README.md b/examples/rwa-country-restrict/README.md deleted file mode 100644 index 104bf6066..000000000 --- a/examples/rwa-country-restrict/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# Country Restrict Module - -Concrete deployable example of the `CountryRestrict` compliance module for -Stellar RWA tokens. - -## What it enforces - -This module blocks tokens from being minted or transferred to recipients whose -registered identity has a country code that appears in the module's per-token -restriction list. - -The country lookup is performed through the Identity Registry Storage (IRS), so -the module must be configured with an IRS contract for each token it serves. - -## Authorization model - -This example uses the bootstrap-admin pattern introduced in this port: - -- The constructor stores a one-time `admin` -- Before `set_compliance_address`, privileged configuration calls require that - admin's auth -- After `set_compliance_address`, the same configuration calls require auth - from the bound Compliance contract -- `set_compliance_address` itself remains a one-time admin action - -This lets the module be configured from the CLI before it is locked to the -Compliance contract. - -## Main entrypoints - -- `__constructor(admin)` initializes the bootstrap admin -- `set_identity_registry_storage(token, irs)` stores the IRS address for a - token -- `add_country_restriction(token, country)` adds an ISO 3166-1 numeric code to - the restriction list -- `remove_country_restriction(token, country)` removes a country code -- `batch_restrict_countries(token, countries)` updates multiple entries -- `batch_unrestrict_countries(token, countries)` removes multiple entries -- `is_country_restricted(token, country)` reads the current restriction state -- `set_compliance_address(compliance)` performs the one-time handoff to the - Compliance contract - -## Notes - -- Storage is token-scoped, so one deployed module can be reused across many - tokens -- This module validates on the compliance read hooks used for transfers and - mints; it does not require extra state-tracking hooks -- In the deploy example, the module is configured before binding and then wired - to the `CanTransfer` and `CanCreate` hooks diff --git a/examples/rwa-country-restrict/src/contract.rs b/examples/rwa-country-restrict/src/contract.rs new file mode 100644 index 000000000..aa2c9794c --- /dev/null +++ b/examples/rwa-country-restrict/src/contract.rs @@ -0,0 +1,101 @@ +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::modules::{ + country_restrict::storage as country_restrict, + storage::{ + get_compliance_address, module_name, set_compliance_address, set_irs_address, + ComplianceModuleStorageKey, + }, + ComplianceModule, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct CountryRestrictContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl CountryRestrictContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } + + pub fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { + require_module_admin_or_compliance_auth(e); + set_irs_address(e, &token, &irs); + } + + pub fn add_country_restriction(e: &Env, token: Address, country: u32) { + require_module_admin_or_compliance_auth(e); + country_restrict::add_country_restriction(e, &token, country); + } + + pub fn remove_country_restriction(e: &Env, token: Address, country: u32) { + require_module_admin_or_compliance_auth(e); + country_restrict::remove_country_restriction(e, &token, country); + } + + pub fn batch_restrict_countries(e: &Env, token: Address, countries: Vec) { + require_module_admin_or_compliance_auth(e); + country_restrict::batch_restrict_countries(e, &token, &countries); + } + + pub fn batch_unrestrict_countries(e: &Env, token: Address, countries: Vec) { + require_module_admin_or_compliance_auth(e); + country_restrict::batch_unrestrict_countries(e, &token, &countries); + } + + pub fn is_country_restricted(e: &Env, token: Address, country: u32) -> bool { + country_restrict::is_country_restricted(e, &token, country) + } +} + +#[contractimpl(contracttrait)] +impl ComplianceModule for CountryRestrictContract { + fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {} + + fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {} + + fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {} + + fn can_transfer(e: &Env, _from: Address, to: Address, _amount: i128, token: Address) -> bool { + country_restrict::can_transfer(e, &to, &token) + } + + fn can_create(e: &Env, to: Address, _amount: i128, token: Address) -> bool { + country_restrict::can_transfer(e, &to, &token) + } + + fn name(e: &Env) -> String { + module_name(e, "CountryRestrictModule") + } + + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-country-restrict/src/lib.rs b/examples/rwa-country-restrict/src/lib.rs index 8d8d4c140..b9aa4d5f0 100644 --- a/examples/rwa-country-restrict/src/lib.rs +++ b/examples/rwa-country-restrict/src/lib.rs @@ -1,88 +1,3 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; -use stellar_tokens::rwa::compliance::modules::{ - country_restrict::{ - storage::{is_country_restricted, remove_country_restricted, set_country_restricted}, - CountryRestrict, CountryRestricted, CountryUnrestricted, - }, - storage::{set_compliance_address, set_irs_address, ComplianceModuleStorageKey}, -}; - -#[contracttype] -enum DataKey { - Admin, -} - -#[contract] -pub struct CountryRestrictContract; - -fn set_admin(e: &Env, admin: &Address) { - e.storage().instance().set(&DataKey::Admin, admin); -} - -fn get_admin(e: &Env) -> Address { - e.storage().instance().get(&DataKey::Admin).expect("admin must be set") -} - -fn require_module_admin_or_compliance_auth(e: &Env) { - if let Some(compliance) = - e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) - { - compliance.require_auth(); - } else { - get_admin(e).require_auth(); - } -} - -#[contractimpl] -impl CountryRestrictContract { - pub fn __constructor(e: &Env, admin: Address) { - set_admin(e, &admin); - } -} - -#[contractimpl(contracttrait)] -impl CountryRestrict for CountryRestrictContract { - fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { - require_module_admin_or_compliance_auth(e); - set_irs_address(e, &token, &irs); - } - - fn add_country_restriction(e: &Env, token: Address, country: u32) { - require_module_admin_or_compliance_auth(e); - set_country_restricted(e, &token, country); - CountryRestricted { token, country }.publish(e); - } - - fn remove_country_restriction(e: &Env, token: Address, country: u32) { - require_module_admin_or_compliance_auth(e); - remove_country_restricted(e, &token, country); - CountryUnrestricted { token, country }.publish(e); - } - - fn batch_restrict_countries(e: &Env, token: Address, countries: Vec) { - require_module_admin_or_compliance_auth(e); - for country in countries.iter() { - set_country_restricted(e, &token, country); - CountryRestricted { token: token.clone(), country }.publish(e); - } - } - - fn batch_unrestrict_countries(e: &Env, token: Address, countries: Vec) { - require_module_admin_or_compliance_auth(e); - for country in countries.iter() { - remove_country_restricted(e, &token, country); - CountryUnrestricted { token: token.clone(), country }.publish(e); - } - } - - fn is_country_restricted(e: &Env, token: Address, country: u32) -> bool { - is_country_restricted(e, &token, country) - } - - fn set_compliance_address(e: &Env, compliance: Address) { - get_admin(e).require_auth(); - set_compliance_address(e, &compliance); - } -} +pub mod contract; 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-max-balance/README.md b/examples/rwa-max-balance/README.md deleted file mode 100644 index 193ebfa64..000000000 --- a/examples/rwa-max-balance/README.md +++ /dev/null @@ -1,62 +0,0 @@ -# Max Balance Module - -Concrete deployable example of the `MaxBalance` compliance module for Stellar -RWA tokens. - -## What it enforces - -This module tracks balances per investor identity, not per wallet, and enforces -a maximum balance cap for each token. - -Because the accounting is identity-based, the module must be configured with an -Identity Registry Storage (IRS) contract for each token it serves. - -## How it stays in sync - -The module maintains internal per-identity balances and therefore must be wired -to all of the hooks it depends on: - -- `CanTransfer` -- `CanCreate` -- `Transferred` -- `Created` -- `Destroyed` - -After those hooks are registered, `verify_hook_wiring()` must be called once so -the module marks itself as armed before mint and transfer validation starts. - -## Authorization model - -This example uses the bootstrap-admin pattern introduced in this port: - -- The constructor stores a one-time `admin` -- Before `set_compliance_address`, configuration calls require that admin's - auth -- After `set_compliance_address`, privileged calls require auth from the bound - Compliance contract -- `set_compliance_address` itself remains a one-time admin action - -This allows the module to be seeded and configured from the CLI before handing -control to Compliance. - -## Main entrypoints - -- `__constructor(admin)` initializes the bootstrap admin -- `set_identity_registry_storage(token, irs)` stores the IRS address for a - token -- `set_max_balance(token, max)` configures the per-identity cap -- `pre_set_module_state(token, identity, balance)` seeds an identity balance -- `batch_pre_set_module_state(token, identities, balances)` seeds many - identity balances -- `required_hooks()` returns the required hook set -- `verify_hook_wiring()` marks the module as armed after registration -- `set_compliance_address(compliance)` performs the one-time handoff to the - Compliance contract - -## Notes - -- Storage is token-scoped, so one deployed module can be reused across many - tokens -- Transfers between two wallets that resolve to the same identity do not change - the tracked balance distribution -- A configured max of `0` behaves as "no cap" diff --git a/examples/rwa-max-balance/src/contract.rs b/examples/rwa-max-balance/src/contract.rs new file mode 100644 index 000000000..32cf25a75 --- /dev/null +++ b/examples/rwa-max-balance/src/contract.rs @@ -0,0 +1,125 @@ +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::{ + modules::{ + max_balance::storage as max_balance, + storage::{ + get_compliance_address, module_name, set_compliance_address, set_irs_address, + ComplianceModuleStorageKey, + }, + ComplianceModule, + }, + ComplianceHook, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct MaxBalanceContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl MaxBalanceContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } + + pub fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { + require_module_admin_or_compliance_auth(e); + set_irs_address(e, &token, &irs); + } + + pub fn set_max_balance(e: &Env, token: Address, max: i128) { + require_module_admin_or_compliance_auth(e); + max_balance::configure_max_balance(e, &token, max); + } + + pub fn pre_set_identity_balance(e: &Env, token: Address, identity: Address, balance: i128) { + require_module_admin_or_compliance_auth(e); + max_balance::pre_set_identity_balance(e, &token, &identity, balance); + } + + pub fn batch_pre_set_identity_balances( + e: &Env, + token: Address, + identities: Vec
, + balances: Vec, + ) { + require_module_admin_or_compliance_auth(e); + max_balance::batch_pre_set_identity_balances(e, &token, &identities, &balances); + } + + pub fn get_max_balance(e: &Env, token: Address) -> i128 { + max_balance::get_max_balance(e, &token) + } + + pub fn get_investor_balance(e: &Env, token: Address, identity: Address) -> i128 { + max_balance::get_id_balance(e, &token, &identity) + } + + pub fn required_hooks(e: &Env) -> Vec { + max_balance::required_hooks(e) + } + + pub fn verify_hook_wiring(e: &Env) { + max_balance::verify_hook_wiring(e); + } +} + +#[contractimpl(contracttrait)] +impl ComplianceModule for MaxBalanceContract { + fn on_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + max_balance::on_transfer(e, &from, &to, amount, &token); + } + + fn on_created(e: &Env, to: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + max_balance::on_created(e, &to, amount, &token); + } + + fn on_destroyed(e: &Env, from: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + max_balance::on_destroyed(e, &from, amount, &token); + } + + fn can_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) -> bool { + max_balance::can_transfer(e, &from, &to, amount, &token) + } + + fn can_create(e: &Env, to: Address, amount: i128, token: Address) -> bool { + max_balance::can_create(e, &to, amount, &token) + } + + fn name(e: &Env) -> String { + module_name(e, "MaxBalanceModule") + } + + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-max-balance/src/lib.rs b/examples/rwa-max-balance/src/lib.rs index 16352fde6..b9aa4d5f0 100644 --- a/examples/rwa-max-balance/src/lib.rs +++ b/examples/rwa-max-balance/src/lib.rs @@ -1,167 +1,3 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, vec, Address, Env, String, Vec}; -use stellar_tokens::rwa::compliance::{ - modules::{ - max_balance::{ - storage::{get_id_balance, get_max_balance, set_id_balance, set_max_balance}, - IDBalancePreSet, MaxBalance, MaxBalanceSet, - }, - storage::{ - add_i128_or_panic, get_irs_client, set_compliance_address, set_irs_address, - sub_i128_or_panic, verify_required_hooks, ComplianceModuleStorageKey, - }, - }, - ComplianceHook, -}; - -#[contracttype] -enum DataKey { - Admin, -} - -#[contract] -pub struct MaxBalanceContract; - -fn set_admin(e: &Env, admin: &Address) { - e.storage().instance().set(&DataKey::Admin, admin); -} - -fn get_admin(e: &Env) -> Address { - e.storage().instance().get(&DataKey::Admin).expect("admin must be set") -} - -fn require_module_admin_or_compliance_auth(e: &Env) { - if let Some(compliance) = - e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) - { - compliance.require_auth(); - } else { - get_admin(e).require_auth(); - } -} - -#[contractimpl] -impl MaxBalanceContract { - pub fn __constructor(e: &Env, admin: Address) { - set_admin(e, &admin); - } -} - -#[contractimpl(contracttrait)] -impl MaxBalance for MaxBalanceContract { - fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { - require_module_admin_or_compliance_auth(e); - set_irs_address(e, &token, &irs); - } - - fn set_max_balance(e: &Env, token: Address, max: i128) { - require_module_admin_or_compliance_auth(e); - stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, max); - set_max_balance(e, &token, max); - MaxBalanceSet { token, max_balance: max }.publish(e); - } - - fn pre_set_module_state(e: &Env, token: Address, identity: Address, balance: i128) { - require_module_admin_or_compliance_auth(e); - stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, balance); - set_id_balance(e, &token, &identity, balance); - IDBalancePreSet { token, identity, balance }.publish(e); - } - - fn batch_pre_set_module_state( - e: &Env, - token: Address, - identities: Vec
, - balances: Vec, - ) { - require_module_admin_or_compliance_auth(e); - assert!( - identities.len() == balances.len(), - "MaxBalanceModule: identities and balances length mismatch" - ); - for i in 0..identities.len() { - let id = identities.get(i).unwrap(); - let bal = balances.get(i).unwrap(); - stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, bal); - set_id_balance(e, &token, &id, bal); - IDBalancePreSet { token: token.clone(), identity: id, balance: bal }.publish(e); - } - } - - fn required_hooks(e: &Env) -> Vec { - vec![ - e, - ComplianceHook::CanTransfer, - ComplianceHook::CanCreate, - ComplianceHook::Transferred, - ComplianceHook::Created, - ComplianceHook::Destroyed, - ] - } - - fn verify_hook_wiring(e: &Env) { - verify_required_hooks(e, Self::required_hooks(e)); - } - - fn on_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) { - require_module_admin_or_compliance_auth(e); - stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, amount); - - let irs = get_irs_client(e, &token); - let from_id = irs.stored_identity(&from); - let to_id = irs.stored_identity(&to); - - if from_id == to_id { - return; - } - - let from_balance = get_id_balance(e, &token, &from_id); - let to_balance = get_id_balance(e, &token, &to_id); - let new_to_balance = add_i128_or_panic(e, to_balance, amount); - - let max = get_max_balance(e, &token); - assert!( - max == 0 || new_to_balance <= max, - "MaxBalanceModule: recipient identity balance exceeds max" - ); - - set_id_balance(e, &token, &from_id, sub_i128_or_panic(e, from_balance, amount)); - set_id_balance(e, &token, &to_id, new_to_balance); - } - - fn on_created(e: &Env, to: Address, amount: i128, token: Address) { - require_module_admin_or_compliance_auth(e); - stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, amount); - - let irs = get_irs_client(e, &token); - let to_id = irs.stored_identity(&to); - - let current = get_id_balance(e, &token, &to_id); - let new_balance = add_i128_or_panic(e, current, amount); - - let max = get_max_balance(e, &token); - assert!( - max == 0 || new_balance <= max, - "MaxBalanceModule: recipient identity balance exceeds max after mint" - ); - - set_id_balance(e, &token, &to_id, new_balance); - } - - fn on_destroyed(e: &Env, from: Address, amount: i128, token: Address) { - require_module_admin_or_compliance_auth(e); - stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, amount); - - let irs = get_irs_client(e, &token); - let from_id = irs.stored_identity(&from); - - let current = get_id_balance(e, &token, &from_id); - set_id_balance(e, &token, &from_id, sub_i128_or_panic(e, current, amount)); - } - - fn set_compliance_address(e: &Env, compliance: Address) { - get_admin(e).require_auth(); - set_compliance_address(e, &compliance); - } -} +pub mod contract; diff --git a/examples/rwa-supply-limit/README.md b/examples/rwa-supply-limit/README.md deleted file mode 100644 index a589cfd70..000000000 --- a/examples/rwa-supply-limit/README.md +++ /dev/null @@ -1,61 +0,0 @@ -# Supply Limit Module - -Concrete deployable example of the `SupplyLimit` compliance module for Stellar -RWA tokens. - -## What it enforces - -This module caps the total amount of tokens that may be minted for a given -token contract. - -It keeps an internal supply counter and checks that each mint would stay within -the configured per-token limit. - -## How it stays in sync - -The module maintains internal supply state and therefore must be wired to all -of the hooks it depends on: - -- `CanCreate` -- `Created` -- `Destroyed` - -After those hooks are registered, `verify_hook_wiring()` must be called once so -the module marks itself as armed before mint validation starts. - -## Authorization model - -This example uses the bootstrap-admin pattern introduced in this port: - -- The constructor stores a one-time `admin` -- Before `set_compliance_address`, configuration calls require that admin's - auth -- After `set_compliance_address`, privileged calls require auth from the bound - Compliance contract -- `set_compliance_address` itself remains a one-time admin action - -This allows the module to be configured from the CLI before handing control to -Compliance. - -## Main entrypoints - -- `__constructor(admin)` initializes the bootstrap admin -- `set_supply_limit(token, limit)` sets the per-token cap -- `pre_set_internal_supply(token, supply)` seeds tracked supply when wiring the - module after historical minting -- `get_supply_limit(token)` reads the configured cap -- `get_internal_supply(token)` reads the tracked internal supply -- `required_hooks()` returns the required hook set -- `verify_hook_wiring()` marks the module as armed after registration -- `set_compliance_address(compliance)` performs the one-time handoff to the - Compliance contract - -## Notes - -- Storage is token-scoped, so one deployed module can be reused across many - tokens -- A configured limit of `0` behaves as "no cap" -- If the module is attached after a token already has minted supply, seed the - existing amount with `pre_set_internal_supply` before relying on `can_create` -- The internal supply is updated only through the registered `Created` and - `Destroyed` hooks diff --git a/examples/rwa-supply-limit/src/contract.rs b/examples/rwa-supply-limit/src/contract.rs new file mode 100644 index 000000000..dbbbba0b9 --- /dev/null +++ b/examples/rwa-supply-limit/src/contract.rs @@ -0,0 +1,112 @@ +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use stellar_tokens::rwa::compliance::{ + modules::{ + storage::{ + get_compliance_address, module_name, set_compliance_address, ComplianceModuleStorageKey, + }, + supply_limit::storage as supply_limit, + ComplianceModule, + }, + ComplianceHook, +}; + +#[contracttype] +enum DataKey { + Admin, +} + +#[contract] +pub struct SupplyLimitContract; + +fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +fn get_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).expect("admin must be set") +} + +fn require_module_admin_or_compliance_auth(e: &Env) { + if let Some(compliance) = + e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) + { + compliance.require_auth(); + } else { + get_admin(e).require_auth(); + } +} + +#[contractimpl] +impl SupplyLimitContract { + pub fn __constructor(e: &Env, admin: Address) { + set_admin(e, &admin); + } + + pub fn set_supply_limit(e: &Env, token: Address, limit: i128) { + require_module_admin_or_compliance_auth(e); + supply_limit::configure_supply_limit(e, &token, limit); + } + + pub fn pre_set_supply(e: &Env, token: Address, supply: i128) { + require_module_admin_or_compliance_auth(e); + supply_limit::pre_set_supply(e, &token, supply); + } + + pub fn get_supply_limit(e: &Env, token: Address) -> i128 { + supply_limit::get_supply_limit(e, &token) + } + + pub fn get_internal_supply(e: &Env, token: Address) -> i128 { + supply_limit::get_internal_supply(e, &token) + } + + pub fn required_hooks(e: &Env) -> Vec { + supply_limit::required_hooks(e) + } + + pub fn verify_hook_wiring(e: &Env) { + supply_limit::verify_hook_wiring(e); + } +} + +#[contractimpl(contracttrait)] +impl ComplianceModule for SupplyLimitContract { + fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {} + + fn on_created(e: &Env, _to: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + supply_limit::on_created(e, amount, &token); + } + + fn on_destroyed(e: &Env, _from: Address, amount: i128, token: Address) { + require_module_admin_or_compliance_auth(e); + supply_limit::on_destroyed(e, amount, &token); + } + + fn can_transfer( + _e: &Env, + _from: Address, + _to: Address, + _amount: i128, + _token: Address, + ) -> bool { + true + } + + fn can_create(e: &Env, _to: Address, amount: i128, token: Address) -> bool { + supply_limit::can_create(e, amount, &token) + } + + fn name(e: &Env) -> String { + module_name(e, "SupplyLimitModule") + } + + fn get_compliance_address(e: &Env) -> Address { + get_compliance_address(e) + } + + fn set_compliance_address(e: &Env, compliance: Address) { + get_admin(e).require_auth(); + set_compliance_address(e, &compliance); + } +} diff --git a/examples/rwa-supply-limit/src/lib.rs b/examples/rwa-supply-limit/src/lib.rs index 2250b7026..b9aa4d5f0 100644 --- a/examples/rwa-supply-limit/src/lib.rs +++ b/examples/rwa-supply-limit/src/lib.rs @@ -1,102 +1,3 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, vec, Address, Env, String, Vec}; -use stellar_tokens::rwa::compliance::{ - modules::{ - storage::{ - add_i128_or_panic, set_compliance_address, sub_i128_or_panic, verify_required_hooks, - ComplianceModuleStorageKey, - }, - supply_limit::{ - storage::{ - get_internal_supply, get_supply_limit, set_internal_supply, set_supply_limit, - }, - SupplyLimit, SupplyLimitSet, - }, - }, - ComplianceHook, -}; - -#[contracttype] -enum DataKey { - Admin, -} - -#[contract] -pub struct SupplyLimitContract; - -fn set_admin(e: &Env, admin: &Address) { - e.storage().instance().set(&DataKey::Admin, admin); -} - -fn get_admin(e: &Env) -> Address { - e.storage().instance().get(&DataKey::Admin).expect("admin must be set") -} - -fn require_module_admin_or_compliance_auth(e: &Env) { - if let Some(compliance) = - e.storage().instance().get::<_, Address>(&ComplianceModuleStorageKey::Compliance) - { - compliance.require_auth(); - } else { - get_admin(e).require_auth(); - } -} - -#[contractimpl] -impl SupplyLimitContract { - pub fn __constructor(e: &Env, admin: Address) { - set_admin(e, &admin); - } -} - -#[contractimpl(contracttrait)] -impl SupplyLimit for SupplyLimitContract { - fn set_supply_limit(e: &Env, token: Address, limit: i128) { - require_module_admin_or_compliance_auth(e); - stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, limit); - set_supply_limit(e, &token, limit); - SupplyLimitSet { token, limit }.publish(e); - } - - fn pre_set_internal_supply(e: &Env, token: Address, supply: i128) { - require_module_admin_or_compliance_auth(e); - stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, supply); - set_internal_supply(e, &token, supply); - } - - fn get_supply_limit(e: &Env, token: Address) -> i128 { - get_supply_limit(e, &token) - } - - fn get_internal_supply(e: &Env, token: Address) -> i128 { - get_internal_supply(e, &token) - } - - fn required_hooks(e: &Env) -> Vec { - vec![e, ComplianceHook::CanCreate, ComplianceHook::Created, ComplianceHook::Destroyed] - } - - fn verify_hook_wiring(e: &Env) { - verify_required_hooks(e, Self::required_hooks(e)); - } - - fn on_created(e: &Env, _to: Address, amount: i128, token: Address) { - require_module_admin_or_compliance_auth(e); - stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, amount); - let current = get_internal_supply(e, &token); - set_internal_supply(e, &token, add_i128_or_panic(e, current, amount)); - } - - fn on_destroyed(e: &Env, _from: Address, amount: i128, token: Address) { - require_module_admin_or_compliance_auth(e); - stellar_tokens::rwa::compliance::modules::storage::require_non_negative_amount(e, amount); - let current = get_internal_supply(e, &token); - set_internal_supply(e, &token, sub_i128_or_panic(e, current, amount)); - } - - fn set_compliance_address(e: &Env, compliance: Address) { - get_admin(e).require_auth(); - set_compliance_address(e, &compliance); - } -} +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/country_allow/mod.rs b/packages/tokens/src/rwa/compliance/modules/country_allow/mod.rs index fe6cdda69..1da53f7f2 100644 --- a/packages/tokens/src/rwa/compliance/modules/country_allow/mod.rs +++ b/packages/tokens/src/rwa/compliance/modules/country_allow/mod.rs @@ -7,14 +7,10 @@ //! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/CountryAllowModule.sol pub mod storage; +#[cfg(test)] +mod test; -use soroban_sdk::{contractevent, contracttrait, Address, Env, String, Vec}; -use storage::{is_country_allowed, remove_country_allowed, set_country_allowed}; - -use super::storage::{ - country_code, get_compliance_address, get_irs_country_data_entries, module_name, - set_irs_address, -}; +use soroban_sdk::{contractevent, Address}; /// Emitted when a country is added to the allowlist. #[contractevent] @@ -33,423 +29,3 @@ pub struct CountryUnallowed { pub token: Address, pub country: u32, } - -/// Country allowlist compliance trait. -/// -/// Provides default implementations for maintaining a per-token country -/// allowlist and validating transfers/mints against it via the Identity -/// Registry Storage. -#[contracttrait] -pub trait CountryAllow { - /// Sets the Identity Registry Storage contract address for `token`. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - /// * `token` - The token this IRS applies to. - /// * `irs` - The IRS contract address. - /// - /// # Authorization - /// - /// Requires compliance contract authorization. - fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { - get_compliance_address(e).require_auth(); - set_irs_address(e, &token, &irs); - } - - /// Adds a country to the allowlist for `token`. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - /// * `token` - The token address. - /// * `country` - The ISO 3166-1 numeric country code to allow. - /// - /// # Authorization - /// - /// Requires compliance contract authorization. - /// - /// # Events - /// - /// Emits [`CountryAllowed`]. - fn add_allowed_country(e: &Env, token: Address, country: u32) { - get_compliance_address(e).require_auth(); - set_country_allowed(e, &token, country); - CountryAllowed { token, country }.publish(e); - } - - /// Removes a country from the allowlist for `token`. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - /// * `token` - The token address. - /// * `country` - The ISO 3166-1 numeric country code to remove. - /// - /// # Authorization - /// - /// Requires compliance contract authorization. - /// - /// # Events - /// - /// Emits [`CountryUnallowed`]. - fn remove_allowed_country(e: &Env, token: Address, country: u32) { - get_compliance_address(e).require_auth(); - remove_country_allowed(e, &token, country); - CountryUnallowed { token, country }.publish(e); - } - - /// Adds multiple countries to the allowlist in a single call. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - /// * `token` - The token address. - /// * `countries` - The country codes to allow. - /// - /// # Authorization - /// - /// Requires compliance contract authorization. - /// - /// # Events - /// - /// Emits [`CountryAllowed`] for each country added. - fn batch_allow_countries(e: &Env, token: Address, countries: Vec) { - get_compliance_address(e).require_auth(); - for country in countries.iter() { - set_country_allowed(e, &token, country); - CountryAllowed { token: token.clone(), country }.publish(e); - } - } - - /// Removes multiple countries from the allowlist in a single call. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - /// * `token` - The token address. - /// * `countries` - The country codes to remove. - /// - /// # Authorization - /// - /// Requires compliance contract authorization. - /// - /// # Events - /// - /// Emits [`CountryUnallowed`] for each country removed. - fn batch_disallow_countries(e: &Env, token: Address, countries: Vec) { - get_compliance_address(e).require_auth(); - for country in countries.iter() { - remove_country_allowed(e, &token, country); - CountryUnallowed { token: token.clone(), country }.publish(e); - } - } - - /// Returns whether `country` is on the allowlist for `token`. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - /// * `token` - The token address. - /// * `country` - The ISO 3166-1 numeric country code. - fn is_country_allowed(e: &Env, token: Address, country: u32) -> bool { - is_country_allowed(e, &token, country) - } - - /// No-op — this module does not track transfer state. - fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {} - - /// No-op — this module does not track mint state. - fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {} - - /// No-op — this module does not track burn state. - fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {} - - /// Checks whether `to` has at least one allowed country in the IRS. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - /// * `_from` - The sender (unused). - /// * `to` - The recipient whose country data is checked. - /// * `_amount` - The transfer amount (unused). - /// * `token` - The token address. - /// - /// # Returns - /// - /// `true` if the recipient has at least one allowed country, `false` - /// otherwise. - /// - /// # Cross-Contract Calls - /// - /// Calls the IRS to resolve country data for `to`. - fn can_transfer(e: &Env, _from: Address, to: Address, _amount: i128, token: Address) -> bool { - let entries = get_irs_country_data_entries(e, &token, &to); - for entry in entries.iter() { - if is_country_allowed(e, &token, country_code(&entry.country)) { - return true; - } - } - false - } - - /// Delegates to [`can_transfer`](CountryAllow::can_transfer) — same - /// country check applies to mints. - fn can_create(e: &Env, to: Address, amount: i128, token: Address) -> bool { - Self::can_transfer(e, to.clone(), to, amount, token) - } - - /// Returns the module name for identification. - fn name(e: &Env) -> String { - module_name(e, "CountryAllowModule") - } - - /// Returns the compliance contract address. - fn get_compliance_address(e: &Env) -> Address { - get_compliance_address(e) - } - - /// Sets the compliance contract address (one-time only). - /// - /// Implementers must gate this entrypoint with bootstrap-admin auth before - /// delegating to - /// [`storage::set_compliance_address`](super::storage::set_compliance_address). - /// - /// - /// # Panics - /// - /// Panics if the compliance address has already been set. - fn set_compliance_address(e: &Env, compliance: Address); -} - -#[cfg(test)] -mod test { - extern crate std; - - use soroban_sdk::{ - contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal, - Val, Vec, - }; - - use super::*; - use crate::rwa::{ - identity_registry_storage::{ - CountryData, CountryDataManager, CountryRelation, IdentityRegistryStorage, - IndividualCountryRelation, OrganizationCountryRelation, - }, - utils::token_binder::TokenBinder, - }; - - #[contract] - struct MockIRSContract; - - #[contracttype] - #[derive(Clone)] - enum MockIRSStorageKey { - Identity(Address), - CountryEntries(Address), - } - - #[contractimpl] - impl TokenBinder for MockIRSContract { - fn linked_tokens(e: &Env) -> Vec
{ - Vec::new(e) - } - - fn bind_token(_e: &Env, _token: Address, _operator: Address) { - unreachable!("bind_token is not used in these tests"); - } - - fn unbind_token(_e: &Env, _token: Address, _operator: Address) { - unreachable!("unbind_token is not used in these tests"); - } - } - - #[contractimpl] - impl IdentityRegistryStorage for MockIRSContract { - fn add_identity( - _e: &Env, - _account: Address, - _identity: Address, - _country_data_list: Vec, - _operator: Address, - ) { - unreachable!("add_identity is not used in these tests"); - } - - fn remove_identity(_e: &Env, _account: Address, _operator: Address) { - unreachable!("remove_identity is not used in these tests"); - } - - fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { - unreachable!("modify_identity is not used in these tests"); - } - - fn recover_identity( - _e: &Env, - _old_account: Address, - _new_account: Address, - _operator: Address, - ) { - unreachable!("recover_identity is not used in these tests"); - } - - fn stored_identity(e: &Env, account: Address) -> Address { - e.storage() - .persistent() - .get(&MockIRSStorageKey::Identity(account.clone())) - .unwrap_or(account) - } - } - - #[contractimpl] - impl CountryDataManager for MockIRSContract { - fn add_country_data_entries( - _e: &Env, - _account: Address, - _country_data_list: Vec, - _operator: Address, - ) { - unreachable!("add_country_data_entries is not used in these tests"); - } - - fn modify_country_data( - _e: &Env, - _account: Address, - _index: u32, - _country_data: Val, - _operator: Address, - ) { - unreachable!("modify_country_data is not used in these tests"); - } - - fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { - unreachable!("delete_country_data is not used in these tests"); - } - - fn get_country_data_entries(e: &Env, account: Address) -> Vec { - let entries: Vec = e - .storage() - .persistent() - .get(&MockIRSStorageKey::CountryEntries(account)) - .unwrap_or_else(|| Vec::new(e)); - - Vec::from_iter(e, entries.iter().map(|entry| entry.into_val(e))) - } - } - - #[contractimpl] - impl MockIRSContract { - pub fn set_country_data_entries(e: &Env, account: Address, entries: Vec) { - e.storage().persistent().set(&MockIRSStorageKey::CountryEntries(account), &entries); - } - } - - #[contract] - struct TestCountryAllowContract; - - #[contractimpl(contracttrait)] - impl CountryAllow for TestCountryAllowContract { - fn set_compliance_address(_e: &Env, _compliance: Address) { - unreachable!("set_compliance_address is not used in these tests"); - } - } - - fn individual_country(code: u32) -> CountryData { - CountryData { - country: CountryRelation::Individual(IndividualCountryRelation::Residence(code)), - metadata: None, - } - } - - fn organization_country(code: u32) -> CountryData { - CountryData { - country: CountryRelation::Organization( - OrganizationCountryRelation::OperatingJurisdiction(code), - ), - metadata: None, - } - } - - #[test] - fn can_transfer_and_create_allow_when_any_country_matches() { - let e = Env::default(); - let module_id = e.register(TestCountryAllowContract, ()); - let irs_id = e.register(MockIRSContract, ()); - let irs = MockIRSContractClient::new(&e, &irs_id); - let token = Address::generate(&e); - let from = Address::generate(&e); - let to = Address::generate(&e); - - irs.set_country_data_entries( - &to, - &vec![&e, individual_country(250), organization_country(276)], - ); - - e.as_contract(&module_id, || { - set_irs_address(&e, &token, &irs_id); - set_country_allowed(&e, &token, 276); - - assert!(::can_transfer( - &e, - from.clone(), - to.clone(), - 100, - token.clone(), - )); - assert!(::can_create( - &e, - to.clone(), - 100, - token.clone(), - )); - }); - } - - #[test] - fn can_transfer_and_create_reject_when_no_country_matches() { - let e = Env::default(); - let module_id = e.register(TestCountryAllowContract, ()); - let irs_id = e.register(MockIRSContract, ()); - let irs = MockIRSContractClient::new(&e, &irs_id); - let token = Address::generate(&e); - let from = Address::generate(&e); - let empty_to = Address::generate(&e); - let disallowed_to = Address::generate(&e); - - irs.set_country_data_entries(&disallowed_to, &vec![&e, individual_country(250)]); - - e.as_contract(&module_id, || { - set_irs_address(&e, &token, &irs_id); - set_country_allowed(&e, &token, 276); - - assert!(!::can_transfer( - &e, - from.clone(), - empty_to.clone(), - 100, - token.clone(), - )); - assert!(!::can_create( - &e, - empty_to, - 100, - token.clone(), - )); - - assert!(!::can_transfer( - &e, - from.clone(), - disallowed_to.clone(), - 100, - token.clone(), - )); - assert!(!::can_create( - &e, - disallowed_to, - 100, - token.clone(), - )); - }); - } -} diff --git a/packages/tokens/src/rwa/compliance/modules/country_allow/storage.rs b/packages/tokens/src/rwa/compliance/modules/country_allow/storage.rs index 767ca0a14..91b748145 100644 --- a/packages/tokens/src/rwa/compliance/modules/country_allow/storage.rs +++ b/packages/tokens/src/rwa/compliance/modules/country_allow/storage.rs @@ -1,6 +1,10 @@ -use soroban_sdk::{contracttype, Address, Env}; +use soroban_sdk::{contracttype, Address, Env, Vec}; -use crate::rwa::compliance::modules::{MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD}; +use super::{CountryAllowed, CountryUnallowed}; +use crate::rwa::compliance::modules::{ + storage::{country_code, get_irs_country_data_entries}, + MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD, +}; #[contracttype] #[derive(Clone)] @@ -9,6 +13,8 @@ pub enum CountryAllowStorageKey { AllowedCountry(Address, u32), } +// ################## RAW STORAGE ################## + /// Returns whether the given country is on the allowlist for `token`. /// /// # Arguments @@ -27,7 +33,7 @@ pub fn is_country_allowed(e: &Env, token: &Address, country: u32) -> bool { .unwrap_or_default() } -/// Adds a country to the allowlist for `token`. +/// Writes a country's allowed flag to persistent storage. /// /// # Arguments /// @@ -40,7 +46,7 @@ pub fn set_country_allowed(e: &Env, token: &Address, country: u32) { e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); } -/// Removes a country from the allowlist for `token`. +/// Removes a country from the allowlist in persistent storage. /// /// # Arguments /// @@ -52,3 +58,94 @@ pub fn remove_country_allowed(e: &Env, token: &Address, country: u32) { .persistent() .remove(&CountryAllowStorageKey::AllowedCountry(token.clone(), country)); } + +// ################## ACTIONS ################## + +/// Adds a country to the allowlist for `token`. +/// +/// Writes the flag to storage and emits [`CountryAllowed`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code to allow. +pub fn add_allowed_country(e: &Env, token: &Address, country: u32) { + set_country_allowed(e, token, country); + CountryAllowed { token: token.clone(), country }.publish(e); +} + +/// Removes a country from the allowlist for `token`. +/// +/// Deletes the flag from storage and emits [`CountryUnallowed`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code to remove. +pub fn remove_allowed_country(e: &Env, token: &Address, country: u32) { + remove_country_allowed(e, token, country); + CountryUnallowed { token: token.clone(), country }.publish(e); +} + +/// Adds multiple countries to the allowlist in a single call. +/// +/// Emits [`CountryAllowed`] for each country added. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `countries` - The country codes to allow. +pub fn batch_allow_countries(e: &Env, token: &Address, countries: &Vec) { + for country in countries.iter() { + set_country_allowed(e, token, country); + CountryAllowed { token: token.clone(), country }.publish(e); + } +} + +/// Removes multiple countries from the allowlist in a single call. +/// +/// Emits [`CountryUnallowed`] for each country removed. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `countries` - The country codes to remove. +pub fn batch_disallow_countries(e: &Env, token: &Address, countries: &Vec) { + for country in countries.iter() { + remove_country_allowed(e, token, country); + CountryUnallowed { token: token.clone(), country }.publish(e); + } +} + +// ################## COMPLIANCE HOOKS ################## + +/// Checks whether `to` has at least one allowed country in the IRS for +/// `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `to` - The recipient whose country data is checked. +/// * `token` - The token address. +/// +/// # Returns +/// +/// `true` if the recipient has at least one allowed country, `false` +/// otherwise. +/// +/// # Cross-Contract Calls +/// +/// Calls the IRS to resolve country data for `to`. +pub fn can_transfer(e: &Env, to: &Address, token: &Address) -> bool { + let entries = get_irs_country_data_entries(e, token, to); + for entry in entries.iter() { + if is_country_allowed(e, token, country_code(&entry.country)) { + return true; + } + } + false +} diff --git a/packages/tokens/src/rwa/compliance/modules/country_allow/test.rs b/packages/tokens/src/rwa/compliance/modules/country_allow/test.rs new file mode 100644 index 000000000..a8b497283 --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/country_allow/test.rs @@ -0,0 +1,183 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal, Val, + Vec, +}; + +use super::storage::{can_transfer, set_country_allowed}; +use crate::rwa::{ + compliance::modules::storage::set_irs_address, + identity_registry_storage::{ + CountryData, CountryDataManager, CountryRelation, IdentityRegistryStorage, + IndividualCountryRelation, OrganizationCountryRelation, + }, + utils::token_binder::TokenBinder, +}; + +#[contract] +struct MockIRSContract; + +#[contracttype] +#[derive(Clone)] +enum MockIRSStorageKey { + Identity(Address), + CountryEntries(Address), +} + +#[contractimpl] +impl TokenBinder for MockIRSContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl IdentityRegistryStorage for MockIRSContract { + fn add_identity( + _e: &Env, + _account: Address, + _identity: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_identity is not used in these tests"); + } + + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { + unreachable!("remove_identity is not used in these tests"); + } + + fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { + unreachable!("modify_identity is not used in these tests"); + } + + fn recover_identity( + _e: &Env, + _old_account: Address, + _new_account: Address, + _operator: Address, + ) { + unreachable!("recover_identity is not used in these tests"); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + e.storage() + .persistent() + .get(&MockIRSStorageKey::Identity(account.clone())) + .unwrap_or(account) + } +} + +#[contractimpl] +impl CountryDataManager for MockIRSContract { + fn add_country_data_entries( + _e: &Env, + _account: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_country_data_entries is not used in these tests"); + } + + fn modify_country_data( + _e: &Env, + _account: Address, + _index: u32, + _country_data: Val, + _operator: Address, + ) { + unreachable!("modify_country_data is not used in these tests"); + } + + fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { + unreachable!("delete_country_data is not used in these tests"); + } + + fn get_country_data_entries(e: &Env, account: Address) -> Vec { + let entries: Vec = e + .storage() + .persistent() + .get(&MockIRSStorageKey::CountryEntries(account)) + .unwrap_or_else(|| Vec::new(e)); + + Vec::from_iter(e, entries.iter().map(|entry| entry.into_val(e))) + } +} + +#[contractimpl] +impl MockIRSContract { + pub fn set_country_data_entries(e: &Env, account: Address, entries: Vec) { + e.storage().persistent().set(&MockIRSStorageKey::CountryEntries(account), &entries); + } +} + +#[contract] +struct TestCountryAllowContract; + +fn individual_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Individual(IndividualCountryRelation::Residence(code)), + metadata: None, + } +} + +fn organization_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Organization(OrganizationCountryRelation::OperatingJurisdiction( + code, + )), + metadata: None, + } +} + +#[test] +fn can_transfer_allows_when_any_country_matches() { + let e = Env::default(); + let module_id = e.register(TestCountryAllowContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let to = Address::generate(&e); + + irs.set_country_data_entries( + &to, + &vec![&e, individual_country(250), organization_country(276)], + ); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_country_allowed(&e, &token, 276); + + assert!(can_transfer(&e, &to, &token)); + }); +} + +#[test] +fn can_transfer_rejects_when_no_country_matches() { + let e = Env::default(); + let module_id = e.register(TestCountryAllowContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let empty_to = Address::generate(&e); + let disallowed_to = Address::generate(&e); + + irs.set_country_data_entries(&disallowed_to, &vec![&e, individual_country(250)]); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_country_allowed(&e, &token, 276); + + assert!(!can_transfer(&e, &empty_to, &token)); + assert!(!can_transfer(&e, &disallowed_to, &token)); + }); +} diff --git a/packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs b/packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs index 09f87a301..2cbd355a1 100644 --- a/packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs +++ b/packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs @@ -7,14 +7,10 @@ //! [trex-src]: https://github.com/TokenySolutions/T-REX/blob/main/contracts/compliance/modular/modules/CountryRestrictModule.sol pub mod storage; +#[cfg(test)] +mod test; -use soroban_sdk::{contractevent, contracttrait, Address, Env, String, Vec}; -use storage::{is_country_restricted, remove_country_restricted, set_country_restricted}; - -use super::storage::{ - country_code, get_compliance_address, get_irs_country_data_entries, module_name, - set_irs_address, -}; +use soroban_sdk::{contractevent, Address}; /// Emitted when a country is added to the restriction list. #[contractevent] @@ -33,427 +29,3 @@ pub struct CountryUnrestricted { pub token: Address, pub country: u32, } - -/// Country restriction compliance trait. -/// -/// Provides default implementations for maintaining a per-token country -/// restriction list and blocking transfers/mints to recipients from -/// restricted countries via the Identity Registry Storage. -#[contracttrait] -pub trait CountryRestrict { - /// Sets the Identity Registry Storage contract address for `token`. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - /// * `token` - The token this IRS applies to. - /// * `irs` - The IRS contract address. - /// - /// # Authorization - /// - /// Requires compliance contract authorization. - fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { - get_compliance_address(e).require_auth(); - set_irs_address(e, &token, &irs); - } - - /// Adds a country to the restriction list for `token`. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - /// * `token` - The token address. - /// * `country` - The ISO 3166-1 numeric country code to restrict. - /// - /// # Authorization - /// - /// Requires compliance contract authorization. - /// - /// # Events - /// - /// Emits [`CountryRestricted`]. - fn add_country_restriction(e: &Env, token: Address, country: u32) { - get_compliance_address(e).require_auth(); - set_country_restricted(e, &token, country); - CountryRestricted { token, country }.publish(e); - } - - /// Removes a country from the restriction list for `token`. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - /// * `token` - The token address. - /// * `country` - The ISO 3166-1 numeric country code to unrestrict. - /// - /// # Authorization - /// - /// Requires compliance contract authorization. - /// - /// # Events - /// - /// Emits [`CountryUnrestricted`]. - fn remove_country_restriction(e: &Env, token: Address, country: u32) { - get_compliance_address(e).require_auth(); - remove_country_restricted(e, &token, country); - CountryUnrestricted { token, country }.publish(e); - } - - /// Adds multiple countries to the restriction list in a single call. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - /// * `token` - The token address. - /// * `countries` - The country codes to restrict. - /// - /// # Authorization - /// - /// Requires compliance contract authorization. - /// - /// # Events - /// - /// Emits [`CountryRestricted`] for each country added. - fn batch_restrict_countries(e: &Env, token: Address, countries: Vec) { - get_compliance_address(e).require_auth(); - for country in countries.iter() { - set_country_restricted(e, &token, country); - CountryRestricted { token: token.clone(), country }.publish(e); - } - } - - /// Removes multiple countries from the restriction list in a single - /// call. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - /// * `token` - The token address. - /// * `countries` - The country codes to unrestrict. - /// - /// # Authorization - /// - /// Requires compliance contract authorization. - /// - /// # Events - /// - /// Emits [`CountryUnrestricted`] for each country removed. - fn batch_unrestrict_countries(e: &Env, token: Address, countries: Vec) { - get_compliance_address(e).require_auth(); - for country in countries.iter() { - remove_country_restricted(e, &token, country); - CountryUnrestricted { token: token.clone(), country }.publish(e); - } - } - - /// Returns whether `country` is on the restriction list for `token`. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - /// * `token` - The token address. - /// * `country` - The ISO 3166-1 numeric country code. - fn is_country_restricted(e: &Env, token: Address, country: u32) -> bool { - is_country_restricted(e, &token, country) - } - - /// No-op — this module does not track transfer state. - fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {} - - /// No-op — this module does not track mint state. - fn on_created(_e: &Env, _to: Address, _amount: i128, _token: Address) {} - - /// No-op — this module does not track burn state. - fn on_destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) {} - - /// Checks whether `to` has any restricted country in the IRS. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - /// * `_from` - The sender (unused). - /// * `to` - The recipient whose country data is checked. - /// * `_amount` - The transfer amount (unused). - /// * `token` - The token address. - /// - /// # Returns - /// - /// `false` if the recipient has any restricted country, `true` - /// otherwise. - /// - /// # Cross-Contract Calls - /// - /// Calls the IRS to resolve country data for `to`. - fn can_transfer(e: &Env, _from: Address, to: Address, _amount: i128, token: Address) -> bool { - let entries = get_irs_country_data_entries(e, &token, &to); - for entry in entries.iter() { - if is_country_restricted(e, &token, country_code(&entry.country)) { - return false; - } - } - true - } - - /// Delegates to [`can_transfer`](CountryRestrict::can_transfer) — same - /// country check applies to mints. - fn can_create(e: &Env, to: Address, amount: i128, token: Address) -> bool { - Self::can_transfer(e, to.clone(), to, amount, token) - } - - /// Returns the module name for identification. - fn name(e: &Env) -> String { - module_name(e, "CountryRestrictModule") - } - - /// Returns the compliance contract address. - fn get_compliance_address(e: &Env) -> Address { - get_compliance_address(e) - } - - /// Sets the compliance contract address (one-time only). - /// - /// Implementers must gate this entrypoint with bootstrap-admin auth before - /// delegating to - /// [`storage::set_compliance_address`](super::storage::set_compliance_address). - /// - /// - /// # Panics - /// - /// Panics if the compliance address has already been set. - fn set_compliance_address(e: &Env, compliance: Address); -} - -#[cfg(test)] -mod test { - extern crate std; - - use soroban_sdk::{ - contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal, - Val, Vec, - }; - - use super::*; - use crate::rwa::{ - identity_registry_storage::{ - CountryData, CountryDataManager, CountryRelation, IdentityRegistryStorage, - IndividualCountryRelation, OrganizationCountryRelation, - }, - utils::token_binder::TokenBinder, - }; - - #[contract] - struct MockIRSContract; - - #[contracttype] - #[derive(Clone)] - enum MockIRSStorageKey { - Identity(Address), - CountryEntries(Address), - } - - #[contractimpl] - impl TokenBinder for MockIRSContract { - fn linked_tokens(e: &Env) -> Vec
{ - Vec::new(e) - } - - fn bind_token(_e: &Env, _token: Address, _operator: Address) { - unreachable!("bind_token is not used in these tests"); - } - - fn unbind_token(_e: &Env, _token: Address, _operator: Address) { - unreachable!("unbind_token is not used in these tests"); - } - } - - #[contractimpl] - impl IdentityRegistryStorage for MockIRSContract { - fn add_identity( - _e: &Env, - _account: Address, - _identity: Address, - _country_data_list: Vec, - _operator: Address, - ) { - unreachable!("add_identity is not used in these tests"); - } - - fn remove_identity(_e: &Env, _account: Address, _operator: Address) { - unreachable!("remove_identity is not used in these tests"); - } - - fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { - unreachable!("modify_identity is not used in these tests"); - } - - fn recover_identity( - _e: &Env, - _old_account: Address, - _new_account: Address, - _operator: Address, - ) { - unreachable!("recover_identity is not used in these tests"); - } - - fn stored_identity(e: &Env, account: Address) -> Address { - e.storage() - .persistent() - .get(&MockIRSStorageKey::Identity(account.clone())) - .unwrap_or(account) - } - } - - #[contractimpl] - impl CountryDataManager for MockIRSContract { - fn add_country_data_entries( - _e: &Env, - _account: Address, - _country_data_list: Vec, - _operator: Address, - ) { - unreachable!("add_country_data_entries is not used in these tests"); - } - - fn modify_country_data( - _e: &Env, - _account: Address, - _index: u32, - _country_data: Val, - _operator: Address, - ) { - unreachable!("modify_country_data is not used in these tests"); - } - - fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { - unreachable!("delete_country_data is not used in these tests"); - } - - fn get_country_data_entries(e: &Env, account: Address) -> Vec { - let entries: Vec = e - .storage() - .persistent() - .get(&MockIRSStorageKey::CountryEntries(account)) - .unwrap_or_else(|| Vec::new(e)); - - Vec::from_iter(e, entries.iter().map(|entry| entry.into_val(e))) - } - } - - #[contractimpl] - impl MockIRSContract { - pub fn set_country_data_entries(e: &Env, account: Address, entries: Vec) { - e.storage().persistent().set(&MockIRSStorageKey::CountryEntries(account), &entries); - } - } - - #[contract] - struct TestCountryRestrictContract; - - #[contractimpl(contracttrait)] - impl CountryRestrict for TestCountryRestrictContract { - fn set_compliance_address(_e: &Env, _compliance: Address) { - unreachable!("set_compliance_address is not used in these tests"); - } - } - - fn individual_country(code: u32) -> CountryData { - CountryData { - country: CountryRelation::Individual(IndividualCountryRelation::Residence(code)), - metadata: None, - } - } - - fn organization_country(code: u32) -> CountryData { - CountryData { - country: CountryRelation::Organization( - OrganizationCountryRelation::OperatingJurisdiction(code), - ), - metadata: None, - } - } - - #[test] - fn can_transfer_and_create_reject_when_any_country_is_restricted() { - let e = Env::default(); - let module_id = e.register(TestCountryRestrictContract, ()); - let irs_id = e.register(MockIRSContract, ()); - let irs = MockIRSContractClient::new(&e, &irs_id); - let token = Address::generate(&e); - let from = Address::generate(&e); - let to = Address::generate(&e); - - irs.set_country_data_entries( - &to, - &vec![&e, individual_country(250), organization_country(408)], - ); - - e.as_contract(&module_id, || { - set_irs_address(&e, &token, &irs_id); - set_country_restricted(&e, &token, 408); - - assert!(!::can_transfer( - &e, - from.clone(), - to.clone(), - 100, - token.clone(), - )); - assert!(!::can_create( - &e, - to.clone(), - 100, - token.clone(), - )); - }); - } - - #[test] - fn can_transfer_and_create_allow_when_no_country_is_restricted() { - let e = Env::default(); - let module_id = e.register(TestCountryRestrictContract, ()); - let irs_id = e.register(MockIRSContract, ()); - let irs = MockIRSContractClient::new(&e, &irs_id); - let token = Address::generate(&e); - let from = Address::generate(&e); - let empty_to = Address::generate(&e); - let unrestricted_to = Address::generate(&e); - - irs.set_country_data_entries( - &unrestricted_to, - &vec![&e, individual_country(250), organization_country(276)], - ); - - e.as_contract(&module_id, || { - set_irs_address(&e, &token, &irs_id); - set_country_restricted(&e, &token, 408); - - assert!(::can_transfer( - &e, - from.clone(), - empty_to.clone(), - 100, - token.clone(), - )); - assert!(::can_create( - &e, - empty_to, - 100, - token.clone(), - )); - - assert!(::can_transfer( - &e, - from.clone(), - unrestricted_to.clone(), - 100, - token.clone(), - )); - assert!(::can_create( - &e, - unrestricted_to, - 100, - token.clone(), - )); - }); - } -} diff --git a/packages/tokens/src/rwa/compliance/modules/country_restrict/storage.rs b/packages/tokens/src/rwa/compliance/modules/country_restrict/storage.rs index 5d8f13cb2..632e2321b 100644 --- a/packages/tokens/src/rwa/compliance/modules/country_restrict/storage.rs +++ b/packages/tokens/src/rwa/compliance/modules/country_restrict/storage.rs @@ -1,6 +1,10 @@ -use soroban_sdk::{contracttype, Address, Env}; +use soroban_sdk::{contracttype, Address, Env, Vec}; -use crate::rwa::compliance::modules::{MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD}; +use super::{CountryRestricted, CountryUnrestricted}; +use crate::rwa::compliance::modules::{ + storage::{country_code, get_irs_country_data_entries}, + MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD, +}; #[contracttype] #[derive(Clone)] @@ -9,6 +13,8 @@ pub enum CountryRestrictStorageKey { RestrictedCountry(Address, u32), } +// ################## RAW STORAGE ################## + /// Returns whether the given country is on the restriction list for `token`. /// /// # Arguments @@ -27,7 +33,7 @@ pub fn is_country_restricted(e: &Env, token: &Address, country: u32) -> bool { .unwrap_or_default() } -/// Adds a country to the restriction list for `token`. +/// Writes a country's restricted flag to persistent storage. /// /// # Arguments /// @@ -40,7 +46,7 @@ pub fn set_country_restricted(e: &Env, token: &Address, country: u32) { e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); } -/// Removes a country from the restriction list for `token`. +/// Removes a country from the restriction list in persistent storage. /// /// # Arguments /// @@ -52,3 +58,92 @@ pub fn remove_country_restricted(e: &Env, token: &Address, country: u32) { .persistent() .remove(&CountryRestrictStorageKey::RestrictedCountry(token.clone(), country)); } + +// ################## ACTIONS ################## + +/// Adds a country to the restriction list for `token`. +/// +/// Writes the flag to storage and emits [`CountryRestricted`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code to restrict. +pub fn add_country_restriction(e: &Env, token: &Address, country: u32) { + set_country_restricted(e, token, country); + CountryRestricted { token: token.clone(), country }.publish(e); +} + +/// Removes a country from the restriction list for `token`. +/// +/// Deletes the flag from storage and emits [`CountryUnrestricted`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `country` - The ISO 3166-1 numeric country code to unrestrict. +pub fn remove_country_restriction(e: &Env, token: &Address, country: u32) { + remove_country_restricted(e, token, country); + CountryUnrestricted { token: token.clone(), country }.publish(e); +} + +/// Adds multiple countries to the restriction list in a single call. +/// +/// Emits [`CountryRestricted`] for each country added. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `countries` - The country codes to restrict. +pub fn batch_restrict_countries(e: &Env, token: &Address, countries: &Vec) { + for country in countries.iter() { + set_country_restricted(e, token, country); + CountryRestricted { token: token.clone(), country }.publish(e); + } +} + +/// Removes multiple countries from the restriction list in a single call. +/// +/// Emits [`CountryUnrestricted`] for each country removed. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `countries` - The country codes to unrestrict. +pub fn batch_unrestrict_countries(e: &Env, token: &Address, countries: &Vec) { + for country in countries.iter() { + remove_country_restricted(e, token, country); + CountryUnrestricted { token: token.clone(), country }.publish(e); + } +} + +// ################## COMPLIANCE HOOKS ################## + +/// Checks whether `to` has any restricted country in the IRS for `token`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `to` - The recipient whose country data is checked. +/// * `token` - The token address. +/// +/// # Returns +/// +/// `false` if the recipient has any restricted country, `true` otherwise. +/// +/// # Cross-Contract Calls +/// +/// Calls the IRS to resolve country data for `to`. +pub fn can_transfer(e: &Env, to: &Address, token: &Address) -> bool { + let entries = get_irs_country_data_entries(e, token, to); + for entry in entries.iter() { + if is_country_restricted(e, token, country_code(&entry.country)) { + return false; + } + } + true +} diff --git a/packages/tokens/src/rwa/compliance/modules/country_restrict/test.rs b/packages/tokens/src/rwa/compliance/modules/country_restrict/test.rs new file mode 100644 index 000000000..f09ed9eab --- /dev/null +++ b/packages/tokens/src/rwa/compliance/modules/country_restrict/test.rs @@ -0,0 +1,186 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal, Val, + Vec, +}; + +use super::storage::{can_transfer, set_country_restricted}; +use crate::rwa::{ + compliance::modules::storage::set_irs_address, + identity_registry_storage::{ + CountryData, CountryDataManager, CountryRelation, IdentityRegistryStorage, + IndividualCountryRelation, OrganizationCountryRelation, + }, + utils::token_binder::TokenBinder, +}; + +#[contract] +struct MockIRSContract; + +#[contracttype] +#[derive(Clone)] +enum MockIRSStorageKey { + Identity(Address), + CountryEntries(Address), +} + +#[contractimpl] +impl TokenBinder for MockIRSContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl IdentityRegistryStorage for MockIRSContract { + fn add_identity( + _e: &Env, + _account: Address, + _identity: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_identity is not used in these tests"); + } + + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { + unreachable!("remove_identity is not used in these tests"); + } + + fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { + unreachable!("modify_identity is not used in these tests"); + } + + fn recover_identity( + _e: &Env, + _old_account: Address, + _new_account: Address, + _operator: Address, + ) { + unreachable!("recover_identity is not used in these tests"); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + e.storage() + .persistent() + .get(&MockIRSStorageKey::Identity(account.clone())) + .unwrap_or(account) + } +} + +#[contractimpl] +impl CountryDataManager for MockIRSContract { + fn add_country_data_entries( + _e: &Env, + _account: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_country_data_entries is not used in these tests"); + } + + fn modify_country_data( + _e: &Env, + _account: Address, + _index: u32, + _country_data: Val, + _operator: Address, + ) { + unreachable!("modify_country_data is not used in these tests"); + } + + fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { + unreachable!("delete_country_data is not used in these tests"); + } + + fn get_country_data_entries(e: &Env, account: Address) -> Vec { + let entries: Vec = e + .storage() + .persistent() + .get(&MockIRSStorageKey::CountryEntries(account)) + .unwrap_or_else(|| Vec::new(e)); + + Vec::from_iter(e, entries.iter().map(|entry| entry.into_val(e))) + } +} + +#[contractimpl] +impl MockIRSContract { + pub fn set_country_data_entries(e: &Env, account: Address, entries: Vec) { + e.storage().persistent().set(&MockIRSStorageKey::CountryEntries(account), &entries); + } +} + +#[contract] +struct TestCountryRestrictContract; + +fn individual_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Individual(IndividualCountryRelation::Residence(code)), + metadata: None, + } +} + +fn organization_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Organization(OrganizationCountryRelation::OperatingJurisdiction( + code, + )), + metadata: None, + } +} + +#[test] +fn can_transfer_rejects_when_any_country_is_restricted() { + let e = Env::default(); + let module_id = e.register(TestCountryRestrictContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let to = Address::generate(&e); + + irs.set_country_data_entries( + &to, + &vec![&e, individual_country(250), organization_country(408)], + ); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_country_restricted(&e, &token, 408); + + assert!(!can_transfer(&e, &to, &token)); + }); +} + +#[test] +fn can_transfer_allows_when_no_country_is_restricted() { + let e = Env::default(); + let module_id = e.register(TestCountryRestrictContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let empty_to = Address::generate(&e); + let unrestricted_to = Address::generate(&e); + + irs.set_country_data_entries( + &unrestricted_to, + &vec![&e, individual_country(250), organization_country(276)], + ); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_country_restricted(&e, &token, 408); + + assert!(can_transfer(&e, &empty_to, &token)); + assert!(can_transfer(&e, &unrestricted_to, &token)); + }); +} diff --git a/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/mod.rs b/packages/tokens/src/rwa/compliance/modules/initial_lockup_period/mod.rs 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/max_balance/mod.rs b/packages/tokens/src/rwa/compliance/modules/max_balance/mod.rs index aa295329f..a20b71d1b 100644 --- a/packages/tokens/src/rwa/compliance/modules/max_balance/mod.rs +++ b/packages/tokens/src/rwa/compliance/modules/max_balance/mod.rs @@ -10,14 +10,7 @@ pub mod storage; #[cfg(test)] mod test; -use soroban_sdk::{contractevent, contracttrait, vec, Address, Env, String, Vec}; -use storage::{get_id_balance, get_max_balance, set_id_balance, set_max_balance}; - -use super::storage::{ - add_i128_or_panic, get_compliance_address, get_irs_client, hooks_verified, module_name, - require_non_negative_amount, set_irs_address, sub_i128_or_panic, verify_required_hooks, -}; -use crate::rwa::compliance::ComplianceHook; +use soroban_sdk::{contractevent, Address}; /// Emitted when a token's per-identity balance cap is configured. #[contractevent] @@ -37,182 +30,3 @@ pub struct IDBalancePreSet { pub identity: Address, pub balance: i128, } - -fn can_increase_identity_balance( - e: &Env, - token: &Address, - identity: &Address, - amount: i128, -) -> bool { - if amount < 0 { - return false; - } - - let max = get_max_balance(e, token); - if max == 0 { - return true; - } - - let current = get_id_balance(e, token, identity); - add_i128_or_panic(e, current, amount) <= max -} - -#[contracttrait] -pub trait MaxBalance { - fn set_identity_registry_storage(e: &Env, token: Address, irs: Address) { - get_compliance_address(e).require_auth(); - set_irs_address(e, &token, &irs); - } - - fn set_max_balance(e: &Env, token: Address, max: i128) { - get_compliance_address(e).require_auth(); - require_non_negative_amount(e, max); - set_max_balance(e, &token, max); - MaxBalanceSet { token, max_balance: max }.publish(e); - } - - fn pre_set_module_state(e: &Env, token: Address, identity: Address, balance: i128) { - get_compliance_address(e).require_auth(); - require_non_negative_amount(e, balance); - set_id_balance(e, &token, &identity, balance); - IDBalancePreSet { token, identity, balance }.publish(e); - } - - fn batch_pre_set_module_state( - e: &Env, - token: Address, - identities: Vec
, - balances: Vec, - ) { - get_compliance_address(e).require_auth(); - assert!( - identities.len() == balances.len(), - "MaxBalanceModule: identities and balances length mismatch" - ); - for i in 0..identities.len() { - let id = identities.get(i).unwrap(); - let bal = balances.get(i).unwrap(); - require_non_negative_amount(e, bal); - set_id_balance(e, &token, &id, bal); - IDBalancePreSet { token: token.clone(), identity: id, balance: bal }.publish(e); - } - } - - fn get_investor_balance(e: &Env, token: Address, identity: Address) -> i128 { - get_id_balance(e, &token, &identity) - } - - fn required_hooks(e: &Env) -> Vec { - vec![ - e, - ComplianceHook::CanTransfer, - ComplianceHook::CanCreate, - ComplianceHook::Transferred, - ComplianceHook::Created, - ComplianceHook::Destroyed, - ] - } - - fn verify_hook_wiring(e: &Env) { - verify_required_hooks(e, Self::required_hooks(e)); - } - - fn on_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) { - get_compliance_address(e).require_auth(); - require_non_negative_amount(e, amount); - - let irs = get_irs_client(e, &token); - let from_id = irs.stored_identity(&from); - let to_id = irs.stored_identity(&to); - - if from_id == to_id { - return; - } - - let from_balance = get_id_balance(e, &token, &from_id); - assert!( - can_increase_identity_balance(e, &token, &to_id, amount), - "MaxBalanceModule: recipient identity balance exceeds max" - ); - - let to_balance = get_id_balance(e, &token, &to_id); - let new_to_balance = add_i128_or_panic(e, to_balance, amount); - set_id_balance(e, &token, &from_id, sub_i128_or_panic(e, from_balance, amount)); - set_id_balance(e, &token, &to_id, new_to_balance); - } - - fn on_created(e: &Env, to: Address, amount: i128, token: Address) { - get_compliance_address(e).require_auth(); - require_non_negative_amount(e, amount); - - let irs = get_irs_client(e, &token); - let to_id = irs.stored_identity(&to); - - assert!( - can_increase_identity_balance(e, &token, &to_id, amount), - "MaxBalanceModule: recipient identity balance exceeds max after mint" - ); - - let current = get_id_balance(e, &token, &to_id); - let new_balance = add_i128_or_panic(e, current, amount); - set_id_balance(e, &token, &to_id, new_balance); - } - - fn on_destroyed(e: &Env, from: Address, amount: i128, token: Address) { - get_compliance_address(e).require_auth(); - require_non_negative_amount(e, amount); - - let irs = get_irs_client(e, &token); - let from_id = irs.stored_identity(&from); - - let current = get_id_balance(e, &token, &from_id); - set_id_balance(e, &token, &from_id, sub_i128_or_panic(e, current, amount)); - } - - fn can_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) -> bool { - assert!( - hooks_verified(e), - "MaxBalanceModule: not armed — call verify_hook_wiring() after wiring hooks \ - [CanTransfer, CanCreate, Transferred, Created, Destroyed]" - ); - if amount < 0 { - return false; - } - let irs = get_irs_client(e, &token); - let from_id = irs.stored_identity(&from); - let to_id = irs.stored_identity(&to); - - if from_id == to_id { - return true; - } - - can_increase_identity_balance(e, &token, &to_id, amount) - } - - fn can_create(e: &Env, to: Address, amount: i128, token: Address) -> bool { - assert!( - hooks_verified(e), - "MaxBalanceModule: not armed — call verify_hook_wiring() after wiring hooks \ - [CanTransfer, CanCreate, Transferred, Created, Destroyed]" - ); - if amount < 0 { - return false; - } - let irs = get_irs_client(e, &token); - let to_id = irs.stored_identity(&to); - can_increase_identity_balance(e, &token, &to_id, amount) - } - - fn name(e: &Env) -> String { - module_name(e, "MaxBalanceModule") - } - - fn get_compliance_address(e: &Env) -> Address { - get_compliance_address(e) - } - - /// Implementers must gate this entrypoint with bootstrap-admin auth before - /// delegating to - /// [`storage::set_compliance_address`](super::storage::set_compliance_address). - fn set_compliance_address(e: &Env, compliance: Address); -} diff --git a/packages/tokens/src/rwa/compliance/modules/max_balance/storage.rs b/packages/tokens/src/rwa/compliance/modules/max_balance/storage.rs index 60e6cb997..6a894523b 100644 --- a/packages/tokens/src/rwa/compliance/modules/max_balance/storage.rs +++ b/packages/tokens/src/rwa/compliance/modules/max_balance/storage.rs @@ -1,6 +1,16 @@ -use soroban_sdk::{contracttype, Address, Env}; +use soroban_sdk::{contracttype, vec, Address, Env, Vec}; -use crate::rwa::compliance::modules::{MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD}; +use super::{IDBalancePreSet, MaxBalanceSet}; +use crate::rwa::compliance::{ + modules::{ + storage::{ + add_i128_or_panic, get_irs_client, hooks_verified, require_non_negative_amount, + sub_i128_or_panic, verify_required_hooks, + }, + MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD, + }, + ComplianceHook, +}; #[contracttype] #[derive(Clone)] @@ -11,6 +21,8 @@ pub enum MaxBalanceStorageKey { IDBalance(Address, Address), } +// ################## RAW STORAGE ################## + /// Returns the per-identity balance cap for `token`, or `0` if not set. /// /// # Arguments @@ -73,3 +85,231 @@ pub fn set_id_balance(e: &Env, token: &Address, identity: &Address, balance: i12 e.storage().persistent().set(&key, &balance); e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); } + +// ################## HELPERS ################## + +fn can_increase_identity_balance( + e: &Env, + token: &Address, + identity: &Address, + amount: i128, +) -> bool { + if amount < 0 { + return false; + } + + let max = get_max_balance(e, token); + if max == 0 { + return true; + } + + let current = get_id_balance(e, token, identity); + add_i128_or_panic(e, current, amount) <= max +} + +// ################## ACTIONS ################## + +/// Validates, stores, and emits [`MaxBalanceSet`] for the given cap. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `max` - The maximum balance per identity. +pub fn configure_max_balance(e: &Env, token: &Address, max: i128) { + require_non_negative_amount(e, max); + set_max_balance(e, token, max); + MaxBalanceSet { token: token.clone(), max_balance: max }.publish(e); +} + +/// Pre-seeds the tracked balance for an identity and emits +/// [`IDBalancePreSet`]. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `identity` - The on-chain identity address. +/// * `balance` - The pre-seeded balance value. +pub fn pre_set_identity_balance(e: &Env, token: &Address, identity: &Address, balance: i128) { + require_non_negative_amount(e, balance); + set_id_balance(e, token, identity, balance); + IDBalancePreSet { token: token.clone(), identity: identity.clone(), balance }.publish(e); +} + +/// Pre-seeds tracked balances for multiple identities. Emits +/// [`IDBalancePreSet`] for each. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `identities` - Identity addresses. +/// * `balances` - Corresponding balance values. +pub fn batch_pre_set_identity_balances( + e: &Env, + token: &Address, + identities: &Vec
, + balances: &Vec, +) { + assert!( + identities.len() == balances.len(), + "MaxBalanceModule: identities and balances length mismatch" + ); + for i in 0..identities.len() { + let id = identities.get(i).unwrap(); + let bal = balances.get(i).unwrap(); + require_non_negative_amount(e, bal); + set_id_balance(e, token, &id, bal); + IDBalancePreSet { token: token.clone(), identity: id, balance: bal }.publish(e); + } +} + +// ################## HOOK WIRING ################## + +/// Returns the set of compliance hooks this module requires. +pub fn required_hooks(e: &Env) -> Vec { + vec![ + e, + ComplianceHook::CanTransfer, + ComplianceHook::CanCreate, + ComplianceHook::Transferred, + ComplianceHook::Created, + ComplianceHook::Destroyed, + ] +} + +/// Cross-calls the compliance contract to verify that this module is +/// registered on all required hooks. +pub fn verify_hook_wiring(e: &Env) { + verify_required_hooks(e, required_hooks(e)); +} + +// ################## COMPLIANCE HOOKS ################## + +/// Updates identity balances after a transfer. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `from` - The sender address. +/// * `to` - The recipient address. +/// * `amount` - The transfer amount. +/// * `token` - The token address. +pub fn on_transfer(e: &Env, from: &Address, to: &Address, amount: i128, token: &Address) { + require_non_negative_amount(e, amount); + + let irs = get_irs_client(e, token); + let from_id = irs.stored_identity(from); + let to_id = irs.stored_identity(to); + + if from_id == to_id { + return; + } + + let from_balance = get_id_balance(e, token, &from_id); + assert!( + can_increase_identity_balance(e, token, &to_id, amount), + "MaxBalanceModule: recipient identity balance exceeds max" + ); + + let to_balance = get_id_balance(e, token, &to_id); + let new_to_balance = add_i128_or_panic(e, to_balance, amount); + set_id_balance(e, token, &from_id, sub_i128_or_panic(e, from_balance, amount)); + set_id_balance(e, token, &to_id, new_to_balance); +} + +/// Updates identity balance after a mint. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `to` - The recipient address. +/// * `amount` - The minted amount. +/// * `token` - The token address. +pub fn on_created(e: &Env, to: &Address, amount: i128, token: &Address) { + require_non_negative_amount(e, amount); + + let irs = get_irs_client(e, token); + let to_id = irs.stored_identity(to); + + assert!( + can_increase_identity_balance(e, token, &to_id, amount), + "MaxBalanceModule: recipient identity balance exceeds max after mint" + ); + + let current = get_id_balance(e, token, &to_id); + let new_balance = add_i128_or_panic(e, current, amount); + set_id_balance(e, token, &to_id, new_balance); +} + +/// Updates identity balance after a burn. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `from` - The burner address. +/// * `amount` - The burned amount. +/// * `token` - The token address. +pub fn on_destroyed(e: &Env, from: &Address, amount: i128, token: &Address) { + require_non_negative_amount(e, amount); + + let irs = get_irs_client(e, token); + let from_id = irs.stored_identity(from); + + let current = get_id_balance(e, token, &from_id); + set_id_balance(e, token, &from_id, sub_i128_or_panic(e, current, amount)); +} + +/// Checks whether a transfer would exceed the recipient identity's +/// balance cap. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `from` - The sender address. +/// * `to` - The recipient address. +/// * `amount` - The transfer amount. +/// * `token` - The token address. +pub fn can_transfer(e: &Env, from: &Address, to: &Address, amount: i128, token: &Address) -> bool { + assert!( + hooks_verified(e), + "MaxBalanceModule: not armed — call verify_hook_wiring() after wiring hooks [CanTransfer, \ + CanCreate, Transferred, Created, Destroyed]" + ); + if amount < 0 { + return false; + } + let irs = get_irs_client(e, token); + let from_id = irs.stored_identity(from); + let to_id = irs.stored_identity(to); + + if from_id == to_id { + return true; + } + + can_increase_identity_balance(e, token, &to_id, amount) +} + +/// Checks whether a mint would exceed the recipient identity's balance +/// cap. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `to` - The recipient address. +/// * `amount` - The mint amount. +/// * `token` - The token address. +pub fn can_create(e: &Env, to: &Address, amount: i128, token: &Address) -> bool { + assert!( + hooks_verified(e), + "MaxBalanceModule: not armed — call verify_hook_wiring() after wiring hooks [CanTransfer, \ + CanCreate, Transferred, Created, Destroyed]" + ); + if amount < 0 { + return false; + } + let irs = get_irs_client(e, token); + let to_id = irs.stored_identity(to); + can_increase_identity_balance(e, token, &to_id, amount) +} diff --git a/packages/tokens/src/rwa/compliance/modules/max_balance/test.rs b/packages/tokens/src/rwa/compliance/modules/max_balance/test.rs index 53281cadb..bf208fb8d 100644 --- a/packages/tokens/src/rwa/compliance/modules/max_balance/test.rs +++ b/packages/tokens/src/rwa/compliance/modules/max_balance/test.rs @@ -4,9 +4,8 @@ use soroban_sdk::{ contract, contractimpl, contracttype, testutils::Address as _, Address, Env, Val, Vec, }; -use super::{ - storage::{set_id_balance, set_max_balance}, - *, +use super::storage::{ + can_create, can_transfer, set_id_balance, set_max_balance, verify_hook_wiring, }; use crate::rwa::{ compliance::{ @@ -196,13 +195,6 @@ impl MockComplianceContract { #[contract] struct TestMaxBalanceContract; -#[contractimpl(contracttrait)] -impl MaxBalance for TestMaxBalanceContract { - fn set_compliance_address(_e: &Env, _compliance: Address) { - unreachable!("set_compliance_address is not used in these tests"); - } -} - fn arm_hooks(e: &Env) { e.storage().instance().set(&ComplianceModuleStorageKey::HooksVerified, &true); } @@ -227,7 +219,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)); }); @@ -251,18 +243,8 @@ fn can_create_rejects_mint_when_cap_would_be_exceeded() { set_max_balance(&e, &token, 100); set_id_balance(&e, &token, &recipient_identity, 60); - assert!(!::can_create( - &e, - recipient.clone(), - 50, - token.clone(), - )); - assert!(::can_create( - &e, - recipient, - 40, - token.clone(), - )); + assert!(!can_create(&e, &recipient, 50, &token)); + assert!(can_create(&e, &recipient, 40, &token)); }); } @@ -287,20 +269,8 @@ fn can_transfer_checks_distinct_recipient_identity_balance() { set_max_balance(&e, &token, 100); set_id_balance(&e, &token, &recipient_identity, 60); - assert!(!::can_transfer( - &e, - sender.clone(), - recipient.clone(), - 50, - token.clone(), - )); - assert!(::can_transfer( - &e, - sender, - recipient, - 40, - token.clone(), - )); + assert!(!can_transfer(&e, &sender, &recipient, 50, &token)); + assert!(can_transfer(&e, &sender, &recipient, 40, &token)); }); } @@ -321,18 +291,8 @@ fn can_create_allows_without_cap_and_rejects_negative_amount() { arm_hooks(&e); set_id_balance(&e, &token, &recipient_identity, 500); - assert!(::can_create( - &e, - recipient.clone(), - 1_000, - token.clone(), - )); - assert!(!::can_create( - &e, - recipient, - -1, - token.clone(), - )); + assert!(can_create(&e, &recipient, 1_000, &token)); + assert!(!can_create(&e, &recipient, -1, &token)); }); } @@ -346,6 +306,6 @@ fn can_create_rejects_negative_amount_before_requiring_irs() { e.as_contract(&module_id, || { arm_hooks(&e); - assert!(!::can_create(&e, recipient, -1, token,)); + assert!(!can_create(&e, &recipient, -1, &token)); }); } diff --git a/packages/tokens/src/rwa/compliance/modules/supply_limit/mod.rs b/packages/tokens/src/rwa/compliance/modules/supply_limit/mod.rs index d138a002b..6082d490d 100644 --- a/packages/tokens/src/rwa/compliance/modules/supply_limit/mod.rs +++ b/packages/tokens/src/rwa/compliance/modules/supply_limit/mod.rs @@ -9,14 +9,7 @@ pub mod storage; #[cfg(test)] mod test; -use soroban_sdk::{contractevent, contracttrait, vec, Address, Env, String, Vec}; -use storage::{get_internal_supply, get_supply_limit, set_internal_supply, set_supply_limit}; - -use super::storage::{ - add_i128_or_panic, get_compliance_address, hooks_verified, module_name, - require_non_negative_amount, sub_i128_or_panic, verify_required_hooks, -}; -use crate::rwa::compliance::ComplianceHook; +use soroban_sdk::{contractevent, Address}; /// Emitted when a token's supply cap is configured or changed. #[contractevent] @@ -26,91 +19,3 @@ pub struct SupplyLimitSet { pub token: Address, pub limit: i128, } - -#[contracttrait] -pub trait SupplyLimit { - fn set_supply_limit(e: &Env, token: Address, limit: i128) { - get_compliance_address(e).require_auth(); - require_non_negative_amount(e, limit); - set_supply_limit(e, &token, limit); - SupplyLimitSet { token, limit }.publish(e); - } - - fn pre_set_internal_supply(e: &Env, token: Address, supply: i128) { - get_compliance_address(e).require_auth(); - require_non_negative_amount(e, supply); - set_internal_supply(e, &token, supply); - } - - fn get_supply_limit(e: &Env, token: Address) -> i128 { - get_supply_limit(e, &token) - } - - fn get_internal_supply(e: &Env, token: Address) -> i128 { - get_internal_supply(e, &token) - } - - fn required_hooks(e: &Env) -> Vec { - vec![e, ComplianceHook::CanCreate, ComplianceHook::Created, ComplianceHook::Destroyed] - } - - fn verify_hook_wiring(e: &Env) { - verify_required_hooks(e, Self::required_hooks(e)); - } - - fn on_transfer(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) {} - - fn on_created(e: &Env, _to: Address, amount: i128, token: Address) { - get_compliance_address(e).require_auth(); - require_non_negative_amount(e, amount); - let current = get_internal_supply(e, &token); - set_internal_supply(e, &token, add_i128_or_panic(e, current, amount)); - } - - fn on_destroyed(e: &Env, _from: Address, amount: i128, token: Address) { - get_compliance_address(e).require_auth(); - require_non_negative_amount(e, amount); - let current = get_internal_supply(e, &token); - set_internal_supply(e, &token, sub_i128_or_panic(e, current, amount)); - } - - fn can_transfer( - _e: &Env, - _from: Address, - _to: Address, - _amount: i128, - _token: Address, - ) -> bool { - true - } - - fn can_create(e: &Env, _to: Address, amount: i128, token: Address) -> bool { - assert!( - hooks_verified(e), - "SupplyLimitModule: not armed — call verify_hook_wiring() after wiring hooks \ - [CanCreate, Created, Destroyed]" - ); - if amount < 0 { - return false; - } - let limit = get_supply_limit(e, &token); - if limit == 0 { - return true; - } - let supply = get_internal_supply(e, &token); - add_i128_or_panic(e, supply, amount) <= limit - } - - fn name(e: &Env) -> String { - module_name(e, "SupplyLimitModule") - } - - fn get_compliance_address(e: &Env) -> Address { - get_compliance_address(e) - } - - /// Implementers must gate this entrypoint with bootstrap-admin auth before - /// delegating to - /// [`storage::set_compliance_address`](super::storage::set_compliance_address). - fn set_compliance_address(e: &Env, compliance: Address); -} diff --git a/packages/tokens/src/rwa/compliance/modules/supply_limit/storage.rs b/packages/tokens/src/rwa/compliance/modules/supply_limit/storage.rs index f1ec2f4df..d337e5a94 100644 --- a/packages/tokens/src/rwa/compliance/modules/supply_limit/storage.rs +++ b/packages/tokens/src/rwa/compliance/modules/supply_limit/storage.rs @@ -1,7 +1,15 @@ -use soroban_sdk::{contracttype, panic_with_error, Address, Env}; +use soroban_sdk::{contracttype, panic_with_error, vec, Address, Env, Vec}; -use crate::rwa::compliance::modules::{ - ComplianceModuleError, MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD, +use super::SupplyLimitSet; +use crate::rwa::compliance::{ + modules::{ + storage::{ + add_i128_or_panic, hooks_verified, require_non_negative_amount, sub_i128_or_panic, + verify_required_hooks, + }, + ComplianceModuleError, MODULE_EXTEND_AMOUNT, MODULE_TTL_THRESHOLD, + }, + ComplianceHook, }; #[contracttype] @@ -13,6 +21,8 @@ pub enum SupplyLimitStorageKey { InternalSupply(Address), } +// ################## RAW STORAGE ################## + /// Returns the supply limit for `token`, or `0` if not set. /// /// # Arguments @@ -94,3 +104,95 @@ pub fn set_internal_supply(e: &Env, token: &Address, supply: i128) { e.storage().persistent().set(&key, &supply); e.storage().persistent().extend_ttl(&key, MODULE_TTL_THRESHOLD, MODULE_EXTEND_AMOUNT); } + +// ################## ACTIONS ################## + +/// Validates, stores, and emits [`SupplyLimitSet`] for the given cap. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `limit` - The supply cap. +pub fn configure_supply_limit(e: &Env, token: &Address, limit: i128) { + require_non_negative_amount(e, limit); + set_supply_limit(e, token, limit); + SupplyLimitSet { token: token.clone(), limit }.publish(e); +} + +/// Pre-seeds the internal supply counter for a token. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token` - The token address. +/// * `supply` - The pre-seeded supply value. +pub fn pre_set_supply(e: &Env, token: &Address, supply: i128) { + require_non_negative_amount(e, supply); + set_internal_supply(e, token, supply); +} + +// ################## HOOK WIRING ################## + +/// Returns the set of compliance hooks this module requires. +pub fn required_hooks(e: &Env) -> Vec { + vec![e, ComplianceHook::CanCreate, ComplianceHook::Created, ComplianceHook::Destroyed] +} + +/// Cross-calls the compliance contract to verify that this module is +/// registered on all required hooks. +pub fn verify_hook_wiring(e: &Env) { + verify_required_hooks(e, required_hooks(e)); +} + +// ################## COMPLIANCE HOOKS ################## + +/// Updates the internal supply counter after a mint. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `amount` - The minted amount. +/// * `token` - The token address. +pub fn on_created(e: &Env, amount: i128, token: &Address) { + require_non_negative_amount(e, amount); + let current = get_internal_supply(e, token); + set_internal_supply(e, token, add_i128_or_panic(e, current, amount)); +} + +/// Updates the internal supply counter after a burn. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `amount` - The burned amount. +/// * `token` - The token address. +pub fn on_destroyed(e: &Env, amount: i128, token: &Address) { + require_non_negative_amount(e, amount); + let current = get_internal_supply(e, token); + set_internal_supply(e, token, sub_i128_or_panic(e, current, amount)); +} + +/// Checks whether a mint would exceed the supply cap. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `amount` - The mint amount. +/// * `token` - The token address. +pub fn can_create(e: &Env, amount: i128, token: &Address) -> bool { + assert!( + hooks_verified(e), + "SupplyLimitModule: not armed — call verify_hook_wiring() after wiring hooks [CanCreate, \ + Created, Destroyed]" + ); + if amount < 0 { + return false; + } + let limit = get_supply_limit(e, token); + if limit == 0 { + return true; + } + let supply = get_internal_supply(e, token); + add_i128_or_panic(e, supply, amount) <= limit +} diff --git a/packages/tokens/src/rwa/compliance/modules/supply_limit/test.rs b/packages/tokens/src/rwa/compliance/modules/supply_limit/test.rs index 21018e09e..7047b8bef 100644 --- a/packages/tokens/src/rwa/compliance/modules/supply_limit/test.rs +++ b/packages/tokens/src/rwa/compliance/modules/supply_limit/test.rs @@ -2,7 +2,10 @@ extern crate std; use soroban_sdk::{contract, contractimpl, contracttype, testutils::Address as _, Address, Env}; -use super::*; +use super::storage::{ + can_create, configure_supply_limit, get_internal_supply, on_created, on_destroyed, + pre_set_supply, verify_hook_wiring, +}; use crate::rwa::{ compliance::{ modules::storage::{hooks_verified, set_compliance_address, ComplianceModuleStorageKey}, @@ -90,13 +93,6 @@ impl MockComplianceContract { #[contract] struct TestSupplyLimitContract; -#[contractimpl(contracttrait)] -impl SupplyLimit for TestSupplyLimitContract { - fn set_compliance_address(_e: &Env, _compliance: Address) { - unreachable!("set_compliance_address is not used in these tests"); - } -} - fn arm_hooks(e: &Env) { e.storage().instance().set(&ComplianceModuleStorageKey::HooksVerified, &true); } @@ -115,7 +111,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)); }); @@ -128,7 +124,7 @@ fn get_supply_limit_returns_zero_when_unconfigured() { let token = Address::generate(&e); e.as_contract(&module_id, || { - assert_eq!(::get_supply_limit(&e, token), 0); + assert_eq!(super::storage::get_supply_limit(&e, &token), 0); }); } @@ -137,75 +133,50 @@ fn can_create_allows_when_limit_is_unset_and_rejects_negative_amount() { let e = Env::default(); let module_id = e.register(TestSupplyLimitContract, ()); let token = Address::generate(&e); - let recipient = Address::generate(&e); e.as_contract(&module_id, || { arm_hooks(&e); - assert!(::can_create( - &e, - recipient.clone(), - 100, - token.clone(), - )); - assert!(!::can_create( - &e, - recipient, - -1, - token.clone(), - )); + assert!(can_create(&e, 100, &token)); + assert!(!can_create(&e, -1, &token)); }); } #[test] fn hooks_update_internal_supply_and_cap_future_mints() { let e = Env::default(); - e.mock_all_auths(); - let module_id = e.register(TestSupplyLimitContract, ()); - let compliance_id = e.register(MockComplianceContract, ()); let token = Address::generate(&e); - let recipient = Address::generate(&e); - let client = TestSupplyLimitContractClient::new(&e, &module_id); e.as_contract(&module_id, || { - set_compliance_address(&e, &compliance_id); arm_hooks(&e); - }); + configure_supply_limit(&e, &token, 100); - client.set_supply_limit(&token, &100); + assert!(can_create(&e, 80, &token)); + on_created(&e, 80, &token); + assert_eq!(get_internal_supply(&e, &token), 80); - assert!(client.can_create(&recipient.clone(), &80, &token)); - client.on_created(&recipient.clone(), &80, &token); - assert_eq!(client.get_internal_supply(&token), 80); + assert!(!can_create(&e, 30, &token)); - assert!(!client.can_create(&recipient.clone(), &30, &token)); - - client.on_destroyed(&recipient.clone(), &20, &token); - assert_eq!(client.get_internal_supply(&token), 60); - assert!(client.can_create(&recipient, &40, &token)); + on_destroyed(&e, 20, &token); + assert_eq!(get_internal_supply(&e, &token), 60); + assert!(can_create(&e, 40, &token)); + }); } #[test] fn pre_set_internal_supply_seeds_existing_supply_for_cap_checks() { let e = Env::default(); - e.mock_all_auths(); - let module_id = e.register(TestSupplyLimitContract, ()); - let compliance_id = e.register(MockComplianceContract, ()); let token = Address::generate(&e); - let recipient = Address::generate(&e); - let client = TestSupplyLimitContractClient::new(&e, &module_id); e.as_contract(&module_id, || { - set_compliance_address(&e, &compliance_id); arm_hooks(&e); - }); + configure_supply_limit(&e, &token, 100); + pre_set_supply(&e, &token, 90); - client.set_supply_limit(&token, &100); - client.pre_set_internal_supply(&token, &90); - - assert_eq!(client.get_internal_supply(&token), 90); - assert!(!client.can_create(&recipient.clone(), &11, &token)); - assert!(client.can_create(&recipient, &10, &token)); + assert_eq!(get_internal_supply(&e, &token), 90); + assert!(!can_create(&e, 11, &token)); + assert!(can_create(&e, 10, &token)); + }); } diff --git a/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/mod.rs b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/mod.rs 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 494c875eb85bf305e0962d97ff7fb2675e8d7be5 Mon Sep 17 00:00:00 2001 From: Aleksandr Pasevin Date: Sat, 11 Apr 2026 13:06:33 +0300 Subject: [PATCH 6/7] refactor: refresh deploy branch standalone follow-ups Sync the carried standalone examples and module-side follow-up tests with the latest country, balance/supply, and transfer cleanup pattern. This keeps the deploy branch aligned with the reviewed standalone PRs and reuses IdentityRegistryStorageClient in the deploy verifier without changing behavior. --- examples/rwa-country-allow/Cargo.toml | 7 + examples/rwa-country-allow/src/lib.rs | 2 + examples/rwa-country-allow/src/test.rs | 259 ++++++++++++++ examples/rwa-country-restrict/Cargo.toml | 7 + examples/rwa-country-restrict/src/lib.rs | 2 + examples/rwa-country-restrict/src/test.rs | 259 ++++++++++++++ examples/rwa-deploy/verifier/src/lib.rs | 14 +- 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-max-balance/Cargo.toml | 7 + examples/rwa-max-balance/src/lib.rs | 2 + examples/rwa-max-balance/src/test.rs | 303 +++++++++++++++++ examples/rwa-supply-limit/Cargo.toml | 7 + examples/rwa-supply-limit/src/lib.rs | 2 + examples/rwa-supply-limit/src/test.rs | 194 +++++++++++ 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/country_allow/storage.rs | 8 +- .../modules/country_restrict/storage.rs | 8 +- .../modules/initial_lockup_period/storage.rs | 7 +- .../compliance/modules/max_balance/test.rs | 138 +++++++- .../compliance/modules/supply_limit/test.rs | 29 +- .../modules/time_transfers_limits/storage.rs | 8 +- .../modules/transfer_restrict/storage.rs | 6 +- 29 files changed, 1892 insertions(+), 33 deletions(-) create mode 100644 examples/rwa-country-allow/src/test.rs create mode 100644 examples/rwa-country-restrict/src/test.rs create mode 100644 examples/rwa-initial-lockup-period/src/test.rs create mode 100644 examples/rwa-max-balance/src/test.rs create mode 100644 examples/rwa-supply-limit/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-country-allow/Cargo.toml b/examples/rwa-country-allow/Cargo.toml index a60408eab..540b26786 100644 --- a/examples/rwa-country-allow/Cargo.toml +++ b/examples/rwa-country-allow/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-country-allow/src/lib.rs b/examples/rwa-country-allow/src/lib.rs index b9aa4d5f0..a879b6f80 100644 --- a/examples/rwa-country-allow/src/lib.rs +++ b/examples/rwa-country-allow/src/lib.rs @@ -1,3 +1,5 @@ #![no_std] pub mod contract; +#[cfg(test)] +mod test; diff --git a/examples/rwa-country-allow/src/test.rs b/examples/rwa-country-allow/src/test.rs new file mode 100644 index 000000000..06b540ed3 --- /dev/null +++ b/examples/rwa-country-allow/src/test.rs @@ -0,0 +1,259 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal, + String, Val, Vec, +}; +use stellar_tokens::rwa::{ + identity_registry_storage::{ + CountryData, CountryDataManager, CountryRelation, IdentityRegistryStorage, + IndividualCountryRelation, OrganizationCountryRelation, + }, + utils::token_binder::TokenBinder, +}; + +use crate::contract::{CountryAllowContract, CountryAllowContractClient}; + +fn create_client<'a>(e: &Env, admin: &Address) -> CountryAllowContractClient<'a> { + let address = e.register(CountryAllowContract, (admin,)); + CountryAllowContractClient::new(e, &address) +} + +#[contract] +struct MockIRSContract; + +#[contracttype] +#[derive(Clone)] +enum MockIRSStorageKey { + Identity(Address), + CountryEntries(Address), +} + +#[contractimpl] +impl TokenBinder for MockIRSContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl IdentityRegistryStorage for MockIRSContract { + fn add_identity( + _e: &Env, + _account: Address, + _identity: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_identity is not used in these tests"); + } + + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { + unreachable!("remove_identity is not used in these tests"); + } + + fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { + unreachable!("modify_identity is not used in these tests"); + } + + fn recover_identity( + _e: &Env, + _old_account: Address, + _new_account: Address, + _operator: Address, + ) { + unreachable!("recover_identity is not used in these tests"); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + e.storage() + .persistent() + .get(&MockIRSStorageKey::Identity(account.clone())) + .unwrap_or(account) + } +} + +#[contractimpl] +impl CountryDataManager for MockIRSContract { + fn add_country_data_entries( + _e: &Env, + _account: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_country_data_entries is not used in these tests"); + } + + fn modify_country_data( + _e: &Env, + _account: Address, + _index: u32, + _country_data: Val, + _operator: Address, + ) { + unreachable!("modify_country_data is not used in these tests"); + } + + fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { + unreachable!("delete_country_data is not used in these tests"); + } + + fn get_country_data_entries(e: &Env, account: Address) -> Vec { + let entries: Vec = e + .storage() + .persistent() + .get(&MockIRSStorageKey::CountryEntries(account)) + .unwrap_or_else(|| Vec::new(e)); + + Vec::from_iter(e, entries.iter().map(|entry| entry.into_val(e))) + } +} + +#[contractimpl] +impl MockIRSContract { + pub fn set_country_data_entries(e: &Env, account: Address, entries: Vec) { + e.storage().persistent().set(&MockIRSStorageKey::CountryEntries(account), &entries); + } +} + +fn individual_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Individual(IndividualCountryRelation::Residence(code)), + metadata: None, + } +} + +fn organization_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Organization(OrganizationCountryRelation::OperatingJurisdiction( + code, + )), + metadata: None, + } +} + +#[test] +fn add_and_remove_allowed_country_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let client = create_client(&e, &admin); + + assert!(!client.is_country_allowed(&token, &276)); + + client.add_allowed_country(&token, &276); + assert!(client.is_country_allowed(&token, &276)); + + client.remove_allowed_country(&token, &276); + assert!(!client.is_country_allowed(&token, &276)); +} + +#[test] +fn batch_allow_and_disallow_countries_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let client = create_client(&e, &admin); + + client.batch_allow_countries(&token, &vec![&e, 250u32, 276u32]); + assert!(client.is_country_allowed(&token, &250)); + assert!(client.is_country_allowed(&token, &276)); + + client.batch_disallow_countries(&token, &vec![&e, 250u32]); + assert!(!client.is_country_allowed(&token, &250)); + assert!(client.is_country_allowed(&token, &276)); +} + +#[test] +fn name_and_compliance_address_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let client = create_client(&e, &admin); + + assert_eq!(client.name(), String::from_str(&e, "CountryAllowModule")); + + client.set_compliance_address(&compliance); + assert_eq!(client.get_compliance_address(), compliance); +} + +#[test] +fn set_identity_registry_storage_uses_admin_auth_before_compliance_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let irs = Address::generate(&e); + let client = create_client(&e, &admin); + + client.set_identity_registry_storage(&token, &irs); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); +} + +#[test] +fn set_identity_registry_storage_uses_compliance_auth_after_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let irs = Address::generate(&e); + let client = create_client(&e, &admin); + + client.set_compliance_address(&compliance); + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); + + client.set_identity_registry_storage(&token, &irs); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &compliance); +} + +#[test] +fn can_transfer_and_can_create_use_irs_country_entries() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let from = Address::generate(&e); + let token = Address::generate(&e); + let allowed_to = Address::generate(&e); + let disallowed_to = Address::generate(&e); + let amount = 100_i128; + let client = create_client(&e, &admin); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + + irs.set_country_data_entries( + &allowed_to, + &vec![&e, individual_country(250), organization_country(276)], + ); + irs.set_country_data_entries(&disallowed_to, &vec![&e, individual_country(250)]); + + client.set_identity_registry_storage(&token, &irs_id); + client.add_allowed_country(&token, &276); + + assert!(client.can_transfer(&from, &allowed_to, &amount, &token)); + assert!(client.can_create(&allowed_to, &amount, &token)); + assert!(!client.can_transfer(&from, &disallowed_to, &amount, &token)); + assert!(!client.can_create(&disallowed_to, &amount, &token)); +} diff --git a/examples/rwa-country-restrict/Cargo.toml b/examples/rwa-country-restrict/Cargo.toml index 27aabc3bc..d160254e9 100644 --- a/examples/rwa-country-restrict/Cargo.toml +++ b/examples/rwa-country-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-country-restrict/src/lib.rs b/examples/rwa-country-restrict/src/lib.rs index b9aa4d5f0..a879b6f80 100644 --- a/examples/rwa-country-restrict/src/lib.rs +++ b/examples/rwa-country-restrict/src/lib.rs @@ -1,3 +1,5 @@ #![no_std] pub mod contract; +#[cfg(test)] +mod test; diff --git a/examples/rwa-country-restrict/src/test.rs b/examples/rwa-country-restrict/src/test.rs new file mode 100644 index 000000000..4d6a10154 --- /dev/null +++ b/examples/rwa-country-restrict/src/test.rs @@ -0,0 +1,259 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, IntoVal, + String, Val, Vec, +}; +use stellar_tokens::rwa::{ + identity_registry_storage::{ + CountryData, CountryDataManager, CountryRelation, IdentityRegistryStorage, + IndividualCountryRelation, OrganizationCountryRelation, + }, + utils::token_binder::TokenBinder, +}; + +use crate::contract::{CountryRestrictContract, CountryRestrictContractClient}; + +fn create_client<'a>(e: &Env, admin: &Address) -> CountryRestrictContractClient<'a> { + let address = e.register(CountryRestrictContract, (admin,)); + CountryRestrictContractClient::new(e, &address) +} + +#[contract] +struct MockIRSContract; + +#[contracttype] +#[derive(Clone)] +enum MockIRSStorageKey { + Identity(Address), + CountryEntries(Address), +} + +#[contractimpl] +impl TokenBinder for MockIRSContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl IdentityRegistryStorage for MockIRSContract { + fn add_identity( + _e: &Env, + _account: Address, + _identity: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_identity is not used in these tests"); + } + + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { + unreachable!("remove_identity is not used in these tests"); + } + + fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { + unreachable!("modify_identity is not used in these tests"); + } + + fn recover_identity( + _e: &Env, + _old_account: Address, + _new_account: Address, + _operator: Address, + ) { + unreachable!("recover_identity is not used in these tests"); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + e.storage() + .persistent() + .get(&MockIRSStorageKey::Identity(account.clone())) + .unwrap_or(account) + } +} + +#[contractimpl] +impl CountryDataManager for MockIRSContract { + fn add_country_data_entries( + _e: &Env, + _account: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_country_data_entries is not used in these tests"); + } + + fn modify_country_data( + _e: &Env, + _account: Address, + _index: u32, + _country_data: Val, + _operator: Address, + ) { + unreachable!("modify_country_data is not used in these tests"); + } + + fn delete_country_data(_e: &Env, _account: Address, _index: u32, _operator: Address) { + unreachable!("delete_country_data is not used in these tests"); + } + + fn get_country_data_entries(e: &Env, account: Address) -> Vec { + let entries: Vec = e + .storage() + .persistent() + .get(&MockIRSStorageKey::CountryEntries(account)) + .unwrap_or_else(|| Vec::new(e)); + + Vec::from_iter(e, entries.iter().map(|entry| entry.into_val(e))) + } +} + +#[contractimpl] +impl MockIRSContract { + pub fn set_country_data_entries(e: &Env, account: Address, entries: Vec) { + e.storage().persistent().set(&MockIRSStorageKey::CountryEntries(account), &entries); + } +} + +fn individual_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Individual(IndividualCountryRelation::Residence(code)), + metadata: None, + } +} + +fn organization_country(code: u32) -> CountryData { + CountryData { + country: CountryRelation::Organization(OrganizationCountryRelation::OperatingJurisdiction( + code, + )), + metadata: None, + } +} + +#[test] +fn add_and_remove_country_restriction_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let client = create_client(&e, &admin); + + assert!(!client.is_country_restricted(&token, &276)); + + client.add_country_restriction(&token, &276); + assert!(client.is_country_restricted(&token, &276)); + + client.remove_country_restriction(&token, &276); + assert!(!client.is_country_restricted(&token, &276)); +} + +#[test] +fn batch_restrict_and_unrestrict_countries_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let client = create_client(&e, &admin); + + client.batch_restrict_countries(&token, &vec![&e, 250u32, 276u32]); + assert!(client.is_country_restricted(&token, &250)); + assert!(client.is_country_restricted(&token, &276)); + + client.batch_unrestrict_countries(&token, &vec![&e, 250u32]); + assert!(!client.is_country_restricted(&token, &250)); + assert!(client.is_country_restricted(&token, &276)); +} + +#[test] +fn name_and_compliance_address_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let client = create_client(&e, &admin); + + assert_eq!(client.name(), String::from_str(&e, "CountryRestrictModule")); + + client.set_compliance_address(&compliance); + assert_eq!(client.get_compliance_address(), compliance); +} + +#[test] +fn set_identity_registry_storage_uses_admin_auth_before_compliance_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let irs = Address::generate(&e); + let client = create_client(&e, &admin); + + client.set_identity_registry_storage(&token, &irs); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); +} + +#[test] +fn set_identity_registry_storage_uses_compliance_auth_after_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let irs = Address::generate(&e); + let client = create_client(&e, &admin); + + client.set_compliance_address(&compliance); + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); + + client.set_identity_registry_storage(&token, &irs); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &compliance); +} + +#[test] +fn can_transfer_and_can_create_use_irs_country_entries() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let from = Address::generate(&e); + let token = Address::generate(&e); + let allowed_to = Address::generate(&e); + let restricted_to = Address::generate(&e); + let amount = 100_i128; + let client = create_client(&e, &admin); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + + irs.set_country_data_entries(&allowed_to, &vec![&e, individual_country(250)]); + irs.set_country_data_entries( + &restricted_to, + &vec![&e, individual_country(250), organization_country(276)], + ); + + client.set_identity_registry_storage(&token, &irs_id); + client.add_country_restriction(&token, &276); + + assert!(client.can_transfer(&from, &allowed_to, &amount, &token)); + assert!(client.can_create(&allowed_to, &amount, &token)); + assert!(!client.can_transfer(&from, &restricted_to, &amount, &token)); + assert!(!client.can_create(&restricted_to, &amount, &token)); +} diff --git a/examples/rwa-deploy/verifier/src/lib.rs b/examples/rwa-deploy/verifier/src/lib.rs index bf77abc79..c8ba550ac 100644 --- a/examples/rwa-deploy/verifier/src/lib.rs +++ b/examples/rwa-deploy/verifier/src/lib.rs @@ -6,7 +6,8 @@ use soroban_sdk::{ use stellar_access::access_control::{self as access_control, AccessControl}; use stellar_macros::only_role; use stellar_tokens::rwa::{ - emit_claim_topics_and_issuers_set, identity_verifier::IdentityVerifier, RWAError, + emit_claim_topics_and_issuers_set, identity_registry_storage::IdentityRegistryStorageClient, + identity_verifier::IdentityVerifier, RWAError, }; #[contracttype] @@ -16,13 +17,6 @@ enum DataKey { ClaimTopicsAndIssuers, } -#[soroban_sdk::contractclient(name = "IRSClient")] -#[allow(dead_code)] -trait IRSView { - fn stored_identity(e: &Env, account: Address) -> Address; - fn get_recovered_to(e: &Env, old: Address) -> Option
; -} - #[contract] pub struct SimpleIdentityVerifier; @@ -46,7 +40,7 @@ impl SimpleIdentityVerifier { impl IdentityVerifier for SimpleIdentityVerifier { fn verify_identity(e: &Env, account: &Address) { let irs = identity_registry_storage(e); - let client = IRSClient::new(e, &irs); + let client = IdentityRegistryStorageClient::new(e, &irs); if client.try_stored_identity(account).is_err() { panic_with_error!(e, RWAError::IdentityVerificationFailed); } @@ -54,7 +48,7 @@ impl IdentityVerifier for SimpleIdentityVerifier { fn recovery_target(e: &Env, old_account: &Address) -> Option
{ let irs = identity_registry_storage(e); - let client = IRSClient::new(e, &irs); + let client = IdentityRegistryStorageClient::new(e, &irs); client.get_recovered_to(old_account) } 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-max-balance/Cargo.toml b/examples/rwa-max-balance/Cargo.toml index 1238118f2..066fdcb4c 100644 --- a/examples/rwa-max-balance/Cargo.toml +++ b/examples/rwa-max-balance/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-max-balance/src/lib.rs b/examples/rwa-max-balance/src/lib.rs index b9aa4d5f0..a879b6f80 100644 --- a/examples/rwa-max-balance/src/lib.rs +++ b/examples/rwa-max-balance/src/lib.rs @@ -1,3 +1,5 @@ #![no_std] pub mod contract; +#[cfg(test)] +mod test; diff --git a/examples/rwa-max-balance/src/test.rs b/examples/rwa-max-balance/src/test.rs new file mode 100644 index 000000000..3beb493c1 --- /dev/null +++ b/examples/rwa-max-balance/src/test.rs @@ -0,0 +1,303 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, String, Val, + Vec, +}; +use stellar_tokens::rwa::{ + compliance::{Compliance, ComplianceHook}, + identity_registry_storage::IdentityRegistryStorage, + utils::token_binder::TokenBinder, +}; + +use crate::contract::{MaxBalanceContract, MaxBalanceContractClient}; + +fn create_client<'a>(e: &Env, admin: &Address) -> (Address, MaxBalanceContractClient<'a>) { + let address = e.register(MaxBalanceContract, (admin,)); + (address.clone(), MaxBalanceContractClient::new(e, &address)) +} + +#[contract] +struct MockIRSContract; + +#[contracttype] +#[derive(Clone)] +enum MockIRSStorageKey { + Identity(Address), +} + +#[contractimpl] +impl TokenBinder for MockIRSContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl IdentityRegistryStorage for MockIRSContract { + fn add_identity( + _e: &Env, + _account: Address, + _identity: Address, + _country_data_list: Vec, + _operator: Address, + ) { + unreachable!("add_identity is not used in these tests"); + } + + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { + unreachable!("remove_identity is not used in these tests"); + } + + fn modify_identity(_e: &Env, _account: Address, _identity: Address, _operator: Address) { + unreachable!("modify_identity is not used in these tests"); + } + + fn recover_identity( + _e: &Env, + _old_account: Address, + _new_account: Address, + _operator: Address, + ) { + unreachable!("recover_identity is not used in these tests"); + } + + fn stored_identity(e: &Env, account: Address) -> Address { + e.storage() + .persistent() + .get(&MockIRSStorageKey::Identity(account.clone())) + .unwrap_or(account) + } +} + +#[contractimpl] +impl MockIRSContract { + pub fn set_identity(e: &Env, account: Address, identity: Address) { + e.storage().persistent().set(&MockIRSStorageKey::Identity(account), &identity); + } +} + +#[contract] +struct MockComplianceContract; + +#[contracttype] +#[derive(Clone)] +enum MockComplianceStorageKey { + Registered(ComplianceHook, Address), +} + +#[contractimpl] +impl Compliance for MockComplianceContract { + fn add_module_to(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("add_module_to is not used in these tests"); + } + + fn remove_module_from(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("remove_module_from is not used in these tests"); + } + + fn get_modules_for_hook(_e: &Env, _hook: ComplianceHook) -> Vec
{ + unreachable!("get_modules_for_hook is not used in these tests"); + } + + fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool { + e.storage().persistent().has(&MockComplianceStorageKey::Registered(hook, module)) + } + + fn transferred(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) { + unreachable!("transferred is not used in these tests"); + } + + fn created(_e: &Env, _to: Address, _amount: i128, _token: Address) { + unreachable!("created is not used in these tests"); + } + + fn destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) { + unreachable!("destroyed is not used in these tests"); + } + + fn can_transfer( + _e: &Env, + _from: Address, + _to: Address, + _amount: i128, + _token: Address, + ) -> bool { + unreachable!("can_transfer is not used in these tests"); + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + unreachable!("can_create is not used in these tests"); + } +} + +#[contractimpl] +impl TokenBinder for MockComplianceContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl MockComplianceContract { + pub fn register_hook(e: &Env, hook: ComplianceHook, module: Address) { + e.storage().persistent().set(&MockComplianceStorageKey::Registered(hook, module), &true); + } +} + +#[test] +fn set_and_get_max_balance_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_max_balance(&token, &100); + + assert_eq!(client.get_max_balance(&token), 100); +} + +#[test] +fn pre_set_identity_balances_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let identity_a = Address::generate(&e); + let identity_b = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.pre_set_identity_balance(&token, &identity_a, &40); + client.batch_pre_set_identity_balances( + &token, + &vec![&e, identity_a.clone(), identity_b.clone()], + &vec![&e, 50_i128, 20_i128], + ); + + assert_eq!(client.get_investor_balance(&token, &identity_a), 50); + assert_eq!(client.get_investor_balance(&token, &identity_b), 20); +} + +#[test] +fn name_compliance_address_and_required_hooks_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + assert_eq!(client.name(), String::from_str(&e, "MaxBalanceModule")); + assert_eq!( + client.required_hooks(), + vec![ + &e, + ComplianceHook::CanTransfer, + ComplianceHook::CanCreate, + ComplianceHook::Transferred, + ComplianceHook::Created, + ComplianceHook::Destroyed, + ] + ); + + client.set_compliance_address(&compliance); + assert_eq!(client.get_compliance_address(), compliance); +} + +#[test] +fn set_identity_registry_storage_uses_admin_auth_before_compliance_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let irs = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_identity_registry_storage(&token, &irs); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); +} + +#[test] +fn set_identity_registry_storage_uses_compliance_auth_after_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let irs = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_compliance_address(&compliance); + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); + + client.set_identity_registry_storage(&token, &irs); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &compliance); +} + +#[test] +fn can_create_and_can_transfer_use_identity_caps() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let recipient = Address::generate(&e); + let sender_identity = Address::generate(&e); + let recipient_identity = Address::generate(&e); + let (module_address, client) = create_client(&e, &admin); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let compliance_id = e.register(MockComplianceContract, ()); + let compliance = MockComplianceContractClient::new(&e, &compliance_id); + + irs.set_identity(&sender, &sender_identity); + irs.set_identity(&recipient, &recipient_identity); + + client.set_compliance_address(&compliance_id); + for hook in [ + ComplianceHook::CanTransfer, + ComplianceHook::CanCreate, + ComplianceHook::Transferred, + ComplianceHook::Created, + ComplianceHook::Destroyed, + ] { + compliance.register_hook(&hook, &module_address); + } + + client.verify_hook_wiring(); + client.set_identity_registry_storage(&token, &irs_id); + client.set_max_balance(&token, &100); + client.pre_set_identity_balance(&token, &recipient_identity, &60); + + assert!(!client.can_create(&recipient, &50, &token)); + assert!(client.can_create(&recipient, &40, &token)); + assert!(!client.can_transfer(&sender, &recipient, &50, &token)); + assert!(client.can_transfer(&sender, &recipient, &40, &token)); +} diff --git a/examples/rwa-supply-limit/Cargo.toml b/examples/rwa-supply-limit/Cargo.toml index b4d0e313c..94977d387 100644 --- a/examples/rwa-supply-limit/Cargo.toml +++ b/examples/rwa-supply-limit/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-supply-limit/src/lib.rs b/examples/rwa-supply-limit/src/lib.rs index b9aa4d5f0..a879b6f80 100644 --- a/examples/rwa-supply-limit/src/lib.rs +++ b/examples/rwa-supply-limit/src/lib.rs @@ -1,3 +1,5 @@ #![no_std] pub mod contract; +#[cfg(test)] +mod test; diff --git a/examples/rwa-supply-limit/src/test.rs b/examples/rwa-supply-limit/src/test.rs new file mode 100644 index 000000000..728fee1f5 --- /dev/null +++ b/examples/rwa-supply-limit/src/test.rs @@ -0,0 +1,194 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, String, Vec, +}; +use stellar_tokens::rwa::{ + compliance::{Compliance, ComplianceHook}, + utils::token_binder::TokenBinder, +}; + +use crate::contract::{SupplyLimitContract, SupplyLimitContractClient}; + +fn create_client<'a>(e: &Env, admin: &Address) -> (Address, SupplyLimitContractClient<'a>) { + let address = e.register(SupplyLimitContract, (admin,)); + (address.clone(), SupplyLimitContractClient::new(e, &address)) +} + +#[contract] +struct MockComplianceContract; + +#[contracttype] +#[derive(Clone)] +enum MockComplianceStorageKey { + Registered(ComplianceHook, Address), +} + +#[contractimpl] +impl Compliance for MockComplianceContract { + fn add_module_to(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("add_module_to is not used in these tests"); + } + + fn remove_module_from(_e: &Env, _hook: ComplianceHook, _module: Address, _operator: Address) { + unreachable!("remove_module_from is not used in these tests"); + } + + fn get_modules_for_hook(_e: &Env, _hook: ComplianceHook) -> Vec
{ + unreachable!("get_modules_for_hook is not used in these tests"); + } + + fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool { + e.storage().persistent().has(&MockComplianceStorageKey::Registered(hook, module)) + } + + fn transferred(_e: &Env, _from: Address, _to: Address, _amount: i128, _token: Address) { + unreachable!("transferred is not used in these tests"); + } + + fn created(_e: &Env, _to: Address, _amount: i128, _token: Address) { + unreachable!("created is not used in these tests"); + } + + fn destroyed(_e: &Env, _from: Address, _amount: i128, _token: Address) { + unreachable!("destroyed is not used in these tests"); + } + + fn can_transfer( + _e: &Env, + _from: Address, + _to: Address, + _amount: i128, + _token: Address, + ) -> bool { + unreachable!("can_transfer is not used in these tests"); + } + + fn can_create(_e: &Env, _to: Address, _amount: i128, _token: Address) -> bool { + unreachable!("can_create is not used in these tests"); + } +} + +#[contractimpl] +impl TokenBinder for MockComplianceContract { + fn linked_tokens(e: &Env) -> Vec
{ + Vec::new(e) + } + + fn bind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("bind_token is not used in these tests"); + } + + fn unbind_token(_e: &Env, _token: Address, _operator: Address) { + unreachable!("unbind_token is not used in these tests"); + } +} + +#[contractimpl] +impl MockComplianceContract { + pub fn register_hook(e: &Env, hook: ComplianceHook, module: Address) { + e.storage().persistent().set(&MockComplianceStorageKey::Registered(hook, module), &true); + } +} + +#[test] +fn set_supply_limit_and_pre_set_supply_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_supply_limit(&token, &100); + client.pre_set_supply(&token, &60); + + assert_eq!(client.get_supply_limit(&token), 100); + assert_eq!(client.get_internal_supply(&token), 60); +} + +#[test] +fn name_compliance_address_and_required_hooks_work() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + assert_eq!(client.name(), String::from_str(&e, "SupplyLimitModule")); + assert_eq!( + client.required_hooks(), + vec![&e, ComplianceHook::CanCreate, ComplianceHook::Created, ComplianceHook::Destroyed,] + ); + + client.set_compliance_address(&compliance); + assert_eq!(client.get_compliance_address(), compliance); +} + +#[test] +fn set_supply_limit_uses_admin_auth_before_compliance_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_supply_limit(&token, &100); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); +} + +#[test] +fn set_supply_limit_uses_compliance_auth_after_bind() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance = Address::generate(&e); + let token = Address::generate(&e); + let (_address, client) = create_client(&e, &admin); + + client.set_compliance_address(&compliance); + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &admin); + + client.set_supply_limit(&token, &100); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (addr, _) = &auths[0]; + assert_eq!(addr, &compliance); +} + +#[test] +fn can_create_and_hooks_update_internal_supply() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let token = Address::generate(&e); + let account = Address::generate(&e); + let (module_address, client) = create_client(&e, &admin); + let compliance_id = e.register(MockComplianceContract, ()); + let compliance = MockComplianceContractClient::new(&e, &compliance_id); + + client.set_compliance_address(&compliance_id); + for hook in [ComplianceHook::CanCreate, ComplianceHook::Created, ComplianceHook::Destroyed] { + compliance.register_hook(&hook, &module_address); + } + + client.verify_hook_wiring(); + client.set_supply_limit(&token, &100); + + assert!(client.can_create(&account, &80, &token)); + + client.on_created(&account, &80, &token); + assert_eq!(client.get_internal_supply(&token), 80); + assert!(!client.can_create(&account, &30, &token)); + + client.on_destroyed(&account, &20, &token); + assert_eq!(client.get_internal_supply(&token), 60); + assert!(client.can_create(&account, &40, &token)); +} diff --git a/examples/rwa-time-transfers-limits/Cargo.toml b/examples/rwa-time-transfers-limits/Cargo.toml 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/country_allow/storage.rs b/packages/tokens/src/rwa/compliance/modules/country_allow/storage.rs index 91b748145..d47da3698 100644 --- a/packages/tokens/src/rwa/compliance/modules/country_allow/storage.rs +++ b/packages/tokens/src/rwa/compliance/modules/country_allow/storage.rs @@ -123,7 +123,7 @@ pub fn batch_disallow_countries(e: &Env, token: &Address, countries: &Vec) // ################## COMPLIANCE HOOKS ################## -/// Checks whether `to` has at least one allowed country in the IRS for +/// Returns `true` if `to` has at least one allowed country in the IRS for /// `token`. /// /// # Arguments @@ -132,10 +132,10 @@ pub fn batch_disallow_countries(e: &Env, token: &Address, countries: &Vec) /// * `to` - The recipient whose country data is checked. /// * `token` - The token address. /// -/// # Returns +/// # Errors /// -/// `true` if the recipient has at least one allowed country, `false` -/// otherwise. +/// * [`crate::rwa::compliance::modules::ComplianceModuleError::IdentityRegistryNotSet`] +/// - When no IRS has been configured for `token`. /// /// # Cross-Contract Calls /// diff --git a/packages/tokens/src/rwa/compliance/modules/country_restrict/storage.rs b/packages/tokens/src/rwa/compliance/modules/country_restrict/storage.rs index 632e2321b..be7c0512e 100644 --- a/packages/tokens/src/rwa/compliance/modules/country_restrict/storage.rs +++ b/packages/tokens/src/rwa/compliance/modules/country_restrict/storage.rs @@ -123,7 +123,8 @@ pub fn batch_unrestrict_countries(e: &Env, token: &Address, countries: &Vec // ################## COMPLIANCE HOOKS ################## -/// Checks whether `to` has any restricted country in the IRS for `token`. +/// Returns `false` if `to` has any restricted country in the IRS for `token`, +/// and `true` otherwise. /// /// # Arguments /// @@ -131,9 +132,10 @@ pub fn batch_unrestrict_countries(e: &Env, token: &Address, countries: &Vec /// * `to` - The recipient whose country data is checked. /// * `token` - The token address. /// -/// # Returns +/// # Errors /// -/// `false` if the recipient has any restricted country, `true` otherwise. +/// * [`crate::rwa::compliance::modules::ComplianceModuleError::IdentityRegistryNotSet`] +/// - When no IRS has been configured for `token`. /// /// # Cross-Contract Calls /// 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/max_balance/test.rs b/packages/tokens/src/rwa/compliance/modules/max_balance/test.rs index bf208fb8d..c45ae068d 100644 --- a/packages/tokens/src/rwa/compliance/modules/max_balance/test.rs +++ b/packages/tokens/src/rwa/compliance/modules/max_balance/test.rs @@ -5,7 +5,8 @@ use soroban_sdk::{ }; use super::storage::{ - can_create, can_transfer, set_id_balance, set_max_balance, verify_hook_wiring, + can_create, can_transfer, get_id_balance, on_created, on_destroyed, on_transfer, + set_id_balance, set_max_balance, verify_hook_wiring, }; use crate::rwa::{ compliance::{ @@ -309,3 +310,138 @@ fn can_create_rejects_negative_amount_before_requiring_irs() { assert!(!can_create(&e, &recipient, -1, &token)); }); } + +#[test] +fn can_transfer_rejects_negative_amount_before_requiring_irs() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let recipient = Address::generate(&e); + + e.as_contract(&module_id, || { + arm_hooks(&e); + + assert!(!can_transfer(&e, &sender, &recipient, -1, &token)); + }); +} + +#[test] +fn can_transfer_allows_when_sender_and_recipient_share_identity() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let recipient = Address::generate(&e); + let shared_identity = Address::generate(&e); + + irs.set_identity(&sender, &shared_identity); + irs.set_identity(&recipient, &shared_identity); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + arm_hooks(&e); + set_max_balance(&e, &token, 1); + + assert!(can_transfer(&e, &sender, &recipient, 1_000, &token)); + }); +} + +#[test] +fn on_transfer_updates_balances_for_distinct_identities() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let recipient = Address::generate(&e); + let sender_identity = Address::generate(&e); + let recipient_identity = Address::generate(&e); + + irs.set_identity(&sender, &sender_identity); + irs.set_identity(&recipient, &recipient_identity); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_max_balance(&e, &token, 200); + set_id_balance(&e, &token, &sender_identity, 100); + set_id_balance(&e, &token, &recipient_identity, 20); + + on_transfer(&e, &sender, &recipient, 30, &token); + + assert_eq!(get_id_balance(&e, &token, &sender_identity), 70); + assert_eq!(get_id_balance(&e, &token, &recipient_identity), 50); + }); +} + +#[test] +fn on_transfer_is_noop_for_same_identity() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let recipient = Address::generate(&e); + let shared_identity = Address::generate(&e); + + irs.set_identity(&sender, &shared_identity); + irs.set_identity(&recipient, &shared_identity); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_id_balance(&e, &token, &shared_identity, 100); + + on_transfer(&e, &sender, &recipient, 30, &token); + + assert_eq!(get_id_balance(&e, &token, &shared_identity), 100); + }); +} + +#[test] +fn on_created_updates_identity_balance() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let recipient = Address::generate(&e); + let recipient_identity = Address::generate(&e); + + irs.set_identity(&recipient, &recipient_identity); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_max_balance(&e, &token, 200); + set_id_balance(&e, &token, &recipient_identity, 50); + + on_created(&e, &recipient, 30, &token); + + assert_eq!(get_id_balance(&e, &token, &recipient_identity), 80); + }); +} + +#[test] +fn on_destroyed_updates_identity_balance() { + let e = Env::default(); + let module_id = e.register(TestMaxBalanceContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let holder = Address::generate(&e); + let holder_identity = Address::generate(&e); + + irs.set_identity(&holder, &holder_identity); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + set_id_balance(&e, &token, &holder_identity, 90); + + on_destroyed(&e, &holder, 40, &token); + + assert_eq!(get_id_balance(&e, &token, &holder_identity), 50); + }); +} diff --git a/packages/tokens/src/rwa/compliance/modules/supply_limit/test.rs b/packages/tokens/src/rwa/compliance/modules/supply_limit/test.rs index 7047b8bef..18245ba92 100644 --- a/packages/tokens/src/rwa/compliance/modules/supply_limit/test.rs +++ b/packages/tokens/src/rwa/compliance/modules/supply_limit/test.rs @@ -3,8 +3,8 @@ extern crate std; use soroban_sdk::{contract, contractimpl, contracttype, testutils::Address as _, Address, Env}; use super::storage::{ - can_create, configure_supply_limit, get_internal_supply, on_created, on_destroyed, - pre_set_supply, verify_hook_wiring, + can_create, configure_supply_limit, get_internal_supply, get_supply_limit_or_panic, on_created, + on_destroyed, pre_set_supply, verify_hook_wiring, }; use crate::rwa::{ compliance::{ @@ -180,3 +180,28 @@ fn pre_set_internal_supply_seeds_existing_supply_for_cap_checks() { assert!(can_create(&e, 10, &token)); }); } + +#[test] +fn get_supply_limit_or_panic_returns_configured_limit() { + let e = Env::default(); + let module_id = e.register(TestSupplyLimitContract, ()); + let token = Address::generate(&e); + + e.as_contract(&module_id, || { + configure_supply_limit(&e, &token, 100); + + assert_eq!(get_supply_limit_or_panic(&e, &token), 100); + }); +} + +#[test] +#[should_panic] +fn get_supply_limit_or_panic_panics_when_unconfigured() { + let e = Env::default(); + let module_id = e.register(TestSupplyLimitContract, ()); + let token = Address::generate(&e); + + e.as_contract(&module_id, || { + let _ = get_supply_limit_or_panic(&e, &token); + }); +} diff --git a/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/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 26617af0816f20f1ab1172d4c15f0e87fc3f33c6 Mon Sep 17 00:00:00 2001 From: Aleksandr Pasevin Date: Sat, 11 Apr 2026 16:54:58 +0300 Subject: [PATCH 7/7] test: improve deploy branch module coverage Add focused module tests for initial lockup and time-based transfer limits so the deploy branch clears the Codecov patch and project thresholds. --- .../modules/initial_lockup_period/test.rs | 155 +++++++++++++++++- .../modules/time_transfers_limits/test.rs | 111 ++++++++++++- 2 files changed, 261 insertions(+), 5 deletions(-) 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 ec6951c91..35090f6d7 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 @@ -1,12 +1,15 @@ extern crate std; use soroban_sdk::{ - contract, contractimpl, contracttype, testutils::Address as _, vec, Address, Env, + contract, contractimpl, contracttype, + testutils::{Address as _, Ledger as _}, + vec, Address, Env, }; use super::storage::{ - can_transfer, get_internal_balance, get_total_locked, pre_set_lockup_state, verify_hook_wiring, - LockedTokens, + can_transfer, configure_lockup_period, get_internal_balance, get_locks, get_total_locked, + on_created, on_destroyed, on_transfer, pre_set_lockup_state, set_internal_balance, set_locks, + set_total_locked, verify_hook_wiring, LockedTokens, }; use crate::rwa::{ compliance::{ @@ -157,3 +160,149 @@ fn pre_set_lockup_state_seeds_existing_locked_balance() { assert!(can_transfer(&e, &wallet, 20, &token)); }); } + +#[test] +fn can_transfer_returns_true_without_locks_and_false_for_negative_amount() { + let e = Env::default(); + let module_id = e.register(TestModuleContract, ()); + let token = Address::generate(&e); + let wallet = Address::generate(&e); + + e.as_contract(&module_id, || { + arm_hooks(&e); + + assert!(!can_transfer(&e, &wallet, -1, &token)); + assert!(can_transfer(&e, &wallet, 1_000, &token)); + }); +} + +#[test] +fn on_created_locks_minted_amount_when_period_is_configured() { + let e = Env::default(); + e.ledger().set_timestamp(100); + + let module_id = e.register(TestModuleContract, ()); + let token = Address::generate(&e); + let wallet = Address::generate(&e); + + e.as_contract(&module_id, || { + configure_lockup_period(&e, &token, 60); + + on_created(&e, &wallet, 40, &token); + + let locks = get_locks(&e, &token, &wallet); + assert_eq!(locks.len(), 1); + let lock = locks.get(0).unwrap(); + assert_eq!(lock.amount, 40); + assert_eq!(lock.release_timestamp, 160); + assert_eq!(get_total_locked(&e, &token, &wallet), 40); + assert_eq!(get_internal_balance(&e, &token, &wallet), 40); + }); +} + +#[test] +fn on_transfer_consumes_unlocked_locks_before_updating_balances() { + let e = Env::default(); + e.ledger().set_timestamp(100); + + let module_id = e.register(TestModuleContract, ()); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let recipient = Address::generate(&e); + + e.as_contract(&module_id, || { + set_internal_balance(&e, &token, &sender, 100); + set_internal_balance(&e, &token, &recipient, 10); + set_locks( + &e, + &token, + &sender, + &vec![ + &e, + LockedTokens { amount: 30, release_timestamp: 90 }, + LockedTokens { amount: 40, release_timestamp: 200 }, + ], + ); + set_total_locked(&e, &token, &sender, 70); + + on_transfer(&e, &sender, &recipient, 50, &token); + + let locks = get_locks(&e, &token, &sender); + assert_eq!(locks.len(), 2); + let first_lock = locks.get(0).unwrap(); + assert_eq!(first_lock.amount, 10); + assert_eq!(first_lock.release_timestamp, 90); + let second_lock = locks.get(1).unwrap(); + assert_eq!(second_lock.amount, 40); + assert_eq!(second_lock.release_timestamp, 200); + assert_eq!(get_total_locked(&e, &token, &sender), 50); + assert_eq!(get_internal_balance(&e, &token, &sender), 50); + assert_eq!(get_internal_balance(&e, &token, &recipient), 60); + }); +} + +#[test] +fn on_destroyed_consumes_unlocked_locks_before_burning() { + let e = Env::default(); + e.ledger().set_timestamp(100); + + let module_id = e.register(TestModuleContract, ()); + let token = Address::generate(&e); + let wallet = Address::generate(&e); + + e.as_contract(&module_id, || { + set_internal_balance(&e, &token, &wallet, 100); + set_locks( + &e, + &token, + &wallet, + &vec![ + &e, + LockedTokens { amount: 30, release_timestamp: 90 }, + LockedTokens { amount: 40, release_timestamp: 200 }, + ], + ); + set_total_locked(&e, &token, &wallet, 70); + + on_destroyed(&e, &wallet, 50, &token); + + let locks = get_locks(&e, &token, &wallet); + assert_eq!(locks.len(), 2); + let first_lock = locks.get(0).unwrap(); + assert_eq!(first_lock.amount, 10); + assert_eq!(first_lock.release_timestamp, 90); + let second_lock = locks.get(1).unwrap(); + assert_eq!(second_lock.amount, 40); + assert_eq!(second_lock.release_timestamp, 200); + assert_eq!(get_total_locked(&e, &token, &wallet), 50); + assert_eq!(get_internal_balance(&e, &token, &wallet), 50); + }); +} + +#[test] +#[should_panic] +fn on_destroyed_panics_when_burn_exceeds_unlocked_balance() { + let e = Env::default(); + e.ledger().set_timestamp(100); + + let module_id = e.register(TestModuleContract, ()); + let token = Address::generate(&e); + let wallet = Address::generate(&e); + + e.as_contract(&module_id, || { + set_internal_balance(&e, &token, &wallet, 100); + set_locks( + &e, + &token, + &wallet, + &vec![ + &e, + LockedTokens { amount: 10, release_timestamp: 90 }, + LockedTokens { amount: 70, release_timestamp: 200 }, + ], + ); + set_total_locked(&e, &token, &wallet, 80); + + on_destroyed(&e, &wallet, 40, &token); + }); +} 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 721336728..65aca552f 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 @@ -1,11 +1,14 @@ extern crate std; use soroban_sdk::{ - contract, contractimpl, contracttype, testutils::Address as _, Address, Env, Val, Vec, + contract, contractimpl, contracttype, + testutils::{Address as _, Ledger as _}, + Address, Env, Val, Vec, }; use super::storage::{ - can_transfer, pre_set_transfer_counter, set_time_transfer_limit, verify_hook_wiring, Limit, + can_transfer, get_counter, get_limits, on_transfer, pre_set_transfer_counter, + remove_time_transfer_limit, set_time_transfer_limit, verify_hook_wiring, Limit, TransferCounter, }; use crate::rwa::{ @@ -272,3 +275,107 @@ fn set_time_transfer_limit_rejects_more_than_four_limits() { set_time_transfer_limit(&e, &token, &Limit { limit_time: 300, limit_value: 100 }); }); } + +#[test] +fn set_time_transfer_limit_replaces_existing_window() { + let e = Env::default(); + let module_id = e.register(TestModuleContract, ()); + let token = Address::generate(&e); + + e.as_contract(&module_id, || { + set_time_transfer_limit(&e, &token, &Limit { limit_time: 60, limit_value: 100 }); + set_time_transfer_limit(&e, &token, &Limit { limit_time: 60, limit_value: 150 }); + + let limits = get_limits(&e, &token); + assert_eq!(limits.len(), 1); + assert_eq!(limits.get(0).unwrap(), Limit { limit_time: 60, limit_value: 150 }); + }); +} + +#[test] +fn on_transfer_resets_finished_counter_before_incrementing() { + let e = Env::default(); + e.ledger().set_timestamp(100); + + let module_id = e.register(TestModuleContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let sender_identity = Address::generate(&e); + + irs.set_identity(&sender, &sender_identity); + + e.as_contract(&module_id, || { + set_irs_address(&e, &token, &irs_id); + 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: 100 }, + ); + + on_transfer(&e, &sender, 20, &token); + + assert_eq!( + get_counter(&e, &token, &sender_identity, 60), + TransferCounter { value: 20, timer: 160 } + ); + }); +} + +#[test] +#[should_panic] +fn remove_time_transfer_limit_panics_when_limit_is_missing() { + let e = Env::default(); + let module_id = e.register(TestModuleContract, ()); + let token = Address::generate(&e); + + e.as_contract(&module_id, || { + remove_time_transfer_limit(&e, &token, 60); + }); +} + +#[test] +#[should_panic] +fn pre_set_transfer_counter_panics_when_limit_is_missing() { + let e = Env::default(); + let module_id = e.register(TestModuleContract, ()); + let token = Address::generate(&e); + let identity = Address::generate(&e); + + e.as_contract(&module_id, || { + pre_set_transfer_counter( + &e, + &token, + &identity, + 60, + &TransferCounter { value: 10, timer: 100 }, + ); + }); +} + +#[test] +fn can_transfer_rejects_negative_amount_and_amounts_above_limit() { + let e = Env::default(); + let module_id = e.register(TestModuleContract, ()); + let irs_id = e.register(MockIRSContract, ()); + let irs = MockIRSContractClient::new(&e, &irs_id); + let token = Address::generate(&e); + let sender = Address::generate(&e); + let sender_identity = Address::generate(&e); + + irs.set_identity(&sender, &sender_identity); + + e.as_contract(&module_id, || { + arm_hooks(&e); + assert!(!can_transfer(&e, &sender, -1, &token)); + + set_irs_address(&e, &token, &irs_id); + set_time_transfer_limit(&e, &token, &Limit { limit_time: 60, limit_value: 100 }); + + assert!(!can_transfer(&e, &sender, 101, &token)); + }); +}