Bridge Contracts
Moving value between an L1 (Ethereum) and a Panda rollup involves two sides:
| Side | Contract | Language | Role |
|---|---|---|---|
| L2 (Panda) | BridgeBalance | Python (Panda) | Holds every user's L2 balance; credited on deposit, deducted on withdrawal. |
| L1 (Ethereum) | PandaBridge | Solidity | Custodies deposited value, verifies withdrawal Merkle proofs, tracks batches. |
| Hub | UniversalGateway | Solidity | Multi-rollup message hub between L1 and each L2. |
| Relay | SettlementMailbox | Solidity | Per-settlement-chain relayer endpoint. |
The Panda-side BridgeBalance contract is deployable in the playground (Cross-Chain category → Bridge Balance Ledger). The Solidity contracts are EVM infrastructure run by the bridge operators and are documented here for reference — they are not deployable from the playground.
For the surrounding rollup design, see Rollup Architecture.
Deposit and withdrawal flow
Deposit (L1 -> L2):
PandaBridge.deposit() / depositToken()
-> UniversalGateway.enqueueInbound
-> bridge relayer
-> SettlementMailbox.receiveFromHub
-> panda-rollup inbox
-> sequencer calls BridgeBalance.InboundFromHub(...) # credits the user
Withdrawal (L2 -> L1):
BridgeBalance.withdraw() # queues a withdrawal
-> sequencer builds a Merkle tree of queued withdrawals
-> root posted to PandaBridge.setWithdrawalRoot()
-> user calls PandaBridge.withdraw() with a Merkle proof
BridgeBalance (L2 ledger)
BridgeBalance is the L2 endpoint the sequencer credits when a deposit arrives, and the queue where withdrawals are recorded for the next Merkle tree.
Security model
- Only the authorized sequencer can credit deposits — this prevents minting balance from thin air.
- Nonce ordering (
last_deposit_nonce) prevents replaying the same deposit message. - An owner freeze switch pauses withdrawals and transfers in an emergency.
- All amounts are validated strictly positive and balance checks prevent overdraft.
Methods
| Method | Kind | Description |
|---|---|---|
deploy(sequencer, rollup_id) | constructor | Sets the authorized sequencer (defaults to deployer) and the expected rollup_id (must be positive). |
InboundFromHub(hub_payload, inbox_nonce, rollup_id) | call | Production entry point. Sequencer-only. Decodes the packed L1 payload and credits the recipient. |
credit_deposit(recipient, amount, nonce, token) | call | Explicit-args deposit path for direct callers and tests. Sequencer-only. Emits Deposited. |
withdraw(amount, l1_recipient, token) | call | Deducts balance and queues an L2→L1 withdrawal. Returns the withdrawal id. Emits WithdrawalQueued. |
transfer(to, amount) | call | Moves L2 balance between users. Emits Transfer. |
freeze() / unfreeze() | call | Owner emergency pause. Emits Frozen / Unfrozen. |
get_balance(user) | query | Native balance for a user. |
get_token_balance(owner, token) | query | Balance for a specific token (or "native"). |
pending_withdrawals() | query | The full withdrawal queue. |
stats() | query | Totals: deposited, withdrawn, active balances, pending withdrawals, frozen. |
Packed payload format
InboundFromHub receives the exact bytes that L1 PandaBridge emits, hex-encoded with a 0x prefix:
| Length | Meaning | Layout |
|---|---|---|
| 52 bytes | Native deposit | recipient(20) + amount(32, big-endian) |
| 72 bytes | Token deposit | recipient(20) + amount(32) + token(20) |
| other | Non-deposit passthrough | recorded but not credited (never reverts) |
Native balances live under balances[recipient]. Token balances use the key "{recipient}|{token}", so each ERC-20 has its own sub-ledger while the legacy native mapping stays bit-compatible.
PandaBridge (L1, Solidity)
PandaBridge is the L1 custody + settlement contract (pragma solidity 0.8.24, built on OpenZeppelin AccessControl, ReentrancyGuard, Pausable). It handles ETH and whitelisted ERC-20 deposits, Merkle-proof withdrawals, sequencer batch submission, and an optimistic challenge window.
Roles
SEQUENCER_ROLE— submits state batches.BRIDGE_ADMIN_ROLE— sets parameters, whitelists tokens, posts withdrawal roots, pauses.
Key external functions
| Function | Description |
|---|---|
deposit(l2Recipient) payable | Deposit ETH; routes a 52-byte packed payload to the hub. Emits Deposited. |
depositToken(token, l2Recipient, amount) | Deposit a whitelisted ERC-20; routes a 72-byte payload. Emits TokenDeposited. |
withdraw(l1Recipient, amount, merkleProof) | Withdraw ETH against the posted withdrawalRoot. Leaf = keccak256(abi.encodePacked(l1Recipient, amount, msg.sender, address(0))). |
withdrawToken(token, l1Recipient, amount, merkleProof) | Withdraw an ERC-20; the leaf pins the token address. |
submitBatch(batchNumber, preStateRoot, postStateRoot, txData, proof) | Sequencer submits a state batch (optionally ZK-verified). Emits BatchSubmitted. |
challengeBatch(batchNumber) / finalizeBatch(batchNumber) / rejectBatch(batchNumber) | Optimistic challenge lifecycle. |
setWithdrawalRoot(bytes32) | Admin posts the Merkle root of queued L2 withdrawals. |
Withdrawals are intentionally not pausable so users can always exit. The challenge window defaults to ~7 days (50_400 blocks).
UniversalGateway (L1 hub, Solidity)
UniversalGateway is the multi-rollup hub. It stores the latest outbound Merkle root per rollup, verifies and executes outbound messages, and enqueues inbound messages toward each L2.
| Function | Description |
|---|---|
setLatestOutboundRoot(rollupId, root) | Gateway admin posts a rollup's outbound root. Emits OutboundRootUpdated. |
executeOutboundMessage(rollupId, leaf, merkleProof, target, payload) | Verifies the leaf against the root, consumes it (replay-protected), then calls target. Emits UniversalMessageExecuted. |
enqueueInbound(rollupId, dest, payload) | Inbox-sender role enqueues an L1→L2 message (bounded by MAX_INBOUND_PAYLOAD). Emits L1ToL2Message. |
outboundMessageLeaf(rollupId, target, payload) | Pure helper: keccak256(abi.encode(rollupId, target, keccak256(payload))). |
An optional outbound-target allow-list gates which contracts messages may call.
SettlementMailbox (Solidity)
SettlementMailbox is the per-settlement-chain relayer endpoint. The relayer holds RELAYER_ROLE and bridges messages both directions:
| Function | Description |
|---|---|
receiveFromHub(rollupId, nonce, dest, payload) | Native inbound; emits InboundFromHub. |
receiveFromHub(rollupId, nonce, dest, token, payload) | Token-aware inbound; emits InboundFromHubWithToken only (avoids double-crediting). |
emitOutboundToHub(rollupId, target, payload) | Builds a leaf matching UniversalGateway.outboundMessageLeaf and emits OutboundToHub. |
Try it
- Playground: deploy
BridgeBalancefrom the Playground (Cross-Chain → Bridge Balance Ledger) and follow the tutorial to trace a deposit and a withdrawal. - Architecture: see Rollup Architecture and Cross-Chain Messaging.