@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/README.md +99 -0
- package/dist/budget.d.ts +97 -0
- package/dist/budget.js +98 -0
- package/dist/gate.d.ts +23 -4
- package/dist/gate.js +219 -7
- package/dist/index.d.ts +5 -3
- package/dist/index.js +5 -3
- package/dist/openai-agents.d.ts +33 -0
- package/dist/openai-agents.js +52 -0
- package/dist/payments.d.ts +40 -7
- package/dist/payments.js +211 -9
- package/dist/x402-remote.d.ts +77 -0
- package/dist/x402-remote.js +88 -0
- package/dist/x402.d.ts +137 -0
- package/dist/x402.js +183 -0
- package/package.json +19 -5
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)
|
|
11
|
-
* tool with
|
|
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
|
package/dist/payments.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { type PaidPayment, type PayableInvoice, type PayableRefund, type PaymentAttempt, type PaymentLimits, type
|
|
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:
|
|
37
|
-
/** Each rule in plain words (`
|
|
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:
|
|
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,
|
|
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
|
|
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
|
-
:
|
|
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) =>
|
|
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 &&
|
|
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
|
-
|
|
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
|