(Updated Aug 5: revised per implementation feedback and the discussion below; changelog in the replies. Canonical text: Add ERC: Zero-Knowledge Spending Policies by junbeomlee · Pull Request #1929 · ethereum/ERCs · GitHub)
Abstract
This ERC standardizes zero-knowledge spending policies: a composable function set that any contract holding a user’s funds (a dedicated escrow, a smart wallet, an ERC-4337 account, or an EIP-7702-delegated EOA) can implement to release those funds only against a zero-knowledge proof that the payment satisfies a spending policy the owner registered in advance. A policy is registered once as a commitment, so its parameters (for example a price cap) can stay private. The proof travels as the implementing contract’s signature: any spender, contract or EOA, that presents a payment-authorization digest to its ERC-1271 isValidSignature receives the magic value exactly when the accompanying proof satisfies the registered policy. On the settlement rail, the implementing contract is the payer. The core of this standard is the policy check itself, verifyPolicy, together with the registration semantics that make a policy single-use; on signature-carrying rails an ERC-1271 adapter exposes that same check as the contract’s signature validation. Rails that already route contract signatures, such as ERC-3009 with the ERC-7598 bytes overload (and HTTP payment flows such as x402 above them), can spend from a conforming contract with no changes, but they are instantiations, not the standard.
Motivation
Autonomous agents are becoming payment clients, but sending money was never the hard part of agent payments; trusting an agent to spend it is. Today an operator picks one of two bad options: a human approves each payment (safe, but the human is the bottleneck and the autonomy is gone), or the agent holds a key or a blanket allowance (autonomous, but unbounded: one bug or prompt injection and the funds can go anywhere, for anything).
What is missing is delegation by constraint: the owner states what a payment must satisfy, the agent acts freely within that envelope, and the settlement layer itself refuses anything outside it. Concretely, this standard targets the property one policy = one payment: each registered policy authorizes exactly one settlement, so worst-case damage is capped at a single pre-authorized payment.
Existing standards do not provide this:
- ERC-7715 and ERC-7710 (wallet permissions, session keys) enforce spending limits in the account, but the policy is public, is evaluated against on-chain state only, and cannot bind off-chain facts such as a merchant-signed quote.
- ERC-8183 standardizes escrow for agent jobs (release on delivery evaluation), not a spending policy on the payer.
- ERC-8004 is a trust and discovery layer and explicitly excludes payments.
- Among proposals still under review: ERC-8150 verifies agent payments with ZK proofs against a per-batch user-signed intent by calldata matching, so it requires a fresh signature per batch, keeps no parameters private, and cannot bind off-chain facts; ERC-8354 gates arbitrary agent actions behind a confidential third-party ruleset, the reverse trust topology of this standard (there the prover is a trusted policy engine and the secret is the rules; here the prover is the untrusted agent itself and the secret is only the policy’s parameters); ERC-8312 meters how much of a bounded mandate an agent has consumed but enforces nothing, and is complementary to the multi-payment budget extension discussed in Rationale.
This function set differs on all three axes: the policy is registered once (no per-payment signature), the proof shows constraint satisfaction rather than calldata equality (so off-chain facts such as signed quotes become provable inputs), and private parameters stay private behind a commitment.
Specification
The key words “MUST”, “MUST NOT”, “SHOULD”, and “MAY” in this document are to be interpreted as described in RFC 2119 and RFC 8174.
Interface
interface IZKSpendingPolicy {
/// A policy was registered for `nonce`.
event PolicyAllowed(bytes32 indexed nonce, bytes32 paramsCommit, address verifier);
/// The policy for `nonce` was revoked before settlement.
event PolicyRevoked(bytes32 indexed nonce);
/// Register a single-use policy.
/// `nonce`: the authorization nonce this policy is bound to; doubles as
/// the policy id.
/// `paramsCommit`: commitment to the policy's (possibly private) parameters.
/// `verifier`: proof-system verifier contract for this policy's circuit.
function allowPolicy(bytes32 nonce, bytes32 paramsCommit, address verifier) external;
/// Revoke a policy that has not settled. MUST revert if already settled.
function revokePolicy(bytes32 nonce) external;
/// The registered policy for `nonce`, or zero values if none.
function allowedPolicy(bytes32 nonce)
external view returns (bytes32 paramsCommit, address verifier);
/// The core check of this standard: does the payment described by
/// `authorization`, with `proof`, satisfy the policy registered for its
/// nonce? View: consumes nothing, so anyone can pre-flight a settlement
/// with a static call before submitting it.
function verifyPolicy(bytes calldata authorization, bytes calldata proof)
external view returns (bool);
}
/// Optional extension for rails without contract-signature signed transfers.
/// Implementations that expose direct settlement declare this interface in
/// addition to `IZKSpendingPolicy`. See "Direct settlement".
interface IZKSpendingPolicySettlement {
/// Emitted by direct settlement.
event Settled(bytes32 indexed nonce, address to, uint256 value);
/// Permissionless: the proof, not the caller, is the authorization.
function settle(bytes calldata authorization, bytes calldata proof) external;
}
allowedPolicy returns zero values both for a nonce that was never registered and for one that was revoked; where the distinction matters, it is available from the events.
isValidSignature is deliberately not part of this interface: it is ERC-1271’s function, not this standard’s. A conforming contract that settles over contract-signature rails MUST also implement ERC-1271 as the rail adapter: its isValidSignature MUST decode the proof envelope, perform the digest-binding step below, and return the magic value exactly when verifyPolicy holds for the decoded fields. The two entry points MUST NOT be able to disagree.
Policy registration
allowPolicyandrevokePolicyMUST be restricted to the owner.nonceMUST be single-use: it is the authorization nonce of the one payment this policy can authorize. Replay protection MUST exist at settlement: with a rail that consumes the nonce atomically with the transfer (as ERC-3009 does) the implementation MAY rely on the rail; on any other path, the settlement component that spends against the escrow MUST consume the nonce itself. Registering anoncethat already has an unrevoked policy MUST revert.- A policy is settled when its nonce has been consumed at the settlement component (for ERC-3009, when
authorizationState(address(this), nonce)is true). Where that state is readable on-chain,allowPolicyMUST reject an already-consumed nonce (such a policy could never settle, yet would read as live to any observer), andrevokePolicyMUST revert for a settled policy; where it is not readable, implementations SHOULD document that both requirements are unenforceable and registrations against consumed nonces are unspendable. - Because
isValidSignaturecannot write state, the policy record survives settlement. A non-zeroallowedPolicyresult therefore means “registered”, not “spendable”; consumers MUST consult the settlement component’s nonce state to distinguish the two. paramsCommitis opaque to the implementation. The commitment scheme (for example a Poseidon hash over a circuit version, a cap, and a quote-signer key) is defined by the policy circuit, not by this standard.- The implementing contract holds the funds: the owner funds it with the token being spent. This standard imposes no relationship between the balance and registered policies (see Rationale). The functionality MAY live in a dedicated escrow contract or inside a general-purpose wallet (for example as a module of a modular smart account); what conforms is the function set and its semantics, not the contract’s shape.
Payment authorization and proof encoding
verifyPolicy takes two arguments. authorization is the cleartext description of the payment. For the reference ERC-3009 schema it MUST decode as:
abi.encode(address to, uint256 value, uint256 validAfter, uint256 validBefore, bytes32 nonce)
from is deliberately absent: implementations bind the payer as address(this) wherever the payer matters (the adapter’s digest recomputation, settle), so a from field would be redundant input. Any other digest schema MUST likewise fix its authorization layout normatively; leaving it implementation-defined would make two conforming implementations of the same schema wire-incompatible, and a facilitator could not construct the payload from the specification alone.
proof is the proof-system-specific encoding defined by the policy’s registered verifier (for the reference Groth16 verifier, abi.encode(a, b, c)).
Neither argument carries public inputs. Every public input is anchored to a source the implementation already trusts (the registered policy, the authorization, the environment), so the implementation constructs the vector itself. Accepting a prover-supplied vector and checking it field-by-field would be sound too, but it turns each forgotten equality check into a critical bug; constructing removes that failure mode.
Verification
verifyPolicy(authorization, proof) MUST return true only if every one of the following succeeds:
-
Policy lookup. Require an unrevoked policy registered for the authorization’s
nonce. -
Public-input construction. Construct the public input vector, at minimum
[to, value, paramsCommit, account, chainid]:toandvaluefromauthorization,paramsCommitfrom the registered policy,accountasaddress(this)(the implementing contract), andchainidas the executing chain id. The last two scope the proof to this contract and chain. Public inputs MUST NOT be taken from the prover except throughauthorization, andverifyPolicyanswers for whatever authorization the caller presents; binding that authorization to an actual settlement is the consuming path’s job (digest binding on the ERC-1271 adapter, nonce consumption atsettle). The minimum vector is what every conforming implementation can anchor; a policy circuit MAY extend it, but every additional public input MUST arrive through one of three channels:- From the authorization. Fields of the payment authorization beyond
to/value(a validity window, a category field in a richer digest schema): they arrive inauthorizationand are anchored by the digest-equality check. - From chain state. Values the implementation reads during the view call (an oracle feed, an allowlist root,
block.timestampfor time-window policies): the policy registration fixes where to read, and the read anchors the value. - From registration. Values fixed when the policy is registered: committed in, or registered alongside,
paramsCommit.
A value that fits none of these channels cannot be anchored by the verifier, and an unanchored public input is semantically a witness, so it MUST be a witness: authenticated inside the circuit against something that is anchored, as a merchant-signed quote is authenticated against a quote-signer key committed in
paramsCommit. - From the authorization. Fields of the payment authorization beyond
-
Proof verification. Verify
proofagainst the policy’s registeredverifierand the constructed public input vector.
On signature rails the two arguments travel packed into the ERC-1271 signature slot as the proof envelope, which MUST decode as:
abi.encode(bytes proof, bytes authorization)
The cleartext authorization rides along because hash is one-way: ERC-1271 hands the implementation only the digest and these bytes, so without the fields it could neither recompute the digest, nor look up the policy, nor construct the public inputs. It is untrusted input; digest binding is what ties it to the settlement.
On isValidSignature(hash, signature) the ERC-1271 adapter MUST decode the envelope and then perform digest binding: recompute the EIP-712 typed-data digest of the payment authorization from authorization, under a digest schema the implementation supports, and require it to equal hash. A digest schema is any typed-data layout that binds at least a payee, an amount, and a single-use nonce; ERC-3009’s TransferWithAuthorization is the reference schema. The adapter MUST NOT accept a digest it cannot decompose into those fields, and MUST return the magic value 0x1626ba7e exactly when digest binding succeeds and verifyPolicy holds for the decoded fields, 0xffffffff otherwise.
isValidSignature MUST NOT modify state (per ERC-1271). These requirements apply to envelopes that decode; a malformed envelope MAY revert during decoding, which ERC-1271 callers already treat as an invalid signature.
What the circuit proves beyond the bound public inputs (a private price cap, a merchant-signed quote, a category restriction, a time window) is the policy’s business and is out of scope for this standard. The standard only fixes where the proof is checked, what it is bound to, and the single-use coupling between a policy and a payment.
Reference pseudocode (informative)
Groth16 is used as the example proof system; the proof encoding and the verifier interface are whatever the policy’s registered verifier defines.
function verifyPolicy(bytes calldata authorization, bytes calldata proof)
external view returns (bool)
{
(address to, uint256 value,,, bytes32 nonce) =
abi.decode(authorization, (address, uint256, uint256, uint256, bytes32));
// 1. Policy lookup. An unrevoked single-use policy must exist for `nonce`.
Policy storage p = policies[nonce];
if (p.paramsCommit == bytes32(0)) return false;
// 2. Public-input construction. Nothing is taken from the prover except
// through `authorization`; the registered policy and the environment
// supply the rest.
uint256[5] memory publicInputs = [
uint256(uint160(to)), // authorization
value, // authorization
uint256(p.paramsCommit), // registered policy
uint256(uint160(address(this))), // scopes proof to this contract
block.chainid // scopes proof to this chain
];
// 3. Groth16 verification against the verifier registered for this
// policy. For Groth16 the proof bytes decode as the (a, b, c) points.
(uint256[2] memory a, uint256[2][2] memory b, uint256[2] memory c) =
abi.decode(proof, (uint256[2], uint256[2][2], uint256[2]));
return IGroth16Verifier(p.verifier).verifyProof(a, b, c, publicInputs);
}
// The ERC-1271 rail adapter: digest binding, then the same check. The token
// calls this; verifyPolicy above is what it is really asking.
function isValidSignature(bytes32 hash, bytes calldata signature)
external view returns (bytes4)
{
(bytes memory proof, bytes memory authorization) =
abi.decode(signature, (bytes, bytes));
// Digest binding. Recompute the EIP-712 digest from the authorization
// fields under a supported schema (reference: ERC-3009
// TransferWithAuthorization), with from = address(this), and require it
// to equal `hash`. Equality pins the payer, since `from` is part of the
// typed data and only this contract's address is ever used.
bytes32 recomputed = eip712Digest(tokenDomainSeparator, authorization);
if (recomputed != hash) return 0xffffffff;
return this.verifyPolicy(authorization, proof)
? bytes4(0x1626ba7e) // ERC-1271 magic value
: bytes4(0xffffffff);
}
Direct settlement (optional)
The required interface contains no function that moves funds, deliberately. On a signed-transfer rail the spend function already exists on the token (transferWithAuthorization), any EOA or contract may call it, and the implementation’s role is validation only; that separation is what lets the functionality attach to deployed rails unchanged.
On a rail with no such spend path, an implementation MAY expose settle(authorization, proof) and act as the settlement component itself. settle MUST require verifyPolicy(authorization, proof); MUST enforce the authorization’s validity window; MUST consume the nonce before transferring (it is the nonce-consuming settlement component required under Policy registration); MUST be permissionless; and SHOULD emit Settled. Unlike isValidSignature, settle is a state-changing call, which is exactly why it can own the nonce consumption.
Instantiation: ERC-3009 / ERC-7598 / x402 (informative)
Nothing in this section is normative; it is one deployment of the function set over rails that are live today. With an ERC-3009 + ERC-7598 token (for example USDC v2.2 and later), settlement is the standard flow: a facilitator submits transferWithAuthorization(from, to, value, validAfter, validBefore, nonce, bytes signature) with the proof envelope as signature. Because from is a contract, the token routes the bytes to from.isValidSignature, which performs the verification above. In an x402 deployment the envelope travels in the X-PAYMENT header of the standard exact scheme; the merchant and facilitator require no changes.
x402 HTTP 402 envelope (how the ask travels)
└─ ERC-3009 transferWithAuthorization (gasless signed transfer)
└─ ERC-7598 bytes-signature overload (the slot that carries the proof)
└─ ERC-1271 isValidSignature (adapter: verify the policy proof)
Rationale
The verifier is the account, not the rail. Everything upstream of the implementing contract treats the proof as an opaque signature. This is what makes “no protocol changes” hold, and it is the portability argument: the EVM instantiation is ERC-3009/7598/1271, but any settlement system that routes an opaque signature blob to a self-verifying account can host the same pattern.
Settlement-time constraint satisfaction, not pre-execution calldata matching. Verifying “the payment satisfies the policy” rather than “the calldata equals the signed intent” is what lets the proof bind off-chain facts such as a merchant-signed quote, and what removes the per-payment signature: the owner’s one registration covers whichever concrete payment the agent finds, as long as it satisfies the constraints.
Commitment, not plaintext policy. Registering paramsCommit instead of the parameters keeps negotiation-sensitive values (the cap) off-chain while still enforcing them.
Privacy model. The public input vector contains no secrets by construction: policy parameters live behind paramsCommit, and off-chain facts such as the merchant’s quote are private witness. to and value are public inputs precisely because they are inherently public at settlement: a transparent ERC-20 token emits Transfer(from, to, value) regardless, so hiding them from the verification call would gain nothing. Hiding them for real requires a private settlement rail, which replaces to/value with the rail’s own commitments; that is an instantiation concern, not a change to this interface. Moving the digest opening into the circuit (making the EIP-712 hash the only public input) was considered and rejected: it puts keccak in the circuit for zero privacy gain.
One policy = one payment. Binding the policy id to the authorization nonce reuses the settlement path’s own replay protection as the single-use mechanism and keeps the implementation stateless in the signature path. Multi-payment budgets (one commitment authorizing a decrementing budget across N settlements) require monotonic spent-state advanced outside the view-only signature check; they are deliberately out of scope for this ERC and are expected to build on it (the implementing contract, as the verifier, is the natural home for that counter).
No balance/policy coupling. The held balance bounds aggregate loss across all outstanding policies; per-payment bounds come from each policy. Coupling the two (reserving balance per policy) is an implementation choice, not a standard requirement.
Backwards Compatibility
No changes to any deployed contract or protocol. Any settlement path that routes a bytes signature to the payer’s ERC-1271 check can spend from a conforming contract; ERC-20 tokens implementing ERC-3009 with the ERC-7598 bytes overload (USDC FiatTokenV2_2 and later) are the deployed example, and x402-style HTTP flows carry the envelope unchanged.
Security Considerations
- Circuit soundness is the policy. A bug in the policy circuit is a bug in the spending control. Policy circuits SHOULD be small, auditable, and versioned inside
paramsCommit(a circuit-version field in the commitment prevents proofs from a retired circuit shape). - Proof-system choice. Groth16 requires a per-circuit trusted setup; the per-policy
verifierfield keeps the proof system swappable per policy. - The verifier is part of the policy. A verifier contract embeds one circuit’s verifying key, and
paramsCommitis only meaningful relative to that circuit, so the pair is registered together inallowPolicy; this structurally prevents validating a proof for one circuit against a commitment meant for another. The verifier address is owner-set and therefore owner-trusted (registering a bad verifier is the same class of mistake as approving a bad policy), and implementations MUST call it as a stateless view function. Verifiers are shared infrastructure: one deployment per circuit per chain serves every policy and every implementing contract, since parameters vary throughparamsCommit, not through the circuit. Where the verifier is universal (one deployment verifying many circuits, as with zkVM verifiers that take a program id), the circuit identity MUST still be fixed at registration, either insideparamsCommitor registered alongside it; the verifier address alone no longer pins the circuit in that case. - View-only verification. ERC-1271 verification cannot write state, so nothing in the signature path may be relied on to record spending. Single-use comes from the settlement path’s nonce consumption, not from the implementation.
- The envelope is not a script. Fields the circuit does not constrain are the agent’s discretion: two payments satisfying the same policy are interchangeable. Owners MUST understand that the policy, not the natural-language task, is the entire enforcement boundary.
- Quote-signer trust. Policies that bind merchant-signed quotes make the merchant’s signing key a trust anchor; a compromised quote signer can attest prices that defeat the cap’s intent (never the payee binding).
- Revocation races.
revokePolicybefore settlement MUST take effect for any laterisValidSignaturecall, but a settlement already in flight in the same block may still verify; owners needing hard cancellation SHOULD also use the settlement path’s own cancellation where it exists (for example ERC-3009cancelAuthorization).
Copyright
Copyright and related rights waived via CC0.