@zeroxyz/sdk 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,27 @@ All notable changes to `@zeroxyz/sdk` will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — with the pre-1.0 caveat that minor versions may include breaking changes.
6
6
 
7
+ ## 0.4.0
8
+
9
+ ### Added
10
+
11
+ - Pre-flight **balance gate** on paid `client.fetch()` / `payments.pay()` / `payments.payMpp()`. Before signing a paid leg the SDK reads the payer's USDC balance (x402 → Base; MPP → Tempo, including the Base→Tempo bridge math) and, if it can't cover the call, aborts with the new `ZeroInsufficientFundsError` instead of signing a doomed transaction. Previously an empty wallet signed anyway, the chain rejected, and the run was recorded at status 402 → the server classified it `payment_gated`, **indistinguishable from a capability that correctly payment-gates** — so unfunded wallets silently dinged cap reliability. (#585)
12
+ - New `FetchOutcome` value **`insufficient_funds`** and new error class **`ZeroInsufficientFundsError`** (`ZeroErrorCode` `insufficient_funds`, carrying `have` / `need`). On this outcome `client.fetch()` records **no run** (`status: null`, `runTrackingSkipped` includes `insufficient_funds_not_recorded`) — a wallet condition is never charged against the capability; the wallet funnel stays observable via the `fetch_executed.outcome` analytics event. (#585)
13
+
14
+ ### Notes
15
+
16
+ - The balance read is **fail-open**: an RPC error proceeds to attempt payment rather than re-introducing a false `payment_gated`. Free capabilities are unaffected — a non-402 response never enters the payment path, and a `0`-amount challenge passes the gate (`balance < 0` is never true). The `maxPay` contract is unchanged. (#585)
17
+
18
+ ## 0.3.0
19
+
20
+ ### Added
21
+
22
+ - `SearchResult` now carries `method` (the capability's HTTP verb) and `brandName`. Both are already returned by the search API but were previously stripped by the SDK's response schema. Surfacing `method` lets a caller tell GET from POST straight from a search result — enough to `fetch()` a no-body GET without a `capabilities.get()` round-trip. Both fields are optional, so a response that omits them still parses. (#593)
23
+
24
+ ### Documentation
25
+
26
+ - Expanded the README with a **Wallet & funding** section (`wallet.balance()` / `wallet.fundingUrl()`), the full `FetchResult` shape, the `runId`-requires-`capabilityId` precondition, and search → get → fetch guidance. (#593)
27
+
7
28
  ## 0.2.0
8
29
 
9
30
  ### Changed
package/README.md CHANGED
@@ -25,7 +25,7 @@ const client = new ZeroClient();
25
25
 
26
26
  const { capabilities } = await client.search("current weather in tokyo");
27
27
  for (const cap of capabilities) {
28
- console.log(cap.id, cap.name, cap.url, cap.availabilityStatus);
28
+ console.log(cap.id, cap.name, cap.method, cap.url, cap.availabilityStatus);
29
29
  }
30
30
  ```
31
31
 
@@ -102,6 +102,8 @@ The SDK refreshes the access token automatically after a `401`, and every refres
102
102
 
103
103
  Endpoints that don't charge pass straight through: a `200` on the first request returns with `payment: null` and `outcome: "success"`. No payment is attempted.
104
104
 
105
+ **Building the request — search → get → fetch.** A `search()` result carries the `url` and `method`, so a no-body `GET` capability can go straight to `fetch()`. For anything that takes a request body, call `capabilities.get()` first: its `bodySchema`, `method`, and `headers` are what tell you how to construct the call. Don't guess the body from the search summary.
106
+
105
107
  ```ts
106
108
  const result = await client.fetch(url, {
107
109
  method: "POST",
@@ -116,9 +118,15 @@ switch (result.outcome) {
116
118
  case "success":
117
119
  // 2xx/3xx — result.body / result.bodyRaw are populated.
118
120
  break;
121
+ case "insufficient_funds":
122
+ // Wallet balance is too low to cover the challenge amount. No payment
123
+ // was attempted. Top off via `zero wallet fund` (CLI) or fund the
124
+ // wallet before retrying. result.error is a ZeroInsufficientFundsError
125
+ // with .have and .need fields.
126
+ break;
119
127
  case "payment_failed":
120
- // Couldn't pay: over your maxPay, insufficient balance, or an
121
- // unrecognized 402 protocol. Nothing settled — generally safe to retry.
128
+ // Couldn't pay: over your maxPay or an unrecognized 402 protocol.
129
+ // Nothing settled — generally safe to retry.
122
130
  break;
123
131
  case "payment_rejected":
124
132
  // Paid, but the capability still returned 402 (a settlement/facilitator
@@ -142,12 +150,65 @@ for (const warning of result.warnings ?? []) {
142
150
  }
143
151
  ```
144
152
 
153
+ ### The `FetchResult`
154
+
155
+ `fetch()` always resolves to one object — it doesn't reject for expected runtime failures (those come back as an `outcome`; see [Errors](#errors)). The full shape:
156
+
157
+ | Field | Type | Notes |
158
+ | --- | --- | --- |
159
+ | `outcome` | `FetchOutcome` | The closed enum above — branch on this first. |
160
+ | `ok` | `boolean` | `true` iff `status` is 2xx. Convenience for the HTTP-success check; `outcome === "success"` is the same signal at the payment-and-HTTP level. |
161
+ | `status` | `number \| null` | Upstream HTTP status; `null` if the request never reached the server. |
162
+ | `body` | `unknown` | Parsed JSON when the response is JSON, otherwise the text (base64 for binary). |
163
+ | `bodyRaw` | `string \| null` | The exact response text, for hashing/forwarding. `bodyEncoding: "base64"` is set when binary. |
164
+ | `payment` | `PaymentResult \| null` | `{ protocol, chain, txHash, amount, asset, … }` when a payment settled; `null` on a free call. |
165
+ | `runId` | `string \| null` | The recorded run, for `runs.review()`. **`null` unless you pass `capabilityId`** (and a wallet is configured). |
166
+ | `latencyMs` | `number` | End-to-end call latency. |
167
+ | `upstreamError` | `UpstreamError?` | Present on `server_error` — the capability's own 4xx/5xx detail. |
168
+ | `warnings` | `string[]?` | Non-fatal notes (truncated body, MPP close failure, …) — keyed by `FETCH_WARNINGS`. |
169
+ | `runTrackingSkipped` | `string[]?` | Why a run wasn't recorded (e.g. no `capabilityId`) — keyed by `FETCH_SKIP_REASONS`. |
170
+
171
+ `capabilityId` accepts the capability's `uid` (`cap_…`) or `slug` — i.e. the `id`/`slug` from a `search()` result or the `uid`/`slug` from `capabilities.get()`.
172
+
145
173
  ### `maxPay`
146
174
 
147
175
  `maxPay` is your hard per-call ceiling, in USDC. The challenge amount is checked against it **before** anything is signed; an over-cap challenge aborts with `outcome: "payment_failed"` and no on-chain spend. Set it on every paid call — it's the guardrail that keeps a mispriced or hostile endpoint from draining the wallet.
148
176
 
149
177
  To put a human in the loop before any spend, read the price up front (`cost.amount` is on every search result and on `capabilities.get()`), show it to your user, and only call `fetch()` with `maxPay` once they approve.
150
178
 
179
+ ## Wallet & funding
180
+
181
+ The wallet you construct the client with **is** the agent's identity — paid calls draw USDC from it, and there are no per-service API keys to provision. Keeping it funded is the only operational task. Two funding models, both first-class:
182
+
183
+ - **Partner-funded shared wallet** *(the common default).* You hold one wallet on behalf of all your users, keep it topped up as a backend cost, and recoup it through your own billing. Per-user spend tracking and caps live in your app — `maxPay` is the per-call guard.
184
+ - **End-user top-up.** Each user funds the wallet directly; you surface a funding link when their balance runs low. Pairs with a per-user wallet.
185
+
186
+ The same two primitives serve both:
187
+
188
+ ```ts
189
+ // The configured address (null in session mode until the first paid call resolves it).
190
+ client.wallet.address;
191
+
192
+ // Balance in USDC. Sums Base + Tempo by default; pass a chain for just one.
193
+ const { amount } = await client.wallet.balance(); // e.g. "12.50"
194
+ const onBase = await client.wallet.balance({ chain: "base" });
195
+
196
+ // A one-time hosted top-up link — Coinbase by default, or provider: "stripe".
197
+ // USDC settles on Base.
198
+ const url = await client.wallet.fundingUrl({ amount: "10" });
199
+ ```
200
+
201
+ Poll `balance()` to decide when to top up. Then either open `fundingUrl()` yourself to refill the shared wallet — from an ops dashboard, a low-balance alert, or alongside a direct USDC transfer to `wallet.address` — or hand the link to the end user. Either way the link is **single-use** and burns when opened, so generate it at the moment of funding and don't pre-open one you intend to give to someone else.
202
+
203
+ In **session mode** (acting for a Zero user) the wallet is Zero-managed, and a couple more methods apply:
204
+
205
+ ```ts
206
+ await client.wallet.list(); // every wallet linked to the session user
207
+ await client.wallet.provision(); // create the user's managed wallet (idempotent)
208
+ ```
209
+
210
+ To move funds from a user's own (BYO) wallet into their Zero-managed wallet, sign an EIP-3009 `ReceiveWithAuthorization` and relay it with `client.wallet.migrateAuthorization(authorization)` — Zero pays the gas and sweeps the USDC to Base.
211
+
151
212
  ## Namespaces
152
213
 
153
214
  Beyond `search()` and `fetch()`, the client groups the rest of the API into resource namespaces:
@@ -162,10 +223,9 @@ const { capabilities } = await client.search("translate text to french", {
162
223
  // Capabilities — full detail for one capability by id
163
224
  const cap = await client.capabilities.get("cap_abc");
164
225
 
165
- // Wallet (account or session mode)
166
- const balance = await client.wallet.balance(); // Base + Tempo
167
- const onBase = await client.wallet.balance({ chain: "base" }); // a single chain
168
- const fundUrl = await client.wallet.fundingUrl({ amount: "10" }); // onramp link
226
+ // Wallet balance + one-time funding URL. See "Wallet & funding" above.
227
+ const { amount } = await client.wallet.balance();
228
+ const fundUrl = await client.wallet.fundingUrl({ amount: "10" });
169
229
 
170
230
  // Runs — fetch() records these for you on paid calls; reach for them
171
231
  // directly when you want to log or review a call yourself
@@ -223,10 +283,11 @@ The SDK throws typed errors instead of strings. Match them with `instanceof`:
223
283
 
224
284
  ```ts
225
285
  import {
226
- ZeroApiError, // a 4xx/5xx from Zero's own API
227
- ZeroAuthError, // missing auth, failed refresh, expired session
228
- ZeroPaymentError, // payment failed before settlement
229
- ZeroSessionCloseFailedError, // MPP session-close voucher rejected
286
+ ZeroApiError, // a 4xx/5xx from Zero's own API
287
+ ZeroAuthError, // missing auth, failed refresh, expired session
288
+ ZeroInsufficientFundsError, // wallet balance too low; .have and .need in USDC
289
+ ZeroPaymentError, // payment failed before settlement
290
+ ZeroSessionCloseFailedError, // MPP session-close voucher rejected
230
291
  ZeroTimeoutError, // the configured timeout was exceeded
231
292
  ZeroConfigurationError, // invalid ClientOptions
232
293
  ZeroWalletError, // a wallet read or write failed
@@ -247,7 +308,7 @@ try {
247
308
  }
248
309
  ```
249
310
 
250
- `client.fetch()` reserves throwing for programmer errors — bad config, missing credentials, and the like. Expected runtime failures (`network_error`, `payment_failed`, an upstream `5xx`) come back as a `FetchResult` with the matching `outcome`, so you handle them by branching on the result rather than by catching.
311
+ `client.fetch()` reserves throwing for programmer errors — bad config, missing credentials, and the like. Expected runtime failures (`insufficient_funds`, `network_error`, `payment_failed`, an upstream `5xx`) come back as a `FetchResult` with the matching `outcome`, so you handle them by branching on the result rather than by catching.
251
312
 
252
313
  ## Logging
253
314
 
@@ -79,6 +79,16 @@ var ZeroPaymentError = class extends ZeroError {
79
79
  this.name = "ZeroPaymentError";
80
80
  }
81
81
  };
82
+ var ZeroInsufficientFundsError = class extends ZeroError {
83
+ have;
84
+ need;
85
+ constructor(message, init) {
86
+ super("insufficient_funds", message, { cause: init.cause });
87
+ this.name = "ZeroInsufficientFundsError";
88
+ this.have = init.have;
89
+ this.need = init.need;
90
+ }
91
+ };
82
92
  var ZeroSessionCloseFailedError = class extends ZeroError {
83
93
  session;
84
94
  response;
@@ -425,7 +435,12 @@ var FETCH_SKIP_REASONS = Object.freeze({
425
435
  bodyReadFailed: "body_read_failed",
426
436
  mppSessionCloseFailedNotRecorded: "mpp_session_close_failed_not_recorded",
427
437
  paidButStill402NotRecorded: FETCH_WARNINGS.paidButStill402NotRecorded,
428
- paymentSettlementUnconfirmed: FETCH_WARNINGS.paymentSettlementUnconfirmed
438
+ paymentSettlementUnconfirmed: FETCH_WARNINGS.paymentSettlementUnconfirmed,
439
+ // Pre-sign abort: deliberately NOT recorded as a run — the wallet was
440
+ // empty before we reached the capability, so charging it as a 402 would
441
+ // corrupt cap reliability. The wallet funnel is tracked via the
442
+ // fetch_executed.outcome analytics event instead.
443
+ insufficientFunds: "insufficient_funds_not_recorded"
429
444
  });
430
445
  var MAX_RECEIPT_HEADER_BYTES = 8 * 1024;
431
446
  var decodeSessionReceiptHeader = (header) => {
@@ -567,6 +582,7 @@ var Payments = class {
567
582
  );
568
583
  let capturedAmount = null;
569
584
  let capturedNetwork;
585
+ let preflightError = null;
570
586
  const x402 = client.registerExactEvmScheme(new client$1.x402Client(), { signer: account });
571
587
  x402.onBeforePaymentCreation(async (context) => {
572
588
  const selected = context.selectedRequirements;
@@ -600,6 +616,29 @@ var Payments = class {
600
616
  reason: `Payment of ${capturedAmount} USDC exceeds maxPay ${input.maxPay}`
601
617
  };
602
618
  }
619
+ const detected = networkToChain(capturedNetwork);
620
+ const balanceChain = detected === "base-sepolia" ? "base-sepolia" : "base";
621
+ let balance = null;
622
+ try {
623
+ balance = await this.readUsdcBalance(account.address, balanceChain);
624
+ } catch (readErr) {
625
+ this.client.logger?.({
626
+ level: "warn",
627
+ message: "Pre-flight balance read failed; proceeding to attempt payment",
628
+ meta: {
629
+ chain: balanceChain,
630
+ error: readErr instanceof Error ? readErr.message : String(readErr)
631
+ }
632
+ });
633
+ }
634
+ if (balance !== null && balance < rawAmountUnits) {
635
+ const have = viem.formatUnits(balance, 6);
636
+ preflightError = new ZeroInsufficientFundsError(
637
+ `wallet has ${have} USDC on Base, needs ${capturedAmount}.`,
638
+ { have, need: capturedAmount }
639
+ );
640
+ return { abort: true, reason: preflightError.message };
641
+ }
603
642
  });
604
643
  const configuredFetch = buildConfiguredFetch(this.client, {
605
644
  timeoutMs: input.timeoutMs
@@ -620,6 +659,7 @@ var Payments = class {
620
659
  signal: input.signal
621
660
  });
622
661
  } catch (err) {
662
+ if (preflightError) throw preflightError;
623
663
  if (err instanceof ZeroError) throw err;
624
664
  throw new ZeroPaymentError(
625
665
  `x402 payment failed: ${err instanceof Error ? err.message : String(err)}`,
@@ -864,23 +904,29 @@ var Payments = class {
864
904
  );
865
905
  if (tempoBalance < requiredRaw) {
866
906
  if (coerced.chain === "tempo-testnet") {
867
- throw new ZeroPaymentError(
868
- `Insufficient pathUSD on Tempo testnet: have ${viem.formatUnits(tempoBalance, 6)}, need ${capturedAmount}. Fund via the Tempo testnet faucet: https://docs.tempo.xyz/quickstart/faucet`
907
+ const have = viem.formatUnits(tempoBalance, 6);
908
+ throw new ZeroInsufficientFundsError(
909
+ `Insufficient pathUSD on Tempo testnet: have ${have}, need ${capturedAmount}. Fund via the Tempo testnet faucet: https://docs.tempo.xyz/quickstart/faucet`,
910
+ { have, need: capturedAmount }
869
911
  );
870
912
  }
871
- await this.bridgeBaseToTempo(account, requiredRaw - tempoBalance);
913
+ await this.bridgeBaseToTempo(account, requiredRaw - tempoBalance, {
914
+ isSessionDeposit: challenge.intent === "session",
915
+ depositTotal: capturedAmount
916
+ });
872
917
  }
873
918
  return capturedAmount;
874
919
  };
875
- bridgeBaseToTempo = async (account, requiredAmount) => {
920
+ bridgeBaseToTempo = async (account, requiredAmount, depositContext) => {
876
921
  this.ensureRelayClient();
877
922
  const baseBalance = await this.readUsdcBalance(account.address, "base");
878
923
  const buffer = calculateBuffer(baseBalance);
879
924
  const bridgeAmount = requiredAmount + buffer;
880
925
  if (baseBalance < bridgeAmount) {
881
- throw new ZeroPaymentError(
882
- `Insufficient Base USDC to bridge: have ${viem.formatUnits(baseBalance, 6)}, need ${viem.formatUnits(bridgeAmount, 6)} (${viem.formatUnits(requiredAmount, 6)} + ${viem.formatUnits(buffer, 6)} buffer). This capability uses an MPP payment channel, which is pre-funded up front. Either fund your wallet on Base, or pass \`maxPay\` to size a smaller channel (e.g. \`maxPay: '0.05'\` for a ~5-cent channel sized for a single cheap call).`
883
- );
926
+ const have = viem.formatUnits(baseBalance, 6);
927
+ const need = viem.formatUnits(bridgeAmount, 6);
928
+ const message = depositContext?.isSessionDeposit ? `wallet has ${have} USDC on Base, but this capability pre-funds a ${depositContext.depositTotal} USDC MPP payment channel up front (the channel is deposited before the call, so it asks for more than a single charge). Needs ${need} on Base to open it \u2014 fund the wallet, or pass a smaller \`maxPay\` (e.g. '0.05') to size the channel down to one cheap call.` : `wallet has ${have} USDC on Base, needs ${need} to fund this capability's MPP payment channel (${viem.formatUnits(requiredAmount, 6)} + ${viem.formatUnits(buffer, 6)} buffer). Tip: pass a smaller \`maxPay\` (e.g. '0.05') to size a cheaper channel.`;
929
+ throw new ZeroInsufficientFundsError(message, { have, need });
884
930
  }
885
931
  this.client.logger?.({
886
932
  level: "info",
@@ -1100,7 +1146,7 @@ var Payments = class {
1100
1146
 
1101
1147
  // package.json
1102
1148
  var package_default = {
1103
- version: "0.2.0"};
1149
+ version: "0.4.0"};
1104
1150
 
1105
1151
  // src/version.ts
1106
1152
  var SDK_VERSION = package_default.version;
@@ -2482,6 +2528,15 @@ var clientFetch = async (client, url, opts = {}) => {
2482
2528
  session: err.session
2483
2529
  };
2484
2530
  warnings.push(FETCH_WARNINGS.mppSessionCloseFailed);
2531
+ } else if (err instanceof ZeroInsufficientFundsError) {
2532
+ return errorFetchResult(
2533
+ startedAt,
2534
+ err.message,
2535
+ FETCH_SKIP_REASONS.insufficientFunds,
2536
+ capabilityIdRequested,
2537
+ "insufficient_funds",
2538
+ null
2539
+ );
2485
2540
  } else if (err instanceof ZeroPaymentError || err instanceof ZeroAuthError) {
2486
2541
  const result2 = errorFetchResult(
2487
2542
  startedAt,
@@ -2679,8 +2734,15 @@ var searchResultSchema = zod.z.object({
2679
2734
  slug: zod.z.string(),
2680
2735
  name: zod.z.string(),
2681
2736
  canonicalName: zod.z.string().nullable().optional(),
2737
+ brandName: zod.z.string().nullable().optional(),
2682
2738
  description: zod.z.string(),
2683
2739
  whatItDoes: zod.z.string().nullable().optional(),
2740
+ // HTTP verb the capability expects. Surfaced on the search row so a caller
2741
+ // can tell GET from POST without a `capabilities.get()` round-trip — enough
2742
+ // to `fetch()` a no-body GET directly. POST bodies still need `get()` for
2743
+ // the `bodySchema`. Optional (not required) so a response that omits it —
2744
+ // an older API deploy — still parses rather than failing the whole search.
2745
+ method: zod.z.string().optional(),
2684
2746
  url: zod.z.string(),
2685
2747
  urlTemplate: zod.z.string().nullable().optional(),
2686
2748
  cost: zod.z.object({ amount: zod.z.string(), asset: zod.z.string() }),
@@ -3107,5 +3169,5 @@ exports.coerceTempoChainId = coerceTempoChainId;
3107
3169
  exports.createManagedAccount = createManagedAccount;
3108
3170
  exports.paymentHasAnchor = paymentHasAnchor;
3109
3171
  exports.tempoChainLabelFromId = tempoChainLabelFromId;
3110
- //# sourceMappingURL=chunk-LHS7KBN7.cjs.map
3111
- //# sourceMappingURL=chunk-LHS7KBN7.cjs.map
3172
+ //# sourceMappingURL=chunk-5ZVIL7AZ.cjs.map
3173
+ //# sourceMappingURL=chunk-5ZVIL7AZ.cjs.map