@bondedhq/sdk 0.1.5 → 0.1.7

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 CHANGED
@@ -299,6 +299,21 @@ await x402.close();
299
299
  - `guardX402Client(client, { guard })` registers a guard and returns the client, if you never close
300
300
  budgets yourself. `x402Price(requirements)` gives the payee and exact price it reads.
301
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
+
302
317
  ## Hosted payment gate (off chain)
303
318
 
304
319
  The same rules as the guard, run by Bonded instead of inside your process. Your vendors, approved
@@ -373,6 +388,27 @@ if (isBondedEpisode()) {
373
388
  `isBondedEpisode()` is true when the runner set valid `BONDED_*` variables. `fromBondedEnv` reads
374
389
  and checks them, naming every missing or malformed one (`BondedError`, code `EPISODE_ENV`).
375
390
 
391
+ That is the vault world, where ratings run. An agent that trades through a broker can also be
392
+ tested on the brokerage world (a simulated brokerage account, no vault; docs/BROKERAGE.md), with a
393
+ runner started with `--world brokerage`. Its episode is read with `fromBondedBrokerageEnv()`, and
394
+ `bondedEpisodeWorld()` says which world the runner set up:
395
+
396
+ ```ts
397
+ import { bondedEpisodeWorld, fromBondedBrokerageEnv, fromBondedEnv } from "@bondedhq/sdk";
398
+
399
+ const world = bondedEpisodeWorld(); // "vault", "brokerage", or undefined outside an episode
400
+ if (world === "brokerage") {
401
+ const episode = fromBondedBrokerageEnv(); // { world, episodeId, task, mode: "tools", endpoints, deadlineMs }
402
+ } else if (world === "vault") {
403
+ const episode = fromBondedEnv();
404
+ }
405
+ ```
406
+
407
+ `isBondedEpisode()` is true in both worlds, so an agent under test never falls through to its
408
+ production tools. In a brokerage episode `fromBondedEnv` and the vault tool helpers throw
409
+ `EPISODE_ENV` rather than guess. By Agent URL, `createBondedHandler` runs brokerage episodes with
410
+ `onBrokerageEpisode` and refuses them (422) without it.
411
+
376
412
  The mode decides how the agent acts:
377
413
 
378
414
  - **tools**: reads and writes are tool calls; `swap`, `transfer` and `approve` are tools, and the
@@ -1,5 +1,5 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
- import type { BondedEpisode } from "./episode.js";
2
+ import type { BondedBrokerageEpisode, BondedEpisode } from "./episode.js";
3
3
  import type { EpisodeUsage } from "./mcp.js";
4
4
  /** For your own handler: the proof a verify answer carries, and the whole answer. */
5
5
  export { agentUrlVerifyProof, answerAgentUrlVerify } from "@bondedhq/shared/agent-url";
@@ -40,6 +40,14 @@ export interface BondedHandlerOptions {
40
40
  secret: string | string[];
41
41
  /** Runs one episode. Resolve when the agent is finished; the handler then posts `/done`. */
42
42
  onEpisode(episode: BondedEpisode, context: EpisodeContext): Promise<EpisodeOutcome | void> | EpisodeOutcome | void;
43
+ /**
44
+ * Runs one brokerage episode (a simulated brokerage account; docs/BROKERAGE.md). Without it, the
45
+ * handler refuses brokerage episodes with 422, so an agent built for the vault world never gets
46
+ * one by surprise. Errors are passed to `onBrokerageError` (default `console.error`).
47
+ */
48
+ onBrokerageEpisode?(episode: BondedBrokerageEpisode, context: EpisodeContext): Promise<EpisodeOutcome | void> | EpisodeOutcome | void;
49
+ /** Called when `onBrokerageEpisode` throws (default: `console.error`). `/done` is still posted. */
50
+ onBrokerageError?: (error: unknown, episode: BondedBrokerageEpisode) => void;
43
51
  /**
44
52
  * Your agent's version (a release tag, a git commit). Reported when Bonded verifies the URL;
45
53
  * it becomes the rating's code hash when you don't declare one.
package/dist/agent-url.js CHANGED
@@ -32,17 +32,29 @@ export function createBondedHandler(options) {
32
32
  /** Signatures already accepted, until they fall out of the window: replays are refused. */
33
33
  const replay = new AgentUrlReplayGuard(tolerance);
34
34
  const running = new Map();
35
- const run = async (episode, controller) => {
35
+ const onBrokerageError = options.onBrokerageError ??
36
+ ((error, episode) => console.error(`Bonded brokerage episode ${episode.episodeId} failed:`, error));
37
+ const run = async (episode, controller, work, reportError) => {
36
38
  const deadline = setTimeout(() => controller.abort(new Error("the episode's deadline passed")), episode.deadlineMs);
39
+ // A reporter that throws must not stop `/done` from being posted: the gateway would wait out
40
+ // the deadline for an episode that has already failed.
41
+ const report = (error) => {
42
+ try {
43
+ reportError(error, episode);
44
+ }
45
+ catch (reporterError) {
46
+ console.error(`Bonded episode ${episode.episodeId}: the error reporter threw:`, reporterError);
47
+ }
48
+ };
37
49
  let outcome;
38
50
  let failed = false;
39
51
  try {
40
- outcome = await options.onEpisode(episode, { signal: controller.signal });
52
+ outcome = await work(episode, { signal: controller.signal });
41
53
  }
42
54
  catch (error) {
43
55
  failed = true;
44
56
  if (!controller.signal.aborted)
45
- onError(error, episode);
57
+ report(error);
46
58
  outcome = {
47
59
  finalMessage: `the agent failed: ${error instanceof Error ? error.message : String(error)}`.slice(0, 4_000),
48
60
  };
@@ -64,7 +76,7 @@ export function createBondedHandler(options) {
64
76
  }
65
77
  catch (error) {
66
78
  if (!failed)
67
- onError(error, episode);
79
+ report(error);
68
80
  }
69
81
  };
70
82
  return async (request, context) => {
@@ -92,20 +104,25 @@ export function createBondedHandler(options) {
92
104
  controller?.abort(new Error("Bonded cancelled the episode"));
93
105
  return json(200, { ok: true, cancelled: controller !== undefined });
94
106
  }
95
- const episode = toEpisode(message);
96
- if (running.has(episode.episodeId))
97
- return json(200, { ok: true, episodeId: episode.episodeId, alreadyRunning: true });
107
+ if (message.world === "brokerage" && !options.onBrokerageEpisode)
108
+ return json(422, {
109
+ error: "this agent doesn't take brokerage episodes (no onBrokerageEpisode)",
110
+ });
111
+ if (running.has(message.episodeId))
112
+ return json(200, { ok: true, episodeId: message.episodeId, alreadyRunning: true });
98
113
  if (running.size >= maxConcurrent)
99
114
  return json(429, { error: `already running ${running.size} episodes` });
100
115
  const controller = new AbortController();
101
- running.set(episode.episodeId, controller);
102
- const work = run(episode, controller);
116
+ running.set(message.episodeId, controller);
117
+ const work = message.world === "brokerage"
118
+ ? run(toBrokerageEpisode(message), controller, (e, c) => options.onBrokerageEpisode(e, c), onBrokerageError)
119
+ : run(toEpisode(message), controller, (e, c) => options.onEpisode(e, c), onError);
103
120
  // Called as a method: Cloudflare's ctx.waitUntil needs its `this`.
104
121
  if (context?.waitUntil)
105
122
  context.waitUntil(work);
106
123
  else
107
124
  options.waitUntil?.(work);
108
- return json(202, { ok: true, episodeId: episode.episodeId });
125
+ return json(202, { ok: true, episodeId: message.episodeId });
109
126
  };
110
127
  }
111
128
  /**
@@ -122,6 +139,17 @@ async function signingSecret(secrets, body, headers, nowMs, toleranceSeconds) {
122
139
  }
123
140
  return secrets[0];
124
141
  }
142
+ /** A brokerage episode as the agent sees it (the same shape `fromBondedBrokerageEnv` returns). */
143
+ function toBrokerageEpisode(message) {
144
+ return {
145
+ world: "brokerage",
146
+ episodeId: message.episodeId,
147
+ task: message.task,
148
+ mode: "tools",
149
+ endpoints: message.endpoints,
150
+ deadlineMs: message.deadlineMs,
151
+ };
152
+ }
125
153
  /** The episode as the agent sees it (the same shape `fromBondedEnv` returns). */
126
154
  function toEpisode(message) {
127
155
  // The runner protocol's message, minus `type`, `nonce` and `teeKeyId` (TEE-bound agents use
package/dist/episode.d.ts CHANGED
@@ -41,6 +41,7 @@ export interface BondedEpisode {
41
41
  * next to `episodeEnv` that writes them, so the runner, the gateway and the SDK can't drift.
42
42
  */
43
43
  export declare const bondedEpisodeEnv: import("zod").ZodObject<{
44
+ BONDED_WORLD: import("zod").ZodOptional<import("zod").ZodLiteral<"vault">>;
44
45
  BONDED_EPISODE_ID: import("zod").ZodString;
45
46
  BONDED_MODE: import("zod").ZodEnum<{
46
47
  tools: "tools";
@@ -57,6 +58,7 @@ export declare const bondedEpisodeEnv: import("zod").ZodObject<{
57
58
  approvalAllowlist: import("zod").ZodArray<import("zod").ZodPipe<import("zod").ZodString, import("zod").ZodTransform<`0x${string}`, string>>>;
58
59
  }, import("zod/v4/core").$strip>>;
59
60
  maxSteps: import("zod").ZodNumber;
61
+ tools: import("zod").ZodOptional<import("zod").ZodReadonly<import("zod").ZodArray<import("zod").ZodString>>>;
60
62
  }, import("zod/v4/core").$strip>>;
61
63
  BONDED_MCP_URL: import("zod").ZodURL;
62
64
  BONDED_TOOLS_URL: import("zod").ZodURL;
@@ -84,6 +86,11 @@ export declare const bondedEpisodeEnv: import("zod").ZodObject<{
84
86
  * ```ts
85
87
  * const tools = isBondedEpisode() ? await bondedLangChainTools() : productionTools;
86
88
  * ```
89
+ *
90
+ * True in every world, so an agent under test never falls through to its production tools. In a
91
+ * world the agent wasn't built for, the helpers then fail loudly: in a brokerage episode
92
+ * `fromBondedEnv` and the vault tool helpers throw `EPISODE_ENV`. `bondedEpisodeWorld()` says
93
+ * which world it is.
87
94
  */
88
95
  export declare function isBondedEpisode(env?: Record<string, string | undefined>): boolean;
89
96
  /**
@@ -105,4 +112,48 @@ export declare function fromBondedEnv(env?: Record<string, string | undefined>):
105
112
  * or when the key is held by a TEE (no session key): use your own signer there.
106
113
  */
107
114
  export declare function episodeVault(episode: BondedEpisode): BondedVault;
115
+ /**
116
+ * A brokerage episode: the agent manages a simulated brokerage account (stocks, filings, orders,
117
+ * cash withdrawals) through the episode's tools. There is no chain, vault or key: every read and
118
+ * write is a tool call, and the Arena judges it by the brokerage ledger. Needs runner protocol 2
119
+ * (`@bondedhq/runner` 0.1.4 or later), started with `--world brokerage`.
120
+ */
121
+ export interface BondedBrokerageEpisode {
122
+ world: "brokerage";
123
+ episodeId: string;
124
+ task: AgentTask;
125
+ mode: "tools";
126
+ endpoints: {
127
+ /** MCP over streamable HTTP. */
128
+ mcp: string;
129
+ /** Plain HTTP: `GET {tools}` lists them, `POST {tools}/{name}` with JSON args calls one. */
130
+ tools: string;
131
+ openapi: string;
132
+ /** `POST` here when finished (`{ finalMessage? }`). */
133
+ done: string;
134
+ };
135
+ /** How long the agent has, in milliseconds from when it starts. */
136
+ deadlineMs: number;
137
+ }
138
+ /**
139
+ * Which world the runner's episode is for: `vault` (on-chain trading from a Mandate Vault, read
140
+ * with `fromBondedEnv`), `brokerage` (read with `fromBondedBrokerageEnv`), or undefined when this
141
+ * process isn't running a Bonded episode.
142
+ *
143
+ * ```ts
144
+ * const world = bondedEpisodeWorld();
145
+ * const tools =
146
+ * world === "brokerage"
147
+ * ? await myBrokerageTools(fromBondedBrokerageEnv())
148
+ * : world === "vault"
149
+ * ? await bondedLangChainTools()
150
+ * : productionTools;
151
+ * ```
152
+ */
153
+ export declare function bondedEpisodeWorld(env?: Record<string, string | undefined>): "vault" | "brokerage" | undefined;
154
+ /**
155
+ * Reads a brokerage episode the Bonded runner set up from the environment. Throws `BondedError`
156
+ * (`EPISODE_ENV`) naming every missing or malformed variable.
157
+ */
158
+ export declare function fromBondedBrokerageEnv(env?: Record<string, string | undefined>): BondedBrokerageEpisode;
108
159
  //# sourceMappingURL=episode.d.ts.map
package/dist/episode.js CHANGED
@@ -1,4 +1,4 @@
1
- import { EpisodeEnv, parseEpisodeEnv } from "@bondedhq/shared";
1
+ import { EpisodeEnv, parseBrokerageEpisodeEnv, parseEpisodeEnv, } from "@bondedhq/shared";
2
2
  import { defineChain } from "viem";
3
3
  import { privateKeyToAccount } from "viem/accounts";
4
4
  import { BondedError } from "./errors.js";
@@ -15,9 +15,14 @@ export const bondedEpisodeEnv = EpisodeEnv;
15
15
  * ```ts
16
16
  * const tools = isBondedEpisode() ? await bondedLangChainTools() : productionTools;
17
17
  * ```
18
+ *
19
+ * True in every world, so an agent under test never falls through to its production tools. In a
20
+ * world the agent wasn't built for, the helpers then fail loudly: in a brokerage episode
21
+ * `fromBondedEnv` and the vault tool helpers throw `EPISODE_ENV`. `bondedEpisodeWorld()` says
22
+ * which world it is.
18
23
  */
19
24
  export function isBondedEpisode(env = typeof process === "undefined" ? {} : process.env) {
20
- return parseEpisodeEnv(env).ok;
25
+ return bondedEpisodeWorld(env) !== undefined;
21
26
  }
22
27
  /**
23
28
  * Reads the episode the Bonded runner set up from the environment. Throws `BondedError`
@@ -62,4 +67,37 @@ export function episodeVault(episode) {
62
67
  router: episode.contracts.exchange,
63
68
  });
64
69
  }
70
+ /**
71
+ * Which world the runner's episode is for: `vault` (on-chain trading from a Mandate Vault, read
72
+ * with `fromBondedEnv`), `brokerage` (read with `fromBondedBrokerageEnv`), or undefined when this
73
+ * process isn't running a Bonded episode.
74
+ *
75
+ * ```ts
76
+ * const world = bondedEpisodeWorld();
77
+ * const tools =
78
+ * world === "brokerage"
79
+ * ? await myBrokerageTools(fromBondedBrokerageEnv())
80
+ * : world === "vault"
81
+ * ? await bondedLangChainTools()
82
+ * : productionTools;
83
+ * ```
84
+ */
85
+ export function bondedEpisodeWorld(env = typeof process === "undefined" ? {} : process.env) {
86
+ // Fails closed: once the runner has named the brokerage world for an episode, that is the
87
+ // answer even if another variable is malformed. `fromBondedBrokerageEnv` then names the bad
88
+ // variable, where "not an episode" would send the agent to its production tools.
89
+ if (env.BONDED_WORLD === "brokerage" && env.BONDED_EPISODE_ID?.trim())
90
+ return "brokerage";
91
+ return parseEpisodeEnv(env).ok ? "vault" : undefined;
92
+ }
93
+ /**
94
+ * Reads a brokerage episode the Bonded runner set up from the environment. Throws `BondedError`
95
+ * (`EPISODE_ENV`) naming every missing or malformed variable.
96
+ */
97
+ export function fromBondedBrokerageEnv(env = process.env) {
98
+ const parsed = parseBrokerageEpisodeEnv(env);
99
+ if (!parsed.ok)
100
+ throw new BondedError("EPISODE_ENV", `not a valid Bonded brokerage episode environment: ${parsed.problems.join("; ")}`);
101
+ return parsed.episode;
102
+ }
65
103
  //# sourceMappingURL=episode.js.map
package/dist/index.d.ts CHANGED
@@ -24,6 +24,6 @@ export { BONDED_API_URL, PaymentGate, paymentGate, type GateAnswer, type GateChe
24
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";
25
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";
26
26
  export { BONDED_VAULT_TOOLS, bondedVaultFromEnv, bondedVaultTools, type BondedVaultTool, type BondedVaultToolName, type BondedVaultToolsOptions, } from "./vault-tools.js";
27
- export { bondedEpisodeEnv, episodeVault, fromBondedEnv, isBondedEpisode, type BondedEpisode, } from "./episode.js";
27
+ export { bondedEpisodeEnv, bondedEpisodeWorld, episodeVault, fromBondedBrokerageEnv, fromBondedEnv, isBondedEpisode, type BondedBrokerageEpisode, type BondedEpisode, } from "./episode.js";
28
28
  export { checkUnprotectedAllowances, deploymentTokens, unprotectedAllowancesWarning, type AllowanceCheck, type AllowanceFinding, type TokenRef, installMandateModule, type InstallMandateModuleInput, type ModuleAccountType, type ModuleInstallPlan, } from "./module.js";
29
29
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -23,6 +23,6 @@ export { BONDED_API_URL, PaymentGate, paymentGate, } from "./gate.js";
23
23
  export { guardPayments, guardVendorChanges, isPaymentRefusal, PaymentGuard, paymentGuard, } from "./payments.js";
24
24
  export { describePaymentMandate, GATE_RULE_WORDS, GATE_RULES, PAYMENT_RULE_WORDS, PAYMENT_RULES, } from "@bondedhq/shared";
25
25
  export { BONDED_VAULT_TOOLS, bondedVaultFromEnv, bondedVaultTools, } from "./vault-tools.js";
26
- export { bondedEpisodeEnv, episodeVault, fromBondedEnv, isBondedEpisode, } from "./episode.js";
26
+ export { bondedEpisodeEnv, bondedEpisodeWorld, episodeVault, fromBondedBrokerageEnv, fromBondedEnv, isBondedEpisode, } from "./episode.js";
27
27
  export { checkUnprotectedAllowances, deploymentTokens, unprotectedAllowancesWarning, installMandateModule, } from "./module.js";
28
28
  //# sourceMappingURL=index.js.map
@@ -221,7 +221,7 @@ export interface GuardVendorChangesOptions<I> {
221
221
  export declare function guardVendorChanges<I, Rest extends unknown[], R>(update: (input: I, ...rest: Rest) => R | Promise<R>, options: GuardVendorChangesOptions<I>): (input: I, ...rest: Rest) => Promise<R>;
222
222
  /**
223
223
  * The payment guard or gate refused (blocked, frozen, arguments it can't check, or the gate gave no
224
- * answer to act on): no money moved.
224
+ * answer to act on), or a remote x402 signer didn't sign: no money moved.
225
225
  */
226
226
  export declare function isPaymentRefusal(error: unknown): error is PaymentBlockedError | PaymentFrozenError | PaymentGateError | BondedError;
227
227
  //# sourceMappingURL=payments.d.ts.map
package/dist/payments.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { checkBudget, checkGeneralPayment, checkInvoicePayment, checkRefundPayment, GATE_RULE_WORDS, isVerifiedChange, normalAccount, utcDay, usdToMicros, } from "@bondedhq/shared";
2
+ import { X402_SIGNER_ERROR_CODES } from "./x402-remote.js";
2
3
  import { BudgetBase } from "./budget.js";
3
4
  import { BondedError, PaymentBlockedError, PaymentFrozenError, PaymentGateError, } from "./errors.js";
4
5
  const read = async (source) => typeof source === "function" ? await source() : (source ?? []);
@@ -422,13 +423,15 @@ export function guardVendorChanges(update, options) {
422
423
  }
423
424
  /**
424
425
  * The payment guard or gate refused (blocked, frozen, arguments it can't check, or the gate gave no
425
- * answer to act on): no money moved.
426
+ * answer to act on), or a remote x402 signer didn't sign: no money moved.
426
427
  */
427
428
  export function isPaymentRefusal(error) {
428
429
  return (error instanceof PaymentBlockedError ||
429
430
  error instanceof PaymentFrozenError ||
430
431
  error instanceof PaymentGateError ||
431
- (error instanceof BondedError && error.code === "INVALID_PAYMENT"));
432
+ (error instanceof BondedError &&
433
+ (error.code === "INVALID_PAYMENT" ||
434
+ X402_SIGNER_ERROR_CODES.includes(error.code))));
432
435
  }
433
436
  const field = (input, name) => {
434
437
  const value = input[name];
@@ -0,0 +1,77 @@
1
+ import type { Address, Hex } from "viem";
2
+ /**
3
+ * The wire format between an agent and `@bondedhq/signer`, the x402 signer a business runs on its
4
+ * own server. The agent never holds the wallet key: it sends each x402 payment's EIP-712 request
5
+ * to the signer, which checks it against a budget and signs only if it fits.
6
+ */
7
+ export declare const X402_SIGNER_PATHS: {
8
+ /** `GET`: the signer's address and the networks it pays on (`X402SignerInfo`). */
9
+ readonly info: "/v1/x402/signer";
10
+ /** `POST` an `X402SignRequest`; answers an `X402SignResponse`, or an `X402SignerProblem`. */
11
+ readonly sign: "/v1/x402/sign";
12
+ };
13
+ /** One EIP-712 request, as the x402 client builds it. Big numbers travel as decimal strings. */
14
+ export interface X402TypedData {
15
+ domain: Record<string, unknown>;
16
+ types: Record<string, unknown>;
17
+ primaryType: string;
18
+ message: Record<string, unknown>;
19
+ }
20
+ export interface X402SignRequest {
21
+ typedData: X402TypedData;
22
+ }
23
+ export interface X402SignResponse {
24
+ signature: Hex;
25
+ }
26
+ export interface X402SignerInfo {
27
+ address: Address;
28
+ /** CAIP-2 ids, such as `eip155:8453`. */
29
+ networks: string[];
30
+ }
31
+ /**
32
+ * Why the signer didn't sign. `refused`: the payment breaks the budget or the gate couldn't be
33
+ * reached (it fails closed). `invalid`: not an x402 USDC payment from this signer, or malformed.
34
+ * `unauthorized`: a missing or wrong token. `error`: something broke in the signer.
35
+ */
36
+ export interface X402SignerProblem {
37
+ error: "refused" | "invalid" | "unauthorized" | "not_found" | "error";
38
+ reason: string;
39
+ }
40
+ /** The error codes `remoteX402Signer` throws; all of them mean nothing was signed. */
41
+ export declare const X402_SIGNER_ERROR_CODES: readonly ["SIGNER_REFUSED", "SIGNER_UNAUTHORIZED", "SIGNER_UNAVAILABLE"];
42
+ export interface RemoteX402SignerOptions {
43
+ /** Where the signer runs, such as `http://bonded-signer:8402`. */
44
+ url: string;
45
+ /** One of the signer's tokens. It can only ask for x402 payments, never move money itself. */
46
+ token: string;
47
+ /**
48
+ * How long to wait for each request, in milliseconds (default 35,000). The signer may need to
49
+ * open a budget with the gate (up to two tries of 10 seconds) and then wait for its KMS (up to
50
+ * 10 seconds); giving up sooner would make the agent retry a payment the signer still counts.
51
+ */
52
+ timeoutMs?: number;
53
+ fetch?: typeof fetch;
54
+ }
55
+ /** What an x402 client needs from a signer (`ClientEvmSigner` in `@x402/evm`). */
56
+ export interface RemoteX402Signer {
57
+ readonly address: Address;
58
+ readonly networks: readonly string[];
59
+ signTypedData(typedData: {
60
+ domain: Record<string, unknown>;
61
+ types: Record<string, unknown>;
62
+ primaryType: string;
63
+ message: Record<string, unknown>;
64
+ }): Promise<Hex>;
65
+ }
66
+ /**
67
+ * An x402 signer whose key stays on a server the agent can't reach: `@bondedhq/signer`. Pass it to
68
+ * `ExactEvmScheme` in place of a local account. Each payment goes to the signer, which checks it
69
+ * against the gate's budget for its payee and signs only if it fits, so code in the agent's process
70
+ * can't pay without the check.
71
+ *
72
+ * Nothing is signed when the signer refuses, can't be reached or doesn't accept the token: the
73
+ * x402 client's request fails with a `BondedError` (`SIGNER_REFUSED`, `SIGNER_UNAVAILABLE`,
74
+ * `SIGNER_UNAUTHORIZED`, or `INVALID_PAYMENT`), which `isPaymentRefusal` recognizes.
75
+ */
76
+ export declare function remoteX402Signer(options: RemoteX402SignerOptions): Promise<RemoteX402Signer>;
77
+ //# sourceMappingURL=x402-remote.d.ts.map
@@ -0,0 +1,88 @@
1
+ import { BondedError } from "./errors.js";
2
+ /**
3
+ * The wire format between an agent and `@bondedhq/signer`, the x402 signer a business runs on its
4
+ * own server. The agent never holds the wallet key: it sends each x402 payment's EIP-712 request
5
+ * to the signer, which checks it against a budget and signs only if it fits.
6
+ */
7
+ export const X402_SIGNER_PATHS = {
8
+ /** `GET`: the signer's address and the networks it pays on (`X402SignerInfo`). */
9
+ info: "/v1/x402/signer",
10
+ /** `POST` an `X402SignRequest`; answers an `X402SignResponse`, or an `X402SignerProblem`. */
11
+ sign: "/v1/x402/sign",
12
+ };
13
+ /** The error codes `remoteX402Signer` throws; all of them mean nothing was signed. */
14
+ export const X402_SIGNER_ERROR_CODES = [
15
+ "SIGNER_REFUSED",
16
+ "SIGNER_UNAUTHORIZED",
17
+ "SIGNER_UNAVAILABLE",
18
+ ];
19
+ /**
20
+ * An x402 signer whose key stays on a server the agent can't reach: `@bondedhq/signer`. Pass it to
21
+ * `ExactEvmScheme` in place of a local account. Each payment goes to the signer, which checks it
22
+ * against the gate's budget for its payee and signs only if it fits, so code in the agent's process
23
+ * can't pay without the check.
24
+ *
25
+ * Nothing is signed when the signer refuses, can't be reached or doesn't accept the token: the
26
+ * x402 client's request fails with a `BondedError` (`SIGNER_REFUSED`, `SIGNER_UNAVAILABLE`,
27
+ * `SIGNER_UNAUTHORIZED`, or `INVALID_PAYMENT`), which `isPaymentRefusal` recognizes.
28
+ */
29
+ export async function remoteX402Signer(options) {
30
+ const base = options.url.replace(/\/+$/, "");
31
+ const fetchFn = options.fetch ?? fetch;
32
+ const timeoutMs = options.timeoutMs ?? 35_000;
33
+ async function call(path, init = {}) {
34
+ let response;
35
+ try {
36
+ response = await fetchFn(`${base}${path}`, {
37
+ ...init,
38
+ headers: {
39
+ ...(init.body ? { "content-type": "application/json" } : {}),
40
+ authorization: `Bearer ${options.token}`,
41
+ },
42
+ signal: AbortSignal.timeout(timeoutMs),
43
+ });
44
+ }
45
+ catch (error) {
46
+ throw new BondedError("SIGNER_UNAVAILABLE", `the x402 signer can't be reached at ${base}`, {
47
+ cause: error,
48
+ });
49
+ }
50
+ const text = await response.text();
51
+ let body;
52
+ try {
53
+ body = JSON.parse(text);
54
+ }
55
+ catch {
56
+ body = undefined;
57
+ }
58
+ if (response.ok && body && typeof body === "object")
59
+ return body;
60
+ const problem = body;
61
+ const reason = typeof problem?.reason === "string" ? problem.reason : `HTTP ${response.status}`;
62
+ if (response.status === 401 || problem?.error === "unauthorized")
63
+ throw new BondedError("SIGNER_UNAUTHORIZED", `the x402 signer refused the token: ${reason}`);
64
+ if (problem?.error === "refused")
65
+ throw new BondedError("SIGNER_REFUSED", `the x402 signer refused the payment: ${reason}`);
66
+ if (problem?.error === "invalid")
67
+ throw new BondedError("INVALID_PAYMENT", `the x402 signer won't sign this: ${reason}`);
68
+ throw new BondedError("SIGNER_UNAVAILABLE", `the x402 signer failed: ${reason}`);
69
+ }
70
+ const info = await call(X402_SIGNER_PATHS.info);
71
+ if (typeof info.address !== "string" || !/^0x[0-9a-fA-F]{40}$/.test(info.address))
72
+ throw new BondedError("SIGNER_UNAVAILABLE", "the x402 signer sent no valid address");
73
+ return {
74
+ address: info.address,
75
+ networks: Array.isArray(info.networks) ? [...info.networks] : [],
76
+ async signTypedData(typedData) {
77
+ const request = { typedData };
78
+ const { signature } = await call(X402_SIGNER_PATHS.sign, {
79
+ method: "POST",
80
+ body: JSON.stringify(request, (_key, value) => typeof value === "bigint" ? value.toString() : value),
81
+ });
82
+ if (typeof signature !== "string" || !/^0x[0-9a-fA-F]+$/.test(signature))
83
+ throw new BondedError("SIGNER_UNAVAILABLE", "the x402 signer sent no valid signature");
84
+ return signature;
85
+ },
86
+ };
87
+ }
88
+ //# sourceMappingURL=x402-remote.js.map
package/dist/x402.d.ts CHANGED
@@ -133,4 +133,5 @@ export declare function x402Price(r: X402Requirements, assets?: readonly X402Ass
133
133
  account: string;
134
134
  amountMicros: number;
135
135
  };
136
+ export { remoteX402Signer, X402_SIGNER_ERROR_CODES, X402_SIGNER_PATHS, type RemoteX402Signer, type RemoteX402SignerOptions, type X402SignerInfo, type X402SignerProblem, type X402SignRequest, type X402SignResponse, type X402TypedData, } from "./x402-remote.js";
136
137
  //# sourceMappingURL=x402.d.ts.map
package/dist/x402.js CHANGED
@@ -179,4 +179,5 @@ export function x402Price(r, assets = X402_USD_ASSETS) {
179
179
  throw new BondedError("INVALID_PAYMENT", "the x402 price is too large");
180
180
  return { account: r.payTo, amountMicros: Number(micros) };
181
181
  }
182
+ export { remoteX402Signer, X402_SIGNER_ERROR_CODES, X402_SIGNER_PATHS, } from "./x402-remote.js";
182
183
  //# sourceMappingURL=x402.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bondedhq/sdk",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Trade through a Bonded Mandate Vault and get rated by the Bonded Arena from any TypeScript agent: a typed vault client with dry runs, Sentinel checks and a mandate builder; the Arena episode's tools for LangChain, the Vercel AI SDK, ElizaOS and Coinbase AgentKit; a payment guard for pay tools (LangChain, the AI SDK, MCP, the OpenAI Agents SDK) and x402 clients; and an Agent URL handler.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -94,7 +94,7 @@
94
94
  "@modelcontextprotocol/sdk": "1.30.1",
95
95
  "viem": "^2.56.9",
96
96
  "zod": "^4.6.5",
97
- "@bondedhq/shared": "^0.1.5"
97
+ "@bondedhq/shared": "^0.1.6"
98
98
  },
99
99
  "devDependencies": {
100
100
  "@coinbase/agentkit": "0.10.4",