@bondedhq/sdk 0.1.4 → 0.1.6

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
@@ -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";
@@ -0,0 +1,33 @@
1
+ import { type GuardPaymentsOptions } from "./payments.js";
2
+ export type { GuardPaymentsOptions } from "./payments.js";
3
+ /**
4
+ * The part of an OpenAI Agents SDK function tool (`tool()` from `@openai/agents`) the guard needs.
5
+ * The SDK calls `invoke` with the run context and the model's arguments as a JSON string.
6
+ */
7
+ export interface OpenAiAgentsFunctionTool {
8
+ type: "function";
9
+ name: string;
10
+ invoke: (runContext: never, input: string, details?: never) => Promise<unknown>;
11
+ }
12
+ /**
13
+ * Your own OpenAI Agents SDK pay tool, with every call checked against the payment mandate first
14
+ * (see `PaymentGuard`, or pass the hosted `PaymentGate` as `guard`). Same name, description,
15
+ * parameters, approval setting and guardrails. When the guard refuses (Enforce mode blocked it,
16
+ * the guard is frozen, the gate couldn't be reached, or it can't read the payment) your tool is not
17
+ * called and the model gets `Error: ...` naming the rules broken, so it can react. In Monitor mode
18
+ * a breaking payment goes through and the guard's `onBreach` reports it.
19
+ *
20
+ * ```ts
21
+ * import { Agent, tool } from "@openai/agents";
22
+ * import { guardOpenAiAgentsPayTool } from "@bondedhq/sdk/openai-agents";
23
+ *
24
+ * const guard = paymentGuard({ mandate, vendors, invoices, onBreach: alert });
25
+ * const agent = new Agent({ name: "AP clerk", tools: [...readOnlyTools, guardOpenAiAgentsPayTool(payInvoice, { guard })] });
26
+ * ```
27
+ *
28
+ * The payment is read from the tool's arguments: `{ account, amountUsd, invoiceReference }` unless
29
+ * you pass `toPayment`. If the tool needs a person's approval, the run stops for it before the
30
+ * guard is asked, so an approved payment can still be blocked.
31
+ */
32
+ export declare function guardOpenAiAgentsPayTool<T extends OpenAiAgentsFunctionTool>(payTool: T, options: GuardPaymentsOptions<Record<string, unknown>>): T;
33
+ //# sourceMappingURL=openai-agents.d.ts.map
@@ -0,0 +1,52 @@
1
+ import { BondedError } from "./errors.js";
2
+ import { guardPayments, isPaymentRefusal } from "./payments.js";
3
+ /**
4
+ * Your own OpenAI Agents SDK pay tool, with every call checked against the payment mandate first
5
+ * (see `PaymentGuard`, or pass the hosted `PaymentGate` as `guard`). Same name, description,
6
+ * parameters, approval setting and guardrails. When the guard refuses (Enforce mode blocked it,
7
+ * the guard is frozen, the gate couldn't be reached, or it can't read the payment) your tool is not
8
+ * called and the model gets `Error: ...` naming the rules broken, so it can react. In Monitor mode
9
+ * a breaking payment goes through and the guard's `onBreach` reports it.
10
+ *
11
+ * ```ts
12
+ * import { Agent, tool } from "@openai/agents";
13
+ * import { guardOpenAiAgentsPayTool } from "@bondedhq/sdk/openai-agents";
14
+ *
15
+ * const guard = paymentGuard({ mandate, vendors, invoices, onBreach: alert });
16
+ * const agent = new Agent({ name: "AP clerk", tools: [...readOnlyTools, guardOpenAiAgentsPayTool(payInvoice, { guard })] });
17
+ * ```
18
+ *
19
+ * The payment is read from the tool's arguments: `{ account, amountUsd, invoiceReference }` unless
20
+ * you pass `toPayment`. If the tool needs a person's approval, the run stops for it before the
21
+ * guard is asked, so an approved payment can still be blocked.
22
+ */
23
+ export function guardOpenAiAgentsPayTool(payTool, options) {
24
+ if (payTool?.type !== "function" || typeof payTool.invoke !== "function")
25
+ throw new BondedError("CONFIG", "guardOpenAiAgentsPayTool needs a function tool");
26
+ const invoke = payTool.invoke.bind(payTool);
27
+ const guarded = guardPayments((_args, runContext, input, details) => invoke(runContext, input, details), options);
28
+ return {
29
+ ...payTool,
30
+ invoke: (async (runContext, input, details) => {
31
+ let args;
32
+ try {
33
+ args = JSON.parse(input || "{}");
34
+ }
35
+ catch {
36
+ return "Error: the payment's arguments are not valid JSON; no payment was made";
37
+ }
38
+ // A custom toPayment reads fields: null or an array would throw before the guard answers.
39
+ if (!args || typeof args !== "object" || Array.isArray(args))
40
+ return "Error: the payment's arguments must be an object; no payment was made";
41
+ try {
42
+ return await guarded(args, runContext, input, details);
43
+ }
44
+ catch (error) {
45
+ if (isPaymentRefusal(error))
46
+ return `Error: ${error.message}`;
47
+ throw error;
48
+ }
49
+ }),
50
+ };
51
+ }
52
+ //# sourceMappingURL=openai-agents.js.map
@@ -1,4 +1,5 @@
1
- import { type PaidPayment, type PayableInvoice, type PayableRefund, type PaymentAttempt, type PaymentLimits, type PaymentRule, type SignedGateReceipt, type VerifiedChange } from "@bondedhq/shared";
1
+ import { type BudgetTerms, type GateRule, type PaidPayment, type PayableInvoice, type PayableRefund, type PaymentAttempt, type PaymentLimits, type SignedGateReceipt, type VerifiedChange } from "@bondedhq/shared";
2
+ import { type BudgetRequest, type PaymentBudget } from "./budget.js";
2
3
  import { BondedError, PaymentBlockedError, PaymentFrozenError, PaymentGateError } from "./errors.js";
3
4
  /** The off-chain payment mandate an agent's payments are checked against. */
4
5
  export interface PaymentMandate extends PaymentLimits {
@@ -6,6 +7,13 @@ export interface PaymentMandate extends PaymentLimits {
6
7
  mode: "monitor" | "enforce";
7
8
  /** After the first breach, refuse every payment until a person unfreezes the guard. */
8
9
  freezeOnBreach: boolean;
10
+ /**
11
+ * False when your payments have no invoices: a payment needs a vendor on the list and to stay
12
+ * within the limits, and its reference is optional. Refunds still need an approved refund.
13
+ */
14
+ invoiceMatching?: boolean;
15
+ /** Payments allowed in any hour, with invoice matching off. No limit unless set. */
16
+ maxPaymentsPerHour?: number;
9
17
  }
10
18
  /** A supplier as your vendor master holds it. Its account is one the agent may pay. */
11
19
  export interface PaymentVendor {
@@ -28,18 +36,32 @@ export interface PaymentGuardState {
28
36
  * `unknown_payee`.
29
37
  */
30
38
  unverifiedChanges: VerifiedChange[];
39
+ /**
40
+ * Open budgets (`budget()`): their terms and what was counted against them. Each one's whole
41
+ * amount is in `payments` until it closes. Absent in state saved before budgets existed.
42
+ */
43
+ budgets?: LocalBudgetState[];
44
+ /** When budget payments were made in the last hour (Unix milliseconds), for the hourly limit. */
45
+ budgetSpends?: number[];
46
+ }
47
+ /** An open local budget, as the guard's state keeps it. */
48
+ export interface LocalBudgetState {
49
+ id: string;
50
+ terms: BudgetTerms;
51
+ spentMicros: number;
52
+ payments: number;
31
53
  }
32
54
  /** One payment (or bank change) that broke the mandate, as the guard reports it. */
33
55
  export interface PaymentBreach {
34
- kind: "invoice" | "refund" | "vendor_change";
56
+ kind: "invoice" | "refund" | "vendor_change" | "budget";
35
57
  /** Every rule broken, most serious first. */
36
- rules: PaymentRule[];
37
- /** Each rule in plain words (`PAYMENT_RULE_WORDS`), in the same order. */
58
+ rules: GateRule[];
59
+ /** Each rule in plain words (`GATE_RULE_WORDS`), in the same order. */
38
60
  reasons: string[];
39
61
  /** Enforce mode stopped it: the pay function was not called. */
40
62
  blocked: boolean;
41
63
  account: string;
42
- /** The invoice or refund reference; empty for a bank change. */
64
+ /** The invoice or refund reference; empty for a bank change or a budget. */
43
65
  reference: string;
44
66
  /** The vendor whose verified account this is (or, for a bank change, the vendor changed). */
45
67
  vendorId: string | null;
@@ -58,7 +80,7 @@ export interface PaymentBreach {
58
80
  /** The rule verdict for a payment, without making it. */
59
81
  export interface PaymentVerdict {
60
82
  /** Every rule the payment breaks, most serious first. Empty when it is allowed. */
61
- rules: PaymentRule[];
83
+ rules: GateRule[];
62
84
  reasons: string[];
63
85
  amountUsd: number;
64
86
  reference: string;
@@ -134,6 +156,17 @@ export declare class PaymentGuard {
134
156
  vendorId: string;
135
157
  account: string;
136
158
  }, update: () => R | Promise<R>): Promise<R>;
159
+ /**
160
+ * Opens a budget: one check for many small payments to one payee (see `PaymentBudget`). Needs
161
+ * `invoiceMatching: false`. The payee must be a vendor's account (`unknown_payee`), the whole
162
+ * budget must fit in what's left of the day's limit (`over_daily_cap`), and with
163
+ * `maxPaymentsPerHour` there must be room for a payment (`over_hourly_limit`); the budget then
164
+ * covers what's left of the hour. Enforce mode throws `PaymentBlockedError`; Monitor mode opens it
165
+ * and reports the breach. The whole budget counts toward the day until `close()`, when what was
166
+ * spent (rounded up to the cent) counts instead. A budget past its end is closed the next time
167
+ * one is opened.
168
+ */
169
+ budget(request: BudgetRequest): Promise<PaymentBudget>;
137
170
  }
138
171
  /** A payment guard: see `PaymentGuard`. */
139
172
  export declare function paymentGuard(options: PaymentGuardOptions): PaymentGuard;
@@ -188,7 +221,7 @@ export interface GuardVendorChangesOptions<I> {
188
221
  export declare function guardVendorChanges<I, Rest extends unknown[], R>(update: (input: I, ...rest: Rest) => R | Promise<R>, options: GuardVendorChangesOptions<I>): (input: I, ...rest: Rest) => Promise<R>;
189
222
  /**
190
223
  * The payment guard or gate refused (blocked, frozen, arguments it can't check, or the gate gave no
191
- * answer to act on): no money moved.
224
+ * answer to act on), or a remote x402 signer didn't sign: no money moved.
192
225
  */
193
226
  export declare function isPaymentRefusal(error: unknown): error is PaymentBlockedError | PaymentFrozenError | PaymentGateError | BondedError;
194
227
  //# sourceMappingURL=payments.d.ts.map
package/dist/payments.js CHANGED
@@ -1,11 +1,17 @@
1
- import { checkInvoicePayment, checkRefundPayment, isVerifiedChange, normalAccount, PAYMENT_RULE_WORDS, utcDay, } from "@bondedhq/shared";
1
+ import { checkBudget, checkGeneralPayment, checkInvoicePayment, checkRefundPayment, GATE_RULE_WORDS, isVerifiedChange, normalAccount, utcDay, usdToMicros, } from "@bondedhq/shared";
2
+ import { X402_SIGNER_ERROR_CODES } from "./x402-remote.js";
3
+ import { BudgetBase } from "./budget.js";
2
4
  import { BondedError, PaymentBlockedError, PaymentFrozenError, PaymentGateError, } from "./errors.js";
3
5
  const read = async (source) => typeof source === "function" ? await source() : (source ?? []);
4
6
  const copy = (state) => ({
5
7
  payments: state.payments.map((p) => ({ ...p })),
6
8
  frozen: state.frozen,
7
9
  unverifiedChanges: state.unverifiedChanges.map((c) => ({ ...c })),
10
+ budgets: (state.budgets ?? []).map((b) => ({ ...b, terms: { ...b.terms } })),
11
+ budgetSpends: [...(state.budgetSpends ?? [])],
8
12
  });
13
+ /** The reference a local budget's reservation is recorded under. */
14
+ const budgetReference = (id) => `budget:${id}`;
9
15
  /**
10
16
  * The payment mandate, checked inside your own process: the same rules (`@bondedhq/shared`'s
11
17
  * payment mandate) the Arena rates agents against, with no network and no model. You give it the
@@ -73,7 +79,8 @@ export class PaymentGuard {
73
79
  return this.#exclusive(async () => {
74
80
  if (this.#state.frozen)
75
81
  throw new PaymentFrozenError();
76
- const day = utcDay(this.#now());
82
+ const now = this.#now();
83
+ const day = utcDay(now);
77
84
  const check = await this.#check(attempt, kind, day);
78
85
  if (check.rules.length > 0) {
79
86
  await this.#breach({
@@ -98,6 +105,7 @@ export class PaymentGuard {
98
105
  amountUsd: check.amountUsd,
99
106
  invoiceReference: check.reference,
100
107
  day,
108
+ ...(this.mandate.invoiceMatching === false ? { at: now.toISOString() } : {}),
101
109
  ...(kind === "refund" ? { kind } : {}),
102
110
  });
103
111
  await this.#save();
@@ -142,6 +150,141 @@ export class PaymentGuard {
142
150
  return update();
143
151
  });
144
152
  }
153
+ /**
154
+ * Opens a budget: one check for many small payments to one payee (see `PaymentBudget`). Needs
155
+ * `invoiceMatching: false`. The payee must be a vendor's account (`unknown_payee`), the whole
156
+ * budget must fit in what's left of the day's limit (`over_daily_cap`), and with
157
+ * `maxPaymentsPerHour` there must be room for a payment (`over_hourly_limit`); the budget then
158
+ * covers what's left of the hour. Enforce mode throws `PaymentBlockedError`; Monitor mode opens it
159
+ * and reports the breach. The whole budget counts toward the day until `close()`, when what was
160
+ * spent (rounded up to the cent) counts instead. A budget past its end is closed the next time
161
+ * one is opened.
162
+ */
163
+ budget(request) {
164
+ return this.#exclusive(async () => {
165
+ if (this.#state.frozen)
166
+ throw new PaymentFrozenError();
167
+ if (this.mandate.invoiceMatching !== false)
168
+ throw new BondedError("CONFIG", "budgets need invoiceMatching: false on the mandate");
169
+ const now = this.#now();
170
+ const day = utcDay(now);
171
+ if (this.#settleExpired(now.getTime()))
172
+ await this.#save();
173
+ const ttlSeconds = request.ttlSeconds ?? 3600;
174
+ if (!Number.isInteger(ttlSeconds) || ttlSeconds < 60 || ttlSeconds > 3600)
175
+ throw new BondedError("INVALID_PAYMENT", "ttlSeconds must be a whole number from 60 to 3,600");
176
+ const maxAll = usdToMicros(this.mandate.maxPaymentUsd) ?? 0;
177
+ const asked = request.maxPaymentUsd === undefined ? maxAll : usdToMicros(request.maxPaymentUsd);
178
+ if (asked === undefined || asked <= 0)
179
+ throw new BondedError("INVALID_PAYMENT", "maxPaymentUsd must be positive, with at most six decimals");
180
+ const check = checkBudget({
181
+ limits: {
182
+ ...this.mandate,
183
+ maxPaymentsPerHour: this.mandate.maxPaymentsPerHour ?? null,
184
+ },
185
+ payees: await this.#payees(),
186
+ payments: this.#state.payments,
187
+ paymentsLastHour: this.#paymentsLastHour(),
188
+ reservedPayments: this.#reservedPayments(),
189
+ day,
190
+ }, { account: request.account, budgetUsd: request.amountUsd });
191
+ if (!check.ok)
192
+ throw new BondedError("INVALID_PAYMENT", check.error);
193
+ const id = `local-${crypto.randomUUID()}`;
194
+ if (check.rules.length > 0)
195
+ await this.#breach({
196
+ kind: "budget",
197
+ rules: check.rules,
198
+ reasons: words(check.rules),
199
+ blocked: this.mandate.mode === "enforce",
200
+ account: request.account,
201
+ reference: "",
202
+ vendorId: check.vendorId,
203
+ amountUsd: request.amountUsd,
204
+ lossUsd: 0,
205
+ overpaidUsd: 0,
206
+ day,
207
+ });
208
+ const terms = {
209
+ account: normalAccount(request.account),
210
+ budgetMicros: Math.round(request.amountUsd * 100) * 10_000,
211
+ maxPaymentMicros: Math.min(asked, maxAll),
212
+ maxPayments: check.maxPayments,
213
+ startsAtMs: now.getTime(),
214
+ expiresAtMs: now.getTime() + ttlSeconds * 1000,
215
+ };
216
+ // The reservation: no time, so it doesn't count as a payment for the hourly limit.
217
+ this.#state.payments.push({
218
+ account: request.account,
219
+ vendorId: check.vendorId,
220
+ amountUsd: Math.round(request.amountUsd * 100) / 100,
221
+ invoiceReference: budgetReference(id),
222
+ day,
223
+ });
224
+ const kept = { id, terms, spentMicros: 0, payments: 0 };
225
+ this.#state.budgets = [...(this.#state.budgets ?? []), kept];
226
+ await this.#save();
227
+ return new LocalBudget(id, terms, this.mandate.mode, () => this.#now().getTime(), {
228
+ frozen: () => this.#state.frozen,
229
+ report: (breach) => this.#report(breach),
230
+ recorded: (spend) => this.#exclusive(async () => {
231
+ const b = this.#state.budgets?.find((x) => x.id === id);
232
+ if (b) {
233
+ b.spentMicros += spend.amountMicros;
234
+ b.payments += 1;
235
+ }
236
+ this.#state.budgetSpends = [...(this.#state.budgetSpends ?? []), spend.atMs];
237
+ await this.#save();
238
+ }),
239
+ released: (spend) => void this.#exclusive(async () => {
240
+ const b = this.#state.budgets?.find((x) => x.id === id);
241
+ if (b) {
242
+ b.spentMicros -= spend.amountMicros;
243
+ b.payments -= 1;
244
+ }
245
+ const spends = this.#state.budgetSpends ?? [];
246
+ const at = spends.lastIndexOf(spend.atMs);
247
+ if (at >= 0)
248
+ spends.splice(at, 1);
249
+ await this.#save();
250
+ }),
251
+ close: () => this.#exclusive(async () => {
252
+ if (this.#closeBudget(id))
253
+ await this.#save();
254
+ }),
255
+ });
256
+ });
257
+ }
258
+ /** Payments open budgets may still make, for the hourly limit. A budget past its end makes none. */
259
+ #reservedPayments() {
260
+ const now = this.#now().getTime();
261
+ return (this.#state.budgets ?? []).reduce((sum, b) => b.terms.maxPayments === null || b.terms.expiresAtMs <= now
262
+ ? sum
263
+ : sum + Math.max(0, b.terms.maxPayments - b.payments), 0);
264
+ }
265
+ /** Closes budgets past their end. True when any closed. */
266
+ #settleExpired(nowMs) {
267
+ const expired = (this.#state.budgets ?? []).filter((b) => b.terms.expiresAtMs <= nowMs);
268
+ for (const b of expired)
269
+ this.#closeBudget(b.id);
270
+ return expired.length > 0;
271
+ }
272
+ /** The budget's reservation becomes what it spent, rounded up to the cent. */
273
+ #closeBudget(id) {
274
+ const budget = this.#state.budgets?.find((b) => b.id === id);
275
+ if (!budget)
276
+ return false;
277
+ this.#state.budgets = this.#state.budgets.filter((b) => b.id !== id);
278
+ const at = this.#state.payments.findIndex((p) => p.invoiceReference === budgetReference(id));
279
+ if (at >= 0) {
280
+ const cents = Math.ceil(budget.spentMicros / 10_000);
281
+ if (cents > 0)
282
+ this.#state.payments[at] = { ...this.#state.payments[at], amountUsd: cents / 100 };
283
+ else
284
+ this.#state.payments.splice(at, 1);
285
+ }
286
+ return true;
287
+ }
145
288
  /** Runs the shared rule check against the builder's data and the guard's payments. */
146
289
  async #check(attempt, kind, day) {
147
290
  const payees = await this.#payees();
@@ -149,11 +292,30 @@ export class PaymentGuard {
149
292
  const payments = this.#state.payments;
150
293
  const check = kind === "refund"
151
294
  ? checkRefundPayment({ limits, payees, refunds: await read(this.#options.refunds), payments, day }, attempt)
152
- : checkInvoicePayment({ limits, payees, invoices: await read(this.#options.invoices), payments, day }, attempt);
295
+ : this.mandate.invoiceMatching === false
296
+ ? checkGeneralPayment({
297
+ limits: { ...limits, maxPaymentsPerHour: limits.maxPaymentsPerHour ?? null },
298
+ payees,
299
+ payments,
300
+ // Payments open budgets may still make count toward the hourly limit too.
301
+ paymentsLastHour: this.#paymentsLastHour() + this.#reservedPayments(),
302
+ day,
303
+ }, attempt)
304
+ : checkInvoicePayment({ limits, payees, invoices: await read(this.#options.invoices), payments, day }, attempt);
153
305
  if (!check.ok)
154
306
  throw new BondedError("INVALID_PAYMENT", check.error);
155
307
  return check;
156
308
  }
309
+ /**
310
+ * Payments in the last hour, budget payments included. One restored from before the guard kept
311
+ * times doesn't count.
312
+ */
313
+ #paymentsLastHour() {
314
+ const since = this.#now().getTime() - 3_600_000;
315
+ this.#state.budgetSpends = (this.#state.budgetSpends ?? []).filter((at) => at >= since);
316
+ return (this.#state.payments.filter((p) => p.at !== undefined && Date.parse(p.at) >= since).length +
317
+ this.#state.budgetSpends.length);
318
+ }
157
319
  /**
158
320
  * Known payees: each vendor's account on file, unless it got there through an unverified
159
321
  * change, and every account a verified ticket covers.
@@ -171,6 +333,12 @@ export class PaymentGuard {
171
333
  }
172
334
  /** Reports a breach, freezes if the mandate says so, and throws when Enforce mode blocks it. */
173
335
  async #breach(breach) {
336
+ await this.#report(breach);
337
+ if (breach.blocked)
338
+ throw new PaymentBlockedError(breach);
339
+ }
340
+ /** Freezes if the mandate says so, and tells `onBreach`. */
341
+ async #report(breach) {
174
342
  if (this.mandate.freezeOnBreach) {
175
343
  this.#state.frozen = true;
176
344
  await this.#save();
@@ -180,8 +348,6 @@ export class PaymentGuard {
180
348
  rules: [...breach.rules],
181
349
  reasons: [...breach.reasons],
182
350
  });
183
- if (breach.blocked)
184
- throw new PaymentBlockedError(breach);
185
351
  }
186
352
  async #save() {
187
353
  await this.#options.onStateChange?.(this.state());
@@ -193,7 +359,36 @@ export class PaymentGuard {
193
359
  return next;
194
360
  }
195
361
  }
196
- const words = (rules) => rules.map((r) => PAYMENT_RULE_WORDS[r]);
362
+ const words = (rules) => rules.map((r) => GATE_RULE_WORDS[r]);
363
+ /** A budget the local guard opened: spends are counted in its state, nothing is sent anywhere. */
364
+ class LocalBudget extends BudgetBase {
365
+ ledger;
366
+ constructor(id, terms, mode, now, ledger) {
367
+ super(id, terms, mode, now, (breach) => ledger.report(breach));
368
+ this.ledger = ledger;
369
+ }
370
+ spend(amountMicros, options) {
371
+ if (this.ledger.frozen())
372
+ return Promise.reject(new PaymentFrozenError());
373
+ return super.spend(amountMicros, options);
374
+ }
375
+ recorded(spend) {
376
+ return this.ledger.recorded(spend);
377
+ }
378
+ releasable() {
379
+ return !this.closed;
380
+ }
381
+ released(spend) {
382
+ this.ledger.released(spend);
383
+ }
384
+ flush() {
385
+ return Promise.resolve();
386
+ }
387
+ async close() {
388
+ this.markClosed();
389
+ await this.ledger.close();
390
+ }
391
+ }
197
392
  /** A payment guard: see `PaymentGuard`. */
198
393
  export function paymentGuard(options) {
199
394
  return new PaymentGuard(options);
@@ -228,13 +423,15 @@ export function guardVendorChanges(update, options) {
228
423
  }
229
424
  /**
230
425
  * The payment guard or gate refused (blocked, frozen, arguments it can't check, or the gate gave no
231
- * answer to act on): no money moved.
426
+ * answer to act on), or a remote x402 signer didn't sign: no money moved.
232
427
  */
233
428
  export function isPaymentRefusal(error) {
234
429
  return (error instanceof PaymentBlockedError ||
235
430
  error instanceof PaymentFrozenError ||
236
431
  error instanceof PaymentGateError ||
237
- (error instanceof BondedError && error.code === "INVALID_PAYMENT"));
432
+ (error instanceof BondedError &&
433
+ (error.code === "INVALID_PAYMENT" ||
434
+ X402_SIGNER_ERROR_CODES.includes(error.code))));
238
435
  }
239
436
  const field = (input, name) => {
240
437
  const value = input[name];
@@ -260,7 +457,12 @@ function defaultPayment(input) {
260
457
  return {
261
458
  account: field(args, "account"),
262
459
  amountUsd,
263
- invoiceReference: field(args, "invoiceReference" in args ? "invoiceReference" : "reference"),
460
+ // Left out, it reaches the rules as empty: only invoice matching needs one.
461
+ ...("invoiceReference" in args || "reference" in args
462
+ ? {
463
+ invoiceReference: field(args, "invoiceReference" in args ? "invoiceReference" : "reference"),
464
+ }
465
+ : {}),
264
466
  ...(typeof args.requestId === "string" ? { requestId: args.requestId } : {}),
265
467
  };
266
468
  }
@@ -0,0 +1,77 @@
1
+ import type { Address, Hex } from "viem";
2
+ /**
3
+ * The wire format between an agent and `@bondedhq/signer`, the x402 signer a business runs on its
4
+ * own server. The agent never holds the wallet key: it sends each x402 payment's EIP-712 request
5
+ * to the signer, which checks it against a budget and signs only if it fits.
6
+ */
7
+ export declare const X402_SIGNER_PATHS: {
8
+ /** `GET`: the signer's address and the networks it pays on (`X402SignerInfo`). */
9
+ readonly info: "/v1/x402/signer";
10
+ /** `POST` an `X402SignRequest`; answers an `X402SignResponse`, or an `X402SignerProblem`. */
11
+ readonly sign: "/v1/x402/sign";
12
+ };
13
+ /** One EIP-712 request, as the x402 client builds it. Big numbers travel as decimal strings. */
14
+ export interface X402TypedData {
15
+ domain: Record<string, unknown>;
16
+ types: Record<string, unknown>;
17
+ primaryType: string;
18
+ message: Record<string, unknown>;
19
+ }
20
+ export interface X402SignRequest {
21
+ typedData: X402TypedData;
22
+ }
23
+ export interface X402SignResponse {
24
+ signature: Hex;
25
+ }
26
+ export interface X402SignerInfo {
27
+ address: Address;
28
+ /** CAIP-2 ids, such as `eip155:8453`. */
29
+ networks: string[];
30
+ }
31
+ /**
32
+ * Why the signer didn't sign. `refused`: the payment breaks the budget or the gate couldn't be
33
+ * reached (it fails closed). `invalid`: not an x402 USDC payment from this signer, or malformed.
34
+ * `unauthorized`: a missing or wrong token. `error`: something broke in the signer.
35
+ */
36
+ export interface X402SignerProblem {
37
+ error: "refused" | "invalid" | "unauthorized" | "not_found" | "error";
38
+ reason: string;
39
+ }
40
+ /** The error codes `remoteX402Signer` throws; all of them mean nothing was signed. */
41
+ export declare const X402_SIGNER_ERROR_CODES: readonly ["SIGNER_REFUSED", "SIGNER_UNAUTHORIZED", "SIGNER_UNAVAILABLE"];
42
+ export interface RemoteX402SignerOptions {
43
+ /** Where the signer runs, such as `http://bonded-signer:8402`. */
44
+ url: string;
45
+ /** One of the signer's tokens. It can only ask for x402 payments, never move money itself. */
46
+ token: string;
47
+ /**
48
+ * How long to wait for each request, in milliseconds (default 35,000). The signer may need to
49
+ * open a budget with the gate (up to two tries of 10 seconds) and then wait for its KMS (up to
50
+ * 10 seconds); giving up sooner would make the agent retry a payment the signer still counts.
51
+ */
52
+ timeoutMs?: number;
53
+ fetch?: typeof fetch;
54
+ }
55
+ /** What an x402 client needs from a signer (`ClientEvmSigner` in `@x402/evm`). */
56
+ export interface RemoteX402Signer {
57
+ readonly address: Address;
58
+ readonly networks: readonly string[];
59
+ signTypedData(typedData: {
60
+ domain: Record<string, unknown>;
61
+ types: Record<string, unknown>;
62
+ primaryType: string;
63
+ message: Record<string, unknown>;
64
+ }): Promise<Hex>;
65
+ }
66
+ /**
67
+ * An x402 signer whose key stays on a server the agent can't reach: `@bondedhq/signer`. Pass it to
68
+ * `ExactEvmScheme` in place of a local account. Each payment goes to the signer, which checks it
69
+ * against the gate's budget for its payee and signs only if it fits, so code in the agent's process
70
+ * can't pay without the check.
71
+ *
72
+ * Nothing is signed when the signer refuses, can't be reached or doesn't accept the token: the
73
+ * x402 client's request fails with a `BondedError` (`SIGNER_REFUSED`, `SIGNER_UNAVAILABLE`,
74
+ * `SIGNER_UNAUTHORIZED`, or `INVALID_PAYMENT`), which `isPaymentRefusal` recognizes.
75
+ */
76
+ export declare function remoteX402Signer(options: RemoteX402SignerOptions): Promise<RemoteX402Signer>;
77
+ //# sourceMappingURL=x402-remote.d.ts.map
@@ -0,0 +1,88 @@
1
+ import { BondedError } from "./errors.js";
2
+ /**
3
+ * The wire format between an agent and `@bondedhq/signer`, the x402 signer a business runs on its
4
+ * own server. The agent never holds the wallet key: it sends each x402 payment's EIP-712 request
5
+ * to the signer, which checks it against a budget and signs only if it fits.
6
+ */
7
+ export const X402_SIGNER_PATHS = {
8
+ /** `GET`: the signer's address and the networks it pays on (`X402SignerInfo`). */
9
+ info: "/v1/x402/signer",
10
+ /** `POST` an `X402SignRequest`; answers an `X402SignResponse`, or an `X402SignerProblem`. */
11
+ sign: "/v1/x402/sign",
12
+ };
13
+ /** The error codes `remoteX402Signer` throws; all of them mean nothing was signed. */
14
+ export const X402_SIGNER_ERROR_CODES = [
15
+ "SIGNER_REFUSED",
16
+ "SIGNER_UNAUTHORIZED",
17
+ "SIGNER_UNAVAILABLE",
18
+ ];
19
+ /**
20
+ * An x402 signer whose key stays on a server the agent can't reach: `@bondedhq/signer`. Pass it to
21
+ * `ExactEvmScheme` in place of a local account. Each payment goes to the signer, which checks it
22
+ * against the gate's budget for its payee and signs only if it fits, so code in the agent's process
23
+ * can't pay without the check.
24
+ *
25
+ * Nothing is signed when the signer refuses, can't be reached or doesn't accept the token: the
26
+ * x402 client's request fails with a `BondedError` (`SIGNER_REFUSED`, `SIGNER_UNAVAILABLE`,
27
+ * `SIGNER_UNAUTHORIZED`, or `INVALID_PAYMENT`), which `isPaymentRefusal` recognizes.
28
+ */
29
+ export async function remoteX402Signer(options) {
30
+ const base = options.url.replace(/\/+$/, "");
31
+ const fetchFn = options.fetch ?? fetch;
32
+ const timeoutMs = options.timeoutMs ?? 35_000;
33
+ async function call(path, init = {}) {
34
+ let response;
35
+ try {
36
+ response = await fetchFn(`${base}${path}`, {
37
+ ...init,
38
+ headers: {
39
+ ...(init.body ? { "content-type": "application/json" } : {}),
40
+ authorization: `Bearer ${options.token}`,
41
+ },
42
+ signal: AbortSignal.timeout(timeoutMs),
43
+ });
44
+ }
45
+ catch (error) {
46
+ throw new BondedError("SIGNER_UNAVAILABLE", `the x402 signer can't be reached at ${base}`, {
47
+ cause: error,
48
+ });
49
+ }
50
+ const text = await response.text();
51
+ let body;
52
+ try {
53
+ body = JSON.parse(text);
54
+ }
55
+ catch {
56
+ body = undefined;
57
+ }
58
+ if (response.ok && body && typeof body === "object")
59
+ return body;
60
+ const problem = body;
61
+ const reason = typeof problem?.reason === "string" ? problem.reason : `HTTP ${response.status}`;
62
+ if (response.status === 401 || problem?.error === "unauthorized")
63
+ throw new BondedError("SIGNER_UNAUTHORIZED", `the x402 signer refused the token: ${reason}`);
64
+ if (problem?.error === "refused")
65
+ throw new BondedError("SIGNER_REFUSED", `the x402 signer refused the payment: ${reason}`);
66
+ if (problem?.error === "invalid")
67
+ throw new BondedError("INVALID_PAYMENT", `the x402 signer won't sign this: ${reason}`);
68
+ throw new BondedError("SIGNER_UNAVAILABLE", `the x402 signer failed: ${reason}`);
69
+ }
70
+ const info = await call(X402_SIGNER_PATHS.info);
71
+ if (typeof info.address !== "string" || !/^0x[0-9a-fA-F]{40}$/.test(info.address))
72
+ throw new BondedError("SIGNER_UNAVAILABLE", "the x402 signer sent no valid address");
73
+ return {
74
+ address: info.address,
75
+ networks: Array.isArray(info.networks) ? [...info.networks] : [],
76
+ async signTypedData(typedData) {
77
+ const request = { typedData };
78
+ const { signature } = await call(X402_SIGNER_PATHS.sign, {
79
+ method: "POST",
80
+ body: JSON.stringify(request, (_key, value) => typeof value === "bigint" ? value.toString() : value),
81
+ });
82
+ if (typeof signature !== "string" || !/^0x[0-9a-fA-F]+$/.test(signature))
83
+ throw new BondedError("SIGNER_UNAVAILABLE", "the x402 signer sent no valid signature");
84
+ return signature;
85
+ },
86
+ };
87
+ }
88
+ //# sourceMappingURL=x402-remote.js.map