@zeroxyz/sdk 0.3.0 → 0.5.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,17 @@ 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
+
7
18
  ## 0.3.0
8
19
 
9
20
  ### Added
package/README.md CHANGED
@@ -118,9 +118,15 @@ switch (result.outcome) {
118
118
  case "success":
119
119
  // 2xx/3xx — result.body / result.bodyRaw are populated.
120
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;
121
127
  case "payment_failed":
122
- // Couldn't pay: over your maxPay, insufficient balance, or an
123
- // 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.
124
130
  break;
125
131
  case "payment_rejected":
126
132
  // Paid, but the capability still returned 402 (a settlement/facilitator
@@ -277,10 +283,11 @@ The SDK throws typed errors instead of strings. Match them with `instanceof`:
277
283
 
278
284
  ```ts
279
285
  import {
280
- ZeroApiError, // a 4xx/5xx from Zero's own API
281
- ZeroAuthError, // missing auth, failed refresh, expired session
282
- ZeroPaymentError, // payment failed before settlement
283
- 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
284
291
  ZeroTimeoutError, // the configured timeout was exceeded
285
292
  ZeroConfigurationError, // invalid ClientOptions
286
293
  ZeroWalletError, // a wallet read or write failed
@@ -301,7 +308,7 @@ try {
301
308
  }
302
309
  ```
303
310
 
304
- `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.
305
312
 
306
313
  ## Logging
307
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.3.0"};
1149
+ version: "0.5.0"};
1104
1150
 
1105
1151
  // src/version.ts
1106
1152
  var SDK_VERSION = package_default.version;
@@ -1757,6 +1803,11 @@ var capabilityResponseSchema = zod.z.object({
1757
1803
  lastUsedAt: zod.z.string().nullable().optional(),
1758
1804
  lastSuccessfullyRanAt: zod.z.string().nullable().optional()
1759
1805
  });
1806
+ var resolveCapabilityResponseSchema = zod.z.object({
1807
+ capabilityId: zod.z.string(),
1808
+ capabilitySlug: zod.z.string(),
1809
+ matchedVia: zod.z.enum(["exact", "template"])
1810
+ });
1760
1811
 
1761
1812
  // src/namespaces/capabilities.ts
1762
1813
  var Capabilities = class {
@@ -1773,6 +1824,31 @@ var Capabilities = class {
1773
1824
  },
1774
1825
  capabilityResponseSchema
1775
1826
  );
1827
+ // Resolve a raw endpoint URL + method back to the capability that owns it,
1828
+ // for the `zero fetch <url>` case where the client has no local search
1829
+ // context to match against (a memoized/cross-session URL). Returns null on
1830
+ // 404 — i.e. no match or an ambiguous multi-match, both of which the server
1831
+ // reports as 404 (resolveByUrl fails closed rather than mis-attribute).
1832
+ // The URL travels in the POST body so it stays out of access logs and is
1833
+ // never stored; the caller uses the returned capabilityId to record an
1834
+ // attributed run without the URL ever touching the run row (ZERO-335).
1835
+ resolveByUrl = async (url, method, opts = {}) => {
1836
+ try {
1837
+ return await request(
1838
+ this.client,
1839
+ {
1840
+ method: "POST",
1841
+ path: "/v1/capabilities/resolve",
1842
+ body: { url, method },
1843
+ signal: opts.signal
1844
+ },
1845
+ resolveCapabilityResponseSchema
1846
+ );
1847
+ } catch (err) {
1848
+ if (err instanceof ZeroApiError && err.status === 404) return null;
1849
+ throw err;
1850
+ }
1851
+ };
1776
1852
  };
1777
1853
  var createRunResponseSchema = zod.z.object({
1778
1854
  runId: zod.z.string()
@@ -2353,14 +2429,17 @@ var resolveRecordingClient = (client, opts) => {
2353
2429
  const canRecord = opts.account !== void 0 || credentials.kind === "account" || credentials.kind === "session";
2354
2430
  return { recordingClient, canRecord };
2355
2431
  };
2356
- var recordErrorFetchRun = async (client, opts, result) => {
2357
- if (opts.capabilityId === void 0) return;
2432
+ var recordErrorFetchRun = async (client, opts, result, capabilityIdOverride) => {
2433
+ const effectiveCapId = opts.capabilityId;
2434
+ if (effectiveCapId === void 0) return;
2358
2435
  const { recordingClient, canRecord } = resolveRecordingClient(client, opts);
2359
2436
  if (!canRecord) return;
2437
+ const fetchOriginForRecord = opts.capabilityId === void 0 ? "direct_url" : opts.fetchOrigin;
2360
2438
  try {
2361
2439
  const created = await recordingClient.runs.create({
2362
- capabilityId: opts.capabilityId,
2440
+ capabilityId: effectiveCapId,
2363
2441
  ...opts.searchId && { searchId: opts.searchId },
2442
+ ...fetchOriginForRecord && { fetchOrigin: fetchOriginForRecord },
2364
2443
  ...result.status !== null && { status: result.status },
2365
2444
  latencyMs: result.latencyMs,
2366
2445
  ...opts.requestSchema && { requestSchema: opts.requestSchema }
@@ -2370,7 +2449,7 @@ var recordErrorFetchRun = async (client, opts, result) => {
2370
2449
  } catch {
2371
2450
  }
2372
2451
  };
2373
- var recordTimeoutRun = async (client, opts, err, startedAt, capabilityIdRequested) => {
2452
+ var recordTimeoutRun = async (client, opts, err, startedAt, capabilityIdRequested, capabilityIdOverride) => {
2374
2453
  const result = errorFetchResult(
2375
2454
  startedAt,
2376
2455
  err.message,
@@ -2407,6 +2486,11 @@ var clientFetch = async (client, url, opts = {}) => {
2407
2486
  const startedAt = Date.now();
2408
2487
  const capabilityIdRequested = opts.capabilityId !== void 0;
2409
2488
  const method = opts.method ?? (opts.body !== void 0 ? "POST" : "GET");
2489
+ const { canRecord: canRecordForResolve } = resolveRecordingClient(
2490
+ client,
2491
+ opts
2492
+ );
2493
+ const resolvePromise = !capabilityIdRequested && canRecordForResolve ? client.capabilities.resolveByUrl(redactUrl(url), method).catch(() => null) : Promise.resolve(null);
2410
2494
  const probeHeaders = withDefaultContentType(opts.headers, opts.body);
2411
2495
  const requestInit = {
2412
2496
  method,
@@ -2482,6 +2566,15 @@ var clientFetch = async (client, url, opts = {}) => {
2482
2566
  session: err.session
2483
2567
  };
2484
2568
  warnings.push(FETCH_WARNINGS.mppSessionCloseFailed);
2569
+ } else if (err instanceof ZeroInsufficientFundsError) {
2570
+ return errorFetchResult(
2571
+ startedAt,
2572
+ err.message,
2573
+ FETCH_SKIP_REASONS.insufficientFunds,
2574
+ capabilityIdRequested,
2575
+ "insufficient_funds",
2576
+ null
2577
+ );
2485
2578
  } else if (err instanceof ZeroPaymentError || err instanceof ZeroAuthError) {
2486
2579
  const result2 = errorFetchResult(
2487
2580
  startedAt,
@@ -2589,7 +2682,15 @@ var clientFetch = async (client, url, opts = {}) => {
2589
2682
  else if (closeFailed)
2590
2683
  skipReasons.push(FETCH_SKIP_REASONS.mppSessionCloseFailedNotRecorded);
2591
2684
  }
2592
- if (opts.capabilityId !== void 0 && !paidButStill402 && !closeFailed) {
2685
+ const resolvedCap = await resolvePromise;
2686
+ const effectiveCapabilityId = opts.capabilityId ?? resolvedCap?.capabilityId;
2687
+ const capabilityResolution = !capabilityIdRequested && canRecordForResolve ? resolvedCap !== null ? {
2688
+ resolved: true,
2689
+ capabilityId: resolvedCap.capabilityId,
2690
+ capabilitySlug: resolvedCap.capabilitySlug,
2691
+ matchedVia: resolvedCap.matchedVia
2692
+ } : { resolved: false } : void 0;
2693
+ if (effectiveCapabilityId !== void 0 && !paidButStill402 && !closeFailed) {
2593
2694
  const { recordingClient, canRecord } = resolveRecordingClient(client, opts);
2594
2695
  if (!canRecord) {
2595
2696
  skipReasons.push("no_credentials");
@@ -2609,8 +2710,11 @@ var clientFetch = async (client, url, opts = {}) => {
2609
2710
  }
2610
2711
  }
2611
2712
  const result2 = await recordingClient.runs.create({
2612
- capabilityId: opts.capabilityId,
2713
+ capabilityId: effectiveCapabilityId,
2613
2714
  searchId: opts.searchId,
2715
+ // Auto-resolved runs are always direct_url — they only fire when
2716
+ // no capabilityId was supplied (the direct_url path by definition).
2717
+ ...opts.capabilityId === void 0 ? { fetchOrigin: "direct_url" } : opts.fetchOrigin && { fetchOrigin: opts.fetchOrigin },
2614
2718
  status: response.status,
2615
2719
  latencyMs,
2616
2720
  ...opts.requestSchema && { requestSchema: opts.requestSchema },
@@ -2643,7 +2747,7 @@ var clientFetch = async (client, url, opts = {}) => {
2643
2747
  message: "Failed to record run for fetch",
2644
2748
  meta: {
2645
2749
  url: redactUrl(url),
2646
- capabilityId: opts.capabilityId,
2750
+ capabilityId: effectiveCapabilityId,
2647
2751
  error: errorMessage2,
2648
2752
  errorCode: code
2649
2753
  }
@@ -2671,6 +2775,8 @@ var clientFetch = async (client, url, opts = {}) => {
2671
2775
  result.runTrackingSkipped = skipReasons;
2672
2776
  }
2673
2777
  if (warnings.length > 0) result.warnings = warnings;
2778
+ if (capabilityResolution !== void 0)
2779
+ result.capabilityResolution = capabilityResolution;
2674
2780
  return result;
2675
2781
  };
2676
2782
  var searchResultSchema = zod.z.object({
@@ -3114,5 +3220,5 @@ exports.coerceTempoChainId = coerceTempoChainId;
3114
3220
  exports.createManagedAccount = createManagedAccount;
3115
3221
  exports.paymentHasAnchor = paymentHasAnchor;
3116
3222
  exports.tempoChainLabelFromId = tempoChainLabelFromId;
3117
- //# sourceMappingURL=chunk-LVK77C6A.cjs.map
3118
- //# sourceMappingURL=chunk-LVK77C6A.cjs.map
3223
+ //# sourceMappingURL=chunk-ED754B5U.cjs.map
3224
+ //# sourceMappingURL=chunk-ED754B5U.cjs.map