Rollup Architecture

Panda supports a multi-layer architecture where Python smart contracts execute on a high-performance L2 rollup while settling to an Ethereum L1 for security. Cross-chain messages flow between layers through a system of bridge contracts, a sequencer, and a relayer.

Architecture Overview

                    Panda Hub (Coordination Layer)
                          |
            +-------------+-------------+
            |                           |
      Panda L2 (Execution)      Ethereum L1 (Settlement)
            |                           |
    +-------+-------+          +--------+--------+
    | Python         |         | Settlement       |
    | Contracts      |         | Mailbox          |
    | (PandaVM)      |         | (Solidity)       |
    +----------------+         +-----------------+

Layer Roles

LayerRoleTechnology
Panda L2Executes Python smart contracts at high throughputPandaVM + Geth fork
Ethereum L1Final settlement, data availability, fraud/validity proofsStandard Ethereum
Panda HubCross-chain coordination, message routing, operator registryPanda chain
SolanaAlternative settlement and cross-chain messaging targetAgave fork

Message Flow

L2 to L1 (Withdrawal / Bridge Out)

  1. User calls a contract on Panda L2 that emits a cross-chain message (via send_message() or Chain.ethereum())
  2. The PandaVM captures the message in the execution output
  3. The rollup sequencer batches the message into an L2 block
  4. The bridge relayer picks up the message from the sequenced batch
  5. The relayer submits the message to the SettlementMailbox contract on Ethereum L1
  6. After the challenge period (or validity proof verification), the message is finalized

L1 to L2 (Deposit / Bridge In)

  1. User deposits to the PandaBridge contract on Ethereum L1
  2. The bridge relayer monitors L1 events for deposit messages
  3. The relayer submits the message to the RollupInboxMirror contract on Panda L2
  4. The inbox mirror triggers the destination contract's @receiver method
  5. The contract processes the deposit (e.g., mints wrapped tokens)

L2 to L2 (Cross-Rollup)

Messages between Panda L2 instances route through the Panda Hub:

  1. Source L2 emits a cross-chain message targeting another L2
  2. The hub relayer picks up the message and routes it
  3. The destination L2's inbox mirror delivers the message

Writing Rollup-Aware Contracts

Contracts running on the rollup use the same @receiver and Chain APIs as any cross-chain contract. Here are real patterns from the Panda repo.

Receiving L1 Deposits

Use @receiver(chains=["ethereum"]) to handle deposit messages from L1. This is the real EthOnlyBridge test contract:

from panda import contract, receiver, event

@contract
class EthOnlyBridge:
    class State:
        total_received: int = 0
        last_source: str = ""
        last_msg_id: str = ""
        messages: list = []

    @receiver(chains=["ethereum"])
    def on_tokens(self, ctx, amount: int, from_addr: str = ""):
        self.state.total_received += amount
        self.state.last_source = ctx.source_chain
        self.state.last_msg_id = ctx.message_id
        self.state.messages.append({
            "amount": amount,
            "from_addr": from_addr,
            "source_chain": ctx.source_chain,
            "message_id": ctx.message_id,
            "is_cross_chain": ctx.is_cross_chain,
        })
        self.emit(event.TokensReceived(
            amount=amount, source_chain=ctx.source_chain,
        ))

Only the authorized relayer can set ctx.is_cross_chain = True, so direct calls from users are rejected automatically. The chains=["ethereum"] whitelist ensures only L1 messages are accepted — a Solana message would be rejected with a ContractError.

Async Withdrawals (L2 to L1)

For withdrawals, use await chain.call() to suspend until the L1 side confirms. From the real BridgeV2:

from panda import contract, constructor, call, query, receiver, event, Chain

@contract
class BridgeV2:
    class State:
        owner: str = ""
        total_bridged: int = 0
        pending_bridges: dict = {}
        bridge_count: int = 0

    @constructor
    def deploy(self, ctx):
        self.state.owner = ctx.sender

    @call
    async def bridge_tokens(self, ctx, amount: int, recipient: str = ""):
        """Bridge tokens to Ethereum. Awaits confirmation from L1."""
        if amount <= 0:
            raise ValueError("Amount must be positive")

        target = recipient or ctx.sender
        eth = Chain.ethereum()

        bridge_id = self.state.bridge_count
        self.state.pending_bridges[str(bridge_id)] = {
            "sender": ctx.sender,
            "amount": amount,
            "recipient": target,
            "status": "pending",
        }
        self.state.bridge_count += 1

        # Suspend until L1 confirms (may take minutes to hours)
        _result = await eth.call(
            "0xEthBridge", "lock_and_mint",
            amount=amount, recipient=target,
        )

        # Runs when L1 response arrives
        self.state.pending_bridges[str(bridge_id)]["status"] = "confirmed"
        self.state.total_bridged += amount
        self.emit(event.BridgeConfirmed(bridge_id=bridge_id, amount=amount))

The await line is where message latency matters most. On an optimistic rollup, the L1 confirmation may take the full challenge window. On a validity rollup, it completes after proof verification. Record pending state before the await so users can query bridge status while waiting.

Fire-and-Forget Bridge Messages

When you don't need confirmation, use fire-and-forget. The message is emitted and execution continues immediately:

@call
def bridge_tokens_fire_and_forget(self, ctx, amount: int, recipient: str = ""):
    """Bridge tokens without waiting for confirmation."""
    if amount <= 0:
        raise ValueError("Amount must be positive")

    target = recipient or ctx.sender
    eth = Chain.ethereum()

    # Fire-and-forget -- msg_id is a string, execution continues
    msg_id = eth.call(
        "0xEthBridge", "lock_and_mint",
        amount=amount, recipient=target,
    )
    self.state.total_bridged += amount
    self.emit(event.BridgeSent(msg_id=str(msg_id), amount=amount))

Multi-Chain Receivers

Contracts can accept messages from multiple settlement layers. The real BridgeV2 demonstrates this:

# Accept from both Ethereum and Solana
@receiver(chains=["ethereum", "solana"])
def on_bridge_complete(self, ctx, bridge_id: int, status: str):
    key = str(bridge_id)
    if key in self.state.pending_bridges:
        self.state.pending_bridges[key]["status"] = status
        self.emit(event.BridgeStatusUpdated(
            bridge_id=bridge_id, status=status,
        ))

# Accept from any chain (no whitelist)
@receiver()
def on_any_chain_message(self, ctx, data: str):
    self.emit(event.AnyChainMessage(
        data=data,
        source_chain=ctx.source_chain,
    ))

Multi-Chain Sequential Operations

The real CrossChainSwap demonstrates calling multiple chains sequentially — useful for cross-rollup operations that route through the hub:

@call
async def multi_chain_operation(self, ctx, eth_amount: int, sol_amount: int):
    """Perform operations on multiple chains sequentially."""
    eth = Chain.ethereum()
    sol = Chain.solana()

    # First, call Ethereum and wait for response
    eth_remote = eth.contract("0xEthDex")
    _result1 = await eth_remote.deposit(amount=eth_amount, sender=ctx.sender)

    # Then, call Solana (runs after Ethereum response)
    sol_remote = sol.contract("SolDex")
    _result2 = await sol_remote.deposit(amount=sol_amount, sender=ctx.sender)

    self.state.total_volume += eth_amount + sol_amount
    self.emit(event.MultiChainOp(eth_amount=eth_amount, sol_amount=sol_amount))

Token Bridge with Lock/Release

The real TokenBridge implements the full lock-on-source, mint-on-destination pattern:

from panda import contract, constructor, call, query, event
from panda import send_message, Chain

@contract
class TokenBridge:
    class State:
        name: str = "PandaBridge"
        owner: str = ""
        locked_balances: dict = {}
        total_locked: int = 0
        total_released: int = 0
        message_count: int = 0

    @constructor
    def deploy(self, ctx, name: str = "PandaBridge"):
        self.state.name = name
        self.state.owner = ctx.sender

    @call
    def lock_and_bridge(self, ctx, dest_chain: str, recipient: str, amount: int):
        """Lock tokens on this chain and emit a cross-chain mint message."""
        if amount <= 0:
            raise ValueError("Amount must be positive")

        balance = self.state.locked_balances.get(ctx.sender, 0)
        self.state.locked_balances[ctx.sender] = balance + amount
        self.state.total_locked += amount
        self.state.message_count += 1

        msg_id = send_message(
            dest_chain, recipient,
            action="mint", amount=amount, sender=ctx.sender,
        )
        self.emit(event.TokensLocked(
            sender=ctx.sender, dest_chain=dest_chain,
            recipient=recipient, amount=amount, msg_id=msg_id,
        ))

    @call
    def receive_and_release(self, ctx, recipient: str, amount: int, source_chain: str = ""):
        """Release tokens when a cross-chain message arrives."""
        if amount <= 0:
            raise ValueError("Amount must be positive")
        locked = self.state.locked_balances.get(recipient, 0)
        if locked < amount:
            raise ValueError(f"Insufficient locked balance: {locked} < {amount}")
        self.state.locked_balances[recipient] = locked - amount
        self.state.total_released += amount
        self.emit(event.TokensReleased(recipient=recipient, amount=amount))

    @query
    def stats(self) -> dict:
        return {
            "name": self.state.name,
            "total_locked": self.state.total_locked,
            "total_released": self.state.total_released,
            "message_count": self.state.message_count,
        }

System Contracts

RollupInboxMirror

The inbox mirror contract on L2 receives cross-chain messages from the relayer and dispatches them to destination contracts. Your @receiver methods are called by this system:

# Messages delivered to your contract come through the inbox mirror.
# You receive them via @receiver methods:

@receiver(chains=["ethereum"])
def on_deposit(self, ctx, amount: int, depositor: str):
    """Called when the relayer delivers an L1 deposit message."""
    self.state.balances[depositor] = self.state.balances.get(depositor, 0) + amount
    self.emit(event.Deposited(depositor=depositor, amount=amount))

Settlement Contracts (Ethereum L1)

ContractPurpose
SettlementMailboxReceives L2 batch commitments, mirrors inbound messages from the hub, and accepts outbound messages from L2 contracts
PandaBridgeHandles token deposits/withdrawals between L1 and L2 with escrow and Merkle proof verification
UniversalGatewayRoutes messages between multiple rollup instances via inbound queueing and outbound Merkle verification
OptimisticBatchAssertionsMinimal assert → challenge → finalize registry for optimistic mode -- accepts batch assertions (state-root commitments), lets a CHALLENGER_ROLE veto a pending assertion during the window, and finalizes unchallenged assertions
PandaFraudProofEconomic fraud-proof layer -- holds sequencer stakes, accepts challenger bonds, slashes fraudulent sequencers, and rewards successful challengers
OutboundRootTimelockAnchors L2 Merkle roots on L1 with a configurable block delay before they become executable
PandaVerifierVerifies ZK validity proofs on-chain (Groth16) for instant-finality mode

These are Solidity contracts deployed on Ethereum. Users do not interact with them directly -- the bridge relayer handles all cross-layer communication.

Rollup Sequencer

The panda-rollup sequencer is a Rust service (built on Tokio + Alloy) that orchestrates L2 block production and L1 settlement through three concurrent async loops:

  1. Block ticker -- Drains pending transactions (up to a configurable batch size, default 32), executes them against the L2 Panda-Geth node via the panda_callContract RPC, and commits a new L2 block with receipt hashes. The state root chain is computed as SHA256(prev_root || tx_hashes) with a deterministic genesis root.

  2. Inbox ticker -- Polls the SettlementMailbox for InboundFromHub events, deduplicates by (rollupId, nonce), and enqueues decoded messages as pending L2 transactions. The L2 RollupInboxMirror contract validates caller authorization, rollup ID binding, and strictly-increasing nonce ordering to prevent replay attacks.

  3. Batch ticker -- Aggregates the L2 blocks accumulated since the last batch, reads the next batch number from the L1 bridge contract, and submits the batch with pre-state and post-state roots. Default cadence is one batch every 8 seconds; each batch contains every block produced during that interval (so with a 2-second block time and 32-tx blocks, roughly four blocks per batch).

Under the default configuration (panda-rollup/src/config.rs), the L2 block time is 2 seconds (block_time_ms = 2000), a new batch is submitted every 8 seconds (batch_interval_ms = 8000), and L2-to-L1 finality is gated by a 100-L1-block challenge window (challenge_window_l1_blocks = 100). End-to-end L1-to-L2 message latency depends on L1 finality and relayer polling cadence and has not been benchmarked as a single wall-clock figure.

Batch Lifecycle

Transactions -> Sequencing -> Execution -> State Root -> L1 Submission -> Finalization

Each batch contains:

  • Ordered list of L2 transactions
  • Pre-state root and post-state root
  • Cross-chain message receipts
  • Proof data (validity proof or fraud proof window)

Bridge Relayer

The bridge relayer is a stateless Rust service that connects the hub, settlement forks, and L2 chains through a four-stage pipeline:

Pipeline Stages

  1. Scan -- Poll the hub and settlement chains for new L1ToL2Message and OutboundToHub events, tracking per-chain block cursors for crash recovery.

  2. Decode -- Extract structured message fields from ABI-encoded event data, including rollup ID, nonce, destination, and variable-length payload (capped at 64 KB).

  3. Prove -- For outbound messages (L2 → hub), anchor the Merkle root on the hub. Three publication paths are supported in order of trust minimization:

    • ZK-verified: A Groth16 proof is submitted to the PandaVerifier contract, which verifies the proof on-chain and publishes the root.
    • Timelock: The root is proposed via OutboundRootTimelock and becomes executable after a configurable block delay.
    • Direct: The root is set by a trusted admin role (development only).
  4. Submit -- Forward inbound messages to the settlement mailbox; execute outbound messages on the hub gateway with the appropriate Merkle proof.

Reliability

The relayer is designed for high-availability deployment:

  • Idempotent: Reprocessing a message that has already been consumed produces a soft error, not a crash.
  • Stateless: Block cursors are the only persistent state. Multiple concurrent instances can run without coordination.
  • Crash recovery: Detects chain resets (cursor exceeds chain head) and recovers gracefully by resetting its scan position.

Message Delivery

When the relayer delivers a message, it calls the destination contract with cross-chain context:

# The relayer sets these context fields automatically:
ctx.is_cross_chain = True
ctx.source_chain = "ethereum"    # or "solana", "panda_l2", "panda_hub"
ctx.message_id = "0xabc..."      # unique message identifier

Your @receiver methods can trust these fields because only the authorized relayer can set is_cross_chain = True.

Proof Systems

Panda supports both optimistic and validity proof modes for rollup settlement.

Optimistic Mode

In optimistic mode, batches are submitted without proofs and enter a challenge window during which fraud proofs may be submitted. The OptimisticBatchAssertions contract registers each batch assertion and lets a challenger veto it during the window, while the PandaFraudProof contract provides the economic layer (stakes, bonds, slashing):

Stake requirements:

  • Sequencer must stake ≥ 1 ETH before submitting batches
  • Challengers must post a 0.1 ETH bond per challenge

Batch lifecycle:

  • SubmittedFinalized (if unchallenged after the window expires)
  • SubmittedChallengedRejected (if fraud is proven)

Challenge window:

  • Default: 50,400 blocks (~7 days at 12-second L1 block time)
  • During this window, anyone can submit a fraud proof against a batch
  • If fraud is proven, the sequencer's stake is slashed and distributed to the challenger
  • If the challenge is invalid, the challenger's bond is forfeited
  • Unchallenged batches auto-finalize after the window expires

Key properties:

  • Lower operational cost per batch (no proof generation)
  • Higher latency to finality (7-day challenge window)
  • Economic security: slashing makes fraud unprofitable

Validity Mode

In validity mode, each batch includes a ZK validity proof verified on-chain by the PandaVerifier contract:

  • The prover generates a Groth16 proof (388 bytes, per Groth16RootPublicationVerifier.sol's _PROOF_BYTES) for each batch
  • L1 verifies the proof on-chain. On-chain verification gas has not yet been benchmarked
  • Valid proofs bypass the challenge window entirely, providing instant finality
  • The prover supports two ZK backends: RISC Zero (general execution) and Halo2 (ML-optimized circuits)

Key properties:

  • Higher cost per batch (proof generation + on-chain verification)
  • Instant finality once verified (no challenge window)
  • See the Proofs guide for details on proof generation

Hybrid Upgrade Path

A rollup can start in optimistic mode (lower cost, delayed finality) and upgrade to validity mode (higher cost, instant finality) without contract migration -- both paths settle through the same PandaBridge contract.

Design Considerations

Message Latency

Cross-chain messages are not instant. When you use await chain.call(), the coroutine suspends and resumes in a future block -- potentially many blocks later. Always record pending state before the await:

# Record pending state BEFORE the await
self.state.pending_bridges[str(bridge_id)] = {
    "sender": ctx.sender,
    "amount": amount,
    "status": "pending",
}

# This may take minutes to hours depending on L1 finality
_result = await eth.call("0xBridge", "lock", amount=amount)

# Update state when response arrives
self.state.pending_bridges[str(bridge_id)]["status"] = "confirmed"

Message Ordering

Messages between the same pair of chains are delivered in order. Messages to different chains may arrive out of order.

Cancellation and State Checks

Since state can change between the await and the continuation, always re-read state after resuming. The Scheduled Job pattern demonstrates this:

await sleep(blocks=delay_blocks)

# Re-read state -- it may have changed during the sleep
jobs = dict(self.state.jobs)
job = jobs.get(key)
if not job or job["status"] != "pending":
    return  # cancelled or removed during the delay

PandaBridge Security Model

The PandaBridge underwent a comprehensive security hardening (PR #162) guided by a "simple-is-secure" philosophy: every line of code that is not strictly necessary for correctness is an attack surface, so it was removed.

Fee Removal

L1-side fee logic was removed entirely per the simple-is-secure principle. The following were all deleted from the bridge contract:

  • tokenFeeBps -- basis-point fee applied to token deposits
  • feeRecipient -- address that collected bridge fees
  • MAX_FEE_BPS -- upper bound on fee configuration

Deposits and withdrawals are now fee-free at the bridge level. If fees are needed in the future, they belong in a separate, auditable fee module rather than entangled with bridge custody logic.

Gateway Wire Protocol

Deposits now route through UniversalGateway.enqueueInbound instead of being processed directly by the bridge. The wire format is minimal and fixed-length:

  • Native deposit payload: 52 bytes (20-byte recipient + 32-byte amount)
  • Token deposit payload: 72 bytes (20-byte token + 20-byte recipient + 32-byte amount)

The gateway requires INBOX_SENDER_ROLE to accept inbound messages. This role is granted exclusively to the PandaBridge contract, ensuring only the bridge can enqueue deposits into the L2 inbox.

Token Withdrawal (withdrawToken)

PandaBridge.withdrawToken closes the L1-to-L2 ERC-20 round-trip. When a user withdraws ERC-20 tokens from L2 back to L1, the bridge verifies a token-aware withdrawal leaf in the bridge Merkle tree, confirms the proof against the finalized outbound root, and releases the escrowed tokens to the recipient. This is a separate code path from native ETH withdrawals to prevent type confusion between token types.

Bridge Invariants

Six invariants are enforced and verified through Foundry fuzz testing (500,000 calls per invariant: 5,000 runs at depth 100):

  1. Funds never stuck -- Withdrawals are always processable. No combination of admin actions, pauses, or contract states can make deposited funds permanently irretrievable.
  2. Admin cannot move user funds -- The admin role can pause the bridge and manage roles, but cannot transfer, redirect, or access user-deposited funds under any circumstances.
  3. Pause does not trap funds -- Withdrawals continue to work even when the bridge is paused. Pause only prevents new deposits, never blocks exits.
  4. No upgrade path -- The bridge logic is immutable. There is no proxy pattern, no UUPS, no delegatecall -- the deployed bytecode is final.
  5. Immutable proof -- Once a batch is finalized and its Merkle root is anchored, the proof data cannot be altered, replaced, or invalidated by any party.
  6. One token one path -- Native ETH and each ERC-20 token follow strictly separate deposit/withdrawal paths. There is no cross-contamination between token types.

Safe Deploy

The bridge is deployed with a Gnosis Safe multisig as the admin to prevent single-key compromise:

  • INBOX_SENDER_ROLE on the UniversalGateway is granted to the PandaBridge contract
  • DEFAULT_ADMIN_ROLE is renounced from the deployer after setup completes

After renunciation, no single externally-owned account holds admin privileges over the bridge or gateway. All administrative actions require multisig approval through the Gnosis Safe.

Test Coverage

The security model is backed by extensive automated testing:

  • 199 Foundry tests (forge test) covering all bridge, gateway, and settlement contract logic
  • Invariant fuzz testing: 500,000 calls per invariant (5,000 runs x depth 100) verifying that none of the six bridge invariants can be violated through any sequence of operations

Next Steps