Skip to Content
How it worksThe agent SDK and x402

The agent SDK and x402

The SDK is the package @zx402/core. An agent needs nothing else: it gives the SDK a seed, a chain provider, the indexer and relayer URLs and the circuit files, and gets back deposits, private payments and exits. The code below follows the token demo in ops/src/demo.ts, trimmed.

Setting it up

import { createZx402, fileStore } from '@zx402/core'; import { IndexerClient, RelayerClient } from '@zx402/api'; const sdk = createZx402({ seed: agentSeed, // 32 bytes. Everything derives from it ctx, // the chain context: provider, network, deployment indexer: new IndexerClient('http://localhost:4010'), relayer: new RelayerClient('http://localhost:4011'), artifacts, // the spend and ragequit circuit files and keys store: fileStore('./agent-notes.json'), // where the notes live. Memory by default });

The seed is the root of the note secrets, the refund key and the one-time keys. The store holds the notes: their secrets, values, labels, status and the counters. Keep both. With the seed alone the SDK can find and exit every deposit, but not a change note.

The deployment in ctx selects the pool asset. Amounts use its base units: 1,000,000 units equal 1 tUSDM, which has 6 decimals. An ADA pool uses lovelace instead. The same SDK and validators support both configurations.

The interface

interface Zx402 { prepareDeposit(a: { amount: bigint; refundKeyHash: string }): Promise<PreparedDeposit>; deposit(a: { amount: bigint; walletSeed: Uint8Array }): Promise<{ txId: string; prepared: PreparedDeposit }>; sync(): Promise<void>; listNotes(): NoteRecord[]; balance(): { spendable: bigint; pending: bigint }; waitForNote(noteId: string): Promise<NoteRecord>; settlePrivately(a: { payouts: { address: string; amount: bigint }[]; noteId?: string }): Promise<SettleReceipt>; waitForSettle(id: string): Promise<SettleStatus>; ragequit(a: { noteId: string; refundSeed: Uint8Array; payTo?: string }): Promise<{ txId: string }>; refund(a: { noteId: string; refundSeed: Uint8Array; payTo?: string }): Promise<{ txId: string }>; recoverOneTimeFunds(a: { index: number; payTo: string }): Promise<{ txId: string; amount: bigint } | null>; x402Signer(a?: { mode?: 'stealth'; maxPrice?: bigint }): ClientCardanoSigner; }
MethodWhat it does
prepareDepositMakes a note and the deposit datum for a wallet that builds its own transaction
depositSyncs, makes a note, builds and submits the deposit from the given wallet seed
syncDownloads the leaves, approved labels, nullifiers and deposits, checks the roots against the chain, and updates every note’s status
waitForNotePolls until a note is in the tree and approved
settlePrivatelyProves a spend and sends it through the relayer. Picks a note by itself unless told which
ragequitExits a note in public to the refund key, or to payTo
refundTakes back a deposit that was never absorbed
recoverOneTimeFundsMoves money that was left at a one-time address after a failed second leg
x402SignerReturns a signer that pays any stock x402 seller from the pool

A deposit

const deposit = await sdk.deposit({ amount: 10_000_000n, walletSeed }); const note = await sdk.waitForNote(deposit.prepared.noteId); // note.value is 10_000_000n: 10 tUSDM with the M0 protocol fee of zero

The wallet supplies the tokens, the deposit output’s minimum ADA (about 1.4 ADA), and the network fee. The crank keeps the attached ADA. It takes no crank fee in tokens, so the note receives gross - fee. In an ADA pool, the configured 0.3 ADA crank fee still applies; a 10 ADA deposit creates a 9.7 ADA note.

Before it makes the note, deposit syncs, so it never reuses a note secret that the pool has seen. After it submits, it follows the exact output reference of its own transaction, so a copy of the datum made by someone else cannot take the note’s place.

A private x402 payment

The x402 client library from Coinbase, @x402/fetch, wraps fetch. It handles the 402, builds the payment and retries the request. The Cardano scheme from @x402/cardano needs a signer with two methods: getAddress and buildAndSignPaymentTransaction. The SDK’s x402Signer is that signer.

import { wrapFetchWithPaymentFromConfig } from '@x402/fetch'; import { ExactCardanoScheme } from '@x402/cardano/exact/client'; const poolAsset = '16a55b2a349361ff88c03788f93e1e966e5d689605d044fef722ddde.0014df10745553444d'; const signer = sdk.x402Signer({ mode: 'stealth', maxPrice: 2_000_000n }); const paidFetch = wrapFetchWithPaymentFromConfig(fetch, { schemes: [{ network: 'cardano:preprod', client: new ExactCardanoScheme(signer) }], spendControls: { allowedAssets: [{ network: 'cardano:preprod', asset: poolAsset, maxAmountPerPayment: '2000000' }] }, }); const response = await paidFetch('http://localhost:4021/weather'); // 200 OK, paid 2 tUSDM from the pool

This unit identifies the Masumi test USDM used by the pool. The x402 package’s default Preprod USDM policy starts with e675b46e, so it is a different asset. The seller must price in the pool’s unit, and the buyer must include that unit in allowedAssets. The signer reads its asset from ctx.deployment; maxPrice limits the seller price in base units, before fees.

Inside buildAndSignPaymentTransaction the signer does the whole private flow.

  1. It checks that the network matches the deployment and the asset matches the pool. The amount must be positive and at most maxPrice.
  2. It computes the seller output’s minimum ADA plus the second transaction’s fee, about 1.4 ADA. It reserves and saves the next one-time key index before moving funds.
  3. It requests one payout of exactly the token price to that address. Before proving, it refuses a quote whose payoutLovelace cannot cover the required ADA. The relayer fee enters withdrawn, not the payout amount.
  4. It polls its provider until that Settle output holds exactly the token price and enough ADA. The relayer supplies 2 ADA by default.
  5. It builds a plain payment of the tokens and all attached ADA minus the fee to the seller. It signs and evaluates that payment, then returns it to the x402 client. The client retries with the PAYMENT-SIGNATURE header, and the seller’s facilitator submits the transaction.

Leg 2 has one input, one seller output, and no change output. For an ADA pool, leg 1 pays the price plus the second transaction’s fee, and payoutLovelace is zero. The token pool charges 1 tUSDM per payout by default because the relayer supplies ADA for every output. The quote’s relayerFee is the total charge. One 2 tUSDM payment withdraws 3 tUSDM when the protocol fee is zero.

The key is wiped from memory after each use. If anything fails after the reservation, the error names the index, so recoverOneTimeFunds({ index, payTo }) can move the money.

What the SDK checks before it pays

The SDK trusts its seed and its chain provider. Everything an operator sends is checked.

CheckWhy
The rebuilt tree and ASP roots match the pool datum on chainA lying indexer cannot feed it a false tree
The protocol fee in the quote matches settle_fee_bps in the config outputA lying relayer cannot take most of a note as fee
The relayer fee is at most maxRelayerFee, 2,000,000 base units by default (2 tUSDM or 2 ADA)The cap applies to the quote’s total relayer charge
The asset matches the pool, and each payout meets its floorToken payouts start at 1 base unit; ADA payouts start at 1 ADA
For token x402 payments, payoutLovelace covers the seller minimum ADA plus the second transaction’s feeThe second leg has enough ADA before the SDK proves or reserves the note
The quote deadline is after the chain tip and within 15 minutes of itA relayer cannot keep a note reserved for hours
The proof is bound to the payouts, the relayer key and the deadlineA relayer cannot redirect the money

After it sends a proof, the SDK marks the note as pending. Only a definite refusal from the relayer, one of ten known codes, releases the note. An uncertain answer, a timeout or an unreadable reply keeps the note reserved until the chain shows the result or the deadline passes. The change note is kept in every case.

The change

A 2 tUSDM payment and a 1 tUSDM relayer fee leave 7 tUSDM of change from a 10 tUSDM note. M0 charges no protocol fee. The relayer’s attached ADA does not reduce that token change. The SDK records the change note at once, as queued, and waitForNote turns it spendable after the crank absorbs it. The next payment spends the change. One deposit can pay many times.

Exiting

await sdk.ragequit({ noteId: change.id, refundSeed: walletSeed });

The refund seed is the wallet seed that made the deposit, because its key hash is part of the label. The money goes to that wallet, or to payTo. A deposit that was never absorbed goes back through refund instead.

In a token pool, the exit carries the note’s tokens plus minimum ADA from the refund wallet. The builder preserves the pool’s ADA.

Note statuses

StatusMeaning
createdThe note exists in the store. Nothing is on chain yet
depositedThe deposit transaction is on chain, waiting for the crank
queuedA change note sits in the pool queue, waiting for the crank
insertedIn the tree, not yet approved
spendableIn the tree and approved
spentIts nullifier hash is on chain after a settle
exitedIts nullifier hash is on chain after a ragequit
refundedThe deposit was taken back before the crank absorbed it

A note that is reserved for a settle, an exit or a refund also carries a pending record with the deadline of that attempt. The record is cleared when the chain shows the result or the deadline passes.

Limits of the SDK today

  • A seller must request the pool’s exact asset. The SDK refuses another token or an ADA price for a token pool.
  • A stock x402 client that repeats a request after an unclear answer pays twice. The SDK keeps no memory of a payment per request.
  • The one-time address counter lives in the store. After a lost store the addresses repeat, which links payments but loses nothing.
  • There is no spend limit per agent beyond maxPrice and the x402 client’s own spendControls.
  • The default store is in memory. A restart without a file store forgets unspent change notes.