Building Bridges

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:

  1. L1 Contract: Handles deposits and withdrawals on Ethereum

  2. L2 Contract: Manages the L2 token representation

Depositing Assets (L1 → Facet)

1

Submit Deposit

User deposits assets (e.g., USDC, WETH) into an L1 bridge contract.

2

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.

3

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.

4

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:

1

Initialize Withdrawal

User burns L2 assets through the bridge's L2 contract, which calls L2ToL1MessagePasser predeploy.

2

Wait for State Root

The withdrawal is included in a Facet state root. Proposers post roots to Rollup.sol.

3

Prove Withdrawal

Once the root is accepted, user submits a proof that their withdrawal exists.

4

Claim Assets

After any bridge-specific delays, user claims assets from the L1 bridge contract.

Implementation Guide

1. L1 Bridge Contract

Start with the core deposit functionality:

import { LibFacet } from "facet-sol/src/utils/LibFacet.sol";

contract L1Bridge {
    IERC20 public token;
    address public l2Bridge;
    
    function depositERC20(uint256 amount, address to) external {
        // Transfer tokens from user
        token.safeTransferFrom(msg.sender, address(this), amount);
        
        // Encode the L2 transaction
        bytes memory bridgeData = abi.encodeWithSelector(
            IL2Bridge.finalizeERC20Deposit.selector,
            amount,
            to
        );
        
        // Send to Facet via 0xface7
        LibFacet.sendFacetTransaction({
            to: l2Bridge,
            gasLimit: 250_000,
            data: bridgeData
        });
    }
}

2. Withdrawal Verification

Integrate with the proof system for withdrawals. Here's a complete example:

interface IRollup {
    function getProposal(uint256 id) external view returns (Proposal memory);
    function canonicalProposalOf(uint256 block) external view returns (uint32);
}

contract L1Bridge {
    IRollup public rollup;
    
    function proveWithdrawal(
        uint256 amount,
        address to,
        uint256 nonce,
        uint256 proposalId,
        Types.OutputRootProof calldata outputRootProof,
        bytes[] calldata withdrawalProof
    ) external {
        // Get proposal and verify it's canonical
        IRollup.Proposal memory proposal = rollup.getProposal(proposalId);
        require(
            rollup.canonicalProposalOf(proposal.l2BlockNumber) == proposalId,
            "proposal not canonical"
        );
        
        // Verify the output root matches
        require(
            proposal.rootClaim == Hashing.hashOutputRootProof(outputRootProof),
            "invalid output root"
        );
        
        // Build withdrawal hash
        bytes32 withdrawalHash = _hashWithdrawal(amount, to, nonce);
        
        // Verify merkle proof of withdrawal in L2 state
        bytes32 storageKey = keccak256(abi.encode(withdrawalHash, uint256(0)));
        bool valid = SecureMerkleTrie.verifyInclusionProof({
            _key: abi.encode(storageKey),
            _value: hex"01",  // value of 1 indicates withdrawal exists
            _proof: withdrawalProof,
            _root: outputRootProof.messagePasserStorageRoot
        });
        require(valid, "invalid merkle proof");
        
        // Store proven withdrawal for later finalization
        provenWithdrawals[withdrawalHash] = ProvenWithdrawal({
            proposalId: uint32(proposalId),
            provenAt: uint32(block.timestamp)
        });
    }
}

3. L2 Bridge Contract

Deploy on Facet to handle the L2 side:

contract L2Bridge is ERC20 {
    address public constant MESSAGE_PASSER = 0x4200000000000000000000000000000000000016;
    address public l1Bridge;
    
    modifier onlyL1Bridge() {
        // Check aliased address from L1
        require(
            msg.sender == AddressAliasHelper.applyL1ToL2Alias(l1Bridge),
            "Not L1 bridge"
        );
        _;
    }
    
    function finalizeERC20Deposit(uint256 amount, address to) 
        external 
        onlyL1Bridge 
    {
        _mint(to, amount);
    }
    
    function initiateERC20Withdrawal(uint256 amount, address to) external {
        _burn(msg.sender, amount);
        
        IL2ToL1MessagePasser(MESSAGE_PASSER).initiateWithdrawal(
            l1Bridge,
            0,
            abi.encode(amount, to)
        );
    }
}

4. Adding Training Wheels

Consider what safety features your users need:

contract L1BridgeWithSafety is L1Bridge, Ownable {
    bool public paused;
    uint256 public withdrawalDelay = 24 hours;
    mapping(bytes32 => bool) public blacklistedRoots;
    
    modifier whenNotPaused() {
        require(!paused, "Bridge paused");
        _;
    }
    
    function finalizeWithdrawal(...) external whenNotPaused {
        // Check delay has passed
        require(
            block.timestamp >= provenAt + withdrawalDelay,
            "Delay not met"
        );
        
        // Check root not blacklisted
        require(!blacklistedRoots[proposal.rootClaim], "Root blacklisted");
        
        // Execute withdrawal
    }
    
    // Admin functions
    function pause() external onlyOwner {
        paused = true;
    }
    
    function setWithdrawalDelay(uint256 delay) external onlyOwner {
        withdrawalDelay = delay;
    }
}

Last updated