@bondedhq/sdk 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +203 -5
- package/dist/ai-sdk.d.ts +19 -0
- package/dist/ai-sdk.js +46 -0
- package/dist/budget.d.ts +97 -0
- package/dist/budget.js +98 -0
- package/dist/errors.d.ts +32 -0
- package/dist/errors.js +36 -0
- package/dist/gate.d.ts +158 -0
- package/dist/gate.js +416 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +10 -1
- package/dist/langchain.d.ts +19 -1
- package/dist/langchain.js +34 -0
- package/dist/mcp.d.ts +20 -0
- package/dist/mcp.js +30 -0
- package/dist/openai-agents.d.ts +33 -0
- package/dist/openai-agents.js +52 -0
- package/dist/payments.d.ts +227 -0
- package/dist/payments.js +474 -0
- package/dist/x402.d.ts +136 -0
- package/dist/x402.js +182 -0
- package/package.json +17 -3
package/README.md
CHANGED
|
@@ -15,11 +15,11 @@ ESM only, Node 22 or later. Inside the Bonded repository, depend on it with
|
|
|
15
15
|
|
|
16
16
|
| Entry point | What it is | Peer dependency |
|
|
17
17
|
|---|---|---|
|
|
18
|
-
| `@bondedhq/sdk` | The vault client, dry runs, the guard, Sentinel, mandates, `fromBondedEnv
|
|
19
|
-
| `@bondedhq/sdk/mcp` | An episode's tools over MCP, `/done`, MCP config | |
|
|
18
|
+
| `@bondedhq/sdk` | The vault client, dry runs, the guard, Sentinel, mandates, `fromBondedEnv`, the payment guard | |
|
|
19
|
+
| `@bondedhq/sdk/mcp` | An episode's tools over MCP, `/done`, MCP config; a guarded pay tool handler | |
|
|
20
20
|
| `@bondedhq/sdk/agent-url` | The Agent URL handler | |
|
|
21
|
-
| `@bondedhq/sdk/langchain` | The episode's tools as LangChain `StructuredTool`s; the vault's trading tools | `@langchain/core` 1 |
|
|
22
|
-
| `@bondedhq/sdk/ai-sdk` | The episode's tools as a Vercel AI SDK tool set; the vault's trading tools | `ai` 5 to 7 |
|
|
21
|
+
| `@bondedhq/sdk/langchain` | The episode's tools as LangChain `StructuredTool`s; the vault's trading tools; a guarded pay tool | `@langchain/core` 1 |
|
|
22
|
+
| `@bondedhq/sdk/ai-sdk` | The episode's tools as a Vercel AI SDK tool set; the vault's trading tools; a guarded pay tool | `ai` 5 to 7 |
|
|
23
23
|
| `@bondedhq/sdk/eliza` | The ElizaOS plugin, with a rating mode | `@elizaos/core` 1.7 |
|
|
24
24
|
| `@bondedhq/sdk/agentkit` | AgentKit wallet provider for the vault; the episode's tools as actions | `@coinbase/agentkit` 0.10 |
|
|
25
25
|
| `@bondedhq/sdk/solana-agent-kit` | Solana Agent Kit plugin for the Solana vault | `solana-agent-kit` 2, `@bondedhq/solana` |
|
|
@@ -92,6 +92,9 @@ Every error the SDK throws on purpose is a `BondedError` with a stable `code`:
|
|
|
92
92
|
| `PredictedBreachError` (`PREDICTED_BREACH`) | A guarded vault refused: the dry run predicts a breach (`error.dryRun`) |
|
|
93
93
|
| `SentinelRefusedError` (`SENTINEL_REFUSED`) | A guarded vault refused on Sentinel's advice (`error.verdict`) |
|
|
94
94
|
| `SentinelError` (`SENTINEL_ERROR`) | The Sentinel API refused the request or didn't answer (`error.status`) |
|
|
95
|
+
| `PaymentBlockedError` (`PAYMENT_BLOCKED`) | The payment guard's Enforce mode stopped a payment or bank change (`error.breach`). Your pay function wasn't called |
|
|
96
|
+
| `PaymentFrozenError` (`PAYMENT_FROZEN`) | The payment guard is frozen after a breach; `guard.unfreeze()` lets payments through again |
|
|
97
|
+
| `BondedError` (`INVALID_PAYMENT`) | The payment guard couldn't read the payment (a missing account or reference, an amount that isn't a positive number of cents) |
|
|
95
98
|
|
|
96
99
|
In Monitor mode a breaching action is **not** an error: it runs, and its breaches come back in the result. Check `result.breaches`, or use `guarded`.
|
|
97
100
|
|
|
@@ -144,6 +147,201 @@ const text = describeMandate(mandate, tokens); // for Sentinel checks
|
|
|
144
147
|
|
|
145
148
|
The same builder the web app's onboarding uses, so both hash the same rules the same way. Cover requires `freezeOnBreach`, which is the default.
|
|
146
149
|
|
|
150
|
+
## Payment guard (off chain)
|
|
151
|
+
|
|
152
|
+
For an agent that pays invoices or refunds from a bank or payment API: check every payment
|
|
153
|
+
against the payment mandate first, inside your own process. It runs the same rule engine the
|
|
154
|
+
Arena rates off-chain agents against (`@bondedhq/shared`'s payment mandate), with no network and no
|
|
155
|
+
model, so invoices never leave your system. It is free.
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
import { guardPayments, guardVendorChanges, paymentGuard } from "@bondedhq/sdk";
|
|
159
|
+
|
|
160
|
+
const guard = paymentGuard({
|
|
161
|
+
mandate: { mode: "enforce", maxPaymentUsd: 10_000, maxDailyUsd: 25_000, freezeOnBreach: true },
|
|
162
|
+
vendors: () => db.vendors(), // [{ id, account }]: the vendor master
|
|
163
|
+
invoices: () => db.approvedInvoices(), // [{ reference, vendorId, amountUsd }]
|
|
164
|
+
refunds: () => db.approvedRefunds(), // [{ reference, account, amountUsd }]
|
|
165
|
+
changes: () => db.verifiedBankChanges(), // [{ vendorId, account }]: finance-verified tickets
|
|
166
|
+
state: await store.load(), // what onStateChange saved last time, if anything
|
|
167
|
+
onStateChange: (state) => store.save(state),
|
|
168
|
+
onBreach: (breach) => alert(breach), // { rules, reasons, blocked, account, amountUsd, lossUsd, ... }
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const payInvoice = guardPayments(bank.payInvoice, { guard });
|
|
172
|
+
const issueRefund = guardPayments(bank.refund, { guard, kind: "refund" });
|
|
173
|
+
const updateVendorBank = guardVendorChanges(db.updateVendorBank, { guard });
|
|
174
|
+
|
|
175
|
+
await payInvoice({ account: "DE89 3704 0044 0532 0130 00", amountUsd: 1840.5, invoiceReference: "ACM-2291" });
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
The six rules: pay only a vendor's account on file or one a verified ticket covers
|
|
179
|
+
(`unknown_payee`), change a vendor's bank account only with a verified ticket
|
|
180
|
+
(`unverified_vendor_change`), pay only approved invoices for exactly the approved amount
|
|
181
|
+
(`unmatched_invoice`) and only once (`duplicate_payment`), and stay under the per-payment and daily
|
|
182
|
+
limits (`over_payment_cap`, `over_daily_cap`; days are UTC). `describePaymentMandate(mandate)` says
|
|
183
|
+
them in plain words, and `PAYMENT_RULE_WORDS` names each rule.
|
|
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
|
+
|
|
190
|
+
- **Enforce** blocks: a payment that breaks a rule throws `PaymentBlockedError` naming every rule
|
|
191
|
+
broken in plain words, and your pay function is never called. **Monitor** calls it anyway and
|
|
192
|
+
reports the breach to `onBreach`. A bank change no ticket covers that Monitor lets through is
|
|
193
|
+
remembered: its account is never treated as the vendor's.
|
|
194
|
+
- **Freeze on breach:** after the first breach every payment and bank change is refused
|
|
195
|
+
(`PaymentFrozenError`) until a person calls `guard.unfreeze()`.
|
|
196
|
+
- **State:** the guard keeps the payments made, the frozen flag and unverified bank changes in
|
|
197
|
+
memory. `onStateChange` gets a copy after every change; pass it back as `state` to restore it
|
|
198
|
+
after a restart (or seed `state.payments` with what you paid before). A payment is recorded only
|
|
199
|
+
once your pay function returns, so a failed one can be retried.
|
|
200
|
+
- Payments go through the guard one at a time, so two calls at once can't both pass the duplicate
|
|
201
|
+
or daily check. `guard.check(payment)` gives the verdict without paying.
|
|
202
|
+
- Your function's first argument is read as `{ account, amountUsd, invoiceReference }` (or
|
|
203
|
+
`reference`, as the Arena's `pay_invoice` and `issue_refund` tools take them); pass `toPayment`
|
|
204
|
+
for any other shape.
|
|
205
|
+
|
|
206
|
+
For framework tools, wrap your existing pay tool; a refusal goes back to the model so it can react
|
|
207
|
+
(AI SDK: `{ error }`; LangChain: `Error: ...` text; MCP: an `isError` result):
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
import { guardAiSdkPayTool } from "@bondedhq/sdk/ai-sdk";
|
|
211
|
+
import { guardLangChainPayTool } from "@bondedhq/sdk/langchain";
|
|
212
|
+
import { guardMcpPayHandler } from "@bondedhq/sdk/mcp";
|
|
213
|
+
|
|
214
|
+
const tools = { ...readOnlyTools, pay_invoice: guardAiSdkPayTool(payInvoiceTool, { guard }) };
|
|
215
|
+
const lcTools = [...readOnlyTools, guardLangChainPayTool(payInvoiceTool, { guard })];
|
|
216
|
+
server.registerTool("pay_invoice", config, guardMcpPayHandler(payInvoiceHandler, { guard }));
|
|
217
|
+
```
|
|
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
|
+
|
|
234
|
+
What it doesn't do: it is a local check, so code that calls your pay function directly skips it.
|
|
235
|
+
Keep the unwrapped function out of the agent's reach. It makes no receipts: nothing outside your
|
|
236
|
+
process records or signs its decisions, so `onBreach` and `onStateChange` are your only log.
|
|
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
|
+
## Hosted payment gate (off chain)
|
|
303
|
+
|
|
304
|
+
The same rules as the guard, run by Bonded instead of inside your process. Your vendors, approved
|
|
305
|
+
invoices and limits live in a gate account (set up at
|
|
306
|
+
[bondedhq.xyz/gate](https://www.bondedhq.xyz/gate)), every decision is logged, and each comes back
|
|
307
|
+
with a signed receipt anyone can check at
|
|
308
|
+
[bondedhq.xyz/gate/verify](https://www.bondedhq.xyz/gate/verify).
|
|
309
|
+
|
|
310
|
+
```ts
|
|
311
|
+
import { guardPayments, paymentGate } from "@bondedhq/sdk";
|
|
312
|
+
|
|
313
|
+
const gate = paymentGate({
|
|
314
|
+
apiKey: process.env.BONDED_GATE_KEY!, // bonded_gk_...: keep it server side
|
|
315
|
+
onBreach: (breach) => alert(breach), // { rules, reasons, blocked, decisionId, receipt, ... }
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
const payInvoice = guardPayments(bank.payInvoice, { guard: gate });
|
|
319
|
+
|
|
320
|
+
await payInvoice({ account: "DE89 3704 0044 0532 0130 00", amountUsd: 1840.5, invoiceReference: "ACM-2291" });
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
Pass `gate` wherever a `guard` goes: `guardPayments`, `guardVendorChanges` and the framework
|
|
324
|
+
helpers above. Or call it directly: `gate.pay(payment, send)`, `gate.check(payment)`,
|
|
325
|
+
`gate.decisions()`, `gate.proof(decisionId)`.
|
|
326
|
+
|
|
327
|
+
- **It fails closed.** If the gate can't be reached, your pay function isn't called and
|
|
328
|
+
`PaymentGateError` says why. A check that got no answer is asked once more with the same request
|
|
329
|
+
id, which is safe.
|
|
330
|
+
- **It checks the gate.** Each answer's receipt must carry Bonded's signature and match the payment
|
|
331
|
+
you asked about, or nothing is paid. Pass `receiptKey` to pin the signing address; by default it
|
|
332
|
+
reads the published one once.
|
|
333
|
+
- **Enforce** throws `PaymentBlockedError`, **Monitor** pays and reports to `onBreach`, and a frozen
|
|
334
|
+
account throws `PaymentFrozenError` until the owner lifts the freeze at bondedhq.xyz/gate.
|
|
335
|
+
- **Retries:** the gate counts an allowed payment as made, so if your bank call fails and you try
|
|
336
|
+
again, pass the same `requestId` both times (in the payment, or from `toPayment`). The gate gives
|
|
337
|
+
back the first decision instead of a duplicate breach. A new id is made when you don't pass one.
|
|
338
|
+
- **Bank changes** go through `gate.changeVendorAccount({ vendorId, account, ticket }, update)`.
|
|
339
|
+
`ticket` is a finance ticket the owner added to the account.
|
|
340
|
+
|
|
341
|
+
Like the guard, it only sees payments your code sends it, so keep the unwrapped pay function out of
|
|
342
|
+
the agent's reach. Use the guard to keep invoices inside your system; use the gate for a log and
|
|
343
|
+
receipts someone else can check.
|
|
344
|
+
|
|
147
345
|
## Getting rated by the Arena
|
|
148
346
|
|
|
149
347
|
The Bonded Arena rates an agent by running it through episodes: each one gives the agent a task and
|
|
@@ -479,6 +677,6 @@ episode's tools: see [Framework helpers](#framework-helpers).
|
|
|
479
677
|
pnpm --filter @bondedhq/sdk test
|
|
480
678
|
```
|
|
481
679
|
|
|
482
|
-
Tests run against a local anvil with the protocol deployed by the production scripts, and check allowed and breaching actions against the vault's breach log, for the core client and each framework. The Agent URL handler's tests (signatures, verify, acknowledging then working, cancel, deadline) run without a chain. The vault trading tools are tested on the same local chain (`test/vault-tools.int.test.ts`): a swap by symbol, a breaching transfer refused by the guard, Sentinel fed from the conversation, and a Monitor-mode breach with the guard off. The framework helpers are tested end to end against a real Arena Gateway in `examples/frameworks` (`pnpm --filter @bonded/example-frameworks test`). The Solana tests need the programs built (`pnpm solana:build`) and the Solana CLI's `solana-test-validator`; without them they skip with a message. `pnpm --filter @bondedhq/sdk typecheck` also checks the ElizaOS plugin against `@elizaos/core`'s own types.
|
|
680
|
+
Tests run against a local anvil with the protocol deployed by the production scripts, and check allowed and breaching actions against the vault's breach log, for the core client and each framework. The Agent URL handler's tests (signatures, verify, acknowledging then working, cancel, deadline) run without a chain. The vault trading tools are tested on the same local chain (`test/vault-tools.int.test.ts`): a swap by symbol, a breaching transfer refused by the guard, Sentinel fed from the conversation, and a Monitor-mode breach with the guard off. The payment guard runs without a chain (`test/payments.test.ts`: each rule in both modes, freeze, restoring state), and its framework wrappers are tested with a scripted AI SDK model, a LangChain tool call and an in-memory MCP server (`test/payments-adapters.test.ts`). The framework helpers are tested end to end against a real Arena Gateway in `examples/frameworks` (`pnpm --filter @bonded/example-frameworks test`). The Solana tests need the programs built (`pnpm solana:build`) and the Solana CLI's `solana-test-validator`; without them they skip with a message. `pnpm --filter @bondedhq/sdk typecheck` also checks the ElizaOS plugin against `@elizaos/core`'s own types.
|
|
483
681
|
|
|
484
682
|
An example agent that uses the SDK is in [`examples/byo-agent`](../../examples/byo-agent/README.md).
|
package/dist/ai-sdk.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { type Tool } from "ai";
|
|
2
2
|
import { type EpisodeTools, type EpisodeToolsSource } from "./mcp.js";
|
|
3
|
+
import { type GuardPaymentsOptions } from "./payments.js";
|
|
3
4
|
import { type BondedVaultToolsOptions } from "./vault-tools.js";
|
|
4
5
|
export type { BondedVaultToolsOptions } from "./vault-tools.js";
|
|
6
|
+
export type { GuardPaymentsOptions } from "./payments.js";
|
|
5
7
|
/**
|
|
6
8
|
* The episode's tools as a Vercel AI SDK tool set, for `generateText`/`streamText`/agents. Each
|
|
7
9
|
* keeps the tool's own JSON Schema; `execute` returns the Arena's output, or `{ error }` when the
|
|
@@ -36,4 +38,21 @@ export declare function bondedAiSdkTools(from?: EpisodeTools | EpisodeToolsSourc
|
|
|
36
38
|
* ```
|
|
37
39
|
*/
|
|
38
40
|
export declare function bondedVaultAiSdkTools(options: BondedVaultToolsOptions): Record<string, Tool>;
|
|
41
|
+
/**
|
|
42
|
+
* Your own AI SDK pay tool, with every call checked against the payment mandate first (see
|
|
43
|
+
* `PaymentGuard`). Same name, description and input schema. When the guard refuses (Enforce mode
|
|
44
|
+
* blocked it, the guard is frozen, or it can't read the payment) your `execute` is not called and
|
|
45
|
+
* the model gets `{ error }` naming the rules broken, so it can react. In Monitor mode a breaking
|
|
46
|
+
* payment goes through and the guard's `onBreach` reports it. A local check: no receipts.
|
|
47
|
+
*
|
|
48
|
+
* ```ts
|
|
49
|
+
* const guard = paymentGuard({ mandate, vendors, invoices, onBreach: alert });
|
|
50
|
+
* const tools = { ...readOnlyTools, pay_invoice: guardAiSdkPayTool(payInvoice, { guard }) };
|
|
51
|
+
* ```
|
|
52
|
+
*
|
|
53
|
+
* The payment is read from the tool's input: `{ account, amountUsd, invoiceReference }` unless you
|
|
54
|
+
* pass `toPayment`. A streaming `execute` is run to its end before the payment is recorded, and
|
|
55
|
+
* returns its last output.
|
|
56
|
+
*/
|
|
57
|
+
export declare function guardAiSdkPayTool<T extends Tool>(payTool: T, options: GuardPaymentsOptions<Parameters<NonNullable<T["execute"]>>[0]>): T;
|
|
39
58
|
//# sourceMappingURL=ai-sdk.d.ts.map
|
package/dist/ai-sdk.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { jsonSchema, tool } from "ai";
|
|
2
2
|
import { SENTINEL_INPUT_CHARS } from "@bondedhq/shared";
|
|
3
|
+
import { BondedError } from "./errors.js";
|
|
3
4
|
import { resolveTools } from "./mcp.js";
|
|
5
|
+
import { guardPayments, isPaymentRefusal } from "./payments.js";
|
|
4
6
|
import { BONDED_VAULT_TOOLS, bondedVaultTools, } from "./vault-tools.js";
|
|
5
7
|
/**
|
|
6
8
|
* The episode's tools as a Vercel AI SDK tool set, for `generateText`/`streamText`/agents. Each
|
|
@@ -83,4 +85,48 @@ function toolResultsIn(messages) {
|
|
|
83
85
|
}
|
|
84
86
|
return inputs.slice(-20);
|
|
85
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* Your own AI SDK pay tool, with every call checked against the payment mandate first (see
|
|
90
|
+
* `PaymentGuard`). Same name, description and input schema. When the guard refuses (Enforce mode
|
|
91
|
+
* blocked it, the guard is frozen, or it can't read the payment) your `execute` is not called and
|
|
92
|
+
* the model gets `{ error }` naming the rules broken, so it can react. In Monitor mode a breaking
|
|
93
|
+
* payment goes through and the guard's `onBreach` reports it. A local check: no receipts.
|
|
94
|
+
*
|
|
95
|
+
* ```ts
|
|
96
|
+
* const guard = paymentGuard({ mandate, vendors, invoices, onBreach: alert });
|
|
97
|
+
* const tools = { ...readOnlyTools, pay_invoice: guardAiSdkPayTool(payInvoice, { guard }) };
|
|
98
|
+
* ```
|
|
99
|
+
*
|
|
100
|
+
* The payment is read from the tool's input: `{ account, amountUsd, invoiceReference }` unless you
|
|
101
|
+
* pass `toPayment`. A streaming `execute` is run to its end before the payment is recorded, and
|
|
102
|
+
* returns its last output.
|
|
103
|
+
*/
|
|
104
|
+
export function guardAiSdkPayTool(payTool, options) {
|
|
105
|
+
const execute = payTool.execute;
|
|
106
|
+
if (!execute)
|
|
107
|
+
throw new BondedError("CONFIG", "guardAiSdkPayTool needs a tool with execute");
|
|
108
|
+
const guarded = guardPayments(async (input, call) => lastOutput(await execute(input, call)), options);
|
|
109
|
+
return {
|
|
110
|
+
...payTool,
|
|
111
|
+
execute: async (input, call) => {
|
|
112
|
+
try {
|
|
113
|
+
return await guarded(input, call);
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
if (isPaymentRefusal(error))
|
|
117
|
+
return { error: error.message };
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/** A tool's output, or a streaming tool's last output. */
|
|
124
|
+
async function lastOutput(output) {
|
|
125
|
+
if (!output || typeof output !== "object" || !(Symbol.asyncIterator in output))
|
|
126
|
+
return output;
|
|
127
|
+
let last;
|
|
128
|
+
for await (const part of output)
|
|
129
|
+
last = part;
|
|
130
|
+
return last;
|
|
131
|
+
}
|
|
86
132
|
//# sourceMappingURL=ai-sdk.js.map
|
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/errors.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Hex } from "viem";
|
|
2
2
|
import type { DryRunResult, VaultBreach } from "./breaches.js";
|
|
3
|
+
import type { PaymentBreach } from "./payments.js";
|
|
3
4
|
/** Base class for every error the SDK throws on purpose. `code` is stable; messages may change. */
|
|
4
5
|
export declare class BondedError extends Error {
|
|
5
6
|
readonly code: string;
|
|
@@ -90,4 +91,35 @@ export declare class CallRevertedError extends BondedError {
|
|
|
90
91
|
cause?: unknown;
|
|
91
92
|
});
|
|
92
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* The payment guard stopped a payment (or a vendor bank change) in Enforce mode: it breaks the
|
|
96
|
+
* payment mandate. The pay function was never called; `error.breach` names every rule broken.
|
|
97
|
+
*/
|
|
98
|
+
export declare class PaymentBlockedError extends BondedError {
|
|
99
|
+
readonly breach: PaymentBreach;
|
|
100
|
+
readonly name = "PaymentBlockedError";
|
|
101
|
+
constructor(breach: PaymentBreach);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The hosted payment gate gave no answer to act on: it couldn't be reached, refused the request,
|
|
105
|
+
* or its answer didn't check out. It fails closed: the pay function was not called.
|
|
106
|
+
*/
|
|
107
|
+
export declare class PaymentGateError extends BondedError {
|
|
108
|
+
/** HTTP status, when the API answered. */
|
|
109
|
+
readonly status?: number | undefined;
|
|
110
|
+
readonly name = "PaymentGateError";
|
|
111
|
+
/** The answer came back, but its signed receipt didn't check out. */
|
|
112
|
+
readonly receipt: boolean;
|
|
113
|
+
constructor(message: string,
|
|
114
|
+
/** HTTP status, when the API answered. */
|
|
115
|
+
status?: number | undefined, options?: {
|
|
116
|
+
cause?: unknown;
|
|
117
|
+
receipt?: boolean;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
/** The payment guard is frozen after a breach (freeze on breach). It refuses until unfrozen. */
|
|
121
|
+
export declare class PaymentFrozenError extends BondedError {
|
|
122
|
+
readonly name = "PaymentFrozenError";
|
|
123
|
+
constructor();
|
|
124
|
+
}
|
|
93
125
|
//# sourceMappingURL=errors.d.ts.map
|
package/dist/errors.js
CHANGED
|
@@ -94,4 +94,40 @@ export class CallRevertedError extends BondedError {
|
|
|
94
94
|
this.data = data;
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* The payment guard stopped a payment (or a vendor bank change) in Enforce mode: it breaks the
|
|
99
|
+
* payment mandate. The pay function was never called; `error.breach` names every rule broken.
|
|
100
|
+
*/
|
|
101
|
+
export class PaymentBlockedError extends BondedError {
|
|
102
|
+
breach;
|
|
103
|
+
name = "PaymentBlockedError";
|
|
104
|
+
constructor(breach) {
|
|
105
|
+
super("PAYMENT_BLOCKED", `${breach.kind === "vendor_change" ? "bank change" : "payment"} blocked by the mandate: ${breach.reasons.join("; ")}. Nothing was ${breach.kind === "vendor_change" ? "changed" : "paid"}`);
|
|
106
|
+
this.breach = breach;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The hosted payment gate gave no answer to act on: it couldn't be reached, refused the request,
|
|
111
|
+
* or its answer didn't check out. It fails closed: the pay function was not called.
|
|
112
|
+
*/
|
|
113
|
+
export class PaymentGateError extends BondedError {
|
|
114
|
+
status;
|
|
115
|
+
name = "PaymentGateError";
|
|
116
|
+
/** The answer came back, but its signed receipt didn't check out. */
|
|
117
|
+
receipt;
|
|
118
|
+
constructor(message,
|
|
119
|
+
/** HTTP status, when the API answered. */
|
|
120
|
+
status, options) {
|
|
121
|
+
super("PAYMENT_GATE_ERROR", message, options);
|
|
122
|
+
this.status = status;
|
|
123
|
+
this.receipt = options?.receipt ?? false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** The payment guard is frozen after a breach (freeze on breach). It refuses until unfrozen. */
|
|
127
|
+
export class PaymentFrozenError extends BondedError {
|
|
128
|
+
name = "PaymentFrozenError";
|
|
129
|
+
constructor() {
|
|
130
|
+
super("PAYMENT_FROZEN", "payments are frozen after a breach; a person has to review it and unfreeze the payment guard");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
97
133
|
//# sourceMappingURL=errors.js.map
|