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;
}| Method | What it does |
|---|---|
prepareDeposit | Makes a note and the deposit datum for a wallet that builds its own transaction |
deposit | Syncs, makes a note, builds and submits the deposit from the given wallet seed |
sync | Downloads the leaves, approved labels, nullifiers and deposits, checks the roots against the chain, and updates every note’s status |
waitForNote | Polls until a note is in the tree and approved |
settlePrivately | Proves a spend and sends it through the relayer. Picks a note by itself unless told which |
ragequit | Exits a note in public to the refund key, or to payTo |
refund | Takes back a deposit that was never absorbed |
recoverOneTimeFunds | Moves money that was left at a one-time address after a failed second leg |
x402Signer | Returns 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 zeroThe 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 poolThis 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.
- It checks that the network matches the deployment and the asset matches the pool. The amount must be positive and at most
maxPrice. - 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.
- It requests one payout of exactly the token price to that address. Before proving, it refuses a quote whose
payoutLovelacecannot cover the required ADA. The relayer fee enterswithdrawn, not the payout amount. - It polls its provider until that Settle output holds exactly the token price and enough ADA. The relayer supplies 2 ADA by default.
- 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-SIGNATUREheader, 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.
| Check | Why |
|---|---|
| The rebuilt tree and ASP roots match the pool datum on chain | A lying indexer cannot feed it a false tree |
The protocol fee in the quote matches settle_fee_bps in the config output | A 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 floor | Token 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 fee | The 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 it | A relayer cannot keep a note reserved for hours |
| The proof is bound to the payouts, the relayer key and the deadline | A 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
| Status | Meaning |
|---|---|
created | The note exists in the store. Nothing is on chain yet |
deposited | The deposit transaction is on chain, waiting for the crank |
queued | A change note sits in the pool queue, waiting for the crank |
inserted | In the tree, not yet approved |
spendable | In the tree and approved |
spent | Its nullifier hash is on chain after a settle |
exited | Its nullifier hash is on chain after a ragequit |
refunded | The 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
maxPriceand the x402 client’s ownspendControls. - The default store is in memory. A restart without a file store forgets unspent change notes.