Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

World Chain Specs

About World Chain

World Chain is a blockchain designed for humans. Prioritizing scalability and accessibility for real users, World Chain provides the rails for a frictionless onchain UX.

Navigate this site using the sidebar on the left, the search icon found at the top of this page, or the left/right navigation buttons found to the sides of each page.

Flashblocks P2P Extension

This document is an extension to the original Flashblocks specification, modifying the flashblock propagation mechanism to use a peer-to-peer (P2P) network instead of WebSockets. It highlights the new P2P protocol and the changes in Rollup-Boost and builder interactions, aimed at simplifying distribution and improving fault tolerance in High Availability (HA) sequencer setups.

Table of Contents

Abstract

This document introduces an enhancement to Flashblocks where the propagation of partial blocks (“flashblocks”) is done over an Ethereum P2P subprotocol instead of a WebSocket broadcast. By integrating flashblock distribution into the peer-to-peer network, we eliminate the need for a dedicated WebSocket proxy and enable more robust, decentralized propagation of flashblock data. Crucially, this P2P approach uses cryptographic authorization to ensure that only an authorized block builder (and its designated successors in an HA setup) can publish flashblocks, improving fault tolerance during sequencer failovers. The end result is a simpler and more resilient system for delivering rapid preconfirmation data to users, without altering the core OP Stack protocol.

Motivation

The original Flashblocks design relied on a centralized broadcast (via Rollup-Boost and a WebSocket proxy) to propagate flashblocks to RPC providers. While effective, that design introduced operational complexity and potential single points of failure:

  • Operational Complexity: Sequencer operators had to manage a WebSocket broadcasting service (e.g. Rollup-Boost’s WebSocket proxy) to fan-out flashblocks to providers. In multi-sequencer (HA) configurations, handing off this connection or migrating subscribers was cumbersome.
  • Failover Challenges: In a High Availability sequencer setup, if the active sequencer failed the act of switching to a new sequencer/rollup-boost/builder combo would mean that already published flashblocks would not make it in the new block produced by the new builder. This breaks the promise that flashblocks makes to its consumers.
  • Scalability and Decentralization: Relying on a single hub (the sequencer’s Rollup-Boost) to redistribute flashblocks could become a bottleneck. A P2P approach can naturally scale out to many peers and align with Ethereum’s existing propagation model for blocks and transactions.

P2P Propagation addresses these issues by leveraging a gossip network for flashblocks. In this model, any number of RPC provider nodes (or other interested parties) can connect to the flashblock P2P network to receive preconfirmation updates. Failover is handled gracefully through the RLPx protocol: if a new sequencer takes over, its builder is already aware of previously published flashblocks, and so it can build on top of what has already been promised to the network.

Specification

Terminology

We inherit all terminology from the original Flashblocks spec (Sequencer, Block Builder, Rollup-Boost, etc.), with a few new terms introduced:

  • Authorizer – The entity that vouches for a block builder’s legitimacy to produce flashblocks. In practice, this is rollup-boost who signs an authorization for a given builder each block cycle.
  • Builder Public Key – A cryptographic public key identifying a builder on the flashblocks P2P network. This is distinct from an Ethereum address; it’s used for signing/validating flashblock messages.
  • Flashblocks P2P Network – The peer-to-peer overlay network (using Ethereum’s devp2p protocols) through which flashblock messages are gossiped. Participants include all builders and one or more subscribing nodes (e.g. RPC providers, possibly other sequencer nodes in standby).
  • Publisher – The current active builder that is publishing flashblocks for the ongoing L2 block. In an HA setup, the role of publisher can transfer to a new builder if the sequencer fails over.

Flashblocks Sequence Diagram

%%{init: {'sequence': {'noteMargin': 15}} }%%
sequenceDiagram
    participant ON as op-node<br/>(Sequencer)
    participant RB as rollup-boost<br/>(Authorizer)
    participant B as world-chain reth<br/>(Builder)
    participant P2P as P2P Peers<br/>(Receiving Nodes)

    Note over ON,P2P: ── Slot N (T=0s) ── FCU + Authorization
    ON->>RB: engine_forkchoiceUpdatedV3(fcs, attributes)
    Note over RB: Derive payload_id deterministically:<br/>payload_id = hash(parent_block_hash, attributes)<br/>(same algorithm as the builder)
    Note over RB: Create Authorization token:<br/>sign(payload_id ‖ timestamp ‖ builder_vk)<br/>with authorizer_sk
    RB->>B: flashblocks_forkchoiceUpdatedV3(fcs, attributes, authorization)
    Note over B: Derive same payload_id from attributes<br/>Match authorization.payload_id to job
    B-->>RB: ForkchoiceUpdated { payload_id }
    RB-->>ON: ForkchoiceUpdated { payload_id }

    Note over ON,P2P: Flashblock production (T≈0s–2s)
    Note over B: start_publishing(auth)
    B->>P2P: P2P: StartPublish (Authorized)
    Note over P2P: Verify authorizer_sig<br/>Verify builder_sig<br/>Track active publisher

    Note over B: spawn_build_job()<br/>reads txpool, builds payload

    Note over B: T≈200ms: flashblock interval fires
    Note over B: Flashblock #0 diff:<br/>empty → current best
    B->>P2P: P2P: FlashblocksPayloadV1 #0 (Authorized)
    Note over P2P: Verify signatures<br/>Apply flashblock #0

    Note over B: ~50ms recommits<br/>rebuild with new txs

    Note over B: T≈400ms: flashblock interval fires
    Note over B: Flashblock #1 diff:<br/>#0 → new best (new txs only)
    B->>P2P: P2P: FlashblocksPayloadV1 #1 (Authorized)

    Note over B: ~50ms recommits...

    Note over B: T≈600ms: flashblock interval fires
    B->>P2P: P2P: FlashblocksPayloadV1 #2 (Authorized)

    Note over B: ... repeats every ~200ms until deadline

    Note over ON,P2P: Seal the block (T≈2s)
    ON->>RB: engine_getPayloadV3(payload_id)
    RB->>B: engine_getPayloadV3(payload_id)
    Note over B: Return committed payload<br/>(state at last flashblock)
    B-->>RB: ExecutionPayloadEnvelopeV3
    RB-->>ON: ExecutionPayloadEnvelopeV3
    Note over B: stop_publishing()
    B->>P2P: P2P: StopPublish (Authorized)
    Note over P2P: Verify signatures<br/>Remove active publisher
    Note over ON,P2P: ── Slot N+1 (T≈2s) ──

Data Structures

The fundamental flashblock data structures (FlashblocksPayloadV1, ExecutionPayloadFlashblockResultV1, ExecutionPayloadStaticV1, and the various Metadata containers) remain unchanged. Flashblocks are still represented as a sequence of incremental payloads culminating in a full block.

To support P2P propagation and authorization, we introduce several new structures:

Authorization

Represents a sequencer’s cryptographic authorization for a specific builder to produce a block with a given payload context. This is essentially a signed token from the sequencer (authorizer) that the builder includes with its flashblocks.

#![allow(unused)]
fn main() {
pub struct Authorization {
    pub payload_id: PayloadId,
    pub timestamp: u64,
    pub builder_vk: VerifyingKey,
    pub authorizer_sig: Signature,
}
}
  • payload_id: The unique ID for this block’s payload (as provided by engine_forkchoiceUpdated in the OP Stack Engine API). All flashblocks for the block share this ID.
  • timestamp: The timestamp associated with this payload
  • builder_vk: The verifying key identifying the builder authorized to publish this block’s flashblocks. Peers will use this to verify the builder’s signatures on messages.
  • authorizer_sig: A signature produced by the sequencer (authorizer) over the concatenation of payload_id, timestamp, and builder_vk. This proves that the sequencer has approved the given builder (and key) to act for this block. Only one authorizer key (controlled by the rollup-boost operator) is recognized by the network, and all peers are configured with its public key for verification.

Authorized Message

Container for any flashblocks P2P message that requires authorization. It bundles a payload (one of the message types defined below) with the authorization and a builder’s signature.

#![allow(unused)]
fn main() {
pub struct Authorized {
    pub msg: AuthorizedMsg,
    pub authorization: Authorization,
    pub actor_sig: Signature,
}
}
#![allow(unused)]
fn main() {
pub enum AuthorizedMsg {
    FlashblocksPayloadV1(FlashblocksPayloadV1) = 0x00,
    StartPublish(StartPublish) = 0x01,
    StopPublish(StopPublish) = 0x02,
}
}
  • authorization: The Authorization object, as described above.

  • msg: The message content. This is a tagged union that can be one of:

    • A Flashblock Payload – Contains a FlashblocksPayloadV1 (partial block delta), see below.
    • A StartPublish signal – Indicates the builder is starting to publish a new block (detailed in StartPublish).
    • A StopPublish signal – Indicates the builder is stopping publication (detailed in StopPublish).
  • actor_sig: The builder’s signature over the combination of the msg and the authorization. This attests that the message indeed comes from the holder of the builder_sk in the Authorization, and that it hasn’t been tampered with in transit.

Every P2P message in the Flashblocks protocol is sent as an AuthorizedMessage. This double-signature scheme (authorizer + builder) provides two layers of security:

  1. Only a builder with a valid Authorization (signed by the sequencer) can get its messages accepted by peers.
  2. Only the genuine builder (holding the private key corresponding to builder_sk) can produce a valid builder_signature on the message content.

StartPublish

A small message indicating the intention to begin publishing flashblocks for a new L2 block.

#![allow(unused)]
fn main() {
pub struct StartPublish;
}

The StartPublish message is always sent wrapped in an AuthorizedMessage (with the appropriate authorization and signatures). It serves as an announcement to the network that “Builder X is about to start publishing”

StopPublish

An authorized message indicating that the builder will no longer publish any flashblocks

#![allow(unused)]
fn main() {
pub struct StopPublish;
}

Note: A builder will typically send a StopPublish when it receives a ForkChoiceUpdated without an accompanying Authorization from rollup-boost or upon handing off flashblock production to a new builder.

Flashblocks P2P Protocol

See also: Flashblocks P2P Protocol v2 amends this section with bounded, latency-optimized peer selection.

Protocol Overview

Flashblocks P2P communication is implemented as a custom Ethereum subprotocol. Specifically, it defines a new devp2p capability:

  • Protocol Name: flblk (flashblocks)
  • Version: 1

Nodes that support flashblocks will advertise this capability when establishing devp2p connections. Once connected, they can exchange flashblock messages as defined in this spec.

All flashblock messages are encoded in a compact binary format (analogous to Ethereum block gossip). Each message begins with a one-byte type discriminator, followed by the serialized content. The primary message type is an AuthorizedMessage (discriminator 0x00), which, as described, contains a nested payload type.

Key design features of the P2P protocol:

  • Multipeer Gossip: A builder’s flashblock is forwarded to all connected peers, who in turn may forward it to their peers, etc., ensuring the payload reaches all participants without needing a single central broadcaster. The protocol includes basic duplicate suppression so that flashblocks aren’t endlessly propagated in loops.
  • Real-time Coordination: Using StartPublish and StopPublish signals, multiple potential publishers (builders) can coordinate access to the network. This prevents conflicts where two builders might try to publish simultaneously, and allows a smooth handoff in failover scenarios (detailed below).

Message Types

Within the AuthorizedMsg union, we define the following variants and their semantics:

  • Flashblock Payload Message: Carries a FlashblocksPayloadV1 (as defined in the original spec) for a specific partial block. This includes the incremental transactions, updated state root, receipts root, logs bloom, etc., up through that flashblock. Peers receiving this message will apply the included state updates to their preconfirmation cache. Each Flashblock message has an index (the flashblock sequence number) and may include the base section if it’s the first flashblock (index 0) for that block.
  • StartPublish Message: Announces the start of a new publishers flashblock sequence. Peers use this to note which builder is now active for a given L2 block number, possibly resetting any previous state or halting their own publishing.
  • StopPublish Message: Indicates the end of the flashblock sequence for the current publisher. After this message, no further flashblocks from that publisher should arrive. Inactive or waiting publishers use this as a cue that they may now take over for subsequent flashblocks.

All these are encapsulated in AuthorizedMsg with the requisite signatures.

Authorization and Security

The P2P protocol introduces a trust model wherein peers accept flashblocks only from an authorized builder. The security measures include:

  • Authorizer Signature Verification: Upon receiving any AuthorizedMessage, a peer will first verify the authorizer_sig in the Authorization against the known authorizer public key. This confirms that rollup-boost has indeed permitted the stated builder to produce the block with the given payload_id and timestamp. If this signature is missing or invalid, the message is discarded as untrusted.

  • Builder Signature Verification: Next, the peer verifies the builder_signature on the message content using the builder_vk provided in the Authorization. This ensures the message was genuinely produced by the authorized builder and not altered. If this check fails, the message is rejected.

  • Payload Consistency Checks: Peers also check that the fields in the message are self-consistent and match expectations:

    • The payload_id in the Authorization must match the FlashblocksPayloadV1.payload_id (for flashblock messages). Each builder’s flashblock messages carry the same payload_id that was authorized, ensuring they all belong to the same block-building session.
    • Freshness: The timestamp in Authorization helps guard against replay of old messages. If a flashblock or StartPublish arrives with a significantly older timestamp (or for an already completed block), peers will ignore it and decrement the sender’s reputation.

These measures ensure that only the rollup-boost sanctioned builder’s data is propagated and that it’s cryptographically sound. Unauthorized parties cannot inject false flashblocks or tamper with content without detection. This design also allows dynamic builder changes: as long as the sequencer signs a new Authorization, the peers will accept the new builder’s messages even if they have never seen that builder before, because trust is transitive from the authorizers’s key.

Multi-Builder Coordination

A major benefit of the P2P approach is the ability to coordinate multiple builders in an HA (High Availability) setting. The StartPublish and StopPublish messages, in conjunction with a small amount of logic in Rollup-Boost and the network, handle the arbitration:

  • Single Publisher Rule: The network expects at most one builder to be actively publishing flashblocks for a given L2 block number at any time. If two different builders both attempt to publish for the same block, the conflict must be resolved to maintain a consistent preconfirmation state.
  • Announcing Intent – StartPublish: When Rollup-Boost (sequencer) initiates a new block with an external builder, it immediately broadcasts a StartPublish message (as an AuthorizedMessage) from that builder. This tells all peers: “Builder X is about to start publishing” If any other builder was thinking of building block N (perhaps there was a recent failover), it will see this and stand down.
  • Graceful Yield – reacting to StartPublish: If a builder is currently publishing and receives a StartPublish from a different builder for the same or next block, it means a failover or override is happening. The expected behavior is that the current publisher will cease publishing (and issue a StopPublish). The protocol is designed such that the honest builder who is not supposed to publish will yield to the authorized one. The reference implementation will automatically send a StopPublish if it is publishing and learns that another builder has taken over authority for the block. The new builder will wait until it receives the StopPublish before continuing.
  • Completion – StopPublish: When a builder receives the next FCU without an accompanying Authorization, it will send out a StopPublish. This removes the builder from the “active publisher” role in the eyes of the network. If there was another builder in waiting (perhaps one that had attempted to start earlier but was told to wait), that waiting builder will now see that the coast is clear.
  • Timeouts and Fallback: There is an implicit timeout in the coordination. If a builder is in a waiting state after announcing StartPublish but for some reason the previous publisher fails to produce a StopPublish (for example, if it crashed mid-block), other participants will not wait indefinitely. In our design, if a new block number is reached and the previous publisher hasn’t stopped we assume the previous builder is incapacitated and proceed with the new publisher.

This coordination ensures that in an HA setup with multiple sequencer instances and multiple builders, preconfirmation data remains consistent: only one set of flashblocks is ever in flight for a given block. If a sequencer failover occurs, the worst-case scenario (which occurs only during a very rare race condition) is a single block publication gap or discontinuity at a block boundary. In the far more likely case, there will be exactly no flashblock disruption. The next publisher will simply start where the last publisher left off, even if that is mid block.

Rollup-Boost and Builder Communication

In the P2P-enhanced design, Rollup-Boost’s interaction with the external block builder is slightly adjusted:

  • Authorization Delivery: When the sequencer (op-node) triggers a new block proposal via engine_forkchoiceUpdated (with payload attributes), Rollup-Boost creates an Authorization for the chosen builder. This requires that Rollup-Boost knows the builder’s public key in advance. In practice, the builder can be configured or registered with Rollup-Boost, providing its long-term public key. Rollup-Boost uses its authorizer private key (associated with the L2 chain or sequencer) to sign the authorization (covering payload_id, timestamp, builder’s key).
  • Forkchoice Updated Forwarding: Rollup-Boost forwards the fork choice update to the builder as usual (so the builder can start building the block). In this modified protocol, the fork choice update (or a parallel communication) includes the newly created Authorization. For example, a custom field or side-channel could convey the authorizer’s signature to the builder. (Implementation-wise, this might be an extension of the Engine API or an internal call – the key point is the builder receives the Authorization token before it begins sending flashblocks.)
  • StartPublish Broadcast: If the builder was not previously publishing, then immediately after receiving the authorization it will emit a StartPublish message over the P2P network. This tells all listening nodes that the authorized builder will begin flashblock publication.
  • Streaming Flashblocks: The builder executes transactions and produces flashblocks incrementally just as described in the original spec’s Flashblock Construction Process. However, instead of returning these payloads to Rollup-Boost, the builder now signs each flashblock with its key and directly broadcasts an Authorized Flashblock message to the P2P network.
  • No Inline Validation by Sequencer: In the original design, Rollup-Boost would validate each flashblock against the local execution engine before propagating it. In the P2P model, this is not done synchronously for each flashblock (it would negate some latency benefits). Instead, trust is managed via the Authorization. The sequencer trusts its chosen builder to only send valid blocks (and will ultimately verify the final block when engine_getPayload is called). Peers trust the flashblocks because they trust the Rollup-Boost’s signature.

In summary, Rollup-Boost’s role shifts from being a middleman for data to being a controller and coordinator. It authorizes the builder and informs the network about which builder is active, but it doesn’t need to ferry every flashblock through itself. This streamlines the path from builder to RPC providers.

Flashblocks P2P Protocol v2

This document specifies changes to the flashblocks P2P protocol (flblk) to replace full-broadcast fanout with bounded, latency-optimized peer selection. It is an amendment to the existing Flashblocks P2P Extension.

Problem

The current flashblocks P2P protocol broadcasts every FlashblocksPayloadV1 to all connected peers (handler.rs:585, connection.rs:97-129). A node with N peers sends N copies of every flashblock. For a node connected to 50 peers, that is 50x outgoing bandwidth per flashblock. As the network grows, this becomes unsustainable.

Design Goals

  1. Reduce bandwidth — Each node sends flashblocks to a bounded number of peers instead of all peers.
  2. Optimize latency — Nodes periodically rotate out their slowest receive peers in favor of random alternatives.
  3. Reliability — Multiple receive peers provide redundancy against individual peer failures.
  4. Trusted peer priority — Trusted peers are always served when they request flashblocks, regardless of limits.
  5. Uniform roles — Builders and consumers follow the same protocol. No special-casing by role.

Overview

Each node maintains two bounded peer sets:

  • Send Set (max max_send_peers, default 10): Peers this node actively forwards flashblocks to. These are peers that have sent a RequestFlashblocks and been accepted. Trusted peers bypass the limit.
  • Receive Set (max max_receive_peers, default 3): Peers this node actively receives flashblocks from. These are peers this node has selected as active feed sources.

Flashblocks propagate through the network as a directed acyclic graph: the builder sends to its send set, those nodes relay to their send sets, and so on. With a fanout of 10, a network of N nodes requires approximately log₁₀(N) hops from builder to the most distant node.

Periodically, each node evaluates the latency of its receive peers and may rotate out the highest-latency peer in favor of a randomly-selected alternative, one peer at a time.

Protocol Version

This change adds new message types to the flblk protocol. The protocol version is bumped from 1 to 2. Nodes advertising flblk/2 support the fanout control messages described below. Nodes running flblk/1 will not understand these messages and are incompatible.

New Message Types

Four unsigned control messages are added to FlashblocksP2PMsg:

DiscriminatorMessageDirectionDescription
0x01RequestFlashblocksReceiver → Sender“I want to receive flashblocks from you”
0x02AcceptFlashblocksSender → Receiver“Accepted. I will send you flashblocks”
0x03RejectFlashblocksSender → Receiver“Rejected. I am at capacity”
0x04CancelFlashblocksReceiver → Sender“Stop sending me flashblocks”

These messages carry no payload. The connection context (peer ID) provides all necessary information.

#![allow(unused)]
fn main() {
pub enum FlashblocksP2PMsg {
    /// Existing authorized message wrapper (flashblock payloads, StartPublish, StopPublish).
    Authorized(Authorized) = 0x00,
    /// New fanout control messages (unsigned).
    RequestFlashblocks = 0x01,
    AcceptFlashblocks = 0x02,
    RejectFlashblocks = 0x03,
    CancelFlashblocks = 0x04,
}
}

Message Semantics

RequestFlashblocks — Sent by a node that wants to receive flashblocks from the connected peer. The recipient evaluates:

  1. Is the requester a trusted peer? → Always accept (trusted peers bypass max_send_peers).
  2. Is the number of non-trusted peers in the send set below max_send_peers? → Accept.
  3. Otherwise → Reject.

AcceptFlashblocks — Response to RequestFlashblocks. After this, the sender begins forwarding all Authorized messages to the receiver and adds the receiver to its send set.

RejectFlashblocks — Response to RequestFlashblocks when the sender cannot accommodate more peers. The requester should try another peer.

CancelFlashblocks — Sent only by a receiver to the sender it no longer wants to receive flashblocks from (e.g., during peer rotation).

After receiving CancelFlashblocks, the sender immediately stops forwarding flashblocks to that peer and removes it from its send set.

Peer Management

Per-Node State

#![allow(unused)]
fn main() {
struct FanoutState {
    /// Peers we are actively sending flashblocks to.
    send_set: HashSet<PeerId>,
    /// Peers we are actively receiving flashblocks from.
    receive_set: HashSet<PeerId>,
    /// Peers we have sent RequestFlashblocks to but not yet received a response.
    pending_requests: HashSet<PeerId>,
    /// Sliding-window latency stats per receive peer.
    peer_latency: HashMap<PeerId, LatencyTracker>,
    /// Whether a rotation is currently in progress.
    rotation_in_progress: bool,
}
}

Bootstrapping (Node Startup)

When a node starts and connects to peers via devp2p:

  1. As peers connect and complete the flblk/2 handshake, discover whether they are trusted or untrusted.
  2. Only request peers whose trust classification is known, so trusted peers are always considered first.
  3. Continue sending requests as new peers connect until receive_set.len() >= max_receive_peers.
  4. Once the receive set is full, stop sending unsolicited requests (further changes happen via rotation).

Handling Incoming RequestFlashblocks

receive RequestFlashblocks from peer P:

if P is trusted:
    add P to send_set
    send AcceptFlashblocks to P

else if non_trusted_send_count < max_send_peers:
    add P to send_set
    send AcceptFlashblocks to P

else:
    send RejectFlashblocks to P

Handling Disconnections

When a peer disconnects unexpectedly (connection drops):

  • If peer was in send set → remove it. The slot is now available for future requests.
  • If peer was in receive set → remove it. Immediately attempt to fill the slot by sending RequestFlashblocks to a random connected peer not already in receive_set or pending_requests.
  • If peer was in pending requests → remove it. If receive set is not full, try another peer.

Forwarding Rules

When a node receives an Authorized message from a peer in its receive set:

  • FlashblocksPayloadV1: Verify signatures, process the flashblock (update state, emit to flashblock stream). Then forward the serialized bytes to all peers in the send set except the peer that sent it.
  • StartPublish: Verify signatures and process locally. Do not relay it beyond the direct neighbor that sent it.
  • StopPublish: Same as StartPublish — process locally, do not relay.

If a node receives an Authorized(FlashblocksPayloadV1) from a peer not in its receive set, or from a peer whose RequestFlashblocks is still pending, the message should be ignored and the peer should be penalized. This prevents unsolicited data delivery.

Duplicate Handling

Since a node receives from up to max_receive_peers peers, it will receive multiple copies of each flashblock. This is expected behavior in the new protocol.

  • First copy: Process normally (update state, emit to stream, forward to send set). Record latency for the delivering peer.
  • Subsequent copies (same flashblock from different peers): Record the latency for the delivering peer. Discard the flashblock data.
  • Same flashblock from the same peer twice: This is NOT expected. Apply reputation penalty as before (ReputationChangeKind::AlreadySeenTransaction).

This is a behavioral change from the current protocol, where any duplicate from any peer triggers a reputation penalty (connection.rs:288-290).

Latency-Based Peer Rotation

Latency Measurement

Each FlashblocksPayloadV1 includes a flashblock_timestamp in its metadata, set by the builder at creation time. When a node receives a flashblock from a receive peer, it computes:

one_way_latency = now() - flashblock_timestamp

This measurement is attributed to the specific peer that delivered the flashblock. Nodes maintain a sliding window of the last latency_window (default 1000) measurements per receive peer and compute a moving average.

Since all receive peers deliver the same flashblock (with the same flashblock_timestamp), the relative ordering of peers by latency is accurate even with clock skew between the builder and receiver.

Rotation Algorithm

The receive set must never exceed max_receive_peers.

When rotating:

  1. Select the worst-scoring peer in the current receive set.
  2. Remove that peer from the receive set immediately and send CancelFlashblocks.
  3. Pick a replacement candidate, prioritizing trusted peers.
  4. Add the replacement peer to the receive set in a provisional state and send RequestFlashblocks.

The provisional peer occupies a receive slot immediately, so the node still never exceeds max_receive_peers. While provisional, the peer is scored for missed flashblocks the same as any other receive peer. If it fails to respond or fails to deliver flashblocks, its score will deteriorate and it can be rotated out on a later interval.

Configuration Parameters

ParameterDefaultDescription
max_send_peers10Maximum non-trusted peers to send flashblocks to
max_receive_peers3Maximum peers to receive flashblocks from
rotation_interval30sHow often to evaluate and potentially rotate receive peers
latency_window1000Number of flashblocks to track for per-peer latency averaging

Trusted peers are always served on request and do not count toward max_send_peers.

Interaction with Existing Protocol

Unchanged Components

The existing Authorized message types (FlashblocksPayloadV1, StartPublish, StopPublish) remain unchanged. They continue to use the Authorized wrapper with sequencer + builder signatures. The multi-builder coordination state machine (Publishing, WaitingToPublish, NotPublishing) is unaffected. StartPublish and StopPublish remain direct-neighbor messages and are not relayed.

Required Changes to Existing Code

  1. Duplicate handling must change — The current per-peer duplicate check at connection.rs:278-291 penalizes any duplicate flashblock with ReputationChangeKind::AlreadySeenTransaction. In the new protocol, receiving the same flashblock from different receive peers is expected. Only same-peer duplicates (same flashblock index from the same peer twice) should trigger a penalty.

  2. Flashblock forwarding must be scoped to send set — The current broadcast channel (peer_tx) sends to all connections. This must be replaced with targeted sends to only peers in the send set. The PeerMsg::FlashblocksPayloadV1 variant currently uses a broadcast channel subscribed by all connections; this must be changed so each connection checks whether the destination peer is in the send set before forwarding.

  3. Receive-peer selection must respect trust discovery — Nodes should not request unknown peers before their trust classification is available, otherwise untrusted peers can fill the bounded receive set before trusted peers are considered.

  4. Protocol version bumpCapability::new_static("flblk", 1) at handler.rs:239 must be updated to version 2.

Priority Blockspace for Humans

Priority Blockspace for Humans introduces a new transaction ordering policy on World Chain that grants verified World ID holders top-of-block priority, reducing friction and making transactions fairer for real users.

Where bots create congestion, PBH is a highway for humans.

PBH Architecture

World Chain is an OP Stack chain that enables Priority Blockspace for Humans (PBH) through the World Chain Builder. World Chain leverages rollup-boost to support external block production, allowing the builder to propose PBH blocks to the sequencer while remaining fully compatible with the OP Stack.

Block Production on the OP Stack

The Engine API defines the communication protocol between the Consensus Layer (CL) and the Execution Layer (EL) and is responsible for orchestrating block production on the OP Stack. Periodically, the sequencer’s consensus client will send a fork choice update (FCU) to its execution client, signaling for a new block to be built. After a series of API calls between the CL and EL, the EL will return a new ExecutionPayload containing a newly constructed block. The CL will then advance the unsafe head of the chain and peer the new block to other nodes in the network.

sequenceDiagram
    box OP Stack Sequencer
        participant sequencer-cl as Sequencer CL
        participant sequencer-el as Sequencer EL
    end
    box Network
        participant peers-cl as Peers
    end

    Note over sequencer-cl: FCU with Attributes
    sequencer-cl->>sequencer-el: engine_forkChoiceUpdatedV3(ForkChoiceState, Attrs)
    sequencer-el-->>sequencer-cl: {payloadStatus: {status: VALID, ...}, payloadId: PayloadId}
    sequencer-el->>sequencer-el: Build execution payload
    sequencer-cl->>sequencer-el: engine_getPayloadV3(PayloadId)
    sequencer-el-->>sequencer-cl: {executionPayload, blockValue}
    sequencer-cl->>peers-cl: Propagate new block


For a detailed look at how block production works on the OP Stack, see the OP Stack specs.

Rollup Boost

rollup-boost is a block building sidecar for OP Stack chains, enabling external block production while remaining fully compatible with the OP Stack. rollup-boost acts as an intermediary between the sequencer’s consensus and execution client. When sequencer-cl sends a new FCU to rollup-boost, the request will be multiplexed to both the sequencer’s execution client and external block builders signaling that a new block should be built.

When the sequencer is ready to propose a new block, op-node will send an engine_getPayload request to rollup-boost which is forwarded to the default execution client and external block builders.

Once rollup-boost receives the built block from external builder, it will then validate the block by sending it to the sequencer’s execution client via engine_newPayload. If the external block is valid, it is returned to the sequencer’s op-node, otherwise rollup-boost will return the fallback block. Note that rollup-boost will always fallback to the default execution client’s block in the case that the external builder does not respond in time or returns an invalid block.

sequenceDiagram
    box Sequencer
        participant sequencer-cl as Sequencer CL
        participant rollup-boost
        participant sequencer-el as Sequencer EL
    end
    box Builder
        participant builder-el as Builder EL
    end

    Note over sequencer-cl: FCU with Attributes
    sequencer-cl->>rollup-boost: engine_forkChoiceUpdatedV3(..., Attrs)

    Note over rollup-boost: Forward FCU
    rollup-boost->>builder-el: engine_forkChoiceUpdatedV3(..., Attrs)

    rollup-boost->>sequencer-el: engine_forkChoiceUpdatedV3(..., Attrs)
    sequencer-el-->>rollup-boost: {payloadId: PayloadId}
    rollup-boost-->>sequencer-cl: {payloadId: PayloadId}


    Note over sequencer-cl: Get Payload
    sequencer-cl->>rollup-boost: engine_getPayloadV3(PayloadId)
    Note over rollup-boost: Forward Get Payload
    rollup-boost->>sequencer-el: engine_getPayloadV3(PayloadId)
    rollup-boost->>builder-el: engine_getPayloadV3(PayloadId)
    builder-el-->>rollup-boost: {executionPayload, blockValue}
    sequencer-el-->>rollup-boost: {executionPayload, blockValue}



    Note over rollup-boost, sequencer-el: Validate builder block
    rollup-boost->>sequencer-el: engine_newPayloadV3(ExecutionPayload)
    sequencer-el->>rollup-boost: {status: VALID, ...}

    Note over rollup-boost: Propose execution payload
    rollup-boost->>sequencer-cl: {executionPayload, blockValue}
    
    Note over sequencer-cl: Propagate new block

In addition to Engine API requests, rollup-boost will proxy all RPC calls from the sequencer op-node to its local execution client. The following RPC calls will also be forwarded to external builders:

  • miner_*
    • The Miner API is used to notify execution clients of changes in effective gas price, extra data, and DA throttling requests from the batcher.
  • eth_sendRawTransaction*
    • Forwards transactions the sequencer receives to the builder for block building.

Block Production on World Chain

World Chain leverages rollup-boost to enable external block production and integrates the World Chain Builder as a block builder in the network. The World Chain Builder implements a custom block ordering policy (ie. PBH) to provide priority inclusion for transactions with a valid World ID proof. Note that the custom ordering policy adheres to the OP Stack spec.

Each block has a “PBH blockspace capacity”, which determines how many PBH transactions will be included in the block. Blocks on World Chain will always reserve a percentage of blockspace for non-PBH transactions to ensure inclusion for automated systems and non-verified users. If there are not enough pending PBH transactions to fill the entirety of PBH blockspace, standard transactions will be used to fill the remainder of the block.


Default Block
Tx Hash Fee
0xaaaa$0.04
0xbbbb$0.04
0xcccc$0.03
0xdddd$0.03
0xeeee$0.03
0x2222$0.02
0x3333$0.02
0x4444$0.02
0x5555$0.01
0x6666$0.01
PBH Block
Tx Hash Fee
0x3333$0.02
0x4444$0.02
0x5555$0.01
0x6666$0.01
0xaaaa$0.04
0xbbbb$0.04
0xcccc$0.03
0xdddd$0.03
0xeeee$0.03
0x2222$0.02

If the amount of pending PBH transactions exceed the PBH blockspace capacity, the remaining PBH transactions will carry over to the next block. PBH transactions aim to provide verified users with faster, cheaper transaction inclusion, especially during network congestion. Note that transactions within PBH blockspace are ordered by priority fee.

In the event that the block builder is offline, rollup-boost will fallback to the block built by the default execution client with standard OP Stack ordering rules.

PBH Transactions

The World Chain Builder introduces the concept of PBH transactions, which are standard OP transactions that target the PBHEntryPoint and includes a PBHPayload encoded in the tx calldata.

PBH 4337 UserOps

The PBHEntryPoint contract also provides priority inclusion for 4337 UserOps through PBH bundles. A PBH bundle is a standard 4337 bundle where the aggregated signature field is consists of an array of PBHPayload. A valid PBH bundle should include a n PBHPayloads, with each item corresponding to a UserOp in the bundle.

When creating a PBH UserOp, users will append the PBHPayload to the signature field and specify the PBHSignatureAggregator as the sigAuthorizer. The UserOp can then be sent to a 4337 bundler that supports PBH and maintains an alt-mempool for PBH UserOps.

The bundler will validate the PBHPayload, strip the payload from the userOp.signature field and add it to the aggregated signature.

    /**
     * Aggregate multiple signatures into a single value.
     * This method is called off-chain to calculate the signature to pass with handleOps()
     * @param userOps              - Array of UserOperations to collect the signatures from.
     * @return aggregatedSignature - The aggregated signature.
     */
    function aggregateSignatures(PackedUserOperation[] calldata userOps)
        external
        view
        returns (bytes memory aggregatedSignature)
    {
        IPBHEntryPoint.PBHPayload[] memory pbhPayloads = new IPBHEntryPoint.PBHPayload[](userOps.length);
        for (uint256 i = 0; i < userOps.length; ++i) {
            (, bytes memory proofData) = SafeModuleSignatures.extractProof(
                userOps[i].signature, ISafe(payable(userOps[i].sender)).getThreshold()
            );
            pbhPayloads[i] = abi.decode(proofData, (IPBHEntryPoint.PBHPayload));
        }
        aggregatedSignature = abi.encode(pbhPayloads);
    }

Upon submitting a PBH bundle to the network, the World Chain builder will ensure that all PBH bundles have valid proofs and mark the bundle for priority inclusion.

Visit the validation section of the docs to see how to encode the signalHash for a PBH UserOps work, check out the handleAggregatedOps() function and PBH4337Module.

PBH Payload

PBH Payload

A PBHPayload consists of the following RLP encoded fields:

FieldTypeDescription
external_nullifierbytesA unique identifier derived from the proof version, date marker, and nonce.
nullifier_hashFieldA cryptographic nullifier ensuring uniqueness and preventing double-signaling.
rootFieldThe Merkle root proving inclusion in the World ID set.
proofProofSemaphore proof verifying membership in the World ID set.

External Nullifier

The external_nullifier is a unique identifier used when verifying PBH transactions to prevent double-signaling and enforce PBH transaction rate limiting. For a given external_nullifier, the resulting nullifier_hash will be the same, meaning it can be used a unique marker for a given World ID user while maintaining all privacy preserving guarantees provided by World ID. Note that since the external_nullifier is different for every PBH transaction a user sends, no third party can know if two PBH transactions are from the same World ID.

The external_nullifier is used to enforce that a World ID user can only submit n PBH transactions per month, with the Builder ensuring that all proofs and external_nullifiers are valid before a transaction is included.

The external_nullifier is encoded as a 32-bit packed unsigned integer, with the following structure:

  • Version (uint8): Defines the schema version.
  • Year (uint16): The current year expressed as yyyy.
  • Month (uint8): The current month expressed as (1-12).
  • PBH Nonce (uint8): The PBH nonce, where 0 <= n < pbhNonceLimit.

Below is an example of how to encode and decode the external_nullifier for a given PBH transaction.

uint8 public constant V1 = 1;

/// @notice Encodes a PBH external nullifier using the provided year, month, and nonce.
/// @param version An 8-bit version number (0-255) used to identify the encoding format.
/// @param pbhNonce An 8-bit nonce value (0-255) used to uniquely identify the nullifier within a month.
/// @param month An 8-bit 1-indexed value representing the month (1-12).
/// @param year A 16-bit value representing the year (e.g., 2024).
/// @return The encoded PBHExternalNullifier.
function encode(uint8 version, uint8 pbhNonce, uint8 month, uint16 year) internal pure returns (uint256) {
    require(month > 0 && month < 13, InvalidExternalNullifierMonth());
    return (uint256(year) << 24) | (uint256(month) << 16) | (uint256(pbhNonce) << 8) | uint256(version);
}

/// @notice Decodes an encoded PBHExternalNullifier into its constituent components.
/// @param externalNullifier The encoded external nullifier to decode.
/// @return version The 8-bit version extracted from the external nullifier.
/// @return pbhNonce The 8-bit nonce extracted from the external nullifier.
/// @return month The 8-bit month extracted from the external nullifier.
/// @return year The 16-bit year extracted from the external nullifier.
function decode(uint256 externalNullifier)
    internal
    pure
    returns (uint8 version, uint8 pbhNonce, uint8 month, uint16 year)
{
    year = uint16(externalNullifier >> 24);
    month = uint8((externalNullifier >> 16) & 0xFF);
    pbhNonce = uint8((externalNullifier >> 8) & 0xFF);
    version = uint8(externalNullifier & 0xFF);
}

The World Chain Builder enforces:

  • The external_nullifier is correctly formatted and specifies the current month, year and a valid nonce.
  • The proof is valid and specifies the external_nullifier as a public input to the proof.
  • The external_nullifier has not been used before, ensuring that the pbh_nonce is unique for the given month and year.

Signal Hash

One of the required inputs when generating a World ID proof is the signal_hash. As the name suggests, the signal_hash is the hash of the signal which is an arbitrary message provided by the prover, allowing the verifier to hash the signal when verifying the proof, ensuring that the proof was generated with the expected inputs.

Within the context of PBH, the signal_hash is used to ensure the proof was created for the transaction being submitted. Depending on the type of the PBH transaction, this value could be a hash of the tx calldata, a 4337 UserOp hash or the tx hash itself.

PBH Validation

Upon receiving new transactions, the World Chain Builder will first ensure that the payload is a valid OP Stack tranasaction. In addition to the default checks, the builder will also evaluate transactions for PBH conditions.

Any transaction that calls the pbhMulticall() or handleAggregatedOps() function on the PBHEntyrPoint will be considered a PBH transaction and must clear PBH Validation. PBH transactions must contain a valid PBHPayload or PBHPayload[] in the case of PBH 4337 bundles.

    struct PBHPayload {
        uint256 root;
        uint256 pbhExternalNullifier;
        uint256 nullifierHash;
        uint256[8] proof;
    }

Signal Hash

Transactions that target the pbhMulticall() function must provide a valid PBHPayload where included proof is generated with a signalHash specified as:

uint256 signalHash = abi.encode(msg.sender, calls).hashToField();

Transactions that target the handleAggregatedOps()function (ie. PBH 4337 Bundles) must contain an aggregated signature consisting of an array of PBHPayload where there is a PBHPayload for each UserOp in the bundle. The included proof must be generated with a signalHash specified as:

uint256 signalHash = abi.encodePacked(sender, userOp.nonce, userOp.callData).hashToField();

External Nullifier

PBH transactions must contain a valid external nullifier where:

  • The month is the current month
  • The year is the current year (specified as yyyy)
  • The pbhNonce is < pbhNonceLimit. PBH nonces are 0 indexed, meaning if the pbhNonce limit is 29, a user is allotted 30 PBH transactions per month.

Root

The root provided must be a valid World ID Root with a timestamp less than 7 days old.

Proof

The proof must be a valid semaphore proof, proving inclusion in the World ID set associated with the specified root.

CLI Reference

Auto-generated — run cargo xtask docs to regenerate.

world-chain

The World Chain node binary. All flags below are passed to world-chain.

World Chain Node

Usage: world-chain [OPTIONS]

Options:
  -h, --help
          Print help (see a summary with '-h')

Rollup:
      --rollup.sequencer <SEQUENCER>
          Endpoint for the sequencer mempool (can be both HTTP and WS)
          
          [aliases: --rollup.sequencer-http, --rollup.sequencer-ws]

      --rollup.disable-tx-pool-gossip
          Disable transaction pool gossip

      --rollup.compute-pending-block
          By default the pending block equals the latest block to save resources and not leak txs from the tx-pool, this flag enables computing of the pending block from the tx-pool instead.
          
          If `compute_pending_block` is not enabled, the payload builder will use the payload attributes from the latest block. Note that this flag is not yet functional.

      --rollup.discovery.v4
          enables discovery v4 if provided

      --rollup.enable-tx-conditional
          Enable transaction conditional support on sequencer

      --rollup.supervisor-http <SUPERVISOR_HTTP_URL>
          HTTP endpoint for the supervisor. When not set, interop transaction validation is disabled

      --rollup.supervisor-safety-level <SUPERVISOR_SAFETY_LEVEL>
          Safety level for the supervisor
          
          [default: CrossUnsafe]

      --rollup.sequencer-headers <SEQUENCER_HEADERS>
          Optional headers to use when connecting to the sequencer

      --rollup.historicalrpc <HISTORICAL_HTTP_URL>
          RPC endpoint for historical data

      --min-suggested-priority-fee <MIN_SUGGESTED_PRIORITY_FEE>
          Minimum suggested priority fee (tip) in wei, default `1_000_000`
          
          [default: 1000000]

      --flashblocks-url <FLASHBLOCKS_URL>
          A URL pointing to a secure websocket subscription that streams out flashblocks.
          
          If given, the flashblocks are received to build pending block. All request with "pending" block tag will use the pending state based on flashblocks.

      --flashblock-consensus
          Enable flashblock consensus client to drive the chain forward
          
          When enabled, the flashblock consensus client will process flashblock sequences and submit them to the engine API to advance the chain. Requires `flashblocks_url` to be set.

      --proofs-history
          If true, initialize external-proofs exex to save and serve trie nodes to provide proofs faster

      --proofs-history.storage-path <PROOFS_HISTORY_STORAGE_PATH>
          The path to the storage DB for proofs history

      --proofs-history.window <PROOFS_HISTORY_WINDOW>
          The window to span blocks for proofs history. Value is the number of blocks. Default is 1 month of blocks based on 2 seconds block time. 30 * 24 * 60 * 60 / 2 = `1_296_000`
          
          [default: 1296000]

      --proofs-history.prune-interval <PROOFS_HISTORY_PRUNE_INTERVAL>
          Interval between proof-storage prune runs. Accepts human-friendly durations like "100s", "5m", "1h". Defaults to 15s.
          
          - Shorter intervals prune smaller batches more often, so each prune run tends to be faster and the blocking pause for writes is shorter, at the cost of more frequent pauses. - Longer intervals prune larger batches less often, which reduces how often pruning runs, but each run can take longer and block writes for longer.
          
          A shorter interval is preferred so that prune runs stay small and don’t stall writes for too long.
          
          CLI: `--proofs-history.prune-interval 10m`
          
          [default: 15s]

      --proofs-history.verification-interval <PROOFS_HISTORY_VERIFICATION_INTERVAL>
          Verification interval: perform full block execution every N blocks for data integrity. - 0: Disabled (Default) (always use fast path with pre-computed data from notifications) - 1: Always verify (always execute blocks, slowest) - N: Verify every Nth block (e.g., 100 = every 100 blocks)
          
          Periodic verification helps catch data corruption or consensus bugs while maintaining good performance.
          
          CLI: `--proofs-history.verification-interval 100`
          
          [default: 0]

Priority Blockspace for Humans:
      --pbh.verified-blockspace-capacity <VERIFIED_BLOCKSPACE_CAPACITY>
          Sets the max blockspace reserved for verified transactions. If there are not enough verified transactions to fill the capacity, the remaining blockspace will be filled with unverified transactions. This arg is a percentage of the total blockspace with the default set to 70 (ie 70%)
          
          [default: 70]

      --pbh.entrypoint <ENTRYPOINT>
          Sets the ERC-4337 EntryPoint Proxy contract address This contract is used to validate 4337 PBH bundles
          
          [default: 0x0000000000000000000000000000000000000000]

      --pbh.world-id <WORLD_ID>
          Sets the WorldID contract address. This contract is used to provide the latest merkle root on chain
          
          [default: 0x0000000000000000000000000000000000000000]

      --pbh.signature-aggregator <SIGNATURE_AGGREGATOR>
          Sets the ERC0-7766 Signature Aggregator contract address This contract signifies that a given bundle should receive priority inclusion if it passes validation
          
          [default: 0x0000000000000000000000000000000000000000]

Block Builder:
      --builder.enabled
          

      --builder.private-key <PRIVATE_KEY>
          Private key for the builder used to update PBH nullifiers
          
          [env: BUILDER_PRIVATE_KEY=]
          [default: 0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef]

      --builder.block-uncompressed-size-limit <BLOCK_UNCOMPRESSED_SIZE_LIMIT>
          Maximum cumulative uncompressed (EIP-2718 encoded) block size in bytes
          
          [env: BUILDER_BLOCK_UNCOMPRESSED_SIZE_LIMIT=]

Flashblocks:
      --flashblocks.enabled
          

      --flashblocks.authorizer-vk <AUTHORIZER_VK>
          Authorizer verifying key used to verify flashblock authenticity
          
          [env: FLASHBLOCKS_AUTHORIZER_VK=]

      --flashblocks.builder-sk <BUILDER_SK>
          Flashblocks signing key used to sign authorized flashblocks payloads
          
          [env: FLASHBLOCKS_BUILDER_SK=]

      --flashblocks.override-authorizer-sk <OVERRIDE_AUTHORIZER_SK>
          Override incoming authorizations from rollup boost
          
          [env: FLASHBLOCKS_OVERRIDE_AUTHORIZER_SK=]

      --flashblocks.force-publish
          Publish flashblocks payloads even when an authorization has not been received from rollup boost.
          
          This should only be used for testing and development purposes.
          
          [env: FLASHBLOCKS_FORCE_PUBLISH=]

      --flashblocks.interval <FLASHBLOCKS_INTERVAL>
          The interval to publish pre-confirmations when building a payload in milliseconds
          
          [env: FLASHBLOCKS_INTERVAL=]
          [default: 200]

      --flashblocks.recommit-interval <RECOMMIT_INTERVAL>
          Interval at which the block builder should re-commit to the transaction pool when building a payload.
          
          In milliseconds.
          
          [env: FLASHBLOCKS_RECOMMIT_INTERVAL=]
          [default: 200]

      --flashblocks.access-list
          Enables flashblocks access list support.
          
          Will create access lists when building flashblocks payloads. and will use access lists for parallel transaction execution when verifying flashblocks payloads.
          
          [env: FLASHBLOCKS_ACCESS_LIST=]

      --flashblocks.store
          Store accepted flashblocks to a separate libmdbx database
          
          [env: FLASHBLOCKS_STORE=]

      --flashblocks.store-path <STORE_PATH>
          Path to the libmdbx database directory used by --flashblocks.store.
          
          Defaults to <datadir>/flashblocks/flashblocks.mdbx.
          
          [env: FLASHBLOCKS_STORE_PATH=]

      --flashblocks.max-send-peers <MAX_SEND_PEERS>
          Override the flashblocks send-set size
          
          [env: FLASHBLOCKS_MAX_SEND_PEERS=]
          [default: 10]

      --flashblocks.max-receive-peers <MAX_RECEIVE_PEERS>
          Override the number of receive peers maintained for flashblocks fanout
          
          [env: FLASHBLOCKS_MAX_RECEIVE_PEERS=]
          [default: 3]

      --flashblocks.rotation-interval <ROTATION_INTERVAL>
          Override the flashblocks rotation interval in seconds
          
          [env: FLASHBLOCKS_ROTATION_INTERVAL=]
          [default: 30]

      --flashblocks.score-samples <SCORE_SAMPLES>
          Override the number of latency samples retained for receive-peer scoring
          
          [env: FLASHBLOCKS_SCORE_SAMPLES=]
          [default: 1000]

      --flashblocks.force-receive-peers <PEER_ID>
          Peers to always receive flashblocks from regardless of their score.
          
          These peers will be requested as soon as they connect and will never be evicted by rotation. They count toward `max_receive_peers`.
          
          [env: FLASHBLOCKS_FORCE_RECEIVE_PEERS=]

      --tx-peers <PEER_ID>
          Comma-separated list of peer IDs to which transactions should be propagated

      --worldchain.disable-bootnodes
          Disable the default World Chain bootnodes

World Chain Improvement Proposals (WIPs)

World Chain Improvement Proposals (WIPs) are design documents that describe new features, standards, or processes for the World Chain protocol. WIPs provide a structured way to propose changes, collect community feedback, and document design decisions.

WIPs are modeled after Ethereum Improvement Proposals (EIPs) and follow a similar format and workflow.


Table of Contents


WIP Index

WIPTitleStatusCategoryCreated
1001WorldID Native Account AbstractionDraftCore2026-03-27
1002WorldID Subsidy AccountingDraftCore2026-04-21
1003World ID Transaction SubsidiesDraftCore2026-04-03
1004EdDSA Verification PrecompileDraftCore2026-05-14
1005Proof System UpgradeDraftCore2026-05-20
1006Proof System ArchitectureDraftCore2026-05-27

WIP Types

Each WIP must declare one of the following types:

TypeDescription
Standards TrackDescribes a change that affects most or all World Chain implementations, such as a change to the protocol, transaction format, or precompile behavior. Standards Track WIPs are further categorized as Core, Networking, or Interface.
MetaDescribes a process surrounding World Chain or proposes a change to a process. Meta WIPs require community consensus but do not change the protocol itself.
InformationalProvides general guidelines, information, or describes a World Chain design issue without proposing a protocol change. Informational WIPs do not necessarily represent community consensus.

Standards Track Categories

CategoryDescription
CoreImprovements requiring a consensus fork or changes to core protocol components (e.g., precompiles, transaction types, state).
NetworkingImprovements to the p2p networking layer or node communication protocols.
InterfaceImprovements around client API/RPC specifications and standards.

WIP Statuses

WIPs follow this lifecycle:

Idea → Draft → Review → Last Call → Final
                              ↓
                           Stagnant
                              ↓
                          Withdrawn
StatusDescription
IdeaAn idea that is not yet a formal WIP. Discussed informally before being formalized.
DraftThe first formally tracked stage. The WIP is being actively developed and is not yet stable.
ReviewThe WIP author has marked the WIP as ready for peer review. Reviewers may submit feedback via GitHub comments.
Last CallThe final review window before finalization. The WIP will move to Final if no substantial objections are raised during this window.
FinalThe WIP has been finalized and represents an accepted standard. No further changes are expected other than errata corrections.
StagnantA WIP in Draft or Review that has had no activity for 6 months. It may be resurrected by updating its status back to Draft.
WithdrawnThe WIP author(s) have withdrawn the proposal. This state is final; the WIP number will not be reused.

WIP Format

All WIPs are written in Markdown and stored as wip-NNNN.md in this directory. Each WIP begins with a YAML front matter block (preamble), followed by a set of standard sections.

Use wip-template.md as your starting point.

Front Matter (Preamble)

---
wip: <number>
title: <Short title, 44 characters or less>
description: <One-sentence description of the proposal>
author: <FirstName LastName (@GitHubUsername), ...>
status: <Draft | Review | Last Call | Final | Stagnant | Withdrawn>
type: <Standards Track | Meta | Informational>
category: <Core | Networking | Interface>  # Standards Track only
created: <YYYY-MM-DD>
requires: <WIP-NNNN, EIP-NNNN>  # if applicable
---

Field descriptions:

FieldRequiredDescription
wipThe unique WIP number (assigned by a maintainer).
titleA short, descriptive title. Must not repeat the WIP number.
descriptionA single full sentence summarizing the proposal.
authorComma-separated list of authors. Each author listed as Name (@GitHubHandle) or Name <email@example.com>.
statusCurrent lifecycle status (see WIP Statuses).
typeThe WIP type (see WIP Types).
category⚠️Required for Standards Track WIPs only.
createdISO 8601 date (YYYY-MM-DD) when the WIP was first submitted.
requiresOther WIPs or EIPs that this WIP depends on.

Required Sections

Every WIP must include the following sections in this order:

  1. Abstract — A multi-sentence paragraph summarizing the proposal. Should be readable in isolation.
  2. Specification — Detailed technical description of the proposed change. Must include the RFC 2119 keyword boilerplate if RFC 2119 terms are used.
  3. Rationale — Explanation of design decisions, alternatives considered, and why this approach was chosen.
  4. Security Considerations — Discussion of all relevant security implications. A WIP cannot advance to Final without this section being deemed sufficient by reviewers.
  5. Copyright — All WIPs must end with: Copyright and related rights waived via [CC0](https://creativecommons.org/publicdomain/zero/1.0/).

Optional Sections

The following sections are optional but encouraged where applicable:

  • Motivation — Describe the problem this WIP solves. Include this section if the motivation is not immediately obvious.
  • Backwards Compatibility — Required if the proposal introduces breaking changes.
  • Test Cases — Expected input/output pairs or executable tests. Required for Core WIPs before Final.
  • Reference Implementation — A minimal implementation to aid understanding. Do not use this as a substitute for the Specification.

Numbering Convention

WIP numbers are assigned sequentially by maintainers when a Draft PR is opened.

  • WIPs in the 1000–1999 range cover Core protocol features for World Chain.
  • Numbers below 1000 are reserved for future Meta and Informational WIPs.

Do not self-assign a WIP number when initially opening a PR. Use a descriptive branch name (e.g., wip/account-abstraction) and a maintainer will assign a number upon review.


How to Contribute

  1. Discuss your idea first. Open a GitHub Discussion or reach out on the World Chain Discord before writing a full WIP. Early feedback saves time.

  2. Fork and branch. Fork the worldcoin/world-chain repository and create a branch named wip/<short-description>.

  3. Use the template. Copy wip-template.md to a new file named wip-draft_<short_title>.md (you’ll rename it once a number is assigned).

  4. Fill in the front matter and sections. Delete all HTML comment blocks before submitting.

  5. Open a Pull Request. Target the main branch. A maintainer will review your WIP, suggest a number, and rename the file.

  6. Iterate. Respond to review feedback. Once consensus is reached, the WIP status will be updated.

  7. Assets. If your WIP requires diagrams, code, or other assets, add them to assets/wip-<number>/.


Assets

Large assets (diagrams, reference implementations, test vectors) that cannot reasonably be embedded in the WIP file itself should be placed in:

assets/wip-<number>/

Keep the WIP file focused on the specification; reference assets by relative path.

WIP-1001: WorldID Native Account Abstraction

Abstract

We define a native World Chain account type managed by the WORLD_CHAIN_ACCOUNT_MANAGER predeploy. Each account has one immutable EIP-1271 compliant admin signer and one active key ring of session verifier instances backed by EIP-1271 compliant verifier implementations.

We extend EIP-2718 with a new typed transaction envelope with type flag 0x1D. 0x1D transactions are executed with the World Chain account framed as the sender. Session verifier instances act as the transaction level signatories via programmable EIP-1271 smart contracts. Session verifier implementations dually function as signature verifiers, and session key policy evaluators.

We design with the principle of not defining signer-specific authentication as native execution paths. Signature schemes, proof systems, recovery policies, and spending policies are fully programmable. The protocol only prescribes deterministic transaction context, validation frames, and reusable precompiles for cryptographic operations.

Motivation

World Chain accounts need programmable authentication without adding a protocol branch for every signature scheme, or session key policy. Smart contracts allow us to converge protocol level authentication over a single authorization boundary with determinism enforced via protocol level restrictions on programmable validation frames.

Programmable policies over Session Keys unlock many capabilities that simply are not possible on traditional accounts. Some examples include account subscription models, delegated agent payments with scoped permissions enforced by the VM, social recovery, policy based authentication (e.g. Multi Factor authentication for high value transactions). World Chain accounts are unique in that the policies constraining a Session Key are fully programmable. This allows for expressive policies, and authentication strategies to be composed and updated dynamically without prescriptive standards enforced by the protocol.

Specification

The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “NOT RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119 and RFC 8174.

Protocol Constants

NameTypeValueMeaning
WORLD_TX_TYPEuint80x1DEIP-2718 transaction type for World Chain account transactions.
MAX_SESSION_VERIFIERSuint25620Maximum active session verifier instances per account.
WORLD_CHAIN_ACCOUNT_DOMAINbytes32keccak256("WIP1001_ACCOUNT")Domain for account address derivation.
WORLD_CHAIN_ACCOUNT_CREATE_DOMAINbytes32keccak256("WORLD_CHAIN_ACCOUNT_CREATE")Domain for account creation authorization.
WORLD_CHAIN_ACCOUNT_SET_DOMAINbytes32keccak256("WORLD_CHAIN_ACCOUNT_SET")Domain for key ring replacement authorization.
WORLD_CHAIN_SESSION_VERIFIER_INSTANCE_DOMAINbytes32keccak256("WORLD_CHAIN_SESSION_VERIFIER_INSTANCE")Domain for session verifier instance derivation.

Activation Parameters

WIP-1001 MUST NOT activate until every parameter below is assigned in fork configuration.

NameTypeValueRequirement
WORLD_CHAIN_ACCOUNT_MANAGERaddressTBDPredeploy address for the account manager.
WORLD_CHAIN_ACCOUNT_ROUTER_CODE_HASHbytes32TBDRuntime code hash required at every World Chain account address.
EIP1271_VALIDATION_GAS_LIMITuint64TBDFixed gas forwarded to isValidSignature.
EXECUTION_TRACE_VALIDATION_GAS_LIMITuint64TBDFixed gas forwarded to evaluateSessionPolicy.
BLOCK_VALIDATION_GAS_BUDGETuint64TBDPer-block gas budget reserved for 0x1D validation calls.
MIN_VALIDATION_FAILURE_FEEuint256TBDMinimum wei charged to the account on validation failure.
MAX_EXECUTION_TRACE_BYTESuint32TBDMaximum ABI-encoded trace size passed to a session verifier.
MAX_PAYLOAD_DATA_BYTESuint32TBDMaximum length of data in a 0x1D envelope.
MAX_ACCESS_LIST_ENTRIESuint32TBDMaximum number of EIP-2930 access-list entries in a 0x1D envelope.
MAX_SESSION_SIGNATURE_BYTESuint32TBDMaximum length of the session signature field.
MAX_ADMIN_AUTHORIZATION_BYTESuint32TBDMaximum length of adminAuthorization calldata.
MAX_VERIFIER_INSTALL_DATA_BYTESuint32TBDMaximum length of installation in an admin or session verifier configuration.
EDDSA_PRECOMPILEaddressTBDEdDSA verification precompile address and ABI.
BLS12_381_PRECOMPILEaddressTBDBLS12-381 verification precompile address and ABI.

Reusable Crypto Precompiles

Signer contracts MAY call protocol-supported cryptographic precompiles from restricted validation frames.

PrimitiveSource
ecrecoverEthereum precompile
sha256Ethereum precompile
ripemd160Ethereum precompile
identityEthereum precompile
modexpEthereum precompile
bn254 addEthereum precompile
bn254 scalar multiplicationEthereum precompile
bn254 pairingEthereum precompile
secp256r1 verifyRIP-7212
EdDSA verifyActivation parameter
BLS12-381 verifyActivation parameter

Account State

World Chain accounts are created and managed by WORLD_CHAIN_ACCOUNT_MANAGER.

struct Account {
    WorldChainAccountVerifier admin;
    bytes32 accountSalt;
    WorldChainAccountVerifier[] sessionVerifiers;
    mapping(bytes32 sessionVerifierInstance => InstalledSessionVerifier) sessionVerifierInstances;
    bytes32 keyRingHash;
    uint64 adminNonce;
    uint64 transactionNonce;
}

struct WorldChainAccountVerifier {
    address verifier;
    bytes installation;
}

struct InstalledSessionVerifier {
    address verifier;
    bytes32 keyRingHash;
}

For every existing account:

  • the account address MUST have deployed runtime bytecode whose hash equals WORLD_CHAIN_ACCOUNT_ROUTER_CODE_HASH;
  • admin.verifier MUST be a deployed EIP-1271 verifier implementation and the full admin value is immutable for the life of the account address;
  • sessionVerifiers.length MUST be in [1, MAX_SESSION_VERIFIERS];
  • each installation.length in admin and sessionVerifiers MUST be at most MAX_VERIFIER_INSTALL_DATA_BYTES;
  • each sessionVerifiers[i].verifier MUST be a deployed EIP-1271 verifier implementation adhering to IWorldChainSessionVerifier;
  • sessionVerifiers MUST NOT contain duplicate session verifier instances;
  • for each active sessionVerifiers[i], the corresponding derived sessionVerifierInstance MUST resolve to sessionVerifiers[i].verifier and the current keyRingHash;
  • keyRingHash == keccak256(abi.encode(sessionVerifiers));
  • adminNonce is consumed only by admin operations;
  • transactionNonce is consumed only by successful 0x1D transaction execution attempts;

Verifier code is selected by verifier implementation address, not by a protocol-level enum. Session verifier membership and configuration are selected by session verifier instance. For each active sessionVerifiers[i], the corresponding instance identifier is:

sessionVerifierInstance = keccak256(abi.encode(
    WORLD_CHAIN_SESSION_VERIFIER_INSTANCE_DOMAIN,
    chainId,
    account,
    sessionVerifiers[i].verifier,
    keccak256(sessionVerifiers[i].installation)
));

A session verifier implementation is the contract address whose code is executed by the account router. A session verifier instance is one installed use of that implementation for a specific account and installation payload. Multiple session verifier instances MAY reference the same verifier implementation address, provided their installation hashes differ and therefore their sessionVerifierInstance identifiers are distinct. installation is opaque to the protocol and is interpreted only by the selected verifier implementation.

sessionVerifierInstances is account-router resolver state. It maps each installed session verifier instance to the verifier implementation address to DELEGATECALL and the keyRingHash for which that resolver entry is active. A session verifier instance is active iff sessionVerifierInstances[sessionVerifierInstance].keyRingHash == keyRingHash != 0 and sessionVerifierInstances[sessionVerifierInstance].verifier != address(0). Entries from previous key rings MAY remain in storage, but MUST NOT authorize after keyRingHash changes.

After account creation, setKeyRing is the only execution path that mutates sessionVerifiers, sessionVerifierInstances, keyRingHash, or validation-affecting account storage for active session verifier instances. WIP-1001 defines no operation that mutates admin or the account router runtime code.

Address Derivation

The manager derives an account address as:

adminHash = keccak256(abi.encode(admin));

account = address(uint160(uint256(keccak256(abi.encode(
    WORLD_CHAIN_ACCOUNT_DOMAIN,
    chainId,
    WORLD_CHAIN_ACCOUNT_MANAGER,
    WORLD_CHAIN_ACCOUNT_ROUTER_CODE_HASH,
    adminHash,
    accountSalt
)))));

The address binds the account to the manager instance, chain, account router runtime code, full admin verifier configuration, and salt. WIP-1001 defines no operation that rotates or reinstalls admin for an existing account.

Admin Operation Hashes

For every admin authorization, the manager computes a domain-separated adminOperationHash and calls:

IWorldChainAccountRouter(account).isValidSignatureForAdmin(
    adminOperationHash,
    adminAuthorization
)

The hash for each operation is:

createHash = keccak256(abi.encode(
    WORLD_CHAIN_ACCOUNT_CREATE_DOMAIN,
    chainId,
    WORLD_CHAIN_ACCOUNT_MANAGER,
    WORLD_CHAIN_ACCOUNT_ROUTER_CODE_HASH,
    account,
    adminHash,
    accountSalt,
    keyRingHash
));

setKeyRingHash = keccak256(abi.encode(
    WORLD_CHAIN_ACCOUNT_SET_DOMAIN,
    chainId,
    WORLD_CHAIN_ACCOUNT_MANAGER,
    account,
    adminNonce,
    expectedCurrentKeyRingHash,
    newKeyRingHash
));

Account Router and Verifier Interfaces

The account router is the only validation target called by the manager. It dispatches to configured verifier implementations using a single controlled DELEGATECALL, so verifier implementations execute against the account’s storage.

interface IWorldChainAccountRouter {
    function installAdmin(WorldChainAccountVerifier calldata admin) external;

    function installKeyRing(
        WorldChainAccountVerifier[] calldata sessionVerifiers
    ) external;

    function isValidSignatureForAdmin(
        bytes32 hash,
        bytes calldata signature
    ) external view returns (bytes4 magicValue);

    function isValidSignatureForVerifier(
        bytes32 sessionVerifierInstance,
        bytes32 hash,
        bytes calldata signature
    ) external view returns (bytes4 magicValue);

    function evaluateSessionPolicyForVerifier(
        bytes32 sessionVerifierInstance,
        IWorldChainSessionVerifier.ExecutionTraceContext calldata context
    ) external view returns (bool allowed);
}

installAdmin and installKeyRing MUST reject any caller other than WORLD_CHAIN_ACCOUNT_MANAGER. The account router MUST expose no non-manager path that can mutate validation-affecting verifier storage. isValidSignatureForAdmin, isValidSignatureForVerifier, and evaluateSessionPolicyForVerifier MUST be callable by WORLD_CHAIN_ACCOUNT_MANAGER in restricted validation frames.

The account router MUST dispatch admin validation only to its installed admin.verifier. It MUST dispatch session validation only to an active session verifier instance; unknown or stale session verifier instances MUST fail without executing verifier implementation code. For session validation, the router MUST resolve sessionVerifierInstance through sessionVerifierInstances, require the stored keyRingHash to equal the account’s current keyRingHash, and then use the stored verifier address for the controlled DELEGATECALL.

When the account router handles a manager-issued admin signature check (isValidSignatureForAdmin), it MUST DELEGATECALL the selected admin verifier implementation with calldata IERC1271.isValidSignature.selector || abi.encode(hash, signature). When the router handles a session signature check (isValidSignatureForVerifier), it MUST DELEGATECALL the selected session verifier implementation with calldata IERC1271.isValidSignature.selector || abi.encode(hash, abi.encode(sessionVerifierInstance, signature)). When the router handles evaluateSessionPolicyForVerifier, it MUST DELEGATECALL the selected session verifier implementation with calldata IWorldChainSessionVerifier.evaluateSessionPolicy.selector || abi.encode(context).

For session signature checks, the signature bytes visible to the session verifier’s ERC-1271 entrypoint are router-encoded as abi.encode(sessionVerifierInstance, signature), where the second field is the 0x1D envelope’s verifier-defined signature field. The 0x1D transaction sender supplies only the envelope sessionVerifierInstance and verifier-defined signature; the account router performs this wrapping before dispatching to the verifier implementation.

Verifier implementations install scoped storage through the following installation hook:

interface IWorldChainAccountHooks {
    function install(bytes calldata installation) external;
}

Admin verifier implementations MUST implement EIP-1271 and IWorldChainAccountHooks. Session verifier implementations MUST implement IWorldChainSessionVerifier:

interface IWorldChainSessionVerifier is IERC1271, IWorldChainAccountHooks {
    struct AccessListEntry {
        address account;
        bytes32[] storageKeys;
    }

    struct WorldChainTransactionContext {
        bytes32 signingHash;
        uint256 chainId;
        address account;
        bytes32 sessionVerifierInstance;
        uint64 nonce;
        uint256 maxPriorityFeePerGas;
        uint256 maxFeePerGas;
        uint64 gasLimit;
        bool isCreate;
        address target;
        uint256 value;
        bytes data;
        bytes4 selector;
        AccessListEntry[] accessList;
        bytes32 keyRingHash;
    }

    enum TraceCallKind {
        Call,
        StaticCall,
        DelegateCall,
        Create,
        Create2
    }

    struct CallTrace {
        uint32 depth;
        TraceCallKind kind;
        address caller;
        address target;
        uint256 value;
        bytes4 selector;
        bytes32 inputHash;
        bytes32 outputHash;
        bool success;
        uint64 gasUsed;
    }

    struct LogTrace {
        address emitter;
        bytes32[] topics;
        bytes32 dataHash;
        uint32 callIndex;
    }

    struct WorldChainExecutionTrace {
        bool success;
        uint64 gasUsed;
        bytes32 outputHash;
        CallTrace[] calls;
        LogTrace[] logs;
    }

    struct ExecutionTraceContext {
        WorldChainTransactionContext transaction;
        WorldChainExecutionTrace trace;
    }

    function evaluateSessionPolicy(
        ExecutionTraceContext calldata context
    ) external view returns (bool allowed);
}

installation is verifier-defined. The protocol does not parse it, but it commits to it through adminHash, createHash, and keyRingHash. For session verifier installs, the account router MUST derive each sessionVerifierInstance, store sessionVerifierInstances[sessionVerifierInstance] = InstalledSessionVerifier(verifier, keyRingHash), and call install with abi.encode(sessionVerifierInstance, installation). A session verifier implementation’s install function MUST fully write all validation-affecting state implied by installation into a deterministic account-storage namespace derived from sessionVerifierInstance. Because sessionVerifierInstance includes keccak256(installation), two installs of the same implementation with different installation bytes MUST use different validation-affecting storage slots. Removed verifier state MAY remain in account storage, but it MUST be unreachable unless the same session verifier instance is installed again through a later setKeyRing.

Verifier implementations MUST NOT rely on validation-affecting state stored at the verifier implementation address. Validation-affecting state MUST live in account storage installed through the account router.

Manager Interface

WORLD_CHAIN_ACCOUNT_MANAGER exposes:

interface IWorldChainAccountManager {
    function create(
        WorldChainAccountVerifier calldata admin,
        bytes32 accountSalt,
        WorldChainAccountVerifier[] calldata initialSessionVerifiers,
        bytes calldata adminAuthorization
    ) external returns (address account);

    function setKeyRing(
        address account,
        bytes32 expectedCurrentKeyRingHash,
        WorldChainAccountVerifier[] calldata sessionVerifiers,
        bytes calldata adminAuthorization
    ) external;

    function getAdmin(address account) external view returns (WorldChainAccountVerifier memory);
    function getAdminNonce(address account) external view returns (uint64);
    function getTransactionNonce(address account) external view returns (uint64);
    function getKeyRingHash(address account) external view returns (bytes32);
    function getSessionVerifiers(address account) external view returns (WorldChainAccountVerifier[] memory);
    function isAuthorizedSessionVerifier(address account, bytes32 sessionVerifierInstance) external view returns (bool);
    function getAuthorizedSessionVerifier(address account, bytes32 sessionVerifierInstance) external view returns (WorldChainAccountVerifier memory);
}

isAuthorizedSessionVerifier MUST return true only when sessionVerifierInstance resolves to an InstalledSessionVerifier whose keyRingHash equals the account’s current keyRingHash. getAuthorizedSessionVerifier MUST return the matching current sessionVerifiers entry and MUST fail for an absent or stale sessionVerifierInstance.

Manager Events

Each successful state transition MUST emit exactly one matching event in the same transaction that mutates state.

interface IWorldChainAccountManagerEvents {
    event AccountCreated(
        address indexed account,
        address indexed adminVerifier,
        bytes32 indexed adminHash,
        bytes32 accountSalt,
        bytes32 keyRingHash
    );

    event KeyRingSet(
        address indexed account,
        bytes32 indexed previousKeyRingHash,
        bytes32 indexed newKeyRingHash,
        uint64 adminNonce
    );
}

Manager State Transitions

Every admin-mutating method MUST reject adminAuthorization.length > MAX_ADMIN_AUTHORIZATION_BYTES. It MUST also reject any installation.length > MAX_VERIFIER_INSTALL_DATA_BYTES. If any requirement in a manager state transition fails, the method MUST revert without changing account state, account code, account storage, incrementing adminNonce, or emitting its state-transition event.

Admin authorization is valid only when IWorldChainAccountRouter(account).isValidSignatureForAdmin(adminOperationHash, adminAuthorization) succeeds in a restricted validation frame and returns EIP1271_MAGIC_VALUE.

Installing canonical account-router runtime bytecode is a native manager state transition. It MUST NOT execute account-supplied init code and MUST NOT be represented as an EVM CREATE or CREATE2 from the manager.

create

create MUST:

  1. reject admin.verifier == address(0);
  2. require admin.verifier and each initial session verifier to have deployed code;
  3. require initialSessionVerifiers.length to be in [1, MAX_SESSION_VERIFIERS];
  4. reject zero session verifier implementation addresses;
  5. reject oversized admin.installation or session verifier installation;
  6. compute adminHash = keccak256(abi.encode(admin));
  7. derive account;
  8. reject if account already exists or already has deployed code;
  9. reject duplicate session verifier instances for account;
  10. compute keyRingHash = keccak256(abi.encode(initialSessionVerifiers));
  11. atomically install canonical account-router runtime bytecode at account;
  12. call IWorldChainAccountRouter(account).installAdmin(admin);
  13. call IWorldChainAccountRouter(account).installKeyRing(initialSessionVerifiers);
  14. verify createHash through the account router’s admin validation path;
  15. initialize manager state with admin, adminNonce = 0, transactionNonce = 0, sessionVerifiers = initialSessionVerifiers, active sessionVerifierInstances, and keyRingHash;
  16. emit AccountCreated(account, admin.verifier, adminHash, accountSalt, keyRingHash).

setKeyRing

setKeyRing is the only post-creation method that mutates the key ring and MUST:

  1. require account to exist;
  2. require expectedCurrentKeyRingHash == keyRingHash;
  3. require sessionVerifiers.length to be in [1, MAX_SESSION_VERIFIERS];
  4. reject duplicate session verifier instances and zero, undeployed, or oversized session verifier entries;
  5. compute newKeyRingHash = keccak256(abi.encode(sessionVerifiers));
  6. reject newKeyRingHash == keyRingHash;
  7. compute setKeyRingHash using the current adminNonce;
  8. verify setKeyRingHash through the account router’s admin validation path;
  9. call IWorldChainAccountRouter(account).installKeyRing(sessionVerifiers);
  10. set previousKeyRingHash = keyRingHash;
  11. replace the account’s full sessionVerifiers set with the supplied sessionVerifiers;
  12. write active sessionVerifierInstances entries for the supplied set under newKeyRingHash;
  13. set keyRingHash = newKeyRingHash;
  14. increment adminNonce by one;
  15. emit KeyRingSet(account, previousKeyRingHash, newKeyRingHash, adminNonce).

Restricted Validation Frames

The protocol uses restricted validation frames for admin authorization, session signature verification, and session policy evaluation.

For each admin or session signature check, the protocol MUST perform a STATICCALL from WORLD_CHAIN_ACCOUNT_MANAGER to the account router at account with value 0, gas exactly EIP1271_VALIDATION_GAS_LIMIT, and calldata:

  • IWorldChainAccountRouter.isValidSignatureForAdmin.selector || abi.encode(hash, signature) for admin authorization checks; or
  • IWorldChainAccountRouter.isValidSignatureForVerifier.selector || abi.encode(sessionVerifierInstance, hash, signature) for session signature checks.

The call succeeds only if it returns EIP1271_MAGIC_VALUE and triggers no tracer rule violation. The account router is responsible for DELEGATECALLing the configured verifier implementation per the dispatch rules in “Account Router and Verifier Interfaces”; the manager never calls a verifier implementation directly.

For each session policy check, the protocol MUST perform a STATICCALL from WORLD_CHAIN_ACCOUNT_MANAGER to the account router at account with value 0, gas exactly EXECUTION_TRACE_VALIDATION_GAS_LIMIT, and calldata IWorldChainAccountRouter.evaluateSessionPolicyForVerifier.selector || abi.encode(sessionVerifierInstance, context). The call succeeds only if it returns true and triggers no tracer rule violation. The account router is responsible for DELEGATECALLing the configured session verifier implementation.

Failure, revert, out-of-gas, malformed return data, or a tracer violation MUST be treated as signature or policy failure.

Restricted frames are enforced by a runtime opcode-and-target tracer derived from ERC-7562. Runtime enforcement is authoritative.

Rule IDERC-7562 refDescription
WIP1001-VR-1OP-011Forbid block-context opcodes: BLOCKHASH, COINBASE, TIMESTAMP, NUMBER, PREVRANDAO/DIFFICULTY, GASLIMIT, BASEFEE, BLOBHASH, BLOBBASEFEE.
WIP1001-VR-2OP-011Forbid transaction-context opcodes: GASPRICE, ORIGIN.
WIP1001-VR-3OP-020Forbid external account inspection: BALANCE, SELFBALANCE, EXTCODESIZE, EXTCODECOPY, EXTCODEHASH.
WIP1001-VR-4OP-031, OP-032Forbid persistent and transient state mutation: SSTORE, TLOAD, TSTORE.
WIP1001-VR-5OP-031Forbid log emission: LOG0, LOG1, LOG2, LOG3, LOG4.
WIP1001-VR-6OP-031, OP-040Forbid CALL, CALLCODE, CREATE, CREATE2.
WIP1001-VR-7OP-051Forbid SELFDESTRUCT.
WIP1001-VR-8OP-061Permit STATICCALL only to allowlisted precompiles.
WIP1001-VR-9OP-052Permit DELEGATECALL only when the delegatee bytecode satisfies the same rule set. The tracer MUST validate delegatees recursively.
WIP1001-VR-10-Precompile execution does not create an additional EVM code frame.
WIP1001-VR-11-Permit reads of the signer contract’s own bytecode via CODECOPY and CODESIZE.
WIP1001-VR-12-Permit reads of the signer contract’s own storage via SLOAD.
WIP1001-VR-13-Permit KECCAK256, CHAINID, GAS, stack, memory, calldata, and deterministic arithmetic opcodes.

Adding a new precompile to the validation allowlist is a hard-fork change.

Transaction Type 0x1D

The wire encoding is:

0x1D || rlp([
    chainId,
    nonce,
    maxPriorityFeePerGas,
    maxFeePerGas,
    gasLimit,
    account,
    sessionVerifierInstance,
    to,
    value,
    data,
    accessList,
    signature
])

The signing hash is:

signingHash = keccak256(
    0x1D ||
    rlp([
        chainId,
        nonce,
        maxPriorityFeePerGas,
        maxFeePerGas,
        gasLimit,
        account,
        sessionVerifierInstance,
        to,
        value,
        data,
        accessList
    ])
);

The transaction hash is:

txHash = keccak256(
    0x1D ||
    rlp([
        chainId,
        nonce,
        maxPriorityFeePerGas,
        maxFeePerGas,
        gasLimit,
        account,
        sessionVerifierInstance,
        to,
        value,
        data,
        accessList,
        signature
    ])
);

Envelope validity requirements:

  • chainId MUST equal the executing chain ID;
  • nonce MUST equal Account.transactionNonce;
  • to MUST be the empty byte string for contract creation or exactly 20 bytes for a call;
  • data.length <= MAX_PAYLOAD_DATA_BYTES;
  • accessList.length <= MAX_ACCESS_LIST_ENTRIES;
  • signature.length <= MAX_SESSION_SIGNATURE_BYTES;
  • sessionVerifierInstance MUST be exactly 32 bytes;
  • accessList MUST use EIP-2930 encoding;
  • maxFeePerGas and maxPriorityFeePerGas follow EIP-1559 validity rules.

Validation, Execution, and Failure Semantics

Block builders MUST reserve EIP1271_VALIDATION_GAS_LIMIT + EXECUTION_TRACE_VALIDATION_GAS_LIMIT from BLOCK_VALIDATION_GAS_BUDGET before including a 0x1D transaction. If the remaining budget is insufficient, the transaction MUST be deferred; deferral is not a validation failure and MUST NOT mutate account state.

To include a 0x1D transaction, the protocol MUST:

  1. decode and validate the envelope;
  2. load account from WORLD_CHAIN_ACCOUNT_MANAGER;
  3. load the current keyRingHash, resolve sessionVerifierInstance through sessionVerifierInstances, require verifier != address(0) and require the stored keyRingHash to equal the current keyRingHash, and load the resolved verifier implementation address;
  4. require the account balance to cover the worst-case payload gas charge and MIN_VALIDATION_FAILURE_FEE;
  5. compute signingHash;
  6. call IWorldChainAccountRouter(account).isValidSignatureForVerifier(sessionVerifierInstance, signingHash, signature) in a restricted validation frame;
  7. if signature validation fails, apply validation-failure semantics and stop;
  8. increment Account.transactionNonce by one;
  9. execute the payload tentatively with account as the EVM sender and gas charged against envelope gasLimit;
  10. construct ExecutionTraceContext with context.transaction.sessionVerifierInstance set to the selected session verifier instance and context.transaction.keyRingHash set to the current account keyRingHash;
  11. if abi.encode(context.trace).length > MAX_EXECUTION_TRACE_BYTES, discard tentative payload state, apply trace-overflow semantics, and stop;
  12. call IWorldChainAccountRouter(account).evaluateSessionPolicyForVerifier(sessionVerifierInstance, context) in a restricted validation frame;
  13. if policy validation fails, discard tentative payload state, apply validation-failure semantics, and stop;
  14. otherwise commit the normal payload result, including a reverted payload result if the target call reverted.

Failure classes are:

FailureNoncePayload state/logsFee behavior
Envelope, account, balance, nonce, or authorization precheck failureunchangednonetransaction is invalid and not included
Validation budget exhaustedunchangednonetransaction is deferred
Signature validation failureincrementednonecharge max(MIN_VALIDATION_FAILURE_FEE, baseFee * EIP1271_VALIDATION_GAS_LIMIT)
Trace exceeds MAX_EXECUTION_TRACE_BYTESincrementeddiscardedcharge as a failed payload execution under the envelope gas rules
Session policy failureincrementeddiscardedcharge max(MIN_VALIDATION_FAILURE_FEE, baseFee * EIP1271_VALIDATION_GAS_LIMIT); payload gas is not additionally charged
Payload execution revert with accepted policyincrementedEVM revert result committedcharge normal payload gas

All validation gas consumed by signature and policy checks MUST count against BLOCK_VALIDATION_GAS_BUDGET. Validation gas is not deducted from the envelope gasLimit.

Txpool Revalidation

World Chain clients MUST revalidate every pooled 0x1D transaction at least once per block until inclusion or eviction.

On pool admission or successful revalidation, the client MUST record account, sessionVerifierInstance, nonce, signingHash, signature, and the account’s active keyRingHash. A transaction is eligible to remain pooled only while all conditions hold:

  • account exists;
  • the account balance can cover MIN_VALIDATION_FAILURE_FEE at the current baseFee;
  • the transaction nonce can still become executable under the account nonce ordering rules;
  • sessionVerifierInstance resolves to a nonzero verifier in sessionVerifierInstances with the account’s current keyRingHash;
  • the current account keyRingHash equals the hash recorded when the transaction was admitted or last revalidated.

A client MAY reuse a prior successful signature-validation result only when the cached (account, sessionVerifierInstance, signingHash, signature, keyRingHash) tuple exactly matches the current transaction and account state. Block builders MAY rely on such a cache to avoid repeating signature verification before block construction; if the active keyRingHash changes, the cached result MUST be discarded and the signature MUST be revalidated.

The keyRingHash comparison is the deterministic anchor for block validation: a verifier MAY accept a cached signature result only if the active account hash equals the recorded hash; otherwise it MUST execute signature validation again.

Signer-State Determinism

Session verifier behavior that can change isValidSignature or evaluateSessionPolicy for already accepted transactions MUST be immutable for a session verifier instance. Validation-affecting updates MUST be represented by selecting a different verifier implementation address or different installation bytes, deriving a different sessionVerifierInstance, and installing a replacement set through setKeyRing, which updates keyRingHash.

This applies to root rotations, verification-key rotations, signer recovery changes, signer revocation lists, account binding changes, and policy configuration changes. State that cannot affect validation MAY update in place.

Rationale

EIP-1271 gives World Chain one protocol boundary for all authorization schemes. The protocol verifies a signer contract through the account router; the signer contract decides how to authenticate.

Execution policy belongs inside the session verifier. The protocol supplies the canonical transaction context and execution trace, then asks the verifier for a boolean decision.

Validation gas limits are protocol constants, not signer-selected values. This gives clients a fixed resource bound for mempool validation and block execution.

Validation gas is supplied from a per-block budget rather than the envelope gasLimit. This keeps the payload gas model close to EIP-1559 transactions while bounding validation work per block.

Whole-set setKeyRing updates make the account’s session authorization state a single ordered set with one deterministic keyRingHash. Session verifier instances allow that ordered set to contain multiple entries backed by the same verifier implementation while still giving clients a stable membership key for cached validation. The account-router resolver stores the verifier implementation address beside the key-ring generation so validation can dispatch from sessionVerifierInstance without accepting a verifier address from transaction calldata. Clients can bind cached signature validation to that hash and must revalidate when it changes.

Restricted validation frames prevent validation from depending on mutable external state, block metadata, or arbitrary external contract calls.

Backwards Compatibility

WIP-1001 introduces a new EIP-2718 transaction type and a new account manager predeploy. Existing Ethereum transaction types, EOAs, contracts, and Safe accounts are unchanged.

Existing Safe accounts MAY deploy or delegate to EIP-1271 signer contracts that satisfy this specification, but WIP-1001 does not require Safe migration.

Test Cases

Conforming implementations MUST pass vectors for:

  • address derivation across multiple (chainId, manager, adminSigner, accountSalt) tuples;
  • keyRingHash = keccak256(abi.encode(sessionVerifiers)) for empty-forbidden, single-verifier, multi-verifier, repeated implementation with distinct installations, and reordered verifier sets;
  • sessionVerifierInstance derivation, including domain separation, chain ID binding, account binding, implementation-address binding, installation-hash binding, duplicate-instance rejection, and repeated implementation addresses with distinct installation bytes;
  • sessionVerifierInstances resolution, including verifier address lookup, stale key-ring hash rejection, zero verifier rejection, and repeated implementation addresses with distinct instance IDs;
  • create validation, including deployed-code checks, duplicate instance and zero verifier rejection, account-exists rejection, initial nonce values, initial keyRingHash, AccountCreated, and default World ID admin signer account binding;
  • setKeyRing validation, including stale expectedCurrentKeyRingHash, duplicate instance, zero, undeployed, empty, oversized, and no-op verifier set rejection;
  • setKeyRing success behavior, including full-set replacement, newKeyRingHash, adminNonce increment, KeyRingSet, authorization lookup updates, and failure atomicity;
  • createHash and setKeyRingHash domain separation, parameter binding, nonce binding, and negative replay cases across accounts, chains, managers, hashes, and operation types;
  • 0x1D RLP encoding, signing hash, and transaction hash;
  • malformed envelope rejection for chainId, nonce, to, data, accessList, signature, and EIP-1559 fee bounds;
  • transaction validation against the current session verifier instance resolver, including unauthorized session verifier instance rejection and ExecutionTraceContext.transaction.keyRingHash;
  • failure semantics for envelope/precheck failures, validation-budget exhaustion, signature failures, trace overflow, policy failures, and payload reverts;
  • txpool revalidation, signature-validation cache reuse, and cache invalidation when keyRingHash, session verifier instance authorization, account balance, or nonce eligibility changes;
  • signer-state determinism, including rejection of validation-affecting session verifier updates behind an unchanged session verifier instance;
  • restricted-frame positive cases and each forbidden opcode or call target;
  • default signer factory CREATE2 address computation, idempotency, metadata, and incompatible-code rejection;
  • World ID worldIdField, worldIdAction, admin proof, and session proof inputs;
  • default secp256k1 low-s, v, owner, and signature-length checks;
  • block validation budget exhaustion and validation-failure fee charging.

Complete golden vectors and a conformance test suite MUST be published before this WIP advances to Review.

Security Considerations

  • Admin authorization hashes bind domain, chain ID, manager, account, operation parameters, and nonce where applicable. A signature valid for create MUST NOT be valid for setKeyRing, another account, another chain, another manager, or another key ring hash.
  • The admin signer is immutable in the account address. A broken or lost admin signer requires migrating assets to a new account; applications that need recovery SHOULD use admin signers with recovery or multisig logic from account creation.
  • setKeyRing is the only post-creation key ring mutation path. A compromised session verifier instance remains authorized until an admin-authorized replacement set is included.
  • expectedCurrentKeyRingHash protects setKeyRing authorizations from applying to an unexpected current set. Admin tooling SHOULD surface the complete replacement set before signing because omitted verifiers are removed atomically.
  • keyRingHash binds cached validation to the ordered sessionVerifiers set. Clients MUST discard cached signature results when the active hash changes, even if the transaction’s sessionVerifierInstance remains present in the new set.
  • keyRingHash does not commit to mutable verifier behavior by itself. Session verifier state that can affect isValidSignature or evaluateSessionPolicy MUST be immutable for that verifier instance; upgrades that affect validation require a different verifier implementation address or different installation bytes installed through setKeyRing.
  • The protocol MUST verify that sessionVerifierInstance is authorized for account before calling verifier code. Unauthorized instances MUST fail as a precheck rather than receiving a validation call.
  • sessionVerifierInstances entries from prior key rings MAY remain in storage. They MUST NOT authorize unless their stored keyRingHash equals the account’s current keyRingHash.
  • Fixed validation gas limits bound signer execution. Signers that exceed the limit fail validation, and all signature and policy validation gas counts against BLOCK_VALIDATION_GAS_BUDGET.
  • BLOCK_VALIDATION_GAS_BUDGET is scarce. MIN_VALIDATION_FAILURE_FEE MUST be tuned so validation spam is costly, and clients MUST reject pooled transactions that cannot pay it.
  • Builders may prefer transactions with cheaper validation profiles. Fixed validation gas bounds the worst-case bias but does not eliminate ordering incentives.
  • MAX_EXECUTION_TRACE_BYTES, MAX_PAYLOAD_DATA_BYTES, MAX_ACCESS_LIST_ENTRIES, MAX_SESSION_SIGNATURE_BYTES, and MAX_ADMIN_AUTHORIZATION_BYTES bound calldata, decoding, memory, and ABI-encoding costs.
  • Restricted frames forbid mutable external-state reads and block-context opcodes. Signers that need time, block, or external-state constraints MUST bind them into the signature or proof input.
  • DELEGATECALL inside restricted validation frames is safe only when the delegatee bytecode satisfies the same tracer rules recursively.
  • Because verifier implementations execute via DELEGATECALL against account storage, storage isolation is not enforced by the EVM. Session verifier implementations MUST use deterministic account-storage namespaces derived from sessionVerifierInstance and MUST NOT write outside their assigned namespace except through explicitly specified account-router storage APIs, if any. Protocol deployments SHOULD allow only audited or approved verifier implementations when verifier code is delegatecalled.
  • The 0x1D envelope exposes account and sessionVerifierInstance. Applications that need stronger privacy should use verifier contracts that aggregate or rotate credentials.
  • World ID signers MUST bind block.chainid into worldIdAction or session nonce inputs to prevent cross-chain proof replay.
  • The default World ID admin signer self-reports account(). The manager checks this only for default-factory-deployed World ID admin signers; custom signers are responsible for their own consistency.
  • create does not bind msg.sender, so a copied call can be front-run. The resulting account state is identical because the authorization commits to all state-determining parameters.

WIP-1002: WorldID Gas Accounting

Abstract

This document introduces an accounting system for World ID Subsidies, which allow World ID users to claim an ETH-denominated budget for each eligible credential issued to their World ID. Budgets can be used to pay transaction fees on World Chain and refresh every 30 days. Users claim a budget by submitting a period-scoped uniqueness proof and delegate it to a set of authorized on-chain accounts.

The system is implemented as an upgradeable predeployed contract that owns the canonical subsidy state and exposes the claim, authorization, view and governance logic. A native revm extension performs per-transaction budget debits in-protocol by writing to a pinned subset of the contract’s storage slots.

This document does not specify how subsidies are applied to incoming transactions, including how users submit transactions without paying gas up front. That mechanism is specified in WIP-1003.

Motivation

World ID holders are currently subsidized for their World Chain transaction fees through off-chain paymaster infrastructure routed via World App. This couples subsidy delivery to the app, leaves transactions submitted directly to the chain (e.g. third-party wallets or dApps) unsupported, and relies on trusted paymaster operators to honor the entitlement. WIP-1002 lifts subsidy accounting into the protocol so that any authorized account can spend a World-ID-backed budget without holding native ETH up front, regardless of how the transaction reaches the sequencer.

Additionally, subsidies are no longer unlimited for World App users, but are bound to the eligible credentials held by a given World ID. This allows the protocol to provide larger subsidies to verified humans while bounding subsidies for less strongly verified users.

Finally, moving subsidies into the protocol makes the mechanism transparent and verifiable, aligning it more closely with the values of the World project.

High Level Architecture

Every World ID-verified user can claim a subsidy budget denominated in wei. The size of the budget depends on the user’s subsidy-eligible World ID credentials. For example, a user may claim a budget for their PoH Credential from the Orb, their Phone Credential from World App, and their Passport Credential. Budgets refresh every 30 days.

Each period derives a unique World ID action. Users claim their period budget and delegate it to a list of authorized accounts by submitting a Uniqueness Proof to a dedicated smart contract on World Chain. The user’s subsidy budget is identified by the unique period nullifier. Users also submit a Session ID during the initial claim, so that later updates, such as changing authorized accounts or claiming budgets for additional credentials, can be authenticated using World ID Session Proofs.

Subsidy claims and account authorization are handled by a Solidity predeploy. Gas-cost deductions for subsidized transactions are implemented as an EVM post-execution hook in revm, which directly updates the subsidy contract’s storage in-protocol.

Specification

The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “NOT RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119 and RFC 8174.

Constants

NameTypeValueMeaning
WORLD_ID_SUBSIDIES_RP_IDuint256481RP identifier registered in the World ID RpRegistry with the Subsidy Accounting proxy as signer.
PERIOD_LENGTHuint6430 daysLength of one subsidy period; per-period state namespace rotates at every multiple.
MAX_REQUEST_TTLuint641 hoursUpper bound on expiresAt − createdAt accepted by verifyRpRequest.

Architecture

The Subsidy Accounting component is deployed as a hybrid of two layers sharing one canonical state:

  • An upgradeable Solidity proxy holds all canonical state and exposes every claim, authorization, governance, and view path: claimSubsidy, updateRecord, setCredentialBudget, and read methods.
  • A revm/op-reth extension performs the per-transaction subsidy debit by reading and writing a pinned subset of the proxy’s storage slots directly during fee collection. The debit runs as protocol work, outside EVM gas metering, after the transaction’s normal execution. No consumeBudget function (or similar) exists in the public Solidity ABI.

Relying Party Signer

In World ID 4.0, Relying Parties (RPs) initiate the proof flow by providing a signed RP request to the user. For use cases where the RP is a smart contract, WIP-101 introduces ERC-1271-inspired on-chain RPs: any contract that implements IWIP101 can be registered at the RpRegistry contract, and OPRF nodes will eth_call verifyRpRequest before producing a share for an RP request with a matching rpId.

interface IWIP101 is IERC165 {
    error RpInvalidRequest(uint256 code);

    function verifyRpRequest(
        uint8   version,
        uint256 nonce,
        uint64  createdAt,
        uint64  expiresAt,
        uint256 action,
        bytes   calldata data
    ) external view returns (bytes4 magicValue);
}

Acceptance returns the magic value 0x35dbc8de. Rejection reverts with RpInvalidRequest(code) (any other revert is treated by OPRF nodes as an incompatible signer).

World ID Subsidies RP has rpId = WORLD_ID_SUBSIDIES_RP_ID and the registered RP signer is the Subsidy Accounting proxy itself — ISubsidyAccounting implements IWIP101 directly.

Validation of Auxiliary Fields

  • Version: The contract MUST revert if version != 1.
  • Nonce Check: The contract MUST NOT restrict nonces. OPRF nodes cache nonces and do not allow for nonce replay even accross RPs and actions. Hence, restricting the nonce would risk user requests being blocked.
  • Request TTL bound. The contract SHOULD verify createdAt ≤ block.timestamp ≤ expiresAt and expiresAt − createdAt ≤ MAX_REQUEST_TTL. Request expiry checks are not critical, but serve as a simple sanity check and could prevent bugs on the client side.
  • **data field.** The request’s wip101_data field (capped at 1 KiB by the OPRF-node layer) is unused. The contract MUST revert on non-empty data.

Action Validation

Validation branches on the most-significant byte of action (canonical extraction: uint8(action >> 248)), which the World ID 4.0 protocol uses to discriminate action types (Uniqueness Proof, Session OPRF Seed, Session Proof). Three classes are routed:

  • **action[0] == 0x00 — Uniqueness Proof, restricted.** Accept iff action ∈ { actionForPeriod(currentPeriod()), actionForPeriod(currentPeriod() + 1) }. The contract MUST revert for any other action. The boundary window (current + next) lets users submit the next period’s initial claim proof during the current period (see Budget Refresh). Restricting the action prevents malicious applications from using the World ID Subsidies RP for uniqueness proofs on arbitrary actions.
  • **action[0] == 0x01 — Session OPRF seed, client-random.** Used by the authenticator to derive r = OPRF(pk_rpId, DS_C || leafIndex || oprf_seed) for session-proof generation. The contract MUST accept unconditionally once the class prefix is confirmed.
  • **action[0] == 0x02 — Session Proof, client-random.** Per-proof randomness for the Session Proof itself; the only per-proof entropy source in the Session Proof circuit. Rhe contract MUST accept unconditionally once the class prefix is confirmed.

Subsidy Accounting Interface

ISubsidyAccounting extends IWIP101 (see Relying Party Signer) and defines the following methods.

interface ISubsidyAccounting is IWIP101 {
    /// @notice One per-credential Uniqueness Proof emitted by a single multi-item `ProofRequest`.
    ///         All items of a `claimSubsidy` call MUST share the same `nullifier` and the same
    ///         `signal_hash` public input (achieved by setting the same `signal` on every `RequestItem`).
    struct ClaimItem {
        uint64    issuerSchemaId;
        uint256[5] proof;
    }

    /// @notice Atomic initial per-period claim. Accepts the full set of per-credential
    ///         Uniqueness Proofs emitted from one multi-item `ProofRequest` for the current
    ///         period's claim action. Opens a subsidy record keyed by `nullifier`, stores
    ///         `sessionId` and the initial `addAddresses` set, and credits every credential
    ///         in `items`.
    ///         The contract recomputes the expected `signalHash` from the call parameters and
    ///         rejects any proof whose `signal_hash` public input does not match.
    ///
    ///         MUST revert if `items` is empty.
    ///         MUST revert if `nullifier` already has a record in the current period.
    ///         MUST revert on duplicate `issuerSchemaId` within `items`.
    ///         `addAddresses` MAY be empty (dormant record; addresses installed later via
    ///         `updateRecord`).
    function claimSubsidy(
        uint256 nullifier,
        uint256 sessionId,
        address[] calldata addAddresses,
        ClaimItem[] calldata items
    ) external;

    /// @notice Mid-period record mutation. Verifies a Session Proof against the `sessionId`
    ///         stored for `nullifier` and applies up to two sub-operations atomically:
    ///
    ///         (a) credential sub-op — if `issuerSchemaId` is not yet claimed under
    ///             `nullifier`, mark it claimed and credit its configured budget into
    ///             `budget[action][nullifier]`; otherwise the sub-op no-ops.
    ///         (b) auth-set sub-op — if `newSet` differs from the record's current
    ///             authorized set, full-replace the set (drop old cross-references in
    ///             `nullifiersOf`, install new ones); empty `newSet` revokes all; otherwise
    ///             the sub-op no-ops.
    ///
    ///         On any successful state change, `updateNonce` is bumped.
    ///
    ///         The Session Proof always binds to a real credential the caller holds — there
    ///         is no credential-agnostic sentinel. Pure auth-set rotations pick any
    ///         `issuerSchemaId` the caller already holds (the credential sub-op then
    ///         no-ops). `sessionAction` is the per-proof random action value
    ///         (`action[0] == 0x02`) supplied as a verifier public input; it is the only
    ///         per-proof entropy source in the Session Proof circuit and cannot be
    ///         derived contract-side.
    ///
    ///         The contract recomputes the expected `signalHash` from the call parameters.
    ///         MUST revert (`RecordDoesNotExist`) if `auth[currentAction()][nullifier].sessionId == 0`.
    ///         MUST revert (`StaleUpdateNonce`) if `nonce != auth[currentAction()][nullifier].updateNonce`.
    ///         MUST revert (`DuplicateAuthorizedAddress`) on duplicate addresses in `newSet`.
    ///         MUST revert (`NoOpUpdate`) if neither sub-op produces a state change.
    function updateRecord(
        uint256 nullifier,
        uint64  nonce,
        uint64  issuerSchemaId,
        address[] calldata newSet,
        uint256 sessionNullifier,
        uint256 sessionAction,
        uint256[5] calldata proof
    ) external;

    /// @notice Get remaining subsidy budget (in Wei) for a subsidy record in the current period.
    function getBudget(uint256 nullifier) external view returns (uint256 remainingWei);

    /// @notice Get remaining subsidy budget (in Wei) available to an address in the current period.
    ///         Sums `budget[currentAction()][nᵢ]` across every current-period claim the address is
    ///         authorized under, mirroring the walk performed by the per-tx debit hook.
    function getBudget(address account) external view returns (uint256 remainingWei);

    /// @notice Check whether an address is authorized under a given subsidy record.
    function isAuthorized(address account, uint256 nullifier) external view returns (bool);

    /// @notice Get all current-period subsidy records associated with an address, in the order
    ///         the per-tx debit hook walks them.
    function getNullifiers(address account) external view returns (uint256[] memory);

    /// @notice Check whether a credential has been claimed under `nullifier` this period.
    function isClaimed(uint256 nullifier, uint64 issuerSchemaId) external view returns (bool);

    /// @notice `IWIP101` surface called by OPRF nodes via `eth_call` before each share is
    ///         contributed. Validates the request envelope and the `action` value:
    ///           - TTL: `createdAt <= block.timestamp <= expiresAt` and
    ///             `expiresAt - createdAt <= MAX_REQUEST_TTL`;
    ///           - `data` MUST be empty;
    ///           - action-class routing on `uint8(action >> 248)`:
    ///               * `0x00` (Uniqueness) — accept iff `action` equals `actionForPeriod` of
    ///                 the current or next period (boundary window);
    ///               * `0x01` (Session OPRF seed) — accept unconditionally;
    ///               * `0x02` (Session inner action) — accept unconditionally.
    ///         Returns `0x35dbc8de` on acceptance, reverts `RpInvalidRequest(code)` otherwise.
    function verifyRpRequest(
        uint8   version,
        uint256 nonce,
        uint64  createdAt,
        uint64  expiresAt,
        uint256 action,
        bytes   calldata data
    ) external view returns (bytes4);

    /// @notice Per-period claim action for `period`, derived per [Storage Layout](#storage-layout).
    ///         Used inside `verifyRpRequest` and by off-chain callers constructing a Uniqueness
    ///         `ProofRequest`.
    function actionForPeriod(uint64 period) external view returns (uint256);

    /// @notice Set the claimable budget amount (in Wei) for a credential type. Governance only.
    function setCredentialBudget(uint64 issuerSchemaId, uint256 budgetWei) external;
}

Note: A consumeBudget is intentionally absent from the public ABI — the per-tx debit is performed by the revm extension over the pinned slots, not by an EVM call (see Per-tx Debit Path.

Storage Layout

The storage layout a the protocol-level commitment. The revm extension reads and writes a pinned subset of these slots directly during fee collection, so any reshape of the pinned subset is a coordinated protocol release.

Every period-scoped map is keyed by a verifier action , adopting the AddressBook pattern. The action is:

period   = block.timestamp / PERIOD_LENGTH
action   = keccak256("period_proof" || period) >> 8

>> 8 truncates the action to 248 bits to fit the BN254 scalar field.

Period-scoped maps:

mapping(uint256 action => mapping(uint256 nullifier => uint128))                  budget;
mapping(uint256 action => mapping(address account   => uint256[]))                nullifiersOf;
mapping(uint256 action => mapping(uint256 nullifier => AuthRecord))               auth;
mapping(uint256 action => mapping(uint256 nullifier => address[]))                authorized;
mapping(uint256 action => mapping(uint256 nullifier => mapping(uint64 => bool))) claimed;

struct AuthRecord {
    uint256 sessionId;     // bound at claimSubsidy; non-zero ⇔ claim exists this period
    uint64  updateNonce;   // bumped on any successful updateRecord state change
}

Pinned subset (protocol commitment, read/written by the revm extension):

  • The budget slot — uint128 width
  • The nullifiersOf slot
  • The action derivation: PERIOD_LENGTH and the action formula

Non-pinned (normal upgradeable state, append-only by convention):

  • auth, authorized, claimed, credentialBudget, admin/governance slots

Authorization Maps

  • Forward: authorized[action][nullifier] : address[] — addresses currently allowed to spend the record’s budget.
  • Reverse: nullifiersOf[action][account] : uint256[] — records the address may draw from, in the order the per-tx debit hook walks them.

Subsidy Budget Map

  • budget[action][nullifier] : uint128 is the counter representing the remaining budget in Wei.
  • getBudget(uint256 nullifier) returns budget[currentAction()][nullifier].
  • getBudget(address account) sums budget[currentAction()][nᵢ] across every current-period claim the address is authorized under.

Auth Map

auth[action][nullifier] : AuthRecord { sessionId, updateNonce } is the Session-Proof gate for the per-claim state.

  • sessionId is bound at claimSubsidy and retained for the period.
  • updateNonce is monotonic, bumped on every successful updateRecord state change. It blocks Session-Proof replay of the auth-set sub-op (caller-supplied nonce MUST equal the stored value).

Claimed-Credentials Map

  • claimed[action][nullifier][issuerSchemaId] : bool indicates wether a credential has already been claimed in a given period.

Periods, Nullifiers, and Sessions

Subsidy claims are bound to a per-period nullifier derived from a World ID 4.0 Uniqueness Proof with rpId = WORLD_ID_SUBSIDIES_RP_ID, the current-period claim action (see Storage Layout), and a signal per Signal Binding.

Every Uniqueness Proof natively carries an issuerSchemaId (mandatory public input in the circuit). The nullifier is credential-independent: for a given World ID and period it does not depend on issuerSchemaId. To claim budget for multiple credentials against the same period nullifier, the authenticator builds a single ProofRequest containing one RequestItem per credential the WorldID holds; all RequestItems MUST carry the same signal (the shared claimSubsidy signal described under Signal Binding) so every emitted proof has the same signal_hash public input. This yields one per-credential proof for each issuerSchemaId, all sharing the same nullifier and signal_hash (see World ID 4.0). The full bundle is submitted in a single atomic claimSubsidy call.

The sessionId associated with a nullifier is generated by the authenticator through an OPRF call. It is stored by the component on the first claimSubsidy call for a given nullifier and retained for Session Proof verification during the period.

Signal Binding

Each method’s proof(s) carry a signal_hash public input that commits the proof to its on-chain operation parameters. signal_hash is NOT a caller input: the contract recomputes the expected value from the call parameters and rejects any proof whose signal_hash public input does not match. For claimSubsidy, every proof in items MUST share the same signal_hash (the authenticator sets the same signal on every RequestItem, so one recomputed signalHash is checked against every proof).

Each method has a dedicated signal struct. signalHash is computed by ABI-encoding an instance of that struct and Keccak-hashing; >> 8 truncates to 248 bits so the value fits in the BN254 scalar field. ABI encoding of address[] is the standard Solidity dynamic-array layout (uint256 length followed by each element right-padded to 32 bytes); the struct definitions below pin the layout for off-chain reproducers.

struct ClaimSubsidySignal {
    bytes32   tag;              // == keccak256("WIP-1002/claimSubsidy")
    uint256   sessionId;
    address[] addAddresses;
    address   msgSender;
}

struct UpdateRecordSignal {
    bytes32   tag;              // == keccak256("WIP-1002/updateRecord")
    uint256   nullifier;
    uint64    nonce;
    uint64    issuerSchemaId;
    address[] newSet;
    address   msgSender;
}

For each method:

signalHash = uint256(keccak256(abi.encode(signal))) >> 8

where signal is the corresponding struct populated from the call parameters and msg.sender.

proofNonce, expiresAtMin, and credentialGenesisIssuedAtMin are NOT part of the public ABI — the authenticator commits fixed/derived values: PROOF_NONCE = 0, expiresAtMin = currentPeriod() * PERIOD_LENGTH, credentialGenesisIssuedAtMin = 0 (subsidy has no recency requirement on credential issuance).

Replay protection. For claimSubsidy auth[currentAction()][nullifier].sessionId != 0 blocks re-opening in the same period. For updateRecord the supplied nonce MUST equal the stored auth[...][nullifier].updateNonce. The contract bumps it on any successful state change. The claimed-credentials map independently blocks re-claim of an already-claimed issuerSchemaId (the credential sub-op no-ops if already claimed). signal_hash binds the full operation context (nullifier, nonce, issuerSchemaId, newSet, msgSender), preventing cross-call replay.

Per-Proof Behavior

For claimSubsidy (atomic over all items):

  1. Require items to be non-empty (EmptyItems) and items[*].issuerSchemaId to be distinct.
  2. Require nullifier to have no claim in the current period (i.e. auth[currentAction()][nullifier].sessionId == 0).
  3. Recompute signalHash from a populated ClaimSubsidySignal.
  4. Verify every items[i].proof as a Uniqueness Proof for the current period’s claim action against the recomputed signalHash; all proofs MUST share the same nullifier public input.
  5. Open the claim: write auth[currentAction()][nullifier] = AuthRecord({ sessionId: sessionId, updateNonce: 0 }), set the authorized-address set to addAddresses (which MAY be empty, leaving a dormant claim), cross-reference nullifiersOf[addr] for each entry of addAddresses, mark every items[i].issuerSchemaId as claimed, and set budget[currentAction()][nullifier] = sum(credentialBudget[items[i].issuerSchemaId]).

For updateRecord:

  1. Require nullifier to have a claim in the current period (RecordDoesNotExist if auth[currentAction()][nullifier].sessionId == 0); load sessionId and updateNonce from auth[currentAction()][nullifier].
  2. Require the caller-supplied nonce to equal the stored updateNonce (StaleUpdateNonce).
  3. Reject duplicates within newSet (DuplicateAuthorizedAddress).
  4. Recompute signalHash from a populated UpdateRecordSignal. Verify a Session Proof against sessionId with the recomputed signalHash, the caller-supplied issuerSchemaId as the proof’s credential public input, and the caller-supplied sessionAction as the per-proof random verifier public input.
  5. Credential sub-op. If issuerSchemaId is not already in the claimed-credentials map for this nullifier, mark it claimed and budget[currentAction()][nullifier] += credentialBudget[issuerSchemaId] (overflow-guarded). Otherwise no-op.
  6. Auth-set sub-op. If newSet differs from the record’s current authorized set, drop cross-references in nullifiersOf for every address in the old set, install newSet as the new authorized-address set, add cross-references in nullifiersOf for every address in newSet, and emit AuthorizedSetUpdated(nullifier, newSet). Otherwise no-op. Empty newSet revokes all authorized addresses.
  7. If both sub-ops were no-ops, revert NoOpUpdate. Otherwise bump auth[currentAction()][nullifier].updateNonce.

The Session Proof always binds to a credential the caller holds — its in-circuit cred_pk check is run against the supplied issuerSchemaId. To rotate the authorized set without claiming a new credential, the caller picks any issuerSchemaId they hold (typically one already claimed under the record); the credential sub-op then no-ops while the auth-set sub-op rotates and bumps the update nonce. A previously-claimed credential in the same period under the same nullifier MUST NOT be re-credited via updateRecord (the credential sub-op no-ops in that case).

Example: Authorization and Budget Claim Flow

Assume governance has configured the following credential budgets (Wei amounts shown in Gwei for readability; 1 Gwei = 10⁹ Wei):

CredentialissuerSchemaIdBudget
Proof-of-Human (Orb)0x0150,000 Gwei
Phone0x0210,000 Gwei
NFC0x0320,000 Gwei

A user holds a World ID with PoH and Phone credentials at the start of period 7. Later in the period they acquire an NFC credential. They want to authorize two addresses (0xAlice, 0xBob) initially and later revoke 0xBob.

Step 1: Initial claim — bundle PoH + Phone in a single ProofRequest and one on-chain call.

claimSubsidy(
    nullifier,
    sessionId,
    [0xAlice, 0xBob],
    [ ClaimItem(0x01, proof_PoH), ClaimItem(0x02, proof_Phone) ]
);

State after step 1: budget = 60,000 Gwei, authorized = [0xAlice, 0xBob], claimed = {0x01, 0x02}, update nonce = 0.

Step 2: Mid-period — acquire NFC credential and revoke 0xBob atomically.

updateRecord(nullifier, 0, 0x03, [0xAlice], sessionNullifier2, sessionAction2, proof);

The Session Proof binds to the NFC credential (issuerSchemaId = 0x03). The credential sub-op claims it (budget += 20,000 Gwei); the auth-set sub-op replaces [0xAlice, 0xBob] with [0xAlice]. State after step 2: budget = 80,000 Gwei, authorized = [0xAlice], claimed = {0x01, 0x02, 0x03}, update nonce = 1.

Alternative Step 2 — pure auth-set rotation, no new credential. To revoke 0xBob without claiming NFC, the caller picks an already-held issuerSchemaId (e.g. 0x01):

updateRecord(nullifier, 0, 0x01, [0xAlice], sessionNullifier', sessionAction', proof);

The credential sub-op no-ops (0x01 already claimed); the auth-set sub-op rotates the set; the update nonce bumps to 1.

Budget Refresh

A subsidy record under the current action and its authorized accounts list are active for exactly one period. At the end of the period, the next period’s action differs and all current-period state becomes structurally unreachable.

To prevent congestion from all accounts submitting refresh proofs at the start of a new period, the next period’s initial claimSubsidy MAY be submitted during the current period — the new record lands under the next period’s action and activates automatically at the period boundary.

Per-tx Debit Path

The per-transaction debit is performed by a revm/op-reth extension and is NOT exposed in the Solidity ABI. The extension MUST run as a post-execution step, after the quantitie of the subsidised fees has been determined.

The hook walks nullifiersOf[currentAction()][account] from index 0 and debits subsidy_amount Wei across one or more current-period records, in insertion order:

fn on_subsidised_tx(account, subsidy_amount):
    action     = keccak256("period_proof" || (block.timestamp / PERIOD_LENGTH)) >> 8
    let nullifiers = SLOAD(nullifiersOf[action][account])
    let remaining  = subsidy_amount
    for i in 0..nullifiers.len():
        let n = nullifiers[i]
        let b = SLOAD(budget[action][n])
        if b == 0: continue
        let charge = min(b, remaining)
        SSTORE(budget[action][n], b - charge)
        emit SubsidyDebit(action, n, charge)
        remaining -= charge
        if remaining == 0: break
  • Walk order. Insertion order, index 0 first. No rotation, no per-tx user override. Empty-budget claims are skipped. There is no explicit walk cap.
  • Validator verification. Every input to the debit — block.timestamp, sender, the pinned slots, subsidy_amount — is consensus state. Validators re-derive the writes via state root rather than trusting a builder.

Claim Transaction Subsidy

The claim-side mutations (claimSubsidy, updateRecord) themselves cost gas, and a user who has no native ETH on World Chain cannot pay for the very transaction that would mint their subsidy budget. The Subsidy Accounting component does not make those transactions self-subsidizing. The expected bootstrap path is a specialized bundler or relayer that accepts the user’s proof bundle, submits the claim transaction, and pays the gas on the user’s behalf.

Fully self-subsidized claim transactions, where the protocol applies a subsidy to the transaction that creates or updates the budget, are a sibling subsidy-enforcement concern rather than part of this accounting component. If adopted, that eligibility rule should be specified by the transaction-subsidy mechanism, not by WIP-1002.

Rationale

Hybrid Solidity + revm shared-storage fast path. Not having the enshrined accouting would require an expensive system transaction or similar. Fully enshrining the admin logic would add significant complexity, specifically for updates. The enshrined part of the logic is very simple and is unlikely to change in the future.

Action-namespaced storage. Adopting the AddressBook pattern keys every period-scoped map by the verifier action.

Nullifier-keyed budget records. Keying budget, authorization, and claimed-credentials state by the per-period nullifier collapses what would otherwise be two independent state objects (a budget record and a nullifier-used set) into a single map whose existence is itself the per-period replay guard. (nullifier, issuerSchemaId) in the claimed-credentials map prevents double-claiming a specific credential.

Atomic multi-item initial claim + Session Proof additions. When the WorldID’s credentials and authorized addresses are known at the start of a period, bundling every held credential into one multi-item ProofRequest and submitting them via a single atomic claimSubsidy collapses the budget setup into one transaction. Credentials acquired later in the period (or the rare case where the user deliberately spreads claims) are admitted via Session Proofs against the stored sessionId. The multi-item bundle is therefore an optimisation for the common up-front case.

Per-credential budgets. Different credentials represent different levels of verification. Proof-of-Human from an Orb carries more weight than a phone credential. Allowing governance to configure budget amounts per issuerSchemaId enables fine-grained subsidy policy.

Signal binding against frontrunning. Each method binds its non-ZK-committed call parameters into signalHash. The contract recomputes the expected signalHash rather than accepting it from the caller. Mirrors the WIP-1001 signal-binding pattern.

Periodic refresh with early submission. Fixed periods with expiring subsidies provide a clean budget lifecycle. Allowing early refresh prevents a thundering herd at period boundaries.

Backwards Compatibility

The setup is compatible with the current wallet setup. Users can now bypass the bundler and directly submit a transaction with their EOA without the need to pay fees.

Security Considerations

Anonymity. Claim nullifiers are unlinkable across periods by construction. A World ID that authorizes different addresses each period cannot be tracked. However, if the same addresses are reused across periods, on-chain observers can infer continuity, i.e. the address itself becomes the deanonymization vector.

Refresh congestion. Even with early refresh support, a significant fraction of users may refresh near period boundaries. Sub-period slots with proportional sub-budgets are a possible mitigation, deferred as future scope.

Storage-layout commitment. The protocol-level commitment is the pinned subset of the contract’s storage layout — budget, nullifiersOf, PERIOD_LENGTH, and the action derivation. Solidity upgrades MUST preserve the pinned subset; reshapes require a coordinated protocol release.

Revm-extension trust boundary. The per-tx debit is performed by protocol code (the revm extension), not by an EVM call. Validators independently re-execute the debit from consensus inputs (block.timestamp, sender, the pinned slots, subsidy_amount) and accept the resulting state root only if the debit matches. Hence, the debit path introduces no additional trust assumptions about the sequencer.

Replay protection. Per-period replay is enforced two mechanisms. The existence of a record under the current action blocks re-opening under the same nullifier, and the per-record claimed-credentials map blocks double-claiming any specific issuerSchemaId. For mid-period mutations, the monotonic per-record updateNonce additionally prevents in-flight Session Proof replay.

Signal binding. signalHash MUST commit to every non-ZK-committed call parameter (see Signal Binding). The contract recomputes signalHash from the call parameters rather than trusting any caller-supplied value, so a frontrunner cannot replay a witnessed proof with a substituted msg.sender or authorized-address list.

Authenticator-generated sessionId. The sessionId stored for a nullifier is derived by the authenticator from an OPRF output keyed by (rpId, leafIndex, oprf_seed). The component cannot distinguish on-chain between an authenticator-issued sessionId and an attacker-chosen value, so Session Proof verification is the enforcement layer. A Session Proof against an unissued sessionId will simply fail to verify.

Governance capture of budget parameters. setCredentialBudget is governance-controlled. Misuse (e.g. setting an extravagant budget for a cheap credential) could drain subsidy pools.

Future Upgrades

State Growth Over Time

Per-period storage is not reclaimed in the current design. Action-keying makes stale slots structurally unreachable from current-period code paths, but they remain in the chain state, so the contract’s storage footprint grows monotonically with the number of periods and the active claimer population. At WorldID target scale this accumulates meaningfully over time. A future revision SHOULD specify a state-management mechanism (e.g. a state-expiry primitive enabled by a later protocol upgrade), subject to the anonymity constraint described under Security Considerations. Note, that state growth caused by tracking nullifiers is a more general problem, due to the heavy use of World ID proofs on-chain.

Another potential solution to this problem could be to not use a protocol that requires periodic refresh, but use long-lived nullifiers instead. To allow for anonymous rotation, a Merkle-tree-based scheme could be used, where users prove that their previous budget nullifier has been deleted, when creating a new one.

WIP-1003: World ID Transaction Subsidies

Abstract

This WIP replaces PBH with a builder-enforced transaction subsidy system for World ID verified addresses.

The protocol fee market is effectively disabled: the in-protocol gas target and gas limit are set high enough that the protocol base fee never rises above the configured minimum base fee, and once the chain is running the minBaseFee-aware payload path that minimum base fee is set to 0. As a result, baseFeePerGas is always 0.

Fee pricing moves out of protocol. Builders maintain a virtual fee market with a virtualBaseFee, virtualMinBaseFee, virtualGasTarget, and virtualGasLimit. For ordinary transactions, the virtual base fee is enforced through maxPriorityFeePerGas, so the chain behaves like a simulated EIP-1559 market even though the protocol base fee is 0. For eligible World ID transactions, the builder may discount or fully waive that virtual fee floor and charge the waived amount against the account’s subsidy budget for the current WIP-1002 epoch.

Motivation

World Chain wants two properties at the same time:

  • a real fee market for non-verified traffic
  • fully subsidized transactions for verified humans up to a bounded limit

PBH solves priority and fairness, but it does not provide a unified fee model for free verified transactions and market-priced non-verified transactions. This WIP makes the subsidy model native to normal transaction inclusion. Verified transactions can be literally free up to a per-epoch wei limit, while the rest of the chain still sees a fee floor and blockspace competition.

Proposal

1. Protocol Fee Market

  • The protocol gas target and protocol gas limit are set arbitrarily high, so the protocol base fee does not rise above the protocol minimum base fee.
  • The chain must run the fork and client path that supports configurable minBaseFee in payload attributes and block extra data.
  • Once that path is active, SystemConfig.setMinBaseFee(0) disables the protocol minimum base fee floor.
  • After that, the protocol baseFeePerGas is always 0.
  • This WIP does not introduce a new transaction type.

2. Virtual Fee Market

Builders maintain a virtual fee market with the following values:

  • virtualBaseFee
  • virtualMinBaseFee
  • virtualGasTarget
  • virtualGasLimit

These values are out of protocol. They control transaction admission and ordering in the World Chain client, but they do not affect block validity at the protocol level.

virtualGasLimit is the builder-local effective gas limit used for transaction selection and verified blockspace segmentation. virtualGasTarget is the target used by the virtual fee controller when deriving the next virtualBaseFee.

This WIP assumes the virtual fee parameters are made deterministic by a system contract, referred to here as VirtualFeeOracle.

  • A builder-owned transaction at the end of block N writes the virtual fee parameters that apply to block N + 1.
  • All builders validating or constructing block N + 1 use the parameters committed in block N.
  • The virtual fee controller is intended to be EIP-1559-like, but the exact control law is out of scope for this WIP so long as the per-block values are deterministic.

3. Transaction Pricing

Because the protocol base fee is 0, the virtual base fee is represented through the priority fee.

For an ordinary non-subsidized transaction:

  • maxPriorityFeePerGas must be at least virtualBaseFee
  • any amount above virtualBaseFee is an optional tip

For a subsidy-eligible transaction, the required priority fee may be reduced by the account’s remaining subsidy.

4. Transaction Validation

The World Chain transaction validator is extended to enforce the virtual fee floor for in-scope transactions. WIP-1002 contains the specification for how to determine the subsidies available to an address.

  • If a transaction is not subsidy-eligible, it is invalid unless it pays at least the virtual fee floor through maxPriorityFeePerGas.
  • If a transaction is subsidy-eligible, the validator discounts the required priority fee using the charged account’s remaining subsidy.
  • For admission, a conservative rule may use the transaction gas limit G and remaining subsidy S to derive: discountPerGas = min(virtualBaseFee, floor(S / G)) requiredPriorityFeePerGas = virtualBaseFee - discountPerGas
  • This allows full subsidy when S >= G * virtualBaseFee and partial subsidy otherwise.
  • Final settlement uses gasUsed, not gasLimit.
  • PBH is removed by this WIP. Bundler transactions remain out of scope.

5. Subsidy Eligibility

A transaction is subsidy-eligible if either of the following is true:

  • the sender is verified in the WIP-1002 defined contract for the current epoch
  • the transaction is a valid WIP-1002 registration transaction that verifies a specific address for the current epoch or next epoch

For WIP-1002 registration transactions:

  • the subsidy is attributed to the address being verified, not necessarily msg.sender
  • the registration transaction counts toward that address’s subsidy budget for the current WIP-1002 epoch
  • only canonical WIP-1002 registration calls are eligible

Exact WIP-1002 ABI and storage changes are out of scope here. A separate spec can define them in detail.

6. Subsidy Accounting

Each verified address has a subsidy budget denominated in wei for each WIP-1002 epoch.

  • the subsidy budget uses the same epoch boundaries as the WIP-1002
  • the full virtual base fee liability for an included transaction is gasUsed * virtualBaseFee
  • the actual subsidy applied is min(remainingSubsidy, gasUsed * virtualBaseFee)
  • the unsubsidized remainder is paid by the sender through priority fee
  • any fee paid above the unsubsidized remainder is an optional tip and is not subsidized
  • partial subsidy is allowed

7. Block Construction

The builder preferentially uses a fraction of each block, and optionally each flashblock, for verified transactions. This replaces PBH block segmentation.

  • verified transactions get preferential access to the verified portion even if they have no subsidy remaining
  • non-verified transactions may use the remainder of that portion near the end of block construction if verified demand does not fill it
  • the rest of the block remains open to ordinary transactions
  • transaction selection is bounded by virtualGasLimit, not the protocol gas limit

When constructing a block, the builder:

  1. determines the charged account for each subsidy-eligible transaction
  2. executes the transaction
  3. computes virtualBaseFeeLiability = gasUsed * virtualBaseFee
  4. computes actualSubsidy = min(remainingSubsidy, virtualBaseFeeLiability)
  5. checks that the sender-paid priority fee covers virtualBaseFeeLiability - actualSubsidy
  6. updates the charged account by actualSubsidy

Within the verified segment, transactions may still compete via optional tip.

8. Address-Book Updates

The WIP-1002 is extended to track subsidy usage per address per epoch.

At minimum it must support:

  • checking whether an address is verified for the current epoch
  • tracking total subsidized wei used by an address for a given epoch
  • exposing enough state for builders to determine remaining subsidy

At the end of each block, the builder sends a builder-owned transaction that updates the WIP-1002 with the subsidy deltas for that block.

9. RPC Behavior

Current wallets typically estimate fees using some combination of:

  • eth_estimateGas
  • eth_maxPriorityFeePerGas
  • eth_gasPrice
  • eth_feeHistory
  • the latest block’s baseFeePerGas

The World Chain client overrides fee suggestion RPC behavior so wallets quote the virtual fee market instead of the protocol base fee.

This includes:

  • eth_maxPriorityFeePerGas
  • eth_gasPrice
  • eth_feeHistory, where needed for wallet compatibility

eth_estimateGas continues to estimate gas usage, not fees.

For ordinary transactions, overriding these fee RPCs is sufficient for compatibility with wallets that already rely on them.

Wallets that derive fees directly from the block header baseFeePerGas may underquote, because the protocol header value remains 0. Wallets integrating World Chain should therefore prefer the overridden fee RPCs rather than raw block-header base fee.

World Chain clients should also expose account-aware subsidy information. A client can read the WIP-1002 state for an account and return at least:

  • whether the account is currently verified
  • its remaining subsidy for the current epoch
  • an account-specific suggested maxPriorityFeePerGas given an estimated gas limit

Subsidy-aware wallets may then send a lower priority fee, including 0, when the sender’s remaining subsidy covers the virtual base fee liability.

Rationale

The protocol base fee is pinned to 0 so verified transactions can be literally free. The fee market is moved into builder policy so World Chain can still price ordinary traffic while waiving fees for verified humans up to a bounded limit.

The virtual fee parameters are committed one block ahead so all builders can validate the same fee floor for the next block. Using the WIP-1002 epoch for subsidy accounting keeps verification state and subsidy state aligned.

Charging registration subsidies to the address being verified avoids tying subsidy attribution to the relayer or caller, which matches the intended beneficiary of the verification.

Backwards Compatibility

This WIP intentionally changes World Chain transaction admission, fee quoting, and block segmentation behavior.

  • PBH is replaced by subsidy-aware verified blockspace
  • the protocol block header still exposes baseFeePerGas = 0
  • ordinary wallets interacting through standard fee RPCs continue to see a fee quote, but that quote is virtual rather than protocol-derived

Security Considerations

Because the fee market is out of protocol, all builders must derive the same virtual fee parameters for the same block. If they do not, transaction admission diverges. The fallback builder must therefore run the same World Chain client behavior.

Verified free transactions create a DoS surface unless both of the following are enforced:

  • the preferential verified portion remains capped
  • each verified address remains bounded by a per-epoch wei subsidy limit

Address-book registration transactions need careful attribution and selector filtering so callers cannot redirect subsidy eligibility to arbitrary calls. Only canonical registration calls that successfully verify an address should receive subsidy treatment.

The builder-owned end-of-block accounting transaction is trusted operational infrastructure. If it is omitted or delayed, subsidy accounting becomes stale.

WIP-1004: EdDSA Verification Precompile

Abstract

This WIP introduces a precompiled contract that verifies an Ed25519 signature over an arbitrary-length message, using the cofactored verification equation standardized by ZIP-215.

The precompile takes a concatenated (publicKey, signature, message) input and returns a 32-byte big-endian 1 on a valid signature, or 0 on any failure mode (malformed input, non-canonical encoding, or invalid signature). It never reverts on signature failure; it reverts only on out-of-gas.

Motivation

Ed25519 is the dominant non-secp256k1 signature scheme in the broader ecosystem: it is the default in XMTP, NaCl/libsodium, OpenSSH, age, TLS 1.3, Solana, Cosmos / Tendermint consensus, Stellar, NEAR, Aptos, and Sui. Native verification on World Chain unlocks several first-class use cases:

Off-chain attestation services (orb signatures, OPRF transcripts, oracle quorums) can sign with Ed25519 — the format most existing key-management hardware already produces — without forcing on-chain consumers onto secp256k1.

Specification

The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “NOT RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119 and RFC 8174.

Constants

NameTypeValueMeaning
EDDSA_PRECOMPILE_ADDRESSaddress0x0000000000000000000000000000000000000100Precompile entry-point address.
EDDSA_BASE_GASuint642000Flat per-call cost.
EDDSA_WORD_GASuint6412Gas charged per 32-byte word of message.
ED25519_PUBKEY_BYTESuint3232Length of an Ed25519 public key.
ED25519_SIGNATURE_BYTESuint3264Length of an Ed25519 signature.
MAX_MESSAGE_BYTESuint322^16Maximum message length the precompile will hash.

EDDSA_PRECOMPILE_ADDRESS is the activation value for the EDDSA_PRECOMPILE parameter declared by WIP-1001.

Scheme

The precompile implements pure Ed25519 verification as specified in RFC 8032 §5.1.7, with the following pinned choices:

  • Curve: edwards25519 (Ed25519). Ed25519ph and Ed448 are out of scope; a separate WIP MAY introduce additional variants at distinct addresses.
  • Context: empty. Ed25519ctx is not supported.
  • Verification equation: cofactored, per ZIP-215. Verification accepts the signature iff [8]SB = [8]R + [8]([hash(R, A, M) mod L])A, where S, R, A, M, and L are as defined in RFC 8032 §5.1.
  • Public key validation: A MUST decode as a valid edwards25519 point per RFC 8032 §5.1.3. Non-canonical y-coordinate encodings (y >= p) MUST be rejected. Small-order public keys are NOT rejected — application-level policy is responsible for additional restrictions.
  • Signature R validation: R MUST decode as a valid edwards25519 point. Non-canonical y-coordinate encodings of R MUST be rejected.
  • Signature S validation: S is interpreted as little-endian and MUST satisfy 0 <= S < L, where L = 2^252 + 27742317777372353535851937790883648493. Non-canonical S MUST be rejected.

Input Encoding

The precompile input is the concatenation:

input = publicKey (32 bytes) || signature (64 bytes) || message (variable)
  • publicKey is the 32-byte encoded edwards25519 point A, per RFC 8032 §5.1.2.
  • signature is R (32 bytes) || S (32 bytes), per RFC 8032 §5.1.6.
  • message is the byte string M passed verbatim to the hash function.

The total input length MUST satisfy 96 <= len(input) <= 96 + MAX_MESSAGE_BYTES. Any other length is treated as a failure (the precompile consumes gas as specified in Gas Cost and returns the zero word).

Output Encoding

The precompile output is exactly 32 bytes:

  • 0x0000…0001 (big-endian) if the signature is valid under the rules above.
  • 0x0000…0000 (big-endian) otherwise, including for all malformed input, non-canonical encoding, point-decoding failure, scalar-range failure, and equation failure.

The precompile MUST NOT revert on a failed verification. It MUST revert only when the caller supplies insufficient gas, in which case standard out-of-gas semantics apply.

Gas Cost

For an input of length N bytes:

gasCost = EDDSA_BASE_GAS + EDDSA_WORD_GAS * ceil(max(N, 96) - 96 / 32)
        = EDDSA_BASE_GAS + EDDSA_WORD_GAS * ceil(messageLength / 32)

messageLength = max(N, 96) - 96. The per-word charge covers SHA-512 absorption of R || A || M and is intentionally aligned with the per-word cost of the sha256 precompile (EIP-2).

Gas is charged in full before execution. Inputs shorter than 96 bytes are charged EDDSA_BASE_GAS and return the zero word.

Rationale

Ed25519 only. Ed25519 dominates the non-secp256k1 deployed base by orders of magnitude. Supporting Ed448 doubles the surface area for marginal real-world demand; it can be added in a follow-on WIP at a separate address if needed. Pre-hashed variants (Ed25519ph, Ed25519ctx) are likewise excluded — callers that need them can hash off-chain and use the pure verifier, or a future WIP can allocate a distinct address.

ZIP-215 / cofactored verification. RFC 8032 leaves verification underspecified on edge cases involving small-order points and non-canonical encodings, and historical libraries have disagreed. ZIP-215 pins these choices: cofactored equation, canonical encoding required, small-order keys accepted. This is the modern consensus-rule choice (Zcash, Tendermint/CometBFT) and admits straightforward batch verification by re-deriving the cofactored form from a random linear combination. Cofactorless RFC 8032 verification would reject a strict subset of signatures that cofactored accepts; pinning the more permissive rule means a signature accepted by the precompile is also accepted by any conformant ZIP-215 verifier, which is the property bridges and light clients depend on.

Failure returns zero, never reverts. This mirrors ecrecover and EIP-7951 (secp256r1 verify): callers branch on the boolean result. Reverting on bad signatures would force every caller to wrap the call in a try/catch and would make the precompile incompatible with WIP-1001 restricted validation frames, which forbid revert-based control flow inside STATICCALLs.

Concatenated, variable-length input. A length prefix on the message would add a decoding branch without reducing ambiguity — the EVM already provides the total calldata length. The 32 + 64 prefix layout matches the wire layout produced by libsodium’s crypto_sign_open and Solana / Cosmos transaction signatures, so callers can forward bytes verbatim.

MAX_MESSAGE_BYTES bound. Caps worst-case SHA-512 work per call to a predictable upper bound, which simplifies gas-cost validation and matches the bounded-input style of other consensus-critical precompiles.

Gas cost. The base cost is calibrated against benchmark data for ed25519-dalek and curve25519-dalek (single-signature verify on modern x86_64 completes in roughly 50–100 µs), giving Ed25519 verification a per-call cost comparable to ecrecover (3000 gas) and modestly cheaper than RIP-7212 (3450 gas). The per-word charge matches sha256 because the dominant variable-cost component is the SHA-512 over R || A || M. Concrete values SHOULD be refined against client benchmarks before this WIP advances to Review.

Small-order public keys are not rejected. Rejecting them at the precompile boundary would deviate from ZIP-215 and break compatibility with signatures produced by certain hardware modules. Applications that care (e.g., signers used as identity commitments) MUST reject small-order keys themselves.

Address 0x0100. Reserves a precompile slot above the Ethereum-reserved 0x010x0A range and aligns with RIP-7212’s convention of placing non-mainnet precompiles starting at 0x0100. The concrete address MAY be revised by maintainers before activation; the activation value flows through EDDSA_PRECOMPILE in WIP-1001.

Backwards Compatibility

This WIP adds a precompile at a previously unused address. No existing transaction type, opcode, gas cost, or precompile behavior is modified. Contracts that previously called EDDSA_PRECOMPILE_ADDRESS would have received an empty return; after activation they receive a 32-byte response. Such pre-activation calls have no on-chain precedent on World Chain.

Test Cases

Conforming implementations MUST pass:

  • RFC 8032 §7.1 vectors: Test 1–4 (empty, 1-byte, 2-byte, and 1023-byte messages); all MUST return 1.
  • ZIP-215 §“Tests” vectors: every signature listed as ZIP-215-valid MUST return 1; every signature listed as ZIP-215-invalid MUST return 0. This includes the small-order public key cases that diverge from strict RFC 8032 cofactorless verification.
  • Non-canonical encodings: A with y >= p, R with y >= p, and S with S >= L MUST each return 0.
  • Mutated signature: every RFC 8032 §7.1 vector with the last byte of S incremented by 1 MUST return 0.
  • Mutated message: every RFC 8032 §7.1 vector with the message extended by one zero byte MUST return 0.
  • Truncated input: inputs of length 0, 31, 32, 95, 96 - 1 MUST return 0 and charge exactly EDDSA_BASE_GAS.
  • Maximum-length message: input of length 96 + MAX_MESSAGE_BYTES MUST be accepted (modulo signature validity) and charge EDDSA_BASE_GAS + EDDSA_WORD_GAS * (MAX_MESSAGE_BYTES / 32).
  • Oversize input: input of length 96 + MAX_MESSAGE_BYTES + 1 MUST return 0.
  • Identity / zero inputs: 32-byte zero public key, 64-byte zero signature MUST return 0.
  • Gas-charge boundary: a caller supplying exactly gasCost - 1 gas MUST observe an out-of-gas revert; a caller supplying exactly gasCost MUST observe a successful return.

Golden vectors with hex-encoded (publicKey, signature, message, expected) tuples covering each row above MUST be published in assets/wip-1004/ before this WIP advances to Review.

Reference Implementation

The precompile is implementable in a few lines on top of any conformant Ed25519 library that exposes a ZIP-215 verification mode. For Rust clients (reth, OP-reth derivatives) the recommended primitive is ed25519-dalek v2 with the zebra / cofactored verification feature, or the ed25519-zebra crate directly:

#![allow(unused)]
fn main() {
fn run_eddsa_verify(input: &[u8], gas_limit: u64) -> Result<(Vec<u8>, u64), PrecompileError> {
    let msg_len = input.len().saturating_sub(96);
    let gas_used = EDDSA_BASE_GAS + EDDSA_WORD_GAS * msg_len.div_ceil(32) as u64;
    if gas_used > gas_limit {
        return Err(PrecompileError::OutOfGas);
    }

    let mut output = [0u8; 32]; // default: invalid

    if input.len() < 96 || msg_len > MAX_MESSAGE_BYTES as usize {
        return Ok((output.to_vec(), gas_used));
    }

    let pk_bytes: [u8; 32] = input[0..32].try_into().unwrap();
    let sig_bytes: [u8; 64] = input[32..96].try_into().unwrap();
    let message = &input[96..];

    let ok = ed25519_zebra::VerificationKey::try_from(pk_bytes)
        .and_then(|vk| {
            let sig = ed25519_zebra::Signature::from(sig_bytes);
            vk.verify(&sig, message)
        })
        .is_ok();

    if ok {
        output[31] = 1;
    }
    Ok((output.to_vec(), gas_used))
}
}

ed25519-zebra implements ZIP-215 directly. Other clients SHOULD select an Ed25519 implementation that explicitly documents ZIP-215 compatibility; libraries that implement only strict RFC 8032 cofactorless verification are non-conforming.

Security Considerations

Verification-rule monoculture. All World Chain clients MUST implement identical edge-case behavior. A client that accepts a signature another client rejects causes a consensus split. Selecting ZIP-215 — a written, testable, and widely-deployed specification — is the primary mitigation. Implementers MUST pass the ZIP-215 test corpus, not just RFC 8032 §7.1.

Signature malleability. Ed25519 is non-malleable only when S is canonical (S < L). The precompile enforces this; callers MUST NOT assume malleability resistance from any other property of the encoding. In particular, the 32-byte encoding of R is canonicalized but the underlying point is not: ZIP-215 accepts certain low-order R values that strict RFC 8032 rejects.

Small-order public keys. Per ZIP-215, the precompile accepts public keys of small order. A small-order A makes the discrete log of A trivial, so any party can forge signatures under such a key. Identity systems and authentication contracts MUST reject small-order keys at registration time, not at verification time. WIP-1001 session verifiers that bind a public key into account state SHOULD perform this check during install.

Batch verification not exposed. This precompile verifies one signature per call. Consumers that need batch verification (e.g., light clients ingesting a consensus quorum) MUST batch at the application layer or rely on a future batched-verify WIP. Single-signature verification under ZIP-215 is, however, batch-equivalent: any signature accepted singly is accepted in a batch.

Side channels. Verification handles only public data; constant-time implementation is not required for correctness. Implementations SHOULD still use constant-time field arithmetic to remain consistent with their underlying curve library and to avoid surprising callers who later use the same library on secret data.

Restricted validation frames. When called from a WIP-1001 session verifier inside a restricted frame, the precompile’s STATICCALL MUST be the only externally-visible effect; it has no storage, no logs, and no nested calls, so it satisfies the WIP-1001 tracer rules by construction. The precompile MUST NOT be modified to read block context, account state, or any external mutable input.

Gas-cost calibration. Underpriced verification is a DoS vector. The recommended values in Constants MUST be re-benchmarked on representative consensus-client hardware before activation, and adjusted upward if Ed25519 verification proves slower than the priced cost on the slowest supported client.

WIP-1005: Proof System Upgrade

All numbers in the document for time windows and ETH values are not calculated. They are just put as templates with more/less appropriate order of magnitude.

Abstract

World Chain currently runs a permissioned Cannon-based fault proof system (game-type=1) with op-proposer posting output roots every 20 minutes. This WIP evaluates different proof system options to upgrade the fault proof mechanism using ZKP or TEE technologies that are compatible with OP Stack dispute games.

Motivation

The current Cannon-based system has several limitations:

  • 7-day challenge window required to safely accommodate the interactive bisection game
  • Large bond requirements (hundreds of ETH at mainnet scale) to deter adversarial challenges
  • Multi-round interactive bisection game: ~30–40 on-chain transactions per dispute
  • Game-theoretic security only — requires an honest party to be watching and able to respond within each bisection round

The goal is to upgrade to a proof system that:

  • Reduces the challenge window to ~1 day
  • Reduces bond requirements to 5–15 ETH
  • Eliminates the multi-round interactive bisection game

Specification

ZK Options (zkVMs — compatible with OP Stack dispute games)


Option 1: OP Succinct Lite (SP1 zkVM)

OP Succinct Lite replaces the interactive bisection game with a single ZK proof generated only when a dispute occurs. The chain runs optimistically by default — no proving cost on the happy path.

Proof pipeline
L2 blocks (proposal range)
  │
  ├─ split into N sub-ranges (RANGE_SPLIT_COUNT)
  │
  ├─ parallel range proofs via Succinct Prover Network
  │    world-chain-range-ethereum ELF runs in SP1 zkVM (RISC-V)
  │    produces: compressed STARK proof per range
  │
  └─ aggregation proof
       world-chain-aggregation ELF verifies all range proofs
       produces: single Groth16/Plonk SNARK
         │
         └─ game.prove(agg_proof.bytes()) → L1 tx
              SP1_VERIFIER.verifyProof(AGGREGATION_VKEY, publicValues, proofBytes)
Program identity (keys)
  • aggregation_vkey — SP1 verifying key, deterministic fingerprint of world-chain-aggregation ELF
  • range_vkey_commitment — SP1 verifying key commitment for world-chain-range-ethereum ELF
  • rollup_config_hash — SHA-256 of Kona RollupConfig + World-specific tropo_time + strato_time

All three MUST match between the deployed contract and the running proposer, or games are classified as foreign and will not be proven.

World-specific changes vs upstream OP Succinct
  • Replace ELF imports with world-chain-proof-succinct-elfs
  • Replace hash_rollup_config(...) with World hash helper (appends tropo_time and strato_time)
  • Replace ETH witness generation with World witness data carrying the hardfork schedule
Dispute flow
Proposer posts output root (no proof required)
  │
  │  challenge window (~1 day)
  │
  ├─ No challenge → DEFENDER_WINS  (no proof ever needed)
  │
  └─ Challenger calls challenge() + bond
       │
       Proposer requests ZK proof from Succinct Network (minutes–hours)
       Proposer calls prove(proofBytes)
         │
         ├─ proof valid → DEFENDER_WINS
         └─ proof missing/invalid by deadline → CHALLENGER_WINS
Properties
PropertyValue
Trust rootCryptographic (SP1 zkVM math)
Challenge window~1 day
Bond size5–15 ETH
Proof generatedOnly on dispute
On-chain verification gas~300k gas (Groth16 pairing check)
AWS dependencyNone
Hardware dependencyNone
Prover network dependencySuccinct Prover Network

Option 2: RISC Zero zkVM (Boundless / op-risc0)

RISC Zero’s Zeth proves the OP Stack state transition inside the RISC Zero RISC-V zkVM. Like SP1, it runs op-program (Kona) as a RISC-V ELF and produces a STARK proof compressed to a Groth16 SNARK for on-chain verification. RISC Zero offers both a managed proving network (Bonsai / Boundless) and self-hosted provers.

Key differences vs SP1
  • Prover flexibility: RISC Zero supports self-hosted proving clusters, reducing dependency on an external prover network.
  • Continuations: RISC Zero uses a segment/continuation model for long-running programs; SP1 uses a range-split model. Both handle the full EVM transition.
  • Circuit audit maturity: SP1 has a production OP Stack integration (op-succinct); RISC Zero’s op-risc0 is at a similar stage but less field-tested on OP Stack.
  • Proof system: RISC Zero uses FRI-based STARKs (Plonky2/Plonky3) internally; final on-chain proof is Groth16 or Plonk (~300k gas).
  • Verifying key: imageId (SHA-256 of the guest ELF) plays the same role as SP1’s aggregation_vkey.
Properties
PropertyValue
Trust rootCryptographic (RISC Zero zkVM math)
Challenge window~1 day
Bond size5–15 ETH
Proof generatedOnly on dispute
On-chain verification gas~300k gas (Groth16)
AWS dependencyNone
Hardware dependencyNone (GPU-accelerated prover recommended)
Prover network dependencyBonsai/Boundless (optional; self-host possible)

Option 3: Jolt zkVM (a16z)

Jolt is a RISC-V zkVM developed by a16z crypto research, based on sumcheck-based lookup arguments (Lasso + Jolt). It is currently at research/early-prototype stage and does not yet have a production OP Stack integration, but the RISC-V architecture is compatible in principle.

Key characteristics
  • Novel proof system: Uses a sumcheck + multilinear polynomial approach rather than FRI-based STARKs. Potentially faster proving for certain workloads.
  • No production OP Stack integration yet: No published op-jolt equivalent exists as of this writing.
  • On-chain verification: Hyperplonk/HyperKZG-based verifier; on-chain gas costs not yet characterised for OP Stack use.
  • Maturity: Significantly less mature than SP1 or RISC Zero. Not recommended for near-term deployment.
Properties
PropertyValue
Trust rootCryptographic (Jolt proof system)
Challenge window~1 day
Proof generatedOnly on dispute
On-chain verification gasTBD (HyperKZG verifier)
OP Stack integration❌ Not available
Maturity🔬 Research / early prototype

TEE Options


Option 4: AWS Nitro Enclaves

The L2 state transition function runs inside an AWS Nitro Enclave — a hypervisor-isolated VM partitioned from the parent EC2 instance. The Nitro Hypervisor signs an attestation document proving that a specific program (identified by PCR measurements) produced a specific output.

Attestation pipeline
L2 blocks + previous state root
  │
  └─ load into Nitro Enclave (isolated VM, no external network, no storage)
       │
       state transition function runs natively at full CPU speed
       computes: new_state_root
         │
         └─ request attestation from Nitro Hypervisor
              {
                PCR0: SHA384(enclave image),    ← program fingerprint
                PCR1: SHA384(kernel),
                PCR2: SHA384(application),
                user_data: new_state_root,       ← the output
              }
              signed with P-384 ECDSA by AWS Nitro PKI
                │
                └─ post attestation doc on-chain
                     L1 contract verifies:
                       - AWS PKI cert chain
                       - PCR0 matches expected image hash
                       - output_root extracted from user_data
Properties
PropertyValue
Trust rootAWS (owns attestation key + infrastructure)
Challenge windowCould be ~1 hour
Bond sizeLow
On-chain verification gas~21k gas (ECDSA signature check)
AWS dependencyHard dependency on AWS
Hardware dependencyAWS Nitro Security Chip
Risks
  • Single trust point: AWS owns both the attestation key and the infrastructure. A compromised or malicious AWS invalidates all proofs.
  • Not Intel SGX: AWS Nitro uses hypervisor-based isolation, not CPU memory encryption. AWS disables Intel SGX at the BIOS level on all EC2 instances.
  • Certificate expiry: AWS Nitro attestation certificates expire after 3 hours, requiring active rotation.

Option 5: Intel SGX TEE

Intel SGX (Software Guard Extensions) is a CPU-level TEE. The state transition function runs inside an SGX enclave — a private memory region that is hardware-encrypted and isolated from the OS, hypervisor, and root users. Intel’s Provisioning Certification Service (PCS) signs attestation quotes.

Note: Intel SGX is not available on AWS. It requires Azure DC-series instances, IBM Cloud bare metal, or physical Intel Skylake/Ice Lake hardware with SGX enabled in BIOS.

Attestation pipeline
L2 blocks + previous state root
  │
  └─ load into SGX enclave (CPU-encrypted memory region)
       │
       state transition function runs natively
       computes: new_state_root
         │
         └─ generate SGX quote (DCAP attestation)
              {
                MRENCLAVE: SHA256(enclave code + data),  ← program fingerprint
                MRSIGNER:  SHA256(enclave signing key),
                report_data: new_state_root,              ← the output
              }
              signed by Intel Provisioning Certification Key (PCK)
                │
                └─ post quote on-chain
                     Automata DCAP contracts verify on-chain:
                       - PCK cert chain back to Intel root CA
                       - MRENCLAVE matches expected value
                       - report_data extracted as new state root
Properties
PropertyValue
Trust rootIntel (attestation key) + operator (hardware)
Challenge windowCould be ~1 hour
Bond sizeLow
On-chain verification gas~30k–300k gas (DCAP; SNARK-compressed via Automata)
AWS dependencyNone (but requires Azure/IBM/bare metal)
Hardware dependencyIntel CPU with SGX enabled
Risks
  • Intel trust: Intel owns the PCK signing key. Side-channel attacks (Spectre, Plundervolt, SGAxe, Foreshadow) have repeatedly compromised SGX enclaves.
  • Not on AWS: Requires migrating proposer/sequencer infra to Azure or bare metal.
  • EPC memory limits: SGX1 limits enclave memory to ~256 MB; full L2 STF may exceed this. SGX2 (EDMM) and multi-enclave sharding partially mitigate this.
  • Maintenance burden: Regular TCB recovery updates required as Intel patches microcode.

Option 6: AMD SEV-SNP TEE

AMD Secure Encrypted Virtualization — Secure Nested Paging (SEV-SNP) is a CPU-level TEE that isolates full VMs (rather than individual enclaves like SGX). Memory pages are encrypted with a VM-unique key managed by the AMD Secure Processor. SEV-SNP adds memory integrity protection and a hardware-attested measurement of the entire VM image.

Attestation pipeline
L2 blocks + previous state root
  │
  └─ load into SEV-SNP VM (full VM, hardware memory-encrypted)
       │
       state transition function runs natively at full VM speed
       computes: new_state_root
         │
         └─ generate SNP attestation report
              {
                MEASUREMENT: SHA384(VM launch digest),  ← program fingerprint
                HOST_DATA: new_state_root,               ← the output
                VMPL: privilege level of requesting vCPU
              }
              signed by AMD VCEK (Versioned Chip Endorsement Key)
              cert chain: VCEK → AMD ASK → AMD ARK (root CA)
                │
                └─ post attestation report on-chain
                     L1 contract verifies:
                       - AMD cert chain
                       - MEASUREMENT matches expected VM digest
                       - HOST_DATA extracted as new state root
Properties
PropertyValue
Trust rootAMD (VCEK attestation key)
Challenge windowCould be ~1 hour
Bond sizeLow
On-chain verification gas~30k–200k gas (ECDSA + cert chain; SNARK-compression possible)
AWS dependencyNone
Hardware dependencyAMD EPYC Milan/Genoa CPU (Azure Mnn/NCC, Google Cloud C2D, Equinix bare metal)
Cloud availabilityAzure (NCC v5 with SEV-SNP), Google Cloud, Equinix
Risks
  • AMD trust: AMD owns the VCEK signing key. Unlike SGX, AMD SEV-SNP has fewer published side-channel exploits, but the attack surface of a full VM TEE is broader than a CPU enclave.
  • VCEK cert rotation: VCEK certificates are versioned per TCB level and must be refreshed after firmware updates.
  • No AWS support: AWS does not expose AMD SEV-SNP to tenants (analogous to the SGX situation).
  • On-chain verifier: No widely-deployed production on-chain AMD SNP verifier exists yet; would require custom development or adaptation of existing work.

Option 7: Intel TDX TEE

Intel Trust Domain Extensions (TDX) is Intel’s VM-level TEE — the conceptual successor to SGX but for full VMs rather than per-process enclaves. TDX Trust Domains (TDs) are hardware-isolated VMs with encrypted memory and CPU state. Attestation uses the same DCAP infrastructure as SGX.

Key characteristics
  • Full VM isolation: No EPC memory limits; the full state transition function runs inside the VM without SGX’s ~256 MB enclave restriction.
  • Same attestation infrastructure as SGX: TDX attestation quotes use the same PCK cert chain and DCAP verification flow as SGX, enabling reuse of Automata-style on-chain verifiers.
  • Cloud availability: Intel TDX VMs are available on Azure (DCesv5/DCedsv5 series), Google Cloud (C3 with TDX), and Alibaba Cloud.
  • MRTD measurement: The VM launch measurement (MRTD) plays the same role as SGX’s MRENCLAVE.
  • Not available on AWS: Same constraint as SGX.
Properties
PropertyValue
Trust rootIntel (PCK attestation key)
Challenge windowCould be ~1 hour
Bond sizeLow
On-chain verification gas~30k–300k gas (DCAP; reuses SGX verifier stack)
AWS dependencyNone (requires Azure/GCP/Alibaba)
Hardware dependencyIntel 4th-gen Xeon Scalable (Sapphire Rapids) or later
Cloud availabilityAzure DCesv5, Google Cloud C3 TDX
Risks
  • Intel trust: Same Intel PKI trust assumption as SGX.
  • Newer / less battle-tested: TDX is more recent than SGX; fewer published attacks but also less third-party review.
  • Not on AWS: Infra migration required.

Comparison

Dispute Game & Trust Model

OptionTypeDispute Game SuitabilityValidity Proof SuitabilityTrust Assumption
SP1 (OP Succinct Lite)zkVM✅ Yes — production OP Stack integration✅ Yes (can also run as validity prover)Math only (cryptographic soundness)
RISC ZerozkVM✅ Yes — op-risc0 available✅ YesMath only
JoltzkVM⚠️ In principle yes, no OP Stack integration yet⚠️ In principleMath only
AWS NitroTEE✅ Yes❌ No (not a proof; attestation only)Hardware (trust AWS)
Intel SGXTEE✅ Yes❌ NoHardware (trust Intel)
AMD SEV-SNPTEE✅ Yes❌ NoHardware (trust AMD)
Intel TDXTEE✅ Yes❌ NoHardware (trust Intel)

Speed, Cost, and World Chain Fit

OptionProof/Attest TimeOn-chain GasChallenge WindowHappy Path CostWorld Chain Fit
SP1 (OP Succinct Lite)Minutes–hours (Succinct Network)~300k (Groth16)~1 day$0Best ZK option — production integration, no AWS lock-in
RISC ZeroMinutes–hours (Bonsai or self-hosted)~300k (Groth16)~1 day$0✅ Good alternative — self-hosted proving reduces external dependency
JoltTBDTBD~1 day$0🔬 Research only — not ready for production
AWS NitroSeconds–minutes~21k (ECDSA)~1 hour$0Best TEE option — minimal infra change, already on AWS
Intel SGXSeconds–minutes~30k–300k (DCAP)~1 hour$0⚠️ Requires Azure/bare metal migration
AMD SEV-SNPSeconds–minutes~30k–200k~1 hour$0⚠️ Requires Azure/GCP; no production on-chain verifier
Intel TDXSeconds–minutes~30k–300k~1 hour$0⚠️ Requires Azure/GCP; newer, less battle-tested

Backwards Compatibility

Introducing a new game-type=42 is additive. The existing Cannon challenger (game-type=1) (with Optimism upgrade 19 Cannon Kona challenger game-type=8) continues to operate in parallel and MUST NOT be decommissioned until the ZK game type is deployed, funded, and producing verified games on devnet.

Security Considerations

ZK Options (SP1, RISC Zero, Jolt)

  • The verifying key (aggregation_vkey for SP1, imageId for RISC Zero) deployed in the contract MUST exactly match the values used by the running proposer. A mismatch causes the proposer to classify games as foreign and refuse to prove them, leaving valid proposals vulnerable to challenger wins.
  • The CHALLENGER_BOND MUST be set high enough to make spam challenges economically irrational (recommended: at least 10× the cost to generate one proof).
  • The tropo_time and strato_time hardfork timestamps MUST be finalised before deploying contracts — the rollup_config_hash is derived from these values and cannot be changed post-deployment without redeployment.
  • Proof generation relies on an external prover network (Succinct / Bonsai). Availability risk should be mitigated via MAX_PROVE_DURATION being set conservatively and a fallback mock-mode tested on devnet. Self-hosted proving for RISC Zero mitigates this.

TEE Options (AWS Nitro, Intel SGX, AMD SEV-SNP, Intel TDX)

  • All TEE options introduce a trust dependency on the hardware vendor’s PKI. Compromise of the vendor’s attestation key allows forging arbitrary state roots.
  • TEE enclaves and VMs have a history of side-channel vulnerabilities. Any production deployment requires a thorough security audit and an ongoing TCB update process.
  • AWS Nitro certificates expire after 3 hours and must be actively rotated.
  • Intel SGX’s EPC memory limit (~256 MB) may be insufficient for the full L2 state transition function without sharding.

Threshold Multi-Proof / Multi-Attestation System

The single-prover options above each provide a significant improvement over the current Cannon-based system, but they each carry a single point of failure — whether that is a cryptographic bug in one zkVM, a hardware vendor’s PKI, or a prover-network outage. This section describes a threshold multi-proof architecture that layers two or more of the proof systems described above into an N-of-M pool, so that no single system’s failure or compromise is sufficient to accept a fraudulent state root or to halt the chain.

Overview

                      ┌─────────────────────────────────────────────────┐
                      │            ProofAggregator (L1)                  │
                      │                                                   │
  SP1 prover ─────────┤→ submitProof(gameId, PROVER_SP1, proofBytes)     │
  RISC Zero prover ───┤→ submitProof(gameId, PROVER_RISCZ, proofBytes)   │
  SGX prover ─────────┤→ submitAttestation(gameId, PROVER_SGX, quote)    │
                      │                                                   │
                      │  validCount = sum of accepted submissions         │
                      │                                                   │
                      │  validCount ≥ ⌈2N/3⌉  → ACCEPT  (happy path)    │
                      │  ⌈N/3⌉ ≤ validCount < ⌈2N/3⌉ → COUNCIL REVIEW  │
                      │  validCount < ⌊N/3⌋  → REJECT                   │
                      └─────────────────────────────────────────────────┘

With the reference configuration of N = 3 provers (SP1 + RISC Zero + Intel SGX), the thresholds are:

validCountOutcome
3✅ Auto-accepted (happy path, all agree)
2✅ Auto-accepted (2-of-3 threshold met)
1🔶 Security Council review window opens
0❌ Auto-rejected

8.1 Happy Path — 2/3 Threshold

When at least 2 of 3 provers submit valid proofs/attestations for the same output root within the challenge window, the ProofAggregator automatically marks the game as DEFENDER_WINS and no human intervention is required. The sequencer can then proceed with state root finalisation.

Why 2/3? A threshold strictly greater than half is a standard requirement for Byzantine fault tolerance: an attacker controlling fewer than 1/3 of the pool cannot unilaterally cause acceptance of a fraudulent root. Requiring a simple majority (1/2) would mean that compromising just one additional prover beyond 50 % is sufficient; the 2/3 threshold requires compromising two independent systems simultaneously, which is substantially harder.

Economic security: An attacker attempting to submit a fraudulent state root must:

  1. Break the soundness of at least two zkVM proof systems (SP1 + RISC Zero), or
  2. Break one zkVM system and exploit the SGX hardware attestation chain, or
  3. Compromise the Security Council without triggering a timely response.

Each of these requires defeating fundamentally different security assumptions (cryptographic soundness vs hardware vendor PKI vs human governance), making simultaneous compromise extremely unlikely.


8.2 Security Council Fallback — Between 1/3 and 2/3

If exactly 1 of 3 provers submits a valid proof within the challenge window, the ProofAggregator enters a council review period (recommended: 72 hours) rather than auto-rejecting.

This situation is expected to occur legitimately when:

  • One prover network (e.g. Succinct) is temporarily offline or degraded.
  • One zkVM has a known bug that is actively being patched.
  • One TEE platform has an expired TCB update pending from the hardware vendor.
  • A new prover is being added to the pool and has not yet generated its first proof.

In each of these cases, auto-rejection would halt chain progress unnecessarily. The Security Council exists to distinguish a liveness failure (one prover is down) from a safety failure (two provers are producing conflicting results).

Security Council Composition

The Security Council is an on-chain multisig (e.g. SafeMultisig) controlled by a set of trusted entities.

A 3-of-5 or 4-of-7 approval threshold within the council is recommended. Larger councils reduce coup risk at the cost of slower response time; the 72-hour review window should be calibrated to the council’s expected decision latency.

Council Decision Time-lock

Council approval does not take effect immediately. A 24-hour time-lock is enforced on-chain between a council vote and state root finalisation. This gives the broader community a window to:

  • Observe the pending council action.
  • Issue an emergency veto if the council is acting maliciously or under duress.
  • Coordinate a client-level soft-fork if necessary.

The time-lock contract should allow a guardian (a separate cold-key multisig) to cancel a pending council action within the lock period.

Council Governance

  • Council seats are held for fixed terms (e.g. 12 months) and are renewable.
  • Seat changes require an on-chain governance vote (World ID-gated or token-gated, per the chain’s governance model at the time of deployment).
  • Council members are expected to publish public rationale for any approval or rejection decision in the council review period. Decisions are permanently recorded on-chain.
  • If the council fails to reach a quorum within the review window, the disputed game defaults to rejection to preserve safety.

We already have a Security Council that can be used.


8.3 Automatic Rejection — < 1/3 Valid

If zero provers agree on the proposed state root, the output is immediately and automatically rejected. This is the expected outcome when:

  • The proposer has submitted a fraudulent root.
  • The proposer’s client software has a critical bug that produces an incorrect output.
  • A re-org has occurred and the root no longer corresponds to the canonical chain.

No council intervention is permitted in this state. Automatic rejection prevents the council from being used as a mechanism to push through fraudulent state roots even if the council is fully compromised.


8.4 N-of-M Proof Pool Design

The proof pool is defined by an ordered list of (proverId, verifierAddress, weight) tuples stored in the ProofAggregator contract. The recommended initial pool:

IDSystemVerifierType
PROVER_SP1SP1 (OP Succinct Lite)SP1Verifier (deployed)zkVM
PROVER_RISCZRISC ZeroRiscZeroGroth16VerifierzkVM
PROVER_SGXIntel SGX (via Automata DCAP)AutomataDcapAttestationTEE

Each prover submits its result independently. The ProofAggregator verifies each submission against its registered verifier contract before incrementing validCount. Submissions arriving after the challenge window are discarded.

Combining zkVM and TEE provers is intentional. The two categories have orthogonal trust assumptions: breaking a zkVM proof requires finding a mathematical soundness flaw, while breaking a TEE attestation requires compromising vendor PKI or hardware. An attacker capable of doing one is unlikely to be capable of doing both.


8.5 Liveness vs Safety Tradeoff

ScenariovalidCountOutcomeAnalysis
All provers healthy3AcceptBest case; no council needed
One prover down (liveness failure)2AcceptHappy path still clears; chain unaffected
Two provers down1Council reviewRare; council distinguishes outage from attack
All provers down0RejectHalt confirmations of state root on L1 *; emergency recovery required
One prover compromised (attacker injects false root)At most 1 valid for correct rootCouncil review or RejectAttacker cannot force acceptance
Two provers compromised2 “valid” for fraudulent root⚠️ Accept (attack success)Requires simultaneous compromise of 2 independent systems

The 2/3 threshold means the system tolerates up to 1 prover failure without losing liveness, and requires an attacker to compromise at least 2 independent systems to achieve a safety failure. This matches the standard BFT fault-tolerance guarantee (tolerate ⌊(N−1)/3⌋ = 1 Byzantine fault for N = 3).

* We can also halt chain.


8.6 Comparison with Taiko’s Multi-Prover Model

Taiko’s TEE Prover and multi-prover architecture is the closest published analogue to what is described here. Key differences:

DimensionTaikoWorld Chain (proposed)
Pool size4 provers (ZK + TEE)3 provers (2 ZK + 1 TEE)
Required threshold2-of-42-of-3
SGXRequired (mandatory TEE slot)Yes (one of three)
Council fallbackNot describedYes — explicit, time-locked
Rejection threshold0 valid≤ 1/3 (≤ 1-of-3)
Upgrade mechanismNot specifiedOn-chain governance with pool versioning

Taiko’s model requires SGX as a mandatory slot, meaning SGX is a hard dependency. The World Chain model treats SGX as one of three equal participants; if the SGX slot is eventually replaced by a newer TEE (e.g. Intel TDX or AMD SEV-SNP), the pool can be updated via governance without changing the threshold logic.


8.7 On-Chain Contract Design Sketch

ProofAggregator

/// @notice Collects proofs/attestations from the registered prover pool,
///         checks the threshold, and gates DisputeGame resolution.
contract ProofAggregator {

    // -- Configuration --

    struct Prover {
        address verifier;   // IProofVerifier — verify(gameId, proof) → bool
        bool    active;
    }

    Prover[]  public provers;           // ordered pool; index = proverId
    uint256   public threshold;         // min valid count to auto-accept (e.g. 2)
    uint256   public councilThreshold;  // min valid count to allow council review (e.g. 1)
    uint256   public reviewWindow;      // seconds council has to vote (e.g. 72 hours)
    uint256   public timeLockDelay;     // seconds between council approval and execution (e.g. 24 hours)

    ISecurityCouncil public council;

    // -- State per game --

    struct GameState {
        uint128 validCount;
        uint128 submittedMask;  // bitmask of which provers have submitted
        Status  status;         // Pending | Accepted | CouncilReview | Rejected
        uint64  reviewDeadline;
    }

    mapping(bytes32 gameId => GameState) public games;

    // -- Events --

    event ProofSubmitted(bytes32 indexed gameId, uint256 proverId, bool valid);
    event ThresholdReached(bytes32 indexed gameId, uint256 validCount);
    event CouncilReviewOpened(bytes32 indexed gameId, uint64 deadline);
    event CouncilApproved(bytes32 indexed gameId, uint64 executionTime);
    event GameRejected(bytes32 indexed gameId, uint256 validCount);

    // -- Submission --

    /// @notice Called by each prover's off-chain agent.
    function submitProof(bytes32 gameId, uint256 proverId, bytes calldata proof) external {
        GameState storage g = games[gameId];
        require(g.status == Status.Pending, "game not pending");
        require(!_hasBit(g.submittedMask, proverId), "already submitted");

        g.submittedMask = uint128(_setBit(g.submittedMask, proverId));

        bool valid = provers[proverId].verifier != address(0)
            && IProofVerifier(provers[proverId].verifier).verify(gameId, proof);

        if (valid) g.validCount++;

        emit ProofSubmitted(gameId, proverId, valid);
        _evaluate(gameId, g);
    }

    // -- Internal threshold evaluation --

    function _evaluate(bytes32 gameId, GameState storage g) internal {
        uint256 total = _activeProverCount();
        if (g.validCount >= _twoThirds(total)) {
            g.status = Status.Accepted;
            emit ThresholdReached(gameId, g.validCount);
        } else if (_allSubmitted(g, total) || block.timestamp >= g.reviewDeadline) {
            // All submissions received or window closed — check council threshold
            if (g.validCount > total / 3) {
                g.status = Status.CouncilReview;
                g.reviewDeadline = uint64(block.timestamp + reviewWindow);
                emit CouncilReviewOpened(gameId, g.reviewDeadline);
            } else {
                g.status = Status.Rejected;
                emit GameRejected(gameId, g.validCount);
            }
        }
    }

    // -- Council approval --

    /// @notice Called by the SecurityCouncil contract after quorum is reached.
    function councilApprove(bytes32 gameId) external onlyCouncil {
        GameState storage g = games[gameId];
        require(g.status == Status.CouncilReview, "not in review");
        require(block.timestamp <= g.reviewDeadline, "review window expired");
        // Time-lock: emit event; actual execution happens after timeLockDelay
        emit CouncilApproved(gameId, uint64(block.timestamp + timeLockDelay));
        // Execution is handled by a separate TimelockController.execute() call.
    }
}

Integration with DisputeGame

The DisputeGame.resolve() function is gated by a call to ProofAggregator.gameStatus(gameId). The game can only be resolved as DEFENDER_WINS if the aggregator returns Status.Accepted (or Status.CouncilApproved after the time-lock has elapsed). Any other status results in CHALLENGER_WINS or a revert while the game is still in review.

DisputeGame.resolve()
  │
  └─ require(aggregator.gameStatus(id) == Accepted || CouncilApproved, "proof threshold not met")
       │
       ├─ Accepted → DEFENDER_WINS immediately
       └─ CouncilApproved → DEFENDER_WINS only if timeLock.elapsed(id)

8.8 Upgrade Path and Pool Governance

The proof pool is designed to evolve over time:

  • Adding a new prover: Governance vote → ProofAggregator.addProver(verifier) → pool size N increases → threshold recalculated. A 3-of-4 configuration (threshold = 3) would give stronger safety guarantees.
  • Removing a deprecated prover: Governance vote → ProofAggregator.deactivateProver(id) → pool size decreases. A minimum pool size of 2 should be enforced.
  • Replacing a verifier contract: e.g. upgrading from Groth16 to a newer SP1 verifier — update the verifier address for the relevant prover slot after governance approval.
  • Changing the threshold: Requires a governance vote and an on-chain parameter update. Any threshold change takes effect only after a 7-day delay.

The pool configuration (provers[], threshold, councilThreshold) is stored in an upgradeable proxy, allowing future governance to adapt the system without redeploying the full aggregator.


8.9 Summary

PropertyValue
PoolSP1 (zkVM) + RISC Zero (zkVM) + Intel SGX (TEE)
Auto-accept threshold2-of-3 valid
Council review trigger1-of-3 valid
Auto-reject0-of-3 valid
Safety assumptionMust compromise ≥ 2 independent systems simultaneously
Liveness toleranceTolerates 1 prover outage with no chain disruption
Council3-of-5 (or 4-of-7) multisig; 72-hour review window
Time-lock24 hours between council approval and finalisation
Upgrade mechanismOn-chain governance; 7-day delay for threshold changes

9. Economic Escalation Game

The multi-proof architecture in Section 8 establishes what counts as a valid proof. This section describes how economic incentives govern the challenge/response lifecycle — specifically, a bond escalation mechanism that makes griefing attacks progressively more expensive while ensuring honest parties are always compensated for participating in disputes.

9.1 Core Concept

The escalation game replaces the OP Stack’s fixed-bond model with a dynamic bidding mechanism. Both the challenger and the defender can raise their bond at any point during the challenge window. A higher bid signals confidence and forces the opposing party to match or forfeit. The winner receives their own bond back plus the loser’s bond (minus a small protocol fee).

This creates a game-theoretic equilibrium:

  • Honest defenders always match escalations because their expected value of winning exceeds the cost of matching.
  • Frivolous challengers face escalating capital at risk with no economic upside on a valid root.
  • Griefing attackers must keep outbidding an honest defender, losing their full cumulative stake each time they fail.

9.2 Mechanism Design

9.2.1 Initial Bonds

  • The challenger posts an initial bond to open a dispute. This bond is locked in escrow and signals the challenger’s confidence in their claim.
  • The defender (proposer) must post a matching or exceeding bond to formally respond. Failing to respond within the initial response window constitutes a concession.
  • Asymmetric entry costs apply: the challenger pays more to open a dispute than to respond to an escalation, deterring spam while keeping honest response cheap.

9.2.2 Escalation Rounds

Either party may raise their bond at any point during the active challenge window. The opponent then has a fixed escalation response window (24 hours per round) to match the new bond level. Failure to match within that window constitutes a forfeit for that round — the forfeiting party concedes the dispute.

There is no limit on the number of rounds, but a bond cap (see Section 9.6) ensures costs remain bounded.

9.2.3 Winner-Takes-All Resolution

When the dispute resolves (either by proof outcome, bond forfeit, or challenge window expiry), the winning party receives:

payout = winner_bond + loser_bond × (1 − protocol_fee_rate)

The protocol_fee_rate portion of the loser’s bond is transferred to the protocol treasury / prover incentive fund (see Section 9.7).

9.3 Anti-Griefing Properties

The escalation model provides three layers of protection against griefing:

  1. Raising is expensive. An attacker attempting to grief an honest proposer must continually match or exceed the defender’s escalations. Each round increases the attacker’s capital at risk without any economic upside, since the honest prover will eventually produce a valid ZK proof and win.

  2. Honest parties have economic certainty. Because the multi-proof system (Section 8) produces a deterministic proof outcome, an honest defender knows with certainty they will win. Their expected value of matching any escalation is strictly positive; their dominant strategy is always to match.

  3. Bond escalation caps prevent runaway costs. A configurable maximum bond cap (see Section 9.6) bounds the maximum capital at risk for any participant, ensuring that no single dispute can drain an honest party’s reserves entirely.

9.4 Interaction with the Multi-Proof System (Section 8)

The economic game runs in parallel with the proof submission lifecycle:

  • Early termination: If the ProofAggregator reaches the 2/3 acceptance threshold before the challenge window closes, the escalation game terminates immediately. No further escalation rounds are permitted; the winner is determined by the proof outcome.
  • Disagreement signal: If exactly 1-of-3 provers has submitted a valid proof when the escalation game is active, the current bond level is used as a priority signal to the Security Council — a higher bond level indicates that one or both parties have high conviction, which the council should weight accordingly when triaging its review queue.
  • Proof-independent forfeit: A party that forfeits the economic game (by failing to match within the response window) loses the dispute regardless of the eventual proof outcome. This ensures the economic and proof-based systems are independently enforceable.
Challenge window opens
  │
  ├─ ProofAggregator: 2/3 threshold reached → escalation game terminates early
  │                                            winner = proof outcome
  │
  ├─ Escalation: one party fails to match within response window
  │              → that party forfeits (proof outcome irrelevant)
  │
  └─ Challenge window closes with no forfeit
       → winner determined by ProofAggregator status
            ├─ Accepted (2/3) → DEFENDER_WINS
            ├─ CouncilReview (1/3) → council decides; bond level informs priority
            └─ Rejected (0) → CHALLENGER_WINS

9.5 Game Theory Analysis

Nash Equilibrium

Let B_d be the defender’s current bond and B_c the challenger’s. The honest defender’s expected payoff from matching any escalation to B_c' is:

E[payoff] = B_d + B_c' × (1 − fee) > 0   (because they win with certainty)

Since the honest defender’s win probability approaches 1 (they can produce a valid ZK proof), they will always match. The unique Nash equilibrium is therefore:

The honest defender matches every escalation; a rational attacker, anticipating this, never escalates.

In practice, an attacker with deep pockets might still try to exhaust the defender’s liquidity. The bond cap (Section 9.6) and the yield-bearing escrow design (Section 9.8) mitigate this.

Attacker’s Cost

Under the recommended Option B + E design, the costs diverge between the escalating attacker and the responding honest party:

attacker_cumulative_stake  = B_initial × 2.0^N   (escalating party: 2.0× per round)
honest_defender_stake      = B_initial × 1.5^N   (responding party: 1.5× per round)

With an initial defender bond of 0.1 ETH and a 32 ETH cap, the honest defender exhausts the cap at approximately ⌊log_{1.5}(320)⌋ = 13 rounds, while the attacker (using 2.0× increments) exhausts the cap at approximately ⌊log_{2.0}(320)⌋ = 8 rounds. The attacker forfeits their entire cumulative stake on loss, and because their costs compound faster, the honest party maintains a structural capital advantage in any prolonged escalation.

Comparison to the OP Stack Fixed-Bond Model

PropertyOP Stack Fixed BondEconomic Escalation Game
Griefing costFixed (one bond per challenge)Escalating (compounds per round)
Deep-pocket attackerCan spam challenges cheaplyMust match each escalation; losses grow exponentially
Honest party compensationBond refund onlyBond refund + loser’s bond
Capital at risk (honest)FixedBounded by cap (32 ETH)
On-chain complexitySimpleModerate (state machine + escrow)

9.6 Bond Asymmetry Design Options

The single most consequential parameter choice in the escalation game is the entry-bond asymmetry between the challenger (the party making an extraordinary claim against an existing output root) and the defender (the proposer responding to that claim). Five distinct design options are presented below, each with trade-offs.


Option A: Symmetric Bonds (Equal Entry)

Both challenger and defender post the same initial bond to open or respond to a dispute.

Pros:

  • Simple, fair, and requires no special-casing in the contract.
  • The escalation mechanic alone handles anti-spam — a frivolous challenger still faces escalating capital at risk with no upside.
  • Easy to reason about on-chain; reduces audit surface.

Cons:

  • May deter low-capital honest challengers who cannot afford the same bond as a well-resourced proposer/sequencer.
  • Does not account for the asymmetry of risk: the challenger is the initiating (riskier) party making the extraordinary claim.

Option B: Challenger Pays More (Initiator Premium)

The challenger must post a higher bond than the defender to open a dispute (e.g. 1.5× the required defender response bond).

Pros:

  • Raises the cost of spam and griefing — the challenger is the party making the extraordinary claim, so a higher barrier is economically justified.
  • Honest challengers are still protected by the winner-takes-all payout; their expected value remains positive when they are correct.
  • Natural deterrent against frivolous disputes without banning challengers with lower capital (they can still participate, just at a higher initial cost).

Cons:

  • Higher barrier to entry could deter undercapitalized honest watchers, reducing decentralization of the challenger network.
  • Wealthier attackers can still afford the higher entry bond, so it raises the floor but does not eliminate deep-pocket attacks.
  • May discourage challenger networks or watchdog services from operating at scale.

Option C: Challenger Pays Less (Watcher Incentive)

The challenger posts a smaller bond (e.g. 50% of the defender’s bond) to lower the barrier for honest watchers.

Pros:

  • Encourages a broad, decentralized watcher network by minimizing capital requirements for honest challengers.
  • Reduces the economic burden on parties who are already spending resources monitoring the chain.

Cons:

  • Enables cheap spam — low-cost entry means attackers can flood the system with frivolous disputes.
  • The defender (sequencer) must repeatedly respond and lock up capital for each dispute, even if most are meritless.
  • The asymmetry can be deliberately exploited: an attacker need only risk a small bond to force the defender to tie up large capital.

Option D: Time-Asymmetric Response Windows

Bonds are symmetric in size, but the defender receives a longer response window at each escalation round (e.g. challenger must respond in 12 hours, defender gets 24 hours). Rationale: the defender is a reactive party, not a proactive one.

Pros:

  • Protects honest defenders from being timed out by a challenger who escalates just before a deadline.
  • No capital asymmetry is needed — fairness is achieved through time rather than cost.
  • Relatively simple to implement: only the responseWindow logic branches on which party is responding.

Cons:

  • Slower overall dispute resolution — a single escalation chain now takes longer to resolve at each round.
  • A malicious defender can use the extra response time to delay finality without conceding.
  • Does not address spam at the point of entry; a low-bond challenger can still open many cheap disputes.

Option E: Progressive Escalation Multiplier (Asymmetric Escalation Cost)

Both parties start with the same initial bond, but each escalation round requires a higher multiplier from the escalating party than from the responding party (e.g. the party that raises must increase by , while the party responding need only match by 1.5×).

Pros:

  • Makes aggressive escalation increasingly expensive for the attacking party while keeping honest responses comparatively cheaper.
  • An honest defender who is confident in their root can always match at a lower incremental cost than the cost of the attack.
  • Naturally terminates runaway escalation: the escalating party’s capital grows faster than the responding party’s, so rational attackers abandon escalation early.

Cons:

  • More complex to implement and audit; the asymmetric multiplier introduces additional contract state and branching logic.
  • The multiplier ratio (2× escalate vs 1.5× respond) needs careful calibration — too large a gap may over-penalize honest challengers who legitimately want to escalate.
  • May confuse participants who expect symmetric rules; requires clear documentation in the dispute interface.

Comparison Table

OptionAnti-Spam ProtectionHonest Challenger AccessibilityImplementation ComplexityCapital EfficiencyRecommended for World Chain?
A — Symmetric BondsMediumHighLowHighMaybe
B — Challenger Pays MoreHighMediumLowMediumYes
C — Challenger Pays LessLowHighLowLowNo
D — Time-Asymmetric WindowsLowHighMediumHighMaybe
E — Progressive MultiplierHighMediumHighMediumYes

9.7 Protocol Fee

A 3% fee on the loser’s bond is deducted before paying out the winner. These funds are routed to a designated protocol treasury address that serves a dual purpose:

  1. Prover incentive fund: Partially reimburses ZK prover costs (prover network fees from Succinct / Bonsai) incurred during the dispute.
  2. Protocol reserves: Builds a reserve that can be used to cover prover costs on the happy path if a validity-proof model is adopted in future.

The fee rate is a governance-controlled parameter, stored in the EscalationGame contract and bounded between 1% and 10%.

9.8 Capital Efficiency (Optional)

Locking large bonds in a smart contract carries significant opportunity cost. Two mechanisms reduce this burden for honest participants:

Yield-Bearing Escrow (Optional)

Bonds held in escrow are deposited into a yield-bearing position (e.g. an ERC-4626 vault backed by wstETH or aETH) rather than held as raw ETH. The depositor retains the yield during the lock period; only the principal is at risk. This approach:

  • Reduces the effective cost of bonding for honest parties (yield offsets lock cost).
  • Does not change the slashing semantics — only the principal is forfeit on loss.
  • Requires the escrow contract to wrap/unwrap positions atomically when bonds are posted or released.

Asymmetric Bond Ratios (Optional)

The challenger pays the full bond to open a dispute but only 50% of the current bond level to respond to a subsequent escalation initiated by the defender. The inverse applies to the defender. This asymmetry:

  • Increases the cost of opening frivolous disputes.
  • Keeps the marginal cost of honest response lower than the marginal cost of escalation.
  • Preserves the winner-takes-all payout (the loser forfeits the full amount they posted).

9.9 On-Chain Contract Design Sketch

EscalationGame Contract

/// @notice Manages the economic escalation game for a single DisputeGame instance.
///         Deployed per game or as a shared singleton with per-game state.
contract EscalationGame {

    // -- Types --

    enum GameStatus { Open, Active, Escalated, Resolved, Forfeited }

    struct EscalationState {
        address  challenger;
        address  defender;
        uint256  challengerBond;   // current bond posted by challenger
        uint256  defenderBond;     // current bond posted by defender
        uint256  roundDeadline;    // timestamp by which the responding party must match
        address  pendingResponder; // party that must respond (match or forfeit)
        GameStatus status;
    }

    // -- State --

    mapping(bytes32 gameId => EscalationState) public games;

    IProofAggregator public aggregator;
    IVault           public escrowVault;   // ERC-4626 yield-bearing vault
    address          public treasury;
    uint256          public protocolFeeRate;   // e.g. 300 = 3%
    uint256          public maxBondCap;        // e.g. 32 ether
    uint256          public minEscalationMultiplier; // e.g. 150 = 1.5×, in bps (10000 = 1×)
    uint256          public responseWindow;    // seconds per escalation round (e.g. 86400)

    // -- Events --

    event ChallengeOpened(bytes32 indexed gameId, address challenger, uint256 bond);
    event DefenderResponded(bytes32 indexed gameId, address defender, uint256 bond);
    event BondEscalated(bytes32 indexed gameId, address escalator, uint256 newBond);
    event PartyForfeited(bytes32 indexed gameId, address forfeiter, uint256 bondLost);
    event GameResolved(bytes32 indexed gameId, address winner, uint256 payout);

    // -- Actions --

    /// @notice Challenger calls this to open a new dispute.
    /// @param gameId  The ID of the associated DisputeGame.
    /// @param bond    ETH bond amount (must equal msg.value).
    function openChallenge(bytes32 gameId, uint256 bond) external payable {
        require(msg.value == bond, "bond mismatch");
        require(bond >= MIN_INITIAL_BOND, "bond too low");
        EscalationState storage s = games[gameId];
        require(s.status == GameStatus(0), "already opened"); // default is Open

        _depositToVault(gameId, msg.sender, bond);

        s.challenger    = msg.sender;
        s.challengerBond = bond;
        s.roundDeadline = block.timestamp + responseWindow;
        s.pendingResponder = address(0); // defender must respond
        s.status        = GameStatus.Open;

        emit ChallengeOpened(gameId, msg.sender, bond);
    }

    /// @notice Defender calls this to respond to an open challenge.
    function respond(bytes32 gameId, uint256 bond) external payable {
        EscalationState storage s = games[gameId];
        require(s.status == GameStatus.Open, "not open");
        require(block.timestamp <= s.roundDeadline, "response window expired");
        require(bond >= s.challengerBond, "must match or exceed challenger bond");
        require(msg.value == bond, "bond mismatch");

        _depositToVault(gameId, msg.sender, bond);

        s.defender    = msg.sender;
        s.defenderBond = bond;
        s.status      = GameStatus.Active;

        emit DefenderResponded(gameId, msg.sender, bond);
    }

    /// @notice Either party escalates by raising their bond.
    /// @param newBond  New total bond (must be ≥ 1.5× the current highest bond).
    function escalate(bytes32 gameId, uint256 newBond) external payable {
        EscalationState storage s = games[gameId];
        require(s.status == GameStatus.Active || s.status == GameStatus.Escalated,
                "cannot escalate");
        require(msg.sender == s.challenger || msg.sender == s.defender,
                "not a party");

        uint256 currentMax = s.challengerBond > s.defenderBond
            ? s.challengerBond : s.defenderBond;
        uint256 minNew = currentMax * minEscalationMultiplier / 10000;
        require(newBond >= minNew, "escalation too small");
        require(newBond <= maxBondCap, "exceeds bond cap");

        uint256 existing = (msg.sender == s.challenger)
            ? s.challengerBond : s.defenderBond;
        uint256 topUp = newBond - existing;
        require(msg.value == topUp, "incorrect top-up");

        _depositToVault(gameId, msg.sender, topUp);

        if (msg.sender == s.challenger) {
            s.challengerBond  = newBond;
            s.pendingResponder = s.defender;
        } else {
            s.defenderBond    = newBond;
            s.pendingResponder = s.challenger;
        }

        s.roundDeadline = block.timestamp + responseWindow;
        s.status        = GameStatus.Escalated;

        emit BondEscalated(gameId, msg.sender, newBond);
    }

    /// @notice Called when a party fails to match within the response window.
    function concede(bytes32 gameId) external {
        EscalationState storage s = games[gameId];
        require(s.status == GameStatus.Active || s.status == GameStatus.Escalated,
                "not active");
        require(block.timestamp > s.roundDeadline, "window still open");

        // The pending responder has forfeited.
        address loser = s.pendingResponder != address(0)
            ? s.pendingResponder
            : s.defender; // defender never responded

        _settleGame(gameId, s, loser);
        emit PartyForfeited(gameId, loser, _bondOf(s, loser));
    }

    /// @notice Called after ProofAggregator has reached a final status.
    function resolve(bytes32 gameId) external {
        EscalationState storage s = games[gameId];
        require(s.status != GameStatus.Resolved && s.status != GameStatus.Forfeited,
                "already settled");

        IProofAggregator.Status ps = aggregator.gameStatus(gameId);
        require(
            ps == IProofAggregator.Status.Accepted ||
            ps == IProofAggregator.Status.Rejected,
            "proof not finalised"
        );

        address loser = (ps == IProofAggregator.Status.Accepted)
            ? s.challenger  // defender wins
            : s.defender;   // challenger wins

        _settleGame(gameId, s, loser);
    }

    // -- Internal --

    function _settleGame(bytes32 gameId, EscalationState storage s, address loser) internal {
        address winner = (loser == s.challenger) ? s.defender : s.challenger;

        uint256 loserBond  = _bondOf(s, loser);
        uint256 winnerBond = _bondOf(s, winner);
        uint256 fee        = loserBond * protocolFeeRate / 10000;
        uint256 payout     = winnerBond + loserBond - fee;

        s.status = GameStatus.Resolved;

        _withdrawFromVault(gameId, winner, payout);
        _withdrawFromVault(gameId, treasury, fee);

        emit GameResolved(gameId, winner, payout);
    }

    function _bondOf(EscalationState storage s, address party) internal view returns (uint256) {
        if (party == s.challenger) return s.challengerBond;
        if (party == s.defender)   return s.defenderBond;
        return 0;
    }
}

State Machine

                  openChallenge()
                       │
                    [ Open ]
                       │  respond() within responseWindow
                       │
                    [ Active ]
                       │
             ┌─────────┴─────────┐
       escalate()           concede() / resolve()
             │                   │
         [ Escalated ]       [ Forfeited / Resolved ]
             │
    match within responseWindow?
             │
      ┌──────┴──────┐
    Yes             No
      │              │
  [ Active ]    [ Forfeited ]
      │
  resolve() (proof finalised)
      │
  [ Resolved ]

Integration Points

  • DisputeGame: EscalationGame is called by DisputeGame.challenge() to lock the challenger’s bond and start the timer.
  • ProofAggregator (Section 8): EscalationGame.resolve() reads ProofAggregator.gameStatus() to determine the winner once proofs are finalised.
  • DisputeGame.resolve(): Requires EscalationGame to be in Resolved or Forfeited state before finalising the dispute outcome on-chain. This ensures the economic and proof-based layers are both settled before state root finalisation.

9.10 Summary

PropertyValue
Initial challenger bond0.15 ETH (Option B: 1.5× defender entry bond)
Initial defender bond0.1 ETH
Escalation multiplier (escalating party)2.0× current bond (Option E)
Response multiplier (responding party)1.5× current bond (Option E)
Maximum bond cap32 ETH
Escalation response window (challenger)12 hours per round
Escalation response window (defender)24 hours per round (Option D complement)
Protocol fee3% of loser’s bond
Escrow modelYield-bearing (ERC-4626 vault)
Winner payoutOwn bond + loser bond − protocol fee
IntegrationEscalationGameDisputeGameProofAggregator
Griefing resistanceEscalating party cost ∝ 2.0^N; responding party cost ∝ 1.5^N; capped at 32 ETH
Bond designOption B (Initiator Premium) + Option E (Progressive Multiplier) + Option D (time windows)
Nash equilibriumHonest parties always match at lower cost; rational attackers never escalate

WIP-1006: Proof System Architecture

Abstract

We outline an architecture for an optimistic fault proof system. A proposer posts a root optimistically without any proof. If no staked participant challenges the root within a one-day window, the root finalizes purely on the basis of elapsed time. A challenge goes through optimistically — the challenger submits no proof of invalidity — and the burden then shifts to the proposer to defend the root. Defending opens a 7 day proof window during which the proposer must assemble n / m proofs establishing the root’s validity. If the proposer does not defend the root within this window, the root is invalidated and the proposer’s bond is forfeited.

Motivation

The vanilla OP Stack fault-proof system inherits a multi-day challenge window for every proposal and routes every dispute through a single interactive fault-proof lane. Base’s Azul proof system reduces that window when two heterogeneous proofs (TEE and ZK) agree, but still requires a proof on the common path and still rests final resolution on at most two lanes.

World Chain targets two properties that neither model provides together:

  • A cheap common path. No proof is paid for or produced when no staked participant objects to a root.
  • A diversified disputed path. When a root is challenged, the proposer must defend it, and no single prover, TEE vendor, or council action can finalize that defense. At least two independent lanes must agree.

The result is fast finality in the common case and n / m threshold security in the dispute case.

Specification

The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “NOT RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119 and RFC 8174.

Constants

Protocol constants are fixed by this WIP and MUST NOT be changed without a new WIP.

NameValueMeaning
PROOF_THRESHOLD2Number of distinct proof lanes that MUST support a challenged root before it finalizes.
PROOF_LANE_COUNT3Number of configured proof lanes.

Activation Parameters

Activation parameters are set at hardfork activation and held as immutable values on the proof system contracts. They MAY be retuned only by a subsequent activation. All implementations MUST commit to these values in their deployed configuration and MUST NOT permit per-call overrides.

NameInitial ValueMeaning
CHALLENGE_PERIOD1 dayDuration after proposal creation during which a staked participant MAY challenge a root without submitting proof material. Per-proposal challengeDeadline = createdAt + CHALLENGE_PERIOD.
PROOF_PERIOD7 daysDuration after a root is challenged during which the proposer MAY defend the root by accumulating lane submissions toward PROOF_THRESHOLD. Per-proposal proofDeadline = challengedAt + PROOF_PERIOD. A challenged root that the proposer has not defended to PROOF_THRESHOLD by proofDeadline MUST be invalidated.
PROPOSER_BONDTBDSlashable stake required of the proposer. Locked at proposal time, refunded if the root reaches FINALIZED, and forfeited if the root reaches INVALIDATED.
CHALLENGER_BONDTBDSlashable stake required of each challenger. Locked at challenge time and forfeited if the challenged root finalizes; refunded if the root is invalidated.

Proof Lanes

A challenged root finalizes only when at least PROOF_THRESHOLD distinct lanes support the same root commitment. Each lane counts at most once per root.

LaneSourceSubmission
VALIDITY_PROOFA configured validity proof verifier (zkVM or SNARK).Permissionless.
TEE_ATTESTATIONA registered TEE signer attesting to the transition.Anyone MAY relay a valid signed attestation.
SECURITY_COUNCILA Security Council threshold signature or multisig action.Council-controlled attestation.

Multiple proofs from the same lane MUST NOT increase the threshold count.

Root Commitments

The data bound by every proof and attestation is split into two layers so that the proposer’s submission surface is the same as the OP Stack and Base Azul proposer surfaces. Per-proposal data is supplied by the proposer or captured by the factory at proposal creation. Domain constants are fixed on the verifier implementation and are not part of the proposer’s calldata.

Proposal (per-proposal)

FieldSourceMeaning
rootClaimProposerClaimed L2 output root, computed as in the OP Stack output-root V1 encoding.
l2BlockNumberProposerL2 block number for rootClaim, matching the l2BlockNumber field of an OP Stack L2 output proposal.
parentRefProposerAddress of the parent — the AnchorStateRegistry for the first proposal, otherwise the parent proposal. Same convention as the OP Stack [Fault Dispute Game extraData parent reference][op-dgi] and the parent reference encoded in Base Azul’s AggregateVerifier extra-data layout (see [Base Azul proof system][base-azul]). The parent’s rootClaim and l2BlockNumber are read from this reference rather than re-supplied.
intermediateRootsHashProposerCommitment to ordered intermediate output roots, compatible with Base Azul’s intermediate-root commitment. Implementations that do not use intermediate roots MUST set this field to bytes32(0).
l1OriginHashFactoryL1 origin hash captured at proposal creation. The proposer does not pass it as calldata; it is read via blockhash() or EIP-2935 history and pinned by the factory, mirroring the OP Stack [l1Head snapshot at clone creation][op-dgi] that Base Azul inherits.
l1OriginNumberFactoryL1 block number paired with l1OriginHash.

This matches the OP Stack and Base proposer surface field-for-field. Any OP Stack proposer that already submits (rootClaim, l2BlockNumber) against a parent reference can be reused without protocol-level change. See Backwards Compatibility for the full compatibility statement.

Domain (verifier-immutable)

FieldMeaning
chainIdWorld Chain L2 chain ID.
proofSystemVersionVersion of this proof system’s proof-domain encoding.
rollupConfigHashHash of the rollup configuration and World Chain hardfork schedule.
blockIntervalDistance in L2 blocks between a parent root and a proposed root.
intermediateBlockIntervalDistance in L2 blocks between intermediate roots inside one proposal.

These values are set once on the verifier implementation (analogous to Base’s CONFIG_HASH, L2_CHAIN_ID, BLOCK_INTERVAL, and INTERMEDIATE_BLOCK_INTERVAL immutables) and are not supplied per proposal. Lane-specific verifier constants — such as the active TEE image hash, the validity proof program key, and the fault proof game type — live in the lane verifiers’ own configuration and are committed to in those lanes’ proof journals.

Canonical identifiers

The contracts use two related identifiers:

  • proposalKey is the factory lookup key. It is deterministic from proposer-supplied proposal data and excludes the factory-captured L1 origin, so proposers can discover existing games before knowing which L1 block created them.
  • rootId is the proof-bound commitment. It includes the factory-captured L1 origin and is the value every proof lane MUST bind to.

The contract computes:

domainHash = keccak256(abi.encode(
    chainId,
    proofSystemVersion,
    rollupConfigHash,
    blockInterval,
    intermediateBlockInterval
))

proposalKey = keccak256(abi.encode(
    domainHash,
    parentRef,
    rootClaim,
    l2BlockNumber,
    intermediateRootsHash
))

rootId = keccak256(abi.encode(
    domainHash,
    parentRef,
    rootClaim,
    l2BlockNumber,
    intermediateRootsHash,
    l1OriginHash,
    l1OriginNumber
))

The factory MUST reject duplicate games with the same proposalKey. All proofs and attestations MUST bind to rootId. A proof, game result, or attestation that binds to a different domainHash, parent, root, block range, or L1 origin MUST NOT be accepted for rootId.

State Machine

A root moves through four states:

stateDiagram-v2
    [*] --> PROPOSED: propose(root)
    PROPOSED --> FINALIZED: challenge deadline elapsed & no challenge
    PROPOSED --> CHALLENGED: staked challenge before challenge deadline
    CHALLENGED --> FINALIZED: proposer defends with PROOF_THRESHOLD distinct lanes before proof deadline
    CHALLENGED --> INVALIDATED: proposer fails to defend by proof deadline
    FINALIZED --> [*]
    INVALIDATED --> [*]

Proposal Lifecycle

A root enters the system in the PROPOSED state.

  1. The proposer submits the root commitment and locks PROPOSER_BOND as slashable collateral against the proposal.
  2. The contract records createdAt = block.timestamp and challengeDeadline = createdAt + CHALLENGE_PERIOD.
  3. The root remains open to challenges until challengeDeadline.

The proposer MUST NOT be required to submit material from any proof lane when creating the proposal, however they may choose to do so.

If the root reaches FINALIZED, the locked PROPOSER_BOND MUST be refunded to the proposer. If the root reaches INVALIDATED, the locked PROPOSER_BOND MUST be forfeited.

Challenge Lifecycle

Any staked participant MAY challenge a proposed root before challengeDeadline. A challenge goes through optimistically: the challenger is not asked to prove that the root is invalid, and the challenge succeeds by default unless the proposer defends the root. The challenge merely shifts the burden of proof onto the proposer, who must then establish the root’s validity per Proposer Defense and Challenged Finality.

The challenge transaction:

  • MUST verify that the caller is currently staked according to the configured staking registry.
  • MUST NOT require the caller to submit proof material.
  • MUST lock CHALLENGER_BOND of the caller’s stake as slashable collateral against the challenged root.
  • MUST mark the root as CHALLENGED.
  • MUST record challengedAt = block.timestamp and proofDeadline = challengedAt + PROOF_PERIOD on the root, the first time it transitions to CHALLENGED. Subsequent challenges MUST NOT extend proofDeadline.

If the challenged root finalizes through the proof-lane threshold, the locked CHALLENGER_BOND is forfeited. If the root is invalidated, the locked CHALLENGER_BOND is refunded to the challenger.

Multiple challenges MAY be recorded for accounting, but only the existence of at least one valid challenge affects the finality path. Each additional challenger locks their own CHALLENGER_BOND.

A challenge submitted at or after challengeDeadline MUST revert.

Unchallenged Finality

If no valid challenge has been submitted by challengeDeadline, anyone MAY call finalize(rootId).

The contract MUST finalize the root when all of the following hold:

  • the root is still PROPOSED;
  • block.timestamp >= challengeDeadline;
  • the root’s parent is the current finalized anchor or otherwise accepted by the anchor registry; and
  • the root has not been invalidated, blacklisted, retired, or paused by a configured safety control.

An unchallenged root finalizes without any proof-lane submissions.

Proposer Defense and Challenged Finality

Once a root is challenged, the burden of proof rests with the proposer, who MUST defend the root by establishing its validity. A challenged root MUST NOT finalize merely because time has elapsed. It finalizes only when the proposer’s defense assembles support from at least PROOF_THRESHOLD distinct proof lanes for the root, and only if the threshold is met before proofDeadline.

Defending the root is the proposer’s responsibility because the proposer’s PROPOSER_BOND is forfeited if the root fails to reach the threshold. Lane submission itself remains permissionless — any party MAY relay a valid lane submission, and a proposer MAY coordinate independent provers, TEE signers, and the Security Council to do so — but the protocol assigns the economic responsibility for the defense to the proposer and to no other party.

Each root tracks a per-lane support set (the proof bitmap) with one bit per entry in Proof Lanes. Each lane’s bit is set at most once, so duplicate submissions from the same lane MUST NOT increase the threshold count.

For each lane submission, the contract MUST:

  1. Verify that the root is in CHALLENGED.
  2. Verify that block.timestamp < proofDeadline.
  3. Verify that the proof or attestation binds to rootId.
  4. Verify the proof or attestation according to the lane-specific verifier.
  5. Set the lane’s bit in the root’s proof bitmap.
  6. Finalize the root when the bitmap contains at least PROOF_THRESHOLD distinct lanes.

A lane submission at or after proofDeadline MUST revert. Finalization of a challenged root is permissionless once the threshold is met.

If block.timestamp >= proofDeadline and the root’s proof bitmap contains fewer than PROOF_THRESHOLD distinct lanes, anyone MAY call invalidate(rootId). The contract MUST move the root to INVALIDATED and forfeit PROPOSER_BOND per Proposal Lifecycle. This is the only path to INVALIDATED; see Invalidity and Conflicts.

Invalidity and Conflicts

Invalidation occurs only via the CHALLENGED → INVALIDATED transition described in Proposer Defense and Challenged Finality: a challenged root that the proposer has not defended to PROOF_THRESHOLD distinct supporting lanes by proofDeadline MUST be invalidated. There is no fast-path invalidation from PROPOSED. A party in possession of evidence that a proposed root is incorrect MUST challenge the root; absence of supporting proofs within PROOF_PERIOD is the protocol’s sole in-band signal of invalidity.

An invalidated root MUST NOT finalize, and descendants of an invalidated root MUST NOT become accepted anchors.

If invalidity of a finalized root is discovered after the fact — for example, via an offchain proof exhibiting a conflicting (parent, l2BlockNumber) → rootId' — the protocol MUST NOT silently roll back finalized state. The configured safety process MUST be triggered: pausing the proof game type, blacklisting the game, or routing the incident to governance.

Lane-Specific Requirements

Validity Proof

The validity proof lane verifies that the transition from parentRoot at parentL2BlockNumber to rootClaim at l2BlockNumber is valid under rollupConfigHash. The verifier MAY be implemented with a zkVM, a SNARK, or another cryptographic proof system. The proof MUST bind to rootId and MUST be verified onchain or by an onchain verifier gateway.

TEE Attestation

The TEE lane accepts an attestation from a registered TEE signer over rootId. The verifier MUST check that the signer is registered, that the signer is valid for the active TEE image or measurement, that the attestation binds to rootId, and that the attestation has not expired or been revoked.

Security Council Attestation

The Security Council lane accepts a threshold signature, multisig transaction, or equivalent onchain action from the configured council. The attestation MUST bind to rootId and MUST be domain-separated from other council actions. A council attestation counts as one lane and MUST NOT finalize a challenged root by itself.

Anchor Updates

Only finalized roots may update the canonical anchor. The anchor registry MUST enforce:

  • parent validity;
  • monotonically increasing L2 block numbers;
  • at most one accepted root per (parent, l2BlockNumber); and
  • rejection of roots that are blacklisted, retired, paused, or invalidated.

The blacklist, retirement, and pause controls MUST be gated by the configured Security Council (or an equivalent guardian role). Setting these controls is the council’s safety responsibility — they are not permissionless. Anchor updates themselves SHOULD be permissionless and self-validating once the root is finalized and not gated by any of these controls.

Contract Interfaces

This WIP specifies interfaces only. Implementation details — storage layouts, clone-with-immutable-args payloads, journal encodings, verifier nullification, guardian controls, and bond escrow mechanics — follow Base Azul’s [proof system specification][base-azul] and the OP Stack [dispute game interface][op-dgi], except where this WIP explicitly diverges.

InterfaceRoleReference
IDisputeGameFactoryCreates per-proposal game clones; indexes games by proposalKey; pins l1OriginHash at creation.DisputeGameFactory
IProofSystemGamePer-proposal game: holds state, accepts lane submissions, finalizes, advances anchor. World Chain analogue of Base’s AggregateVerifier.AggregateVerifier
IAnchorStateRegistryTracks the current anchor, finalized roots, blacklist, retirement, and pause.AnchorStateRegistry
IValidityProofVerifierVerifies the VALIDITY_PROOF lane against rootId.ZKVerifier
ITEEVerifierVerifies the TEE_ATTESTATION lane against rootId.TEEVerifier
ITEEProverRegistryMaintains accepted TEE signer identities, image hashes, and proposer allowlisting.TEEProverRegistry
ISecurityCouncilSubmits and verifies SECURITY_COUNCIL attestations bound to rootId.World Chain Security Council multisig.
IStakingRegistryChecks challenger eligibility and locks, forfeits, or refunds PROPOSER_BOND and CHALLENGER_BOND.World Chain–specific; no Base analogue.

Backwards Compatibility

This proposal is intended to be implemented as a new World Chain proof game type. Existing output roots and existing Cannon-style games do not need to be reinterpreted.

The proposer interface is preserved field-for-field with the OP Stack. Specifically:

An existing OP Stack proposer that already produces (rootClaim, l2BlockNumber) against a parent reference can be reused. Domain constants (chainId, proofSystemVersion, rollupConfigHash, blockInterval, intermediateBlockInterval) and lane-specific verifier constants live on the verifier implementation, not in the proposer’s calldata, so changes to those values do not require a proposer change.

Test Cases

Before this WIP can move beyond Draft, implementations SHOULD cover at least:

  • an unchallenged root finalizes after exactly CHALLENGE_PERIOD;
  • an unstaked account cannot challenge;
  • a staked account can challenge without proof before the deadline;
  • a challenge at or after the deadline reverts;
  • a challenged root with one supporting lane does not finalize;
  • a proposer defends a challenged root to finalization with two distinct supporting lanes;
  • a challenged root the proposer fails to defend to PROOF_THRESHOLD lanes by proofDeadline invalidates and forfeits PROPOSER_BOND;
  • a lane submission at or after proofDeadline reverts and does not affect the proof bitmap;
  • subsequent challenges on an already-challenged root do not extend proofDeadline;
  • duplicate submissions from the same lane do not increase the threshold count;
  • every proof lane rejects material that does not bind to rootId;
  • anchor updates reject non-finalized, invalidated, paused, retired, or non-monotonic roots.

Security Considerations

Unchallenged-path liveness depends on honest watchers. Safety in the common case relies on at least one honest staked participant challenging an invalid root within CHALLENGE_PERIOD. The architecture does not protect against a root that no one watches.

Disputed-path safety depends on lane independence. A false challenged finality requires two lanes to accept the same invalid root. Implementations MUST avoid shared signing keys, shared verifier keys, shared operator control, and shared offchain infrastructure across lanes, because any such sharing collapses the effective threshold below two.

TEE trust assumptions live in one lane. The TEE lane rests on the hardware vendor, the enclave image measurement, the registrar that admits signers, and the revocation process. Each of these is a potential single point of failure for that lane. The TEE lane is therefore counted exactly once in the threshold and MUST NOT be used as the sole finality mechanism. A compromised registrar, stale signer set, or weakened image-measurement policy can turn this lane unsafe without affecting the other three.

Security Council is a safety valve, not a finality mechanism. Council signing keys, quorum rules, and action domains MUST be isolated from unrelated governance actions. A council attestation alone MUST NOT finalize a challenged root.

rootId domain separation is critical. Every lane MUST bind to the same rootId, and rootId itself MUST commit to chainId, proofSystemVersion, rollupConfigHash, and the parent/child block range. Missing domain separation allows replay across chains, hardfork schedules, game types, proof-system versions, or block ranges.

proposalKey is not a proof target. It exists to make factory duplicate prevention and proposer recovery deterministic. Proofs and attestations MUST bind to rootId, not merely to proposalKey, because proposalKey does not include the L1 origin captured at game creation.

Bond and stake calibration are security-critical. If CHALLENGER_BOND is too cheap, attackers can grief honest roots into the slow path. If CHALLENGER_BOND is too expensive, honest watchers may fail to challenge invalid roots. If PROPOSER_BOND is too cheap, invalid-proposal spam is cheap and forces honest watchers to lock capital per challenge; if PROPOSER_BOND is too expensive, only well-capitalized actors can propose, which centralizes the role. Exact economic parameters are out of scope for this WIP but MUST be tuned before mainnet deployment.

Copyright and related rights waived via CC0.

WIP Template

Abstract

Motivation

Specification

The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “NOT RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119 and RFC 8174.

Chain Specifics

Rationale

TBD

Backwards Compatibility

No backward compatibility issues found.

Test Cases

Reference Implementation

Security Considerations

Needs discussion.