@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/README.md +18 -0
- package/dist/index.d.ts +73 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +370 -148
- package/dist/index.js.map +1 -1
- package/dist/swap.d.ts.map +1 -1
- package/dist/swap.js +0 -4
- package/dist/swap.js.map +1 -1
- package/dist/types.d.ts +16 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
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.
|
|
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
|
|
1163
|
-
const fundResp = await this.
|
|
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
|
|
1666
|
-
response = await this.
|
|
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
|
|
1717
|
-
const execResp = await this.
|
|
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
|
-
//
|
|
1722
|
-
//
|
|
1723
|
-
//
|
|
1724
|
-
//
|
|
1725
|
-
|
|
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
|
|
1803
|
+
result = await Promise.race([wsBranch, httpBranch]);
|
|
1729
1804
|
}
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
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
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
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
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2040
|
+
emit?.(String(job.status));
|
|
1971
2041
|
retries = 0;
|
|
1972
|
-
|
|
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 =
|
|
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
|
-
*
|
|
2043
|
-
*
|
|
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.
|
|
2050
|
-
|
|
2051
|
-
|
|
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
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
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,
|
|
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
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
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
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
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;
|