@oneshot-agent/sdk 0.25.0 → 0.27.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,7 +49,7 @@ 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.25.0';
52
+ const SDK_VERSION = '0.27.0';
53
53
  // ============================================================================
54
54
  // Environment Configuration
55
55
  // ============================================================================
@@ -76,6 +76,39 @@ __exportStar(require("./types"), exports);
76
76
  * await agent.email({ to: 'user@example.com', subject: 'Hi', body: 'Hello' });
77
77
  * ```
78
78
  */
79
+ /**
80
+ * Reject a budget config the server would reject, at construction rather than
81
+ * on the first paid call — a typo'd cap must not become "no cap".
82
+ */
83
+ function validateBudgetConfig(budgets) {
84
+ if (!budgets)
85
+ return undefined;
86
+ // A typo'd key (`daliy`) from an untyped caller must not silently mean "no cap".
87
+ for (const key of Object.keys(budgets)) {
88
+ if (!['daily', 'perTransaction', 'alertAt', 'pauseAt'].includes(key)) {
89
+ throw new errors_1.ValidationError(`budgets.${key} is not a recognized field`, `budgets.${key}`);
90
+ }
91
+ }
92
+ const positive = (v, field) => {
93
+ if (v === undefined)
94
+ return;
95
+ if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) {
96
+ throw new errors_1.ValidationError(`budgets.${field} must be a positive number`, `budgets.${field}`);
97
+ }
98
+ };
99
+ const fraction = (v, field) => {
100
+ if (v === undefined)
101
+ return;
102
+ if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0 || v > 1) {
103
+ throw new errors_1.ValidationError(`budgets.${field} must be a fraction in (0, 1]`, `budgets.${field}`);
104
+ }
105
+ };
106
+ positive(budgets.daily, 'daily');
107
+ positive(budgets.perTransaction, 'perTransaction');
108
+ fraction(budgets.alertAt, 'alertAt');
109
+ fraction(budgets.pauseAt, 'pauseAt');
110
+ return budgets;
111
+ }
79
112
  class OneShot {
80
113
  /**
81
114
  * Async factory — required for CDP wallets (account creation is async).
@@ -117,6 +150,8 @@ class OneShot {
117
150
  this.logger = config.logger ?? console.log;
118
151
  this._currency = config.currency ?? 'USDC';
119
152
  this._slippage = config.slippage ?? 0.01;
153
+ this._budgets = validateBudgetConfig(config.budgets);
154
+ this._alertEmail = config.alerts?.email;
120
155
  this.rpcProvider = new ethers_1.ethers.JsonRpcProvider(config.rpcUrl ?? RPC_URL);
121
156
  if (walletProvider) {
122
157
  this.provider = walletProvider;
@@ -153,6 +188,10 @@ class OneShot {
153
188
  get slippage() {
154
189
  return this._slippage;
155
190
  }
191
+ /** The budget config this instance was constructed with, if any. */
192
+ get budgetConfig() {
193
+ return this._budgets;
194
+ }
156
195
  // ---------------------------------------------------------------------------
157
196
  // Public methods
158
197
  // ---------------------------------------------------------------------------
@@ -375,7 +414,7 @@ class OneShot {
375
414
  onQuote: (ctx) => this.log(`Commerce quote: $${ctx.total} for "${ctx.product_title}"`),
376
415
  });
377
416
  if (buyResp.status !== 202) {
378
- throw new errors_1.ToolError('Commerce buy failed', buyResp.status, await buyResp.text());
417
+ await this.failFromResponse('Commerce buy failed', buyResp);
379
418
  }
380
419
  const result = await buyResp.json();
381
420
  this.log(`Order submitted: ${result.request_id}`);
@@ -452,7 +491,7 @@ class OneShot {
452
491
  },
453
492
  });
454
493
  if (callResp.status !== 202) {
455
- throw new errors_1.ToolError('Voice call initiation failed', callResp.status, await callResp.text());
494
+ await this.failFromResponse('Voice call initiation failed', callResp);
456
495
  }
457
496
  const result = await callResp.json();
458
497
  this.log(`Call initiated: ${result.request_id}`);
@@ -515,7 +554,7 @@ class OneShot {
515
554
  },
516
555
  });
517
556
  if (sendResp.status !== 202) {
518
- throw new errors_1.ToolError('SMS send failed', sendResp.status, await sendResp.text());
557
+ await this.failFromResponse('SMS send failed', sendResp);
519
558
  }
520
559
  const result = await sendResp.json();
521
560
  this.log(`SMS queued: ${result.request_id}`);
@@ -584,7 +623,7 @@ class OneShot {
584
623
  },
585
624
  });
586
625
  if (buildResp.status !== 202) {
587
- throw new errors_1.ToolError('Build initiation failed', buildResp.status, await buildResp.text());
626
+ await this.failFromResponse('Build initiation failed', buildResp);
588
627
  }
589
628
  const result = await buildResp.json();
590
629
  this.log(`Build initiated: ${result.request_id}`);
@@ -646,7 +685,7 @@ class OneShot {
646
685
  },
647
686
  });
648
687
  if (execResp.status !== 202) {
649
- throw new errors_1.ToolError('Browser task initiation failed', execResp.status, await execResp.text());
688
+ await this.failFromResponse('Browser task initiation failed', execResp);
650
689
  }
651
690
  const result = await execResp.json();
652
691
  this.log(`Browser task initiated: ${result.request_id}`);
@@ -911,7 +950,7 @@ class OneShot {
911
950
  },
912
951
  });
913
952
  if (createResp.status !== 202) {
914
- throw new errors_1.ToolError('Compute goal creation failed', createResp.status, await createResp.text());
953
+ await this.failFromResponse('Compute goal creation failed', createResp);
915
954
  }
916
955
  return createResp.json();
917
956
  }
@@ -1099,13 +1138,16 @@ class OneShot {
1099
1138
  }
1100
1139
  const path = `/v1/compute/${goalId}/fund`;
1101
1140
  const payload = { amount };
1141
+ await this.ensureBudgetsSynced();
1102
1142
  // First call: get quote (402)
1103
1143
  const quoteResp = await this.makeRequest(path, payload);
1104
1144
  if (quoteResp.status !== 402) {
1105
- throw new errors_1.ToolError('Expected 402 for compute fund quote', quoteResp.status, await quoteResp.text());
1145
+ // A 403 here is the budget gate on the quote leg BudgetExceededError.
1146
+ await this.failFromResponse('Expected 402 for compute fund quote', quoteResp);
1106
1147
  }
1107
1148
  const quoteData = await quoteResp.json();
1108
1149
  this.log(`Compute fund quote: $${quoteData.payment_request.amount} to top up ${goalId}`);
1150
+ this.assertWithinBudget(quoteData.payment_request.amount);
1109
1151
  const paymentInfo = {
1110
1152
  protocol: 'x402',
1111
1153
  network: `eip155:${quoteData.payment_request.chain_id}`,
@@ -1116,10 +1158,11 @@ class OneShot {
1116
1158
  token: { address: quoteData.payment_request.token_address, symbol: 'USDC', decimals: 6 }
1117
1159
  };
1118
1160
  const { accepted, resource, extensions } = await this.getAcceptedRequirements(quoteResp, path, payload, quoteData.context.quote_id);
1161
+ paymentInfo.amount = this.chargeAmount(accepted, quoteData.payment_request.amount);
1119
1162
  const auth = await this.signPaymentAuthorization(paymentInfo, accepted, resource, extensions);
1120
1163
  const fundResp = await this.makeRequest(path, payload, auth, quoteData.context.quote_id);
1121
1164
  if (!fundResp.ok) {
1122
- throw new errors_1.ToolError('Failed to fund compute goal', fundResp.status, await fundResp.text());
1165
+ await this.failFromResponse('Failed to fund compute goal', fundResp);
1123
1166
  }
1124
1167
  const json = await fundResp.json();
1125
1168
  return json.data;
@@ -1347,6 +1390,100 @@ class OneShot {
1347
1390
  }
1348
1391
  return qs.toString();
1349
1392
  }
1393
+ /**
1394
+ * Push `config.budgets` to the server once, before the first paid call.
1395
+ *
1396
+ * Lazy rather than in the constructor because the PUT is signed (async) and
1397
+ * the constructor is sync. Idempotent per instance via the cached promise,
1398
+ * which is only kept once the sync SUCCEEDS.
1399
+ *
1400
+ * FAILS CLOSED. If the server can't confirm the budget — network error,
1401
+ * 5xx, 429, or a rejected config — this throws BudgetSyncError and the paid
1402
+ * call is not made. Proceeding would silently drop the guardrail the
1403
+ * developer configured, which is exactly the "empty wallet at 3am" the
1404
+ * budget exists to prevent. The marker is cleared so the next paid call
1405
+ * retries.
1406
+ */
1407
+ async ensureBudgetsSynced() {
1408
+ if (!this._budgets && !this._alertEmail)
1409
+ return;
1410
+ if (this._budgetSync)
1411
+ return this._budgetSync;
1412
+ const attempt = (async () => {
1413
+ const body = {};
1414
+ if (this._budgets?.daily !== undefined)
1415
+ body.daily = this._budgets.daily;
1416
+ if (this._budgets?.perTransaction !== undefined)
1417
+ body.per_transaction = this._budgets.perTransaction;
1418
+ if (this._budgets?.alertAt !== undefined)
1419
+ body.alert_at = this._budgets.alertAt;
1420
+ if (this._budgets?.pauseAt !== undefined)
1421
+ body.pause_at = this._budgets.pauseAt;
1422
+ if (this._alertEmail !== undefined)
1423
+ body.alert_email = this._alertEmail;
1424
+ let response;
1425
+ try {
1426
+ response = await fetch(`${this.baseUrl}/v1/agents/me/budgets`, {
1427
+ method: 'PUT',
1428
+ headers: { 'Content-Type': 'application/json', ...(await this.signedReadHeaders('write')) },
1429
+ body: JSON.stringify(body),
1430
+ });
1431
+ }
1432
+ catch (err) {
1433
+ throw new errors_1.BudgetSyncError(`Could not sync spend budget (network): ${err}`);
1434
+ }
1435
+ if (!response.ok) {
1436
+ const text = await response.text();
1437
+ throw new errors_1.BudgetSyncError(`Could not sync spend budget (${response.status}): ${text}`, response.status, text);
1438
+ }
1439
+ this.log('Budget synced');
1440
+ })();
1441
+ this._budgetSync = attempt;
1442
+ try {
1443
+ await attempt;
1444
+ }
1445
+ catch (err) {
1446
+ // Not synced: clear the marker so the next paid call retries, then
1447
+ // refuse this one rather than run it unguarded.
1448
+ this._budgetSync = undefined;
1449
+ this.log(`${err.message} — paid call refused until the budget is confirmed`);
1450
+ throw err;
1451
+ }
1452
+ }
1453
+ /**
1454
+ * Local fast-fail on the per-transaction cap, so an oversized call fails
1455
+ * without a network round-trip. The server enforces the same cap (and the
1456
+ * daily one, which needs the ledger) regardless of which client calls —
1457
+ * this is the same SDK-checks/server-enforces split as maxCost.
1458
+ */
1459
+ assertWithinBudget(total) {
1460
+ const cap = this._budgets?.perTransaction;
1461
+ if (!cap || cap <= 0)
1462
+ return;
1463
+ const amount = parseFloat(total);
1464
+ if (Number.isFinite(amount) && amount > cap) {
1465
+ throw new errors_1.BudgetExceededError(`Quote $${total} exceeds this agent's per-transaction budget of $${cap}`, 'per_transaction', cap, undefined, amount);
1466
+ }
1467
+ }
1468
+ /**
1469
+ * Current spend budget and today's utilization.
1470
+ *
1471
+ * @example
1472
+ * ```typescript
1473
+ * const b = await agent.budgets();
1474
+ * console.log(`${b.spent_today_usdc} of ${b.daily_usdc} spent, resets ${b.resets_at}`);
1475
+ * ```
1476
+ */
1477
+ async budgets() {
1478
+ const response = await fetch(`${this.baseUrl}/v1/agents/me/budgets`, {
1479
+ headers: await this.signedReadHeaders(),
1480
+ });
1481
+ if (!response.ok) {
1482
+ throw new errors_1.ToolError('Failed to fetch budgets', response.status, await response.text());
1483
+ }
1484
+ const body = await response.json();
1485
+ return (body.data ?? body);
1486
+ }
1350
1487
  /** Local fast-fail guard: throw when a quote total exceeds the caller's cap. */
1351
1488
  assertWithinMaxCost(total, maxCost) {
1352
1489
  if (maxCost && parseFloat(total) > maxCost) {
@@ -1360,6 +1497,99 @@ class OneShot {
1360
1497
  * a server-side enforcement layer so non-SDK callers (MCP server, custom
1361
1498
  * integrations) can't ignore the cap.
1362
1499
  */
1500
+ /**
1501
+ * The amount to sign, in decimal USDC.
1502
+ *
1503
+ * A 402 advertises its price twice: the x402 v2 `PAYMENT-REQUIRED` header
1504
+ * (`accepts[0].amount`, atomic units) and the legacy JSON body
1505
+ * (`payment_request.amount`, decimal). The header is authoritative — it is
1506
+ * what the server rebuilds its requirement from and what
1507
+ * `findMatchingRequirements` compares the signature against.
1508
+ *
1509
+ * Preferring the body is how quote-based routes (email/send, sms, voice,
1510
+ * build, commerce/buy, compute) silently failed: the body carried a
1511
+ * hardcoded "0.00", so the SDK signed a zero-cost authorization against a
1512
+ * real charge and the server answered with a bodiless 402. It stayed hidden
1513
+ * for as long as credits covered those calls, since the credit path returns
1514
+ * 202 without any payment handshake.
1515
+ *
1516
+ * Falls back to the body when the header is missing or unparseable.
1517
+ */
1518
+ /**
1519
+ * Throw the most specific error a failed response supports.
1520
+ *
1521
+ * A 402 arriving on the PAID retry means the facilitator refused the
1522
+ * signature — not the ordinary "here is your quote" 402. The API names the
1523
+ * cause in that body (`payment_verification_failed` + reason + expected vs
1524
+ * received amount); surface it as a `PaymentError` so callers can branch on
1525
+ * `err.reason` instead of string-matching. Anything else keeps the existing
1526
+ * `ToolError` shape.
1527
+ */
1528
+ async failFromResponse(message, response) {
1529
+ const text = await response.text();
1530
+ const rejection = response.status === 402 ? this.parsePaymentRejection(text) : undefined;
1531
+ if (rejection)
1532
+ throw rejection;
1533
+ const budget = response.status === 403 ? this.parseBudgetRejection(text) : undefined;
1534
+ if (budget)
1535
+ throw budget;
1536
+ throw new errors_1.ToolError(message, response.status, text);
1537
+ }
1538
+ /**
1539
+ * Map a 403 `budget_exceeded` body onto a typed error, so callers can catch
1540
+ * "my own budget stopped this" separately from an auth failure or a payment
1541
+ * rejection. Any other 403 falls through to ToolError.
1542
+ */
1543
+ parseBudgetRejection(text) {
1544
+ try {
1545
+ const body = JSON.parse(text);
1546
+ if (body.error !== 'budget_exceeded')
1547
+ return undefined;
1548
+ const b = body.budget ?? {};
1549
+ 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);
1550
+ }
1551
+ catch {
1552
+ return undefined;
1553
+ }
1554
+ }
1555
+ parsePaymentRejection(text) {
1556
+ let data;
1557
+ try {
1558
+ data = JSON.parse(text);
1559
+ }
1560
+ catch {
1561
+ return undefined;
1562
+ }
1563
+ if (data?.error !== 'payment_verification_failed')
1564
+ return undefined;
1565
+ const reason = data.reason || 'unknown';
1566
+ const expectedAmount = data.expected?.amount;
1567
+ const receivedAmount = data.received?.amount;
1568
+ const detail = [
1569
+ expectedAmount ? `expected $${expectedAmount}` : null,
1570
+ receivedAmount ? `signed $${receivedAmount}` : null,
1571
+ ].filter(Boolean).join(', ');
1572
+ return new errors_1.PaymentError(`payment rejected: ${reason}${detail ? ` — ${detail}` : ''}${data.message ? ` (${data.message})` : ''}`, reason, {
1573
+ amount: expectedAmount,
1574
+ asset: data.expected?.asset,
1575
+ network: data.expected?.network,
1576
+ payTo: data.expected?.pay_to,
1577
+ }, { amount: receivedAmount }, data.quote_id);
1578
+ }
1579
+ chargeAmount(accepted, bodyAmount) {
1580
+ if (accepted?.amount == null || !/^\d+$/.test(String(accepted.amount))) {
1581
+ return bodyAmount ?? '0';
1582
+ }
1583
+ const fromHeader = ethers_1.ethers.formatUnits(accepted.amount, 6);
1584
+ // When the two agree (every fixed-price route), keep the body's string
1585
+ // verbatim — downstream consumers such as the ETH auto-swap pass this
1586
+ // value along, and there is no reason to reformat it. Only a genuine
1587
+ // disagreement flips to the header.
1588
+ if (bodyAmount != null && parseFloat(bodyAmount) === parseFloat(fromHeader)) {
1589
+ return bodyAmount;
1590
+ }
1591
+ return fromHeader;
1592
+ }
1363
1593
  maxCostHeader(maxCost) {
1364
1594
  if (!maxCost || maxCost <= 0)
1365
1595
  return undefined;
@@ -1409,6 +1639,9 @@ class OneShot {
1409
1639
  if (signal?.aborted) {
1410
1640
  throw new errors_1.OneShotError('Operation cancelled');
1411
1641
  }
1642
+ // One-time push of config.budgets before the first paid call, so the
1643
+ // server-side gate knows about them on this very request.
1644
+ await this.ensureBudgetsSynced();
1412
1645
  let response = await this.makeRequest(endpoint, payload, undefined, quoteId, signal, undefined, extraHeaders);
1413
1646
  // Handle 402 Payment Required
1414
1647
  if (response.status === 402) {
@@ -1421,18 +1654,19 @@ class OneShot {
1421
1654
  protocol: 'x402',
1422
1655
  network: accepted.network,
1423
1656
  payTo: accepted.payTo,
1424
- amount: data.payment_request?.amount ?? ethers_1.ethers.formatUnits(accepted.amount, 6),
1657
+ amount: this.chargeAmount(accepted, data.payment_request?.amount),
1425
1658
  currency: 'USD',
1426
1659
  facilitator_url: this.baseUrl,
1427
1660
  token: { address: accepted.asset, symbol: 'USDC', decimals: 6 }
1428
1661
  };
1429
1662
  this.log(`Payment required: ${paymentInfo.amount} USDC`);
1663
+ this.assertWithinBudget(paymentInfo.amount);
1430
1664
  this.checkAbortBeforePayment(signal);
1431
1665
  const auth = await this.signPaymentAuthorization(paymentInfo, accepted, resource, extensions);
1432
1666
  response = await this.makeRequest(endpoint, payload, auth, quoteId, signal, undefined, extraHeaders);
1433
1667
  }
1434
1668
  if (!response.ok) {
1435
- throw new errors_1.ToolError('Tool request failed', response.status, await response.text());
1669
+ await this.failFromResponse('Tool request failed', response);
1436
1670
  }
1437
1671
  const result = await response.json();
1438
1672
  // Handle async jobs
@@ -1453,16 +1687,20 @@ class OneShot {
1453
1687
  * body directly), since that differs per tool.
1454
1688
  */
1455
1689
  async runQuoteToPay(cfg) {
1690
+ await this.ensureBudgetsSynced();
1456
1691
  const quoteResp = await this.makeRequest(cfg.endpoint, cfg.payload, undefined, undefined, cfg.signal, cfg.quoteTimeoutMs, this.maxCostHeader(cfg.maxCost));
1457
1692
  if (quoteResp.status === 400 && cfg.on400) {
1458
1693
  await cfg.on400(quoteResp);
1459
1694
  }
1460
1695
  if (quoteResp.status !== 402) {
1461
- throw new errors_1.ToolError(cfg.expectMsg, quoteResp.status, await quoteResp.text());
1696
+ // Routes the budget gate on the quote leg (fixed-price ones) surface a
1697
+ // 403 here; failFromResponse maps it to BudgetExceededError.
1698
+ await this.failFromResponse(cfg.expectMsg, quoteResp);
1462
1699
  }
1463
1700
  const quoteData = await quoteResp.json();
1464
1701
  cfg.onQuote(quoteData.context);
1465
1702
  this.assertWithinMaxCost(cfg.totalOf(quoteData.context), cfg.maxCost);
1703
+ this.assertWithinBudget(cfg.totalOf(quoteData.context));
1466
1704
  const paymentInfo = {
1467
1705
  protocol: 'x402',
1468
1706
  network: `eip155:${quoteData.payment_request.chain_id}`,
@@ -1474,6 +1712,7 @@ class OneShot {
1474
1712
  };
1475
1713
  this.checkAbortBeforePayment(cfg.signal);
1476
1714
  const { accepted, resource, extensions } = await this.getAcceptedRequirements(quoteResp, cfg.endpoint, cfg.payload, quoteData.context.quote_id, cfg.signal);
1715
+ paymentInfo.amount = this.chargeAmount(accepted, quoteData.payment_request.amount);
1477
1716
  const auth = await this.signPaymentAuthorization(paymentInfo, accepted, resource, extensions);
1478
1717
  const execResp = await this.makeRequest(cfg.endpoint, cfg.payload, auth, quoteData.context.quote_id, cfg.signal, cfg.execTimeoutMs);
1479
1718
  return { context: quoteData.context, execResp };