FABRKNT
Cross-Chain Bridges — From CCIP to Light Clients
Building a Bridge
Lesson 6 of 7·CONTENT22 min55 XP

Treat this page as a workbench, not a blog post. The goal is to extract a reusable mental model from the source and carry it into the rest of the Fabrknt stack.

Course
Cross-Chain Bridges — From CCIP to Light Clients
Lesson role
CONTENT
Sequence
6 / 7

Lesson 6 — Building a minimal bridge on Reth — light-client-verified messaging

Question

You want a custom bridge between your Reth-based L2 (e.g. Tempo) and Ethereum. Build it. Three components: a light-client verifier on-chain, a relayer (off-chain), and a bridge contract (on-chain). This is what Tempo, Berachain, Hyperliquid all built in some form.

Principle (minimum model)

  • Three components. (1) Light-client verifier (on-chain) — verifies Ethereum block headers against the sync committee. (2) Relayer (off-chain Rust process) — fetches headers and messages from Ethereum, submits to the bridge contract on the L2. (3) Bridge contract (on-chain L2) — accepts verified messages, executes mint/transfer.
  • Light-client verifier (Solidity). Stores the sync committee public keys; on each header update verifies BLS signatures from 2/3 of the committee; updates the canonical Ethereum state root.
  • Relayer (Rust, ~50 lines using Alloy). Watches Ethereum for new headers via Provider::subscribe_blocks(); fetches messages emitted via eth_getLogs; submits headerUpdate + message + merkleProof to the L2 bridge contract.
  • Bridge contract (Solidity, TempoBridge). Receives relayMessage(header, proof, message) → calls the light-client verifier to validate the header → verifies the Merkle proof against the validated state root → executes the message (mint / transfer / function call).
  • Why this is gold-standard. No trusted validators. No multi-sig. The trust anchor is Ethereum's consensus itself; the bridge contract verifies that consensus on-chain. Same security as IBC, on EVM.
  • Trade-offs. BLS verification = ~140 K gas per header update (every ~27 hours). Relayer must be live — but anyone can run it (the contract is permissionless). For high-frequency low-value transfers, optimistic rollup-style adaptations are cheaper.
  • Reth SDK angle. A custom L2 built on Reth SDK can include the light-client verifier as a precompile (cheaper than Solidity) or as a system contract. Stage 9 precompiles in the openhl track show how.

Worked example + steps

Building a minimal bridge on Reth — light-client-verified messaging

You've read the theory. You've read the production code. Now you build the smallest possible trust-minimized bridge — an Ethereum→Tempo flow where Tempo runs an Ethereum light client and verifies inclusion proofs of source-chain events. No multisig, no guardians, no fast-withdrawal LP. Just: source chain emits an event, light client says "yes, that event is in a finalized Ethereum block," destination chain mints. Three contracts, one relayer, one trust assumption.

1. The architecture

flowchart LR
    User["User on Ethereum"] -->|1. lock USDC + emit event| L1Contract["L1 Bridge Contract"]
    L1Contract -->|2. observe event| Relayer["Relayer<br/>(off-chain, anyone)"]
    Relayer -->|3. submit proof| L2Contract["Tempo Bridge Contract"]
    L2Contract -->|4. verify against Ethereum light client| LightClient["Ethereum Light Client<br/>on Tempo"]
    LightClient -->|valid| L2Contract
    L2Contract -->|5. mint wrapped USDC| User2["User on Tempo"]

Each component:

ComponentTrust modelWhat it trusts
L1 ContractSelfNobody — it's the source of truth
RelayerPermissionlessAnyone can be a relayer; no trust required
L2 ContractLight clientTrusts only Ethereum's consensus via its light client
Light clientEthereum consensusTrusts only the sync committee signatures

The full system is trustless — you trust Ethereum's PoS to be honest, nothing else.

2. The L1 contract

The L1 contract is the simplest part — just emit events:

contract EthereumBridge {
    mapping(address => mapping(address => uint256)) public locked;

    event Locked(
        address indexed user,
        address indexed token,
        uint256 amount,
        bytes32 indexed destChainId
    );

    function lock(address token, uint256 amount, bytes32 destChainId) external {
        IERC20(token).transferFrom(msg.sender, address(this), amount);
        locked[msg.sender][token] += amount;
        emit Locked(msg.sender, token, amount, destChainId);
    }
}

That's it. No relayer needed; the event is on-chain. Anyone can observe the event and try to claim on Tempo with a proof.

3. The relayer

The relayer's job:

  1. Watch for Locked events on Ethereum
  2. Generate an inclusion proof: "this event was in block N, here's the Merkle path"
  3. Submit the proof to Tempo's bridge contract

Relayer in Rust:

use alloy_provider::{Provider, ProviderBuilder};
use alloy_primitives::Address;

#[tokio::main]
async fn main() -> eyre::Result<()> {
    let l1_provider = ProviderBuilder::new()
        .on_http("https://ethereum-rpc.url".parse()?);
    let l2_provider = ProviderBuilder::new()
        .on_http("https://tempo-rpc.url".parse()?);

    // Get latest finalized block on L1
    let block = l1_provider
        .get_block(BlockId::finalized())
        .full()
        .await?
        .expect("no finalized block");

    // Find Locked events in recent blocks
    let logs = l1_provider
        .get_logs(&Filter::new()
            .from_block(block.header.number - 100)
            .address(L1_BRIDGE)
            .event("Locked(address,address,uint256,bytes32)"))
        .await?;

    for log in logs {
        // Build inclusion proof
        let proof = build_inclusion_proof(&log, &block).await?;

        // Submit to L2
        let tx = l2_provider
            .send_transaction(TransactionRequest::default()
                .with_to(L2_BRIDGE)
                .with_input(encode_claim_call(&log, &proof)))
            .await?;

        let receipt = tx.get_receipt().await?;
        println!("Submitted claim for {:?}, tx: {:?}",
            log.transaction_hash,
            receipt.transaction_hash);
    }

    Ok(())
}

The relayer is stateless — anyone can run it. It just observes Ethereum and submits proofs. If it goes down, others take over.

4. The light client on Tempo

The bridge contract on Tempo needs to verify "this event happened on Ethereum block N." For that, it needs to know what's the latest verified Ethereum block on Tempo.

The light client contract maintains:

contract EthereumLightClient {
    struct Header {
        bytes32 blockRoot;
        uint64 slot;
        bytes32 stateRoot;
        bytes32 receiptsRoot;
    }

    mapping(uint64 => Header) public headers;
    bytes32 public currentSyncCommitteeHash;

    function updateSyncCommittee(
        SyncCommitteeUpdate calldata update
    ) external {
        // Verify the update was signed by 2/3+ of the current committee
        verifyCommitteeSignature(update);
        // Update current committee for the next period
        currentSyncCommitteeHash = computeCommitteeHash(update.newCommittee);
    }

    function addHeader(
        Header calldata header,
        bytes calldata signatures
    ) external {
        // Verify the header was signed by 2/3+ of the current committee
        verifyHeaderSignature(header, signatures, currentSyncCommitteeHash);
        headers[header.slot] = header;
    }

    function verifyInclusion(
        uint64 slot,
        bytes32 leaf,
        bytes calldata proof
    ) external view returns (bool) {
        return MerkleProof.verify(headers[slot].receiptsRoot, leaf, proof);
    }
}

Now the bridge contract on Tempo uses this:

contract TempoBridge {
    EthereumLightClient public lightClient;

    function claim(
        uint64 slot,
        bytes32 eventHash,
        bytes calldata merkleProof,
        address user,
        address token,
        uint256 amount
    ) external {
        // Verify the event was in Ethereum block N via light client
        require(
            lightClient.verifyInclusion(slot, eventHash, merkleProof),
            "invalid proof"
        );
        // Mint wrapped USDC on Tempo
        IERC20(wrappedToken[token]).mint(user, amount);
    }
}

That's the entire bridge. 3 contracts, 1 relayer service, and trust only in Ethereum's consensus.

5. The cost breakdown

Per bridge transaction:

OperationCost on chainWhen?
L1 lock80k gas ($2)Per user tx
Light client updateSyncCommittee50k gas ($1.50)Per 27 hours (1 sync committee period)
Light client addHeader20k gas ($0.60)Per Ethereum block (~12s)
L2 claim150k gas ($4.50)Per user tx

The light client updates run continuously (anyone can pay to update; market keeps it running). User-facing cost: ~$6-7 per bridge tx, depending on gas.

For ZK light client variant, replace addHeader with one constant-cost proof verification per epoch, dropping total cost ~10x.

6. The full system you've built

Source (Ethereum)                 Destination (Tempo)
─────────────────                 ─────────────────────
EthereumBridge.sol                EthereumLightClient.sol
   ↓ Locked event                    ↑ updateSyncCommittee
   ↓                                 ↑ addHeader
Relayer (off-chain)  ──────►       TempoBridge.sol
                                    ↑ claim (uses light client)
                                    ↓ mint wrapped USDC

Trust assumption: Ethereum's PoS works. Nothing else.

This is what OP Standard Bridge does without the optimistic delay, what ZK rollups will do once they're production, what Espresso and similar shared sequencers do today.

7. The hard parts (in detail)

This sketch glosses over some real complexity:

7.1 Light client trusted setup

The Tempo light client needs an initial trusted checkpoint. How? Two options:

  • Trust the Tempo team on launch (acceptable for launch)
  • DAO governance updates the initial checkpoint (used by IBC for new clients)

Both are reasonable for production. The trust assumption is only at setup, not ongoing.

7.2 Replay protection

Each claim must reference a unique source event. If you bridge the same USDC twice with the same event hash, the L2 contract should reject.

Standard pattern: track claimed event hashes in a mapping:

mapping(bytes32 => bool) public claimed;
require(!claimed[eventHash], "already claimed");
claimed[eventHash] = true;

7.3 Withdrawal direction (Tempo → Ethereum)

The system above is deposit-only. Withdrawal needs the inverse:

  • Tempo bridge emits Withdrawn event
  • Tempo light client on Ethereum (the hard one)
  • L1 bridge accepts proofs against the Tempo light client

For Tempo (Reth-based BFT), the light client on Ethereum is much simpler than Ethereum's — bounded validator set, BFT signatures. ~30 validators with BLS aggregation = ~5k gas per header.

8. Practice

  1. Sketch the EthereumLightClient contract more completely
  2. Estimate: at 12s block times, how often does the L1→Tempo light client need to be updated?
  3. What if a relayer submits an invalid proof? What does the bridge do?
  4. How does the system handle Ethereum reorgs (finality before/after)?

9. Reading list

Final check: in one sentence, what's the single trust assumption of a light-client-verified bridge, and what makes it the gold standard? If your answer doesn't reference "the source chain's consensus + nothing else," re-read §1.

Summary (3 lines)

  • Minimal light-client-verified bridge has three components: light-client verifier (on-chain Solidity), relayer (off-chain Rust ~50 lines using Alloy), bridge contract (on-chain Solidity).
  • Trust anchor = Ethereum consensus itself. No validators / multi-sigs / federated guardians — same security as IBC, on EVM. Permissionless relayer.
  • ~140 K gas per header update (every 27 h) is the cost; precompile light-client verifier on Reth SDK reduces this further. Tempo / Berachain / Hyperliquid built variations of this. Next: final quiz.