@haven_ai/signer 0.1.31-alpha.0 → 0.1.33-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/README.md CHANGED
@@ -1,17 +1,31 @@
1
1
  # @haven_ai/signer
2
2
 
3
3
  The Haven **edge signer**. It holds the delegate key locally and signs — and
4
- that's all it does. It pairs with the hosted, keyless `@haven_ai/mcp-server`:
5
- the hosted server constructs and relays, this signs. The key never leaves this
6
- process, and only signatures (and the standard x402 header) ever come out.
4
+ that's very nearly all it does. It pairs with the hosted, keyless
5
+ `@haven_ai/mcp-server`: the hosted server identifies the agent, constructs
6
+ unsigned payloads and relays signatures; this one signs. **The delegate key
7
+ never leaves this process** — it is not part of any request or response, and
8
+ only signatures (and the standard x402 merchant payment header) ever come out.
7
9
 
8
10
  Design: [`docs/architecture/07-edge-signer.md`](../../docs/architecture/07-edge-signer.md).
9
11
  Contract: [`docs/architecture/06-hosted-mcp-connect-flow.md`](../../docs/architecture/06-hosted-mcp-connect-flow.md).
10
12
 
13
+ Requires **Node >= 22**; the signer refuses to start on anything older, before
14
+ it reads a key.
15
+
11
16
  ## Two ways to use it
12
17
 
13
18
  **As a local MCP signer** (for Claude Desktop / Code / Cursor) — run it
14
- alongside the hosted Haven connection:
19
+ alongside the hosted Haven connection. The supported install is the connector
20
+ the Haven dashboard hands out, which writes the MCP config and pins the
21
+ runtime:
22
+
23
+ ```sh
24
+ npx @haven_ai/connect@alpha
25
+ ```
26
+
27
+ Rerunning it is also the documented fix for a signer that has fallen behind the
28
+ backend's expected-context version. To run the signer directly:
15
29
 
16
30
  ```sh
17
31
  HAVEN_DELEGATE_KEY=0x... npx @haven_ai/signer
@@ -24,12 +38,22 @@ metadata found in the credential file, and the sign-only tool list. It refuses
24
38
  to start until acknowledged with either `HAVEN_SIGNER_ACK=<hash>` or
25
39
  `npx @haven_ai/signer --credentials /path/to/haven-agent.json --ack`.
26
40
 
27
- It exposes two stdio MCP tools:
41
+ It exposes four stdio MCP tools, all sign-only:
28
42
 
29
43
  | Tool | Does | Emits |
30
44
  |---|---|---|
31
- | `haven_sign` | Sign the `payload_hash` from `haven_pay` / `haven_x402_authorize`; for x402, record `x402.expected` and return a binding | `{ signature }` or `{ signature, x402_binding }` |
32
- | `haven_x402_sign_header` | Build + sign the EIP-3009 `X-PAYMENT` header only when `payment_required` matches the recorded `x402_binding` | `{ payment_header }` |
45
+ | `haven_sign` | Sign one payment. Preferred form is `{ payment_id }` alone — the signer fetches the exact payload itself. Signs an EIP-712 typed-data payload on the delegation rail (a redemption, or an erc7710 settlement child), or a bare `payload_hash` on a v1 context; for the EIP-3009 x402 bridge it also records the funding context and returns a binding | `{ signature }` or `{ signature, x402_binding }` |
46
+ | `haven_sign_x402` | One-shot x402: funding signature **and** the merchant header in a single local call (`haven_sign` + `haven_x402_sign_header`). `{ payment_id }` alone is the preferred call | `{ signature, x402_binding, payment_header, accepted }` |
47
+ | `haven_x402_sign_header` | Build + sign the EIP-3009 merchant payment header, only when the fresh merchant `payment_required` matches the recorded `x402_binding` | `{ payment_header, accepted }` |
48
+ | `haven_sign_sweep_delegate` | Sign a Haven-prepared gasless EIP-3009 sweep that recovers stranded funds from the delegate wallet back to your own account. Never broadcasts | `{ signature }` |
49
+
50
+ The `initialize` handshake advertises which binding versions this signer
51
+ understands, under `capabilities.experimental['haven/signer-compatibility']`
52
+ and in the MCP `instructions` string. Both are **derived** from
53
+ `SUPPORTED_X402_EXPECTED_VERSIONS` / `SUPPORTED_SWEEP_BINDING_VERSIONS` in
54
+ `src/core.ts` — the same constants the signing path enforces — so this README
55
+ deliberately does not restate the numbers. Read them from the handshake, or
56
+ from those constants.
33
57
 
34
58
  **As a library** (for SDK / autonomous agents):
35
59
 
@@ -37,54 +61,135 @@ It exposes two stdio MCP tools:
37
61
  import { createEdgeSigner } from '@haven_ai/signer'
38
62
 
39
63
  const signer = createEdgeSigner(process.env.HAVEN_DELEGATE_KEY!)
40
- const signature = signer.signPaymentHash(payloadHash)
41
- const funding = signer.signX402FundingHash(payloadHash, {
42
- resourceUrl,
43
- merchantTo,
44
- amount,
45
- asset,
46
- network,
47
- auth,
48
- })
49
- const { paymentHeader } = await signer.buildX402PaymentHeader(paymentRequired, funding.x402Binding)
64
+
65
+ // Delegation-rail direct payment: sign the EIP-712 typed data the account
66
+ // validates — not the bare ERC-4337 hash.
67
+ const signature = await signer.signDelegationTypedData(typedData)
68
+
69
+ // x402, EIP-3009 bridge: sign the funding leg, then the merchant header.
70
+ const funding = await signer.signX402FundingTypedData(fundingTypedData, expected)
71
+ const { paymentHeader } = await signer.buildX402PaymentHeader(
72
+ paymentRequired,
73
+ funding.x402Binding,
74
+ )
50
75
  ```
51
76
 
77
+ The signer also exposes `signPaymentHash(hash)` (raw ECDSA over a legacy
78
+ AllowanceModule funding/transfer hash) and `signX402FundingHash(hash, expected)`
79
+ for v1 contexts, and `signSweepAuthorization(input)` for the gasless sweep. All
80
+ five are methods on the object `createEdgeSigner` returns, not standalone
81
+ exports.
82
+
52
83
  ## Orchestration
53
84
 
85
+ Direct payment:
86
+
87
+ ```
88
+ hosted: haven_pay -> { payment_id, payload to sign }
89
+ local: haven_sign -> { signature }
90
+ hosted: haven_submit -> { status, tx_hash }
91
+ ```
92
+
93
+ On the delegation rail the payload is the EIP-712 typed data the account
94
+ validates, not the bare ERC-4337 hash. Note the trust-model asymmetry: this
95
+ direct leg has no Haven-signed expected context to verify against, so the
96
+ authority boundary is the account's on-chain caveat enforcers rather than a
97
+ client-side gate — unlike the x402 legs below.
98
+
99
+ x402 — **erc7710 direct settlement**, the preferred scheme when the account is
100
+ on the delegation rail and the merchant advertises
101
+ `extra.assetTransferMethod: "erc7710"`. There is **no funding leg**, so there is
102
+ no delegate hot balance and no `haven_x402_sign_header` step:
103
+
54
104
  ```
55
- hosted: haven_pay -> { payment_id, payload_hash }
56
- local: haven_sign -> { signature }
57
- hosted: haven_submit -> { status, tx_hash }
105
+ hosted: haven_pay_x402_quote -> settlement child + settlement_scheme: erc7710
106
+ local: haven_sign { payment_id } -> child signature (caveats verified locally)
107
+ hosted: haven_submit { settlement_scheme: "erc7710" } -> payment_header
108
+ agent: retry merchant, setting PAYMENT-SIGNATURE ONLY (never X-PAYMENT:
109
+ this header carries a delegation chain and duplicating it is
110
+ refused with HTTP 431)
58
111
  ```
59
112
 
60
- x402 (two delegate signatures, both local):
113
+ x402 **EIP-3009 bridge**, the fallback for merchants without facilitator-side
114
+ erc7710 support (still most of them). Two local delegate signatures, and a
115
+ bounded funding leg:
61
116
 
62
117
  ```
63
- hosted: haven_x402_authorize -> { payment_id, payload_hash, x402.expected }
64
- local: haven_sign + expected -> funding signature + x402_binding
65
- hosted: haven_submit -> funds Safe -> delegate EOA
66
- local: haven_x402_sign_header -> X-PAYMENT header only if binding matches
67
- agent: retry merchant with X-PAYMENT
118
+ hosted: haven_pay_x402_quote -> { payment_id, payload_hash, x402.expected }
119
+ local: haven_sign + expected -> funding signature + x402_binding
120
+ hosted: haven_submit -> funds account -> delegate EOA
121
+ local: haven_x402_sign_header -> payment header only if binding matches
122
+ agent: retry merchant, setting BOTH PAYMENT-SIGNATURE + X-PAYMENT
123
+ (both names are correct HERE — the bridged header is small)
68
124
  ```
69
125
 
70
- For x402, pass `x402.expected` from the hosted `haven_x402_authorize` response
71
- unchanged into the local `haven_sign` call. The signer records that context and
72
- returns a process-local `x402_binding`; pass that binding into
73
- `haven_x402_sign_header` after `haven_submit` confirms. The signer refuses to
74
- authorize the merchant header when the fresh merchant challenge has a different
75
- amount, merchant recipient, resource URL, token asset, or network than the
76
- recorded funding intent, and consumes the binding after one successful header.
77
- The expected context must also carry Haven's `auth` signature; configure
78
- `HAVEN_X402_BINDING_SIGNER` (or `x402_binding_signer` in the credential file) so
79
- the signer can reject locally invented or tampered x402 contexts before signing
80
- the funding hash.
126
+ On the bridge, pass `x402.expected` from the hosted quote unchanged into the
127
+ local `haven_sign` call, or just pass `{ payment_id }` and let the signer fetch
128
+ it. The signer records that context and returns a process-local `x402_binding`;
129
+ pass that binding into `haven_x402_sign_header` after `haven_submit` confirms.
130
+ The signer refuses to authorize the merchant header when the fresh merchant
131
+ challenge has a different amount, merchant recipient, resource URL, token asset
132
+ or network than the recorded funding intent, refuses an expired window, and
133
+ consumes the binding after one header.
134
+
135
+ The merchant payment header's validity window starts when it is signed, not when
136
+ funding confirms — so relay it promptly.
137
+
138
+ ## What the signer refuses to sign
139
+
140
+ These are local, independent checks. They do not trust Haven's assertion about
141
+ what a payload means; they re-derive it.
142
+
143
+ - **Unauthenticated context.** The expected context must carry Haven's `auth`
144
+ signature over it. Configure `HAVEN_X402_BINDING_SIGNER` (or
145
+ `x402_binding_signer` in the credential file) so the signer can reject
146
+ locally invented or tampered contexts before signing anything.
147
+ - **Wrong signing mode.** The *context* selects the path, never the caller's
148
+ arguments: a context that commits to a typed-data digest requires the typed
149
+ data, one that does not requires the bare hash. A mismatch is refused rather
150
+ than signed into an on-chain failure.
151
+ - **Another agent's quote.** A context naming a `payer_delegate` that is not
152
+ this signer's own delegate is refused.
153
+ - **An unbound delegation payload.** Typed data with `primaryType: "Delegation"`
154
+ is never raw-signed without a context binding it.
155
+ - **An erc7710 settlement child whose caveats do not match what Haven declared.**
156
+ The signer re-derives the child's meaning from its own pinned
157
+ `DelegationManager` and caveat-enforcer addresses (cross-checked against
158
+ `@metamask/smart-accounts-kit` by a test, never fetched from Haven, which
159
+ would make the check circular): the payee pin, the exact token and amount,
160
+ the chain, and a settlement window bounded at 600 seconds. Extra caveats are
161
+ allowed — top-level caveats are AND-ed during redemption, so an unrecognised
162
+ one can only add a constraint.
163
+ - **A binding version it does not understand.** The refusal is machine-readable
164
+ — `code`, `supported_versions`, `received_version`, `fallback` — and names
165
+ updating the signer as the fix.
166
+ - **A sweep that does not move funds out of this delegate's own key** — the
167
+ `from` check is unconditional. The **destination** check is not, and this is
168
+ the one asymmetry in this list: the signer compares the sweep's `to` against
169
+ the account address **only when the local credential records one**
170
+ (`safe_address`). Run with `HAVEN_DELEGATE_KEY` alone — or with a credential
171
+ whose `safe_address` is absent — and there is no local value to compare
172
+ against, so the destination is authenticated by Haven's binding signature and
173
+ the token/chain canonicality check, but not independently re-derived. Prefer
174
+ a credential file that carries the account address.
81
175
 
82
176
  ## Custody
83
177
 
84
178
  The delegate key is read from `HAVEN_DELEGATE_KEY` or a `--credentials` file's
85
- `delegate_key` (with a permissive-file warning). It stays in this process. The
86
- signer makes no network calls — it can't leak the key to Haven or anyone else.
87
- It needs no `api_key`: identity lives with the hosted connection, not here.
179
+ `delegate_key` (with a permissive-file warning). It stays in this process, and
180
+ is never transmitted.
181
+
182
+ **The signer makes exactly one kind of network call.** Since
183
+ [#1263](https://github.com/d-hinders/Haven-AI/issues/1263) it performs an
184
+ authenticated, read-only `GET /x402/:payment_id/sign-context` against Haven, so
185
+ that agents never have to relay multi-KB EIP-712 payloads through a model's
186
+ context window. It reads `api_url` and `api_key` from an `identity.json` sitting
187
+ next to the signer credential file — the signer's own credential still needs no
188
+ `api_key`. The signer **core** (`src/core.ts`) remains network-free, and fetched
189
+ bytes are treated as untrusted input exactly like a tool argument: the same
190
+ digest re-derivation and Haven-binding verification apply, because what makes
191
+ them safe is the verification, not where they came from. It never relays,
192
+ submits, or broadcasts.
88
193
 
89
194
  Connect Agent 2 may create the signer credential file locally during setup. In
90
195
  that flow Haven receives the public signing address, proof, API-key hash/prefix,
@@ -95,14 +200,17 @@ protected storage/runtime config.
95
200
 
96
201
  Every MCP signing operation appends a JSONL row locally. File-backed runs write
97
202
  next to the credential as `<credential>.signer-audit.jsonl`; env-only runs use
98
- `~/.haven/signer-audit.jsonl`. Rows include timestamp, tool, payload hash, and
99
- delegate address. They never include the delegate key, signature, or x402
203
+ `~/.haven/signer-audit.jsonl`. Rows include timestamp, tool, payload hash and
204
+ delegate address, plus the account address and chain id when the credential
205
+ carries them. They never include the delegate key, the signature, or the x402
100
206
  payment header.
101
207
 
102
208
  ## Hot-wallet minimization
103
209
 
104
- Standard x402 briefly funds the delegate EOA before the merchant settles the
105
- EIP-3009 authorization. Keep delegate balances transient: use small/reset-bound
106
- x402 allowances, retry the original merchant session only after funding
107
- confirms, and reconcile or sweep stranded delegate balances when the merchant
108
- retry fails or does not settle before authorization expiry.
210
+ This applies to the **EIP-3009 bridge only** the erc7710 path above has no
211
+ funding leg and no delegate balance to strand. On the bridge, the account
212
+ briefly funds the delegate EOA before the merchant settles the EIP-3009
213
+ authorization. Keep delegate balances transient: keep budgets small and
214
+ period-bound, retry the original merchant session only after funding confirms,
215
+ and sweep stranded delegate balances (`haven_sign_sweep_delegate`) when the
216
+ merchant retry fails or does not settle before authorization expiry.
package/dist/cli.cjs CHANGED
@@ -184,6 +184,15 @@ function createEdgeSigner(delegateKey, options = {}) {
184
184
  );
185
185
  }
186
186
  const x402Bindings = /* @__PURE__ */ new Map();
187
+ const retiredX402Bindings = /* @__PURE__ */ new Map();
188
+ const RETIRED_BINDING_MEMORY = 64;
189
+ function retireX402Binding(id, reason) {
190
+ retiredX402Bindings.set(id, reason);
191
+ if (retiredX402Bindings.size > RETIRED_BINDING_MEMORY) {
192
+ const oldest = retiredX402Bindings.keys().next();
193
+ if (!oldest.done) retiredX402Bindings.delete(oldest.value);
194
+ }
195
+ }
187
196
  function signAndVerify(hash) {
188
197
  const signature = sdk.signHash(delegateKey, hash);
189
198
  if (!sdk.verifySignature(hash, signature, delegateAddress)) {
@@ -249,14 +258,26 @@ function createEdgeSigner(delegateKey, options = {}) {
249
258
  async buildX402PaymentHeader(paymentRequired, x402Binding) {
250
259
  const expected = x402Bindings.get(x402Binding);
251
260
  if (!expected) {
261
+ const retired = retiredX402Bindings.get(x402Binding);
262
+ if (retired === "header_built") {
263
+ throw new sdk.HavenSigningError(
264
+ "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."
265
+ );
266
+ }
267
+ if (retired === "window_expired") {
268
+ throw new sdk.HavenSigningError(
269
+ "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."
270
+ );
271
+ }
252
272
  throw new sdk.HavenSigningError(
253
- "x402 funding binding is required before signing a merchant header. Sign the hosted funding hash with x402_expected first."
273
+ "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."
254
274
  );
255
275
  }
256
276
  try {
257
277
  assertX402PaymentWindowOpen(expected);
258
278
  } catch (err) {
259
279
  x402Bindings.delete(x402Binding);
280
+ retireX402Binding(x402Binding, "window_expired");
260
281
  throw err;
261
282
  }
262
283
  const option = sdk.selectStandardPaymentOption(paymentRequired.accepts);
@@ -276,6 +297,7 @@ function createEdgeSigner(delegateKey, options = {}) {
276
297
  );
277
298
  if (paymentRequired.x402Version < 2) {
278
299
  x402Bindings.delete(x402Binding);
300
+ retireX402Binding(x402Binding, "header_built");
279
301
  return { paymentHeader: header, accepted: option };
280
302
  }
281
303
  try {
@@ -288,6 +310,7 @@ function createEdgeSigner(delegateKey, options = {}) {
288
310
  return { paymentHeader: wrapped, accepted: option };
289
311
  } finally {
290
312
  x402Bindings.delete(x402Binding);
313
+ retireX402Binding(x402Binding, "header_built");
291
314
  }
292
315
  },
293
316
  async signSweepAuthorization({
@@ -689,19 +712,23 @@ var SIGN_DESCRIPTION = [
689
712
  "relay the returned signature via mcp__haven__haven_submit."
690
713
  ].join(" ");
691
714
  var X402_SIGN_HEADER_DESCRIPTION = [
692
- "Build and sign the EIP-3009 X-PAYMENT header for the merchant leg of an x402 payment.",
715
+ "Build and sign the EIP-3009 merchant payment header for the merchant leg of an x402 payment.",
693
716
  "The delegate key stays local \u2014 only the signed header crosses any boundary.",
694
717
  "Pass the payment_required from the original merchant 402 response and the x402_binding",
695
- "returned by haven_sign. The signer validates the merchant, amount, resource, asset, and",
718
+ "returned by haven_sign. It must be haven_sign \u2014 NOT haven_sign_x402, which is a",
719
+ "one-shot that builds the header itself and spends its own binding doing so. If you called",
720
+ "haven_sign_x402, its result already carries payment_header; retry the merchant with that and",
721
+ "do not call this tool. The signer validates the merchant, amount, resource, asset, and",
696
722
  "network against the recorded funding context before signing, checks expires_at when present,",
697
723
  "and rejects mismatches or expired payment windows.",
698
- "Returns { payment_header, accepted }. Set X-PAYMENT: <payment_header> on your retry to the",
699
- "merchant. Only call after haven_submit has confirmed the funding step (nextAction=none or",
724
+ "Returns { payment_header, accepted }. On your retry set BOTH PAYMENT-SIGNATURE (x402 v2) and",
725
+ "X-PAYMENT (v1) to <payment_header>; a strict v2 merchant reads only the first.",
726
+ "Only call after haven_submit has confirmed the funding step (nextAction=none or",
700
727
  "the funding tx has a confirmed status). Next for paid MCP tools: call mcp__haven__haven_complete_mcp_tool."
701
728
  ].join(" ");
702
729
  var SIGN_X402_DESCRIPTION = [
703
730
  "One-shot x402 signing for the fast 3-call flow: sign the funding hash AND build the EIP-3009",
704
- "X-PAYMENT header in a single local call (equivalent to haven_sign followed by",
731
+ "merchant payment header in a single local call (equivalent to haven_sign followed by",
705
732
  "haven_x402_sign_header). The delegate key never leaves this process. From the haven_pay_mcp_tool",
706
733
  "result pass JUST payment_id \u2014 PREFERRED (#1263, #1355): this signer fetches the exact signing",
707
734
  "payload, expected context, and merchant payment_required from Haven itself, so nothing bulky",
@@ -716,7 +743,13 @@ var SIGN_X402_DESCRIPTION = [
716
743
  "mcp__haven__haven_settle_mcp_tool to fund and settle in one hosted call. The header is built now (before",
717
744
  "funding confirms), so its short validity window starts here \u2014 call mcp__haven__haven_settle_mcp_tool promptly,",
718
745
  "and re-run mcp__haven__haven_pay_mcp_tool with the same idempotency_key if a tool returns PAYMENT_WINDOW_EXPIRED.",
719
- "Next: call mcp__haven__haven_settle_mcp_tool."
746
+ "The returned x402_binding is ALREADY SPENT \u2014 this tool consumed it building payment_header \u2014",
747
+ "so never pass it to mcp__haven-signer__haven_x402_sign_header; that tool is the follow-up to",
748
+ "haven_sign, not to this one. payment_header IS the header to use.",
749
+ "Next: for a paid MCP tool, call mcp__haven__haven_settle_mcp_tool. For a direct plain-HTTP x402",
750
+ "merchant (the haven_pay_x402_quote path), relay signature via mcp__haven__haven_submit and then",
751
+ "retry the original merchant URL YOURSELF, setting BOTH PAYMENT-SIGNATURE (x402 v2) and",
752
+ "X-PAYMENT (v1) to payment_header \u2014 Haven never contacts that merchant."
720
753
  ].join(" ");
721
754
  var SIGN_SWEEP_DELEGATE_DESCRIPTION = [
722
755
  "Sign a Haven-prepared gasless USDC sweep that recovers stranded funds from the delegate",
@@ -1206,7 +1239,7 @@ async function warnIfCredentialFilePermissive(path, log = (message) => process.s
1206
1239
 
1207
1240
  // src/server.ts
1208
1241
  var SIGNER_NAME = "@haven_ai/signer";
1209
- var SIGNER_VERSION = "0.1.31-alpha.0";
1242
+ var SIGNER_VERSION = "0.1.33-alpha.0";
1210
1243
  async function resolveSignerRuntime(options = {}) {
1211
1244
  assertSupportedNodeVersion(options.nodeVersion);
1212
1245
  if (options.delegateKey) {