Cross-chain Bridge Development
A cross-chain bridge is a system that allows moving assets or data between independent blockchain networks. At first glance, the task seems simple: lock tokens on chain A, issue equivalent on chain B. In practice, it's one of the most technically and security-complex products in Web3. Bridge exploits comprise a large portion of all crypto losses ($2B+ lost on Ronin, Wormhole, Nomad, Harmony).
Architectural Patterns
Lock-and-Mint (wrapped tokens)
The classic scheme:
- User locks ETH in Lock contract on Ethereum
- Bridge issues wETH (wrapped ETH) on Polygon
- On reverse transfer: burn wETH on Polygon → unlock ETH on Ethereum
Risks: all collateral concentrated in one contract on Ethereum. Hack = loss of entire locked TVL. Wormhole lost $320M exactly this way.
Burn-and-Mint (native tokens)
Applied for tokens with cross-chain minting capability (USDC via Circle CCTP, USDT via Tether bridge):
- Burn USDC on Ethereum (Circle destroys backing)
- Mint native USDC on Arbitrum (Circle issues new backing)
Advantage: no locked TVL = no single point of failure. But requires control over token contract on all chains.
Liquidity Pool (liquidity network)
Used in Stargate, Hop Protocol:
- Liquidity pool on each chain
- User deposits USDC on Ethereum pool → receives USDC from Arbitrum pool
- LP providers receive fees for providing liquidity
Advantage: fast execution without waiting for finality. Risk: imbalanced pools (more withdrawals from one side than deposits).
Native verification (light client bridges)
Most decentralized option: smart contract on chain B verifies block headers of chain A through light client. Proves transaction occurred without relying on validators.
Examples: ICS-23 (Cosmos IBC), Rainbow Bridge (NEAR → Ethereum). Complexity: high gas cost for verification, especially for PoW (Ethereum pre-merge).
Optimistic bridges
Optimistic verification: messages accepted as valid, but there's a period (usually 30 min — 7 days) during which a watcher can challenge and block fraudulent transaction. Used in Across Protocol, Connext (partially).
Tradeoff: security vs speed. 7-day period = slow, but very safe.
Implementation Details
Lock Contract (Ethereum)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/security/Pausable.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";
contract BridgeLock is ReentrancyGuard, Pausable, AccessControl {
bytes32 public constant RELAYER_ROLE = keccak256("RELAYER_ROLE");
bytes32 public constant GUARDIAN_ROLE = keccak256("GUARDIAN_ROLE");
// TVL limits for protection
mapping(address => uint256) public tokenTVLLimits;
mapping(address => uint256) public tokenCurrentTVL;
// Daily limits
mapping(address => uint256) public dailyBridgeLimit;
mapping(address => uint256) public dailyBridgedAmount;
mapping(address => uint256) public lastResetTimestamp;
// Nonce for replay prevention
mapping(bytes32 => bool) public processedDeposits;
event Deposit(
bytes32 indexed depositId,
address indexed sender,
address indexed token,
uint256 amount,
uint256 destinationChainId,
address recipient
);
function deposit(
address token,
uint256 amount,
uint256 destinationChainId,
address recipient
) external nonReentrant whenNotPaused returns (bytes32 depositId) {
require(amount > 0, "Zero amount");
require(tokenTVLLimits[token] > 0, "Token not supported");
// TVL check
require(
tokenCurrentTVL[token] + amount <= tokenTVLLimits[token],
"TVL limit exceeded"
);
// Daily limit check
_checkAndUpdateDailyLimit(token, amount);
// Generate unique deposit ID
depositId = keccak256(abi.encodePacked(
msg.sender, token, amount, destinationChainId, recipient,
block.chainid, block.number, block.timestamp
));
require(!processedDeposits[depositId], "Duplicate deposit");
processedDeposits[depositId] = true;
// Transfer tokens
IERC20(token).safeTransferFrom(msg.sender, address(this), amount);
tokenCurrentTVL[token] += amount;
emit Deposit(depositId, msg.sender, token, amount, destinationChainId, recipient);
}
// Only RELAYER_ROLE can unlock
function unlock(
bytes32 depositId,
address token,
uint256 amount,
address recipient,
bytes calldata proof
) external onlyRole(RELAYER_ROLE) nonReentrant {
// Verify proof (validator signatures or merkle proof)
require(_verifyProof(depositId, token, amount, recipient, proof), "Invalid proof");
// Idempotency
require(!processedUnlocks[depositId], "Already unlocked");
processedUnlocks[depositId] = true;
tokenCurrentTVL[token] -= amount;
IERC20(token).safeTransfer(recipient, amount);
emit Unlock(depositId, recipient, token, amount);
}
}
Mint Contract (destination chain)
contract BridgeMint is ERC20, AccessControl {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
// Mapping originTxHash → already processed
mapping(bytes32 => bool) public mintedDeposits;
function mint(
bytes32 depositId,
address recipient,
uint256 amount,
bytes calldata validatorSignatures
) external onlyRole(MINTER_ROLE) {
require(!mintedDeposits[depositId], "Already minted");
// Verify M-of-N signatures from validators
_verifyValidatorSignatures(depositId, recipient, amount, validatorSignatures);
mintedDeposits[depositId] = true;
_mint(recipient, amount);
emit Minted(depositId, recipient, amount);
}
function burn(uint256 amount, uint256 targetChainId, address recipient) external {
_burn(msg.sender, amount);
emit Burned(msg.sender, amount, targetChainId, recipient);
}
}
Validator / Relayer
Off-chain component that monitors source chain events and initiates mint/unlock on destination:
class BridgeRelayer {
private validators: Signer[];
private threshold: number;
async watchSourceChain() {
this.sourceBridge.on("Deposit", async (depositId, sender, token, amount, destChain, recipient, event) => {
// Wait for sufficient confirmations
await this.waitForConfirmations(event.blockNumber, REQUIRED_CONFIRMATIONS);
// Each validator signs deposit data
const signatures = await this.collectValidatorSignatures(
depositId, token, amount, destChain, recipient
);
if (signatures.length >= this.threshold) {
await this.executeMint(destChain, depositId, recipient, amount, signatures);
}
});
}
private async collectValidatorSignatures(
depositId: string,
token: string,
amount: bigint,
destChain: number,
recipient: string
): Promise<string[]> {
const messageHash = ethers.solidityPackedKeccak256(
["bytes32", "address", "uint256", "uint256", "address"],
[depositId, token, amount, destChain, recipient]
);
const signatures = await Promise.all(
this.validators.map(v => v.signMessage(ethers.getBytes(messageHash)))
);
return signatures;
}
}
Security — Critical Points
Replay attack protection. Same signed transaction should not execute twice or on another chain. Solution: include chainId and unique nonce in signed data.
Signature malleability. ECDSA signatures are mathematically malleable — one can be transformed into another valid one. OpenZeppelin ECDSA.recover solves this, but it's important to use it, not raw ecrecover.
Reentrancy. unlock/mint functions must update state BEFORE external calls (transfer). Checks-effects-interactions pattern or nonReentrant modifier.
TVL caps. Limiting total TVL on contract reduces maximum hack damage. If cap is $10M — max loss is $10M, not $500M.
Timelock for upgrades. Bridge contract changes must have timelock (minimum 48 hours). This gives users time to withdraw funds if change seems suspicious.
Emergency pause. Guardian role can stop all incoming/outgoing transfers when anomalies detected. Automatically via circuit breaker (abnormally large withdrawal).
Monitoring
Bridge without monitoring is not production. Minimum set of alerts:
- TVL sudden drop (>10% in 5 minutes)
- Abnormally large transactions (>1% of TVL)
- Mismatch between locked and minted (invariant check)
- Validator downtime (no signatures from validator >N minutes)
Choosing Existing Solution vs Custom Bridge
Build custom bridge if:
- Custom token economics (not standard ERC-20)
- Specific security requirements
- Unsupported chains in existing protocols
- Full control over fee structure
Use existing (LayerZero, Axelar, CCIP) if:
- Standard ERC-20 bridging
- Need fast launch
- No resources for custom bridge security audit
Custom bridge audit cost — $50-200k. This is mandatory, not optional.
Stack
| Component | Technology |
|---|---|
| Smart contracts | Solidity 0.8.x + OpenZeppelin + Foundry |
| Validator network | Node.js + TypeScript + ethers.js |
| Relayer | Node.js + Bull queue + Redis |
| Monitoring | Grafana + Prometheus + PagerDuty |
| Frontend | React + wagmi + viem |
| Infrastructure | AWS ECS + RDS + CloudWatch |
Timelines
- Basic lock-and-mint (2 chains, ERC-20): 6-8 weeks
- Multi-chain support (+3 chains): +4-6 weeks
- Security audit: mandatory, 4-8 weeks
- Production hardening + monitoring: +3-4 weeks
- Total: 4-5 months







