← All standards
SEP-0057Source ↗

T-REX (Token for Regulated EXchanges)

34 min · 7,378 words

#Summary

This proposal defines a comprehensive suite of smart contracts for T-REX (Token for Regulated EXchanges) - colloquially known as RWA token - on Stellar. T-REX tokens represent tokenized real-world assets such as securities, real estate, or other regulated financial instruments that require compliance with regulatory frameworks. T-REX tokens are permissioned tokens, ensuring secure and compliant transactions for all parties involved in the token transfer.

This standard is based on the T-REX (Token for Regulated Exchanges) framework, as implemented in ERC-3643 (https://github.com/ERC-3643/ERC-3643), but introduces significant architectural improvements for flexibility and modularity.

#Motivation

Real World Assets (RWAs) represent a significant opportunity for blockchain adoption, enabling the tokenization of traditional financial instruments and physical assets. However, unlike standard fungible tokens, RWAs must comply with complex regulatory requirements including but not limited to:

  • Know Your Customer (KYC) and Anti-Money Laundering (AML) compliance
  • Identity verification and investor accreditation
  • Freezing capabilities for regulatory enforcement
  • Recovery mechanisms for lost or compromised wallets
  • Compliance hooks for regulatory reporting

The T-REX standard provides a comprehensive framework for compliant security tokens. This SEP adapts T-REX to the Stellar ecosystem, enabling:

  • Modular hook-based compliance framework with pluggable compliance rules
  • Flexible identity verification supporting multiple approaches (claim-based, Merkle tree, zero-knowledge, etc.)
  • Sophisticated freezing mechanisms at both address and token levels
  • Administrative controls with role-based access control (RBAC)

#Architecture Overview

This T-REX standard introduces an approach built around loose coupling and implementation abstraction.

#Core Design Principles

  1. Separation of Concerns: Core token functionality is cleanly separated from compliance and identity verification
  2. Implementation Flexibility: Compliance and identity systems are treated as pluggable implementation details
  3. Shared Infrastructure: Components can be shared across multiple token contracts to reduce deployment and management costs
  4. Regulatory Adaptability: The system can adapt to different regulatory frameworks without core changes

#Component Architecture

The Stellar T-REX consists of several interconnected but loosely coupled components:

┌─────────────────┐    ┌──────────────────┐    ┌─────────────────────┐
│   T-REX         │───▶│   Compliance     │───▶│  Compliance Modules │
│   (Core)        │    │                  │    │  (Pluggable Rules)  │
└─────────────────┘    └──────────────────┘    └─────────────────────┘
         │
         ▼
┌─────────────────┐    ┌──────────────────┐    ┌─────────────────-─┐
│ Identity        │───▶│ Claim Topics &   │    │ Identity Registry │
│ Verifier        │    │ Issuers          │───▶│                   │
└─────────────────┘    └──────────────────┘    └─────────────────-─┘

This design enables the same core T-REX interface to work with vastly different regulatory and technical requirements for identity verification, such as Merkle trees, Zero-Knowledge proofs, claim-based systems, and others. Furthermore, the modular hook-based system supports diverse regulatory requirements through pluggable compliance rules.

#Compliance Hook Design

Two properties of Soroban's execution model shape how the token communicates with compliance modules, and both move this design away from the EVM-based ERC-3643 it adapts. They are worth understanding before reading the hook interface specified later in this document.

1. One hook per operation, instead of a validate-then-act pair.

ERC-3643 follows a validate-then-act pattern: a read-only pre-check (canTransfer) consulted before tokens move, and a separate state-update call (transferred) made after.

On Soroban, atomicity makes that separate pre-check redundant for enforcement. Any panic anywhere in a call tree reverts the entire transaction and leaves no partial state behind, so a module can reject an operation by panicking from a hook that runs as part of that operation (after its balance changes, but in the same transaction). The result is the same as blocking it up front: nothing the operation did survives the revert. A dedicated read-only validation hook would add nothing on top of a single hook that both checks and panics.

This standard therefore uses one hook per operation (transferred, created, destroyed). Each hook enforces policy by panicking to reject, and records whatever state the module needs. This keeps the design simpler and removes a class of potential misconfigurations.

2. All relevant token state is pushed to modules, to keep the interface future-proof.

Soroban forbids reentrancy: while a hook is executing, the calling token contract is still on the call stack, so a module cannot call back into the token to read state such as a balance or a frozen amount. Everything a module needs to reach its decision must be handed to it at call time.

This may create a versioning hazard. The day a new kind of compliance module needs a piece of token-held state that the hooks do not already carry, supplying it would be a breaking change to the signature every existing module implements. To avoid recurring breaking changes, the token relays the relevant account state to modules proactively, compacted into structs rather than an ever-growing list of parameters. Each account is passed as an AccountSnapshot bundling its address, balance, and frozen amount, and the transfer hook additionally carries a TransferKind describing how the transfer was initiated. The concrete shapes are defined in the Compliance Trait section.

#Interface

The T-REX interface extends the fungible token (SEP-41) with regulatory required features: freezing, pausing and recovery.

#Architecture Overview

The T-REX token does not implement compliance or identity logic itself. It delegates both to external contracts, a Compliance contract and an Identity Verifier contract, and calls into them at fixed points during transfers, minting, burning, and recovery. The token depends on these contracts only through a fixed set of function signatures.

The standard pins down the interface and leaves the behavior to the contract sitting behind that interface. Any Compliance or Identity Verifier contract can be plugged into a token deployment.

From the token's side, that required surface is small. A Compliance contract must expose:

fn transferred(e: &Env, from: AccountSnapshot, to: AccountSnapshot, amount: i128, kind: TransferKind, token: Address);
fn created(e: &Env, to: AccountSnapshot, amount: i128, token: Address);
fn destroyed(e: &Env, from: AccountSnapshot, amount: i128, token: Address);

The token calls transferred after every transfer (standard, delegated, or forced), created after every mint, and destroyed after every burn. Each one runs once the operation's balance changes are already applied, but still inside the same transaction. A hook enforces policy by panicking, which reverts the whole transaction atomically, and otherwise records whatever state the module needs. With no modules registered these hooks do nothing, so a token without compliance modules behaves as if every operation is allowed.

An Identity Verifier contract must expose:

fn verify_identity(e: &Env, user_address: &Address);
fn recovery_target(e: &Env, old_account: &Address) -> Option<Address>;

The token calls verify_identity for every account that sends or receives tokens, and that call panics when the account is not verified.

The token calls recovery_target during recover_balance, to confirm that the new wallet is the registered recovery target for the lost one before moving any balance.

Defining the interfaces, and delegating them to other contracts rather than baking them into the token, is what gives the standard its flexibility for different implementation strategies.

#Contract Connection Interface

The token contract provides simple setter/getter functions for external contracts:

// Compliance Contract Management
fn set_compliance(e: &Env, compliance: Address, operator: Address);
fn compliance(e: &Env) -> Address;

// Identity Verifier Contract Management
fn set_identity_verifier(e: &Env, identity_verifier: Address, operator: Address);
fn identity_verifier(e: &Env) -> Address;

#Integration Pattern

To deploy a compliant T-REX token and make it functional:

  1. Deploy Core Token Contract
  2. Deploy/Connect Compliance Contract
  3. Deploy/Connect Identity Verifier
  4. Configure Connections: Link the T-REX token to its compliance and identity verifier contracts using set_compliance() and set_identity_verifier(). Additionally, configure internal connections within the compliance stack (e.g., linking compliance contract to compliance modules) and identity stack (e.g., linking identity verifier to claim topics/issuers or custom registries) as needed for your implementation.
use soroban_sdk::{Address, Env, String};
use stellar_contract_utils::pausable::Pausable;
use crate::fungible::FungibleToken;

/// Real World Asset Token Trait
///
/// The `RWAToken` trait defines the core functionality for Real World Asset
/// tokens, implementing the T-REX standard for regulated securities. It
/// provides a comprehensive interface for managing compliant token transfers,
/// identity verification, compliance rules, and administrative controls.
///
/// This trait extends basic fungible token functionality with regulatory
/// features required for security tokens.
pub trait RWAToken: TokenInterface {
    // ################## CORE TOKEN FUNCTIONS ##################

    /// Returns the total amount of tokens in circulation.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to the Soroban environment.
    fn total_supply(e: &Env) -> i128;

    /// Forces a transfer of tokens between two whitelisted wallets.
    /// This function can only be called by the operator with necessary
    /// privileges. RBAC checks are expected to be enforced on the
    /// `operator`.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to the Soroban environment.
    /// * `from` - The address of the sender.
    /// * `to` - The address of the receiver.
    /// * `amount` - The number of tokens to transfer.
    /// * `operator` - The address of the operator.
    ///
    /// # Events
    ///
    /// * topics - `["transfer", from: Address, to: Address]`
    /// * data - `[amount: i128]`
    fn forced_transfer(e: &Env, from: Address, to: Address, amount: i128, operator: Address);

    /// Mints tokens to a wallet. Tokens can only be minted to verified
    /// addresses. This function can only be called by the operator with
    /// necessary privileges. RBAC checks are expected to be enforced on the
    /// `operator`.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to the Soroban environment.
    /// * `to` - Address to mint the tokens to.
    /// * `amount` - Amount of tokens to mint.
    /// * `operator` - The address of the operator.
    ///
    /// # Events
    ///
    /// * topics - `["mint", to: Address]`
    /// * data - `[amount: i128]`
    fn mint(e: &Env, to: Address, amount: i128, operator: Address);

    /// Burns tokens from a wallet.
    /// This function can only be called by the operator with necessary
    /// privileges. RBAC checks are expected to be enforced on the
    /// `operator`.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to the Soroban environment.
    /// * `user_address` - Address to burn the tokens from.
    /// * `amount` - Amount of tokens to burn.
    /// * `operator` - The address of the operator.
    ///
    /// # Events
    ///
    /// * topics - `["burn", user_address: Address]`
    /// * data - `[amount: i128]`
    fn burn(e: &Env, user_address: Address, amount: i128, operator: Address);

    /// Recovery function used to force transfer tokens from a old account
    /// to a new account for an investor.
    /// This function can only be called by the operator with necessary
    /// privileges. RBAC checks are expected to be enforced on the
    /// `operator`.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to the Soroban environment.
    /// * `old_account` - The wallet that the investor lost.
    /// * `new_account` - The newly provided wallet for token transfer.
    /// * `operator` - The address of the operator.
    ///
    /// # Events
    ///
    /// * topics - `["transfer", old_account: Address, new_account: Address]`
    /// * data - `[amount: i128]`
    /// * topics - `["recovery_success", old_account: Address, new_account: Address]`
    /// * data - `[]`
    fn recover_balance(
        e: &Env,
        old_account: Address,
        new_account: Address,
        operator: Address,
    ) -> bool;

    /// Sets the frozen status for an address. Frozen addresses cannot send or
    /// receive tokens. This function can only be called by the operator
    /// with necessary privileges. RBAC checks are expected to be enforced
    /// on the `operator`.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to the Soroban environment.
    /// * `user_address` - The address for which to update frozen status.
    /// * `freeze` - Frozen status of the address.
    /// * `operator` - The address of the operator.
    ///
    /// # Events
    ///
    /// * topics - `["address_frozen", user_address: Address, is_frozen: bool]`
    /// * data - `[]`
    fn set_address_frozen(e: &Env, user_address: Address, freeze: bool, operator: Address);

    /// Freezes a specified amount of tokens for a given address.
    /// This function can only be called by the operator with necessary
    /// privileges. RBAC checks are expected to be enforced on the
    /// `operator`.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to the Soroban environment.
    /// * `user_address` - The address for which to freeze tokens.
    /// * `amount` - Amount of tokens to be frozen.
    /// * `operator` - The address of the operator.
    ///
    /// # Events
    ///
    /// * topics - `["tokens_frozen", user_address: Address]`
    /// * data - `[amount: i128]`
    fn freeze_partial_tokens(e: &Env, user_address: Address, amount: i128, operator: Address);

    /// Unfreezes a specified amount of tokens for a given address.
    /// This function can only be called by the operator with necessary
    /// privileges. RBAC checks are expected to be enforced on the
    /// `operator`.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to the Soroban environment.
    /// * `user_address` - The address for which to unfreeze tokens.
    /// * `amount` - Amount of tokens to be unfrozen.
    /// * `operator` - The address of the operator.
    ///
    /// # Events
    ///
    /// * topics - `["tokens_unfrozen", user_address: Address]`
    /// * data - `[amount: i128]`
    fn unfreeze_partial_tokens(e: &Env, user_address: Address, amount: i128, operator: Address);

    /// Returns the freezing status of a wallet.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to the Soroban environment.
    /// * `user_address` - The address of the wallet to check.
    fn is_frozen(e: &Env, user_address: Address) -> bool;

    /// Returns the amount of tokens that are partially frozen on a wallet.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to the Soroban environment.
    /// * `user_address` - The address of the wallet to check.
    fn get_frozen_tokens(e: &Env, user_address: Address) -> i128;

    // ################## METADATA FUNCTIONS ##################

    /// Returns the version of the token (T-REX version).
    ///
    /// # Arguments
    ///
    /// * `e` - Access to the Soroban environment.
    fn version(e: &Env) -> String;

    /// Returns the address of the onchain ID of the token.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to the Soroban environment.
    fn onchain_id(e: &Env) -> Address;

    // ################## COMPLIANCE AND IDENTITY FUNCTIONS ##################

    /// Sets the compliance contract of the token.
    /// This function can only be called by the operator with necessary
    /// privileges. RBAC checks are expected to be enforced on the
    /// `operator`.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to the Soroban environment.
    /// * `compliance` - The address of the compliance contract to set.
    /// * `operator` - The address of the operator.
    ///
    /// # Events
    ///
    /// * topics - `["compliance_set", compliance: Address]`
    /// * data - `[]`
    fn set_compliance(e: &Env, compliance: Address, operator: Address);

    /// Sets the identity verifier contract of the token.
    ///
    /// This function can only be called by the operator with necessary
    /// privileges. RBAC checks are expected to be enforced on the
    /// `operator`.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to the Soroban environment.
    /// * `identity_verifier` - The address of the identity verifier contract to
    ///   set.
    /// * `operator` - The address of the operator.
    ///
    /// # Events
    ///
    /// * topics - `["identity_verifier_set", identity_verifier: Address]`
    /// * data - `[]`
    fn set_identity_verifier(e: &Env, identity_verifier: Address, operator: Address);

    /// Returns the Compliance contract linked to the token.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to the Soroban environment.
    fn compliance(e: &Env) -> Address;

    /// Returns the Identity Verifier contract linked to the token.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to the Soroban environment.
    fn identity_verifier(e: &Env) -> Address;

    // ################## PAUSABLE FUNCTIONS ##################

    /// Returns true if the contract is paused, and false otherwise.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to Soroban environment.
    /// * `caller` - The address of the caller.
    fn pause(e: &Env, caller: Address);

    /// Triggers `Unpaused` state.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to Soroban environment.
    /// * `caller` - The address of the caller.
    fn unpause(e: &Env, caller: Address);

}

#Events

#Transfer Event

The transfer event is emitted when tokens are transferred from one address to another, including forced transfers and recovery operations.

#[contractevent]
pub struct Transfer {
    /// The address holding the tokens that were transferred
    #[topic]
    pub from: Address,
    /// The address that received the tokens
    #[topic]
    pub to: Address,
    /// The amount of tokens transferred
    pub amount: i128,
}

#Mint Event

The mint event is emitted when tokens are minted to a verified address.

#[contractevent]
pub struct Mint {
    /// The address receiving the newly minted tokens
    #[topic]
    pub to: Address,
    /// The amount of tokens minted
    pub amount: i128,
}

#Burn Event

The burn event is emitted when tokens are burned from an address.

#[contractevent]
pub struct Burn {
    /// The address from which tokens were burned
    #[topic]
    pub from: Address,
    /// The amount of tokens burned
    pub amount: i128,
}

#Recovery Success Event

The recovery success event is emitted when tokens are successfully recovered from a lost wallet to a new wallet.

#[contractevent]
pub struct RecoverySuccess {
    /// The old account address from which tokens were recovered
    #[topic]
    pub old_account: Address,
    /// The new account address that received the recovered tokens
    #[topic]
    pub new_account: Address,
}

#Address Frozen Event

The address frozen event is emitted when an address is frozen or unfrozen.

#[contractevent]
pub struct AddressFrozen {
    /// The address that was frozen/unfrozen
    #[topic]
    pub user_address: Address,
    /// The frozen status (true for frozen, false for unfrozen)
    #[topic]
    pub is_frozen: bool,
}

#Tokens Frozen Event

The tokens frozen event is emitted when a specific amount of tokens is frozen for an address.

#[contractevent]
pub struct TokensFrozen {
    /// The address for which tokens were frozen
    #[topic]
    pub user_address: Address,
    /// The amount of tokens frozen
    pub amount: i128,
}

#Tokens Unfrozen Event

The tokens unfrozen event is emitted when a specific amount of tokens is unfrozen for an address.

#[contractevent]
pub struct TokensUnfrozen {
    /// The address for which tokens were unfrozen
    #[topic]
    pub user_address: Address,
    /// The amount of tokens unfrozen
    pub amount: i128,
}

#Compliance Set Event

The compliance set event is emitted when the compliance contract is updated.

#[contractevent]
pub struct ComplianceSet {
    /// The address of the new compliance contract
    #[topic]
    pub compliance: Address,
}

#Identity Verifier Set Event

The identity verifier set event is emitted when the identity verifier contract is updated.

#[contractevent]
pub struct IdentityVerifierSet {
    /// The address of the new identity verifier contract
    #[topic]
    pub identity_verifier: Address,
}

#Reference Implementation: Component Deep Dive

#1. Identity Verification System

Philosophy: The entire identity stack is treated as an implementation detail, enabling maximum regulatory and technical flexibility.

#The IdentityVerifier Trait

pub trait IdentityVerifier {
    /// Core verification function - panics if user is not verified
    fn verify_identity(e: &Env, user_address: &Address);

    /// Returns the target address for the recovery process for the old
    /// account, or `None` if the old account is not a target of any recovery
    /// process. Consumed by `RWAToken::recover_balance` to validate that an
    /// identity recovery has already been performed before moving balances.
    fn recovery_target(e: &Env, old_account: &Address) -> Option<Address>;

    // Setters and getters for the claim topics and issuers contract
    fn set_claim_topics_and_issuers(e: &Env, contract: Address, operator: Address);
    fn claim_topics_and_issuers(e: &Env) -> Address;
}

#Implementation Strategies

Different regulatory environments may require different approaches. Here are some examples:

1. Claim-Based Verification (Reference Implementation)

  • Use Case: Traditional KYC/AML with trusted issuers
  • Components: ClaimTopicsAndIssuers + IdentityRegistryStorage + IdentityClaims + ClaimIssuer
  • Benefits: Familiar to regulators, rich metadata support

2. Merkle Tree Verification

  • Use Case: Privacy-focused compliance with efficient proofs
  • Components: ClaimTopicsAndIssuers + Merkle root storage + proof validation
  • Benefits: Minimal storage, efficient verification

3. Zero-Knowledge Verification

  • Use Case: Privacy-preserving compliance
  • Components: ClaimTopicsAndIssuers + ZK circuit + proof verification
  • Benefits: Maximum privacy, selective disclosure

4. Custom Approaches

  • Use Case: Jurisdiction-specific requirements
  • Components: ClaimTopicsAndIssuers + Custom verification logic
  • Benefits: Tailored to specific regulatory needs

#Reference Implementation Architecture

The claim-based reference implementation demonstrates the full complexity of traditional T-REX compliance:

┌─────────────────────┐
│  Identity Verifier  │
│  (Orchestrator)     │
│                     │
└─────────────────────┘
              │
              ├────▶┌───────────────────────────┐
              │     │ Claim Topics & Issuers    │
              │     │ (Shared Registry)         │
              │     └───────────────────────────┘
              │
              ├────▶┌───────────────────────────┐
              │     │ Identity Registry Storage │
              │     │ (Investor Profiles)       │
              │     └───────────────────────────┘
              │
              ├────▶┌───────────────────────────┐
              │     │ Identity Claims           │
              │     │ (Investor Claims Storage) │
              │     └───────────────────────────┘
              │
              └────▶┌───────────────────────────┐
                    │ Claim Issuer              │
                    │ (Claims Validation)       │
                    └───────────────────────────┘

Key Components:

  • ClaimTopicsAndIssuers: Registry contract managing both trusted issuers and required claim types (KYC=1, AML=2, etc.).
  • IdentityRegistryStorage: Component storing investors identity profiles with country relations and metadata.
  • IdentityClaims: Required for every investor as a separate contract or as an extension to existing identity management systems. Its goal is to store cryptographic claims issued to that investor by trusted claim issuers. This component is under investor's control. Its structure mirrors the evolving OnchainID specification (https://github.com/ERC-3643/ERCs/blob/erc-oid/ERCS/erc-xxxx.md).
  • ClaimIssuer: Contracts belonging to trusted 3rd parties to validate cryptographic claims about investors' attributes by using multiple signature schemes (Ed25519, Secp256k1, Secp256r1).

Note that, Claim-Based Identity Verification is a reference implementation, it is not a part of the specification. For detailed interface specifications of these components, see the Appendix: Claim-Based Identity Verification Reference Implementation section.

#2. Compliance System

Modular hook-based architecture supporting diverse regulatory requirements through pluggable compliance modules.

#The Compliance Trait

/// A point-in-time view of one account, captured as of *before* the operation
/// that triggered the hook. Soroban forbids reentrancy and the token contract
/// is still on the call stack while a hook runs, so a module cannot call back
/// into the token to read a balance; the token passes this snapshot in instead.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AccountSnapshot {
    pub address: Address,
    pub balance: i128, // total balance, before the operation
    pub frozen: i128,  // partially-frozen portion of `balance`
}

/// Identifies who initiated a transfer and under what authority, so a module
/// can decide whether its policy applies.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum TransferKind {
    Standard,           // the holder moves its own tokens
    Delegated(Address), // a delegate moves the holder's tokens; carries the spender
    Forced,             // a privileged operation (forced transfer, recovery)
}

pub trait Compliance {
    // Module management
    fn add_module_to(e: &Env, hook: ComplianceHook, module: Address, operator: Address);
    fn remove_module_from(e: &Env, hook: ComplianceHook, module: Address, operator: Address);
    fn get_modules_for_hook(e: &Env, hook: ComplianceHook) -> Vec<Address>;
    fn is_module_registered(e: &Env, hook: ComplianceHook, module: Address) -> bool;

    // Operation hooks - one per token operation, each called after the
    // operation's balance changes are applied but within the same transaction.
    // A module rejects the operation by panicking (reverting the whole
    // transaction) and records any bookkeeping it needs otherwise.
    fn transferred(e: &Env, from: AccountSnapshot, to: AccountSnapshot, amount: i128, kind: TransferKind, token: Address);
    fn created(e: &Env, to: AccountSnapshot, amount: i128, token: Address);
    fn destroyed(e: &Env, from: AccountSnapshot, amount: i128, token: Address);
}

Each hook accepts a token: Address parameter identifying the calling RWA token contract. This allows a single compliance contract to serve multiple tokens while maintaining per-token state and applying token-specific business logic (e.g., per-token transfer limits, per-token balance tracking).

Each participating account is passed as an AccountSnapshot carrying its pre-operation balance and partially-frozen amount. Because Soroban forbids reentrancy and the token contract is still on the call stack while a hook runs, a module cannot call back into the token to read these values; the snapshot is how the token hands them in, enabling rules such as maximum-balance or holding-period checks without the module mirroring balances itself.

The transfer hook additionally receives a TransferKind. Privileged operations (forced_transfer, recover_balance) pass TransferKind::Forced, letting a policy module exempt them from its checks (a sanctions or lockup rule should not block a court-ordered seizure or an account recovery), while a bookkeeping module still records the movement so its state stays consistent. Standard transfers pass TransferKind::Standard, and delegated (transfer_from) transfers pass TransferKind::Delegated, carrying the spender.

Validity requirements for hooks:

A hook that mutates per-token state MUST be protected against caller spoofing of the token parameter. Concretely, such implementations MUST:

  1. Require authorization from token (e.g., token.require_auth()), so the call can only originate from the token contract whose state is being updated.
  2. Reject calls where token is not bound to the compliance contract.

Without both checks, an attacker could call a hook directly with a token they do not control and corrupt that token's per-module state (e.g., transfer counters, holding-period trackers). A hook that only enforces a policy and keeps no state of its own (panicking on violation, mutating nothing) needs no such authorization: an unauthenticated call can at worst fail the caller's own transaction.

#Hook-Based Architecture

The compliance system uses a sophisticated hook mechanism:

#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
pub enum ComplianceHook {
    Transferred,    // After a transfer: enforce policy (panic to reject) and/or update state
    Created,        // After a mint: enforce policy (panic to reject) and/or update state
    Destroyed,      // After a burn: enforce policy (panic to reject) and/or update state
}

A module registers only for the hooks of the operations it cares about. A module that enforces a rule on transfers but keeps no state registers for Transferred alone; a module that tracks supply registers for Created and Destroyed; and so on.

#Compliance Module Examples

Transfer Limits Module:

  • Hook: Transferred
  • Logic: Track cumulative volume per user and panic when a transfer would exceed a daily/monthly limit

Jurisdiction Restrictions Module:

  • Hook: Transferred + Created
  • Logic: Panic when the recipient's jurisdiction is blocked (forced transfers exempt via TransferKind::Forced)

Holding Period Module:

  • Hook: Created + Transferred
  • Logic: Record a lock on each mint, and panic on a transfer that would spend still-locked tokens

Investor Accreditation Module:

  • Hook: Created
  • Logic: Panic when minting to an unaccredited investor

#Shared Compliance Infrastructure

Compliance contracts are designed to be shared across multiple T-REX tokens, reducing deployment costs and ensuring consistent regulatory enforcement:

┌─────────────┐    ┌─────────────────────┐    ┌──────────────────┐
│   Token A   │───▶│                     │───▶│ Transfer Limits  │
├─────────────┤    │   Shared Compliance │    │ Module           │
│   Token B   │───▶│   Contract          │───▶├──────────────────┤
├─────────────┤    │                     │    │ Jurisdiction     │
│   Token C   │───▶│                     │    │ Module           │
└─────────────┘    └─────────────────────┘    └──────────────────┘

#3. Advanced Token Controls

#Freezing Mechanisms

The system supports multiple freezing strategies to accommodate for different regulatory requirements:

Address-Level Freezing:

fn set_address_frozen(e: &Env, user_address: Address, freeze: bool, operator: Address);
fn is_frozen(e: &Env, user_address: Address) -> bool;
  • Use Case: Complete account suspension for regulatory investigations
  • Effect: Prevents all token operations (send/receive)

Partial Token Freezing:

fn freeze_partial_tokens(e: &Env, user_address: Address, amount: i128, operator: Address);
fn unfreeze_partial_tokens(e: &Env, user_address: Address, amount: i128, operator: Address);
fn get_frozen_tokens(e: &Env, user_address: Address) -> i128;
  • Use Case: Escrow scenarios, disputed transactions
  • Effect: Freezes specific token amounts while allowing operations on remaining balance

#Recovery System

Two distinct recovery flows are supported:

  1. Identity Recovery: Managed in the Identity Stack (Identity Registry Storage), transfers the identity contract reference and profile (including country data) from old account to new account, and creates a recovery mapping.
fn recover_identity(e: &Env, old_account: Address, new_account: Address, operator: Address);
  1. Balance Recovery: Managed by the T-REX token, transfers tokens from old to new account after verifying the identity recovery mapping exists
fn recover_balance(e: &Env, old_account: Address, new_account: Address, operator: Address) -> bool;

#Forced Transfers

For regulatory compliance (court orders, sanctions):

fn forced_transfer(e: &Env, from: Address, to: Address, amount: i128, operator: Address);
  • Use Case: Court-ordered asset transfers, regulatory seizures
  • Authorization: Requires operator with forced transfer permissions
  • Compliance: The compliance hook still runs, but the transfer is signalled as TransferKind::Forced. Policy modules are expected to exempt forced operations (this is what lets a seizure or recovery proceed past a lockup or jurisdiction rule), while modules that track state still update their books. This is finer-grained than an outright bypass: whether a forced operation is exempt becomes a per-module decision rather than all-or-nothing.

#4. Access Control & Governance

T-REX tokens require proper access control to ensure that sensitive operations are only performed by authorized entities:

  • Operator Authorization: All administrative functions require operator authorization
  • Flexible Access Control: While the T-REX interface itself doesn't prescribe a specific access control model, implementations can integrate with external access control systems as needed
  • Compliance Integration: Access control permissions should be integrated with compliance rules to ensure regulatory requirements are met

#Extensions

The T-REX standard can be extended with additional functionality beyond the core specification. Extensions are optional modules that add specialized features while maintaining compatibility with the base T-REX interface.

#Document Manager (ERC-1643)

The Document Manager extension provides document management capabilities for T-REX tokens, following the ERC-1643 standard. This is particularly useful for attaching legal documents, prospectuses, or regulatory disclosures to token contracts.

Key Features:

  • Attach documents with URI, hash, and timestamp
  • Update existing document metadata
  • Remove documents from the contract
  • Retrieve individual or all documents

Use Cases:

  • Legal agreements and terms of service
  • Offering memorandums and prospectuses
  • Regulatory compliance documents
  • Audit reports and certifications

#Custom Extensions

Implementers are free to create custom extensions tailored to their specific requirements.

#Deviations from ERC-3643

Several limitations in the original ERC-3643 standard and its reference implementation were identified and respectively addressed:

1. Tight Coupling Issues

  • Problem: ERC-3643 tightly couples identity verification with specific contract structures
  • Solution: Abstract identity verification as pluggable implementation detail

2. Inflexible Identity Models

  • Problem: Hardcoded ERC-734/735 identity contracts don't translate to all blockchain architectures
  • Solution: Support multiple identity verification approaches (Merkle, ZK, claims, custom)

3. Redundant Contract Hierarchies

  • Problem: Complex IdentityRegistry ↔ IdentityRegistryStorage relationships
  • Solution: Direct access patterns

4. Limited Compliance Flexibility

  • Problem: Monolithic compliance validation
  • Solution: Modular hook-based compliance system

#Upgrade and Migration Strategies

Compliance Evolution:

  • Modular compliance system supports rule updates without token redeployment
  • New compliance modules can be added for evolving regulations

Identity System Migration:

  • Abstract identity verification enables migration between verification approaches
  • Gradual migration strategies for existing user bases

For general contract upgrade patterns and best practices, refer to SEP-49: Upgradeable Contracts.


#Appendix: Claim-Based Identity Verification Reference Implementation

IMPORTANT NOTICE: This appendix describes a reference implementation and is NOT a part of the T-REX specification. The T-REX standard only requires that an IdentityVerifier contract exposing a verify_identity() function. The claim-based approach described here is one possible implementation among many (others include Merkle tree verification, zero-knowledge proofs, or custom approaches). This section is provided for informational purposes to demonstrate a complete, production-ready implementation that follows traditional KYC/AML compliance patterns.

#Overview

The claim-based approach uses cryptographic claims issued by trusted authorities (KYC providers, compliance firms) to verify investor identities. This implementation consists of four main components that work together:

  1. ClaimTopicsAndIssuers: Trust registry defining required claim types and authorized issuers
  2. IdentityRegistryStorage: Storage for investor identity profiles and country relations
  3. IdentityClaims: Per-investor contract storing cryptographic claims
  4. ClaimIssuer: Validator contracts operated by trusted third parties

#ClaimTopicsAndIssuers Interface

The ClaimTopicsAndIssuers contract acts as the trust registry, defining which claim topics are required for token participation and which issuers are authorized to provide those claims.

Purpose:

  • Maintains a registry of trusted claim issuers (e.g., KYC providers, compliance firms)
  • Defines which types of claims are required (e.g., KYC=1, AML=2, Accredited Investor=3)
  • Maps each trusted issuer to the specific claim topics they are authorized to issue
  • Can be shared across multiple tokens to reduce deployment costs

Interface:

pub trait ClaimTopicsAndIssuers {
    // ################## CLAIM TOPICS ##################

    /// Adds a claim topic (for example: KYC=1, AML=2).
    ///
    /// Only an operator with sufficient permissions should be able to call this
    /// function.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to the Soroban environment.
    /// * `claim_topic` - The claim topic index.
    ///
    /// # Events
    ///
    /// * topics - `["claim_added", claim_topic: u32]`
    /// * data - `[]`
    fn add_claim_topic(e: &Env, claim_topic: u32, operator: Address);

    /// Removes a claim topic (for example: KYC=1, AML=2).
    ///
    /// Only an operator with sufficient permissions should be able to call this
    /// function.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to the Soroban environment.
    /// * `claim_topic` - The claim topic index.
    ///
    /// # Events
    ///
    /// * topics - `["claim_removed", claim_topic: u32]`
    /// * data - `[]`
    fn remove_claim_topic(e: &Env, claim_topic: u32, operator: Address);

    /// Returns the claim topics for the security token.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to the Soroban environment.
    fn get_claim_topics(e: &Env) -> Vec<u32>;

    // ################## TRUSTED ISSUERS ##################

    /// Registers a claim issuer contract as trusted claim issuer.
    ///
    /// Only an operator with sufficient permissions should be able to call this
    /// function.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to the Soroban environment.
    /// * `trusted_issuer` - The claim issuer contract address of the trusted
    ///   claim issuer.
    /// * `claim_topics` - The set of claim topics that the trusted issuer is
    ///   allowed to emit.
    ///
    /// # Events
    ///
    /// * topics - `["issuer_added", trusted_issuer: Address]`
    /// * data - `[claim_topics: Vec<u32>]`
    fn add_trusted_issuer(
        e: &Env,
        trusted_issuer: Address,
        claim_topics: Vec<u32>,
        operator: Address,
    );

    /// Removes the claim issuer contract of a trusted claim issuer.
    ///
    /// Only an operator with sufficient permissions should be able to call this
    /// function.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to the Soroban environment.
    /// * `trusted_issuer` - The claim issuer to remove.
    ///
    /// # Events
    ///
    /// * topics - `["issuer_removed", trusted_issuer: Address]`
    /// * data - `[]`
    fn remove_trusted_issuer(e: &Env, trusted_issuer: Address, operator: Address);

    /// Updates the set of claim topics that a trusted issuer is allowed to
    /// emit.
    ///
    /// Only an operator with sufficient permissions should be able to call this
    /// function.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to the Soroban environment.
    /// * `trusted_issuer` - The claim issuer to update.
    /// * `claim_topics` - The set of claim topics that the trusted issuer is
    ///   allowed to emit.
    ///
    /// # Events
    ///
    /// * topics - `["topics_updated", trusted_issuer: Address]`
    /// * data - `[claim_topics: Vec<u32>]`
    fn update_issuer_claim_topics(
        e: &Env,
        trusted_issuer: Address,
        claim_topics: Vec<u32>,
        operator: Address,
    );

    /// Returns all the trusted claim issuers stored.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to the Soroban environment.
    fn get_trusted_issuers(e: &Env) -> Vec<Address>;

    /// Returns all the trusted issuers allowed for a given claim topic.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to the Soroban environment.
    /// * `claim_topic` - The claim topic to get the trusted issuers for.
    fn get_claim_topic_issuers(e: &Env, claim_topic: u32) -> Vec<Address>;

    /// Returns all the claim topics and their corresponding trusted issuers as
    /// a Mapping.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to the Soroban environment.
    fn get_claim_topics_and_issuers(e: &Env) -> Map<u32, Vec<Address>>;

    /// Checks if the claim issuer contract is trusted.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to the Soroban environment.
    /// * `issuer` - The address of the claim issuer contract.
    fn is_trusted_issuer(e: &Env, issuer: Address) -> bool;

    /// Returns all the claim topics of trusted claim issuer.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to the Soroban environment.
    /// * `trusted_issuer` - The trusted issuer concerned.
    fn get_trusted_issuer_claim_topics(e: &Env, trusted_issuer: Address) -> Vec<u32>;

    /// Checks if the trusted claim issuer is allowed to emit a certain claim
    /// topic.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to the Soroban environment.
    /// * `issuer` - The address of the trusted issuer's claim issuer contract.
    /// * `claim_topic` - The claim topic that has to be checked to know if the
    ///   issuer is allowed to emit it.
    fn has_claim_topic(e: &Env, issuer: Address, claim_topic: u32) -> bool;
}

#IdentityRegistryStorage Interface

The IdentityRegistryStorage contract stores identity information for verified investors, including their identity contract references and country relations.

Purpose:

  • Maps wallet addresses to their onchain identity contracts
  • Stores country information for regulatory compliance
  • Supports both individual and organizational identities
  • Manages recovery account mappings for lost wallet scenarios

Country Data Type Flexibility:

The trait interface uses Val for country data parameters instead of a concrete type, allowing implementors to define their own country data structure (e.g., the reference CountryData with rich jurisdictional modeling, hash commitments, Merkle commitments, ZK commitments, or off-chain attestations) while preserving the same contract interface. Implementors handle serialization between Val and their concrete type internally.

An alternative design would use an associated type on the trait (e.g., type CountryData: FromVal<Env, Val>), but the Soroban SDK does not currently support deriving #[contractclient] for traits with associated types, which would prevent generating cross-contract clients from the trait definition. Using Val directly avoids this limitation.

Interface:

pub trait IdentityRegistryStorage {
    /// Stores a new identity with a set of country data entries.
    ///
    /// # Arguments
    ///
    /// * `e` - The Soroban environment.
    /// * `account` - The account address to associate with the identity.
    /// * `identity` - The identity address to store.
    /// * `country_data_list` - A vector of initial country data entries
    ///   (each entry is a `Val` whose concrete shape is defined by the
    ///   implementor).
    /// * `operator` - The address authorizing the invocation.
    ///
    /// # Events
    ///
    /// * topics - `["identity_stored", account: Address, identity: Address]`
    /// * data - `[]`
    fn add_identity(
        e: &Env,
        account: Address,
        identity: Address,
        country_data_list: Vec<Val>,
        operator: Address,
    );

    /// Removes an identity and all associated country data entries.
    ///
    /// # Arguments
    ///
    /// * `e` - The Soroban environment.
    /// * `account` - The account address whose identity is being removed.
    /// * `operator` - The address authorizing the invocation.
    ///
    /// # Events
    ///
    /// * topics - `["identity_unstored", account: Address, identity: Address]`
    /// * data - `[]`
    ///
    /// Emits for each country data removed:
    /// * topics - `["country_removed", account: Address]`
    /// * data - `[country_data: Val]`
    fn remove_identity(e: &Env, account: Address, operator: Address);

    /// Modifies an existing identity.
    ///
    /// # Arguments
    ///
    /// * `e` - The Soroban environment.
    /// * `account` - The account address whose identity is being modified.
    /// * `new_identity` - The new identity address.
    /// * `operator` - The address authorizing the invocation.
    ///
    /// # Events
    ///
    /// * topics - `["identity_modified", old_identity: Address, new_identity:
    ///   Address]`
    /// * data - `[]`
    fn modify_identity(e: &Env, account: Address, identity: Address, operator: Address);

    /// Recovers an identity by transferring it from an old account to a new
    /// account.
    ///
    /// # Arguments
    ///
    /// * `e` - The Soroban environment.
    /// * `old_account` - The account address from which to recover the
    ///   identity.
    /// * `new_account` - The account address to which the identity will be
    ///   transferred.
    /// * `operator` - The address authorizing the invocation.
    ///
    /// # Events
    ///
    /// * topics - `["identity_recovered", old_account: Address, new_account:
    ///   Address]`
    /// * data - `[]`
    fn recover_identity(e: &Env, old_account: Address, new_account: Address, operator: Address);

    /// Retrieves the stored identity for a given account.
    ///
    /// # Arguments
    ///
    /// * `e` - The Soroban environment.
    /// * `account` - The account address to query.
    fn stored_identity(e: &Env, account: Address) -> Address;

    /// Retrieves the recovery target address for a recovered account.
    ///
    /// Returns `Some(new_account)` if the account has been recovered to a new
    /// account, or `None` if the account has not been recovered.
    ///
    /// # Arguments
    ///
    /// * `e` - The Soroban environment.
    /// * `old_account` - The old account address to check.
    fn get_recovered_to(e: &Env, old_account: Address) -> Option<Address>;
}

#IdentityClaims Interface

The IdentityClaims contract manages on-chain identity claims with cryptographic signatures. This contract is controlled by the investor and stores claims issued by trusted authorities.

Purpose:

  • Store cryptographic claims issued to an investor
  • Manage claim lifecycle (add, retrieve, remove)
  • Support multiple claims per topic from different issuers
  • Enable claim-based identity verification

Interface:

pub trait IdentityClaims {
    /// Adds a new claim to the identity or updates an existing one.
    ///
    /// # Arguments
    ///
    /// * `e` - The Soroban environment.
    /// * `topic` - The claim topic (u32 identifier).
    /// * `scheme` - The signature scheme used.
    /// * `issuer` - The address of the claim issuer.
    /// * `signature` - The cryptographic signature of the claim.
    /// * `data` - The claim data.
    /// * `uri` - Optional URI for additional claim information.
    ///
    /// # Events
    ///
    /// * topics - `["claim_added", claim_id: BytesN<32>, topic: u32]`
    /// * data - `[]`
    ///
    /// OR (for updates):
    ///
    /// * topics - `["claim_changed", claim_id: BytesN<32>, topic: u32]`
    /// * data - `[]`
    fn add_claim(
        e: &Env,
        topic: u32,
        scheme: u32,
        issuer: Address,
        signature: Bytes,
        data: Bytes,
        uri: String,
    ) -> BytesN<32>;

    /// Retrieves a claim by its ID.
    ///
    /// # Arguments
    ///
    /// * `e` - The Soroban environment.
    /// * `claim_id` - The unique claim identifier.
    fn get_claim(e: &Env, claim_id: BytesN<32>) -> Claim;

    /// Retrieves all claim IDs for a specific topic.
    ///
    /// # Arguments
    ///
    /// * `e` - The Soroban environment.
    /// * `topic` - The claim topic to filter by.
    fn get_claim_ids_by_topic(e: &Env, topic: u32) -> Vec<BytesN<32>>;
}

Claim Structure:

Each claim contains:

  • Topic: Numeric identifier (e.g., KYC=1, AML=2)
  • Scheme: Signature algorithm identifier
  • Issuer: Address of the claim issuer contract
  • Signature: Cryptographic signature
  • Data: Claim information (can include expiration, metadata)
  • URI: Optional URI for additional information

#ClaimIssuer Interface

The ClaimIssuer contract validates cryptographic claims about investors. These contracts are operated by trusted third parties (KYC providers, compliance firms).

Purpose:

  • Validate cryptographic signatures on claims
  • Support multiple signature schemes (Ed25519, Secp256k1, Secp256r1)
  • Manage claim revocation and expiration
  • Provide key management for signing authorities

Interface:

pub trait ClaimIssuer {
    /// Validates whether a claim is valid for a given identity. Panics if claim
    /// is invalid.
    ///
    /// # Arguments
    ///
    /// * `e` - The Soroban environment.
    /// * `identity` - The identity address the claim is about.
    /// * `claim_topic` - The topic of the claim to validate.
    /// * `scheme` - The signature scheme used.
    /// * `sig_data` - The signature data as bytes: public key, signature and
    ///   other data required by the concrete signature scheme.
    /// * `claim_data` - The claim data to validate.
    fn is_claim_valid(
        e: &Env,
        identity: Address,
        claim_topic: u32,
        scheme: u32,
        sig_data: Bytes,
        claim_data: Bytes,
    );
}

#Verification Flow

The following describes how these components work together during token operations:

  1. Off-chain: Investor submits identity documents to KYC Provider
  2. Off-chain: KYC Provider verifies identity and signs claim with private key
  3. On-chain: Signed claim is stored in investor's IdentityClaims contract
  4. On-chain: During token operations (transfer/mint), the token contract:
    • Calls IdentityVerifier's verify_identity()
    • Required claim topics from ClaimTopicsAndIssuers is retrieved
    • Claims from investor's IdentityClaims contract are fetched
    • ClaimIssuer is called to validate signatures
    • Revocation status is checked
    • Issuer trust is verified via ClaimTopicsAndIssuers

Note: The KYC Provider and Claim Issuer are the same entity. Signing happens off-chain; only the signed claim and revocation data are stored on-chain.


#Changelog

  • v0.1.0 - Initial draft
  • v0.2.0 - Add token: Address to all compliance hooks (can_transfer, can_create, transferred, created, destroyed); add the get_modules_for_hook and is_module_registered getters; add IdentityVerifier::recovery_target; replace the IdentityRegistryStorage CountryData associated type with Vec<Val> parameters; rename the Recovery event to RecoverySuccess; drop the operator topic from AddressFrozen.
  • v0.3.0 - Merge the compliance validation and state-update hooks. Remove can_transfer and can_create (and the CanTransfer/CanCreate ComplianceHook variants), so each token operation has a single hook (transferred, created, destroyed) that both enforces policy (by panicking to reject, which reverts the whole operation atomically) and updates state, running after the operation's balance changes within the same transaction. Pass each account participant as an AccountSnapshot (address, balance, frozen) instead of a bare Address, so modules receive pre-operation balances without calling back into the token. Add a TransferKind (Standard / Delegated(spender) / Forced) parameter to transferred, letting modules exempt privileged (forced/recovery) operations from policy while still applying bookkeeping.