@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/README.md +59 -0
- package/dist/errors.d.ts +33 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +42 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +78 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +296 -83
- 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 +56 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
1147
|
+
await this.ensureBudgetsSynced();
|
|
1103
1148
|
const quoteResp = await this.makeRequest(path, payload);
|
|
1104
1149
|
if (quoteResp.status !== 402) {
|
|
1105
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1558
|
-
//
|
|
1559
|
-
//
|
|
1560
|
-
//
|
|
1561
|
-
|
|
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
|
|
1772
|
+
result = await Promise.race([wsBranch, httpBranch]);
|
|
1565
1773
|
}
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
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
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
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
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2009
|
+
emit?.(String(job.status));
|
|
1807
2010
|
retries = 0;
|
|
1808
|
-
|
|
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 =
|
|
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
|
|
2188
|
+
chainId,
|
|
1976
2189
|
verifyingContract: paymentInfo.token.address
|
|
1977
2190
|
}, {
|
|
1978
2191
|
TransferWithAuthorization: [
|