T-REX v5 Audit
OpenZeppelin Security
Findings
Total issues: 54 (51 resolved, 1 partially resolved)
Critical: 0 (0 resolved) · High: 1 (1 resolved) · Medium: 4 (4 resolved) · Low: 35 (32 resolved, 1 partially resolved)
Notes & Additional Information
12 notes raised (12 resolved)
Client Reported Issues
2 reported issues (2 resolved)
Scope
OpenZeppelin performed an audit of the T-REX-Network/T-REX repository at commit 4e7775e.
In scope were the following files:
contracts
├── compliance
│ └── modular
│ ├── modules
│ │ ├── AbstractModuleUpgradeable.sol
│ │ ├── IModule.sol
│ │ ├── MaxBalancePerIdentityModule.sol
│ │ ├── ModuleProxy.sol
│ │ ├── SpenderVerificationModule.sol
│ │ └── SpenderWhitelistModule.sol
│ ├── ComplianceLedger.sol
│ ├── IComplianceLedger.sol
│ ├── IModularCompliance.sol
│ ├── ITransferValidation.sol
│ ├── ModularCompliance.sol
│ ├── TransferContextLib.sol
│ └── TransferValidation.sol
├── ERC-3643
│ ├── base
│ │ ├── ERC3643ClaimTopicsRegistry.sol
│ │ ├── ERC3643Compliance.sol
│ │ ├── ERC3643IdentityRegistry.sol
│ │ ├── ERC3643IdentityRegistryStorage.sol
│ │ ├── ERC3643Token.sol
│ │ └── ERC3643TrustedIssuersRegistry.sol
│ ├── ERC3643ErrorsLib.sol
│ ├── IERC3643.sol
│ ├── IERC3643ClaimTopicsRegistry.sol
│ ├── IERC3643Compliance.sol
│ ├── IERC3643IdentityRegistry.sol
│ ├── IERC3643IdentityRegistryStorage.sol
│ └── IERC3643TrustedIssuersRegistry.sol
├── factory
│ ├── ITREXFactory.sol
│ └── TREXFactory.sol
├── interop
│ ├── ISettlementHandler.sol
│ ├── ITREXMessaging.sol
│ ├── ITrustedGatewayRegistry.sol
│ ├── TREXMessaging.sol
│ └── TrustedGatewayRegistry.sol
├── libraries
│ ├── AccessManagerSetupLib.sol
│ ├── ErrorsLib.sol
│ ├── EventsLib.sol
│ ├── MessageTypesLib.sol
│ ├── RolesLib.sol
│ ├── VersionLib.sol
│ └── WalletKeyLib.sol
├── proxy
│ └── beacon
│ ├── ITREXImplementationAuthority.sol
│ └── TREXImplementationAuthority.sol
├── registry
│ ├── implementation
│ │ ├── IdentityRegistryStorage.sol
│ │ └── TREXRegistry.sol
│ └── interface
│ ├── IIdentityRegistryStorage.sol
│ └── ITREXRegistry.sol
├── token
│ ├── IToken.sol
│ └── Token.sol
├── utils
│ ├── AccessManagedOwnable.sol
│ ├── AccessManagedOwnableBase.sol
│ ├── AccessManagedOwnableUpgradeable.sol
│ ├── IUtilityChecker.sol
│ ├── TREXAccessManager.sol
│ ├── UtilityChecker.sol
│ └── UtilityCheckerProxy.sol
└── vendor
└── IERC173.solUpdate: The fixes for the findings highlighted in this report have all been merged at commit 8a83d80.
System Overview
T-REX is an implementation of ERC-3643, the standard for permissioned security tokens. By default, a holder's transfer reaches only wallets whose ONCHAINID identity holds valid claims from issuers the token trusts, and only when the token's compliance rules allow it. The audited version (5.0.0) keeps the standard surface in a separate layer of abstract base contracts. It adds an identity-level position ledger, a role model built on the OpenZeppelin AccessManager, and positions on satellite chains reconciled through ERC-7786 messaging.
Standard layer: The contracts/ERC-3643/ directory holds one abstract base per ERC-3643 interface, each over its own ERC-7201 storage namespace. Every role check in these bases is an internal hook that the T-REX contracts implement. The token base uses the hook names of a proposed OpenZeppelin ERC-3643 implementation, so that it can later be replaced by it.
Token (Token): The token is an upgradeable ERC-20 with ERC-2612 permit, pause, per-address and partial freezes, minting, burning, forced transfers and wallet recovery. A plain transfer requires an unpaused token, no frozen wallet, and an unfrozen balance that covers the amount. The registry must also verify the recipient, and the compliance must approve. A transferFrom additionally asks the compliance whether the spender may execute it. An investor's ONCHAINID contract can move tokens out of its own linked wallets through identityTransfer. Beyond native ERC-20 balances, the token keeps a bridged ledger of positions held on satellite chains, and its totalSupply counts both. The token is also the only reference-chain contract that sends or receives cross-chain messages.
Registry (TREXRegistry): One contract plays the ERC-3643 identity registry, trusted issuers registry and claim topics registry. It resolves a wallet to an ONCHAINID identity and checks that the identity holds a valid claim for every required topic. Wallet bindings live in a separate identity storage (IdentityRegistryStorage) that several registries can share.
Compliance (ModularCompliance): Each token has one compliance contract. It runs pluggable modules, keeps a ledger of positions per identity, and issues the validations that satellite chains execute. Modules declare one or more types. A RULE module caps the amount a transfer or mint may carry. A SPENDER module approves the account executing a delegated transfer, and a TRACKER module is notified after each movement. Three modules are in scope: MaxBalancePerIdentityModule (a RULE that caps an identity's total position), SpenderVerificationModule and SpenderWhitelistModule (two SPENDER modules). Modules are UUPS proxies, upgraded by whoever their own AccessManager authorizes.
Messaging (TREXMessaging, TrustedGatewayRegistry): The token exchanges ERC-7786 messages with its counterpart on each satellite chain, called the Lite token. A network-wide gateway registry lists the gateways any token may use. Each token then chooses one gateway (its route) and one counterpart address (its peer) per chain. Outbound, the token sends compliance validations, mint instructions and recall instructions. Inbound, it accepts settlement notifications and burn proofs.
Deployment and upgrades (TREXFactory, TREXImplementationAuthority): The factory deploys a suite of registry, compliance and token, plus an optional AccessManager and identity storage, at deterministic CREATE3 addresses, each behind a BeaconProxy. A shared suite uses the five beacons owned by the implementation authority. The authority upgrades all of them together to a published version with a higher version number. An isolated suite receives its own token, registry, storage and compliance beacons, owned by the suite's AccessManager. When the factory also deploys that AccessManager, its beacon is owned directly by the issuer's admin account.
Pre-checks (UtilityChecker): The utility checker exposes view functions that let off-chain systems pre-check a transfer's pause, freeze, eligibility and compliance status. It sits behind its own UUPS proxy and is upgraded by its Ownable owner.
Cross-Chain Transfer Validation
The chain hosting Token is the reference chain. The Lite token on a satellite chain is meant to move tokens only against a ComplianceValidation that the reference compliance issued for that movement. The sending wallet of a validation is always a satellite wallet. The internal functions that would move a native position to a satellite and back (_delegateOut and _recall) have no caller at the audited commit.
A validation starts when requestTransferValidation is called with ERC-7930 addresses for the sender, the recipient and an optional spender. The caller must be the ONCHAINID identity that the registry links to the sending wallet, or an AGENT of the suite. The compliance then performs the following checks:
- The default validity window must be set, and the registry must verify the recipient.
- Every chain that receives a leg must have a reconciliation window and must not be paused for issuance.
- The requested range is capped at the sender's available bridged balance, then at the smallest amount any
RULEmodule allows. - Every
SPENDERmodule must accept the named spender.
The rule check is skipped when both wallets belong to one identity.
On issuance, the compliance stores the validation's terms and reserves the maximum amount against the sending wallet on the token. Unless both wallets belong to one identity, it also reserves that amount against both identities in its ledger. It then sends one leg to the sender's chain. When the wallets sit on two different satellite chains, a second leg goes to the recipient's chain, because one chain burns the tokens and the other mints them. The token pins the gateway each leg used, and a settlement for that leg must return through the same gateway.
A settlement notification reaches the token through receiveMessage. The token checks that the gateway is trusted, that the sender is the chain's peer, that the gateway's message identifier is new, and that the gateway matches the pinned one. The compliance then matches the leg to the stored validation and checks that the amount lies within the issued range. A notification for a never-issued validation, or a matching in-range leg already accounted for, makes the token pause itself. For a two-leg validation, the first burn leg places the amount in transit on the token, and the completing leg must report the same amount. The completing leg moves the identities' positions and releases the reservations. It also either credits the recipient's bridged balance or mints native ERC-20 tokens to a reference-chain recipient.
A validation expires after the default validity window. Its reservation can be released once the largest reconciliation window of the chains involved has also elapsed. After that point, the VALIDATION_KEEPER role can discard a validation that received no leg. A leg arriving after a discard is still applied. If the recorded state then breaches a rule, issuance to that leg's chain is paused.
For a two-leg validation missing one leg, the keeper can call resolveStuckValidation after a second reconciliation window. Held burned tokens then return to the sending wallet. If only the mint leg arrived, issuance to the sending chain is paused instead. Any matching in-range leg arriving after this resolution pauses the token.
Identity Ledger and Eligibility
The registry's isVerified decides eligibility for every mint, transfer and forced transfer. Recovery and a validation settled to a reference-chain wallet do not call it. The registry first resolves the wallet's identity. A binding written in the identity storage takes precedence. Otherwise, the storage asks the ONCHAINID IdentityFactory of each registry bound to it and takes the first non-zero identity. For eligibility, the registry treats a wallet that its own factory reports as revoked as having no identity. It then reads the identity's type from that factory, and applies the claim topics configured for that type or the default topics. For each topic, the identity must hold a claim whose issuer is a trusted issuer for that topic, and that issuer's isClaimValid must confirm it. Cross-chain wallets go through resolveIdentity and isWalletVerified, which ask the factory directly for wallets outside the reference chain.
The registry OWNER can switch eligibility checks off with disableEligibilityChecks. While they are off, isVerified and isWalletVerified return true for every address, so neither native transfers nor cross-chain validations check the recipient's claims, and SpenderVerificationModule accepts any spender.
The identity storage keeps one binding per wallet for all registries bound to it. Any registry holding the IRS_WRITER role on the storage can add, change or delete those bindings. That registry's agents therefore write the bindings every suite sharing the storage reads.
The compliance ledger (ComplianceLedger) tracks one position per identity, summed across the identity's native and satellite wallets, plus pending amounts reserved by open validations. Movements between two wallets of one identity change no position. The ledger records which identity it credited each native wallet to. When an agent relinks a wallet, the next native transfer, mint or burn through that wallet first moves its balance to the new identity. An amount the ledger cannot attribute to an identity is not dropped. It is counted in a signed positionGap. The compliance OWNER can move amounts from the gap or from an identity's position, up to what that position holds, to another position or to the gap through fixPosition. Rules such as MaxBalancePerIdentityModule read these positions and pending amounts, so the ledger's accuracy determines what the rules allow.
Security Model and Trust Assumptions
The following assumptions concern the off-chain services and cross-chain infrastructure:
- The satellite-chain Lite tokens are out of scope. They are assumed to execute a validation only within its amount range and before its expiry, and to report each executed leg exactly once.
- Mint and recall instructions carry a body the sending
AGENTcomposes, and they change no balance on the reference chain. The Lite token is assumed to act on them only in ways the reference ledger reflects. Burn proofs are only recorded as events. - A gateway listed in the
TrustedGatewayRegistryis trusted to report the true author of every message it delivers. A compromised trusted gateway can forge messages for every token that routes a chain through it. Removing the gateway from the registry stops its inbound and outbound traffic for every token using that registry at once. Legs pinned to it can then no longer settle. - The identity manager chooses each chain's route and peer, and therefore which gateway and which sender a token accepts messages from.
- Gateways are assumed to issue a unique message identifier per delivery, because the token's replay guard is keyed by gateway and identifier. A settlement fails while the token is paused, so the gateway is assumed to deliver it again later.
- The token never attaches value or attributes when it sends a message. Any fee a gateway charges is assumed to be paid outside these contracts.
- On EVM satellite chains, the default peer is the token's own address. This assumes the Lite token is deployed at the same address on every chain, unless a peer is set explicitly.
- Validation keepers are assumed to discard expired validations and resolve stuck ones. Until they do, reserved amounts stay unavailable to the sending wallet, and the amount pending toward the recipient's identity still counts against its cap under
MaxBalancePerIdentityModule.
The following assumptions concern external contracts:
- The ONCHAINID
IdentityFactoryset in the registry implementation is fully trusted. It supplies identity types and revocation status. For a wallet without a local binding, the identity storage takes the binding from the factory of any registry bound to it. Suites sharing a storage therefore also trust each other's factories. None of these factory calls is guarded, so a reverting factory halts the identity lookup for wallets without a local binding, and with it every mint, transfer and burn involving them, whether or not eligibility checks are on. While eligibility checks are on, a revert in the registry's own factory also haltsisVerifiedand every holder transfer. - Trusted claim issuers are trusted to answer
isClaimValidtruthfully for their topics. An investor's identity contract is untrusted. The registry checks the issuer it returns against the configured trusted issuer. - Compliance modules are chosen by the compliance
OWNER.RULEmodules are asked on every holder transfer and mint, and cap every validation between wallets of different identities.SPENDERmodules are asked on everytransferFromand every validation that names a spender.TRACKERmodules run after every native transfer, mint and burn (forced transfers and recoveries included) and after every completed settlement. A revertingTRACKERmodule blocks transfers and settlements until it is removed. - The OpenZeppelin
AccessManager(5.7.0), extended byTREXAccessManager, is the only source of role-based authorization. Other checks compare the caller with a bound contract, a linked identity, a gateway listed in theTrustedGatewayRegistryor anOwnableowner. The external ERC-7786 and ERC-7930 libraries are assumed to behave as specified.
The following assumptions concern upgradeability and deployment:
- Every factory-deployed suite contract sits behind a beacon. In shared mode, the platform
VERSION_MANAGERcontrols the implementation of every shared suite's token, registry and compliance, and of its identity storage andAccessManagerwhen the factory deployed them. The implementation authority enforces only that a new version is published and numbered higher than the current one. - A token is initialized paused. Its compliance is bound to the token's predicted address before the token exists. The token's gateway registry is fixed at initialization.
- When a suite uses an
AccessManagersupplied by the issuer, the factory configures no roles. The issuer must commission the suite before it is usable. - The factory grants only
ADMIN_ROLEto the issuer's admin account. Every other suite role must then be granted explicitly. Every grant the factory and the commissioning routine make carries no execution delay. - The compiler targets the Cancun EVM version, and the token's reentrancy guard relies on transient storage (EIP-1153).
Privileged Roles
Roles are AccessManager role identifiers that combine a domain number with a role number. The factory assigns each suite it commissions its own domain. Platform roles live in a reserved domain. Unless stated otherwise, the admin that grants and revokes a role is ADMIN_ROLE. No role guardian is set, so ADMIN_ROLE is every role's guardian.
Suite administrator (ADMIN_ROLE): The factory grants this role to the issuer's admin account and then renounces it. The role is not tied to a domain, so it governs every suite on its AccessManager. It grants and revokes OWNER, AGENT_ADMIN, SUITE_ADMIN and IRS_WRITER. It can remap any function to any role, change role admins, guardians and grant delays, create domains and assign contracts to them. When the factory creates the token's ONCHAINID, the suite AccessManager holds its management key, so this role controls that identity. It calls every function no other role is mapped to, including authority rotation through transferOwnership. In an isolated suite, it upgrades the token, registry, storage and compliance beacons.
Owner (OWNER): On the registry, the owner manages trusted issuers and claim topics, replaces the identity storage, and can disable eligibility checks. On the compliance, it adds and removes modules, calls module functions, binds and unbinds the token, and moves ledger positions through fixPosition. On the identity storage, it unbinds registries.
Agent (AGENT, granted by AGENT_ADMIN): An agent registers, updates and deletes identity bindings through the registry. By rebinding a holder's wallet to another identity, it lets that identity move the wallet's balance through identityTransfer. It can request a transfer validation for any satellite wallet, and dispatch mint and recall instructions to satellite chains. The token contract itself holds this role, because recovery registers and deletes identities.
Specialized agents (granted by AGENT_ADMIN): The following roles each control one capability of the suite:
- Minter (
AGENT_MINTER): mints, including while the token is paused. - Burner (
AGENT_BURNER): burns from any wallet, including frozen balances. - Freezers (
AGENT_PARTIAL_FREEZER,AGENT_ADDRESS_FREEZER): freeze balances or whole addresses. - Recovery agent (
AGENT_RECOVERY_ADDRESS): moves a lost wallet's whole balance to a new wallet bound to the investor identity the agent names, without compliance or eligibility checks. - Forced-transfer agent (
AGENT_FORCED_TRANSFER): moves balances to a verified wallet without compliance or freeze-flag checks. - Pauser (
AGENT_PAUSER): pauses and unpauses the token, including after a settlement-triggered halt. - Validation keeper (
VALIDATION_KEEPER): discards expired validations and resolves stuck ones. - Registry binder (
IRS_BINDER): binds registries to the identity storage, which adds their factories to its identity lookup.
Managers (granted by SUITE_ADMIN): The token manager (TOKEN_MANAGER) sets the name and symbol. The identity manager (IDENTITY_MANAGER) sets the token's ONCHAINID, registry, compliance, routes and peers. The compliance manager (COMPLIANCE_MANAGER) sets the validity and reconciliation windows and pauses issuance per chain.
Storage writer (IRS_WRITER): Each commissioned registry holds this role on the identity storage to write wallet bindings.
Isolated AccessManager beacon owner: When the factory deploys an isolated suite's AccessManager, the issuer's admin account owns that beacon directly and can upgrade the AccessManager implementation.
Platform roles: The platform OWNER deploys suites and sets the implementation authority, IdentityFactory and gateway registry that later suites use. The VERSION_MANAGER publishes and applies implementation versions on the implementation authority. The INTEROP_MANAGER adds and removes trusted gateways. The ASSET_DEPLOYER role lets the factory create token identities on the ONCHAINID IdentityFactory. The platform ADMIN_ROLE grants these roles and calls every unmapped function on the factory, the implementation authority and the gateway registry.
Module upgraders: Each compliance module's own AccessManager decides who may upgrade it.
UtilityChecker owner: The Ownable owner upgrades the UtilityChecker.
All holders of these roles are assumed to act in the interest of the issuer and investors of the suites they serve.
High Severity
Agent of One Domain Can Seize Holder Balances of Another Domain Through Shared Identity Storage
An IdentityRegistryStorage instance can be shared by suites assigned to different role domains on the same TREXAccessManager. commissionSuite keeps a reused storage in its first domain, and each suite's registry is granted IRS_WRITER in that storage's domain. The project documentation states that two domains are isolated and that a storage shared across domains gives the other domain's agents nothing.
This isolation does not hold. updateIdentity only checks the caller against the called registry's own role mapping, and the storage then replaces the binding in a single wallet-keyed mapping without recording which suite registered the wallet. An AGENT of domain B can therefore act on a holder W of token A as follows:
- Through
B's registry, register a recipient wallet underW's identity, so the recipient passesA's eligibility checks. - Through
B's registry, rebindWto an address the agent controls. - Call
identityTransferon tokenA, which authorizes the move solely on the caller matching the identity resolved forW, with no allowance.
The full balance of W can be moved to the recipient whenever A is unpaused, the balance is unfrozen, and no compliance rule refuses the movement. The same write path also lets a foreign agent delete or overwrite the identity attribution of any holder of A. The precondition is an administrator configuration that the project documents and tests. An agent holding no role in A obtains an effect stronger than A's own forced-transfer role without any of its grants.
Consider scoping identity writes to the suite or domain that created each binding, for example by recording the registering registry or domain per wallet and refusing modification or deletion from any other, or alternatively by refusing to bind registries from a different domain than the storage's own. If cross-domain sharing is not a required feature, consider removing it and correcting the documented isolation guarantee.
Update: Resolved in pull request #148 at commit 603a599.
Medium Severity
Mint Instructions Can Be Dispatched Without a Reference-Chain Debit
The reference chain reports totalSupply as the whole issuance, native supply plus totalBridged, and a native position reaches a satellite only through _delegateOut, which burns the native amount and credits the bridged ledger. However, dispatchMintInstruction sends a MINT_INSTRUCTION with a caller-supplied body and moves no balance, and _delegateOut has no caller. Its selector is assigned to AGENT, whereas native minting requires AGENT_MINTER.
If a satellite mints on receipt, an AGENT holder without minting rights can create supply that totalSupply does not report, and any later reference-chain debit of that position reverts with InsufficientBridgedBalance.
Consider sending the mint instruction only from the delegation-out flow, after _delegateOut debits the holder, with the body derived from its arguments, or leaving the selector unassigned until that flow exists.
Update: Resolved in pull request #141 at commit 8280547.
Token Implementation Exceeds The EIP-170 Code Size Limit And Cannot Be Deployed
Each token suite is built on beacon proxies, and the TREXFactory creates the token beacon on top of a previously deployed Token implementation. That implementation is an ordinary contract, so its runtime bytecode is subject to the 24,576-byte limit of EIP-170. Under the repository's only build profile (solc 0.8.30, via-IR, 200 optimizer runs, Cancun), Token compiles to 30,574 bytes of runtime code, 5,998 bytes over the limit. Its creation transaction therefore fails on every configured network (Ethereum, Base, Polygon and Base Sepolia), and without a token implementation the factory cannot deploy any suite.
The condition does not surface during development. The unit tests deploy Token directly, for example in the shared token test setup, and pass, and the CI workflow runs only tests and coverage with no size check. The changelog notes that Token was already over the limit but records no plan or dependency on a future limit increase such as EIP-7954, which would have to be active on every target network before the current bytecode could be deployed.
Consider reducing the runtime size of Token below 24,576 bytes, for example by moving logic into external libraries or separate contracts. Alternatively, if deployment is intended to wait for a higher limit, consider documenting that dependency and gating deployment on its activation per network. In either case, consider adding a size check such as forge build --sizes to CI so that growth past the limit fails the build.
Update: Resolved in pull request #142 at commit 9de216e.
Registry Agents Can Rebind a Wallet to Their Own Identity and Spend Its Balance
The registry role table grants the plain AGENT role the ability to register, update, and delete wallet identities, while the token role table reserves forcedTransfer and recoveryAddress for the separate AGENT_FORCED_TRANSFER and AGENT_RECOVERY_ADDRESS roles. However, registerIdentity and updateIdentity accept any identity address without wallet consent, the storage layer only checks for a non-zero identity and the presence of an existing entry, and a local binding takes precedence over the factory-resolved identity. Meanwhile, identityTransfer treats the caller as authorized to move a wallet's tokens whenever it equals the identity the registry currently resolves for that wallet, with no allowance and no wallet signature.
As a result, an account holding only the registry AGENT role can bind a funded wallet to an identity it controls and then call identityTransfer from that identity to move the wallet's free balance to any verified recipient. This turns identity attribution into the seizure power that the role split assigns to AGENT_FORCED_TRANSFER, without holding that role. The transfer remains subject to the pause, freeze, verification, and compliance checks, and the rebinding is visible on chain through the registry events, but a compromised or malicious registry agent can drain the free balance of any holder it can rebind.
Consider decoupling identityTransfer authority from mutable registry attribution, for example by requiring wallet-authenticated consent to establish or change the identity allowed to spend a wallet, or by requiring the forced-transfer role for any registry write that replaces an existing or factory-resolved identity of a wallet with a non-zero balance.
Update: Resolved in pull request #186 at commit d0b7258.
Self-Transfer After a Wallet Relink Overstates the New Owner's Ledger Position
The compliance ledger records which identity owns each native wallet and catches up on a registry relink lazily, on the wallet's next movement. Because the token hooks run after balances are updated, _applyMovement passes an adjustment of +value for the sender and -value for the recipient, and _currentOwner adds it to the current balance to recover what the wallet held before the movement. Neither the token's _update nor transferred rejects a transfer where from == to.
In a self-transfer the wallet's balance does not change, yet the sender leg still computes the pre-movement balance as balanceOf + value. When this is the first movement after a relink, _moveWalletBalance debits the previous identity and credits the new owner with that inflated amount. The recipient leg then finds the owner already followed, and _movePosition returns early because both sides resolve to the same identity, so the excess is never reversed. A reproduction that relinks a wallet holding 1000 tokens and self-transfers 100 records 1100 moved to the new owner. The new owner's position is overstated by value, and the previous identity's position is understated by the same amount or, when it holds less, the shortfall comes off the gap. Cap and holder-count rules read these positions and can evaluate incorrect figures until the owner repairs them with fixPosition, and a holder can trigger this deliberately to free cap room for the previous identity. The error is bounded by the wallet balance and occurs at most once per relink, and no tokens move.
Consider making the adjustment reflect the net effect of the movement on each wallet, so that a self-transfer passes a zero adjustment, or skipping the ledger movement entirely when from == to.
Update: Resolved in pull request #143 and superseded by pull request #199. #143 made the ledger adjustment the movement's net effect on the wallet, so a self-transfer passes zero. #199 then stopped the ledger from moving a balance between identities on a relink. Any movement through a funded wallet whose registry identity differs from the credited one now reverts, and only an empty wallet takes its new owner. A self-transfer as the first movement after a relink therefore reverts instead of crediting the new owner, and neither identity's position can be overstated.
Low Severity
Recovery Agent Can Bind a Fresh Wallet to Any Identity
The _recoveryAddress override compares investorOnchainId with the registry only when the new wallet is already registered. When the new wallet is unregistered and the lost wallet is registered, any identity passes. The _registerRecoveredWallet function writes that binding into the shared identity storage using the registry AGENT role that the token is granted.
As a result, a holder of AGENT_RECOVERY_ADDRESS can register a fresh wallet under an arbitrary identity, a write otherwise reserved for the registry AGENT role. The call needs a registered wallet with a non-zero balance and moves that entire balance to the fresh wallet. The fresh wallet is registered on every registry bound to the shared storage and verified wherever the chosen identity satisfies the claim requirements. Both roles are administered by AGENT_ADMIN, so the escalation stays within one trust tier. Recovery onto a wallet already registered to another identity is intended behavior, so the new capability is the binding itself.
Consider requiring, when the new wallet is unregistered, that investorOnchainId equal the identity the registry attributes to the lost wallet. Recovery onto a different identity remains available in two steps: a holder of the registry AGENT role registers the new wallet first, and the existing check then applies.
Update: Resolved in pull request #183 at commit c72411a.
Zero-Value Transfers Revert Despite Passing Every Transfer Check
_update lets a zero amount pass the freeze, balance, identity, and compliance checks, moves the balance, and then notifies compliance with the unfiltered amount. However, transferred reverts with ZeroValue when the amount is zero, so every transfer and transferFrom of zero tokens reverts. This breaks the ERC-20 requirement that zero-value transfers be treated as normal transfers, and integrations that submit computed zero amounts may fail. The same guard in created and destroyed also makes zero-value mints and burns revert.
Consider accepting zero amounts in the compliance notifications (skipping ledger updates when nothing moves), or returning early in the token before notifying compliance, so that zero-value transfers complete and emit their Transfer event.
Update: Resolved in pull request #146 at commit 4467f6d.
Trusted Issuer Returning No Data Validates Any Claim Naming It
When checking a claim, _hasValidClaimForTopic queries the trusted issuer through staticcallReturn64Bytes and accepts any non-zero first word. That helper reads scratch memory without checking returndatasize, so an issuer with no code, or one that returns no data, yields a stale word that is typically non-zero. Since _addTrustedIssuer rejects only the zero address, an owner who trusts such an address (for example, a signer key instead of the ClaimIssuer contract) allows any registered identity whose getClaim names that issuer to pass isVerified without the issuer ever affirming the claim.
Consider accepting the answer only when the call succeeds, returns at least 32 bytes, and decodes to true, and rejecting issuers without code when they are added.
Update: Resolved in pull request #184 at commit 693b095.
Batch Selectors Cannot Be Restricted to ADMIN_ROLE
A batch function without a role of its own is authorized through its single-item counterpart, and _checkCanCallSelector detects that case as a zero getTargetFunctionRole result. Since ADMIN_ROLE is also zero, an explicit ADMIN_ROLE assignment on batchMint is ignored, and AGENT_MINTER holders can still call it. No capability beyond repeated mint calls is gained.
Consider documenting that batches can only be restricted through a nonzero role, or assigning batch selectors explicitly in the suite permission tables.
Update: Resolved in pull request #180 at commit 2e9a655.
Predicted Token Address Can Be Squatted Through Public Identity Types
The token address of each suite is predictable from the salt. When INDIVIDUAL or CORPORATE identity creation is open to PUBLIC_ROLE, as in the reference policy, the onchain-id createIdentityFor function binds such an identity to any account that is its sole MANAGEMENT key, without a signature from that account. An attacker can therefore bind the undeployed token address in advance. Deployment through _deployToken then reverts: automatic ASSET identity creation fails on the occupied binding, and a supplied ONCHAINID fails the TokenIdentityAlreadyBound check. The binding is permanent, so the salt is unusable on that chain, each retry with a new salt can be front-run, and same-address deployments across chains may be prevented.
Consider requiring proof of account control for third-party bindings of non-single-binding types, or keeping those types off PUBLIC_ROLE wherever this factory deploys suites.
Update: Partially Resolved in pull request #181 at commit c438483. The TREXFactory NatSpec now states that createIdentityFor for INDIVIDUAL, CORPORATE and every other type that is not single-binding must not be open to PUBLIC_ROLE, and the reference policy gates those types behind a named platform role. No contract logic changed, so the requirement is not enforced on-chain: setupIdentityFactoryPolicy registers only ASSET, and _deployToken still reverts on a predicted token address that any holder of a non-single-binding type role has bound in advance. Removing the vector requires proof of account control for third-party bindings in ONCHAINID, which the team places outside T-REX.
Late Settlements of Discarded Validations Can Consume Balance Reserved for Newer Validations
A keeper can discard an expired validation, which releases its wallet reservation even if the satellite already executed the transfer. A newer validation can then reserve the same amount through availableOf. When the old settlement arrives, it is still applied, and _debitBridged checks only the total bridged balance, so the debit can consume the backing of the newer reservation. A late first burn leg has the same effect through _holdInTransit.
If the newer validation executes on the satellite against a credit whose settlement is still pending, its settlement reverts with InsufficientBridgedBalance. The delivery remains retryable, so reconciliation is delayed rather than lost.
Consider keeping the potential liability of a discarded validation counted against the wallet until its outcome is known, or explicitly resolving conflicts with newer reservations when a late debit is applied.
Update: Acknowledged, will resolve. Pull request #185 changes no behaviour for this issue: a discard still releases the wallet reservation, and a late settlement or burn leg is still applied against the bridged balance. The pull request instead documents the limitation on discardExpiredValidations and advises setting each chain's reconciliation window above the gateway's longest delivery delay. The team intends to close the issue separately by having the satellite report each validation it let expire, so that a discard keeps the wallet reservation until either that report or the late settlement arrives, pending a product decision on how long the wallet may stay reserved.
Rebinding A Compliance To A New Token Carries Over Stale Ledger Positions
The token binding check of ModularCompliance only requires the incoming token to have zero supply. It neither inspects nor resets the identity positions, pending amounts, or gap left by a previously bound token, and a zero-supply token can switch to another compliance, freeing the old one for a new token.
Positions can survive a zero supply. Tokens minted to an unresolved wallet land in gap, the owner moves them to identity X, and a later burn from the still-unresolved wallet debits gap instead. After a new token is bound, MaxBalancePerIdentityModule counts the stale position toward the cap of X, which can block legitimate mints and transfers until the owner repairs the ledger.
Consider requiring an empty ledger (every position, pending amount, and gap at zero) before binding a different token, or scoping ledger state to the bound token.
Update: Resolved in pull request #189 at commit 403b999.
Unbinding A Registry Can Change The Fallback Identity Of Other Wallets
When a wallet has no local binding, storedIdentity falls back to _globalIdentity, which returns the first identity found across the factories of the bound registries, in set order. Each TREXRegistry implementation fixes its own IdentityFactory, so registries bound to one storage may map the same wallet to different identities, and no rule defines which one prevails.
Unbinding a registry uses swap-and-pop removal, which reorders the remaining registries. Unbinding a registry that does not know a wallet can therefore switch that wallet from one factory's identity to another's, with no wallet-level event. Claim verification and compliance attribution then follow the new identity.
Consider rejecting registries whose factory differs from those already bound, or resolving the fallback through an explicit, stable precedence.
Update: Resolved in pull request #188 at commit f250163.
Verification Diagnostics Revert for Wallets Without an Identity
The getVerifiedDetails view is meant to report per-topic claim checks and the wallet status. A wallet with no local binding and no linked global identity resolves to the zero address, yet the default claim topics are still returned. For any topic with a trusted issuer, _getEligibility calls getClaim on the zero address, which reverts. As a result, the diagnostic fails for the most common reason a wallet is unverified, including wallets whose local registration was deleted. The registry's isVerified decision and transfer enforcement are unaffected.
Consider returning early when the identity is the zero address, with failed entries for each required topic alongside the wallet status.
Update: Resolved in pull request #146 at commit 4467f6d.
Closing A Chain Route Does Not Stop Pinned Inbound Settlements
An issuer closes a chain by setting its route to zero, and the interface describes isChainOpen as true only when a message can be accepted from that chain. However, a settlement for an already dispatched validation is checked by _requireExpectedGateway against the gateway pinned at dispatch alone, never against the current route. Since closing the route clears neither the pin nor the peer, a still-trusted gateway can deliver a settlement that is forwarded to the compliance and applied after the chain has been closed. Pausing the token or removing the gateway from the registry still blocks delivery.
Consider rejecting pinned settlements when the current route is zero while keeping pin matching for switches between nonzero routes, or alternatively documenting that closing a chain only stops outbound traffic and new validations.
Update: Resolved in pull request #146 at commit 4467f6d.
Accumulated Large Expired Claims Can Exhaust Gas During Identity Verification
For each required topic and trusted issuer, _hasValidClaimForTopic copies the full claim, including signature, payload, and URI, before asking the issuer whether it is still valid. The registry caps bound topics and issuers but not claim size, and the pinned ONCHAINID revision allows up to 2,048 bytes per field and keeps expired claims until removal. Roughly 40 to 50 maximum-size expired claims ahead of the valid ones can push isVerified past a 16,777,216-gas transaction cap, so incoming transfers and mints to that holder revert. Only trusted issuers can sign such claims, and the holder can recover by removing stale claims.
Consider reading a claim's expiry before its dynamic fields, or capping the combined claim size so that worst-case verification fits the target chain's gas limit.
Update: Acknowledged, not resolved. In pull request #184 at commit a458df3, the NatSpec of _hasValidClaimForTopic now documents the limit: each claim is loaded whole through getClaim before its issuer can report it expired, about 30 maximum-size expired claims from issuers trusted ahead of the valid one exceed a 16,777,216-gas transaction, and the holder recovers by removing the stale claims. The verification loop is otherwise unchanged in this respect. The team considers both recommended mitigations infeasible on the pinned ONCHAINID revision, since getClaim is the only getter that exposes validUntil and the registry has no hook at addClaim through which to impose a smaller size cap, and regards a getter in ONCHAINID that returns a claim's validity window without its dynamic fields as the proper fix.
Gas-Exhausting Trusted Issuer Can Block Verification Despite Valid Alternative Claims
The verification loop queries each trusted issuer through a staticcall with no gas cap, although the function documentation states that a hostile issuer must not halt verification. An issuer that consumes its forwarded gas leaves only one sixty-fourth of the remaining gas, so checking a later issuer or the remaining topics can run out of gas. Verification of any identity holding that issuer's claim can then fail, and transfers to it revert until the issuer is removed.
Consider bounding the gas forwarded to each isClaimValid call and treating an exhausted call as an invalid claim.
Update: Resolved in pull request #184 at commit 389ce14.
Delayed Owners Cannot Bind or Unbind Tokens on Compliance
The token binding hooks of ModularCompliance admit the owner through _isOwner, which queries the synthetic BIND_UNBIND_TOKEN selector and discards the returned execution delay. Only that synthetic selector is mapped to OWNER, while the real bindToken and unbindToken selectors fall back to the admin role.
As a result, an OWNER granted with an execution delay cannot call either function directly and cannot schedule them. Scheduled execution through the manager also fails, because the manager is never authorized for a selector that does not appear in calldata. An owner configured with a timelock therefore cannot unbind a token, and must keep an immediate OWNER holder, which defeats the timelock.
Consider mapping the real bindToken and unbindToken selectors to OWNER and authorizing the owner path through the standard access-managed check that consumes scheduled operations, keeping the token-self paths unchanged.
Update: Resolved in pull request #146 at commit 4467f6d.
Suite Migration Can Silently Shorten Execution Delays on Shared Destination Roles
The migrateSuitesToDomains function copies each listed role assignment through _grantFrom, which grants the destination role with the source execution delay without consulting any existing destination membership. If the account already holds that role with a longer delay, the grant overwrites it, and the reduction takes effect once the delay-change setback elapses.
Because tokens in one domain share their roles, the shorter delay then applies to every suite in the destination domain, such as mint through AGENT_MINTER on a token unrelated to the migration. Revoking the source role afterwards does not restore it, so a timelock safeguard is weakened as a side effect of the migration.
Consider keeping the longer of the existing and source delays when the account already holds the destination role, or reverting so that the operator resolves the conflict explicitly.
Update: Resolved in pull request #148 at commit 603a599.
Wallets Revoked by One Identity Factory Remain Verified Through Registries Sharing Storage
When a wallet has no local entry, _globalIdentity returns the first identity that any bound registry's identity factory links to it, revoked bindings included, and discards that factory's status. Admission through _activeIdentityOf then asks only the consuming registry's own factory about revocation. If registries bound to one storage use different identity factories, a wallet revoked by factory A but unknown to factory B (status None) passes isVerified in registry B once B's required claims are met, so B's token can mint or transfer to it. This requires a privileged configuration that binds registries with different identity factories to the same storage.
Consider requiring every registry bound to a storage to share one identity factory, or having the fallback return the supplying factory's status so that admission can deny a revoked wallet.
Update: Resolved in pull request #188 at commit f250163.
Shared Storage Can Resolve a Wallet to Another Factory's Identity
An IdentityRegistryStorage can be bound to registries whose implementations use different identity factories. For a wallet without a local binding, _globalIdentity returns the first identity known to any bound registry's factory, regardless of which registry asks. The requesting registry then reads the identity type from its own factory, where a foreign identity has type 0, so the default claim topics apply instead of the override set for the wallet's actual type. With an empty default set, isVerified can succeed without the required claims. Similarly, a wallet revoked in another factory is checked for revocation only in the requesting registry's factory and can still be admitted.
Consider resolving the fallback through the requesting registry's own factory, or restricting a shared storage to registries that use the same factory.
Update: Resolved in pull request #188 at commit f250163.
Suite Migration Can Interrupt Agent Functions When Destination Roles Have A Grant Delay
The migrateSuitesToDomains function grants roles in the destination domain, remaps every suite selector to that domain, and revokes the source roles in a single call. Its _grantFrom helper requires the source grant to be active, but the destination grant is subject to that role's own grant delay, and nothing checks that it is active before the remap and revocation. If an administrator has configured a nonzero grant delay on destination roles, operators can lose access to remapped functions such as mint and pause until the delay elapses, unless they already hold the destination role.
Consider requiring each destination grant to be active before remapping selectors and revoking source roles, or documenting that destination roles must be granted in advance when a grant delay applies.
Update: Resolved in pull request #148 at commit 603a599.
Peer Changes Strand Settlements From the Previously Addressed Peer
When a compliance validation is dispatched, _sendComplianceValidation pins the gateway of each leg but not the peer it addressed, and _setPeer replaces a chain's peer regardless of outstanding validations. Inbound messages are authenticated against the current peer before the pinned gateway is checked. If an IDENTITY_MANAGER changes the peer while a validation is in flight, the former peer's settlement reverts with SenderNotPeer, leaving the reference-chain ledger and reservations unreconciled even if the transfer executed. Recovery requires restoring the former peer and redelivering, since discarding the expired validation does not apply the settlement.
Consider pinning the addressed peer alongside the gateway at first dispatch and authenticating settlements for pinned validations against that peer, while messages without a pinned validation continue to use the current peer.
Update: Resolved in pull request #146 at commit 4467f6d.
Zero Receive IDs Block All Later Inbound Messages From a Gateway
ERC-7786 allows a gateway to omit the receive identifier by passing bytes32(0). However, _processMessage records every (gateway, receiveId) pair as consumed, including zero. If a trusted gateway supplies zero identifiers, its first inbound message succeeds and every later settlement or burn proof from it reverts with MessageAlreadyReceived. Settlements for validations pinned to that gateway cannot be delivered through another route and are left to expire.
Consider skipping the replay check for a zero receiveId, leaving deduplication to the gateway, or keying replay protection on a protocol-level message identifier.
Update: Resolved in pull request #185 at commit 88e2a29.
Cross-Chain Validations Bypass Identity Caps for Recipients Without an Identity
When eligibility checks are disabled, isWalletVerified accepts any wallet, so a validation can be issued to a satellite wallet that resolves to no identity. Unlike the native rule check, which refuses such a recipient whenever a RULE module is bound, _narrowAmountRange passes the zero identity to the rules. MaxBalancePerIdentityModule answers no limit for a zero recipient, and settlement credits the amount to the position gap. Tokens can therefore reach a holder beyond the configured cap and remain outside every identity position until the owner repairs the ledger with fixPosition.
Consider refusing a recipient that resolves to no identity at validation issuance whenever a RULE module is bound, matching the native path.
Update: Resolved in pull request #189 at commit deefee3.
Out-of-Range fixPosition Amounts Break Ledger Conservation
The owner-only fixPosition moves position between an identity and the signed gap, which keeps sum(positions) + gap == totalSupply. When either side is the gap, _fixPosition converts amount with an explicit int256(amount) cast, which does not revert in Solidity 0.8. An amount above type(int256).max therefore changes sign. For example, fixPosition(address(0), X, type(uint256).max) raises gap by one while crediting X with 2^256 - 1, breaking the relation. Limits reading that position, such as the per-identity balance check, may then revert or evaluate a meaningless value. Triggering it requires an erroneous owner input, and the same call reversed restores both sides.
Consider converting amount with a checked cast such as SafeCast.toInt256, or rejecting amounts above type(int256).max.
Update: Resolved in pull request #189 at commit deefee3.
Binding a Base ERC-3643 Compliance Can Disable Delegated Transfers After Issuance
The token's _setCompliance only requires the new contract to advertise IERC3643Compliance through ERC-165. However, transferFrom unconditionally calls canSpenderCall, which exists only in IModularCompliance, and settlement delivery calls handleSettlement from ISettlementHandler. A compliance implementing only the base interface can therefore be bound, after which minting and direct transfers succeed while every transferFrom and every settlement delivery reverts.
Once tokens are minted, the circulation guard blocks setCompliance, so recovery requires burning the entire supply or upgrading the compliance. Binding requires a token administrator, which limits the likelihood.
Consider requiring ERC-165 support for every interface the token calls on its compliance, such as IModularCompliance and ISettlementHandler, all of which ModularCompliance already advertises.
Update: Resolved in pull request #187 at commit 34cd0d9.
Token Recovery Can Credit A Revoked Wallet
Minting, ordinary transfers, and forced transfers all require the recipient to pass isVerified. Recovery only requires the new wallet to be unbound or bound to the supplied identity, and _registerRecoveredWallet accepts an already-bound wallet as is. A wallet revoked through the identity factory keeps its binding while isVerified refuses it, so a recovery agent can move the lost wallet's entire balance onto it. Since ordinary transfers check only the recipient, the revoked wallet can then move those tokens out, which matters if it was revoked because its key was compromised.
Consider requiring the new wallet to pass isVerified in the recovery path, consistent with forced transfers.
Update: Resolved in pull request #183 at commit c72411a.
Supplied Token Identity Is Never Bound, Allowing a Later Conflicting Binding
When a suite is deployed with a caller-supplied ONCHAINID, _deployToken accepts an unbound IdentityFactory slot for the predicted token address and then deploys the token without binding that address to the supplied identity. The guard exists to stop an ASSET creator from binding the token address to a different identity, but it only covers bindings made before deployment. Any ASSET_DEPLOYER holder can afterwards call createIdentityFor for the live token address, since single-binding types are gated by role alone, and permanently bind it to an identity it controls. The token's onchainID and the IdentityFactory then disagree, and off-chain or cross-chain consumers that resolve the token through the factory may trust the wrong identity.
Consider binding the token address to the supplied identity during deployment, or requiring the slot to already resolve to that identity.
Update: Resolved in pull request #181 at commit c438483.
Rule and Tracker Modules Never Receive the Delegated Spender
The module interface documents TransferContext.spender as the caller of transferFrom on a native movement. However, the token passes that caller only to canSpenderCall, which consults SPENDER modules alone. The canTransfer and transferred hooks carry only the sender, recipient and amount, so _canTransfer and _applyMovement always build the context with an empty spender.
No shipped RULE or TRACKER module reads spender, so current deployments are unaffected. A custom module written against the documented interface, such as a rule capping delegated transfers, cannot distinguish a delegated transfer from a direct one and may silently fail to enforce its limit.
Consider forwarding the transferFrom caller through the compliance hooks into the RULE and TRACKER contexts, keeping spender empty for direct transfers, mints and burns. Alternatively, consider documenting that only SPENDER modules receive the spender.
Update: Resolved in pull request #145 at commit a53d832.
Unbinding A Live Identity Registry Erases Global-Only Identities
When a wallet has no local binding, storedIdentity falls back to the identity factories of the bound registries. However, unbindIdentityRegistry removes a registry from that set without checking whether the registry still uses this storage, while the interface describes the bound set as enumeration only. An owner relying on that documentation can unbind a live registry and make every wallet known only to its factory resolve to the zero identity. Such wallets then fail contains, are refused as recipients by the token's eligibility check unless checks are disabled, and are refused on either side by any bound RULE module until the registry is rebound.
Consider rejecting the unbind of a registry whose identityStorage still points to this storage, and correcting the interface documentation to state that the bound set drives identity resolution.
Update: Resolved in pull request #188 at commit f250163.
Identity Holding Cap Is Evaluated Against a Stale Position After a Wallet Relink
When a registry agent relinks a funded wallet, the ledger moves its balance to the new identity only on the next movement through that wallet. However, _canTransfer evaluates rules before that catch-up, so MaxBalancePerIdentityModule prices room from a position that excludes the relinked balance. The hook then credits that balance without consulting any rule.
As a result, holders can push an identity past its cap by up to the stale room. With a cap of 100, an identity holding 60 that receives a relinked 60-token wallet can still accept 40, ending at 160.
Consider settling relinked wallet ownership, or accounting for the unsynced balance, before evaluating RULE modules.
Update: Resolved in pull request #199 at commit 201ccf5. The ledger now refuses every movement through a funded wallet whose registry identity differs from the identity it credited, so a balance never changes identity outside a movement the rules evaluated.
Splitting Suites That Share an Identity Registry Leaves the Registry in the Last Destination Domain
Two tokens in one domain can share an identity registry. When such tokens are sent to different domains, migrateSuitesToDomains maps the registry selectors once per token, and each mapping overwrites the previous one. The last destination's AGENT and OWNER roles therefore control the shared registry. Agents of the earlier destinations can no longer register investors, while the last destination's agents can modify identities that every other token relies on, defeating the intended separation.
Consider rejecting migrations that send tokens sharing a registry to different domains, or keeping a shared registry's selectors in one explicitly shared domain.
Update: Resolved in pull request #148 at commit 603a599.
Domain Migration Drops Role Grant Delays and Guardians
When migrateSuitesToDomains moves suites to a new domain, _grantFrom copies each member's execution delay but not the source role's grant delay or guardian. Unconfigured destination roles keep the AccessManager defaults: no grant delay and ADMIN_ROLE as guardian. A migrated AGENT_ADMIN member can therefore grant the destination AGENT_MINTER role it administers with immediate effect, and a former guardian can no longer cancel scheduled operations it did not propose. Only ADMIN_ROLE can run the migration and restore these settings, so the effect is a silent loss of configured intervention time rather than an escalation path.
Consider applying each source role's grant delay and guardian to the destination role during migration, or documenting that operators must reconfigure them and let new grant delays take effect before remapping selectors.
Update: Resolved in pull request #148 at commit 603a599.
Identity Storage Replacement Can Orphan Circulating Holders
The registry owner can replace the identity storage while tokens circulate. The authorization hook checks only role, interface and binding. It does not require that local wallet bindings were migrated, nor that the registry holds the IRS_WRITER role on the new storage. The token's own registry setter refuses the equivalent change with TokenCirculating.
After the swap, a holder bound only locally in the old storage and unknown to the identity factory resolves to no identity. Its outbound transfers fail while a RULE module is bound, identityTransfer reverts, and its compliance position stays under an identity no wallet resolves to until the owner intervenes.
Consider forbidding storage replacement once identities are registered, or requiring migration of local bindings and an IRS_WRITER grant before the pointer is updated.
Update: Resolved in pull request #188 at commit f250163.
Compliance Owner Can Detach a Circulating Token and Freeze All Token Operations
The binding policy states that no binding path admits a circulating token, but it checks only the supply of the token being bound. The owner can therefore detach a token with supply, either through _authorizeTokenUnbinding, which applies no supply check, or by binding a fresh token, since _bindToken overwrites the current one.
Once detached, the onlyBoundToken hooks make every mint, burn, transfer, forced transfer, recovery and settlement revert. Neither path can restore the binding while supply exists, so balances stay frozen until a beacon upgrade.
Consider rejecting an owner unbind, or a bind that displaces the current token, while the currently bound token has supply.
Update: Resolved in pull request #187 at commit 0a30324.
UtilityChecker Predictions Diverge From On-Chain Verification and Compliance
The UtilityChecker helper predicts verification and transfer outcomes, but diverges from the on-chain checks in three ways. _getEligibility never confirms that the returned claim names the trusted issuer, which the registry requires. getVerifiedDetails lets the last matching trusted issuer overwrite a topic's result, whereas the registry accepts any valid claim. getTransferDetails omits the compliance's refusal of identity-less wallets, so a transfer from a wallet deleted from the registry, or to an unregistered wallet once eligibility checks are disabled, is reported as passing yet reverts. Integrators gating onboarding or settlement on these answers may act on incorrect predictions.
Consider aligning each path with the on-chain logic: require the claim issuer to match the trusted issuer, mark a topic as passing once any trusted issuer holds a valid claim, and model the zero-identity refusal while a RULE module is bound.
Update: Resolved in pull request #146 at commit 4467f6d.
Ownership Transfer Bypasses the Target Admin Delay for Authority Changes
AccessManager applies a target's admin delay to every authority change through updateAuthority or the setAuthority selector. However, the ERC-173 transferOwnership shim calls setAuthority internally and is not mapped to any role, so it defaults to ADMIN_ROLE without that delay. An ADMIN_ROLE holder with zero execution delay can therefore replace a token's authority immediately through execute, even when a target admin delay is configured. This removes the window for monitoring or cancelling the change, contrary to the project's statement that replacing the manager is unsupported.
Consider making transferOwnership revert on managed contracts so that authority changes go through updateAuthority.
Update: Resolved in pull request #180 at commit 2e9a655.
Native Settlement Credits the Old Identity After the Recipient Wallet Is Relinked
Although _settledMovement documents that a native recipient is resolved again at settlement, it reuses the identity stored at issuance instead of querying the registry. If a registry agent relinks the recipient wallet in between, the settled amount is credited to the old identity, and rule checks and tracker modules see that identity until the next native movement through the wallet. Meanwhile, the new identity's understated position can let a later receipt exceed the MaxBalancePerIdentityModule cap. The path becomes reachable only once a delegation-out flow creates bridged balances, since _delegateOut currently has no caller.
Consider resolving the native recipient's current identity from the registry at settlement.
Update: Resolved in pull request #146 at commit 4467f6d.
Any Contract Can Claim an Unbound Compliance and Forge Ledger Positions
_authorizeTokenBinding lets an unbound compliance accept a bind from any caller that passes its own address as the token, and only checks that caller's self-reported totalSupply. Since bindToken has no access modifier, any zero-supply contract can bind itself. Nothing confirms that the caller's compliance pointer names this compliance or that the caller shares its authority, although the token side of the same handshake requires shared authority. The NatSpec describes the path as the way a token claims a fresh compliance during setup, and the CHANGELOG states that nothing outside the compliance writes ledger positions.
Once bound, the attacker contract passes onlyBoundToken and can drive the created, transferred and destroyed hooks. _applyMovement resolves identities through the bound token's identity registry, which the attacker controls, so the attacker can credit a position to any identity. The legitimate token's setCompliance then reverts with ComplianceAlreadyBoundToToken. In a proof of concept, a forged position of 1e24 survived the owner's unbind and the legitimate rebind while token supply was zero, and it feeds every RULE module that reads positions, such as per-identity caps. The owner can recover by unbinding and repairing with fixPosition. However, unbinding (OWNER) and setCompliance (IDENTITY_MANAGER) are separate calls, so an attacker may bind again between them unless the administrator batches both. No funds can be stolen. Factory-deployed compliances are not exposed, because init binds the token. The window opens after setCompliance moves a token away from a compliance, after an owner calls unbindToken, or when a spare compliance is prepared by initializing and then unbinding it.
Consider requiring, on the token-initiated bind path, that IERC3643(token).compliance() returns this compliance, which _setCompliance writes before calling bindToken, or that the caller shares this compliance's authority.
Update: Resolved in pull request #187 at commit aee8c26.
Notes & Additional Information
Authority Changes Through setAuthority Emit No OwnershipTransferred Event
The contracts implement ERC-173 and owner returns the current authority, yet only transferOwnership emits OwnershipTransferred. The upgradeable and non-upgradeable setAuthority overrides, reachable through AccessManager.updateAuthority, change the owner without that event, so off-chain trackers relying on ERC-173 events may record a stale owner.
Consider emitting OwnershipTransferred from both setAuthority overrides and removing the emission from transferOwnership.
Update: Resolved in pull request #180 at commit 2e9a655.
EVM Peers Are Accepted Without a 20-Byte Account
_setPeer validates the envelope length and chain key of an explicit peer, but does not require the account of an EVM peer to be exactly 20 bytes. A malformed EVM peer still opens the chain, so compliance validations, mint instructions, and recall instructions can be sent to a recipient that EVM gateways cannot parse. Messages sent before the peer is corrected remain undeliverable.
Consider requiring a 20-byte account in _setPeer whenever the peer's chain type is EVM.
Update: Resolved in pull request #145 at commit a53d832.
Delisted Spenders Can Execute Outstanding Transfer Validations
A spender is judged only when a transfer validation is issued. disallowSpender cancels no pending validation and settlement does not recheck spender policy, so a delisted spender may still execute an approved movement until expiry. The recipient, amount range, and expiry stay fixed.
Consider documenting that delisting affects only future validations and keeping the validity window short, or adding a path to revoke pending validations naming a delisted spender.
Update: Resolved in pull request #145 at commit a53d832.
Outbound Messages Are Incompatible With Prepaid Gateways
The token sends every protocol message through _send, which forwards no ETH and an empty attributes array, and no entry point is payable. A trusted gateway that requires an upfront fee would therefore revert every validation issuance, mint, and recall on its route.
Consider supporting fee payment and attributes in the send path, or documenting that only post-paid gateways may be added to the trusted registry.
Update: Resolved in pull request #145 at commit a53d832.
EVM Envelope Canonicality Check Accepts Padded Addresses and Empty Chain References
parse documents one encoding per wallet, yet accepts EVM envelopes with an address field of any length or an empty chain reference. Since canonicalKey hashes the raw bytes, a padded address yields a second key for the same wallet. A spender listed under a padded envelope is refused by canSpenderCall, which uses the 20-byte form.
Consider requiring EVM envelopes to carry a non-empty chain reference and an address of exactly 20 bytes, or of 0 or 20 bytes if address-less envelopes are intended.
Update: Resolved in pull request #145 at commit a53d832.
Spender Verification Module Breaks transferFrom Under a Standard-Only Registry
The token only requires a new identity registry to advertise the standard IERC3643IdentityRegistry interface, yet SpenderVerificationModule calls isWalletVerified, a T-REX-specific method outside that interface, and binds without checking for it. If an issuer pairs the module with a standard-only registry, every transferFrom reverts while minting and direct transfers succeed, until the module is removed.
Consider requiring ITREXRegistry support through ERC-165 when setting the registry, or checking for it when the module binds.
Update: Resolved in pull request #187 at commit aed2856.
Tracker Modules Receive an Empty Spender Despite the Context Documentation
The spender field is documented as the caller of transferFrom or the spender named on a validation, yet both settlement contexts and native movement contexts reach TRACKER modules with it empty. No shipped tracker reads it, but a third-party tracker relying on the documentation may misattribute volume.
Consider either documenting that TRACKER contexts carry no spender or populating the field consistently on both paths.
Update: Resolved in pull request #145 at commit a53d832.
Issuance Rule Context Drops the Named Validation Spender
requestTransferValidation accepts a named spender, but _capAtWhatTheRulesAllow builds the issuance context given to every RULE module with a literal empty spender. As #238 reports for the native path, this contradicts the IModule NatSpec, which documents spender as the spender named on a validation, empty only when the sender executes itself. No shipped RULE module reads spender, so only custom spender-aware RULE modules are affected.
Consider passing the requested spender to _capAtWhatTheRulesAllow, or documenting that RULE contexts omit it.
Update: Resolved in pull request #145 at commit a53d832.
Over-Freezing A Partially Frozen Wallet Reverts With A Panic
When a wallet already holds frozen tokens, the balance check in _freezePartialTokens adds the requested amount to the frozen amount under checked arithmetic. A sufficiently large request therefore reverts with Panic(0x11) instead of the ERC20InsufficientBalance error that the migration notes promise. The call still reverts, so the only effect is a misleading error for callers and off-chain tooling.
Consider comparing the requested amount against the balance minus the frozen amount, which cannot overflow while the frozen amount never exceeds the balance.
Update: Resolved in pull request #184 at commit cd70e3f.
Zero-Amount Burn Leg Leaves a Validation That Cannot Be Resolved
Issuance accepts a minimum of 0, so a zero-amount burn leg records a hold of 0, and resolveStuckValidation then reverts with NothingInTransit. If the mint leg is lost, the validation and both identities' pending reservations remain in place indefinitely. The path becomes reachable only once a delegation-out flow creates bridged balances, since _delegateOut currently has no caller.
Consider requiring a non-zero minimum at issuance, or skipping the hold return during resolution when the executed amount is 0.
Update: Resolved in pull request #185 at commit 34ada22.
Implementation Authority Accepts a Codeless Access Manager
The TREXImplementationAuthority constructor only rejects the zero address for its access manager, and OpenZeppelin's AccessManaged constructor sets the authority without the code check that setAuthority applies. If the deployer passes an address without code, such as a typo, a wrong-chain address, or an undeployed predicted address, the restricted publish, upgrade and publishAndUpgrade functions all revert, and the five beacons stay at their initial version. Because setAuthority must be called by the current authority, the authority cannot be replaced unless someone holds the key for that address. The failure is visible at the first publish and is fixed by redeploying before anything references the beacons. The factory already requires code at a supplied access manager.
Consider requiring the access manager to have code in the constructor, consistent with the factory's check.
Update: Resolved in pull request #145 at commit a53d832.
Predicted Suite Address Accepted as Access Manager Administrator
When deployTREXSuite deploys a fresh access manager, the deployment input validation rejects an administrator that is the zero address, the factory itself, or the predicted access manager. It accepts the other predicted suite addresses, such as the predicted token. In that case, _handOverAccessManager grants ADMIN_ROLE to a proxy that has no call path to the manager and then renounces the factory's own role. The suite then has no usable administrator: no agent role can be granted, the token stays paused and cannot mint, and ADMIN_ROLE cannot be rotated. This contradicts the CHANGELOG, which requires the administrator to be a real external account. Only the platform owner can deploy a suite, no funds are at risk, and redeploying under a new salt recovers.
Consider rejecting every predicted suite address as the access manager administrator, alongside the existing checks.
Update: Resolved in pull request #145 at commit a53d832.
Client Reported
Any Suite Deployer Can Redirect the Factory Configuration Used by All Later Suites
The TREXFactory exposes two functions that deploy suites and three setters that choose the implementation authority, the identity factory and the trusted gateway registry from which every new suite is built. setupTREXFactoryRoles maps all five functions to the platform OWNER role. The PlatformRole enumeration defines no role for deploying suites, so an account can only be allowed to deploy suites by granting it platform OWNER. The integration test helper grants that role to its deployers, and issuers and tokenization providers receive it in order to deploy their tokens.
Every account that can deploy a suite can therefore also repoint the factory. _setImplementationAuthority only checks that the new authority returns five non-zero beacons, so an authority that returns beacons controlled by the deployer is accepted. deployTREXSuite reads these beacons on every call, and deployTREXSuiteIsolated reads its implementations from the same authority. As a result, every suite deployed afterwards, by any party, can run on code that one deployer controls. The identity factory and trusted gateway registry setters only reject the zero address. The gateway registry is fixed into each token at deployment, even though the interface documentation states that gateway trust stays a network-level decision. Suites that are already deployed are not affected.
The shared role also defeats the separation of platform duties. Publishing and upgrading implementations is restricted to VERSION_MANAGER, and gateway trust is restricted to INTEROP_MANAGER. A deployer that holds platform OWNER can sidestep both roles for all future suites by replacing the authority or the registry the factory points to. The CHANGELOG describes platform roles as governance roles rather than issuer roles, but the codebase offers no way to delegate suite deployment without also delegating these governance powers.
Consider gating the two deployment functions with a role distinct from the one that gates the three setters, granting the deployment role to suite deployers and keeping the setters with Network governance. Reusing ASSET_DEPLOYER for this purpose is not advisable, since that role also allows its holder to call createIdentityFor directly and bind an ASSET identity to the predicted address of a token that has not yet been deployed, which blocks that salt. Existing deployments can migrate without redeploying by remapping the two deployment selectors through setTargetFunctionRole, granting the new role to current deployers, and revoking platform OWNER from them.
Update: Resolved in pull request #181 at commit c438483.
Identity Contracts Cannot Hold Tokens on Their Own Address
A wallet with no local binding is attributed through the identity factory. The identity registry storage resolves it with _globalIdentity, which reads getIdentityIncludingRevoked, and the registry admits it only if _activeIdentityOf finds it non-revoked through getAccountStatus. In the pinned ONCHAINID revision, identity creation links only the creating account and never records an entry for the identity contract's own address. As a result, getIdentity, getIdentityIncludingRevoked and getAccountStatus return the zero identity and the None status for that address.
An identity contract therefore cannot hold tokens on its own address. For that address, identity returns the zero identity, contains and isVerified return false, and minting or transferring to it reverts with UnverifiedIdentity in _update. The restriction can be lifted per identity, since an agent can register the address locally under its identity, after which it is attributed and verified.
Consider pinning an ONCHAINID revision whose factory views resolve an identity's own address to that identity, or resolving such an address to itself in the registry fallback when the factory's isFactoryIdentity confirms it. In either case, consider adding a test that mints to and transfers out of an identity's own address.
Update: Resolved in pull request #182 at commit 9a99fa4.
Conclusion
T-REX implements ERC-3643 permissioned security tokens. The audited version adds a ledger of positions per identity, role domains on the OpenZeppelin AccessManager, and positions on satellite chains reconciled through ERC-7786 messaging. OpenZeppelin audited the full smart contract codebase, from the standard base layer to the factory, the compliance and the cross-chain messaging endpoint.
The audit identified one high-severity issue. When suites assigned to different role domains share an identity storage, an agent of one domain can rewrite the identity bindings of another domain's holders. That access lets the agent seize those holders' balances. The audit also identified four medium-severity issues. The token implementation exceeds the EIP-170 code size limit and cannot be deployed as written. Registry agents can rebind a holder's wallet to their own identity and spend its balance. Mint instructions can be dispatched to satellite chains without a matching debit on the reference chain, and a self-transfer after a wallet relink overstates the new owner's ledger position. The remaining issues are of lower severity.
The token-movement and message-intake paths are written defensively. A single reentrancy guard spans each token movement, from the compliance check through the module hooks that follow the balance change. A module therefore cannot reenter and have a later module evaluate both operations against accounting that records neither. The messaging endpoint checks the gateway against the trusted registry when each message arrives, and marks a message as received before handling it. If the handler fails, the whole delivery reverts, so the message can still be delivered again. The NatSpec records the reasoning behind the reentrancy guard, which made its intent straightforward to verify.
The T-REX team maintains a layered test suite. It spans unit, integration, fuzz and invariant tests, and the invariants hold the token's supply to the sum of its free, frozen, bridged and in-transit balances. A separate standard suite runs the ERC-3643 base contracts alone against mocks. It is the regression net for the planned swap to OpenZeppelin's ERC-3643 implementation.
OpenZeppelin thanks the T-REX team for their responsiveness and collaboration throughout the engagement.
Appendix
Issue Classification
OpenZeppelin classifies smart contract vulnerabilities on a 5-level scale:
- Critical
- High
- Medium
- Low
- Note/Information
Critical Severity
This classification is applied when the issue’s impact is catastrophic, threatening extensive damage to the client's reputation and/or causing severe financial loss to the client or users. The likelihood of exploitation can be high, warranting a swift response. Critical issues typically involve significant risks such as the permanent loss or locking of a large volume of users' sensitive assets or the failure of core system functionalities without viable mitigations. These issues demand immediate attention due to their potential to compromise system integrity or user trust significantly.
High Severity
These issues are characterized by the potential to substantially impact the client’s reputation and/or result in considerable financial losses. The likelihood of exploitation is significant, warranting a swift response. Such issues might include temporary loss or locking of a significant number of users' sensitive assets or disruptions to critical system functionalities, albeit with potential, yet limited, mitigations available. The emphasis is on the significant but not always catastrophic effects on system operation or asset security, necessitating prompt and effective remediation.
Medium Severity
Issues classified as being of medium severity can lead to a noticeable negative impact on the client's reputation and/or moderate financial losses. Such issues, if left unattended, have a moderate likelihood of being exploited or may cause unwanted side effects in the system. These issues are typically confined to a smaller subset of users' sensitive assets or might involve deviations from the specified system design that, while not directly financial in nature, compromise system integrity or user experience. The focus here is on issues that pose a real but contained risk, warranting timely attention to prevent escalation.
Low Severity
Low-severity issues are those that have a low impact on the client's operations and/or reputation. These issues may represent minor risks or inefficiencies to the client's specific business model. They are identified as areas for improvement that, while not urgent, could enhance the security and quality of the codebase if addressed.
Notes & Additional Information Severity
This category is reserved for issues that, despite having a minimal impact, are still important to resolve. Addressing these issues contributes to the overall security posture and code quality improvement but does not require immediate action. It reflects a commitment to maintaining high standards and continuous improvement, even in areas that do not pose immediate risks.