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
OptimisticBatchAssertionsManages the challenge game for optimistic mode -- accepts batch assertions, tracks sequencer stakes, and adjudicates fraud proofs
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 completed L2 blocks into batches, reads the current 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, with each batch containing up to 128 transactions.

This architecture achieves an L2 block time of 2 seconds (configurable), throughput of up to 16 transactions per second per rollup, and end-to-end L1-to-L2 message latency of approximately 5.5 seconds under default configuration.

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 manages the full lifecycle:

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) for each batch
  • L1 verifies the proof on-chain (~500K--1M gas per verification)
  • Valid proofs bypass the challenge window entirely, providing instant finality
  • Three ZK backends are supported: RISC Zero, Halo2, and SP1

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

Next Steps