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.
— Leslie Lamport
  1. 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.

  2. 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.

  3. 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.

  4. 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.

← Previous section
Next section →