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); + } + } +}