Token Bridge Contract Development
A token bridge is infrastructure that enables moving assets between incompatible blockchains. From the user's perspective it's simple: locked 100 USDC on Ethereum, got 100 USDC on Arbitrum. Behind that simplicity lies one of the most vulnerable classes of smart contracts: Ronin ($625M), Wormhole ($320M), Nomad ($190M) — all hacks happened through bridges.
This is not coincidence. A bridge by definition manages locked assets on one chain and issues synthetic assets on another. Hacking a bridge = stealing all locked funds at once. Complexity is increased by the fact that system security depends on cross-chain message security — this is a fundamentally hard problem.
Architectural Patterns
Lock-and-Mint vs Burn-and-Release
Lock-and-Mint: token is locked on source chain, wrapped version is minted on destination chain. Example: WBTC — BTC locked with custodian, ERC-20 WBTC minted on Ethereum.
Advantage: original token needs no modifications (no burn function needed). Disadvantage: liquidity is fragmented — wrapped token on each chain is separate.
Burn-and-Release: native token is burned on source chain, unlocked on destination chain. Requires token to have cross-chain-aware logic or be specially designed (Circle CCTP for USDC uses this model).
Liquidity pool model (hub-and-spoke): liquidity pool of native token on each chain. User deposits on one side, receives from pool on other. How Hop Protocol and Across Protocol work. Advantage: native tokens on both sides. Disadvantage: pools need liquidity, otherwise bridge doesn't work.
For custom projects: if your token and you control its contract — Burn-and-Release is simpler and safer (no locked funds as attack target). If bridging someone else's token — Lock-and-Mint.
Message Verification Models
This is the key architectural choice. How does destination chain know that an event on source chain actually happened?
Optimistic verification (Nomad, Across): message is considered valid if no one disputes it within period (usually 30 minutes to several hours). Disadvantage: latency. Advantage: cheaper to operate. Nomad was hacked due to dispute logic error — trusted message could be replicated with different payload.
Multisig verification (most production bridges): N of M validators sign event confirmation. Wormhole used 19 guardians. Vulnerability: compromising threshold number of keys. Ronin was hacked this way — 5 of 9 validator keys were compromised.
Light client verification (zkBridge, IBC): destination chain verifies consensus proof of source chain. Most secure but expensive on gas. ZK-based verification (Succinct, =nil; Foundation) allows compressing proof, making it practical.
Native bridges (Arbitrum, Optimism canonical bridge): use rollup's own fraud proof or validity proof mechanism. Most secure, but only for specific L1-L2 pair and with 7-day withdrawal period (optimistic rollups).
Detailed Lock-and-Mint Bridge Implementation
Source chain contract (Locker)
contract BridgeLocker {
mapping(uint32 => bool) public supportedChains;
mapping(bytes32 => bool) public processedNonces;
event TokensLocked(
address indexed token,
address indexed sender,
address indexed recipient,
uint256 amount,
uint32 destinationChain,
bytes32 nonce
);
function lock(
address token,
uint256 amount,
address recipient,
uint32 destinationChain
) external nonReentrant {
require(supportedChains[destinationChain], "Chain not supported");
require(amount > 0, "Zero amount");
// Generate unique nonce for this transfer
bytes32 nonce = keccak256(abi.encodePacked(
block.chainid,
destinationChain,
msg.sender,
recipient,
token,
amount,
block.timestamp,
blockhash(block.number - 1)
));
IERC20(token).safeTransferFrom(msg.sender, address(this), amount);
emit TokensLocked(token, msg.sender, recipient, amount, destinationChain, nonce);
}
function release(
address token,
address recipient,
uint256 amount,
bytes32 nonce,
bytes[] calldata signatures
) external {
require(!processedNonces[nonce], "Already processed");
require(_verifySignatures(token, recipient, amount, nonce, signatures), "Invalid signatures");
processedNonces[nonce] = true;
IERC20(token).safeTransfer(recipient, amount);
}
}
Critical moment with nonce: it must be unpredictable and unique. Simple counter (nonce++) is vulnerable — attacker can precompute nonce and attempt replay. Including blockhash adds unpredictability.
Destination chain contract (Minter)
contract BridgeMinter {
mapping(address => address) public wrappedTokens; // original → wrapped
mapping(bytes32 => bool) public mintedNonces;
function mint(
address originalToken,
address recipient,
uint256 amount,
bytes32 nonce,
bytes[] calldata signatures
) external {
require(!mintedNonces[nonce], "Already minted");
require(_verifySignatures(originalToken, recipient, amount, nonce, signatures), "Invalid");
mintedNonces[nonce] = true;
address wrapped = wrappedTokens[originalToken];
if (wrapped == address(0)) {
wrapped = _deployWrappedToken(originalToken);
wrappedTokens[originalToken] = wrapped;
}
IWrappedToken(wrapped).mint(recipient, amount);
emit TokensMinted(originalToken, wrapped, recipient, amount, nonce);
}
function burn(
address wrappedToken,
uint256 amount,
address recipient,
uint32 destinationChain
) external nonReentrant {
IWrappedToken(wrappedToken).burnFrom(msg.sender, amount);
// emit event for relayers
emit TokensBurned(wrappedToken, msg.sender, recipient, amount, destinationChain);
}
}
Validator Signature Verification
function _verifySignatures(
address token,
address recipient,
uint256 amount,
bytes32 nonce,
bytes[] calldata signatures
) internal view returns (bool) {
require(signatures.length >= threshold, "Not enough signatures");
bytes32 messageHash = keccak256(abi.encodePacked(
block.chainid,
token,
recipient,
amount,
nonce
));
bytes32 ethSignedHash = MessageHashUtils.toEthSignedMessageHash(messageHash);
address lastSigner = address(0);
for (uint256 i = 0; i < signatures.length; i++) {
address signer = ECDSA.recover(ethSignedHash, signatures[i]);
require(isValidator[signer], "Not a validator");
require(signer > lastSigner, "Duplicate signer"); // protection from duplicates
lastSigner = signer;
}
return true;
}
Protection against signature duplication through sorting — classic pattern from Gnosis Safe. Without signer > lastSigner check, one validator can sign N times and pass threshold.
Relayer Infrastructure
Relayer is off-chain service that monitors events on source chain and initiates transactions on destination chain.
Reliable Relayer Architecture
class BridgeRelayer {
async watchSourceChain() {
const filter = lockerContract.filters.TokensLocked();
sourceProvider.on(filter, async (event) => {
// Wait for confirmations (finality)
const receipt = await this.waitForFinality(event.transactionHash);
// Collect signatures from validators
const signatures = await this.collectSignatures(event);
// Submit to destination chain with retry
await this.submitWithRetry(event, signatures);
});
}
async waitForFinality(txHash: string): Promise<TransactionReceipt> {
// For Ethereum: 12 blocks (~2.5 minutes)
// For Polygon: 128 blocks (Bor finality)
// For Arbitrum: 1 block is enough (sequencer finality for L2→L2)
const CONFIRMATIONS = this.config.requiredConfirmations[this.sourceChainId];
return await sourceProvider.waitForTransaction(txHash, CONFIRMATIONS);
}
}
Finality is critical parameter. Ethereum has probabilistic finality, but with PoS checkpoint finality every ~12 minutes. If relayer submits mint before source transaction finality, reorg on source chain creates situation: mint happened but lock didn't. This enabled some early bridge attacks.
Retry and Idempotency
Destination chain transaction might fail: insufficient gas, nonce collision, destination chain congestion. Relayer should retry with exponential backoff. Idempotency is ensured by mintedNonces[nonce] check in contract — repeated mint with same nonce is rejected.
Security: Top 5 Attack Vectors
1. Replay attack between networks
Message valid for Arbitrum is replicated on Optimism. Protection: include block.chainid (EIP-155) and destinationChainId in signed message.
2. Signature malleability
ECDSA allows two valid s values for one signature. OpenZeppelin ECDSA.recover since version 4.7.3 checks s in lower half of curve. Never use ecrecover directly.
3. Reentrancy in release/mint
If release calls safeTransfer before updating processedNonces — attacker through callback can repeat the call. Checks-Effects-Interactions pattern + nonReentrant are mandatory.
4. Validator key compromise
Solution: threshold signature scheme (TSS) instead of regular multisig. TSS generates distributed key — no one knows full private key. Even with one participant compromised, key is unrecoverable. Libraries: tss-lib (Binance), Silence Laboratories SDK.
5. Infinite mint through upgradeable proxy
If Minter is upgradeable proxy, upgrade function must be under timelock + multisig. Wormhole Solana exploit was through direct call to deprecated function without check.
Testing
Foundry is perfect for bridges: fork tests allow working with real mainnet state.
function test_bridgeRoundTrip() public {
// Fork Ethereum mainnet
vm.createSelectFork(vm.envString("ETH_RPC"), 19_000_000);
// Simulate lock on Ethereum
vm.prank(user);
locker.lock(USDC, 1000e6, user, ARBITRUM_CHAIN_ID);
// Collect validator signatures (mock)
bytes[] memory sigs = _signMessage(messageHash, validatorKeys);
// Switch to Arbitrum fork
vm.createSelectFork(vm.envString("ARB_RPC"), 180_000_000);
// Mint on Arbitrum
minter.mint(USDC_ARB, user, 1000e6, nonce, sigs);
assertEq(wrappedUSDC.balanceOf(user), 1000e6);
}
Tests for edge cases: double-spend through nonce replay, wrong chain ID in signature, threshold signatures with duplicate addresses.
Stack and Timeline
Contracts: Solidity 0.8.x + Foundry + OpenZeppelin 5.x + Hardhat (for multi-chain deploy scripts). Relayer: TypeScript + viem + BullMQ (task queue) + PostgreSQL (pending transfers storage). Monitoring: Tenderly for alerts + Grafana for relayer metrics.
| Component | Complexity | Timeline |
|---|---|---|
| Locker + Minter contracts | High | 2–3 weeks |
| Signature verification | Medium | 1 week |
| Relayer service | High | 2–3 weeks |
| Wrapped token factory | Low | 3–5 days |
| Tests (unit + fork) | High | 2 weeks |
| Audit (external) | — | 3–6 weeks |
Audit is mandatory. Not as formality, but as deployment condition. Minimum one specialized auditor with bridge project experience. Contract managing locked funds, without audit — this is risk of losing entire TVL.
Total timeline from kick-off to mainnet: 3–5 months with audit. Cost calculated after clarifying chain pairs, verification model, and decentralization requirements for validator network.







