Advanced
Designing Bridges — Project — minimal bridge skeleton
Understand cross-chain architecture.
Developer Advanced
7/8 — Designing Bridges — Project — minimal bridge skeleton
What to writeLock on source, release on target, with verifier-signed proofs and replay protection.
A distributed system is one in which the failure of a computer you didn’t even know existed can render your own computer unusable.
- State & trust
Store token, verifier, used message IDs, and pause flag.IERC20 public immutable token; address public verifier; bool public paused; mapping(bytes32 => bool) public used; event Locked(address indexed sender, address indexed to, uint256 amount, uint256 targetChain, uint256 nonce, bytes32 id); event Released(address indexed to, uint256 amount, uint256 sourceChain, uint256 nonce, bytes32 id); event VerifierUpdated(address indexed oldV, address indexed newV); event Paused(bool status);Verifier can be a multi-sig/operator; add rotation + pause controls.
- Hash builder
Domain-separated message ID.function _messageId( uint256 sourceChain, uint256 targetChain, address to, uint256 amount, uint256 nonce ) internal pure returns (bytes32) { return keccak256(abi.encodePacked(sourceChain, targetChain, to, amount, nonce)); }Includes both chain IDs and nonce to block cross-domain replays.
- Lock on source
Take tokens, emit ID for relayers.function lock(address to, uint256 amount, uint256 targetChain, uint256 nonce) external { require(!paused, "paused"); token.transferFrom(msg.sender, address(this), amount); bytes32 id = _messageId(block.chainid, targetChain, to, amount, nonce); require(!used[id], "used"); used[id] = true; emit Locked(msg.sender, to, amount, targetChain, nonce, id); }Marks message used at lock time to prevent duplicate lock events.
- Verify + release on target
Check signer, replay guard, and chain IDs before transfer.function release( address to, uint256 amount, uint256 sourceChain, uint256 nonce, bytes calldata sig ) external { require(!paused, "paused"); bytes32 id = _messageId(sourceChain, block.chainid, to, amount, nonce); require(!used[id], "used"); bytes32 digest = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", id)); address signer = ECDSA.recover(digest, sig); require(signer == verifier, "bad sig"); used[id] = true; token.transfer(to, amount); emit Released(to, amount, sourceChain, nonce, id); }Uses ECDSA over the message ID; marks used before transfer to block replays.
WhyEven a trusted-signer bridge must block replays, bind chain IDs, and expose events for monitoring.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract MiniBridge {
IERC20 public immutable token;
address public verifier;
bool public paused;
mapping(bytes32 => bool) public used;
event Locked(address indexed sender, address indexed to, uint256 amount, uint256 targetChain, uint256 nonce, bytes32 id);
event Released(address indexed to, uint256 amount, uint256 sourceChain, uint256 nonce, bytes32 id);
event VerifierUpdated(address indexed oldV, address indexed newV);
event Paused(bool status);
constructor(address _token, address _verifier) {
token = IERC20(_token);
verifier = _verifier;
}
modifier notPaused() { require(!paused, "paused"); _; }
function setVerifier(address v) external {
require(msg.sender == verifier, "only verifier"); // simple guard; swap to multi-sig in prod
emit VerifierUpdated(verifier, v);
verifier = v;
}
function setPaused(bool p) external {
require(msg.sender == verifier, "only verifier");
paused = p;
emit Paused(p);
}
function _messageId(
uint256 sourceChain,
uint256 targetChain,
address to,
uint256 amount,
uint256 nonce
) internal pure returns (bytes32) {
return keccak256(abi.encodePacked(sourceChain, targetChain, to, amount, nonce));
}
function lock(address to, uint256 amount, uint256 targetChain, uint256 nonce) external notPaused {
token.transferFrom(msg.sender, address(this), amount);
bytes32 id = _messageId(block.chainid, targetChain, to, amount, nonce);
require(!used[id], "used");
used[id] = true;
emit Locked(msg.sender, to, amount, targetChain, nonce, id);
}
function release(
address to,
uint256 amount,
uint256 sourceChain,
uint256 nonce,
bytes calldata sig
) external notPaused {
bytes32 id = _messageId(sourceChain, block.chainid, to, amount, nonce);
require(!used[id], "used");
bytes32 digest = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", id));
address signer = ECDSA.recover(digest, sig);
require(signer == verifier, "bad sig");
used[id] = true;
token.transfer(to, amount);
emit Released(to, amount, sourceChain, nonce, id);
}
}
Try itTests: duplicate lock/release reverts; wrong chain IDs reverts; bad signature reverts; pause blocks lock/release; verifier rotation works.