diff --git a/Cargo.lock b/Cargo.lock
index 469b78e3b..6f22cdc5b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1586,6 +1586,22 @@ dependencies = [
"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-token-example"
version = "0.6.0"
diff --git a/Cargo.toml b/Cargo.toml
index 1fd7fbbfc..5f7a1bb48 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -20,6 +20,8 @@ members = [
"examples/ownable",
"examples/pausable",
"examples/rwa/*",
+ "examples/rwa-max-balance",
+ "examples/rwa-supply-limit",
"examples/sac-admin-generic",
"examples/sac-admin-wrapper",
"examples/multisig-smart-account/*",
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/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..4655d99f1 100644
--- a/packages/tokens/src/rwa/compliance/modules/mod.rs
+++ b/packages/tokens/src/rwa/compliance/modules/mod.rs
@@ -1,6 +1,8 @@
use soroban_sdk::{contracterror, contracttrait, Address, Env, String};
+pub mod max_balance;
pub mod storage;
+pub mod supply_limit;
#[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));
+}