Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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_call during 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 require action to equal the expected per-period claim action for some periodNumber that falls within the current boundary-window derived from block.timestamp / PERIOD_LENGTH. The contract SHOULD also validate createdAt ≤ block.timestamp ≤ expiresAt and bound expiresAt - createdAt to a reasonable MAX_REQUEST_TTL.
  • Session Proofs (action[0] == 0x02). Session Proof action values 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.signer is 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_ID registered in the World ID RpRegistry with this contract as signer and a matching oprfKeyId.
  • OPRF DKG ceremony run for this rpId so 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 nullifier records 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 nullifier values to the remaining ETH-denominated budget (in Wei). Budget accumulates as additional credential proofs are submitted under that nullifier.
  • Claimed-credentials map: for each nullifier, tracks which issuerSchemaId values have already been claimed (e.g., as a bitmap or set). Prevents double-claiming a credential under the same nullifier.
  • Session bridge map: maps each nullifier to the sessionId established at initial claim, so Session Proofs for subsequent operations on that nullifier can be verified.
  • Credential budget configuration: maps issuerSchemaId to 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 the nullifier record with its sessionId and initial authorized addresses, and credit every supplied credential’s budget)
  • Mid-period credential additions (verify a Session Proof against the stored sessionId and credit an additional credential’s budget)
  • Address updates (verify a Session Proof against the stored sessionId and update the authorized addresses)
  • Budget lookup (remaining budget for a nullifier or 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):

  1. Require items to be non-empty and items[*].issuerSchemaId to be distinct.
  2. Require nullifier to have no record in the current period.
  3. Recompute signalHash from a populated ClaimSubsidySignal.
  4. Verify every items[i].proof as a Uniqueness Proof for "period_proof" || periodNumber against the recomputed signalHash; all proofs MUST share the same nullifier public input.
  5. Create the record: store sessionId, set the authorized-address set to addAddresses, mark every items[i].issuerSchemaId as claimed, sum their configured budgets into remainingWei, set the update nonce to 0.

For claimAdditionalCredential:

  1. Require nullifier to have a record in the current period; load its sessionId.
  2. Recompute signalHash from a populated ClaimAdditionalCredentialSignal.
  3. Verify a Session Proof against sessionId with the recomputed signalHash.
  4. Require issuerSchemaId to not already be in the claimed-credentials bitmap for this nullifier; mark it claimed and add its configured budget.

For updateAddresses:

  1. Require nullifier to have a record in the current period; load its sessionId and update nonce.
  2. Require the caller-supplied nonce to equal the stored update nonce.
  3. Recompute signalHash from a populated UpdateAddressesSignal.
  4. Verify a Session Proof against sessionId with the recomputed signalHash.
  5. Apply addAddresses and removeAddresses to 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):

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

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

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

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 signal on both items (its bytes are the pre-image of the shared claimSubsidy signal binding)
  • A fresh sessionId generated 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:

  1. The protocol (or builder) looks up whether the sender address has an associated subsidy nullifier in the authorization map.
  2. If the address maps to more than one nullifier, a deterministic rule selects which record’s budget to use. A WIP-1001 0x1D transaction MAY extend its envelope with an OPTIONAL subsidy_nullifier field; if present and the account is authorized under that nullifier, the declared budget is consumed.
  3. The remaining ETH-denominated budget (remainingWei) is decremented by gasUsed * 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:

  1. 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 nonce n + 1, and simultaneously deletes the old nullifier -> subsidy mapping.
  2. After a mandatory delay, the user proves inclusion of the successor leaf and creates a new nullifier -> subsidy mapping for a fresh nullifier.

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.