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.
Site Navigation
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 byengine_forkchoiceUpdatedin the OP Stack Engine API). All flashblocks for the block share this ID.timestamp: The timestamp associated with this payloadbuilder_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 ofpayload_id,timestamp, andbuilder_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).
- A Flashblock Payload – Contains a
-
actor_sig: The builder’s signature over the combination of themsgand theauthorization. This attests that the message indeed comes from the holder of thebuilder_skin 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:
- Only a builder with a valid Authorization (signed by the sequencer) can get its messages accepted by peers.
- Only the genuine builder (holding the private key corresponding to
builder_sk) can produce a validbuilder_signatureon 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
StartPublishandStopPublishsignals, 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 anindex(the flashblock sequence number) and may include thebasesection 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 theauthorizer_sigin theAuthorizationagainst the known authorizer public key. This confirms that rollup-boost has indeed permitted the stated builder to produce the block with the givenpayload_idand timestamp. If this signature is missing or invalid, the message is discarded as untrusted. -
Builder Signature Verification: Next, the peer verifies the
builder_signatureon the message content using thebuilder_vkprovided 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_idin the Authorization must match theFlashblocksPayloadV1.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
timestampin 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.
- The
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 aStartPublishmessage (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 aStartPublishfrom 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 aStopPublish). 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 aStopPublishif it is publishing and learns that another builder has taken over authority for the block. The new builder will wait until it receives theStopPublishbefore continuing. - Completion –
StopPublish: When a builder receives the next FCU without an accompanyingAuthorization, it will send out aStopPublish. 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
StartPublishbut for some reason the previous publisher fails to produce aStopPublish(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 anAuthorizationfor 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
StartPublishmessage 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_getPayloadis 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
- Reduce bandwidth — Each node sends flashblocks to a bounded number of peers instead of all peers.
- Optimize latency — Nodes periodically rotate out their slowest receive peers in favor of random alternatives.
- Reliability — Multiple receive peers provide redundancy against individual peer failures.
- Trusted peer priority — Trusted peers are always served when they request flashblocks, regardless of limits.
- 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 aRequestFlashblocksand 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:
| Discriminator | Message | Direction | Description |
|---|---|---|---|
0x01 | RequestFlashblocks | Receiver → Sender | “I want to receive flashblocks from you” |
0x02 | AcceptFlashblocks | Sender → Receiver | “Accepted. I will send you flashblocks” |
0x03 | RejectFlashblocks | Sender → Receiver | “Rejected. I am at capacity” |
0x04 | CancelFlashblocks | Receiver → 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:
- Is the requester a trusted peer? → Always accept (trusted peers bypass
max_send_peers). - Is the number of non-trusted peers in the send set below
max_send_peers? → Accept. - 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:
- As peers connect and complete the
flblk/2handshake, discover whether they are trusted or untrusted. - Only request peers whose trust classification is known, so trusted peers are always considered first.
- Continue sending requests as new peers connect until
receive_set.len() >= max_receive_peers. - 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
RequestFlashblocksto a random connected peer not already inreceive_setorpending_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 asStartPublish— 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:
- Select the worst-scoring peer in the current receive set.
- Remove that peer from the receive set immediately and send
CancelFlashblocks. - Pick a replacement candidate, prioritizing trusted peers.
- 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
| Parameter | Default | Description |
|---|---|---|
max_send_peers | 10 | Maximum non-trusted peers to send flashblocks to |
max_receive_peers | 3 | Maximum peers to receive flashblocks from |
rotation_interval | 30s | How often to evaluate and potentially rotate receive peers |
latency_window | 1000 | Number 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
-
Duplicate handling must change — The current per-peer duplicate check at
connection.rs:278-291penalizes any duplicate flashblock withReputationChangeKind::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. -
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. ThePeerMsg::FlashblocksPayloadV1variant 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. -
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.
-
Protocol version bump —
Capability::new_static("flblk", 1)athandler.rs:239must be updated to version2.
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.
| 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 |
| 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:
| Field | Type | Description |
|---|---|---|
external_nullifier | bytes | A unique identifier derived from the proof version, date marker, and nonce. |
nullifier_hash | Field | A cryptographic nullifier ensuring uniqueness and preventing double-signaling. |
root | Field | The Merkle root proving inclusion in the World ID set. |
proof | Proof | Semaphore 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 asyyyy. - Month (
uint8): The current month expressed as (1-12). - PBH Nonce (
uint8): The PBH nonce, where0 <= 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_nullifieris correctly formatted and specifies the current month, year and a valid nonce. - The
proofis valid and specifies theexternal_nullifieras a public input to the proof. - The
external_nullifierhas not been used before, ensuring that thepbh_nonceis 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
monthis the current month - The
yearis the current year (specified asyyyy) - The
pbhNonceis <pbhNonceLimit. PBH nonces are0indexed, meaning if thepbhNoncelimit is29, a user is allotted30PBH 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 docsto 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.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
| WIP | Title | Status | Category | Created |
|---|---|---|---|---|
| 1001 | WorldID Native Account Abstraction | Draft | Core | 2026-03-27 |
| 1002 | WorldID Subsidy Accounting | Draft | Core | 2026-04-21 |
WIP Types
Each WIP must declare one of the following types:
| Type | Description |
|---|---|
| Standards Track | Describes 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. |
| Meta | Describes a process surrounding World Chain or proposes a change to a process. Meta WIPs require community consensus but do not change the protocol itself. |
| Informational | Provides 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
| Category | Description |
|---|---|
| Core | Improvements requiring a consensus fork or changes to core protocol components (e.g., precompiles, transaction types, state). |
| Networking | Improvements to the p2p networking layer or node communication protocols. |
| Interface | Improvements around client API/RPC specifications and standards. |
WIP Statuses
WIPs follow this lifecycle:
Idea → Draft → Review → Last Call → Final
↓
Stagnant
↓
Withdrawn
| Status | Description |
|---|---|
| Idea | An idea that is not yet a formal WIP. Discussed informally before being formalized. |
| Draft | The first formally tracked stage. The WIP is being actively developed and is not yet stable. |
| Review | The WIP author has marked the WIP as ready for peer review. Reviewers may submit feedback via GitHub comments. |
| Last Call | The final review window before finalization. The WIP will move to Final if no substantial objections are raised during this window. |
| Final | The WIP has been finalized and represents an accepted standard. No further changes are expected other than errata corrections. |
| Stagnant | A WIP in Draft or Review that has had no activity for 6 months. It may be resurrected by updating its status back to Draft. |
| Withdrawn | The 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:
| Field | Required | Description |
|---|---|---|
wip | ✅ | The unique WIP number (assigned by a maintainer). |
title | ✅ | A short, descriptive title. Must not repeat the WIP number. |
description | ✅ | A single full sentence summarizing the proposal. |
author | ✅ | Comma-separated list of authors. Each author listed as Name (@GitHubHandle) or Name <email@example.com>. |
status | ✅ | Current lifecycle status (see WIP Statuses). |
type | ✅ | The WIP type (see WIP Types). |
category | ⚠️ | Required for Standards Track WIPs only. |
created | ✅ | ISO 8601 date (YYYY-MM-DD) when the WIP was first submitted. |
requires | ❌ | Other WIPs or EIPs that this WIP depends on. |
Required Sections
Every WIP must include the following sections in this order:
- Abstract — A multi-sentence paragraph summarizing the proposal. Should be readable in isolation.
- Specification — Detailed technical description of the proposed change. Must include the RFC 2119 keyword boilerplate if RFC 2119 terms are used.
- Rationale — Explanation of design decisions, alternatives considered, and why this approach was chosen.
- Security Considerations — Discussion of all relevant security implications. A WIP cannot advance to
Finalwithout this section being deemed sufficient by reviewers. - 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
-
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.
-
Fork and branch. Fork the
worldcoin/world-chainrepository and create a branch namedwip/<short-description>. -
Use the template. Copy
wip-template.mdto a new file namedwip-draft_<short_title>.md(you’ll rename it once a number is assigned). -
Fill in the front matter and sections. Delete all HTML comment blocks before submitting.
-
Open a Pull Request. Target the
mainbranch. A maintainer will review your WIP, suggest a number, and rename the file. -
Iterate. Respond to review feedback. Once consensus is reached, the WIP status will be updated.
-
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 EIP-1271 compliant session verifiers.
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 verifiers act as the transaction level signatories via programmable EIP-1271 smart contracts. Session verifiers 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
| Name | Type | Value | Meaning |
|---|---|---|---|
WORLD_TX_TYPE | uint8 | 0x1D | EIP-2718 transaction type for World Chain account transactions. |
MAX_SESSION_VERIFIERS | uint256 | 20 | Maximum active session verifiers per account. |
WORLD_CHAIN_ACCOUNT_DOMAIN | bytes32 | keccak256("WIP1001_ACCOUNT") | Domain for account address derivation. |
WORLD_CHAIN_ACCOUNT_CREATE_DOMAIN | bytes32 | keccak256("WORLD_CHAIN_ACCOUNT_CREATE") | Domain for account creation authorization. |
WORLD_CHAIN_ACCOUNT_SET_DOMAIN | bytes32 | keccak256("WORLD_CHAIN_ACCOUNT_SET") | Domain for key ring replacement authorization. |
Activation Parameters
WIP-1001 MUST NOT activate until every parameter below is assigned in fork configuration.
| Name | Type | Value | Requirement |
|---|---|---|---|
WORLD_CHAIN_ACCOUNT_MANAGER | address | TBD | Predeploy address for the account manager. |
WORLD_CHAIN_ACCOUNT_ROUTER_CODE_HASH | bytes32 | TBD | Runtime code hash required at every World Chain account address. |
EIP1271_VALIDATION_GAS_LIMIT | uint64 | TBD | Fixed gas forwarded to isValidSignature. |
EXECUTION_TRACE_VALIDATION_GAS_LIMIT | uint64 | TBD | Fixed gas forwarded to evaluateSessionPolicy. |
BLOCK_VALIDATION_GAS_BUDGET | uint64 | TBD | Per-block gas budget reserved for 0x1D validation calls. |
MIN_VALIDATION_FAILURE_FEE | uint256 | TBD | Minimum wei charged to the account on validation failure. |
MAX_EXECUTION_TRACE_BYTES | uint32 | TBD | Maximum ABI-encoded trace size passed to a session verifier. |
MAX_PAYLOAD_DATA_BYTES | uint32 | TBD | Maximum length of data in a 0x1D envelope. |
MAX_ACCESS_LIST_ENTRIES | uint32 | TBD | Maximum number of EIP-2930 access-list entries in a 0x1D envelope. |
MAX_SESSION_SIGNATURE_BYTES | uint32 | TBD | Maximum length of the session signature field. |
MAX_ADMIN_AUTHORIZATION_BYTES | uint32 | TBD | Maximum length of adminAuthorization calldata. |
MAX_VERIFIER_INSTALL_DATA_BYTES | uint32 | TBD | Maximum length of installation in an admin or session verifier configuration. |
EDDSA_PRECOMPILE | address | TBD | EdDSA verification precompile address and ABI. |
BLS12_381_PRECOMPILE | address | TBD | BLS12-381 verification precompile address and ABI. |
Reusable Crypto Precompiles
Signer contracts MAY call protocol-supported cryptographic precompiles from restricted validation frames.
| Primitive | Source |
|---|---|
ecrecover | Ethereum precompile |
sha256 | Ethereum precompile |
ripemd160 | Ethereum precompile |
identity | Ethereum precompile |
modexp | Ethereum precompile |
bn254 add | Ethereum precompile |
bn254 scalar multiplication | Ethereum precompile |
bn254 pairing | Ethereum precompile |
secp256r1 verify | RIP-7212 |
EdDSA verify | Activation parameter |
BLS12-381 verify | Activation parameter |
Account State
World Chain accounts are created and managed by WORLD_CHAIN_ACCOUNT_MANAGER.
struct Account {
WorldChainAccountVerifier admin;
bytes32 accountSalt;
WorldChainAccountVerifier[] sessionVerifiers;
bytes32 keyRingHash;
uint64 adminNonce;
uint64 transactionNonce;
}
struct WorldChainAccountVerifier {
address verifier;
bytes installation;
}
For every existing account:
- the account address MUST have deployed runtime bytecode whose hash equals
WORLD_CHAIN_ACCOUNT_ROUTER_CODE_HASH; admin.verifierMUST be a deployed EIP-1271 verifier implementation and the fulladminvalue is immutable for the life of the account address;sessionVerifiers.lengthMUST be in[1, MAX_SESSION_VERIFIERS];- each
installation.lengthinadminandsessionVerifiersMUST be at mostMAX_VERIFIER_INSTALL_DATA_BYTES; - each
sessionVerifiers[i].verifierMUST be a deployed EIP-1271 verifier implementation adhering toIWorldChainSessionVerifier; sessionVerifiersMUST NOT contain duplicate verifier addresses;keyRingHash == keccak256(abi.encode(sessionVerifiers));adminNonceis consumed only by admin operations;transactionNonceis consumed only by successful0x1Dtransaction execution attempts;
Verifier behavior is selected by verifier implementation address, not by a protocol-level enum. installation is opaque to the protocol and is interpreted only by the selected verifier implementation.
After account creation, setKeyRing is the only execution path that mutates sessionVerifiers, keyRingHash, or validation-affecting account storage for active session verifiers. 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(
address verifier,
bytes32 hash,
bytes calldata signature
) external view returns (bytes4 magicValue);
function evaluateSessionPolicyForVerifier(
address verifier,
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 installed session verifier address; unknown session verifier addresses MUST fail without executing verifier implementation code.
isValidSignatureForAdmin and isValidSignatureForVerifier MUST dispatch using calldata IERC1271.isValidSignature.selector || abi.encode(hash, signature). evaluateSessionPolicyForVerifier MUST dispatch using calldata IWorldChainSessionVerifier.evaluateSessionPolicy.selector || abi.encode(context).
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;
address sessionVerifier;
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. A verifier implementation’s install function MUST fully write all validation-affecting state implied by installation into the verifier’s own deterministic account-storage namespace. Removed verifier state MAY remain in account storage, but it MUST be unreachable unless that verifier address 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, address sessionVerifier) external view returns (bool);
function getAuthorizedSessionVerifier(address account, address sessionVerifier) external view returns (WorldChainAccountVerifier memory);
}
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:
- reject
admin.verifier == address(0); - require
admin.verifierand each initial session verifier to have deployed code; - require
initialSessionVerifiers.lengthto be in[1, MAX_SESSION_VERIFIERS]; - reject duplicate or zero session verifier addresses;
- reject oversized
admin.installationor session verifierinstallation; - compute
adminHash = keccak256(abi.encode(admin)); - derive
account; - reject if
accountalready exists or already has deployed code; - compute
keyRingHash = keccak256(abi.encode(initialSessionVerifiers)); - atomically install canonical account-router runtime bytecode at
account; - call
IWorldChainAccountRouter(account).installAdmin(admin); - call
IWorldChainAccountRouter(account).installKeyRing(initialSessionVerifiers); - verify
createHashthrough the account router’s admin validation path; - initialize manager state with
admin,adminNonce = 0,transactionNonce = 0,sessionVerifiers = initialSessionVerifiers, andkeyRingHash; - emit
AccountCreated(account, admin.verifier, adminHash, accountSalt, keyRingHash).
setKeyRing
setKeyRing is the only post-creation method that mutates the key ring and MUST:
- require
accountto exist; - require
expectedCurrentKeyRingHash == keyRingHash; - require
sessionVerifiers.lengthto be in[1, MAX_SESSION_VERIFIERS]; - reject duplicate, zero, undeployed, or oversized session verifier entries;
- compute
newKeyRingHash = keccak256(abi.encode(sessionVerifiers)); - reject
newKeyRingHash == keyRingHash; - compute
setKeyRingHashusing the currentadminNonce; - verify
setKeyRingHashthrough the account router’s admin validation path; - call
IWorldChainAccountRouter(account).installKeyRing(sessionVerifiers); - set
previousKeyRingHash = keyRingHash; - replace the account’s full
sessionVerifiersset with the suppliedsessionVerifiers; - set
keyRingHash = newKeyRingHash; - increment
adminNonceby one; - 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 EIP-1271 signature check, the protocol MUST perform a STATICCALL from WORLD_CHAIN_ACCOUNT_MANAGER to the signer with value 0, gas exactly EIP1271_VALIDATION_GAS_LIMIT, and calldata IERC1271.isValidSignature.selector || abi.encode(hash, signature). The call succeeds only if it returns EIP1271_MAGIC_VALUE and triggers no tracer rule violation.
For each session policy check, the protocol MUST perform a STATICCALL from WORLD_CHAIN_ACCOUNT_MANAGER to the session verifier with value 0, gas exactly EXECUTION_TRACE_VALIDATION_GAS_LIMIT, and calldata IWorldChainSessionVerifier.evaluateSessionPolicy.selector || abi.encode(context). The call succeeds only if it returns true and triggers no tracer rule violation.
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 ID | ERC-7562 ref | Description |
|---|---|---|
WIP1001-VR-1 | OP-011 | Forbid block-context opcodes: BLOCKHASH, COINBASE, TIMESTAMP, NUMBER, PREVRANDAO/DIFFICULTY, GASLIMIT, BASEFEE, BLOBHASH, BLOBBASEFEE. |
WIP1001-VR-2 | OP-011 | Forbid transaction-context opcodes: GASPRICE, ORIGIN. |
WIP1001-VR-3 | OP-020 | Forbid external account inspection: BALANCE, SELFBALANCE, EXTCODESIZE, EXTCODECOPY, EXTCODEHASH. |
WIP1001-VR-4 | OP-031, OP-032 | Forbid persistent and transient state mutation: SSTORE, TLOAD, TSTORE. |
WIP1001-VR-5 | OP-031 | Forbid log emission: LOG0, LOG1, LOG2, LOG3, LOG4. |
WIP1001-VR-6 | OP-031, OP-040 | Forbid CALL, CALLCODE, CREATE, CREATE2. |
WIP1001-VR-7 | OP-051 | Forbid SELFDESTRUCT. |
WIP1001-VR-8 | OP-061 | Permit STATICCALL only to allowlisted precompiles. |
WIP1001-VR-9 | OP-052 | Permit 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,
sessionVerifier,
to,
value,
data,
accessList,
signature
])
The signing hash is:
signingHash = keccak256(
0x1D ||
rlp([
chainId,
nonce,
maxPriorityFeePerGas,
maxFeePerGas,
gasLimit,
account,
sessionVerifier,
to,
value,
data,
accessList
])
);
The transaction hash is:
txHash = keccak256(
0x1D ||
rlp([
chainId,
nonce,
maxPriorityFeePerGas,
maxFeePerGas,
gasLimit,
account,
sessionVerifier,
to,
value,
data,
accessList,
signature
])
);
Envelope validity requirements:
chainIdMUST equal the executing chain ID;nonceMUST equalAccount.transactionNonce;toMUST 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;accessListMUST use EIP-2930 encoding;maxFeePerGasandmaxPriorityFeePerGasfollow 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:
- decode and validate the envelope;
- load
accountfromWORLD_CHAIN_ACCOUNT_MANAGER; - require
sessionVerifierto be authorized foraccountin the currentsessionVerifiersset and load the currentkeyRingHash; - require the account balance to cover the worst-case payload gas charge and
MIN_VALIDATION_FAILURE_FEE; - compute
signingHash; - call
sessionVerifier.isValidSignature(signingHash, signature)in a restricted validation frame; - if signature validation fails, apply validation-failure semantics and stop;
- increment
Account.transactionNonceby one; - execute the payload tentatively with
accountas the EVM sender and gas charged against envelopegasLimit; - construct
ExecutionTraceContextwithcontext.transaction.keyRingHashset to the current accountkeyRingHash; - if
abi.encode(context.trace).length > MAX_EXECUTION_TRACE_BYTES, discard tentative payload state, apply trace-overflow semantics, and stop; - call
sessionVerifier.evaluateSessionPolicy(context)in a restricted validation frame; - if policy validation fails, discard tentative payload state, apply validation-failure semantics, and stop;
- otherwise commit the normal payload result, including a reverted payload result if the target call reverted.
Failure classes are:
| Failure | Nonce | Payload state/logs | Fee behavior |
|---|---|---|---|
| Envelope, account, balance, nonce, or authorization precheck failure | unchanged | none | transaction is invalid and not included |
| Validation budget exhausted | unchanged | none | transaction is deferred |
| Signature validation failure | incremented | none | charge max(MIN_VALIDATION_FAILURE_FEE, baseFee * EIP1271_VALIDATION_GAS_LIMIT) |
Trace exceeds MAX_EXECUTION_TRACE_BYTES | incremented | discarded | charge as a failed payload execution under the envelope gas rules |
| Session policy failure | incremented | discarded | charge max(MIN_VALIDATION_FAILURE_FEE, baseFee * EIP1271_VALIDATION_GAS_LIMIT); payload gas is not additionally charged |
| Payload execution revert with accepted policy | incremented | EVM revert result committed | charge 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, sessionVerifier, nonce, signingHash, signature, and the account’s active keyRingHash. A transaction is eligible to remain pooled only while all conditions hold:
accountexists;- the account balance can cover
MIN_VALIDATION_FAILURE_FEEat the currentbaseFee; - the transaction nonce can still become executable under the account nonce ordering rules;
sessionVerifieris a member of the account’s currentsessionVerifiersset;- the current account
keyRingHashequals 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, sessionVerifier, 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 verifier address. Validation-affecting updates MUST be represented by deploying or selecting a different session verifier address 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; 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. 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, and reordered verifier sets;createvalidation, including deployed-code checks, duplicate and zero verifier rejection, account-exists rejection, initial nonce values, initialkeyRingHash,AccountCreated, and default World ID admin signer account binding;setKeyRingvalidation, including staleexpectedCurrentKeyRingHash, duplicate, zero, undeployed, empty, oversized, and no-op verifier set rejection;setKeyRingsuccess behavior, including full-set replacement,newKeyRingHash,adminNonceincrement,KeyRingSet, authorization lookup updates, and failure atomicity;createHashandsetKeyRingHashdomain separation, parameter binding, nonce binding, and negative replay cases across accounts, chains, managers, hashes, and operation types;0x1DRLP 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 verifier set, including unauthorized verifier 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, verifier membership, account balance, or nonce eligibility changes; - signer-state determinism, including rejection of validation-affecting session verifier updates behind an unchanged verifier address;
- restricted-frame positive cases and each forbidden opcode or call target;
- default signer factory
CREATE2address 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
createMUST NOT be valid forsetKeyRing, 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.
setKeyRingis the only post-creation key ring mutation path. A compromised session verifier remains authorized until an admin-authorized replacement set is included.expectedCurrentKeyRingHashprotectssetKeyRingauthorizations from applying to an unexpected current set. Admin tooling SHOULD surface the complete replacement set before signing because omitted verifiers are removed atomically.keyRingHashbinds cached validation to the orderedSessionVerifier[]set. Clients MUST discard cached signature results when the active hash changes, even if the transaction’ssessionVerifierremains present in the new set.keyRingHashdoes not commit to mutable verifier behavior by itself. Session verifier state that can affectisValidSignatureorevaluateSessionPolicyMUST be immutable for that verifier address; upgrades that affect validation require a new verifier address installed throughsetKeyRing.- The protocol MUST verify that
sessionVerifieris authorized foraccountbefore calling EIP-1271. Unauthorized verifiers MUST fail as a precheck rather than receiving a validation call. - 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_BUDGETis scarce.MIN_VALIDATION_FAILURE_FEEMUST 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, andMAX_ADMIN_AUTHORIZATION_BYTESbound 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.
DELEGATECALLinside restricted validation frames is safe only when the delegatee bytecode satisfies the same tracer rules recursively.- The
0x1Denvelope exposesaccountandsessionVerifier. Applications that need stronger privacy should use verifier contracts that aggregate or rotate credentials. - World ID signers MUST bind
block.chainidintoworldIdActionor 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. createdoes not bindmsg.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
A WorldID Subsidy Accounting system enables per-credential, ETH-denominated transaction-fee subsidies for verified humans on World Chain. A World ID opens a per-period subsidy record whose nullifier — derived from a World ID 4.0 Uniqueness Proof — is the record’s primary key for budget, authorization, and replay state. The initial claim is driven by a multi-item proof request authorized by a World-Chain-operated WIP-101 relying party contract (an ERC-1271-style smart-contract signer); the authenticator lists one RequestItem per credential the WorldID holds and emits one Uniqueness Proof per item, all sharing the same nullifier. Each proof’s signal carries the initial set of authorized accounts permitted to spend the budget. The first on-chain call also binds a World ID sessionId to the nullifier record; subsequent Session Proofs against that sessionId may claim credentials acquired later in the period or add / remove authorized addresses. Budgets are governance-configurable per issuerSchemaId, and subsidy records expire at period boundaries.
Authorized addresses may be legacy EOAs, smart contract accounts, or WIP-1001 World Chain Accounts — the subsidy system is orthogonal to the account type it funds.
Motivation
World ID holders are currently already being subsidized for their transaction fees on World Chain. Native, ETH-denominated subsidies allow for a more fluid fee market, and enshrine fee allowances for World ID holders at the protocol (or builder) level rather than relying on off-chain paymasters. Denominating in ETH (rather than gas units) gives governance more flexibility to fold in factors like the L1 blob-fee price or DeFi congestion when sizing budgets.
Per-credential budgeting lets subsidy weight reflect credential strength — a Proof-of-Human (Orb) credential carries more weight than a Phone credential, and governance can tune each independently via issuerSchemaId.
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.
Deployment Model
The Subsidy Accounting component can be deployed as either:
- A precompile, enabling the protocol to natively read subsidy accounting state during transaction validation and execution. This is required if subsidies are enforced at the protocol level (e.g., the EVM itself checks and deducts budget).
- A standard contract (e.g., a predeploy), where the builder reads contract state via
eth_callduring block construction to determine subsidy eligibility and track budget consumption. This avoids protocol-level changes and allows iterating on subsidy logic without hard forks.
The interface is identical in both cases. The choice of deployment model determines where enforcement happens (protocol vs. builder) but does not affect the accounting logic itself.
Relying Party Signer
For rpId = WORLD_CHAIN_RP_ID the registered RP signer in the World ID RpRegistry is a stateless WIP-101-style smart contract deployed on World Chain, rather than an off-chain EOA signer service. OPRF nodes detect the contract via ERC-165 supportsInterface at registry ingest and validate every incoming ProofRequest by performing a read-only eth_call to its verifyRpRequest(...) entry point before contributing their OPRF share. Framing the RP as an on-chain primitive removes the trusted off-chain key-holder dependency; all economic policy (authorised addresses, budget, replay) remains in the Subsidy Accounting component, not the signer.
The signer conforms to the WIP-101 interface — an ERC-1271-style magic-value check specialised for proof-request approval:
interface IRpSigner 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).
Validation branches on the most-significant byte of action, which the World ID 4.0 protocol uses to discriminate proof class:
- Uniqueness Proofs (
action[0] == 0x00). The contract MUST requireactionto equal the expected per-period claim action for someperiodNumberthat falls within the current boundary-window derived fromblock.timestamp / PERIOD_LENGTH. The contract SHOULD also validatecreatedAt ≤ block.timestamp ≤ expiresAtand boundexpiresAt - createdAtto a reasonableMAX_REQUEST_TTL. - Session Proofs (
action[0] == 0x02). Session Proofactionvalues are random per-proof OPRF outputs and are NOT signed by the RP — the contract MUST accept them unconditionally once the class prefix is confirmed. Replay / authorization guarantees for Session-Proof-gated operations live on-chain in the claim component (nullifier-record existence, claimed-credentials bitmap, monotonic update nonce — see Signal Binding and Per-Proof Behavior), not at the RP layer.
The contract reads no subsidy, authorization, or per-user state. data (the request’s wip101_data field, capped at 1 KiB by the OPRF-node layer) is RESERVED and MUST either be required empty or ignored outright — deployments MAY revert on non-empty data to reduce attack surface.
Operational invariants:
- OPRF-node RPC target. WIP-101 verification is off-circuit: each OPRF node independently
eth_calls the contract against its configured RPC. All participating nodes’ RPC endpoints MUST reach World Chain for this signer registration to function. - Monotone / reorg-safe. Because verification is quorum-trusted rather than π₁-bound, the contract MUST NOT depend on state that can flip between nodes’ observation windows. The stateless shape above trivially satisfies this.
- Single-signer constraint.
RpRegistry.RelyingParty.signeris scalar; multi-key registration is deferred by the registry. Multi-party RP policy (governance, rate-limiting, rotation) MUST therefore be embedded inside this single contract.
Deployment preconditions:
rpId = WORLD_CHAIN_RP_IDregistered in the World IDRpRegistrywith this contract assignerand a matchingoprfKeyId.- OPRF DKG ceremony run for this
rpIdso nodes can contribute shares. - Participating OPRF nodes’ RPC configuration updated to point at World Chain.
Subsidy Accounting Interface
Manages all subsidy accounting state:
- Authorization map: reverse index from authorized account address to the set of
nullifierrecords the address may draw budget from. Used at transaction-execution time to select which record’s budget is charged; one address MAY be authorized under multiple records simultaneously. - Subsidy budget map: maps
nullifiervalues to the remaining ETH-denominated budget (in Wei). Budget accumulates as additional credential proofs are submitted under thatnullifier. - Claimed-credentials map: for each
nullifier, tracks whichissuerSchemaIdvalues have already been claimed (e.g., as a bitmap or set). Prevents double-claiming a credential under the samenullifier. - Session bridge map: maps each
nullifierto thesessionIdestablished at initial claim, so Session Proofs for subsequent operations on thatnullifiercan be verified. - Credential budget configuration: maps
issuerSchemaIdto claimable budget amount in Wei (governance-configurable per credential type).
Existence of a record in the subsidy budget map is itself the per-period replay guard: a Uniqueness Proof carrying a nullifier that already has a record in the current period is rejected as a replay.
Exposes methods for:
- Initial subsidy claims (atomically verify the per-credential Uniqueness Proofs emitted from a multi-item
ProofRequest, open thenullifierrecord with itssessionIdand initial authorized addresses, and credit every supplied credential’s budget) - Mid-period credential additions (verify a Session Proof against the stored
sessionIdand credit an additional credential’s budget) - Address updates (verify a Session Proof against the stored
sessionIdand update the authorized addresses) - Budget lookup (remaining budget for a
nullifieror authorized address) - Budget consumption (called by the protocol or builder during transaction execution)
interface ISubsidyAccounting {
/// @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 {
uint256 issuerSchemaId;
bytes proof;
}
/// @notice Atomic initial per-period claim. Accepts the full set of per-credential
/// Uniqueness Proofs emitted from one multi-item `ProofRequest` for
/// `"period_proof" || periodNumber`. 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`.
function claimSubsidy(
uint256 nullifier,
uint256 sessionId,
address[] calldata addAddresses,
ClaimItem[] calldata items
) external;
/// @notice Adds budget for a credential acquired after the initial claim in the
/// current period. Verifies a Session Proof against the `sessionId` stored
/// for `nullifier`; the contract recomputes the expected `signalHash`.
/// MUST revert if `nullifier` has no subsidy record in the current period.
/// MUST revert if `issuerSchemaId` has already been claimed under `nullifier`.
function claimAdditionalCredential(
uint256 nullifier,
uint256 issuerSchemaId,
uint256 sessionNullifier,
bytes calldata proof
) external;
/// @notice Updates the subsidized addresses for the subsidy record keyed by `nullifier`.
/// Verifies a Session Proof against the `sessionId` stored for `nullifier`;
/// the contract recomputes the expected `signalHash`.
/// `nonce` MUST equal the record's monotonic update nonce (starts at 0); the contract
/// bumps it on success. Prevents Session Proof replay of prior address updates.
function updateAddresses(
uint256 nullifier,
uint256 nonce,
address[] calldata addAddresses,
address[] calldata removeAddresses,
uint256 sessionNullifier,
bytes 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.
/// If the address maps to multiple nullifiers, the same deterministic selection rule
/// used during budget consumption applies here.
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 subsidy records associated with an address.
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, uint256 issuerSchemaId) external view returns (bool);
/// @notice Consume budget for a subsidy record. Called during tx execution (by protocol or builder).
function consumeBudget(uint256 nullifier, uint256 gasUsed, uint256 baseFee) external;
/// @notice Set the claimable budget amount (in Wei) for a credential type. Governance only.
function setCredentialBudget(uint256 issuerSchemaId, uint256 budgetWei) external;
}
Authorization Map
Reverse index from authorized account address to the set of nullifier records the address may draw budget from. Populated on claimSubsidy from the addAddresses list; mutated throughout the period by updateAddresses. Authorized addresses MAY be EOAs, smart contract accounts, or WIP-1001 World Chain Accounts.
Subsidy Budget Map
Maps nullifier values to the per-period subsidy record — remaining ETH-denominated budget (in Wei), the sessionId used to verify subsequent Session Proofs, the bitmap of claimed issuerSchemaId values, the authorized-address set, and a monotonic update nonce incremented on each successful updateAddresses (prevents Session Proof replay). Budget accumulates as the initial multi-item claimSubsidy and later claimAdditionalCredential calls add credentials. A helper lookup by authorized address MAY resolve the budget through the same deterministic nullifier-selection rule used during transaction execution.
Claimed-Credentials Map
For each active nullifier, tracks which issuerSchemaId values have already been claimed. Prevents a caller from submitting two proofs for the same (nullifier, issuerSchemaId) pair. Together with nullifier-record existence, this is the full replay guard — no separate “used nullifier” set is required.
Periods, Nullifiers, and Sessions
Subsidy claims are bound to a per-period nullifier derived from a World ID 4.0 Uniqueness Proof:
- Relying party is World Chain (e.g.,
rpId = 480) - Action is
"period_proof" || periodNumber - Signal is described under 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 at initial-claim time and is NOT a caller-chosen value. It is stored by the component on the first claimSubsidy call for a given nullifier and retained for Session Proof verification during the period.
Session Proof Primer
A Session Proof proves continuity with the same stored sessionId without reusing the per-period claim action. The sessionId is the long-lived identifier retained for the subsidy record during the period. The sessionNullifier is fresh per proof, passed only to verifySession, and discarded after verification. Session Proofs are used for two distinct operations under a nullifier:
- Adding an additional credential’s budget (
claimAdditionalCredential) when a credential is acquired after the initial claim - Adding or removing authorized addresses (
updateAddresses)
A period could be e.g. one month. It likely makes sense to divide the period into multiple slots with sub-budgets for each slot to prevent traffic spikes (e.g., when all users claim their monthly budget at once after a token launch).
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, mirroring the WIP-1001 pattern. 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 ClaimAdditionalCredentialSignal {
bytes32 tag; // == keccak256("WIP-1002/claimAdditionalCredential")
uint256 nullifier;
address msgSender;
}
struct UpdateAddressesSignal {
bytes32 tag; // == keccak256("WIP-1002/updateAddresses")
uint256 nullifier;
uint256 nonce;
address[] addAddresses;
address[] removeAddresses;
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.
Replay protection. For claimSubsidy — nullifier-record existence; the record cannot be re-opened in the same period. For claimAdditionalCredential — the per-record claimed-credentials bitmap; the same issuerSchemaId cannot be claimed twice under one nullifier. For updateAddresses — the supplied nonce MUST equal the subsidy record’s monotonic update nonce; the contract bumps it on success, invalidating any earlier Session Proof for an address update.
Per-Proof Behavior
For claimSubsidy (atomic over all items):
- Require
itemsto be non-empty anditems[*].issuerSchemaIdto be distinct. - Require
nullifierto have no record in the current period. - Recompute
signalHashfrom a populatedClaimSubsidySignal. - Verify every
items[i].proofas a Uniqueness Proof for"period_proof" || periodNumberagainst the recomputedsignalHash; all proofs MUST share the samenullifierpublic input. - Create the record: store
sessionId, set the authorized-address set toaddAddresses, mark everyitems[i].issuerSchemaIdas claimed, sum their configured budgets intoremainingWei, set the update nonce to0.
For claimAdditionalCredential:
- Require
nullifierto have a record in the current period; load itssessionId. - Recompute
signalHashfrom a populatedClaimAdditionalCredentialSignal. - Verify a Session Proof against
sessionIdwith the recomputedsignalHash. - Require
issuerSchemaIdto not already be in the claimed-credentials bitmap for thisnullifier; mark it claimed and add its configured budget.
For updateAddresses:
- Require
nullifierto have a record in the current period; load itssessionIdand update nonce. - Require the caller-supplied
nonceto equal the stored update nonce. - Recompute
signalHashfrom a populatedUpdateAddressesSignal. - Verify a Session Proof against
sessionIdwith the recomputedsignalHash. - Apply
addAddressesandremoveAddressesto the authorized-address set; bump the stored update nonce.
A previously-claimed credential in the same period under the same nullifier MUST NOT be reused in either claimSubsidy or claimAdditionalCredential. updateAddresses changes authorization only; it does not mint additional budget.
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):
| Credential | issuerSchemaId | Budget |
|---|---|---|
| Proof-of-Human (Orb) | 0x01 | 50,000 Gwei |
| Phone | 0x02 | 10,000 Gwei |
| NFC | 0x03 | 20,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.
The authenticator builds one ProofRequest for the user’s World ID with:
- Action:
"period_proof" || 7 - Two
RequestItems: one for PoH (issuerSchemaId = 0x01) and one for Phone (issuerSchemaId = 0x02) - The same
signalon both items (its bytes are the pre-image of the sharedclaimSubsidysignal binding) - A fresh
sessionIdgenerated by the authenticator via OPRF
This yields two per-credential Uniqueness Proofs sharing one nullifier and one signal_hash. The user submits them in a single atomic call:
claimSubsidy(
nullifier,
sessionId,
[0xAlice, 0xBob],
[ ClaimItem(0x01, proof_PoH), ClaimItem(0x02, proof_Phone) ]
);
The component recomputes signalHash, verifies both proofs against it, creates the nullifier record, stores sessionId, authorizes [0xAlice, 0xBob], marks {0x01, 0x02} claimed, sets update nonce to 0, and credits 60,000 Gwei (PoH + 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 claim its budget.
Later in period 7 the user obtains an NFC credential. The authenticator generates a Session Proof against the stored sessionId with a fresh sessionNullifier2, for issuerSchemaId = 0x03; the signal is set so the proof’s signal_hash equals the recomputed claimAdditionalCredential signalHash. The user calls:
claimAdditionalCredential(nullifier, 0x03, sessionNullifier2, proof);
The component verifies the Session Proof against the stored sessionId, marks 0x03 claimed under nullifier, and credits 20,000 Gwei.
State after step 2: budget = 80,000 Gwei, authorized = [0xAlice, 0xBob], claimed = {0x01, 0x02, 0x03}, update nonce = 0.
Step 3: Session Proof — revoke an address.
The user wants to remove 0xBob. They generate another Session Proof against sessionId with a fresh sessionNullifier3; the signal is set so the proof’s signal_hash equals the recomputed updateAddresses signalHash for nonce = 0 and the address lists [] / [0xBob]:
updateAddresses(nullifier, 0, [], [0xBob], sessionNullifier3, proof);
The component verifies the Session Proof, removes 0xBob from nullifier’s authorized set, and bumps the update nonce to 1.
State after step 3: budget = 80,000 Gwei (minus any consumed), authorized = [0xAlice], claimed = {0x01, 0x02, 0x03}, update nonce = 1.
Budget Refresh
A subsidy record keyed by nullifier and its authorized accounts list is active for exactly one period. At the end of the period, all subsidies expire.
To prevent congestion from all accounts submitting refresh proofs at the start of a new period, the next period’s initial claimSubsidy can be submitted during the current period.
Note that the claim nullifiers of the same World ID across different periods cannot be linked. A user that wants a new on-chain pseudonym can simply authorize different addresses under the next period’s nullifier; if a World ID authorizes independent addresses in each period, it remains fully anonymous across periods.
Subsidy Accounting Flow
For an incoming transaction:
- The protocol (or builder) looks up whether the sender address has an associated subsidy
nullifierin the authorization map. - If the address maps to more than one
nullifier, a deterministic rule selects which record’s budget to use. A WIP-10010x1Dtransaction MAY extend its envelope with an OPTIONALsubsidy_nullifierfield; if present and theaccountis authorized under thatnullifier, the declared budget is consumed. - The remaining ETH-denominated budget (
remainingWei) is decremented bygasUsed * baseFee(Wei) of the transaction.
getBudget(address) SHOULD apply the same deterministic nullifier-selection rule so off-chain callers observe the same effective budget that transaction execution would consume.
A 0x1D transaction MAY also explicitly opt out of budget consumption via the same extension path.
Claim Transaction Subsidy
The claim-side mutations (claimSubsidy, claimAdditionalCredential, updateAddresses) 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 prescribe how this bootstrap gas is paid; deployments MAY adopt any of the following, or combine them:
- User-paid. The claim transaction is paid in ETH by the caller like any ordinary transaction. Simplest; degenerates for users with zero ETH, which is the target population this system is designed to serve.
- Self-subsidized. The protocol or builder simulates the claim, observes the resulting budget, and charges the claim transaction’s own gas against it. Most aligned with the “no native ETH required” goal; requires execution-layer support for speculative simulation or a predictable upper bound on the claim’s gas so the subsidy can be pre-deducted.
- Protocol-funded bootstrap allowance. A small fixed allowance per
(rpId, nullifier, period), drawn from a protocol pool, covers just the claim transaction independently of the budget it ultimately mints. No simulation required; pool sizing and top-ups are a governance question. - Relayer-paid. An off-chain relayer (e.g. WorldApp infrastructure) submits and pays for the claim transaction and is reimbursed out of band. Orthogonal to on-chain accounting; decouples bootstrap from the protocol at the cost of a trusted or economically-incentivised relayer.
Non-claim transactions consume budget through the Subsidy Accounting Flow above; for the virtual base-fee discount mechanism consumed by those transactions see the sibling WIP-1003.
Rationale
Subsidy accounting deployment flexibility. The component is designed with an identical interface whether deployed as a precompile or a standard contract (e.g., predeploy). As a contract with builder-level enforcement, it avoids protocol changes and allows faster iteration. As a precompile, it enables in-protocol enforcement. Starting with a contract is a pragmatic first step; migration to a precompile is straightforward if needed.
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; nullifier-record existence prevents re-opening a record under the same nullifier.
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 — one record creation, one authorization-set write, one user confirmation — instead of N serial calls. 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, which also sidesteps the World ID 4.0 authenticator’s refusal to reissue a (rpId, action) nullifier across separate ProofRequests. The multi-item bundle is therefore an optimisation for the common up-front case, not a spec-compliance requirement — the Session Proof path alone would also work.
Authenticator-generated sessionId. For rpId = WORLD_CHAIN_RP_ID, sessionId is produced by the authenticator via OPRF at session-init time (sessionId = encode(C, oprf_seed) with r = OPRF(pk_rpId, DS_C || leafIndex || oprf_seed)). The component stores whatever sessionId the first claimSubsidy call supplies and never accepts a caller-chosen value that was not authenticator-issued for this World ID.
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.
Orthogonality to World Chain Accounts. WIP-1001 World Chain Accounts are authorized here like any other address — no special coupling is required, and the subsidy system is agnostic to the account’s admin type (WorldID, Secp256k1, P256). Legacy EOAs can opt in to fee subsidies without migrating account types, and World Chain Accounts can sign 0x1D transactions that either do or do not consume subsidy budget.
Backwards Compatibility
This WIP introduces a new protocol/builder-level accounting system. It does not modify the semantics of any existing transaction type. Any account type — legacy EOA, smart contract, or WIP-1001 World Chain Account — may be authorized under a subsidy nullifier and thereby have its transaction fees subsidized. Whether EIP-1559 transactions from authorized accounts can consume subsidy budgets is an open question (see optional requirements).
Test Cases
TODO: Test cases to be defined before moving out of Draft status.
Reference Implementation
TODO: Reference implementation to be added before moving out of Draft status.
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 — 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 can mitigate traffic spikes.
Subsidy accounting trust boundary. The consumeBudget method must be access-controlled. As a precompile, it must only be callable by the protocol during transaction execution. As a contract, it must be restricted to the builder’s designated caller (e.g., via onlyOwner or a builder-specific access control mechanism).
Replay protection. Per-period replay is enforced by two layers: existence of a nullifier record blocks re-opening under the same nullifier, and the per-record claimed-credentials map blocks double-claiming any specific issuerSchemaId. No separate “used nullifiers” set is needed.
Signal binding. signalHash MUST commit to every non-ZK-committed call parameter — see the per-method schemas under 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. For updateAddresses, the monotonic record-scoped nonce additionally prevents in-flight Session Proof replay; claimSubsidy replay is precluded by nullifier-record existence, and claimAdditionalCredential replay by the claimed-credentials bitmap.
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. Implementations SHOULD document that clients MUST NOT supply sessionId values that were not produced by an authenticator session for WORLD_CHAIN_RP_ID.
Governance capture of budget parameters. setCredentialBudget is governance-controlled. Misuse (e.g. setting an extravagant budget for a cheap credential) could drain subsidy pools. Governance timelocks or caps SHOULD apply.
Future Upgrades
Persistent Subsidies
The current design intentionally requires fresh claimSubsidy calls each period. A future upgrade could remove periodNumber from the action so each (issuerSchemaId, World ID) pair yields one stable nullifier, allowing long-lived subsidies without periodic re-claims.
That change creates a privacy problem: if the stable subsidy were directly rebound to a different authorized account, observers could link all rotations for that credential. One way around this is to replace direct nullifier tracking with an on-chain Merkle tree of active subsidy commitments with a monotonic rotation nonce, e.g. leaf = H(issuerSchemaId || nullifier || sessionID || rotationNonce).
Under that model, the persistent subsidy would need to live behind the commitment lineage rather than the public nullifier; the nullifier would become only a revocable handle into that hidden subsidy state. Rotating to a fresh public record would then be a two-stage process:
- In a single proof and transaction, the user proves inclusion of the current leaf at rotation nonce
n, deletes it, inserts the successor leaf at noncen + 1, and simultaneously deletes the oldnullifier -> subsidymapping. - After a mandatory delay, the user proves inclusion of the successor leaf and creates a new
nullifier -> subsidymapping for a freshnullifier.
The proof system and contract would need to enforce that the rotation nonce increments monotonically, that there is at most one active leaf for a given hidden (issuerSchemaId, nullifier), and that the rebinding delay is measured from activation of the successor leaf before a new nullifier can be attached. This upgrade is out of scope for the current WIP, but it sketches a path toward persistent subsidies with unlinkable record and account rotation.
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.