@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/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,104 @@ 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
|
+
|
|
302
|
+
The guard runs in the agent's process, so code that holds the wallet key can still sign without
|
|
303
|
+
it. To stop that, keep the key out of the agent: run [`@bondedhq/signer`](../signer/README.md) on
|
|
304
|
+
your own server, with the key in your KMS, and give the agent `remoteX402Signer` in place of a
|
|
305
|
+
wallet. The signer runs the same budgets and signs only payments that fit:
|
|
306
|
+
|
|
307
|
+
```ts
|
|
308
|
+
import { remoteX402Signer } from "@bondedhq/sdk/x402";
|
|
309
|
+
|
|
310
|
+
const signer = await remoteX402Signer({ url: "http://bonded-signer:8402", token });
|
|
311
|
+
const client = new x402Client().register("eip155:*", new ExactEvmScheme(signer));
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
A payment the signer won't sign fails with a `BondedError` (`SIGNER_REFUSED`, `SIGNER_UNAVAILABLE`,
|
|
315
|
+
`SIGNER_UNAUTHORIZED` or `INVALID_PAYMENT`); `isPaymentRefusal` recognizes each.
|
|
316
|
+
|
|
218
317
|
## Hosted payment gate (off chain)
|
|
219
318
|
|
|
220
319
|
The same rules as the guard, run by Bonded instead of inside your process. Your vendors, approved
|
package/dist/budget.d.ts
ADDED
|
@@ -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
|
|
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:
|
|
34
|
+
rules: GateRule[];
|
|
34
35
|
/** Each rule in plain words, in the same order. */
|
|
35
|
-
reasons:
|
|
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
|
-
:
|
|
131
|
-
r.
|
|
132
|
-
|
|
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
|
-
: "
|
|
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)
|
|
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, 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";
|