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
| Layer | Role | Technology |
|---|---|---|
| Panda L2 | Executes Python smart contracts at high throughput | PandaVM + Geth fork |
| Ethereum L1 | Final settlement, data availability, fraud/validity proofs | Standard Ethereum |
| Panda Hub | Cross-chain coordination, message routing, operator registry | Panda chain |
| Solana | Alternative settlement and cross-chain messaging target | Agave fork |
Message Flow
L2 to L1 (Withdrawal / Bridge Out)
- User calls a contract on Panda L2 that emits a cross-chain message (via
send_message()orChain.ethereum()) - The PandaVM captures the message in the execution output
- The rollup sequencer batches the message into an L2 block
- The bridge relayer picks up the message from the sequenced batch
- The relayer submits the message to the SettlementMailbox contract on Ethereum L1
- After the challenge period (or validity proof verification), the message is finalized
L1 to L2 (Deposit / Bridge In)
- User deposits to the PandaBridge contract on Ethereum L1
- The bridge relayer monitors L1 events for deposit messages
- The relayer submits the message to the RollupInboxMirror contract on Panda L2
- The inbox mirror triggers the destination contract's
@receivermethod - 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:
- Source L2 emits a cross-chain message targeting another L2
- The hub relayer picks up the message and routes it
- 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)
| Contract | Purpose |
|---|---|
| SettlementMailbox | Receives L2 batch commitments, mirrors inbound messages from the hub, and accepts outbound messages from L2 contracts |
| PandaBridge | Handles token deposits/withdrawals between L1 and L2 with escrow and Merkle proof verification |
| UniversalGateway | Routes messages between multiple rollup instances via inbound queueing and outbound Merkle verification |
| OptimisticBatchAssertions | Manages the challenge game for optimistic mode -- accepts batch assertions, tracks sequencer stakes, and adjudicates fraud proofs |
| OutboundRootTimelock | Anchors L2 Merkle roots on L1 with a configurable block delay before they become executable |
| PandaVerifier | Verifies 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:
-
Block ticker -- Drains pending transactions (up to a configurable batch size, default 32), executes them against the L2 Panda-Geth node via the
panda_callContractRPC, and commits a new L2 block with receipt hashes. The state root chain is computed asSHA256(prev_root || tx_hashes)with a deterministic genesis root. -
Inbox ticker -- Polls the SettlementMailbox for
InboundFromHubevents, deduplicates by(rollupId, nonce), and enqueues decoded messages as pending L2 transactions. The L2RollupInboxMirrorcontract validates caller authorization, rollup ID binding, and strictly-increasing nonce ordering to prevent replay attacks. -
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
-
Scan -- Poll the hub and settlement chains for new
L1ToL2MessageandOutboundToHubevents, tracking per-chain block cursors for crash recovery. -
Decode -- Extract structured message fields from ABI-encoded event data, including rollup ID, nonce, destination, and variable-length payload (capped at 64 KB).
-
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
PandaVerifiercontract, which verifies the proof on-chain and publishes the root. - Timelock: The root is proposed via
OutboundRootTimelockand becomes executable after a configurable block delay. - Direct: The root is set by a trusted admin role (development only).
- ZK-verified: A Groth16 proof is submitted to the
-
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:
Submitted→Finalized(if unchallenged after the window expires)Submitted→Challenged→Rejected(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
- Read the Cross-Chain Messaging guide for the full SDK API
- See the Async Contracts guide for
await sleep()and timer patterns - See the Proofs guide for ZK proof generation and verification
- Try the Contract Playground to experiment with cross-chain contracts