@oneshot-agent/sdk 0.26.0 → 0.28.1

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/dist/index.js CHANGED
@@ -49,13 +49,22 @@ Object.defineProperty(exports, "getSwapQuote", { enumerable: true, get: function
49
49
  Object.defineProperty(exports, "executeSwap", { enumerable: true, get: function () { return swap_1.executeSwap; } });
50
50
  __exportStar(require("./errors"), exports);
51
51
  // Keep in sync with package.json `version`. Guarded by version.test.ts.
52
- const SDK_VERSION = '0.26.0';
52
+ const SDK_VERSION = '0.28.1';
53
+ /** HTTP poll cadence while push is unconfirmed: fast first checks, settling at 2s. */
54
+ const HTTP_POLL_BACKOFF_MS = [300, 600, 1000, 2000];
55
+ /** HTTP poll cadence once the WebSocket has delivered for this request. */
56
+ const HTTP_POLL_RELAXED_MS = 5000;
53
57
  // ============================================================================
54
58
  // Environment Configuration
55
59
  // ============================================================================
56
60
  const BASE_URL = 'https://win.oneshotagent.com';
57
61
  const RPC_URL = 'https://mainnet.base.org';
58
62
  const CHAIN_ID = 8453;
63
+ /** Chain id from an x402 network id such as `eip155:84532`; undefined when absent or malformed. */
64
+ function chainIdFromNetwork(network) {
65
+ const m = /^eip155:(\d+)$/.exec(network ?? '');
66
+ return m ? Number(m[1]) : undefined;
67
+ }
59
68
  const USDC_ADDRESS = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
60
69
  // ============================================================================
61
70
  // Public types — defined in ./types.ts. Re-exported below so existing
@@ -76,6 +85,39 @@ __exportStar(require("./types"), exports);
76
85
  * await agent.email({ to: 'user@example.com', subject: 'Hi', body: 'Hello' });
77
86
  * ```
78
87
  */
88
+ /**
89
+ * Reject a budget config the server would reject, at construction rather than
90
+ * on the first paid call — a typo'd cap must not become "no cap".
91
+ */
92
+ function validateBudgetConfig(budgets) {
93
+ if (!budgets)
94
+ return undefined;
95
+ // A typo'd key (`daliy`) from an untyped caller must not silently mean "no cap".
96
+ for (const key of Object.keys(budgets)) {
97
+ if (!['daily', 'perTransaction', 'alertAt', 'pauseAt'].includes(key)) {
98
+ throw new errors_1.ValidationError(`budgets.${key} is not a recognized field`, `budgets.${key}`);
99
+ }
100
+ }
101
+ const positive = (v, field) => {
102
+ if (v === undefined)
103
+ return;
104
+ if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) {
105
+ throw new errors_1.ValidationError(`budgets.${field} must be a positive number`, `budgets.${field}`);
106
+ }
107
+ };
108
+ const fraction = (v, field) => {
109
+ if (v === undefined)
110
+ return;
111
+ if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0 || v > 1) {
112
+ throw new errors_1.ValidationError(`budgets.${field} must be a fraction in (0, 1]`, `budgets.${field}`);
113
+ }
114
+ };
115
+ positive(budgets.daily, 'daily');
116
+ positive(budgets.perTransaction, 'perTransaction');
117
+ fraction(budgets.alertAt, 'alertAt');
118
+ fraction(budgets.pauseAt, 'pauseAt');
119
+ return budgets;
120
+ }
79
121
  class OneShot {
80
122
  /**
81
123
  * Async factory — required for CDP wallets (account creation is async).
@@ -117,6 +159,8 @@ class OneShot {
117
159
  this.logger = config.logger ?? console.log;
118
160
  this._currency = config.currency ?? 'USDC';
119
161
  this._slippage = config.slippage ?? 0.01;
162
+ this._budgets = validateBudgetConfig(config.budgets);
163
+ this._alertEmail = config.alerts?.email;
120
164
  this.rpcProvider = new ethers_1.ethers.JsonRpcProvider(config.rpcUrl ?? RPC_URL);
121
165
  if (walletProvider) {
122
166
  this.provider = walletProvider;
@@ -127,7 +171,6 @@ class OneShot {
127
171
  else {
128
172
  throw new errors_1.ValidationError('Provide privateKey or use OneShot.create() for CDP/custom wallets', 'config');
129
173
  }
130
- // Validate ETH mode requirements
131
174
  if (this._currency === 'ETH' && !this.provider.sendTransaction) {
132
175
  throw new errors_1.ValidationError('ETH currency mode requires a wallet provider that supports sendTransaction', 'currency');
133
176
  }
@@ -153,6 +196,10 @@ class OneShot {
153
196
  get slippage() {
154
197
  return this._slippage;
155
198
  }
199
+ /** The budget config this instance was constructed with, if any. */
200
+ get budgetConfig() {
201
+ return this._budgets;
202
+ }
156
203
  // ---------------------------------------------------------------------------
157
204
  // Public methods
158
205
  // ---------------------------------------------------------------------------
@@ -412,7 +459,6 @@ class OneShot {
412
459
  async voice(options) {
413
460
  this.validate(options.objective, 'objective');
414
461
  this.validate(options.target_number, 'target_number');
415
- // Check for empty arrays
416
462
  if (Array.isArray(options.target_number) && options.target_number.length === 0) {
417
463
  throw new errors_1.ValidationError('target_number array cannot be empty', 'target_number');
418
464
  }
@@ -476,7 +522,6 @@ class OneShot {
476
522
  async sms(options) {
477
523
  this.validate(options.message, 'message');
478
524
  this.validate(options.to_number, 'to_number');
479
- // Check for empty arrays
480
525
  if (Array.isArray(options.to_number) && options.to_number.length === 0) {
481
526
  throw new errors_1.ValidationError('to_number array cannot be empty', 'to_number');
482
527
  }
@@ -1099,13 +1144,15 @@ class OneShot {
1099
1144
  }
1100
1145
  const path = `/v1/compute/${goalId}/fund`;
1101
1146
  const payload = { amount };
1102
- // First call: get quote (402)
1147
+ await this.ensureBudgetsSynced();
1103
1148
  const quoteResp = await this.makeRequest(path, payload);
1104
1149
  if (quoteResp.status !== 402) {
1105
- throw new errors_1.ToolError('Expected 402 for compute fund quote', quoteResp.status, await quoteResp.text());
1150
+ // A 403 here is the budget gate on the quote leg BudgetExceededError.
1151
+ await this.failFromResponse('Expected 402 for compute fund quote', quoteResp);
1106
1152
  }
1107
1153
  const quoteData = await quoteResp.json();
1108
1154
  this.log(`Compute fund quote: $${quoteData.payment_request.amount} to top up ${goalId}`);
1155
+ this.assertWithinBudget(quoteData.payment_request.amount);
1109
1156
  const paymentInfo = {
1110
1157
  protocol: 'x402',
1111
1158
  network: `eip155:${quoteData.payment_request.chain_id}`,
@@ -1348,6 +1395,100 @@ class OneShot {
1348
1395
  }
1349
1396
  return qs.toString();
1350
1397
  }
1398
+ /**
1399
+ * Push `config.budgets` to the server once, before the first paid call.
1400
+ *
1401
+ * Lazy rather than in the constructor because the PUT is signed (async) and
1402
+ * the constructor is sync. Idempotent per instance via the cached promise,
1403
+ * which is only kept once the sync SUCCEEDS.
1404
+ *
1405
+ * FAILS CLOSED. If the server can't confirm the budget — network error,
1406
+ * 5xx, 429, or a rejected config — this throws BudgetSyncError and the paid
1407
+ * call is not made. Proceeding would silently drop the guardrail the
1408
+ * developer configured, which is exactly the "empty wallet at 3am" the
1409
+ * budget exists to prevent. The marker is cleared so the next paid call
1410
+ * retries.
1411
+ */
1412
+ async ensureBudgetsSynced() {
1413
+ if (!this._budgets && !this._alertEmail)
1414
+ return;
1415
+ if (this._budgetSync)
1416
+ return this._budgetSync;
1417
+ const attempt = (async () => {
1418
+ const body = {};
1419
+ if (this._budgets?.daily !== undefined)
1420
+ body.daily = this._budgets.daily;
1421
+ if (this._budgets?.perTransaction !== undefined)
1422
+ body.per_transaction = this._budgets.perTransaction;
1423
+ if (this._budgets?.alertAt !== undefined)
1424
+ body.alert_at = this._budgets.alertAt;
1425
+ if (this._budgets?.pauseAt !== undefined)
1426
+ body.pause_at = this._budgets.pauseAt;
1427
+ if (this._alertEmail !== undefined)
1428
+ body.alert_email = this._alertEmail;
1429
+ let response;
1430
+ try {
1431
+ response = await fetch(`${this.baseUrl}/v1/agents/me/budgets`, {
1432
+ method: 'PUT',
1433
+ headers: { 'Content-Type': 'application/json', ...(await this.signedReadHeaders('write')) },
1434
+ body: JSON.stringify(body),
1435
+ });
1436
+ }
1437
+ catch (err) {
1438
+ throw new errors_1.BudgetSyncError(`Could not sync spend budget (network): ${err}`);
1439
+ }
1440
+ if (!response.ok) {
1441
+ const text = await response.text();
1442
+ throw new errors_1.BudgetSyncError(`Could not sync spend budget (${response.status}): ${text}`, response.status, text);
1443
+ }
1444
+ this.log('Budget synced');
1445
+ })();
1446
+ this._budgetSync = attempt;
1447
+ try {
1448
+ await attempt;
1449
+ }
1450
+ catch (err) {
1451
+ // Not synced: clear the marker so the next paid call retries, then
1452
+ // refuse this one rather than run it unguarded.
1453
+ this._budgetSync = undefined;
1454
+ this.log(`${err.message} — paid call refused until the budget is confirmed`);
1455
+ throw err;
1456
+ }
1457
+ }
1458
+ /**
1459
+ * Local fast-fail on the per-transaction cap, so an oversized call fails
1460
+ * without a network round-trip. The server enforces the same cap (and the
1461
+ * daily one, which needs the ledger) regardless of which client calls —
1462
+ * this is the same SDK-checks/server-enforces split as maxCost.
1463
+ */
1464
+ assertWithinBudget(total) {
1465
+ const cap = this._budgets?.perTransaction;
1466
+ if (!cap || cap <= 0)
1467
+ return;
1468
+ const amount = parseFloat(total);
1469
+ if (Number.isFinite(amount) && amount > cap) {
1470
+ throw new errors_1.BudgetExceededError(`Quote $${total} exceeds this agent's per-transaction budget of $${cap}`, 'per_transaction', cap, undefined, amount);
1471
+ }
1472
+ }
1473
+ /**
1474
+ * Current spend budget and today's utilization.
1475
+ *
1476
+ * @example
1477
+ * ```typescript
1478
+ * const b = await agent.budgets();
1479
+ * console.log(`${b.spent_today_usdc} of ${b.daily_usdc} spent, resets ${b.resets_at}`);
1480
+ * ```
1481
+ */
1482
+ async budgets() {
1483
+ const response = await fetch(`${this.baseUrl}/v1/agents/me/budgets`, {
1484
+ headers: await this.signedReadHeaders(),
1485
+ });
1486
+ if (!response.ok) {
1487
+ throw new errors_1.ToolError('Failed to fetch budgets', response.status, await response.text());
1488
+ }
1489
+ const body = await response.json();
1490
+ return (body.data ?? body);
1491
+ }
1351
1492
  /** Local fast-fail guard: throw when a quote total exceeds the caller's cap. */
1352
1493
  assertWithinMaxCost(total, maxCost) {
1353
1494
  if (maxCost && parseFloat(total) > maxCost) {
@@ -1394,8 +1535,28 @@ class OneShot {
1394
1535
  const rejection = response.status === 402 ? this.parsePaymentRejection(text) : undefined;
1395
1536
  if (rejection)
1396
1537
  throw rejection;
1538
+ const budget = response.status === 403 ? this.parseBudgetRejection(text) : undefined;
1539
+ if (budget)
1540
+ throw budget;
1397
1541
  throw new errors_1.ToolError(message, response.status, text);
1398
1542
  }
1543
+ /**
1544
+ * Map a 403 `budget_exceeded` body onto a typed error, so callers can catch
1545
+ * "my own budget stopped this" separately from an auth failure or a payment
1546
+ * rejection. Any other 403 falls through to ToolError.
1547
+ */
1548
+ parseBudgetRejection(text) {
1549
+ try {
1550
+ const body = JSON.parse(text);
1551
+ if (body.error !== 'budget_exceeded')
1552
+ return undefined;
1553
+ const b = body.budget ?? {};
1554
+ return new errors_1.BudgetExceededError(body.message ?? 'Agent spend budget exceeded', b.reason === 'per_transaction' ? 'per_transaction' : 'daily', b.cap !== undefined ? parseFloat(b.cap) : undefined, b.spent !== undefined ? parseFloat(b.spent) : undefined, b.charge !== undefined ? parseFloat(b.charge) : undefined, b.resets_at);
1555
+ }
1556
+ catch {
1557
+ return undefined;
1558
+ }
1559
+ }
1399
1560
  parsePaymentRejection(text) {
1400
1561
  let data;
1401
1562
  try {
@@ -1455,7 +1616,6 @@ class OneShot {
1455
1616
  ...this.maxCostHeader(maxCost),
1456
1617
  ...this.idempotencyHeader(idempotencyKey),
1457
1618
  };
1458
- // Validate memo
1459
1619
  if (payload.memo !== undefined) {
1460
1620
  if (typeof payload.memo !== 'string' || payload.memo.trim().length === 0) {
1461
1621
  delete payload.memo; // Drop invalid memo silently
@@ -1468,7 +1628,6 @@ class OneShot {
1468
1628
  else if (!endpoint.includes('/inbox') && !endpoint.includes('/notifications') && !endpoint.includes('/balance')) {
1469
1629
  this.log('No memo provided — consider adding a reason for audit trail');
1470
1630
  }
1471
- // Validate decisionContext
1472
1631
  if (payload.decisionContext !== undefined) {
1473
1632
  if (typeof payload.decisionContext !== 'object' || payload.decisionContext === null) {
1474
1633
  delete payload.decisionContext;
@@ -1483,6 +1642,9 @@ class OneShot {
1483
1642
  if (signal?.aborted) {
1484
1643
  throw new errors_1.OneShotError('Operation cancelled');
1485
1644
  }
1645
+ // One-time push of config.budgets before the first paid call, so the
1646
+ // server-side gate knows about them on this very request.
1647
+ await this.ensureBudgetsSynced();
1486
1648
  let response = await this.makeRequest(endpoint, payload, undefined, quoteId, signal, undefined, extraHeaders);
1487
1649
  // Handle 402 Payment Required
1488
1650
  if (response.status === 402) {
@@ -1501,6 +1663,7 @@ class OneShot {
1501
1663
  token: { address: accepted.asset, symbol: 'USDC', decimals: 6 }
1502
1664
  };
1503
1665
  this.log(`Payment required: ${paymentInfo.amount} USDC`);
1666
+ this.assertWithinBudget(paymentInfo.amount);
1504
1667
  this.checkAbortBeforePayment(signal);
1505
1668
  const auth = await this.signPaymentAuthorization(paymentInfo, accepted, resource, extensions);
1506
1669
  response = await this.makeRequest(endpoint, payload, auth, quoteId, signal, undefined, extraHeaders);
@@ -1527,16 +1690,20 @@ class OneShot {
1527
1690
  * body directly), since that differs per tool.
1528
1691
  */
1529
1692
  async runQuoteToPay(cfg) {
1693
+ await this.ensureBudgetsSynced();
1530
1694
  const quoteResp = await this.makeRequest(cfg.endpoint, cfg.payload, undefined, undefined, cfg.signal, cfg.quoteTimeoutMs, this.maxCostHeader(cfg.maxCost));
1531
1695
  if (quoteResp.status === 400 && cfg.on400) {
1532
1696
  await cfg.on400(quoteResp);
1533
1697
  }
1534
1698
  if (quoteResp.status !== 402) {
1535
- throw new errors_1.ToolError(cfg.expectMsg, quoteResp.status, await quoteResp.text());
1699
+ // Routes the budget gate on the quote leg (fixed-price ones) surface a
1700
+ // 403 here; failFromResponse maps it to BudgetExceededError.
1701
+ await this.failFromResponse(cfg.expectMsg, quoteResp);
1536
1702
  }
1537
1703
  const quoteData = await quoteResp.json();
1538
1704
  cfg.onQuote(quoteData.context);
1539
1705
  this.assertWithinMaxCost(cfg.totalOf(quoteData.context), cfg.maxCost);
1706
+ this.assertWithinBudget(cfg.totalOf(quoteData.context));
1540
1707
  const paymentInfo = {
1541
1708
  protocol: 'x402',
1542
1709
  network: `eip155:${quoteData.payment_request.chain_id}`,
@@ -1553,23 +1720,62 @@ class OneShot {
1553
1720
  const execResp = await this.makeRequest(cfg.endpoint, cfg.payload, auth, quoteData.context.quote_id, cfg.signal, cfg.execTimeoutMs);
1554
1721
  return { context: quoteData.context, execResp };
1555
1722
  }
1723
+ /**
1724
+ * Wait for a previously dispatched job and return its result.
1725
+ *
1726
+ * Use this to resolve the `request_id` returned by a call made with
1727
+ * `wait: false`, or to resume waiting after a client restart. Delivery is the
1728
+ * same as for blocking calls: WebSocket push when the server offers it, HTTP
1729
+ * polling of `GET /v1/requests/:id` as the source of truth.
1730
+ */
1731
+ async waitForResult(requestId, options = {}) {
1732
+ this.validate(requestId, 'requestId');
1733
+ return this.pollJob(requestId, options.timeout, options.signal, options.onStatusUpdate, options.waitForPhones ? { waitForPhones: true, phoneTimeoutSec: options.phoneTimeoutSec } : undefined);
1734
+ }
1556
1735
  async pollJob(requestId, timeoutSec, signal, onStatusUpdate, phoneOpts) {
1557
- // Try WebSocket push first, fall back to HTTP polling. Both phases share a
1558
- // single deadline so the combined wait never exceeds the caller's timeout —
1559
- // previously the WS cap (≤180s) plus a fresh full HTTP timeout could total
1560
- // up to ~1.6× timeoutSec (e.g. ~480s for a 300s request).
1561
- const deadline = timeoutSec != null ? Date.now() + timeoutSec * 1000 : undefined;
1736
+ // HTTP polling is the source of truth; the WebSocket is an accelerator.
1737
+ // Both run concurrently from the start and the first terminal outcome wins,
1738
+ // so a broken push channel (Redis down, worker unconfigured, proxy that
1739
+ // swallows frames) costs nothing beyond the poll cadence. Previously the
1740
+ // client waited 60% of its timeout on the socket before polling at all —
1741
+ // in prod that was ~72s of dead air on every call while the push never came.
1742
+ const inner = new AbortController();
1743
+ const onOuterAbort = () => inner.abort();
1744
+ if (signal?.aborted)
1745
+ inner.abort();
1746
+ else
1747
+ signal?.addEventListener('abort', onOuterAbort, { once: true });
1748
+ const wait = { pushConfirmed: false, lastStatus: undefined, via: undefined };
1749
+ // Two sources now report status; only surface changes to the caller.
1750
+ const emit = (status) => {
1751
+ if (status === wait.lastStatus)
1752
+ return;
1753
+ wait.lastStatus = status;
1754
+ onStatusUpdate?.(status, requestId);
1755
+ };
1756
+ const startedAt = Date.now();
1757
+ const wsBranch = this.waitViaWebSocket(requestId, inner.signal, emit, wait).catch((err) => {
1758
+ // A failed job or a cancellation is a real outcome. Anything else is a
1759
+ // transport problem: never settle the race on it, HTTP carries on.
1760
+ if (err instanceof errors_1.OneShotError)
1761
+ throw err;
1762
+ this.log(`WebSocket unavailable (${err instanceof Error ? err.message : String(err)}) — relying on HTTP polling`);
1763
+ return new Promise(() => { });
1764
+ });
1765
+ const httpBranch = this.pollJobHttp(requestId, timeoutSec, inner.signal, emit, wait);
1766
+ // The losing branch rejects on abort; mark both handled so it never
1767
+ // surfaces as an unhandled rejection.
1768
+ wsBranch.catch(() => { });
1769
+ httpBranch.catch(() => { });
1562
1770
  let result;
1563
1771
  try {
1564
- result = await this.waitViaWebSocket(requestId, timeoutSec, signal, onStatusUpdate);
1772
+ result = await Promise.race([wsBranch, httpBranch]);
1565
1773
  }
1566
- catch {
1567
- this.log('WebSocket unavailable, falling back to HTTP polling');
1568
- const remainingSec = deadline != null
1569
- ? Math.max(1, Math.ceil((deadline - Date.now()) / 1000))
1570
- : undefined;
1571
- result = await this.pollJobHttp(requestId, remainingSec, signal, onStatusUpdate);
1774
+ finally {
1775
+ inner.abort();
1776
+ signal?.removeEventListener('abort', onOuterAbort);
1572
1777
  }
1778
+ this.log(`Job ${requestId} ready after ${Date.now() - startedAt}ms via ${wait.via ?? 'unknown'}`);
1573
1779
  // Optional second phase: keep polling for the async phone-reveal webhook.
1574
1780
  // Only kicks in when the caller explicitly opts in AND the result still
1575
1781
  // has phones_pending=true (set by the worker when the upstream enrichment
@@ -1653,88 +1859,70 @@ class OneShot {
1653
1859
  // so the consumer knows phones never arrived.
1654
1860
  return lastResult;
1655
1861
  }
1656
- waitViaWebSocket(requestId, timeoutSec, signal, onStatusUpdate) {
1862
+ /**
1863
+ * WebSocket branch of a job wait. Resolves on a `completed` push for this
1864
+ * request, rejects with `JobError` on `failed` and with `OneShotError` on
1865
+ * abort. Every other failure (no WebSocket global, handshake refused, socket
1866
+ * closed early) rejects with a plain `Error`, which `pollJob` treats as
1867
+ * "no push available" rather than as an outcome. There is no timeout here:
1868
+ * the HTTP branch owns the deadline and aborts this one when it settles.
1869
+ */
1870
+ waitViaWebSocket(requestId, signal, emit, wait) {
1657
1871
  return new Promise((resolve, reject) => {
1658
- const maxWaitMs = (timeoutSec ?? 120) * 1000;
1659
- // Cap WS timeout to 60% of caller timeout so HTTP fallback has real time to work.
1660
- // Floor at 10s (below that, skip WS entirely). This prevents the WS timeout
1661
- // from racing with Cloud Run / load balancer timeouts.
1662
- const wsTimeoutMs = Math.max(Math.floor(maxWaitMs * 0.6), 10000);
1663
- if (wsTimeoutMs >= maxWaitMs) {
1664
- // Timeout too short for WS + HTTP — reject immediately to force HTTP path
1665
- return reject(new Error('Timeout too short for WebSocket path'));
1872
+ if (typeof WebSocket === 'undefined') {
1873
+ return reject(new Error('WebSocket not available'));
1874
+ }
1875
+ if (signal.aborted) {
1876
+ return reject(new errors_1.OneShotError('Operation cancelled'));
1666
1877
  }
1667
1878
  const wsUrl = this.baseUrl.replace(/^http/, 'ws') +
1668
1879
  `/v1/requests/subscribe?wallet=${encodeURIComponent(this.provider.address)}`;
1669
1880
  let ws;
1670
- let settled = false;
1671
- let receivedAnyMessage = false;
1672
1881
  try {
1673
1882
  ws = new WebSocket(wsUrl);
1674
1883
  }
1675
1884
  catch {
1676
1885
  return reject(new Error('WebSocket not available'));
1677
1886
  }
1887
+ let settled = false;
1678
1888
  const settle = (fn) => {
1679
1889
  if (settled)
1680
1890
  return;
1681
1891
  settled = true;
1682
1892
  fn();
1683
1893
  };
1684
- // Overall WS timeout — bail to HTTP fallback with time to spare
1685
- const timeout = setTimeout(() => {
1686
- settle(() => {
1687
- ws.close();
1688
- reject(new Error('WebSocket timeout — falling back to HTTP'));
1689
- });
1690
- }, wsTimeoutMs);
1691
- // First-message deadline: if no relevant message arrives within 15s of
1692
- // subscribing, the WS connection is likely silent (load balancer proxying
1693
- // without forwarding, scale-from-zero, etc). Bail fast to HTTP.
1694
- let firstMessageTimer = null;
1695
1894
  const cleanup = () => {
1696
- clearTimeout(timeout);
1697
- if (firstMessageTimer)
1698
- clearTimeout(firstMessageTimer);
1699
- if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
1895
+ signal.removeEventListener('abort', onAbort);
1896
+ // 0 = CONNECTING, 1 = OPEN (avoid relying on static props of a fake global)
1897
+ if (ws.readyState === 0 || ws.readyState === 1) {
1700
1898
  ws.close();
1701
1899
  }
1702
1900
  };
1703
- if (signal) {
1704
- signal.addEventListener('abort', () => {
1705
- settle(() => {
1706
- cleanup();
1707
- reject(new errors_1.OneShotError('Operation cancelled'));
1708
- });
1709
- }, { once: true });
1710
- }
1901
+ const onAbort = () => {
1902
+ settle(() => {
1903
+ cleanup();
1904
+ reject(new errors_1.OneShotError('Operation cancelled'));
1905
+ });
1906
+ };
1907
+ signal.addEventListener('abort', onAbort, { once: true });
1711
1908
  ws.onopen = () => {
1712
1909
  ws.send(JSON.stringify({ subscribe: [requestId] }));
1713
- // Start first-message deadline after subscribing
1714
- firstMessageTimer = setTimeout(() => {
1715
- if (!receivedAnyMessage) {
1716
- settle(() => {
1717
- this.log('WebSocket silent after subscribe — falling back to HTTP');
1718
- cleanup();
1719
- reject(new Error('WebSocket silent — no messages received'));
1720
- });
1721
- }
1722
- }, 15000);
1723
1910
  };
1724
1911
  ws.onmessage = (event) => {
1725
- receivedAnyMessage = true;
1726
- if (firstMessageTimer) {
1727
- clearTimeout(firstMessageTimer);
1728
- firstMessageTimer = null;
1729
- }
1730
1912
  try {
1731
1913
  const msg = JSON.parse(typeof event.data === 'string' ? event.data : event.data.toString());
1914
+ // The subscribe ack and other jobs' pushes are not request-scoped.
1732
1915
  if (msg.request_id !== requestId)
1733
1916
  return;
1917
+ if (!wait.pushConfirmed) {
1918
+ wait.pushConfirmed = true;
1919
+ this.log('WebSocket push confirmed — relaxing HTTP polling');
1920
+ }
1734
1921
  if (msg.status === 'completed') {
1735
1922
  this.log('Job completed (WebSocket)');
1736
1923
  settle(() => {
1737
1924
  cleanup();
1925
+ wait.via = 'ws';
1738
1926
  const result = (msg.result ?? msg);
1739
1927
  if (msg.request_id && typeof result === 'object' && result !== null && !('request_id' in result)) {
1740
1928
  result.request_id = msg.request_id;
@@ -1745,11 +1933,12 @@ class OneShot {
1745
1933
  else if (msg.status === 'failed') {
1746
1934
  settle(() => {
1747
1935
  cleanup();
1936
+ wait.via = 'ws';
1748
1937
  reject(new errors_1.JobError(`Job failed: ${msg.error ?? 'Unknown'}`, requestId, String(msg.error ?? 'Unknown'), msg.error_code));
1749
1938
  });
1750
1939
  }
1751
1940
  else {
1752
- onStatusUpdate?.(msg.status, requestId);
1941
+ emit(String(msg.status));
1753
1942
  }
1754
1943
  }
1755
1944
  catch {
@@ -1763,9 +1952,7 @@ class OneShot {
1763
1952
  });
1764
1953
  };
1765
1954
  ws.onclose = () => {
1766
- // Any close before we got a result reject so HTTP fallback kicks in.
1767
- // Previously we only rejected on non-1000 codes, but a clean close
1768
- // without a result is equally fatal for the poll loop.
1955
+ // Any close before we got a result means no push is coming.
1769
1956
  settle(() => {
1770
1957
  cleanup();
1771
1958
  reject(new Error('WebSocket closed before result'));
@@ -1773,12 +1960,18 @@ class OneShot {
1773
1960
  };
1774
1961
  });
1775
1962
  }
1776
- async pollJobHttp(requestId, timeoutSec, signal, onStatusUpdate) {
1963
+ /**
1964
+ * HTTP branch of a job wait: polls `GET /v1/requests/:id` immediately, then
1965
+ * on a short backoff (300ms → 2s). Once the WebSocket has proven it delivers
1966
+ * for this request, polling relaxes to 5s and acts as a safety net only.
1967
+ * Owns the caller's deadline (`JobTimeoutError`).
1968
+ */
1969
+ async pollJobHttp(requestId, timeoutSec, signal, emit, wait) {
1777
1970
  const maxWaitMs = (timeoutSec ?? 120) * 1000;
1778
1971
  const startTime = Date.now();
1779
- const pollInterval = 2000;
1780
1972
  let retries = 0;
1781
1973
  const maxRetries = 3;
1974
+ let polls = 0;
1782
1975
  while (Date.now() - startTime < maxWaitMs) {
1783
1976
  if (signal?.aborted)
1784
1977
  throw new errors_1.OneShotError('Operation cancelled');
@@ -1788,11 +1981,19 @@ class OneShot {
1788
1981
  signal
1789
1982
  });
1790
1983
  if (!resp.ok) {
1791
- throw new errors_1.ToolError('Failed to check job status', resp.status, await resp.text());
1984
+ const body = await resp.text();
1985
+ // 5xx / 429 from the poll endpoint are transient — keep polling.
1986
+ // Any other non-2xx (401/403/404) is a real answer about this job.
1987
+ if (resp.status >= 500 || resp.status === 429) {
1988
+ throw new Error(`Poll returned ${resp.status}: ${body.slice(0, 200)}`);
1989
+ }
1990
+ throw new errors_1.ToolError('Failed to check job status', resp.status, body);
1792
1991
  }
1793
1992
  const job = await resp.json();
1794
1993
  if (job.status === 'completed') {
1795
1994
  this.log('Job completed');
1995
+ if (wait)
1996
+ wait.via = 'http';
1796
1997
  const result = (job.result ?? job);
1797
1998
  // Propagate request_id into the result so callers always have it
1798
1999
  if (job.request_id && typeof result === 'object' && result !== null && !('request_id' in result)) {
@@ -1801,11 +2002,20 @@ class OneShot {
1801
2002
  return result;
1802
2003
  }
1803
2004
  if (job.status === 'failed') {
2005
+ if (wait)
2006
+ wait.via = 'http';
1804
2007
  throw new errors_1.JobError(`Job failed: ${job.error ?? 'Unknown'}`, requestId, String(job.error ?? 'Unknown'), job.error_code);
1805
2008
  }
1806
- onStatusUpdate?.(job.status, requestId);
2009
+ emit?.(String(job.status));
1807
2010
  retries = 0;
1808
- await this.sleep(pollInterval, signal);
2011
+ const interval = wait?.pushConfirmed
2012
+ ? HTTP_POLL_RELAXED_MS
2013
+ : HTTP_POLL_BACKOFF_MS[Math.min(polls, HTTP_POLL_BACKOFF_MS.length - 1)];
2014
+ polls++;
2015
+ const remaining = maxWaitMs - (Date.now() - startTime);
2016
+ if (remaining <= 0)
2017
+ break;
2018
+ await this.sleep(Math.min(interval, remaining), signal);
1809
2019
  }
1810
2020
  catch (err) {
1811
2021
  if (err instanceof errors_1.OneShotError)
@@ -1813,7 +2023,7 @@ class OneShot {
1813
2023
  if (++retries > maxRetries) {
1814
2024
  throw new errors_1.OneShotError(`Polling failed after ${maxRetries} retries: ${err}`);
1815
2025
  }
1816
- const backoff = pollInterval * Math.pow(2, retries - 1);
2026
+ const backoff = 2000 * Math.pow(2, retries - 1);
1817
2027
  this.log(`Retry ${retries}/${maxRetries} in ${backoff}ms`);
1818
2028
  await this.sleep(backoff, signal);
1819
2029
  }
@@ -1848,7 +2058,6 @@ class OneShot {
1848
2058
  }
1849
2059
  if (quoteId)
1850
2060
  headers['x-quote-id'] = quoteId;
1851
- // Create timeout signal if specified
1852
2061
  let fetchSignal = signal;
1853
2062
  let timeoutId;
1854
2063
  if (timeoutMs && !signal) {
@@ -1965,14 +2174,18 @@ class OneShot {
1965
2174
  const validAfter = now - 300; // Buffer for clock skew
1966
2175
  const validBefore = now + 3600;
1967
2176
  const nonceHex = ethers_1.ethers.hexlify(nonce);
1968
- // Use EIP-712 domain from the server's payment requirements
2177
+ // Use the EIP-712 domain from the server's payment requirements — name,
2178
+ // version AND chain. The chain used to be the mainnet constant, which made
2179
+ // every signature invalid against Base Sepolia (the domain separator
2180
+ // includes chainId), so the SDK could never pay on staging.
1969
2181
  const domainName = accepted.extra?.name || 'USD Coin';
1970
2182
  const domainVersion = accepted.extra?.version || '2';
2183
+ const chainId = chainIdFromNetwork(accepted.network ?? paymentInfo.network) ?? CHAIN_ID;
1971
2184
  // Sign EIP-3009 TransferWithAuthorization
1972
2185
  const signature = await this.provider.signTypedData({
1973
2186
  name: domainName,
1974
2187
  version: domainVersion,
1975
- chainId: CHAIN_ID,
2188
+ chainId,
1976
2189
  verifyingContract: paymentInfo.token.address
1977
2190
  }, {
1978
2191
  TransferWithAuthorization: [