DeFi Intel

ERC-8366: Zero-Knowledge Spending Policies

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. Implementing the set turns the contract into a policy escrow for the funds it holds; the standard is the function set and its verification semantics, not a contract type. 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 gets the magic value exactly when the accompanying proof satisfies the registered policy. On the rail, the implementing contract is the payer. The core of this standard is that verification hook: what the implementation must bind, and what makes a policy single-use. Rails that already route contract signatures, such as ERC-3009 with the ERC-7598 bytes overload (and HTTP flows like x402 above them), can spend from a conforming account 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 / 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-8150 verifies agent payments with ZK proofs, but against a per-batch user-signed intent, by calldata matching. It requires a fresh signature per batch, cannot keep parameters private from the counterparty tooling, and cannot bind off-chain facts either. ERC-8183 standardizes escrow for agent jobs (release on delivery evaluation), not a spending policy on the payer. ERC-8354 gates arbitrary agent actions behind a confidential third-party ruleset: a policy-domain engine, not the agent, evaluates the action against rules the agent never sees and proves the verdict. Its trust topology is the reverse of this standard’s: 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. It also guards execution through account hooks, while this standard enforces at settlement in the payer’s signature path. ERC-8312 meters how much of a bounded mandate an agent has consumed but enforces nothing; it is complementary, and a natural companion to the multi-payment budget extension discussed in Rationale. ERC-8004 is a trust and discovery layer and explicitly excludes payments. 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” are to be interpreted as described in RFC 2119. 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); /// ERC-1271. `signature` carries the encoded proof envelope (see below). function isValidSignature(bytes32 hash, bytes calldata signature) external view returns (bytes4); /// OPTIONAL. Emitted by direct settlement. event Settled(bytes32 indexed nonce, address to, uint256 value); /// OPTIONAL. Direct settlement for rails without contract-signature /// signed transfers. Permissionless: the proof, not the caller, is the /// authorization. See "Direct settlement". function settle(bytes calldata authorization, bytes calldata proof) external; } Policy registration allowPolicy and revokePolicy MUST be restricted to the owner. nonce MUST 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 a nonce that already has an unrevoked policy MUST revert. paramsCommit is opaque to the account. The commitment scheme (for example Poseidon([CIRCUIT_VERSION, cap, quoteSignerPubKey]) ) 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. Proof envelope The signature bytes passed to isValidSignature MUST decode as: abi.encode(bytes proof, bytes authorization) authorization exists because hash is one-way: ERC-1271 hands the implementation only the digest and these bytes, so the cleartext authorization fields (payee, amount, nonce, validity window) must ride along for the implementation to recompute the digest, look up the policy, and construct the public inputs. It is untrusted input; the digest-equality check in step 1 is what makes it safe. The envelope deliberately carries no public inputs. Every public input is anchored to a source the implementation already trusts (the registered policy, the verified digest, 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 On isValidSignature(hash, signature) the implementation MUST perform all of the following, and MUST return the ERC-1271 magic value 0x1626ba7e only if every step succeeds: 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 implementation MUST NOT accept a digest it cannot decompose into those fields. Policy lookup. Require an unrevoked policy registered for the digest’s nonce . Public-input construction. Construct the public input vector, at minimum [to, value, paramsCommit, account, chainid] : to and value from the verified digest, paramsCommit from the registered policy, account as address(this) (the implementing contract), and chainid as the executing chain id. The last two scope the proof to this contract and chain. Public inputs MUST NOT be read from the proof envelope. 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 in authorization and 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.timestamp for 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 the merchant-signed quote is authenticated against the quote-signer key committed in paramsCommit . Proof verification. Verify proof against the policy’s registered verifier and the public input vector. isValidSignature MUST NOT modify state (per ERC-1271). 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 isValidSignature(bytes32 hash, bytes calldata signature) external view returns (bytes4) { (bytes memory proof, bytes memory authorization) = abi.decode(signature, (bytes, bytes)); // 1. Digest binding. Recompute the EIP-712 digest from authorization under a // supported schema (reference: ERC-3009 TransferWithAuthorization) and // require it to equal `hash`. Equality also pins from == address(this), // since `from` is part of the typed data. (address from, address to, uint256 value,,, bytes32 nonce) = decodeAuthorization(authorization); bytes32 recomputed = eip712Digest(tokenDomainSeparator, authorization); if (recomputed != hash || from != address(this)) return 0xffffffff; // 2. Policy lookup. An unrevoked single-use policy must exist for `nonce`. Policy storage p = policies[nonce]; if (p.paramsCommit == bytes32(0)) return 0xffffffff; // 3. Public-input construction. Nothing is taken from the prover: the // digest, the registered policy, and the environment supply every value. uint256[5] memory publicInputs = [ uint256(uint160(to)), // verified digest value, // verified digest uint256(p.paramsCommit), // registered policy uint256(uint160(address(this))), // scopes proof to this contract block.chainid // scopes proof to this chain ]; // 4. 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])); if (!IGroth16Verifier(p.verifier).verifyProof(a, b, c, publicInputs)) return 0xffffffff; return 0x1626ba7e; // ERC-1271 magic value } 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. 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 perform the same verification as isValidSignature steps 1–4, computing the authorization digest locally from authorization under the schema in use; 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 account over rails that are live today. With an ERC-3009 + ERC-7598 token (for example USDC ≥ v2.2), 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 (this standard: verify the policy proof) Rationale The verifier is the account, not the rail. Everything upstream of the account 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 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 ERC-3009 nonce reuses the token’s own replay protection as the single-use mechanism and keeps the account 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 account, as the verifier, is the natural home for that counter). No balance/policy coupling. The escrowed balance bounds aggregate loss across all outstanding policies; per-payment bounds come from each policy. Coupling the two (reserving balance per policy) is an account-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 account; 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 verifier field keeps the proof system swappable per policy. The verifier is part of the policy. A verifier contract embeds one circuit’s verifying key, and paramsCommit is only meaningful relative to that circuit, so the pair is registered together in allowPolicy ; 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 through paramsCommit , 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 inside paramsCommit or 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 token’s nonce burn, not from the account. 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. revokePolicy before settlement MUST take effect for any later isValidSignature call, but a settlement already in flight in the same block may still verify; owners needing hard cancellation SHOULD also use ERC-3009 cancelAuthorization on the token. Copyright Copyright and related rights waived via CC0 . 1 post - 1 participant Read full topic

Read full article on Ethereum_magicians →

DeFi Intel is an entity-graph aggregator: we curate, tag and link crypto news to a typed knowledge graph of protocols, tokens, people and incidents. We do not republish the full article body. Use the link above to read the original report at Ethereum_magicians.

Stay current on entity-tagged crypto news

Get the weekly DeFi Intel brief — entity-graph intelligence covering protocols, tokens and incidents, free to your inbox.