@zeroxyz/sdk 0.3.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,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.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,
@@ -3114,5 +3169,5 @@ exports.coerceTempoChainId = coerceTempoChainId;
3114
3169
  exports.createManagedAccount = createManagedAccount;
3115
3170
  exports.paymentHasAnchor = paymentHasAnchor;
3116
3171
  exports.tempoChainLabelFromId = tempoChainLabelFromId;
3117
- //# sourceMappingURL=chunk-LVK77C6A.cjs.map
3118
- //# sourceMappingURL=chunk-LVK77C6A.cjs.map
3172
+ //# sourceMappingURL=chunk-5ZVIL7AZ.cjs.map
3173
+ //# sourceMappingURL=chunk-5ZVIL7AZ.cjs.map