@bondedhq/sdk 0.1.3 → 0.1.4
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 +119 -5
- package/dist/ai-sdk.d.ts +19 -0
- package/dist/ai-sdk.js +46 -0
- package/dist/errors.d.ts +32 -0
- package/dist/errors.js +36 -0
- package/dist/gate.d.ts +139 -0
- package/dist/gate.js +204 -0
- package/dist/index.d.ts +8 -1
- package/dist/index.js +8 -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/payments.d.ts +194 -0
- package/dist/payments.js +275 -0
- package/package.json +4 -4
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,117 @@ 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
|
+
- **Enforce** blocks: a payment that breaks a rule throws `PaymentBlockedError` naming every rule
|
|
186
|
+
broken in plain words, and your pay function is never called. **Monitor** calls it anyway and
|
|
187
|
+
reports the breach to `onBreach`. A bank change no ticket covers that Monitor lets through is
|
|
188
|
+
remembered: its account is never treated as the vendor's.
|
|
189
|
+
- **Freeze on breach:** after the first breach every payment and bank change is refused
|
|
190
|
+
(`PaymentFrozenError`) until a person calls `guard.unfreeze()`.
|
|
191
|
+
- **State:** the guard keeps the payments made, the frozen flag and unverified bank changes in
|
|
192
|
+
memory. `onStateChange` gets a copy after every change; pass it back as `state` to restore it
|
|
193
|
+
after a restart (or seed `state.payments` with what you paid before). A payment is recorded only
|
|
194
|
+
once your pay function returns, so a failed one can be retried.
|
|
195
|
+
- Payments go through the guard one at a time, so two calls at once can't both pass the duplicate
|
|
196
|
+
or daily check. `guard.check(payment)` gives the verdict without paying.
|
|
197
|
+
- Your function's first argument is read as `{ account, amountUsd, invoiceReference }` (or
|
|
198
|
+
`reference`, as the Arena's `pay_invoice` and `issue_refund` tools take them); pass `toPayment`
|
|
199
|
+
for any other shape.
|
|
200
|
+
|
|
201
|
+
For framework tools, wrap your existing pay tool; a refusal goes back to the model so it can react
|
|
202
|
+
(AI SDK: `{ error }`; LangChain: `Error: ...` text; MCP: an `isError` result):
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
import { guardAiSdkPayTool } from "@bondedhq/sdk/ai-sdk";
|
|
206
|
+
import { guardLangChainPayTool } from "@bondedhq/sdk/langchain";
|
|
207
|
+
import { guardMcpPayHandler } from "@bondedhq/sdk/mcp";
|
|
208
|
+
|
|
209
|
+
const tools = { ...readOnlyTools, pay_invoice: guardAiSdkPayTool(payInvoiceTool, { guard }) };
|
|
210
|
+
const lcTools = [...readOnlyTools, guardLangChainPayTool(payInvoiceTool, { guard })];
|
|
211
|
+
server.registerTool("pay_invoice", config, guardMcpPayHandler(payInvoiceHandler, { guard }));
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
What it doesn't do: it is a local check, so code that calls your pay function directly skips it.
|
|
215
|
+
Keep the unwrapped function out of the agent's reach. It makes no receipts: nothing outside your
|
|
216
|
+
process records or signs its decisions, so `onBreach` and `onStateChange` are your only log.
|
|
217
|
+
|
|
218
|
+
## Hosted payment gate (off chain)
|
|
219
|
+
|
|
220
|
+
The same rules as the guard, run by Bonded instead of inside your process. Your vendors, approved
|
|
221
|
+
invoices and limits live in a gate account (set up at
|
|
222
|
+
[bondedhq.xyz/gate](https://www.bondedhq.xyz/gate)), every decision is logged, and each comes back
|
|
223
|
+
with a signed receipt anyone can check at
|
|
224
|
+
[bondedhq.xyz/gate/verify](https://www.bondedhq.xyz/gate/verify).
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
import { guardPayments, paymentGate } from "@bondedhq/sdk";
|
|
228
|
+
|
|
229
|
+
const gate = paymentGate({
|
|
230
|
+
apiKey: process.env.BONDED_GATE_KEY!, // bonded_gk_...: keep it server side
|
|
231
|
+
onBreach: (breach) => alert(breach), // { rules, reasons, blocked, decisionId, receipt, ... }
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const payInvoice = guardPayments(bank.payInvoice, { guard: gate });
|
|
235
|
+
|
|
236
|
+
await payInvoice({ account: "DE89 3704 0044 0532 0130 00", amountUsd: 1840.5, invoiceReference: "ACM-2291" });
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Pass `gate` wherever a `guard` goes: `guardPayments`, `guardVendorChanges` and the framework
|
|
240
|
+
helpers above. Or call it directly: `gate.pay(payment, send)`, `gate.check(payment)`,
|
|
241
|
+
`gate.decisions()`, `gate.proof(decisionId)`.
|
|
242
|
+
|
|
243
|
+
- **It fails closed.** If the gate can't be reached, your pay function isn't called and
|
|
244
|
+
`PaymentGateError` says why. A check that got no answer is asked once more with the same request
|
|
245
|
+
id, which is safe.
|
|
246
|
+
- **It checks the gate.** Each answer's receipt must carry Bonded's signature and match the payment
|
|
247
|
+
you asked about, or nothing is paid. Pass `receiptKey` to pin the signing address; by default it
|
|
248
|
+
reads the published one once.
|
|
249
|
+
- **Enforce** throws `PaymentBlockedError`, **Monitor** pays and reports to `onBreach`, and a frozen
|
|
250
|
+
account throws `PaymentFrozenError` until the owner lifts the freeze at bondedhq.xyz/gate.
|
|
251
|
+
- **Retries:** the gate counts an allowed payment as made, so if your bank call fails and you try
|
|
252
|
+
again, pass the same `requestId` both times (in the payment, or from `toPayment`). The gate gives
|
|
253
|
+
back the first decision instead of a duplicate breach. A new id is made when you don't pass one.
|
|
254
|
+
- **Bank changes** go through `gate.changeVendorAccount({ vendorId, account, ticket }, update)`.
|
|
255
|
+
`ticket` is a finance ticket the owner added to the account.
|
|
256
|
+
|
|
257
|
+
Like the guard, it only sees payments your code sends it, so keep the unwrapped pay function out of
|
|
258
|
+
the agent's reach. Use the guard to keep invoices inside your system; use the gate for a log and
|
|
259
|
+
receipts someone else can check.
|
|
260
|
+
|
|
147
261
|
## Getting rated by the Arena
|
|
148
262
|
|
|
149
263
|
The Bonded Arena rates an agent by running it through episodes: each one gives the agent a task and
|
|
@@ -479,6 +593,6 @@ episode's tools: see [Framework helpers](#framework-helpers).
|
|
|
479
593
|
pnpm --filter @bondedhq/sdk test
|
|
480
594
|
```
|
|
481
595
|
|
|
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.
|
|
596
|
+
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
597
|
|
|
484
598
|
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/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
|
package/dist/gate.d.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { type GateInput, type GateLogProof, type PaymentAttempt, type PaymentRule, type SignedGateReceipt } from "@bondedhq/shared";
|
|
2
|
+
import { type Address, type Hex } from "viem";
|
|
3
|
+
import type { PaymentBreach } from "./payments.js";
|
|
4
|
+
/** Bonded's hosted API. */
|
|
5
|
+
export declare const BONDED_API_URL = "https://api-production-ba50.up.railway.app";
|
|
6
|
+
/** A payment for the gate. Pass the same `requestId` to retry one: see `PaymentGate.pay`. */
|
|
7
|
+
export type GatePayment = PaymentAttempt & {
|
|
8
|
+
requestId?: string;
|
|
9
|
+
};
|
|
10
|
+
/** A vendor bank change for the gate. `ticket` names the finance ticket that verified it. */
|
|
11
|
+
export interface GateVendorChange {
|
|
12
|
+
vendorId: string;
|
|
13
|
+
account: string;
|
|
14
|
+
ticket?: string;
|
|
15
|
+
requestId?: string;
|
|
16
|
+
}
|
|
17
|
+
/** What `POST /payments/check` takes. */
|
|
18
|
+
export type GateCheck = (GatePayment & {
|
|
19
|
+
kind?: "invoice" | "refund";
|
|
20
|
+
}) | (GateVendorChange & {
|
|
21
|
+
kind: "bank_change";
|
|
22
|
+
});
|
|
23
|
+
/** A decision, as the gate's log keeps it. */
|
|
24
|
+
export interface GateDecision {
|
|
25
|
+
id: string;
|
|
26
|
+
kind: "invoice" | "refund" | "bank_change" | "card";
|
|
27
|
+
decision: "pass" | "breach" | "frozen";
|
|
28
|
+
result: "allowed" | "blocked" | "refused";
|
|
29
|
+
allowed: boolean;
|
|
30
|
+
message: string;
|
|
31
|
+
mode: "monitor" | "enforce";
|
|
32
|
+
/** Every rule broken, most serious first. */
|
|
33
|
+
rules: PaymentRule[];
|
|
34
|
+
/** Each rule in plain words, in the same order. */
|
|
35
|
+
reasons: string[];
|
|
36
|
+
amountUsd: number;
|
|
37
|
+
lossUsd: number;
|
|
38
|
+
overpaidUsd: number;
|
|
39
|
+
payee: string;
|
|
40
|
+
vendorId: string | null;
|
|
41
|
+
reference: string;
|
|
42
|
+
requestId: string | null;
|
|
43
|
+
inputHash: Hex;
|
|
44
|
+
/** What the rules were given. */
|
|
45
|
+
input?: GateInput | null;
|
|
46
|
+
mandate: {
|
|
47
|
+
version: number;
|
|
48
|
+
hash: Hex;
|
|
49
|
+
};
|
|
50
|
+
createdAt: string;
|
|
51
|
+
/** Bonded's signed receipt. Anyone can check it at bondedhq.xyz/gate/verify. */
|
|
52
|
+
receipt: SignedGateReceipt;
|
|
53
|
+
}
|
|
54
|
+
/** The gate's answer to one check. */
|
|
55
|
+
export interface GateAnswer extends GateDecision {
|
|
56
|
+
frozen: boolean;
|
|
57
|
+
/** The same `requestId` was checked before: this is that first decision, unchanged. */
|
|
58
|
+
replayed: boolean;
|
|
59
|
+
}
|
|
60
|
+
/** Where a receipt sits in its day's log root, and where that root is on chain. */
|
|
61
|
+
export interface GateReceiptProof {
|
|
62
|
+
decisionId: string;
|
|
63
|
+
day: string;
|
|
64
|
+
dayNumber: number;
|
|
65
|
+
accountKey: Hex;
|
|
66
|
+
receiptHash: Hex;
|
|
67
|
+
root: Hex;
|
|
68
|
+
proof: GateLogProof;
|
|
69
|
+
posted: {
|
|
70
|
+
chainId: number;
|
|
71
|
+
contract: Address;
|
|
72
|
+
txHash: Hex | null;
|
|
73
|
+
postedAt: string;
|
|
74
|
+
} | null;
|
|
75
|
+
}
|
|
76
|
+
export interface PaymentGateOptions {
|
|
77
|
+
/** The gate account's API key (`bonded_gk_...`), from bondedhq.xyz/gate. Keep it server side. */
|
|
78
|
+
apiKey: string;
|
|
79
|
+
/** Default: Bonded's hosted API. */
|
|
80
|
+
apiUrl?: string;
|
|
81
|
+
/**
|
|
82
|
+
* The address receipts must be signed by. Default: the one `GET /gate/receipt-key` publishes,
|
|
83
|
+
* read once. Pin it so that a wrong API address can't vouch for itself.
|
|
84
|
+
*/
|
|
85
|
+
receiptKey?: Address;
|
|
86
|
+
/** Check each answer's signed receipt before acting on it (default true). */
|
|
87
|
+
verifyReceipts?: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Called with every breach before the gate acts on it: blocked in Enforce mode, let through in
|
|
90
|
+
* Monitor mode. If it throws, the payment is not made.
|
|
91
|
+
*/
|
|
92
|
+
onBreach?: (breach: PaymentBreach) => void | Promise<void>;
|
|
93
|
+
/** How long to wait for each try, in milliseconds (default 10,000). */
|
|
94
|
+
timeoutMs?: number;
|
|
95
|
+
fetch?: typeof fetch;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The hosted payment gate: every payment goes to Bonded before it is made, and comes back allowed
|
|
99
|
+
* or blocked with a signed receipt. The rules are the ones the Arena rates agents against, the
|
|
100
|
+
* vendors and invoices are the gate account's (set up at bondedhq.xyz/gate), and the decisions are
|
|
101
|
+
* logged where the owner can see them and anyone can check them.
|
|
102
|
+
*
|
|
103
|
+
* It fails closed: if the gate can't be reached, or its answer doesn't check out, your pay function
|
|
104
|
+
* isn't called and `PaymentGateError` says why. Pass it as `guard` to `guardPayments` or a framework
|
|
105
|
+
* helper in place of a `PaymentGuard`. Like the guard, it only sees payments your code sends it:
|
|
106
|
+
* keep the unwrapped pay function out of the agent's reach.
|
|
107
|
+
*/
|
|
108
|
+
export declare class PaymentGate {
|
|
109
|
+
#private;
|
|
110
|
+
constructor(options: PaymentGateOptions);
|
|
111
|
+
/** Asks the gate about a payment or bank change. An allowed payment counts as made from here. */
|
|
112
|
+
check(check: GateCheck): Promise<GateAnswer>;
|
|
113
|
+
/**
|
|
114
|
+
* Checks a payment, then calls `pay` if the gate allows it. Throws `PaymentBlockedError`
|
|
115
|
+
* (Enforce), `PaymentFrozenError` or `PaymentGateError` without calling `pay`.
|
|
116
|
+
*
|
|
117
|
+
* The gate counts an allowed payment as made, so if `pay` fails and you try again, pass the same
|
|
118
|
+
* `requestId` both times: the gate gives the first decision back instead of a duplicate breach.
|
|
119
|
+
*/
|
|
120
|
+
pay<R>(attempt: GatePayment, pay: () => R | Promise<R>, kind?: "invoice" | "refund"): Promise<R>;
|
|
121
|
+
/** The same for a vendor bank change: `update` runs only if the gate allows the change. */
|
|
122
|
+
changeVendorAccount<R>(request: GateVendorChange, update: () => R | Promise<R>): Promise<R>;
|
|
123
|
+
/** The gate account's decisions, newest first. Pass `next` back as `before` for older ones. */
|
|
124
|
+
decisions(query?: {
|
|
125
|
+
limit?: number;
|
|
126
|
+
before?: string;
|
|
127
|
+
result?: GateDecision["result"];
|
|
128
|
+
}): Promise<{
|
|
129
|
+
items: GateDecision[];
|
|
130
|
+
next: string | null;
|
|
131
|
+
}>;
|
|
132
|
+
/** One decision with its receipt. */
|
|
133
|
+
decision(id: string): Promise<GateDecision>;
|
|
134
|
+
/** A receipt's proof in its day's log root. The gate has one once that UTC day has ended. */
|
|
135
|
+
proof(id: string): Promise<GateReceiptProof>;
|
|
136
|
+
}
|
|
137
|
+
/** The hosted payment gate: see `PaymentGate`. */
|
|
138
|
+
export declare function paymentGate(options: PaymentGateOptions): PaymentGate;
|
|
139
|
+
//# sourceMappingURL=gate.d.ts.map
|
package/dist/gate.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { normalAccount, verifyGateReceipt, } from "@bondedhq/shared";
|
|
2
|
+
import { isAddress } from "viem";
|
|
3
|
+
import { BondedError, PaymentBlockedError, PaymentFrozenError, PaymentGateError, } from "./errors.js";
|
|
4
|
+
/** Bonded's hosted API. */
|
|
5
|
+
export const BONDED_API_URL = "https://api-production-ba50.up.railway.app";
|
|
6
|
+
/** One retry of a check the gate may not have answered: its request id makes that safe. */
|
|
7
|
+
const TRIES = 2;
|
|
8
|
+
/**
|
|
9
|
+
* The hosted payment gate: every payment goes to Bonded before it is made, and comes back allowed
|
|
10
|
+
* or blocked with a signed receipt. The rules are the ones the Arena rates agents against, the
|
|
11
|
+
* vendors and invoices are the gate account's (set up at bondedhq.xyz/gate), and the decisions are
|
|
12
|
+
* logged where the owner can see them and anyone can check them.
|
|
13
|
+
*
|
|
14
|
+
* It fails closed: if the gate can't be reached, or its answer doesn't check out, your pay function
|
|
15
|
+
* isn't called and `PaymentGateError` says why. Pass it as `guard` to `guardPayments` or a framework
|
|
16
|
+
* helper in place of a `PaymentGuard`. Like the guard, it only sees payments your code sends it:
|
|
17
|
+
* keep the unwrapped pay function out of the agent's reach.
|
|
18
|
+
*/
|
|
19
|
+
export class PaymentGate {
|
|
20
|
+
#options;
|
|
21
|
+
#url;
|
|
22
|
+
#signer;
|
|
23
|
+
constructor(options) {
|
|
24
|
+
if (!options.apiKey)
|
|
25
|
+
throw new BondedError("INVALID_PAYMENT", "apiKey is required");
|
|
26
|
+
this.#options = options;
|
|
27
|
+
this.#url = (options.apiUrl ?? BONDED_API_URL).replace(/\/+$/, "");
|
|
28
|
+
}
|
|
29
|
+
/** Asks the gate about a payment or bank change. An allowed payment counts as made from here. */
|
|
30
|
+
async check(check) {
|
|
31
|
+
// Only what the API takes: it refuses unknown fields, and a caller's `toPayment` may add some.
|
|
32
|
+
const requestId = check.requestId ?? crypto.randomUUID();
|
|
33
|
+
const body = check.kind === "bank_change"
|
|
34
|
+
? {
|
|
35
|
+
kind: check.kind,
|
|
36
|
+
vendorId: check.vendorId,
|
|
37
|
+
account: check.account,
|
|
38
|
+
...(check.ticket === undefined ? {} : { ticket: check.ticket }),
|
|
39
|
+
requestId,
|
|
40
|
+
}
|
|
41
|
+
: {
|
|
42
|
+
kind: check.kind ?? "invoice",
|
|
43
|
+
account: check.account,
|
|
44
|
+
amountUsd: check.amountUsd,
|
|
45
|
+
invoiceReference: check.invoiceReference,
|
|
46
|
+
requestId,
|
|
47
|
+
};
|
|
48
|
+
let last;
|
|
49
|
+
for (let attempt = 0; attempt < TRIES; attempt++) {
|
|
50
|
+
try {
|
|
51
|
+
const answer = await this.#request("/payments/check", {
|
|
52
|
+
method: "POST",
|
|
53
|
+
body: JSON.stringify(body),
|
|
54
|
+
});
|
|
55
|
+
await this.#verify(answer, body);
|
|
56
|
+
return answer;
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
last = error;
|
|
60
|
+
// Only a check the gate may not have answered is worth asking again.
|
|
61
|
+
const unanswered = error instanceof PaymentGateError && (!error.status || error.status >= 500);
|
|
62
|
+
if (!unanswered || error.receipt)
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
throw last;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Checks a payment, then calls `pay` if the gate allows it. Throws `PaymentBlockedError`
|
|
70
|
+
* (Enforce), `PaymentFrozenError` or `PaymentGateError` without calling `pay`.
|
|
71
|
+
*
|
|
72
|
+
* The gate counts an allowed payment as made, so if `pay` fails and you try again, pass the same
|
|
73
|
+
* `requestId` both times: the gate gives the first decision back instead of a duplicate breach.
|
|
74
|
+
*/
|
|
75
|
+
async pay(attempt, pay, kind = "invoice") {
|
|
76
|
+
await this.#settle(await this.check({ ...attempt, kind }));
|
|
77
|
+
return pay();
|
|
78
|
+
}
|
|
79
|
+
/** The same for a vendor bank change: `update` runs only if the gate allows the change. */
|
|
80
|
+
async changeVendorAccount(request, update) {
|
|
81
|
+
await this.#settle(await this.check({ ...request, kind: "bank_change" }));
|
|
82
|
+
return update();
|
|
83
|
+
}
|
|
84
|
+
/** The gate account's decisions, newest first. Pass `next` back as `before` for older ones. */
|
|
85
|
+
decisions(query = {}) {
|
|
86
|
+
const params = new URLSearchParams();
|
|
87
|
+
for (const [name, value] of Object.entries(query))
|
|
88
|
+
if (value !== undefined)
|
|
89
|
+
params.set(name, String(value));
|
|
90
|
+
return this.#request(`/payments/decisions${params.size ? `?${params}` : ""}`);
|
|
91
|
+
}
|
|
92
|
+
/** One decision with its receipt. */
|
|
93
|
+
decision(id) {
|
|
94
|
+
return this.#request(`/payments/decisions/${encodeURIComponent(id)}`);
|
|
95
|
+
}
|
|
96
|
+
/** A receipt's proof in its day's log root. The gate has one once that UTC day has ended. */
|
|
97
|
+
proof(id) {
|
|
98
|
+
return this.#request(`/payments/decisions/${encodeURIComponent(id)}/proof`);
|
|
99
|
+
}
|
|
100
|
+
/** Reports a breach, then stops the payment unless the gate allowed it. */
|
|
101
|
+
async #settle(answer) {
|
|
102
|
+
if (answer.decision === "frozen")
|
|
103
|
+
throw new PaymentFrozenError();
|
|
104
|
+
if (answer.decision === "breach")
|
|
105
|
+
await this.#options.onBreach?.(breachOf(answer));
|
|
106
|
+
if (!answer.allowed)
|
|
107
|
+
throw new PaymentBlockedError(breachOf(answer));
|
|
108
|
+
}
|
|
109
|
+
async #verify(answer, asked) {
|
|
110
|
+
if (this.#options.verifyReceipts === false)
|
|
111
|
+
return;
|
|
112
|
+
const wrong = (what) => new PaymentGateError(`the gate's answer can't be trusted: ${what}`, undefined, {
|
|
113
|
+
receipt: true,
|
|
114
|
+
});
|
|
115
|
+
const signed = answer.receipt;
|
|
116
|
+
if (!signed?.receipt)
|
|
117
|
+
throw wrong("it has no receipt");
|
|
118
|
+
const signer = await this.#receiptSigner();
|
|
119
|
+
if (!(await verifyGateReceipt(signed, signer).catch(() => false)))
|
|
120
|
+
throw wrong("its receipt isn't signed by Bonded's receipt key");
|
|
121
|
+
const r = signed.receipt;
|
|
122
|
+
const matches = r.decisionId === answer.id &&
|
|
123
|
+
r.decision === answer.decision &&
|
|
124
|
+
r.result === answer.result &&
|
|
125
|
+
answer.allowed === (r.result === "allowed") &&
|
|
126
|
+
r.rules.join() === answer.rules.join() &&
|
|
127
|
+
r.payee === normalAccount(asked.account) &&
|
|
128
|
+
(asked.kind === "bank_change"
|
|
129
|
+
? r.kind === "bank_change"
|
|
130
|
+
: r.kind === (asked.kind ?? "invoice") &&
|
|
131
|
+
r.amountCents === Math.round(asked.amountUsd * 100) &&
|
|
132
|
+
r.reference === asked.invoiceReference.trim());
|
|
133
|
+
if (!matches)
|
|
134
|
+
throw wrong("its receipt is for a different decision or payment");
|
|
135
|
+
}
|
|
136
|
+
#receiptSigner() {
|
|
137
|
+
if (this.#options.receiptKey)
|
|
138
|
+
return Promise.resolve(this.#options.receiptKey);
|
|
139
|
+
this.#signer ??= this.#request("/gate/receipt-key")
|
|
140
|
+
.then((key) => {
|
|
141
|
+
// Without an address verifyGateReceipt would accept any self-signed receipt.
|
|
142
|
+
if (!isAddress(key?.address))
|
|
143
|
+
throw new PaymentGateError("the gate's receipt key is not an address");
|
|
144
|
+
return key.address;
|
|
145
|
+
})
|
|
146
|
+
.catch((error) => {
|
|
147
|
+
this.#signer = undefined;
|
|
148
|
+
throw error;
|
|
149
|
+
});
|
|
150
|
+
return this.#signer;
|
|
151
|
+
}
|
|
152
|
+
async #request(path, init = {}) {
|
|
153
|
+
const url = `${this.#url}${path}`;
|
|
154
|
+
let response;
|
|
155
|
+
try {
|
|
156
|
+
response = await (this.#options.fetch ?? fetch)(url, {
|
|
157
|
+
...init,
|
|
158
|
+
headers: {
|
|
159
|
+
Authorization: `Bearer ${this.#options.apiKey}`,
|
|
160
|
+
...(init.body ? { "Content-Type": "application/json" } : {}),
|
|
161
|
+
},
|
|
162
|
+
signal: AbortSignal.timeout(this.#options.timeoutMs ?? 10_000),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
throw new PaymentGateError(`couldn't reach ${url}: ${error.message}`, undefined, {
|
|
167
|
+
cause: error,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
const body = (await response.json().catch(() => undefined));
|
|
171
|
+
if (response.ok && body)
|
|
172
|
+
return body;
|
|
173
|
+
const message = typeof body?.error === "string" ? body.error : `unexpected answer from ${url}`;
|
|
174
|
+
if (response.status === 400)
|
|
175
|
+
throw new BondedError("INVALID_PAYMENT", message);
|
|
176
|
+
throw new PaymentGateError(message, response.status);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/** The hosted payment gate: see `PaymentGate`. */
|
|
180
|
+
export function paymentGate(options) {
|
|
181
|
+
return new PaymentGate(options);
|
|
182
|
+
}
|
|
183
|
+
function breachOf(answer) {
|
|
184
|
+
return {
|
|
185
|
+
kind: answer.kind === "bank_change"
|
|
186
|
+
? "vendor_change"
|
|
187
|
+
: answer.kind === "refund"
|
|
188
|
+
? "refund"
|
|
189
|
+
: "invoice",
|
|
190
|
+
rules: answer.rules,
|
|
191
|
+
reasons: answer.reasons,
|
|
192
|
+
blocked: !answer.allowed,
|
|
193
|
+
account: answer.payee,
|
|
194
|
+
reference: answer.reference,
|
|
195
|
+
vendorId: answer.vendorId,
|
|
196
|
+
amountUsd: answer.amountUsd,
|
|
197
|
+
lossUsd: answer.lossUsd,
|
|
198
|
+
overpaidUsd: answer.overpaidUsd,
|
|
199
|
+
day: answer.createdAt.slice(0, 10),
|
|
200
|
+
decisionId: answer.id,
|
|
201
|
+
receipt: answer.receipt,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
//# sourceMappingURL=gate.js.map
|