@haven_ai/signer 0.8.1-alpha.0 → 0.9.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/README.md CHANGED
@@ -61,7 +61,7 @@ metadata found in the credential file, and the sign-only tool list. It refuses
61
61
  to start until acknowledged with either `HAVEN_SIGNER_ACK=<hash>` or
62
62
  `npx @haven_ai/signer@alpha --credentials /path/to/haven-agent.json --ack`.
63
63
 
64
- It exposes four stdio MCP tools, all sign-only:
64
+ It exposes five stdio MCP tools, all sign-only:
65
65
 
66
66
  | Tool | Does | Emits |
67
67
  |---|---|---|
@@ -69,6 +69,7 @@ It exposes four stdio MCP tools, all sign-only:
69
69
  | `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 }` |
70
70
  | `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 }` |
71
71
  | `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 }` |
72
+ | `haven_sign_siwx` | Answer a merchant x402 Sign-In-With-X (SIWX) challenge (#3728): the signer composes the EIP-4361 message ITSELF from the grammar-validated `sign-in-with-x` challenge and the delegate address read from the key — never a caller-supplied message — signs it EIP-191, and returns the finished header. Refuses (`SIWX_REFUSED`) unless the challenge domain matches the `url` host (the FINAL URL after redirects — never follow a redirect with the header or a resulting session token attached), the resource uri origin matches, the URL is https, the nonce is well-formed, the expiry window is ≤ 5 minutes (plus a small clock-skew tolerance), and `supportedChains` offers the credential chain with `eip191`. Moves no funds and grants no spend authority | `{ sign_in_with_x_header }` — retry the merchant with the `SIGN-IN-WITH-X` header set to that value |
72
73
 
73
74
  The `initialize` handshake advertises which binding versions this signer
74
75
  understands, under `capabilities.experimental['haven/signer-compatibility']`
@@ -99,8 +100,11 @@ const { paymentHeader } = await signer.buildX402PaymentHeader(
99
100
  )
100
101
  ```
101
102
 
102
- The signer also exposes `signSweepAuthorization(input)` for the gasless sweep.
103
- All four are methods on the object `createEdgeSigner` returns, not standalone
103
+ The signer also exposes `signSweepAuthorization(input)` for the gasless sweep and
104
+ `signSiwxMessage(message)` for #3728's composed EIP-4361 sign-in — the message
105
+ always reaches it composed in-package from grammar-validated challenge fields,
106
+ with the address taken from the key.
107
+ All five are methods on the object `createEdgeSigner` returns, not standalone
104
108
  exports. There is NO raw-hash primitive: `signPaymentHash(hash)` (raw ECDSA over
105
109
  the retired AllowanceModule rail's hash) was removed in #3169, and
106
110
  `signX402FundingHash` (the expected-context v1 bare-hash path) in #3272 — the
@@ -202,6 +206,26 @@ relays ("Connection closed" plus this process's exit message) names the same
202
206
  command. The consent hash covers identity, tool names and the surface version —
203
207
  not the block's prose — so neither change re-prompts an acknowledged install.
204
208
 
209
+ ## Several Haven pairs on one machine (#3738)
210
+
211
+ One harness can carry several Haven pairs — `haven-research` +
212
+ `haven-signer-research`, `haven-ops` + `haven-signer-ops`, alongside a bare
213
+ `haven` + `haven-signer` (Codex: `haven` + `haven_signer`). A model must act as
214
+ one agent per task and keep hosted and signer calls inside that pair. Server
215
+ names are easy to cross, so the signer's `initialize` instructions state what
216
+ it is bound to:
217
+
218
+ ```
219
+ This signer is bound to agent id <agent_id> and delegate address <0x…>.
220
+ ```
221
+
222
+ The agent id comes from the credential file's `agent_id` (or `HAVEN_AGENT_ID`
223
+ alongside `HAVEN_DELEGATE_KEY`); when neither is set the line says "no recorded
224
+ agent id" and still gives the delegate address, which is then compared alone.
225
+ The model compares it with `haven_get_agent`'s `id` and `delegateAddress` from
226
+ the hosted server it called, and signs nothing on a mismatch. Neither value is secret (the consent screen prints both), and
227
+ the line is advisory: what the signer refuses to sign is unchanged.
228
+
205
229
  ## Orchestration
206
230
 
207
231
  Direct payment:
@@ -289,6 +313,17 @@ what a payload means; they re-derive it.
289
313
  - **A binding version it does not understand.** The refusal is machine-readable
290
314
  — `code`, `supported_versions`, `received_version`, `fallback`, and (#3103)
291
315
  `next_tool_omitted_reason` — and names updating the signer as the fix.
316
+ - **A sign-in challenge that does not describe the origin it is sent to (#3728).**
317
+ `haven_sign_siwx` composes the EIP-4361 message itself from the challenge's
318
+ grammar-validated fields and the delegate address from the key; a challenge
319
+ whose domain is not the host of the `url` being called, whose resource uri
320
+ origin differs, that is not https, that carries a malformed or missing nonce,
321
+ a `version` other than `"1"`, an expiry window beyond the spec's 5-minute max
322
+ age (plus a small clock-skew tolerance), or that does not offer the credential
323
+ chain with an `eip191` signature type is refused with no signature and no
324
+ audit entry. A caller-supplied message, hash or byte string is never signed —
325
+ a SIWX signature therefore cannot double as a payment authorization, a
326
+ UserOp signature or any other Haven signature.
292
327
  - **A sweep that does not move funds out of this delegate's own key** — the
293
328
  `from` check is unconditional. The **destination** check is not, and this is
294
329
  the one asymmetry in this list: the signer compares the sweep's `to` against
@@ -318,7 +353,7 @@ refusal class — `fallback`, `retry_with_new_quote`, `http_status`,
318
353
  `SIGN_CONTEXT_REFUSED` (other) row names the hosted status read
319
354
  (`next_tool_server_role: hosted`, `next_tool_name: haven_get_payment_status`,
320
355
  `next_arguments: { payment_id }` — resolve the role against your own server
321
- names); every other row carries `next_tool_omitted_reason` with the exact
356
+ names, and with several Haven pairs against this signer's own pair); every other row carries `next_tool_omitted_reason` with the exact
322
357
  remedy. `message` is unchanged.
323
358
 
324
359
  **#3271: `haven_sign` (never `haven_sign_x402`) has one escape from this
package/dist/cli.cjs CHANGED
@@ -154,6 +154,8 @@ function createSigningAuditEntry(tool, payloadHash, context, now = /* @__PURE__
154
154
  };
155
155
  if (context.accountAddress) entry.safe_address = context.accountAddress;
156
156
  if (typeof context.chainId === "number") entry.chain_id = context.chainId;
157
+ if (context.domain !== void 0) entry.domain = context.domain;
158
+ if (context.nonce !== void 0) entry.nonce = context.nonce;
157
159
  return entry;
158
160
  }
159
161
  function hashPayloadForAudit(payload) {
@@ -677,10 +679,237 @@ async function fetchTaskBudgetSignContext(identity, taskBudgetId, fetchImpl = fe
677
679
  }
678
680
  return malformed(`unknown purpose '${String(purpose)}'`);
679
681
  }
682
+ var SIWX_REFUSED = "SIWX_REFUSED";
683
+ var HavenSiwxRefusedError = class extends edge.HavenSigningError {
684
+ next_action = edge.AgentPaymentNextAction.StopAndTellUser;
685
+ step;
686
+ constructor(message) {
687
+ super(message);
688
+ this.code = SIWX_REFUSED;
689
+ this.name = "HavenSiwxRefusedError";
690
+ this.step = signerRefusalStep({
691
+ nextAction: edge.AgentPaymentNextAction.StopAndTellUser,
692
+ nextTool: null,
693
+ nextToolOmittedReason: "check the 402 sign-in-with-x challenge and the URL against the refusal above, then call haven_sign_siwx again with corrected inputs \u2014 a challenge that fails validation is never signed"
694
+ });
695
+ }
696
+ };
697
+ var SIWX_MAX_AGE_SECONDS = 300;
698
+ var SIWX_CLOCK_SKEW_SECONDS = 30;
699
+ var SIWX_VERSION = "1";
700
+ function refuse(message) {
701
+ throw new HavenSiwxRefusedError(message);
702
+ }
703
+ function isPlainObject(value) {
704
+ return typeof value === "object" && value !== null && !Array.isArray(value);
705
+ }
706
+ function stringField(info, name) {
707
+ const value = info[name];
708
+ if (value === void 0) return void 0;
709
+ if (typeof value !== "string") {
710
+ refuse(`The sign-in-with-x challenge field "${name}" must be a string.`);
711
+ }
712
+ return value;
713
+ }
714
+ function assertSingleLine(value, name) {
715
+ if (/[\n\r]/.test(value)) {
716
+ refuse(`The sign-in-with-x challenge field "${name}" contains a line break; EIP-4361 fields are single-line. Refusing \u2014 a line break could smuggle an attacker-chosen line into the signed message.`);
717
+ }
718
+ }
719
+ function validateSiwxChallenge(input) {
720
+ const { url, challenge, chainId } = input;
721
+ const now = input.now ?? /* @__PURE__ */ new Date();
722
+ if (typeof url !== "string" || url.length === 0) {
723
+ refuse("url is required: the URL the agent is calling, so the signature is bound to that origin.");
724
+ }
725
+ let urlObj;
726
+ try {
727
+ urlObj = new URL(url);
728
+ } catch {
729
+ refuse("url is not a valid absolute URL.");
730
+ }
731
+ if (urlObj.protocol !== "https:") {
732
+ refuse(
733
+ `url must be https \u2014 this signer never signs a sign-in bound to a plain-HTTP origin (got '${urlObj.protocol}//').`
734
+ );
735
+ }
736
+ if (!isPlainObject(challenge)) {
737
+ refuse('challenge must be the sign-in-with-x object from the merchant 402 (extensions["sign-in-with-x"]).');
738
+ }
739
+ const hasWrapper = isPlainObject(challenge.info);
740
+ const info = hasWrapper ? challenge.info : challenge;
741
+ const supportedChains = hasWrapper ? challenge.supportedChains : challenge.supportedChains;
742
+ const domain = stringField(info, "domain");
743
+ if (domain === void 0) refuse("The sign-in-with-x challenge carries no domain.");
744
+ assertSingleLine(domain, "domain");
745
+ if (/\s/.test(domain)) {
746
+ refuse("The sign-in-with-x challenge domain is not a valid authority (contains whitespace).");
747
+ }
748
+ if (domain.toLowerCase() !== urlObj.host.toLowerCase()) {
749
+ refuse(
750
+ `The challenge domain '${domain}' does not match the host of the URL being called ('${urlObj.host}'). A sign-in is only valid for the origin that issued it.`
751
+ );
752
+ }
753
+ const uri = stringField(info, "uri");
754
+ if (uri === void 0) refuse("The sign-in-with-x challenge carries no uri.");
755
+ assertSingleLine(uri, "uri");
756
+ let uriObj;
757
+ try {
758
+ uriObj = new URL(uri);
759
+ } catch {
760
+ refuse("The sign-in-with-x challenge uri is not a valid absolute URI.");
761
+ }
762
+ if (uriObj.protocol !== urlObj.protocol || uriObj.host.toLowerCase() !== urlObj.host.toLowerCase()) {
763
+ refuse(
764
+ `The challenge resource uri origin ('${uriObj.protocol}//${uriObj.host}') does not match the origin of the URL being called ('${urlObj.protocol}//${urlObj.host}').`
765
+ );
766
+ }
767
+ const version = stringField(info, "version");
768
+ if (version === void 0) {
769
+ refuse("The sign-in-with-x challenge carries no version; this signer composes CAIP-122 version 1 only.");
770
+ }
771
+ if (version !== SIWX_VERSION) {
772
+ refuse(
773
+ `The sign-in-with-x challenge version is '${version}'; this signer composes version '${SIWX_VERSION}' only. Update @haven_ai/signer if the spec has moved on.`
774
+ );
775
+ }
776
+ const nonce = stringField(info, "nonce");
777
+ if (nonce === void 0) {
778
+ refuse("The sign-in-with-x challenge carries no nonce \u2014 refusing: the nonce is what makes the sign-in single-use.");
779
+ }
780
+ if (!/^[A-Za-z0-9]{8,}$/.test(nonce)) {
781
+ refuse("The sign-in-with-x challenge nonce fails the EIP-4361 grammar (alphanumeric, at least 8 characters).");
782
+ }
783
+ const issuedAt = stringField(info, "issuedAt");
784
+ if (issuedAt === void 0) refuse("The sign-in-with-x challenge carries no issuedAt timestamp.");
785
+ assertSingleLine(issuedAt, "issuedAt");
786
+ if (Number.isNaN(Date.parse(issuedAt))) {
787
+ refuse("The sign-in-with-x challenge issuedAt is not a parseable timestamp.");
788
+ }
789
+ const expirationTime = stringField(info, "expirationTime");
790
+ if (expirationTime === void 0) {
791
+ refuse(
792
+ "The sign-in-with-x challenge carries no absolute expirationTime. A relative expirationSeconds is not accepted by this signer \u2014 only a challenge with an absolute expiry window the signer can bound is signed."
793
+ );
794
+ }
795
+ assertSingleLine(expirationTime, "expirationTime");
796
+ const expiresAtMs = Date.parse(expirationTime);
797
+ if (Number.isNaN(expiresAtMs)) {
798
+ refuse("The sign-in-with-x challenge expirationTime is not a parseable timestamp.");
799
+ }
800
+ if (expiresAtMs < now.getTime() - SIWX_CLOCK_SKEW_SECONDS * 1e3) {
801
+ refuse("The sign-in-with-x challenge has already expired (expirationTime is in the past, beyond the clock-skew tolerance). Nothing was signed.");
802
+ }
803
+ if (expiresAtMs - now.getTime() > (SIWX_MAX_AGE_SECONDS + SIWX_CLOCK_SKEW_SECONDS) * 1e3) {
804
+ refuse(
805
+ `The sign-in-with-x challenge expiry window is longer than the spec's ${SIWX_MAX_AGE_SECONDS}-second max age (beyond the clock-skew tolerance). Refusing to sign a long-lived challenge.`
806
+ );
807
+ }
808
+ const statement = stringField(info, "statement");
809
+ if (statement !== void 0) assertSingleLine(statement, "statement");
810
+ const notBefore = stringField(info, "notBefore");
811
+ if (notBefore !== void 0) {
812
+ assertSingleLine(notBefore, "notBefore");
813
+ if (Number.isNaN(Date.parse(notBefore))) {
814
+ refuse("The sign-in-with-x challenge notBefore is not a parseable timestamp.");
815
+ }
816
+ }
817
+ const requestId = stringField(info, "requestId");
818
+ if (requestId !== void 0) assertSingleLine(requestId, "requestId");
819
+ let resources;
820
+ if (info.resources !== void 0) {
821
+ if (!Array.isArray(info.resources) || info.resources.some((r) => typeof r !== "string")) {
822
+ refuse("The sign-in-with-x challenge resources must be an array of URI strings.");
823
+ }
824
+ for (const resource of info.resources) {
825
+ assertSingleLine(resource, "resources[]");
826
+ try {
827
+ new URL(resource);
828
+ } catch {
829
+ refuse(`The sign-in-with-x challenge resources entry '${resource}' is not a valid absolute URI.`);
830
+ }
831
+ }
832
+ resources = info.resources;
833
+ }
834
+ if (typeof chainId !== "number" || !Number.isInteger(chainId) || chainId <= 0) {
835
+ refuse(
836
+ "This signer credential carries no chain id, so the signer cannot check the challenge against the chain its delegate key pays on. Re-run the connector to refresh the credential."
837
+ );
838
+ }
839
+ const chainIdCaip2 = `eip155:${chainId}`;
840
+ if (!Array.isArray(supportedChains) || supportedChains.length === 0) {
841
+ refuse("The sign-in-with-x challenge carries no supportedChains array.");
842
+ }
843
+ const matched = supportedChains.some(
844
+ (entry) => isPlainObject(entry) && typeof entry.chainId === "string" && entry.chainId.toLowerCase() === chainIdCaip2 && entry.type === "eip191"
845
+ );
846
+ if (!matched) {
847
+ refuse(
848
+ `The challenge's supportedChains does not offer the credential chain ${chainIdCaip2} with an eip191 signature type \u2014 the delegate EOA cannot answer this challenge.`
849
+ );
850
+ }
851
+ return {
852
+ domain,
853
+ uri,
854
+ version,
855
+ nonce,
856
+ issuedAt,
857
+ expirationTime,
858
+ statement,
859
+ notBefore,
860
+ requestId,
861
+ resources,
862
+ chainIdCaip2,
863
+ numericChainId: chainId
864
+ };
865
+ }
866
+ function composeSiwxMessage(fields, address) {
867
+ const suffixLines = [
868
+ `URI: ${fields.uri}`,
869
+ `Version: ${fields.version ?? SIWX_VERSION}`,
870
+ `Chain ID: ${fields.numericChainId}`,
871
+ `Nonce: ${fields.nonce}`,
872
+ `Issued At: ${fields.issuedAt}`
873
+ ];
874
+ if (fields.expirationTime !== void 0) suffixLines.push(`Expiration Time: ${fields.expirationTime}`);
875
+ if (fields.notBefore !== void 0) suffixLines.push(`Not Before: ${fields.notBefore}`);
876
+ if (fields.requestId !== void 0) suffixLines.push(`Request ID: ${fields.requestId}`);
877
+ if (fields.resources !== void 0) {
878
+ suffixLines.push(["Resources:", ...fields.resources.map((r) => `- ${r}`)].join("\n"));
879
+ }
880
+ let head = [`${fields.domain} wants you to sign in with your Ethereum account:`, address].join("\n");
881
+ if (fields.statement === void 0) {
882
+ head += "\n\n";
883
+ } else {
884
+ head = [head, fields.statement].join("\n\n") + "\n";
885
+ }
886
+ return [head, suffixLines.join("\n")].join("\n");
887
+ }
888
+ function buildSiwxPayload(fields, address, signature) {
889
+ return {
890
+ domain: fields.domain,
891
+ address,
892
+ statement: fields.statement,
893
+ uri: fields.uri,
894
+ version: fields.version ?? SIWX_VERSION,
895
+ chainId: fields.chainIdCaip2,
896
+ type: "eip191",
897
+ nonce: fields.nonce,
898
+ issuedAt: fields.issuedAt,
899
+ expirationTime: fields.expirationTime,
900
+ notBefore: fields.notBefore,
901
+ requestId: fields.requestId,
902
+ resources: fields.resources,
903
+ signature
904
+ };
905
+ }
906
+ function encodeSiwxHeader(payload) {
907
+ return Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
908
+ }
680
909
 
681
910
  // src/tools.ts
682
911
  var SIGNER_NAME = "@haven_ai/signer";
683
- var SIGNER_VERSION = "0.8.1-alpha.0";
912
+ var SIGNER_VERSION = "0.9.0-alpha.0";
684
913
  var UNSUPPORTED_ARGUMENT_MARKER = "HavenSignerUnsupportedArgument:";
685
914
  var USEROP_BINDING_MISMATCH = "USEROP_BINDING_MISMATCH";
686
915
  var HavenUserOpBindingRefusedError = class extends edge.HavenSigningError {
@@ -843,6 +1072,18 @@ var toolSchemas = {
843
1072
  typed_data: v3.z.record(v3.z.string(), v3.z.unknown()).optional(),
844
1073
  // #1255: see haven_sign.typed_data_b64 — the copy-through-safe form.
845
1074
  typed_data_b64: v3.z.string().min(1).max(262144).optional()
1075
+ },
1076
+ haven_sign_siwx: {
1077
+ // #3728: the https URL the agent is calling — the challenge's domain and
1078
+ // resource uri are pinned to THIS origin before anything is signed.
1079
+ url: v3.z.string().min(1),
1080
+ // The merchant's sign-in-with-x challenge. Accepted as EITHER the 402
1081
+ // extension object (payment_required.extensions['sign-in-with-x'] —
1082
+ // { info, supportedChains, schema? }) or the flattened info object the
1083
+ // x402 reference client builds ({ ...info, chainId, type }). Validated
1084
+ // field by field in siwx.ts; the signer composes the EIP-4361 message
1085
+ // itself and never signs a caller-supplied message.
1086
+ challenge: v3.z.record(v3.z.string(), v3.z.unknown())
846
1087
  }
847
1088
  // #3101: keys survive on the type (see the hosted server's contracts.ts).
848
1089
  };
@@ -881,8 +1122,10 @@ var X402_SIGN_HEADER_DESCRIPTION = [
881
1122
  "do not call this tool. The signer validates the merchant, amount, resource, asset, and",
882
1123
  "network against the recorded funding context before signing, checks expires_at when present,",
883
1124
  "and rejects mismatches or expired payment windows.",
884
- "Returns { payment_header, accepted }. On your retry set BOTH PAYMENT-SIGNATURE (x402 v2) and",
885
- "X-PAYMENT (v1) to <payment_header>; a strict v2 merchant reads only the first.",
1125
+ "Returns { payment_header, retry_headers, accepted }. On your retry set EVERY header named in",
1126
+ "retry_headers to <payment_header> \u2014 the names come from the same rule the wire uses (both",
1127
+ "PAYMENT-SIGNATURE (x402 v2) and X-PAYMENT (v1) on the EIP-3009 bridge; a strict v2 merchant reads",
1128
+ "only the first).",
886
1129
  "Only call after haven_submit has confirmed the funding step (nextAction=none or",
887
1130
  "the funding tx has a confirmed status). Next for paid MCP tools: call mcp__haven__haven_complete_mcp_tool."
888
1131
  ].join(" ");
@@ -908,8 +1151,8 @@ var SIGN_X402_DESCRIPTION = [
908
1151
  "haven_sign, not to this one. payment_header IS the header to use.",
909
1152
  "Next: for a paid MCP tool, call mcp__haven__haven_settle_mcp_tool. For a direct plain-HTTP x402",
910
1153
  "merchant (the haven_pay_x402_quote path), relay signature via mcp__haven__haven_submit and then",
911
- "retry the original merchant URL YOURSELF, setting BOTH PAYMENT-SIGNATURE (x402 v2) and",
912
- "X-PAYMENT (v1) to payment_header \u2014 Haven never contacts that merchant."
1154
+ "retry the original merchant URL YOURSELF, setting EVERY header the result names in",
1155
+ "retry_headers to payment_header \u2014 Haven never sends that merchant the paid request."
913
1156
  ].join(" ");
914
1157
  var SIGN_SWEEP_DELEGATE_DESCRIPTION = [
915
1158
  "Sign a Haven-prepared gasless USDC sweep that recovers stranded funds from the delegate",
@@ -921,8 +1164,28 @@ var SIGN_SWEEP_DELEGATE_DESCRIPTION = [
921
1164
  "when the credential carries one, and otherwise rests on Haven's binding signature. Then it",
922
1165
  "returns { signature } to hand back to mcp__haven__haven_sweep_delegate to complete recovery."
923
1166
  ].join(" ");
1167
+ var SIGN_SIWX_DESCRIPTION = [
1168
+ "Answer a merchant x402 Sign-In-With-X (SIWX) sign-in challenge with the local delegate key (#3728).",
1169
+ "Use it when a merchant 402 carries a sign-in-with-x extension, or when the merchant documents a",
1170
+ "sign-in route (CAIP-122): pass url \u2014 the FINAL URL the header will be sent to, i.e. after any",
1171
+ "redirects, not the originally requested one \u2014 and challenge, the sign-in-with-x object from the",
1172
+ "402 (extensions['sign-in-with-x'], or the flattened info object). NEVER follow a redirect with",
1173
+ "the SIGN-IN-WITH-X header (or a resulting session token) attached: re-sign for the final origin.",
1174
+ "This signer composes the EIP-4361 message ITSELF from the validated challenge fields and the",
1175
+ "delegate address read from the key \u2014 it never signs a caller-supplied message, and an address",
1176
+ "inside the challenge cannot change whose wallet signs in. It refuses (SIWX_REFUSED) unless the",
1177
+ "challenge domain matches the URL host, its resource uri origin matches the URL origin, the URL is",
1178
+ "https, the nonce is well-formed, the expiry window is at most 5 minutes (plus a small clock-skew",
1179
+ "tolerance), and supportedChains offers the credential chain with an eip191 signature type. This",
1180
+ "tool MOVES NO FUNDS and grants no spend authority: it only proves the delegate wallet is the one",
1181
+ "that paid the merchant. Returns { sign_in_with_x_header } \u2014 retry the merchant request with the",
1182
+ "SIGN-IN-WITH-X header set to that value. The merchant then recognises the delegate wallet as a",
1183
+ "paying customer (a session it mints typically outlives the 5-minute signature; what the session",
1184
+ "reaches is merchant-defined)."
1185
+ ].join(" ");
924
1186
  var toolDescriptions = {
925
1187
  haven_sign: SIGN_DESCRIPTION,
1188
+ haven_sign_siwx: SIGN_SIWX_DESCRIPTION,
926
1189
  haven_x402_sign_header: X402_SIGN_HEADER_DESCRIPTION,
927
1190
  haven_sign_x402: SIGN_X402_DESCRIPTION,
928
1191
  haven_sign_sweep_delegate: SIGN_SWEEP_DELEGATE_DESCRIPTION
@@ -1250,7 +1513,13 @@ function createToolHandlers(signer, options = {}) {
1250
1513
  "haven_x402_sign_header",
1251
1514
  hashPayloadForAudit(args.payment_required)
1252
1515
  );
1253
- return { payment_header: result.paymentHeader, accepted: result.accepted };
1516
+ return {
1517
+ payment_header: result.paymentHeader,
1518
+ accepted: result.accepted,
1519
+ // #3727: the ready-made retry headers beside the prose — the names
1520
+ // come from the SDK's live rule, never re-derived here.
1521
+ retry_headers: edge.x402RetryHeadersFor(result.paymentHeader)
1522
+ };
1254
1523
  }),
1255
1524
  haven_sign_x402: async (input) => runTool(async () => {
1256
1525
  const args = parseStrictFor("haven_sign_x402", coerceX402Expected(coercePaymentRequired(input)));
@@ -1285,6 +1554,9 @@ function createToolHandlers(signer, options = {}) {
1285
1554
  x402_binding: funding.x402Binding,
1286
1555
  payment_header: header.paymentHeader,
1287
1556
  accepted: header.accepted,
1557
+ // #3727: the ready-made retry headers beside the prose — the names
1558
+ // come from the SDK's live rule, never re-derived here.
1559
+ retry_headers: edge.x402RetryHeadersFor(header.paymentHeader),
1288
1560
  ...clientUpdateField(resolved)
1289
1561
  };
1290
1562
  }),
@@ -1302,15 +1574,35 @@ function createToolHandlers(signer, options = {}) {
1302
1574
  hashPayloadForAudit(args.authorization)
1303
1575
  );
1304
1576
  return { signature: result.signature };
1577
+ }),
1578
+ haven_sign_siwx: async (input) => runTool(async () => {
1579
+ const args = parseStrictFor("haven_sign_siwx", input);
1580
+ const chainId = options.audit?.chainId;
1581
+ const fields = validateSiwxChallenge({
1582
+ url: args.url,
1583
+ challenge: args.challenge,
1584
+ chainId
1585
+ });
1586
+ const message = composeSiwxMessage(fields, signer.delegateAddress);
1587
+ const signature = await signer.signSiwxMessage(message);
1588
+ const header = encodeSiwxHeader(
1589
+ buildSiwxPayload(fields, signer.delegateAddress, signature)
1590
+ );
1591
+ await auditSigning("haven_sign_siwx", hashPayloadForAudit(message), {
1592
+ domain: fields.domain,
1593
+ nonce: fields.nonce
1594
+ });
1595
+ return { sign_in_with_x_header: header };
1305
1596
  })
1306
1597
  };
1307
- async function auditSigning(tool, payloadHash) {
1598
+ async function auditSigning(tool, payloadHash, extra = {}) {
1308
1599
  if (!options.audit) return;
1309
1600
  const { auditPath, ...context } = options.audit;
1310
1601
  try {
1311
1602
  await appendSigningAuditEntry(
1312
1603
  createSigningAuditEntry(tool, payloadHash, {
1313
1604
  ...context,
1605
+ ...extra,
1314
1606
  delegateAddress: signer.delegateAddress
1315
1607
  }),
1316
1608
  auditPath
@@ -1465,6 +1757,15 @@ function normalizeError(err) {
1465
1757
  ...err.next_tool_omitted_reason ? { next_tool_omitted_reason: err.next_tool_omitted_reason } : {}
1466
1758
  };
1467
1759
  }
1760
+ if (err instanceof HavenSiwxRefusedError) {
1761
+ return {
1762
+ success: false,
1763
+ code: err.code,
1764
+ message: err.message,
1765
+ next_action: err.next_action,
1766
+ ...nextStepWireFields(err.step)
1767
+ };
1768
+ }
1468
1769
  if (err instanceof edge.HavenSigningError) {
1469
1770
  return { success: false, code: err.code, message: err.message };
1470
1771
  }
@@ -1501,6 +1802,7 @@ function normalizeError(err) {
1501
1802
  // src/consent.ts
1502
1803
  var toolSummaries = {
1503
1804
  haven_sign: "signs a Haven-prepared payment, task-budget or sub-budget open or close (a UserOp from your own account redeeming a delegation made to it, directly or via a task-budget child; a sub-budget child narrowing your budget for another agent of yours; or an x402 funding intent against a Haven-signed context)",
1805
+ haven_sign_siwx: "signs a merchant x402 Sign-In-With-X sign-in challenge as the delegate wallet \u2014 proves the wallet paid that merchant, moves no funds and grants no spend authority",
1504
1806
  haven_x402_sign_header: "builds and signs the EIP-3009 merchant payment header for an x402 payment",
1505
1807
  haven_sign_x402: "the two above in one call: funding payload + merchant header",
1506
1808
  haven_sign_sweep_delegate: "signs a Haven-prepared gasless sweep of stranded funds back to your own account"
@@ -1540,13 +1842,16 @@ function renderSignerConsentBlock(input, hash) {
1540
1842
  if (input.network) lines.push(`Network: ${input.network}`);
1541
1843
  lines.push("");
1542
1844
  lines.push("This local signer holds the delegate key on this machine and signs");
1543
- lines.push("payment payloads or x402 merchant headers for the delegate address above.");
1845
+ lines.push("payment payloads, x402 merchant headers, or x402 Sign-In-With-X sign-in");
1846
+ lines.push("challenges for the delegate address above. A sign-in challenge proves to a");
1847
+ lines.push("merchant that this wallet paid it before; it moves no funds and grants no");
1848
+ lines.push("spend authority.");
1544
1849
  lines.push("Its one network use is a read-only fetch of a pending payment's signing");
1545
1850
  lines.push("payload from Haven, authenticated with the agent credential stored next to");
1546
1851
  lines.push("this signer's key file - it never sends the key, a signature, or anything");
1547
1852
  lines.push("else outbound, and it cannot show a live allowance summary.");
1548
1853
  lines.push("The agent's signed budget delegation is the real spend gate, and the wallet");
1549
- lines.push("owner can pause or revoke agent authority outside this signer.");
1854
+ lines.push("owner can stop the agent's budget or remove the agent outside this signer.");
1550
1855
  lines.push("");
1551
1856
  lines.push("Tools this signer will expose to your agent runtime (one line each; the");
1552
1857
  lines.push("full agent-facing descriptions are what the runtime sees):");
@@ -1556,7 +1861,7 @@ function renderSignerConsentBlock(input, hash) {
1556
1861
  lines.push("");
1557
1862
  lines.push("A local audit entry is appended for every signing operation. Audit entries");
1558
1863
  lines.push("record timestamp, tool, payload hash, and delegate address; never the key,");
1559
- lines.push("signature, or x402 payment header.");
1864
+ lines.push("signature, x402 payment header, or sign-in message.");
1560
1865
  lines.push("");
1561
1866
  lines.push(`Consent hash: ${hash}`);
1562
1867
  lines.push("");
@@ -1872,6 +2177,10 @@ function createEdgeSigner(delegateKey, options = {}) {
1872
2177
  );
1873
2178
  }
1874
2179
  return { signature };
2180
+ },
2181
+ async signSiwxMessage(message) {
2182
+ const account = accounts.privateKeyToAccount(delegateKey);
2183
+ return account.signMessage({ message });
1875
2184
  }
1876
2185
  };
1877
2186
  }
@@ -2040,7 +2349,13 @@ function signerCompatibility() {
2040
2349
  function signerCapabilityAdvertisement() {
2041
2350
  return { experimental: { [SIGNER_CAPABILITY_KEY]: signerCompatibility() } };
2042
2351
  }
2043
- function signerInstructions() {
2352
+ function signerIdentityLines(identity) {
2353
+ if (!identity?.agentId && !identity?.delegateAddress) return [];
2354
+ const agent = identity.agentId ? `agent id ${identity.agentId}` : "no recorded agent id";
2355
+ const delegate = identity.delegateAddress ? ` and delegate address ${identity.delegateAddress}` : "";
2356
+ return [`This signer is bound to ${agent}${delegate}.`];
2357
+ }
2358
+ function signerInstructions(identity) {
2044
2359
  const compatibility = signerCompatibility();
2045
2360
  return [
2046
2361
  "Haven edge signer: sign-only tools bound to the local delegate key. It never emits the",
@@ -2049,6 +2364,16 @@ function signerInstructions() {
2049
2364
  "#3271, and delegation-rail x402) or haven_sign_x402 (x402 only) instead of relaying bulky",
2050
2365
  "typed-data payloads yourself.",
2051
2366
  "",
2367
+ ...signerIdentityLines(identity),
2368
+ "When more than one Haven pair is configured, you act as ONE agent per task: if the",
2369
+ "user has not said which (in the request, or a project-level choice they stated), ask",
2370
+ "before any payment tool. Sign only through the signer of the hosted server you called:",
2371
+ "haven-<slug> with haven-signer-<slug>, bare haven with haven-signer, Codex haven with",
2372
+ "haven_signer. Before signing, compare the identity this signer states with",
2373
+ "haven_get_agent (its id and delegateAddress) from that hosted server \u2014 the delegate",
2374
+ "address alone when this signer has no recorded agent id. If they differ, stop and sign",
2375
+ "nothing \u2014 switch to the signer whose identity matches.",
2376
+ "",
2052
2377
  "Version compatibility (check this BEFORE signing, not after):",
2053
2378
  `- x402 expected-context versions supported: ${compatibility.x402_expected_context_versions.join(", ")}`,
2054
2379
  `- sweep authorization binding versions supported: ${compatibility.sweep_binding_versions.join(", ")}`,
@@ -2067,28 +2392,36 @@ function signerInstructions() {
2067
2392
  "machine-readable, not just prose: it carries code, supported_versions, received_version, and",
2068
2393
  "fallback fields alongside the message, so you can branch on it directly. Every signer",
2069
2394
  "refusal also carries the next-step family: next_tool_name + next_tool_server_role when a",
2070
- "hosted tool follows (resolve the role against your own server names), else",
2395
+ "hosted tool follows (resolve the role against your own server names \u2014 with several",
2396
+ "Haven pairs, the hosted server of THIS signer's pair), else",
2071
2397
  "next_tool_omitted_reason saying why not.",
2072
2398
  "",
2073
2399
  "An undeclared top-level argument is refused, not stripped: haven_sign answers",
2074
2400
  "UNSUPPORTED_ARGUMENT with unknown_arguments, signer_version and fallback (the update",
2075
2401
  "command) instead of dropping the key and answering the generic signing error \u2014 if you see",
2076
2402
  "that refusal, this signer predates the argument form you sent. Update the signer; nothing",
2077
- "was signed, fetched or audited."
2403
+ "was signed, fetched or audited.",
2404
+ "",
2405
+ "Sign-In-With-X (#3728): when a merchant 402 carries a sign-in-with-x extension (or the",
2406
+ "merchant documents a CAIP-122 sign-in route), call haven_sign_siwx with { url, challenge }.",
2407
+ "The signer composes the EIP-4361 message itself from the validated challenge and signs it with",
2408
+ "the delegate key \u2014 it returns { sign_in_with_x_header }; retry the merchant with the",
2409
+ "SIGN-IN-WITH-X header set to that value. This proves the delegate wallet paid the merchant; it",
2410
+ "moves no funds and cannot approve any payment."
2078
2411
  ].join("\n");
2079
2412
  }
2080
2413
  async function loadSignerCredentials(path = process.env.HAVEN_CREDENTIALS) {
2081
2414
  if (path) return loadFromFile(path);
2082
- const envKey = stringField(process.env.HAVEN_DELEGATE_KEY);
2415
+ const envKey = stringField2(process.env.HAVEN_DELEGATE_KEY);
2083
2416
  if (envKey) {
2084
2417
  const accountAddress = readAccountAddressEnv(process.env);
2085
2418
  return {
2086
2419
  delegateKey: envKey,
2087
- agentId: stringField(process.env.HAVEN_AGENT_ID),
2420
+ agentId: stringField2(process.env.HAVEN_AGENT_ID),
2088
2421
  accountAddress,
2089
2422
  chainId: chainIdField(process.env.HAVEN_CHAIN_ID, "HAVEN_CHAIN_ID"),
2090
- network: stringField(process.env.HAVEN_NETWORK),
2091
- x402BindingSigner: stringField(process.env.HAVEN_X402_BINDING_SIGNER)
2423
+ network: stringField2(process.env.HAVEN_NETWORK),
2424
+ x402BindingSigner: stringField2(process.env.HAVEN_X402_BINDING_SIGNER)
2092
2425
  };
2093
2426
  }
2094
2427
  throw new Error(
@@ -2111,30 +2444,30 @@ async function loadFromFile(path) {
2111
2444
  } catch {
2112
2445
  throw new Error("Haven credentials must be JSON with a delegate_key field.");
2113
2446
  }
2114
- const delegateKey = stringField(raw.delegate_key ?? raw.delegateKey);
2447
+ const delegateKey = stringField2(raw.delegate_key ?? raw.delegateKey);
2115
2448
  if (!delegateKey) {
2116
2449
  throw new Error("Haven credentials are missing delegate_key \u2014 the edge signer needs it to sign.");
2117
2450
  }
2118
2451
  const accountAddress = readAccountAddressField(raw);
2119
2452
  return {
2120
2453
  delegateKey,
2121
- agentId: stringField(raw.agent_id ?? raw.agentId),
2454
+ agentId: stringField2(raw.agent_id ?? raw.agentId),
2122
2455
  accountAddress,
2123
2456
  chainId: chainIdField(raw.chain_id ?? raw.chainId, "chain_id"),
2124
- network: stringField(raw.network),
2125
- x402BindingSigner: stringField(
2457
+ network: stringField2(raw.network),
2458
+ x402BindingSigner: stringField2(
2126
2459
  raw.x402_binding_signer ?? raw.x402BindingSigner ?? process.env.HAVEN_X402_BINDING_SIGNER
2127
2460
  ),
2128
2461
  sourcePath: path
2129
2462
  };
2130
2463
  }
2131
2464
  function readAccountAddressField(raw) {
2132
- return stringField(raw.account_address ?? raw.safe_address ?? raw.safeAddress);
2465
+ return stringField2(raw.account_address ?? raw.safe_address ?? raw.safeAddress);
2133
2466
  }
2134
2467
  function readAccountAddressEnv(env) {
2135
- const current = stringField(env.HAVEN_ACCOUNT_ADDRESS);
2468
+ const current = stringField2(env.HAVEN_ACCOUNT_ADDRESS);
2136
2469
  for (const name of ["HAVEN_WALLET_ADDRESS", "HAVEN_SAFE_ADDRESS"]) {
2137
- const retired = stringField(env[name]);
2470
+ const retired = stringField2(env[name]);
2138
2471
  if (!retired) continue;
2139
2472
  if (!current) {
2140
2473
  throw new Error(
@@ -2149,7 +2482,7 @@ function readAccountAddressEnv(env) {
2149
2482
  }
2150
2483
  return current;
2151
2484
  }
2152
- function stringField(value) {
2485
+ function stringField2(value) {
2153
2486
  return typeof value === "string" && value.trim() ? value.trim() : void 0;
2154
2487
  }
2155
2488
  function chainIdField(value, label) {
@@ -2203,7 +2536,12 @@ function buildSignerMcpServer(signer, options = {}) {
2203
2536
  { name: SIGNER_NAME, version: SIGNER_VERSION },
2204
2537
  {
2205
2538
  capabilities: signerCapabilityAdvertisement(),
2206
- instructions: signerInstructions()
2539
+ // #3738: the identity line lets a model holding several Haven pairs
2540
+ // confirm this signer belongs to the hosted server it called.
2541
+ instructions: signerInstructions({
2542
+ agentId: options.credentials?.agentId,
2543
+ delegateAddress: signer.delegateAddress
2544
+ })
2207
2545
  }
2208
2546
  );
2209
2547
  const credentialsPath = options.credentials?.sourcePath;