article

How Confidential x402 Works

9 min read

In our previous post, we made the case for why x402 needs a privacy layer: the average agent payment is $0.31, the legacy rails cannot service it, and the moment B2B pricing moves onchain it becomes a public broadcast. We also introduced Merces, our confidential token transfer system, and the confidential scheme we built on top of it.

Here we describe the architecture, the cryptographic primitives, the ZK circuit, the protocol end-to-end, and an honest comparison with every serious alternative we have found. The implementation is live on Base Sepolia and available on GitHub.

Architecture at a Glance

Five components cooperate to complete a confidential x402 payment.

The Client is the agent or user-side library. It holds the spending key, maintains local balance state, generates the ZK proof, and constructs the payment payload.

The Resource Server is the API provider. It is unchanged from standard x402 in structure: it issues HTTP 402 responses, forwards payment payloads to a facilitator, and serves content on successful verification. The only difference is that it advertises scheme: "confidential" in its payment requirements.

The Facilitator is the off-chain verifier and settlement router. It checks ZK proofs via snarkjs, queries the MPC network for balance sufficiency, validates EIP-712 signatures, and submits settlement transactions. It has the same interface contract as a stock x402 facilitator, with a verifier for the new scheme plugged in.

The MPC Network is a committee of operators that holds the secret-shared balances. It answers yes/no affordability queries, decrypts the amount shares during settlement, and produces its own ZK proof that balance updates are correct.

The PrivateBalance contract lives on Base. It stores balance commitments, not balances. It verifies the client Groth16 proof onchain during transferFrom(), enqueues the MPC action, and verifies the MPC’s proof when the balance update is finalized.

The Cryptographic Stack

The ZK Circuit

TACEO Confidential x402 GitHub repository

In a single Groth16 proof, the client demonstrates four facts simultaneously:

Public inputs: the commitment, the three ciphertexts, the three operator public keys, sender address, receiver address, nonce.

Private inputs: the amount, the blinding factor, the three plaintext shares, the BabyJubJub ephemeral secrets.

Performance, measured against the deployed circuit:

Protocol Walkthrough, End to End

Phase 1: Discovery (Client → Resource Server)

  1. Client sends HTTP request to a protected endpoint:
    • GET /api/premium-data HTTP/1.1
    • api.endpoint.com (as an example)
  2. Resource server checks config, sees it is protected and that there is no payment-signature header, and returns 402:
    • HTTP/1.1 402 Payment Required
    • PAYMENT REQUIRED: <base64-encoded PaymentRequired>

Payment-required response format:

{
  "x402Version": 2,
  "resource": {
    "url": "/api/premium-data",
    "description": "Premium data endpoint"
  },
  "accepts": [
    {
      "scheme": "confidential",
      "network": "eip155:8453",
      "amount": "100000",
      "asset": "0xUSDC...",
      "payTo": "0xResourceServer...",
      "maxTimeoutSeconds": 30,
      "extra": {
        "confidentialToken": "0xConfToken...",
        "eip712Domain": {
          "name": "Merces",
          "version": "1"
        },
        "mpcPks": [
          ["pk0X","pk0Y"],
          ["pk1X","pk1Y"],
          ["pk2X","pk2Y"]
        ]
      }
    }
  ]
}

Phase 2: Payment Construction (Client-Side)

  1. Generate blinding factor rr.
  2. Generate a Groth16 proof which proves:
    1. Poseidon2 commitment matches (amount, rr).
    2. Additive 3-of-3 secret shares sum correctly.
    3. BabyJubJub ECDH encryption of each share to the corresponding MPC pubkey.
    4. Amount fits in 80 bits.
    5. Ephemeral public key is derived correctly from the secret key.
  3. Create CipherText, obtain the MPC pubkeys from extra.mpcPublicKeys:
ciphertext = {
  amount: [share1, share2, share3],    // secret shares of val
  r: [rShare1, rShare2, rShare3],      // secret shares of randomness r
  sender_pk: { x, y }                  // ephemeral BabyJubJub pub key
}
  1. Create EIP-712 typed-data message containing:

    1. sender — bind payment to client address.
    2. receiver — ensure it only goes here, not somewhere else.
    3. amountCommitmentPoseidon2([val, r], 0x..).
    4. keccak256(CipherText).
    5. nonce — replay protection.
    6. deadline.
  2. Package payload to be sent:

{
  "x402Version": 2,
  "resource": { "url": "/api/premium-data" },
  "accepted": {
    "scheme": "confidential",
    "network": "eip155:8453",
    "amount": "val..",
    "asset": "0xUSDC...",
    "payTo": "0xResourceServer...",
    "maxTimeoutSeconds": 30,
    "extra": { }
  },
  "payload": {
    "signature": "0xSignature",
    "authorization": {
      "from": "0xClient...",
      "to": "0xResourceServer..",
      "amountCommitment": "<poseidon2 hash>",
      "amountR": "<r>",
      "beta": "...",
      "ciphertexts": ["...", "...", "...", "...", "...", "..."],
      "senderPk": ["...","..."],
      "proof": {
        "pi_a": ["...", "..."],
        "pi_b": [["...", "..."], ["...", "..."]],
        "pi_c": ["...", "..."],
        "protocol": "groth16",
        "curve": "bn128"
      },
      "deadline": "...",
      "nonce": "..."
    }
  }
}
  1. Resend the original request to the protected endpoint with the payment attached:
    • GET /api/premium-data HTTP/1.1
    • api.endpoint.com
    • PAYMENT-SIGNATURE: <base64 of PaymentPayload>

Phase 3: Verification (Resource Server → Facilitator)

  1. Resource server receives and decodes Payment-Signature.
  2. Resource server checks: does payload.accepted match one of its own accepts entries?
  3. Resource server sends a verify request to the Facilitator:
POST /verify
{
  "x402Version": 2,
  "paymentPayload": { },
  "paymentRequirements": {
    "scheme": "confidential",
    "network": "eip155:8453",
    "amount": "100000",
    "asset": "0xUSDC...",
    "payTo": "0xResourceServer..."
  }
}
  1. Facilitator routes to the “confidential” scheme handler and runs verify():
    • Check ciphertext — senderPk is on BabyJubJub and all shares are in the prime field.
    • Check sufficient balance for sender — query MPC network: “does sender have ≥ val (amount needed to pay resource server for query)”.
    • Check validity of signed typed data — signed payload by client covering amountCommitment, deadline, nonce, etc.
    • Check commitment matches amount — recompute commit(amount, blindingFactor) off-chain and verify it equals amountCommitment.
    • Check client ZK proof — verify the Groth16 proof off-chain against 15 public signals constructed from (senderPk, amountCommitment, ciphertext shares interleaved, mpcPublicKeys).
  2. Facilitator returns a verify() response saying valid for 0xClient...

Phase 4: Serve Content & Settlement (Resource Server → Facilitator → Contract → MPC)

  1. Resource server sends settle request to facilitator:
POST /settle
{
  "x402Version": 2,
  "paymentPayload": { },
  "paymentRequirements": { }
}

The facilitator sends the ciphertext (encrypted amount + sender address) to the MPC network before settling. Each MPC operator decrypts its share, reconstructs the amount, compares against the sender’s real balance, and they collectively return a signed {sufficient: true/false}. The facilitator sees only yes/no and then settles.

  1. Facilitator calls transferFrom() on the confidential token contract:
confToken.transferFrom(
  0xClient,           // sender: Client address
  ClientAuth,         // Signature from client to execute this transfer
  nonce,              // Unique
  deadline,           // Did this come in the required window
  0xResourceServer,   // receiver (payTo)
  amountCommitment,   // Poseidon2 commitment
  ciphertext,         // 3 secret shares + ephemeral pk
  clientProof         // Groth16 proof { pA, pB, pC }
)
  1. The contract validates inputs (commitment in field, pk on curve, shares in field), verifies the Groth16 client proof onchain via ClientTransferVerifier.verifyProof(pA, pB, pC, pubSignals) where pubSignals is a 15-element array built from (senderPk, amountCommitment, interleaved ciphertext, stored mpc_pk1/pk2/pk3). If the proof is invalid, it reverts with InvalidProof(). Otherwise it enqueues the transfer action.
  2. Facilitator returns a settle response with tx hash:
{
  "success": true,
  "transaction": "0xTxHash...",
  "network": "eip155:8453"
}
  1. MPC picks up the transfer action.
  2. MPC decrypts the secret shares and recovers the actual amount (val) and randomness (r). It verifies: Poseidon2([val, r], 0x..) == amountCommitment.
  3. MPC updates balances.
  4. MPC generates a Groth16 proof showing the balance update is valid.
  5. MPC calls processMPC(inputs, proof) on the contract. The contract verifies the proof, updates balance commitments for both parties, and removes the action from the queue.
  6. Resource server receives the facilitator’s verification and serves content to the client:
    • HTTP/1.1 200 OK
    • Content-Type: application/json
    • PAYMENT-RESPONSE: <base64 of SettleResponse>

Confidential x402 sequence diagram: client, resource server, facilitator, MPC network, and contract interactions across discovery, verification, settlement, and content delivery.

Privacy Analysis

What is protected

WhatStandard x402 (exact)Confidential x402How
Transfer amountsPlaintext in transferWithAuthorization callPoseidon2 commitment onchainReal amount secret-shared with MPC network
Account balancesPublic via ERC-20 balanceOf()Confidential token contract only exposes a commitmentActual balances only retrievable by MPC network via secret sharing
Spending patternsFull payment history enables behavior profiling of API consumersHistory only shows who interacted with whom; behavior profiling more limitedObservers can count payments but cannot sum amounts
Price discrimination evidenceOnchain proof of what each user paidNo onchain evidence; the resource server (API provider) maintains all client payment dataCannot prove different users paid different prices for the same API

Onchain visibility

Leaked dataWhereImpact
Sender addressActionQuery.senderAnyone can see who sends
Receiver addressActionQuery.receiverAnyone can see who receives
Payment frequency and timingtransferFrom() callsObservers can count API calls a client makes and when
Nonce consumptionusedNonces mapping in contractNumber of nonces reveals payment count per sender
Sender-receiver graphAggregated from all transfers over timeCan map which clients use which APIs

HTTP-layer visibility

Leaked dataWhereWho can see itChanged in Confidential x402?
Service price (amount)PaymentRequirements.amount and PaymentPayload.accepted.amountResource server (sets it); protected via TLS with client, facilitatorYes
API endpointresource.urlResource server, facilitatorNo
Client wallet addresspayload.authorization.senderResource server, facilitatorNo

Roadmap to Full Privacy

Merces already supports fully private transactions in its standalone form. The broader Merces protocol encrypts sender, receiver, and amount, building a private transaction graph on top of the same cryptographic stack described above.

We’re working on integrating that capability into x402: extending the confidential scheme with a private variant that uses stealth addresses and additional in-circuit checks on the relationship between commitments across transactions.

Try It, Build On It

The full implementation is open source.

The repo contains the contracts, the facilitator, the client library (Rust + TypeScript libs), the ZK circuit, the MPC network, and an end-to-end demo where a user makes real USDC payments on Base Sepolia with hidden amounts.

Merces integrates with x402 as a standard scheme. If you are already running an x402 facilitator or resource server, adding confidential payments is a scheme registration.

Additional resources:

We want to hear from you! Issues, pull requests, and threat-model critiques are welcome on GitHub.

For partnership, integration, or research conversations, get in touch about a Merces design partnership.