Protocol Specification
Formal u402 protocol specification — message formats, verification algorithm, settlement flow, and proof system dispatch.
u402 Protocol Specification
Version: 0.1.0-draft
Status: Draft
1. Overview
u402 defines a "private" payment scheme for the x402 protocol. When a server returns HTTP 402 with scheme: "private", the client generates zero-knowledge proofs against the Universal Private Pool and returns them in the payment header. The server settles the proofs on-chain without learning the payer's identity.
1.1 Design Goals
- No key transmission. The client's private keys never leave the client. Only ZK proofs are transmitted.
- Proof-as-authorization. The ZK proof replaces the EIP-3009 signature. It simultaneously proves note ownership, balance sufficiency, and (optionally) ASP compliance.
- Proof-system agnostic. The protocol supports multiple proof systems (PLONK/BLS12-381, Circle STARK/M31) through a
proofSystemdiscriminator. - x402 compatible. u402 follows x402 v2's plugin architecture. Servers can accept both
"exact"(plain x402) and"private"(u402) schemes simultaneously.
2. Payment-Required (Server → Client)
When a server requires private payment, it returns HTTP 402 with a Payment-Required header containing:
{
"scheme": "private",
"network": "eip155:11155111",
"pool": "0x3b5ed60adbd5dd57f47ec54179e4b1c06636285b",
"asset": "0xTokenAddress",
"payTo": "0xServiceAddress",
"amount": "1000000000000000000",
"aspId": 1,
"acceptedProofSystems": ["plonk", "circle-stark"]
}2.1 Field Definitions
| Field | Type | Required | Description |
|---|---|---|---|
scheme | string | Yes | Must be "private" |
network | string | Yes | CAIP-2 chain identifier (e.g., "eip155:11155111" for Sepolia) |
pool | address | Yes | UPP contract address |
asset | address | Yes | ERC-20 token address (must be shieldable in the pool) |
payTo | address | Yes | Withdrawal recipient — the service provider's public address |
amount | string | Yes | Minimum payment in wei (base units of the token) |
aspId | uint256 | Yes | Required ASP ID for compliance. 0 = ragequit (no compliance required) |
acceptedProofSystems | string[] | No | Proof systems the server will accept. Default: ["plonk"] |
3. Payment-Signature (Client → Server)
The client generates two ZK proofs — a transfer (split the agent's note into a payment note and change) and a withdraw (convert the payment note to a public ERC-20 transfer to the service provider).
{
"scheme": "private",
"proofSystem": "plonk",
"transfer": {
"proof": { ... },
"nullifier": "0x...",
"stateRoot": "0x...",
"aspRoot": "0x...",
"token": "0xTokenAddress",
"outputCommitment1": "0x...",
"outputCommitment2": "0x...",
"encryptedNote1": "0x...",
"encryptedNote2": "0x..."
},
"withdraw": {
"proof": { ... },
"nullifier": "0x...",
"stateRoot": "0x...",
"aspRoot": "0x...",
"aspId": 1,
"token": "0xTokenAddress",
"amount": "1000000000000000000",
"recipient": "0xServiceAddress",
"isRagequit": false
}
}3.1 PLONK Proof Format
When proofSystem is "plonk", the proof object contains:
| Field | Type | Description |
|---|---|---|
A, B, C, Z | bytes (128 each) | G1 polynomial commitments |
T1, T2, T3 | bytes (128 each) | Quotient polynomial commitments |
eval_a, eval_b, eval_c | uint256 | Wire evaluations at challenge point |
eval_s1, eval_s2 | uint256 | Permutation polynomial evaluations |
eval_zw | uint256 | Shifted permutation evaluation |
Wxi, Wxiw | bytes (128 each) | Opening proof commitments |
G1 points are encoded as 128-byte uncompressed coordinates (64 bytes x, 64 bytes y) per EIP-2537 format.
3.2 Circle STARK Proof Format
When proofSystem is "circle-stark", the proof field is a single bytes blob — the serialized STARK proof as produced by the Stwo prover, verified by the on-chain CircleStarkVerifier.
3.3 Transfer Fields
| Field | Type | Description |
|---|---|---|
proof | object or bytes | ZK proof (format depends on proofSystem) |
nullifier | bytes32 | Nullifier of the spent input note |
stateRoot | uint256 | Merkle root the proof was generated against |
aspRoot | uint256 | ASP Merkle root for compliance check |
token | address | ERC-20 token address |
outputCommitment1 | bytes32 | Commitment of the payment note (for the service) |
outputCommitment2 | bytes32 | Commitment of the change note (back to agent) |
encryptedNote1 | bytes | AES-GCM encrypted payment note |
encryptedNote2 | bytes | AES-GCM encrypted change note |
3.4 Withdraw Fields
| Field | Type | Description |
|---|---|---|
proof | object or bytes | ZK proof |
nullifier | bytes32 | Nullifier of the payment note created by the transfer |
stateRoot | uint256 | Merkle root (predicted root after transfer insertion) |
aspRoot | uint256 | ASP Merkle root |
aspId | uint256 | ASP ID used for compliance proof |
token | address | ERC-20 token address |
amount | uint256 | Withdrawal amount (must be >= server's required amount) |
recipient | address | Must match payTo from Payment-Required |
isRagequit | boolean | Whether this is a ragequit withdrawal (bypasses ASP) |
4. Verification Algorithm (Server)
The server MUST perform the following checks before settling:
4.1 Format Validation
schemeequals"private"proofSystemis in the server'sacceptedProofSystemslist- All required fields are present and correctly typed
withdraw.recipientequals the server'spayToaddresswithdraw.amount>= the server's requiredamountwithdraw.tokenequals the server's requiredassettransfer.tokenequalswithdraw.tokentransfer.nullifier!=withdraw.nullifier(different notes)
4.2 Freshness Validation
transfer.stateRootis a known recent root on the pool contract (isKnownRoot())transfer.nullifierhas not been used (!nullifierUsed())withdraw.nullifierhas not been used (!nullifierUsed())
Root Prediction
The withdraw proof's stateRoot must account for the transfer's output commitments being inserted into the Merkle tree. The client predicts the post-transfer root by computing the insertions locally. If another transaction changes the tree between proof generation and settlement, the pool's ROOT_HISTORY_SIZE provides a window of valid recent roots.
4.3 Settlement
- Call
pool.transfer(...)with the transfer proof and public inputs - Call
pool.withdraw(...)with the withdraw proof and public inputs - Verify both transactions succeed
If either transaction reverts, the payment has failed. The server MUST NOT return 200.
5. Settlement Dispatch
The server dispatches to different contract functions based on proofSystem:
proofSystem | Transfer Function | Withdraw Function | Verifier |
|---|---|---|---|
plonk | pool.transfer(...) | pool.withdraw(...) | PlonkVerifierBLS12381 (EIP-2537) |
circle-stark | pool.transferSTARK(...) | pool.withdrawSTARK(...) | CircleStarkVerifier |
PLONK and Circle STARK notes live in separate Merkle trees within the same pool contract. A PLONK transfer creates notes in the Poseidon/BLS12-381 tree; a STARK transfer creates notes in the Keccak/M31 tree.
6. Atomicity
The transfer and withdraw are two separate on-chain transactions. This creates a failure window: the transfer may succeed but the withdraw may fail (e.g., due to root staleness).
6.1 Recommended: Router Contract
A router contract that executes both operations in a single transaction eliminates this risk:
function settleU402(
TransferCalldata calldata transferData,
WithdrawCalldata calldata withdrawData
) external {
pool.transfer(transferData);
pool.withdraw(withdrawData);
}If either call reverts, the entire transaction reverts.
6.2 Alternative: Sequential Settlement
If no router is deployed, the server settles sequentially. If the transfer succeeds but the withdraw fails:
- The payment note exists in the pool but has not been withdrawn
- The agent's nullifier is consumed (no double-spend risk)
- The server should retry the withdraw, or return an error so the agent can reclaim the note
7. Error Handling
| Error | HTTP Status | Description |
|---|---|---|
INVALID_PROOF_FORMAT | 400 | Payment header is malformed or missing fields |
UNSUPPORTED_PROOF_SYSTEM | 400 | Server doesn't accept the offered proof system |
RECIPIENT_MISMATCH | 400 | withdraw.recipient doesn't match payTo |
INSUFFICIENT_AMOUNT | 402 | withdraw.amount is less than required |
STALE_ROOT | 409 | Merkle root is no longer in the pool's history window |
NULLIFIER_USED | 409 | One of the nullifiers has already been spent |
PROOF_VERIFICATION_FAILED | 402 | On-chain proof verification reverted |
SETTLEMENT_FAILED | 502 | On-chain transaction reverted for other reasons |
8. Compliance
u402 inherits UPP's compliance model. The aspId field in both the Payment-Required and withdraw proof determines which ASP's allowlist is checked during proof verification.
- ASP ID > 0: The withdraw circuit verifies that the original deposit address is in the specified ASP's Merkle tree. The verifier learns "this address is in the approved set" without learning which address.
- ASP ID = 0 + ragequit: The original depositor can withdraw to their deposit address regardless of ASP status. No compliance proof required.
Servers choose their compliance requirements by setting aspId. Agents that cannot satisfy a particular ASP can use ragequit to reclaim their funds.
For details on running an ASP, see UPC Operators.
9. Reference Implementations
| Component | Repository | Description |
|---|---|---|
| Pool contract | universal-private-pool | transfer(), withdraw(), transferSTARK(), withdrawSTARK() |
| Proof generation | @permissionless-technologies/upp-sdk | snarkjs.plonk (BLS12-381) and stwo (Circle STARK) provers |
| ASP compliance | @permissionless-technologies/upc-sdk | ASP client, Merkle tree management, proof generation |
| Starter template | x402-upd-starter | Next.js server + client with x402 and u402 support |