@oneshot-agent/sdk 0.27.0 → 0.29.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/dist/index.js CHANGED
@@ -49,14 +49,44 @@ 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.27.0';
52
+ const SDK_VERSION = '0.29.0';
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';
69
+ // ETH-currency mode. A payment is an EIP-3009 authorization that the
70
+ // facilitator settles AFTER the API has responded, so for seconds after a
71
+ // successful call `balanceOf` still shows pre-payment funds. The SDK therefore
72
+ // keeps a ledger of signed-but-unsettled authorizations and works from
73
+ // `balanceOf − pending`, over-reserving (one early buffered swap) rather than
74
+ // under-reserving (a failed settlement).
75
+ /** One balanceOf read per paid call, deduped within roughly one Base block. */
76
+ const USDC_BALANCE_CACHE_MS = 2000;
77
+ /** How long a signed authorization stays subtracted from the on-chain balance (settlement lands well within this). */
78
+ const USDC_RESERVATION_TTL_MS = 90000;
79
+ const DEFAULT_SWAP_BUFFER_MULTIPLIER = 10;
80
+ const MAX_SWAP_BUFFER_MULTIPLIER = 1000;
81
+ const ERC20_BALANCE_ABI = ['function balanceOf(address) view returns (uint256)'];
82
+ function validateSwapBufferMultiplier(m) {
83
+ if (m === undefined)
84
+ return DEFAULT_SWAP_BUFFER_MULTIPLIER;
85
+ if (typeof m !== 'number' || !Number.isFinite(m) || m < 1 || m > MAX_SWAP_BUFFER_MULTIPLIER) {
86
+ throw new errors_1.ValidationError(`swapBufferMultiplier must be a finite number between 1 and ${MAX_SWAP_BUFFER_MULTIPLIER}`, 'swapBufferMultiplier');
87
+ }
88
+ return m;
89
+ }
60
90
  // ============================================================================
61
91
  // Public types — defined in ./types.ts. Re-exported below so existing
62
92
  // consumer imports (`import { EmailToolOptions, ... } from '@oneshot-agent/sdk'`)
@@ -145,11 +175,17 @@ class OneShot {
145
175
  * For CDP wallets, use OneShot.create() instead.
146
176
  */
147
177
  constructor(config, walletProvider) {
178
+ /** ETH mode: signed payments not yet observed as settled, keyed by reservation id. */
179
+ this._usdcPending = new Map();
180
+ this._usdcReservationSeq = 0;
181
+ /** ETH mode: serializes read → decide → swap → reserve per instance (also prevents concurrent swap nonce races). */
182
+ this._usdcLock = Promise.resolve();
148
183
  this.baseUrl = config.baseUrl ?? BASE_URL;
149
184
  this.debug = config.debug ?? false;
150
185
  this.logger = config.logger ?? console.log;
151
186
  this._currency = config.currency ?? 'USDC';
152
187
  this._slippage = config.slippage ?? 0.01;
188
+ this._swapBufferMultiplier = validateSwapBufferMultiplier(config.swapBufferMultiplier);
153
189
  this._budgets = validateBudgetConfig(config.budgets);
154
190
  this._alertEmail = config.alerts?.email;
155
191
  this.rpcProvider = new ethers_1.ethers.JsonRpcProvider(config.rpcUrl ?? RPC_URL);
@@ -162,7 +198,6 @@ class OneShot {
162
198
  else {
163
199
  throw new errors_1.ValidationError('Provide privateKey or use OneShot.create() for CDP/custom wallets', 'config');
164
200
  }
165
- // Validate ETH mode requirements
166
201
  if (this._currency === 'ETH' && !this.provider.sendTransaction) {
167
202
  throw new errors_1.ValidationError('ETH currency mode requires a wallet provider that supports sendTransaction', 'currency');
168
203
  }
@@ -188,6 +223,10 @@ class OneShot {
188
223
  get slippage() {
189
224
  return this._slippage;
190
225
  }
226
+ /** ETH mode: how many payments' worth of USDC a swap buys (default 10). */
227
+ get swapBufferMultiplier() {
228
+ return this._swapBufferMultiplier;
229
+ }
191
230
  /** The budget config this instance was constructed with, if any. */
192
231
  get budgetConfig() {
193
232
  return this._budgets;
@@ -451,7 +490,6 @@ class OneShot {
451
490
  async voice(options) {
452
491
  this.validate(options.objective, 'objective');
453
492
  this.validate(options.target_number, 'target_number');
454
- // Check for empty arrays
455
493
  if (Array.isArray(options.target_number) && options.target_number.length === 0) {
456
494
  throw new errors_1.ValidationError('target_number array cannot be empty', 'target_number');
457
495
  }
@@ -515,7 +553,6 @@ class OneShot {
515
553
  async sms(options) {
516
554
  this.validate(options.message, 'message');
517
555
  this.validate(options.to_number, 'to_number');
518
- // Check for empty arrays
519
556
  if (Array.isArray(options.to_number) && options.to_number.length === 0) {
520
557
  throw new errors_1.ValidationError('to_number array cannot be empty', 'to_number');
521
558
  }
@@ -1139,7 +1176,6 @@ class OneShot {
1139
1176
  const path = `/v1/compute/${goalId}/fund`;
1140
1177
  const payload = { amount };
1141
1178
  await this.ensureBudgetsSynced();
1142
- // First call: get quote (402)
1143
1179
  const quoteResp = await this.makeRequest(path, payload);
1144
1180
  if (quoteResp.status !== 402) {
1145
1181
  // A 403 here is the budget gate on the quote leg → BudgetExceededError.
@@ -1159,8 +1195,8 @@ class OneShot {
1159
1195
  };
1160
1196
  const { accepted, resource, extensions } = await this.getAcceptedRequirements(quoteResp, path, payload, quoteData.context.quote_id);
1161
1197
  paymentInfo.amount = this.chargeAmount(accepted, quoteData.payment_request.amount);
1162
- const auth = await this.signPaymentAuthorization(paymentInfo, accepted, resource, extensions);
1163
- const fundResp = await this.makeRequest(path, payload, auth, quoteData.context.quote_id);
1198
+ const signed = await this.signPaymentAuthorization(paymentInfo, accepted, resource, extensions);
1199
+ const fundResp = await this.makePaidRequest(signed, path, payload, quoteData.context.quote_id);
1164
1200
  if (!fundResp.ok) {
1165
1201
  await this.failFromResponse('Failed to fund compute goal', fundResp);
1166
1202
  }
@@ -1611,7 +1647,6 @@ class OneShot {
1611
1647
  ...this.maxCostHeader(maxCost),
1612
1648
  ...this.idempotencyHeader(idempotencyKey),
1613
1649
  };
1614
- // Validate memo
1615
1650
  if (payload.memo !== undefined) {
1616
1651
  if (typeof payload.memo !== 'string' || payload.memo.trim().length === 0) {
1617
1652
  delete payload.memo; // Drop invalid memo silently
@@ -1624,7 +1659,6 @@ class OneShot {
1624
1659
  else if (!endpoint.includes('/inbox') && !endpoint.includes('/notifications') && !endpoint.includes('/balance')) {
1625
1660
  this.log('No memo provided — consider adding a reason for audit trail');
1626
1661
  }
1627
- // Validate decisionContext
1628
1662
  if (payload.decisionContext !== undefined) {
1629
1663
  if (typeof payload.decisionContext !== 'object' || payload.decisionContext === null) {
1630
1664
  delete payload.decisionContext;
@@ -1662,8 +1696,8 @@ class OneShot {
1662
1696
  this.log(`Payment required: ${paymentInfo.amount} USDC`);
1663
1697
  this.assertWithinBudget(paymentInfo.amount);
1664
1698
  this.checkAbortBeforePayment(signal);
1665
- const auth = await this.signPaymentAuthorization(paymentInfo, accepted, resource, extensions);
1666
- response = await this.makeRequest(endpoint, payload, auth, quoteId, signal, undefined, extraHeaders);
1699
+ const signed = await this.signPaymentAuthorization(paymentInfo, accepted, resource, extensions);
1700
+ response = await this.makePaidRequest(signed, endpoint, payload, quoteId, signal, undefined, extraHeaders);
1667
1701
  }
1668
1702
  if (!response.ok) {
1669
1703
  await this.failFromResponse('Tool request failed', response);
@@ -1713,27 +1747,66 @@ class OneShot {
1713
1747
  this.checkAbortBeforePayment(cfg.signal);
1714
1748
  const { accepted, resource, extensions } = await this.getAcceptedRequirements(quoteResp, cfg.endpoint, cfg.payload, quoteData.context.quote_id, cfg.signal);
1715
1749
  paymentInfo.amount = this.chargeAmount(accepted, quoteData.payment_request.amount);
1716
- const auth = await this.signPaymentAuthorization(paymentInfo, accepted, resource, extensions);
1717
- const execResp = await this.makeRequest(cfg.endpoint, cfg.payload, auth, quoteData.context.quote_id, cfg.signal, cfg.execTimeoutMs);
1750
+ const signed = await this.signPaymentAuthorization(paymentInfo, accepted, resource, extensions);
1751
+ const execResp = await this.makePaidRequest(signed, cfg.endpoint, cfg.payload, quoteData.context.quote_id, cfg.signal, cfg.execTimeoutMs);
1718
1752
  return { context: quoteData.context, execResp };
1719
1753
  }
1754
+ /**
1755
+ * Wait for a previously dispatched job and return its result.
1756
+ *
1757
+ * Use this to resolve the `request_id` returned by a call made with
1758
+ * `wait: false`, or to resume waiting after a client restart. Delivery is the
1759
+ * same as for blocking calls: WebSocket push when the server offers it, HTTP
1760
+ * polling of `GET /v1/requests/:id` as the source of truth.
1761
+ */
1762
+ async waitForResult(requestId, options = {}) {
1763
+ this.validate(requestId, 'requestId');
1764
+ return this.pollJob(requestId, options.timeout, options.signal, options.onStatusUpdate, options.waitForPhones ? { waitForPhones: true, phoneTimeoutSec: options.phoneTimeoutSec } : undefined);
1765
+ }
1720
1766
  async pollJob(requestId, timeoutSec, signal, onStatusUpdate, phoneOpts) {
1721
- // Try WebSocket push first, fall back to HTTP polling. Both phases share a
1722
- // single deadline so the combined wait never exceeds the caller's timeout —
1723
- // previously the WS cap (≤180s) plus a fresh full HTTP timeout could total
1724
- // up to ~1.6× timeoutSec (e.g. ~480s for a 300s request).
1725
- const deadline = timeoutSec != null ? Date.now() + timeoutSec * 1000 : undefined;
1767
+ // HTTP polling is the source of truth; the WebSocket is an accelerator.
1768
+ // Both run concurrently from the start and the first terminal outcome wins,
1769
+ // so a broken push channel (Redis down, worker unconfigured, proxy that
1770
+ // swallows frames) costs nothing beyond the poll cadence. Previously the
1771
+ // client waited 60% of its timeout on the socket before polling at all —
1772
+ // in prod that was ~72s of dead air on every call while the push never came.
1773
+ const inner = new AbortController();
1774
+ const onOuterAbort = () => inner.abort();
1775
+ if (signal?.aborted)
1776
+ inner.abort();
1777
+ else
1778
+ signal?.addEventListener('abort', onOuterAbort, { once: true });
1779
+ const wait = { pushConfirmed: false, lastStatus: undefined, via: undefined };
1780
+ // Two sources now report status; only surface changes to the caller.
1781
+ const emit = (status) => {
1782
+ if (status === wait.lastStatus)
1783
+ return;
1784
+ wait.lastStatus = status;
1785
+ onStatusUpdate?.(status, requestId);
1786
+ };
1787
+ const startedAt = Date.now();
1788
+ const wsBranch = this.waitViaWebSocket(requestId, inner.signal, emit, wait).catch((err) => {
1789
+ // A failed job or a cancellation is a real outcome. Anything else is a
1790
+ // transport problem: never settle the race on it, HTTP carries on.
1791
+ if (err instanceof errors_1.OneShotError)
1792
+ throw err;
1793
+ this.log(`WebSocket unavailable (${err instanceof Error ? err.message : String(err)}) — relying on HTTP polling`);
1794
+ return new Promise(() => { });
1795
+ });
1796
+ const httpBranch = this.pollJobHttp(requestId, timeoutSec, inner.signal, emit, wait);
1797
+ // The losing branch rejects on abort; mark both handled so it never
1798
+ // surfaces as an unhandled rejection.
1799
+ wsBranch.catch(() => { });
1800
+ httpBranch.catch(() => { });
1726
1801
  let result;
1727
1802
  try {
1728
- result = await this.waitViaWebSocket(requestId, timeoutSec, signal, onStatusUpdate);
1803
+ result = await Promise.race([wsBranch, httpBranch]);
1729
1804
  }
1730
- catch {
1731
- this.log('WebSocket unavailable, falling back to HTTP polling');
1732
- const remainingSec = deadline != null
1733
- ? Math.max(1, Math.ceil((deadline - Date.now()) / 1000))
1734
- : undefined;
1735
- result = await this.pollJobHttp(requestId, remainingSec, signal, onStatusUpdate);
1805
+ finally {
1806
+ inner.abort();
1807
+ signal?.removeEventListener('abort', onOuterAbort);
1736
1808
  }
1809
+ this.log(`Job ${requestId} ready after ${Date.now() - startedAt}ms via ${wait.via ?? 'unknown'}`);
1737
1810
  // Optional second phase: keep polling for the async phone-reveal webhook.
1738
1811
  // Only kicks in when the caller explicitly opts in AND the result still
1739
1812
  // has phones_pending=true (set by the worker when the upstream enrichment
@@ -1817,88 +1890,70 @@ class OneShot {
1817
1890
  // so the consumer knows phones never arrived.
1818
1891
  return lastResult;
1819
1892
  }
1820
- waitViaWebSocket(requestId, timeoutSec, signal, onStatusUpdate) {
1893
+ /**
1894
+ * WebSocket branch of a job wait. Resolves on a `completed` push for this
1895
+ * request, rejects with `JobError` on `failed` and with `OneShotError` on
1896
+ * abort. Every other failure (no WebSocket global, handshake refused, socket
1897
+ * closed early) rejects with a plain `Error`, which `pollJob` treats as
1898
+ * "no push available" rather than as an outcome. There is no timeout here:
1899
+ * the HTTP branch owns the deadline and aborts this one when it settles.
1900
+ */
1901
+ waitViaWebSocket(requestId, signal, emit, wait) {
1821
1902
  return new Promise((resolve, reject) => {
1822
- const maxWaitMs = (timeoutSec ?? 120) * 1000;
1823
- // Cap WS timeout to 60% of caller timeout so HTTP fallback has real time to work.
1824
- // Floor at 10s (below that, skip WS entirely). This prevents the WS timeout
1825
- // from racing with Cloud Run / load balancer timeouts.
1826
- const wsTimeoutMs = Math.max(Math.floor(maxWaitMs * 0.6), 10000);
1827
- if (wsTimeoutMs >= maxWaitMs) {
1828
- // Timeout too short for WS + HTTP — reject immediately to force HTTP path
1829
- return reject(new Error('Timeout too short for WebSocket path'));
1903
+ if (typeof WebSocket === 'undefined') {
1904
+ return reject(new Error('WebSocket not available'));
1905
+ }
1906
+ if (signal.aborted) {
1907
+ return reject(new errors_1.OneShotError('Operation cancelled'));
1830
1908
  }
1831
1909
  const wsUrl = this.baseUrl.replace(/^http/, 'ws') +
1832
1910
  `/v1/requests/subscribe?wallet=${encodeURIComponent(this.provider.address)}`;
1833
1911
  let ws;
1834
- let settled = false;
1835
- let receivedAnyMessage = false;
1836
1912
  try {
1837
1913
  ws = new WebSocket(wsUrl);
1838
1914
  }
1839
1915
  catch {
1840
1916
  return reject(new Error('WebSocket not available'));
1841
1917
  }
1918
+ let settled = false;
1842
1919
  const settle = (fn) => {
1843
1920
  if (settled)
1844
1921
  return;
1845
1922
  settled = true;
1846
1923
  fn();
1847
1924
  };
1848
- // Overall WS timeout — bail to HTTP fallback with time to spare
1849
- const timeout = setTimeout(() => {
1850
- settle(() => {
1851
- ws.close();
1852
- reject(new Error('WebSocket timeout — falling back to HTTP'));
1853
- });
1854
- }, wsTimeoutMs);
1855
- // First-message deadline: if no relevant message arrives within 15s of
1856
- // subscribing, the WS connection is likely silent (load balancer proxying
1857
- // without forwarding, scale-from-zero, etc). Bail fast to HTTP.
1858
- let firstMessageTimer = null;
1859
1925
  const cleanup = () => {
1860
- clearTimeout(timeout);
1861
- if (firstMessageTimer)
1862
- clearTimeout(firstMessageTimer);
1863
- if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
1926
+ signal.removeEventListener('abort', onAbort);
1927
+ // 0 = CONNECTING, 1 = OPEN (avoid relying on static props of a fake global)
1928
+ if (ws.readyState === 0 || ws.readyState === 1) {
1864
1929
  ws.close();
1865
1930
  }
1866
1931
  };
1867
- if (signal) {
1868
- signal.addEventListener('abort', () => {
1869
- settle(() => {
1870
- cleanup();
1871
- reject(new errors_1.OneShotError('Operation cancelled'));
1872
- });
1873
- }, { once: true });
1874
- }
1932
+ const onAbort = () => {
1933
+ settle(() => {
1934
+ cleanup();
1935
+ reject(new errors_1.OneShotError('Operation cancelled'));
1936
+ });
1937
+ };
1938
+ signal.addEventListener('abort', onAbort, { once: true });
1875
1939
  ws.onopen = () => {
1876
1940
  ws.send(JSON.stringify({ subscribe: [requestId] }));
1877
- // Start first-message deadline after subscribing
1878
- firstMessageTimer = setTimeout(() => {
1879
- if (!receivedAnyMessage) {
1880
- settle(() => {
1881
- this.log('WebSocket silent after subscribe — falling back to HTTP');
1882
- cleanup();
1883
- reject(new Error('WebSocket silent — no messages received'));
1884
- });
1885
- }
1886
- }, 15000);
1887
1941
  };
1888
1942
  ws.onmessage = (event) => {
1889
- receivedAnyMessage = true;
1890
- if (firstMessageTimer) {
1891
- clearTimeout(firstMessageTimer);
1892
- firstMessageTimer = null;
1893
- }
1894
1943
  try {
1895
1944
  const msg = JSON.parse(typeof event.data === 'string' ? event.data : event.data.toString());
1945
+ // The subscribe ack and other jobs' pushes are not request-scoped.
1896
1946
  if (msg.request_id !== requestId)
1897
1947
  return;
1948
+ if (!wait.pushConfirmed) {
1949
+ wait.pushConfirmed = true;
1950
+ this.log('WebSocket push confirmed — relaxing HTTP polling');
1951
+ }
1898
1952
  if (msg.status === 'completed') {
1899
1953
  this.log('Job completed (WebSocket)');
1900
1954
  settle(() => {
1901
1955
  cleanup();
1956
+ wait.via = 'ws';
1902
1957
  const result = (msg.result ?? msg);
1903
1958
  if (msg.request_id && typeof result === 'object' && result !== null && !('request_id' in result)) {
1904
1959
  result.request_id = msg.request_id;
@@ -1909,11 +1964,12 @@ class OneShot {
1909
1964
  else if (msg.status === 'failed') {
1910
1965
  settle(() => {
1911
1966
  cleanup();
1967
+ wait.via = 'ws';
1912
1968
  reject(new errors_1.JobError(`Job failed: ${msg.error ?? 'Unknown'}`, requestId, String(msg.error ?? 'Unknown'), msg.error_code));
1913
1969
  });
1914
1970
  }
1915
1971
  else {
1916
- onStatusUpdate?.(msg.status, requestId);
1972
+ emit(String(msg.status));
1917
1973
  }
1918
1974
  }
1919
1975
  catch {
@@ -1927,9 +1983,7 @@ class OneShot {
1927
1983
  });
1928
1984
  };
1929
1985
  ws.onclose = () => {
1930
- // Any close before we got a result reject so HTTP fallback kicks in.
1931
- // Previously we only rejected on non-1000 codes, but a clean close
1932
- // without a result is equally fatal for the poll loop.
1986
+ // Any close before we got a result means no push is coming.
1933
1987
  settle(() => {
1934
1988
  cleanup();
1935
1989
  reject(new Error('WebSocket closed before result'));
@@ -1937,12 +1991,18 @@ class OneShot {
1937
1991
  };
1938
1992
  });
1939
1993
  }
1940
- async pollJobHttp(requestId, timeoutSec, signal, onStatusUpdate) {
1994
+ /**
1995
+ * HTTP branch of a job wait: polls `GET /v1/requests/:id` immediately, then
1996
+ * on a short backoff (300ms → 2s). Once the WebSocket has proven it delivers
1997
+ * for this request, polling relaxes to 5s and acts as a safety net only.
1998
+ * Owns the caller's deadline (`JobTimeoutError`).
1999
+ */
2000
+ async pollJobHttp(requestId, timeoutSec, signal, emit, wait) {
1941
2001
  const maxWaitMs = (timeoutSec ?? 120) * 1000;
1942
2002
  const startTime = Date.now();
1943
- const pollInterval = 2000;
1944
2003
  let retries = 0;
1945
2004
  const maxRetries = 3;
2005
+ let polls = 0;
1946
2006
  while (Date.now() - startTime < maxWaitMs) {
1947
2007
  if (signal?.aborted)
1948
2008
  throw new errors_1.OneShotError('Operation cancelled');
@@ -1952,11 +2012,19 @@ class OneShot {
1952
2012
  signal
1953
2013
  });
1954
2014
  if (!resp.ok) {
1955
- throw new errors_1.ToolError('Failed to check job status', resp.status, await resp.text());
2015
+ const body = await resp.text();
2016
+ // 5xx / 429 from the poll endpoint are transient — keep polling.
2017
+ // Any other non-2xx (401/403/404) is a real answer about this job.
2018
+ if (resp.status >= 500 || resp.status === 429) {
2019
+ throw new Error(`Poll returned ${resp.status}: ${body.slice(0, 200)}`);
2020
+ }
2021
+ throw new errors_1.ToolError('Failed to check job status', resp.status, body);
1956
2022
  }
1957
2023
  const job = await resp.json();
1958
2024
  if (job.status === 'completed') {
1959
2025
  this.log('Job completed');
2026
+ if (wait)
2027
+ wait.via = 'http';
1960
2028
  const result = (job.result ?? job);
1961
2029
  // Propagate request_id into the result so callers always have it
1962
2030
  if (job.request_id && typeof result === 'object' && result !== null && !('request_id' in result)) {
@@ -1965,11 +2033,20 @@ class OneShot {
1965
2033
  return result;
1966
2034
  }
1967
2035
  if (job.status === 'failed') {
2036
+ if (wait)
2037
+ wait.via = 'http';
1968
2038
  throw new errors_1.JobError(`Job failed: ${job.error ?? 'Unknown'}`, requestId, String(job.error ?? 'Unknown'), job.error_code);
1969
2039
  }
1970
- onStatusUpdate?.(job.status, requestId);
2040
+ emit?.(String(job.status));
1971
2041
  retries = 0;
1972
- await this.sleep(pollInterval, signal);
2042
+ const interval = wait?.pushConfirmed
2043
+ ? HTTP_POLL_RELAXED_MS
2044
+ : HTTP_POLL_BACKOFF_MS[Math.min(polls, HTTP_POLL_BACKOFF_MS.length - 1)];
2045
+ polls++;
2046
+ const remaining = maxWaitMs - (Date.now() - startTime);
2047
+ if (remaining <= 0)
2048
+ break;
2049
+ await this.sleep(Math.min(interval, remaining), signal);
1973
2050
  }
1974
2051
  catch (err) {
1975
2052
  if (err instanceof errors_1.OneShotError)
@@ -1977,7 +2054,7 @@ class OneShot {
1977
2054
  if (++retries > maxRetries) {
1978
2055
  throw new errors_1.OneShotError(`Polling failed after ${maxRetries} retries: ${err}`);
1979
2056
  }
1980
- const backoff = pollInterval * Math.pow(2, retries - 1);
2057
+ const backoff = 2000 * Math.pow(2, retries - 1);
1981
2058
  this.log(`Retry ${retries}/${maxRetries} in ${backoff}ms`);
1982
2059
  await this.sleep(backoff, signal);
1983
2060
  }
@@ -2012,7 +2089,6 @@ class OneShot {
2012
2089
  }
2013
2090
  if (quoteId)
2014
2091
  headers['x-quote-id'] = quoteId;
2015
- // Create timeout signal if specified
2016
2092
  let fetchSignal = signal;
2017
2093
  let timeoutId;
2018
2094
  if (timeoutMs && !signal) {
@@ -2038,17 +2114,152 @@ class OneShot {
2038
2114
  throw new errors_1.OneShotError('Operation cancelled before payment');
2039
2115
  }
2040
2116
  }
2117
+ // ---------------------------------------------------------------------------
2118
+ // ETH-currency mode: USDC ledger + buffered swaps
2119
+ // ---------------------------------------------------------------------------
2041
2120
  /**
2042
- * If currency is ETH, swap ETH→USDC to ensure the wallet has enough USDC for payment.
2043
- * This is called before signing the x402 payment authorization.
2121
+ * ETH mode only. Make sure the wallet's *effective* USDC (on-chain balance
2122
+ * minus payments signed but not yet settled) covers this charge, swapping
2123
+ * ETH→USDC for a buffer of `swapBufferMultiplier` payments when it does not,
2124
+ * then reserve the charge. Returns the reservation so the caller can release
2125
+ * it if the payment never settles (request failed). Runs under a per-instance
2126
+ * lock so concurrent calls never double-swap or race the wallet nonce.
2044
2127
  */
2045
2128
  async ensureUsdcBalance(paymentInfo) {
2046
2129
  if (this._currency !== 'ETH')
2047
- return;
2130
+ return undefined;
2131
+ const { chainId, usdcAddress } = this.assertEthModeSupported(paymentInfo);
2132
+ const charge = ethers_1.ethers.parseUnits(paymentInfo.amount, paymentInfo.token.decimals);
2133
+ return this.withUsdcLock(async () => {
2134
+ let balance = await this.readUsdcBalance(usdcAddress);
2135
+ const effective = this.effectiveUsdcBalance(balance);
2136
+ if (effective >= charge) {
2137
+ this.log(`USDC balance covers payment (${ethers_1.ethers.formatUnits(effective, 6)} available for ${paymentInfo.amount}); skipping ETH swap`);
2138
+ }
2139
+ else {
2140
+ const swapAmount = await this.sizeSwap(charge, effective, chainId);
2141
+ const swapAmountStr = ethers_1.ethers.formatUnits(swapAmount, 6);
2142
+ this.log(`USDC ${ethers_1.ethers.formatUnits(effective, 6)} is below ${paymentInfo.amount}; swapping ETH→USDC for ${swapAmountStr} USDC (${this._swapBufferMultiplier}x buffer, slippage: ${this._slippage * 100}%)`);
2143
+ try {
2144
+ const result = await this.executeUsdcSwap(swapAmountStr, chainId);
2145
+ balance += result.usdcReceived;
2146
+ // exactOutput delivers exactly amountOut and tx.wait() has confirmed it.
2147
+ this._usdcBalanceCache = { balance, at: Date.now() };
2148
+ this.log(`Swap complete: tx=${result.txHash}, USDC received=${ethers_1.ethers.formatUnits(result.usdcReceived, 6)}`);
2149
+ }
2150
+ catch (err) {
2151
+ this._usdcBalanceCache = undefined;
2152
+ throw err;
2153
+ }
2154
+ }
2155
+ return this.reserveUsdc(charge);
2156
+ });
2157
+ }
2158
+ /** ETH mode is Base-mainnet-only (that is where the Uniswap route lives); fail clearly before any RPC. */
2159
+ assertEthModeSupported(paymentInfo) {
2160
+ const chainId = chainIdFromNetwork(paymentInfo.network) ?? CHAIN_ID;
2161
+ if (chainId !== CHAIN_ID) {
2162
+ throw new errors_1.ValidationError(`ETH currency mode is only supported on Base mainnet (eip155:${CHAIN_ID}); this payment is on ${paymentInfo.network}. Fund the wallet with USDC or use currency: 'USDC'.`, 'currency');
2163
+ }
2164
+ const usdcAddress = paymentInfo.token.address;
2165
+ if (usdcAddress.toLowerCase() !== USDC_ADDRESS.toLowerCase()) {
2166
+ throw new errors_1.ValidationError(`ETH→USDC swap buys ${USDC_ADDRESS} but this payment requires ${usdcAddress}`, 'currency');
2167
+ }
2168
+ return { chainId, usdcAddress };
2169
+ }
2170
+ withUsdcLock(fn) {
2171
+ // A failed predecessor must not poison the chain: the waiter runs its own attempt.
2172
+ const run = this._usdcLock.then(fn, fn);
2173
+ this._usdcLock = run.then(() => undefined, () => undefined);
2174
+ return run;
2175
+ }
2176
+ /** On-chain USDC balance (atomic units), deduped within one block. Overridable in tests. */
2177
+ async readUsdcBalance(usdcAddress) {
2178
+ const cached = this._usdcBalanceCache;
2179
+ if (cached && Date.now() - cached.at < USDC_BALANCE_CACHE_MS)
2180
+ return cached.balance;
2181
+ const usdc = new ethers_1.ethers.Contract(usdcAddress, ERC20_BALANCE_ABI, this.rpcProvider);
2182
+ const balance = BigInt(await usdc.balanceOf(this.provider.address));
2183
+ this._usdcBalanceCache = { balance, at: Date.now() };
2184
+ return balance;
2185
+ }
2186
+ /** `balanceOf − Σ pending` (never negative); drops reservations older than the TTL. */
2187
+ effectiveUsdcBalance(balance) {
2188
+ const cutoff = Date.now() - USDC_RESERVATION_TTL_MS;
2189
+ let pending = 0n;
2190
+ for (const [id, r] of this._usdcPending) {
2191
+ if (r.createdAt < cutoff)
2192
+ this._usdcPending.delete(id);
2193
+ else
2194
+ pending += r.amount;
2195
+ }
2196
+ return balance > pending ? balance - pending : 0n;
2197
+ }
2198
+ reserveUsdc(amount) {
2199
+ const reservation = { id: ++this._usdcReservationSeq, amount, createdAt: Date.now() };
2200
+ this._usdcPending.set(reservation.id, reservation);
2201
+ return reservation;
2202
+ }
2203
+ /** Forget a reservation whose payment will never settle (signing or request failed). */
2204
+ releaseUsdcReservation(reservation) {
2205
+ if (reservation)
2206
+ this._usdcPending.delete(reservation.id);
2207
+ }
2208
+ /**
2209
+ * How much USDC to buy: top up to `charge × swapBufferMultiplier`, counting
2210
+ * whatever effective balance is already there. If the wallet's ETH cannot
2211
+ * cover the buffered quote, fall back to the bare shortfall so an ETH-poor
2212
+ * wallet can still make the one payment in front of it.
2213
+ */
2214
+ async sizeSwap(charge, effective, chainId) {
2215
+ const shortfall = charge - effective;
2216
+ // Integer math on a 1000× scale: no float→BigInt on an unbounded value.
2217
+ const mScaled = BigInt(Math.round(this._swapBufferMultiplier * 1000));
2218
+ const target = (charge * mScaled + 999n) / 1000n;
2219
+ let amount = target > effective ? target - effective : shortfall;
2220
+ if (amount > shortfall && this.provider.getBalance) {
2221
+ try {
2222
+ const amountInMax = await this.quoteSwapAmountInMax(ethers_1.ethers.formatUnits(amount, 6), chainId);
2223
+ const eth = await this.provider.getBalance();
2224
+ if (amountInMax !== undefined && eth < amountInMax) {
2225
+ this.log(`ETH balance cannot cover a ${ethers_1.ethers.formatUnits(amount, 6)} USDC buffer; swapping only the ${ethers_1.ethers.formatUnits(shortfall, 6)} USDC shortfall`);
2226
+ amount = shortfall;
2227
+ }
2228
+ }
2229
+ catch {
2230
+ // Quoting failed — let executeSwap quote again and report properly.
2231
+ }
2232
+ }
2233
+ return amount;
2234
+ }
2235
+ /** Max ETH the buffered swap could cost (quote), or undefined if unavailable. Overridable in tests. */
2236
+ async quoteSwapAmountInMax(usdcAmount, chainId) {
2237
+ const { getSwapQuote } = await Promise.resolve().then(() => __importStar(require('./swap')));
2238
+ const quote = await getSwapQuote(this.rpcProvider, usdcAmount, chainId, this._slippage);
2239
+ return quote?.amountInMax;
2240
+ }
2241
+ /** Perform the on-chain swap. Overridable in tests. */
2242
+ async executeUsdcSwap(usdcAmount, chainId) {
2048
2243
  const { executeSwap } = await Promise.resolve().then(() => __importStar(require('./swap')));
2049
- this.log(`Swapping ETH→USDC for ${paymentInfo.amount} USDC (slippage: ${this._slippage * 100}%)`);
2050
- const result = await executeSwap(this.provider, this.rpcProvider, paymentInfo.amount, CHAIN_ID, this._slippage);
2051
- this.log(`Swap complete: tx=${result.txHash}, USDC received=${ethers_1.ethers.formatUnits(result.usdcReceived, 6)}`);
2244
+ return executeSwap(this.provider, this.rpcProvider, usdcAmount, chainId, this._slippage);
2245
+ }
2246
+ /**
2247
+ * Send the paid leg of a request. The server settles the authorization only
2248
+ * on a 2xx, so a thrown request or a non-2xx response means nothing will be
2249
+ * debited on-chain — release the ETH-mode reservation in that case.
2250
+ */
2251
+ async makePaidRequest(signed, endpoint, data, quoteId, signal, timeoutMs, extraHeaders) {
2252
+ let resp;
2253
+ try {
2254
+ resp = await this.makeRequest(endpoint, data, signed.auth, quoteId, signal, timeoutMs, extraHeaders);
2255
+ }
2256
+ catch (err) {
2257
+ this.releaseUsdcReservation(signed.reservation);
2258
+ throw err;
2259
+ }
2260
+ if (!resp.ok)
2261
+ this.releaseUsdcReservation(signed.reservation);
2262
+ return resp;
2052
2263
  }
2053
2264
  /** Parse the PAYMENT-REQUIRED header from a 402 response into the accepted requirements and Bazaar metadata. */
2054
2265
  parsePaymentRequired(header) {
@@ -2103,76 +2314,87 @@ class OneShot {
2103
2314
  // always receives a payment-signature header (avoids 402 from x402 SDK).
2104
2315
  if (parseFloat(paymentInfo.amount) === 0) {
2105
2316
  this.log('Credits cover full cost — sending zero-cost authorization');
2106
- return {
2107
- x402Version: 2,
2108
- ...(resource ? { resource } : {}),
2109
- ...(extensions ? { extensions } : {}),
2110
- accepted,
2111
- payload: {
2112
- signature: '0x',
2113
- authorization: {
2114
- from: this.provider.address,
2115
- to: paymentInfo.payTo,
2116
- value: '0',
2117
- validAfter: '0',
2118
- validBefore: '0',
2119
- nonce: '0x' + '00'.repeat(32),
2317
+ return { auth: {
2318
+ x402Version: 2,
2319
+ ...(resource ? { resource } : {}),
2320
+ ...(extensions ? { extensions } : {}),
2321
+ accepted,
2322
+ payload: {
2323
+ signature: '0x',
2324
+ authorization: {
2325
+ from: this.provider.address,
2326
+ to: paymentInfo.payTo,
2327
+ value: '0',
2328
+ validAfter: '0',
2329
+ validBefore: '0',
2330
+ nonce: '0x' + '00'.repeat(32),
2331
+ },
2120
2332
  },
2121
- },
2122
- };
2333
+ } };
2123
2334
  }
2124
- // If paying with ETH, swap to USDC first
2125
- await this.ensureUsdcBalance(paymentInfo);
2335
+ // If paying with ETH, make sure USDC covers the charge (swapping a buffer if not) and reserve it
2336
+ const reservation = await this.ensureUsdcBalance(paymentInfo);
2126
2337
  const now = Math.floor(Date.now() / 1000);
2127
2338
  const nonce = ethers_1.ethers.randomBytes(32);
2128
2339
  const value = ethers_1.ethers.parseUnits(paymentInfo.amount, paymentInfo.token.decimals);
2129
2340
  const validAfter = now - 300; // Buffer for clock skew
2130
2341
  const validBefore = now + 3600;
2131
2342
  const nonceHex = ethers_1.ethers.hexlify(nonce);
2132
- // Use EIP-712 domain from the server's payment requirements
2343
+ // Use the EIP-712 domain from the server's payment requirements — name,
2344
+ // version AND chain. The chain used to be the mainnet constant, which made
2345
+ // every signature invalid against Base Sepolia (the domain separator
2346
+ // includes chainId), so the SDK could never pay on staging.
2133
2347
  const domainName = accepted.extra?.name || 'USD Coin';
2134
2348
  const domainVersion = accepted.extra?.version || '2';
2349
+ const chainId = chainIdFromNetwork(accepted.network ?? paymentInfo.network) ?? CHAIN_ID;
2135
2350
  // Sign EIP-3009 TransferWithAuthorization
2136
- const signature = await this.provider.signTypedData({
2137
- name: domainName,
2138
- version: domainVersion,
2139
- chainId: CHAIN_ID,
2140
- verifyingContract: paymentInfo.token.address
2141
- }, {
2142
- TransferWithAuthorization: [
2143
- { name: 'from', type: 'address' },
2144
- { name: 'to', type: 'address' },
2145
- { name: 'value', type: 'uint256' },
2146
- { name: 'validAfter', type: 'uint256' },
2147
- { name: 'validBefore', type: 'uint256' },
2148
- { name: 'nonce', type: 'bytes32' }
2149
- ]
2150
- }, {
2151
- from: this.provider.address,
2152
- to: paymentInfo.payTo,
2153
- value,
2154
- validAfter,
2155
- validBefore,
2156
- nonce: nonceHex
2157
- });
2351
+ let signature;
2352
+ try {
2353
+ signature = await this.provider.signTypedData({
2354
+ name: domainName,
2355
+ version: domainVersion,
2356
+ chainId,
2357
+ verifyingContract: paymentInfo.token.address
2358
+ }, {
2359
+ TransferWithAuthorization: [
2360
+ { name: 'from', type: 'address' },
2361
+ { name: 'to', type: 'address' },
2362
+ { name: 'value', type: 'uint256' },
2363
+ { name: 'validAfter', type: 'uint256' },
2364
+ { name: 'validBefore', type: 'uint256' },
2365
+ { name: 'nonce', type: 'bytes32' }
2366
+ ]
2367
+ }, {
2368
+ from: this.provider.address,
2369
+ to: paymentInfo.payTo,
2370
+ value,
2371
+ validAfter,
2372
+ validBefore,
2373
+ nonce: nonceHex
2374
+ });
2375
+ }
2376
+ catch (err) {
2377
+ this.releaseUsdcReservation(reservation);
2378
+ throw err;
2379
+ }
2158
2380
  // Return x402 PaymentPayload v2 format (including resource + extensions for Bazaar discovery)
2159
- return {
2160
- x402Version: 2,
2161
- ...(resource ? { resource } : {}),
2162
- ...(extensions ? { extensions } : {}),
2163
- accepted,
2164
- payload: {
2165
- signature,
2166
- authorization: {
2167
- from: this.provider.address,
2168
- to: paymentInfo.payTo,
2169
- value: value.toString(),
2170
- validAfter: validAfter.toString(),
2171
- validBefore: validBefore.toString(),
2172
- nonce: nonceHex,
2381
+ return { reservation, auth: {
2382
+ x402Version: 2,
2383
+ ...(resource ? { resource } : {}),
2384
+ ...(extensions ? { extensions } : {}),
2385
+ accepted,
2386
+ payload: {
2387
+ signature,
2388
+ authorization: {
2389
+ from: this.provider.address,
2390
+ to: paymentInfo.payTo,
2391
+ value: value.toString(),
2392
+ validAfter: validAfter.toString(),
2393
+ validBefore: validBefore.toString(),
2394
+ nonce: nonceHex,
2395
+ },
2173
2396
  },
2174
- },
2175
- };
2397
+ } };
2176
2398
  }
2177
2399
  }
2178
2400
  exports.OneShot = OneShot;