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 viaeth_getLogs; submitsheaderUpdate + message + merkleProofto the L2 bridge contract. - Bridge contract (Solidity,
TempoBridge). ReceivesrelayMessage(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:
| Component | Trust model | What it trusts |
|---|---|---|
| L1 Contract | Self | Nobody — it's the source of truth |
| Relayer | Permissionless | Anyone can be a relayer; no trust required |
| L2 Contract | Light client | Trusts only Ethereum's consensus via its light client |
| Light client | Ethereum consensus | Trusts 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:
- Watch for
Lockedevents on Ethereum - Generate an inclusion proof: "this event was in block N, here's the Merkle path"
- 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:
| Operation | Cost on chain | When? |
|---|---|---|
L1 lock | Per user tx | |
Light client updateSyncCommittee | Per 27 hours (1 sync committee period) | |
Light client addHeader | Per Ethereum block (~12s) | |
L2 claim | 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
- Sketch the EthereumLightClient contract more completely
- Estimate: at 12s block times, how often does the L1→Tempo light client need to be updated?
- What if a relayer submits an invalid proof? What does the bridge do?
- How does the system handle Ethereum reorgs (finality before/after)?
9. Reading list
- Helios source — Rust Ethereum light client (reference for the relayer)
- LayerZero V2 — modular bridge architecture
- Espresso shared sequencer — production shared bridge
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.