
Cross-chain asset bridges represent the highest-risk infrastructure in Web3, having suffered over $2.5 billion in cumulative exploit losses. A recurring architectural flaw in compromised bridges is the conflation of authorization and execution: a single relayer or automated multisig detects an event on Chain A and immediately releases funds on Chain B without independent message validation. In Passage Bridge, this single point of failure is dismantled through an operated bridge model: enforcing strict on-chain role separation between approvers and relayers (
_checkSeparateApprover), domain-separated message binding with non-reusable nonce keys, configurable UTC day payout limit circuit breakers, and crash-resilient relayer transaction recovery.
1. The Production Failure State: Why Automated Relayers Drain Bridge Vaults
Historically, cross-chain bridge exploits (such as Nomad, Wormhole, Ronin, and Horizon) trace back to two primary vulnerabilities:
- Unchecked Event Ingestion: An attacker exploits a smart contract bug on the source chain to emit a legitimate-looking deposit event without locking underlying collateral (a “phantom deposit”). The automated relayer ingests the event and executes payouts on the destination chain.
- Relayer Key Compromise: If the relayer key possesses both the authority to approve a message and the authority to submit the payout transaction, compromising that single private key grants the attacker immediate access to 100% of the destination vault inventory.
Consider the failure sequence in an automated, non-separated bridge:
[Failure Mode: Automated Relayer Compromise]
T0: Relayer bot key is compromised via server memory dump or unauthorized RPC access.
T1: Attacker crafts forged transfer payload: sourceChain=1, destChain=10, amount=5,000,000 USDC.
T2: Attacker submits claim directly to destination bridge.
T3: The destination contract checks: "Does msg.sender have RELAYER_ROLE?" -> Yes.
T4: Contract executes payout immediately.
Result: 5,000,000 USDC drained in a single transaction. Total loss.
In an enterprise or consortium bridge operating liquidity vaults across Ethereum L1, Optimism, Base, and Arbitrum, speed must be bounded by defensive verification.
A production operated bridge must enforce two mandatory invariants:
- Separation of Powers: The entity that executes the payout transaction on the destination chain (the Relayer) must be cryptographically prohibited from authorizing or approving the message.
- On-Chain Payout Ceilings: Even if both the approver and relayer keys are compromised simultaneously, the destination contract must enforce immutable rate limits (per-message and per-UTC-day) that cap maximum capital outflow, giving operators time to trigger emergency pauses.
2. On-Chain Message Architecture: MessageManager.sol

The core message inbox and outbox logic in Passage Bridge is encapsulated in bridge-contracts/bridge-contracts/src/core/message/MessageManager.sol:L1-L88.
State Storage and Domain Separation
Rather than passing raw parameters across contracts, all bridge transfers are encapsulated within an immutable Message struct bound by an explicit cryptographic domain separator:
contract MessageManager is InventoryAccounting, IMessageManager {
uint256 public nextMessageNumber;
address public poolManagerAddress;
mapping(bytes32 => bool) public sentMessageStatus;
mapping(bytes32 => bool) public cliamMessageStatus;
mapping(bytes32 => bool) public approvedMessages;
mapping(bytes32 => bool) public consumedNonces;
mapping(bytes32 => bytes32) public approvedNonceHash;
struct PayoutLimit {
uint256 perMessage;
uint256 perDay;
uint256 spent;
uint256 day;
}
mapping(address => PayoutLimit) public payoutLimits;
bytes32 public constant MESSAGE_APPROVER_ROLE = keccak256("MESSAGE_APPROVER_ROLE");
bytes32 public constant MESSAGE_DOMAIN = keccak256("Bridge.Message.v2");
error InvalidMessage();
error MessageNotApproved();
error MessageAlreadyConsumed();
error LegacyMessageDisabled();
error InvalidPayoutLimits();
error PayoutLimitExceeded(address token);
Notice the separation between three distinct mapping sets:
approvedMessages[messageHash]: Tracks whether a specific, exact payload has been vetted and approved by an authorized compliance/governance reviewer.approvedNonceHash[nonceKey]: Binds an outbound sequence slot to one—and only one—message hash. This prevents an approver from approving two competing payouts for the same source deposit.consumedNonces[nonceKey]: Tracks whether the payout has been successfully finalized on-chain, preventing replay attacks.
Full Domain Hashing and Nonce Key Construction
In src/core/message/MessageManager.sol:L91-L105:
function hashMessage(Message calldata message) public pure returns (bytes32) {
return keccak256(abi.encode(MESSAGE_DOMAIN, message));
}
function nonceKey(Message calldata message) public pure returns (bytes32) {
return keccak256(abi.encode(message.sourceChainId, message.sourceBridge, message.nonce));
}
The nonceKey incorporates sourceChainId, sourceBridge, and nonce. Even if two source chains happen to issue the same sequence number (nonce = 42), their destination nonceKey hashes are mathematically isolated, preventing cross-chain collisions.
3. Cryptographic Role Separation: Enforcing Approver != Relayer
A central defense in Passage Bridge is the structural enforcement of separation between approval and execution roles.
In src/core/message/FeeSettlement.sol:L55-L66, the contract inspects caller roles during the approval invocation:
function _checkSeparateApprover() internal view {
require(!IAccessControl(_feePool()).hasRole(POOL_RELAYER_ROLE, msg.sender), "Approver is relayer");
}
When approveMessage is called in MessageManager.sol:L160-L175:
function approveMessage(Message calldata message) external onlyRole(MESSAGE_APPROVER_ROLE) {
// Enforce separation at authorization time, even if governance assigned both roles.
_checkSeparateApprover();
validateMessage(message);
bytes32 key = nonceKey(message);
if (consumedNonces[key]) revert MessageAlreadyConsumed();
bytes32 hash = hashMessage(message);
if (approvedNonceHash[key] != bytes32(0) && approvedNonceHash[key] != hash) revert InvalidMessage();
approvedNonceHash[key] = hash;
approvedMessages[hash] = true;
emit MessageApproved(hash, msg.sender);
}
Even if an administrator accidentally grants both MESSAGE_APPROVER_ROLE and POOL_RELAYER_ROLE to the same address, _checkSeparateApprover() reverts the transaction. Authorization requires an independent key pair, ensuring that an automated relayer service cannot self-authorize arbitrary withdrawals.
4. On-Chain Payout Circuit Breakers: Rolling UTC Day Limits
To contain the blast radius of any potential compromise, MessageManager.sol enforces configurable destination token rate limits (src/core/message/MessageManager.sol:L185-L210):
function setPayoutLimits(address token, uint256 perMessage, uint256 perDay) external onlyRole(DEFAULT_ADMIN_ROLE) {
if (token == address(0) || (perMessage == 0 && perDay != 0) || perMessage > perDay) {
revert InvalidPayoutLimits();
}
PayoutLimit storage limit = payoutLimits[token];
emit PayoutLimitsSet(token, msg.sender, limit.perMessage, limit.perDay, perMessage, perDay);
limit.perMessage = perMessage;
limit.perDay = perDay;
}
function _consumePayoutBudget(Message calldata message, bytes32 hash) private {
PayoutLimit storage limit = payoutLimits[message.destToken];
uint256 day = block.timestamp / 1 days; // UTC calendar day
uint256 spent = limit.day == day ? limit.spent : 0;
if (
message.amount > limit.perMessage ||
message.amount > limit.perDay ||
spent > limit.perDay - message.amount
) revert PayoutLimitExceeded(message.destToken);
limit.day = day;
limit.spent = spent + message.amount;
emit PayoutBudgetConsumed(message.destToken, hash, message.amount, limit.spent, day);
}
Operational Invariants
- Default-Closed: If
setPayoutLimitshas not been configured for an asset (limits are0/0),message.amount > limit.perMessagetriggers immediately, halting all payouts for that token. - Atomic Rollback:
_consumePayoutBudgetexecutes insideclaimMessage. If the subsequent transfer to the user fails (e.g. recipient contract rejects the transfer or pool balance is insufficient), the entire transaction rolls back, restoring the spent daily budget quota.
5. Destination Execution & Source Token Integrity
When a user initiates an outbound transfer on the source chain, the bridge must defend against fee-on-transfer tokens, rebasing balances, or unauthorized assets.
In src/core/bridge/TokenBridgeBase.sol:L190-L205, _receiveToken asserts exact balance deltas:
function _receiveToken(address token, uint256 value) internal returns (uint256 received) {
uint256 beforeBalance = IERC20(token).balanceOf(address(this));
uint256 senderBefore = IERC20(token).balanceOf(msg.sender);
IERC20(token).safeTransferFrom(msg.sender, address(this), value);
received = IERC20(token).balanceOf(address(this)) - beforeBalance;
if (received != value || senderBefore - IERC20(token).balanceOf(msg.sender) != value) {
revert NonstandardDepositToken();
}
}
Any asset that burns tokens on transfer or mints deflationary fractions is rejected with NonstandardDepositToken().
Payout Execution (TokenBridgeBase.sol:L238-L258)
On the destination chain, the Relayer executes BridgeFinalizeMessage:
function BridgeFinalizeMessage(IBridgeMessage.Message calldata message)
external
onlyRole(ReLayer)
whenNotPaused
nonReentrant
returns (bool)
{
// 1. Consume message in MessageManager (verifies approval, limits, and consumes nonce)
messageManager.claimMessage(message);
// 2. Transfer inventory to recipient
address ledger = message.assetType == 0
? ContractsAddress.ETHAddress
: message.assetType == 1 ? ContractsAddress.WETH : message.destToken;
SendAssertToUser(ledger, message.to, message.amount);
emit FinalizeERC20(message.sourceChainId, block.chainid, ledger, message.from, message.to, message.amount);
return true;
}
Because claimMessage runs before SendAssertToUser, reentrancy is structurally blocked. Furthermore, SendAssertToUser asserts that IERC20(destToken).balanceOf(to) - beforeBalance == amount, ensuring that the recipient receives the exact intended payout.
6. Durable Relayer Recovery: Surviving Restarts and Ambiguous Broadcasts
In production, automated relayer nodes face network disconnects, node restarts, and RPC timeouts. If a relayer signs a transaction, broadcasts it, and crashes before receiving the receipt, a naive restart script might attempt to allocate a new nonce, causing consecutive transaction collisions.
In bridge-platform/src/modules/bridge/relayer-runner.ts:L1-L209, LocalRelayer implements deterministic crash recovery:
export function canRelay(message: any) {
return (
!!message &&
message.source_status === 'finalized' &&
message.approval_status === 'approved' &&
message.approval_finality === 'finalized' &&
message.indexing_status === 'indexed' &&
message.execution_status === 'not_claimed'
);
}
The Recovery Sequence (relayer-runner.ts:L120-L185)
When the relayer worker restarts:
- Acquire Distributed Account Lock:
relayAccountLock(chainId, signerAddress)serializes all relayer instances to ensure that only one thread manages nonces for a given key. - Scan for Active Attempts: Before looking for new bridge messages, the runner queries
bridge_attemptsfor any record inprepared,broadcast,pending, orincludedstatus. - Receipt vs. Mempool Disambiguation:
- If
getTransactionReceipt(txHash)returns a confirmed receipt, the database updates the message toclaimed. - If the receipt is missing but
getTransaction(txHash)shows the transaction in the mempool, the runner setsstatus = 'pending'and waits. - If the node has dropped the transaction entirely, the runner rebroadcasts the exact same signed raw transaction bytes (
signed_raw_tx) up to 3 times, without changing the nonce or signing a new payload.
- If
- Attention Escalation: If the account’s on-chain nonce has incremented unexpectedly (
latestNonce > attempt.nonce), indicating that an external transaction consumed the sequence, the runner marks the attempt asattention(NONCE_CONSUMED_UNKNOWN_TRANSACTION) and halts automatic broadcasting until an operator reviews the ledger.
7. Concrete Verification: Foundry Test Suite
The security invariants of Passage Bridge are verified in bridge-contracts/bridge-contracts/test/MessageApproval.t.sol:
Approval Field Tampering Fuzz Test
Verifies that tampering with any of the 12 fields inside Message (source chain, destination chain, bridges, tokens, recipient, amount, fee, or nonce) breaks cryptographic authorization and reverts:
function testFuzz_EveryMessageFieldIsBound(uint8 field) public {
_deposit(alice, 2 ether);
IBridgeMessage.Message memory m = _message();
vm.prank(approver);
messages.approveMessage(m);
uint8 f = uint8(bound(field, 0, 11));
if (f == 0) m.sourceChainId = _baseChain();
else if (f == 1) m.destChainId = _opChain();
else if (f == 2) m.sourceBridge = alice;
else if (f == 3) m.destBridge = alice;
else if (f == 4) m.assetType = 1;
else if (f == 5) m.sourceToken = address(token);
else if (f == 6) m.destToken = address(token);
else if (f == 7) m.from = bob;
else if (f == 8) m.to = alice;
else if (f == 9) m.amount = 0.5 ether;
else if (f == 10) m.fee = 0;
else m.nonce = 2;
vm.prank(relayer);
vm.expectRevert();
pool.BridgeFinalizeMessage(m);
assertEq(bob.balance, 1000 ether);
}
Relayer Self-Approval Rejection Test
Verifies that an address holding the POOL_RELAYER_ROLE is barred from calling approveMessage, even if the admin mistakenly grants it the approver role:
function test_RelayerCannotApproveItsOwnMessage() public {
IBridgeMessage.Message memory m = _message();
vm.prank(relayer);
vm.expectRevert();
messages.approveMessage(m);
vm.prank(admin);
messages.grantRole(keccak256("MESSAGE_APPROVER_ROLE"), relayer);
vm.prank(relayer);
vm.expectRevert();
messages.approveMessage(m);
}
8. Architectural Comparison: Bridge Security Models
| Technical Dimension | Optimistic Multisig Bridge | Automated Oracle Bridge | Operated Bridge (Passage Bridge) |
|---|---|---|---|
| Approval-Execution Coupling | Coupled (Relayers sign and execute) | Coupled (Oracle pushes data directly) | Decoupled (Approver != Relayer check) |
| Message Domain Binding | Basic tuple encoding | Chain-specific struct | Full struct binding with MESSAGE_DOMAIN |
| Nonce Reuse Protection | Array lookup | Dynamic mapping | Deterministic nonceKey mapping |
| Outflow Rate Limiting | None | Global pause only | UTC Day & Per-Message Payout Limits |
| Nonstandard Token Defense | Standard safeTransfer |
Basic checks | Exact balance delta assertion before & after |
| Relayer Crash Recovery | Blind nonce increment | Manual database fix | Durable bridge_attempts signed-byte replay |
| Operator Visibility | External explorer only | Webhook logs | Full Admin Console with Rescan / Retry Jobs |
9. Integration & Next Steps
Cross-chain liquidity infrastructure requires defense-in-depth engineering. Automated, unconstrained bridge relayers are structural targets for exploits.
The architecture outlined here—enforcing cryptographic role separation, domain-separated message binding, on-chain payout circuit breakers, and crash-resilient relayer recovery—forms the technical foundation of Passage Bridge.
Recommended Implementation Roadmap:
- Audit Existing Bridge Contracts: Ensure that outbound deposit messages bind source and destination chain IDs, bridge contract addresses, and token pairs explicitly.
- Review Operational Architecture Guidelines: Explore our companion guides on Binding Cross-Chain Messages to Their Execution Context and Recovering a Bridge Relayer After a Restart.
- Explore the Working Product: Review the screen captures and verified multichain workflows on the Passage Bridge Product Page or explore the Operations Overview.
- Schedule a Technical Architecture Consultation: If your organization is launching custom multichain liquidity pools, app-chain asset bridges, or institutional token transfers, book an architecture walkthrough or request an implementation proposal.