@bondedhq/sdk 0.1.4 → 0.1.5

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 CHANGED
@@ -182,6 +182,11 @@ The six rules: pay only a vendor's account on file or one a verified ticket cove
182
182
  limits (`over_payment_cap`, `over_daily_cap`; days are UTC). `describePaymentMandate(mandate)` says
183
183
  them in plain words, and `PAYMENT_RULE_WORDS` names each rule.
184
184
 
185
+ Payments with no invoices: set `invoiceMatching: false` on the mandate, and optionally
186
+ `maxPaymentsPerHour`. A payment then needs a listed vendor and to stay within the limits, and
187
+ `invoiceReference` is optional. `GATE_RULE_WORDS` adds the hourly rule's words. The hosted gate
188
+ reads the same setting from your account, so your code doesn't change.
189
+
185
190
  - **Enforce** blocks: a payment that breaks a rule throws `PaymentBlockedError` naming every rule
186
191
  broken in plain words, and your pay function is never called. **Monitor** calls it anyway and
187
192
  reports the breach to `onBreach`. A bank change no ticket covers that Monitor lets through is
@@ -211,10 +216,89 @@ const lcTools = [...readOnlyTools, guardLangChainPayTool(payInvoiceTool, { guard
211
216
  server.registerTool("pay_invoice", config, guardMcpPayHandler(payInvoiceHandler, { guard }));
212
217
  ```
213
218
 
219
+ The OpenAI Agents SDK works the same way, and the model gets `Error: ...` text back:
220
+
221
+ ```ts
222
+ import { Agent } from "@openai/agents";
223
+ import { guardOpenAiAgentsPayTool } from "@bondedhq/sdk/openai-agents";
224
+
225
+ const agent = new Agent({
226
+ name: "AP clerk",
227
+ tools: [...readOnlyTools, guardOpenAiAgentsPayTool(payInvoiceTool, { guard })],
228
+ });
229
+ ```
230
+
231
+ If the tool needs a person's approval (`needsApproval`), the run stops for that first. The guard
232
+ still checks the payment once it is approved.
233
+
214
234
  What it doesn't do: it is a local check, so code that calls your pay function directly skips it.
215
235
  Keep the unwrapped function out of the agent's reach. It makes no receipts: nothing outside your
216
236
  process records or signs its decisions, so `onBreach` and `onStateChange` are your only log.
217
237
 
238
+ ### Budgets
239
+
240
+ For many small payments to one payee, such as an API that charges $0.001 a call, ask for a budget
241
+ and check each payment against it. The budget is checked once against the rules; each payment is
242
+ checked against the budget with no rule check and no network call, in exact micro-dollars
243
+ (millionths of a dollar), so $0.001 counts as $0.001. Budgets need `invoiceMatching: false`.
244
+
245
+ ```ts
246
+ const budget = await guard.budget({ account: "0x1111111111111111111111111111111111111111", amountUsd: 1 });
247
+
248
+ await budget.spend(1_000); // $0.001: throws PaymentBlockedError in Enforce mode if it doesn't fit
249
+ await budget.close(); // the unspent part stops counting toward the day
250
+ ```
251
+
252
+ - **The check:** the payee must be a vendor's account (`unknown_payee`), the whole budget must fit
253
+ in what's left of the day (`over_daily_cap`), and with `maxPaymentsPerHour` there must be room
254
+ for a payment (`over_hourly_limit`). Enforce throws `PaymentBlockedError`; Monitor opens it and
255
+ reports the breach.
256
+ - **The terms** (`budget.terms`): the payee, the budget, the most one payment may be
257
+ (`maxPaymentUsd`, default the mandate's per-payment limit), how many payments it covers (what's
258
+ left of the hour, with an hourly limit) and its end (`ttlSeconds`, an hour at most).
259
+ - **Each payment:** `spend(amountMicros)` checks it against the terms. One that doesn't fit breaks
260
+ `over_budget`: Enforce throws and doesn't count it; Monitor counts it and reports it. Spend
261
+ before you pay. `release(id)` takes back one the payee turned down.
262
+ - **The day:** the whole budget counts toward the daily limit until `close()`; then what was spent,
263
+ rounded up to the cent. A budget past its end is closed the next time one is opened.
264
+ - **The hosted gate:** `gate.budget(...)` is the same, with a signed receipt whose input commits to
265
+ the terms; the SDK checks the receipt, recomputes the input's hash and reruns its rules. Payments
266
+ go to the gate in the background and on `close()`, whose receipt commits to every one. Unclosed
267
+ budgets close themselves soon after they end.
268
+
269
+ ### x402
270
+
271
+ For an agent that pays for APIs with [x402](https://x402.org): check each payment before the x402
272
+ client signs it. Turn invoice matching off, and put each service's `payTo` address on the vendor
273
+ list, so the agent pays only services you chose:
274
+
275
+ ```ts
276
+ import { wrapFetchWithPayment, x402Client } from "@x402/fetch";
277
+ import { ExactEvmScheme } from "@x402/evm/exact/client";
278
+ import { x402Guard } from "@bondedhq/sdk/x402";
279
+
280
+ const guard = paymentGate({ apiKey: process.env.BONDED_GATE_KEY! }); // or a local paymentGuard
281
+ const x402 = x402Guard({ guard, budget: { amountUsd: 1 } });
282
+ const client = x402.register(new x402Client().register("eip155:*", new ExactEvmScheme(signer)));
283
+ const paidFetch = wrapFetchWithPayment(fetch, client);
284
+
285
+ // ...and when the agent stops:
286
+ await x402.close();
287
+ ```
288
+
289
+ - The first payment to a service opens a budget for it (`budget.amountUsd`, default $1, raised to
290
+ fit a bigger price). Each payment then checks against that budget, exact to the micro-dollar.
291
+ When it's used up or ends, the next payment closes it and opens another.
292
+ - A refused payment is never signed: the request fails with the reason. Monitor mode signs it and
293
+ reports the breach.
294
+ - A payment the server turns down (it answers 402 again, or settlement fails) is taken back. One
295
+ that settles is reported with its transaction hash.
296
+ - Prices count only in USDC on the networks x402 lists it for (Base, Ethereum, Arbitrum, Polygon,
297
+ Avalanche and Solana, plus Base Sepolia and Solana devnet). A price in any other token is
298
+ refused; add your own dollar stablecoins with `assets`.
299
+ - `guardX402Client(client, { guard })` registers a guard and returns the client, if you never close
300
+ budgets yourself. `x402Price(requirements)` gives the payee and exact price it reads.
301
+
218
302
  ## Hosted payment gate (off chain)
219
303
 
220
304
  The same rules as the guard, run by Bonded instead of inside your process. Your vendors, approved
@@ -0,0 +1,97 @@
1
+ import { type BudgetTerms } from "@bondedhq/shared";
2
+ import type { PaymentBreach } from "./payments.js";
3
+ /**
4
+ * A budget: one check for many small payments to one payee (an x402 API, say). Ask for one with
5
+ * `guard.budget(...)` on the local `PaymentGuard` or the hosted `PaymentGate`; then check each
6
+ * payment with `spend` before making it. `spend` needs no network: it checks the payment against
7
+ * the budget's terms (the payee, the per-payment limit, how many payments it covers, its end, and
8
+ * what's left) in exact micro-dollars, so a $0.001 call counts as $0.001.
9
+ *
10
+ * In Enforce mode a payment that breaks the terms throws `PaymentBlockedError` and isn't counted;
11
+ * in Monitor mode it is counted and reported to `onBreach`. A hosted budget sends its payments to
12
+ * the gate in the background and on `close()`; closing it gets a receipt that commits to every one.
13
+ */
14
+ export interface PaymentBudget {
15
+ /** The budget's id: the hosted gate's decision id, or a local one. */
16
+ readonly id: string;
17
+ /** What it allows: payee, budget, per-payment limit, payment count and end. */
18
+ readonly terms: Readonly<BudgetTerms>;
19
+ readonly mode: "monitor" | "enforce";
20
+ /** Micro-dollars counted so far, released payments left out. */
21
+ readonly spentMicros: number;
22
+ /** Payments counted so far. */
23
+ readonly payments: number;
24
+ /** Closed, or past its end. */
25
+ readonly closed: boolean;
26
+ /** What a payment of this size would break, in plain words; undefined when it fits. */
27
+ problem(amountMicros: number): string | undefined;
28
+ /** Check a payment against the terms and count it. See the interface's description. */
29
+ spend(amountMicros: number, options?: {
30
+ id?: string;
31
+ reference?: string;
32
+ }): Promise<BudgetSpend>;
33
+ /**
34
+ * Take back a payment that never went out (the payee turned it down). Only before it was sent to
35
+ * the gate; returns whether it was taken back.
36
+ */
37
+ release(spendId: string): boolean;
38
+ /** Attach what the payment settled as (an x402 transaction hash) before it is sent. */
39
+ settle(spendId: string, reference: string): void;
40
+ /** Send payments to the gate now (hosted budgets; nothing to do locally). */
41
+ flush(): Promise<void>;
42
+ /** Send what's left and close: the unspent part stops counting toward the day's limit. */
43
+ close(): Promise<void>;
44
+ }
45
+ /** A payment counted against a budget. */
46
+ export interface BudgetSpend {
47
+ id: string;
48
+ amountMicros: number;
49
+ reference: string;
50
+ /** Unix milliseconds. */
51
+ atMs: number;
52
+ }
53
+ /** What `guard.budget(...)` takes. */
54
+ export interface BudgetRequest {
55
+ /** The one account it pays: an x402 service's `payTo` address, say. */
56
+ account: string;
57
+ /** The budget, in US dollars with at most two decimals. */
58
+ amountUsd: number;
59
+ /** The most one payment may be, up to six decimals. Default: the mandate's per-payment limit. */
60
+ maxPaymentUsd?: number;
61
+ /** How long it lasts, 60 to 3,600 seconds (the default). */
62
+ ttlSeconds?: number;
63
+ /** Hosted gate: the same id again gives back the first budget instead of a second one. */
64
+ requestId?: string;
65
+ }
66
+ /** The bookkeeping both kinds of budget share: the terms check and the running totals. */
67
+ export declare abstract class BudgetBase implements PaymentBudget {
68
+ #private;
69
+ readonly id: string;
70
+ readonly terms: Readonly<BudgetTerms>;
71
+ readonly mode: "monitor" | "enforce";
72
+ protected readonly now: () => number;
73
+ protected readonly onBreach?: ((breach: PaymentBreach) => void | Promise<void>) | undefined;
74
+ protected readonly counted: Map<string, BudgetSpend>;
75
+ constructor(id: string, terms: Readonly<BudgetTerms>, mode: "monitor" | "enforce", now: () => number, onBreach?: ((breach: PaymentBreach) => void | Promise<void>) | undefined);
76
+ get spentMicros(): number;
77
+ get payments(): number;
78
+ get closed(): boolean;
79
+ protected markClosed(): void;
80
+ problem(amountMicros: number): string | undefined;
81
+ spend(amountMicros: number, options?: {
82
+ id?: string;
83
+ reference?: string;
84
+ }): Promise<BudgetSpend>;
85
+ release(spendId: string): boolean;
86
+ settle(spendId: string, reference: string): void;
87
+ /** The spend was counted. */
88
+ protected abstract recorded(spend: BudgetSpend): Promise<void>;
89
+ /** Whether a counted spend can still be taken back. */
90
+ protected abstract releasable(spend: BudgetSpend): boolean;
91
+ /** The spend was taken back. */
92
+ protected abstract released(spend: BudgetSpend): void;
93
+ abstract flush(): Promise<void>;
94
+ abstract close(): Promise<void>;
95
+ private breachOf;
96
+ }
97
+ //# sourceMappingURL=budget.d.ts.map
package/dist/budget.js ADDED
@@ -0,0 +1,98 @@
1
+ import { budgetSpendProblem, GATE_RULE_WORDS, MICROS_PER_USD, utcDay, } from "@bondedhq/shared";
2
+ import { BondedError, PaymentBlockedError } from "./errors.js";
3
+ /** Spends are refused this long before the budget's end, so they reach the gate inside it. */
4
+ const END_MARGIN_MS = 5_000;
5
+ /** The bookkeeping both kinds of budget share: the terms check and the running totals. */
6
+ export class BudgetBase {
7
+ id;
8
+ terms;
9
+ mode;
10
+ now;
11
+ onBreach;
12
+ #spentMicros = 0;
13
+ #payments = 0;
14
+ #closed = false;
15
+ counted = new Map();
16
+ constructor(id, terms, mode, now, onBreach) {
17
+ this.id = id;
18
+ this.terms = terms;
19
+ this.mode = mode;
20
+ this.now = now;
21
+ this.onBreach = onBreach;
22
+ }
23
+ get spentMicros() {
24
+ return this.#spentMicros;
25
+ }
26
+ get payments() {
27
+ return this.#payments;
28
+ }
29
+ get closed() {
30
+ return this.#closed || this.now() >= this.terms.expiresAtMs - END_MARGIN_MS;
31
+ }
32
+ markClosed() {
33
+ this.#closed = true;
34
+ }
35
+ problem(amountMicros) {
36
+ if (this.#closed)
37
+ return "the budget is closed";
38
+ const atMs = this.now();
39
+ if (atMs >= this.terms.expiresAtMs - END_MARGIN_MS)
40
+ return "it was paid after the budget expired";
41
+ return budgetSpendProblem(this.terms, { spentMicros: this.#spentMicros, payments: this.#payments }, { amountMicros, atMs });
42
+ }
43
+ async spend(amountMicros, options = {}) {
44
+ if (!Number.isSafeInteger(amountMicros) || amountMicros <= 0)
45
+ throw new BondedError("INVALID_PAYMENT", "amountMicros must be a positive whole number");
46
+ const problem = this.problem(amountMicros);
47
+ const spend = {
48
+ id: options.id ?? crypto.randomUUID(),
49
+ amountMicros,
50
+ reference: options.reference ?? "",
51
+ atMs: this.now(),
52
+ };
53
+ if (problem) {
54
+ const breach = this.breachOf(spend, problem);
55
+ await this.onBreach?.(breach);
56
+ if (breach.blocked)
57
+ throw new PaymentBlockedError(breach);
58
+ }
59
+ this.#spentMicros += amountMicros;
60
+ this.#payments += 1;
61
+ this.counted.set(spend.id, spend);
62
+ await this.recorded(spend);
63
+ return spend;
64
+ }
65
+ release(spendId) {
66
+ const spend = this.counted.get(spendId);
67
+ if (!spend || !this.releasable(spend))
68
+ return false;
69
+ this.counted.delete(spendId);
70
+ this.#spentMicros -= spend.amountMicros;
71
+ this.#payments -= 1;
72
+ this.released(spend);
73
+ return true;
74
+ }
75
+ settle(spendId, reference) {
76
+ const spend = this.counted.get(spendId);
77
+ if (spend)
78
+ spend.reference = reference.slice(0, 200);
79
+ }
80
+ breachOf(spend, problem) {
81
+ const rules = ["over_budget"];
82
+ return {
83
+ kind: "budget",
84
+ rules,
85
+ reasons: [`${GATE_RULE_WORDS.over_budget}: ${problem}`],
86
+ blocked: this.mode === "enforce",
87
+ account: this.terms.account,
88
+ reference: spend.reference,
89
+ vendorId: null,
90
+ amountUsd: spend.amountMicros / MICROS_PER_USD,
91
+ lossUsd: 0,
92
+ overpaidUsd: 0,
93
+ day: utcDay(new Date(spend.atMs)),
94
+ decisionId: this.id,
95
+ };
96
+ }
97
+ }
98
+ //# sourceMappingURL=budget.js.map
package/dist/gate.d.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { type GateInput, type GateLogProof, type PaymentAttempt, type PaymentRule, type SignedGateReceipt } from "@bondedhq/shared";
1
+ import { type GateInput, type GateLogProof, type GateRule, type PaymentAttempt, type SignedGateReceipt } from "@bondedhq/shared";
2
2
  import { type Address, type Hex } from "viem";
3
3
  import type { PaymentBreach } from "./payments.js";
4
+ import { type BudgetRequest, type PaymentBudget } from "./budget.js";
4
5
  /** Bonded's hosted API. */
5
6
  export declare const BONDED_API_URL = "https://api-production-ba50.up.railway.app";
6
7
  /** A payment for the gate. Pass the same `requestId` to retry one: see `PaymentGate.pay`. */
@@ -23,16 +24,19 @@ export type GateCheck = (GatePayment & {
23
24
  /** A decision, as the gate's log keeps it. */
24
25
  export interface GateDecision {
25
26
  id: string;
26
- kind: "invoice" | "refund" | "bank_change" | "card";
27
+ kind: "invoice" | "refund" | "bank_change" | "card" | "payment" | "budget" | "budget_close";
27
28
  decision: "pass" | "breach" | "frozen";
28
29
  result: "allowed" | "blocked" | "refused";
29
30
  allowed: boolean;
30
31
  message: string;
31
32
  mode: "monitor" | "enforce";
32
33
  /** Every rule broken, most serious first. */
33
- rules: PaymentRule[];
34
+ rules: GateRule[];
34
35
  /** Each rule in plain words, in the same order. */
35
- reasons: string[];
36
+ reasons: {
37
+ rule: GateRule;
38
+ words: string;
39
+ }[];
36
40
  amountUsd: number;
37
41
  lossUsd: number;
38
42
  overpaidUsd: number;
@@ -120,6 +124,21 @@ export declare class PaymentGate {
120
124
  pay<R>(attempt: GatePayment, pay: () => R | Promise<R>, kind?: "invoice" | "refund"): Promise<R>;
121
125
  /** The same for a vendor bank change: `update` runs only if the gate allows the change. */
122
126
  changeVendorAccount<R>(request: GateVendorChange, update: () => R | Promise<R>): Promise<R>;
127
+ /**
128
+ * Opens a budget: one check for many small payments to one payee (see `PaymentBudget`). The gate
129
+ * checks it against the general rules (the account needs invoice matching off), reserves the
130
+ * whole budget against the day's limit and signs a receipt whose input commits to the terms;
131
+ * this checks the receipt, then recomputes the input's hash and reruns its rules. Each `spend`
132
+ * is checked here with no network call; payments go to the gate in the background (every
133
+ * `flushMs`, 5 seconds by default, once `holdMs` has passed so a turned-down payment can be taken
134
+ * back) and on `close()`, whose receipt commits to every one. Throws `PaymentBlockedError`
135
+ * (Enforce), `PaymentFrozenError` or `PaymentGateError` without opening one.
136
+ */
137
+ budget(request: BudgetRequest, options?: {
138
+ flushMs?: number;
139
+ holdMs?: number;
140
+ onError?: (error: unknown) => void;
141
+ }): Promise<PaymentBudget>;
123
142
  /** The gate account's decisions, newest first. Pass `next` back as `before` for older ones. */
124
143
  decisions(query?: {
125
144
  limit?: number;
package/dist/gate.js CHANGED
@@ -1,6 +1,7 @@
1
- import { normalAccount, verifyGateReceipt, } from "@bondedhq/shared";
1
+ import { budgetCloseCents, evaluateGateInput, gateInputHash, normalAccount, verifyGateReceipt, } from "@bondedhq/shared";
2
2
  import { isAddress } from "viem";
3
3
  import { BondedError, PaymentBlockedError, PaymentFrozenError, PaymentGateError, } from "./errors.js";
4
+ import { BudgetBase } from "./budget.js";
4
5
  /** Bonded's hosted API. */
5
6
  export const BONDED_API_URL = "https://api-production-ba50.up.railway.app";
6
7
  /** One retry of a check the gate may not have answered: its request id makes that safe. */
@@ -42,7 +43,7 @@ export class PaymentGate {
42
43
  kind: check.kind ?? "invoice",
43
44
  account: check.account,
44
45
  amountUsd: check.amountUsd,
45
- invoiceReference: check.invoiceReference,
46
+ ...(check.invoiceReference?.trim() ? { invoiceReference: check.invoiceReference } : {}),
46
47
  requestId,
47
48
  };
48
49
  let last;
@@ -81,6 +82,66 @@ export class PaymentGate {
81
82
  await this.#settle(await this.check({ ...request, kind: "bank_change" }));
82
83
  return update();
83
84
  }
85
+ /**
86
+ * Opens a budget: one check for many small payments to one payee (see `PaymentBudget`). The gate
87
+ * checks it against the general rules (the account needs invoice matching off), reserves the
88
+ * whole budget against the day's limit and signs a receipt whose input commits to the terms;
89
+ * this checks the receipt, then recomputes the input's hash and reruns its rules. Each `spend`
90
+ * is checked here with no network call; payments go to the gate in the background (every
91
+ * `flushMs`, 5 seconds by default, once `holdMs` has passed so a turned-down payment can be taken
92
+ * back) and on `close()`, whose receipt commits to every one. Throws `PaymentBlockedError`
93
+ * (Enforce), `PaymentFrozenError` or `PaymentGateError` without opening one.
94
+ */
95
+ async budget(request, options = {}) {
96
+ const body = {
97
+ account: request.account,
98
+ amountUsd: request.amountUsd,
99
+ ...(request.maxPaymentUsd === undefined ? {} : { maxPaymentUsd: request.maxPaymentUsd }),
100
+ ...(request.ttlSeconds === undefined ? {} : { ttlSeconds: request.ttlSeconds }),
101
+ requestId: request.requestId ?? crypto.randomUUID(),
102
+ };
103
+ let answer;
104
+ let last;
105
+ for (let attempt = 0; attempt < TRIES && !answer; attempt++) {
106
+ try {
107
+ answer = await this.#request("/payments/budgets", {
108
+ method: "POST",
109
+ body: JSON.stringify(body),
110
+ });
111
+ }
112
+ catch (error) {
113
+ last = error;
114
+ const unanswered = error instanceof PaymentGateError && (!error.status || error.status >= 500);
115
+ if (!unanswered)
116
+ throw error;
117
+ }
118
+ }
119
+ if (!answer)
120
+ throw last;
121
+ const terms = await this.#verifyBudget(answer, body);
122
+ await this.#settle(answer);
123
+ return new HostedBudget(answer.id, terms, answer.mode, Date.now, this.#options.onBreach, {
124
+ flushMs: options.flushMs ?? 5_000,
125
+ holdMs: options.holdMs ?? 10_000,
126
+ onError: options.onError ?? (() => undefined),
127
+ send: (spends) => this.#request(`/payments/budgets/${encodeURIComponent(answer.id)}/spends`, {
128
+ method: "POST",
129
+ body: JSON.stringify({
130
+ spends: spends.map((s) => ({
131
+ id: s.id,
132
+ amountMicros: s.amountMicros,
133
+ reference: s.reference,
134
+ at: new Date(s.atMs).toISOString(),
135
+ })),
136
+ }),
137
+ }),
138
+ close: async () => {
139
+ const closed = await this.#request(`/payments/budgets/${encodeURIComponent(answer.id)}/close`, { method: "POST" });
140
+ await this.#verifyClose(closed, answer.id);
141
+ return closed;
142
+ },
143
+ });
144
+ }
84
145
  /** The gate account's decisions, newest first. Pass `next` back as `before` for older ones. */
85
146
  decisions(query = {}) {
86
147
  const params = new URLSearchParams();
@@ -127,12 +188,84 @@ export class PaymentGate {
127
188
  r.payee === normalAccount(asked.account) &&
128
189
  (asked.kind === "bank_change"
129
190
  ? r.kind === "bank_change"
130
- : r.kind === (asked.kind ?? "invoice") &&
131
- r.amountCents === Math.round(asked.amountUsd * 100) &&
132
- r.reference === asked.invoiceReference.trim());
191
+ : // An account with invoice matching off answers an invoice check as kind "payment".
192
+ (r.kind === (asked.kind ?? "invoice") ||
193
+ (r.kind === "payment" && (asked.kind ?? "invoice") === "invoice")) &&
194
+ r.amountCents === Math.round(asked.amountUsd * 100) &&
195
+ r.reference === (asked.invoiceReference ?? "").trim());
133
196
  if (!matches)
134
197
  throw wrong("its receipt is for a different decision or payment");
135
198
  }
199
+ /** The budget answer's receipt and input check out; returns the terms it grants. */
200
+ async #verifyBudget(answer, asked) {
201
+ const wrong = (what) => new PaymentGateError(`the gate's budget can't be trusted: ${what}`, undefined, {
202
+ receipt: true,
203
+ });
204
+ const signed = answer.receipt;
205
+ if (!signed?.receipt)
206
+ throw wrong("it has no receipt");
207
+ if (this.#options.verifyReceipts !== false) {
208
+ const signer = await this.#receiptSigner();
209
+ if (!(await verifyGateReceipt(signed, signer).catch(() => false)))
210
+ throw wrong("its receipt isn't signed by Bonded's receipt key");
211
+ }
212
+ const r = signed.receipt;
213
+ const input = answer.input;
214
+ if (r.kind !== "budget" ||
215
+ r.decisionId !== answer.id ||
216
+ r.decision !== answer.decision ||
217
+ r.result !== answer.result ||
218
+ answer.allowed !== (r.result === "allowed") ||
219
+ r.payee !== normalAccount(asked.account) ||
220
+ r.amountCents !== Math.round(asked.amountUsd * 100))
221
+ throw wrong("its receipt is for a different budget");
222
+ if (!input || input.kind !== "budget" || gateInputHash(input) !== r.inputHash)
223
+ throw wrong("its input doesn't match the receipt");
224
+ if (r.decision !== "frozen") {
225
+ const verdict = evaluateGateInput(input);
226
+ if (!verdict.ok || verdict.rules.join() !== r.rules.join())
227
+ throw wrong("its rules don't follow from its input");
228
+ }
229
+ const t = input.terms;
230
+ const ttlMs = (asked.ttlSeconds ?? 3600) * 1000;
231
+ const maxAsked = asked.maxPaymentUsd === undefined ? Infinity : Math.round(asked.maxPaymentUsd * 1e6);
232
+ if (t.account !== r.payee ||
233
+ t.budgetMicros !== r.amountCents * 10_000 ||
234
+ t.expiresAtMs - t.startsAtMs !== ttlMs ||
235
+ t.maxPaymentMicros > maxAsked)
236
+ throw wrong("its terms aren't the ones asked for");
237
+ return t;
238
+ }
239
+ /** A budget close's receipt checks out and commits to its input. */
240
+ async #verifyClose(answer, budgetId) {
241
+ if (this.#options.verifyReceipts === false)
242
+ return;
243
+ const wrong = (what) => new PaymentGateError(`the gate's budget close can't be trusted: ${what}`, undefined, {
244
+ receipt: true,
245
+ });
246
+ const signed = answer.receipt;
247
+ if (!signed?.receipt)
248
+ throw wrong("it has no receipt");
249
+ const signer = await this.#receiptSigner();
250
+ if (!(await verifyGateReceipt(signed, signer).catch(() => false)))
251
+ throw wrong("its receipt isn't signed by Bonded's receipt key");
252
+ const r = signed.receipt;
253
+ if (r.kind !== "budget_close" || r.reference !== budgetId || r.decisionId !== answer.id)
254
+ throw wrong("its receipt is for a different budget");
255
+ const input = answer.input;
256
+ // A close given back again has no input to check beyond its signed receipt.
257
+ if (!input)
258
+ return;
259
+ if (input.kind !== "budget_close" ||
260
+ input.budgetId !== budgetId ||
261
+ gateInputHash(input) !== r.inputHash)
262
+ throw wrong("its input doesn't match the receipt");
263
+ const verdict = evaluateGateInput(input);
264
+ if (!verdict.ok ||
265
+ verdict.rules.join() !== r.rules.join() ||
266
+ r.amountCents !== budgetCloseCents(input))
267
+ throw wrong("its rules or amount don't follow from its input");
268
+ }
136
269
  #receiptSigner() {
137
270
  if (this.#options.receiptKey)
138
271
  return Promise.resolve(this.#options.receiptKey);
@@ -180,15 +313,94 @@ export class PaymentGate {
180
313
  export function paymentGate(options) {
181
314
  return new PaymentGate(options);
182
315
  }
316
+ /**
317
+ * A budget the hosted gate opened. Spends wait `holdMs` (so one the payee turned down can be taken
318
+ * back), then go to the gate every `flushMs`; `close()` sends the rest and closes it.
319
+ */
320
+ class HostedBudget extends BudgetBase {
321
+ link;
322
+ #pending = [];
323
+ /** Spends on their way to the gate: too late to take back. */
324
+ #inFlight = new Set();
325
+ #sending;
326
+ #timer;
327
+ #closing;
328
+ constructor(id, terms, mode, now, onBreach, link) {
329
+ super(id, terms, mode, now, onBreach);
330
+ this.link = link;
331
+ }
332
+ /** The close decision, once closed. */
333
+ closeDecision;
334
+ recorded(spend) {
335
+ this.#pending.push(spend);
336
+ if (!this.#timer) {
337
+ this.#timer = setInterval(() => {
338
+ this.#send(false).catch(this.link.onError);
339
+ }, this.link.flushMs);
340
+ this.#timer.unref?.();
341
+ }
342
+ return Promise.resolve();
343
+ }
344
+ releasable(spend) {
345
+ return this.#pending.includes(spend) && !this.#inFlight.has(spend);
346
+ }
347
+ released(spend) {
348
+ this.#pending = this.#pending.filter((s) => s !== spend);
349
+ }
350
+ flush() {
351
+ return this.#send(true);
352
+ }
353
+ close() {
354
+ this.#closing ??= (async () => {
355
+ this.markClosed();
356
+ if (this.#timer)
357
+ clearInterval(this.#timer);
358
+ while (this.#pending.length > 0 || this.#sending)
359
+ await this.#send(true);
360
+ this.closeDecision = await this.link.close();
361
+ })().catch((error) => {
362
+ // Nothing is lost: what wasn't sent is still pending, so closing again tries again.
363
+ this.#closing = undefined;
364
+ throw error;
365
+ });
366
+ return this.#closing;
367
+ }
368
+ /** Sends what's due (everything when `all`), 500 at a time, one send at a time. */
369
+ async #send(all) {
370
+ while (this.#sending)
371
+ await this.#sending;
372
+ const cutoff = this.now() - this.link.holdMs;
373
+ const due = this.#pending.filter((s) => all || s.atMs <= cutoff).slice(0, 500);
374
+ if (due.length === 0)
375
+ return;
376
+ for (const s of due)
377
+ this.#inFlight.add(s);
378
+ this.#sending = (async () => {
379
+ const answer = await this.link.send(due);
380
+ this.#pending = this.#pending.filter((s) => !due.includes(s));
381
+ if (answer.frozen)
382
+ this.markClosed();
383
+ })().finally(() => {
384
+ for (const s of due)
385
+ this.#inFlight.delete(s);
386
+ this.#sending = undefined;
387
+ });
388
+ await this.#sending;
389
+ if (all && this.#pending.length > 0)
390
+ await this.#send(true);
391
+ }
392
+ }
183
393
  function breachOf(answer) {
184
394
  return {
185
395
  kind: answer.kind === "bank_change"
186
396
  ? "vendor_change"
187
397
  : answer.kind === "refund"
188
398
  ? "refund"
189
- : "invoice",
399
+ : answer.kind === "budget"
400
+ ? "budget"
401
+ : "invoice",
190
402
  rules: answer.rules,
191
- reasons: answer.reasons,
403
+ reasons: answer.reasons.map((r) => (typeof r === "string" ? r : r.words)),
192
404
  blocked: !answer.allowed,
193
405
  account: answer.payee,
194
406
  reference: answer.reference,
package/dist/index.d.ts CHANGED
@@ -7,8 +7,10 @@
7
7
  * `@bondedhq/sdk/mcp`, and the Agent URL handler in `@bondedhq/sdk/agent-url`.
8
8
  *
9
9
  * Off chain, `paymentGuard` and `guardPayments` check an agent's payments against the payment
10
- * mandate inside your own process (no network, no receipts); the framework subpaths wrap a pay
11
- * tool with it.
10
+ * mandate inside your own process (no network, no receipts), and `paymentGate` asks Bonded's
11
+ * hosted gate instead. The framework subpaths wrap a pay tool with either, `@bondedhq/sdk/openai-agents`
12
+ * does the same for the OpenAI Agents SDK, and `@bondedhq/sdk/x402` checks x402 payments before
13
+ * the client signs them.
12
14
  */
13
15
  export { BondedVault, type ActionResult, type BondedVaultConfig, type VaultStatus, } from "./vault.js";
14
16
  export { demoRouter, describeAction, type ApproveParams, type CallParams, type SwapParams, type SwapRouter, type TransferParams, type VaultAction, type VaultCall, } from "./actions.js";
@@ -20,7 +22,7 @@ export { BlockedByMandateError, BondedError, CallRevertedError, NotAgentError, P
20
22
  export type { BreachType } from "@bondedhq/shared";
21
23
  export { BONDED_API_URL, PaymentGate, paymentGate, type GateAnswer, type GateCheck, type GateDecision, type GatePayment, type GateReceiptProof, type GateVendorChange, type PaymentGateOptions, } from "./gate.js";
22
24
  export { guardPayments, guardVendorChanges, isPaymentRefusal, PaymentGuard, paymentGuard, type GuardPaymentsOptions, type GuardVendorChangesOptions, type PaymentBreach, type PaymentGuardOptions, type PaymentGuardState, type PaymentMandate, type PaymentSource, type PaymentVendor, type PaymentVerdict, } from "./payments.js";
23
- export { describePaymentMandate, PAYMENT_RULE_WORDS, PAYMENT_RULES, type PaidPayment, type PayableInvoice, type PayableRefund, type PaymentAttempt, type PaymentRule, type VerifiedChange, } from "@bondedhq/shared";
25
+ export { describePaymentMandate, GATE_RULE_WORDS, GATE_RULES, PAYMENT_RULE_WORDS, PAYMENT_RULES, type GateRule, type PaidPayment, type PayableInvoice, type PayableRefund, type PaymentAttempt, type PaymentRule, type VerifiedChange, } from "@bondedhq/shared";
24
26
  export { BONDED_VAULT_TOOLS, bondedVaultFromEnv, bondedVaultTools, type BondedVaultTool, type BondedVaultToolName, type BondedVaultToolsOptions, } from "./vault-tools.js";
25
27
  export { bondedEpisodeEnv, episodeVault, fromBondedEnv, isBondedEpisode, type BondedEpisode, } from "./episode.js";
26
28
  export { checkUnprotectedAllowances, deploymentTokens, unprotectedAllowancesWarning, type AllowanceCheck, type AllowanceFinding, type TokenRef, installMandateModule, type InstallMandateModuleInput, type ModuleAccountType, type ModuleInstallPlan, } from "./module.js";
package/dist/index.js CHANGED
@@ -7,8 +7,10 @@
7
7
  * `@bondedhq/sdk/mcp`, and the Agent URL handler in `@bondedhq/sdk/agent-url`.
8
8
  *
9
9
  * Off chain, `paymentGuard` and `guardPayments` check an agent's payments against the payment
10
- * mandate inside your own process (no network, no receipts); the framework subpaths wrap a pay
11
- * tool with it.
10
+ * mandate inside your own process (no network, no receipts), and `paymentGate` asks Bonded's
11
+ * hosted gate instead. The framework subpaths wrap a pay tool with either, `@bondedhq/sdk/openai-agents`
12
+ * does the same for the OpenAI Agents SDK, and `@bondedhq/sdk/x402` checks x402 payments before
13
+ * the client signs them.
12
14
  */
13
15
  export { BondedVault, } from "./vault.js";
14
16
  export { demoRouter, describeAction, } from "./actions.js";
@@ -19,7 +21,7 @@ export { buildMandate, describeMandate, } from "./mandate.js";
19
21
  export { BlockedByMandateError, BondedError, CallRevertedError, NotAgentError, PaymentBlockedError, PaymentFrozenError, PaymentGateError, PredictedBreachError, SentinelError, SentinelRefusedError, VaultFrozenError, } from "./errors.js";
20
22
  export { BONDED_API_URL, PaymentGate, paymentGate, } from "./gate.js";
21
23
  export { guardPayments, guardVendorChanges, isPaymentRefusal, PaymentGuard, paymentGuard, } from "./payments.js";
22
- export { describePaymentMandate, PAYMENT_RULE_WORDS, PAYMENT_RULES, } from "@bondedhq/shared";
24
+ export { describePaymentMandate, GATE_RULE_WORDS, GATE_RULES, PAYMENT_RULE_WORDS, PAYMENT_RULES, } from "@bondedhq/shared";
23
25
  export { BONDED_VAULT_TOOLS, bondedVaultFromEnv, bondedVaultTools, } from "./vault-tools.js";
24
26
  export { bondedEpisodeEnv, episodeVault, fromBondedEnv, isBondedEpisode, } from "./episode.js";
25
27
  export { checkUnprotectedAllowances, deploymentTokens, unprotectedAllowancesWarning, installMandateModule, } from "./module.js";