Building Bridges
Overview
For the high-level flow of how Facet derives state and how canonical roots are exposed on L1 for bridges, see the Architecture Overview.
Facet has no canonical bridge, but anyone can build and deploy a bridge. This guide covers the complete process from understanding bridge mechanics to deploying production-ready contracts.
How Bridging Works
Architecture Overview
Bridges on Facet consist of two main components:
L1 Contract: Handles deposits and withdrawals on Ethereum
L2 Contract: Manages the L2 token representation
Depositing Assets (L1 → Facet)
Submit Deposit
User deposits assets (e.g., USDC, WETH) into an L1 bridge contract.
Bridge Emits Event
The bridge contract emits an event with the Facet event signature:
0x00000000000000000000000000000000000000000000000000000000000face7
This event contains the deposit details in RLP-encoded format.
Facet Nodes Process
Facet nodes monitor L1 for these events and convert them into L2 transactions from the L1 bridge contract to the L2 bridge contract.
Assets Available on Facet
The L2 bridge contract credits the user's L2 account with the bridged assets when it receives the L2 transaction.
Withdrawing Assets (Facet → L1)
Withdrawals use Facet's ZK Fault Proof system:
Initialize Withdrawal
User burns L2 assets through the bridge's L2 contract, which calls L2ToL1MessagePasser
predeploy.
Wait for State Root
The withdrawal is included in a Facet state root. Proposers post roots to Rollup.sol
.
Prove Withdrawal
Once the root is accepted, user submits a proof that their withdrawal exists.
Claim Assets
After any bridge-specific delays, user claims assets from the L1 bridge contract.
Implementation Guide
This section walks through the reference ETH bridge implementation in zk-fault-proofs. Source:
L1: https://github.com/0xFacet/zk-fault-proofs/blob/facet/contracts/src/L1Bridge.sol
L2: https://github.com/0xFacet/zk-fault-proofs/blob/facet/contracts/src/L2Bridge.sol
1) L1Bridge: Deposits, Proofs, Finalization
Key capabilities:
Deposits ETH to L2 with replay support if L2 blocks are full
Optional training wheels (pause, withdrawal delay, root blacklist)
Fork handling via
setRollup
(owner-controlled or renounced)
Core deposit data and send path:
// Deposit parameters recorded for replay
struct DepositTransaction {
uint256 nonce; // unique deposit id
address to; // L2 recipient
uint256 amount; // ETH amount in wei
}
// Store deposit hash and send to L2 via 0xface7
function initiateDeposit(address to) public payable returns (uint256 nonce) {
nonce = ++depositNonce;
DepositTransaction memory d = DepositTransaction({ nonce: nonce, to: to, amount: msg.value });
depositHashes[nonce] = _hashDeposit(d);
bytes memory data = abi.encodeWithSelector(L2Bridge.finalizeDeposit.selector, d);
LibFacet.sendFacetTransaction({ to: l2Bridge, gasLimit: 500_000, data: data });
}
// Retry with identical parameters if the previous attempt failed due to full blocks
function replayDeposit(DepositTransaction calldata d) external {
require(depositHashes[d.nonce] == _hashDeposit(d), "param mismatch");
bytes memory data = abi.encodeWithSelector(L2Bridge.finalizeDeposit.selector, d);
LibFacet.sendFacetTransaction({ to: l2Bridge, gasLimit: 500_000, data: data });
}
EOA convenience path:
// EOA-only direct deposit to self (supports EIP-7702 delegated EOAs)
receive() external payable {
if (!EOA.isSenderEOA()) revert OnlyCanDepositWithoutTo();
initiateDeposit(msg.sender);
}
Training wheels and fork handling:
// Update Rollup contract used for proofs (owner-controlled)
function setRollup(address _rollup) external onlyOwner { /* ... */ }
// Pause/unpause and safety delay
function pause() external onlyOwner { _pause(); }
function unpause() external onlyOwner { _unpause(); }
function setWithdrawalDelay(uint256 secs) external onlyOwner { /* ... */ }
// Blacklist a specific root (by hash) if needed
function setRootBlacklisted(bytes32 root, bool blacklisted) external onlyOwner { /* ... */ }
Withdrawal verification and finalization:
// Prove a withdrawal exists in canonical L2 state
function proveWithdrawal(
address to,
uint256 amount,
uint256 nonce,
uint256 proposalId,
Types.OutputRootProof calldata rootProof,
bytes[] calldata withdrawalProof
) external {
bytes32 withdrawalHash = _hashWithdrawal(to, amount, nonce);
require(!finalized[withdrawalHash], "already finalized");
// Ensure the proposal is canonical
if (!rollup.proposalIsCanonical(proposalId)) revert ProposalNotCanonical();
Rollup.Proposal memory prop = rollup.getProposal(proposalId);
// Optional safety check
if (rootBlacklisted[prop.rootClaim]) revert RootBlacklisted();
// Root and storage proof verification
if (prop.rootClaim != Hashing.hashOutputRootProof(rootProof)) revert InvalidOutputRoot();
bytes32 storageKey = keccak256(abi.encode(withdrawalHash, uint256(0))); // slot 0
bool ok = SecureMerkleTrie.verifyInclusionProof({
_key: abi.encode(storageKey),
_value: hex"01",
_proof: withdrawalProof,
_root: rootProof.messagePasserStorageRoot
});
if (!ok) revert InvalidWithdrawalProof();
proven[withdrawalHash][rollup] = ProvenWithdrawal({ proposalId: uint32(proposalId), provenAt: uint32(block.timestamp) });
}
// Finalize after delay and safety checks
function finalizeWithdrawal(address to, uint256 amount, uint256 nonce) external {
bytes32 withdrawalHash = _hashWithdrawal(to, amount, nonce);
ProvenWithdrawal memory info = proven[withdrawalHash][rollup];
if (info.provenAt == 0) revert WithdrawalNotProven();
if (block.timestamp <= info.provenAt + withdrawalDelay) revert WithdrawalDelayNotMet();
Rollup.Proposal memory prop = rollup.getProposal(info.proposalId);
if (rootBlacklisted[prop.rootClaim]) revert RootBlacklisted();
finalized[withdrawalHash] = true;
to.forceSafeTransferETH(amount, SafeTransferLib.GAS_STIPEND_NO_STORAGE_WRITES);
}
Trust model note: With ownership renounced, the bridge becomes human-free but permanently locked to one Rollup contract (fork). With ownership active, the operator can switch Rollup to follow new rules—introducing operator trust but preserving flexibility.
2) L2Bridge: Finalize Deposits, Initiate Withdrawals
Core responsibilities:
Finalize deposits from L1 with nonce-based replay protection
Initiate withdrawals to L1 by burning and calling the message passer
// Only the aliased L1 bridge can finalize deposits
modifier onlyL1Bridge() {
if (msg.sender != AddressAliasHelper.applyL1ToL2Alias(l1Bridge)) revert UnauthorizedBridge();
_;
}
// Finalize deposit exactly once per nonce
function finalizeDeposit(L1Bridge.DepositTransaction calldata d) external onlyL1Bridge {
if (finalizedDeposits[d.nonce]) revert DepositAlreadyFinalized();
finalizedDeposits[d.nonce] = true;
_mint(d.to, d.amount); // wrap ETH on L2
}
// Burn on L2 and send withdrawal message to L1
function initiateWithdrawal(address to, uint256 amount) external {
if (amount == 0) revert InvalidWithdrawalAmount();
_burn(msg.sender, amount);
bytes memory data = abi.encode(to, amount);
MESSAGE_PASSER.initiateWithdrawal(l1Bridge, 0, data);
}
3) Putting It Together
End-to-end flow:
Deposit (L1 → L2): Users send ETH to
L1Bridge.initiateDeposit
. The bridge records the deposit (nonce + hash) and sends a Facet transaction toL2Bridge.finalizeDeposit
. If an L2 block is full,replayDeposit
can resend the same deposit (same nonce/params).Withdraw (L2 → L1): Users call
L2Bridge.initiateWithdrawal
to burn wrapped ETH and create a message inL2ToL1MessagePasser
. On L1, users prove the withdrawal viaL1Bridge.proveWithdrawal
using the canonical root inRollup.sol
and finalize after any delay.
Security & safety gears:
Replay-safe deposits (nonce + hash) and single-use finalize on L2
Optional pause, withdrawal delay, and root blacklist
Fork handling via
setRollup
(trust vs flexibility)
Last updated