@z402-net/client
Every export of @z402-net/client: the prepaid-voucher scheme, the Zallet wallet, the chain status source, events, errors and the lower-level parts.
On this page
@z402-net/client is the paying side of z402. Its main export, PrepaidVoucherScheme, plugs into the stock x402 client. For each provider it makes the deposits, signs a voucher for every call, and reads the outcome of each call to decide what to do next. Pay from an agent shows it at work. This page lists everything the package exports.
Install and import
npm install --save-exact @z402-net/client @z402-net/lightwalletd @x402/fetch@2.28.0 @x402/core@2.28.0@z402-net/client is an ES module. It depends on @z402-net/protocol, which is installed with it, and it takes @x402/core as a peer dependency at exactly 2.28.0, so you install that yourself. It has no dependency on @x402/fetch: install that yourself for x402Client and wrapFetchWithPayment. The package needs no gRPC or wallet library. It reaches the chain through an object you give it, such as the client from @z402-net/lightwalletd, and Zallet through fetch. It also re-exports the seams that a wallet or a chain source of your own implements, so you need no @z402-net/protocol import for them.
import {
LightwalletdTxStatus,
PrepaidVoucherScheme,
ZalletRpcWallet,
} from "@z402-net/client";
// For a wallet or a chain source of your own
import { ChainUnavailableError, type TxStatus, type TxStatusSource, type ZcashWallet } from "@z402-net/client";At a glance
| Export | Kind | What it is |
|---|---|---|
PrepaidVoucherScheme | class | The x402 client scheme for prepaid-voucher. Deposits, vouchers, recovery. |
PrepaidVoucherSchemeOptions | interface | The options of its constructor. |
SessionView | interface | A read-only copy of one session, from session() and sessions(). |
ClientEvent | type | What onEvent receives. |
ZalletRpcWallet | class | A wallet that pays deposits through Zallet's JSON-RPC. |
ZalletRpcWalletOptions | interface | The options of its constructor. |
SentTransaction | interface | What findSentByMemo returns. |
zatToZecString | function | Exact conversion of zat to a ZEC decimal string. |
LightwalletdTxStatus | class | A TxStatusSource over a lightwalletd client. |
LightwalletdLike | interface | The two methods it needs from that client. |
RawTx | type | What getTransaction answers. |
ZalletRpcError | class | Zallet refused a request, or could not be reached. |
DepositStatusUnknownError | class | A deposit may exist and cannot be found. |
DepositUncertainError | class | A deposit with a known txid may or may not have been broadcast. |
InsufficientFundsError | class | The wallet could not fund the deposit. |
ZcashWallet, DepositRequest | interfaces | What a wallet of your own implements, and the request it receives. Re-exported from @z402-net/protocol. |
TxStatusSource, TxStatus | interface, type | What a chain source of your own implements, and what its status answers. Re-exported from @z402-net/protocol. |
ChainUnavailableError | class | What a chain source throws for an answer that is not definitive. Re-exported from @z402-net/protocol. |
Session | class | The state of one provider's session. |
LocalDeposit | interface | One deposit as the session records it. |
waitForConfirmation | function | Polls the chain until a transaction is mined, dead or timed out. |
WaitOptions | interface | Its options. |
Clock, systemClock | interface, constant | The time source of the scheme, and the real one. |
Mutex | class | A first-in, first-out async lock. |
classifyOutcome, actionFor, snapshotFromExtra | functions | Reading the outcome of a call, and choosing what to do about a refusal. |
Outcome, Action | types | Their results. |
PrepaidVoucherScheme
class PrepaidVoucherScheme implements SchemeNetworkClient {
readonly scheme: "prepaid-voucher";
readonly schemeHooks: { onPaymentResponse(ctx: PaymentResponseContext): Promise<void | { recovered: true }> };
constructor(opts: PrepaidVoucherSchemeOptions);
findDefaultAsset(asset: string, network: Network): DefaultAsset | undefined;
createPaymentPayload(x402Version: number, req: PaymentRequirements, ctx?: PaymentPayloadContext): Promise<PaymentPayloadResult>;
session(payTo: string, network?: string): SessionView | undefined;
sessions(): SessionView[];
close(): Promise<void>;
}The scheme keeps one session for each network and payTo pair it pays. A session is created on the first offer from that pair: a new random Ed25519 key, a running total of 0, and no deposits. Sessions live in memory only.
Scheme options
| Option | Type | Default | What it does |
|---|---|---|---|
wallet | ZcashWallet | required | Sends the deposits. See ZcashWallet. |
chain | TxStatusSource | required | Reads the tip and a transaction's status. The scheme uses it to wait for a deposit to be mined, to look for one the provider could not find, and to recheck an uncertain one. |
maxDeposits | number | Infinity | The most deposits one session may make. A deposit counts once the wallet returns its txid, and one that dies later still counts. 1 is the demo's cheat mode, and 0 never deposits. |
depositTriggerZat | bigint | the offer's windowZat | Start a deposit in the background when the headroom falls below this. |
maxDepositZat | bigint | 1_000_000n | Refuse an offer whose windowZat is larger. |
confirmationWaitMs | number | 600_000 | How long to wait after z402_awaiting_confirmation for the pending deposit to be mined, and after z402_deposit_not_found for the deposit to be seen. |
pollIntervalMs | number | 2_000 | The gap between its own chain reads while it waits. It must be above 0. |
facilitatorLagMs | number | 6_000 | The pause after it sees a deposit mined and before it retries, so that the facilitator has seen the block too. |
safetyReleaseMs | number | 120_000 | How long a session's lock may be held with no outcome, counted from the moment a voucher is returned, before it is released and the call counts as ambiguous. |
onEvent | (e: ClientEvent) => void | none | Receives every event. An exception it throws is ignored. |
seed | Uint8Array | random | Tests only. A fixed 32-byte seed for the session key. Every new session uses it. |
clock | Clock | systemClock | Tests only. The time source. |
The constructor throws a RangeError for a seed that is not 32 bytes, for a pollIntervalMs that is not above 0, and for a negative or NaN safetyReleaseMs, maxDeposits, confirmationWaitMs or facilitatorLagMs. The message names the option.
Members
| Member | What it does |
|---|---|
scheme | Always "prepaid-voucher". |
schemeHooks. | The stock client calls it after each paid request, with what came back. It reads the outcome, updates the session, acts on a refusal, releases the session's lock, and returns { recovered: true } when the wrapper should retry once. It never throws: an unexpected error becomes a fatal event with the code client_error. |
findDefaultAsset(asset, | Returns { asset: for ZEC on a Zcash network, and undefined for anything else. It is what lets the stock client's spend controls price ZEC. |
createPaymentPayload(x402Version, | Checks the offer, takes the session's lock, deposits if needed, and returns the signed voucher. See below. |
session(payTo, network?) | The first session with this payTo, narrowed to network when you pass one, as a SessionView. undefined when there is none. |
sessions() | Every session, in the order they started. |
close() | Cancels the safety timers, waits for deposits in flight to finish, then closes the wallet if it has a close. It does not close the chain reader. |
createPaymentPayload returns { x402Version, payload }, where x402Version is the one it was given and payload is { sessionKey,. The cumulativeZat is the session's running total plus the price of this call. sig is the Ed25519 signature of the voucher message, and depositTxid is the newest deposit in the state sent, until a settle acknowledges it. The fields are in Protocol.
What createPaymentPayload checks
It checks the offer before it takes any lock or calls the wallet, and every error message names its field. The rules run in this order, and the first one that fails throws.
| Field | Rule | The message starts with |
|---|---|---|
scheme | Must be prepaid-voucher. | scheme: |
network | Must be zcash:regtest, zcash:testnet or zcash:mainnet. | network: |
asset | Must be ZEC. | asset: expected "ZEC" |
payTo | Must be a shielded-only unified address for the network. | payTo: |
amount | A canonical zat string of at least 1. | amount: |
extra | Must hold valid terms: windowZat, creditRatioBps from 0 to 10,000, creditCapZat and zeroConfMinExpiryBlocks. Other keys are ignored. | extra: |
amount again | At most windowZat. | amount: |
extra.windowZat | At most maxDepositZat. | windowZat: |
context. | When the stock client sets it, it must be an integer zat string, and the window must not exceed it. | maxAmountPerPayment: or windowZat: |
After the checks, it throws session halted by <code> on deposit <txid> when the session was halted, before and after waiting for the lock. A session halts when the provider rejected a deposit, when the provider asked for another deposit while the one this call presented was pending or not listed, which the scheme treats as a fault on the provider's side, or when the wallet threw DepositStatusUnknownError. A halted session never deposits or signs again, and its halted field says why.
When the scheme deposits
The scheme decides before each voucher. The first rule that matches wins.
| Situation | Decision |
|---|---|
The session is halted, or maxDeposits is reached | No deposit. |
No snapshot yet, and a deposit exists that is sending, sent, acked or uncertain | No deposit. |
| No snapshot yet, and no such deposit: the first call | Inline. The call waits for the deposit. |
A deposit is pending: the last snapshot lists one as pending that is not dead or rejected here, or the newest deposit here is sent and not acknowledged | No deposit. |
| The headroom is below the price of this call | Inline. |
The headroom is below depositTriggerZat | In the background. The call does not wait. |
| Otherwise | No deposit. |
The headroom is the limit in the last snapshot minus the running total. A background deposit runs at most one at a time for a session, and a failed one is reported as deposit_failed and tried again at the next trigger. Its txid is attached to the first voucher signed after it returns.
With the stock client
- Registration.
new x402Client().is the whole setup. The scheme pays only offers on the network you register.register(network, scheme) - Spend controls.
findDefaultAssetmakes ZEC a default asset with 8 decimals. The stock client's default cap of one dollar per payment is then 100,000,000 zat. The scheme gets that cap asmaxAmountPerPaymentand refuses a window above it. Set a cap in zat withclient..setSpendControls({ allowedAssets: [{ network, asset: "ZEC", maxAmountPerPayment: ". . . " }] }) - One call at a time per provider.
createPaymentPayloadtakes the session's lock, and the outcome hook releases it. Calls to other providers do not wait. If the hook never runs, for example because the request threw, the lock is released aftersafetyReleaseMsand the call counts as ambiguous. - One retry. The stock wrapper retries once when the hook returns
{ recovered: true }. The scheme knows the retry is the final attempt because the wrapper reuses the same offer object. On a final attempt it never waits and never deposits inline, though it may start a background deposit for the next call.
SessionView
session() and sessions() return copies, so changing one changes nothing in the scheme.
interface SessionView {
readonly network: string;
readonly payTo: string;
readonly sessionKey: string;
readonly cumulativeZat: bigint;
readonly snapshot: SessionSnapshot | undefined;
readonly deposits: readonly Readonly<LocalDeposit>[];
readonly nextIndex: number;
readonly depositsMade: number;
readonly halted: { readonly code: string; readonly txid: string | undefined } | undefined;
}| Field | Meaning |
|---|---|
network, payTo | The pair the session belongs to. |
sessionKey | The session's public key, 64 lowercase hex characters. It is in every voucher and in every deposit memo. |
cumulativeZat | The running total, in zat. It resyncs from the provider's snapshots, and it moves up to the attempted value when a call is ambiguous. So it counts a call that failed after its voucher. |
snapshot | The last snapshot of this session that a provider sent, or undefined before the first. |
deposits | Every deposit this session tried, oldest first. |
nextIndex | The index of the next deposit's memo. It starts at 1 and moves for every attempt, failed ones included. |
depositsMade | Deposits that count against maxDeposits. |
halted | Why the session refuses every payment from now on, and undefined while it can still pay. Once it is set it holds the code that stopped the session and the txid of the deposit it concerns. The code is the provider's rejection of a deposit (for example z402_deposit_memo_mismatch), z402_deposit_required_while_presented, or deposit_status_unknown after the wallet throws DepositStatusUnknownError. The txid is the one the deposit was presented under, undefined when the refusal named none, and "(unknown)" for deposit_status_unknown. It is the same pair as in the session halted by <code> on deposit <txid> error that every later payment to this provider throws, where an undefined txid reads (none presented). Other providers' sessions are unaffected. |
const view = scheme.session("uregtest1...", "zcash:regtest");
console.log(view?.cumulativeZat, view?.snapshot?.limitZat, view?.deposits.map((d) => d.state));
if (view?.halted !== undefined) console.warn(`halted by ${view.halted.code} on ${view.halted.txid ?? "no txid"}`);halted says whether a session is halted, and why. The fatal event and the session halted by error from paidFetch report it too.
Deposit states
interface LocalDeposit {
index: number;
memo: string; // "<sessionKey>:<index>"
txid?: string;
state: "sending" | "sent" | "acked" | "dead" | "rejected" | "failed" | "uncertain";
}| State | Meaning | Counts in depositsMade | Sent with the next voucher |
|---|---|---|---|
sending | The wallet was asked to send it. | No | No |
sent | The wallet returned the txid. | Yes | The newest one, until it is acknowledged |
acked | A successful settle acknowledged it, by its transaction or by listing it in the snapshot. | Yes | No |
dead | It expired, was dropped or was orphaned, as a refusal, a snapshot or the scheme's own chain read said. | Yes, if it was ever sent | No |
rejected | The provider cannot use it: wrong address, wrong memo, a transparent part, or another session's. The session halts. | Yes | No |
failed | The wallet threw, and nothing was sent. | No | No |
uncertain | The wallet threw DepositUncertainError, so it has a txid the scheme keeps checking, or DepositStatusUnknownError, which has none and halts the session. | Only when it becomes sent | No |
Only a successful settle acknowledges a deposit. A snapshot that a refusal carries is a projection: the scheme resyncs the running total from it and copies dead statuses, but never acknowledges a deposit from it. An uncertain deposit with a txid is checked at most once every 10 seconds, when the next payload is created. It becomes sent once the chain shows it, and dead after a not-found streak of 120 seconds and 2 blocks, or at its expiry.
ClientEvent
onEvent receives one of these. All amounts are zat strings.
type ClientEvent =
| { type: "session"; network: string; payTo: string; sessionKey: string }
| { type: "deposit_started"; index: number; inline: boolean }
| { type: "deposit_broadcast"; index: number; txid: string; ms: number }
| { type: "deposit_failed"; index: number; error: Error }
| { type: "deposit_acked"; txid: string }
| { type: "deposit_confirmed"; txid: string; index?: number; height?: number; snapshot?: SessionSnapshot }
| { type: "deposit_dead"; txid: string }
| { type: "voucher"; cumulativeZat: string; depositTxid?: string }
| { type: "paid"; cumulativeZat: string; snapshot?: SessionSnapshot }
| { type: "refused"; code: string; snapshot?: SessionSnapshot; recovering: boolean }
| { type: "waiting"; txid: string }
| { type: "ambiguous"; attemptedZat: string }
| { type: "fatal"; code: string; message: string };| Event | Emitted when |
|---|---|
session | The first offer from a network and payTo pair arrives, and the session is created. sessionKey is its public key. |
deposit_started | Before the wallet is asked to send. index is the memo index. inline is true when a call is waiting for this deposit. |
deposit_broadcast | The wallet returned the txid. ms is how long sendDeposit took. With Zallet that is nearly all proving time. |
deposit_failed | The wallet threw. error is the original error object, before the stock wrapper wraps it. |
deposit_acked | A successful settle acknowledged the deposit. |
deposit_confirmed | Once for each deposit. Either a snapshot first listed it as confirmed or final (index and snapshot are set, and height is not), or the scheme's own wait saw it mined (index and height are set). |
deposit_dead | A deposit became dead. |
voucher | A voucher was signed. depositTxid is set when a deposit rides along. |
paid | A call settled. cumulativeZat is the session's total after it. snapshot is set when the receipt carried a valid snapshot of this session. |
refused | The provider refused a voucher. recovering is true when the scheme is going to try to recover: wait, deposit, back off or resync. The attempt can still end without a retry. |
waiting | The scheme is waiting for this deposit to be mined, or to be seen. |
ambiguous | The outcome of a call is unknown. attemptedZat is the total that was signed, and the session's total moved up to it. |
fatal | The scheme will not recover this call. See Fatal codes. |
Fatal codes
The code of a fatal event is a refusal code, or one of four codes of the client's own:
| Code | Meaning |
|---|---|
client_error | An unexpected exception inside the outcome hook. The call still returns the provider's response. |
snapshot_session_mismatch | A receipt carried a snapshot of another session, which the scheme ignored. |
deposit_status_unknown | The wallet cannot say whether a deposit exists. The session halts, because another deposit could pay twice. |
z402_deposit_required_while_presented | The provider asked for a deposit while the one this call presented was pending or not listed. The session halts rather than pay again. |
Order of events
| Call | Events, in order |
|---|---|
| The first call to a provider | session, deposit_started with inline true, deposit_broadcast, voucher, deposit_acked, paid |
| A later call | voucher, paid |
| The first call after a snapshot lists the deposit as confirmed | voucher, deposit_confirmed, paid |
Refusal codes
A provider's refusal reaches the scheme as a code in settleResponse. or in paymentRequired.error, and the code carries the action. A refusal's snapshot is applied first, if it is this session's. recovered means the scheme returns { recovered: true }, and the stock wrapper then retries once with a new voucher. On the final attempt the scheme never waits and never deposits inline.
| Code | The first attempt | The final attempt |
|---|---|---|
z402_awaiting_confirmation | Does not deposit while a deposit is pending. Waits, up to confirmationWaitMs, for the pending deposit to be mined, pauses facilitatorLagMs, and recovers. If the deposit turns out dead, it marks it dead, deposits again when allowed, and recovers. On a timeout, or when it knows of no pending txid, it gives up. | Stops. |
z402_deposit_required | Deposits inline when allowed, and recovers. If a deposit it has not presented yet exists, it recovers without depositing, so the retry presents that one. When the txid this call presented is listed pending, or is not listed and the snapshot shows fewer than 4 final deposits (which includes a refusal with no snapshot), it halts the session with z402_deposit_required_while_presented. | Stops, and starts a background deposit for the next call when one is needed and allowed. The same halt rule applies. |
z402_deposit_dead | Marks the presented deposit dead, deposits again when allowed, and recovers. When it may not deposit, it marks it dead and stops. | Marks it dead, stops, and starts a background deposit when one is needed and allowed. |
z402_cumulative_mismatch | The snapshot has already resynced the running total, so it recovers. Without a snapshot of this session it gives up. | Stops. |
z402_deposit_not_found | Looks for the presented deposit on its own chain reader, up to confirmationWaitMs. When it sees it, it pauses 2 seconds and recovers. When the deposit is dead, it deposits again when allowed. On a timeout it gives up. | Stops. |
z402_chain_unavailable | Waits 5 seconds and recovers. | Stops. |
unexpected_verify_error, unexpected_settle_error | Waits 1 second and recovers. | Stops. |
z402_deposit_not_to_merchant, z402_deposit_memo_mismatch, z402_deposit_transparent, z402_deposit_other_session | Marks the presented txid rejected, halts the session, and emits fatal. Never recovers. | The same. |
Any other code, such as z402_invalid_signature or invalid_payload | Emits fatal. Never recovers. | The same. |
Where a step needs a txid or a snapshot and there is none, the scheme gives up on that call. Errors and reason codes lists the meaning of all 19 codes.
When the response to a paid request carries neither a PAYMENT-RESPONSE nor a PAYMENT-REQUIRED header, the outcome is unknown. The scheme moves the running total up to the attempted value, emits ambiguous, and does not recover. The next voucher then pays for that call too.
ZalletRpcWallet
class ZalletRpcWallet implements ZcashWallet {
constructor(o: ZalletRpcWalletOptions);
sendDeposit(req: DepositRequest): Promise<{ txid: string }>;
findSentByMemo(memo: string): Promise<SentTransaction | undefined>;
spendableBalance(): Promise<bigint>;
}
interface SentTransaction {
txid: string;
minedHeight: number | null; // null while it is in the mempool
expired: boolean; // it expired before it was mined
}It is a regtest wallet for an agent: it talks JSON-RPC 2.0 to Zallet with HTTP Basic credentials. It has no close method.
Wallet options
| Option | Type | Default | What it does |
|---|---|---|---|
url | string | required | Zallet's JSON-RPC endpoint. The regtest default is on port 50232. |
user, password | string | required | The HTTP Basic login. |
account | string | the first account that z_listaccounts returns | The account's uuid. |
fromAddress | string | the account's Orchard-only address at the lowest diversifier index from 1 to 16 that accepts one | The unified address to send from. |
clock | now and sleep of a Clock | systemClock | Tests only. |
opPollMs | number | 250 | The gap between polls of the send operation. |
opTimeoutMs | number | 300_000 | How long to wait for the operation, and for a ledger row with the memo, before it throws DepositStatusUnknownError. |
preBroadcastRetries | number | 3 | Retries after a failure for insufficient funds. |
retryWaitMs | number | 25_000 | The wait before each such retry. |
requestTimeoutMs | number | 30_000 | The network timeout of one request. |
sendDeposit
It sends amountZat to to with memo as a text memo, and resolves with the txid. The request is one z_sendmany from the sending address, with these parameters: the recipient with its amount as a ZEC string and its memo as hex, a minimum of 1 confirmation, the default fee, and the FullPrivacy policy. One send runs at a time, and the lock is held from the request until its operation settles, so deposits to two providers never pick the same notes.
| Outcome | Result |
|---|---|
| The operation succeeds with a txid. | Resolves { txid }. |
| Zallet refuses the request (connection refused, HTTP 401, or a JSON-RPC error), the operation is cancelled, or it fails and no transaction with the memo exists. | Throws ZalletRpcError. Nothing was sent. |
| The funds are not enough, meaning Zallet's message says "insufficient". | Waits retryWaitMs, tries again up to preBroadcastRetries times, then throws InsufficientFundsError. |
| Zallet no longer knows the operation, and no transaction with the memo exists. | Throws DepositStatusUnknownError at once. |
| The outcome is in doubt: a timeout, a reply that cannot be read, or an operation that succeeded with no txid. | Looks the memo up in the wallet's transactions until opTimeoutMs. Resolves with the txid it finds, or throws DepositStatusUnknownError when the time is out. |
The lookup is what stops a window from being paid twice after an error.
Zallet methods it calls
| Method | Used for |
|---|---|
z_listaccounts | The default account. |
z_getaddressforaccount | The Orchard-only sending address, asked for at diversifier indices 1 to 16. Zallet's refusal with code -4, "already generated with different receiver types", moves it to the next index. After 16 it throws and asks you to pass fromAddress. |
z_sendmany | The deposit. |
z_getoperationresult, z_getoperationstatus | The outcome of the send. A result is listed once and then forgotten, so the status call tells a running operation from a lost one. |
z_listtransactions | findSentByMemo. |
z_getbalanceforaccount | spendableBalance. |
findSentByMemo and spendableBalance
findSentByMemo(memo) returns the transaction in the account's list that has an output with exactly this memo, as text or as hex, or undefined. When several match, a pending one ranks above any mined one, a mined one ranks by its height, and an expired one ranks below every live one.
spendableBalance() returns the zat the account can spend: the orchard and ironwood pools with at least 1 confirmation. Transparent funds do not count, which keeps an immature coinbase out.
zatToZecString
function zatToZecString(zat: bigint): string;An exact conversion with no floating point, trimmed of trailing zeros. A negative amount throws a RangeError.
| zat | Result |
|---|---|
500n | "0.000005" |
100_000n | "0.001" |
125_000n | "0.00125" |
100_000_000n | "1" |
LightwalletdTxStatus
class LightwalletdTxStatus implements TxStatusSource {
constructor(lwd: LightwalletdLike);
tip(): Promise<number>;
status(txid: string): Promise<TxStatus>;
}
interface LightwalletdLike {
getTip(): Promise<number>;
getTransaction(txid: string): Promise<RawTx>;
}
type RawTx =
| { found: false }
| { found: true; raw: Uint8Array; location: "mempool" | "mined" | "orphaned"; height: number | null };It adapts a lightwalletd client, for example the one from createLightwalletdClient in @z402-net/lightwalletd, to the TxStatusSource the scheme reads. It is typed against the two methods above, so the package depends on neither @z402-net/lightwalletd nor gRPC.
| Method | Result |
|---|---|
tip() | The chain height, from getTip(). |
status(txid) | { found: false } when the node has no such transaction. Otherwise { found:, where minedHeight is set only when the location is mined, and expiryHeight is read from the raw transaction. |
Every failure becomes a ChainUnavailableError, an outage as opposed to a definitive answer. That includes a raw transaction it cannot read. The scheme treats such an error as no answer: it neither counts nor resets a not-found streak.
Errors
| Class | Extends | Fields | Thrown when |
|---|---|---|---|
ZalletRpcError | Error | code?: number, cause | Zallet refused a request, could not be reached or answered something unreadable. code is the JSON-RPC error code when there is one. |
DepositStatusUnknownError | ZalletRpcError | memo: string | A deposit may exist and no txid is known, so nobody may pay again. The scheme halts the session. Throw it from your own wallet when you cannot tell. |
DepositUncertainError | Error | txid: string | A send failed after the transaction was built, so it may still reach the chain. The scheme keeps checking the txid. ZalletRpcWallet never throws it, and it is for your own wallet. |
InsufficientFundsError | Error | cause | ZalletRpcWallet could not fund the deposit after its retries. |
The constructors are new DepositUncertainError(txid,, new ZalletRpcError(message,, new DepositStatusUnknownError(memo, and new InsufficientFundsError(message,. Each sets name to its class name. When a payment cannot be built, the stock wrapper throws a plain Error whose message starts Failed to create payment payload:, with no cause. On its one recovery retry the wrapper does not wrap, so the original error (an InsufficientFundsError, or session halted by ...) is thrown as it is. Check the prefix and instanceof both. The deposit_failed event always carries the original error.
ZcashWallet and TxStatusSource
These are the two seams the scheme needs. They are defined in @z402-net/protocol, and @z402-net/client re-exports them, with DepositRequest, TxStatus and ChainUnavailableError: the very same objects, not copies. A wallet or a chain source of your own needs no other import:
import { ChainUnavailableError, type DepositRequest, type TxStatus, type TxStatusSource, type ZcashWallet } from "@z402-net/client";interface DepositRequest {
to: string; // the provider's payTo
amountZat: bigint; // exactly one window
memo: string; // "<sessionKey>:<index>", sent as a text memo
}
interface ZcashWallet {
sendDeposit(req: DepositRequest): Promise<{ txid: string }>;
close?(): Promise<void>;
}
type TxStatus =
| { found: false }
| { found: true; location: "mempool" | "mined" | "orphaned"; minedHeight: number | null; expiryHeight: number };
interface TxStatusSource {
tip(): Promise<number>;
status(txid: string): Promise<TxStatus>; // throws ChainUnavailableError on a transport error
}A wallet must send exactly amountZat to to, shielded, with the memo as a text memo, and resolve with the txid in display order. It must not send twice for one memo. A chain source must throw ChainUnavailableError for any answer that is not definitive, and { found: false } only for a definitive not-found. The scheme treats a ChainUnavailableError as no answer: it neither counts nor resets a not-found streak.
Lower-level exports
These are the parts the scheme is built from. They are exported for tests and for people who write a scheme of their own, and an ordinary agent does not need them.
waitForConfirmation
interface WaitOptions {
timeoutMs: number;
pollMs: number; // must be above 0, or it throws a RangeError
clock: Clock;
}
function waitForConfirmation(chain: TxStatusSource, txid: string, o: WaitOptions): Promise<"mined" | "dead" | "timeout">;It polls chain.status(txid) every pollMs until the transaction is mined, dead or the time is up, with the last poll on the deadline. A transaction is dead when it is unmined and the tip has reached its expiry height, or when every definitive not-found answer since the first spans 120 seconds and 2 blocks. Any found answer resets that streak, and a ChainUnavailableError neither counts nor resets it.
Clock and systemClock
interface Clock {
now(): number;
sleep(ms: number): Promise<void>;
setTimer(ms: number, fn: () => void): () => void; // returns the cancel function
}
const systemClock: Clock;systemClock uses Date.now() and setTimeout. The timers from its setTimer are unreferenced, so a lone safety timer never keeps the process alive.
Mutex
class Mutex {
acquire(): Promise<() => void>;
}A first-in, first-out async lock. acquire() resolves with a release function that works only once.
Reading an outcome
type Outcome =
| { kind: "paid"; snapshot?: SessionSnapshot; transaction: string }
| { kind: "refused"; code: string; snapshot?: SessionSnapshot; via: "verify" | "settle" }
| { kind: "ambiguous" };
type Action =
| "wait_confirmation"
| "deposit"
| "resync"
| "redeposit"
| "check_deposit"
| "backoff"
| "reject_txid"
| "fatal"
| "stop";
function classifyOutcome(ctx: PaymentResponseContext): Outcome;
function actionFor(code: string, o: { isFinal: boolean; canDeposit: boolean }): Action;
function snapshotFromExtra(extra: unknown): SessionSnapshot | undefined;classifyOutcome reads what a paid call came to. A settleResponse wins over a paymentRequired. A successful settle is paid. A failed one is refused with its errorReason (or unexpected_settle_error when it has none) and via: "settle". A paymentRequired is refused with its error (or unknown) and via: "verify", and its snapshot is the extra.session of the accepts entry that has the same scheme, network and payTo. With neither, the outcome is ambiguous.
actionFor maps a refusal code to what the scheme does. On a final attempt every action except reject_txid and fatal becomes stop, and canDeposit is false once maxDeposits is reached. The mapping is the table in Refusal codes.
snapshotFromExtra returns the validated extra.session of a verify or settle answer, or undefined.
Session
class Session {
constructor(i: { payTo: string; network: string; seed: Uint8Array });
readonly payTo: string;
readonly network: string;
readonly sessionKey: string;
cumulative: bigint;
snapshot?: SessionSnapshot;
readonly deposits: LocalDeposit[];
nextIndex: number;
depositsMade: number;
// methods below
}Session is pure state: no I/O and no timers. The scheme holds one for each provider.
| Method | What it does |
|---|---|
hasPending(), pendingTxid() | Whether a deposit is pending, and its txid. A deposit is pending when the snapshot lists one as pending that is not dead or rejected here, or when the newest local deposit is sent. |
depositDecision(price, | "inline", "background" or "none", by the table in When the scheme deposits. |
resendTxid() | The newest deposit in the state sent, which rides with the next voucher. |
owns(snapshot) | Whether a snapshot names this session's key. |
applySnapshot(snapshot) | Resyncs the running total from a refusal's snapshot and copies dead statuses. It never acknowledges a deposit. |
applySettle(settle, | On a successful settle, acknowledges the deposit named by transaction, resyncs from the snapshot, or else sets the total to attempted. |
newlyConfirmed(snapshot) | The local deposits a snapshot lists as confirmed or final that were not reported before, and it marks them reported. |
noteConfirmed(txid) | true the first time a txid is reported confirmed. |
indexOf(txid) | The deposit's memo index. |
markDead(txid), markRejected(txid) | Set the state. |
advanceAmbiguous(attempted) | Raises the total to attempted when it is higher. It never lowers it. |
References
packages/client/src/index.ts: the list of exports.packages/client/src/scheme.ts:PrepaidVoucherScheme, its options, the offer checks, the lock and the safety release.packages/client/src/respond.ts: what the scheme does with an outcome and with each refusal code.packages/client/src/outcome.ts:classifyOutcome,actionForandsnapshotFromExtra.packages/client/src/session.ts:Session,LocalDepositand the deposit decision.packages/client/src/events.ts:ClientEvent.packages/client/src/wait.ts:waitForConfirmationand the dead rules.packages/client/src/wallet/zallet-rpc.ts:ZalletRpcWallet, its options, its Zallet calls and its recovery after an error.packages/client/src/chain/lightwalletd.ts:LightwalletdTxStatus.packages/client/src/errors.ts: the error classes.packages/client/src/clock.ts:ClockandsystemClock.packages/client/src/mutex.ts:Mutex.packages/protocol/src/seams.ts:ZcashWallet,DepositRequest,TxStatusandTxStatusSource.packages/protocol/src/snapshot.ts:SessionSnapshotand its parser.packages/protocol/src/reasons.ts: the closed list of reason codes.docs/superpowers/specs/2026-10-27-z402-client-design.md: the client's design, including the outcome and recovery tables.docs/spec/protocol.md: the wire contract. Section 8 lists the codes and the client's actions, and section 9 the client rules.apps/agent/src/zcash.ts: how the demo agent builds the wallet, the chain reader and the scheme.- x402 repository:
x402Client,SchemeNetworkClientand the spend controls in@x402/core2.28.0. - Zallet: the wallet that
ZalletRpcWallettalks to.