@haven_ai/signer 0.0.0-dev.202609232125.72f3d20 → 0.0.0-dev.202609241802.ea320de

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -70,7 +70,8 @@ The `initialize` handshake advertises which binding versions this signer
70
70
  understands, under `capabilities.experimental['haven/signer-compatibility']`
71
71
  and in the MCP `instructions` string. Both are **derived** from
72
72
  `SUPPORTED_X402_EXPECTED_VERSIONS` / `SUPPORTED_SWEEP_BINDING_VERSIONS` in
73
- `src/core.ts` — the same constants the signing path enforces — so this README
73
+ `src/core.ts` and `SUPPORTED_DIRECT_SIGN_CONTEXT_VERSIONS` in
74
+ `src/sign-context.ts` (#3271) — the same constants the signing path enforces — so this README
74
75
  deliberately does not restate the numbers. Read them from the handshake, or
75
76
  from those constants.
76
77
 
@@ -102,6 +103,27 @@ verifies something before it signs, and `haven_sign` called with a bare
102
103
  `payload_hash` answers `BARE_HASH_REFUSED` with a typed next step instead of a
103
104
  signature.
104
105
 
106
+ **Direct payments (#3271).** A direct payment (`POST /payments`, surfaced as
107
+ `haven_send` / `haven_pay`) is signed as the account's EIP-712
108
+ `PackedUserOperation`, and Haven returns both that typed data and
109
+ `payload_hash` — the ERC-4337 v0.7 UserOperation hash of the same operation.
110
+ Before `haven_sign` signs one, it recomputes that hash from the typed data's
111
+ own domain, types and message and refuses (`USEROP_BINDING_MISMATCH`) unless
112
+ it equals `payload_hash` exactly, in the HybridDeleGator domain of the typed
113
+ data's own sender, against the v0.7 EntryPoint. This is a **corruption**
114
+ check: the caller supplies both values, so it proves they describe the same
115
+ operation, never that Haven prepared it — provenance is `payment_id` (below).
116
+ It runs whether the typed data arrived as a tool argument or by the
117
+ `payment_id` fetch; a fetched direct context that is not a
118
+ `PackedUserOperation` at all is refused by the same check. The x402 EIP-3009
119
+ bridge's funding leg keeps its own digest check against the Haven-signed
120
+ expected context in this signer (the SDK's `signForData` runs this binding
121
+ check there too). The check exists because this typed data is
122
+ multi-KB and can reach the signer through a language model relaying it by
123
+ hand: one corrupted character used to produce a valid-looking signature over
124
+ the wrong digest, surfacing only as an opaque `AA24 signature error` from the
125
+ bundler, after the signature was already produced.
126
+
105
127
  ## Startup, CLI options and the consent screen (#3173)
106
128
 
107
129
  **Startup cost.** The signer loads `@haven_ai/sdk/edge` — the ethers-free
@@ -195,6 +217,10 @@ funding confirms — so relay it promptly.
195
217
 
196
218
  ## What the signer refuses to sign
197
219
 
220
+ (`USEROP_BINDING_MISMATCH`, the #3271 direct-payment refusal, is described
221
+ under [Two ways to use it](#two-ways-to-use-it) and in the
222
+ [refusal table](#sign-context-refusal-codes).)
223
+
198
224
  These are local, independent checks. They do not trust Haven's assertion about
199
225
  what a payload means; they re-derive it.
200
226
 
@@ -239,7 +265,8 @@ what a payload means; they re-derive it.
239
265
  ## Sign-context refusal codes
240
266
 
241
267
  `{ payment_id }` calls (`haven_sign` / `haven_sign_x402`) fetch the exact
242
- signing payload from Haven (`GET /x402/:id/sign-context`, see
268
+ signing payload from Haven (`GET /x402/:id/sign-context`, and for a direct
269
+ payment `GET /payments/:id/sign-context` — see the #3271 paragraph below and
243
270
  [Custody](#custody)). Every refusal on that fetch is a `HavenSignContextError`
244
271
  (#3001) — a `HavenSigningError` subclass, so `instanceof HavenSigningError`
245
272
  still holds everywhere it did before, but structured like the version-mismatch
@@ -252,24 +279,44 @@ refusal class — `fallback`, `retry_with_new_quote`, `http_status`,
252
279
  names); every other row carries `next_tool_omitted_reason` with the exact
253
280
  remedy. `message` is unchanged.
254
281
 
282
+ **#3271: `haven_sign` (never `haven_sign_x402`) has one escape from this
283
+ table.** When the x402 fetch answers `SIGN_CONTEXT_REFUSED` with
284
+ `http_status: 409` and `backend_error_code: 'sign_context_unavailable'` — this
285
+ `payment_id` names a direct payment, not an x402 intent — `haven_sign` fetches
286
+ `GET /payments/:id/sign-context` instead, same auth header, timeout and
287
+ refusal structuring. That second fetch's own refusals reuse the codes in the
288
+ table below with direct-payment remedies: no quote to re-run, and the
289
+ `typed_data_b64` relay from the `haven_send` / `haven_pay` result is the
290
+ fallback. A 409 `sign_context_unavailable` from the direct route too (an
291
+ x402 row the x402 route could not serve, or a direct row with no stored
292
+ signing payload) surfaces the x402 route's own refusal instead. `haven_sign_x402` never takes this branch: a direct payment
293
+ carries no x402 context to fund a merchant retry with, so it surfaces the
294
+ 409 unchanged.
295
+
255
296
  | `code` | When | `next_action` | `fallback` | extra |
256
297
  |---|---|---|---|---|
257
298
  | `SIGN_CONTEXT_TIMEOUT` | The fetch (or its body read) did not finish within `SIGN_CONTEXT_TIMEOUT_MS` | `stop_and_tell_user` | `typed_data_b64` | — |
258
299
  | `SIGN_CONTEXT_UNREACHABLE` | The fetch failed before any response (DNS, connection refused, TLS, …) | `stop_and_tell_user` | `typed_data_b64` | — |
259
- | `SIGN_CONTEXT_MALFORMED` | The response body was missing `sign_data.typed_data` or `x402_expected` (a pre-#1263 backend) | `stop_and_tell_user` | `typed_data_b64` | — |
260
- | `SIGN_CONTEXT_REFUSED` (410 / `expired`) | The quote's window closed | `payment_window_expired` | — | `retry_with_new_quote: true`, `http_status`, `backend_error_code: 'expired'` |
261
- | `SIGN_CONTEXT_REFUSED` (other) | Unknown `payment_id` (404), `already_executed` / `not_signable` / `sign_context_unavailable` (409) | `stop_and_tell_user` | — | `http_status`, `backend_error_code` |
300
+ | `SIGN_CONTEXT_MALFORMED` | The response body was missing `sign_data.typed_data` / `x402_expected` (a pre-#1263 backend), or — on the direct-payment fetch — an unsupported `direct_sign_context_version` or a `signature_scheme` other than `eip712_userop` | `stop_and_tell_user` | `typed_data_b64` | — |
301
+ | `SIGN_CONTEXT_REFUSED` (x402: 410 or `expired`; direct: `expired` only) | x402: the quote's window closed. Direct: the payment's window closed — call `haven_send` / `haven_pay` again with the same `idempotency_key` | `payment_window_expired` | — | x402 only: `retry_with_new_quote: true`; both: `http_status`, and `backend_error_code: 'expired'` when the backend sent one |
302
+ | `SIGN_CONTEXT_REFUSED` (404, direct fetch) | The `payment_id` is not this agent's, or the backend predates #3271 and has no direct route | `stop_and_tell_user` | `typed_data_b64` | `http_status`, `backend_error_code` |
303
+ | `SIGN_CONTEXT_REFUSED` (other) | Unknown `payment_id` (404, x402 fetch), `already_executed` / `not_signable` (409), a bare 410 retired-rail tombstone (direct fetch) — or `sign_context_unavailable` (409): always on `haven_sign_x402`; on `haven_sign` only when neither route can serve the row | `stop_and_tell_user` | — | `http_status`, `backend_error_code` |
304
+ | `USEROP_BINDING_MISMATCH` | A direct payment's `PackedUserOperation` typed data (from the direct fetch, or a tool argument) does not recompute to its own `payload_hash`, or a fetched direct context is not a `PackedUserOperation` — see [Two ways to use it](#two-ways-to-use-it) above | `stop_and_tell_user` | — | no `http_status` — this is a local recomputation, not a backend refusal |
262
305
 
263
306
  `fallback: 'typed_data_b64'` appears only where signing OTHER bytes is a
264
- remedy — a transport failure or a body this signer could not read. It is
265
- **not** in the default quote result since #1272: obtain it by re-running the
266
- SAME quote tool with the SAME `idempotency_key` plus
267
- `include_signing_payload: true`, then pass `typed_data_b64` (plus
268
- `payload_hash` / `x402_expected`) instead of `payment_id`. A backend REFUSAL
307
+ remedy — a transport failure or a body this signer could not read, plus one
308
+ backend refusal: a 404 from the direct-payment fetch (an older backend with
309
+ no direct route, #3271). For x402 it is **not** in the default quote result
310
+ since #1272: obtain it by re-running the SAME quote tool with the SAME
311
+ `idempotency_key` plus `include_signing_payload: true`, then pass
312
+ `typed_data_b64` (plus `payload_hash` / `x402_expected`) instead of
313
+ `payment_id`. A direct payment's `haven_send` / `haven_pay` result always
314
+ carries `payload_hash` + `typed_data_b64`. Any other backend REFUSAL
269
315
  carries no fallback: an expired, executed or unsignable intent cannot be
270
- rescued by re-signing its bytes — an expired one is re-quoted (the same
316
+ rescued by re-signing its bytes — an expired x402 one is re-quoted (the same
271
317
  `payment_window_expired` + `retry_with_new_quote` the signer emits for
272
- `PAYMENT_WINDOW_EXPIRED`), the rest stop. These codes are signer-local,
318
+ `PAYMENT_WINDOW_EXPIRED`), an expired direct one is re-sent with the same
319
+ `idempotency_key` (no `retry_with_new_quote`), the rest stop. These codes are signer-local,
273
320
  not part of `@haven_ai/sdk`'s `AgentPaymentFailureCode` taxonomy, since they
274
321
  describe a local fetch failure, not a payment-domain outcome, and never reach
275
322
  the backend's REST/OpenAPI surface — only this package's MCP tool responses.
@@ -280,19 +327,23 @@ The delegate key is read from `HAVEN_DELEGATE_KEY` or a `--credentials` file's
280
327
  `delegate_key` (with a permissive-file warning). It stays in this process, and
281
328
  is never transmitted.
282
329
 
283
- **The signer makes at most one kind of network call, on one path, and it is a
284
- read.** Since [#1263](https://github.com/d-hinders/Haven-AI/issues/1263) the
285
- `{ payment_id }` form of `haven_sign` and `haven_sign_x402` performs an
286
- authenticated, read-only `GET /x402/:payment_id/sign-context` against Haven, so
287
- that agents never have to relay multi-KB EIP-712 payloads through a model's
288
- context window. **Only the Bearer API key goes out; the delegate key is never
289
- part of that request or its response.** Since #2985 that read is bounded:
290
- it aborts after `SIGN_CONTEXT_TIMEOUT_MS` (15 s) and reports a
291
- `HavenSignContextError` naming the timeout and the `typed_data_b64` fallback,
292
- so a hung backend cannot hang the signer — and the agent — past the funding
293
- window. Every refusal on this fetch (timeout, unreachable host, a non-ok
294
- backend response, a malformed body) is structured the same way, not just
295
- prose — see [Sign-context refusal codes](#sign-context-refusal-codes) below.
330
+ **The signer makes at most two kinds of network call, both reads.** Since
331
+ [#1263](https://github.com/d-hinders/Haven-AI/issues/1263) the `{ payment_id }`
332
+ form of `haven_sign` and `haven_sign_x402` performs an authenticated,
333
+ read-only `GET /x402/:payment_id/sign-context` against Haven, so that agents
334
+ never have to relay multi-KB EIP-712 payloads through a model's context
335
+ window. Since #3271, `haven_sign` (never `haven_sign_x402`) falls back to a
336
+ second read — `GET /payments/:payment_id/sign-context` — only when that first
337
+ fetch answers the backend's 409 `sign_context_unavailable`, i.e. this
338
+ `payment_id` names a direct payment rather than an x402 intent. **Only the
339
+ Bearer API key goes out on either read; the delegate key is never part of
340
+ either request or response.** Since #2985 both reads are bounded: each aborts
341
+ after `SIGN_CONTEXT_TIMEOUT_MS` (15 s) and reports a `HavenSignContextError`
342
+ naming the timeout and the `typed_data_b64` fallback, so a hung backend cannot
343
+ hang the signer — and the agent — past the funding window. Every refusal on
344
+ either fetch (timeout, unreachable host, a non-ok backend response, a
345
+ malformed body) is structured the same way, not just prose — see
346
+ [Sign-context refusal codes](#sign-context-refusal-codes) below.
296
347
  Nothing else in the package reaches the
297
348
  network: `haven_x402_sign_header` and `haven_sign_sweep_delegate` never fetch,
298
349
  the library surface above (`createEdgeSigner` and its six signing methods, over
package/dist/cli.cjs CHANGED
@@ -289,6 +289,7 @@ function stableStringify(value) {
289
289
  const object = value;
290
290
  return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(object[key])}`).join(",")}}`;
291
291
  }
292
+ var DIRECT_RELAY_FALLBACK = "call haven_sign with payload_hash and typed_data_b64 from the haven_send / haven_pay result, passed through unchanged";
292
293
  var HavenSignContextError = class extends edge.HavenSigningError {
293
294
  /**
294
295
  * #3001 / #3010 review: the recovery path, when signing OTHER bytes is
@@ -296,24 +297,31 @@ var HavenSignContextError = class extends edge.HavenSigningError {
296
297
  * body this signer could not read. `typed_data_b64` is NOT in the default
297
298
  * quote result since #1272: obtain it by re-running the SAME quote tool
298
299
  * with the SAME idempotency_key plus `include_signing_payload: true`.
299
- * Absent on a backend REFUSAL: an expired, executed or unsignable intent
300
- * cannot be rescued by re-signing its bytes.
300
+ * Absent on a backend REFUSAL (an expired, executed or unsignable intent
301
+ * cannot be rescued by re-signing its bytes) — with one #3271 exception: a
302
+ * 404 from the DIRECT fetch (an older backend with no direct route), where
303
+ * the `haven_send` / `haven_pay` result's relay fields still work.
301
304
  */
302
305
  fallback;
303
306
  /**
304
307
  * #3001: `AgentPaymentNextAction` values the signer already emits — the
305
308
  * version-mismatch refusal's `stop_and_tell_user` for the classes where
306
309
  * retrying the same call cannot help, and `payment_window_expired` (with
307
- * `retry_with_new_quote`) for the backend's 410 `expired`, exactly as the
308
- * plain `HavenError` branch already does for that code.
310
+ * `retry_with_new_quote`) for the x402 fetch's 410 / `expired`, exactly as
311
+ * the plain `HavenError` branch already does for that code. On the direct
312
+ * fetch (#3271) only `error_code: 'expired'` means an expired window, with
313
+ * no `retry_with_new_quote`; a bare 410 there is a retired-rail tombstone.
309
314
  */
310
315
  next_action;
311
316
  /**
312
317
  * #3103 (epic #3105, decisions 1 and 3): the typed next step beside the
313
318
  * action. A refusal Haven made (`SIGN_CONTEXT_REFUSED`, not expired) names
314
- * the hosted status read with the payment id; a transport failure or a
319
+ * the hosted status read with the payment id — except a 404 from the direct
320
+ * fetch (#3271), which names none and points at the `typed_data_b64`
321
+ * relay; a transport failure or a
315
322
  * malformed body names no tool — the remedy is re-running the SAME quote
316
- * tool with `include_signing_payload: true` — and an expired window names
323
+ * tool with `include_signing_payload: true` (x402), or the payment
324
+ * result's `typed_data_b64` relay (direct, #3271) — and an expired window names
317
325
  * none either (which quote tool depends on the flow). Never null: a step
318
326
  * with no tool carries `next_tool_omitted_reason`. Additive: the class is
319
327
  * not exported from the package index — the published surface is the
@@ -331,7 +339,7 @@ var HavenSignContextError = class extends edge.HavenSigningError {
331
339
  http_status;
332
340
  /** Present only for `SIGN_CONTEXT_REFUSED`: the backend's own `error_code`. */
333
341
  backend_error_code;
334
- constructor(message, code, refusal, paymentId) {
342
+ constructor(message, code, refusal, paymentId, flow = "x402") {
335
343
  super(message);
336
344
  this.code = code;
337
345
  this.name = "HavenSignContextError";
@@ -339,7 +347,22 @@ var HavenSignContextError = class extends edge.HavenSigningError {
339
347
  if (code === "SIGN_CONTEXT_REFUSED") {
340
348
  this.http_status = refusal?.httpStatus;
341
349
  this.backend_error_code = refusal?.errorCode;
342
- if (refusal?.httpStatus === 410 || refusal?.errorCode === "expired") {
350
+ if (flow === "direct" && refusal?.httpStatus === 404) {
351
+ this.fallback = "typed_data_b64";
352
+ this.next_action = edge.AgentPaymentNextAction.StopAndTellUser;
353
+ step = signerRefusalStep({
354
+ nextAction: edge.AgentPaymentNextAction.StopAndTellUser,
355
+ nextTool: null,
356
+ nextToolOmittedReason: DIRECT_RELAY_FALLBACK
357
+ });
358
+ } else if (flow === "direct" && refusal?.errorCode === "expired") {
359
+ this.next_action = edge.AgentPaymentNextAction.PaymentWindowExpired;
360
+ step = signerRefusalStep({
361
+ nextAction: edge.AgentPaymentNextAction.PaymentWindowExpired,
362
+ nextTool: null,
363
+ nextToolOmittedReason: "call haven_send / haven_pay again with the same idempotency_key \u2014 the expired payment frees it"
364
+ });
365
+ } else if (flow === "x402" && (refusal?.httpStatus === 410 || refusal?.errorCode === "expired")) {
343
366
  this.next_action = edge.AgentPaymentNextAction.PaymentWindowExpired;
344
367
  this.retry_with_new_quote = true;
345
368
  step = signerRefusalStep({
@@ -361,7 +384,7 @@ var HavenSignContextError = class extends edge.HavenSigningError {
361
384
  step = signerRefusalStep({
362
385
  nextAction: edge.AgentPaymentNextAction.StopAndTellUser,
363
386
  nextTool: null,
364
- 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"
387
+ 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"
365
388
  });
366
389
  }
367
390
  Object.assign(this, nextStepWireFields(step));
@@ -443,8 +466,89 @@ async function fetchX402SignContext(identity, paymentId, fetchImpl = fetch, time
443
466
  paymentRequired: paymentRequired && typeof paymentRequired === "object" && !Array.isArray(paymentRequired) ? paymentRequired : null
444
467
  };
445
468
  }
469
+ var SUPPORTED_DIRECT_SIGN_CONTEXT_VERSIONS = [edge.DIRECT_SIGN_CONTEXT_VERSION];
470
+ async function fetchDirectSignContext(identity, paymentId, fetchImpl = fetch, timeoutMs = SIGN_CONTEXT_TIMEOUT_MS) {
471
+ let response;
472
+ try {
473
+ response = await fetchImpl(
474
+ `${identity.apiUrl}/payments/${encodeURIComponent(paymentId)}/sign-context`,
475
+ {
476
+ headers: { Authorization: `Bearer ${identity.apiKey}` },
477
+ signal: AbortSignal.timeout(timeoutMs)
478
+ }
479
+ );
480
+ } catch (err) {
481
+ const timedOut = err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError");
482
+ throw new HavenSignContextError(
483
+ 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.`,
484
+ timedOut ? "SIGN_CONTEXT_TIMEOUT" : "SIGN_CONTEXT_UNREACHABLE",
485
+ void 0,
486
+ paymentId,
487
+ "direct"
488
+ );
489
+ }
490
+ let body;
491
+ try {
492
+ body = await response.json();
493
+ } catch (err) {
494
+ if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
495
+ throw new HavenSignContextError(
496
+ `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.`,
497
+ "SIGN_CONTEXT_TIMEOUT",
498
+ void 0,
499
+ paymentId,
500
+ "direct"
501
+ );
502
+ }
503
+ body = {};
504
+ }
505
+ if (!response.ok) {
506
+ const detail = typeof body.error === "string" ? body.error : `HTTP ${response.status}`;
507
+ throw new HavenSignContextError(
508
+ `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." : ""),
509
+ "SIGN_CONTEXT_REFUSED",
510
+ { httpStatus: response.status, errorCode: typeof body.error_code === "string" ? body.error_code : void 0 },
511
+ paymentId,
512
+ "direct"
513
+ );
514
+ }
515
+ const signData = body.sign_data;
516
+ const version = body.direct_sign_context_version;
517
+ 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)) {
518
+ throw new HavenSignContextError(
519
+ 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.",
520
+ "SIGN_CONTEXT_MALFORMED",
521
+ void 0,
522
+ paymentId,
523
+ "direct"
524
+ );
525
+ }
526
+ return {
527
+ paymentId: String(body.payment_id ?? paymentId),
528
+ payloadHash: signData.hash,
529
+ typedData: signData.typed_data,
530
+ status: typeof body.status === "string" ? body.status : "pending_signature",
531
+ expiresAt: typeof body.expires_at === "string" ? body.expires_at : void 0
532
+ };
533
+ }
446
534
 
447
535
  // src/tools.ts
536
+ var USEROP_BINDING_MISMATCH = "USEROP_BINDING_MISMATCH";
537
+ var HavenUserOpBindingRefusedError = class extends edge.HavenSigningError {
538
+ next_action = edge.AgentPaymentNextAction.StopAndTellUser;
539
+ step;
540
+ /** `message`: the SDK's `HavenUserOpBindingError.message` verbatim — it already names the mismatch and the remedy. */
541
+ constructor(message) {
542
+ super(message);
543
+ this.code = USEROP_BINDING_MISMATCH;
544
+ this.name = "HavenUserOpBindingRefusedError";
545
+ this.step = signerRefusalStep({
546
+ nextAction: edge.AgentPaymentNextAction.StopAndTellUser,
547
+ nextTool: null,
548
+ nextToolOmittedReason: "call haven_sign again with payment_id alone (preferred), or with typed_data copied unchanged from the payment result"
549
+ });
550
+ }
551
+ };
448
552
  var sweepAuthorizationSchema = v3.z.object({
449
553
  from: v3.z.string().regex(/^0x[0-9a-fA-F]{40}$/, "from must be a 0x address"),
450
554
  to: v3.z.string().regex(/^0x[0-9a-fA-F]{40}$/, "to must be a 0x address"),
@@ -565,8 +669,13 @@ var SIGN_DESCRIPTION = [
565
669
  "and returns { signature, x402_binding }. x402_expected includes expires_at; sign before that",
566
670
  "window closes. DELEGATION-RAIL x402 accounts (#1263): pass payment_id ALONE (preferred) \u2014 this",
567
671
  "signer fetches the exact signing payload and expected context from Haven itself, so nothing",
568
- "bulky ever crosses your context. Fallback: pass typed_data_b64 through UNCHANGED (never re-type",
569
- "the nested typed_data JSON); the account validates that EIP-712 payload, not payload_hash.",
672
+ "bulky ever crosses your context. DIRECT payments (#3271, haven_send / haven_pay): payment_id",
673
+ "ALONE also works here \u2014 the signer fetches the exact PackedUserOperation typed data and its",
674
+ "payload_hash from Haven and checks them against each other before signing, so a payload corrupted",
675
+ "in transit is refused (USEROP_BINDING_MISMATCH) instead of producing a bad signature. Fallback for",
676
+ "either flow: pass typed_data_b64 through UNCHANGED (never re-type the nested typed_data JSON); the",
677
+ "account validates that EIP-712 payload, not payload_hash. On a direct payment the same binding",
678
+ "check runs on the relayed payload too.",
570
679
  "Next: call mcp__haven__haven_submit with signature, then pass x402_binding",
571
680
  "to mcp__haven-signer__haven_x402_sign_header. A bare payload_hash with no payment_id, typed_data",
572
681
  "or x402_expected is REFUSED (BARE_HASH_REFUSED): a hash carries nothing this signer can verify."
@@ -679,7 +788,14 @@ function resolveTypedData(args) {
679
788
  }
680
789
  }
681
790
  function createToolHandlers(signer, options = {}) {
682
- async function resolveSignContext(args) {
791
+ function checkPayloadHashMatch(suppliedHash, fetchedHash, paymentId) {
792
+ if (suppliedHash && suppliedHash.toLowerCase() !== fetchedHash.toLowerCase()) {
793
+ throw new edge.HavenSigningError(
794
+ `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.`
795
+ );
796
+ }
797
+ }
798
+ async function resolveSignContext(args, opts) {
683
799
  if (!args.payment_id) return null;
684
800
  const identity = await options.signContext?.loadIdentity() ?? null;
685
801
  if (!identity) {
@@ -687,30 +803,39 @@ function createToolHandlers(signer, options = {}) {
687
803
  `payment_id signing needs the agent identity (identity.json next to the signer credentials), which this signer could not load. Re-run \`${edge.connectorRerunCommand()}\` to restore it, or pass typed_data_b64 from the quote result instead.`
688
804
  );
689
805
  }
690
- const ctx = await fetchX402SignContext(
691
- identity,
692
- args.payment_id,
693
- options.signContext?.fetchImpl
694
- );
695
- if (args.payload_hash && args.payload_hash.toLowerCase() !== ctx.payloadHash.toLowerCase()) {
696
- throw new edge.HavenSigningError(
697
- `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.`
698
- );
806
+ try {
807
+ const ctx = await fetchX402SignContext(identity, args.payment_id, options.signContext?.fetchImpl);
808
+ checkPayloadHashMatch(args.payload_hash, ctx.payloadHash, args.payment_id);
809
+ return { kind: "x402", ctx };
810
+ } catch (err) {
811
+ if (opts.allowDirectFallback && err instanceof HavenSignContextError && err.http_status === 409 && err.backend_error_code === "sign_context_unavailable") {
812
+ let ctx;
813
+ try {
814
+ ctx = await fetchDirectSignContext(identity, args.payment_id, options.signContext?.fetchImpl);
815
+ } catch (directErr) {
816
+ if (directErr instanceof HavenSignContextError && directErr.http_status === 409 && directErr.backend_error_code === "sign_context_unavailable") {
817
+ throw err;
818
+ }
819
+ throw directErr;
820
+ }
821
+ checkPayloadHashMatch(args.payload_hash, ctx.payloadHash, args.payment_id);
822
+ return { kind: "direct", ctx };
823
+ }
824
+ throw err;
699
825
  }
700
- return ctx;
701
826
  }
702
827
  return {
703
828
  haven_sign: async (input) => runTool(async () => {
704
829
  const args = parse("haven_sign", coerceX402Expected(input));
705
- const fetched = await resolveSignContext(args);
706
- const typedData = fetched?.typedData ?? resolveTypedData(args);
707
- const payloadHash = fetched?.payloadHash ?? args.payload_hash;
830
+ const resolved = await resolveSignContext(args, { allowDirectFallback: true });
831
+ const typedData = resolved?.ctx.typedData ?? resolveTypedData(args);
832
+ const payloadHash = resolved?.ctx.payloadHash ?? args.payload_hash;
708
833
  if (!payloadHash) {
709
834
  throw new edge.HavenSigningError(
710
- "Pass payment_id (preferred for delegation-rail x402) or payload_hash."
835
+ "Pass payment_id (preferred for delegation-rail x402 or direct payments) or payload_hash."
711
836
  );
712
837
  }
713
- const expectedRaw = fetched ? parseFetchedExpected(fetched) : args.x402_expected;
838
+ const expectedRaw = resolved?.kind === "x402" ? parseFetchedExpected(resolved.ctx) : resolved?.kind === "direct" ? null : args.x402_expected;
714
839
  const x402Expected = expectedRaw ? toExpectedX402(expectedRaw) : null;
715
840
  const result = x402Expected ? await signFundingLeg(signer, x402Expected, payloadHash, typedData) : null;
716
841
  if (!result) {
@@ -720,6 +845,16 @@ function createToolHandlers(signer, options = {}) {
720
845
  "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."
721
846
  );
722
847
  }
848
+ if (resolved?.kind === "direct" || edge.isPackedUserOperationTypedData(typedData)) {
849
+ try {
850
+ edge.assertUserOpTypedDataBinding(typedData, payloadHash);
851
+ } catch (err) {
852
+ if (err instanceof edge.HavenUserOpBindingError) {
853
+ throw new HavenUserOpBindingRefusedError(err.message);
854
+ }
855
+ throw err;
856
+ }
857
+ }
723
858
  const signature = await signer.signDelegationTypedData(typedData);
724
859
  await auditSigning("haven_sign", payloadHash);
725
860
  return { signature };
@@ -743,7 +878,8 @@ function createToolHandlers(signer, options = {}) {
743
878
  }),
744
879
  haven_sign_x402: async (input) => runTool(async () => {
745
880
  const args = parse("haven_sign_x402", coerceX402Expected(coercePaymentRequired(input)));
746
- const fetched = await resolveSignContext(args);
881
+ const resolved = await resolveSignContext(args, { allowDirectFallback: false });
882
+ const fetched = resolved?.kind === "x402" ? resolved.ctx : void 0;
747
883
  const payloadHash = fetched?.payloadHash ?? args.payload_hash;
748
884
  const expectedRaw = fetched ? parseFetchedExpected(fetched) : args.x402_expected;
749
885
  if (!payloadHash || !expectedRaw) {
@@ -883,6 +1019,15 @@ function normalizeError(err) {
883
1019
  ...nextStepWireFields(err.step)
884
1020
  };
885
1021
  }
1022
+ if (err instanceof HavenUserOpBindingRefusedError) {
1023
+ return {
1024
+ success: false,
1025
+ code: err.code,
1026
+ message: err.message,
1027
+ next_action: err.next_action,
1028
+ ...nextStepWireFields(err.step)
1029
+ };
1030
+ }
886
1031
  if (err instanceof HavenSignContextError) {
887
1032
  return {
888
1033
  success: false,
@@ -1467,7 +1612,8 @@ var SIGNER_CAPABILITY_KEY = "haven/signer-compatibility";
1467
1612
  function signerCompatibility() {
1468
1613
  return {
1469
1614
  x402_expected_context_versions: [...SUPPORTED_X402_EXPECTED_VERSIONS],
1470
- sweep_binding_versions: [...SUPPORTED_SWEEP_BINDING_VERSIONS]
1615
+ sweep_binding_versions: [...SUPPORTED_SWEEP_BINDING_VERSIONS],
1616
+ direct_sign_context_versions: [...SUPPORTED_DIRECT_SIGN_CONTEXT_VERSIONS]
1471
1617
  };
1472
1618
  }
1473
1619
  function signerCapabilityAdvertisement() {
@@ -1478,12 +1624,14 @@ function signerInstructions() {
1478
1624
  return [
1479
1625
  "Haven edge signer: sign-only tools bound to the local delegate key. It never emits the",
1480
1626
  "key. Its one network capability is an authenticated READ of a signing context from",
1481
- "Haven by payment_id \u2014 pass payment_id to haven_sign / haven_sign_x402 (preferred for",
1482
- "delegation-rail x402) instead of relaying bulky typed-data payloads yourself.",
1627
+ "Haven by payment_id \u2014 pass payment_id to haven_sign (preferred for both a direct payment,",
1628
+ "#3271, and delegation-rail x402) or haven_sign_x402 (x402 only) instead of relaying bulky",
1629
+ "typed-data payloads yourself.",
1483
1630
  "",
1484
1631
  "Version compatibility (check this BEFORE signing, not after):",
1485
1632
  `- x402 expected-context versions supported: ${compatibility.x402_expected_context_versions.join(", ")}`,
1486
1633
  `- sweep authorization binding versions supported: ${compatibility.sweep_binding_versions.join(", ")}`,
1634
+ `- 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`,
1487
1635
  "",
1488
1636
  "Haven quote and prepare results report the expected-context version they will emit",
1489
1637
  "(signer_compatibility.x402_expected_context_version). If that version is not in the list",
@@ -1601,7 +1749,7 @@ async function warnIfCredentialFilePermissive(path, log = (message) => process.s
1601
1749
 
1602
1750
  // src/server.ts
1603
1751
  var SIGNER_NAME = "@haven_ai/signer";
1604
- var SIGNER_VERSION = "0.0.0-dev.202609232125.72f3d20";
1752
+ var SIGNER_VERSION = "0.0.0-dev.202609241802.ea320de";
1605
1753
  async function resolveSignerRuntime(options = {}) {
1606
1754
  assertSupportedNodeVersion(options.nodeVersion);
1607
1755
  if (options.delegateKey) {
@@ -1639,8 +1787,9 @@ function buildSignerMcpServer(signer, options = {}) {
1639
1787
  accountAddress: options.credentials?.accountAddress,
1640
1788
  chainId: options.credentials?.chainId
1641
1789
  },
1642
- // #1263: the payment_id signing path — the ONLY network call this server
1643
- // can make, an authenticated read of a signing context from Haven, using
1790
+ // #1263: the payment_id signing path — the ONLY network path this server
1791
+ // has: up to two authenticated reads of a signing context from Haven (x402,
1792
+ // then direct for haven_sign, #3271), using
1644
1793
  // the agent identity the connector stores next to the signer credential.
1645
1794
  // The signer CORE stays network-free; fetched bytes still pass the same
1646
1795
  // binding verification + digest re-derivation as tool-argument bytes.