--- eip: 8480 title: Sovereign Multi-Issuer Cross-Chain Transfer description: Cross-chain ERC-20 transfers by burn-and-mint, where a compromise of one issuer's verifier set cannot reach another issuer's token. author: Tao Tang (@0xsptt) , Zhe Han (@iampkuhz) , Shenglong Chen (@bladechen) , Dasong Ji (@jidasong) , Ray Liu (@xiaoliuray007) , Shenyan Liu , Hao Zou (@BruceZH0915) , Wenqi Zhang , ShanShan Yuan (@xuanyuan) , Xiangyu Gao (@GXY1122) , Siyuan Zheng (@andrewcoder666) discussions-to: https://ethereum-magicians.org/t/erc-8480-sovereign-multi-issuer-cross-chain-transfer/30144 status: Draft type: Standards Track category: ERC created: 2026-10-08 requires: 2, 20, 165, 712 --- ## Abstract This proposal defines a protocol for transferring [ERC-20](./eip-20.md) tokens between EVM chains by burn-and-mint: tokens are destroyed on the source chain and minted on the destination chain, preserving aggregate supply without custody. The protocol is split into three layers: 1. **Transport Layer** (`CrossChainRelay`) — message envelope, signature verification, and replay protection. 2. **Transfer Layer** (`TokenRouter`) — transfer lifecycle, multi-issuer token registry, and cross-chain mapping. 3. **Execution Layer** (`TokenExecutor`) — mint and burn on registered tokens. The protocol hosts **multiple institutional token issuers** on one shared deployment. Each issuer binds its tokens to a verifier set, owns its mappings, and enables or disables its tokens independently. Issuers on **different** verifier sets do not share verifier-key trust; they remain reliant on the same shared Relay, Router, Executor, and any contract ownership or upgrade administration. ## Motivation This proposal targets banks and asset issuers that issue tokens on several chains or move them across chains they already operate. One shared relay, router, and executor replaces the bespoke bridge and per-counterparty integration that every chain pair would otherwise demand. Issuers are bound by the shared modules' rules — dispatch, delivery, and verification go through the protocol contracts — yet keep full sovereignty over their own assets: verifier set, token mappings, and enablement. No existing standard combines this multi-issuer sovereignty with shared infrastructure. [ERC-7802](./eip-7802.md) lacks caller and verification models; Sovereign Bridged Tokens addresses per-bridge mint limits; [ERC-7786](./eip-7786.md) and [ERC-5164](./eip-5164.md) are generic messaging. Proprietary frameworks (LayerZero OFT, Wormhole NTT) each require a separate deployment. Three problems remain: 1. **Lock-and-mint fragmentation.** Vault-based bridges fragment liquidity, require trust in the vault, and make exploits systemic. 2. **Verifier-key isolation within shared infrastructure.** A shared bridge with a single validator set makes one compromise reach every asset. Per-token verifier sets limit the blast radius to the tokens bound to that set, but all tokens still share the underlying Relay, Router, Executor, and contract governance keys. 3. **Monolithic contracts.** Coupling transport, transfer, and token operations makes trust boundaries hard to locate and audit. This proposal responds by burning (no custody), per-token issuer sovereignty, per-token verifier sets (blast radius = one issuer), and three separated layers. ## Specification The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174. ### Terminology | Term | Meaning | | --- | --- | | Zone | A participating chain, identified by a protocol-assigned `zoneId`. | | Verifier | An address holding an ECDSA key authorized to sign messages. | | Verifier set | A named group of verifiers with a signature `threshold`, a monotonic `uint32` `version`, a `configHash` that is immutable per version, and an administering `manager`. | | Verification | The concatenated verifier signatures accompanying a message on delivery. | | Verification service | The issuer's off-chain service that collects and returns the verification for a dispatched message, and can be asked to re-collect it under the destination zone's current verifier set. Its normative requirements appear in [Security Considerations](#security-considerations). | | Issuer | The institutional entity that owns and manages one registered token; the sole authority over that token's configuration. | | Relay | The Transport Layer contract, `CrossChainRelay`. | | Router | The Transfer Layer contract, `TokenRouter`. | | Executor | The Execution Layer contract, `TokenExecutor`. | | `CrossChainMessage` | The 332-byte wire encoding — header plus body — that is dispatched, signed over, and delivered. | | `CrossChainTransfer` | Used for two unrelated things: the [EIP-712](./eip-712.md) struct that verifiers sign, and the `ITokenRouter` event emitted when a transfer is initiated. Text referring to the struct says "the signed struct"; text referring to the log says "the router event". | | Deployment domain | The EIP-712 domain separator of a zone, as `bytes32`. It binds a signature to that zone's protocol name and version, [EIP-155](./eip-155.md) chain ID, and relay address. | ### Architecture Overview The Transfer Layer depends on the Transport Layer for delivery and on the Execution Layer for supply changes. The Transport Layer and the Execution Layer MUST NOT depend on each other, and neither MAY hold the other's address or call into it. ![Architecture](../assets/eip-8480/Architecture.png) > The architecture diagram is illustrative. The normative contract and interface names are defined in this specification. > ### Zone Identifiers Each participating chain is assigned a `uint32` `zoneId`, unique and immutable for the deployment lifetime. `zoneId` is independent of EIP-155 chain ID; implementations MUST NOT assume equality or derive one from the other. Every relay MUST expose `localZoneId()`. Every relay MUST also maintain an **on-chain zone registry** mapping each `zoneId` to that zone's EIP-155 `chainId` and relay contract address, administered by the relay owner through `connectZone` and readable through `getZoneInfo`. The registry is the normative source for every field of the signed digest that is not carried in the message header (see [Verification Model](#verification-model)); `deliver` MUST rebuild the digest using registry values only and MUST NOT rely on an off-chain mapping table supplied by the caller. A zone MUST be registered before `dispatch` to it is permitted and before a message from it can be delivered; `dispatch` MUST revert for an unregistered `destinationZoneId`, and `deliver` MUST revert for an unregistered `sourceZoneId`. The published off-chain `zoneId`-to-chain mapping SHOULD accompany the deployment for tooling, and MUST agree with the registry. ### Message Format (CrossChainMessage) A message is a fixed-size header followed by a fixed-size body. The header carries routing and verification metadata; the body carries the `TransferPayload`. ![Message Format](../assets/eip-8480/Message_Format.png) | Field | Size | Offset | Description | | --- | --- | --- | --- | | `sourceZoneId` | 4 bytes | 0 | Originating zone (`uint32`) | | `destinationZoneId` | 4 bytes | 4 | Target zone (`uint32`) | | `verifierSetName` | 32 bytes | 8 | Verifier set whose verifiers signed the message (`bytes32`). Dispatch stamps the source binding; signing replaces it with the destination's binding. | | `verifierSetVersion` | 4 bytes | 40 | Version of the verifier set config (`uint32`). Stamped at dispatch, replaced at signing, validated at delivery. | | `globalMessageId` | 32 bytes | 44 | Unique message identifier (`bytes32`) | | `sender` | 32 bytes | 76 | Sender on the source zone (`bytes32`, left-padded) | | `targetContract` | 32 bytes | 108 | Contract that receives the message on the destination zone (`bytes32`) | | `authorizedRelayer` | 32 bytes | 140 | Authorized caller of `deliver` (`bytes32`) | | `messageBody` | 160 bytes | 172 | Encoded `TransferPayload` | Fields MUST be packed without padding and in big-endian byte order, as produced by `abi.encodePacked`. The length of the header is therefore exactly 172 bytes. Implementations MUST reject any message whose length is less than 172 bytes. The Transport Layer is body-agnostic: `deliver` requires at least the 172 header bytes and treats everything past offset 172 as an opaque body whose content is bound by `messageBodyHash`. The length of the body is fixed by the Transfer Layer instead — a `TransferPayload` is exactly 160 bytes, so a well-formed cross-chain transfer message is exactly 332 bytes, and `TokenRouter` MUST reject any other length. Trailing bytes beyond 332 therefore reach the relay but are rejected by the router, which reverts the delivery and leaves the message unconsumed. Addresses are carried as `bytes32` so that the header keeps a fixed layout across address widths. An EVM address MUST be left-padded with twelve zero bytes, and an implementation MUST reject a `bytes32` whose high-order 12 bytes are non-zero where it is interpreted as an EVM address. On `dispatch`, the relay MUST set `sender` to `msg.sender` (left-padded to `bytes32`) and MUST set `sourceZoneId` to `localZoneId()`. Neither field may be supplied by the caller; the relay derives both from the execution context. This guarantees that the receiving router's check — `sender` equals the recorded peer router — authenticates the actual dispatching contract, not a value the caller chose. `dispatch` MUST revert unless `verifierSetName` names a registered set on the local relay, and MUST stamp `verifierSetVersion` with that set's `version` at the moment of dispatch. The stamped value is informational — it records the source configuration and lets indexers and the verification service identify what was current when the burn happened — and is never the acceptance criterion: verifiers align the descriptor to the destination registry before signing (see [Off-Chain Signing and Relay Orchestration](#off-chain-signing-and-relay-orchestration)), and the destination validates against its own registry ([Verification Model](#verification-model)). Wherever a `bytes32` field is interpreted as an EVM address — including `sender`, `targetContract`, `authorizedRelayer` in the header, and `sourceToken`, `destinationToken`, `recipient`, `messageSender` in the payload — the implementation MUST validate that the high-order 12 bytes are zero before converting to `address`. A `bytes32` with non-zero high-order bytes MUST be rejected at the entry point that reads it, not silently truncated. #### `globalMessageId` Identifies a message uniquely across zones and verifier sets; the replay-protection key. Derived as: ```plain globalMessageId = keccak256(abi.encodePacked(sourceZoneId, txHash, eventIndex)) ``` where: - `txHash` is the hash of the source-chain transaction that called `dispatch`, as `bytes32`. - `eventIndex` is the zero-based index of the `Dispatched` event log within that transaction's receipt, encoded as a `uint256`. Because `abi.encodePacked` emits each operand at its declared width, the three operands are fixed: `sourceZoneId` is `uint32` (4 bytes), `txHash` is `bytes32` (32 bytes), and `eventIndex` is `uint256` (32 bytes), giving a 68-byte packed input. An implementation that widens or narrows any operand derives a different `globalMessageId` for the same event, which breaks replay protection and makes the verifiers' reconstruction disagree with the destination's acceptance check. Every deployment MUST publish a cross-language test vector for this derivation — the three inputs and the resulting `globalMessageId` — alongside its reference implementation. Uniqueness follows because `txHash` is unique within a chain, `sourceZoneId` disambiguates across chains, and `eventIndex` disambiguates multiple dispatches within a single transaction. The relay MUST set `globalMessageId` to `bytes32(0)` in the `message` bytes emitted in the `Dispatched` event. Each verifier MUST derive `globalMessageId` independently from the source-chain transaction receipt and substitute it into the message header, and MUST verify that every other header field still matches the `Dispatched` event log, except the `verifierSetName`/`verifierSetVersion` descriptor, which the verifier replaces with the destination's current binding. A verifier MUST NOT sign if any field outside those three slots differs from the emitted log. #### `authorizedRelayer` If `bytes32(0)`, any address MAY call `deliver`. Otherwise only `authorizedRelayer` may. It restricts who submits, not who receives tokens. ### Delivery Validation On `deliver`, the relay MUST perform the following checks and MUST revert if any fails: 1. `message.length` is at least 172 bytes, so every fixed-offset header field is present. 2. `destinationZoneId` equals `localZoneId()`. 3. `authorizedRelayer` is `bytes32(0)` or equals `msg.sender`. 4. `verifierSetName` names a registered verifier set. 5. `sourceZoneId` has an entry in the local zone registry; the entry supplies the `chainId` and relay address required to rebuild the signed digest. A message from an unregistered zone MUST NOT be verified against any off-chain mapping table. 6. The verification satisfies the rules in [Verification Model](#verification-model). 7. The pair `(verifierSetName, globalMessageId)` has not been consumed. 8. `targetContract` points to an address with non-zero code length. A relay MUST NOT deliver a message to an address without deployed contract code, since the `onCrossChainMessage` call would succeed vacuously and the message would be permanently consumed with no effect. The relay MUST record `(verifierSetName, globalMessageId)` as consumed **before** invoking the target contract, so that a reentrant call cannot process the same message twice. It MUST then invoke `onCrossChainMessage` on `targetContract`, passing the `sourceZoneId`, `verifierSetName`, and `sender` decoded from the header together with the **full `message` bytes, as verified — including the substituted `globalMessageId` and the descriptor the verifiers signed** — so that the receiver can validate the fields the relay does not decode for it, notably `verifierSetVersion`. The relay MUST propagate a revert from that call, and MUST emit `Delivered` only on success; because the consumption record is written in the same transaction, a reverted delivery leaves the message replayable. ### TransferPayload Format The `TransferPayload` is the body carried inside a `CrossChainMessage`. | Field | Size | Offset | Description | | --- | --- | --- | --- | | `sourceToken` | 32 bytes | 0 | Token destroyed on the source zone (`bytes32`) | | `destinationToken` | 32 bytes | 32 | Token to mint on the destination zone (`bytes32`) | | `recipient` | 32 bytes | 64 | Recipient of the minted tokens (`bytes32`) | | `amount` | 32 bytes | 96 | Amount, in the token's smallest unit (`uint256`) | | `messageSender` | 32 bytes | 128 | Address that initiated the transfer (`bytes32`) | The fixed part of the payload is exactly 160 bytes. Implementations MUST reject a payload whose length is not exactly 160 bytes. `messageSender` is set by the router to the address that called `transferCrossChain` (left-padded to `bytes32`). It is carried for informational and audit purposes; receiving implementations MAY use it for rate limiting, compliance, or event logging. ### Token Compatibility A participating token MUST implement [ERC-20](./eip-20.md), `IMintBurnToken`, and `decimals()`. Mapped tokens MUST report the same `decimals()`. `amount` is carried verbatim; the Transfer Layer MUST NOT rescale it. Issuers MUST confirm decimals match before `mapToken`. Fee-on-transfer, rebasing, and other tokens where the delivered amount differs from the transferred amount are incompatible. Issuers MUST NOT map such tokens, or MUST use a wrapper that guarantees lossless transfers. ### Interface: ICrossChainRelay (Transport Layer) ```solidity interface ICrossChainRelay { /// @notice Emitted when a message is dispatched. `globalMessageId` inside /// `message` is bytes32(0); verifiers derive and substitute it. event Dispatched(uint64 indexed nonce, bytes message); /// @notice Emitted when a verified message is delivered to its target. /// `messageBody` duplicates data already visible in the source chain's /// `Dispatched` event and costs destination gas; it is included so that /// indexers can settle a delivery from destination logs alone. event Delivered( address indexed caller, bytes32 indexed globalMessageId, uint32 sourceZoneId, bytes32 sender, bytes messageBody ); /// @notice Dispatches a cross-chain message. /// @param destinationZoneId Target zone. /// @param verifierSetName Verifier set bound to the token on this zone. It is /// stamped into the header for indexing; the set that actually signs is /// the one bound to the destination token on the destination zone. /// @param targetContract Destination contract (bytes32). /// @param authorizedRelayer Authorized caller of `deliver` (bytes32(0) = anyone). /// @param messageBody Encoded TransferPayload. /// @return nonce Relay-local sequence number. function dispatch( uint32 destinationZoneId, bytes32 verifierSetName, bytes32 targetContract, bytes32 authorizedRelayer, bytes calldata messageBody ) external returns (uint64 nonce); /// @notice Verifies and delivers a message to its target contract. /// @param message Full encoded CrossChainMessage. /// @param verification Concatenated 65-byte ECDSA signatures. function deliver(bytes calldata message, bytes calldata verification) external; /// @notice Returns true if a message has been consumed in this verifier set name. function isConsumed(bytes32 verifierSetName, bytes32 globalMessageId) external view returns (bool); /// @notice Returns the zone identifier of this deployment. function localZoneId() external view returns (uint32); /// @notice Returns the EIP-712 domain separator of the LOCAL zone, i.e. the /// value used when this relay is the source of a message. Verifying an /// inbound message requires the SOURCE zone's separator, which this /// function is NOT: rebuild it from getZoneInfo(sourceZoneId). function deploymentDomain() external view returns (bytes32); // ── Zone Registry ──────────────────────────────────────────────── event ZoneConnected(uint32 indexed zoneId, uint256 chainId, bytes32 relay); /// @notice Records or updates a zone's EIP-155 chainId and relay address. /// Relay-owner only. MUST be set before dispatch to `zoneId` is /// permitted and before a message from `zoneId` can be delivered. /// SHOULD be timelocked: an incorrect entry silently breaks /// digest reconstruction for every message from that zone. function connectZone(uint32 zoneId, uint256 chainId, bytes32 relay) external; /// @notice Returns the registered chainId and relay address for a zone. /// MUST revert if `zoneId` is not registered. function getZoneInfo(uint32 zoneId) external view returns (uint256 chainId, bytes32 relay); // ── Verifier Set Administration ────────────────────────────────── event VerifierSetRegistered(bytes32 indexed setName, address indexed manager, uint32 version, bytes32 configHash); event VerifierAdded(bytes32 indexed setName, address indexed verifier, uint32 newVersion, bytes32 newConfigHash); event VerifierRemoved(bytes32 indexed setName, address indexed verifier, uint32 newVersion, bytes32 newConfigHash); event ThresholdChanged(bytes32 indexed setName, uint256 newThreshold, uint32 newVersion, bytes32 newConfigHash); /// @dev Emitted when the new manager calls `acceptVerifierSetManager`. event VerifierSetManagerTransferred( bytes32 indexed setName, address indexed previousManager, address indexed newManager ); /// @notice Registers a verifier set. Relay-owner only. /// Initializes `version` to 1 with no verifiers and `threshold` 0; /// such a set verifies nothing until the manager populates it. function registerVerifierSet(bytes32 setName, address manager) external; /// @notice Adds a verifier. Set manager only. /// Reverts on the zero address or an existing member. function addVerifier(bytes32 setName, address verifier) external; /// @notice Removes a verifier. Set manager only. /// Reverts if the address is not a member, or if the remaining /// count would fall below `threshold`. function removeVerifier(bytes32 setName, address verifier) external; /// @notice Changes the signature threshold. Set manager only. /// `newThreshold` MUST be > 0 and ≤ verifier count. function setThreshold(bytes32 setName, uint256 newThreshold) external; /// @notice Nominates a new manager. Current manager only. /// Completes when `newManager` calls `acceptVerifierSetManager`. function transferVerifierSetManager(bytes32 setName, address newManager) external; /// @notice Accepts the manager role. Nominee only. function acceptVerifierSetManager(bytes32 setName) external; // ── Verifier Set Views ─────────────────────────────────────────── function getVerifierSetInfo(bytes32 setName) external view returns (uint256 threshold, uint256 verifierCount, uint32 version, bytes32 configHash, address manager); function getVerifierAtIndex(bytes32 setName, uint256 index) external view returns (address); /// @notice Returns the `configHash` recorded for a historical version of a set. /// SHOULD be supported for every version that was ever active, so that /// delivered messages can be audited and reconciled against the /// configuration they were signed under. function getConfigHashAtVersion(bytes32 setName, uint32 version) external view returns (bytes32 configHash); } ``` `nonce` is a relay-local strictly increasing counter for transaction correlation. It is not in the header and MUST NOT be used for replay protection. ### Interface: ITokenRouter (Transfer Layer) ```solidity interface ITokenRouter { /// @notice Emitted when the router initiates a cross-chain transfer, i.e. after /// the source burn and the corresponding `dispatch`. Distinct from the /// `CrossChainTransfer` struct that verifiers sign. event CrossChainTransfer( uint64 indexed nonce, address indexed token, uint256 amount, address indexed sender, bytes32 recipient, uint32 destinationZoneId ); event TokenMinted(address indexed token, address indexed recipient, uint256 amount); event IssuerRegistered(address indexed token, address indexed issuer); event TokenVerifierSetChanged(address indexed token, bytes32 verifierSetName); event TokenMapped( address indexed localToken, uint32 indexed remoteZoneId, bytes32 remoteToken ); event PeerConnected(uint32 indexed zoneId, bytes32 peerRouter); event TokenOutboundEnabledSet(address indexed token, bool enabled); event TokenInboundEnabledSet(address indexed token, bool enabled); event IssuerTransferInitiated( address indexed token, address indexed currentIssuer, address indexed newIssuer ); event IssuerTransferred( address indexed token, address indexed previousIssuer, address indexed newIssuer ); /// @notice Burns tokens on source and dispatches a cross-chain transfer. /// @param amount Amount to transfer; MUST be non-zero. /// @param destinationZoneId Target zone. /// @param recipient Recipient on destination (bytes32); MUST be non-zero. /// @param token Token to burn on this zone. /// @param authorizedRelayer Authorized caller of `deliver` (bytes32(0) = anyone). /// @return nonce Relay-assigned nonce. function transferCrossChain( uint256 amount, uint32 destinationZoneId, bytes32 recipient, address token, bytes32 authorizedRelayer ) external returns (uint64 nonce); /// @notice Registers the issuer for a token. Router-owner only, once per token. function registerIssuer(address token, address issuer) external; /// @notice Binds a token to a verifier set. Token issuer only. /// MUST revert unless `verifierSetName` names a set registered on the /// local relay: a binding to an unknown set makes every inbound message /// for the token undeliverable, with the tokens already burned upstream. function setTokenVerifierSet(address token, bytes32 verifierSetName) external; /// @notice Maps a local token to its remote counterpart for a given zone. /// Token issuer only. MUST revert on a zero `remoteToken`. /// Overwrites any existing mapping for the same /// `localToken` and `remoteZoneId`. function mapToken(address localToken, uint32 remoteZoneId, bytes32 remoteToken) external; /// @notice Records peer TokenRouter addresses per zone. Router-owner only. /// One-time per zone: MUST revert if any listed zone already has a /// non-zero peer. Overwriting a live peer silently disconnects every /// token that routes through that zone, on the outbound path /// (`targetContract`) and the inbound path (the peer check) at once. /// Replacements go through `proposePeerUpdate` / `applyPeerUpdate`. function connectPeer(uint32[] calldata zoneIds, bytes32[] calldata peerRouters) external; event PeerUpdateProposed( uint32 indexed zoneId, bytes32 currentPeer, bytes32 proposedPeer, uint64 executableAt ); event PeerUpdated(uint32 indexed zoneId, bytes32 previousPeer, bytes32 newPeer); /// @notice Queue a replacement peer router for a zone. Router-owner only. /// `executableAt` MUST be at least `PEER_UPDATE_DELAY` seconds ahead. /// A peer that is merely proposed has no effect on validation yet, so /// issuers and relayers can read `getPendingPeerUpdate` and object /// before traffic is silently redirected. function proposePeerUpdate(uint32 zoneId, bytes32 newPeerRouter) external; /// @notice Apply the queued peer update. Router-owner only. /// MUST revert if nothing is proposed for the zone or the delay has not /// elapsed. The previous peer stays authoritative until this call /// succeeds; a re-proposal replaces the pending value and restarts the /// delay. function applyPeerUpdate(uint32 zoneId) external; /// @notice Enables or disables outbound transfers for a token. Token issuer only. function setTokenOutboundEnabled(address token, bool enabled) external; /// @notice Enables or disables inbound minting for a token. Token issuer only. function setTokenInboundEnabled(address token, bool enabled) external; /// @notice Convenience function that calls `setTokenOutboundEnabled` and /// `setTokenInboundEnabled` with the same value. Token issuer only. function setTokenEnabled(address token, bool enabled) external; /// @notice Nominates a new issuer. Current issuer only. /// Completes when `newIssuer` calls `acceptIssuer`. function transferIssuer(address token, address newIssuer) external; /// @notice Accepts the issuer role. Nominee only. function acceptIssuer(address token) external; // ── Configuration Views ────────────────────────────────────────── /// @notice Issuer recorded for a token; address(0) if the token is not /// registered in the protocol. function getIssuer(address token) external view returns (address issuer); /// @notice Verifier set currently bound to a token; bytes32(0) if none is /// bound. Inbound delivery for an unbound token always reverts. function getVerifierSet(address token) external view returns (bytes32 verifierSetName); /// @notice Remote token mapped from `localToken` for `remoteZoneId`; /// bytes32(0) if no mapping exists. function getMappedToken(address localToken, uint32 remoteZoneId) external view returns (bytes32 remoteToken); /// @notice Peer router recorded for a zone; bytes32(0) if none. function getPeerRouter(uint32 zoneId) external view returns (bytes32 peerRouter); /// @notice Peer router proposed for a zone and the earliest timestamp at which /// it may be applied. (bytes32(0), 0) when no update is pending. function getPendingPeerUpdate(uint32 zoneId) external view returns (bytes32 proposedPeerRouter, uint64 executableAt); /// @notice Whether outbound transfers are enabled for a token. function isOutboundEnabled(address token) external view returns (bool); /// @notice Whether inbound mints are enabled for a token. function isInboundEnabled(address token) external view returns (bool); } ``` Every function above MUST be callable by anyone. They exist so that an issuer can establish the state of its own token without trusting an off-chain index, and so that a relayer can decide whether a message is deliverable before spending gas: the binding in `getVerifierSet`, the mapping in `getMappedToken`, the authority chain in `getIssuer` / `getPeerRouter`, and the two enablement flags together are exactly the inputs to the receiving checks, and none of them is otherwise readable on-chain. Deployments that hold tokens across several zones SHOULD additionally expose an aggregate read that walks every peer zone in one call. `PEER_UPDATE_DELAY` MUST be a positive duration fixed at deployment and SHOULD be at least 24 hours — long enough for an issuer monitoring `getPendingPeerUpdate` to disable inbound for its tokens before a misdirected peer becomes authoritative. An update applied in error is undone by the same two-step path, so no peer change is ever instantaneous. #### Initiating a transfer On `transferCrossChain`, the router MUST verify that: 1. `token` is registered and outbound-enabled. 2. A verifier set is bound to `token`. 3. A mapping exists from `token` to a non-zero `destinationToken` for `destinationZoneId`. 4. A non-zero peer router is recorded for `destinationZoneId`. 5. `amount` and `recipient` are non-zero. It MUST then transfer `amount` from the caller to the Execution Layer, MUST require that transfer to report success — a falsy return from an ERC-20 `transfer` is a failure and MUST revert the whole call — MUST call `burn(token, executor, amount)` so that the source supply falls from the balance the executor just received, and only then MUST dispatch the message with `targetContract` set to the recorded peer router for `destinationZoneId` and `verifierSetName` set to the verifier set bound to `token`. Because `burn` takes a `from` address chosen by the router, the router's caller model is the only thing standing between a transfer and a burn of someone else's balance: `from` MUST be the Execution Layer address that the preceding `transfer` funded, and MUST NOT be taken from the function arguments. #### Receiving a transfer On `onCrossChainMessage`, the router MUST decode the `sourceZoneId`, `destinationZoneId`, `verifierSetName`, `verifierSetVersion`, `sender`, `targetContract`, `authorizedRelayer`, and `messageBody` from the `message` bytes rather than trusting the separately supplied arguments, and MUST verify that: 1. `msg.sender` is the local relay. 2. The separately supplied `sourceZoneId`, `verifierSetName`, and `sender` equal the values decoded from `message`; a mismatch MUST revert. 3. `sender` equals the peer router recorded for `sourceZoneId`, and that peer is non-zero. 4. `message` is exactly 332 bytes — a 172-byte header followed by a `messageBody` of exactly 160 bytes. 5. `destinationToken` resolves to a token that is registered locally and inbound-enabled. 6. `verifierSetName` equals the set currently bound to `destinationToken`, and `verifierSetVersion` equals the current `version` of that set on the local relay. 7. The mapping for `destinationToken` on `sourceZoneId` equals `sourceToken`. 8. `recipient` and `amount` are non-zero. 9. `targetContract` equals the local router's own address; a message addressed to another contract MUST NOT be minted through this router. Check 6 enforces per-token verifier set isolation. Check 3 prevents arbitrary accounts from dispatching forged payloads through the shared relay. Check 2 exists because the relay decodes those arguments on the receiver's behalf: the signed message, not its decoded shadow, is the authoritative input. ### Interface: ITokenExecutor (Execution Layer) ```solidity interface ITokenExecutor { event Minted(address indexed token, address indexed to, uint256 amount); event Burned(address indexed token, address indexed from, uint256 amount); /// @notice Mints tokens to a recipient. /// Callable only by the local TokenRouter. function mint(address token, address to, uint256 amount) external; /// @notice Burns tokens held by `from`. /// Callable only by the local TokenRouter. function burn(address token, address from, uint256 amount) external; } ``` The Execution Layer holds exclusive mint/burn rights. It MUST reject calls from any address other than the local router. Token validation (registration, enabled state, mapping) is the Router's responsibility; the executor trusts the router's judgment and does not maintain an independent token registry. ### Interface: IMintBurnToken A participating token MUST implement ERC-20 and MUST additionally implement `IMintBurnToken`: ```solidity interface IMintBurnToken { /// @notice Mints `amount` tokens to `to`. function mint(address to, uint256 amount) external; /// @notice Burns `amount` tokens from `from`. function burn(address from, uint256 amount) external; } ``` A token MUST restrict `mint` and `burn` to the local `TokenExecutor`. Wider grants place supply outside the protocol's guarantees. Tokens that implement [ERC-7802](./eip-7802.md) `crosschainMint`/`crosschainBurn` are functionally equivalent. An executor MAY call `crosschainMint`/`crosschainBurn` instead of `mint`/`burn` for such tokens, provided the caller is restricted to the local `TokenExecutor` and the supply semantics remain identical. ### Interface: ICrossChainReceiver A contract that receives cross-chain messages MUST implement: ```solidity interface ICrossChainReceiver { /// @notice Invoked by the local relay when a verified message is delivered. /// @param sourceZoneId The originating zone, decoded from `message`. /// @param verifierSetName The verifier set that verified the message, decoded from `message`. /// @param sender The sender on the source zone (bytes32), decoded from `message`. /// @param message The full encoded CrossChainMessage — 172-byte header plus body — /// with the derived `globalMessageId` substituted into the header. function onCrossChainMessage( uint32 sourceZoneId, bytes32 verifierSetName, bytes32 sender, bytes calldata message ) external; } ``` The first three arguments are conveniences decoded by the relay from `message`; they add no assurance that the receiver could not obtain itself, and a receiver that needs header fields the relay does not decode — `verifierSetVersion` above all — MUST parse `message`. A receiver MUST therefore treat `message` as the authoritative input and MUST revert if a supplied argument disagrees with the decoded value. Implementations MUST reject calls from any address other than the local relay, and MUST revert on a malformed message so the relay does not record an unprocessed message. `verifierSetName` is passed through because the receiver, not the relay, knows the acceptable set for a given asset. ### Verification Model Each verifier set has a unique `setName` (`bytes32`), a `threshold` (`uint256`), a monotonic `version` (`uint32`), an immutable `configHash` per version, verifier addresses, and a `manager` (the issuer, for token-bound sets). The `configHash` for a given version is: ```solidity configHash = keccak256(abi.encode( setName, // bytes32 version, // uint32 threshold, // uint256 verifierAddressesSortedAscending // address[] )); ``` `verifierAddressesSortedAscending` is the array of enabled verifier addresses sorted in strictly ascending order. The four arguments MUST be encoded with exactly the Solidity types above. Note that `abi.encode` head-pads every integer to a 32-byte word, so `version` declared `uint32` and the same value widened to `uint256` produce identical `configHash` bytes — the width is not what this hash commits to. The declared width is normative elsewhere: in the EIP-712 struct, `CrossChainTransfer.verifierSetVersion` MUST stay `uint32`, because the typehash commits to the declared type string and re-declaring it as `uint256` changes `CROSS_CHAIN_TRANSFER_TYPEHASH`, hence the digest, so signatures gathered under one declaration fail under the other; and in any `abi.encodePacked` use, where `uint32` contributes 4 bytes and `uint256` contributes 32. Implementations MUST NOT normalise, pad, or re-order these values in any other way. The `configHash` MUST change whenever `threshold` or membership changes. The issuer holds signing keys; the relay stores the set as configuration only. `version` is `uint32` everywhere it appears — in the message header, in the signed struct, in the administration events, in `getVerifierSetInfo`, and in `getConfigHashAtVersion`. Monotonic increase MUST NOT overflow; once `version` reaches `2**32 - 1` the set MUST be considered exhausted and a new set created. Signatures are produced according to [EIP-712](./eip-712.md). The domain separator is a pure function of four normative values — the protocol name, the protocol version, the EIP-155 `chainId` of a given zone, and the relay address of that same zone: ```solidity bytes32 domainSeparator(uint256 zoneChainId, bytes32 zoneRelay) { return keccak256(abi.encode( keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), keccak256("CrossChainBurnMint"), keccak256("1"), zoneChainId, zoneRelay )); } ``` The name `"CrossChainBurnMint"` and the version `"1"` are constants of this specification; a deployment MUST NOT substitute its own identifiers, otherwise a message verified on one zone cannot be recomputed on another. The protocol version constant MUST be raised only by a specification revision that changes the struct layout or the verification rules. Every relay MUST expose `deploymentDomain()`, which returns `domainSeparator(localChainId, localRelay)` — the separator of the zone the contract is deployed on. This is the value used when that zone is the **source** of a message. It is NOT the value used to verify an inbound message. Because a signed digest is verified on the destination zone, the destination relay MUST recompute the **source** separator as `domainSeparator(chainId, relay)` from its own zone registry entry for `sourceZoneId` (see [Zone Identifiers](#zone-identifiers)). No field of the digest that is absent from the message header MAY be taken from caller-supplied data or from an off-chain mapping table: `deliver` MUST resolve `sourceChainId` and `sourceRelay` through `getZoneInfo(sourceZoneId)`, and MUST use its own contract address as `destinationRelay` and its own chain's `chainId` where the destination side of the digest is needed. A message whose `sourceZoneId` is not registered MUST be rejected rather than guessed. The typed data signed by each verifier is the `CrossChainTransfer` struct: ```solidity struct CrossChainTransfer { bytes32 deploymentDomain; uint32 sourceZoneId; uint32 destinationZoneId; bytes32 verifierSetName; uint32 verifierSetVersion; bytes32 verifierSetConfigHash; bytes32 globalMessageId; bytes32 sender; bytes32 targetContract; bytes32 sourceRelay; bytes32 destinationRelay; bytes32 authorizedRelayer; bytes32 messageBodyHash; } ``` The digest is: ```solidity bytes32 digest = keccak256(abi.encodePacked( "\x19\x01", deploymentDomain, keccak256(abi.encode( CROSS_CHAIN_TRANSFER_TYPEHASH, deploymentDomain, sourceZoneId, destinationZoneId, verifierSetName, verifierSetVersion, verifierSetConfigHash, globalMessageId, sender, targetContract, sourceRelay, destinationRelay, authorizedRelayer, keccak256(messageBody) )) )); ``` where `CROSS_CHAIN_TRANSFER_TYPEHASH` is: ```solidity keccak256("CrossChainTransfer(bytes32 deploymentDomain,uint32 sourceZoneId,uint32 destinationZoneId,bytes32 verifierSetName,uint32 verifierSetVersion,bytes32 verifierSetConfigHash,bytes32 globalMessageId,bytes32 sender,bytes32 targetContract,bytes32 sourceRelay,bytes32 destinationRelay,bytes32 authorizedRelayer,bytes32 messageBodyHash)") ``` In the struct and the digest above, `deploymentDomain` is the separator of the **source** zone — `domainSeparator(sourceChainId, sourceRelay)` — not the separator of the zone performing the verification. `sourceRelay` and `destinationRelay` are the relay contract addresses of the two zones, each as `bytes32` left-padded from its 20-byte address. Every one of these values is resolved from the header plus the zone registry, and is identical whether computed by a signer or by the destination relay. A verification is concatenated 65-byte ECDSA signatures over `digest`. Each signature is `r || s || v`, where `r` and `s` are 32-byte big-endian values and `v` is the recovery id encoded as `27` or `28`. The relay MUST reject unless: 1. the named set's `threshold` is greater than zero, and the set contains at least `threshold` registered verifiers. 2. `verification.length` equals exactly `65 * threshold`; a verification whose length is not a non-zero multiple of 65 is therefore rejected without parsing. 3. Every signature recovers to an address registered in that set. 4. Recovered addresses are strictly ascending, which also rejects duplicate signers. 5. Every signature uses canonical low-`s` form per [EIP-2](./eip-2.md). 6. `verifierSetVersion` matches the current version of the named set on the local relay; previous versions are never accepted. 7. `verifierSetConfigHash` equals the `configHash` recorded locally for that (`verifierSetName`, `verifierSetVersion`) pair. 8. The `digest` rebuilt from the header, the registry entries and the local set configuration matches the digest recovered from the signatures. Rule 1 exists because `registerVerifierSet` creates an empty container: a set whose `threshold` is still zero would otherwise satisfy rule 2 with an empty `verification` and be accepted as "verified" with no signature at all. The EIP-712 struct binds the signature to a specific deployment pair, direction, verifier set version, verifier set configuration, and message body. Keys MUST NOT be reused for any other purpose. ### Verifier Set Administration - `registerVerifierSet` is restricted to the relay owner; registration designates the set's `manager`, initializes `version` to 1, and creates the set with no verifiers and `threshold` 0. Such a set verifies nothing until the manager populates it (see verification rule 1), so an issuer that binds a token before populating the set simply blocks inbound delivery for that token. - Adding/removing verifiers and changing `threshold` are restricted to the `manager` (the issuer, for a token-bound set). Each such change MUST increment `version` by exactly one and recompute `configHash`. - `addVerifier` MUST revert on the zero address and on an address that is already a member; silently accepting a duplicate would inflate `verifierCount` without adding signing capacity. - `removeVerifier` MUST revert if the address is not a member, and MUST revert if the remaining count would fall below `threshold`. Managers must lower `threshold` first, so a set can never become structurally unable to reach its own threshold. - `threshold` MUST be greater than zero and MUST NOT exceed the current verifier count; `setThreshold` MUST revert on a value outside that range. - Manager transfer MUST require the nominee to accept in a separate transaction. - Implementations SHOULD bound the number of verifiers in a set and SHOULD document that bound, because a verification grows by 65 bytes per required signature and an oversized `threshold` can make `deliver` exceed the destination block gas limit — stranding every message signed under that set. - A verifier set MUST NOT be removed while undelivered messages may exist. - The `configHash` is immutable for a given `version`; historical versions SHOULD remain queryable through `getConfigHashAtVersion` so that already-delivered messages can be audited and reconciled against the config they were signed under. Only the current `version` is ever accepted at delivery, so a superseded version is recovered off-chain by re-verification rather than on-chain by an acceptance window. The relay owner creates the set container and designates the manager; the issuer populates the set with its own keys and threshold. The operator holds no signing key. ### Off-Chain Signing and Relay Orchestration Every signature is produced by the token issuer's signing infrastructure. The operator observes dispatched messages, requests signatures from the issuer's signing API, and submits `deliver` — but holds no signing key. For a dispatched transfer, the signing flow is: 1. The operator observes the `Dispatched` event log on the source chain and extracts `verifierSetName` and the `message` bytes (with `globalMessageId` set to `bytes32(0)`). 2. The operator identifies the source-chain transaction hash (`txHash`) and the zero-based index of the `Dispatched` log within that transaction's receipt (`eventIndex`). 3. The operator determines the digest's out-of-header values from the zone registries, not from any off-chain table: the source EIP-155 `chainId` and source relay address from `getZoneInfo(sourceZoneId)` **on the destination relay**, and the destination EIP-155 `chainId` and destination relay address from the destination deployment itself (cross-checked against `getZoneInfo(destinationZoneId)` on the source relay). The two registries MUST agree; if they disagree, the message is not deliverable and the operator MUST NOT fall back to a locally configured mapping. 4. The verification service resolves the verifier set descriptor that the **destination** will accept: the set bound to the `destinationToken` on the destination zone, that set's current `version`, and the `configHash` recorded for that version on the destination relay. This descriptor overrides the one the source relay stamped at dispatch, because zones configure their bindings independently and the destination is the only authority on what it will accept. A client-supplied descriptor MUST NOT be used in place of the resolved value. 5. For each verifier of the resolved set, the operator requests a signature via the issuer's signing API: a. The verifier derives `globalMessageId = keccak256(abi.encodePacked(sourceZoneId, txHash, eventIndex))`. b. The verifier constructs the full wire message by writing the derived `globalMessageId` into the `globalMessageId` offset, and the resolved `verifierSetName` and `verifierSetVersion` into their header offsets. The bytes submitted to `deliver` are these rewritten bytes, so exactly those three slots differ from the emitted log. c. The verifier compares every other field of the reconstructed header against the `Dispatched` event log. If any field differs, the verifier MUST refuse to sign. The `globalMessageId` slot and the `verifierSetName`/`verifierSetVersion` descriptor are the only permitted differences, and the descriptor may come only from the destination resolution in step 4. d. The verifier computes `messageBodyHash = keccak256(messageBody)`. e. The verifier reads `verifierSetConfigHash` from the **destination** relay for the resolved `verifierSetName` and `verifierSetVersion`, and refuses to sign if it does not match the issuer's expected configuration. The destination relay is the authoritative source for this value because `deliver` validates the `configHash` against the destination registry; reading it from the source would sign a descriptor the destination rejects. A given (`verifierSetName`, `verifierSetVersion`) pair whose `configHash` differs between the two zones is a registration defect, not a message defect: it MUST be repaired on the destination by the set manager, and the verifier MUST refuse to sign until the issuer's expected hash matches the destination's recorded hash. f. The verifier constructs the EIP-712 `CrossChainTransfer` struct using `deploymentDomain` recomputed as `domainSeparator(sourceChainId, sourceRelay)` from the registry values in step 3, the reconstructed header fields, the resolved set descriptor, `verifierSetConfigHash`, and `messageBodyHash`. g. The verifier signs the EIP-712 digest as defined in the [Verification Model](#verification-model). 6. Once `threshold` signatures are collected, the operator submits `deliver` with the reconstructed `message` (containing the derived `globalMessageId` and the resolved descriptor) and the concatenated signatures. The operator is a liveness dependency, not a trust dependency — it cannot forge signatures. Deployments SHOULD govern operator powers through timelocks or multi-sig controls. ### Multi-Issuer Token Management Model One shared deployment hosts many issuers. Each issuer exclusively manages its own tokens, bound to a chosen verifier set. Access control: | Operation | Authorized caller | | --- | --- | | `registerIssuer` | Router owner, once per token | | `connectPeer` | Router owner, once per zone | | `proposePeerUpdate` / `applyPeerUpdate` | Router owner, after `PEER_UPDATE_DELAY` | | `connectZone` | Relay owner | | Registering a verifier set | Relay owner | | Adding/removing verifiers, `threshold` | Set manager (the token's issuer) | | `setTokenVerifierSet`, `mapToken`, `setTokenOutboundEnabled`, `setTokenInboundEnabled`, `setTokenEnabled` | Token's issuer | | `transferIssuer` / `acceptIssuer` | Current issuer nominates; new issuer accepts | | `mint` / `burn` (router) | Local router only | | `mint` / `burn` (token) | Local executor only | Deployment-wide operations belong to the router/relay owner; per-token operations belong to that token's issuer. ### Deployment Linking The three layers MUST be linked at deployment time: - The Router MUST be configured with the address of the local Relay. - The Executor MUST be configured with the address of the local Router. - These addresses MUST be immutable after initialization. Changing them requires redeploying the affected layer. Implementations MAY set these addresses via constructor parameters, an `initialize` function (for proxy deployments), or a one-time owner-only setter. The Router calls `dispatch` on the Relay; the Relay delivers to the Router via `onCrossChainMessage`; the Router calls `mint` and `burn` on the Executor. No other cross-layer calls are permitted. ### Cross-Chain Transfer Lifecycle ![Cross-Chain Transfer Lifecycle](../assets/eip-8480/Lifecycle.png) ### Supply Conservation Invariant For a token `T` across connected zones `Z`, the following is expected to hold: ```plain sum(T.totalSupply() on z for z in Z) == initialAggregateSupply + sum(issuedLocally[z] for z in Z) - sum(redeemedLocally[z] for z in Z) - inFlight ``` where, for the aggregated token group the issuer registered through `mapToken`: - `initialAggregateSupply` is the sum of `T.totalSupply()` across all zones in `Z` at the moment the group's last mapping was completed and inbound/outbound transfer was first enabled. - `issuedLocally[z]` is the amount minted on zone `z` by paths outside this protocol — `TokenExecutor.mint` calls not attributable to a delivered message, including ERC-7802 `crosschainMint`. - `redeemedLocally[z]` is the amount burned on zone `z` by paths outside this protocol, including ERC-7802 `crosschainBurn`. - `inFlight` is the amount burned on a source zone whose message has been dispatched but not yet delivered, computable by diffing `Dispatched` against `Delivered` for the token group. Every cross-chain mint MUST be attributable to a delivered message that destroyed the same `amount` on the source zone. `inFlight` SHOULD be treated as pending settlement, not as lost value. ### [ERC-165](./eip-165.md) Interface Detection `CrossChainRelay` MUST return `true` for `type(ICrossChainRelay).interfaceId` and `type(IERC165).interfaceId`; `TokenRouter` MUST return `true` for `type(ITokenRouter).interfaceId` and `type(IERC165).interfaceId`; `TokenExecutor` MUST return `true` for `type(ITokenExecutor).interfaceId` and `type(IERC165).interfaceId`; and `ICrossChainReceiver` implementations MUST return `true` for `type(ICrossChainReceiver).interfaceId` and `type(IERC165).interfaceId`. A contract that implements more than one of these interfaces MUST return `true` for every interface identifier it implements. ## Rationale ### Why a layered architecture Each layer is a contract boundary: the relay is agnostic to tokens, the router is agnostic to verification, and the executor is the single access-controlled entry point for supply changes. A compromised relay holds no mint right and cannot call the executor directly, but it is not contained by the router: the router re-checks configuration and the peer binding, not the signatures, so a relay that delivers a well-formed message without valid signatures still gets it minted. The relay is therefore one of the shared trust roots (see [Shared deployment trust roots](#shared-deployment-trust-roots)), not a layer the router can independently neutralise. ### Why burn-and-mint rather than lock-and-mint 1. **No custody.** Tokens are destroyed, not held — no vault to exploit. 2. **Deterministic supply.** Every mint is backed by a verified burn; aggregate is verifiable from logs. 3. **Clean accounting.** Source supply falls, destination supply rises — the issuer's own token, not a wrapper. ### Why multi-issuer token management Every per-token permission is reserved to the token's issuer: 1. **Issuer sovereignty.** Each issuer selects and populates its own verifier set; institutional issuers retain on-chain authority and signing keys. 2. **Independent risk profiles.** Per-token thresholds (e.g., 7-of-10 vs. 3-of-5) are expressible on the same deployment. 3. **Verifier-key isolation.** A compromise of one issuer's verifier keys cannot reach tokens bound to another issuer's verifier set. 4. **Onboarding gate.** `registerIssuer` is one-time and owner-restricted. 5. **Front-running protection.** Only the registered issuer can call `setTokenVerifierSet` and `mapToken`. ### Why per-token verifier sets A single validator set makes every asset share one compromise. Per-token sets contain the blast radius and allow different thresholds. The binding is enforced by the receiver, which refuses a message whose `verifierSetName` is not the set bound to the destination token. An issuer MAY delegate to a set managed by another issuer; the binding issuer accepts the delegated set's membership risk. ### Why `uint32` zone identifiers rather than EIP-155 chain IDs A `uint32` keeps the header fields at fixed offsets, enabling packed-byte hashing and constant-offset parsing. The header layout is EVM-oriented: addresses are carried as `bytes32` and interpreted as 20-byte EVM addresses when converting to `address`. While `zoneId` is independent of EIP-155 chain ID and could in principle identify non-EVM zones, the token and signature semantics in this proposal are defined for EVM chains. Non-EVM participation would require a separate specification for address width, token interface, and signature curve. ### Token mint/burn interface [ERC-7802](./eip-7802.md) defines `crosschainMint`/`crosschainBurn` with cross-chain-specific semantics but leaves access control unspecified. This proposal uses plain `mint`/`burn` on `IMintBurnToken` because: 1. **Explicit caller model.** Rights are confined to the local `TokenExecutor`. 2. **No bridge coupling.** The token has no knowledge of zones or peers. 3. **Composability.** Plain `mint`/`burn` can be reused for non-cross-chain purposes. A trade-off is that plain `mint`/`burn` may conflict with tokens that already use those names for local issuance and redemption. Implementations MAY require such tokens to use a dedicated wrapper, or MAY support [ERC-7802](./eip-7802.md) `crosschainMint`/`crosschainBurn` as an alternative execution path while keeping the same protocol semantics. That path is informational: ERC-7802 is not listed in this document's `requires` because a conforming implementation never has to consult it, and a deployment that supports only `IMintBurnToken` is complete. ### Why the message identifier is derived off-chain Deriving `globalMessageId` from the transaction hash gives uniqueness without a per-chain counter. The cost is that verifiers reconstruct the header rather than sign what they read. A nonce-based derivation would keep emitted and verified bytes identical but depends on the relay's counter surviving upgrades. ### Why the verifier set descriptor is destination-authoritative Zones are configured independently: an issuer binds a token to a set on each zone separately, so the set that protects a token on the destination need not be the one recorded at dispatch. Since the destination is the chain that decides whether to mint, it is also the only chain whose answer matters for "which set must have signed". Making the source binding normative would mean every source rebinding silently invalidates messages already in flight to destinations that did not rebind; making the destination binding normative means a rebinding invalidates only in-flight messages for that destination, and re-verification restores them. The traded-away property is that the header as emitted is not the header as signed, which is why the three mutable slots are named explicitly rather than left to implementers. ### Why the zone registry is on-chain The signed digest covers values that a fixed-length header cannot carry economically: the source zone's EIP-155 chain ID and relay address, which the EIP-712 domain separator commits to. If those came from caller-supplied or off-chain data, the destination chain would be trusting the submitter with the one input that decides whether the signature checks out, and a wrong entry would either reject valid messages or — if an implementation hashed what it was handed instead of what it resolved — accept messages for a deployment that does not exist. Resolving them from a relay-owned registry makes the verification self-contained and turns a misconfiguration into a visible registration event rather than a silent one. ### Relationship to existing proposals | Proposal | Difference | | --- | --- | | [ERC-7802](./eip-7802.md) | Leaves caller and verification unspecified; this proposal specifies both | | Sovereign Bridged Tokens | Per-bridge mint limits; here the issuer selects the verifier set | | [ERC-7786](./eip-7786.md) | Generic messaging, variable-length addresses; this proposal has fixed header + supply semantics | | [ERC-5164](./eip-5164.md) | Arbitrary calls, no supply accounting | | [ERC-7930](./eip-7930.md) | Variable-length addresses break fixed header layout | ## Backwards Compatibility This proposal introduces new interfaces without changing existing ones. A participating token remains a standard ERC-20 to non-protocol holders, with additional `IMintBurnToken` rights granted to the local `TokenExecutor`. Non-upgradeable or fixed-supply tokens require a wrapper, which reintroduces custody. `zoneId` is independent of EIP-155 chain ID. The on-chain zone registry is the authoritative mapping and is read by `deliver` when it rebuilds a digest; the published off-chain mapping is a convenience for tooling and MUST agree with the registry. ## Security Considerations ### Verifier keys are the trust boundary Control of `threshold` keys within a set equals the ability to mint any token bound to that set on any zone. Key custody (HSMs, MPC, rotation) is the primary control, not a hardening measure. Per-token verifier sets bound the blast radius to the tokens bound to that set; a compromise does not bound to one chain since the same set may be registered on several zones. ### Shared deployment trust roots All tokens on a deployment share the Relay, Router, Executor, and any contract ownership or upgrade administration. A compromised Relay can deliver arbitrary messages to the Router; a compromised Router can mint or burn any token through the Executor; a compromised owner or upgrade key can replace any contract logic. Per-token verifier sets isolate **verifier-key** compromise only. Implementations MUST document these shared trust roots and SHOULD use timelocks, multi-sig governance, and immutable deployment links to limit their blast radius. ### Verifier set changes affect messages in flight Raising or lowering `threshold`, adding or removing a verifier, and re-binding a token with `setTokenVerifierSet` all change what the destination zone is willing to accept for an undelivered message. Signatures collected under the superseded parameters, or under the superseded binding, then fail verification on the target zone — and because the tokens have already been burned on the source zone, a holder who cannot obtain a fresh verification loses value permanently. The stranding risk originates on-chain, but the failure mode a holder actually experiences is off-chain: the issuer's verification service keeps returning a cached verification that was produced before the change. A client that polls repeatedly, expecting the condition to clear on its own, is handed the same stale bytes indefinitely and never learns that a freshly collected signature set would now be accepted. To close this gap, the issuer's off-chain verification service — the signing API referenced in [Off-Chain Signing and Relay Orchestration](#off-chain-signing-and-relay-orchestration) — MUST expose two retrieval paths rather than one: 1. **Verification retrieval (default).** Given a dispatched message, return the verification already collected for it. This path MAY be served from cache; it is the cheap, high-frequency path used by relayers assembling a `deliver` transaction. 2. **Re-verification (forced refresh).** A distinct endpoint — e.g. `POST /verifications/{globalMessageId}/refresh`, or a `forceRefresh=true` flag on the retrieval request — that MUST NOT ever return a cached result. On every call the service MUST: a. re-read the verifier set currently bound to the `destinationToken` on the destination zone, together with that set's latest `version` and the `configHash` recorded for that version on the destination relay; b. re-run steps 4 to 6 of [Off-Chain Signing and Relay Orchestration](#off-chain-signing-and-relay-orchestration) from scratch against those values, collecting signatures from verifiers of the resolved set; c. return the newly collected signatures, or an explicit error if `threshold` signatures cannot be produced under the destination's current set. Responses SHOULD carry the `verifierSetName` and `verifierSetVersion` the verification was signed under, plus a cache indicator or an `expiresAt` timestamp, so that clients and relayers can recognise a rejection caused by a superseded version and retry through the refresh path instead of surfacing an unrecoverable error. A refusal to re-sign, or an insufficient-signature condition, MUST be reported as a distinct error and MUST NOT be answered by replaying the stale cached verification. Re-verification is not a replay vector. `globalMessageId` is derived from the source transaction hash and the log index, so it is unchanged by re-signing and the refreshed verification carries exactly the same identifier; a refresh only replaces the signature material attached to an already-unique message. The `(verifierSetName, globalMessageId)` consumed key still bounds execution to once per set, and because delivery accepts only the set currently bound to the destination token, a re-binding cannot be exploited to execute one message twice: the stale binding is rejected by the acceptance check before its replay slot is ever exercised. This is precisely why the on-chain acceptance rule stays strict — an on-chain window that accepted previous bindings would widen the replay surface, whereas a refresh window that only re-signs does not. A forced-refresh endpoint, however, converts a public identifier into signing work, and a `globalMessageId` is not secret: it is the hash of a public transaction hash and a log index, so anyone able to read the source chain can enumerate in-flight messages and drive the endpoint. Deployments MUST build the refresh path to absorb that rather than only legitimate traffic: - rate-limit per caller and per `globalMessageId`, and coalesce concurrent refresh requests for the same message into one signing round, so retries against a single identifier cannot multiply into `threshold` signature requests each; - resolve the descriptor server-side, as above, and MUST NOT honour a caller-supplied `verifierSetName`, `verifierSetVersion` or `configHash` — doing so would let an outsider spend verifier capacity on descriptors no destination would ever accept; - confirm there is something to sign before spending capacity: the message corresponds to a `Dispatched` log on the source chain, the descriptor resolves on the destination, and `(verifierSetName, globalMessageId)` is not yet consumed. An already-settled message MUST be reported with a distinct result rather than re-signed; - keep the endpoint reachable by any party that can present a dispatched message. Per-call authentication is deliberately absent: an account requirement, allow-list, or commercial relationship would re-create the stranding the endpoint exists to remove, since the holder of stranded tokens is exactly the party that may have no relationship with the issuer. What bounds the exposure is therefore not access control but the properties above — a refresh can only ever produce signatures for a message that already exists, only under the descriptor the destination currently accepts, and only for a message that has not already been consumed. Deployments SHOULD log every refresh request, SHOULD alert on volume anomalies, and MUST treat a sustained refusal to serve as a breach of the [Issuer Execution Guarantee](#issuer-execution-guarantee) rather than as an operational limitation. The two halves must stay aligned: the destination accepts only its own current binding and current `version`, so the only recovery path available to a holder is a fresh signature from the issuer's service. Managers SHOULD therefore confirm that no messages are in flight before changing `threshold` or verifier membership; that check is made off-chain, by diffing `Dispatched` against `Delivered` for the set. The constraints stated in [Verifier Set Administration](#verifier-set-administration) — a non-zero `threshold` bounded by current membership, and no removal of a set while undelivered messages may exist — are precisely what keep this recovery path open: a set reduced below its own threshold, or removed outright, leaves the refresh with nothing to sign under. ### The emitted message is not the verified message `globalMessageId` is zero in the emitted log, and the `verifierSetName`/`verifierSetVersion` pair the source relay stamps is the *source* binding, which the destination is under no obligation to share. Verifiers sign an EIP-712 digest of the reconstructed `CrossChainTransfer` struct, whose header differs from the emitted log in exactly those three slots. Verifiers MUST compare the remaining wire header fields against the `Dispatched` event log field by field, and MUST refuse to sign on any further difference. Any other participant — a relayer, an indexer, or an auditor — that assumes byte-for-byte equality between the emitted log and the delivered message will reach a false conclusion. Domain separation is provided by the EIP-712 domain separator of the source zone, which is the protocol name, the protocol version, the source EIP-155 `chainId`, and the source relay contract address, recomputable on the destination from the zone registry. The signed `CrossChainTransfer` struct additionally carries both relay addresses, the destination zone, the verifier set name, version and `configHash`, and the message body hash. The `destinationRelay` field is what keeps a signature from taking effect on a third deployment whose relay happens to share an address with the source relay. Verifier keys MUST be single-purpose. ### Chain reorganizations `globalMessageId` derives from the source transaction hash. If the source transaction is reorganized after verifiers sign, tokens may be minted on destination while the burn is reversed. Deployments SHOULD define an operator policy that waits for a sufficient number of confirmations before requesting signatures. Operators SHOULD record the block hash and block number observed at signing for audit purposes. ### Off-chain derivation of `globalMessageId` The message header does not contain `txHash` or `eventIndex`; the relay therefore cannot recompute or independently verify `globalMessageId` during `deliver`. Correct replay protection depends on at least `threshold` verifiers honestly deriving `globalMessageId` from the source-chain transaction receipt and substituting it into the header before signing. A colluding threshold can replay a message by signing the same `globalMessageId` for a different payload, or suppress a message by refusing to sign a valid derivation. This is a known trust assumption of the verifier set model and MUST be disclosed in any security audit or deployment documentation. ### Replay protection Replay is prevented by recording `(verifierSetName, globalMessageId)`. The record must be exact (no probabilistic structures like Bloom filters) and written before the target contract is invoked (prevents reentrant replay). `globalMessageId` is a deployment-wide unique identifier for a source dispatch event. The same event MUST NOT be deliverable more than once within the same verifier set. Keying the consumed set by verifierSetName ensures that, delivery under one set does not consume the replay slot for the same globalMessageId under a different set — preventing a relayer acting for one set from preemptively blocking another set's legitimate delivery on the destination chain. The router independently checks that the message's `verifierSetName` and `verifierSetVersion` match the set currently bound to the destination token and that set's current version; this is a verification check, not a replay check, and MUST NOT expand the number of times a message can be executed. ### Rate limiting Key compromise allows unlimited minting on every zone. Deployments SHOULD implement per-token rate limits (daily cap, per-transfer maximum, circuit breaker) configurable by the issuer. ### Role separation Operator (router/relay owner) and issuer keys MUST NOT be held by the same party. Collapsing them removes the onboarding review and lets the operator populate an issuer's verifier set with its own keys. `registerIssuer` is one-time per token and not recoverable. ### Administration key loss is not recoverable by design The protocol has four authorities — relay owner, router owner, per-token issuer, per-set manager — and losing an issuer or manager key has no built-in fallback. This is a consequence of issuer sovereignty, not an omission: any owner-side override that could reassign a token or reconfigure a set would be exactly the privilege the model refuses to grant the operator, so the specification MUST NOT be read as recommending one. What each loss costs: - **Issuer key lost.** The token cannot be rebound to another verifier set, mapped to another zone, or enabled and disabled again, and `transferIssuer` cannot even be nominated because only the current issuer may nominate. The configuration freezes as-is. If inbound remains enabled and the set is intact, transfers keep flowing — but the issuer has lost the ability to respond to an incident, including pausing a token that is being exploited. - **Set manager key lost.** Verifier membership and `threshold` can no longer change. A verifier key that is later compromised cannot be removed, and one that is lost cannot be replaced. - **Manager key and `threshold` verifier keys lost together.** No signature can ever be produced for that set, so every undelivered message bound to it is undeliverable and the corresponding burned tokens are unrecoverable through this protocol. - **A single verifier key lost while `threshold` < membership.** Survivable, which is the one asymmetry worth engineering for: signing capacity has slack, administrative authority does not. Deployments MUST document the custody arrangement and the recovery runbook for each of the four roles, and MUST disclose in their security documentation that administrative key loss is permanent. Deployments SHOULD hold the issuer and manager roles in a threshold multisig or an MPC-backed administrative contract rather than a single EOA, SHOULD keep the manager key distinct from the signing keys it controls membership of, SHOULD run `transferIssuer` and `transferVerifierSetManager` as planned operations with the successor key exercised beforehand rather than as a response to an accident, and SHOULD size every set so that losing one signer does not reduce available signers below `threshold`. ### Configuration errors Asymmetric mappings (A→B exists but B→A does not) destroy tokens on A that are rejected on B. Symmetry MUST be verified on both chains before enabling. Decimals mismatch is the most damaging: `amount` is carried verbatim, so an 18-decimal source mapped to a 6-decimal destination mints `10^12`× the intended value, while the 6-decimal source to 18-decimal destination silently delivers `10^12`× fewer. Token enablement is split into outbound and inbound controls. `setTokenOutboundEnabled(token, false)` stops new transfers from being dispatched on the source chain. `setTokenInboundEnabled(token, false)` stops new inbound mints on the destination chain. `connectPeer` is one-time per zone. Replacing a peer requires `proposePeerUpdate` and then `applyPeerUpdate` after `PEER_UPDATE_DELAY`, and the pending value is readable through `getPendingPeerUpdate`. Issuers whose tokens route through a zone SHOULD watch `PeerUpdateProposed` for that zone and disable inbound for their tokens if the proposal is not one they authorised; the delay exists precisely to make that response possible. Zone registry entries carry the same hazard on the transport side — `ZoneConnected` changes the digest every message from that zone is checked against — and SHOULD be monitored with the same care. ### Issuer execution guarantee This protocol does not define message cancellation or automatic refund. Once a transfer is dispatched and the source tokens are burned, the token issuer MUST ensure that the corresponding message can be successfully executed on the destination chain. This obligation includes, but is not limited to, maintaining correct token mappings, keeping the destination token inbound-enabled, preserving the verifier set binding, and ensuring the destination token contract will not revert during mint. If a `deliver` call reverts because of issuer misconfiguration — for example, the destination token is disabled, the mapping is wrong, or the token contract rejects the mint — the burned tokens on the source chain are not automatically recoverable through this protocol. The issuer, not the protocol or the user, bears responsibility for resolving such failures. `rescueTokens` or similar issuer-discretionary recovery functions MUST NOT be used to compensate tokens that participate in cross-chain supply, because they break the supply-conservation invariant. Any recovery function for accidentally sent non-protocol tokens MUST be explicitly restricted to tokens that are not registered in the protocol. ### Relayer economics and liveness `deliver` is permissionless unless `authorizedRelayer` is set, so any address with destination-chain gas can act as a relayer. The protocol does not embed fee payment in the message header; relayer compensation is a deployment concern. Implementations SHOULD document how relayers are rewarded (e.g., source-chain fee locked at dispatch, destination-chain fee deducted from the transferred amount, or off-chain reimbursement). Relayers SHOULD size their `deliver` transactions to a target gas limit and SHOULD retry transient failures such as destination-chain congestion. Because delivery is permissionless, a stalled relayer can be taken over by another without issuer action. Issuers MAY set `authorizedRelayer` to a trusted relay service for operational or compliance reasons, but this introduces a liveness dependency on that service. `authorizedRelayer` restricts only the address that submits `deliver`; it does not change the token recipient or the message body. Relayers MUST NOT modify the message or verification bytes; any modification invalidates the signatures. ### The delivery path and message expiry `deliver` is permissionless unless `authorizedRelayer` is set. The protocol defines no message expiry; deployments SHOULD consider a maximum message age. Deployments SHOULD consider rate-limiting dispatch calls. ### Layer boundaries Each layer MUST validate independently: the relay must not trust the router's payload; the router must not trust the relay's source (peer check); the executor must not trust its caller beyond the address check. ## Copyright Copyright and related rights waived via [CC0](../LICENSE.md).