@haven_ai/signer 0.4.0-alpha.0 → 0.6.0-alpha.0

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/dist/cli.js CHANGED
@@ -1,25 +1,139 @@
1
1
  #!/usr/bin/env node
2
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
2
  import { randomUUID, createHash } from 'crypto';
5
- import { readFile, mkdir, writeFile, stat, appendFile } from 'fs/promises';
6
- import { homedir } from 'os';
3
+ import { readFile, mkdir, writeFile, stat, lstat, rename, appendFile, chmod } from 'fs/promises';
7
4
  import { resolve, dirname, join } from 'path';
8
- import { createNextStepBuilder, isSupportedNodeVersion, unsupportedNodeVersionMessage, addressFromKey, HavenSigningError, buildSweepTypedData, selectStandardPaymentOption, HavenApiError, toStandardPaymentRequirements, decodeBase64Json, encodeBase64Json, x402V2PaymentEnvelope, connectorRerunCommand, buildSweepAuthorizationMessage, verifySignature, HavenError, AgentPaymentFailureCode, x402AuthorizationAmount, buildX402ExpectedMessage, signHash, SignerRefusalCode, SIGNER_UPDATE_FALLBACK, HavenUnsupportedSignerVersionError, AgentPaymentNextAction } from '@haven_ai/sdk';
5
+ import { createNextStepBuilder, havenClientIdentity, isSupportedNodeVersion, unsupportedNodeVersionMessage, addressFromKey, HavenSigningError, buildSweepTypedData, selectStandardPaymentOption, HavenApiError, toStandardPaymentRequirements, decodeBase64Json, encodeBase64Json, x402V2PaymentEnvelope, isSettlementChildTypedData, chainIdForNetwork, assertOwnSettlementChild, isPackedUserOperationTypedData, assertUserOpTypedDataBinding, assertBoundDirectPaymentUserOp as assertBoundDirectPaymentUserOp$1, assertFundingLegPaysDelegate, HavenTypedDataRefusedError, connectorRerunCommand, buildSweepAuthorizationMessage, verifySignature, x402AuthorizationAmount, SignerRefusalCode, SIGNER_UPDATE_FALLBACK, HavenUnsupportedSignerVersionError, buildX402ExpectedMessage, DIRECT_SIGN_CONTEXT_VERSION, HavenError, AgentPaymentFailureCode, AgentPaymentNextAction, HavenUserOpBindingError, assertOwnTaskChild, assertOwnTaskBudgetCloseUserOp, readClientUpdate, HAVEN_CLIENT_HEADER } from '@haven_ai/sdk/edge';
9
6
  import { recoverTypedDataAddress, hashTypedData, hashMessage } from 'viem';
10
- import { privateKeyToAccount } from 'viem/accounts';
11
- import { exact } from 'x402/schemes';
12
7
  import { z as z$1 } from 'zod/v3';
13
8
  import { z } from 'zod';
9
+ import { homedir } from 'os';
10
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
11
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
12
+ import { privateKeyToAccount } from 'viem/accounts';
13
+
14
+ var SIGNER_HOSTED_HANDOFF_SHAPES = {
15
+ haven_get_payment_status: { payment_id: z.string().min(1) }
16
+ };
17
+ function target(shape) {
18
+ const schema = z.object(shape).strict();
19
+ return {
20
+ role: "hosted",
21
+ validate: (input) => {
22
+ const r = schema.safeParse(input ?? {});
23
+ return r.success ? null : r.error.errors.map((e) => `${e.path.join(".") || "(root)"}: ${e.message}`).join("; ");
24
+ }
25
+ };
26
+ }
27
+ var SIGNER_NEXT_STEP_TARGETS = {
28
+ haven_get_payment_status: target(SIGNER_HOSTED_HANDOFF_SHAPES.haven_get_payment_status)
29
+ };
30
+ var nextStep = createNextStepBuilder(SIGNER_NEXT_STEP_TARGETS);
31
+ function signerRefusalStep(input) {
32
+ return nextStep({ ...input, safeToContinue: false, reason: "" });
33
+ }
34
+ function nextStepWireFields(step) {
35
+ return {
36
+ ...step.next_tool ? { next_tool: step.next_tool } : {},
37
+ ...step.next_tool_server ? { next_tool_server: step.next_tool_server } : {},
38
+ ...step.next_tool_name ? { next_tool_name: step.next_tool_name } : {},
39
+ ...step.next_tool_server_role ? { next_tool_server_role: step.next_tool_server_role } : {},
40
+ ...step.next_arguments ? { next_arguments: step.next_arguments } : {},
41
+ ...step.next_tool_omitted_reason ? { next_tool_omitted_reason: step.next_tool_omitted_reason } : {}
42
+ };
43
+ }
44
+
45
+ // src/bare-hash.ts
46
+ var BARE_HASH_REFUSED = "BARE_HASH_REFUSED";
47
+ var HavenBareHashRefusedError = class extends HavenSigningError {
48
+ next_action = AgentPaymentNextAction.StopAndTellUser;
49
+ step;
50
+ constructor() {
51
+ super(
52
+ "Refusing to sign a bare payload_hash. A hash carries no shape, amount, merchant or binding, so this signer cannot tell a Haven payment from a transfer out of the delegate wallet or a delegation of its authority \u2014 and the only rail that ever validated a raw hash signature is retired. Call this tool with { payment_id } (the signer fetches and verifies the Haven-signed context itself), or with typed_data / typed_data_b64 (the account validates that EIP-712 payload), or with x402_expected for an x402 funding leg."
53
+ );
54
+ this.code = BARE_HASH_REFUSED;
55
+ this.name = "HavenBareHashRefusedError";
56
+ this.step = signerRefusalStep({
57
+ nextAction: AgentPaymentNextAction.StopAndTellUser,
58
+ nextTool: null,
59
+ nextToolOmittedReason: "call haven_sign again with payment_id (preferred), typed_data / typed_data_b64, or x402_expected \u2014 a bare payload_hash is never signed"
60
+ });
61
+ }
62
+ };
63
+ var OWNER_ONLY_MODE = 384;
64
+ var defaultLog = (message) => process.stderr.write(`${message}
65
+ `);
66
+ async function permissiveMode(path, platform = process.platform, stats, follow = "nofollow") {
67
+ if (platform === "win32") return null;
68
+ let info;
69
+ try {
70
+ info = stats ?? (follow === "follow" ? await stat(path) : await lstat(path));
71
+ } catch {
72
+ return null;
73
+ }
74
+ if ((info.mode & 63) === 0) return null;
75
+ return { octal: (info.mode & 511).toString(8).padStart(4, "0"), regular: info.isFile() };
76
+ }
77
+ async function warnIfFilePermissive(kind, path, log = defaultLog, platform = process.platform) {
78
+ const found = await permissiveMode(path, platform, void 0, "follow");
79
+ if (found === null) return;
80
+ log(
81
+ `haven-signer: warning: ${kind} at ${path} is readable beyond the owner (mode ${found.octal}). Run: chmod 600 ${path}`
82
+ );
83
+ }
84
+ async function tightenIfFilePermissive(kind, path, log = defaultLog, platform = process.platform, stats) {
85
+ if (platform === "win32") return "skipped";
86
+ let info = stats ?? null;
87
+ if (!info) {
88
+ try {
89
+ info = await lstat(path);
90
+ } catch {
91
+ return "owner-only";
92
+ }
93
+ }
94
+ if (!info.isFile()) {
95
+ log(
96
+ `haven-signer: warning: ${kind} at ${path} is not a regular file (a symlink or similar); audit rows are being written through it and it was not tightened. Remove it: rm ${path}`
97
+ );
98
+ return "warned";
99
+ }
100
+ const found = await permissiveMode(path, platform, info);
101
+ if (found === null) return "owner-only";
102
+ {
103
+ try {
104
+ await chmod(path, OWNER_ONLY_MODE);
105
+ log(
106
+ `haven-signer: ${kind} at ${path} was readable beyond the owner (mode ${found.octal}); tightened to 0600.`
107
+ );
108
+ return "tightened";
109
+ } catch {
110
+ }
111
+ }
112
+ log(
113
+ `haven-signer: warning: ${kind} at ${path} is readable beyond the owner (mode ${found.octal}) and could not be tightened. Run: chmod 600 ${path}`
114
+ );
115
+ return "warned";
116
+ }
14
117
 
118
+ // src/audit.ts
119
+ var AUDIT_ROTATE_BYTES = 8 * 1024 * 1024;
15
120
  function defaultSigningAuditPath(credentialsPath) {
16
121
  if (credentialsPath) return resolve(`${credentialsPath}.signer-audit.jsonl`);
17
122
  return resolve(homedir(), ".haven", "signer-audit.jsonl");
18
123
  }
19
- async function appendSigningAuditEntry(entry, path) {
124
+ async function appendSigningAuditEntry(entry, path, options = {}) {
20
125
  await mkdir(dirname(path), { recursive: true });
126
+ const rotateAt = options.rotateAtBytes ?? AUDIT_ROTATE_BYTES;
127
+ const existing = await lstat(path).catch(() => null);
128
+ if (existing) {
129
+ await tightenIfFilePermissive("audit sidecar", path, options.log, options.platform, existing);
130
+ if (existing.size >= rotateAt) {
131
+ await rename(path, `${path}.1`).catch(() => {
132
+ });
133
+ }
134
+ }
21
135
  await appendFile(path, `${JSON.stringify(entry)}
22
- `, "utf8");
136
+ `, { encoding: "utf8", mode: OWNER_ONLY_MODE });
23
137
  }
24
138
  function createSigningAuditEntry(tool, payloadHash, context, now = /* @__PURE__ */ new Date()) {
25
139
  const entry = {
@@ -45,1278 +159,1664 @@ function stableStringify(value) {
45
159
  const object = value;
46
160
  return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(object[key])}`).join(",")}}`;
47
161
  }
48
- var DELEGATION_MANAGER = "0xdb9B1e94B5b69Df7e401DDbedE43491141047dB3";
49
- var CAVEAT_ENFORCERS = {
50
- erc20TransferAmount: "0xf100b0819427117EcF76Ed94B358B1A5b5C6D2Fc",
51
- allowedCalldata: "0xc2b0d624c1c4319760C96503BA27C347F3260f55",
52
- timestamp: "0x1046bb45C8d673d4ea75321280DB34899413c069"};
53
- var MAX_SETTLEMENT_WINDOW_SECONDS = 600;
54
- function isSettlementChildTypedData(value) {
55
- const td = value;
56
- return !!td && td.primaryType === "Delegation" && typeof td.domain?.verifyingContract === "string" && Array.isArray(td.message?.caveats);
57
- }
58
- function same(a, b) {
59
- return !!a && !!b && a.toLowerCase() === b.toLowerCase();
60
- }
61
- function hex(value) {
62
- return value.startsWith("0x") ? value.slice(2).toLowerCase() : value.toLowerCase();
63
- }
64
- function slice(terms, start, end) {
65
- const body = hex(terms);
66
- return end === void 0 ? body.slice(start * 2) : body.slice(start * 2, end * 2);
162
+ var DIRECT_RELAY_FALLBACK = "call haven_sign with payload_hash and typed_data_b64 from the haven_send / haven_pay result, passed through unchanged";
163
+ var HavenSignContextError = class extends HavenSigningError {
164
+ /**
165
+ * #3001 / #3010 review: the recovery path, when signing OTHER bytes is
166
+ * actually a remedy — a transport failure (timeout, unreachable) or a
167
+ * body this signer could not read. `typed_data_b64` is NOT in the default
168
+ * quote result since #1272: obtain it by re-running the SAME quote tool
169
+ * with the SAME idempotency_key plus `include_signing_payload: true`.
170
+ * Absent on a backend REFUSAL (an expired, executed or unsignable intent
171
+ * cannot be rescued by re-signing its bytes) — with one #3271 exception: a
172
+ * 404 from the DIRECT fetch (an older backend with no direct route), where
173
+ * the `haven_send` / `haven_pay` result's relay fields still work.
174
+ */
175
+ fallback;
176
+ /**
177
+ * #3001: `AgentPaymentNextAction` values the signer already emits — the
178
+ * version-mismatch refusal's `stop_and_tell_user` for the classes where
179
+ * retrying the same call cannot help, and `payment_window_expired` (with
180
+ * `retry_with_new_quote`) for the x402 fetch's 410 / `expired`, exactly as
181
+ * the plain `HavenError` branch already does for that code. On the direct
182
+ * fetch (#3271) only `error_code: 'expired'` means an expired window, with
183
+ * no `retry_with_new_quote`; a bare 410 there is a retired-rail tombstone.
184
+ */
185
+ next_action;
186
+ /**
187
+ * #3103 (epic #3105, decisions 1 and 3): the typed next step beside the
188
+ * action. A refusal Haven made (`SIGN_CONTEXT_REFUSED`, not expired) names
189
+ * the hosted status read with the payment id — except a 404 from the direct
190
+ * fetch (#3271), which names none and points at the `typed_data_b64`
191
+ * relay; a transport failure or a
192
+ * malformed body names no tool — the remedy is re-running the SAME quote
193
+ * tool with `include_signing_payload: true` (x402), or the payment
194
+ * result's `typed_data_b64` relay (direct, #3271) — and an expired window names
195
+ * none either (which quote tool depends on the flow). Never null: a step
196
+ * with no tool carries `next_tool_omitted_reason`. Additive: the class is
197
+ * not exported from the package index — the published surface is the
198
+ * `ToolFailure` envelope `tools.ts` builds from these fields — and every
199
+ * constructor call site is in this file.
200
+ */
201
+ next_tool;
202
+ next_tool_server;
203
+ next_tool_name;
204
+ next_tool_server_role;
205
+ next_arguments;
206
+ next_tool_omitted_reason;
207
+ retry_with_new_quote;
208
+ /** Present only for `SIGN_CONTEXT_REFUSED` (the backend's HTTP status). */
209
+ http_status;
210
+ /** Present only for `SIGN_CONTEXT_REFUSED`: the backend's own `error_code`. */
211
+ backend_error_code;
212
+ /**
213
+ * #3303: on a 426 `client_outdated` refusal, the backend's update hint —
214
+ * this signer is below the minimum the deployment set, and
215
+ * `client_update.upgrade_command` is what updates it.
216
+ */
217
+ client_update;
218
+ constructor(message, code, refusal, paymentId, flow = "x402") {
219
+ super(message);
220
+ this.code = code;
221
+ this.name = "HavenSignContextError";
222
+ let step;
223
+ if (code === "SIGN_CONTEXT_REFUSED") {
224
+ this.http_status = refusal?.httpStatus;
225
+ this.backend_error_code = refusal?.errorCode;
226
+ if (refusal?.errorCode === "client_outdated") {
227
+ this.client_update = refusal.clientUpdate;
228
+ this.next_action = AgentPaymentNextAction.StopAndTellUser;
229
+ step = signerRefusalStep({
230
+ nextAction: AgentPaymentNextAction.StopAndTellUser,
231
+ nextTool: null,
232
+ nextToolOmittedReason: `this signer is below the minimum version Haven accepts \u2014 tell the user to run ${refusal.clientUpdate?.upgrade_command ?? "the connector again"}, restart the agent runtime, then retry the same haven_sign / haven_sign_x402 call`
233
+ });
234
+ } else if (flow === "direct" && refusal?.httpStatus === 404) {
235
+ this.fallback = "typed_data_b64";
236
+ this.next_action = AgentPaymentNextAction.StopAndTellUser;
237
+ step = signerRefusalStep({
238
+ nextAction: AgentPaymentNextAction.StopAndTellUser,
239
+ nextTool: null,
240
+ nextToolOmittedReason: DIRECT_RELAY_FALLBACK
241
+ });
242
+ } else if (flow === "direct" && refusal?.errorCode === "expired") {
243
+ this.next_action = AgentPaymentNextAction.PaymentWindowExpired;
244
+ step = signerRefusalStep({
245
+ nextAction: AgentPaymentNextAction.PaymentWindowExpired,
246
+ nextTool: null,
247
+ nextToolOmittedReason: "call haven_send / haven_pay again with the same idempotency_key \u2014 the expired payment frees it"
248
+ });
249
+ } else if (flow === "x402" && (refusal?.httpStatus === 410 || refusal?.errorCode === "expired")) {
250
+ this.next_action = AgentPaymentNextAction.PaymentWindowExpired;
251
+ this.retry_with_new_quote = true;
252
+ step = signerRefusalStep({
253
+ nextAction: AgentPaymentNextAction.PaymentWindowExpired,
254
+ nextTool: null,
255
+ nextToolOmittedReason: "re-run the hosted quote tool you called with the same idempotency_key; which one depends on the flow"
256
+ });
257
+ } else if (flow === "task") {
258
+ this.next_action = AgentPaymentNextAction.StopAndTellUser;
259
+ step = signerRefusalStep({
260
+ nextAction: AgentPaymentNextAction.StopAndTellUser,
261
+ nextTool: null,
262
+ nextToolOmittedReason: `re-check this task budget's status (id ${paymentId}) with the hosted Haven server before retrying`
263
+ });
264
+ } else {
265
+ this.next_action = AgentPaymentNextAction.StopAndTellUser;
266
+ step = signerRefusalStep({
267
+ nextAction: AgentPaymentNextAction.StopAndTellUser,
268
+ nextTool: "haven_get_payment_status",
269
+ nextArguments: { payment_id: paymentId }
270
+ });
271
+ }
272
+ } else if (flow === "task") {
273
+ this.next_action = AgentPaymentNextAction.StopAndTellUser;
274
+ step = signerRefusalStep({
275
+ nextAction: AgentPaymentNextAction.StopAndTellUser,
276
+ nextTool: null,
277
+ nextToolOmittedReason: "retry the same haven_sign call with the same task_budget_id"
278
+ });
279
+ } else {
280
+ this.fallback = "typed_data_b64";
281
+ this.next_action = AgentPaymentNextAction.StopAndTellUser;
282
+ step = signerRefusalStep({
283
+ nextAction: AgentPaymentNextAction.StopAndTellUser,
284
+ nextTool: null,
285
+ nextToolOmittedReason: flow === "direct" ? DIRECT_RELAY_FALLBACK : "re-run the SAME hosted quote tool with the same idempotency_key and include_signing_payload: true, then pass its typed_data_b64 to this signer"
286
+ });
287
+ }
288
+ Object.assign(this, nextStepWireFields(step));
289
+ }
290
+ };
291
+ async function loadHavenIdentity(credentialsPath) {
292
+ if (!credentialsPath) return null;
293
+ try {
294
+ const raw = JSON.parse(
295
+ await readFile(join(dirname(credentialsPath), "identity.json"), "utf8")
296
+ );
297
+ const apiKey = typeof raw.api_key === "string" ? raw.api_key : void 0;
298
+ const apiUrl = typeof raw.api_url === "string" ? raw.api_url : void 0;
299
+ if (!apiKey || !apiUrl) return null;
300
+ return { apiKey, apiUrl: apiUrl.replace(/\/+$/, "") };
301
+ } catch {
302
+ return null;
303
+ }
67
304
  }
68
- function findCaveat(caveats, enforcer) {
69
- return caveats.find((c) => same(c.enforcer, enforcer));
305
+ var SIGN_CONTEXT_TIMEOUT_MS = 15e3;
306
+ function signContextHeaders(identity, clientIdentity) {
307
+ return {
308
+ Authorization: `Bearer ${identity.apiKey}`,
309
+ ...clientIdentity ? { [HAVEN_CLIENT_HEADER]: clientIdentity } : {}
310
+ };
70
311
  }
71
- function refuse(what, detail) {
72
- throw new HavenSigningError(
73
- `Refusing to sign the x402 settlement child: ${what}. ${detail} The signature on this child is what lets a merchant pull from the treasury, so it is verified locally against the Haven-signed expected context rather than trusted.`
74
- );
312
+ function refusalOf(status, body) {
313
+ return {
314
+ httpStatus: status,
315
+ errorCode: typeof body.error_code === "string" ? body.error_code : void 0,
316
+ clientUpdate: readClientUpdate(body.client_update)
317
+ };
75
318
  }
76
- function chainIdForNetwork(network) {
77
- if (!network) return void 0;
78
- const caip = /^eip155:(\d+)$/.exec(network);
79
- if (caip) return Number(caip[1]);
80
- if (network === "base") return 8453;
81
- if (network === "base-sepolia") return 84532;
82
- return void 0;
83
- }
84
- function verifySettlementChild(typedData, expected, now = Date.now()) {
85
- if (typedData.primaryType !== "Delegation") {
86
- refuse("it is not a Delegation payload", `primaryType was '${typedData.primaryType}'.`);
87
- }
88
- if (!same(typedData.domain?.verifyingContract, DELEGATION_MANAGER)) {
89
- refuse(
90
- "the EIP-712 domain names an unknown DelegationManager",
91
- `Expected ${DELEGATION_MANAGER}, got ${typedData.domain?.verifyingContract}.`
92
- );
93
- }
94
- const domainChain = Number(typedData.domain?.chainId);
95
- if (!Number.isFinite(domainChain) || domainChain !== expected.chainId) {
96
- refuse(
97
- "it is scoped to the wrong chain",
98
- `The expected context says chain ${expected.chainId}; the child says ${typedData.domain?.chainId}.`
319
+ async function fetchX402SignContext(identity, paymentId, fetchImpl = fetch, timeoutMs = SIGN_CONTEXT_TIMEOUT_MS, clientIdentity) {
320
+ let response;
321
+ try {
322
+ response = await fetchImpl(
323
+ `${identity.apiUrl}/x402/${encodeURIComponent(paymentId)}/sign-context`,
324
+ {
325
+ headers: signContextHeaders(identity, clientIdentity),
326
+ signal: AbortSignal.timeout(timeoutMs)
327
+ }
99
328
  );
100
- }
101
- const caveats = typedData.message?.caveats ?? [];
102
- if (caveats.length === 0) {
103
- refuse("it carries no caveats at all", "An unconstrained delegation is not a payment.");
104
- }
105
- const transfer = findCaveat(caveats, CAVEAT_ENFORCERS.erc20TransferAmount);
106
- if (!transfer) {
107
- refuse(
108
- "it has no ERC-20 transfer-amount caveat",
109
- "Without it the delegation is not bounded to an amount."
329
+ } catch (err) {
330
+ const timedOut = err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError");
331
+ throw new HavenSignContextError(
332
+ timedOut ? `Haven did not answer the signing-context fetch for ${paymentId} within ${timeoutMs} ms. Retry, or pass typed_data_b64 from the quote result instead.` : `Could not reach Haven to fetch the signing context for ${paymentId}: ${err instanceof Error ? err.message : String(err)}. Retry, or pass typed_data_b64 from the quote result instead.`,
333
+ timedOut ? "SIGN_CONTEXT_TIMEOUT" : "SIGN_CONTEXT_UNREACHABLE",
334
+ void 0,
335
+ paymentId
110
336
  );
111
337
  }
112
- const token = `0x${slice(transfer.terms, 0, 20)}`;
113
- if (!same(token, expected.asset)) {
114
- refuse("it spends a different token", `Expected ${expected.asset}, child pins ${token}.`);
338
+ let body;
339
+ try {
340
+ body = await response.json();
341
+ } catch (err) {
342
+ if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
343
+ throw new HavenSignContextError(
344
+ `Haven did not finish sending the signing context for ${paymentId} within ${timeoutMs} ms. Retry, or pass typed_data_b64 from the quote result instead.`,
345
+ "SIGN_CONTEXT_TIMEOUT",
346
+ void 0,
347
+ paymentId
348
+ );
349
+ }
350
+ body = {};
115
351
  }
116
- const amount = BigInt(`0x${slice(transfer.terms, 20, 52)}`);
117
- if (amount !== BigInt(expected.amount)) {
118
- refuse(
119
- "the amount does not match",
120
- `The expected context says ${expected.amount}; the child allows ${amount.toString()}.`
352
+ if (!response.ok) {
353
+ const detail = typeof body.error === "string" ? body.error : `HTTP ${response.status}`;
354
+ throw new HavenSignContextError(
355
+ `Haven refused the signing-context fetch for ${paymentId}: ${detail}` + (response.status === 404 ? " \u2014 check the payment_id came from this agent\u2019s own quote." : response.status === 410 ? " Re-run the quote with the same idempotency key, then sign the fresh payment_id." : ""),
356
+ "SIGN_CONTEXT_REFUSED",
357
+ refusalOf(response.status, body),
358
+ paymentId
121
359
  );
122
360
  }
123
- const calldata = findCaveat(caveats, CAVEAT_ENFORCERS.allowedCalldata);
124
- if (!calldata) {
125
- refuse(
126
- "it has no payee pin",
127
- "Without an allowed-calldata caveat the merchant could redeem it to any address."
361
+ const signData = body.sign_data;
362
+ const x402Expected = body.x402_expected;
363
+ if (!signData || typeof signData.hash !== "string" || !signData.typed_data || typeof signData.typed_data !== "object" || !x402Expected) {
364
+ throw new HavenSignContextError(
365
+ "The Haven sign-context response is missing sign_data.typed_data or x402_expected \u2014 the backend may predate #1263. Pass typed_data_b64 from the quote result instead.",
366
+ "SIGN_CONTEXT_MALFORMED",
367
+ void 0,
368
+ paymentId
128
369
  );
129
370
  }
130
- const startIndex = BigInt(`0x${slice(calldata.terms, 0, 32)}`);
131
- if (startIndex !== 4n) {
132
- refuse(
133
- "the payee pin points at the wrong calldata offset",
134
- `Expected offset 4 (the transfer's \`to\` word), got ${startIndex.toString()}.`
371
+ const paymentRequired = body.payment_required;
372
+ return {
373
+ paymentId: String(body.payment_id ?? paymentId),
374
+ payloadHash: signData.hash,
375
+ typedData: signData.typed_data,
376
+ x402Expected,
377
+ paymentRequired: paymentRequired && typeof paymentRequired === "object" && !Array.isArray(paymentRequired) ? paymentRequired : null,
378
+ clientUpdate: readClientUpdate(body.client_update)
379
+ };
380
+ }
381
+ var SUPPORTED_DIRECT_SIGN_CONTEXT_VERSIONS = [DIRECT_SIGN_CONTEXT_VERSION];
382
+ async function fetchDirectSignContext(identity, paymentId, fetchImpl = fetch, timeoutMs = SIGN_CONTEXT_TIMEOUT_MS, clientIdentity) {
383
+ let response;
384
+ try {
385
+ response = await fetchImpl(
386
+ `${identity.apiUrl}/payments/${encodeURIComponent(paymentId)}/sign-context`,
387
+ {
388
+ headers: signContextHeaders(identity, clientIdentity),
389
+ signal: AbortSignal.timeout(timeoutMs)
390
+ }
135
391
  );
136
- }
137
- const paddedPayee = slice(calldata.terms, 32, 64);
138
- if (paddedPayee.slice(0, 24) !== "0".repeat(24)) {
139
- refuse("the pinned payee is not a plain address", "Its 32-byte word is not a padded address.");
140
- }
141
- const payee = `0x${paddedPayee.slice(24)}`;
142
- if (!same(payee, expected.merchantTo)) {
143
- refuse(
144
- "it pays a different address than Haven declared",
145
- `The expected context says ${expected.merchantTo}; the child pins ${payee}.`
392
+ } catch (err) {
393
+ const timedOut = err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError");
394
+ throw new HavenSignContextError(
395
+ timedOut ? `Haven did not answer the direct-payment signing-context fetch for ${paymentId} within ${timeoutMs} ms. Retry, or pass typed_data_b64 from the payment result instead.` : `Could not reach Haven to fetch the direct-payment signing context for ${paymentId}: ${err instanceof Error ? err.message : String(err)}. Retry, or pass typed_data_b64 from the payment result instead.`,
396
+ timedOut ? "SIGN_CONTEXT_TIMEOUT" : "SIGN_CONTEXT_UNREACHABLE",
397
+ void 0,
398
+ paymentId,
399
+ "direct"
146
400
  );
147
401
  }
148
- const timestamp = findCaveat(caveats, CAVEAT_ENFORCERS.timestamp);
149
- if (!timestamp) {
150
- refuse("it never expires", "A settlement child without a timestamp caveat is open-ended.");
151
- }
152
- const beforeThreshold = Number(BigInt(`0x${slice(timestamp.terms, 16, 32)}`));
153
- if (beforeThreshold <= 0) {
154
- refuse("its expiry is unset", "The timestamp caveat has no upper bound.");
155
- }
156
- const nowSec = Math.floor(now / 1e3);
157
- if (beforeThreshold <= nowSec) {
158
- refuse("it has already expired", `Expiry ${beforeThreshold} is not in the future.`);
159
- }
160
- if (beforeThreshold > nowSec + MAX_SETTLEMENT_WINDOW_SECONDS) {
161
- refuse(
162
- "its window is longer than a settlement may live",
163
- `Expiry is ${beforeThreshold - nowSec}s out; the ceiling is ${MAX_SETTLEMENT_WINDOW_SECONDS}s.`
164
- );
165
- }
166
- if (expected.expiresAt) {
167
- const declared = Math.floor(Date.parse(expected.expiresAt) / 1e3);
168
- if (Number.isFinite(declared) && beforeThreshold > declared) {
169
- refuse(
170
- "it outlives the payment window Haven declared",
171
- `The expected context expires at ${expected.expiresAt}; the child lives to ${beforeThreshold}.`
402
+ let body;
403
+ try {
404
+ body = await response.json();
405
+ } catch (err) {
406
+ if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
407
+ throw new HavenSignContextError(
408
+ `Haven did not finish sending the direct-payment signing context for ${paymentId} within ${timeoutMs} ms. Retry, or pass typed_data_b64 from the payment result instead.`,
409
+ "SIGN_CONTEXT_TIMEOUT",
410
+ void 0,
411
+ paymentId,
412
+ "direct"
172
413
  );
173
414
  }
415
+ body = {};
174
416
  }
175
- }
176
- function createEdgeSigner(delegateKey, options = {}) {
177
- let delegateAddress;
178
- try {
179
- delegateAddress = addressFromKey(delegateKey);
180
- } catch (err) {
181
- throw new HavenSigningError(
182
- `Invalid delegate key: ${err instanceof Error ? err.message : String(err)}`
417
+ if (!response.ok) {
418
+ const detail = typeof body.error === "string" ? body.error : `HTTP ${response.status}`;
419
+ throw new HavenSignContextError(
420
+ `Haven refused the direct-payment signing-context fetch for ${paymentId}: ${detail}` + (response.status === 404 ? " \u2014 either the payment_id is not from this agent\u2019s own POST /payments call, or this Haven backend predates #3271. Pass payload_hash and typed_data_b64 from the haven_send / haven_pay result, unchanged, instead." : body.error_code === "expired" ? " The payment window has expired; call haven_send / haven_pay again with the same idempotency_key." : ""),
421
+ "SIGN_CONTEXT_REFUSED",
422
+ refusalOf(response.status, body),
423
+ paymentId,
424
+ "direct"
183
425
  );
184
426
  }
185
- const x402Bindings = /* @__PURE__ */ new Map();
186
- const retiredX402Bindings = /* @__PURE__ */ new Map();
187
- const RETIRED_BINDING_MEMORY = 64;
188
- function retireX402Binding(id, reason) {
189
- retiredX402Bindings.set(id, reason);
190
- if (retiredX402Bindings.size > RETIRED_BINDING_MEMORY) {
191
- const oldest = retiredX402Bindings.keys().next();
192
- if (!oldest.done) retiredX402Bindings.delete(oldest.value);
193
- }
194
- }
195
- function signAndVerify(hash) {
196
- const signature = signHash(delegateKey, hash);
197
- if (!verifySignature(hash, signature, delegateAddress)) {
198
- throw new HavenSigningError(
199
- "Local signature verification failed \u2014 recovered address does not match the delegate key."
200
- );
201
- }
202
- return signature;
427
+ const signData = body.sign_data;
428
+ const version = body.direct_sign_context_version;
429
+ if (!signData || typeof signData.hash !== "string" || !signData.typed_data || typeof signData.typed_data !== "object" || signData.signature_scheme !== "eip712_userop" || typeof version !== "number" || !SUPPORTED_DIRECT_SIGN_CONTEXT_VERSIONS.includes(version)) {
430
+ throw new HavenSignContextError(
431
+ typeof version === "number" && !SUPPORTED_DIRECT_SIGN_CONTEXT_VERSIONS.includes(version) ? `The Haven direct-payment sign-context response is version ${version}, which this signer does not support (supported: ${SUPPORTED_DIRECT_SIGN_CONTEXT_VERSIONS.join(", ")}). Update @haven_ai/signer.` : "The Haven direct-payment sign-context response is missing sign_data.typed_data, or its signature_scheme is not 'eip712_userop' \u2014 the backend may predate #3271. Pass typed_data_b64 from the payment result instead.",
432
+ "SIGN_CONTEXT_MALFORMED",
433
+ void 0,
434
+ paymentId,
435
+ "direct"
436
+ );
203
437
  }
204
438
  return {
205
- delegateAddress,
206
- signPaymentHash(hash) {
207
- return signAndVerify(hash);
208
- },
209
- async signDelegationTypedData(typedData) {
210
- const account = privateKeyToAccount(delegateKey);
211
- return account.signTypedData(typedData);
212
- },
213
- signX402FundingHash(hash, expected) {
214
- assertExpectedBinding(hash, expected, options.x402BindingSigner, "hash");
215
- assertPayerMatchesDelegate(expected, delegateAddress, options.agentId);
216
- const signature = signAndVerify(hash);
217
- const x402Binding = randomUUID();
218
- x402Bindings.set(x402Binding, { ...expected });
219
- return { signature, x402Binding };
220
- },
221
- async signX402FundingTypedData(typedData, expected) {
222
- assertExpectedBinding(expected.payloadHash, expected, options.x402BindingSigner, "typed-data");
223
- assertPayerMatchesDelegate(expected, delegateAddress, options.agentId);
224
- const digest = hashTypedData(typedData);
225
- if (digest.toLowerCase() !== expected.typedDataHash?.toLowerCase()) {
226
- throw new HavenSigningError(
227
- "x402 typed data does not match the digest Haven committed to in the expected context. Refusing to sign \u2014 the payload was altered in transit or Haven declared a different one. The most common cause is the typed data being truncated or reshaped while being copied between tool calls (#1255): re-run the hosted quote and pass its typed_data_b64 string through UNCHANGED instead of re-emitting the nested JSON."
228
- );
229
- }
230
- if (isSettlementChildTypedData(typedData)) {
231
- const settlementChainId = chainIdForNetwork(expected.network);
232
- if (settlementChainId === void 0) {
233
- throw new HavenSigningError(
234
- `Refusing to sign the x402 settlement child: this signer cannot map the network '${expected.network}' to a chain id, so it cannot check which chain the child is scoped to. Update @haven_ai/signer.`
235
- );
236
- }
237
- verifySettlementChild(typedData, {
238
- merchantTo: expected.merchantTo,
239
- amount: expected.amount,
240
- asset: expected.asset,
241
- // From the SIGNED network, not the payload's own claim. Passing
242
- // Number(typedData.domain.chainId) here made the check compare a
243
- // value to itself — vacuous, and precisely the "declared one thing,
244
- // signed another" class this file exists to catch (#1455 review).
245
- chainId: settlementChainId,
246
- expiresAt: expected.expiresAt
247
- });
248
- }
249
- const account = privateKeyToAccount(delegateKey);
250
- const signature = await account.signTypedData(
251
- typedData
252
- );
253
- const x402Binding = randomUUID();
254
- x402Bindings.set(x402Binding, { ...expected });
255
- return { signature, x402Binding };
256
- },
257
- async buildX402PaymentHeader(paymentRequired, x402Binding) {
258
- const expected = x402Bindings.get(x402Binding);
259
- if (!expected) {
260
- const retired = retiredX402Bindings.get(x402Binding);
261
- if (retired === "header_built") {
262
- throw new HavenSigningError(
263
- "This x402 binding was already used to build a merchant header. Bindings are single-use. If you called haven_sign_x402, it already returned the payment_header \u2014 retry the merchant with THAT header instead of building another; haven_x402_sign_header is the follow-up to haven_sign, not to haven_sign_x402. If you no longer have the header, re-run the quote tool with the same idempotency_key."
264
- );
265
- }
266
- if (retired === "window_expired") {
267
- throw new HavenSigningError(
268
- "This x402 binding was retired because its payment window closed before a merchant header could be built \u2014 no header exists to retry with. Re-run the quote tool with the same idempotency_key to get a fresh window, then sign again."
269
- );
270
- }
271
- throw new HavenSigningError(
272
- "x402 funding binding is required before signing a merchant header. Sign the hosted funding hash with x402_expected first (haven_sign returns a binding this tool can use). A binding is also lost when the signer process restarts, since bindings live in memory only \u2014 re-sign to mint a fresh one."
273
- );
274
- }
275
- try {
276
- assertX402PaymentWindowOpen(expected);
277
- } catch (err) {
278
- x402Bindings.delete(x402Binding);
279
- retireX402Binding(x402Binding, "window_expired");
280
- throw err;
281
- }
282
- const option = selectStandardPaymentOption(paymentRequired.accepts);
283
- if (!option) {
284
- throw new HavenApiError(
285
- "No compatible payment option found in x402 requirements. Haven supports standard x402 exact payments on Base USDC.",
286
- 400
287
- );
439
+ paymentId: String(body.payment_id ?? paymentId),
440
+ payloadHash: signData.hash,
441
+ typedData: signData.typed_data,
442
+ status: typeof body.status === "string" ? body.status : "pending_signature",
443
+ expiresAt: typeof body.expires_at === "string" ? body.expires_at : void 0,
444
+ clientUpdate: readClientUpdate(body.client_update)
445
+ };
446
+ }
447
+ var SUPPORTED_TASK_SIGN_CONTEXT_VERSIONS = [1];
448
+ async function fetchTaskBudgetSignContext(identity, taskBudgetId, fetchImpl = fetch, timeoutMs = SIGN_CONTEXT_TIMEOUT_MS, clientIdentity) {
449
+ let response;
450
+ try {
451
+ response = await fetchImpl(
452
+ `${identity.apiUrl}/task-budgets/${encodeURIComponent(taskBudgetId)}/sign-context`,
453
+ {
454
+ headers: signContextHeaders(identity, clientIdentity),
455
+ signal: AbortSignal.timeout(timeoutMs)
288
456
  }
289
- assertX402MatchesExpected(paymentRequired, option, expected);
290
- const account = privateKeyToAccount(delegateKey);
291
- const requirements = toStandardPaymentRequirements(paymentRequired, option);
292
- const header = await exact.evm.createPaymentHeader(
293
- account,
294
- paymentRequired.x402Version,
295
- requirements
457
+ );
458
+ } catch (err) {
459
+ const timedOut = err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError");
460
+ throw new HavenSignContextError(
461
+ timedOut ? `Haven did not answer the task-budget signing-context fetch for ${taskBudgetId} within ${timeoutMs} ms. Retry.` : `Could not reach Haven to fetch the task-budget signing context for ${taskBudgetId}: ${err instanceof Error ? err.message : String(err)}.`,
462
+ timedOut ? "SIGN_CONTEXT_TIMEOUT" : "SIGN_CONTEXT_UNREACHABLE",
463
+ void 0,
464
+ taskBudgetId,
465
+ "task"
466
+ );
467
+ }
468
+ let body;
469
+ try {
470
+ body = await response.json();
471
+ } catch (err) {
472
+ if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
473
+ throw new HavenSignContextError(
474
+ `Haven did not finish sending the task-budget signing context for ${taskBudgetId} within ${timeoutMs} ms. Retry.`,
475
+ "SIGN_CONTEXT_TIMEOUT",
476
+ void 0,
477
+ taskBudgetId,
478
+ "task"
296
479
  );
297
- if (paymentRequired.x402Version < 2) {
298
- x402Bindings.delete(x402Binding);
299
- retireX402Binding(x402Binding, "header_built");
300
- return { paymentHeader: header, accepted: option };
301
- }
302
- try {
303
- const payment = decodeBase64Json(header);
304
- const wrapped = encodeBase64Json(
305
- x402V2PaymentEnvelope(paymentRequired, option, payment.payload)
306
- );
307
- return { paymentHeader: wrapped, accepted: option };
308
- } finally {
309
- x402Bindings.delete(x402Binding);
310
- retireX402Binding(x402Binding, "header_built");
311
- }
312
- },
313
- async signSweepAuthorization({
314
- authorization,
315
- expectedAuth,
316
- expectedSafe
317
- }) {
318
- assertSweepBinding(authorization, expectedAuth, options.x402BindingSigner);
319
- if (!sameAddress(authorization.from, delegateAddress)) {
320
- throw new HavenSigningError(
321
- "Sweep authorization `from` does not match this delegate address."
322
- );
323
- }
324
- if (expectedSafe && !sameAddress(authorization.to, expectedSafe)) {
325
- throw new HavenSigningError(
326
- "Sweep authorization `to` does not match the Safe in the local credential."
327
- );
328
- }
329
- const typedData = buildSweepTypedData(authorization);
330
- const viemTypedData = {
331
- domain: {
332
- ...typedData.domain,
333
- verifyingContract: typedData.domain.verifyingContract
334
- },
335
- types: typedData.types,
336
- primaryType: typedData.primaryType,
337
- message: {
338
- ...typedData.message,
339
- from: typedData.message.from,
340
- to: typedData.message.to,
341
- nonce: typedData.message.nonce
342
- }
343
- };
344
- const account = privateKeyToAccount(delegateKey);
345
- const signature = await account.signTypedData(viemTypedData);
346
- const recovered = await recoverTypedDataAddress({ ...viemTypedData, signature });
347
- if (!sameAddress(recovered, delegateAddress)) {
348
- throw new HavenSigningError(
349
- "Local sweep signature verification failed \u2014 recovered address does not match the delegate key."
350
- );
351
- }
352
- return { signature };
353
480
  }
354
- };
355
- }
356
- function assertSweepBinding(authorization, expectedAuth, trustedSigner) {
357
- if (!expectedAuth || typeof expectedAuth !== "object") {
358
- throw new HavenSigningError("Sweep authorization binding is required before signing.");
481
+ body = {};
359
482
  }
360
- if (!trustedSigner) {
361
- throw new HavenSigningError(
362
- "Sweep binding verifier is not configured. Set HAVEN_X402_BINDING_SIGNER before signing sweep authorizations."
483
+ if (!response.ok) {
484
+ const detail = typeof body.error === "string" ? body.error : `HTTP ${response.status}`;
485
+ throw new HavenSignContextError(
486
+ `Haven refused the task-budget signing-context fetch for ${taskBudgetId}: ${detail}`,
487
+ "SIGN_CONTEXT_REFUSED",
488
+ refusalOf(response.status, body),
489
+ taskBudgetId,
490
+ "task"
363
491
  );
364
492
  }
365
- assertSupportedBindingVersion(
366
- expectedAuth.version,
367
- SUPPORTED_SWEEP_BINDING_VERSIONS,
368
- "sweep authorization binding"
369
- );
370
- const message = buildSweepAuthorizationMessage(authorization);
371
- if (expectedAuth.message !== message) {
372
- throw new HavenSigningError("Sweep authorization binding does not match the authorization being signed.");
493
+ const purpose = body.purpose;
494
+ const typedData = body.typed_data;
495
+ const version = body.task_sign_context_version;
496
+ const expected = body.expected;
497
+ const malformed = (detail) => {
498
+ throw new HavenSignContextError(
499
+ `The Haven task-budget sign-context response for ${taskBudgetId} is malformed: ${detail}.`,
500
+ "SIGN_CONTEXT_MALFORMED",
501
+ void 0,
502
+ taskBudgetId,
503
+ "task"
504
+ );
505
+ };
506
+ if (typeof version !== "number" || !SUPPORTED_TASK_SIGN_CONTEXT_VERSIONS.includes(version)) {
507
+ throw new HavenSignContextError(
508
+ `The Haven task-budget sign-context response is version ${String(version)}, which this signer does not support (supported: ${SUPPORTED_TASK_SIGN_CONTEXT_VERSIONS.join(", ")}). Update @haven_ai/signer.`,
509
+ "SIGN_CONTEXT_MALFORMED",
510
+ void 0,
511
+ taskBudgetId,
512
+ "task"
513
+ );
373
514
  }
374
- if (!sameAddress(expectedAuth.signer, trustedSigner)) {
375
- throw new HavenSigningError("Sweep authorization binding was not signed by the configured Haven signer.");
515
+ if (!typedData || typeof typedData !== "object") return malformed("missing typed_data");
516
+ if (!expected || typeof expected !== "object") return malformed("missing expected");
517
+ const e = expected;
518
+ if (purpose === "open") {
519
+ if (typeof e.delegate_account !== "string" || typeof e.chain_id !== "number" || typeof e.token_address !== "string" || typeof e.max_amount_atomic !== "string" || e.recipient_address !== null && typeof e.recipient_address !== "string" || typeof e.expires_at !== "number" || typeof e.parent_delegation_hash !== "string") {
520
+ return malformed("expected is missing a required field for purpose open");
521
+ }
522
+ return {
523
+ purpose: "open",
524
+ taskBudgetId: String(body.task_budget_id ?? taskBudgetId),
525
+ typedData,
526
+ expected: {
527
+ delegateAccount: e.delegate_account,
528
+ chainId: e.chain_id,
529
+ tokenAddress: e.token_address,
530
+ maxAmountAtomic: e.max_amount_atomic,
531
+ recipientAddress: e.recipient_address ?? null,
532
+ expiresAt: e.expires_at,
533
+ parentDelegationHash: e.parent_delegation_hash
534
+ }
535
+ };
376
536
  }
377
- if (!verifySignature(hashMessage(message), expectedAuth.signature, trustedSigner)) {
378
- throw new HavenSigningError("Sweep authorization binding signature could not be verified.");
537
+ if (purpose === "close") {
538
+ const userOpHash = body.user_op_hash;
539
+ if (typeof userOpHash !== "string") return malformed("missing user_op_hash for purpose close");
540
+ if (typeof e.delegate_account !== "string" || typeof e.chain_id !== "number" || typeof e.delegation_hash !== "string") {
541
+ return malformed("expected is missing a required field for purpose close");
542
+ }
543
+ return {
544
+ purpose: "close",
545
+ taskBudgetId: String(body.task_budget_id ?? taskBudgetId),
546
+ typedData,
547
+ userOpHash,
548
+ expected: {
549
+ delegateAccount: e.delegate_account,
550
+ chainId: e.chain_id,
551
+ delegationHash: e.delegation_hash
552
+ }
553
+ };
379
554
  }
555
+ return malformed(`unknown purpose '${String(purpose)}'`);
380
556
  }
381
- function assertX402MatchesExpected(paymentRequired, option, expected) {
382
- assertExpectedShape(expected);
383
- const headerResource = option.resource ?? paymentRequired.resource.url;
384
- if (headerResource !== expected.resourceUrl) {
385
- throw new HavenSigningError("x402 payment_required resource does not match the funded intent.");
557
+
558
+ // src/tools.ts
559
+ var USEROP_BINDING_MISMATCH = "USEROP_BINDING_MISMATCH";
560
+ var HavenUserOpBindingRefusedError = class extends HavenSigningError {
561
+ next_action = AgentPaymentNextAction.StopAndTellUser;
562
+ step;
563
+ /** `message`: the SDK's `HavenUserOpBindingError.message` verbatim — it already names the mismatch and the remedy. */
564
+ constructor(message) {
565
+ super(message);
566
+ this.code = USEROP_BINDING_MISMATCH;
567
+ this.name = "HavenUserOpBindingRefusedError";
568
+ this.step = signerRefusalStep({
569
+ nextAction: AgentPaymentNextAction.StopAndTellUser,
570
+ nextTool: null,
571
+ nextToolOmittedReason: "call haven_sign again with payment_id alone (preferred), or with typed_data copied unchanged from the payment result"
572
+ });
386
573
  }
387
- if (!sameAddress(option.payTo, expected.merchantTo)) {
388
- throw new HavenSigningError("x402 merchant recipient does not match the funded intent.");
574
+ };
575
+ var HavenTypedDataNotAllowedError = class extends HavenTypedDataRefusedError {
576
+ next_action = AgentPaymentNextAction.StopAndTellUser;
577
+ step;
578
+ constructor(message) {
579
+ super(message);
580
+ this.name = "HavenTypedDataNotAllowedError";
581
+ this.step = signerRefusalStep({
582
+ nextAction: AgentPaymentNextAction.StopAndTellUser,
583
+ nextTool: null,
584
+ nextToolOmittedReason: "sign only Haven-prepared payloads: call haven_sign with payment_id (preferred), or pass typed_data / typed_data_b64 copied unchanged from a haven_send / haven_pay result, or x402_expected from a Haven quote"
585
+ });
389
586
  }
390
- if (x402AuthorizationAmount(option) !== expected.amount) {
391
- throw new HavenSigningError("x402 amount does not match the funded intent.");
392
- }
393
- if (!sameAddress(option.asset, expected.asset)) {
394
- throw new HavenSigningError("x402 asset does not match the funded intent.");
395
- }
396
- if (option.network !== expected.network) {
397
- throw new HavenSigningError("x402 network does not match the funded intent.");
587
+ };
588
+ function assertBoundDirectPaymentUserOp(typedData, delegateAddress) {
589
+ try {
590
+ assertBoundDirectPaymentUserOp$1(typedData, delegateAddress);
591
+ } catch (err) {
592
+ if (err instanceof HavenTypedDataRefusedError && !(err instanceof HavenTypedDataNotAllowedError)) {
593
+ throw new HavenTypedDataNotAllowedError(err.message);
594
+ }
595
+ throw err;
398
596
  }
399
597
  }
400
- function assertExpectedShape(expected) {
401
- if (!expected || typeof expected !== "object") {
402
- throw new HavenSigningError("x402 expected funding context is required before signing a merchant header.");
598
+ var sweepAuthorizationSchema = z$1.object({
599
+ from: z$1.string().regex(/^0x[0-9a-fA-F]{40}$/, "from must be a 0x address"),
600
+ to: z$1.string().regex(/^0x[0-9a-fA-F]{40}$/, "to must be a 0x address"),
601
+ value: z$1.string().regex(/^[0-9]+$/, "value must be a decimal atomic amount"),
602
+ validAfter: z$1.string().regex(/^[0-9]+$/, "validAfter must be a decimal unix time"),
603
+ validBefore: z$1.string().regex(/^[0-9]+$/, "validBefore must be a decimal unix time"),
604
+ nonce: z$1.string().regex(/^0x[0-9a-fA-F]{64}$/, "nonce must be a 0x-prefixed 32-byte hex string"),
605
+ token: z$1.string().regex(/^0x[0-9a-fA-F]{40}$/, "token must be a 0x address"),
606
+ chainId: z$1.number().int().positive()
607
+ });
608
+ var bindingVersionSchema = z$1.number().int().positive();
609
+ var sweepExpectedAuthSchema = z$1.object({
610
+ version: bindingVersionSchema,
611
+ message: z$1.string().min(1),
612
+ signature: z$1.string().regex(/^0x[0-9a-fA-F]+$/, "signature must be a 0x-prefixed hex string"),
613
+ signer: z$1.string().regex(/^0x[0-9a-fA-F]{40}$/, "signer must be a 0x address")
614
+ });
615
+ var x402ExpectedShape = {
616
+ payment_id: z$1.string().min(1),
617
+ payload_hash: z$1.string().regex(/^0x[0-9a-fA-F]{64}$/, "payload_hash must be a 0x-prefixed 32-byte hex string"),
618
+ resource_url: z$1.string().url(),
619
+ merchant_to: z$1.string().min(1),
620
+ amount: z$1.string().min(1),
621
+ asset: z$1.string().min(1),
622
+ network: z$1.string().min(1),
623
+ // Required: expires_at is folded into the Haven-signed binding message, so the
624
+ // signer must receive the exact same value to reconstruct a matching message.
625
+ // Omitting it used to fail downstream with a cryptic "authentication message is
626
+ // invalid" — making it required surfaces a clear INVALID_INPUT at the boundary.
627
+ expires_at: z$1.string().min(1),
628
+ // #1138: present on a delegation-rail (v2) context. It commits to the EIP-712
629
+ // typed data the account validates; the signer refuses to raw-sign the bare
630
+ // hash when it is present, and refuses to sign typed data when it is absent.
631
+ typed_data_hash: z$1.string().regex(/^0x[0-9a-fA-F]{64}$/, "typed_data_hash must be a 0x-prefixed 32-byte hex string").optional(),
632
+ // #1690: present on a v3 context. The delegate this quote was created FOR —
633
+ // the signer refuses to sign when it is not its own. Inside the Haven-signed
634
+ // message, so stripping it here would only break the binding signature.
635
+ payer_delegate: z$1.string().regex(/^0x[0-9a-fA-F]{40}$/, "payer_delegate must be a 0x-prefixed address").optional(),
636
+ payer_agent_id: z$1.string().min(1).optional(),
637
+ auth: z$1.object({
638
+ // Open at the boundary, enforced in the signer — see bindingVersionSchema.
639
+ version: bindingVersionSchema,
640
+ message: z$1.string().min(1),
641
+ signature: z$1.string().regex(/^0x[0-9a-fA-F]+$/, "signature must be a 0x-prefixed hex string"),
642
+ signer: z$1.string().min(1)
643
+ })
644
+ };
645
+ var x402ExpectedSchema = z$1.object(x402ExpectedShape);
646
+ var toolSchemas = {
647
+ haven_sign_sweep_delegate: {
648
+ // The authorization fields prepared by Haven's POST /sweep/prepare. Passed
649
+ // through verbatim from the hosted haven_sweep_delegate tool — the signer
650
+ // re-derives the binding message from these exact values.
651
+ authorization: sweepAuthorizationSchema,
652
+ // Haven's signature over the authorization context (the binding).
653
+ expected_auth: sweepExpectedAuthSchema
654
+ },
655
+ haven_sign: {
656
+ // #1263: THE preferred x402 input — the signer fetches the exact signing
657
+ // payload + expected context from Haven itself (authenticated with the
658
+ // locally-stored agent credential), so no bulky bytes ever cross the
659
+ // model. Pass payment_id ALONE for a delegation-rail x402 funding intent.
660
+ payment_id: z$1.string().min(1).optional(),
661
+ // The unsigned hash from haven_pay / haven_pay_x402_quote (payload_hash).
662
+ // Optional when payment_id is supplied (the fetch carries it).
663
+ payload_hash: z$1.string().regex(/^0x[0-9a-fA-F]{64}$/, "payload_hash must be a 0x-prefixed 32-byte hex string").optional(),
664
+ // Pass x402.expected from hosted haven_pay_x402_quote when this hash funds
665
+ // a standard x402 merchant retry. The signer records it locally and returns
666
+ // an opaque x402_binding for the later header-signing step.
667
+ x402_expected: x402ExpectedSchema.optional(),
668
+ // #1138: delegation-rail intents sign THIS, not payload_hash. Object-typed
669
+ // (not z.unknown()) so MCP clients embed it as JSON rather than a string.
670
+ typed_data: z$1.record(z$1.string(), z$1.unknown()).optional(),
671
+ // #1255: the same payload as ONE opaque base64 string, exactly as returned
672
+ // by the hosted tools. Preferred over typed_data when both are present —
673
+ // an agent re-emitting multi-KB nested JSON between tool calls is the
674
+ // failure mode this field removes (a truncated/reshaped payload fails the
675
+ // digest check and the payment refuses, correctly but pointlessly).
676
+ // Bounded: a realistic redemption payload is ~10KB encoded; 256KB is
677
+ // generous headroom while keeping the offline signer from materializing
678
+ // arbitrarily large caller input.
679
+ typed_data_b64: z$1.string().min(1).max(262144).optional(),
680
+ // #3329: sign a task budget's own child delegation (purpose `open`) or
681
+ // its early-close UserOp (purpose `close`) — the signer fetches the
682
+ // exact typed data and expectation itself, exactly like payment_id.
683
+ // Mutually exclusive with payment_id / payload_hash (checked in the
684
+ // handler, since Zod's object-level refine on a raw shape used by
685
+ // several call sites is not the shape this schema is composed as).
686
+ task_budget_id: z$1.string().min(1).optional()
687
+ },
688
+ haven_x402_sign_header: {
689
+ // The parsed HTTP 402 PaymentRequired from the merchant. Typed as an object
690
+ // (not z.unknown(), which becomes empty JSON Schema `{}`) so MCP clients
691
+ // embed it as JSON rather than serialising the object to a string.
692
+ payment_required: z$1.record(z$1.string(), z$1.unknown()),
693
+ // Opaque binding returned by haven_sign when x402_expected was supplied.
694
+ x402_binding: z$1.string().min(1)
695
+ },
696
+ haven_sign_x402: {
697
+ // #1263: preferred — pass payment_id (plus payment_required) and the
698
+ // signer fetches the exact signing payload + expected context itself.
699
+ payment_id: z$1.string().min(1).optional(),
700
+ // One-shot x402 signing: funding hash + merchant header in one local call.
701
+ // Both optional when payment_id is supplied (the fetch carries them).
702
+ payload_hash: z$1.string().regex(/^0x[0-9a-fA-F]{64}$/, "payload_hash must be a 0x-prefixed 32-byte hex string").optional(),
703
+ x402_expected: x402ExpectedSchema.optional(),
704
+ // #1355: optional when payment_id is supplied — the signer's context fetch
705
+ // carries the stored 402 PaymentRequired, so `{ payment_id }` alone is the
706
+ // preferred call. Still required (via the runtime check in the handler)
707
+ // for the quote-based fallback path without a payment_id.
708
+ payment_required: z$1.record(z$1.string(), z$1.unknown()).optional(),
709
+ // #1138: delegation-rail intents sign THIS, not payload_hash. Object-typed
710
+ // (not z.unknown()) so MCP clients embed it as JSON rather than a string.
711
+ typed_data: z$1.record(z$1.string(), z$1.unknown()).optional(),
712
+ // #1255: see haven_sign.typed_data_b64 — the copy-through-safe form.
713
+ typed_data_b64: z$1.string().min(1).max(262144).optional()
714
+ }
715
+ // #3101: keys survive on the type (see the hosted server's contracts.ts).
716
+ };
717
+ var SIGN_DESCRIPTION = [
718
+ "Sign an unsigned Haven payment with the local delegate key. The delegate key never leaves",
719
+ "this process. Pass payment_id (preferred), or the payload_hash from haven_pay / haven_pay_x402_quote",
720
+ "TOGETHER WITH typed_data / typed_data_b64 or x402_expected \u2014 never a payload_hash alone.",
721
+ "For x402, also pass x402_expected from haven_pay_x402_quote; the signer records it locally",
722
+ "and returns { signature, x402_binding }. x402_expected includes expires_at; sign before that",
723
+ "window closes. DELEGATION-RAIL x402 accounts (#1263): pass payment_id ALONE (preferred) \u2014 this",
724
+ "signer fetches the exact signing payload and expected context from Haven itself, so nothing",
725
+ "bulky ever crosses your context. DIRECT payments (#3271, haven_send / haven_pay): payment_id",
726
+ "ALONE also works here \u2014 the signer fetches the exact PackedUserOperation typed data and its",
727
+ "payload_hash from Haven and checks them against each other before signing, so a payload corrupted",
728
+ "in transit is refused (USEROP_BINDING_MISMATCH) instead of producing a bad signature. Fallback for",
729
+ "either flow: pass typed_data_b64 through UNCHANGED (never re-type the nested typed_data JSON); the",
730
+ "account validates that EIP-712 payload, not payload_hash. On a direct payment the same binding",
731
+ "check runs on the relayed payload too. This tool signs ONLY Haven-prepared payloads (#3272): a",
732
+ "direct-payment UserOp from your own account that redeems a delegation granted to it, or an x402",
733
+ "intent against a Haven-signed context \u2014 never an arbitrary typed_data payload, however it is shaped.",
734
+ "Next: call mcp__haven__haven_submit with signature, then pass x402_binding",
735
+ "to mcp__haven-signer__haven_x402_sign_header. A bare payload_hash with no payment_id, typed_data",
736
+ "or x402_expected is REFUSED (BARE_HASH_REFUSED): a hash carries nothing this signer can verify.",
737
+ "TASK BUDGETS (#3329): pass task_budget_id ALONE \u2014 mutually exclusive with payment_id and",
738
+ "payload_hash \u2014 to sign a task budget\u2019s own child delegation (opening it) or its early-close",
739
+ "UserOp; this signer fetches the exact typed data itself. Result then carries next_tool_name",
740
+ "(haven_submit) and next_arguments { task_budget_id, signature } instead of x402_binding."
741
+ ].join(" ");
742
+ var X402_SIGN_HEADER_DESCRIPTION = [
743
+ "Build and sign the EIP-3009 merchant payment header for the merchant leg of an x402 payment.",
744
+ "The delegate key stays local \u2014 only the signed header crosses any boundary.",
745
+ "Pass the payment_required from the original merchant 402 response and the x402_binding",
746
+ "returned by haven_sign. It must be haven_sign \u2014 NOT haven_sign_x402, which is a",
747
+ "one-shot that builds the header itself and spends its own binding doing so. If you called",
748
+ "haven_sign_x402, its result already carries payment_header; retry the merchant with that and",
749
+ "do not call this tool. The signer validates the merchant, amount, resource, asset, and",
750
+ "network against the recorded funding context before signing, checks expires_at when present,",
751
+ "and rejects mismatches or expired payment windows.",
752
+ "Returns { payment_header, accepted }. On your retry set BOTH PAYMENT-SIGNATURE (x402 v2) and",
753
+ "X-PAYMENT (v1) to <payment_header>; a strict v2 merchant reads only the first.",
754
+ "Only call after haven_submit has confirmed the funding step (nextAction=none or",
755
+ "the funding tx has a confirmed status). Next for paid MCP tools: call mcp__haven__haven_complete_mcp_tool."
756
+ ].join(" ");
757
+ var SIGN_X402_DESCRIPTION = [
758
+ "One-shot x402 signing for the fast 3-call flow: sign the funding payload AND build the EIP-3009",
759
+ "merchant payment header in a single local call (equivalent to haven_sign followed by",
760
+ "haven_x402_sign_header). The delegate key never leaves this process. From the haven_pay_mcp_tool",
761
+ "result pass JUST payment_id \u2014 PREFERRED (#1263, #1355): this signer fetches the exact signing",
762
+ "payload, expected context, and merchant payment_required from Haven itself, so nothing bulky",
763
+ "crosses your context. If the signer reports the context carried no payment_required (older",
764
+ "backend), re-call with payment_id plus payment_required verbatim from the pay result.",
765
+ "Fallback for older backends: pass payload_hash, x402_expected (the nested x402.expected object \u2014",
766
+ "passing the whole x402 object is also accepted and unwrapped for you), and typed_data_b64",
767
+ "through UNCHANGED; the signer",
768
+ "signs the typed data instead of payload_hash and refuses the bare hash when the context commits to typed data.",
769
+ "Returns",
770
+ "{ signature, x402_binding, payment_header, accepted }; hand signature + payment_header to",
771
+ "mcp__haven__haven_settle_mcp_tool to fund and settle in one hosted call. The header is built now (before",
772
+ "funding confirms), so its short validity window starts here \u2014 call mcp__haven__haven_settle_mcp_tool promptly,",
773
+ "and re-run mcp__haven__haven_pay_mcp_tool with the same idempotency_key if a tool returns PAYMENT_WINDOW_EXPIRED.",
774
+ "The returned x402_binding is ALREADY SPENT \u2014 this tool consumed it building payment_header \u2014",
775
+ "so never pass it to mcp__haven-signer__haven_x402_sign_header; that tool is the follow-up to",
776
+ "haven_sign, not to this one. payment_header IS the header to use.",
777
+ "Next: for a paid MCP tool, call mcp__haven__haven_settle_mcp_tool. For a direct plain-HTTP x402",
778
+ "merchant (the haven_pay_x402_quote path), relay signature via mcp__haven__haven_submit and then",
779
+ "retry the original merchant URL YOURSELF, setting BOTH PAYMENT-SIGNATURE (x402 v2) and",
780
+ "X-PAYMENT (v1) to payment_header \u2014 Haven never contacts that merchant."
781
+ ].join(" ");
782
+ var SIGN_SWEEP_DELEGATE_DESCRIPTION = [
783
+ "Sign a Haven-prepared gasless USDC sweep that recovers stranded funds from the delegate",
784
+ "wallet back to the agent's account (Haven wallet). The delegate key never leaves this process and this",
785
+ "tool never broadcasts \u2014 it returns only an EIP-3009 signature that Haven's relayer submits and",
786
+ "pays gas for. Pass the authorization and expected_auth returned by the hosted",
787
+ "haven_sweep_delegate tool. The signer verifies Haven authored the authorization; the",
788
+ "destination is checked against the agent's account (Haven wallet) from the local credential",
789
+ "when the credential carries one, and otherwise rests on Haven's binding signature. Then it",
790
+ "returns { signature } to hand back to mcp__haven__haven_sweep_delegate to complete recovery."
791
+ ].join(" ");
792
+ var toolDescriptions = {
793
+ haven_sign: SIGN_DESCRIPTION,
794
+ haven_x402_sign_header: X402_SIGN_HEADER_DESCRIPTION,
795
+ haven_sign_x402: SIGN_X402_DESCRIPTION,
796
+ haven_sign_sweep_delegate: SIGN_SWEEP_DELEGATE_DESCRIPTION
797
+ };
798
+ async function signFundingLeg(signer, expected, typedData) {
799
+ try {
800
+ return await signer.signX402FundingTypedData(typedData, expected);
801
+ } catch (err) {
802
+ if (err instanceof HavenTypedDataRefusedError && !(err instanceof HavenTypedDataNotAllowedError)) {
803
+ throw new HavenTypedDataNotAllowedError(err.message);
804
+ }
805
+ if (err instanceof HavenUserOpBindingError) {
806
+ throw new HavenUserOpBindingRefusedError(err.message);
807
+ }
808
+ throw err;
403
809
  }
404
810
  }
405
- var SUPPORTED_X402_EXPECTED_VERSIONS = [1, 2, 3];
406
- var SUPPORTED_SWEEP_BINDING_VERSIONS = [1];
407
- function assertSupportedBindingVersion(received, supported, context) {
408
- if (supported.includes(received)) return;
409
- const highest = Math.max(...supported);
410
- const outOfDate = received > highest;
411
- const code = context === "x402 expected context" ? SignerRefusalCode.UnsupportedExpectedContextVersion : SignerRefusalCode.UnsupportedSweepBindingVersion;
412
- const ceiling = outOfDate ? `This signer is out of date: it supports ${context} versions up to ${highest}, and Haven sent version ${received}. Update @haven_ai/signer \u2014 rerun the Haven connector (\`${connectorRerunCommand()}\`), which reinstalls the pinned MCP runtime.` : `Unsupported ${context} version ${received}: this signer supports ${supported.join(", ")}.`;
413
- const fallback = outOfDate ? SIGNER_UPDATE_FALLBACK : `This ${context} version (${received}) is older than what this signer enforces (${supported.join(", ")}) \u2014 updating @haven_ai/signer will not restore it. Nothing was signed or spent; stop and tell the user rather than retrying.`;
414
- throw new HavenUnsupportedSignerVersionError(
415
- `${ceiling} Nothing was signed. Do not rewrite the version field to a supported value: it is part of the Haven-signed binding message, so changing it invalidates the signature and would misrepresent what Haven declared.`,
416
- code,
417
- supported,
418
- received,
419
- fallback
420
- );
811
+ function toExpectedX402(raw) {
812
+ return {
813
+ paymentId: raw.payment_id,
814
+ payloadHash: raw.payload_hash,
815
+ typedDataHash: raw.typed_data_hash,
816
+ payerDelegate: raw.payer_delegate,
817
+ payerAgentId: raw.payer_agent_id,
818
+ resourceUrl: raw.resource_url,
819
+ merchantTo: raw.merchant_to,
820
+ amount: raw.amount,
821
+ asset: raw.asset,
822
+ network: raw.network,
823
+ expiresAt: raw.expires_at,
824
+ auth: raw.auth
825
+ };
421
826
  }
422
- function assertExpectedBinding(payloadHash, expected, trustedSigner, mode = "hash") {
423
- assertExpectedShape(expected);
424
- if (!trustedSigner) {
827
+ function parseFetchedExpected(fetched) {
828
+ const parsed = z$1.object(x402ExpectedShape).safeParse(fetched.x402Expected);
829
+ if (!parsed.success) {
425
830
  throw new HavenSigningError(
426
- "x402 expected-context verifier is not configured. Set HAVEN_X402_BINDING_SIGNER before signing x402 funding hashes."
831
+ `The Haven sign-context response carried a malformed x402_expected \u2014 the backend may predate #1263. Pass payload_hash + x402_expected from the quote result instead. Underlying: ${parsed.error.issues[0]?.message ?? "schema mismatch"}`
427
832
  );
428
833
  }
429
- if (expected.auth) {
430
- assertSupportedBindingVersion(
431
- expected.auth.version,
432
- SUPPORTED_X402_EXPECTED_VERSIONS,
433
- "x402 expected context"
834
+ return parsed.data;
835
+ }
836
+ function clientUpdateField(resolved) {
837
+ return resolved?.ctx.clientUpdate ? { client_update: resolved.ctx.clientUpdate } : {};
838
+ }
839
+ function resolveTypedData(args) {
840
+ if (!args.typed_data_b64) return args.typed_data;
841
+ try {
842
+ const decoded = JSON.parse(
843
+ Buffer.from(args.typed_data_b64, "base64").toString("utf8")
434
844
  );
435
- }
436
- if (expected.payloadHash.toLowerCase() !== payloadHash.toLowerCase()) {
437
- throw new HavenSigningError("x402 expected context does not match the funding hash being signed.");
438
- }
439
- if (mode === "hash" && expected.typedDataHash) {
845
+ if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) {
846
+ throw new Error("decoded value is not an object");
847
+ }
848
+ return decoded;
849
+ } catch (err) {
440
850
  throw new HavenSigningError(
441
- "This x402 funding intent commits to EIP-712 typed data, so its bare hash must not be raw-signed \u2014 the account would reject that signature on-chain. Sign sign_data.typed_data instead."
851
+ `typed_data_b64 did not decode to a JSON object. Pass the exact string returned by the hosted Haven tool, unchanged \u2014 do not re-encode, trim, or reformat it. Underlying error: ${err instanceof Error ? err.message : String(err)}`
442
852
  );
443
853
  }
444
- if (mode === "typed-data" && !expected.typedDataHash) {
445
- throw new HavenSigningError(
446
- "Refusing to sign typed data under an expected context that does not commit to it. Haven must return a v2 x402 expected context (with typedDataHash) for a delegation-rail intent."
447
- );
854
+ }
855
+ function createToolHandlers(signer, options = {}) {
856
+ function checkPayloadHashMatch(suppliedHash, fetchedHash, paymentId) {
857
+ if (suppliedHash && suppliedHash.toLowerCase() !== fetchedHash.toLowerCase()) {
858
+ throw new HavenSigningError(
859
+ `The supplied payload_hash does not match the signing context Haven serves for payment ${paymentId}. Pass payment_id alone, or check which quote the hash came from.`
860
+ );
861
+ }
448
862
  }
449
- const message = buildX402ExpectedMessage({
450
- paymentId: expected.paymentId,
451
- payloadHash: expected.payloadHash,
452
- resourceUrl: expected.resourceUrl,
453
- merchantTo: expected.merchantTo,
454
- amount: expected.amount,
455
- asset: expected.asset,
456
- network: expected.network,
457
- expiresAt: expected.expiresAt,
458
- typedDataHash: expected.typedDataHash,
459
- payerDelegate: expected.payerDelegate,
460
- payerAgentId: expected.payerAgentId
461
- });
462
- const expectedVersion = expected.payerDelegate ? 3 : expected.typedDataHash ? 2 : 1;
463
- if (expected.auth?.version !== expectedVersion || expected.auth.message !== message) {
464
- throw new HavenSigningError("x402 expected context authentication message is invalid.");
465
- }
466
- if (!sameAddress(expected.auth.signer, trustedSigner)) {
467
- throw new HavenSigningError("x402 expected context was not signed by the configured Haven signer.");
468
- }
469
- if (!verifySignature(hashMessage(message), expected.auth.signature, trustedSigner)) {
470
- throw new HavenSigningError("x402 expected context signature could not be verified.");
471
- }
472
- }
473
- function assertPayerMatchesDelegate(expected, delegateAddress, localAgentId) {
474
- if (!expected.payerDelegate) return;
475
- if (sameAddress(expected.payerDelegate, delegateAddress)) return;
476
- const quoteAgent = expected.payerAgentId ?? "unknown";
477
- const localAgent = localAgentId ?? "unknown";
478
- throw new HavenSigningError(
479
- `This quote belongs to a DIFFERENT agent: the quote was created for agent ${quoteAgent} (delegate ${expected.payerDelegate}), but this signer holds agent ${localAgent} (delegate ${delegateAddress}). A long-lived host is holding stale credentials \u2014 its session authenticates as the old agent while the signer on disk belongs to the new one. Nothing was signed. Restart the host so it re-reads its wiring, then re-quote.`
480
- );
481
- }
482
- function assertX402PaymentWindowOpen(expected) {
483
- if (!expected.expiresAt) return;
484
- const expiresAtMs = Date.parse(expected.expiresAt);
485
- if (Number.isNaN(expiresAtMs)) {
486
- throw new HavenSigningError("x402 expected context expiresAt is not a valid ISO timestamp.");
863
+ async function resolveSignContext(args, opts) {
864
+ if (!args.payment_id) return null;
865
+ const identity = await options.signContext?.loadIdentity() ?? null;
866
+ if (!identity) {
867
+ throw new HavenSigningError(
868
+ `payment_id signing needs the agent identity (identity.json next to the signer credentials), which this signer could not load. Re-run \`${connectorRerunCommand()}\` to restore it, or pass typed_data_b64 from the quote result instead.`
869
+ );
870
+ }
871
+ try {
872
+ const ctx = await fetchX402SignContext(
873
+ identity,
874
+ args.payment_id,
875
+ options.signContext?.fetchImpl,
876
+ void 0,
877
+ options.signContext?.clientIdentity
878
+ );
879
+ checkPayloadHashMatch(args.payload_hash, ctx.payloadHash, args.payment_id);
880
+ return { kind: "x402", ctx };
881
+ } catch (err) {
882
+ if (opts.allowDirectFallback && err instanceof HavenSignContextError && err.http_status === 409 && err.backend_error_code === "sign_context_unavailable") {
883
+ let ctx;
884
+ try {
885
+ ctx = await fetchDirectSignContext(
886
+ identity,
887
+ args.payment_id,
888
+ options.signContext?.fetchImpl,
889
+ void 0,
890
+ options.signContext?.clientIdentity
891
+ );
892
+ } catch (directErr) {
893
+ if (directErr instanceof HavenSignContextError && directErr.http_status === 409 && directErr.backend_error_code === "sign_context_unavailable") {
894
+ throw err;
895
+ }
896
+ throw directErr;
897
+ }
898
+ checkPayloadHashMatch(args.payload_hash, ctx.payloadHash, args.payment_id);
899
+ return { kind: "direct", ctx };
900
+ }
901
+ throw err;
902
+ }
487
903
  }
488
- if (expiresAtMs <= Date.now()) {
489
- throw new HavenError(
490
- "The x402 payment window expired before the merchant header could be signed. Re-quote with haven_pay_mcp_tool using the same idempotency_key before trying again.",
491
- AgentPaymentFailureCode.PaymentWindowExpired,
492
- 410,
493
- expected.paymentId
904
+ async function signTaskBudget(taskBudgetId) {
905
+ const identity = await options.signContext?.loadIdentity() ?? null;
906
+ if (!identity) {
907
+ throw new HavenSigningError(
908
+ `task_budget_id signing needs the agent identity (identity.json next to the signer credentials), which this signer could not load. Re-run \`${connectorRerunCommand()}\` to restore it.`
909
+ );
910
+ }
911
+ const ctx = await fetchTaskBudgetSignContext(
912
+ identity,
913
+ taskBudgetId,
914
+ options.signContext?.fetchImpl,
915
+ void 0,
916
+ options.signContext?.clientIdentity
494
917
  );
918
+ let signature;
919
+ if (ctx.purpose === "open") {
920
+ try {
921
+ assertOwnTaskChild(
922
+ ctx.typedData,
923
+ {
924
+ chainId: ctx.expected.chainId,
925
+ tokenAddress: ctx.expected.tokenAddress,
926
+ maxAmountAtomic: ctx.expected.maxAmountAtomic,
927
+ recipientAddress: ctx.expected.recipientAddress,
928
+ expiresAt: ctx.expected.expiresAt,
929
+ parentDelegationHash: ctx.expected.parentDelegationHash
930
+ },
931
+ signer.delegateAddress
932
+ );
933
+ } catch (err) {
934
+ if (err instanceof HavenTypedDataRefusedError) throw err;
935
+ throw new HavenTypedDataRefusedError(err instanceof Error ? err.message : String(err));
936
+ }
937
+ signature = await signer.signDelegationTypedData(ctx.typedData);
938
+ } else {
939
+ try {
940
+ assertUserOpTypedDataBinding(ctx.typedData, ctx.userOpHash);
941
+ } catch (err) {
942
+ if (err instanceof HavenUserOpBindingError) throw new HavenUserOpBindingRefusedError(err.message);
943
+ throw err;
944
+ }
945
+ try {
946
+ assertOwnTaskBudgetCloseUserOp(
947
+ ctx.typedData,
948
+ { delegationHash: ctx.expected.delegationHash },
949
+ signer.delegateAddress
950
+ );
951
+ } catch (err) {
952
+ if (err instanceof HavenTypedDataRefusedError) throw err;
953
+ throw new HavenTypedDataRefusedError(err instanceof Error ? err.message : String(err));
954
+ }
955
+ signature = await signer.signDelegationTypedData(ctx.typedData);
956
+ }
957
+ await auditSigning("haven_sign", hashTypedData(ctx.typedData));
958
+ return {
959
+ signature,
960
+ task_budget_id: ctx.taskBudgetId,
961
+ purpose: ctx.purpose,
962
+ next_tool_name: "haven_submit",
963
+ next_tool_server_role: "hosted",
964
+ next_arguments: { task_budget_id: ctx.taskBudgetId, signature }
965
+ };
495
966
  }
496
- }
497
- function sameAddress(a, b) {
498
- return a.toLowerCase() === b.toLowerCase();
499
- }
500
-
501
- // src/capabilities.ts
502
- var SIGNER_CAPABILITY_KEY = "haven/signer-compatibility";
503
- function signerCompatibility() {
504
967
  return {
505
- x402_expected_context_versions: [...SUPPORTED_X402_EXPECTED_VERSIONS],
506
- sweep_binding_versions: [...SUPPORTED_SWEEP_BINDING_VERSIONS]
968
+ haven_sign: async (input) => runTool(async () => {
969
+ const args = parse("haven_sign", coerceX402Expected(input));
970
+ if (args.task_budget_id) {
971
+ if (args.payment_id || args.payload_hash) {
972
+ throw new z$1.ZodError([
973
+ {
974
+ code: z$1.ZodIssueCode.custom,
975
+ path: ["task_budget_id"],
976
+ message: "task_budget_id is mutually exclusive with payment_id / payload_hash \u2014 pass exactly one."
977
+ }
978
+ ]);
979
+ }
980
+ return signTaskBudget(args.task_budget_id);
981
+ }
982
+ const resolved = await resolveSignContext(args, { allowDirectFallback: true });
983
+ const typedData = resolved?.ctx.typedData ?? resolveTypedData(args);
984
+ const payloadHash = resolved?.ctx.payloadHash ?? args.payload_hash;
985
+ if (!payloadHash) {
986
+ throw new HavenSigningError(
987
+ "Pass payment_id (preferred for delegation-rail x402 or direct payments) or payload_hash."
988
+ );
989
+ }
990
+ const expectedRaw = resolved?.kind === "x402" ? parseFetchedExpected(resolved.ctx) : resolved?.kind === "direct" ? null : args.x402_expected;
991
+ const x402Expected = expectedRaw ? toExpectedX402(expectedRaw) : null;
992
+ const result = x402Expected ? await signFundingLeg(signer, x402Expected, typedData) : null;
993
+ if (!result) {
994
+ if (typedData) {
995
+ if (isSettlementChildTypedData(typedData)) {
996
+ throw new HavenSigningError(
997
+ "Refusing to sign a delegation payload with no expected context. This typed data is a DELEGATION \u2014 signing it grants a third party authority to move funds \u2014 so it is only ever signed against a Haven-signed context that the caveats are verified against. Call this tool with { payment_id } instead (the signer then fetches and verifies the context itself), or use haven_sign_x402."
998
+ );
999
+ }
1000
+ if (resolved?.kind !== "direct" && !isPackedUserOperationTypedData(typedData)) {
1001
+ throw new HavenTypedDataNotAllowedError(
1002
+ "This signer signs only Haven-prepared payloads: a direct-payment PackedUserOperation from your own account that redeems a delegation granted to it, or a Haven-signed x402 context. Call haven_sign with payment_id (preferred), or pass x402_expected for an x402 funding leg."
1003
+ );
1004
+ }
1005
+ try {
1006
+ assertUserOpTypedDataBinding(typedData, payloadHash);
1007
+ } catch (err) {
1008
+ if (err instanceof HavenUserOpBindingError) {
1009
+ throw new HavenUserOpBindingRefusedError(err.message);
1010
+ }
1011
+ throw err;
1012
+ }
1013
+ assertBoundDirectPaymentUserOp(typedData, signer.delegateAddress);
1014
+ const signature = await signer.signDelegationTypedData(typedData);
1015
+ await auditSigning("haven_sign", hashTypedData(typedData));
1016
+ return { signature, ...clientUpdateField(resolved) };
1017
+ }
1018
+ throw new HavenBareHashRefusedError();
1019
+ }
1020
+ await auditSigning("haven_sign", hashTypedData(typedData));
1021
+ return { signature: result.signature, x402_binding: result.x402Binding, ...clientUpdateField(resolved) };
1022
+ }),
1023
+ haven_x402_sign_header: async (input) => runTool(async () => {
1024
+ const args = parse("haven_x402_sign_header", coercePaymentRequired(input));
1025
+ const result = await signer.buildX402PaymentHeader(
1026
+ args.payment_required,
1027
+ args.x402_binding
1028
+ );
1029
+ await auditSigning(
1030
+ "haven_x402_sign_header",
1031
+ hashPayloadForAudit(args.payment_required)
1032
+ );
1033
+ return { payment_header: result.paymentHeader, accepted: result.accepted };
1034
+ }),
1035
+ haven_sign_x402: async (input) => runTool(async () => {
1036
+ const args = parse("haven_sign_x402", coerceX402Expected(coercePaymentRequired(input)));
1037
+ const resolved = await resolveSignContext(args, { allowDirectFallback: false });
1038
+ const fetched = resolved?.kind === "x402" ? resolved.ctx : void 0;
1039
+ const payloadHash = fetched?.payloadHash ?? args.payload_hash;
1040
+ const expectedRaw = fetched ? parseFetchedExpected(fetched) : args.x402_expected;
1041
+ if (!payloadHash || !expectedRaw) {
1042
+ throw new HavenSigningError(
1043
+ "Pass payment_id (preferred \u2014 the signer fetches the signing context itself) or payload_hash + x402_expected from the quote result."
1044
+ );
1045
+ }
1046
+ const fundingTypedData = fetched?.typedData ?? resolveTypedData(args);
1047
+ const funding = await signFundingLeg(signer, toExpectedX402(expectedRaw), fundingTypedData);
1048
+ const paymentRequired = fetched?.paymentRequired ?? args.payment_required;
1049
+ if (!paymentRequired) {
1050
+ throw new HavenSigningError(
1051
+ fetched ? "The Haven sign-context for this payment_id carried no payment_required (backend predates #1355). Pass payment_required from the pay result explicitly, verbatim." : "payment_required is required on the quote-based path (no payment_id to fetch it by). Pass it verbatim from the quote result."
1052
+ );
1053
+ }
1054
+ const header = await signer.buildX402PaymentHeader(
1055
+ paymentRequired,
1056
+ funding.x402Binding
1057
+ );
1058
+ await auditSigning(
1059
+ "haven_sign_x402",
1060
+ hashTypedData(fundingTypedData)
1061
+ );
1062
+ await auditSigning("haven_sign_x402", hashPayloadForAudit(paymentRequired));
1063
+ return {
1064
+ signature: funding.signature,
1065
+ x402_binding: funding.x402Binding,
1066
+ payment_header: header.paymentHeader,
1067
+ accepted: header.accepted,
1068
+ ...clientUpdateField(resolved)
1069
+ };
1070
+ }),
1071
+ haven_sign_sweep_delegate: async (input) => runTool(async () => {
1072
+ const args = parse("haven_sign_sweep_delegate", input);
1073
+ const result = await signer.signSweepAuthorization({
1074
+ authorization: args.authorization,
1075
+ expectedAuth: args.expected_auth,
1076
+ // Cross-check `to` against the agent's account (Haven wallet) from the
1077
+ // local credential, when the credential carries one.
1078
+ expectedSafe: options.audit?.accountAddress
1079
+ });
1080
+ await auditSigning(
1081
+ "haven_sign_sweep_delegate",
1082
+ hashPayloadForAudit(args.authorization)
1083
+ );
1084
+ return { signature: result.signature };
1085
+ })
507
1086
  };
1087
+ async function auditSigning(tool, payloadHash) {
1088
+ if (!options.audit) return;
1089
+ const { auditPath, ...context } = options.audit;
1090
+ try {
1091
+ await appendSigningAuditEntry(
1092
+ createSigningAuditEntry(tool, payloadHash, {
1093
+ ...context,
1094
+ delegateAddress: signer.delegateAddress
1095
+ }),
1096
+ auditPath
1097
+ );
1098
+ } catch (err) {
1099
+ const message = err instanceof Error ? err.message : String(err);
1100
+ process.stderr.write(`haven-signer: warning: audit entry for ${tool} could not be written to ${auditPath}: ${message}
1101
+ `);
1102
+ }
1103
+ }
508
1104
  }
509
- function signerCapabilityAdvertisement() {
510
- return { experimental: { [SIGNER_CAPABILITY_KEY]: signerCompatibility() } };
511
- }
512
- function signerInstructions() {
513
- const compatibility = signerCompatibility();
514
- return [
515
- "Haven edge signer: sign-only tools bound to the local delegate key. It never emits the",
516
- "key. Its one network capability is an authenticated READ of a signing context from",
517
- "Haven by payment_id \u2014 pass payment_id to haven_sign / haven_sign_x402 (preferred for",
518
- "delegation-rail x402) instead of relaying bulky typed-data payloads yourself.",
519
- "",
520
- "Version compatibility (check this BEFORE signing, not after):",
521
- `- x402 expected-context versions supported: ${compatibility.x402_expected_context_versions.join(", ")}`,
522
- `- sweep authorization binding versions supported: ${compatibility.sweep_binding_versions.join(", ")}`,
523
- "",
524
- "Haven quote and prepare results report the expected-context version they will emit",
525
- "(signer_compatibility.x402_expected_context_version). If that version is not in the list",
526
- "above, this signer is out of date: STOP before signing, and tell the user to update",
527
- `@haven_ai/signer by rerunning \`${connectorRerunCommand()}\`, which reinstalls the pinned`,
528
- "MCP runtime. Do not edit the version field to a supported value \u2014 it is part of the",
529
- "Haven-signed binding message, so changing it invalidates the signature.",
530
- "",
531
- "A version-mismatch refusal from haven_sign / haven_sign_x402 / haven_sign_sweep_delegate is",
532
- "machine-readable, not just prose: it carries code, supported_versions, received_version, and",
533
- "fallback fields alongside the message, so you can branch on it directly. Every signer",
534
- "refusal also carries the next-step family: next_tool_name + next_tool_server_role when a",
535
- "hosted tool follows (resolve the role against your own server names), else",
536
- "next_tool_omitted_reason saying why not."
537
- ].join("\n");
1105
+ function parse(name, input) {
1106
+ return z$1.object(toolSchemas[name]).parse(input ?? {});
538
1107
  }
539
- var SIGNER_HOSTED_HANDOFF_SHAPES = {
540
- haven_get_payment_status: { payment_id: z.string().min(1) }
541
- };
542
- function target(shape) {
543
- const schema = z.object(shape).strict();
544
- return {
545
- role: "hosted",
546
- validate: (input) => {
547
- const r = schema.safeParse(input ?? {});
548
- return r.success ? null : r.error.errors.map((e) => `${e.path.join(".") || "(root)"}: ${e.message}`).join("; ");
1108
+ function coerceX402Expected(input) {
1109
+ if (!input || typeof input !== "object") return input;
1110
+ const record = input;
1111
+ let value = record.x402_expected;
1112
+ if (typeof value === "string") {
1113
+ try {
1114
+ value = JSON.parse(value);
1115
+ } catch {
1116
+ return input;
549
1117
  }
550
- };
1118
+ }
1119
+ if (!value || typeof value !== "object") return input;
1120
+ const obj = value;
1121
+ if (obj.auth === void 0 && obj.expected && typeof obj.expected === "object") {
1122
+ return { ...record, x402_expected: obj.expected };
1123
+ }
1124
+ if (value !== record.x402_expected) return { ...record, x402_expected: value };
1125
+ return input;
551
1126
  }
552
- var SIGNER_NEXT_STEP_TARGETS = {
553
- haven_get_payment_status: target(SIGNER_HOSTED_HANDOFF_SHAPES.haven_get_payment_status)
554
- };
555
- var nextStep = createNextStepBuilder(SIGNER_NEXT_STEP_TARGETS);
556
- function signerRefusalStep(input) {
557
- return nextStep({ ...input, safeToContinue: false, reason: "" });
1127
+ function coercePaymentRequired(input) {
1128
+ if (!input || typeof input !== "object") return input;
1129
+ const record = input;
1130
+ if (typeof record.payment_required !== "string") return input;
1131
+ try {
1132
+ return { ...record, payment_required: JSON.parse(record.payment_required) };
1133
+ } catch {
1134
+ return input;
1135
+ }
558
1136
  }
559
- function nextStepWireFields(step) {
560
- return {
561
- ...step.next_tool ? { next_tool: step.next_tool } : {},
562
- ...step.next_tool_server ? { next_tool_server: step.next_tool_server } : {},
563
- ...step.next_tool_name ? { next_tool_name: step.next_tool_name } : {},
564
- ...step.next_tool_server_role ? { next_tool_server_role: step.next_tool_server_role } : {},
565
- ...step.next_arguments ? { next_arguments: step.next_arguments } : {},
566
- ...step.next_tool_omitted_reason ? { next_tool_omitted_reason: step.next_tool_omitted_reason } : {}
567
- };
1137
+ async function runTool(fn) {
1138
+ try {
1139
+ return { success: true, data: await fn() };
1140
+ } catch (err) {
1141
+ return normalizeError(err);
1142
+ }
568
1143
  }
569
-
570
- // src/sign-context.ts
571
- var HavenSignContextError = class extends HavenSigningError {
572
- /**
573
- * #3001 / #3010 review: the recovery path, when signing OTHER bytes is
574
- * actually a remedy — a transport failure (timeout, unreachable) or a
575
- * body this signer could not read. `typed_data_b64` is NOT in the default
576
- * quote result since #1272: obtain it by re-running the SAME quote tool
577
- * with the SAME idempotency_key plus `include_signing_payload: true`.
578
- * Absent on a backend REFUSAL: an expired, executed or unsignable intent
579
- * cannot be rescued by re-signing its bytes.
580
- */
581
- fallback;
582
- /**
583
- * #3001: `AgentPaymentNextAction` values the signer already emits — the
584
- * version-mismatch refusal's `stop_and_tell_user` for the classes where
585
- * retrying the same call cannot help, and `payment_window_expired` (with
586
- * `retry_with_new_quote`) for the backend's 410 `expired`, exactly as the
587
- * plain `HavenError` branch already does for that code.
588
- */
589
- next_action;
590
- /**
591
- * #3103 (epic #3105, decisions 1 and 3): the typed next step beside the
592
- * action. A refusal Haven made (`SIGN_CONTEXT_REFUSED`, not expired) names
593
- * the hosted status read with the payment id; a transport failure or a
594
- * malformed body names no tool — the remedy is re-running the SAME quote
595
- * tool with `include_signing_payload: true` — and an expired window names
596
- * none either (which quote tool depends on the flow). Never null: a step
597
- * with no tool carries `next_tool_omitted_reason`. Additive: the class is
598
- * not exported from the package index — the published surface is the
599
- * `ToolFailure` envelope `tools.ts` builds from these fields — and every
600
- * constructor call site is in this file.
601
- */
602
- next_tool;
603
- next_tool_server;
604
- next_tool_name;
605
- next_tool_server_role;
606
- next_arguments;
607
- next_tool_omitted_reason;
608
- retry_with_new_quote;
609
- /** Present only for `SIGN_CONTEXT_REFUSED` (the backend's HTTP status). */
610
- http_status;
611
- /** Present only for `SIGN_CONTEXT_REFUSED`: the backend's own `error_code`. */
612
- backend_error_code;
613
- constructor(message, code, refusal, paymentId) {
614
- super(message);
615
- this.code = code;
616
- this.name = "HavenSignContextError";
617
- let step;
618
- if (code === "SIGN_CONTEXT_REFUSED") {
619
- this.http_status = refusal?.httpStatus;
620
- this.backend_error_code = refusal?.errorCode;
621
- if (refusal?.httpStatus === 410 || refusal?.errorCode === "expired") {
622
- this.next_action = AgentPaymentNextAction.PaymentWindowExpired;
623
- this.retry_with_new_quote = true;
624
- step = signerRefusalStep({
625
- nextAction: AgentPaymentNextAction.PaymentWindowExpired,
626
- nextTool: null,
627
- nextToolOmittedReason: "re-run the hosted quote tool you called with the same idempotency_key; which one depends on the flow"
628
- });
629
- } else {
630
- this.next_action = AgentPaymentNextAction.StopAndTellUser;
631
- step = signerRefusalStep({
632
- nextAction: AgentPaymentNextAction.StopAndTellUser,
633
- nextTool: "haven_get_payment_status",
634
- nextArguments: { payment_id: paymentId }
635
- });
636
- }
637
- } else {
638
- this.fallback = "typed_data_b64";
639
- this.next_action = AgentPaymentNextAction.StopAndTellUser;
640
- step = signerRefusalStep({
1144
+ function normalizeError(err) {
1145
+ if (err instanceof z$1.ZodError) {
1146
+ return {
1147
+ success: false,
1148
+ code: "INVALID_INPUT",
1149
+ message: err.errors.map((e) => `${e.path.join(".") || "(root)"}: ${e.message}`).join("; "),
1150
+ statusCode: 400
1151
+ };
1152
+ }
1153
+ if (err instanceof HavenUnsupportedSignerVersionError) {
1154
+ return {
1155
+ success: false,
1156
+ code: err.code,
1157
+ message: err.message,
1158
+ supported_versions: [...err.supportedVersions],
1159
+ received_version: err.receivedVersion,
1160
+ fallback: err.fallback,
1161
+ next_action: AgentPaymentNextAction.StopAndTellUser,
1162
+ // #3103: no tool can fix a version skew from inside the call.
1163
+ ...nextStepWireFields(signerRefusalStep({
641
1164
  nextAction: AgentPaymentNextAction.StopAndTellUser,
642
1165
  nextTool: null,
643
- nextToolOmittedReason: "re-run the SAME hosted quote tool with the same idempotency_key and include_signing_payload: true, then pass its typed_data_b64 to this signer"
644
- });
645
- }
646
- Object.assign(this, nextStepWireFields(step));
647
- }
648
- };
649
- async function loadHavenIdentity(credentialsPath) {
650
- if (!credentialsPath) return null;
651
- try {
652
- const raw = JSON.parse(
653
- await readFile(join(dirname(credentialsPath), "identity.json"), "utf8")
654
- );
655
- const apiKey = typeof raw.api_key === "string" ? raw.api_key : void 0;
656
- const apiUrl = typeof raw.api_url === "string" ? raw.api_url : void 0;
657
- if (!apiKey || !apiUrl) return null;
658
- return { apiKey, apiUrl: apiUrl.replace(/\/+$/, "") };
659
- } catch {
660
- return null;
1166
+ nextToolOmittedReason: "update @haven_ai/signer by re-running the connector, then repeat the same call"
1167
+ }))
1168
+ };
661
1169
  }
662
- }
663
- var SIGN_CONTEXT_TIMEOUT_MS = 15e3;
664
- async function fetchX402SignContext(identity, paymentId, fetchImpl = fetch, timeoutMs = SIGN_CONTEXT_TIMEOUT_MS) {
665
- let response;
666
- try {
667
- response = await fetchImpl(
668
- `${identity.apiUrl}/x402/${encodeURIComponent(paymentId)}/sign-context`,
669
- {
670
- headers: { Authorization: `Bearer ${identity.apiKey}` },
671
- signal: AbortSignal.timeout(timeoutMs)
672
- }
673
- );
674
- } catch (err) {
675
- const timedOut = err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError");
676
- throw new HavenSignContextError(
677
- timedOut ? `Haven did not answer the signing-context fetch for ${paymentId} within ${timeoutMs} ms. Retry, or pass typed_data_b64 from the quote result instead.` : `Could not reach Haven to fetch the signing context for ${paymentId}: ${err instanceof Error ? err.message : String(err)}. Retry, or pass typed_data_b64 from the quote result instead.`,
678
- timedOut ? "SIGN_CONTEXT_TIMEOUT" : "SIGN_CONTEXT_UNREACHABLE",
679
- void 0,
680
- paymentId
681
- );
1170
+ if (err instanceof HavenBareHashRefusedError) {
1171
+ return {
1172
+ success: false,
1173
+ code: err.code,
1174
+ message: err.message,
1175
+ next_action: err.next_action,
1176
+ ...nextStepWireFields(err.step)
1177
+ };
682
1178
  }
683
- let body;
684
- try {
685
- body = await response.json();
686
- } catch (err) {
687
- if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
688
- throw new HavenSignContextError(
689
- `Haven did not finish sending the signing context for ${paymentId} within ${timeoutMs} ms. Retry, or pass typed_data_b64 from the quote result instead.`,
690
- "SIGN_CONTEXT_TIMEOUT",
691
- void 0,
692
- paymentId
693
- );
694
- }
695
- body = {};
1179
+ if (err instanceof HavenUserOpBindingRefusedError) {
1180
+ return {
1181
+ success: false,
1182
+ code: err.code,
1183
+ message: err.message,
1184
+ next_action: err.next_action,
1185
+ ...nextStepWireFields(err.step)
1186
+ };
696
1187
  }
697
- if (!response.ok) {
698
- const detail = typeof body.error === "string" ? body.error : `HTTP ${response.status}`;
699
- throw new HavenSignContextError(
700
- `Haven refused the signing-context fetch for ${paymentId}: ${detail}` + (response.status === 404 ? " \u2014 check the payment_id came from this agent\u2019s own quote." : response.status === 410 ? " Re-run the quote with the same idempotency key, then sign the fresh payment_id." : ""),
701
- "SIGN_CONTEXT_REFUSED",
702
- { httpStatus: response.status, errorCode: typeof body.error_code === "string" ? body.error_code : void 0 },
703
- paymentId
704
- );
1188
+ if (err instanceof HavenTypedDataNotAllowedError) {
1189
+ return {
1190
+ success: false,
1191
+ code: err.code,
1192
+ message: err.message,
1193
+ next_action: err.next_action,
1194
+ ...nextStepWireFields(err.step)
1195
+ };
705
1196
  }
706
- const signData = body.sign_data;
707
- const x402Expected = body.x402_expected;
708
- if (!signData || typeof signData.hash !== "string" || !signData.typed_data || typeof signData.typed_data !== "object" || !x402Expected) {
709
- throw new HavenSignContextError(
710
- "The Haven sign-context response is missing sign_data.typed_data or x402_expected \u2014 the backend may predate #1263. Pass typed_data_b64 from the quote result instead.",
711
- "SIGN_CONTEXT_MALFORMED",
712
- void 0,
713
- paymentId
714
- );
1197
+ if (err instanceof HavenSignContextError) {
1198
+ return {
1199
+ success: false,
1200
+ code: err.code,
1201
+ message: err.message,
1202
+ next_action: err.next_action,
1203
+ ...err.fallback !== void 0 ? { fallback: err.fallback } : {},
1204
+ ...err.retry_with_new_quote ? { retry_with_new_quote: true } : {},
1205
+ ...err.http_status !== void 0 ? { http_status: err.http_status } : {},
1206
+ ...err.backend_error_code !== void 0 ? { backend_error_code: err.backend_error_code } : {},
1207
+ ...err.client_update ? { client_update: err.client_update } : {},
1208
+ // #3103: the typed step the error decided beside its action.
1209
+ ...err.next_tool ? { next_tool: err.next_tool } : {},
1210
+ ...err.next_tool_server ? { next_tool_server: err.next_tool_server } : {},
1211
+ ...err.next_tool_name ? { next_tool_name: err.next_tool_name } : {},
1212
+ ...err.next_tool_server_role ? { next_tool_server_role: err.next_tool_server_role } : {},
1213
+ ...err.next_arguments ? { next_arguments: err.next_arguments } : {},
1214
+ ...err.next_tool_omitted_reason ? { next_tool_omitted_reason: err.next_tool_omitted_reason } : {}
1215
+ };
715
1216
  }
716
- const paymentRequired = body.payment_required;
717
- return {
718
- paymentId: String(body.payment_id ?? paymentId),
719
- payloadHash: signData.hash,
720
- typedData: signData.typed_data,
721
- x402Expected,
722
- paymentRequired: paymentRequired && typeof paymentRequired === "object" && !Array.isArray(paymentRequired) ? paymentRequired : null
723
- };
724
- }
725
-
726
- // src/tools.ts
727
- var sweepAuthorizationSchema = z$1.object({
728
- from: z$1.string().regex(/^0x[0-9a-fA-F]{40}$/, "from must be a 0x address"),
729
- to: z$1.string().regex(/^0x[0-9a-fA-F]{40}$/, "to must be a 0x address"),
730
- value: z$1.string().regex(/^[0-9]+$/, "value must be a decimal atomic amount"),
731
- validAfter: z$1.string().regex(/^[0-9]+$/, "validAfter must be a decimal unix time"),
732
- validBefore: z$1.string().regex(/^[0-9]+$/, "validBefore must be a decimal unix time"),
733
- nonce: z$1.string().regex(/^0x[0-9a-fA-F]{64}$/, "nonce must be a 0x-prefixed 32-byte hex string"),
734
- token: z$1.string().regex(/^0x[0-9a-fA-F]{40}$/, "token must be a 0x address"),
735
- chainId: z$1.number().int().positive()
736
- });
737
- var bindingVersionSchema = z$1.number().int().positive();
738
- var sweepExpectedAuthSchema = z$1.object({
739
- version: bindingVersionSchema,
740
- message: z$1.string().min(1),
741
- signature: z$1.string().regex(/^0x[0-9a-fA-F]+$/, "signature must be a 0x-prefixed hex string"),
742
- signer: z$1.string().regex(/^0x[0-9a-fA-F]{40}$/, "signer must be a 0x address")
743
- });
744
- var x402ExpectedShape = {
745
- payment_id: z$1.string().min(1),
746
- payload_hash: z$1.string().regex(/^0x[0-9a-fA-F]+$/, "payload_hash must be a 0x-prefixed hex string"),
747
- resource_url: z$1.string().url(),
748
- merchant_to: z$1.string().min(1),
749
- amount: z$1.string().min(1),
750
- asset: z$1.string().min(1),
751
- network: z$1.string().min(1),
752
- // Required: expires_at is folded into the Haven-signed binding message, so the
753
- // signer must receive the exact same value to reconstruct a matching message.
754
- // Omitting it used to fail downstream with a cryptic "authentication message is
755
- // invalid" — making it required surfaces a clear INVALID_INPUT at the boundary.
756
- expires_at: z$1.string().min(1),
757
- // #1138: present on a delegation-rail (v2) context. It commits to the EIP-712
758
- // typed data the account validates; the signer refuses to raw-sign the bare
759
- // hash when it is present, and refuses to sign typed data when it is absent.
760
- typed_data_hash: z$1.string().regex(/^0x[0-9a-fA-F]+$/, "typed_data_hash must be a 0x-prefixed hex string").optional(),
761
- // #1690: present on a v3 context. The delegate this quote was created FOR —
762
- // the signer refuses to sign when it is not its own. Inside the Haven-signed
763
- // message, so stripping it here would only break the binding signature.
764
- payer_delegate: z$1.string().regex(/^0x[0-9a-fA-F]{40}$/, "payer_delegate must be a 0x-prefixed address").optional(),
765
- payer_agent_id: z$1.string().min(1).optional(),
766
- auth: z$1.object({
767
- // Open at the boundary, enforced in the signer — see bindingVersionSchema.
768
- version: bindingVersionSchema,
769
- message: z$1.string().min(1),
770
- signature: z$1.string().regex(/^0x[0-9a-fA-F]+$/, "signature must be a 0x-prefixed hex string"),
771
- signer: z$1.string().min(1)
772
- })
773
- };
774
- var x402ExpectedSchema = z$1.object(x402ExpectedShape);
775
- var toolSchemas = {
776
- haven_sign_sweep_delegate: {
777
- // The authorization fields prepared by Haven's POST /sweep/prepare. Passed
778
- // through verbatim from the hosted haven_sweep_delegate tool — the signer
779
- // re-derives the binding message from these exact values.
780
- authorization: sweepAuthorizationSchema,
781
- // Haven's signature over the authorization context (the binding).
782
- expected_auth: sweepExpectedAuthSchema
783
- },
784
- haven_sign: {
785
- // #1263: THE preferred x402 input — the signer fetches the exact signing
786
- // payload + expected context from Haven itself (authenticated with the
787
- // locally-stored agent credential), so no bulky bytes ever cross the
788
- // model. Pass payment_id ALONE for a delegation-rail x402 funding intent.
789
- payment_id: z$1.string().min(1).optional(),
790
- // The unsigned hash from haven_pay / haven_pay_x402_quote (payload_hash).
791
- // Optional when payment_id is supplied (the fetch carries it).
792
- payload_hash: z$1.string().regex(/^0x[0-9a-fA-F]+$/, "payload_hash must be a 0x-prefixed hex string").optional(),
793
- // Pass x402.expected from hosted haven_pay_x402_quote when this hash funds
794
- // a standard x402 merchant retry. The signer records it locally and returns
795
- // an opaque x402_binding for the later header-signing step.
796
- x402_expected: x402ExpectedSchema.optional(),
797
- // #1138: delegation-rail intents sign THIS, not payload_hash. Object-typed
798
- // (not z.unknown()) so MCP clients embed it as JSON rather than a string.
799
- typed_data: z$1.record(z$1.string(), z$1.unknown()).optional(),
800
- // #1255: the same payload as ONE opaque base64 string, exactly as returned
801
- // by the hosted tools. Preferred over typed_data when both are present —
802
- // an agent re-emitting multi-KB nested JSON between tool calls is the
803
- // failure mode this field removes (a truncated/reshaped payload fails the
804
- // digest check and the payment refuses, correctly but pointlessly).
805
- // Bounded: a realistic redemption payload is ~10KB encoded; 256KB is
806
- // generous headroom while keeping the offline signer from materializing
807
- // arbitrarily large caller input.
808
- typed_data_b64: z$1.string().min(1).max(262144).optional()
809
- },
810
- haven_x402_sign_header: {
811
- // The parsed HTTP 402 PaymentRequired from the merchant. Typed as an object
812
- // (not z.unknown(), which becomes empty JSON Schema `{}`) so MCP clients
813
- // embed it as JSON rather than serialising the object to a string.
814
- payment_required: z$1.record(z$1.string(), z$1.unknown()),
815
- // Opaque binding returned by haven_sign when x402_expected was supplied.
816
- x402_binding: z$1.string().min(1)
817
- },
818
- haven_sign_x402: {
819
- // #1263: preferred — pass payment_id (plus payment_required) and the
820
- // signer fetches the exact signing payload + expected context itself.
821
- payment_id: z$1.string().min(1).optional(),
822
- // One-shot x402 signing: funding hash + merchant header in one local call.
823
- // Both optional when payment_id is supplied (the fetch carries them).
824
- payload_hash: z$1.string().regex(/^0x[0-9a-fA-F]+$/, "payload_hash must be a 0x-prefixed hex string").optional(),
825
- x402_expected: x402ExpectedSchema.optional(),
826
- // #1355: optional when payment_id is supplied — the signer's context fetch
827
- // carries the stored 402 PaymentRequired, so `{ payment_id }` alone is the
828
- // preferred call. Still required (via the runtime check in the handler)
829
- // for the quote-based fallback path without a payment_id.
830
- payment_required: z$1.record(z$1.string(), z$1.unknown()).optional(),
831
- // #1138: delegation-rail intents sign THIS, not payload_hash. Object-typed
832
- // (not z.unknown()) so MCP clients embed it as JSON rather than a string.
833
- typed_data: z$1.record(z$1.string(), z$1.unknown()).optional(),
834
- // #1255: see haven_sign.typed_data_b64 — the copy-through-safe form.
835
- typed_data_b64: z$1.string().min(1).max(262144).optional()
1217
+ if (err instanceof HavenSigningError) {
1218
+ return { success: false, code: err.code, message: err.message };
836
1219
  }
837
- // #3101: keys survive on the type (see the hosted server's contracts.ts).
838
- };
839
- var SIGN_DESCRIPTION = [
840
- "Sign an unsigned Haven payment hash with the local delegate key. The delegate key never leaves",
841
- "this process. Pass the payload_hash returned by haven_pay or haven_pay_x402_quote.",
842
- "For x402, also pass x402_expected from haven_pay_x402_quote; the signer records it locally",
843
- "and returns { signature, x402_binding }. x402_expected includes expires_at; sign before that",
844
- "window closes. DELEGATION-RAIL x402 accounts (#1263): pass payment_id ALONE (preferred) \u2014 this",
845
- "signer fetches the exact signing payload and expected context from Haven itself, so nothing",
846
- "bulky ever crosses your context. Fallback: pass typed_data_b64 through UNCHANGED (never re-type",
847
- "the nested typed_data JSON); the account validates that EIP-712 payload, not payload_hash.",
848
- "Next: call mcp__haven__haven_submit with signature, then pass x402_binding",
849
- "to mcp__haven-signer__haven_x402_sign_header. For plain SafeTransfer payments, just pass payload_hash and",
850
- "relay the returned signature via mcp__haven__haven_submit."
851
- ].join(" ");
852
- var X402_SIGN_HEADER_DESCRIPTION = [
853
- "Build and sign the EIP-3009 merchant payment header for the merchant leg of an x402 payment.",
854
- "The delegate key stays local \u2014 only the signed header crosses any boundary.",
855
- "Pass the payment_required from the original merchant 402 response and the x402_binding",
856
- "returned by haven_sign. It must be haven_sign \u2014 NOT haven_sign_x402, which is a",
857
- "one-shot that builds the header itself and spends its own binding doing so. If you called",
858
- "haven_sign_x402, its result already carries payment_header; retry the merchant with that and",
859
- "do not call this tool. The signer validates the merchant, amount, resource, asset, and",
860
- "network against the recorded funding context before signing, checks expires_at when present,",
861
- "and rejects mismatches or expired payment windows.",
862
- "Returns { payment_header, accepted }. On your retry set BOTH PAYMENT-SIGNATURE (x402 v2) and",
863
- "X-PAYMENT (v1) to <payment_header>; a strict v2 merchant reads only the first.",
864
- "Only call after haven_submit has confirmed the funding step (nextAction=none or",
865
- "the funding tx has a confirmed status). Next for paid MCP tools: call mcp__haven__haven_complete_mcp_tool."
866
- ].join(" ");
867
- var SIGN_X402_DESCRIPTION = [
868
- "One-shot x402 signing for the fast 3-call flow: sign the funding hash AND build the EIP-3009",
869
- "merchant payment header in a single local call (equivalent to haven_sign followed by",
870
- "haven_x402_sign_header). The delegate key never leaves this process. From the haven_pay_mcp_tool",
871
- "result pass JUST payment_id \u2014 PREFERRED (#1263, #1355): this signer fetches the exact signing",
872
- "payload, expected context, and merchant payment_required from Haven itself, so nothing bulky",
873
- "crosses your context. If the signer reports the context carried no payment_required (older",
874
- "backend), re-call with payment_id plus payment_required verbatim from the pay result.",
875
- "Fallback for older backends: pass payload_hash, x402_expected (the nested x402.expected object \u2014",
876
- "passing the whole x402 object is also accepted and unwrapped for you), and typed_data_b64",
877
- "through UNCHANGED; the signer",
878
- "signs the typed data instead of payload_hash and refuses the bare hash when the context commits to typed data.",
879
- "Returns",
880
- "{ signature, x402_binding, payment_header, accepted }; hand signature + payment_header to",
881
- "mcp__haven__haven_settle_mcp_tool to fund and settle in one hosted call. The header is built now (before",
882
- "funding confirms), so its short validity window starts here \u2014 call mcp__haven__haven_settle_mcp_tool promptly,",
883
- "and re-run mcp__haven__haven_pay_mcp_tool with the same idempotency_key if a tool returns PAYMENT_WINDOW_EXPIRED.",
884
- "The returned x402_binding is ALREADY SPENT \u2014 this tool consumed it building payment_header \u2014",
885
- "so never pass it to mcp__haven-signer__haven_x402_sign_header; that tool is the follow-up to",
886
- "haven_sign, not to this one. payment_header IS the header to use.",
887
- "Next: for a paid MCP tool, call mcp__haven__haven_settle_mcp_tool. For a direct plain-HTTP x402",
888
- "merchant (the haven_pay_x402_quote path), relay signature via mcp__haven__haven_submit and then",
889
- "retry the original merchant URL YOURSELF, setting BOTH PAYMENT-SIGNATURE (x402 v2) and",
890
- "X-PAYMENT (v1) to payment_header \u2014 Haven never contacts that merchant."
891
- ].join(" ");
892
- var SIGN_SWEEP_DELEGATE_DESCRIPTION = [
893
- "Sign a Haven-prepared gasless USDC sweep that recovers stranded funds from the delegate",
894
- "wallet back to your Haven wallet. The delegate key never leaves this process and this tool",
895
- "never broadcasts \u2014 it returns only an EIP-3009 signature that Haven's relayer submits and",
896
- "pays gas for. Pass the authorization and expected_auth returned by the hosted",
897
- "haven_sweep_delegate tool. The signer verifies Haven authored the authorization and that it",
898
- "pays out to your own Safe before signing, then returns { signature } to hand back to",
899
- "mcp__haven__haven_sweep_delegate to complete recovery."
900
- ].join(" ");
901
- var toolDescriptions = {
902
- haven_sign: SIGN_DESCRIPTION,
903
- haven_x402_sign_header: X402_SIGN_HEADER_DESCRIPTION,
904
- haven_sign_x402: SIGN_X402_DESCRIPTION,
905
- haven_sign_sweep_delegate: SIGN_SWEEP_DELEGATE_DESCRIPTION
906
- };
907
- async function signFundingLeg(signer, expected, payloadHash, typedData) {
908
- if (!expected.typedDataHash) {
909
- return signer.signX402FundingHash(payloadHash, expected);
1220
+ if (err instanceof HavenApiError) {
1221
+ return { success: false, code: err.code, message: err.message, statusCode: err.statusCode };
910
1222
  }
911
- if (!typedData) {
912
- throw new HavenSigningError(
913
- "This x402 funding intent is on the delegation rail: it commits to EIP-712 typed data, which was not supplied. Pass typed_data_b64 from haven_pay_mcp_tool / haven_pay_x402_quote through unchanged (or typed_data verbatim) \u2014 the bare payload_hash is not what the account validates."
914
- );
1223
+ if (err instanceof HavenError) {
1224
+ return {
1225
+ success: false,
1226
+ code: err.code,
1227
+ message: err.message,
1228
+ statusCode: err.statusCode,
1229
+ paymentId: err.paymentId,
1230
+ ...err.code === AgentPaymentFailureCode.PaymentWindowExpired ? {
1231
+ next_action: AgentPaymentNextAction.PaymentWindowExpired,
1232
+ retry_with_new_quote: true,
1233
+ suggested_tool: "haven_pay_mcp_tool",
1234
+ // #3103: same omission as the hosted window-expired helper.
1235
+ ...nextStepWireFields(signerRefusalStep({
1236
+ nextAction: AgentPaymentNextAction.PaymentWindowExpired,
1237
+ nextTool: null,
1238
+ nextToolOmittedReason: "re-run the hosted quote tool you called with the same idempotency_key; which one depends on the flow (suggested_tool names the MCP one)"
1239
+ }))
1240
+ } : {}
1241
+ };
915
1242
  }
916
- return signer.signX402FundingTypedData(typedData, expected);
917
- }
918
- function toExpectedX402(raw) {
919
1243
  return {
920
- paymentId: raw.payment_id,
921
- payloadHash: raw.payload_hash,
922
- typedDataHash: raw.typed_data_hash,
923
- payerDelegate: raw.payer_delegate,
924
- payerAgentId: raw.payer_agent_id,
925
- resourceUrl: raw.resource_url,
926
- merchantTo: raw.merchant_to,
927
- amount: raw.amount,
928
- asset: raw.asset,
929
- network: raw.network,
930
- expiresAt: raw.expires_at,
931
- auth: raw.auth
1244
+ success: false,
1245
+ code: "UNKNOWN_ERROR",
1246
+ message: err instanceof Error ? err.message : String(err)
932
1247
  };
933
1248
  }
934
- function parseFetchedExpected(fetched) {
935
- const parsed = z$1.object(x402ExpectedShape).safeParse(fetched.x402Expected);
936
- if (!parsed.success) {
937
- throw new HavenSigningError(
938
- `The Haven sign-context response carried a malformed x402_expected \u2014 the backend may predate #1263. Pass payload_hash + x402_expected from the quote result instead. Underlying: ${parsed.error.issues[0]?.message ?? "schema mismatch"}`
1249
+
1250
+ // src/consent.ts
1251
+ var toolSummaries = {
1252
+ haven_sign: "signs a Haven-prepared payment, task-budget open, or task-budget close (a UserOp from your own account whose only call redeems a delegation made to it, directly or through a self-delegated task-budget child, or an x402 funding intent against a Haven-signed context)",
1253
+ haven_x402_sign_header: "builds and signs the EIP-3009 merchant payment header for an x402 payment",
1254
+ haven_sign_x402: "the two above in one call: funding payload + merchant header",
1255
+ haven_sign_sweep_delegate: "signs a Haven-prepared gasless sweep of stranded funds back to your own account"
1256
+ };
1257
+ var CONNECTOR_DOCTOR_HINT = [
1258
+ "Wired by the Haven connector (Claude Desktop, Codex, ...)? Diagnose and repair with: npx @haven_ai/connect --doctor",
1259
+ " (the doctor shows this as a failed 'Signer stdio handshake' check; the connector's setup outcome reports it as",
1260
+ " local_signer_ack_required; the repair is the connector's --ack-local-tools)."
1261
+ ].join("\n");
1262
+ var SIGNER_ACK_ENV = "HAVEN_SIGNER_ACK";
1263
+ var SIGNER_CONSENT_SURFACE_VERSION = 2;
1264
+ function computeSignerConsentHash(input) {
1265
+ const identity = [
1266
+ input.delegateAddress.toLowerCase(),
1267
+ (input.accountAddress ?? "").toLowerCase(),
1268
+ input.agentId ?? "",
1269
+ input.chainId ?? "",
1270
+ input.network ?? ""
1271
+ ].join("|");
1272
+ const toolCanonical = [...input.toolNames].sort().join(",");
1273
+ return createHash("sha256").update(`${identity}
1274
+ ${toolCanonical}
1275
+ surface:v${SIGNER_CONSENT_SURFACE_VERSION}`).digest("hex").slice(0, 16);
1276
+ }
1277
+ function renderSignerConsentBlock(input, hash) {
1278
+ const lines = [
1279
+ "",
1280
+ "------------------------------------------------------------",
1281
+ "Haven edge signer - first-launch consent",
1282
+ "------------------------------------------------------------",
1283
+ "",
1284
+ `Delegate address: ${input.delegateAddress}`
1285
+ ];
1286
+ lines.push(`Haven wallet: ${input.accountAddress ?? "not provided to this signer"}`);
1287
+ if (input.agentId) lines.push(`Agent ID: ${input.agentId}`);
1288
+ if (typeof input.chainId === "number") lines.push(`Chain ID: ${input.chainId}`);
1289
+ if (input.network) lines.push(`Network: ${input.network}`);
1290
+ lines.push("");
1291
+ lines.push("This local signer holds the delegate key on this machine and signs");
1292
+ lines.push("payment payloads or x402 merchant headers for the delegate address above.");
1293
+ lines.push("Its one network use is a read-only fetch of a pending payment's signing");
1294
+ lines.push("payload from Haven, authenticated with the agent credential stored next to");
1295
+ lines.push("this signer's key file - it never sends the key, a signature, or anything");
1296
+ lines.push("else outbound, and it cannot show a live allowance summary.");
1297
+ lines.push("The agent's signed budget delegation is the real spend gate, and the wallet");
1298
+ lines.push("owner can pause or revoke agent authority outside this signer.");
1299
+ lines.push("");
1300
+ lines.push("Tools this signer will expose to your agent runtime (one line each; the");
1301
+ lines.push("full agent-facing descriptions are what the runtime sees):");
1302
+ for (const name of input.toolNames) {
1303
+ lines.push(` - ${name}: ${toolSummaries[name]}`);
1304
+ }
1305
+ lines.push("");
1306
+ lines.push("A local audit entry is appended for every signing operation. Audit entries");
1307
+ lines.push("record timestamp, tool, payload hash, and delegate address; never the key,");
1308
+ lines.push("signature, or x402 payment header.");
1309
+ lines.push("");
1310
+ lines.push(`Consent hash: ${hash}`);
1311
+ lines.push("");
1312
+ lines.push("To acknowledge, EITHER:");
1313
+ lines.push(` - set ${SIGNER_ACK_ENV}=${hash} in this process's environment, OR`);
1314
+ lines.push(" - re-run with --ack to write the acknowledgement next to your");
1315
+ lines.push(" credential file (sidecar <credentials>.signer-ack.json).");
1316
+ lines.push("");
1317
+ lines.push(CONNECTOR_DOCTOR_HINT);
1318
+ lines.push("");
1319
+ lines.push("------------------------------------------------------------");
1320
+ lines.push("");
1321
+ return lines.join("\n");
1322
+ }
1323
+ async function ensureSignerConsent(input, options = {}) {
1324
+ const env = options.env ?? process.env;
1325
+ const out = options.out ?? process.stderr;
1326
+ const hash = computeSignerConsentHash(input);
1327
+ const envAck = env[SIGNER_ACK_ENV];
1328
+ if (typeof envAck === "string" && envAck.length > 0) {
1329
+ if (envAck === hash) return { ok: true, hash, reason: "env_var_match" };
1330
+ out.write(renderSignerConsentBlock(input, hash));
1331
+ out.write(
1332
+ `${SIGNER_ACK_ENV} was set but did not match the current signer consent hash.
1333
+ Expected: ${hash}
1334
+ Got: ${envAck}
1335
+ Re-acknowledge with the new hash above, or run with --ack.
1336
+
1337
+ `
939
1338
  );
1339
+ return { ok: false, hash, reason: "env_var_mismatch" };
940
1340
  }
941
- return parsed.data;
1341
+ const ackPath = sidecarPath(options.credentialsPath);
1342
+ if (ackPath) {
1343
+ const stored = await readAckFile(ackPath);
1344
+ if (stored?.ack === hash) return { ok: true, hash, reason: "ack_file_match" };
1345
+ }
1346
+ if (options.writeAck && ackPath) {
1347
+ out.write(renderSignerConsentBlock(input, hash));
1348
+ await writeAckFile(ackPath, hash);
1349
+ out.write(`Wrote acknowledgement to ${ackPath}
1350
+
1351
+ `);
1352
+ return { ok: true, hash, reason: "wrote_ack_file" };
1353
+ }
1354
+ out.write(renderSignerConsentBlock(input, hash));
1355
+ return { ok: false, hash, reason: "no_acknowledgement" };
942
1356
  }
943
- function resolveTypedData(args) {
944
- if (!args.typed_data_b64) return args.typed_data;
1357
+ function registeredSignerToolNames() {
1358
+ return Object.keys(toolSchemas);
1359
+ }
1360
+ function sidecarPath(credentialsPath) {
1361
+ if (!credentialsPath) return null;
1362
+ return resolve(`${credentialsPath}.signer-ack.json`);
1363
+ }
1364
+ async function readAckFile(path) {
945
1365
  try {
946
- const decoded = JSON.parse(
947
- Buffer.from(args.typed_data_b64, "base64").toString("utf8")
948
- );
949
- if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) {
950
- throw new Error("decoded value is not an object");
1366
+ const raw = await readFile(path, "utf8");
1367
+ const parsed = JSON.parse(raw);
1368
+ return { ack: typeof parsed.ack === "string" ? parsed.ack : void 0 };
1369
+ } catch {
1370
+ return null;
1371
+ }
1372
+ }
1373
+ async function writeAckFile(path, hash) {
1374
+ await mkdir(dirname(path), { recursive: true });
1375
+ await writeFile(
1376
+ path,
1377
+ JSON.stringify({ ack: hash, at: (/* @__PURE__ */ new Date()).toISOString() }, null, 2),
1378
+ "utf8"
1379
+ );
1380
+ }
1381
+
1382
+ // src/cli-args.ts
1383
+ var CONNECTOR_DOCTOR_COMMAND = "npx @haven_ai/connect --doctor";
1384
+ function parseSignerArgs(argv) {
1385
+ const options = {};
1386
+ for (let i = 0; i < argv.length; i += 1) {
1387
+ const arg = argv[i];
1388
+ if (arg === "--credentials" || arg === "--credentials-path") {
1389
+ options.credentialsPath = argv[i + 1];
1390
+ i += 1;
1391
+ } else if (arg === "--ack") {
1392
+ options.writeAck = true;
1393
+ } else if (arg === "--help" || arg === "-h") {
1394
+ return { kind: "help", text: helpText() };
1395
+ } else {
1396
+ return {
1397
+ kind: "unknown-option",
1398
+ option: arg,
1399
+ text: `haven-signer: unknown option ${arg}. Run with --help for the options this signer takes` + (arg === "--ack-local-tools" ? ` \u2014 --ack-local-tools belongs to the connector (${CONNECTOR_DOCTOR_COMMAND}), not the signer.` : ".")
1400
+ };
951
1401
  }
952
- return decoded;
1402
+ }
1403
+ return { kind: "run", options };
1404
+ }
1405
+ function helpText() {
1406
+ const tools = registeredSignerToolNames();
1407
+ return [
1408
+ "Haven edge signer (local, holds the delegate key)",
1409
+ "",
1410
+ "Runs a local stdio MCP server exposing sign-only tools:",
1411
+ ...tools.map((name) => ` - ${name}`),
1412
+ "Pair it with the hosted, keyless Haven MCP server: the hosted server",
1413
+ "constructs and relays, this one signs.",
1414
+ "",
1415
+ "Usage:",
1416
+ " npx @haven_ai/signer --credentials /path/to/agent.json",
1417
+ "",
1418
+ "Options:",
1419
+ " --credentials <path> Haven credential JSON (delegate_key is read from it).",
1420
+ " Alias: --credentials-path. Also supported: HAVEN_CREDENTIALS,",
1421
+ " or HAVEN_DELEGATE_KEY.",
1422
+ " --ack Acknowledge the first-launch consent block and write",
1423
+ " a signer sidecar acknowledgement next to the credential.",
1424
+ " --help, -h This text. Any other option is refused (exit 2).",
1425
+ "",
1426
+ "Consent:",
1427
+ " On first launch the signer prints the sign-only tool list and delegate",
1428
+ " address, then refuses to start unless acknowledged. Its only Haven API call",
1429
+ " is a read-only fetch of the signing payload for one pending payment, so it",
1430
+ " cannot show a live allowance summary.",
1431
+ " Acknowledge with EITHER --ack OR HAVEN_SIGNER_ACK=<hash> in your environment.",
1432
+ "",
1433
+ "Connector-wired (the usual case)?",
1434
+ ` Diagnose and repair with: ${CONNECTOR_DOCTOR_COMMAND}`,
1435
+ " (an unacknowledged signer shows there as a failed 'Signer stdio handshake'",
1436
+ " check, and in the connector's setup outcome as local_signer_ack_required;",
1437
+ " the repair is the connector's --ack-local-tools, not a signer flag).",
1438
+ ""
1439
+ ].join("\n");
1440
+ }
1441
+ function createEdgeSigner(delegateKey, options = {}) {
1442
+ let delegateAddress;
1443
+ try {
1444
+ delegateAddress = addressFromKey(delegateKey);
953
1445
  } catch (err) {
954
1446
  throw new HavenSigningError(
955
- `typed_data_b64 did not decode to a JSON object. Pass the exact string returned by the hosted Haven tool, unchanged \u2014 do not re-encode, trim, or reformat it. Underlying error: ${err instanceof Error ? err.message : String(err)}`
1447
+ `Invalid delegate key: ${err instanceof Error ? err.message : String(err)}`
956
1448
  );
957
1449
  }
958
- }
959
- function createToolHandlers(signer, options = {}) {
960
- async function resolveSignContext(args) {
961
- if (!args.payment_id) return null;
962
- const identity = await options.signContext?.loadIdentity() ?? null;
963
- if (!identity) {
964
- throw new HavenSigningError(
965
- `payment_id signing needs the agent identity (identity.json next to the signer credentials), which this signer could not load. Re-run \`${connectorRerunCommand()}\` to restore it, or pass typed_data_b64 from the quote result instead.`
966
- );
967
- }
968
- const ctx = await fetchX402SignContext(
969
- identity,
970
- args.payment_id,
971
- options.signContext?.fetchImpl
972
- );
973
- if (args.payload_hash && args.payload_hash.toLowerCase() !== ctx.payloadHash.toLowerCase()) {
974
- throw new HavenSigningError(
975
- `The supplied payload_hash does not match the signing context Haven serves for payment ${args.payment_id}. Pass payment_id alone, or check which quote the hash came from.`
976
- );
1450
+ const x402Bindings = /* @__PURE__ */ new Map();
1451
+ const retiredX402Bindings = /* @__PURE__ */ new Map();
1452
+ const RETIRED_BINDING_MEMORY = 64;
1453
+ function retireX402Binding(id, reason) {
1454
+ retiredX402Bindings.set(id, reason);
1455
+ if (retiredX402Bindings.size > RETIRED_BINDING_MEMORY) {
1456
+ const oldest = retiredX402Bindings.keys().next();
1457
+ if (!oldest.done) retiredX402Bindings.delete(oldest.value);
977
1458
  }
978
- return ctx;
979
1459
  }
980
1460
  return {
981
- haven_sign: async (input) => runTool(async () => {
982
- const args = parse("haven_sign", coerceX402Expected(input));
983
- const fetched = await resolveSignContext(args);
984
- const typedData = fetched?.typedData ?? resolveTypedData(args);
985
- const payloadHash = fetched?.payloadHash ?? args.payload_hash;
986
- if (!payloadHash) {
1461
+ delegateAddress,
1462
+ async signDelegationTypedData(typedData) {
1463
+ const account = privateKeyToAccount(delegateKey);
1464
+ return account.signTypedData(typedData);
1465
+ },
1466
+ async signX402FundingTypedData(typedData, expected) {
1467
+ if (expected?.auth) {
1468
+ assertSupportedBindingVersion(
1469
+ expected.auth.version,
1470
+ SUPPORTED_X402_EXPECTED_VERSIONS,
1471
+ "x402 expected context"
1472
+ );
1473
+ }
1474
+ if (!typedData) {
1475
+ throw new HavenSigningError(
1476
+ "This x402 funding intent is on the delegation rail: it commits to EIP-712 typed data, which was not supplied. Pass typed_data_b64 from haven_pay_mcp_tool / haven_pay_x402_quote through unchanged (or typed_data verbatim) \u2014 a bare payload_hash is not what the account validates."
1477
+ );
1478
+ }
1479
+ const digest = hashTypedData(typedData);
1480
+ assertExpectedBinding(digest, expected, options.x402BindingSigner);
1481
+ assertPayerMatchesDelegate(expected, delegateAddress, options.agentId);
1482
+ if (isSettlementChildTypedData(typedData)) {
1483
+ const settlementChainId = chainIdForNetwork(expected.network);
1484
+ if (settlementChainId === void 0) {
1485
+ throw new HavenSigningError(
1486
+ `Refusing to sign the x402 settlement child: this signer cannot map the network '${expected.network}' to a chain id, so it cannot check which chain the child is scoped to. Update @haven_ai/signer.`
1487
+ );
1488
+ }
1489
+ assertOwnSettlementChild(typedData, {
1490
+ merchantTo: expected.merchantTo,
1491
+ amount: expected.amount,
1492
+ asset: expected.asset,
1493
+ // From the SIGNED network, not the payload's own claim. Passing
1494
+ // Number(typedData.domain.chainId) here made the check compare a
1495
+ // value to itself — vacuous, and precisely the "declared one thing,
1496
+ // signed another" class this file exists to catch (#1455 review).
1497
+ chainId: settlementChainId,
1498
+ expiresAt: expected.expiresAt
1499
+ }, delegateAddress);
1500
+ } else if (isPackedUserOperationTypedData(typedData)) {
1501
+ assertUserOpTypedDataBinding(typedData, expected.payloadHash);
1502
+ assertBoundDirectPaymentUserOp$1(typedData, delegateAddress);
1503
+ assertFundingLegPaysDelegate(typedData, {
1504
+ delegateAddress,
1505
+ asset: expected.asset,
1506
+ amount: expected.amount
1507
+ });
1508
+ } else {
1509
+ throw new HavenTypedDataRefusedError(
1510
+ `This signer's x402 arm signs only a funding-leg PackedUserOperation or an erc7710 settlement Delegation; this typed data is '${String(
1511
+ typedData.primaryType
1512
+ )}'. Refusing, even though its digest is bound by the expected context.`
1513
+ );
1514
+ }
1515
+ const account = privateKeyToAccount(delegateKey);
1516
+ const signature = await account.signTypedData(
1517
+ typedData
1518
+ );
1519
+ const x402Binding = randomUUID();
1520
+ x402Bindings.set(x402Binding, { ...expected });
1521
+ return { signature, x402Binding };
1522
+ },
1523
+ async buildX402PaymentHeader(paymentRequired, x402Binding) {
1524
+ const expected = x402Bindings.get(x402Binding);
1525
+ if (!expected) {
1526
+ const retired = retiredX402Bindings.get(x402Binding);
1527
+ if (retired === "header_built") {
1528
+ throw new HavenSigningError(
1529
+ "This x402 binding was already used to build a merchant header. Bindings are single-use. If you called haven_sign_x402, it already returned the payment_header \u2014 retry the merchant with THAT header instead of building another; haven_x402_sign_header is the follow-up to haven_sign, not to haven_sign_x402. If you no longer have the header, re-run the quote tool with the same idempotency_key."
1530
+ );
1531
+ }
1532
+ if (retired === "window_expired") {
1533
+ throw new HavenSigningError(
1534
+ "This x402 binding was retired because its payment window closed before a merchant header could be built \u2014 no header exists to retry with. Re-run the quote tool with the same idempotency_key to get a fresh window, then sign again."
1535
+ );
1536
+ }
987
1537
  throw new HavenSigningError(
988
- "Pass payment_id (preferred for delegation-rail x402) or payload_hash."
1538
+ "x402 funding binding is required before signing a merchant header. Sign the hosted funding payload with x402_expected first (haven_sign returns a binding this tool can use). A binding is also lost when the signer process restarts, since bindings live in memory only \u2014 re-sign to mint a fresh one."
989
1539
  );
990
1540
  }
991
- const expectedRaw = fetched ? parseFetchedExpected(fetched) : args.x402_expected;
992
- const x402Expected = expectedRaw ? toExpectedX402(expectedRaw) : null;
993
- const result = x402Expected ? await signFundingLeg(signer, x402Expected, payloadHash, typedData) : null;
994
- if (!result) {
995
- if (typedData) {
996
- if (isSettlementChildTypedData(typedData)) {
997
- throw new HavenSigningError(
998
- "Refusing to sign a delegation payload with no expected context. This typed data is a DELEGATION \u2014 signing it grants a third party authority to move funds \u2014 so it is only ever signed against a Haven-signed context that the caveats are verified against. Call this tool with { payment_id } instead (the signer then fetches and verifies the context itself), or use haven_sign_x402."
999
- );
1000
- }
1001
- const signature2 = await signer.signDelegationTypedData(typedData);
1002
- await auditSigning("haven_sign", payloadHash);
1003
- return { signature: signature2 };
1541
+ const assertWindowOpenOrRetire = () => {
1542
+ try {
1543
+ assertX402PaymentWindowOpen(expected);
1544
+ } catch (err) {
1545
+ x402Bindings.delete(x402Binding);
1546
+ retireX402Binding(x402Binding, "window_expired");
1547
+ throw err;
1004
1548
  }
1005
- const signature = signer.signPaymentHash(payloadHash);
1006
- await auditSigning("haven_sign", payloadHash);
1007
- return { signature };
1549
+ };
1550
+ assertWindowOpenOrRetire();
1551
+ const option = selectStandardPaymentOption(paymentRequired.accepts);
1552
+ if (!option) {
1553
+ throw new HavenApiError(
1554
+ "No compatible payment option found in x402 requirements. Haven supports standard x402 exact payments on Base USDC.",
1555
+ 400
1556
+ );
1008
1557
  }
1009
- await auditSigning("haven_sign", payloadHash);
1010
- return { signature: result.signature, x402_binding: result.x402Binding };
1011
- }),
1012
- haven_x402_sign_header: async (input) => runTool(async () => {
1013
- const args = parse("haven_x402_sign_header", coercePaymentRequired(input));
1014
- const result = await signer.buildX402PaymentHeader(
1015
- args.payment_required,
1016
- args.x402_binding
1017
- );
1018
- await auditSigning(
1019
- "haven_x402_sign_header",
1020
- hashPayloadForAudit(args.payment_required)
1558
+ assertX402MatchesExpected(paymentRequired, option, expected);
1559
+ const account = privateKeyToAccount(delegateKey);
1560
+ const requirements = toStandardPaymentRequirements(paymentRequired, option);
1561
+ const { exact } = await loadX402Schemes();
1562
+ assertWindowOpenOrRetire();
1563
+ const header = await exact.evm.createPaymentHeader(
1564
+ account,
1565
+ paymentRequired.x402Version,
1566
+ requirements
1021
1567
  );
1022
- return { payment_header: result.paymentHeader, accepted: result.accepted };
1023
- }),
1024
- haven_sign_x402: async (input) => runTool(async () => {
1025
- const args = parse("haven_sign_x402", coerceX402Expected(coercePaymentRequired(input)));
1026
- const fetched = await resolveSignContext(args);
1027
- const payloadHash = fetched?.payloadHash ?? args.payload_hash;
1028
- const expectedRaw = fetched ? parseFetchedExpected(fetched) : args.x402_expected;
1029
- if (!payloadHash || !expectedRaw) {
1568
+ if (paymentRequired.x402Version < 2) {
1569
+ x402Bindings.delete(x402Binding);
1570
+ retireX402Binding(x402Binding, "header_built");
1571
+ return { paymentHeader: header, accepted: option };
1572
+ }
1573
+ try {
1574
+ const payment = decodeBase64Json(header);
1575
+ const wrapped = encodeBase64Json(
1576
+ x402V2PaymentEnvelope(paymentRequired, option, payment.payload)
1577
+ );
1578
+ return { paymentHeader: wrapped, accepted: option };
1579
+ } finally {
1580
+ x402Bindings.delete(x402Binding);
1581
+ retireX402Binding(x402Binding, "header_built");
1582
+ }
1583
+ },
1584
+ async signSweepAuthorization({
1585
+ authorization,
1586
+ expectedAuth,
1587
+ expectedSafe
1588
+ }) {
1589
+ assertSweepBinding(authorization, expectedAuth, options.x402BindingSigner);
1590
+ if (!sameAddress(authorization.from, delegateAddress)) {
1030
1591
  throw new HavenSigningError(
1031
- "Pass payment_id (preferred \u2014 the signer fetches the signing context itself) or payload_hash + x402_expected from the quote result."
1592
+ "Sweep authorization `from` does not match this delegate address."
1032
1593
  );
1033
1594
  }
1034
- const funding = await signFundingLeg(
1035
- signer,
1036
- toExpectedX402(expectedRaw),
1037
- payloadHash,
1038
- fetched?.typedData ?? resolveTypedData(args)
1039
- );
1040
- const paymentRequired = fetched?.paymentRequired ?? args.payment_required;
1041
- if (!paymentRequired) {
1595
+ if (expectedSafe && !sameAddress(authorization.to, expectedSafe)) {
1042
1596
  throw new HavenSigningError(
1043
- fetched ? "The Haven sign-context for this payment_id carried no payment_required (backend predates #1355). Pass payment_required from the pay result explicitly, verbatim." : "payment_required is required on the quote-based path (no payment_id to fetch it by). Pass it verbatim from the quote result."
1597
+ "Sweep authorization `to` does not match the agent's account (Haven wallet) in the local credential."
1044
1598
  );
1045
1599
  }
1046
- const header = await signer.buildX402PaymentHeader(
1047
- paymentRequired,
1048
- funding.x402Binding
1049
- );
1050
- await auditSigning("haven_sign_x402", payloadHash);
1051
- await auditSigning("haven_sign_x402", hashPayloadForAudit(paymentRequired));
1052
- return {
1053
- signature: funding.signature,
1054
- x402_binding: funding.x402Binding,
1055
- payment_header: header.paymentHeader,
1056
- accepted: header.accepted
1600
+ const typedData = buildSweepTypedData(authorization);
1601
+ const viemTypedData = {
1602
+ domain: {
1603
+ ...typedData.domain,
1604
+ verifyingContract: typedData.domain.verifyingContract
1605
+ },
1606
+ types: typedData.types,
1607
+ primaryType: typedData.primaryType,
1608
+ message: {
1609
+ ...typedData.message,
1610
+ from: typedData.message.from,
1611
+ to: typedData.message.to,
1612
+ nonce: typedData.message.nonce
1613
+ }
1057
1614
  };
1058
- }),
1059
- haven_sign_sweep_delegate: async (input) => runTool(async () => {
1060
- const args = parse("haven_sign_sweep_delegate", input);
1061
- const result = await signer.signSweepAuthorization({
1062
- authorization: args.authorization,
1063
- expectedAuth: args.expected_auth,
1064
- // Cross-check `to` against the Safe in the local credential when present.
1065
- expectedSafe: options.audit?.accountAddress
1066
- });
1067
- await auditSigning(
1068
- "haven_sign_sweep_delegate",
1069
- hashPayloadForAudit(args.authorization)
1070
- );
1071
- return { signature: result.signature };
1072
- })
1615
+ const account = privateKeyToAccount(delegateKey);
1616
+ const signature = await account.signTypedData(viemTypedData);
1617
+ const recovered = await recoverTypedDataAddress({ ...viemTypedData, signature });
1618
+ if (!sameAddress(recovered, delegateAddress)) {
1619
+ throw new HavenSigningError(
1620
+ "Local sweep signature verification failed \u2014 recovered address does not match the delegate key."
1621
+ );
1622
+ }
1623
+ return { signature };
1624
+ }
1073
1625
  };
1074
- async function auditSigning(tool, payloadHash) {
1075
- if (!options.audit) return;
1076
- const { auditPath, ...context } = options.audit;
1077
- await appendSigningAuditEntry(
1078
- createSigningAuditEntry(tool, payloadHash, {
1079
- ...context,
1080
- delegateAddress: signer.delegateAddress
1081
- }),
1082
- auditPath
1626
+ }
1627
+ function assertSweepBinding(authorization, expectedAuth, trustedSigner) {
1628
+ if (!expectedAuth || typeof expectedAuth !== "object") {
1629
+ throw new HavenSigningError("Sweep authorization binding is required before signing.");
1630
+ }
1631
+ if (!trustedSigner) {
1632
+ throw new HavenSigningError(
1633
+ "Sweep binding verifier is not configured. Set HAVEN_X402_BINDING_SIGNER before signing sweep authorizations."
1083
1634
  );
1084
1635
  }
1085
- }
1086
- function parse(name, input) {
1087
- return z$1.object(toolSchemas[name]).parse(input ?? {});
1088
- }
1089
- function coerceX402Expected(input) {
1090
- if (!input || typeof input !== "object") return input;
1091
- const record = input;
1092
- let value = record.x402_expected;
1093
- if (typeof value === "string") {
1094
- try {
1095
- value = JSON.parse(value);
1096
- } catch {
1097
- return input;
1098
- }
1636
+ assertSupportedBindingVersion(
1637
+ expectedAuth.version,
1638
+ SUPPORTED_SWEEP_BINDING_VERSIONS,
1639
+ "sweep authorization binding"
1640
+ );
1641
+ const message = buildSweepAuthorizationMessage(authorization);
1642
+ if (expectedAuth.message !== message) {
1643
+ throw new HavenSigningError("Sweep authorization binding does not match the authorization being signed.");
1099
1644
  }
1100
- if (!value || typeof value !== "object") return input;
1101
- const obj = value;
1102
- if (obj.auth === void 0 && obj.expected && typeof obj.expected === "object") {
1103
- return { ...record, x402_expected: obj.expected };
1645
+ if (!sameAddress(expectedAuth.signer, trustedSigner)) {
1646
+ throw new HavenSigningError("Sweep authorization binding was not signed by the configured Haven signer.");
1647
+ }
1648
+ if (!verifySignature(hashMessage(message), expectedAuth.signature, trustedSigner)) {
1649
+ throw new HavenSigningError("Sweep authorization binding signature could not be verified.");
1104
1650
  }
1105
- if (value !== record.x402_expected) return { ...record, x402_expected: value };
1106
- return input;
1107
1651
  }
1108
- function coercePaymentRequired(input) {
1109
- if (!input || typeof input !== "object") return input;
1110
- const record = input;
1111
- if (typeof record.payment_required !== "string") return input;
1112
- try {
1113
- return { ...record, payment_required: JSON.parse(record.payment_required) };
1114
- } catch {
1115
- return input;
1652
+ function assertX402MatchesExpected(paymentRequired, option, expected) {
1653
+ assertExpectedShape(expected);
1654
+ const headerResource = option.resource ?? paymentRequired.resource.url;
1655
+ if (headerResource !== expected.resourceUrl) {
1656
+ throw new HavenSigningError("x402 payment_required resource does not match the funded intent.");
1657
+ }
1658
+ if (!sameAddress(option.payTo, expected.merchantTo)) {
1659
+ throw new HavenSigningError("x402 merchant recipient does not match the funded intent.");
1660
+ }
1661
+ if (x402AuthorizationAmount(option) !== expected.amount) {
1662
+ throw new HavenSigningError("x402 amount does not match the funded intent.");
1663
+ }
1664
+ if (!sameAddress(option.asset, expected.asset)) {
1665
+ throw new HavenSigningError("x402 asset does not match the funded intent.");
1666
+ }
1667
+ if (option.network !== expected.network) {
1668
+ throw new HavenSigningError("x402 network does not match the funded intent.");
1116
1669
  }
1117
1670
  }
1118
- async function runTool(fn) {
1119
- try {
1120
- return { success: true, data: await fn() };
1121
- } catch (err) {
1122
- return normalizeError(err);
1671
+ function assertExpectedShape(expected) {
1672
+ if (!expected || typeof expected !== "object") {
1673
+ throw new HavenSigningError("x402 expected funding context is required before signing a merchant header.");
1123
1674
  }
1124
1675
  }
1125
- function normalizeError(err) {
1126
- if (err instanceof z$1.ZodError) {
1127
- return {
1128
- success: false,
1129
- code: "INVALID_INPUT",
1130
- message: err.errors.map((e) => `${e.path.join(".") || "(root)"}: ${e.message}`).join("; "),
1131
- statusCode: 400
1132
- };
1676
+ var SUPPORTED_X402_EXPECTED_VERSIONS = [2, 3];
1677
+ var SUPPORTED_SWEEP_BINDING_VERSIONS = [1];
1678
+ function assertSupportedBindingVersion(received, supported, context) {
1679
+ if (supported.includes(received)) return;
1680
+ const highest = Math.max(...supported);
1681
+ const outOfDate = received > highest;
1682
+ const code = context === "x402 expected context" ? SignerRefusalCode.UnsupportedExpectedContextVersion : SignerRefusalCode.UnsupportedSweepBindingVersion;
1683
+ const ceiling = outOfDate ? `This signer is out of date: it supports ${context} versions up to ${highest}, and Haven sent version ${received}. Update @haven_ai/signer \u2014 rerun the Haven connector (\`${connectorRerunCommand()}\`), which reinstalls the pinned MCP runtime.` : `Unsupported ${context} version ${received}: this signer supports ${supported.join(", ")}.`;
1684
+ const fallback = outOfDate ? SIGNER_UPDATE_FALLBACK : `This ${context} version (${received}) is older than what this signer enforces (${supported.join(", ")}) \u2014 updating @haven_ai/signer will not restore it. Nothing was signed or spent; stop and tell the user rather than retrying.`;
1685
+ throw new HavenUnsupportedSignerVersionError(
1686
+ `${ceiling} Nothing was signed. Do not rewrite the version field to a supported value: it is part of the Haven-signed binding message, so changing it invalidates the signature and would misrepresent what Haven declared.`,
1687
+ code,
1688
+ supported,
1689
+ received,
1690
+ fallback
1691
+ );
1692
+ }
1693
+ function assertExpectedBinding(typedDataDigest, expected, trustedSigner) {
1694
+ assertExpectedShape(expected);
1695
+ if (!trustedSigner) {
1696
+ throw new HavenSigningError(
1697
+ "x402 expected-context verifier is not configured. Set HAVEN_X402_BINDING_SIGNER before signing x402 funding payloads."
1698
+ );
1133
1699
  }
1134
- if (err instanceof HavenUnsupportedSignerVersionError) {
1135
- return {
1136
- success: false,
1137
- code: err.code,
1138
- message: err.message,
1139
- supported_versions: [...err.supportedVersions],
1140
- received_version: err.receivedVersion,
1141
- fallback: err.fallback,
1142
- next_action: AgentPaymentNextAction.StopAndTellUser,
1143
- // #3103: no tool can fix a version skew from inside the call.
1144
- ...nextStepWireFields(signerRefusalStep({
1145
- nextAction: AgentPaymentNextAction.StopAndTellUser,
1146
- nextTool: null,
1147
- nextToolOmittedReason: "update @haven_ai/signer by re-running the connector, then repeat the same call"
1148
- }))
1149
- };
1700
+ if (expected.auth) {
1701
+ assertSupportedBindingVersion(
1702
+ expected.auth.version,
1703
+ SUPPORTED_X402_EXPECTED_VERSIONS,
1704
+ "x402 expected context"
1705
+ );
1150
1706
  }
1151
- if (err instanceof HavenSignContextError) {
1152
- return {
1153
- success: false,
1154
- code: err.code,
1155
- message: err.message,
1156
- next_action: err.next_action,
1157
- ...err.fallback !== void 0 ? { fallback: err.fallback } : {},
1158
- ...err.retry_with_new_quote ? { retry_with_new_quote: true } : {},
1159
- ...err.http_status !== void 0 ? { http_status: err.http_status } : {},
1160
- ...err.backend_error_code !== void 0 ? { backend_error_code: err.backend_error_code } : {},
1161
- // #3103: the typed step the error decided beside its action.
1162
- ...err.next_tool ? { next_tool: err.next_tool } : {},
1163
- ...err.next_tool_server ? { next_tool_server: err.next_tool_server } : {},
1164
- ...err.next_tool_name ? { next_tool_name: err.next_tool_name } : {},
1165
- ...err.next_tool_server_role ? { next_tool_server_role: err.next_tool_server_role } : {},
1166
- ...err.next_arguments ? { next_arguments: err.next_arguments } : {},
1167
- ...err.next_tool_omitted_reason ? { next_tool_omitted_reason: err.next_tool_omitted_reason } : {}
1168
- };
1707
+ if (!expected.typedDataHash) {
1708
+ throw new HavenSigningError(
1709
+ "Refusing to sign typed data under an expected context that does not commit to it. Haven must return a v2/v3 x402 expected context (with typedDataHash) for a delegation-rail intent."
1710
+ );
1169
1711
  }
1170
- if (err instanceof HavenSigningError) {
1171
- return { success: false, code: err.code, message: err.message };
1712
+ if (expected.typedDataHash.toLowerCase() !== typedDataDigest.toLowerCase()) {
1713
+ throw new HavenSigningError(
1714
+ "x402 typed data does not match the digest Haven committed to in the expected context. Refusing to sign \u2014 the payload was altered in transit or Haven declared a different one. The most common cause is the typed data being truncated or reshaped while being copied between tool calls (#1255): re-run the hosted quote and pass its typed_data_b64 string through UNCHANGED instead of re-emitting the nested JSON."
1715
+ );
1172
1716
  }
1173
- if (err instanceof HavenApiError) {
1174
- return { success: false, code: err.code, message: err.message, statusCode: err.statusCode };
1717
+ const message = buildX402ExpectedMessage({
1718
+ paymentId: expected.paymentId,
1719
+ payloadHash: expected.payloadHash,
1720
+ resourceUrl: expected.resourceUrl,
1721
+ merchantTo: expected.merchantTo,
1722
+ amount: expected.amount,
1723
+ asset: expected.asset,
1724
+ network: expected.network,
1725
+ expiresAt: expected.expiresAt,
1726
+ typedDataHash: expected.typedDataHash,
1727
+ payerDelegate: expected.payerDelegate,
1728
+ payerAgentId: expected.payerAgentId
1729
+ });
1730
+ const expectedVersion = expected.payerDelegate ? 3 : 2;
1731
+ if (expected.auth?.version !== expectedVersion || expected.auth.message !== message) {
1732
+ throw new HavenSigningError("x402 expected context authentication message is invalid.");
1175
1733
  }
1176
- if (err instanceof HavenError) {
1177
- return {
1178
- success: false,
1179
- code: err.code,
1180
- message: err.message,
1181
- statusCode: err.statusCode,
1182
- paymentId: err.paymentId,
1183
- ...err.code === AgentPaymentFailureCode.PaymentWindowExpired ? {
1184
- next_action: AgentPaymentNextAction.PaymentWindowExpired,
1185
- retry_with_new_quote: true,
1186
- suggested_tool: "haven_pay_mcp_tool",
1187
- // #3103: same omission as the hosted window-expired helper.
1188
- ...nextStepWireFields(signerRefusalStep({
1189
- nextAction: AgentPaymentNextAction.PaymentWindowExpired,
1190
- nextTool: null,
1191
- nextToolOmittedReason: "re-run the hosted quote tool you called with the same idempotency_key; which one depends on the flow (suggested_tool names the MCP one)"
1192
- }))
1193
- } : {}
1194
- };
1734
+ if (!sameAddress(expected.auth.signer, trustedSigner)) {
1735
+ throw new HavenSigningError("x402 expected context was not signed by the configured Haven signer.");
1195
1736
  }
1196
- return {
1197
- success: false,
1198
- code: "UNKNOWN_ERROR",
1199
- message: err instanceof Error ? err.message : String(err)
1200
- };
1201
- }
1202
-
1203
- // src/consent.ts
1204
- var SIGNER_ACK_ENV = "HAVEN_SIGNER_ACK";
1205
- var SIGNER_CONSENT_SURFACE_VERSION = 2;
1206
- function computeSignerConsentHash(input) {
1207
- const identity = [
1208
- input.delegateAddress.toLowerCase(),
1209
- (input.accountAddress ?? "").toLowerCase(),
1210
- input.agentId ?? "",
1211
- input.chainId ?? "",
1212
- input.network ?? ""
1213
- ].join("|");
1214
- const toolCanonical = [...input.toolNames].sort().join(",");
1215
- return createHash("sha256").update(`${identity}
1216
- ${toolCanonical}
1217
- surface:v${SIGNER_CONSENT_SURFACE_VERSION}`).digest("hex").slice(0, 16);
1218
- }
1219
- function renderSignerConsentBlock(input, hash) {
1220
- const lines = [
1221
- "",
1222
- "------------------------------------------------------------",
1223
- "Haven edge signer - first-launch consent",
1224
- "------------------------------------------------------------",
1225
- "",
1226
- `Delegate address: ${input.delegateAddress}`
1227
- ];
1228
- lines.push(`Haven wallet: ${input.accountAddress ?? "not provided to this signer"}`);
1229
- if (input.agentId) lines.push(`Agent ID: ${input.agentId}`);
1230
- if (typeof input.chainId === "number") lines.push(`Chain ID: ${input.chainId}`);
1231
- if (input.network) lines.push(`Network: ${input.network}`);
1232
- lines.push("");
1233
- lines.push("This local signer holds the delegate key on this machine and signs");
1234
- lines.push("payment payloads or x402 merchant headers for the delegate address above.");
1235
- lines.push("Its one network use is a read-only fetch of a pending payment's signing");
1236
- lines.push("payload from Haven, authenticated with the agent credential stored next to");
1237
- lines.push("this signer's key file - it never sends the key, a signature, or anything");
1238
- lines.push("else outbound, and it cannot show a live allowance summary.");
1239
- lines.push("On-chain Safe rules remain the real spend gate, and the wallet owner can");
1240
- lines.push("pause or revoke agent authority outside this signer.");
1241
- lines.push("");
1242
- lines.push("Tools this signer will expose to your agent runtime:");
1243
- for (const name of input.toolNames) {
1244
- lines.push(` - ${name}`);
1245
- lines.push(` ${toolDescriptions[name]}`);
1737
+ if (!verifySignature(hashMessage(message), expected.auth.signature, trustedSigner)) {
1738
+ throw new HavenSigningError("x402 expected context signature could not be verified.");
1246
1739
  }
1247
- lines.push("");
1248
- lines.push("A local audit entry is appended for every signing operation. Audit entries");
1249
- lines.push("record timestamp, tool, payload hash, and delegate address; never the key,");
1250
- lines.push("signature, or x402 payment header.");
1251
- lines.push("");
1252
- lines.push(`Consent hash: ${hash}`);
1253
- lines.push("");
1254
- lines.push("To acknowledge, EITHER:");
1255
- lines.push(` - set ${SIGNER_ACK_ENV}=${hash} in this process's environment, OR`);
1256
- lines.push(" - re-run with --ack to write the acknowledgement next to your");
1257
- lines.push(" credential file (sidecar <credentials>.signer-ack.json).");
1258
- lines.push("");
1259
- lines.push("------------------------------------------------------------");
1260
- lines.push("");
1261
- return lines.join("\n");
1262
1740
  }
1263
- async function ensureSignerConsent(input, options = {}) {
1264
- const env = options.env ?? process.env;
1265
- const out = options.out ?? process.stderr;
1266
- const hash = computeSignerConsentHash(input);
1267
- const envAck = env[SIGNER_ACK_ENV];
1268
- if (typeof envAck === "string" && envAck.length > 0) {
1269
- if (envAck === hash) return { ok: true, hash, reason: "env_var_match" };
1270
- out.write(renderSignerConsentBlock(input, hash));
1271
- out.write(
1272
- `${SIGNER_ACK_ENV} was set but did not match the current signer consent hash.
1273
- Expected: ${hash}
1274
- Got: ${envAck}
1275
- Re-acknowledge with the new hash above, or run with --ack.
1276
-
1277
- `
1741
+ function assertPayerMatchesDelegate(expected, delegateAddress, localAgentId) {
1742
+ if (!expected.payerDelegate) return;
1743
+ if (sameAddress(expected.payerDelegate, delegateAddress)) return;
1744
+ const quoteAgent = expected.payerAgentId ?? "unknown";
1745
+ const localAgent = localAgentId ?? "unknown";
1746
+ throw new HavenSigningError(
1747
+ `This quote belongs to a DIFFERENT agent: the quote was created for agent ${quoteAgent} (delegate ${expected.payerDelegate}), but this signer holds agent ${localAgent} (delegate ${delegateAddress}). A long-lived host is holding stale credentials \u2014 its session authenticates as the old agent while the signer on disk belongs to the new one. Nothing was signed. Restart the host so it re-reads its wiring, then re-quote.`
1748
+ );
1749
+ }
1750
+ async function loadX402Schemes() {
1751
+ try {
1752
+ return await import('x402/schemes');
1753
+ } catch (err) {
1754
+ throw new HavenSigningError(
1755
+ `The x402 scheme module could not be loaded, so no merchant header was built (nothing was sent to the merchant): ${err instanceof Error ? err.message : String(err)}. The signer's runtime install is incomplete \u2014 run: npx @haven_ai/connect --doctor`
1278
1756
  );
1279
- return { ok: false, hash, reason: "env_var_mismatch" };
1280
1757
  }
1281
- const ackPath = sidecarPath(options.credentialsPath);
1282
- if (ackPath) {
1283
- const stored = await readAckFile(ackPath);
1284
- if (stored?.ack === hash) return { ok: true, hash, reason: "ack_file_match" };
1758
+ }
1759
+ function assertX402PaymentWindowOpen(expected) {
1760
+ if (!expected.expiresAt) return;
1761
+ const expiresAtMs = Date.parse(expected.expiresAt);
1762
+ if (Number.isNaN(expiresAtMs)) {
1763
+ throw new HavenSigningError("x402 expected context expiresAt is not a valid ISO timestamp.");
1285
1764
  }
1286
- if (options.writeAck && ackPath) {
1287
- out.write(renderSignerConsentBlock(input, hash));
1288
- await writeAckFile(ackPath, hash);
1289
- out.write(`Wrote acknowledgement to ${ackPath}
1290
-
1291
- `);
1292
- return { ok: true, hash, reason: "wrote_ack_file" };
1765
+ if (expiresAtMs <= Date.now()) {
1766
+ throw new HavenError(
1767
+ "The x402 payment window expired before the merchant header could be signed. Re-quote with haven_pay_mcp_tool using the same idempotency_key before trying again.",
1768
+ AgentPaymentFailureCode.PaymentWindowExpired,
1769
+ 410,
1770
+ expected.paymentId
1771
+ );
1293
1772
  }
1294
- out.write(renderSignerConsentBlock(input, hash));
1295
- return { ok: false, hash, reason: "no_acknowledgement" };
1296
1773
  }
1297
- function registeredSignerToolNames() {
1298
- return Object.keys(toolSchemas);
1774
+ function sameAddress(a, b) {
1775
+ return a.toLowerCase() === b.toLowerCase();
1299
1776
  }
1300
- function sidecarPath(credentialsPath) {
1301
- if (!credentialsPath) return null;
1302
- return resolve(`${credentialsPath}.signer-ack.json`);
1777
+
1778
+ // src/capabilities.ts
1779
+ var SIGNER_CAPABILITY_KEY = "haven/signer-compatibility";
1780
+ function signerCompatibility() {
1781
+ return {
1782
+ x402_expected_context_versions: [...SUPPORTED_X402_EXPECTED_VERSIONS],
1783
+ sweep_binding_versions: [...SUPPORTED_SWEEP_BINDING_VERSIONS],
1784
+ direct_sign_context_versions: [...SUPPORTED_DIRECT_SIGN_CONTEXT_VERSIONS],
1785
+ task_sign_context_versions: [...SUPPORTED_TASK_SIGN_CONTEXT_VERSIONS]
1786
+ };
1303
1787
  }
1304
- async function readAckFile(path) {
1305
- try {
1306
- const raw = await readFile(path, "utf8");
1307
- const parsed = JSON.parse(raw);
1308
- return { ack: typeof parsed.ack === "string" ? parsed.ack : void 0 };
1309
- } catch {
1310
- return null;
1311
- }
1788
+ function signerCapabilityAdvertisement() {
1789
+ return { experimental: { [SIGNER_CAPABILITY_KEY]: signerCompatibility() } };
1312
1790
  }
1313
- async function writeAckFile(path, hash) {
1314
- await mkdir(dirname(path), { recursive: true });
1315
- await writeFile(
1316
- path,
1317
- JSON.stringify({ ack: hash, at: (/* @__PURE__ */ new Date()).toISOString() }, null, 2),
1318
- "utf8"
1319
- );
1791
+ function signerInstructions() {
1792
+ const compatibility = signerCompatibility();
1793
+ return [
1794
+ "Haven edge signer: sign-only tools bound to the local delegate key. It never emits the",
1795
+ "key. Its one network capability is an authenticated READ of a signing context from",
1796
+ "Haven by payment_id \u2014 pass payment_id to haven_sign (preferred for both a direct payment,",
1797
+ "#3271, and delegation-rail x402) or haven_sign_x402 (x402 only) instead of relaying bulky",
1798
+ "typed-data payloads yourself.",
1799
+ "",
1800
+ "Version compatibility (check this BEFORE signing, not after):",
1801
+ `- x402 expected-context versions supported: ${compatibility.x402_expected_context_versions.join(", ")}`,
1802
+ `- sweep authorization binding versions supported: ${compatibility.sweep_binding_versions.join(", ")}`,
1803
+ `- direct-payment (haven_send / haven_pay) sign-context versions supported: ${compatibility.direct_sign_context_versions.join(", ")} \u2014 pass payment_id alone to haven_sign; this signer fetches the exact bytes`,
1804
+ `- task-budget sign-context versions supported: ${compatibility.task_sign_context_versions.join(", ")} \u2014 pass task_budget_id alone to haven_sign; this signer fetches the exact bytes`,
1805
+ "",
1806
+ "Haven quote and prepare results report the expected-context version they will emit",
1807
+ "(signer_compatibility.x402_expected_context_version). If that version is not in the list",
1808
+ "above, this signer is out of date: STOP before signing, and tell the user to update",
1809
+ `@haven_ai/signer by rerunning \`${connectorRerunCommand()}\`, which reinstalls the pinned`,
1810
+ "MCP runtime. Do not edit the version field to a supported value \u2014 it is part of the",
1811
+ "Haven-signed binding message, so changing it invalidates the signature.",
1812
+ "",
1813
+ "A version-mismatch refusal from haven_sign / haven_sign_x402 / haven_sign_sweep_delegate is",
1814
+ "machine-readable, not just prose: it carries code, supported_versions, received_version, and",
1815
+ "fallback fields alongside the message, so you can branch on it directly. Every signer",
1816
+ "refusal also carries the next-step family: next_tool_name + next_tool_server_role when a",
1817
+ "hosted tool follows (resolve the role against your own server names), else",
1818
+ "next_tool_omitted_reason saying why not."
1819
+ ].join("\n");
1320
1820
  }
1321
1821
  async function loadSignerCredentials(path = process.env.HAVEN_CREDENTIALS) {
1322
1822
  if (path) return loadFromFile(path);
@@ -1414,24 +1914,12 @@ function invalidChainIdError(label) {
1414
1914
  }
1415
1915
  async function warnIfCredentialFilePermissive(path, log = (message) => process.stderr.write(`${message}
1416
1916
  `), platform = process.platform) {
1417
- if (platform === "win32") return;
1418
- let mode;
1419
- try {
1420
- mode = (await stat(path)).mode;
1421
- } catch {
1422
- return;
1423
- }
1424
- if ((mode & 63) !== 0) {
1425
- const octal = (mode & 511).toString(8).padStart(4, "0");
1426
- log(
1427
- `haven-signer: warning: credential file at ${path} is readable beyond the owner (mode ${octal}). Run: chmod 600 ${path}`
1428
- );
1429
- }
1917
+ await warnIfFilePermissive("credential file", path, log, platform);
1430
1918
  }
1431
1919
 
1432
1920
  // src/server.ts
1433
1921
  var SIGNER_NAME = "@haven_ai/signer";
1434
- var SIGNER_VERSION = "0.4.0-alpha.0";
1922
+ var SIGNER_VERSION = "0.6.0-alpha.0";
1435
1923
  async function resolveSignerRuntime(options = {}) {
1436
1924
  assertSupportedNodeVersion(options.nodeVersion);
1437
1925
  if (options.delegateKey) {
@@ -1469,13 +1957,17 @@ function buildSignerMcpServer(signer, options = {}) {
1469
1957
  accountAddress: options.credentials?.accountAddress,
1470
1958
  chainId: options.credentials?.chainId
1471
1959
  },
1472
- // #1263: the payment_id signing path — the ONLY network call this server
1473
- // can make, an authenticated read of a signing context from Haven, using
1960
+ // #1263: the payment_id signing path — the ONLY network path this server
1961
+ // has: up to two authenticated reads of a signing context from Haven (x402,
1962
+ // then direct for haven_sign, #3271), using
1474
1963
  // the agent identity the connector stores next to the signer credential.
1475
1964
  // The signer CORE stays network-free; fetched bytes still pass the same
1476
1965
  // binding verification + digest re-derivation as tool-argument bytes.
1477
1966
  signContext: {
1478
- loadIdentity: () => loadHavenIdentity(credentialsPath)
1967
+ loadIdentity: () => loadHavenIdentity(credentialsPath),
1968
+ // #3303: the backend refuses a signer below a minimum it has set, at
1969
+ // sign-context — the one place this signer meets it.
1970
+ clientIdentity: havenClientIdentity(SIGNER_NAME, SIGNER_VERSION)
1479
1971
  }
1480
1972
  });
1481
1973
  const registerTool = server.tool.bind(server);
@@ -1503,7 +1995,7 @@ async function runSignerStdioServer(options = {}) {
1503
1995
  const decision = await runSignerConsentGate(signer, credentials, options);
1504
1996
  if (!decision.ok) {
1505
1997
  const err = new Error(
1506
- decision.reason === "env_var_mismatch" ? "Haven edge signer consent acknowledgement does not match the current configuration." : "Haven edge signer requires a one-time consent acknowledgement before starting."
1998
+ (decision.reason === "env_var_mismatch" ? "Haven edge signer consent acknowledgement does not match the current configuration." : "Haven edge signer requires a one-time consent acknowledgement before starting.") + " See the consent block above; if the Haven connector wired this signer, run: npx @haven_ai/connect --doctor"
1507
1999
  );
1508
2000
  err.code = "HAVEN_SIGNER_NO_CONSENT";
1509
2001
  throw err;
@@ -1538,49 +2030,18 @@ function toMcpResult(payload) {
1538
2030
  }
1539
2031
 
1540
2032
  // src/cli.ts
1541
- function parseArgs(argv) {
1542
- const options = {};
1543
- for (let i = 0; i < argv.length; i += 1) {
1544
- const arg = argv[i];
1545
- if (arg === "--credentials" || arg === "--credentials-path") {
1546
- options.credentialsPath = argv[i + 1];
1547
- i += 1;
1548
- } else if (arg === "--ack") {
1549
- options.writeAck = true;
1550
- } else if (arg === "--help" || arg === "-h") {
1551
- process.stdout.write(
1552
- [
1553
- "Haven edge signer (local, holds the delegate key)",
1554
- "",
1555
- "Runs a local stdio MCP server exposing sign-only tools (haven_sign,",
1556
- "haven_x402_sign_header). Pair it with the hosted, keyless Haven MCP",
1557
- "server: the hosted server constructs and relays, this one signs.",
1558
- "",
1559
- "Usage:",
1560
- " npx @haven_ai/signer --credentials /path/to/agent.json",
1561
- "",
1562
- "Options:",
1563
- " --credentials <path> Haven credential JSON (delegate_key is read from it).",
1564
- " Also supported: HAVEN_CREDENTIALS, or HAVEN_DELEGATE_KEY.",
1565
- " --ack Acknowledge the first-launch consent block and write",
1566
- " a signer sidecar acknowledgement next to the credential.",
1567
- "",
1568
- "Consent:",
1569
- " On first launch the signer prints the sign-only tool list and delegate",
1570
- " address, then refuses to start unless acknowledged. Its only Haven API call",
1571
- " is a read-only fetch of the signing payload for one pending payment, so it",
1572
- " cannot show a live allowance summary.",
1573
- " Acknowledge with EITHER --ack OR HAVEN_SIGNER_ACK=<hash> in your environment.",
1574
- ""
1575
- ].join("\n")
1576
- );
1577
- process.exit(0);
1578
- }
1579
- }
1580
- return options;
1581
- }
1582
2033
  async function main() {
1583
- await runSignerStdioServer(parseArgs(process.argv.slice(2)));
2034
+ const decision = parseSignerArgs(process.argv.slice(2));
2035
+ if (decision.kind === "help") {
2036
+ process.stdout.write(decision.text);
2037
+ process.exit(0);
2038
+ }
2039
+ if (decision.kind === "unknown-option") {
2040
+ process.stderr.write(`${decision.text}
2041
+ `);
2042
+ process.exit(2);
2043
+ }
2044
+ await runSignerStdioServer(decision.options);
1584
2045
  }
1585
2046
  main().catch((err) => {
1586
2047
  process.stderr.write(`${err instanceof Error ? err.message : String(err)}