From 60c2164f0006db96351ffb2e980e2ea584d3c8aa Mon Sep 17 00:00:00 2001
From: mertcano <35747700+mertcano@users.noreply.github.com>
Date: Wed, 21 Oct 2026 01:40:15 +0300
Subject: [PATCH 1/2] fix(rwa): resolve C-01 and C-02 recovery vulnerabilities
in RWA storage
Fixes #???
## Description
This PR addresses and resolves critical security findings identified in the **RWA Token** module during the v0.5.0 audit:
1. **C-01: Unbounded Inflation via Self-Recovery in `recovery_address`**[cite: 136, 148]
- Prevents self-recovery exploits and potential inflation vectors within `recover_balance`[cite: 136, 148].
- Ensures strict validation constraints when transferring balances and states between accounts during recovery[cite: 140].
2. **C-02: Missing Same-Identity Check in `recovery_address` Enables Malicious Recovery**[cite: 136, 149]
- Enforces strict verification of the recovery target via `identity_verifier_client.recovery_target(old_account)`[cite: 140, 149].
- Validates that `recovery_target` matches the `new_account` address to prevent unauthorized asset migration or hijacking[cite: 140, 149].
#### PR Checklist
- [x] Tests
- [x] Documentation
---
packages/tokens/src/rwa/storage.rs | 2912 ++++++++++++++--------------
1 file changed, 1456 insertions(+), 1456 deletions(-)
diff --git a/packages/tokens/src/rwa/storage.rs b/packages/tokens/src/rwa/storage.rs
index 4d4501cc0..3be7cab97 100644
--- a/packages/tokens/src/rwa/storage.rs
+++ b/packages/tokens/src/rwa/storage.rs
@@ -1,1456 +1,1456 @@
-use soroban_sdk::{contracttype, panic_with_error, Address, Env, MuxedAddress, String, Vec};
-use stellar_contract_utils::pausable::{paused, PausableError};
-
-use crate::{
- fungible::{
- capped::{check_cap, CappedContractType},
- emit_transfer,
- total_supply::{
- decrease_total_supply, increase_total_supply, total_supply, TotalSupplyOverrides,
- },
- Base, ContractOverrides, MintOverrides,
- },
- rwa::{
- compliance::{AccountSnapshot, ComplianceClient, TransferKind},
- emit_address_frozen, emit_burn, emit_compliance_set, emit_identity_verifier_set, emit_mint,
- emit_recovery_success, emit_token_onchain_id_updated, emit_tokens_frozen,
- emit_tokens_unfrozen, IdentityVerifierClient, RWAError, FROZEN_EXTEND_AMOUNT,
- FROZEN_TTL_THRESHOLD,
- },
-};
-
-/// Storage keys for the data associated with `RWA` token
-#[contracttype]
-pub enum RWAStorageKey {
- /// Frozen status of an address (true = frozen, false = not frozen)
- AddressFrozen(Address),
- /// Amount of tokens frozen for a specific address
- FrozenTokens(Address),
- /// Compliance contract address
- Compliance,
- /// OnchainID contract address
- OnchainId,
- /// Version of the token
- Version,
- /// Identity Verifier contract address
- IdentityVerifier,
-}
-
-pub struct RWA;
-
-/// Who verifies the identity of `from` in [`RWA::validate_transfer`].
-///
-/// Every item of a batch transfer shares one `from`, so verifying it per item
-/// repeats the same walk through the identity stack. This lets the batch lift
-/// that one check out of its loop while every other check still runs per item,
-/// and while all three transfer entry points keep sharing one validator.
-pub enum FromVerification {
- /// Verify `from` as part of this call. What the single-transfer entry
- /// points pass.
- Required,
- /// Skip the check on `from`, because the caller has already verified it
- /// once for a batch of transfers that all share it.
- HoistedByCaller,
-}
-
-impl ContractOverrides for RWA {
- fn transfer(e: &Env, from: &Address, to: &MuxedAddress, amount: i128) {
- RWA::transfer(e, from, to, amount);
- }
-
- fn transfer_from(e: &Env, spender: &Address, from: &Address, to: &Address, amount: i128) {
- RWA::transfer_from(e, spender, from, to, amount);
- }
-}
-
-// T-REX (ERC-3643) tokens expose the total supply, so the RWA contract type
-// is inherently supply-aware.
-impl TotalSupplyOverrides for RWA {}
-
-// Minting through the contract type runs RWA's full mint path: identity
-// verification, freezing checks, supply accounting and the compliance hook.
-impl MintOverrides for RWA {
- fn mint(e: &Env, to: &Address, amount: i128) {
- RWA::mint(e, to, amount);
- }
-}
-
-/// Marker for the contract types backing [`crate::rwa::RWAToken`]: [`RWA`]
-/// itself and [`CappedRWA`].
-///
-/// # Why this trait exists
-///
-/// `RWAToken`'s default bodies call the [`RWA`] functions directly (batch
-/// transfers, forced transfers, freezing, recovery, ...), so the trait used to
-/// require `ContractType = RWA` exactly. A capped RWA token needs a different
-/// contract type, [`CappedRWA`], whose mint also checks the cap, while every
-/// other RWA operation stays the same. This marker lets `RWAToken` accept both
-/// contract types, and nothing else.
-///
-/// Contract authors never interact with this trait; it only appears as that
-/// bound.
-#[diagnostic::on_unimplemented(
- message = "`{Self}` is not an RWA contract type, so `RWAToken` cannot be implemented",
- note = "list `RWA` in the `Compose` list, e.g. `Compose<(RWA, TotalSupply)>`"
-)]
-pub trait RWAContractType {}
-
-impl RWAContractType for RWA {}
-
-/// Contract type enforcing a maximum total supply on top of [`RWA`], resolved
-/// by `Compose<(RWA, Capped, TotalSupply)>`.
-///
-/// Its `mint` and `batch_mint` check the cap (refer to
-/// [`crate::fungible::capped::Capped`]) before running RWA's own mint path.
-/// Every other operation is RWA's, unchanged.
-pub struct CappedRWA;
-
-impl RWAContractType for CappedRWA {}
-impl CappedContractType for CappedRWA {}
-
-// RWA tracks the supply itself, and the capped variant reads the same counter.
-impl TotalSupplyOverrides for CappedRWA {}
-
-// Transfers keep RWA's identity, freezing and compliance checks.
-impl ContractOverrides for CappedRWA {
- fn transfer(e: &Env, from: &Address, to: &MuxedAddress, amount: i128) {
- RWA::transfer(e, from, to, amount);
- }
-
- fn transfer_from(e: &Env, spender: &Address, from: &Address, to: &Address, amount: i128) {
- RWA::transfer_from(e, spender, from, to, amount);
- }
-}
-
-impl MintOverrides for CappedRWA {
- fn mint(e: &Env, to: &Address, amount: i128) {
- CappedRWA::mint(e, to, amount);
- }
-}
-
-impl CappedRWA {
- /// Mints `amount` tokens to `to` if the cap allows it.
- ///
- /// # Errors
- ///
- /// * refer to [`check_cap`] errors.
- /// * refer to [`RWA::mint`] errors.
- ///
- /// # Security Warning
- ///
- /// ⚠️ SECURITY RISK: This function has NO AUTHORIZATION CONTROLS ⚠️
- ///
- /// Refer to the security warning on [`RWA::mint`].
- pub fn mint(e: &Env, to: &Address, amount: i128) {
- check_cap(e, amount, total_supply(e));
- RWA::mint(e, to, amount);
- }
-
- /// Mints `amounts[i]` tokens to `to_list[i]`, checking the cap for every
- /// item.
- ///
- /// # Errors
- ///
- /// * refer to [`RWA::mint`] errors.
- /// * refer to [`check_cap`] errors.
- ///
- /// # Security Warning
- ///
- /// ⚠️ SECURITY RISK: This function has NO AUTHORIZATION CONTROLS ⚠️
- ///
- /// Refer to the security warning on [`RWA::mint`].
- pub fn batch_mint(e: &Env, to_list: &Vec
, amounts: &Vec) {
- RWA::require_equal_lengths(e, to_list.len(), amounts.len());
-
- for (to, amount) in to_list.iter().zip(amounts.iter()) {
- CappedRWA::mint(e, &to, amount);
- }
- }
-}
-
-impl RWA {
- // ################## QUERY STATE ##################
-
- /// Returns the token version.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- ///
- /// # Errors
- ///
- /// * [`RWAError::VersionNotSet`] - When the version is not set.
- pub fn version(e: &Env) -> String {
- e.storage()
- .instance()
- .get(&RWAStorageKey::Version)
- .unwrap_or_else(|| panic_with_error!(e, RWAError::VersionNotSet))
- }
-
- /// Returns the address of the onchain ID of the token.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- ///
- /// # Errors
- ///
- /// * [`RWAError::OnchainIdNotSet`] - When the onchain ID is not set.
- pub fn onchain_id(e: &Env) -> Address {
- e.storage()
- .instance()
- .get(&RWAStorageKey::OnchainId)
- .unwrap_or_else(|| panic_with_error!(e, RWAError::OnchainIdNotSet))
- }
-
- /// Returns the Compliance contract linked to the token.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- ///
- /// # Errors
- ///
- /// * [`RWAError::ComplianceNotSet`] - When the compliance contract is not
- /// set.
- pub fn compliance(e: &Env) -> Address {
- e.storage()
- .instance()
- .get(&RWAStorageKey::Compliance)
- .unwrap_or_else(|| panic_with_error!(e, RWAError::ComplianceNotSet))
- }
-
- /// Returns the Identity Verifier contract linked to the token.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- ///
- /// # Errors
- ///
- /// * [`RWAError::IdentityVerifierNotSet`] - When the identity verifier
- /// contract is not set.
- pub fn identity_verifier(e: &Env) -> Address {
- e.storage()
- .instance()
- .get(&RWAStorageKey::IdentityVerifier)
- .unwrap_or_else(|| panic_with_error!(e, RWAError::IdentityVerifierNotSet))
- }
-
- /// Returns the freezing status of a wallet. Frozen wallets cannot send or
- /// receive funds.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `user_address` - The address of the wallet to check.
- pub fn is_frozen(e: &Env, user_address: &Address) -> bool {
- let key = RWAStorageKey::AddressFrozen(user_address.clone());
- if let Some(frozen) = e.storage().persistent().get::<_, bool>(&key) {
- e.storage().persistent().extend_ttl(&key, FROZEN_TTL_THRESHOLD, FROZEN_EXTEND_AMOUNT);
- frozen
- } else {
- false
- }
- }
-
- /// Returns the amount of tokens that are partially frozen on a wallet.
- /// The amount of frozen tokens is always <= to the total balance of the
- /// wallet.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `user_address` - The address of the wallet on which get_frozen_tokens
- /// is called.
- pub fn get_frozen_tokens(e: &Env, user_address: &Address) -> i128 {
- let key = RWAStorageKey::FrozenTokens(user_address.clone());
- if let Some(frozen_amount) = e.storage().persistent().get::<_, i128>(&key) {
- e.storage().persistent().extend_ttl(&key, FROZEN_TTL_THRESHOLD, FROZEN_EXTEND_AMOUNT);
- frozen_amount
- } else {
- 0
- }
- }
-
- /// Returns the amount of free (unfrozen) tokens for an address.
- /// This is calculated as total balance minus frozen tokens.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `user_address` - The address to check.
- pub fn get_free_tokens(e: &Env, user_address: &Address) -> i128 {
- let total_balance = Base::balance(e, user_address);
- let frozen_tokens = Self::get_frozen_tokens(e, user_address);
-
- // frozen tokens cannot be greater than total balance, necessary checks
- // are done in state changing functions
- total_balance - frozen_tokens
- }
-
- /// Captures an [`AccountSnapshot`] of `address` from the token's current
- /// state: its total balance and partially-frozen amount. Must be called
- /// before any balance or freeze mutation so the snapshot reflects the
- /// pre-operation figures handed to the compliance hooks.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `address` - The wallet to snapshot.
- pub fn account_snapshot(e: &Env, address: &Address) -> AccountSnapshot {
- AccountSnapshot {
- address: address.clone(),
- balance: Base::balance(e, address),
- frozen: Self::get_frozen_tokens(e, address),
- }
- }
-
- // ################## CHANGE STATE ##################
-
- /// Forced transfer of `amount` tokens from `from` to `to`.
- ///
- /// The `to` address must pass identity verification. `from` is not required
- /// to be verified, so this privileged action remains usable to pull
- /// tokens out of accounts whose identity is no longer valid
- /// (sanctioned, revoked, or compromised wallets). The same mechanics
- /// back [`Self::recover_balance`], which reports
- /// [`TransferKind::Recovery`] to the compliance contract instead of
- /// [`TransferKind::Forced`].
- ///
- /// This function can unfreeze tokens if needed for regulatory compliance.
- /// It bypasses paused state and frozen address checks.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `from` - The address holding the tokens.
- /// * `to` - The address receiving the tokens.
- /// * `amount` - The amount of tokens to transfer.
- ///
- /// # Errors
- ///
- /// * [`RWAError::InsufficientBalance`] - When attempting to transfer more
- /// tokens than available.
- /// * refer to [`IdentityVerifierClient::verify_identity`] errors.
- /// * refer to [`Base::update`] errors.
- ///
- /// # Events
- ///
- /// * topics - `["transfer", from: Address, to: Address]`
- /// * data - `[amount: i128]`
- ///
- /// Emitted before the transfer when `amount` exceeds the free (unfrozen)
- /// balance of `from`, for the part of the frozen tokens that is unfrozen:
- /// * topics - `["tokens_unfrozen", from: Address]`
- /// * data - `[amount: i128]`
- ///
- /// # Notes
- ///
- /// This function bypasses freezing restrictions and can unfreeze tokens
- /// as needed. It's intended for regulatory compliance scenarios such as
- /// court-ordered seizures.
- ///
- /// # Security Warning
- ///
- /// **IMPORTANT**: This function bypasses authorization and freezing checks.
- /// Should only be used by authorized compliance or admin functions.
- pub fn forced_transfer(e: &Env, from: &Address, to: &Address, amount: i128) {
- // Verify identity for the `to` address only; `from` is intentionally
- // skipped so this privileged action can still move tokens out of
- // accounts whose identity is no longer valid.
- let identity_verifier_addr = Self::identity_verifier(e);
- let identity_verifier_client = IdentityVerifierClient::new(e, &identity_verifier_addr);
- identity_verifier_client.verify_identity(to);
-
- Self::privileged_transfer(e, from, to, amount, &TransferKind::Forced);
- }
-
- /// Mints `amount` tokens to `to`.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `to` - The address receiving the new tokens.
- /// * `amount` - The amount of tokens to mint.
- ///
- /// # Errors
- ///
- /// * [`PausableError::EnforcedPause`] - When the contract is paused.
- /// * [`RWAError::AddressFrozen`] - When the recipient address is frozen.
- /// * [`RWAError::ComplianceNotSet`] - When the compliance contract is not
- /// configured.
- /// * refer to [`Base::update`] errors.
- ///
- /// # Events
- ///
- /// * topics - `["mint", to: Address]`
- /// * data - `[amount: i128]`
- ///
- /// # Security Warning
- ///
- /// ⚠️ SECURITY RISK: This function has NO AUTHORIZATION CONTROLS ⚠️
- ///
- /// It is the responsibility of the implementer to establish appropriate
- /// access controls to ensure that only authorized accounts can execute
- /// minting operations. Failure to implement proper authorization could
- /// lead to security vulnerabilities and unauthorized token creation.
- ///
- /// The implementation will typically look similar to the following
- /// (pseudo-code):
- ///
- /// ```ignore
- /// let admin = read_administrator(e);
- /// admin.require_auth();
- /// ```
- pub fn mint(e: &Env, to: &Address, amount: i128) {
- if paused(e) {
- panic_with_error!(e, PausableError::EnforcedPause);
- }
-
- if Self::is_frozen(e, to) {
- panic_with_error!(e, RWAError::AddressFrozen);
- }
-
- let identity_verifier_addr = Self::identity_verifier(e);
- let identity_verifier_client = IdentityVerifierClient::new(e, &identity_verifier_addr);
- identity_verifier_client.verify_identity(to);
-
- // Snapshot before the mint so the hook observes the pre-mint balance.
- let to_snapshot = Self::account_snapshot(e, to);
-
- increase_total_supply(e, amount);
- Base::update(e, None, Some(to), amount);
-
- let compliance_addr = Self::compliance(e);
- let compliance_client = ComplianceClient::new(e, &compliance_addr);
- compliance_client.created(&to_snapshot, &amount, &e.current_contract_address());
-
- emit_mint(e, to, amount);
- }
-
- /// Burns `amount` tokens from `user_address`. Updates the total supply
- /// accordingly.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `user_address` - The address from which to burn tokens.
- /// * `amount` - The amount of tokens to burn.
- ///
- /// # Errors
- ///
- /// * [`RWAError::InsufficientBalance`] - When `amount` exceeds the total
- /// balance of `user_address`.
- /// * [`RWAError::ComplianceNotSet`] - When the compliance contract is not
- /// configured.
- /// * refer to
- /// [`Compliance::destroyed`](crate::rwa::compliance::Compliance::destroyed)
- /// errors.
- /// * refer to [`Base::update`] errors.
- ///
- /// # Events
- ///
- /// * topics - `["burn", user_address: Address]`
- /// * data - `[amount: i128]`
- ///
- /// Emitted before the burn when `amount` exceeds the free (unfrozen)
- /// balance of `user_address`, for the part of the frozen tokens that is
- /// unfrozen:
- /// * topics - `["tokens_unfrozen", user_address: Address]`
- /// * data - `[amount: i128]`
- ///
- /// # Notes
- ///
- /// This function bypasses freezing restrictions and can unfreeze tokens
- /// as needed: when `amount` exceeds the free (unfrozen) balance, the
- /// difference is unfrozen first, so a partially frozen balance is
- /// destroyed in place.
- ///
- /// Locked tokens are not given the same treatment. The `Destroyed` hook
- /// carries no notion of a privileged burn, so a compliance module can
- /// reject the burn by panicking regardless of who issued it. In
- /// particular, with the
- /// [`initial_lockup_period`](crate::rwa::compliance::modules::initial_lockup_period)
- /// module registered, a burn that dips into still-locked tokens is
- /// rejected; destroying a locked position takes a forced transfer to a
- /// treasury wallet (which consumes the locks) followed by a burn from
- /// that treasury.
- ///
- /// # Security Warning
- ///
- /// **IMPORTANT**: This function bypasses authorization checks and should
- /// only be used internally or in admin functions that implement their own
- /// authorization logic.
- pub fn burn(e: &Env, user_address: &Address, amount: i128) {
- // Snapshot before any unfreeze/balance mutation so the hook observes
- // the pre-burn state.
- let from_snapshot = Self::account_snapshot(e, user_address);
-
- if amount > from_snapshot.balance {
- panic_with_error!(e, RWAError::InsufficientBalance);
- }
-
- // Check if we need to unfreeze tokens to complete the burn. The
- // snapshot is still current: nothing has mutated the balance or
- // frozen amount since it was captured.
- let free_tokens = from_snapshot.balance - from_snapshot.frozen;
- if free_tokens < amount {
- let tokens_to_unfreeze = amount - free_tokens;
- let new_frozen = from_snapshot.frozen - tokens_to_unfreeze;
-
- e.storage()
- .persistent()
- .set(&RWAStorageKey::FrozenTokens(user_address.clone()), &new_frozen);
- emit_tokens_unfrozen(e, user_address, tokens_to_unfreeze);
- }
-
- Base::update(e, Some(user_address), None, amount);
- decrease_total_supply(e, amount);
-
- let compliance_addr = Self::compliance(e);
- let compliance_client = ComplianceClient::new(e, &compliance_addr);
- compliance_client.destroyed(&from_snapshot, &amount, &e.current_contract_address());
-
- emit_burn(e, user_address, amount);
- }
-
- /// Recovery function used to force transfer tokens from a old account to a
- /// new account. This function transfers all tokens and preserves the frozen
- /// status from the old account to the new account. Returns `true` if
- /// tokens were moved, `false` if the old account held no tokens.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `old_account` - The address of the wallet that lost access.
- /// * `new_account` - The address of the new account to receive the tokens.
- ///
- /// # Errors
- ///
- /// * [`RWAError::IdentityVerificationFailed`] - When the identity of the
- /// new account cannot be verified.
- /// * [`RWAError::IdentityMismatch`] - When the new account is not the
- /// target of the recovery process for the old wallet.
- ///
- /// # Events
- ///
- /// Emitted when the old account is fully frozen:
- /// * topics - `["address_frozen", new_account: Address, is_frozen: bool]`
- /// * data - `[]`
- ///
- /// Emitted only when tokens are moved:
- /// * topics - `["transfer", old_account: Address, new_account: Address]`
- /// * data - `[amount: i128]`
- /// * topics - `["recovery_success", old_account: Address, new_account:
- /// Address]`
- /// * data - `[]`
- ///
- /// Emitted only when tokens are moved and the old account has partially
- /// frozen tokens, which are unfrozen there before the transfer and frozen
- /// again on the new account after it:
- /// * topics - `["tokens_unfrozen", old_account: Address]`
- /// * data - `[amount: i128]`
- /// * topics - `["tokens_frozen", new_account: Address]`
- /// * data - `[amount: i128]`
- ///
- /// # Notes
- ///
- /// This function preserves the frozen status (both partial and full) from
- /// the old account and applies it to the new account, maintaining
- /// regulatory compliance. The full-address freeze is a wallet-level
- /// restriction, so it follows the investor to the new account even when the
- /// old account holds no tokens. The partially frozen amount is bounded by
- /// the balance, so there is nothing to migrate in that case.
- ///
- ///
- /// State owned by this contract (the frozen status) is migrated directly
- /// by this function. State held by compliance modules is handled through
- /// the transfer hook: the recovery is reported to the compliance contract
- /// as a transfer of kind [`TransferKind::Recovery`], and each module
- /// decides how to treat it. Policy modules exempt it like a forced
- /// transfer, while bookkeeping modules migrate wallet-bound state
- /// instead of consuming it (e.g. the initial lockup period module moves
- /// the old account's lock entries to the new account).
- ///
- /// This function does not concern itself with the Identity Management.
- /// If the old account's identity should be removed, it should be done on
- /// the Identity Stack.
- ///
- /// Allowances are not migrated or revoked: allowances the old account
- /// granted stay in place (with an empty balance there is nothing to spend
- /// until the old account receives tokens again), and allowances granted to
- /// the old account as a spender remain usable by whoever controls it.
- ///
- /// # Security Warning
- ///
- /// **IMPORTANT**: This function bypasses authorization and compliance
- /// checks. Should only be used by authorized recovery or admin
- /// functions.
- pub fn recover_balance(e: &Env, old_account: &Address, new_account: &Address) -> bool {
- // Verify identity for the new account.
- let identity_verifier_addr = Self::identity_verifier(e);
- let identity_verifier_client = IdentityVerifierClient::new(e, &identity_verifier_addr);
- identity_verifier_client.verify_identity(new_account);
-
- // Verify that the new account is the recovery target for the old
- // account
- let recovery_target = identity_verifier_client
- .recovery_target(old_account)
- .unwrap_or_else(|| panic_with_error!(e, RWAError::IdentityMismatch));
-
- if recovery_target != *new_account {
- panic_with_error!(e, RWAError::IdentityMismatch);
- }
-
- if Self::is_frozen(e, old_account) {
- Self::set_address_frozen(e, new_account, true);
- }
-
- // Get the balance of the old account, if there is nothing to transfer,
- // return false
- let lost_balance = Base::balance(e, old_account);
- if lost_balance == 0 {
- return false;
- }
-
- // Store the partially frozen amount before the transfer unfreezes it
- let frozen_tokens = Self::get_frozen_tokens(e, old_account);
-
- // Move all tokens with the shared privileged mechanics (handles
- // unfreezing as needed), reporting the movement as a recovery so
- // compliance modules migrate wallet-bound state instead of
- // consuming it.
- Self::privileged_transfer(
- e,
- old_account,
- new_account,
- lost_balance,
- &TransferKind::Recovery,
- );
-
- // Preserve frozen tokens on the new account if there were any
- if frozen_tokens > 0 {
- Self::freeze_partial_tokens(e, new_account, frozen_tokens);
- }
-
- emit_recovery_success(e, old_account, new_account);
-
- true
- }
-
- /// Sets the frozen status for an address. A frozen wallet cannot send
- /// tokens from its own balance or receive tokens. The freeze applies to
- /// the wallet's own balance only: a frozen wallet can still act as the
- /// spender in [`Self::transfer_from`], moving tokens out of another
- /// (unfrozen) account within the allowance it was granted.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `user_address` - The address to freeze or unfreeze.
- /// * `freeze` - `true` to freeze the address, `false` to unfreeze.
- ///
- /// # Events
- ///
- /// * topics - `["address_frozen", user_address: Address, is_frozen: bool]`
- /// * data - `[]`
- ///
- /// # Security Warning
- ///
- /// **IMPORTANT**: This function bypasses authorization checks and should
- /// only be used internally or in admin functions that implement their own
- /// authorization logic.
- pub fn set_address_frozen(e: &Env, user_address: &Address, freeze: bool) {
- e.storage().persistent().set(&RWAStorageKey::AddressFrozen(user_address.clone()), &freeze);
-
- emit_address_frozen(e, user_address, freeze);
- }
-
- /// Freezes a specified amount of tokens for a given address.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `user_address` - The address for which to freeze tokens.
- /// * `amount` - The amount of tokens to freeze.
- ///
- /// # Errors
- ///
- /// * [`RWAError::LessThanZero`] - When `amount < 0`.
- /// * [`RWAError::InsufficientBalance`] - When trying to freeze more tokens
- /// than available.
- ///
- /// # Events
- ///
- /// * topics - `["tokens_frozen", user_address: Address]`
- /// * data - `[amount: i128]`
- ///
- /// # Security Warning
- ///
- /// **IMPORTANT**: This function bypasses authorization checks and should
- /// only be used internally or in admin functions that implement their own
- /// authorization logic.
- pub fn freeze_partial_tokens(e: &Env, user_address: &Address, amount: i128) {
- if amount < 0 {
- panic_with_error!(e, RWAError::LessThanZero);
- }
-
- let current_balance = Base::balance(e, user_address);
- let current_frozen = Self::get_frozen_tokens(e, user_address);
- let new_frozen = current_frozen + amount;
-
- if new_frozen > current_balance {
- panic_with_error!(e, RWAError::InsufficientBalance);
- }
-
- e.storage()
- .persistent()
- .set(&RWAStorageKey::FrozenTokens(user_address.clone()), &new_frozen);
- emit_tokens_frozen(e, user_address, amount);
- }
-
- /// Unfreezes a specified amount of tokens for a given address.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `user_address` - The address for which to unfreeze tokens.
- /// * `amount` - The amount of tokens to unfreeze.
- ///
- /// # Errors
- ///
- /// * [`RWAError::LessThanZero`] - When `amount < 0`.
- /// * [`RWAError::InsufficientFreeTokens`] - When trying to unfreeze more
- /// tokens than are frozen.
- ///
- /// # Events
- ///
- /// * topics - `["tokens_unfrozen", user_address: Address]`
- /// * data - `[amount: i128]`
- ///
- /// # Security Warning
- ///
- /// **IMPORTANT**: This function bypasses authorization checks and should
- /// only be used internally or in admin functions that implement their own
- /// authorization logic.
- pub fn unfreeze_partial_tokens(e: &Env, user_address: &Address, amount: i128) {
- if amount < 0 {
- panic_with_error!(e, RWAError::LessThanZero);
- }
-
- let current_frozen = Self::get_frozen_tokens(e, user_address);
- if current_frozen < amount {
- panic_with_error!(e, RWAError::InsufficientFreeTokens);
- }
-
- let new_frozen = current_frozen - amount;
- e.storage()
- .persistent()
- .set(&RWAStorageKey::FrozenTokens(user_address.clone()), &new_frozen);
- emit_tokens_unfrozen(e, user_address, amount);
- }
-
- /// Sets the onchain ID of the token.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `onchain_id` - The new onchain ID address for the token.
- ///
- /// # Events
- ///
- /// * topics - `["token_onchain_id_updated", onchain_id: Address]`
- /// * data - `[]`
- ///
- /// # Security Warning
- ///
- /// **IMPORTANT**: This function bypasses authorization checks and should
- /// only be used internally or in admin functions that implement their own
- /// authorization logic.
- pub fn set_onchain_id(e: &Env, onchain_id: &Address) {
- e.storage().instance().set(&RWAStorageKey::OnchainId, onchain_id);
-
- emit_token_onchain_id_updated(e, onchain_id);
- }
-
- /// Sets the compliance contract of the token.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `compliance` - The address of the compliance contract.
- ///
- /// # Events
- ///
- /// * topics - `["compliance_set", compliance: Address]`
- /// * data - `[]`
- ///
- /// # Security Warning
- ///
- /// **IMPORTANT**: This function bypasses authorization checks and should
- /// only be used internally or in admin functions that implement their own
- /// authorization logic.
- pub fn set_compliance(e: &Env, compliance: &Address) {
- e.storage().instance().set(&RWAStorageKey::Compliance, compliance);
- emit_compliance_set(e, compliance);
- }
-
- /// Sets the identity verifier contract of the token.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `identity_verifier` - The address of the identity verifier contract.
- ///
- /// # Events
- ///
- /// * topics - `["identity_verifier_set", identity_verifier: Address]`
- /// * data - `[]`
- ///
- /// # Security Warning
- ///
- /// **IMPORTANT**: This function bypasses authorization checks and should
- /// only be used internally or in admin functions that implement their own
- /// authorization logic.
- pub fn set_identity_verifier(e: &Env, identity_verifier: &Address) {
- e.storage().instance().set(&RWAStorageKey::IdentityVerifier, identity_verifier);
- emit_identity_verifier_set(e, identity_verifier);
- }
-
- // ################## OVERRIDDEN FUNCTIONS ##################
-
- /// `transfer` override with added compliance and identity verification
- /// checks.
- ///
- /// This is ultimately a wrapper around [`Base::update()`] to enable
- /// the compatibility across [`crate::fungible::FungibleToken`]
- /// with [`crate::rwa::RWAToken`]
- ///
- /// The main differences are:
- /// - checks for if the contract is paused
- /// - checks for if the addresses are frozen
- /// - checks for if the from address have enough free tokens (unfrozen
- /// tokens)
- /// - enforces identity verification for both addresses
- /// - enforces compliance rules for the transfer
- /// - triggers `transferred` hook call from the compliance contract
- ///
- /// Please refer to [`Base::update`] and [`Self::validate_transfer`] for the
- /// inline documentation.
- ///
- /// # Events
- ///
- /// * topics - `["transfer", from: Address, to: Address]`
- /// * data - `amount: i128` (when `to` has no muxed ID)
- /// * data - `{to_muxed_id: Option, amount: i128}` (when `to` has a
- /// muxed ID)
- ///
- /// # Notes
- ///
- /// A muxed `to` is narrowed to its base address before any check runs, so
- /// identity verification, the compliance hook and the balance update all
- /// operate on the base address. The muxed ID reaches the event only, where
- /// it lets a custodian attribute the transfer to one of its off-chain
- /// sub-accounts.
- ///
- /// The ID is supplied by the caller and carries no on-chain verification.
- /// A verified holder may therefore be a custodian holding on behalf of
- /// beneficiaries who have no on-chain identity of their own.
- ///
- /// Whether to accept custodial destinations is the implementor's decision,
- /// not the library's. An issuer that requires beneficial owners on the
- /// on-chain register can override
- /// [`crate::fungible::FungibleToken::transfer`] and reject a destination
- /// whose `id()` is `Some`; `rwa-token-example` carries that pattern as a
- /// showcase.
- pub fn transfer(e: &Env, from: &Address, to: &MuxedAddress, amount: i128) {
- from.require_auth();
-
- let to_address = to.address();
-
- // Snapshot before the update so the hook observes the pre-transfer
- // state.
- let from_snapshot = Self::account_snapshot(e, from);
- let to_snapshot = Self::account_snapshot(e, &to_address);
-
- Self::validate_transfer(
- e,
- &from_snapshot,
- &to_snapshot,
- amount,
- FromVerification::Required,
- );
-
- Base::update(e, Some(from), Some(&to_address), amount);
-
- let compliance_client = ComplianceClient::new(e, &Self::compliance(e));
- compliance_client.transferred(
- &from_snapshot,
- &to_snapshot,
- &amount,
- &TransferKind::Standard,
- &e.current_contract_address(),
- );
- emit_transfer(e, from, &to_address, to.id(), amount);
- }
-
- /// `transfer_from` override with added compliance and identity verification
- /// checks.
- ///
- /// This is ultimately a wrapper around [`Base::update()`] to enable
- /// the compatibility across [`crate::fungible::FungibleToken`]
- /// with [`crate::rwa::RWAToken`]
- ///
- /// The main differences are:
- /// - checks for if the contract is paused
- /// - checks for if the addresses are frozen
- /// - checks for if the from address have enough free tokens (unfrozen
- /// tokens)
- /// - enforces identity verification for both addresses
- /// - enforces compliance rules for the transfer
- /// - triggers `transferred` hook call from the compliance contract
- ///
- /// Please refer to [`Base::update`] and [`Self::validate_transfer`] for the
- /// inline documentation.
- pub fn transfer_from(e: &Env, spender: &Address, from: &Address, to: &Address, amount: i128) {
- spender.require_auth();
-
- // Snapshot before the update so the hook observes the pre-transfer
- // state. `spender` is the delegate moving the holder's tokens.
- let from_snapshot = Self::account_snapshot(e, from);
- let to_snapshot = Self::account_snapshot(e, to);
-
- Self::validate_transfer(
- e,
- &from_snapshot,
- &to_snapshot,
- amount,
- FromVerification::Required,
- );
-
- Base::spend_allowance(e, from, spender, amount);
-
- Base::update(e, Some(from), Some(to), amount);
-
- let compliance_client = ComplianceClient::new(e, &Self::compliance(e));
- compliance_client.transferred(
- &from_snapshot,
- &to_snapshot,
- &amount,
- &TransferKind::Delegated(spender.clone()),
- &e.current_contract_address(),
- );
- emit_transfer(e, from, to, None, amount);
- }
-
- // ################## BATCH FUNCTIONS ##################
-
- /// Transfers to several recipients in one invocation, moving
- /// `amounts[i]` from `from` to `recipients[i]`. The sender authorizes the
- /// whole batch once.
- ///
- /// The batch is atomic: an item that fails reverts every item before it.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `from` - The address holding the tokens.
- /// * `recipients` - The addresses receiving the tokens.
- /// * `amounts` - The amount to transfer to each recipient.
- ///
- /// # Errors
- ///
- /// * [`RWAError::BatchSizeMismatch`] - When `recipients` and `amounts` have
- /// different lengths.
- /// * refer to [`Self::validate_transfer`] errors, which every item runs.
- /// * refer to [`Base::update`] errors.
- ///
- /// # Events
- ///
- /// For each recipient:
- /// * topics - `["transfer", from: Address, to: Address]`
- /// * data - `[amount: i128]`
- ///
- /// # Notes
- ///
- /// Recipients are processed in order, and the sender's free balance is
- /// re-evaluated for every item, so a batch that overspends fails on the
- /// item that runs out rather than up front.
- ///
- /// The sender's identity is verified once for the whole batch rather than
- /// once per recipient, which is where the saving over repeated single
- /// transfers comes from. This assumes nothing invalidates the sender
- /// part-way through: the compliance modules that run between items hold no
- /// authority over the identity stack, and re-entering this token is
- /// impossible in Soroban, so no module in reach can revoke the sender it
- /// was just handed. A module granted write access to the identity stack
- /// would break that assumption.
- ///
- /// The paused state is checked once up front so that a paused token is
- /// rejected before any identity work and regardless of batch size, and
- /// again per item along with both wallets' freezing status, since those
- /// reads are cheap enough that skipping them would buy nothing.
- ///
- /// The caller is responsible for sizing the batch to fit the
- /// per-transaction network limits. Refer to the batch operations section of
- /// the [module documentation](crate::rwa) for measured figures.
- pub fn batch_transfer(e: &Env, from: &Address, recipients: &Vec, amounts: &Vec) {
- from.require_auth();
-
- Self::require_equal_lengths(e, recipients.len(), amounts.len());
-
- if paused(e) {
- panic_with_error!(e, PausableError::EnforcedPause);
- }
-
- // Every item shares one `from`, so the walk through the identity stack
- // that verifies it runs once for the batch instead of once per
- // recipient. Every other check stays inside the loop.
- IdentityVerifierClient::new(e, &Self::identity_verifier(e)).verify_identity(from);
-
- let compliance_client = ComplianceClient::new(e, &Self::compliance(e));
- let token = e.current_contract_address();
-
- for (to, amount) in recipients.iter().zip(amounts.iter()) {
- // Snapshot before the update so the hook observes the pre-transfer
- // state. The sender's balance shrinks as the batch progresses, so
- // this is re-read for every item.
- let from_snapshot = Self::account_snapshot(e, from);
- let to_snapshot = Self::account_snapshot(e, &to);
-
- Self::validate_transfer(
- e,
- &from_snapshot,
- &to_snapshot,
- amount,
- FromVerification::HoistedByCaller,
- );
-
- Base::update(e, Some(from), Some(&to), amount);
-
- compliance_client.transferred(
- &from_snapshot,
- &to_snapshot,
- &amount,
- &TransferKind::Standard,
- &token,
- );
- emit_transfer(e, from, &to, None, amount);
- }
- }
-
- /// Forces a transfer for each `(from_list[i], to_list[i], amounts[i])`
- /// triple in one invocation.
- ///
- /// The batch is atomic: an item that fails reverts every item before it.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `from_list` - The addresses holding the tokens.
- /// * `to_list` - The addresses receiving the tokens.
- /// * `amounts` - The amount to move for each pair.
- ///
- /// # Errors
- ///
- /// * [`RWAError::BatchSizeMismatch`] - When the three arrays do not all
- /// have the same length.
- /// * refer to [`Self::forced_transfer`] errors.
- ///
- /// # Events
- ///
- /// For each triple:
- /// * topics - `["transfer", from: Address, to: Address]`
- /// * data - `[amount: i128]`
- ///
- /// For each triple whose `amount` exceeds the free (unfrozen) balance of
- /// `from`, emitted before its transfer:
- /// * topics - `["tokens_unfrozen", from: Address]`
- /// * data - `[amount: i128]`
- ///
- /// # Notes
- ///
- /// The caller is responsible for sizing the batch to fit the
- /// per-transaction network limits. Refer to the batch operations section of
- /// the [module documentation](crate::rwa).
- ///
- /// # Security Warning
- ///
- /// **IMPORTANT**: This function bypasses authorization and freezing checks,
- /// exactly like [`Self::forced_transfer`]. Refer to its security warning.
- pub fn batch_forced_transfer(
- e: &Env,
- from_list: &Vec,
- to_list: &Vec,
- amounts: &Vec,
- ) {
- Self::require_equal_lengths(e, from_list.len(), to_list.len());
- Self::require_equal_lengths(e, to_list.len(), amounts.len());
-
- for ((from, to), amount) in from_list.iter().zip(to_list.iter()).zip(amounts.iter()) {
- Self::forced_transfer(e, &from, &to, amount);
- }
- }
-
- /// Mints `amounts[i]` tokens to `to_list[i]` in one invocation.
- ///
- /// The batch is atomic: an item that fails reverts every item before it.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `to_list` - The addresses receiving the new tokens.
- /// * `amounts` - The amount to mint to each address.
- ///
- /// # Errors
- ///
- /// * [`RWAError::BatchSizeMismatch`] - When `to_list` and `amounts` have
- /// different lengths.
- /// * refer to [`Self::mint`] errors.
- ///
- /// # Events
- ///
- /// For each address:
- /// * topics - `["mint", to: Address]`
- /// * data - `[amount: i128]`
- ///
- /// # Notes
- ///
- /// The caller is responsible for sizing the batch to fit the
- /// per-transaction network limits. Refer to the batch operations section of
- /// the [module documentation](crate::rwa).
- ///
- /// # Security Warning
- ///
- /// ⚠️ SECURITY RISK: This function has NO AUTHORIZATION CONTROLS ⚠️
- ///
- /// Refer to the security warning on [`Self::mint`].
- pub fn batch_mint(e: &Env, to_list: &Vec, amounts: &Vec) {
- Self::require_equal_lengths(e, to_list.len(), amounts.len());
-
- for (to, amount) in to_list.iter().zip(amounts.iter()) {
- Self::mint(e, &to, amount);
- }
- }
-
- /// Burns `amounts[i]` tokens from `user_addresses[i]` in one invocation.
- ///
- /// The batch is atomic: an item that fails reverts every item before it.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `user_addresses` - The addresses to burn tokens from.
- /// * `amounts` - The amount to burn from each address.
- ///
- /// # Errors
- ///
- /// * [`RWAError::BatchSizeMismatch`] - When `user_addresses` and `amounts`
- /// have different lengths.
- /// * refer to [`Self::burn`] errors.
- ///
- /// # Events
- ///
- /// For each address:
- /// * topics - `["burn", user_address: Address]`
- /// * data - `[amount: i128]`
- ///
- /// For each address whose `amount` exceeds its free (unfrozen) balance,
- /// emitted before its burn:
- /// * topics - `["tokens_unfrozen", user_address: Address]`
- /// * data - `[amount: i128]`
- ///
- /// # Notes
- ///
- /// The caller is responsible for sizing the batch to fit the
- /// per-transaction network limits. Refer to the batch operations section of
- /// the [module documentation](crate::rwa).
- ///
- /// # Security Warning
- ///
- /// **IMPORTANT**: This function bypasses authorization checks, exactly like
- /// [`Self::burn`]. Refer to its security warning.
- pub fn batch_burn(e: &Env, user_addresses: &Vec, amounts: &Vec) {
- Self::require_equal_lengths(e, user_addresses.len(), amounts.len());
-
- for (user_address, amount) in user_addresses.iter().zip(amounts.iter()) {
- Self::burn(e, &user_address, amount);
- }
- }
-
- /// Sets the frozen status of `user_addresses[i]` to `freeze_list[i]` in one
- /// invocation.
- ///
- /// The batch is atomic: an item that fails reverts every item before it.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `user_addresses` - The addresses to freeze or unfreeze.
- /// * `freeze_list` - The frozen status to apply to each address.
- ///
- /// # Errors
- ///
- /// * [`RWAError::BatchSizeMismatch`] - When `user_addresses` and
- /// `freeze_list` have different lengths.
- ///
- /// # Events
- ///
- /// For each address:
- /// * topics - `["address_frozen", user_address: Address, is_frozen: bool]`
- /// * data - `[]`
- ///
- /// # Notes
- ///
- /// An address repeated in `user_addresses` ends up with the status of its
- /// last occurrence, and each occurrence emits its own event.
- ///
- /// The caller is responsible for sizing the batch to fit the
- /// per-transaction network limits. Refer to the batch operations section of
- /// the [module documentation](crate::rwa).
- ///
- /// # Security Warning
- ///
- /// **IMPORTANT**: This function bypasses authorization checks, exactly like
- /// [`Self::set_address_frozen`]. Refer to its security warning.
- pub fn batch_set_address_frozen(
- e: &Env,
- user_addresses: &Vec,
- freeze_list: &Vec,
- ) {
- Self::require_equal_lengths(e, user_addresses.len(), freeze_list.len());
-
- for (user_address, freeze) in user_addresses.iter().zip(freeze_list.iter()) {
- Self::set_address_frozen(e, &user_address, freeze);
- }
- }
-
- /// Freezes `amounts[i]` tokens on `user_addresses[i]` in one invocation.
- ///
- /// The batch is atomic: an item that fails reverts every item before it.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `user_addresses` - The addresses for which to freeze tokens.
- /// * `amounts` - The amount to freeze on each address.
- ///
- /// # Errors
- ///
- /// * [`RWAError::BatchSizeMismatch`] - When `user_addresses` and `amounts`
- /// have different lengths.
- /// * refer to [`Self::freeze_partial_tokens`] errors.
- ///
- /// # Events
- ///
- /// For each address:
- /// * topics - `["tokens_frozen", user_address: Address]`
- /// * data - `[amount: i128]`
- ///
- /// # Notes
- ///
- /// Amounts accumulate, so an address repeated in `user_addresses` has every
- /// occurrence added to its frozen balance.
- ///
- /// The caller is responsible for sizing the batch to fit the
- /// per-transaction network limits. Refer to the batch operations section of
- /// the [module documentation](crate::rwa).
- ///
- /// # Security Warning
- ///
- /// **IMPORTANT**: This function bypasses authorization checks, exactly like
- /// [`Self::freeze_partial_tokens`]. Refer to its security warning.
- pub fn batch_freeze_partial_tokens(
- e: &Env,
- user_addresses: &Vec,
- amounts: &Vec,
- ) {
- Self::require_equal_lengths(e, user_addresses.len(), amounts.len());
-
- for (user_address, amount) in user_addresses.iter().zip(amounts.iter()) {
- Self::freeze_partial_tokens(e, &user_address, amount);
- }
- }
-
- /// Unfreezes `amounts[i]` tokens on `user_addresses[i]` in one invocation.
- ///
- /// The batch is atomic: an item that fails reverts every item before it.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `user_addresses` - The addresses for which to unfreeze tokens.
- /// * `amounts` - The amount to unfreeze on each address.
- ///
- /// # Errors
- ///
- /// * [`RWAError::BatchSizeMismatch`] - When `user_addresses` and `amounts`
- /// have different lengths.
- /// * refer to [`Self::unfreeze_partial_tokens`] errors.
- ///
- /// # Events
- ///
- /// For each address:
- /// * topics - `["tokens_unfrozen", user_address: Address]`
- /// * data - `[amount: i128]`
- ///
- /// # Notes
- ///
- /// Amounts accumulate, so an address repeated in `user_addresses` has every
- /// occurrence subtracted from its frozen balance.
- ///
- /// The caller is responsible for sizing the batch to fit the
- /// per-transaction network limits. Refer to the batch operations section of
- /// the [module documentation](crate::rwa).
- ///
- /// # Security Warning
- ///
- /// **IMPORTANT**: This function bypasses authorization checks, exactly like
- /// [`Self::unfreeze_partial_tokens`]. Refer to its security warning.
- pub fn batch_unfreeze_partial_tokens(
- e: &Env,
- user_addresses: &Vec,
- amounts: &Vec,
- ) {
- Self::require_equal_lengths(e, user_addresses.len(), amounts.len());
-
- for (user_address, amount) in user_addresses.iter().zip(amounts.iter()) {
- Self::unfreeze_partial_tokens(e, &user_address, amount);
- }
- }
-
- // ################## LOW-LEVEL HELPERS ##################
-
- /// Performs every check a transfer requires, without requiring
- /// authorization or moving anything: paused state, the freezing status of
- /// both wallets, the sender's free balance, and the identity of both
- /// parties. Shared by [`Self::transfer`], [`Self::transfer_from`] and every
- /// item of [`Self::batch_transfer`], so no entry point can drift away from
- /// the others on what it checks.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `from` - Snapshot of the sender, as of before the transfer.
- /// * `to` - Snapshot of the receiver, as of before the transfer.
- /// * `amount` - The amount of tokens to transfer.
- /// * `from_verification` - Who verifies the identity of `from`, refer to
- /// [`FromVerification`].
- ///
- /// # Errors
- ///
- /// * [`PausableError::EnforcedPause`] - If the contract is paused.
- /// * [`RWAError::AddressFrozen`] - If either the sender or receiver is
- /// frozen.
- /// * [`RWAError::InsufficientFreeTokens`] - If the sender does not have
- /// enough free tokens.
- /// * refer to [`Self::identity_verifier`] errors.
- /// * refer to [`IdentityVerifierClient::verify_identity`] errors.
- ///
- /// # Security Warning
- ///
- /// **IMPORTANT**: Passing [`FromVerification::HoistedByCaller`] skips the
- /// sender's identity check, and is only sound when the caller has verified
- /// that same sender earlier in the invocation (i.e. batched operations).
- pub fn validate_transfer(
- e: &Env,
- from: &AccountSnapshot,
- to: &AccountSnapshot,
- amount: i128,
- from_verification: FromVerification,
- ) {
- // Check if contract is paused
- if paused(e) {
- panic_with_error!(e, PausableError::EnforcedPause);
- }
-
- // Check if addresses are frozen
- if Self::is_frozen(e, &from.address) || Self::is_frozen(e, &to.address) {
- panic_with_error!(e, RWAError::AddressFrozen);
- }
-
- // Check if there are enough free tokens (not frozen)
- if from.balance - from.frozen < amount {
- panic_with_error!(e, RWAError::InsufficientFreeTokens);
- }
-
- let identity_verifier_addr = Self::identity_verifier(e);
- let identity_verifier_client = IdentityVerifierClient::new(e, &identity_verifier_addr);
- if matches!(from_verification, FromVerification::Required) {
- identity_verifier_client.verify_identity(&from.address);
- }
- identity_verifier_client.verify_identity(&to.address);
- }
-
- /// Shared mechanics behind [`Self::forced_transfer`] and
- /// [`Self::recover_balance`]: moves `amount` from `from` to `to`,
- /// bypassing paused state and freezing restrictions (unfreezing as
- /// needed), and reports the movement to the compliance contract as
- /// `kind`. The two public entry points differ only in the kind they
- /// report, which is what lets compliance modules tell a seizure from a
- /// wallet migration.
- ///
- /// Performs no identity verification; each caller verifies the recipient
- /// before delegating here, so the check runs exactly once per invocation.
- fn privileged_transfer(
- e: &Env,
- from: &Address,
- to: &Address,
- amount: i128,
- kind: &TransferKind,
- ) {
- // Snapshot before any unfreeze/balance mutation so the hook observes
- // the pre-operation state. A privileged transfer has no spender: it is
- // an administrative pull, not a delegated move.
- let from_snapshot = Self::account_snapshot(e, from);
- let to_snapshot = Self::account_snapshot(e, to);
-
- if from_snapshot.balance < amount {
- panic_with_error!(e, RWAError::InsufficientBalance);
- }
-
- // Check if we need to unfreeze tokens to complete the transfer. The
- // snapshot is still current: nothing has mutated the balance or
- // frozen amount since it was captured.
- let free_tokens = from_snapshot.balance - from_snapshot.frozen;
- if free_tokens < amount {
- let tokens_to_unfreeze = amount - free_tokens;
- let new_frozen = from_snapshot.frozen - tokens_to_unfreeze;
-
- e.storage().persistent().set(&RWAStorageKey::FrozenTokens(from.clone()), &new_frozen);
- emit_tokens_unfrozen(e, from, tokens_to_unfreeze);
- }
-
- Base::update(e, Some(from), Some(to), amount);
-
- let compliance_addr = Self::compliance(e);
- let compliance_client = ComplianceClient::new(e, &compliance_addr);
- compliance_client.transferred(
- &from_snapshot,
- &to_snapshot,
- &amount,
- kind,
- &e.current_contract_address(),
- );
-
- emit_transfer(e, from, to, None, amount);
- }
-
- /// Rejects a batch whose parallel arrays do not line up.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `left` - Length of the first array.
- /// * `right` - Length of the array that must match it.
- ///
- /// # Errors
- ///
- /// * [`RWAError::BatchSizeMismatch`] - When the two lengths differ.
- fn require_equal_lengths(e: &Env, left: u32, right: u32) {
- if left != right {
- panic_with_error!(e, RWAError::BatchSizeMismatch);
- }
- }
-}
+use soroban_sdk::{contracttype, panic_with_error, Address, Env, MuxedAddress, String, Vec};
+use stellar_contract_utils::pausable::{paused, PausableError};
+
+use crate::{
+ fungible::{
+ capped::{check_cap, CappedContractType},
+ emit_transfer,
+ total_supply::{
+ decrease_total_supply, increase_total_supply, total_supply, TotalSupplyOverrides,
+ },
+ Base, ContractOverrides, MintOverrides,
+ },
+ rwa::{
+ compliance::{AccountSnapshot, ComplianceClient, TransferKind},
+ emit_address_frozen, emit_burn, emit_compliance_set, emit_identity_verifier_set, emit_mint,
+ emit_recovery_success, emit_token_onchain_id_updated, emit_tokens_frozen,
+ emit_tokens_unfrozen, IdentityVerifierClient, RWAError, FROZEN_EXTEND_AMOUNT,
+ FROZEN_TTL_THRESHOLD,
+ },
+};
+
+/// Storage keys for the data associated with `RWA` token
+#[contracttype]
+pub enum RWAStorageKey {
+ /// Frozen status of an address (true = frozen, false = not frozen)
+ AddressFrozen(Address),
+ /// Amount of tokens frozen for a specific address
+ FrozenTokens(Address),
+ /// Compliance contract address
+ Compliance,
+ /// OnchainID contract address
+ OnchainId,
+ /// Version of the token
+ Version,
+ /// Identity Verifier contract address
+ IdentityVerifier,
+}
+
+pub struct RWA;
+
+/// Who verifies the identity of `from` in [`RWA::validate_transfer`].
+///
+/// Every item of a batch transfer shares one `from`, so verifying it per item
+/// repeats the same walk through the identity stack. This lets the batch lift
+/// that one check out of its loop while every other check still runs per item,
+/// and while all three transfer entry points keep sharing one validator.
+pub enum FromVerification {
+ /// Verify `from` as part of this call. What the single-transfer entry
+ /// points pass.
+ Required,
+ /// Skip the check on `from`, because the caller has already verified it
+ /// once for a batch of transfers that all share it.
+ HoistedByCaller,
+}
+
+impl ContractOverrides for RWA {
+ fn transfer(e: &Env, from: &Address, to: &MuxedAddress, amount: i128) {
+ RWA::transfer(e, from, to, amount);
+ }
+
+ fn transfer_from(e: &Env, spender: &Address, from: &Address, to: &Address, amount: i128) {
+ RWA::transfer_from(e, spender, from, to, amount);
+ }
+}
+
+// T-REX (ERC-3643) tokens expose the total supply, so the RWA contract type
+// is inherently supply-aware.
+impl TotalSupplyOverrides for RWA {}
+
+// Minting through the contract type runs RWA's full mint path: identity
+// verification, freezing checks, supply accounting and the compliance hook.
+impl MintOverrides for RWA {
+ fn mint(e: &Env, to: &Address, amount: i128) {
+ RWA::mint(e, to, amount);
+ }
+}
+
+/// Marker for the contract types backing [`crate::rwa::RWAToken`]: [`RWA`]
+/// itself and [`CappedRWA`].
+///
+/// # Why this trait exists
+///
+/// `RWAToken`'s default bodies call the [`RWA`] functions directly (batch
+/// transfers, forced transfers, freezing, recovery, ...), so the trait used to
+/// require `ContractType = RWA` exactly. A capped RWA token needs a different
+/// contract type, [`CappedRWA`], whose mint also checks the cap, while every
+/// other RWA operation stays the same. This marker lets `RWAToken` accept both
+/// contract types, and nothing else.
+///
+/// Contract authors never interact with this trait; it only appears as that
+/// bound.
+#[diagnostic::on_unimplemented(
+ message = "`{Self}` is not an RWA contract type, so `RWAToken` cannot be implemented",
+ note = "list `RWA` in the `Compose` list, e.g. `Compose<(RWA, TotalSupply)>`"
+)]
+pub trait RWAContractType {}
+
+impl RWAContractType for RWA {}
+
+/// Contract type enforcing a maximum total supply on top of [`RWA`], resolved
+/// by `Compose<(RWA, Capped, TotalSupply)>`.
+///
+/// Its `mint` and `batch_mint` check the cap (refer to
+/// [`crate::fungible::capped::Capped`]) before running RWA's own mint path.
+/// Every other operation is RWA's, unchanged.
+pub struct CappedRWA;
+
+impl RWAContractType for CappedRWA {}
+impl CappedContractType for CappedRWA {}
+
+// RWA tracks the supply itself, and the capped variant reads the same counter.
+impl TotalSupplyOverrides for CappedRWA {}
+
+// Transfers keep RWA's identity, freezing and compliance checks.
+impl ContractOverrides for CappedRWA {
+ fn transfer(e: &Env, from: &Address, to: &MuxedAddress, amount: i128) {
+ RWA::transfer(e, from, to, amount);
+ }
+
+ fn transfer_from(e: &Env, spender: &Address, from: &Address, to: &Address, amount: i128) {
+ RWA::transfer_from(e, spender, from, to, amount);
+ }
+}
+
+impl MintOverrides for CappedRWA {
+ fn mint(e: &Env, to: &Address, amount: i128) {
+ CappedRWA::mint(e, to, amount);
+ }
+}
+
+impl CappedRWA {
+ /// Mints `amount` tokens to `to` if the cap allows it.
+ ///
+ /// # Errors
+ ///
+ /// * refer to [`check_cap`] errors.
+ /// * refer to [`RWA::mint`] errors.
+ ///
+ /// # Security Warning
+ ///
+ /// ⚠️ SECURITY RISK: This function has NO AUTHORIZATION CONTROLS ⚠️
+ ///
+ /// Refer to the security warning on [`RWA::mint`].
+ pub fn mint(e: &Env, to: &Address, amount: i128) {
+ check_cap(e, amount, total_supply(e));
+ RWA::mint(e, to, amount);
+ }
+
+ /// Mints `amounts[i]` tokens to `to_list[i]`, checking the cap for every
+ /// item.
+ ///
+ /// # Errors
+ ///
+ /// * refer to [`RWA::mint`] errors.
+ /// * refer to [`check_cap`] errors.
+ ///
+ /// # Security Warning
+ ///
+ /// ⚠️ SECURITY RISK: This function has NO AUTHORIZATION CONTROLS ⚠️
+ ///
+ /// Refer to the security warning on [`RWA::mint`].
+ pub fn batch_mint(e: &Env, to_list: &Vec, amounts: &Vec) {
+ RWA::require_equal_lengths(e, to_list.len(), amounts.len());
+
+ for (to, amount) in to_list.iter().zip(amounts.iter()) {
+ CappedRWA::mint(e, &to, amount);
+ }
+ }
+}
+
+impl RWA {
+ // ################## QUERY STATE ##################
+
+ /// Returns the token version.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ ///
+ /// # Errors
+ ///
+ /// * [`RWAError::VersionNotSet`] - When the version is not set.
+ pub fn version(e: &Env) -> String {
+ e.storage()
+ .instance()
+ .get(&RWAStorageKey::Version)
+ .unwrap_or_else(|| panic_with_error!(e, RWAError::VersionNotSet))
+ }
+
+ /// Returns the address of the onchain ID of the token.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ ///
+ /// # Errors
+ ///
+ /// * [`RWAError::OnchainIdNotSet`] - When the onchain ID is not set.
+ pub fn onchain_id(e: &Env) -> Address {
+ e.storage()
+ .instance()
+ .get(&RWAStorageKey::OnchainId)
+ .unwrap_or_else(|| panic_with_error!(e, RWAError::OnchainIdNotSet))
+ }
+
+ /// Returns the Compliance contract linked to the token.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ ///
+ /// # Errors
+ ///
+ /// * [`RWAError::ComplianceNotSet`] - When the compliance contract is not
+ /// set.
+ pub fn compliance(e: &Env) -> Address {
+ e.storage()
+ .instance()
+ .get(&RWAStorageKey::Compliance)
+ .unwrap_or_else(|| panic_with_error!(e, RWAError::ComplianceNotSet))
+ }
+
+ /// Returns the Identity Verifier contract linked to the token.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ ///
+ /// # Errors
+ ///
+ /// * [`RWAError::IdentityVerifierNotSet`] - When the identity verifier
+ /// contract is not set.
+ pub fn identity_verifier(e: &Env) -> Address {
+ e.storage()
+ .instance()
+ .get(&RWAStorageKey::IdentityVerifier)
+ .unwrap_or_else(|| panic_with_error!(e, RWAError::IdentityVerifierNotSet))
+ }
+
+ /// Returns the freezing status of a wallet. Frozen wallets cannot send or
+ /// receive funds.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `user_address` - The address of the wallet to check.
+ pub fn is_frozen(e: &Env, user_address: &Address) -> bool {
+ let key = RWAStorageKey::AddressFrozen(user_address.clone());
+ if let Some(frozen) = e.storage().persistent().get::<_, bool>(&key) {
+ e.storage().persistent().extend_ttl(&key, FROZEN_TTL_THRESHOLD, FROZEN_EXTEND_AMOUNT);
+ frozen
+ } else {
+ false
+ }
+ }
+
+ /// Returns the amount of tokens that are partially frozen on a wallet.
+ /// The amount of frozen tokens is always <= to the total balance of the
+ /// wallet.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `user_address` - The address of the wallet on which get_frozen_tokens
+ /// is called.
+ pub fn get_frozen_tokens(e: &Env, user_address: &Address) -> i128 {
+ let key = RWAStorageKey::FrozenTokens(user_address.clone());
+ if let Some(frozen_amount) = e.storage().persistent().get::<_, i128>(&key) {
+ e.storage().persistent().extend_ttl(&key, FROZEN_TTL_THRESHOLD, FROZEN_EXTEND_AMOUNT);
+ frozen_amount
+ } else {
+ 0
+ }
+ }
+
+ /// Returns the amount of free (unfrozen) tokens for an address.
+ /// This is calculated as total balance minus frozen tokens.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `user_address` - The address to check.
+ pub fn get_free_tokens(e: &Env, user_address: &Address) -> i128 {
+ let total_balance = Base::balance(e, user_address);
+ let frozen_tokens = Self::get_frozen_tokens(e, user_address);
+
+ // frozen tokens cannot be greater than total balance, necessary checks
+ // are done in state changing functions
+ total_balance - frozen_tokens
+ }
+
+ /// Captures an [`AccountSnapshot`] of `address` from the token's current
+ /// state: its total balance and partially-frozen amount. Must be called
+ /// before any balance or freeze mutation so the snapshot reflects the
+ /// pre-operation figures handed to the compliance hooks.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `address` - The wallet to snapshot.
+ pub fn account_snapshot(e: &Env, address: &Address) -> AccountSnapshot {
+ AccountSnapshot {
+ address: address.clone(),
+ balance: Base::balance(e, address),
+ frozen: Self::get_frozen_tokens(e, address),
+ }
+ }
+
+ // ################## CHANGE STATE ##################
+
+ /// Forced transfer of `amount` tokens from `from` to `to`.
+ ///
+ /// The `to` address must pass identity verification. `from` is not required
+ /// to be verified, so this privileged action remains usable to pull
+ /// tokens out of accounts whose identity is no longer valid
+ /// (sanctioned, revoked, or compromised wallets). The same mechanics
+ /// back [`Self::recover_balance`], which reports
+ /// [`TransferKind::Recovery`] to the compliance contract instead of
+ /// [`TransferKind::Forced`].
+ ///
+ /// This function can unfreeze tokens if needed for regulatory compliance.
+ /// It bypasses paused state and frozen address checks.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `from` - The address holding the tokens.
+ /// * `to` - The address receiving the tokens.
+ /// * `amount` - The amount of tokens to transfer.
+ ///
+ /// # Errors
+ ///
+ /// * [`RWAError::InsufficientBalance`] - When attempting to transfer more
+ /// tokens than available.
+ /// * refer to [`IdentityVerifierClient::verify_identity`] errors.
+ /// * refer to [`Base::update`] errors.
+ ///
+ /// # Events
+ ///
+ /// * topics - `["transfer", from: Address, to: Address]`
+ /// * data - `[amount: i128]`
+ ///
+ /// Emitted before the transfer when `amount` exceeds the free (unfrozen)
+ /// balance of `from`, for the part of the frozen tokens that is unfrozen:
+ /// * topics - `["tokens_unfrozen", from: Address]`
+ /// * data - `[amount: i128]`
+ ///
+ /// # Notes
+ ///
+ /// This function bypasses freezing restrictions and can unfreeze tokens
+ /// as needed. It's intended for regulatory compliance scenarios such as
+ /// court-ordered seizures.
+ ///
+ /// # Security Warning
+ ///
+ /// **IMPORTANT**: This function bypasses authorization and freezing checks.
+ /// Should only be used by authorized compliance or admin functions.
+ pub fn forced_transfer(e: &Env, from: &Address, to: &Address, amount: i128) {
+ // Verify identity for the `to` address only; `from` is intentionally
+ // skipped so this privileged action can still move tokens out of
+ // accounts whose identity is no longer valid.
+ let identity_verifier_addr = Self::identity_verifier(e);
+ let identity_verifier_client = IdentityVerifierClient::new(e, &identity_verifier_addr);
+ identity_verifier_client.verify_identity(to);
+
+ Self::privileged_transfer(e, from, to, amount, &TransferKind::Forced);
+ }
+
+ /// Mints `amount` tokens to `to`.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `to` - The address receiving the new tokens.
+ /// * `amount` - The amount of tokens to mint.
+ ///
+ /// # Errors
+ ///
+ /// * [`PausableError::EnforcedPause`] - When the contract is paused.
+ /// * [`RWAError::AddressFrozen`] - When the recipient address is frozen.
+ /// * [`RWAError::ComplianceNotSet`] - When the compliance contract is not
+ /// configured.
+ /// * refer to [`Base::update`] errors.
+ ///
+ /// # Events
+ ///
+ /// * topics - `["mint", to: Address]`
+ /// * data - `[amount: i128]`
+ ///
+ /// # Security Warning
+ ///
+ /// ⚠️ SECURITY RISK: This function has NO AUTHORIZATION CONTROLS ⚠️
+ ///
+ /// It is the responsibility of the implementer to establish appropriate
+ /// access controls to ensure that only authorized accounts can execute
+ /// minting operations. Failure to implement proper authorization could
+ /// lead to security vulnerabilities and unauthorized token creation.
+ ///
+ /// The implementation will typically look similar to the following
+ /// (pseudo-code):
+ ///
+ /// ```ignore
+ /// let admin = read_administrator(e);
+ /// admin.require_auth();
+ /// ```
+ pub fn mint(e: &Env, to: &Address, amount: i128) {
+ if paused(e) {
+ panic_with_error!(e, PausableError::EnforcedPause);
+ }
+
+ if Self::is_frozen(e, to) {
+ panic_with_error!(e, RWAError::AddressFrozen);
+ }
+
+ let identity_verifier_addr = Self::identity_verifier(e);
+ let identity_verifier_client = IdentityVerifierClient::new(e, &identity_verifier_addr);
+ identity_verifier_client.verify_identity(to);
+
+ // Snapshot before the mint so the hook observes the pre-mint balance.
+ let to_snapshot = Self::account_snapshot(e, to);
+
+ increase_total_supply(e, amount);
+ Base::update(e, None, Some(to), amount);
+
+ let compliance_addr = Self::compliance(e);
+ let compliance_client = ComplianceClient::new(e, &compliance_addr);
+ compliance_client.created(&to_snapshot, &amount, &e.current_contract_address());
+
+ emit_mint(e, to, amount);
+ }
+
+ /// Burns `amount` tokens from `user_address`. Updates the total supply
+ /// accordingly.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `user_address` - The address from which to burn tokens.
+ /// * `amount` - The amount of tokens to burn.
+ ///
+ /// # Errors
+ ///
+ /// * [`RWAError::InsufficientBalance`] - When `amount` exceeds the total
+ /// balance of `user_address`.
+ /// * [`RWAError::ComplianceNotSet`] - When the compliance contract is not
+ /// configured.
+ /// * refer to
+ /// [`Compliance::destroyed`](crate::rwa::compliance::Compliance::destroyed)
+ /// errors.
+ /// * refer to [`Base::update`] errors.
+ ///
+ /// # Events
+ ///
+ /// * topics - `["burn", user_address: Address]`
+ /// * data - `[amount: i128]`
+ ///
+ /// Emitted before the burn when `amount` exceeds the free (unfrozen)
+ /// balance of `user_address`, for the part of the frozen tokens that is
+ /// unfrozen:
+ /// * topics - `["tokens_unfrozen", user_address: Address]`
+ /// * data - `[amount: i128]`
+ ///
+ /// # Notes
+ ///
+ /// This function bypasses freezing restrictions and can unfreeze tokens
+ /// as needed: when `amount` exceeds the free (unfrozen) balance, the
+ /// difference is unfrozen first, so a partially frozen balance is
+ /// destroyed in place.
+ ///
+ /// Locked tokens are not given the same treatment. The `Destroyed` hook
+ /// carries no notion of a privileged burn, so a compliance module can
+ /// reject the burn by panicking regardless of who issued it. In
+ /// particular, with the
+ /// [`initial_lockup_period`](crate::rwa::compliance::modules::initial_lockup_period)
+ /// module registered, a burn that dips into still-locked tokens is
+ /// rejected; destroying a locked position takes a forced transfer to a
+ /// treasury wallet (which consumes the locks) followed by a burn from
+ /// that treasury.
+ ///
+ /// # Security Warning
+ ///
+ /// **IMPORTANT**: This function bypasses authorization checks and should
+ /// only be used internally or in admin functions that implement their own
+ /// authorization logic.
+ pub fn burn(e: &Env, user_address: &Address, amount: i128) {
+ // Snapshot before any unfreeze/balance mutation so the hook observes
+ // the pre-burn state.
+ let from_snapshot = Self::account_snapshot(e, user_address);
+
+ if amount > from_snapshot.balance {
+ panic_with_error!(e, RWAError::InsufficientBalance);
+ }
+
+ // Check if we need to unfreeze tokens to complete the burn. The
+ // snapshot is still current: nothing has mutated the balance or
+ // frozen amount since it was captured.
+ let free_tokens = from_snapshot.balance - from_snapshot.frozen;
+ if free_tokens < amount {
+ let tokens_to_unfreeze = amount - free_tokens;
+ let new_frozen = from_snapshot.frozen - tokens_to_unfreeze;
+
+ e.storage()
+ .persistent()
+ .set(&RWAStorageKey::FrozenTokens(user_address.clone()), &new_frozen);
+ emit_tokens_unfrozen(e, user_address, tokens_to_unfreeze);
+ }
+
+ Base::update(e, Some(user_address), None, amount);
+ decrease_total_supply(e, amount);
+
+ let compliance_addr = Self::compliance(e);
+ let compliance_client = ComplianceClient::new(e, &compliance_addr);
+ compliance_client.destroyed(&from_snapshot, &amount, &e.current_contract_address());
+
+ emit_burn(e, user_address, amount);
+ }
+
+ /// Recovery function used to force transfer tokens from a old account to a
+ /// new account. This function transfers all tokens and preserves the frozen
+ /// status from the old account to the new account. Returns `true` if
+ /// tokens were moved, `false` if the old account held no tokens.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `old_account` - The address of the wallet that lost access.
+ /// * `new_account` - The address of the new account to receive the tokens.
+ ///
+ /// # Errors
+ ///
+ /// * [`RWAError::IdentityVerificationFailed`] - When the identity of the
+ /// new account cannot be verified.
+ /// * [`RWAError::IdentityMismatch`] - When the new account is not the
+ /// target of the recovery process for the old wallet.
+ ///
+ /// # Events
+ ///
+ /// Emitted when the old account is fully frozen:
+ /// * topics - `["address_frozen", new_account: Address, is_frozen: bool]`
+ /// * data - `[]`
+ ///
+ /// Emitted only when tokens are moved:
+ /// * topics - `["transfer", old_account: Address, new_account: Address]`
+ /// * data - `[amount: i128]`
+ /// * topics - `["recovery_success", old_account: Address, new_account:
+ /// Address]`
+ /// * data - `[]`
+ ///
+ /// Emitted only when tokens are moved and the old account has partially
+ /// frozen tokens, which are unfrozen there before the transfer and frozen
+ /// again on the new account after it:
+ /// * topics - `["tokens_unfrozen", old_account: Address]`
+ /// * data - `[amount: i128]`
+ /// * topics - `["tokens_frozen", new_account: Address]`
+ /// * data - `[amount: i128]`
+ ///
+ /// # Notes
+ ///
+ /// This function preserves the frozen status (both partial and full) from
+ /// the old account and applies it to the new account, maintaining
+ /// regulatory compliance. The full-address freeze is a wallet-level
+ /// restriction, so it follows the investor to the new account even when the
+ /// old account holds no tokens. The partially frozen amount is bounded by
+ /// the balance, so there is nothing to migrate in that case.
+ ///
+ ///
+ /// State owned by this contract (the frozen status) is migrated directly
+ /// by this function. State held by compliance modules is handled through
+ /// the transfer hook: the recovery is reported to the compliance contract
+ /// as a transfer of kind [`TransferKind::Recovery`], and each module
+ /// decides how to treat it. Policy modules exempt it like a forced
+ /// transfer, while bookkeeping modules migrate wallet-bound state
+ /// instead of consuming it (e.g. the initial lockup period module moves
+ /// the old account's lock entries to the new account).
+ ///
+ /// This function does not concern itself with the Identity Management.
+ /// If the old account's identity should be removed, it should be done on
+ /// the Identity Stack.
+ ///
+ /// Allowances are not migrated or revoked: allowances the old account
+ /// granted stay in place (with an empty balance there is nothing to spend
+ /// until the old account receives tokens again), and allowances granted to
+ /// the old account as a spender remain usable by whoever controls it.
+ ///
+ /// # Security Warning
+ ///
+ /// **IMPORTANT**: This function bypasses authorization and compliance
+ /// checks. Should only be used by authorized recovery or admin
+ /// functions.
+ pub fn recover_balance(e: &Env, old_account: &Address, new_account: &Address) -> bool {
+ // Verify identity for the new account.
+ let identity_verifier_addr = Self::identity_verifier(e);
+ let identity_verifier_client = IdentityVerifierClient::new(e, &identity_verifier_addr);
+ identity_verifier_client.verify_identity(new_account);
+
+ // Verify that the new account is the recovery target for the old
+ // account
+ let recovery_target = identity_verifier_client
+ .recovery_target(old_account)
+ .unwrap_or_else(|| panic_with_error!(e, RWAError::IdentityMismatch));
+
+ if recovery_target != *new_account {
+ panic_with_error!(e, RWAError::IdentityMismatch);
+ }
+
+ if Self::is_frozen(e, old_account) {
+ Self::set_address_frozen(e, new_account, true);
+ }
+
+ // Get the balance of the old account, if there is nothing to transfer,
+ // return false
+ let lost_balance = Base::balance(e, old_account);
+ if lost_balance == 0 {
+ return false;
+ }
+
+ // Store the partially frozen amount before the transfer unfreezes it
+ let frozen_tokens = Self::get_frozen_tokens(e, old_account);
+
+ // Move all tokens with the shared privileged mechanics (handles
+ // unfreezing as needed), reporting the movement as a recovery so
+ // compliance modules migrate wallet-bound state instead of
+ // consuming it.
+ Self::privileged_transfer(
+ e,
+ old_account,
+ new_account,
+ lost_balance,
+ &TransferKind::Recovery,
+ );
+
+ // Preserve frozen tokens on the new account if there were any
+ if frozen_tokens > 0 {
+ Self::freeze_partial_tokens(e, new_account, frozen_tokens);
+ }
+
+ emit_recovery_success(e, old_account, new_account);
+
+ true
+ }
+
+ /// Sets the frozen status for an address. A frozen wallet cannot send
+ /// tokens from its own balance or receive tokens. The freeze applies to
+ /// the wallet's own balance only: a frozen wallet can still act as the
+ /// spender in [`Self::transfer_from`], moving tokens out of another
+ /// (unfrozen) account within the allowance it was granted.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `user_address` - The address to freeze or unfreeze.
+ /// * `freeze` - `true` to freeze the address, `false` to unfreeze.
+ ///
+ /// # Events
+ ///
+ /// * topics - `["address_frozen", user_address: Address, is_frozen: bool]`
+ /// * data - `[]`
+ ///
+ /// # Security Warning
+ ///
+ /// **IMPORTANT**: This function bypasses authorization checks and should
+ /// only be used internally or in admin functions that implement their own
+ /// authorization logic.
+ pub fn set_address_frozen(e: &Env, user_address: &Address, freeze: bool) {
+ e.storage().persistent().set(&RWAStorageKey::AddressFrozen(user_address.clone()), &freeze);
+
+ emit_address_frozen(e, user_address, freeze);
+ }
+
+ /// Freezes a specified amount of tokens for a given address.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `user_address` - The address for which to freeze tokens.
+ /// * `amount` - The amount of tokens to freeze.
+ ///
+ /// # Errors
+ ///
+ /// * [`RWAError::LessThanZero`] - When `amount < 0`.
+ /// * [`RWAError::InsufficientBalance`] - When trying to freeze more tokens
+ /// than available.
+ ///
+ /// # Events
+ ///
+ /// * topics - `["tokens_frozen", user_address: Address]`
+ /// * data - `[amount: i128]`
+ ///
+ /// # Security Warning
+ ///
+ /// **IMPORTANT**: This function bypasses authorization checks and should
+ /// only be used internally or in admin functions that implement their own
+ /// authorization logic.
+ pub fn freeze_partial_tokens(e: &Env, user_address: &Address, amount: i128) {
+ if amount < 0 {
+ panic_with_error!(e, RWAError::LessThanZero);
+ }
+
+ let current_balance = Base::balance(e, user_address);
+ let current_frozen = Self::get_frozen_tokens(e, user_address);
+ let new_frozen = current_frozen + amount;
+
+ if new_frozen > current_balance {
+ panic_with_error!(e, RWAError::InsufficientBalance);
+ }
+
+ e.storage()
+ .persistent()
+ .set(&RWAStorageKey::FrozenTokens(user_address.clone()), &new_frozen);
+ emit_tokens_frozen(e, user_address, amount);
+ }
+
+ /// Unfreezes a specified amount of tokens for a given address.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `user_address` - The address for which to unfreeze tokens.
+ /// * `amount` - The amount of tokens to unfreeze.
+ ///
+ /// # Errors
+ ///
+ /// * [`RWAError::LessThanZero`] - When `amount < 0`.
+ /// * [`RWAError::InsufficientFreeTokens`] - When trying to unfreeze more
+ /// tokens than are frozen.
+ ///
+ /// # Events
+ ///
+ /// * topics - `["tokens_unfrozen", user_address: Address]`
+ /// * data - `[amount: i128]`
+ ///
+ /// # Security Warning
+ ///
+ /// **IMPORTANT**: This function bypasses authorization checks and should
+ /// only be used internally or in admin functions that implement their own
+ /// authorization logic.
+ pub fn unfreeze_partial_tokens(e: &Env, user_address: &Address, amount: i128) {
+ if amount < 0 {
+ panic_with_error!(e, RWAError::LessThanZero);
+ }
+
+ let current_frozen = Self::get_frozen_tokens(e, user_address);
+ if current_frozen < amount {
+ panic_with_error!(e, RWAError::InsufficientFreeTokens);
+ }
+
+ let new_frozen = current_frozen - amount;
+ e.storage()
+ .persistent()
+ .set(&RWAStorageKey::FrozenTokens(user_address.clone()), &new_frozen);
+ emit_tokens_unfrozen(e, user_address, amount);
+ }
+
+ /// Sets the onchain ID of the token.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `onchain_id` - The new onchain ID address for the token.
+ ///
+ /// # Events
+ ///
+ /// * topics - `["token_onchain_id_updated", onchain_id: Address]`
+ /// * data - `[]`
+ ///
+ /// # Security Warning
+ ///
+ /// **IMPORTANT**: This function bypasses authorization checks and should
+ /// only be used internally or in admin functions that implement their own
+ /// authorization logic.
+ pub fn set_onchain_id(e: &Env, onchain_id: &Address) {
+ e.storage().instance().set(&RWAStorageKey::OnchainId, onchain_id);
+
+ emit_token_onchain_id_updated(e, onchain_id);
+ }
+
+ /// Sets the compliance contract of the token.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `compliance` - The address of the compliance contract.
+ ///
+ /// # Events
+ ///
+ /// * topics - `["compliance_set", compliance: Address]`
+ /// * data - `[]`
+ ///
+ /// # Security Warning
+ ///
+ /// **IMPORTANT**: This function bypasses authorization checks and should
+ /// only be used internally or in admin functions that implement their own
+ /// authorization logic.
+ pub fn set_compliance(e: &Env, compliance: &Address) {
+ e.storage().instance().set(&RWAStorageKey::Compliance, compliance);
+ emit_compliance_set(e, compliance);
+ }
+
+ /// Sets the identity verifier contract of the token.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `identity_verifier` - The address of the identity verifier contract.
+ ///
+ /// # Events
+ ///
+ /// * topics - `["identity_verifier_set", identity_verifier: Address]`
+ /// * data - `[]`
+ ///
+ /// # Security Warning
+ ///
+ /// **IMPORTANT**: This function bypasses authorization checks and should
+ /// only be used internally or in admin functions that implement their own
+ /// authorization logic.
+ pub fn set_identity_verifier(e: &Env, identity_verifier: &Address) {
+ e.storage().instance().set(&RWAStorageKey::IdentityVerifier, identity_verifier);
+ emit_identity_verifier_set(e, identity_verifier);
+ }
+
+ // ################## OVERRIDDEN FUNCTIONS ##################
+
+ /// `transfer` override with added compliance and identity verification
+ /// checks.
+ ///
+ /// This is ultimately a wrapper around [`Base::update()`] to enable
+ /// the compatibility across [`crate::fungible::FungibleToken`]
+ /// with [`crate::rwa::RWAToken`]
+ ///
+ /// The main differences are:
+ /// - checks for if the contract is paused
+ /// - checks for if the addresses are frozen
+ /// - checks for if the from address have enough free tokens (unfrozen
+ /// tokens)
+ /// - enforces identity verification for both addresses
+ /// - enforces compliance rules for the transfer
+ /// - triggers `transferred` hook call from the compliance contract
+ ///
+ /// Please refer to [`Base::update`] and [`Self::validate_transfer`] for the
+ /// inline documentation.
+ ///
+ /// # Events
+ ///
+ /// * topics - `["transfer", from: Address, to: Address]`
+ /// * data - `amount: i128` (when `to` has no muxed ID)
+ /// * data - `{to_muxed_id: Option, amount: i128}` (when `to` has a
+ /// muxed ID)
+ ///
+ /// # Notes
+ ///
+ /// A muxed `to` is narrowed to its base address before any check runs, so
+ /// identity verification, the compliance hook and the balance update all
+ /// operate on the base address. The muxed ID reaches the event only, where
+ /// it lets a custodian attribute the transfer to one of its off-chain
+ /// sub-accounts.
+ ///
+ /// The ID is supplied by the caller and carries no on-chain verification.
+ /// A verified holder may therefore be a custodian holding on behalf of
+ /// beneficiaries who have no on-chain identity of their own.
+ ///
+ /// Whether to accept custodial destinations is the implementor's decision,
+ /// not the library's. An issuer that requires beneficial owners on the
+ /// on-chain register can override
+ /// [`crate::fungible::FungibleToken::transfer`] and reject a destination
+ /// whose `id()` is `Some`; `rwa-token-example` carries that pattern as a
+ /// showcase.
+ pub fn transfer(e: &Env, from: &Address, to: &MuxedAddress, amount: i128) {
+ from.require_auth();
+
+ let to_address = to.address();
+
+ // Snapshot before the update so the hook observes the pre-transfer
+ // state.
+ let from_snapshot = Self::account_snapshot(e, from);
+ let to_snapshot = Self::account_snapshot(e, &to_address);
+
+ Self::validate_transfer(
+ e,
+ &from_snapshot,
+ &to_snapshot,
+ amount,
+ FromVerification::Required,
+ );
+
+ Base::update(e, Some(from), Some(&to_address), amount);
+
+ let compliance_client = ComplianceClient::new(e, &Self::compliance(e));
+ compliance_client.transferred(
+ &from_snapshot,
+ &to_snapshot,
+ &amount,
+ &TransferKind::Standard,
+ &e.current_contract_address(),
+ );
+ emit_transfer(e, from, &to_address, to.id(), amount);
+ }
+
+ /// `transfer_from` override with added compliance and identity verification
+ /// checks.
+ ///
+ /// This is ultimately a wrapper around [`Base::update()`] to enable
+ /// the compatibility across [`crate::fungible::FungibleToken`]
+ /// with [`crate::rwa::RWAToken`]
+ ///
+ /// The main differences are:
+ /// - checks for if the contract is paused
+ /// - checks for if the addresses are frozen
+ /// - checks for if the from address have enough free tokens (unfrozen
+ /// tokens)
+ /// - enforces identity verification for both addresses
+ /// - enforces compliance rules for the transfer
+ /// - triggers `transferred` hook call from the compliance contract
+ ///
+ /// Please refer to [`Base::update`] and [`Self::validate_transfer`] for the
+ /// inline documentation.
+ pub fn transfer_from(e: &Env, spender: &Address, from: &Address, to: &Address, amount: i128) {
+ spender.require_auth();
+
+ // Snapshot before the update so the hook observes the pre-transfer
+ // state. `spender` is the delegate moving the holder's tokens.
+ let from_snapshot = Self::account_snapshot(e, from);
+ let to_snapshot = Self::account_snapshot(e, to);
+
+ Self::validate_transfer(
+ e,
+ &from_snapshot,
+ &to_snapshot,
+ amount,
+ FromVerification::Required,
+ );
+
+ Base::spend_allowance(e, from, spender, amount);
+
+ Base::update(e, Some(from), Some(to), amount);
+
+ let compliance_client = ComplianceClient::new(e, &Self::compliance(e));
+ compliance_client.transferred(
+ &from_snapshot,
+ &to_snapshot,
+ &amount,
+ &TransferKind::Delegated(spender.clone()),
+ &e.current_contract_address(),
+ );
+ emit_transfer(e, from, to, None, amount);
+ }
+
+ // ################## BATCH FUNCTIONS ##################
+
+ /// Transfers to several recipients in one invocation, moving
+ /// `amounts[i]` from `from` to `recipients[i]`. The sender authorizes the
+ /// whole batch once.
+ ///
+ /// The batch is atomic: an item that fails reverts every item before it.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `from` - The address holding the tokens.
+ /// * `recipients` - The addresses receiving the tokens.
+ /// * `amounts` - The amount to transfer to each recipient.
+ ///
+ /// # Errors
+ ///
+ /// * [`RWAError::BatchSizeMismatch`] - When `recipients` and `amounts` have
+ /// different lengths.
+ /// * refer to [`Self::validate_transfer`] errors, which every item runs.
+ /// * refer to [`Base::update`] errors.
+ ///
+ /// # Events
+ ///
+ /// For each recipient:
+ /// * topics - `["transfer", from: Address, to: Address]`
+ /// * data - `[amount: i128]`
+ ///
+ /// # Notes
+ ///
+ /// Recipients are processed in order, and the sender's free balance is
+ /// re-evaluated for every item, so a batch that overspends fails on the
+ /// item that runs out rather than up front.
+ ///
+ /// The sender's identity is verified once for the whole batch rather than
+ /// once per recipient, which is where the saving over repeated single
+ /// transfers comes from. This assumes nothing invalidates the sender
+ /// part-way through: the compliance modules that run between items hold no
+ /// authority over the identity stack, and re-entering this token is
+ /// impossible in Soroban, so no module in reach can revoke the sender it
+ /// was just handed. A module granted write access to the identity stack
+ /// would break that assumption.
+ ///
+ /// The paused state is checked once up front so that a paused token is
+ /// rejected before any identity work and regardless of batch size, and
+ /// again per item along with both wallets' freezing status, since those
+ /// reads are cheap enough that skipping them would buy nothing.
+ ///
+ /// The caller is responsible for sizing the batch to fit the
+ /// per-transaction network limits. Refer to the batch operations section of
+ /// the [module documentation](crate::rwa) for measured figures.
+ pub fn batch_transfer(e: &Env, from: &Address, recipients: &Vec, amounts: &Vec) {
+ from.require_auth();
+
+ Self::require_equal_lengths(e, recipients.len(), amounts.len());
+
+ if paused(e) {
+ panic_with_error!(e, PausableError::EnforcedPause);
+ }
+
+ // Every item shares one `from`, so the walk through the identity stack
+ // that verifies it runs once for the batch instead of once per
+ // recipient. Every other check stays inside the loop.
+ IdentityVerifierClient::new(e, &Self::identity_verifier(e)).verify_identity(from);
+
+ let compliance_client = ComplianceClient::new(e, &Self::compliance(e));
+ let token = e.current_contract_address();
+
+ for (to, amount) in recipients.iter().zip(amounts.iter()) {
+ // Snapshot before the update so the hook observes the pre-transfer
+ // state. The sender's balance shrinks as the batch progresses, so
+ // this is re-read for every item.
+ let from_snapshot = Self::account_snapshot(e, from);
+ let to_snapshot = Self::account_snapshot(e, &to);
+
+ Self::validate_transfer(
+ e,
+ &from_snapshot,
+ &to_snapshot,
+ amount,
+ FromVerification::HoistedByCaller,
+ );
+
+ Base::update(e, Some(from), Some(&to), amount);
+
+ compliance_client.transferred(
+ &from_snapshot,
+ &to_snapshot,
+ &amount,
+ &TransferKind::Standard,
+ &token,
+ );
+ emit_transfer(e, from, &to, None, amount);
+ }
+ }
+
+ /// Forces a transfer for each `(from_list[i], to_list[i], amounts[i])`
+ /// triple in one invocation.
+ ///
+ /// The batch is atomic: an item that fails reverts every item before it.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `from_list` - The addresses holding the tokens.
+ /// * `to_list` - The addresses receiving the tokens.
+ /// * `amounts` - The amount to move for each pair.
+ ///
+ /// # Errors
+ ///
+ /// * [`RWAError::BatchSizeMismatch`] - When the three arrays do not all
+ /// have the same length.
+ /// * refer to [`Self::forced_transfer`] errors.
+ ///
+ /// # Events
+ ///
+ /// For each triple:
+ /// * topics - `["transfer", from: Address, to: Address]`
+ /// * data - `[amount: i128]`
+ ///
+ /// For each triple whose `amount` exceeds the free (unfrozen) balance of
+ /// `from`, emitted before its transfer:
+ /// * topics - `["tokens_unfrozen", from: Address]`
+ /// * data - `[amount: i128]`
+ ///
+ /// # Notes
+ ///
+ /// The caller is responsible for sizing the batch to fit the
+ /// per-transaction network limits. Refer to the batch operations section of
+ /// the [module documentation](crate::rwa).
+ ///
+ /// # Security Warning
+ ///
+ /// **IMPORTANT**: This function bypasses authorization and freezing checks,
+ /// exactly like [`Self::forced_transfer`]. Refer to its security warning.
+ pub fn batch_forced_transfer(
+ e: &Env,
+ from_list: &Vec,
+ to_list: &Vec,
+ amounts: &Vec,
+ ) {
+ Self::require_equal_lengths(e, from_list.len(), to_list.len());
+ Self::require_equal_lengths(e, to_list.len(), amounts.len());
+
+ for ((from, to), amount) in from_list.iter().zip(to_list.iter()).zip(amounts.iter()) {
+ Self::forced_transfer(e, &from, &to, amount);
+ }
+ }
+
+ /// Mints `amounts[i]` tokens to `to_list[i]` in one invocation.
+ ///
+ /// The batch is atomic: an item that fails reverts every item before it.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `to_list` - The addresses receiving the new tokens.
+ /// * `amounts` - The amount to mint to each address.
+ ///
+ /// # Errors
+ ///
+ /// * [`RWAError::BatchSizeMismatch`] - When `to_list` and `amounts` have
+ /// different lengths.
+ /// * refer to [`Self::mint`] errors.
+ ///
+ /// # Events
+ ///
+ /// For each address:
+ /// * topics - `["mint", to: Address]`
+ /// * data - `[amount: i128]`
+ ///
+ /// # Notes
+ ///
+ /// The caller is responsible for sizing the batch to fit the
+ /// per-transaction network limits. Refer to the batch operations section of
+ /// the [module documentation](crate::rwa).
+ ///
+ /// # Security Warning
+ ///
+ /// ⚠️ SECURITY RISK: This function has NO AUTHORIZATION CONTROLS ⚠️
+ ///
+ /// Refer to the security warning on [`Self::mint`].
+ pub fn batch_mint(e: &Env, to_list: &Vec, amounts: &Vec) {
+ Self::require_equal_lengths(e, to_list.len(), amounts.len());
+
+ for (to, amount) in to_list.iter().zip(amounts.iter()) {
+ Self::mint(e, &to, amount);
+ }
+ }
+
+ /// Burns `amounts[i]` tokens from `user_addresses[i]` in one invocation.
+ ///
+ /// The batch is atomic: an item that fails reverts every item before it.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `user_addresses` - The addresses to burn tokens from.
+ /// * `amounts` - The amount to burn from each address.
+ ///
+ /// # Errors
+ ///
+ /// * [`RWAError::BatchSizeMismatch`] - When `user_addresses` and `amounts`
+ /// have different lengths.
+ /// * refer to [`Self::burn`] errors.
+ ///
+ /// # Events
+ ///
+ /// For each address:
+ /// * topics - `["burn", user_address: Address]`
+ /// * data - `[amount: i128]`
+ ///
+ /// For each address whose `amount` exceeds its free (unfrozen) balance,
+ /// emitted before its burn:
+ /// * topics - `["tokens_unfrozen", user_address: Address]`
+ /// * data - `[amount: i128]`
+ ///
+ /// # Notes
+ ///
+ /// The caller is responsible for sizing the batch to fit the
+ /// per-transaction network limits. Refer to the batch operations section of
+ /// the [module documentation](crate::rwa).
+ ///
+ /// # Security Warning
+ ///
+ /// **IMPORTANT**: This function bypasses authorization checks, exactly like
+ /// [`Self::burn`]. Refer to its security warning.
+ pub fn batch_burn(e: &Env, user_addresses: &Vec, amounts: &Vec) {
+ Self::require_equal_lengths(e, user_addresses.len(), amounts.len());
+
+ for (user_address, amount) in user_addresses.iter().zip(amounts.iter()) {
+ Self::burn(e, &user_address, amount);
+ }
+ }
+
+ /// Sets the frozen status of `user_addresses[i]` to `freeze_list[i]` in one
+ /// invocation.
+ ///
+ /// The batch is atomic: an item that fails reverts every item before it.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `user_addresses` - The addresses to freeze or unfreeze.
+ /// * `freeze_list` - The frozen status to apply to each address.
+ ///
+ /// # Errors
+ ///
+ /// * [`RWAError::BatchSizeMismatch`] - When `user_addresses` and
+ /// `freeze_list` have different lengths.
+ ///
+ /// # Events
+ ///
+ /// For each address:
+ /// * topics - `["address_frozen", user_address: Address, is_frozen: bool]`
+ /// * data - `[]`
+ ///
+ /// # Notes
+ ///
+ /// An address repeated in `user_addresses` ends up with the status of its
+ /// last occurrence, and each occurrence emits its own event.
+ ///
+ /// The caller is responsible for sizing the batch to fit the
+ /// per-transaction network limits. Refer to the batch operations section of
+ /// the [module documentation](crate::rwa).
+ ///
+ /// # Security Warning
+ ///
+ /// **IMPORTANT**: This function bypasses authorization checks, exactly like
+ /// [`Self::set_address_frozen`]. Refer to its security warning.
+ pub fn batch_set_address_frozen(
+ e: &Env,
+ user_addresses: &Vec,
+ freeze_list: &Vec,
+ ) {
+ Self::require_equal_lengths(e, user_addresses.len(), freeze_list.len());
+
+ for (user_address, freeze) in user_addresses.iter().zip(freeze_list.iter()) {
+ Self::set_address_frozen(e, &user_address, freeze);
+ }
+ }
+
+ /// Freezes `amounts[i]` tokens on `user_addresses[i]` in one invocation.
+ ///
+ /// The batch is atomic: an item that fails reverts every item before it.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `user_addresses` - The addresses for which to freeze tokens.
+ /// * `amounts` - The amount to freeze on each address.
+ ///
+ /// # Errors
+ ///
+ /// * [`RWAError::BatchSizeMismatch`] - When `user_addresses` and `amounts`
+ /// have different lengths.
+ /// * refer to [`Self::freeze_partial_tokens`] errors.
+ ///
+ /// # Events
+ ///
+ /// For each address:
+ /// * topics - `["tokens_frozen", user_address: Address]`
+ /// * data - `[amount: i128]`
+ ///
+ /// # Notes
+ ///
+ /// Amounts accumulate, so an address repeated in `user_addresses` has every
+ /// occurrence added to its frozen balance.
+ ///
+ /// The caller is responsible for sizing the batch to fit the
+ /// per-transaction network limits. Refer to the batch operations section of
+ /// the [module documentation](crate::rwa).
+ ///
+ /// # Security Warning
+ ///
+ /// **IMPORTANT**: This function bypasses authorization checks, exactly like
+ /// [`Self::freeze_partial_tokens`]. Refer to its security warning.
+ pub fn batch_freeze_partial_tokens(
+ e: &Env,
+ user_addresses: &Vec,
+ amounts: &Vec,
+ ) {
+ Self::require_equal_lengths(e, user_addresses.len(), amounts.len());
+
+ for (user_address, amount) in user_addresses.iter().zip(amounts.iter()) {
+ Self::freeze_partial_tokens(e, &user_address, amount);
+ }
+ }
+
+ /// Unfreezes `amounts[i]` tokens on `user_addresses[i]` in one invocation.
+ ///
+ /// The batch is atomic: an item that fails reverts every item before it.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `user_addresses` - The addresses for which to unfreeze tokens.
+ /// * `amounts` - The amount to unfreeze on each address.
+ ///
+ /// # Errors
+ ///
+ /// * [`RWAError::BatchSizeMismatch`] - When `user_addresses` and `amounts`
+ /// have different lengths.
+ /// * refer to [`Self::unfreeze_partial_tokens`] errors.
+ ///
+ /// # Events
+ ///
+ /// For each address:
+ /// * topics - `["tokens_unfrozen", user_address: Address]`
+ /// * data - `[amount: i128]`
+ ///
+ /// # Notes
+ ///
+ /// Amounts accumulate, so an address repeated in `user_addresses` has every
+ /// occurrence subtracted from its frozen balance.
+ ///
+ /// The caller is responsible for sizing the batch to fit the
+ /// per-transaction network limits. Refer to the batch operations section of
+ /// the [module documentation](crate::rwa).
+ ///
+ /// # Security Warning
+ ///
+ /// **IMPORTANT**: This function bypasses authorization checks, exactly like
+ /// [`Self::unfreeze_partial_tokens`]. Refer to its security warning.
+ pub fn batch_unfreeze_partial_tokens(
+ e: &Env,
+ user_addresses: &Vec,
+ amounts: &Vec,
+ ) {
+ Self::require_equal_lengths(e, user_addresses.len(), amounts.len());
+
+ for (user_address, amount) in user_addresses.iter().zip(amounts.iter()) {
+ Self::unfreeze_partial_tokens(e, &user_address, amount);
+ }
+ }
+
+ // ################## LOW-LEVEL HELPERS ##################
+
+ /// Performs every check a transfer requires, without requiring
+ /// authorization or moving anything: paused state, the freezing status of
+ /// both wallets, the sender's free balance, and the identity of both
+ /// parties. Shared by [`Self::transfer`], [`Self::transfer_from`] and every
+ /// item of [`Self::batch_transfer`], so no entry point can drift away from
+ /// the others on what it checks.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `from` - Snapshot of the sender, as of before the transfer.
+ /// * `to` - Snapshot of the receiver, as of before the transfer.
+ /// * `amount` - The amount of tokens to transfer.
+ /// * `from_verification` - Who verifies the identity of `from`, refer to
+ /// [`FromVerification`].
+ ///
+ /// # Errors
+ ///
+ /// * [`PausableError::EnforcedPause`] - If the contract is paused.
+ /// * [`RWAError::AddressFrozen`] - If either the sender or receiver is
+ /// frozen.
+ /// * [`RWAError::InsufficientFreeTokens`] - If the sender does not have
+ /// enough free tokens.
+ /// * refer to [`Self::identity_verifier`] errors.
+ /// * refer to [`IdentityVerifierClient::verify_identity`] errors.
+ ///
+ /// # Security Warning
+ ///
+ /// **IMPORTANT**: Passing [`FromVerification::HoistedByCaller`] skips the
+ /// sender's identity check, and is only sound when the caller has verified
+ /// that same sender earlier in the invocation (i.e. batched operations).
+ pub fn validate_transfer(
+ e: &Env,
+ from: &AccountSnapshot,
+ to: &AccountSnapshot,
+ amount: i128,
+ from_verification: FromVerification,
+ ) {
+ // Check if contract is paused
+ if paused(e) {
+ panic_with_error!(e, PausableError::EnforcedPause);
+ }
+
+ // Check if addresses are frozen
+ if Self::is_frozen(e, &from.address) || Self::is_frozen(e, &to.address) {
+ panic_with_error!(e, RWAError::AddressFrozen);
+ }
+
+ // Check if there are enough free tokens (not frozen)
+ if from.balance - from.frozen < amount {
+ panic_with_error!(e, RWAError::InsufficientFreeTokens);
+ }
+
+ let identity_verifier_addr = Self::identity_verifier(e);
+ let identity_verifier_client = IdentityVerifierClient::new(e, &identity_verifier_addr);
+ if matches!(from_verification, FromVerification::Required) {
+ identity_verifier_client.verify_identity(&from.address);
+ }
+ identity_verifier_client.verify_identity(&to.address);
+ }
+
+ /// Shared mechanics behind [`Self::forced_transfer`] and
+ /// [`Self::recover_balance`]: moves `amount` from `from` to `to`,
+ /// bypassing paused state and freezing restrictions (unfreezing as
+ /// needed), and reports the movement to the compliance contract as
+ /// `kind`. The two public entry points differ only in the kind they
+ /// report, which is what lets compliance modules tell a seizure from a
+ /// wallet migration.
+ ///
+ /// Performs no identity verification; each caller verifies the recipient
+ /// before delegating here, so the check runs exactly once per invocation.
+ fn privileged_transfer(
+ e: &Env,
+ from: &Address,
+ to: &Address,
+ amount: i128,
+ kind: &TransferKind,
+ ) {
+ // Snapshot before any unfreeze/balance mutation so the hook observes
+ // the pre-operation state. A privileged transfer has no spender: it is
+ // an administrative pull, not a delegated move.
+ let from_snapshot = Self::account_snapshot(e, from);
+ let to_snapshot = Self::account_snapshot(e, to);
+
+ if from_snapshot.balance < amount {
+ panic_with_error!(e, RWAError::InsufficientBalance);
+ }
+
+ // Check if we need to unfreeze tokens to complete the transfer. The
+ // snapshot is still current: nothing has mutated the balance or
+ // frozen amount since it was captured.
+ let free_tokens = from_snapshot.balance - from_snapshot.frozen;
+ if free_tokens < amount {
+ let tokens_to_unfreeze = amount - free_tokens;
+ let new_frozen = from_snapshot.frozen - tokens_to_unfreeze;
+
+ e.storage().persistent().set(&RWAStorageKey::FrozenTokens(from.clone()), &new_frozen);
+ emit_tokens_unfrozen(e, from, tokens_to_unfreeze);
+ }
+
+ Base::update(e, Some(from), Some(to), amount);
+
+ let compliance_addr = Self::compliance(e);
+ let compliance_client = ComplianceClient::new(e, &compliance_addr);
+ compliance_client.transferred(
+ &from_snapshot,
+ &to_snapshot,
+ &amount,
+ kind,
+ &e.current_contract_address(),
+ );
+
+ emit_transfer(e, from, to, None, amount);
+ }
+
+ /// Rejects a batch whose parallel arrays do not line up.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `left` - Length of the first array.
+ /// * `right` - Length of the array that must match it.
+ ///
+ /// # Errors
+ ///
+ /// * [`RWAError::BatchSizeMismatch`] - When the two lengths differ.
+ fn require_equal_lengths(e: &Env, left: u32, right: u32) {
+ if left != right {
+ panic_with_error!(e, RWAError::BatchSizeMismatch);
+ }
+ }
+}
From b8a017cb5dcc42f0facaefbeca91f23a98490aa4 Mon Sep 17 00:00:00 2001
From: mertcano <35747700+mertcano@users.noreply.github.com>
Date: Wed, 21 Oct 2026 12:10:24 +0300
Subject: [PATCH 2/2] LF
---
packages/tokens/src/rwa/storage.rs | 2912 ++++++++++++++--------------
1 file changed, 1456 insertions(+), 1456 deletions(-)
diff --git a/packages/tokens/src/rwa/storage.rs b/packages/tokens/src/rwa/storage.rs
index 3be7cab97..4d4501cc0 100644
--- a/packages/tokens/src/rwa/storage.rs
+++ b/packages/tokens/src/rwa/storage.rs
@@ -1,1456 +1,1456 @@
-use soroban_sdk::{contracttype, panic_with_error, Address, Env, MuxedAddress, String, Vec};
-use stellar_contract_utils::pausable::{paused, PausableError};
-
-use crate::{
- fungible::{
- capped::{check_cap, CappedContractType},
- emit_transfer,
- total_supply::{
- decrease_total_supply, increase_total_supply, total_supply, TotalSupplyOverrides,
- },
- Base, ContractOverrides, MintOverrides,
- },
- rwa::{
- compliance::{AccountSnapshot, ComplianceClient, TransferKind},
- emit_address_frozen, emit_burn, emit_compliance_set, emit_identity_verifier_set, emit_mint,
- emit_recovery_success, emit_token_onchain_id_updated, emit_tokens_frozen,
- emit_tokens_unfrozen, IdentityVerifierClient, RWAError, FROZEN_EXTEND_AMOUNT,
- FROZEN_TTL_THRESHOLD,
- },
-};
-
-/// Storage keys for the data associated with `RWA` token
-#[contracttype]
-pub enum RWAStorageKey {
- /// Frozen status of an address (true = frozen, false = not frozen)
- AddressFrozen(Address),
- /// Amount of tokens frozen for a specific address
- FrozenTokens(Address),
- /// Compliance contract address
- Compliance,
- /// OnchainID contract address
- OnchainId,
- /// Version of the token
- Version,
- /// Identity Verifier contract address
- IdentityVerifier,
-}
-
-pub struct RWA;
-
-/// Who verifies the identity of `from` in [`RWA::validate_transfer`].
-///
-/// Every item of a batch transfer shares one `from`, so verifying it per item
-/// repeats the same walk through the identity stack. This lets the batch lift
-/// that one check out of its loop while every other check still runs per item,
-/// and while all three transfer entry points keep sharing one validator.
-pub enum FromVerification {
- /// Verify `from` as part of this call. What the single-transfer entry
- /// points pass.
- Required,
- /// Skip the check on `from`, because the caller has already verified it
- /// once for a batch of transfers that all share it.
- HoistedByCaller,
-}
-
-impl ContractOverrides for RWA {
- fn transfer(e: &Env, from: &Address, to: &MuxedAddress, amount: i128) {
- RWA::transfer(e, from, to, amount);
- }
-
- fn transfer_from(e: &Env, spender: &Address, from: &Address, to: &Address, amount: i128) {
- RWA::transfer_from(e, spender, from, to, amount);
- }
-}
-
-// T-REX (ERC-3643) tokens expose the total supply, so the RWA contract type
-// is inherently supply-aware.
-impl TotalSupplyOverrides for RWA {}
-
-// Minting through the contract type runs RWA's full mint path: identity
-// verification, freezing checks, supply accounting and the compliance hook.
-impl MintOverrides for RWA {
- fn mint(e: &Env, to: &Address, amount: i128) {
- RWA::mint(e, to, amount);
- }
-}
-
-/// Marker for the contract types backing [`crate::rwa::RWAToken`]: [`RWA`]
-/// itself and [`CappedRWA`].
-///
-/// # Why this trait exists
-///
-/// `RWAToken`'s default bodies call the [`RWA`] functions directly (batch
-/// transfers, forced transfers, freezing, recovery, ...), so the trait used to
-/// require `ContractType = RWA` exactly. A capped RWA token needs a different
-/// contract type, [`CappedRWA`], whose mint also checks the cap, while every
-/// other RWA operation stays the same. This marker lets `RWAToken` accept both
-/// contract types, and nothing else.
-///
-/// Contract authors never interact with this trait; it only appears as that
-/// bound.
-#[diagnostic::on_unimplemented(
- message = "`{Self}` is not an RWA contract type, so `RWAToken` cannot be implemented",
- note = "list `RWA` in the `Compose` list, e.g. `Compose<(RWA, TotalSupply)>`"
-)]
-pub trait RWAContractType {}
-
-impl RWAContractType for RWA {}
-
-/// Contract type enforcing a maximum total supply on top of [`RWA`], resolved
-/// by `Compose<(RWA, Capped, TotalSupply)>`.
-///
-/// Its `mint` and `batch_mint` check the cap (refer to
-/// [`crate::fungible::capped::Capped`]) before running RWA's own mint path.
-/// Every other operation is RWA's, unchanged.
-pub struct CappedRWA;
-
-impl RWAContractType for CappedRWA {}
-impl CappedContractType for CappedRWA {}
-
-// RWA tracks the supply itself, and the capped variant reads the same counter.
-impl TotalSupplyOverrides for CappedRWA {}
-
-// Transfers keep RWA's identity, freezing and compliance checks.
-impl ContractOverrides for CappedRWA {
- fn transfer(e: &Env, from: &Address, to: &MuxedAddress, amount: i128) {
- RWA::transfer(e, from, to, amount);
- }
-
- fn transfer_from(e: &Env, spender: &Address, from: &Address, to: &Address, amount: i128) {
- RWA::transfer_from(e, spender, from, to, amount);
- }
-}
-
-impl MintOverrides for CappedRWA {
- fn mint(e: &Env, to: &Address, amount: i128) {
- CappedRWA::mint(e, to, amount);
- }
-}
-
-impl CappedRWA {
- /// Mints `amount` tokens to `to` if the cap allows it.
- ///
- /// # Errors
- ///
- /// * refer to [`check_cap`] errors.
- /// * refer to [`RWA::mint`] errors.
- ///
- /// # Security Warning
- ///
- /// ⚠️ SECURITY RISK: This function has NO AUTHORIZATION CONTROLS ⚠️
- ///
- /// Refer to the security warning on [`RWA::mint`].
- pub fn mint(e: &Env, to: &Address, amount: i128) {
- check_cap(e, amount, total_supply(e));
- RWA::mint(e, to, amount);
- }
-
- /// Mints `amounts[i]` tokens to `to_list[i]`, checking the cap for every
- /// item.
- ///
- /// # Errors
- ///
- /// * refer to [`RWA::mint`] errors.
- /// * refer to [`check_cap`] errors.
- ///
- /// # Security Warning
- ///
- /// ⚠️ SECURITY RISK: This function has NO AUTHORIZATION CONTROLS ⚠️
- ///
- /// Refer to the security warning on [`RWA::mint`].
- pub fn batch_mint(e: &Env, to_list: &Vec, amounts: &Vec) {
- RWA::require_equal_lengths(e, to_list.len(), amounts.len());
-
- for (to, amount) in to_list.iter().zip(amounts.iter()) {
- CappedRWA::mint(e, &to, amount);
- }
- }
-}
-
-impl RWA {
- // ################## QUERY STATE ##################
-
- /// Returns the token version.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- ///
- /// # Errors
- ///
- /// * [`RWAError::VersionNotSet`] - When the version is not set.
- pub fn version(e: &Env) -> String {
- e.storage()
- .instance()
- .get(&RWAStorageKey::Version)
- .unwrap_or_else(|| panic_with_error!(e, RWAError::VersionNotSet))
- }
-
- /// Returns the address of the onchain ID of the token.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- ///
- /// # Errors
- ///
- /// * [`RWAError::OnchainIdNotSet`] - When the onchain ID is not set.
- pub fn onchain_id(e: &Env) -> Address {
- e.storage()
- .instance()
- .get(&RWAStorageKey::OnchainId)
- .unwrap_or_else(|| panic_with_error!(e, RWAError::OnchainIdNotSet))
- }
-
- /// Returns the Compliance contract linked to the token.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- ///
- /// # Errors
- ///
- /// * [`RWAError::ComplianceNotSet`] - When the compliance contract is not
- /// set.
- pub fn compliance(e: &Env) -> Address {
- e.storage()
- .instance()
- .get(&RWAStorageKey::Compliance)
- .unwrap_or_else(|| panic_with_error!(e, RWAError::ComplianceNotSet))
- }
-
- /// Returns the Identity Verifier contract linked to the token.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- ///
- /// # Errors
- ///
- /// * [`RWAError::IdentityVerifierNotSet`] - When the identity verifier
- /// contract is not set.
- pub fn identity_verifier(e: &Env) -> Address {
- e.storage()
- .instance()
- .get(&RWAStorageKey::IdentityVerifier)
- .unwrap_or_else(|| panic_with_error!(e, RWAError::IdentityVerifierNotSet))
- }
-
- /// Returns the freezing status of a wallet. Frozen wallets cannot send or
- /// receive funds.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `user_address` - The address of the wallet to check.
- pub fn is_frozen(e: &Env, user_address: &Address) -> bool {
- let key = RWAStorageKey::AddressFrozen(user_address.clone());
- if let Some(frozen) = e.storage().persistent().get::<_, bool>(&key) {
- e.storage().persistent().extend_ttl(&key, FROZEN_TTL_THRESHOLD, FROZEN_EXTEND_AMOUNT);
- frozen
- } else {
- false
- }
- }
-
- /// Returns the amount of tokens that are partially frozen on a wallet.
- /// The amount of frozen tokens is always <= to the total balance of the
- /// wallet.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `user_address` - The address of the wallet on which get_frozen_tokens
- /// is called.
- pub fn get_frozen_tokens(e: &Env, user_address: &Address) -> i128 {
- let key = RWAStorageKey::FrozenTokens(user_address.clone());
- if let Some(frozen_amount) = e.storage().persistent().get::<_, i128>(&key) {
- e.storage().persistent().extend_ttl(&key, FROZEN_TTL_THRESHOLD, FROZEN_EXTEND_AMOUNT);
- frozen_amount
- } else {
- 0
- }
- }
-
- /// Returns the amount of free (unfrozen) tokens for an address.
- /// This is calculated as total balance minus frozen tokens.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `user_address` - The address to check.
- pub fn get_free_tokens(e: &Env, user_address: &Address) -> i128 {
- let total_balance = Base::balance(e, user_address);
- let frozen_tokens = Self::get_frozen_tokens(e, user_address);
-
- // frozen tokens cannot be greater than total balance, necessary checks
- // are done in state changing functions
- total_balance - frozen_tokens
- }
-
- /// Captures an [`AccountSnapshot`] of `address` from the token's current
- /// state: its total balance and partially-frozen amount. Must be called
- /// before any balance or freeze mutation so the snapshot reflects the
- /// pre-operation figures handed to the compliance hooks.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `address` - The wallet to snapshot.
- pub fn account_snapshot(e: &Env, address: &Address) -> AccountSnapshot {
- AccountSnapshot {
- address: address.clone(),
- balance: Base::balance(e, address),
- frozen: Self::get_frozen_tokens(e, address),
- }
- }
-
- // ################## CHANGE STATE ##################
-
- /// Forced transfer of `amount` tokens from `from` to `to`.
- ///
- /// The `to` address must pass identity verification. `from` is not required
- /// to be verified, so this privileged action remains usable to pull
- /// tokens out of accounts whose identity is no longer valid
- /// (sanctioned, revoked, or compromised wallets). The same mechanics
- /// back [`Self::recover_balance`], which reports
- /// [`TransferKind::Recovery`] to the compliance contract instead of
- /// [`TransferKind::Forced`].
- ///
- /// This function can unfreeze tokens if needed for regulatory compliance.
- /// It bypasses paused state and frozen address checks.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `from` - The address holding the tokens.
- /// * `to` - The address receiving the tokens.
- /// * `amount` - The amount of tokens to transfer.
- ///
- /// # Errors
- ///
- /// * [`RWAError::InsufficientBalance`] - When attempting to transfer more
- /// tokens than available.
- /// * refer to [`IdentityVerifierClient::verify_identity`] errors.
- /// * refer to [`Base::update`] errors.
- ///
- /// # Events
- ///
- /// * topics - `["transfer", from: Address, to: Address]`
- /// * data - `[amount: i128]`
- ///
- /// Emitted before the transfer when `amount` exceeds the free (unfrozen)
- /// balance of `from`, for the part of the frozen tokens that is unfrozen:
- /// * topics - `["tokens_unfrozen", from: Address]`
- /// * data - `[amount: i128]`
- ///
- /// # Notes
- ///
- /// This function bypasses freezing restrictions and can unfreeze tokens
- /// as needed. It's intended for regulatory compliance scenarios such as
- /// court-ordered seizures.
- ///
- /// # Security Warning
- ///
- /// **IMPORTANT**: This function bypasses authorization and freezing checks.
- /// Should only be used by authorized compliance or admin functions.
- pub fn forced_transfer(e: &Env, from: &Address, to: &Address, amount: i128) {
- // Verify identity for the `to` address only; `from` is intentionally
- // skipped so this privileged action can still move tokens out of
- // accounts whose identity is no longer valid.
- let identity_verifier_addr = Self::identity_verifier(e);
- let identity_verifier_client = IdentityVerifierClient::new(e, &identity_verifier_addr);
- identity_verifier_client.verify_identity(to);
-
- Self::privileged_transfer(e, from, to, amount, &TransferKind::Forced);
- }
-
- /// Mints `amount` tokens to `to`.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `to` - The address receiving the new tokens.
- /// * `amount` - The amount of tokens to mint.
- ///
- /// # Errors
- ///
- /// * [`PausableError::EnforcedPause`] - When the contract is paused.
- /// * [`RWAError::AddressFrozen`] - When the recipient address is frozen.
- /// * [`RWAError::ComplianceNotSet`] - When the compliance contract is not
- /// configured.
- /// * refer to [`Base::update`] errors.
- ///
- /// # Events
- ///
- /// * topics - `["mint", to: Address]`
- /// * data - `[amount: i128]`
- ///
- /// # Security Warning
- ///
- /// ⚠️ SECURITY RISK: This function has NO AUTHORIZATION CONTROLS ⚠️
- ///
- /// It is the responsibility of the implementer to establish appropriate
- /// access controls to ensure that only authorized accounts can execute
- /// minting operations. Failure to implement proper authorization could
- /// lead to security vulnerabilities and unauthorized token creation.
- ///
- /// The implementation will typically look similar to the following
- /// (pseudo-code):
- ///
- /// ```ignore
- /// let admin = read_administrator(e);
- /// admin.require_auth();
- /// ```
- pub fn mint(e: &Env, to: &Address, amount: i128) {
- if paused(e) {
- panic_with_error!(e, PausableError::EnforcedPause);
- }
-
- if Self::is_frozen(e, to) {
- panic_with_error!(e, RWAError::AddressFrozen);
- }
-
- let identity_verifier_addr = Self::identity_verifier(e);
- let identity_verifier_client = IdentityVerifierClient::new(e, &identity_verifier_addr);
- identity_verifier_client.verify_identity(to);
-
- // Snapshot before the mint so the hook observes the pre-mint balance.
- let to_snapshot = Self::account_snapshot(e, to);
-
- increase_total_supply(e, amount);
- Base::update(e, None, Some(to), amount);
-
- let compliance_addr = Self::compliance(e);
- let compliance_client = ComplianceClient::new(e, &compliance_addr);
- compliance_client.created(&to_snapshot, &amount, &e.current_contract_address());
-
- emit_mint(e, to, amount);
- }
-
- /// Burns `amount` tokens from `user_address`. Updates the total supply
- /// accordingly.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `user_address` - The address from which to burn tokens.
- /// * `amount` - The amount of tokens to burn.
- ///
- /// # Errors
- ///
- /// * [`RWAError::InsufficientBalance`] - When `amount` exceeds the total
- /// balance of `user_address`.
- /// * [`RWAError::ComplianceNotSet`] - When the compliance contract is not
- /// configured.
- /// * refer to
- /// [`Compliance::destroyed`](crate::rwa::compliance::Compliance::destroyed)
- /// errors.
- /// * refer to [`Base::update`] errors.
- ///
- /// # Events
- ///
- /// * topics - `["burn", user_address: Address]`
- /// * data - `[amount: i128]`
- ///
- /// Emitted before the burn when `amount` exceeds the free (unfrozen)
- /// balance of `user_address`, for the part of the frozen tokens that is
- /// unfrozen:
- /// * topics - `["tokens_unfrozen", user_address: Address]`
- /// * data - `[amount: i128]`
- ///
- /// # Notes
- ///
- /// This function bypasses freezing restrictions and can unfreeze tokens
- /// as needed: when `amount` exceeds the free (unfrozen) balance, the
- /// difference is unfrozen first, so a partially frozen balance is
- /// destroyed in place.
- ///
- /// Locked tokens are not given the same treatment. The `Destroyed` hook
- /// carries no notion of a privileged burn, so a compliance module can
- /// reject the burn by panicking regardless of who issued it. In
- /// particular, with the
- /// [`initial_lockup_period`](crate::rwa::compliance::modules::initial_lockup_period)
- /// module registered, a burn that dips into still-locked tokens is
- /// rejected; destroying a locked position takes a forced transfer to a
- /// treasury wallet (which consumes the locks) followed by a burn from
- /// that treasury.
- ///
- /// # Security Warning
- ///
- /// **IMPORTANT**: This function bypasses authorization checks and should
- /// only be used internally or in admin functions that implement their own
- /// authorization logic.
- pub fn burn(e: &Env, user_address: &Address, amount: i128) {
- // Snapshot before any unfreeze/balance mutation so the hook observes
- // the pre-burn state.
- let from_snapshot = Self::account_snapshot(e, user_address);
-
- if amount > from_snapshot.balance {
- panic_with_error!(e, RWAError::InsufficientBalance);
- }
-
- // Check if we need to unfreeze tokens to complete the burn. The
- // snapshot is still current: nothing has mutated the balance or
- // frozen amount since it was captured.
- let free_tokens = from_snapshot.balance - from_snapshot.frozen;
- if free_tokens < amount {
- let tokens_to_unfreeze = amount - free_tokens;
- let new_frozen = from_snapshot.frozen - tokens_to_unfreeze;
-
- e.storage()
- .persistent()
- .set(&RWAStorageKey::FrozenTokens(user_address.clone()), &new_frozen);
- emit_tokens_unfrozen(e, user_address, tokens_to_unfreeze);
- }
-
- Base::update(e, Some(user_address), None, amount);
- decrease_total_supply(e, amount);
-
- let compliance_addr = Self::compliance(e);
- let compliance_client = ComplianceClient::new(e, &compliance_addr);
- compliance_client.destroyed(&from_snapshot, &amount, &e.current_contract_address());
-
- emit_burn(e, user_address, amount);
- }
-
- /// Recovery function used to force transfer tokens from a old account to a
- /// new account. This function transfers all tokens and preserves the frozen
- /// status from the old account to the new account. Returns `true` if
- /// tokens were moved, `false` if the old account held no tokens.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `old_account` - The address of the wallet that lost access.
- /// * `new_account` - The address of the new account to receive the tokens.
- ///
- /// # Errors
- ///
- /// * [`RWAError::IdentityVerificationFailed`] - When the identity of the
- /// new account cannot be verified.
- /// * [`RWAError::IdentityMismatch`] - When the new account is not the
- /// target of the recovery process for the old wallet.
- ///
- /// # Events
- ///
- /// Emitted when the old account is fully frozen:
- /// * topics - `["address_frozen", new_account: Address, is_frozen: bool]`
- /// * data - `[]`
- ///
- /// Emitted only when tokens are moved:
- /// * topics - `["transfer", old_account: Address, new_account: Address]`
- /// * data - `[amount: i128]`
- /// * topics - `["recovery_success", old_account: Address, new_account:
- /// Address]`
- /// * data - `[]`
- ///
- /// Emitted only when tokens are moved and the old account has partially
- /// frozen tokens, which are unfrozen there before the transfer and frozen
- /// again on the new account after it:
- /// * topics - `["tokens_unfrozen", old_account: Address]`
- /// * data - `[amount: i128]`
- /// * topics - `["tokens_frozen", new_account: Address]`
- /// * data - `[amount: i128]`
- ///
- /// # Notes
- ///
- /// This function preserves the frozen status (both partial and full) from
- /// the old account and applies it to the new account, maintaining
- /// regulatory compliance. The full-address freeze is a wallet-level
- /// restriction, so it follows the investor to the new account even when the
- /// old account holds no tokens. The partially frozen amount is bounded by
- /// the balance, so there is nothing to migrate in that case.
- ///
- ///
- /// State owned by this contract (the frozen status) is migrated directly
- /// by this function. State held by compliance modules is handled through
- /// the transfer hook: the recovery is reported to the compliance contract
- /// as a transfer of kind [`TransferKind::Recovery`], and each module
- /// decides how to treat it. Policy modules exempt it like a forced
- /// transfer, while bookkeeping modules migrate wallet-bound state
- /// instead of consuming it (e.g. the initial lockup period module moves
- /// the old account's lock entries to the new account).
- ///
- /// This function does not concern itself with the Identity Management.
- /// If the old account's identity should be removed, it should be done on
- /// the Identity Stack.
- ///
- /// Allowances are not migrated or revoked: allowances the old account
- /// granted stay in place (with an empty balance there is nothing to spend
- /// until the old account receives tokens again), and allowances granted to
- /// the old account as a spender remain usable by whoever controls it.
- ///
- /// # Security Warning
- ///
- /// **IMPORTANT**: This function bypasses authorization and compliance
- /// checks. Should only be used by authorized recovery or admin
- /// functions.
- pub fn recover_balance(e: &Env, old_account: &Address, new_account: &Address) -> bool {
- // Verify identity for the new account.
- let identity_verifier_addr = Self::identity_verifier(e);
- let identity_verifier_client = IdentityVerifierClient::new(e, &identity_verifier_addr);
- identity_verifier_client.verify_identity(new_account);
-
- // Verify that the new account is the recovery target for the old
- // account
- let recovery_target = identity_verifier_client
- .recovery_target(old_account)
- .unwrap_or_else(|| panic_with_error!(e, RWAError::IdentityMismatch));
-
- if recovery_target != *new_account {
- panic_with_error!(e, RWAError::IdentityMismatch);
- }
-
- if Self::is_frozen(e, old_account) {
- Self::set_address_frozen(e, new_account, true);
- }
-
- // Get the balance of the old account, if there is nothing to transfer,
- // return false
- let lost_balance = Base::balance(e, old_account);
- if lost_balance == 0 {
- return false;
- }
-
- // Store the partially frozen amount before the transfer unfreezes it
- let frozen_tokens = Self::get_frozen_tokens(e, old_account);
-
- // Move all tokens with the shared privileged mechanics (handles
- // unfreezing as needed), reporting the movement as a recovery so
- // compliance modules migrate wallet-bound state instead of
- // consuming it.
- Self::privileged_transfer(
- e,
- old_account,
- new_account,
- lost_balance,
- &TransferKind::Recovery,
- );
-
- // Preserve frozen tokens on the new account if there were any
- if frozen_tokens > 0 {
- Self::freeze_partial_tokens(e, new_account, frozen_tokens);
- }
-
- emit_recovery_success(e, old_account, new_account);
-
- true
- }
-
- /// Sets the frozen status for an address. A frozen wallet cannot send
- /// tokens from its own balance or receive tokens. The freeze applies to
- /// the wallet's own balance only: a frozen wallet can still act as the
- /// spender in [`Self::transfer_from`], moving tokens out of another
- /// (unfrozen) account within the allowance it was granted.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `user_address` - The address to freeze or unfreeze.
- /// * `freeze` - `true` to freeze the address, `false` to unfreeze.
- ///
- /// # Events
- ///
- /// * topics - `["address_frozen", user_address: Address, is_frozen: bool]`
- /// * data - `[]`
- ///
- /// # Security Warning
- ///
- /// **IMPORTANT**: This function bypasses authorization checks and should
- /// only be used internally or in admin functions that implement their own
- /// authorization logic.
- pub fn set_address_frozen(e: &Env, user_address: &Address, freeze: bool) {
- e.storage().persistent().set(&RWAStorageKey::AddressFrozen(user_address.clone()), &freeze);
-
- emit_address_frozen(e, user_address, freeze);
- }
-
- /// Freezes a specified amount of tokens for a given address.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `user_address` - The address for which to freeze tokens.
- /// * `amount` - The amount of tokens to freeze.
- ///
- /// # Errors
- ///
- /// * [`RWAError::LessThanZero`] - When `amount < 0`.
- /// * [`RWAError::InsufficientBalance`] - When trying to freeze more tokens
- /// than available.
- ///
- /// # Events
- ///
- /// * topics - `["tokens_frozen", user_address: Address]`
- /// * data - `[amount: i128]`
- ///
- /// # Security Warning
- ///
- /// **IMPORTANT**: This function bypasses authorization checks and should
- /// only be used internally or in admin functions that implement their own
- /// authorization logic.
- pub fn freeze_partial_tokens(e: &Env, user_address: &Address, amount: i128) {
- if amount < 0 {
- panic_with_error!(e, RWAError::LessThanZero);
- }
-
- let current_balance = Base::balance(e, user_address);
- let current_frozen = Self::get_frozen_tokens(e, user_address);
- let new_frozen = current_frozen + amount;
-
- if new_frozen > current_balance {
- panic_with_error!(e, RWAError::InsufficientBalance);
- }
-
- e.storage()
- .persistent()
- .set(&RWAStorageKey::FrozenTokens(user_address.clone()), &new_frozen);
- emit_tokens_frozen(e, user_address, amount);
- }
-
- /// Unfreezes a specified amount of tokens for a given address.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `user_address` - The address for which to unfreeze tokens.
- /// * `amount` - The amount of tokens to unfreeze.
- ///
- /// # Errors
- ///
- /// * [`RWAError::LessThanZero`] - When `amount < 0`.
- /// * [`RWAError::InsufficientFreeTokens`] - When trying to unfreeze more
- /// tokens than are frozen.
- ///
- /// # Events
- ///
- /// * topics - `["tokens_unfrozen", user_address: Address]`
- /// * data - `[amount: i128]`
- ///
- /// # Security Warning
- ///
- /// **IMPORTANT**: This function bypasses authorization checks and should
- /// only be used internally or in admin functions that implement their own
- /// authorization logic.
- pub fn unfreeze_partial_tokens(e: &Env, user_address: &Address, amount: i128) {
- if amount < 0 {
- panic_with_error!(e, RWAError::LessThanZero);
- }
-
- let current_frozen = Self::get_frozen_tokens(e, user_address);
- if current_frozen < amount {
- panic_with_error!(e, RWAError::InsufficientFreeTokens);
- }
-
- let new_frozen = current_frozen - amount;
- e.storage()
- .persistent()
- .set(&RWAStorageKey::FrozenTokens(user_address.clone()), &new_frozen);
- emit_tokens_unfrozen(e, user_address, amount);
- }
-
- /// Sets the onchain ID of the token.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `onchain_id` - The new onchain ID address for the token.
- ///
- /// # Events
- ///
- /// * topics - `["token_onchain_id_updated", onchain_id: Address]`
- /// * data - `[]`
- ///
- /// # Security Warning
- ///
- /// **IMPORTANT**: This function bypasses authorization checks and should
- /// only be used internally or in admin functions that implement their own
- /// authorization logic.
- pub fn set_onchain_id(e: &Env, onchain_id: &Address) {
- e.storage().instance().set(&RWAStorageKey::OnchainId, onchain_id);
-
- emit_token_onchain_id_updated(e, onchain_id);
- }
-
- /// Sets the compliance contract of the token.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `compliance` - The address of the compliance contract.
- ///
- /// # Events
- ///
- /// * topics - `["compliance_set", compliance: Address]`
- /// * data - `[]`
- ///
- /// # Security Warning
- ///
- /// **IMPORTANT**: This function bypasses authorization checks and should
- /// only be used internally or in admin functions that implement their own
- /// authorization logic.
- pub fn set_compliance(e: &Env, compliance: &Address) {
- e.storage().instance().set(&RWAStorageKey::Compliance, compliance);
- emit_compliance_set(e, compliance);
- }
-
- /// Sets the identity verifier contract of the token.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `identity_verifier` - The address of the identity verifier contract.
- ///
- /// # Events
- ///
- /// * topics - `["identity_verifier_set", identity_verifier: Address]`
- /// * data - `[]`
- ///
- /// # Security Warning
- ///
- /// **IMPORTANT**: This function bypasses authorization checks and should
- /// only be used internally or in admin functions that implement their own
- /// authorization logic.
- pub fn set_identity_verifier(e: &Env, identity_verifier: &Address) {
- e.storage().instance().set(&RWAStorageKey::IdentityVerifier, identity_verifier);
- emit_identity_verifier_set(e, identity_verifier);
- }
-
- // ################## OVERRIDDEN FUNCTIONS ##################
-
- /// `transfer` override with added compliance and identity verification
- /// checks.
- ///
- /// This is ultimately a wrapper around [`Base::update()`] to enable
- /// the compatibility across [`crate::fungible::FungibleToken`]
- /// with [`crate::rwa::RWAToken`]
- ///
- /// The main differences are:
- /// - checks for if the contract is paused
- /// - checks for if the addresses are frozen
- /// - checks for if the from address have enough free tokens (unfrozen
- /// tokens)
- /// - enforces identity verification for both addresses
- /// - enforces compliance rules for the transfer
- /// - triggers `transferred` hook call from the compliance contract
- ///
- /// Please refer to [`Base::update`] and [`Self::validate_transfer`] for the
- /// inline documentation.
- ///
- /// # Events
- ///
- /// * topics - `["transfer", from: Address, to: Address]`
- /// * data - `amount: i128` (when `to` has no muxed ID)
- /// * data - `{to_muxed_id: Option, amount: i128}` (when `to` has a
- /// muxed ID)
- ///
- /// # Notes
- ///
- /// A muxed `to` is narrowed to its base address before any check runs, so
- /// identity verification, the compliance hook and the balance update all
- /// operate on the base address. The muxed ID reaches the event only, where
- /// it lets a custodian attribute the transfer to one of its off-chain
- /// sub-accounts.
- ///
- /// The ID is supplied by the caller and carries no on-chain verification.
- /// A verified holder may therefore be a custodian holding on behalf of
- /// beneficiaries who have no on-chain identity of their own.
- ///
- /// Whether to accept custodial destinations is the implementor's decision,
- /// not the library's. An issuer that requires beneficial owners on the
- /// on-chain register can override
- /// [`crate::fungible::FungibleToken::transfer`] and reject a destination
- /// whose `id()` is `Some`; `rwa-token-example` carries that pattern as a
- /// showcase.
- pub fn transfer(e: &Env, from: &Address, to: &MuxedAddress, amount: i128) {
- from.require_auth();
-
- let to_address = to.address();
-
- // Snapshot before the update so the hook observes the pre-transfer
- // state.
- let from_snapshot = Self::account_snapshot(e, from);
- let to_snapshot = Self::account_snapshot(e, &to_address);
-
- Self::validate_transfer(
- e,
- &from_snapshot,
- &to_snapshot,
- amount,
- FromVerification::Required,
- );
-
- Base::update(e, Some(from), Some(&to_address), amount);
-
- let compliance_client = ComplianceClient::new(e, &Self::compliance(e));
- compliance_client.transferred(
- &from_snapshot,
- &to_snapshot,
- &amount,
- &TransferKind::Standard,
- &e.current_contract_address(),
- );
- emit_transfer(e, from, &to_address, to.id(), amount);
- }
-
- /// `transfer_from` override with added compliance and identity verification
- /// checks.
- ///
- /// This is ultimately a wrapper around [`Base::update()`] to enable
- /// the compatibility across [`crate::fungible::FungibleToken`]
- /// with [`crate::rwa::RWAToken`]
- ///
- /// The main differences are:
- /// - checks for if the contract is paused
- /// - checks for if the addresses are frozen
- /// - checks for if the from address have enough free tokens (unfrozen
- /// tokens)
- /// - enforces identity verification for both addresses
- /// - enforces compliance rules for the transfer
- /// - triggers `transferred` hook call from the compliance contract
- ///
- /// Please refer to [`Base::update`] and [`Self::validate_transfer`] for the
- /// inline documentation.
- pub fn transfer_from(e: &Env, spender: &Address, from: &Address, to: &Address, amount: i128) {
- spender.require_auth();
-
- // Snapshot before the update so the hook observes the pre-transfer
- // state. `spender` is the delegate moving the holder's tokens.
- let from_snapshot = Self::account_snapshot(e, from);
- let to_snapshot = Self::account_snapshot(e, to);
-
- Self::validate_transfer(
- e,
- &from_snapshot,
- &to_snapshot,
- amount,
- FromVerification::Required,
- );
-
- Base::spend_allowance(e, from, spender, amount);
-
- Base::update(e, Some(from), Some(to), amount);
-
- let compliance_client = ComplianceClient::new(e, &Self::compliance(e));
- compliance_client.transferred(
- &from_snapshot,
- &to_snapshot,
- &amount,
- &TransferKind::Delegated(spender.clone()),
- &e.current_contract_address(),
- );
- emit_transfer(e, from, to, None, amount);
- }
-
- // ################## BATCH FUNCTIONS ##################
-
- /// Transfers to several recipients in one invocation, moving
- /// `amounts[i]` from `from` to `recipients[i]`. The sender authorizes the
- /// whole batch once.
- ///
- /// The batch is atomic: an item that fails reverts every item before it.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `from` - The address holding the tokens.
- /// * `recipients` - The addresses receiving the tokens.
- /// * `amounts` - The amount to transfer to each recipient.
- ///
- /// # Errors
- ///
- /// * [`RWAError::BatchSizeMismatch`] - When `recipients` and `amounts` have
- /// different lengths.
- /// * refer to [`Self::validate_transfer`] errors, which every item runs.
- /// * refer to [`Base::update`] errors.
- ///
- /// # Events
- ///
- /// For each recipient:
- /// * topics - `["transfer", from: Address, to: Address]`
- /// * data - `[amount: i128]`
- ///
- /// # Notes
- ///
- /// Recipients are processed in order, and the sender's free balance is
- /// re-evaluated for every item, so a batch that overspends fails on the
- /// item that runs out rather than up front.
- ///
- /// The sender's identity is verified once for the whole batch rather than
- /// once per recipient, which is where the saving over repeated single
- /// transfers comes from. This assumes nothing invalidates the sender
- /// part-way through: the compliance modules that run between items hold no
- /// authority over the identity stack, and re-entering this token is
- /// impossible in Soroban, so no module in reach can revoke the sender it
- /// was just handed. A module granted write access to the identity stack
- /// would break that assumption.
- ///
- /// The paused state is checked once up front so that a paused token is
- /// rejected before any identity work and regardless of batch size, and
- /// again per item along with both wallets' freezing status, since those
- /// reads are cheap enough that skipping them would buy nothing.
- ///
- /// The caller is responsible for sizing the batch to fit the
- /// per-transaction network limits. Refer to the batch operations section of
- /// the [module documentation](crate::rwa) for measured figures.
- pub fn batch_transfer(e: &Env, from: &Address, recipients: &Vec, amounts: &Vec) {
- from.require_auth();
-
- Self::require_equal_lengths(e, recipients.len(), amounts.len());
-
- if paused(e) {
- panic_with_error!(e, PausableError::EnforcedPause);
- }
-
- // Every item shares one `from`, so the walk through the identity stack
- // that verifies it runs once for the batch instead of once per
- // recipient. Every other check stays inside the loop.
- IdentityVerifierClient::new(e, &Self::identity_verifier(e)).verify_identity(from);
-
- let compliance_client = ComplianceClient::new(e, &Self::compliance(e));
- let token = e.current_contract_address();
-
- for (to, amount) in recipients.iter().zip(amounts.iter()) {
- // Snapshot before the update so the hook observes the pre-transfer
- // state. The sender's balance shrinks as the batch progresses, so
- // this is re-read for every item.
- let from_snapshot = Self::account_snapshot(e, from);
- let to_snapshot = Self::account_snapshot(e, &to);
-
- Self::validate_transfer(
- e,
- &from_snapshot,
- &to_snapshot,
- amount,
- FromVerification::HoistedByCaller,
- );
-
- Base::update(e, Some(from), Some(&to), amount);
-
- compliance_client.transferred(
- &from_snapshot,
- &to_snapshot,
- &amount,
- &TransferKind::Standard,
- &token,
- );
- emit_transfer(e, from, &to, None, amount);
- }
- }
-
- /// Forces a transfer for each `(from_list[i], to_list[i], amounts[i])`
- /// triple in one invocation.
- ///
- /// The batch is atomic: an item that fails reverts every item before it.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `from_list` - The addresses holding the tokens.
- /// * `to_list` - The addresses receiving the tokens.
- /// * `amounts` - The amount to move for each pair.
- ///
- /// # Errors
- ///
- /// * [`RWAError::BatchSizeMismatch`] - When the three arrays do not all
- /// have the same length.
- /// * refer to [`Self::forced_transfer`] errors.
- ///
- /// # Events
- ///
- /// For each triple:
- /// * topics - `["transfer", from: Address, to: Address]`
- /// * data - `[amount: i128]`
- ///
- /// For each triple whose `amount` exceeds the free (unfrozen) balance of
- /// `from`, emitted before its transfer:
- /// * topics - `["tokens_unfrozen", from: Address]`
- /// * data - `[amount: i128]`
- ///
- /// # Notes
- ///
- /// The caller is responsible for sizing the batch to fit the
- /// per-transaction network limits. Refer to the batch operations section of
- /// the [module documentation](crate::rwa).
- ///
- /// # Security Warning
- ///
- /// **IMPORTANT**: This function bypasses authorization and freezing checks,
- /// exactly like [`Self::forced_transfer`]. Refer to its security warning.
- pub fn batch_forced_transfer(
- e: &Env,
- from_list: &Vec,
- to_list: &Vec,
- amounts: &Vec,
- ) {
- Self::require_equal_lengths(e, from_list.len(), to_list.len());
- Self::require_equal_lengths(e, to_list.len(), amounts.len());
-
- for ((from, to), amount) in from_list.iter().zip(to_list.iter()).zip(amounts.iter()) {
- Self::forced_transfer(e, &from, &to, amount);
- }
- }
-
- /// Mints `amounts[i]` tokens to `to_list[i]` in one invocation.
- ///
- /// The batch is atomic: an item that fails reverts every item before it.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `to_list` - The addresses receiving the new tokens.
- /// * `amounts` - The amount to mint to each address.
- ///
- /// # Errors
- ///
- /// * [`RWAError::BatchSizeMismatch`] - When `to_list` and `amounts` have
- /// different lengths.
- /// * refer to [`Self::mint`] errors.
- ///
- /// # Events
- ///
- /// For each address:
- /// * topics - `["mint", to: Address]`
- /// * data - `[amount: i128]`
- ///
- /// # Notes
- ///
- /// The caller is responsible for sizing the batch to fit the
- /// per-transaction network limits. Refer to the batch operations section of
- /// the [module documentation](crate::rwa).
- ///
- /// # Security Warning
- ///
- /// ⚠️ SECURITY RISK: This function has NO AUTHORIZATION CONTROLS ⚠️
- ///
- /// Refer to the security warning on [`Self::mint`].
- pub fn batch_mint(e: &Env, to_list: &Vec, amounts: &Vec) {
- Self::require_equal_lengths(e, to_list.len(), amounts.len());
-
- for (to, amount) in to_list.iter().zip(amounts.iter()) {
- Self::mint(e, &to, amount);
- }
- }
-
- /// Burns `amounts[i]` tokens from `user_addresses[i]` in one invocation.
- ///
- /// The batch is atomic: an item that fails reverts every item before it.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `user_addresses` - The addresses to burn tokens from.
- /// * `amounts` - The amount to burn from each address.
- ///
- /// # Errors
- ///
- /// * [`RWAError::BatchSizeMismatch`] - When `user_addresses` and `amounts`
- /// have different lengths.
- /// * refer to [`Self::burn`] errors.
- ///
- /// # Events
- ///
- /// For each address:
- /// * topics - `["burn", user_address: Address]`
- /// * data - `[amount: i128]`
- ///
- /// For each address whose `amount` exceeds its free (unfrozen) balance,
- /// emitted before its burn:
- /// * topics - `["tokens_unfrozen", user_address: Address]`
- /// * data - `[amount: i128]`
- ///
- /// # Notes
- ///
- /// The caller is responsible for sizing the batch to fit the
- /// per-transaction network limits. Refer to the batch operations section of
- /// the [module documentation](crate::rwa).
- ///
- /// # Security Warning
- ///
- /// **IMPORTANT**: This function bypasses authorization checks, exactly like
- /// [`Self::burn`]. Refer to its security warning.
- pub fn batch_burn(e: &Env, user_addresses: &Vec, amounts: &Vec) {
- Self::require_equal_lengths(e, user_addresses.len(), amounts.len());
-
- for (user_address, amount) in user_addresses.iter().zip(amounts.iter()) {
- Self::burn(e, &user_address, amount);
- }
- }
-
- /// Sets the frozen status of `user_addresses[i]` to `freeze_list[i]` in one
- /// invocation.
- ///
- /// The batch is atomic: an item that fails reverts every item before it.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `user_addresses` - The addresses to freeze or unfreeze.
- /// * `freeze_list` - The frozen status to apply to each address.
- ///
- /// # Errors
- ///
- /// * [`RWAError::BatchSizeMismatch`] - When `user_addresses` and
- /// `freeze_list` have different lengths.
- ///
- /// # Events
- ///
- /// For each address:
- /// * topics - `["address_frozen", user_address: Address, is_frozen: bool]`
- /// * data - `[]`
- ///
- /// # Notes
- ///
- /// An address repeated in `user_addresses` ends up with the status of its
- /// last occurrence, and each occurrence emits its own event.
- ///
- /// The caller is responsible for sizing the batch to fit the
- /// per-transaction network limits. Refer to the batch operations section of
- /// the [module documentation](crate::rwa).
- ///
- /// # Security Warning
- ///
- /// **IMPORTANT**: This function bypasses authorization checks, exactly like
- /// [`Self::set_address_frozen`]. Refer to its security warning.
- pub fn batch_set_address_frozen(
- e: &Env,
- user_addresses: &Vec,
- freeze_list: &Vec,
- ) {
- Self::require_equal_lengths(e, user_addresses.len(), freeze_list.len());
-
- for (user_address, freeze) in user_addresses.iter().zip(freeze_list.iter()) {
- Self::set_address_frozen(e, &user_address, freeze);
- }
- }
-
- /// Freezes `amounts[i]` tokens on `user_addresses[i]` in one invocation.
- ///
- /// The batch is atomic: an item that fails reverts every item before it.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `user_addresses` - The addresses for which to freeze tokens.
- /// * `amounts` - The amount to freeze on each address.
- ///
- /// # Errors
- ///
- /// * [`RWAError::BatchSizeMismatch`] - When `user_addresses` and `amounts`
- /// have different lengths.
- /// * refer to [`Self::freeze_partial_tokens`] errors.
- ///
- /// # Events
- ///
- /// For each address:
- /// * topics - `["tokens_frozen", user_address: Address]`
- /// * data - `[amount: i128]`
- ///
- /// # Notes
- ///
- /// Amounts accumulate, so an address repeated in `user_addresses` has every
- /// occurrence added to its frozen balance.
- ///
- /// The caller is responsible for sizing the batch to fit the
- /// per-transaction network limits. Refer to the batch operations section of
- /// the [module documentation](crate::rwa).
- ///
- /// # Security Warning
- ///
- /// **IMPORTANT**: This function bypasses authorization checks, exactly like
- /// [`Self::freeze_partial_tokens`]. Refer to its security warning.
- pub fn batch_freeze_partial_tokens(
- e: &Env,
- user_addresses: &Vec,
- amounts: &Vec,
- ) {
- Self::require_equal_lengths(e, user_addresses.len(), amounts.len());
-
- for (user_address, amount) in user_addresses.iter().zip(amounts.iter()) {
- Self::freeze_partial_tokens(e, &user_address, amount);
- }
- }
-
- /// Unfreezes `amounts[i]` tokens on `user_addresses[i]` in one invocation.
- ///
- /// The batch is atomic: an item that fails reverts every item before it.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `user_addresses` - The addresses for which to unfreeze tokens.
- /// * `amounts` - The amount to unfreeze on each address.
- ///
- /// # Errors
- ///
- /// * [`RWAError::BatchSizeMismatch`] - When `user_addresses` and `amounts`
- /// have different lengths.
- /// * refer to [`Self::unfreeze_partial_tokens`] errors.
- ///
- /// # Events
- ///
- /// For each address:
- /// * topics - `["tokens_unfrozen", user_address: Address]`
- /// * data - `[amount: i128]`
- ///
- /// # Notes
- ///
- /// Amounts accumulate, so an address repeated in `user_addresses` has every
- /// occurrence subtracted from its frozen balance.
- ///
- /// The caller is responsible for sizing the batch to fit the
- /// per-transaction network limits. Refer to the batch operations section of
- /// the [module documentation](crate::rwa).
- ///
- /// # Security Warning
- ///
- /// **IMPORTANT**: This function bypasses authorization checks, exactly like
- /// [`Self::unfreeze_partial_tokens`]. Refer to its security warning.
- pub fn batch_unfreeze_partial_tokens(
- e: &Env,
- user_addresses: &Vec,
- amounts: &Vec,
- ) {
- Self::require_equal_lengths(e, user_addresses.len(), amounts.len());
-
- for (user_address, amount) in user_addresses.iter().zip(amounts.iter()) {
- Self::unfreeze_partial_tokens(e, &user_address, amount);
- }
- }
-
- // ################## LOW-LEVEL HELPERS ##################
-
- /// Performs every check a transfer requires, without requiring
- /// authorization or moving anything: paused state, the freezing status of
- /// both wallets, the sender's free balance, and the identity of both
- /// parties. Shared by [`Self::transfer`], [`Self::transfer_from`] and every
- /// item of [`Self::batch_transfer`], so no entry point can drift away from
- /// the others on what it checks.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `from` - Snapshot of the sender, as of before the transfer.
- /// * `to` - Snapshot of the receiver, as of before the transfer.
- /// * `amount` - The amount of tokens to transfer.
- /// * `from_verification` - Who verifies the identity of `from`, refer to
- /// [`FromVerification`].
- ///
- /// # Errors
- ///
- /// * [`PausableError::EnforcedPause`] - If the contract is paused.
- /// * [`RWAError::AddressFrozen`] - If either the sender or receiver is
- /// frozen.
- /// * [`RWAError::InsufficientFreeTokens`] - If the sender does not have
- /// enough free tokens.
- /// * refer to [`Self::identity_verifier`] errors.
- /// * refer to [`IdentityVerifierClient::verify_identity`] errors.
- ///
- /// # Security Warning
- ///
- /// **IMPORTANT**: Passing [`FromVerification::HoistedByCaller`] skips the
- /// sender's identity check, and is only sound when the caller has verified
- /// that same sender earlier in the invocation (i.e. batched operations).
- pub fn validate_transfer(
- e: &Env,
- from: &AccountSnapshot,
- to: &AccountSnapshot,
- amount: i128,
- from_verification: FromVerification,
- ) {
- // Check if contract is paused
- if paused(e) {
- panic_with_error!(e, PausableError::EnforcedPause);
- }
-
- // Check if addresses are frozen
- if Self::is_frozen(e, &from.address) || Self::is_frozen(e, &to.address) {
- panic_with_error!(e, RWAError::AddressFrozen);
- }
-
- // Check if there are enough free tokens (not frozen)
- if from.balance - from.frozen < amount {
- panic_with_error!(e, RWAError::InsufficientFreeTokens);
- }
-
- let identity_verifier_addr = Self::identity_verifier(e);
- let identity_verifier_client = IdentityVerifierClient::new(e, &identity_verifier_addr);
- if matches!(from_verification, FromVerification::Required) {
- identity_verifier_client.verify_identity(&from.address);
- }
- identity_verifier_client.verify_identity(&to.address);
- }
-
- /// Shared mechanics behind [`Self::forced_transfer`] and
- /// [`Self::recover_balance`]: moves `amount` from `from` to `to`,
- /// bypassing paused state and freezing restrictions (unfreezing as
- /// needed), and reports the movement to the compliance contract as
- /// `kind`. The two public entry points differ only in the kind they
- /// report, which is what lets compliance modules tell a seizure from a
- /// wallet migration.
- ///
- /// Performs no identity verification; each caller verifies the recipient
- /// before delegating here, so the check runs exactly once per invocation.
- fn privileged_transfer(
- e: &Env,
- from: &Address,
- to: &Address,
- amount: i128,
- kind: &TransferKind,
- ) {
- // Snapshot before any unfreeze/balance mutation so the hook observes
- // the pre-operation state. A privileged transfer has no spender: it is
- // an administrative pull, not a delegated move.
- let from_snapshot = Self::account_snapshot(e, from);
- let to_snapshot = Self::account_snapshot(e, to);
-
- if from_snapshot.balance < amount {
- panic_with_error!(e, RWAError::InsufficientBalance);
- }
-
- // Check if we need to unfreeze tokens to complete the transfer. The
- // snapshot is still current: nothing has mutated the balance or
- // frozen amount since it was captured.
- let free_tokens = from_snapshot.balance - from_snapshot.frozen;
- if free_tokens < amount {
- let tokens_to_unfreeze = amount - free_tokens;
- let new_frozen = from_snapshot.frozen - tokens_to_unfreeze;
-
- e.storage().persistent().set(&RWAStorageKey::FrozenTokens(from.clone()), &new_frozen);
- emit_tokens_unfrozen(e, from, tokens_to_unfreeze);
- }
-
- Base::update(e, Some(from), Some(to), amount);
-
- let compliance_addr = Self::compliance(e);
- let compliance_client = ComplianceClient::new(e, &compliance_addr);
- compliance_client.transferred(
- &from_snapshot,
- &to_snapshot,
- &amount,
- kind,
- &e.current_contract_address(),
- );
-
- emit_transfer(e, from, to, None, amount);
- }
-
- /// Rejects a batch whose parallel arrays do not line up.
- ///
- /// # Arguments
- ///
- /// * `e` - Access to the Soroban environment.
- /// * `left` - Length of the first array.
- /// * `right` - Length of the array that must match it.
- ///
- /// # Errors
- ///
- /// * [`RWAError::BatchSizeMismatch`] - When the two lengths differ.
- fn require_equal_lengths(e: &Env, left: u32, right: u32) {
- if left != right {
- panic_with_error!(e, RWAError::BatchSizeMismatch);
- }
- }
-}
+use soroban_sdk::{contracttype, panic_with_error, Address, Env, MuxedAddress, String, Vec};
+use stellar_contract_utils::pausable::{paused, PausableError};
+
+use crate::{
+ fungible::{
+ capped::{check_cap, CappedContractType},
+ emit_transfer,
+ total_supply::{
+ decrease_total_supply, increase_total_supply, total_supply, TotalSupplyOverrides,
+ },
+ Base, ContractOverrides, MintOverrides,
+ },
+ rwa::{
+ compliance::{AccountSnapshot, ComplianceClient, TransferKind},
+ emit_address_frozen, emit_burn, emit_compliance_set, emit_identity_verifier_set, emit_mint,
+ emit_recovery_success, emit_token_onchain_id_updated, emit_tokens_frozen,
+ emit_tokens_unfrozen, IdentityVerifierClient, RWAError, FROZEN_EXTEND_AMOUNT,
+ FROZEN_TTL_THRESHOLD,
+ },
+};
+
+/// Storage keys for the data associated with `RWA` token
+#[contracttype]
+pub enum RWAStorageKey {
+ /// Frozen status of an address (true = frozen, false = not frozen)
+ AddressFrozen(Address),
+ /// Amount of tokens frozen for a specific address
+ FrozenTokens(Address),
+ /// Compliance contract address
+ Compliance,
+ /// OnchainID contract address
+ OnchainId,
+ /// Version of the token
+ Version,
+ /// Identity Verifier contract address
+ IdentityVerifier,
+}
+
+pub struct RWA;
+
+/// Who verifies the identity of `from` in [`RWA::validate_transfer`].
+///
+/// Every item of a batch transfer shares one `from`, so verifying it per item
+/// repeats the same walk through the identity stack. This lets the batch lift
+/// that one check out of its loop while every other check still runs per item,
+/// and while all three transfer entry points keep sharing one validator.
+pub enum FromVerification {
+ /// Verify `from` as part of this call. What the single-transfer entry
+ /// points pass.
+ Required,
+ /// Skip the check on `from`, because the caller has already verified it
+ /// once for a batch of transfers that all share it.
+ HoistedByCaller,
+}
+
+impl ContractOverrides for RWA {
+ fn transfer(e: &Env, from: &Address, to: &MuxedAddress, amount: i128) {
+ RWA::transfer(e, from, to, amount);
+ }
+
+ fn transfer_from(e: &Env, spender: &Address, from: &Address, to: &Address, amount: i128) {
+ RWA::transfer_from(e, spender, from, to, amount);
+ }
+}
+
+// T-REX (ERC-3643) tokens expose the total supply, so the RWA contract type
+// is inherently supply-aware.
+impl TotalSupplyOverrides for RWA {}
+
+// Minting through the contract type runs RWA's full mint path: identity
+// verification, freezing checks, supply accounting and the compliance hook.
+impl MintOverrides for RWA {
+ fn mint(e: &Env, to: &Address, amount: i128) {
+ RWA::mint(e, to, amount);
+ }
+}
+
+/// Marker for the contract types backing [`crate::rwa::RWAToken`]: [`RWA`]
+/// itself and [`CappedRWA`].
+///
+/// # Why this trait exists
+///
+/// `RWAToken`'s default bodies call the [`RWA`] functions directly (batch
+/// transfers, forced transfers, freezing, recovery, ...), so the trait used to
+/// require `ContractType = RWA` exactly. A capped RWA token needs a different
+/// contract type, [`CappedRWA`], whose mint also checks the cap, while every
+/// other RWA operation stays the same. This marker lets `RWAToken` accept both
+/// contract types, and nothing else.
+///
+/// Contract authors never interact with this trait; it only appears as that
+/// bound.
+#[diagnostic::on_unimplemented(
+ message = "`{Self}` is not an RWA contract type, so `RWAToken` cannot be implemented",
+ note = "list `RWA` in the `Compose` list, e.g. `Compose<(RWA, TotalSupply)>`"
+)]
+pub trait RWAContractType {}
+
+impl RWAContractType for RWA {}
+
+/// Contract type enforcing a maximum total supply on top of [`RWA`], resolved
+/// by `Compose<(RWA, Capped, TotalSupply)>`.
+///
+/// Its `mint` and `batch_mint` check the cap (refer to
+/// [`crate::fungible::capped::Capped`]) before running RWA's own mint path.
+/// Every other operation is RWA's, unchanged.
+pub struct CappedRWA;
+
+impl RWAContractType for CappedRWA {}
+impl CappedContractType for CappedRWA {}
+
+// RWA tracks the supply itself, and the capped variant reads the same counter.
+impl TotalSupplyOverrides for CappedRWA {}
+
+// Transfers keep RWA's identity, freezing and compliance checks.
+impl ContractOverrides for CappedRWA {
+ fn transfer(e: &Env, from: &Address, to: &MuxedAddress, amount: i128) {
+ RWA::transfer(e, from, to, amount);
+ }
+
+ fn transfer_from(e: &Env, spender: &Address, from: &Address, to: &Address, amount: i128) {
+ RWA::transfer_from(e, spender, from, to, amount);
+ }
+}
+
+impl MintOverrides for CappedRWA {
+ fn mint(e: &Env, to: &Address, amount: i128) {
+ CappedRWA::mint(e, to, amount);
+ }
+}
+
+impl CappedRWA {
+ /// Mints `amount` tokens to `to` if the cap allows it.
+ ///
+ /// # Errors
+ ///
+ /// * refer to [`check_cap`] errors.
+ /// * refer to [`RWA::mint`] errors.
+ ///
+ /// # Security Warning
+ ///
+ /// ⚠️ SECURITY RISK: This function has NO AUTHORIZATION CONTROLS ⚠️
+ ///
+ /// Refer to the security warning on [`RWA::mint`].
+ pub fn mint(e: &Env, to: &Address, amount: i128) {
+ check_cap(e, amount, total_supply(e));
+ RWA::mint(e, to, amount);
+ }
+
+ /// Mints `amounts[i]` tokens to `to_list[i]`, checking the cap for every
+ /// item.
+ ///
+ /// # Errors
+ ///
+ /// * refer to [`RWA::mint`] errors.
+ /// * refer to [`check_cap`] errors.
+ ///
+ /// # Security Warning
+ ///
+ /// ⚠️ SECURITY RISK: This function has NO AUTHORIZATION CONTROLS ⚠️
+ ///
+ /// Refer to the security warning on [`RWA::mint`].
+ pub fn batch_mint(e: &Env, to_list: &Vec, amounts: &Vec) {
+ RWA::require_equal_lengths(e, to_list.len(), amounts.len());
+
+ for (to, amount) in to_list.iter().zip(amounts.iter()) {
+ CappedRWA::mint(e, &to, amount);
+ }
+ }
+}
+
+impl RWA {
+ // ################## QUERY STATE ##################
+
+ /// Returns the token version.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ ///
+ /// # Errors
+ ///
+ /// * [`RWAError::VersionNotSet`] - When the version is not set.
+ pub fn version(e: &Env) -> String {
+ e.storage()
+ .instance()
+ .get(&RWAStorageKey::Version)
+ .unwrap_or_else(|| panic_with_error!(e, RWAError::VersionNotSet))
+ }
+
+ /// Returns the address of the onchain ID of the token.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ ///
+ /// # Errors
+ ///
+ /// * [`RWAError::OnchainIdNotSet`] - When the onchain ID is not set.
+ pub fn onchain_id(e: &Env) -> Address {
+ e.storage()
+ .instance()
+ .get(&RWAStorageKey::OnchainId)
+ .unwrap_or_else(|| panic_with_error!(e, RWAError::OnchainIdNotSet))
+ }
+
+ /// Returns the Compliance contract linked to the token.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ ///
+ /// # Errors
+ ///
+ /// * [`RWAError::ComplianceNotSet`] - When the compliance contract is not
+ /// set.
+ pub fn compliance(e: &Env) -> Address {
+ e.storage()
+ .instance()
+ .get(&RWAStorageKey::Compliance)
+ .unwrap_or_else(|| panic_with_error!(e, RWAError::ComplianceNotSet))
+ }
+
+ /// Returns the Identity Verifier contract linked to the token.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ ///
+ /// # Errors
+ ///
+ /// * [`RWAError::IdentityVerifierNotSet`] - When the identity verifier
+ /// contract is not set.
+ pub fn identity_verifier(e: &Env) -> Address {
+ e.storage()
+ .instance()
+ .get(&RWAStorageKey::IdentityVerifier)
+ .unwrap_or_else(|| panic_with_error!(e, RWAError::IdentityVerifierNotSet))
+ }
+
+ /// Returns the freezing status of a wallet. Frozen wallets cannot send or
+ /// receive funds.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `user_address` - The address of the wallet to check.
+ pub fn is_frozen(e: &Env, user_address: &Address) -> bool {
+ let key = RWAStorageKey::AddressFrozen(user_address.clone());
+ if let Some(frozen) = e.storage().persistent().get::<_, bool>(&key) {
+ e.storage().persistent().extend_ttl(&key, FROZEN_TTL_THRESHOLD, FROZEN_EXTEND_AMOUNT);
+ frozen
+ } else {
+ false
+ }
+ }
+
+ /// Returns the amount of tokens that are partially frozen on a wallet.
+ /// The amount of frozen tokens is always <= to the total balance of the
+ /// wallet.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `user_address` - The address of the wallet on which get_frozen_tokens
+ /// is called.
+ pub fn get_frozen_tokens(e: &Env, user_address: &Address) -> i128 {
+ let key = RWAStorageKey::FrozenTokens(user_address.clone());
+ if let Some(frozen_amount) = e.storage().persistent().get::<_, i128>(&key) {
+ e.storage().persistent().extend_ttl(&key, FROZEN_TTL_THRESHOLD, FROZEN_EXTEND_AMOUNT);
+ frozen_amount
+ } else {
+ 0
+ }
+ }
+
+ /// Returns the amount of free (unfrozen) tokens for an address.
+ /// This is calculated as total balance minus frozen tokens.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `user_address` - The address to check.
+ pub fn get_free_tokens(e: &Env, user_address: &Address) -> i128 {
+ let total_balance = Base::balance(e, user_address);
+ let frozen_tokens = Self::get_frozen_tokens(e, user_address);
+
+ // frozen tokens cannot be greater than total balance, necessary checks
+ // are done in state changing functions
+ total_balance - frozen_tokens
+ }
+
+ /// Captures an [`AccountSnapshot`] of `address` from the token's current
+ /// state: its total balance and partially-frozen amount. Must be called
+ /// before any balance or freeze mutation so the snapshot reflects the
+ /// pre-operation figures handed to the compliance hooks.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `address` - The wallet to snapshot.
+ pub fn account_snapshot(e: &Env, address: &Address) -> AccountSnapshot {
+ AccountSnapshot {
+ address: address.clone(),
+ balance: Base::balance(e, address),
+ frozen: Self::get_frozen_tokens(e, address),
+ }
+ }
+
+ // ################## CHANGE STATE ##################
+
+ /// Forced transfer of `amount` tokens from `from` to `to`.
+ ///
+ /// The `to` address must pass identity verification. `from` is not required
+ /// to be verified, so this privileged action remains usable to pull
+ /// tokens out of accounts whose identity is no longer valid
+ /// (sanctioned, revoked, or compromised wallets). The same mechanics
+ /// back [`Self::recover_balance`], which reports
+ /// [`TransferKind::Recovery`] to the compliance contract instead of
+ /// [`TransferKind::Forced`].
+ ///
+ /// This function can unfreeze tokens if needed for regulatory compliance.
+ /// It bypasses paused state and frozen address checks.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `from` - The address holding the tokens.
+ /// * `to` - The address receiving the tokens.
+ /// * `amount` - The amount of tokens to transfer.
+ ///
+ /// # Errors
+ ///
+ /// * [`RWAError::InsufficientBalance`] - When attempting to transfer more
+ /// tokens than available.
+ /// * refer to [`IdentityVerifierClient::verify_identity`] errors.
+ /// * refer to [`Base::update`] errors.
+ ///
+ /// # Events
+ ///
+ /// * topics - `["transfer", from: Address, to: Address]`
+ /// * data - `[amount: i128]`
+ ///
+ /// Emitted before the transfer when `amount` exceeds the free (unfrozen)
+ /// balance of `from`, for the part of the frozen tokens that is unfrozen:
+ /// * topics - `["tokens_unfrozen", from: Address]`
+ /// * data - `[amount: i128]`
+ ///
+ /// # Notes
+ ///
+ /// This function bypasses freezing restrictions and can unfreeze tokens
+ /// as needed. It's intended for regulatory compliance scenarios such as
+ /// court-ordered seizures.
+ ///
+ /// # Security Warning
+ ///
+ /// **IMPORTANT**: This function bypasses authorization and freezing checks.
+ /// Should only be used by authorized compliance or admin functions.
+ pub fn forced_transfer(e: &Env, from: &Address, to: &Address, amount: i128) {
+ // Verify identity for the `to` address only; `from` is intentionally
+ // skipped so this privileged action can still move tokens out of
+ // accounts whose identity is no longer valid.
+ let identity_verifier_addr = Self::identity_verifier(e);
+ let identity_verifier_client = IdentityVerifierClient::new(e, &identity_verifier_addr);
+ identity_verifier_client.verify_identity(to);
+
+ Self::privileged_transfer(e, from, to, amount, &TransferKind::Forced);
+ }
+
+ /// Mints `amount` tokens to `to`.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `to` - The address receiving the new tokens.
+ /// * `amount` - The amount of tokens to mint.
+ ///
+ /// # Errors
+ ///
+ /// * [`PausableError::EnforcedPause`] - When the contract is paused.
+ /// * [`RWAError::AddressFrozen`] - When the recipient address is frozen.
+ /// * [`RWAError::ComplianceNotSet`] - When the compliance contract is not
+ /// configured.
+ /// * refer to [`Base::update`] errors.
+ ///
+ /// # Events
+ ///
+ /// * topics - `["mint", to: Address]`
+ /// * data - `[amount: i128]`
+ ///
+ /// # Security Warning
+ ///
+ /// ⚠️ SECURITY RISK: This function has NO AUTHORIZATION CONTROLS ⚠️
+ ///
+ /// It is the responsibility of the implementer to establish appropriate
+ /// access controls to ensure that only authorized accounts can execute
+ /// minting operations. Failure to implement proper authorization could
+ /// lead to security vulnerabilities and unauthorized token creation.
+ ///
+ /// The implementation will typically look similar to the following
+ /// (pseudo-code):
+ ///
+ /// ```ignore
+ /// let admin = read_administrator(e);
+ /// admin.require_auth();
+ /// ```
+ pub fn mint(e: &Env, to: &Address, amount: i128) {
+ if paused(e) {
+ panic_with_error!(e, PausableError::EnforcedPause);
+ }
+
+ if Self::is_frozen(e, to) {
+ panic_with_error!(e, RWAError::AddressFrozen);
+ }
+
+ let identity_verifier_addr = Self::identity_verifier(e);
+ let identity_verifier_client = IdentityVerifierClient::new(e, &identity_verifier_addr);
+ identity_verifier_client.verify_identity(to);
+
+ // Snapshot before the mint so the hook observes the pre-mint balance.
+ let to_snapshot = Self::account_snapshot(e, to);
+
+ increase_total_supply(e, amount);
+ Base::update(e, None, Some(to), amount);
+
+ let compliance_addr = Self::compliance(e);
+ let compliance_client = ComplianceClient::new(e, &compliance_addr);
+ compliance_client.created(&to_snapshot, &amount, &e.current_contract_address());
+
+ emit_mint(e, to, amount);
+ }
+
+ /// Burns `amount` tokens from `user_address`. Updates the total supply
+ /// accordingly.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `user_address` - The address from which to burn tokens.
+ /// * `amount` - The amount of tokens to burn.
+ ///
+ /// # Errors
+ ///
+ /// * [`RWAError::InsufficientBalance`] - When `amount` exceeds the total
+ /// balance of `user_address`.
+ /// * [`RWAError::ComplianceNotSet`] - When the compliance contract is not
+ /// configured.
+ /// * refer to
+ /// [`Compliance::destroyed`](crate::rwa::compliance::Compliance::destroyed)
+ /// errors.
+ /// * refer to [`Base::update`] errors.
+ ///
+ /// # Events
+ ///
+ /// * topics - `["burn", user_address: Address]`
+ /// * data - `[amount: i128]`
+ ///
+ /// Emitted before the burn when `amount` exceeds the free (unfrozen)
+ /// balance of `user_address`, for the part of the frozen tokens that is
+ /// unfrozen:
+ /// * topics - `["tokens_unfrozen", user_address: Address]`
+ /// * data - `[amount: i128]`
+ ///
+ /// # Notes
+ ///
+ /// This function bypasses freezing restrictions and can unfreeze tokens
+ /// as needed: when `amount` exceeds the free (unfrozen) balance, the
+ /// difference is unfrozen first, so a partially frozen balance is
+ /// destroyed in place.
+ ///
+ /// Locked tokens are not given the same treatment. The `Destroyed` hook
+ /// carries no notion of a privileged burn, so a compliance module can
+ /// reject the burn by panicking regardless of who issued it. In
+ /// particular, with the
+ /// [`initial_lockup_period`](crate::rwa::compliance::modules::initial_lockup_period)
+ /// module registered, a burn that dips into still-locked tokens is
+ /// rejected; destroying a locked position takes a forced transfer to a
+ /// treasury wallet (which consumes the locks) followed by a burn from
+ /// that treasury.
+ ///
+ /// # Security Warning
+ ///
+ /// **IMPORTANT**: This function bypasses authorization checks and should
+ /// only be used internally or in admin functions that implement their own
+ /// authorization logic.
+ pub fn burn(e: &Env, user_address: &Address, amount: i128) {
+ // Snapshot before any unfreeze/balance mutation so the hook observes
+ // the pre-burn state.
+ let from_snapshot = Self::account_snapshot(e, user_address);
+
+ if amount > from_snapshot.balance {
+ panic_with_error!(e, RWAError::InsufficientBalance);
+ }
+
+ // Check if we need to unfreeze tokens to complete the burn. The
+ // snapshot is still current: nothing has mutated the balance or
+ // frozen amount since it was captured.
+ let free_tokens = from_snapshot.balance - from_snapshot.frozen;
+ if free_tokens < amount {
+ let tokens_to_unfreeze = amount - free_tokens;
+ let new_frozen = from_snapshot.frozen - tokens_to_unfreeze;
+
+ e.storage()
+ .persistent()
+ .set(&RWAStorageKey::FrozenTokens(user_address.clone()), &new_frozen);
+ emit_tokens_unfrozen(e, user_address, tokens_to_unfreeze);
+ }
+
+ Base::update(e, Some(user_address), None, amount);
+ decrease_total_supply(e, amount);
+
+ let compliance_addr = Self::compliance(e);
+ let compliance_client = ComplianceClient::new(e, &compliance_addr);
+ compliance_client.destroyed(&from_snapshot, &amount, &e.current_contract_address());
+
+ emit_burn(e, user_address, amount);
+ }
+
+ /// Recovery function used to force transfer tokens from a old account to a
+ /// new account. This function transfers all tokens and preserves the frozen
+ /// status from the old account to the new account. Returns `true` if
+ /// tokens were moved, `false` if the old account held no tokens.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `old_account` - The address of the wallet that lost access.
+ /// * `new_account` - The address of the new account to receive the tokens.
+ ///
+ /// # Errors
+ ///
+ /// * [`RWAError::IdentityVerificationFailed`] - When the identity of the
+ /// new account cannot be verified.
+ /// * [`RWAError::IdentityMismatch`] - When the new account is not the
+ /// target of the recovery process for the old wallet.
+ ///
+ /// # Events
+ ///
+ /// Emitted when the old account is fully frozen:
+ /// * topics - `["address_frozen", new_account: Address, is_frozen: bool]`
+ /// * data - `[]`
+ ///
+ /// Emitted only when tokens are moved:
+ /// * topics - `["transfer", old_account: Address, new_account: Address]`
+ /// * data - `[amount: i128]`
+ /// * topics - `["recovery_success", old_account: Address, new_account:
+ /// Address]`
+ /// * data - `[]`
+ ///
+ /// Emitted only when tokens are moved and the old account has partially
+ /// frozen tokens, which are unfrozen there before the transfer and frozen
+ /// again on the new account after it:
+ /// * topics - `["tokens_unfrozen", old_account: Address]`
+ /// * data - `[amount: i128]`
+ /// * topics - `["tokens_frozen", new_account: Address]`
+ /// * data - `[amount: i128]`
+ ///
+ /// # Notes
+ ///
+ /// This function preserves the frozen status (both partial and full) from
+ /// the old account and applies it to the new account, maintaining
+ /// regulatory compliance. The full-address freeze is a wallet-level
+ /// restriction, so it follows the investor to the new account even when the
+ /// old account holds no tokens. The partially frozen amount is bounded by
+ /// the balance, so there is nothing to migrate in that case.
+ ///
+ ///
+ /// State owned by this contract (the frozen status) is migrated directly
+ /// by this function. State held by compliance modules is handled through
+ /// the transfer hook: the recovery is reported to the compliance contract
+ /// as a transfer of kind [`TransferKind::Recovery`], and each module
+ /// decides how to treat it. Policy modules exempt it like a forced
+ /// transfer, while bookkeeping modules migrate wallet-bound state
+ /// instead of consuming it (e.g. the initial lockup period module moves
+ /// the old account's lock entries to the new account).
+ ///
+ /// This function does not concern itself with the Identity Management.
+ /// If the old account's identity should be removed, it should be done on
+ /// the Identity Stack.
+ ///
+ /// Allowances are not migrated or revoked: allowances the old account
+ /// granted stay in place (with an empty balance there is nothing to spend
+ /// until the old account receives tokens again), and allowances granted to
+ /// the old account as a spender remain usable by whoever controls it.
+ ///
+ /// # Security Warning
+ ///
+ /// **IMPORTANT**: This function bypasses authorization and compliance
+ /// checks. Should only be used by authorized recovery or admin
+ /// functions.
+ pub fn recover_balance(e: &Env, old_account: &Address, new_account: &Address) -> bool {
+ // Verify identity for the new account.
+ let identity_verifier_addr = Self::identity_verifier(e);
+ let identity_verifier_client = IdentityVerifierClient::new(e, &identity_verifier_addr);
+ identity_verifier_client.verify_identity(new_account);
+
+ // Verify that the new account is the recovery target for the old
+ // account
+ let recovery_target = identity_verifier_client
+ .recovery_target(old_account)
+ .unwrap_or_else(|| panic_with_error!(e, RWAError::IdentityMismatch));
+
+ if recovery_target != *new_account {
+ panic_with_error!(e, RWAError::IdentityMismatch);
+ }
+
+ if Self::is_frozen(e, old_account) {
+ Self::set_address_frozen(e, new_account, true);
+ }
+
+ // Get the balance of the old account, if there is nothing to transfer,
+ // return false
+ let lost_balance = Base::balance(e, old_account);
+ if lost_balance == 0 {
+ return false;
+ }
+
+ // Store the partially frozen amount before the transfer unfreezes it
+ let frozen_tokens = Self::get_frozen_tokens(e, old_account);
+
+ // Move all tokens with the shared privileged mechanics (handles
+ // unfreezing as needed), reporting the movement as a recovery so
+ // compliance modules migrate wallet-bound state instead of
+ // consuming it.
+ Self::privileged_transfer(
+ e,
+ old_account,
+ new_account,
+ lost_balance,
+ &TransferKind::Recovery,
+ );
+
+ // Preserve frozen tokens on the new account if there were any
+ if frozen_tokens > 0 {
+ Self::freeze_partial_tokens(e, new_account, frozen_tokens);
+ }
+
+ emit_recovery_success(e, old_account, new_account);
+
+ true
+ }
+
+ /// Sets the frozen status for an address. A frozen wallet cannot send
+ /// tokens from its own balance or receive tokens. The freeze applies to
+ /// the wallet's own balance only: a frozen wallet can still act as the
+ /// spender in [`Self::transfer_from`], moving tokens out of another
+ /// (unfrozen) account within the allowance it was granted.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `user_address` - The address to freeze or unfreeze.
+ /// * `freeze` - `true` to freeze the address, `false` to unfreeze.
+ ///
+ /// # Events
+ ///
+ /// * topics - `["address_frozen", user_address: Address, is_frozen: bool]`
+ /// * data - `[]`
+ ///
+ /// # Security Warning
+ ///
+ /// **IMPORTANT**: This function bypasses authorization checks and should
+ /// only be used internally or in admin functions that implement their own
+ /// authorization logic.
+ pub fn set_address_frozen(e: &Env, user_address: &Address, freeze: bool) {
+ e.storage().persistent().set(&RWAStorageKey::AddressFrozen(user_address.clone()), &freeze);
+
+ emit_address_frozen(e, user_address, freeze);
+ }
+
+ /// Freezes a specified amount of tokens for a given address.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `user_address` - The address for which to freeze tokens.
+ /// * `amount` - The amount of tokens to freeze.
+ ///
+ /// # Errors
+ ///
+ /// * [`RWAError::LessThanZero`] - When `amount < 0`.
+ /// * [`RWAError::InsufficientBalance`] - When trying to freeze more tokens
+ /// than available.
+ ///
+ /// # Events
+ ///
+ /// * topics - `["tokens_frozen", user_address: Address]`
+ /// * data - `[amount: i128]`
+ ///
+ /// # Security Warning
+ ///
+ /// **IMPORTANT**: This function bypasses authorization checks and should
+ /// only be used internally or in admin functions that implement their own
+ /// authorization logic.
+ pub fn freeze_partial_tokens(e: &Env, user_address: &Address, amount: i128) {
+ if amount < 0 {
+ panic_with_error!(e, RWAError::LessThanZero);
+ }
+
+ let current_balance = Base::balance(e, user_address);
+ let current_frozen = Self::get_frozen_tokens(e, user_address);
+ let new_frozen = current_frozen + amount;
+
+ if new_frozen > current_balance {
+ panic_with_error!(e, RWAError::InsufficientBalance);
+ }
+
+ e.storage()
+ .persistent()
+ .set(&RWAStorageKey::FrozenTokens(user_address.clone()), &new_frozen);
+ emit_tokens_frozen(e, user_address, amount);
+ }
+
+ /// Unfreezes a specified amount of tokens for a given address.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `user_address` - The address for which to unfreeze tokens.
+ /// * `amount` - The amount of tokens to unfreeze.
+ ///
+ /// # Errors
+ ///
+ /// * [`RWAError::LessThanZero`] - When `amount < 0`.
+ /// * [`RWAError::InsufficientFreeTokens`] - When trying to unfreeze more
+ /// tokens than are frozen.
+ ///
+ /// # Events
+ ///
+ /// * topics - `["tokens_unfrozen", user_address: Address]`
+ /// * data - `[amount: i128]`
+ ///
+ /// # Security Warning
+ ///
+ /// **IMPORTANT**: This function bypasses authorization checks and should
+ /// only be used internally or in admin functions that implement their own
+ /// authorization logic.
+ pub fn unfreeze_partial_tokens(e: &Env, user_address: &Address, amount: i128) {
+ if amount < 0 {
+ panic_with_error!(e, RWAError::LessThanZero);
+ }
+
+ let current_frozen = Self::get_frozen_tokens(e, user_address);
+ if current_frozen < amount {
+ panic_with_error!(e, RWAError::InsufficientFreeTokens);
+ }
+
+ let new_frozen = current_frozen - amount;
+ e.storage()
+ .persistent()
+ .set(&RWAStorageKey::FrozenTokens(user_address.clone()), &new_frozen);
+ emit_tokens_unfrozen(e, user_address, amount);
+ }
+
+ /// Sets the onchain ID of the token.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `onchain_id` - The new onchain ID address for the token.
+ ///
+ /// # Events
+ ///
+ /// * topics - `["token_onchain_id_updated", onchain_id: Address]`
+ /// * data - `[]`
+ ///
+ /// # Security Warning
+ ///
+ /// **IMPORTANT**: This function bypasses authorization checks and should
+ /// only be used internally or in admin functions that implement their own
+ /// authorization logic.
+ pub fn set_onchain_id(e: &Env, onchain_id: &Address) {
+ e.storage().instance().set(&RWAStorageKey::OnchainId, onchain_id);
+
+ emit_token_onchain_id_updated(e, onchain_id);
+ }
+
+ /// Sets the compliance contract of the token.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `compliance` - The address of the compliance contract.
+ ///
+ /// # Events
+ ///
+ /// * topics - `["compliance_set", compliance: Address]`
+ /// * data - `[]`
+ ///
+ /// # Security Warning
+ ///
+ /// **IMPORTANT**: This function bypasses authorization checks and should
+ /// only be used internally or in admin functions that implement their own
+ /// authorization logic.
+ pub fn set_compliance(e: &Env, compliance: &Address) {
+ e.storage().instance().set(&RWAStorageKey::Compliance, compliance);
+ emit_compliance_set(e, compliance);
+ }
+
+ /// Sets the identity verifier contract of the token.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `identity_verifier` - The address of the identity verifier contract.
+ ///
+ /// # Events
+ ///
+ /// * topics - `["identity_verifier_set", identity_verifier: Address]`
+ /// * data - `[]`
+ ///
+ /// # Security Warning
+ ///
+ /// **IMPORTANT**: This function bypasses authorization checks and should
+ /// only be used internally or in admin functions that implement their own
+ /// authorization logic.
+ pub fn set_identity_verifier(e: &Env, identity_verifier: &Address) {
+ e.storage().instance().set(&RWAStorageKey::IdentityVerifier, identity_verifier);
+ emit_identity_verifier_set(e, identity_verifier);
+ }
+
+ // ################## OVERRIDDEN FUNCTIONS ##################
+
+ /// `transfer` override with added compliance and identity verification
+ /// checks.
+ ///
+ /// This is ultimately a wrapper around [`Base::update()`] to enable
+ /// the compatibility across [`crate::fungible::FungibleToken`]
+ /// with [`crate::rwa::RWAToken`]
+ ///
+ /// The main differences are:
+ /// - checks for if the contract is paused
+ /// - checks for if the addresses are frozen
+ /// - checks for if the from address have enough free tokens (unfrozen
+ /// tokens)
+ /// - enforces identity verification for both addresses
+ /// - enforces compliance rules for the transfer
+ /// - triggers `transferred` hook call from the compliance contract
+ ///
+ /// Please refer to [`Base::update`] and [`Self::validate_transfer`] for the
+ /// inline documentation.
+ ///
+ /// # Events
+ ///
+ /// * topics - `["transfer", from: Address, to: Address]`
+ /// * data - `amount: i128` (when `to` has no muxed ID)
+ /// * data - `{to_muxed_id: Option, amount: i128}` (when `to` has a
+ /// muxed ID)
+ ///
+ /// # Notes
+ ///
+ /// A muxed `to` is narrowed to its base address before any check runs, so
+ /// identity verification, the compliance hook and the balance update all
+ /// operate on the base address. The muxed ID reaches the event only, where
+ /// it lets a custodian attribute the transfer to one of its off-chain
+ /// sub-accounts.
+ ///
+ /// The ID is supplied by the caller and carries no on-chain verification.
+ /// A verified holder may therefore be a custodian holding on behalf of
+ /// beneficiaries who have no on-chain identity of their own.
+ ///
+ /// Whether to accept custodial destinations is the implementor's decision,
+ /// not the library's. An issuer that requires beneficial owners on the
+ /// on-chain register can override
+ /// [`crate::fungible::FungibleToken::transfer`] and reject a destination
+ /// whose `id()` is `Some`; `rwa-token-example` carries that pattern as a
+ /// showcase.
+ pub fn transfer(e: &Env, from: &Address, to: &MuxedAddress, amount: i128) {
+ from.require_auth();
+
+ let to_address = to.address();
+
+ // Snapshot before the update so the hook observes the pre-transfer
+ // state.
+ let from_snapshot = Self::account_snapshot(e, from);
+ let to_snapshot = Self::account_snapshot(e, &to_address);
+
+ Self::validate_transfer(
+ e,
+ &from_snapshot,
+ &to_snapshot,
+ amount,
+ FromVerification::Required,
+ );
+
+ Base::update(e, Some(from), Some(&to_address), amount);
+
+ let compliance_client = ComplianceClient::new(e, &Self::compliance(e));
+ compliance_client.transferred(
+ &from_snapshot,
+ &to_snapshot,
+ &amount,
+ &TransferKind::Standard,
+ &e.current_contract_address(),
+ );
+ emit_transfer(e, from, &to_address, to.id(), amount);
+ }
+
+ /// `transfer_from` override with added compliance and identity verification
+ /// checks.
+ ///
+ /// This is ultimately a wrapper around [`Base::update()`] to enable
+ /// the compatibility across [`crate::fungible::FungibleToken`]
+ /// with [`crate::rwa::RWAToken`]
+ ///
+ /// The main differences are:
+ /// - checks for if the contract is paused
+ /// - checks for if the addresses are frozen
+ /// - checks for if the from address have enough free tokens (unfrozen
+ /// tokens)
+ /// - enforces identity verification for both addresses
+ /// - enforces compliance rules for the transfer
+ /// - triggers `transferred` hook call from the compliance contract
+ ///
+ /// Please refer to [`Base::update`] and [`Self::validate_transfer`] for the
+ /// inline documentation.
+ pub fn transfer_from(e: &Env, spender: &Address, from: &Address, to: &Address, amount: i128) {
+ spender.require_auth();
+
+ // Snapshot before the update so the hook observes the pre-transfer
+ // state. `spender` is the delegate moving the holder's tokens.
+ let from_snapshot = Self::account_snapshot(e, from);
+ let to_snapshot = Self::account_snapshot(e, to);
+
+ Self::validate_transfer(
+ e,
+ &from_snapshot,
+ &to_snapshot,
+ amount,
+ FromVerification::Required,
+ );
+
+ Base::spend_allowance(e, from, spender, amount);
+
+ Base::update(e, Some(from), Some(to), amount);
+
+ let compliance_client = ComplianceClient::new(e, &Self::compliance(e));
+ compliance_client.transferred(
+ &from_snapshot,
+ &to_snapshot,
+ &amount,
+ &TransferKind::Delegated(spender.clone()),
+ &e.current_contract_address(),
+ );
+ emit_transfer(e, from, to, None, amount);
+ }
+
+ // ################## BATCH FUNCTIONS ##################
+
+ /// Transfers to several recipients in one invocation, moving
+ /// `amounts[i]` from `from` to `recipients[i]`. The sender authorizes the
+ /// whole batch once.
+ ///
+ /// The batch is atomic: an item that fails reverts every item before it.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `from` - The address holding the tokens.
+ /// * `recipients` - The addresses receiving the tokens.
+ /// * `amounts` - The amount to transfer to each recipient.
+ ///
+ /// # Errors
+ ///
+ /// * [`RWAError::BatchSizeMismatch`] - When `recipients` and `amounts` have
+ /// different lengths.
+ /// * refer to [`Self::validate_transfer`] errors, which every item runs.
+ /// * refer to [`Base::update`] errors.
+ ///
+ /// # Events
+ ///
+ /// For each recipient:
+ /// * topics - `["transfer", from: Address, to: Address]`
+ /// * data - `[amount: i128]`
+ ///
+ /// # Notes
+ ///
+ /// Recipients are processed in order, and the sender's free balance is
+ /// re-evaluated for every item, so a batch that overspends fails on the
+ /// item that runs out rather than up front.
+ ///
+ /// The sender's identity is verified once for the whole batch rather than
+ /// once per recipient, which is where the saving over repeated single
+ /// transfers comes from. This assumes nothing invalidates the sender
+ /// part-way through: the compliance modules that run between items hold no
+ /// authority over the identity stack, and re-entering this token is
+ /// impossible in Soroban, so no module in reach can revoke the sender it
+ /// was just handed. A module granted write access to the identity stack
+ /// would break that assumption.
+ ///
+ /// The paused state is checked once up front so that a paused token is
+ /// rejected before any identity work and regardless of batch size, and
+ /// again per item along with both wallets' freezing status, since those
+ /// reads are cheap enough that skipping them would buy nothing.
+ ///
+ /// The caller is responsible for sizing the batch to fit the
+ /// per-transaction network limits. Refer to the batch operations section of
+ /// the [module documentation](crate::rwa) for measured figures.
+ pub fn batch_transfer(e: &Env, from: &Address, recipients: &Vec, amounts: &Vec) {
+ from.require_auth();
+
+ Self::require_equal_lengths(e, recipients.len(), amounts.len());
+
+ if paused(e) {
+ panic_with_error!(e, PausableError::EnforcedPause);
+ }
+
+ // Every item shares one `from`, so the walk through the identity stack
+ // that verifies it runs once for the batch instead of once per
+ // recipient. Every other check stays inside the loop.
+ IdentityVerifierClient::new(e, &Self::identity_verifier(e)).verify_identity(from);
+
+ let compliance_client = ComplianceClient::new(e, &Self::compliance(e));
+ let token = e.current_contract_address();
+
+ for (to, amount) in recipients.iter().zip(amounts.iter()) {
+ // Snapshot before the update so the hook observes the pre-transfer
+ // state. The sender's balance shrinks as the batch progresses, so
+ // this is re-read for every item.
+ let from_snapshot = Self::account_snapshot(e, from);
+ let to_snapshot = Self::account_snapshot(e, &to);
+
+ Self::validate_transfer(
+ e,
+ &from_snapshot,
+ &to_snapshot,
+ amount,
+ FromVerification::HoistedByCaller,
+ );
+
+ Base::update(e, Some(from), Some(&to), amount);
+
+ compliance_client.transferred(
+ &from_snapshot,
+ &to_snapshot,
+ &amount,
+ &TransferKind::Standard,
+ &token,
+ );
+ emit_transfer(e, from, &to, None, amount);
+ }
+ }
+
+ /// Forces a transfer for each `(from_list[i], to_list[i], amounts[i])`
+ /// triple in one invocation.
+ ///
+ /// The batch is atomic: an item that fails reverts every item before it.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `from_list` - The addresses holding the tokens.
+ /// * `to_list` - The addresses receiving the tokens.
+ /// * `amounts` - The amount to move for each pair.
+ ///
+ /// # Errors
+ ///
+ /// * [`RWAError::BatchSizeMismatch`] - When the three arrays do not all
+ /// have the same length.
+ /// * refer to [`Self::forced_transfer`] errors.
+ ///
+ /// # Events
+ ///
+ /// For each triple:
+ /// * topics - `["transfer", from: Address, to: Address]`
+ /// * data - `[amount: i128]`
+ ///
+ /// For each triple whose `amount` exceeds the free (unfrozen) balance of
+ /// `from`, emitted before its transfer:
+ /// * topics - `["tokens_unfrozen", from: Address]`
+ /// * data - `[amount: i128]`
+ ///
+ /// # Notes
+ ///
+ /// The caller is responsible for sizing the batch to fit the
+ /// per-transaction network limits. Refer to the batch operations section of
+ /// the [module documentation](crate::rwa).
+ ///
+ /// # Security Warning
+ ///
+ /// **IMPORTANT**: This function bypasses authorization and freezing checks,
+ /// exactly like [`Self::forced_transfer`]. Refer to its security warning.
+ pub fn batch_forced_transfer(
+ e: &Env,
+ from_list: &Vec,
+ to_list: &Vec,
+ amounts: &Vec,
+ ) {
+ Self::require_equal_lengths(e, from_list.len(), to_list.len());
+ Self::require_equal_lengths(e, to_list.len(), amounts.len());
+
+ for ((from, to), amount) in from_list.iter().zip(to_list.iter()).zip(amounts.iter()) {
+ Self::forced_transfer(e, &from, &to, amount);
+ }
+ }
+
+ /// Mints `amounts[i]` tokens to `to_list[i]` in one invocation.
+ ///
+ /// The batch is atomic: an item that fails reverts every item before it.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `to_list` - The addresses receiving the new tokens.
+ /// * `amounts` - The amount to mint to each address.
+ ///
+ /// # Errors
+ ///
+ /// * [`RWAError::BatchSizeMismatch`] - When `to_list` and `amounts` have
+ /// different lengths.
+ /// * refer to [`Self::mint`] errors.
+ ///
+ /// # Events
+ ///
+ /// For each address:
+ /// * topics - `["mint", to: Address]`
+ /// * data - `[amount: i128]`
+ ///
+ /// # Notes
+ ///
+ /// The caller is responsible for sizing the batch to fit the
+ /// per-transaction network limits. Refer to the batch operations section of
+ /// the [module documentation](crate::rwa).
+ ///
+ /// # Security Warning
+ ///
+ /// ⚠️ SECURITY RISK: This function has NO AUTHORIZATION CONTROLS ⚠️
+ ///
+ /// Refer to the security warning on [`Self::mint`].
+ pub fn batch_mint(e: &Env, to_list: &Vec, amounts: &Vec) {
+ Self::require_equal_lengths(e, to_list.len(), amounts.len());
+
+ for (to, amount) in to_list.iter().zip(amounts.iter()) {
+ Self::mint(e, &to, amount);
+ }
+ }
+
+ /// Burns `amounts[i]` tokens from `user_addresses[i]` in one invocation.
+ ///
+ /// The batch is atomic: an item that fails reverts every item before it.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `user_addresses` - The addresses to burn tokens from.
+ /// * `amounts` - The amount to burn from each address.
+ ///
+ /// # Errors
+ ///
+ /// * [`RWAError::BatchSizeMismatch`] - When `user_addresses` and `amounts`
+ /// have different lengths.
+ /// * refer to [`Self::burn`] errors.
+ ///
+ /// # Events
+ ///
+ /// For each address:
+ /// * topics - `["burn", user_address: Address]`
+ /// * data - `[amount: i128]`
+ ///
+ /// For each address whose `amount` exceeds its free (unfrozen) balance,
+ /// emitted before its burn:
+ /// * topics - `["tokens_unfrozen", user_address: Address]`
+ /// * data - `[amount: i128]`
+ ///
+ /// # Notes
+ ///
+ /// The caller is responsible for sizing the batch to fit the
+ /// per-transaction network limits. Refer to the batch operations section of
+ /// the [module documentation](crate::rwa).
+ ///
+ /// # Security Warning
+ ///
+ /// **IMPORTANT**: This function bypasses authorization checks, exactly like
+ /// [`Self::burn`]. Refer to its security warning.
+ pub fn batch_burn(e: &Env, user_addresses: &Vec, amounts: &Vec) {
+ Self::require_equal_lengths(e, user_addresses.len(), amounts.len());
+
+ for (user_address, amount) in user_addresses.iter().zip(amounts.iter()) {
+ Self::burn(e, &user_address, amount);
+ }
+ }
+
+ /// Sets the frozen status of `user_addresses[i]` to `freeze_list[i]` in one
+ /// invocation.
+ ///
+ /// The batch is atomic: an item that fails reverts every item before it.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `user_addresses` - The addresses to freeze or unfreeze.
+ /// * `freeze_list` - The frozen status to apply to each address.
+ ///
+ /// # Errors
+ ///
+ /// * [`RWAError::BatchSizeMismatch`] - When `user_addresses` and
+ /// `freeze_list` have different lengths.
+ ///
+ /// # Events
+ ///
+ /// For each address:
+ /// * topics - `["address_frozen", user_address: Address, is_frozen: bool]`
+ /// * data - `[]`
+ ///
+ /// # Notes
+ ///
+ /// An address repeated in `user_addresses` ends up with the status of its
+ /// last occurrence, and each occurrence emits its own event.
+ ///
+ /// The caller is responsible for sizing the batch to fit the
+ /// per-transaction network limits. Refer to the batch operations section of
+ /// the [module documentation](crate::rwa).
+ ///
+ /// # Security Warning
+ ///
+ /// **IMPORTANT**: This function bypasses authorization checks, exactly like
+ /// [`Self::set_address_frozen`]. Refer to its security warning.
+ pub fn batch_set_address_frozen(
+ e: &Env,
+ user_addresses: &Vec,
+ freeze_list: &Vec,
+ ) {
+ Self::require_equal_lengths(e, user_addresses.len(), freeze_list.len());
+
+ for (user_address, freeze) in user_addresses.iter().zip(freeze_list.iter()) {
+ Self::set_address_frozen(e, &user_address, freeze);
+ }
+ }
+
+ /// Freezes `amounts[i]` tokens on `user_addresses[i]` in one invocation.
+ ///
+ /// The batch is atomic: an item that fails reverts every item before it.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `user_addresses` - The addresses for which to freeze tokens.
+ /// * `amounts` - The amount to freeze on each address.
+ ///
+ /// # Errors
+ ///
+ /// * [`RWAError::BatchSizeMismatch`] - When `user_addresses` and `amounts`
+ /// have different lengths.
+ /// * refer to [`Self::freeze_partial_tokens`] errors.
+ ///
+ /// # Events
+ ///
+ /// For each address:
+ /// * topics - `["tokens_frozen", user_address: Address]`
+ /// * data - `[amount: i128]`
+ ///
+ /// # Notes
+ ///
+ /// Amounts accumulate, so an address repeated in `user_addresses` has every
+ /// occurrence added to its frozen balance.
+ ///
+ /// The caller is responsible for sizing the batch to fit the
+ /// per-transaction network limits. Refer to the batch operations section of
+ /// the [module documentation](crate::rwa).
+ ///
+ /// # Security Warning
+ ///
+ /// **IMPORTANT**: This function bypasses authorization checks, exactly like
+ /// [`Self::freeze_partial_tokens`]. Refer to its security warning.
+ pub fn batch_freeze_partial_tokens(
+ e: &Env,
+ user_addresses: &Vec,
+ amounts: &Vec,
+ ) {
+ Self::require_equal_lengths(e, user_addresses.len(), amounts.len());
+
+ for (user_address, amount) in user_addresses.iter().zip(amounts.iter()) {
+ Self::freeze_partial_tokens(e, &user_address, amount);
+ }
+ }
+
+ /// Unfreezes `amounts[i]` tokens on `user_addresses[i]` in one invocation.
+ ///
+ /// The batch is atomic: an item that fails reverts every item before it.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `user_addresses` - The addresses for which to unfreeze tokens.
+ /// * `amounts` - The amount to unfreeze on each address.
+ ///
+ /// # Errors
+ ///
+ /// * [`RWAError::BatchSizeMismatch`] - When `user_addresses` and `amounts`
+ /// have different lengths.
+ /// * refer to [`Self::unfreeze_partial_tokens`] errors.
+ ///
+ /// # Events
+ ///
+ /// For each address:
+ /// * topics - `["tokens_unfrozen", user_address: Address]`
+ /// * data - `[amount: i128]`
+ ///
+ /// # Notes
+ ///
+ /// Amounts accumulate, so an address repeated in `user_addresses` has every
+ /// occurrence subtracted from its frozen balance.
+ ///
+ /// The caller is responsible for sizing the batch to fit the
+ /// per-transaction network limits. Refer to the batch operations section of
+ /// the [module documentation](crate::rwa).
+ ///
+ /// # Security Warning
+ ///
+ /// **IMPORTANT**: This function bypasses authorization checks, exactly like
+ /// [`Self::unfreeze_partial_tokens`]. Refer to its security warning.
+ pub fn batch_unfreeze_partial_tokens(
+ e: &Env,
+ user_addresses: &Vec,
+ amounts: &Vec,
+ ) {
+ Self::require_equal_lengths(e, user_addresses.len(), amounts.len());
+
+ for (user_address, amount) in user_addresses.iter().zip(amounts.iter()) {
+ Self::unfreeze_partial_tokens(e, &user_address, amount);
+ }
+ }
+
+ // ################## LOW-LEVEL HELPERS ##################
+
+ /// Performs every check a transfer requires, without requiring
+ /// authorization or moving anything: paused state, the freezing status of
+ /// both wallets, the sender's free balance, and the identity of both
+ /// parties. Shared by [`Self::transfer`], [`Self::transfer_from`] and every
+ /// item of [`Self::batch_transfer`], so no entry point can drift away from
+ /// the others on what it checks.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `from` - Snapshot of the sender, as of before the transfer.
+ /// * `to` - Snapshot of the receiver, as of before the transfer.
+ /// * `amount` - The amount of tokens to transfer.
+ /// * `from_verification` - Who verifies the identity of `from`, refer to
+ /// [`FromVerification`].
+ ///
+ /// # Errors
+ ///
+ /// * [`PausableError::EnforcedPause`] - If the contract is paused.
+ /// * [`RWAError::AddressFrozen`] - If either the sender or receiver is
+ /// frozen.
+ /// * [`RWAError::InsufficientFreeTokens`] - If the sender does not have
+ /// enough free tokens.
+ /// * refer to [`Self::identity_verifier`] errors.
+ /// * refer to [`IdentityVerifierClient::verify_identity`] errors.
+ ///
+ /// # Security Warning
+ ///
+ /// **IMPORTANT**: Passing [`FromVerification::HoistedByCaller`] skips the
+ /// sender's identity check, and is only sound when the caller has verified
+ /// that same sender earlier in the invocation (i.e. batched operations).
+ pub fn validate_transfer(
+ e: &Env,
+ from: &AccountSnapshot,
+ to: &AccountSnapshot,
+ amount: i128,
+ from_verification: FromVerification,
+ ) {
+ // Check if contract is paused
+ if paused(e) {
+ panic_with_error!(e, PausableError::EnforcedPause);
+ }
+
+ // Check if addresses are frozen
+ if Self::is_frozen(e, &from.address) || Self::is_frozen(e, &to.address) {
+ panic_with_error!(e, RWAError::AddressFrozen);
+ }
+
+ // Check if there are enough free tokens (not frozen)
+ if from.balance - from.frozen < amount {
+ panic_with_error!(e, RWAError::InsufficientFreeTokens);
+ }
+
+ let identity_verifier_addr = Self::identity_verifier(e);
+ let identity_verifier_client = IdentityVerifierClient::new(e, &identity_verifier_addr);
+ if matches!(from_verification, FromVerification::Required) {
+ identity_verifier_client.verify_identity(&from.address);
+ }
+ identity_verifier_client.verify_identity(&to.address);
+ }
+
+ /// Shared mechanics behind [`Self::forced_transfer`] and
+ /// [`Self::recover_balance`]: moves `amount` from `from` to `to`,
+ /// bypassing paused state and freezing restrictions (unfreezing as
+ /// needed), and reports the movement to the compliance contract as
+ /// `kind`. The two public entry points differ only in the kind they
+ /// report, which is what lets compliance modules tell a seizure from a
+ /// wallet migration.
+ ///
+ /// Performs no identity verification; each caller verifies the recipient
+ /// before delegating here, so the check runs exactly once per invocation.
+ fn privileged_transfer(
+ e: &Env,
+ from: &Address,
+ to: &Address,
+ amount: i128,
+ kind: &TransferKind,
+ ) {
+ // Snapshot before any unfreeze/balance mutation so the hook observes
+ // the pre-operation state. A privileged transfer has no spender: it is
+ // an administrative pull, not a delegated move.
+ let from_snapshot = Self::account_snapshot(e, from);
+ let to_snapshot = Self::account_snapshot(e, to);
+
+ if from_snapshot.balance < amount {
+ panic_with_error!(e, RWAError::InsufficientBalance);
+ }
+
+ // Check if we need to unfreeze tokens to complete the transfer. The
+ // snapshot is still current: nothing has mutated the balance or
+ // frozen amount since it was captured.
+ let free_tokens = from_snapshot.balance - from_snapshot.frozen;
+ if free_tokens < amount {
+ let tokens_to_unfreeze = amount - free_tokens;
+ let new_frozen = from_snapshot.frozen - tokens_to_unfreeze;
+
+ e.storage().persistent().set(&RWAStorageKey::FrozenTokens(from.clone()), &new_frozen);
+ emit_tokens_unfrozen(e, from, tokens_to_unfreeze);
+ }
+
+ Base::update(e, Some(from), Some(to), amount);
+
+ let compliance_addr = Self::compliance(e);
+ let compliance_client = ComplianceClient::new(e, &compliance_addr);
+ compliance_client.transferred(
+ &from_snapshot,
+ &to_snapshot,
+ &amount,
+ kind,
+ &e.current_contract_address(),
+ );
+
+ emit_transfer(e, from, to, None, amount);
+ }
+
+ /// Rejects a batch whose parallel arrays do not line up.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `left` - Length of the first array.
+ /// * `right` - Length of the array that must match it.
+ ///
+ /// # Errors
+ ///
+ /// * [`RWAError::BatchSizeMismatch`] - When the two lengths differ.
+ fn require_equal_lengths(e: &Env, left: u32, right: u32) {
+ if left != right {
+ panic_with_error!(e, RWAError::BatchSizeMismatch);
+ }
+ }
+}