Abstract
This document introduces an accounting system for World ID Subsidies, which allow World ID users to claim an ETH-denominated budget for each eligible credential issued to their World ID. Budgets can be used to pay transaction fees on World Chain and refresh every 30 days. Users claim a budget by submitting a period-scoped uniqueness proof and delegate it to a set of authorized on-chain accounts.
The system is implemented as an upgradeable predeployed contract that owns the canonical subsidy state and exposes the claim, authorization, view and governance logic. A native revm extension performs per-transaction budget debits in-protocol by writing to a pinned subset of the contract’s storage slots.
This document does not specify how subsidies are applied to incoming transactions, including how users submit transactions without paying gas up front. That mechanism is specified in WIP-1003.
Motivation
World ID holders are currently subsidized for their World Chain transaction fees through off-chain paymaster infrastructure routed via World App. This couples subsidy delivery to the app, leaves transactions submitted directly to the chain (e.g. third-party wallets or dApps) unsupported, and relies on trusted paymaster operators to honor the entitlement. WIP-1002 lifts subsidy accounting into the protocol so that any authorized account can spend a World-ID-backed budget without holding native ETH up front, regardless of how the transaction reaches the sequencer.
Additionally, subsidies are no longer unlimited for World App users, but are bound to the eligible credentials held by a given World ID. This allows the protocol to provide larger subsidies to verified humans while bounding subsidies for less strongly verified users.
Finally, moving subsidies into the protocol makes the mechanism transparent and verifiable, aligning it more closely with the values of the World project.
High Level Architecture
Every World ID-verified user can claim a subsidy budget denominated in wei. The size of the budget depends on the user’s subsidy-eligible World ID credentials. For example, a user may claim a budget for their PoH Credential from the Orb, their Phone Credential from World App, and their Passport Credential. Budgets refresh every 30 days.
Each period derives a unique World ID action. Users claim their period budget and delegate it to a list of authorized accounts by submitting a Uniqueness Proof to a dedicated smart contract on World Chain. The user’s subsidy budget is identified by the unique period nullifier. Users also submit a Session ID during the initial claim, so that later updates, such as changing authorized accounts or claiming budgets for additional credentials, can be authenticated using World ID Session Proofs.
Subsidy claims and account authorization are handled by a Solidity predeploy. Gas-cost deductions for subsidized transactions are implemented as an EVM post-execution hook in revm, which directly updates the subsidy contract’s storage in-protocol.
Specification
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “NOT RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119 and RFC 8174.
Constants
| Name | Type | Value | Meaning |
|---|---|---|---|
WORLD_ID_SUBSIDIES_RP_ID | uint256 | 481 | RP identifier registered in the World ID RpRegistry with the Subsidy Accounting proxy as signer. |
PERIOD_LENGTH | uint64 | 30 days | Length of one subsidy period; per-period state namespace rotates at every multiple. |
MAX_REQUEST_TTL | uint64 | 1 hours | Upper bound on expiresAt − createdAt accepted by verifyRpRequest. |
Architecture
The Subsidy Accounting component is deployed as a hybrid of two layers sharing one canonical state:
- An upgradeable Solidity proxy holds all canonical state and exposes every claim, authorization, governance, and view path:
claimSubsidy,updateRecord,setCredentialBudget, and read methods. - A revm/op-reth extension performs the per-transaction subsidy debit by reading and writing a pinned subset of the proxy’s storage slots directly during fee collection. The debit runs as protocol work, outside EVM gas metering, after the transaction’s normal execution. No
consumeBudgetfunction (or similar) exists in the public Solidity ABI.
Relying Party Signer
In World ID 4.0, Relying Parties (RPs) initiate the proof flow by providing a signed RP request to the user. For use cases where the RP is a smart contract, WIP-101 introduces ERC-1271-inspired on-chain RPs: any contract that implements IWIP101 can be registered at the RpRegistry contract, and OPRF nodes will eth_call verifyRpRequest before producing a share for an RP request with a matching rpId.
interface IWIP101 is IERC165 {
error RpInvalidRequest(uint256 code);
function verifyRpRequest(
uint8 version,
uint256 nonce,
uint64 createdAt,
uint64 expiresAt,
uint256 action,
bytes calldata data
) external view returns (bytes4 magicValue);
}
Acceptance returns the magic value 0x35dbc8de. Rejection reverts with RpInvalidRequest(code) (any other revert is treated by OPRF nodes as an incompatible signer).
World ID Subsidies RP has rpId = WORLD_ID_SUBSIDIES_RP_ID and the registered RP signer is the Subsidy Accounting proxy itself — ISubsidyAccounting implements IWIP101 directly.
Validation of Auxiliary Fields
- Version: The contract MUST revert if
version != 1. - Nonce Check: The contract MUST NOT restrict nonces. OPRF nodes cache nonces and do not allow for nonce replay even accross RPs and actions. Hence, restricting the nonce would risk user requests being blocked.
- Request TTL bound. The contract SHOULD verify
createdAt ≤ block.timestamp ≤ expiresAtandexpiresAt − createdAt ≤ MAX_REQUEST_TTL. Request expiry checks are not critical, but serve as a simple sanity check and could prevent bugs on the client side. **datafield.** The request’swip101_datafield (capped at 1 KiB by the OPRF-node layer) is unused. The contract MUST revert on non-emptydata.
Action Validation
Validation branches on the most-significant byte of action (canonical extraction: uint8(action >> 248)), which the World ID 4.0 protocol uses to discriminate action types (Uniqueness Proof, Session OPRF Seed, Session Proof). Three classes are routed:
**action[0] == 0x00— Uniqueness Proof, restricted.** Accept iffaction ∈ { actionForPeriod(currentPeriod()), actionForPeriod(currentPeriod() + 1) }. The contract MUST revert for any other action. The boundary window (current + next) lets users submit the next period’s initial claim proof during the current period (see Budget Refresh). Restricting the action prevents malicious applications from using theWorld ID Subsidies RPfor uniqueness proofs on arbitrary actions.**action[0] == 0x01— Session OPRF seed, client-random.** Used by the authenticator to deriver = OPRF(pk_rpId, DS_C || leafIndex || oprf_seed)for session-proof generation. The contract MUST accept unconditionally once the class prefix is confirmed.**action[0] == 0x02— Session Proof, client-random.** Per-proof randomness for the Session Proof itself; the only per-proof entropy source in the Session Proof circuit. Rhe contract MUST accept unconditionally once the class prefix is confirmed.
Subsidy Accounting Interface
ISubsidyAccounting extends IWIP101 (see Relying Party Signer) and defines the following methods.
interface ISubsidyAccounting is IWIP101 {
/// @notice One per-credential Uniqueness Proof emitted by a single multi-item `ProofRequest`.
/// All items of a `claimSubsidy` call MUST share the same `nullifier` and the same
/// `signal_hash` public input (achieved by setting the same `signal` on every `RequestItem`).
struct ClaimItem {
uint64 issuerSchemaId;
uint256[5] proof;
}
/// @notice Atomic initial per-period claim. Accepts the full set of per-credential
/// Uniqueness Proofs emitted from one multi-item `ProofRequest` for the current
/// period's claim action. Opens a subsidy record keyed by `nullifier`, stores
/// `sessionId` and the initial `addAddresses` set, and credits every credential
/// in `items`.
/// The contract recomputes the expected `signalHash` from the call parameters and
/// rejects any proof whose `signal_hash` public input does not match.
///
/// MUST revert if `items` is empty.
/// MUST revert if `nullifier` already has a record in the current period.
/// MUST revert on duplicate `issuerSchemaId` within `items`.
/// `addAddresses` MAY be empty (dormant record; addresses installed later via
/// `updateRecord`).
function claimSubsidy(
uint256 nullifier,
uint256 sessionId,
address[] calldata addAddresses,
ClaimItem[] calldata items
) external;
/// @notice Mid-period record mutation. Verifies a Session Proof against the `sessionId`
/// stored for `nullifier` and applies up to two sub-operations atomically:
///
/// (a) credential sub-op — if `issuerSchemaId` is not yet claimed under
/// `nullifier`, mark it claimed and credit its configured budget into
/// `budget[action][nullifier]`; otherwise the sub-op no-ops.
/// (b) auth-set sub-op — if `newSet` differs from the record's current
/// authorized set, full-replace the set (drop old cross-references in
/// `nullifiersOf`, install new ones); empty `newSet` revokes all; otherwise
/// the sub-op no-ops.
///
/// On any successful state change, `updateNonce` is bumped.
///
/// The Session Proof always binds to a real credential the caller holds — there
/// is no credential-agnostic sentinel. Pure auth-set rotations pick any
/// `issuerSchemaId` the caller already holds (the credential sub-op then
/// no-ops). `sessionAction` is the per-proof random action value
/// (`action[0] == 0x02`) supplied as a verifier public input; it is the only
/// per-proof entropy source in the Session Proof circuit and cannot be
/// derived contract-side.
///
/// The contract recomputes the expected `signalHash` from the call parameters.
/// MUST revert (`RecordDoesNotExist`) if `auth[currentAction()][nullifier].sessionId == 0`.
/// MUST revert (`StaleUpdateNonce`) if `nonce != auth[currentAction()][nullifier].updateNonce`.
/// MUST revert (`DuplicateAuthorizedAddress`) on duplicate addresses in `newSet`.
/// MUST revert (`NoOpUpdate`) if neither sub-op produces a state change.
function updateRecord(
uint256 nullifier,
uint64 nonce,
uint64 issuerSchemaId,
address[] calldata newSet,
uint256 sessionNullifier,
uint256 sessionAction,
uint256[5] calldata proof
) external;
/// @notice Get remaining subsidy budget (in Wei) for a subsidy record in the current period.
function getBudget(uint256 nullifier) external view returns (uint256 remainingWei);
/// @notice Get remaining subsidy budget (in Wei) available to an address in the current period.
/// Sums `budget[currentAction()][nᵢ]` across every current-period claim the address is
/// authorized under, mirroring the walk performed by the per-tx debit hook.
function getBudget(address account) external view returns (uint256 remainingWei);
/// @notice Check whether an address is authorized under a given subsidy record.
function isAuthorized(address account, uint256 nullifier) external view returns (bool);
/// @notice Get all current-period subsidy records associated with an address, in the order
/// the per-tx debit hook walks them.
function getNullifiers(address account) external view returns (uint256[] memory);
/// @notice Check whether a credential has been claimed under `nullifier` this period.
function isClaimed(uint256 nullifier, uint64 issuerSchemaId) external view returns (bool);
/// @notice `IWIP101` surface called by OPRF nodes via `eth_call` before each share is
/// contributed. Validates the request envelope and the `action` value:
/// - TTL: `createdAt <= block.timestamp <= expiresAt` and
/// `expiresAt - createdAt <= MAX_REQUEST_TTL`;
/// - `data` MUST be empty;
/// - action-class routing on `uint8(action >> 248)`:
/// * `0x00` (Uniqueness) — accept iff `action` equals `actionForPeriod` of
/// the current or next period (boundary window);
/// * `0x01` (Session OPRF seed) — accept unconditionally;
/// * `0x02` (Session inner action) — accept unconditionally.
/// Returns `0x35dbc8de` on acceptance, reverts `RpInvalidRequest(code)` otherwise.
function verifyRpRequest(
uint8 version,
uint256 nonce,
uint64 createdAt,
uint64 expiresAt,
uint256 action,
bytes calldata data
) external view returns (bytes4);
/// @notice Per-period claim action for `period`, derived per [Storage Layout](#storage-layout).
/// Used inside `verifyRpRequest` and by off-chain callers constructing a Uniqueness
/// `ProofRequest`.
function actionForPeriod(uint64 period) external view returns (uint256);
/// @notice Set the claimable budget amount (in Wei) for a credential type. Governance only.
function setCredentialBudget(uint64 issuerSchemaId, uint256 budgetWei) external;
}
Note: A consumeBudget is intentionally absent from the public ABI — the per-tx debit is performed by the revm extension over the pinned slots, not by an EVM call (see Per-tx Debit Path.
Storage Layout
The storage layout a the protocol-level commitment. The revm extension reads and writes a pinned subset of these slots directly during fee collection, so any reshape of the pinned subset is a coordinated protocol release.
Every period-scoped map is keyed by a verifier action , adopting the AddressBook pattern. The action is:
period = block.timestamp / PERIOD_LENGTH
action = keccak256("period_proof" || period) >> 8
>> 8 truncates the action to 248 bits to fit the BN254 scalar field.
Period-scoped maps:
mapping(uint256 action => mapping(uint256 nullifier => uint128)) budget;
mapping(uint256 action => mapping(address account => uint256[])) nullifiersOf;
mapping(uint256 action => mapping(uint256 nullifier => AuthRecord)) auth;
mapping(uint256 action => mapping(uint256 nullifier => address[])) authorized;
mapping(uint256 action => mapping(uint256 nullifier => mapping(uint64 => bool))) claimed;
struct AuthRecord {
uint256 sessionId; // bound at claimSubsidy; non-zero ⇔ claim exists this period
uint64 updateNonce; // bumped on any successful updateRecord state change
}
Pinned subset (protocol commitment, read/written by the revm extension):
- The
budgetslot —uint128width - The
nullifiersOfslot - The action derivation:
PERIOD_LENGTHand theactionformula
Non-pinned (normal upgradeable state, append-only by convention):
auth,authorized,claimed,credentialBudget, admin/governance slots
Authorization Maps
- Forward:
authorized[action][nullifier] : address[]— addresses currently allowed to spend the record’s budget. - Reverse:
nullifiersOf[action][account] : uint256[]— records the address may draw from, in the order the per-tx debit hook walks them.
Subsidy Budget Map
budget[action][nullifier] : uint128is the counter representing the remaining budget in Wei.getBudget(uint256 nullifier)returnsbudget[currentAction()][nullifier].getBudget(address account)sumsbudget[currentAction()][nᵢ]across every current-period claim the address is authorized under.
Auth Map
auth[action][nullifier] : AuthRecord { sessionId, updateNonce } is the Session-Proof gate for the per-claim state.
sessionIdis bound atclaimSubsidyand retained for the period.updateNonceis monotonic, bumped on every successfulupdateRecordstate change. It blocks Session-Proof replay of the auth-set sub-op (caller-suppliednonceMUST equal the stored value).
Claimed-Credentials Map
claimed[action][nullifier][issuerSchemaId] : boolindicates wether a credential has already been claimed in a given period.
Periods, Nullifiers, and Sessions
Subsidy claims are bound to a per-period nullifier derived from a World ID 4.0 Uniqueness Proof with rpId = WORLD_ID_SUBSIDIES_RP_ID, the current-period claim action (see Storage Layout), and a signal per Signal Binding.
Every Uniqueness Proof natively carries an issuerSchemaId (mandatory public input in the circuit). The nullifier is credential-independent: for a given World ID and period it does not depend on issuerSchemaId. To claim budget for multiple credentials against the same period nullifier, the authenticator builds a single ProofRequest containing one RequestItem per credential the WorldID holds; all RequestItems MUST carry the same signal (the shared claimSubsidy signal described under Signal Binding) so every emitted proof has the same signal_hash public input. This yields one per-credential proof for each issuerSchemaId, all sharing the same nullifier and signal_hash (see World ID 4.0). The full bundle is submitted in a single atomic claimSubsidy call.
The sessionId associated with a nullifier is generated by the authenticator through an OPRF call. It is stored by the component on the first claimSubsidy call for a given nullifier and retained for Session Proof verification during the period.
Signal Binding
Each method’s proof(s) carry a signal_hash public input that commits the proof to its on-chain operation parameters. signal_hash is NOT a caller input: the contract recomputes the expected value from the call parameters and rejects any proof whose signal_hash public input does not match. For claimSubsidy, every proof in items MUST share the same signal_hash (the authenticator sets the same signal on every RequestItem, so one recomputed signalHash is checked against every proof).
Each method has a dedicated signal struct. signalHash is computed by ABI-encoding an instance of that struct and Keccak-hashing; >> 8 truncates to 248 bits so the value fits in the BN254 scalar field. ABI encoding of address[] is the standard Solidity dynamic-array layout (uint256 length followed by each element right-padded to 32 bytes); the struct definitions below pin the layout for off-chain reproducers.
struct ClaimSubsidySignal {
bytes32 tag; // == keccak256("WIP-1002/claimSubsidy")
uint256 sessionId;
address[] addAddresses;
address msgSender;
}
struct UpdateRecordSignal {
bytes32 tag; // == keccak256("WIP-1002/updateRecord")
uint256 nullifier;
uint64 nonce;
uint64 issuerSchemaId;
address[] newSet;
address msgSender;
}
For each method:
signalHash = uint256(keccak256(abi.encode(signal))) >> 8
where signal is the corresponding struct populated from the call parameters and msg.sender.
proofNonce, expiresAtMin, and credentialGenesisIssuedAtMin are NOT part of the public ABI — the authenticator commits fixed/derived values: PROOF_NONCE = 0, expiresAtMin = currentPeriod() * PERIOD_LENGTH, credentialGenesisIssuedAtMin = 0 (subsidy has no recency requirement on credential issuance).
Replay protection. For claimSubsidy auth[currentAction()][nullifier].sessionId != 0 blocks re-opening in the same period. For updateRecord the supplied nonce MUST equal the stored auth[...][nullifier].updateNonce. The contract bumps it on any successful state change. The claimed-credentials map independently blocks re-claim of an already-claimed issuerSchemaId (the credential sub-op no-ops if already claimed). signal_hash binds the full operation context (nullifier, nonce, issuerSchemaId, newSet, msgSender), preventing cross-call replay.
Per-Proof Behavior
For claimSubsidy (atomic over all items):
- Require
itemsto be non-empty (EmptyItems) anditems[*].issuerSchemaIdto be distinct. - Require
nullifierto have no claim in the current period (i.e.auth[currentAction()][nullifier].sessionId == 0). - Recompute
signalHashfrom a populatedClaimSubsidySignal. - Verify every
items[i].proofas a Uniqueness Proof for the current period’s claim action against the recomputedsignalHash; all proofs MUST share the samenullifierpublic input. - Open the claim: write
auth[currentAction()][nullifier] = AuthRecord({ sessionId: sessionId, updateNonce: 0 }), set the authorized-address set toaddAddresses(which MAY be empty, leaving a dormant claim), cross-referencenullifiersOf[addr]for each entry ofaddAddresses, mark everyitems[i].issuerSchemaIdas claimed, and setbudget[currentAction()][nullifier] = sum(credentialBudget[items[i].issuerSchemaId]).
For updateRecord:
- Require
nullifierto have a claim in the current period (RecordDoesNotExistifauth[currentAction()][nullifier].sessionId == 0); loadsessionIdandupdateNoncefromauth[currentAction()][nullifier]. - Require the caller-supplied
nonceto equal the storedupdateNonce(StaleUpdateNonce). - Reject duplicates within
newSet(DuplicateAuthorizedAddress). - Recompute
signalHashfrom a populatedUpdateRecordSignal. Verify a Session Proof againstsessionIdwith the recomputedsignalHash, the caller-suppliedissuerSchemaIdas the proof’s credential public input, and the caller-suppliedsessionActionas the per-proof random verifier public input. - Credential sub-op. If
issuerSchemaIdis not already in the claimed-credentials map for thisnullifier, mark it claimed andbudget[currentAction()][nullifier] += credentialBudget[issuerSchemaId](overflow-guarded). Otherwise no-op. - Auth-set sub-op. If
newSetdiffers from the record’s current authorized set, drop cross-references innullifiersOffor every address in the old set, installnewSetas the new authorized-address set, add cross-references innullifiersOffor every address innewSet, and emitAuthorizedSetUpdated(nullifier, newSet). Otherwise no-op. EmptynewSetrevokes all authorized addresses. - If both sub-ops were no-ops, revert
NoOpUpdate. Otherwise bumpauth[currentAction()][nullifier].updateNonce.
The Session Proof always binds to a credential the caller holds — its in-circuit cred_pk check is run against the supplied issuerSchemaId. To rotate the authorized set without claiming a new credential, the caller picks any issuerSchemaId they hold (typically one already claimed under the record); the credential sub-op then no-ops while the auth-set sub-op rotates and bumps the update nonce. A previously-claimed credential in the same period under the same nullifier MUST NOT be re-credited via updateRecord (the credential sub-op no-ops in that case).
Example: Authorization and Budget Claim Flow
Assume governance has configured the following credential budgets (Wei amounts shown in Gwei for readability; 1 Gwei = 10⁹ Wei):
| 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.
claimSubsidy(
nullifier,
sessionId,
[0xAlice, 0xBob],
[ ClaimItem(0x01, proof_PoH), ClaimItem(0x02, proof_Phone) ]
);
State after step 1: budget = 60,000 Gwei, authorized = [0xAlice, 0xBob], claimed = {0x01, 0x02}, update nonce = 0.
Step 2: Mid-period — acquire NFC credential and revoke 0xBob atomically.
updateRecord(nullifier, 0, 0x03, [0xAlice], sessionNullifier2, sessionAction2, proof);
The Session Proof binds to the NFC credential (issuerSchemaId = 0x03). The credential sub-op claims it (budget += 20,000 Gwei); the auth-set sub-op replaces [0xAlice, 0xBob] with [0xAlice]. State after step 2: budget = 80,000 Gwei, authorized = [0xAlice], claimed = {0x01, 0x02, 0x03}, update nonce = 1.
Alternative Step 2 — pure auth-set rotation, no new credential. To revoke 0xBob without claiming NFC, the caller picks an already-held issuerSchemaId (e.g. 0x01):
updateRecord(nullifier, 0, 0x01, [0xAlice], sessionNullifier', sessionAction', proof);
The credential sub-op no-ops (0x01 already claimed); the auth-set sub-op rotates the set; the update nonce bumps to 1.
Budget Refresh
A subsidy record under the current action and its authorized accounts list are active for exactly one period. At the end of the period, the next period’s action differs and all current-period state becomes structurally unreachable.
To prevent congestion from all accounts submitting refresh proofs at the start of a new period, the next period’s initial claimSubsidy MAY be submitted during the current period — the new record lands under the next period’s action and activates automatically at the period boundary.
Per-tx Debit Path
The per-transaction debit is performed by a revm/op-reth extension and is NOT exposed in the Solidity ABI. The extension MUST run as a post-execution step, after the quantitie of the subsidised fees has been determined.
The hook walks nullifiersOf[currentAction()][account] from index 0 and debits subsidy_amount Wei across one or more current-period records, in insertion order:
fn on_subsidised_tx(account, subsidy_amount):
action = keccak256("period_proof" || (block.timestamp / PERIOD_LENGTH)) >> 8
let nullifiers = SLOAD(nullifiersOf[action][account])
let remaining = subsidy_amount
for i in 0..nullifiers.len():
let n = nullifiers[i]
let b = SLOAD(budget[action][n])
if b == 0: continue
let charge = min(b, remaining)
SSTORE(budget[action][n], b - charge)
emit SubsidyDebit(action, n, charge)
remaining -= charge
if remaining == 0: break
- Walk order. Insertion order, index 0 first. No rotation, no per-tx user override. Empty-budget claims are skipped. There is no explicit walk cap.
- Validator verification. Every input to the debit —
block.timestamp, sender, the pinned slots,subsidy_amount— is consensus state. Validators re-derive the writes via state root rather than trusting a builder.
Claim Transaction Subsidy
The claim-side mutations (claimSubsidy, updateRecord) themselves cost gas, and a user who has no native ETH on World Chain cannot pay for the very transaction that would mint their subsidy budget. The Subsidy Accounting component does not make those transactions self-subsidizing. The expected bootstrap path is a specialized bundler or relayer that accepts the user’s proof bundle, submits the claim transaction, and pays the gas on the user’s behalf.
Fully self-subsidized claim transactions, where the protocol applies a subsidy to the transaction that creates or updates the budget, are a sibling subsidy-enforcement concern rather than part of this accounting component. If adopted, that eligibility rule should be specified by the transaction-subsidy mechanism, not by WIP-1002.
Rationale
Hybrid Solidity + revm shared-storage fast path. Not having the enshrined accouting would require an expensive system transaction or similar. Fully enshrining the admin logic would add significant complexity, specifically for updates. The enshrined part of the logic is very simple and is unlikely to change in the future.
Action-namespaced storage. Adopting the AddressBook pattern keys every period-scoped map by the verifier action.
Nullifier-keyed budget records. Keying budget, authorization, and claimed-credentials state by the per-period nullifier collapses what would otherwise be two independent state objects (a budget record and a nullifier-used set) into a single map whose existence is itself the per-period replay guard. (nullifier, issuerSchemaId) in the claimed-credentials map prevents double-claiming a specific credential.
Atomic multi-item initial claim + Session Proof additions. When the WorldID’s credentials and authorized addresses are known at the start of a period, bundling every held credential into one multi-item ProofRequest and submitting them via a single atomic claimSubsidy collapses the budget setup into one transaction. Credentials acquired later in the period (or the rare case where the user deliberately spreads claims) are admitted via Session Proofs against the stored sessionId. The multi-item bundle is therefore an optimisation for the common up-front case.
Per-credential budgets. Different credentials represent different levels of verification. Proof-of-Human from an Orb carries more weight than a phone credential. Allowing governance to configure budget amounts per issuerSchemaId enables fine-grained subsidy policy.
Signal binding against frontrunning. Each method binds its non-ZK-committed call parameters into signalHash. The contract recomputes the expected signalHash rather than accepting it from the caller. Mirrors the WIP-1001 signal-binding pattern.
Periodic refresh with early submission. Fixed periods with expiring subsidies provide a clean budget lifecycle. Allowing early refresh prevents a thundering herd at period boundaries.
Backwards Compatibility
The setup is compatible with the current wallet setup. Users can now bypass the bundler and directly submit a transaction with their EOA without the need to pay fees.
Security Considerations
Anonymity. Claim nullifiers are unlinkable across periods by construction. A World ID that authorizes different addresses each period cannot be tracked. However, if the same addresses are reused across periods, on-chain observers can infer continuity, i.e. the address itself becomes the deanonymization vector.
Refresh congestion. Even with early refresh support, a significant fraction of users may refresh near period boundaries. Sub-period slots with proportional sub-budgets are a possible mitigation, deferred as future scope.
Storage-layout commitment. The protocol-level commitment is the pinned subset of the contract’s storage layout — budget, nullifiersOf, PERIOD_LENGTH, and the action derivation. Solidity upgrades MUST preserve the pinned subset; reshapes require a coordinated protocol release.
Revm-extension trust boundary. The per-tx debit is performed by protocol code (the revm extension), not by an EVM call. Validators independently re-execute the debit from consensus inputs (block.timestamp, sender, the pinned slots, subsidy_amount) and accept the resulting state root only if the debit matches. Hence, the debit path introduces no additional trust assumptions about the sequencer.
Replay protection. Per-period replay is enforced two mechanisms. The existence of a record under the current action blocks re-opening under the same nullifier, and the per-record claimed-credentials map blocks double-claiming any specific issuerSchemaId. For mid-period mutations, the monotonic per-record updateNonce additionally prevents in-flight Session Proof replay.
Signal binding. signalHash MUST commit to every non-ZK-committed call parameter (see Signal Binding). The contract recomputes signalHash from the call parameters rather than trusting any caller-supplied value, so a frontrunner cannot replay a witnessed proof with a substituted msg.sender or authorized-address list.
Authenticator-generated sessionId. The sessionId stored for a nullifier is derived by the authenticator from an OPRF output keyed by (rpId, leafIndex, oprf_seed). The component cannot distinguish on-chain between an authenticator-issued sessionId and an attacker-chosen value, so Session Proof verification is the enforcement layer. A Session Proof against an unissued sessionId will simply fail to verify.
Governance capture of budget parameters. setCredentialBudget is governance-controlled. Misuse (e.g. setting an extravagant budget for a cheap credential) could drain subsidy pools.
Future Upgrades
State Growth Over Time
Per-period storage is not reclaimed in the current design. Action-keying makes stale slots structurally unreachable from current-period code paths, but they remain in the chain state, so the contract’s storage footprint grows monotonically with the number of periods and the active claimer population. At WorldID target scale this accumulates meaningfully over time. A future revision SHOULD specify a state-management mechanism (e.g. a state-expiry primitive enabled by a later protocol upgrade), subject to the anonymity constraint described under Security Considerations. Note, that state growth caused by tracking nullifiers is a more general problem, due to the heavy use of World ID proofs on-chain.
Another potential solution to this problem could be to not use a protocol that requires periodic refresh, but use long-lived nullifiers instead. To allow for anonymous rotation, a Merkle-tree-based scheme could be used, where users prove that their previous budget nullifier has been deleted, when creating a new one.