@haven_ai/signer 0.1.18-alpha.0 → 0.1.19-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.cjs +700 -616
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +700 -616
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +99 -12
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +93 -2
- package/dist/index.d.ts +93 -2
- package/dist/index.js +96 -14
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/cli.cjs
CHANGED
|
@@ -7,11 +7,11 @@ var crypto = require('crypto');
|
|
|
7
7
|
var promises = require('fs/promises');
|
|
8
8
|
var os = require('os');
|
|
9
9
|
var path = require('path');
|
|
10
|
-
var sdk = require('@haven_ai/sdk');
|
|
11
|
-
var v3 = require('zod/v3');
|
|
12
10
|
var viem = require('viem');
|
|
13
11
|
var accounts = require('viem/accounts');
|
|
14
12
|
var schemes = require('x402/schemes');
|
|
13
|
+
var sdk = require('@haven_ai/sdk');
|
|
14
|
+
var v3 = require('zod/v3');
|
|
15
15
|
|
|
16
16
|
function defaultSigningAuditPath(credentialsPath) {
|
|
17
17
|
if (credentialsPath) return path.resolve(`${credentialsPath}.signer-audit.jsonl`);
|
|
@@ -46,684 +46,753 @@ function stableStringify(value) {
|
|
|
46
46
|
const object = value;
|
|
47
47
|
return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(object[key])}`).join(",")}}`;
|
|
48
48
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
validBefore: v3.z.string().regex(/^[0-9]+$/, "validBefore must be a decimal unix time"),
|
|
55
|
-
nonce: v3.z.string().regex(/^0x[0-9a-fA-F]{64}$/, "nonce must be a 0x-prefixed 32-byte hex string"),
|
|
56
|
-
token: v3.z.string().regex(/^0x[0-9a-fA-F]{40}$/, "token must be a 0x address"),
|
|
57
|
-
chainId: v3.z.number().int().positive()
|
|
58
|
-
});
|
|
59
|
-
var bindingVersionSchema = v3.z.number().int().positive();
|
|
60
|
-
var sweepExpectedAuthSchema = v3.z.object({
|
|
61
|
-
version: bindingVersionSchema,
|
|
62
|
-
message: v3.z.string().min(1),
|
|
63
|
-
signature: v3.z.string().regex(/^0x[0-9a-fA-F]+$/, "signature must be a 0x-prefixed hex string"),
|
|
64
|
-
signer: v3.z.string().regex(/^0x[0-9a-fA-F]{40}$/, "signer must be a 0x address")
|
|
65
|
-
});
|
|
66
|
-
var x402ExpectedSchema = v3.z.object({
|
|
67
|
-
payment_id: v3.z.string().min(1),
|
|
68
|
-
payload_hash: v3.z.string().regex(/^0x[0-9a-fA-F]+$/, "payload_hash must be a 0x-prefixed hex string"),
|
|
69
|
-
resource_url: v3.z.string().url(),
|
|
70
|
-
merchant_to: v3.z.string().min(1),
|
|
71
|
-
amount: v3.z.string().min(1),
|
|
72
|
-
asset: v3.z.string().min(1),
|
|
73
|
-
network: v3.z.string().min(1),
|
|
74
|
-
// Required: expires_at is folded into the Haven-signed binding message, so the
|
|
75
|
-
// signer must receive the exact same value to reconstruct a matching message.
|
|
76
|
-
// Omitting it used to fail downstream with a cryptic "authentication message is
|
|
77
|
-
// invalid" — making it required surfaces a clear INVALID_INPUT at the boundary.
|
|
78
|
-
expires_at: v3.z.string().min(1),
|
|
79
|
-
// #1138: present on a delegation-rail (v2) context. It commits to the EIP-712
|
|
80
|
-
// typed data the account validates; the signer refuses to raw-sign the bare
|
|
81
|
-
// hash when it is present, and refuses to sign typed data when it is absent.
|
|
82
|
-
typed_data_hash: v3.z.string().regex(/^0x[0-9a-fA-F]+$/, "typed_data_hash must be a 0x-prefixed hex string").optional(),
|
|
83
|
-
auth: v3.z.object({
|
|
84
|
-
// Open at the boundary, enforced in the signer — see bindingVersionSchema.
|
|
85
|
-
version: bindingVersionSchema,
|
|
86
|
-
message: v3.z.string().min(1),
|
|
87
|
-
signature: v3.z.string().regex(/^0x[0-9a-fA-F]+$/, "signature must be a 0x-prefixed hex string"),
|
|
88
|
-
signer: v3.z.string().min(1)
|
|
89
|
-
})
|
|
90
|
-
});
|
|
91
|
-
var toolSchemas = {
|
|
92
|
-
haven_sign_sweep_delegate: {
|
|
93
|
-
// The authorization fields prepared by Haven's POST /sweep/prepare. Passed
|
|
94
|
-
// through verbatim from the hosted haven_sweep_delegate tool — the signer
|
|
95
|
-
// re-derives the binding message from these exact values.
|
|
96
|
-
authorization: sweepAuthorizationSchema,
|
|
97
|
-
// Haven's signature over the authorization context (the binding).
|
|
98
|
-
expected_auth: sweepExpectedAuthSchema
|
|
99
|
-
},
|
|
100
|
-
haven_sign: {
|
|
101
|
-
// The unsigned hash from haven_pay / haven_pay_x402_quote (payload_hash).
|
|
102
|
-
payload_hash: v3.z.string().regex(/^0x[0-9a-fA-F]+$/, "payload_hash must be a 0x-prefixed hex string"),
|
|
103
|
-
// Pass x402.expected from hosted haven_pay_x402_quote when this hash funds
|
|
104
|
-
// a standard x402 merchant retry. The signer records it locally and returns
|
|
105
|
-
// an opaque x402_binding for the later header-signing step.
|
|
106
|
-
x402_expected: x402ExpectedSchema.optional(),
|
|
107
|
-
// #1138: delegation-rail intents sign THIS, not payload_hash. Object-typed
|
|
108
|
-
// (not z.unknown()) so MCP clients embed it as JSON rather than a string.
|
|
109
|
-
typed_data: v3.z.record(v3.z.string(), v3.z.unknown()).optional()
|
|
110
|
-
},
|
|
111
|
-
haven_x402_sign_header: {
|
|
112
|
-
// The parsed HTTP 402 PaymentRequired from the merchant. Typed as an object
|
|
113
|
-
// (not z.unknown(), which becomes empty JSON Schema `{}`) so MCP clients
|
|
114
|
-
// embed it as JSON rather than serialising the object to a string.
|
|
115
|
-
payment_required: v3.z.record(v3.z.string(), v3.z.unknown()),
|
|
116
|
-
// Opaque binding returned by haven_sign when x402_expected was supplied.
|
|
117
|
-
x402_binding: v3.z.string().min(1)
|
|
118
|
-
},
|
|
119
|
-
haven_sign_x402: {
|
|
120
|
-
// One-shot x402 signing: funding hash + merchant header in one local call.
|
|
121
|
-
payload_hash: v3.z.string().regex(/^0x[0-9a-fA-F]+$/, "payload_hash must be a 0x-prefixed hex string"),
|
|
122
|
-
x402_expected: x402ExpectedSchema,
|
|
123
|
-
payment_required: v3.z.record(v3.z.string(), v3.z.unknown()),
|
|
124
|
-
// #1138: delegation-rail intents sign THIS, not payload_hash. Object-typed
|
|
125
|
-
// (not z.unknown()) so MCP clients embed it as JSON rather than a string.
|
|
126
|
-
typed_data: v3.z.record(v3.z.string(), v3.z.unknown()).optional()
|
|
127
|
-
}
|
|
128
|
-
};
|
|
129
|
-
var SIGN_DESCRIPTION = [
|
|
130
|
-
"Sign an unsigned Haven payment hash with the local delegate key. The delegate key never leaves",
|
|
131
|
-
"this process. Pass the payload_hash returned by haven_pay or haven_pay_x402_quote.",
|
|
132
|
-
"For x402, also pass x402_expected from haven_pay_x402_quote; the signer records it locally",
|
|
133
|
-
"and returns { signature, x402_binding }. x402_expected includes expires_at; sign before that",
|
|
134
|
-
"window closes. DELEGATION-RAIL accounts: when the hosted result carries typed_data, pass it too \u2014",
|
|
135
|
-
"the account validates that EIP-712 payload, not payload_hash, and signing is refused without it.",
|
|
136
|
-
"Next: call mcp__haven__haven_submit with signature, then pass x402_binding",
|
|
137
|
-
"to mcp__haven-signer__haven_x402_sign_header. For plain SafeTransfer payments, just pass payload_hash and",
|
|
138
|
-
"relay the returned signature via mcp__haven__haven_submit."
|
|
139
|
-
].join(" ");
|
|
140
|
-
var X402_SIGN_HEADER_DESCRIPTION = [
|
|
141
|
-
"Build and sign the EIP-3009 X-PAYMENT header for the merchant leg of an x402 payment.",
|
|
142
|
-
"The delegate key stays local \u2014 only the signed header crosses any boundary.",
|
|
143
|
-
"Pass the payment_required from the original merchant 402 response and the x402_binding",
|
|
144
|
-
"returned by haven_sign. The signer validates the merchant, amount, resource, asset, and",
|
|
145
|
-
"network against the recorded funding context before signing, checks expires_at when present,",
|
|
146
|
-
"and rejects mismatches or expired payment windows.",
|
|
147
|
-
"Returns { payment_header, accepted }. Set X-PAYMENT: <payment_header> on your retry to the",
|
|
148
|
-
"merchant. Only call after haven_submit has confirmed the funding step (nextAction=none or",
|
|
149
|
-
"the funding tx has a confirmed status). Next for paid MCP tools: call mcp__haven__haven_complete_mcp_tool."
|
|
150
|
-
].join(" ");
|
|
151
|
-
var SIGN_X402_DESCRIPTION = [
|
|
152
|
-
"One-shot x402 signing for the fast 3-call flow: sign the funding hash AND build the EIP-3009",
|
|
153
|
-
"X-PAYMENT header in a single local call (equivalent to haven_sign followed by",
|
|
154
|
-
"haven_x402_sign_header). The delegate key never leaves this process. From the haven_pay_mcp_tool",
|
|
155
|
-
"result pass payload_hash, x402_expected (the nested x402.expected object \u2014 passing the whole x402",
|
|
156
|
-
"object is also accepted and unwrapped for you), and payment_required (verbatim). On a",
|
|
157
|
-
"delegation-rail account the result also carries typed_data \u2014 pass that verbatim too; the signer",
|
|
158
|
-
"signs it instead of payload_hash and refuses the bare hash when the context commits to typed data.",
|
|
159
|
-
"Returns",
|
|
160
|
-
"{ signature, x402_binding, payment_header, accepted }; hand signature + payment_header to",
|
|
161
|
-
"mcp__haven__haven_settle_mcp_tool to fund and settle in one hosted call. The header is built now (before",
|
|
162
|
-
"funding confirms), so its short validity window starts here \u2014 call mcp__haven__haven_settle_mcp_tool promptly,",
|
|
163
|
-
"and re-run mcp__haven__haven_pay_mcp_tool with the same idempotency_key if a tool returns PAYMENT_WINDOW_EXPIRED.",
|
|
164
|
-
"Next: call mcp__haven__haven_settle_mcp_tool."
|
|
165
|
-
].join(" ");
|
|
166
|
-
var SIGN_SWEEP_DELEGATE_DESCRIPTION = [
|
|
167
|
-
"Sign a Haven-prepared gasless USDC sweep that recovers stranded funds from the delegate",
|
|
168
|
-
"wallet back to your Haven wallet. The delegate key never leaves this process and this tool",
|
|
169
|
-
"never broadcasts \u2014 it returns only an EIP-3009 signature that Haven's relayer submits and",
|
|
170
|
-
"pays gas for. Pass the authorization and expected_auth returned by the hosted",
|
|
171
|
-
"haven_sweep_delegate tool. The signer verifies Haven authored the authorization and that it",
|
|
172
|
-
"pays out to your own Safe before signing, then returns { signature } to hand back to",
|
|
173
|
-
"mcp__haven__haven_sweep_delegate to complete recovery."
|
|
174
|
-
].join(" ");
|
|
175
|
-
var toolDescriptions = {
|
|
176
|
-
haven_sign: SIGN_DESCRIPTION,
|
|
177
|
-
haven_x402_sign_header: X402_SIGN_HEADER_DESCRIPTION,
|
|
178
|
-
haven_sign_x402: SIGN_X402_DESCRIPTION,
|
|
179
|
-
haven_sign_sweep_delegate: SIGN_SWEEP_DELEGATE_DESCRIPTION
|
|
180
|
-
};
|
|
181
|
-
async function signFundingLeg(signer, expected, payloadHash, typedData) {
|
|
182
|
-
if (!expected.typedDataHash) {
|
|
183
|
-
return signer.signX402FundingHash(payloadHash, expected);
|
|
184
|
-
}
|
|
185
|
-
if (!typedData) {
|
|
49
|
+
function createEdgeSigner(delegateKey, options = {}) {
|
|
50
|
+
let delegateAddress;
|
|
51
|
+
try {
|
|
52
|
+
delegateAddress = sdk.addressFromKey(delegateKey);
|
|
53
|
+
} catch (err) {
|
|
186
54
|
throw new sdk.HavenSigningError(
|
|
187
|
-
|
|
55
|
+
`Invalid delegate key: ${err instanceof Error ? err.message : String(err)}`
|
|
188
56
|
);
|
|
189
57
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
asset: raw.asset,
|
|
201
|
-
network: raw.network,
|
|
202
|
-
expiresAt: raw.expires_at,
|
|
203
|
-
auth: raw.auth
|
|
204
|
-
};
|
|
205
|
-
}
|
|
206
|
-
function createToolHandlers(signer, options = {}) {
|
|
58
|
+
const x402Bindings = /* @__PURE__ */ new Map();
|
|
59
|
+
function signAndVerify(hash) {
|
|
60
|
+
const signature = sdk.signHash(delegateKey, hash);
|
|
61
|
+
if (!sdk.verifySignature(hash, signature, delegateAddress)) {
|
|
62
|
+
throw new sdk.HavenSigningError(
|
|
63
|
+
"Local signature verification failed \u2014 recovered address does not match the delegate key."
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return signature;
|
|
67
|
+
}
|
|
207
68
|
return {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
69
|
+
delegateAddress,
|
|
70
|
+
signPaymentHash(hash) {
|
|
71
|
+
return signAndVerify(hash);
|
|
72
|
+
},
|
|
73
|
+
async signDelegationTypedData(typedData) {
|
|
74
|
+
const account = accounts.privateKeyToAccount(delegateKey);
|
|
75
|
+
return account.signTypedData(typedData);
|
|
76
|
+
},
|
|
77
|
+
signX402FundingHash(hash, expected) {
|
|
78
|
+
assertExpectedBinding(hash, expected, options.x402BindingSigner, "hash");
|
|
79
|
+
const signature = signAndVerify(hash);
|
|
80
|
+
const x402Binding = crypto.randomUUID();
|
|
81
|
+
x402Bindings.set(x402Binding, { ...expected });
|
|
82
|
+
return { signature, x402Binding };
|
|
83
|
+
},
|
|
84
|
+
async signX402FundingTypedData(typedData, expected) {
|
|
85
|
+
assertExpectedBinding(expected.payloadHash, expected, options.x402BindingSigner, "typed-data");
|
|
86
|
+
const digest = viem.hashTypedData(typedData);
|
|
87
|
+
if (digest.toLowerCase() !== expected.typedDataHash?.toLowerCase()) {
|
|
88
|
+
throw new sdk.HavenSigningError(
|
|
89
|
+
"x402 typed data does not match the digest Haven committed to in the expected context. Refusing to sign \u2014 the payload was altered in transit or Haven declared a different one. The most common cause is the typed data being truncated or reshaped while being copied between tool calls (#1255): re-run the hosted quote and pass its typed_data_b64 string through UNCHANGED instead of re-emitting the nested JSON."
|
|
90
|
+
);
|
|
216
91
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
haven_x402_sign_header: async (input) => runTool(async () => {
|
|
221
|
-
const args = parse("haven_x402_sign_header", coercePaymentRequired(input));
|
|
222
|
-
const result = await signer.buildX402PaymentHeader(
|
|
223
|
-
args.payment_required,
|
|
224
|
-
args.x402_binding
|
|
225
|
-
);
|
|
226
|
-
await auditSigning(
|
|
227
|
-
"haven_x402_sign_header",
|
|
228
|
-
hashPayloadForAudit(args.payment_required)
|
|
229
|
-
);
|
|
230
|
-
return { payment_header: result.paymentHeader, accepted: result.accepted };
|
|
231
|
-
}),
|
|
232
|
-
haven_sign_x402: async (input) => runTool(async () => {
|
|
233
|
-
const args = parse("haven_sign_x402", coerceX402Expected(coercePaymentRequired(input)));
|
|
234
|
-
const funding = await signFundingLeg(
|
|
235
|
-
signer,
|
|
236
|
-
toExpectedX402(args.x402_expected),
|
|
237
|
-
args.payload_hash,
|
|
238
|
-
args.typed_data
|
|
92
|
+
const account = accounts.privateKeyToAccount(delegateKey);
|
|
93
|
+
const signature = await account.signTypedData(
|
|
94
|
+
typedData
|
|
239
95
|
);
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
96
|
+
const x402Binding = crypto.randomUUID();
|
|
97
|
+
x402Bindings.set(x402Binding, { ...expected });
|
|
98
|
+
return { signature, x402Binding };
|
|
99
|
+
},
|
|
100
|
+
async buildX402PaymentHeader(paymentRequired, x402Binding) {
|
|
101
|
+
const expected = x402Bindings.get(x402Binding);
|
|
102
|
+
if (!expected) {
|
|
103
|
+
throw new sdk.HavenSigningError(
|
|
104
|
+
"x402 funding binding is required before signing a merchant header. Sign the hosted funding hash with x402_expected first."
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
assertX402PaymentWindowOpen(expected);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
x402Bindings.delete(x402Binding);
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
113
|
+
const option = sdk.selectStandardPaymentOption(paymentRequired.accepts);
|
|
114
|
+
if (!option) {
|
|
115
|
+
throw new sdk.HavenApiError(
|
|
116
|
+
"No compatible payment option found in x402 requirements. Haven supports standard x402 exact payments on Base USDC.",
|
|
117
|
+
400
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
assertX402MatchesExpected(paymentRequired, option, expected);
|
|
121
|
+
const account = accounts.privateKeyToAccount(delegateKey);
|
|
122
|
+
const requirements = sdk.toStandardPaymentRequirements(paymentRequired, option);
|
|
123
|
+
const header = await schemes.exact.evm.createPaymentHeader(
|
|
124
|
+
account,
|
|
125
|
+
paymentRequired.x402Version,
|
|
126
|
+
requirements
|
|
243
127
|
);
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
128
|
+
if (paymentRequired.x402Version < 2) {
|
|
129
|
+
x402Bindings.delete(x402Binding);
|
|
130
|
+
return { paymentHeader: header, accepted: option };
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
const payment = sdk.decodeBase64Json(header);
|
|
134
|
+
const wrapped = sdk.encodeBase64Json({
|
|
135
|
+
x402Version: paymentRequired.x402Version,
|
|
136
|
+
accepted: option,
|
|
137
|
+
payload: payment.payload
|
|
138
|
+
});
|
|
139
|
+
return { paymentHeader: wrapped, accepted: option };
|
|
140
|
+
} finally {
|
|
141
|
+
x402Bindings.delete(x402Binding);
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
async signSweepAuthorization({
|
|
145
|
+
authorization,
|
|
146
|
+
expectedAuth,
|
|
147
|
+
expectedSafe
|
|
148
|
+
}) {
|
|
149
|
+
assertSweepBinding(authorization, expectedAuth, options.x402BindingSigner);
|
|
150
|
+
if (!sameAddress(authorization.from, delegateAddress)) {
|
|
151
|
+
throw new sdk.HavenSigningError(
|
|
152
|
+
"Sweep authorization `from` does not match this delegate address."
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
if (expectedSafe && !sameAddress(authorization.to, expectedSafe)) {
|
|
156
|
+
throw new sdk.HavenSigningError(
|
|
157
|
+
"Sweep authorization `to` does not match the Safe in the local credential."
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
const typedData = sdk.buildSweepTypedData(authorization);
|
|
161
|
+
const viemTypedData = {
|
|
162
|
+
domain: {
|
|
163
|
+
...typedData.domain,
|
|
164
|
+
verifyingContract: typedData.domain.verifyingContract
|
|
165
|
+
},
|
|
166
|
+
types: typedData.types,
|
|
167
|
+
primaryType: typedData.primaryType,
|
|
168
|
+
message: {
|
|
169
|
+
...typedData.message,
|
|
170
|
+
from: typedData.message.from,
|
|
171
|
+
to: typedData.message.to,
|
|
172
|
+
nonce: typedData.message.nonce
|
|
173
|
+
}
|
|
251
174
|
};
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
"haven_sign_sweep_delegate",
|
|
263
|
-
hashPayloadForAudit(args.authorization)
|
|
264
|
-
);
|
|
265
|
-
return { signature: result.signature };
|
|
266
|
-
})
|
|
175
|
+
const account = accounts.privateKeyToAccount(delegateKey);
|
|
176
|
+
const signature = await account.signTypedData(viemTypedData);
|
|
177
|
+
const recovered = await viem.recoverTypedDataAddress({ ...viemTypedData, signature });
|
|
178
|
+
if (!sameAddress(recovered, delegateAddress)) {
|
|
179
|
+
throw new sdk.HavenSigningError(
|
|
180
|
+
"Local sweep signature verification failed \u2014 recovered address does not match the delegate key."
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
return { signature };
|
|
184
|
+
}
|
|
267
185
|
};
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
auditPath
|
|
186
|
+
}
|
|
187
|
+
function assertSweepBinding(authorization, expectedAuth, trustedSigner) {
|
|
188
|
+
if (!expectedAuth || typeof expectedAuth !== "object") {
|
|
189
|
+
throw new sdk.HavenSigningError("Sweep authorization binding is required before signing.");
|
|
190
|
+
}
|
|
191
|
+
if (!trustedSigner) {
|
|
192
|
+
throw new sdk.HavenSigningError(
|
|
193
|
+
"Sweep binding verifier is not configured. Set HAVEN_X402_BINDING_SIGNER before signing sweep authorizations."
|
|
277
194
|
);
|
|
278
195
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
if (typeof value === "string") {
|
|
288
|
-
try {
|
|
289
|
-
value = JSON.parse(value);
|
|
290
|
-
} catch {
|
|
291
|
-
return input;
|
|
292
|
-
}
|
|
196
|
+
assertSupportedBindingVersion(
|
|
197
|
+
expectedAuth.version,
|
|
198
|
+
SUPPORTED_SWEEP_BINDING_VERSIONS,
|
|
199
|
+
"sweep authorization binding"
|
|
200
|
+
);
|
|
201
|
+
const message = sdk.buildSweepAuthorizationMessage(authorization);
|
|
202
|
+
if (expectedAuth.message !== message) {
|
|
203
|
+
throw new sdk.HavenSigningError("Sweep authorization binding does not match the authorization being signed.");
|
|
293
204
|
}
|
|
294
|
-
if (!
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
205
|
+
if (!sameAddress(expectedAuth.signer, trustedSigner)) {
|
|
206
|
+
throw new sdk.HavenSigningError("Sweep authorization binding was not signed by the configured Haven signer.");
|
|
207
|
+
}
|
|
208
|
+
if (!sdk.verifySignature(viem.hashMessage(message), expectedAuth.signature, trustedSigner)) {
|
|
209
|
+
throw new sdk.HavenSigningError("Sweep authorization binding signature could not be verified.");
|
|
298
210
|
}
|
|
299
|
-
if (value !== record.x402_expected) return { ...record, x402_expected: value };
|
|
300
|
-
return input;
|
|
301
211
|
}
|
|
302
|
-
function
|
|
303
|
-
|
|
304
|
-
const
|
|
305
|
-
if (
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
212
|
+
function assertX402MatchesExpected(paymentRequired, option, expected) {
|
|
213
|
+
assertExpectedShape(expected);
|
|
214
|
+
const headerResource = option.resource ?? paymentRequired.resource.url;
|
|
215
|
+
if (headerResource !== expected.resourceUrl) {
|
|
216
|
+
throw new sdk.HavenSigningError("x402 payment_required resource does not match the funded intent.");
|
|
217
|
+
}
|
|
218
|
+
if (!sameAddress(option.payTo, expected.merchantTo)) {
|
|
219
|
+
throw new sdk.HavenSigningError("x402 merchant recipient does not match the funded intent.");
|
|
220
|
+
}
|
|
221
|
+
if (sdk.x402AuthorizationAmount(option) !== expected.amount) {
|
|
222
|
+
throw new sdk.HavenSigningError("x402 amount does not match the funded intent.");
|
|
223
|
+
}
|
|
224
|
+
if (!sameAddress(option.asset, expected.asset)) {
|
|
225
|
+
throw new sdk.HavenSigningError("x402 asset does not match the funded intent.");
|
|
226
|
+
}
|
|
227
|
+
if (option.network !== expected.network) {
|
|
228
|
+
throw new sdk.HavenSigningError("x402 network does not match the funded intent.");
|
|
310
229
|
}
|
|
311
230
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
} catch (err) {
|
|
316
|
-
return normalizeError(err);
|
|
231
|
+
function assertExpectedShape(expected) {
|
|
232
|
+
if (!expected || typeof expected !== "object") {
|
|
233
|
+
throw new sdk.HavenSigningError("x402 expected funding context is required before signing a merchant header.");
|
|
317
234
|
}
|
|
318
235
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
}
|
|
236
|
+
var SUPPORTED_X402_EXPECTED_VERSIONS = [1, 2];
|
|
237
|
+
var SUPPORTED_SWEEP_BINDING_VERSIONS = [1];
|
|
238
|
+
function assertSupportedBindingVersion(received, supported, context) {
|
|
239
|
+
if (supported.includes(received)) return;
|
|
240
|
+
const highest = Math.max(...supported);
|
|
241
|
+
const ceiling = received > highest ? `This signer is out of date: it supports ${context} versions up to ${highest}, and Haven sent version ${received}. Update @haven_ai/signer \u2014 rerun the Haven connector (\`npx @haven_ai/connect@alpha\`), which reinstalls the pinned MCP runtime.` : `Unsupported ${context} version ${received}: this signer supports ${supported.join(", ")}.`;
|
|
242
|
+
throw new sdk.HavenSigningError(
|
|
243
|
+
`${ceiling} Nothing was signed. Do not rewrite the version field to a supported value: it is part of the Haven-signed binding message, so changing it invalidates the signature and would misrepresent what Haven authorised.`
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
function assertExpectedBinding(payloadHash, expected, trustedSigner, mode = "hash") {
|
|
247
|
+
assertExpectedShape(expected);
|
|
248
|
+
if (!trustedSigner) {
|
|
249
|
+
throw new sdk.HavenSigningError(
|
|
250
|
+
"x402 expected-context verifier is not configured. Set HAVEN_X402_BINDING_SIGNER before signing x402 funding hashes."
|
|
251
|
+
);
|
|
327
252
|
}
|
|
328
|
-
if (
|
|
329
|
-
|
|
253
|
+
if (expected.auth) {
|
|
254
|
+
assertSupportedBindingVersion(
|
|
255
|
+
expected.auth.version,
|
|
256
|
+
SUPPORTED_X402_EXPECTED_VERSIONS,
|
|
257
|
+
"x402 expected context"
|
|
258
|
+
);
|
|
330
259
|
}
|
|
331
|
-
if (
|
|
332
|
-
|
|
260
|
+
if (expected.payloadHash.toLowerCase() !== payloadHash.toLowerCase()) {
|
|
261
|
+
throw new sdk.HavenSigningError("x402 expected context does not match the funding hash being signed.");
|
|
333
262
|
}
|
|
334
|
-
if (
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
263
|
+
if (mode === "hash" && expected.typedDataHash) {
|
|
264
|
+
throw new sdk.HavenSigningError(
|
|
265
|
+
"This x402 funding intent commits to EIP-712 typed data, so its bare hash must not be raw-signed \u2014 the account would reject that signature on-chain. Sign sign_data.typed_data instead."
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
if (mode === "typed-data" && !expected.typedDataHash) {
|
|
269
|
+
throw new sdk.HavenSigningError(
|
|
270
|
+
"Refusing to sign typed data under an expected context that does not commit to it. Haven must return a v2 x402 expected context (with typedDataHash) for a delegation-rail intent."
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
const message = sdk.buildX402ExpectedMessage({
|
|
274
|
+
paymentId: expected.paymentId,
|
|
275
|
+
payloadHash: expected.payloadHash,
|
|
276
|
+
resourceUrl: expected.resourceUrl,
|
|
277
|
+
merchantTo: expected.merchantTo,
|
|
278
|
+
amount: expected.amount,
|
|
279
|
+
asset: expected.asset,
|
|
280
|
+
network: expected.network,
|
|
281
|
+
expiresAt: expected.expiresAt,
|
|
282
|
+
typedDataHash: expected.typedDataHash
|
|
283
|
+
});
|
|
284
|
+
const expectedVersion = expected.typedDataHash ? 2 : 1;
|
|
285
|
+
if (expected.auth?.version !== expectedVersion || expected.auth.message !== message) {
|
|
286
|
+
throw new sdk.HavenSigningError("x402 expected context authentication message is invalid.");
|
|
287
|
+
}
|
|
288
|
+
if (!sameAddress(expected.auth.signer, trustedSigner)) {
|
|
289
|
+
throw new sdk.HavenSigningError("x402 expected context was not signed by the configured Haven signer.");
|
|
290
|
+
}
|
|
291
|
+
if (!sdk.verifySignature(viem.hashMessage(message), expected.auth.signature, trustedSigner)) {
|
|
292
|
+
throw new sdk.HavenSigningError("x402 expected context signature could not be verified.");
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
function assertX402PaymentWindowOpen(expected) {
|
|
296
|
+
if (!expected.expiresAt) return;
|
|
297
|
+
const expiresAtMs = Date.parse(expected.expiresAt);
|
|
298
|
+
if (Number.isNaN(expiresAtMs)) {
|
|
299
|
+
throw new sdk.HavenSigningError("x402 expected context expiresAt is not a valid ISO timestamp.");
|
|
300
|
+
}
|
|
301
|
+
if (expiresAtMs <= Date.now()) {
|
|
302
|
+
throw new sdk.HavenError(
|
|
303
|
+
"The x402 payment window expired before the merchant header could be signed. Re-quote with haven_pay_mcp_tool using the same idempotency_key before trying again.",
|
|
304
|
+
sdk.AgentPaymentFailureCode.PaymentWindowExpired,
|
|
305
|
+
410,
|
|
306
|
+
expected.paymentId
|
|
307
|
+
);
|
|
347
308
|
}
|
|
309
|
+
}
|
|
310
|
+
function sameAddress(a, b) {
|
|
311
|
+
return a.toLowerCase() === b.toLowerCase();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// src/capabilities.ts
|
|
315
|
+
var SIGNER_CAPABILITY_KEY = "haven/signer-compatibility";
|
|
316
|
+
function signerCompatibility() {
|
|
348
317
|
return {
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
message: err instanceof Error ? err.message : String(err)
|
|
318
|
+
x402_expected_context_versions: [...SUPPORTED_X402_EXPECTED_VERSIONS],
|
|
319
|
+
sweep_binding_versions: [...SUPPORTED_SWEEP_BINDING_VERSIONS]
|
|
352
320
|
};
|
|
353
321
|
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
var SIGNER_ACK_ENV = "HAVEN_SIGNER_ACK";
|
|
357
|
-
function computeSignerConsentHash(input) {
|
|
358
|
-
const identity = [
|
|
359
|
-
input.delegateAddress.toLowerCase(),
|
|
360
|
-
(input.safeAddress ?? "").toLowerCase(),
|
|
361
|
-
input.agentId ?? "",
|
|
362
|
-
input.chainId ?? "",
|
|
363
|
-
input.network ?? ""
|
|
364
|
-
].join("|");
|
|
365
|
-
const toolCanonical = [...input.toolNames].sort().join(",");
|
|
366
|
-
return crypto.createHash("sha256").update(`${identity}
|
|
367
|
-
${toolCanonical}`).digest("hex").slice(0, 16);
|
|
322
|
+
function signerCapabilityAdvertisement() {
|
|
323
|
+
return { experimental: { [SIGNER_CAPABILITY_KEY]: signerCompatibility() } };
|
|
368
324
|
}
|
|
369
|
-
function
|
|
370
|
-
const
|
|
325
|
+
function signerInstructions() {
|
|
326
|
+
const compatibility = signerCompatibility();
|
|
327
|
+
return [
|
|
328
|
+
"Haven edge signer: sign-only tools bound to the local delegate key. It performs no",
|
|
329
|
+
"network I/O and never emits the key.",
|
|
371
330
|
"",
|
|
372
|
-
"
|
|
373
|
-
|
|
374
|
-
"
|
|
331
|
+
"Version compatibility (check this BEFORE signing, not after):",
|
|
332
|
+
`- x402 expected-context versions supported: ${compatibility.x402_expected_context_versions.join(", ")}`,
|
|
333
|
+
`- sweep authorization binding versions supported: ${compatibility.sweep_binding_versions.join(", ")}`,
|
|
375
334
|
"",
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
lines.push("This local signer holds the delegate key on this machine and signs");
|
|
384
|
-
lines.push("payment payloads or x402 merchant headers for the delegate address above.");
|
|
385
|
-
lines.push("It does not call the Haven API, so it cannot show a live allowance summary.");
|
|
386
|
-
lines.push("On-chain Safe rules remain the real spend gate, and the wallet owner can");
|
|
387
|
-
lines.push("pause or revoke agent authority outside this signer.");
|
|
388
|
-
lines.push("");
|
|
389
|
-
lines.push("Tools this signer will expose to your agent runtime:");
|
|
390
|
-
for (const name of input.toolNames) {
|
|
391
|
-
lines.push(` - ${name}`);
|
|
392
|
-
lines.push(` ${toolDescriptions[name]}`);
|
|
393
|
-
}
|
|
394
|
-
lines.push("");
|
|
395
|
-
lines.push("A local audit entry is appended for every signing operation. Audit entries");
|
|
396
|
-
lines.push("record timestamp, tool, payload hash, and delegate address; never the key,");
|
|
397
|
-
lines.push("signature, or x402 payment header.");
|
|
398
|
-
lines.push("");
|
|
399
|
-
lines.push(`Consent hash: ${hash}`);
|
|
400
|
-
lines.push("");
|
|
401
|
-
lines.push("To acknowledge, EITHER:");
|
|
402
|
-
lines.push(` - set ${SIGNER_ACK_ENV}=${hash} in this process's environment, OR`);
|
|
403
|
-
lines.push(" - re-run with --ack to write the acknowledgement next to your");
|
|
404
|
-
lines.push(" credential file (sidecar <credentials>.signer-ack.json).");
|
|
405
|
-
lines.push("");
|
|
406
|
-
lines.push("------------------------------------------------------------");
|
|
407
|
-
lines.push("");
|
|
408
|
-
return lines.join("\n");
|
|
335
|
+
"Haven quote and prepare results report the expected-context version they will emit",
|
|
336
|
+
"(signer_compatibility.x402_expected_context_version). If that version is not in the list",
|
|
337
|
+
"above, this signer is out of date: STOP before signing, and tell the user to update",
|
|
338
|
+
"@haven_ai/signer by rerunning `npx @haven_ai/connect@alpha`, which reinstalls the pinned",
|
|
339
|
+
"MCP runtime. Do not edit the version field to a supported value \u2014 it is part of the",
|
|
340
|
+
"Haven-signed binding message, so changing it invalidates the signature."
|
|
341
|
+
].join("\n");
|
|
409
342
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
343
|
+
var sweepAuthorizationSchema = v3.z.object({
|
|
344
|
+
from: v3.z.string().regex(/^0x[0-9a-fA-F]{40}$/, "from must be a 0x address"),
|
|
345
|
+
to: v3.z.string().regex(/^0x[0-9a-fA-F]{40}$/, "to must be a 0x address"),
|
|
346
|
+
value: v3.z.string().regex(/^[0-9]+$/, "value must be a decimal atomic amount"),
|
|
347
|
+
validAfter: v3.z.string().regex(/^[0-9]+$/, "validAfter must be a decimal unix time"),
|
|
348
|
+
validBefore: v3.z.string().regex(/^[0-9]+$/, "validBefore must be a decimal unix time"),
|
|
349
|
+
nonce: v3.z.string().regex(/^0x[0-9a-fA-F]{64}$/, "nonce must be a 0x-prefixed 32-byte hex string"),
|
|
350
|
+
token: v3.z.string().regex(/^0x[0-9a-fA-F]{40}$/, "token must be a 0x address"),
|
|
351
|
+
chainId: v3.z.number().int().positive()
|
|
352
|
+
});
|
|
353
|
+
var bindingVersionSchema = v3.z.number().int().positive();
|
|
354
|
+
var sweepExpectedAuthSchema = v3.z.object({
|
|
355
|
+
version: bindingVersionSchema,
|
|
356
|
+
message: v3.z.string().min(1),
|
|
357
|
+
signature: v3.z.string().regex(/^0x[0-9a-fA-F]+$/, "signature must be a 0x-prefixed hex string"),
|
|
358
|
+
signer: v3.z.string().regex(/^0x[0-9a-fA-F]{40}$/, "signer must be a 0x address")
|
|
359
|
+
});
|
|
360
|
+
var x402ExpectedSchema = v3.z.object({
|
|
361
|
+
payment_id: v3.z.string().min(1),
|
|
362
|
+
payload_hash: v3.z.string().regex(/^0x[0-9a-fA-F]+$/, "payload_hash must be a 0x-prefixed hex string"),
|
|
363
|
+
resource_url: v3.z.string().url(),
|
|
364
|
+
merchant_to: v3.z.string().min(1),
|
|
365
|
+
amount: v3.z.string().min(1),
|
|
366
|
+
asset: v3.z.string().min(1),
|
|
367
|
+
network: v3.z.string().min(1),
|
|
368
|
+
// Required: expires_at is folded into the Haven-signed binding message, so the
|
|
369
|
+
// signer must receive the exact same value to reconstruct a matching message.
|
|
370
|
+
// Omitting it used to fail downstream with a cryptic "authentication message is
|
|
371
|
+
// invalid" — making it required surfaces a clear INVALID_INPUT at the boundary.
|
|
372
|
+
expires_at: v3.z.string().min(1),
|
|
373
|
+
// #1138: present on a delegation-rail (v2) context. It commits to the EIP-712
|
|
374
|
+
// typed data the account validates; the signer refuses to raw-sign the bare
|
|
375
|
+
// hash when it is present, and refuses to sign typed data when it is absent.
|
|
376
|
+
typed_data_hash: v3.z.string().regex(/^0x[0-9a-fA-F]+$/, "typed_data_hash must be a 0x-prefixed hex string").optional(),
|
|
377
|
+
auth: v3.z.object({
|
|
378
|
+
// Open at the boundary, enforced in the signer — see bindingVersionSchema.
|
|
379
|
+
version: bindingVersionSchema,
|
|
380
|
+
message: v3.z.string().min(1),
|
|
381
|
+
signature: v3.z.string().regex(/^0x[0-9a-fA-F]+$/, "signature must be a 0x-prefixed hex string"),
|
|
382
|
+
signer: v3.z.string().min(1)
|
|
383
|
+
})
|
|
384
|
+
});
|
|
385
|
+
var toolSchemas = {
|
|
386
|
+
haven_sign_sweep_delegate: {
|
|
387
|
+
// The authorization fields prepared by Haven's POST /sweep/prepare. Passed
|
|
388
|
+
// through verbatim from the hosted haven_sweep_delegate tool — the signer
|
|
389
|
+
// re-derives the binding message from these exact values.
|
|
390
|
+
authorization: sweepAuthorizationSchema,
|
|
391
|
+
// Haven's signature over the authorization context (the binding).
|
|
392
|
+
expected_auth: sweepExpectedAuthSchema
|
|
393
|
+
},
|
|
394
|
+
haven_sign: {
|
|
395
|
+
// The unsigned hash from haven_pay / haven_pay_x402_quote (payload_hash).
|
|
396
|
+
payload_hash: v3.z.string().regex(/^0x[0-9a-fA-F]+$/, "payload_hash must be a 0x-prefixed hex string"),
|
|
397
|
+
// Pass x402.expected from hosted haven_pay_x402_quote when this hash funds
|
|
398
|
+
// a standard x402 merchant retry. The signer records it locally and returns
|
|
399
|
+
// an opaque x402_binding for the later header-signing step.
|
|
400
|
+
x402_expected: x402ExpectedSchema.optional(),
|
|
401
|
+
// #1138: delegation-rail intents sign THIS, not payload_hash. Object-typed
|
|
402
|
+
// (not z.unknown()) so MCP clients embed it as JSON rather than a string.
|
|
403
|
+
typed_data: v3.z.record(v3.z.string(), v3.z.unknown()).optional(),
|
|
404
|
+
// #1255: the same payload as ONE opaque base64 string, exactly as returned
|
|
405
|
+
// by the hosted tools. Preferred over typed_data when both are present —
|
|
406
|
+
// an agent re-emitting multi-KB nested JSON between tool calls is the
|
|
407
|
+
// failure mode this field removes (a truncated/reshaped payload fails the
|
|
408
|
+
// digest check and the payment refuses, correctly but pointlessly).
|
|
409
|
+
// Bounded: a realistic redemption payload is ~10KB encoded; 256KB is
|
|
410
|
+
// generous headroom while keeping the offline signer from materializing
|
|
411
|
+
// arbitrarily large caller input.
|
|
412
|
+
typed_data_b64: v3.z.string().min(1).max(262144).optional()
|
|
413
|
+
},
|
|
414
|
+
haven_x402_sign_header: {
|
|
415
|
+
// The parsed HTTP 402 PaymentRequired from the merchant. Typed as an object
|
|
416
|
+
// (not z.unknown(), which becomes empty JSON Schema `{}`) so MCP clients
|
|
417
|
+
// embed it as JSON rather than serialising the object to a string.
|
|
418
|
+
payment_required: v3.z.record(v3.z.string(), v3.z.unknown()),
|
|
419
|
+
// Opaque binding returned by haven_sign when x402_expected was supplied.
|
|
420
|
+
x402_binding: v3.z.string().min(1)
|
|
421
|
+
},
|
|
422
|
+
haven_sign_x402: {
|
|
423
|
+
// One-shot x402 signing: funding hash + merchant header in one local call.
|
|
424
|
+
payload_hash: v3.z.string().regex(/^0x[0-9a-fA-F]+$/, "payload_hash must be a 0x-prefixed hex string"),
|
|
425
|
+
x402_expected: x402ExpectedSchema,
|
|
426
|
+
payment_required: v3.z.record(v3.z.string(), v3.z.unknown()),
|
|
427
|
+
// #1138: delegation-rail intents sign THIS, not payload_hash. Object-typed
|
|
428
|
+
// (not z.unknown()) so MCP clients embed it as JSON rather than a string.
|
|
429
|
+
typed_data: v3.z.record(v3.z.string(), v3.z.unknown()).optional(),
|
|
430
|
+
// #1255: see haven_sign.typed_data_b64 — the copy-through-safe form.
|
|
431
|
+
typed_data_b64: v3.z.string().min(1).max(262144).optional()
|
|
432
432
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
433
|
+
};
|
|
434
|
+
var SIGN_DESCRIPTION = [
|
|
435
|
+
"Sign an unsigned Haven payment hash with the local delegate key. The delegate key never leaves",
|
|
436
|
+
"this process. Pass the payload_hash returned by haven_pay or haven_pay_x402_quote.",
|
|
437
|
+
"For x402, also pass x402_expected from haven_pay_x402_quote; the signer records it locally",
|
|
438
|
+
"and returns { signature, x402_binding }. x402_expected includes expires_at; sign before that",
|
|
439
|
+
"window closes. DELEGATION-RAIL accounts: when the hosted result carries typed_data_b64, pass that",
|
|
440
|
+
"single string through UNCHANGED (preferred \u2014 never re-type the nested typed_data JSON yourself);",
|
|
441
|
+
"the account validates that EIP-712 payload, not payload_hash, and signing is refused without it.",
|
|
442
|
+
"Next: call mcp__haven__haven_submit with signature, then pass x402_binding",
|
|
443
|
+
"to mcp__haven-signer__haven_x402_sign_header. For plain SafeTransfer payments, just pass payload_hash and",
|
|
444
|
+
"relay the returned signature via mcp__haven__haven_submit."
|
|
445
|
+
].join(" ");
|
|
446
|
+
var X402_SIGN_HEADER_DESCRIPTION = [
|
|
447
|
+
"Build and sign the EIP-3009 X-PAYMENT header for the merchant leg of an x402 payment.",
|
|
448
|
+
"The delegate key stays local \u2014 only the signed header crosses any boundary.",
|
|
449
|
+
"Pass the payment_required from the original merchant 402 response and the x402_binding",
|
|
450
|
+
"returned by haven_sign. The signer validates the merchant, amount, resource, asset, and",
|
|
451
|
+
"network against the recorded funding context before signing, checks expires_at when present,",
|
|
452
|
+
"and rejects mismatches or expired payment windows.",
|
|
453
|
+
"Returns { payment_header, accepted }. Set X-PAYMENT: <payment_header> on your retry to the",
|
|
454
|
+
"merchant. Only call after haven_submit has confirmed the funding step (nextAction=none or",
|
|
455
|
+
"the funding tx has a confirmed status). Next for paid MCP tools: call mcp__haven__haven_complete_mcp_tool."
|
|
456
|
+
].join(" ");
|
|
457
|
+
var SIGN_X402_DESCRIPTION = [
|
|
458
|
+
"One-shot x402 signing for the fast 3-call flow: sign the funding hash AND build the EIP-3009",
|
|
459
|
+
"X-PAYMENT header in a single local call (equivalent to haven_sign followed by",
|
|
460
|
+
"haven_x402_sign_header). The delegate key never leaves this process. From the haven_pay_mcp_tool",
|
|
461
|
+
"result pass payload_hash, x402_expected (the nested x402.expected object \u2014 passing the whole x402",
|
|
462
|
+
"object is also accepted and unwrapped for you), and payment_required (verbatim). On a",
|
|
463
|
+
"delegation-rail account the result also carries typed_data_b64 \u2014 pass that single string through",
|
|
464
|
+
"UNCHANGED (preferred over re-typing the nested typed_data JSON); the signer",
|
|
465
|
+
"signs it instead of payload_hash and refuses the bare hash when the context commits to typed data.",
|
|
466
|
+
"Returns",
|
|
467
|
+
"{ signature, x402_binding, payment_header, accepted }; hand signature + payment_header to",
|
|
468
|
+
"mcp__haven__haven_settle_mcp_tool to fund and settle in one hosted call. The header is built now (before",
|
|
469
|
+
"funding confirms), so its short validity window starts here \u2014 call mcp__haven__haven_settle_mcp_tool promptly,",
|
|
470
|
+
"and re-run mcp__haven__haven_pay_mcp_tool with the same idempotency_key if a tool returns PAYMENT_WINDOW_EXPIRED.",
|
|
471
|
+
"Next: call mcp__haven__haven_settle_mcp_tool."
|
|
472
|
+
].join(" ");
|
|
473
|
+
var SIGN_SWEEP_DELEGATE_DESCRIPTION = [
|
|
474
|
+
"Sign a Haven-prepared gasless USDC sweep that recovers stranded funds from the delegate",
|
|
475
|
+
"wallet back to your Haven wallet. The delegate key never leaves this process and this tool",
|
|
476
|
+
"never broadcasts \u2014 it returns only an EIP-3009 signature that Haven's relayer submits and",
|
|
477
|
+
"pays gas for. Pass the authorization and expected_auth returned by the hosted",
|
|
478
|
+
"haven_sweep_delegate tool. The signer verifies Haven authored the authorization and that it",
|
|
479
|
+
"pays out to your own Safe before signing, then returns { signature } to hand back to",
|
|
480
|
+
"mcp__haven__haven_sweep_delegate to complete recovery."
|
|
481
|
+
].join(" ");
|
|
482
|
+
var toolDescriptions = {
|
|
483
|
+
haven_sign: SIGN_DESCRIPTION,
|
|
484
|
+
haven_x402_sign_header: X402_SIGN_HEADER_DESCRIPTION,
|
|
485
|
+
haven_sign_x402: SIGN_X402_DESCRIPTION,
|
|
486
|
+
haven_sign_sweep_delegate: SIGN_SWEEP_DELEGATE_DESCRIPTION
|
|
487
|
+
};
|
|
488
|
+
async function signFundingLeg(signer, expected, payloadHash, typedData) {
|
|
489
|
+
if (!expected.typedDataHash) {
|
|
490
|
+
return signer.signX402FundingHash(payloadHash, expected);
|
|
440
491
|
}
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
return Object.keys(toolSchemas);
|
|
446
|
-
}
|
|
447
|
-
function sidecarPath(credentialsPath) {
|
|
448
|
-
if (!credentialsPath) return null;
|
|
449
|
-
return path.resolve(`${credentialsPath}.signer-ack.json`);
|
|
450
|
-
}
|
|
451
|
-
async function readAckFile(path) {
|
|
452
|
-
try {
|
|
453
|
-
const raw = await promises.readFile(path, "utf8");
|
|
454
|
-
const parsed = JSON.parse(raw);
|
|
455
|
-
return { ack: typeof parsed.ack === "string" ? parsed.ack : void 0 };
|
|
456
|
-
} catch {
|
|
457
|
-
return null;
|
|
492
|
+
if (!typedData) {
|
|
493
|
+
throw new sdk.HavenSigningError(
|
|
494
|
+
"This x402 funding intent is on the delegation rail: it commits to EIP-712 typed data, which was not supplied. Pass typed_data_b64 from haven_pay_mcp_tool / haven_pay_x402_quote through unchanged (or typed_data verbatim) \u2014 the bare payload_hash is not what the account validates."
|
|
495
|
+
);
|
|
458
496
|
}
|
|
497
|
+
return signer.signX402FundingTypedData(typedData, expected);
|
|
459
498
|
}
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
499
|
+
function toExpectedX402(raw) {
|
|
500
|
+
return {
|
|
501
|
+
paymentId: raw.payment_id,
|
|
502
|
+
payloadHash: raw.payload_hash,
|
|
503
|
+
typedDataHash: raw.typed_data_hash,
|
|
504
|
+
resourceUrl: raw.resource_url,
|
|
505
|
+
merchantTo: raw.merchant_to,
|
|
506
|
+
amount: raw.amount,
|
|
507
|
+
asset: raw.asset,
|
|
508
|
+
network: raw.network,
|
|
509
|
+
expiresAt: raw.expires_at,
|
|
510
|
+
auth: raw.auth
|
|
511
|
+
};
|
|
467
512
|
}
|
|
468
|
-
function
|
|
469
|
-
|
|
513
|
+
function resolveTypedData(args) {
|
|
514
|
+
if (!args.typed_data_b64) return args.typed_data;
|
|
470
515
|
try {
|
|
471
|
-
|
|
516
|
+
const decoded = JSON.parse(
|
|
517
|
+
Buffer.from(args.typed_data_b64, "base64").toString("utf8")
|
|
518
|
+
);
|
|
519
|
+
if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) {
|
|
520
|
+
throw new Error("decoded value is not an object");
|
|
521
|
+
}
|
|
522
|
+
return decoded;
|
|
472
523
|
} catch (err) {
|
|
473
524
|
throw new sdk.HavenSigningError(
|
|
474
|
-
`
|
|
525
|
+
`typed_data_b64 did not decode to a JSON object. Pass the exact string returned by the hosted Haven tool, unchanged \u2014 do not re-encode, trim, or reformat it. Underlying error: ${err instanceof Error ? err.message : String(err)}`
|
|
475
526
|
);
|
|
476
527
|
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
const signature = sdk.signHash(delegateKey, hash);
|
|
480
|
-
if (!sdk.verifySignature(hash, signature, delegateAddress)) {
|
|
481
|
-
throw new sdk.HavenSigningError(
|
|
482
|
-
"Local signature verification failed \u2014 recovered address does not match the delegate key."
|
|
483
|
-
);
|
|
484
|
-
}
|
|
485
|
-
return signature;
|
|
486
|
-
}
|
|
528
|
+
}
|
|
529
|
+
function createToolHandlers(signer, options = {}) {
|
|
487
530
|
return {
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
if (digest.toLowerCase() !== expected.typedDataHash?.toLowerCase()) {
|
|
503
|
-
throw new sdk.HavenSigningError(
|
|
504
|
-
"x402 typed data does not match the digest Haven committed to in the expected context. Refusing to sign \u2014 the payload was altered in transit or Haven declared a different one."
|
|
505
|
-
);
|
|
531
|
+
haven_sign: async (input) => runTool(async () => {
|
|
532
|
+
const args = parse("haven_sign", coerceX402Expected(input));
|
|
533
|
+
const typedData = resolveTypedData(args);
|
|
534
|
+
const x402Expected = args.x402_expected ? toExpectedX402(args.x402_expected) : null;
|
|
535
|
+
const result = x402Expected ? await signFundingLeg(signer, x402Expected, args.payload_hash, typedData) : null;
|
|
536
|
+
if (!result) {
|
|
537
|
+
if (typedData) {
|
|
538
|
+
const signature2 = await signer.signDelegationTypedData(typedData);
|
|
539
|
+
await auditSigning("haven_sign", args.payload_hash);
|
|
540
|
+
return { signature: signature2 };
|
|
541
|
+
}
|
|
542
|
+
const signature = signer.signPaymentHash(args.payload_hash);
|
|
543
|
+
await auditSigning("haven_sign", args.payload_hash);
|
|
544
|
+
return { signature };
|
|
506
545
|
}
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
546
|
+
await auditSigning("haven_sign", args.payload_hash);
|
|
547
|
+
return { signature: result.signature, x402_binding: result.x402Binding };
|
|
548
|
+
}),
|
|
549
|
+
haven_x402_sign_header: async (input) => runTool(async () => {
|
|
550
|
+
const args = parse("haven_x402_sign_header", coercePaymentRequired(input));
|
|
551
|
+
const result = await signer.buildX402PaymentHeader(
|
|
552
|
+
args.payment_required,
|
|
553
|
+
args.x402_binding
|
|
510
554
|
);
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
},
|
|
515
|
-
async buildX402PaymentHeader(paymentRequired, x402Binding) {
|
|
516
|
-
const expected = x402Bindings.get(x402Binding);
|
|
517
|
-
if (!expected) {
|
|
518
|
-
throw new sdk.HavenSigningError(
|
|
519
|
-
"x402 funding binding is required before signing a merchant header. Sign the hosted funding hash with x402_expected first."
|
|
520
|
-
);
|
|
521
|
-
}
|
|
522
|
-
try {
|
|
523
|
-
assertX402PaymentWindowOpen(expected);
|
|
524
|
-
} catch (err) {
|
|
525
|
-
x402Bindings.delete(x402Binding);
|
|
526
|
-
throw err;
|
|
527
|
-
}
|
|
528
|
-
const option = sdk.selectStandardPaymentOption(paymentRequired.accepts);
|
|
529
|
-
if (!option) {
|
|
530
|
-
throw new sdk.HavenApiError(
|
|
531
|
-
"No compatible payment option found in x402 requirements. Haven supports standard x402 exact payments on Base USDC.",
|
|
532
|
-
400
|
|
533
|
-
);
|
|
534
|
-
}
|
|
535
|
-
assertX402MatchesExpected(paymentRequired, option, expected);
|
|
536
|
-
const account = accounts.privateKeyToAccount(delegateKey);
|
|
537
|
-
const requirements = sdk.toStandardPaymentRequirements(paymentRequired, option);
|
|
538
|
-
const header = await schemes.exact.evm.createPaymentHeader(
|
|
539
|
-
account,
|
|
540
|
-
paymentRequired.x402Version,
|
|
541
|
-
requirements
|
|
555
|
+
await auditSigning(
|
|
556
|
+
"haven_x402_sign_header",
|
|
557
|
+
hashPayloadForAudit(args.payment_required)
|
|
542
558
|
);
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
assertSweepBinding(authorization, expectedAuth, options.x402BindingSigner);
|
|
565
|
-
if (!sameAddress(authorization.from, delegateAddress)) {
|
|
566
|
-
throw new sdk.HavenSigningError(
|
|
567
|
-
"Sweep authorization `from` does not match this delegate address."
|
|
568
|
-
);
|
|
569
|
-
}
|
|
570
|
-
if (expectedSafe && !sameAddress(authorization.to, expectedSafe)) {
|
|
571
|
-
throw new sdk.HavenSigningError(
|
|
572
|
-
"Sweep authorization `to` does not match the Safe in the local credential."
|
|
573
|
-
);
|
|
574
|
-
}
|
|
575
|
-
const typedData = sdk.buildSweepTypedData(authorization);
|
|
576
|
-
const viemTypedData = {
|
|
577
|
-
domain: {
|
|
578
|
-
...typedData.domain,
|
|
579
|
-
verifyingContract: typedData.domain.verifyingContract
|
|
580
|
-
},
|
|
581
|
-
types: typedData.types,
|
|
582
|
-
primaryType: typedData.primaryType,
|
|
583
|
-
message: {
|
|
584
|
-
...typedData.message,
|
|
585
|
-
from: typedData.message.from,
|
|
586
|
-
to: typedData.message.to,
|
|
587
|
-
nonce: typedData.message.nonce
|
|
588
|
-
}
|
|
559
|
+
return { payment_header: result.paymentHeader, accepted: result.accepted };
|
|
560
|
+
}),
|
|
561
|
+
haven_sign_x402: async (input) => runTool(async () => {
|
|
562
|
+
const args = parse("haven_sign_x402", coerceX402Expected(coercePaymentRequired(input)));
|
|
563
|
+
const funding = await signFundingLeg(
|
|
564
|
+
signer,
|
|
565
|
+
toExpectedX402(args.x402_expected),
|
|
566
|
+
args.payload_hash,
|
|
567
|
+
resolveTypedData(args)
|
|
568
|
+
);
|
|
569
|
+
const header = await signer.buildX402PaymentHeader(
|
|
570
|
+
args.payment_required,
|
|
571
|
+
funding.x402Binding
|
|
572
|
+
);
|
|
573
|
+
await auditSigning("haven_sign_x402", args.payload_hash);
|
|
574
|
+
await auditSigning("haven_sign_x402", hashPayloadForAudit(args.payment_required));
|
|
575
|
+
return {
|
|
576
|
+
signature: funding.signature,
|
|
577
|
+
x402_binding: funding.x402Binding,
|
|
578
|
+
payment_header: header.paymentHeader,
|
|
579
|
+
accepted: header.accepted
|
|
589
580
|
};
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
const
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
581
|
+
}),
|
|
582
|
+
haven_sign_sweep_delegate: async (input) => runTool(async () => {
|
|
583
|
+
const args = parse("haven_sign_sweep_delegate", input);
|
|
584
|
+
const result = await signer.signSweepAuthorization({
|
|
585
|
+
authorization: args.authorization,
|
|
586
|
+
expectedAuth: args.expected_auth,
|
|
587
|
+
// Cross-check `to` against the Safe in the local credential when present.
|
|
588
|
+
expectedSafe: options.audit?.safeAddress
|
|
589
|
+
});
|
|
590
|
+
await auditSigning(
|
|
591
|
+
"haven_sign_sweep_delegate",
|
|
592
|
+
hashPayloadForAudit(args.authorization)
|
|
593
|
+
);
|
|
594
|
+
return { signature: result.signature };
|
|
595
|
+
})
|
|
600
596
|
};
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
597
|
+
async function auditSigning(tool, payloadHash) {
|
|
598
|
+
if (!options.audit) return;
|
|
599
|
+
const { auditPath, ...context } = options.audit;
|
|
600
|
+
await appendSigningAuditEntry(
|
|
601
|
+
createSigningAuditEntry(tool, payloadHash, {
|
|
602
|
+
...context,
|
|
603
|
+
delegateAddress: signer.delegateAddress
|
|
604
|
+
}),
|
|
605
|
+
auditPath
|
|
609
606
|
);
|
|
610
607
|
}
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
608
|
+
}
|
|
609
|
+
function parse(name, input) {
|
|
610
|
+
return v3.z.object(toolSchemas[name]).parse(input ?? {});
|
|
611
|
+
}
|
|
612
|
+
function coerceX402Expected(input) {
|
|
613
|
+
if (!input || typeof input !== "object") return input;
|
|
614
|
+
const record = input;
|
|
615
|
+
let value = record.x402_expected;
|
|
616
|
+
if (typeof value === "string") {
|
|
617
|
+
try {
|
|
618
|
+
value = JSON.parse(value);
|
|
619
|
+
} catch {
|
|
620
|
+
return input;
|
|
621
|
+
}
|
|
622
622
|
}
|
|
623
|
-
if (!
|
|
624
|
-
|
|
623
|
+
if (!value || typeof value !== "object") return input;
|
|
624
|
+
const obj = value;
|
|
625
|
+
if (obj.auth === void 0 && obj.expected && typeof obj.expected === "object") {
|
|
626
|
+
return { ...record, x402_expected: obj.expected };
|
|
625
627
|
}
|
|
628
|
+
if (value !== record.x402_expected) return { ...record, x402_expected: value };
|
|
629
|
+
return input;
|
|
626
630
|
}
|
|
627
|
-
function
|
|
628
|
-
|
|
629
|
-
const
|
|
630
|
-
if (
|
|
631
|
-
|
|
631
|
+
function coercePaymentRequired(input) {
|
|
632
|
+
if (!input || typeof input !== "object") return input;
|
|
633
|
+
const record = input;
|
|
634
|
+
if (typeof record.payment_required !== "string") return input;
|
|
635
|
+
try {
|
|
636
|
+
return { ...record, payment_required: JSON.parse(record.payment_required) };
|
|
637
|
+
} catch {
|
|
638
|
+
return input;
|
|
632
639
|
}
|
|
633
|
-
|
|
634
|
-
|
|
640
|
+
}
|
|
641
|
+
async function runTool(fn) {
|
|
642
|
+
try {
|
|
643
|
+
return { success: true, data: await fn() };
|
|
644
|
+
} catch (err) {
|
|
645
|
+
return normalizeError(err);
|
|
635
646
|
}
|
|
636
|
-
|
|
637
|
-
|
|
647
|
+
}
|
|
648
|
+
function normalizeError(err) {
|
|
649
|
+
if (err instanceof v3.z.ZodError) {
|
|
650
|
+
return {
|
|
651
|
+
success: false,
|
|
652
|
+
code: "INVALID_INPUT",
|
|
653
|
+
message: err.errors.map((e) => `${e.path.join(".") || "(root)"}: ${e.message}`).join("; "),
|
|
654
|
+
statusCode: 400
|
|
655
|
+
};
|
|
638
656
|
}
|
|
639
|
-
if (
|
|
640
|
-
|
|
657
|
+
if (err instanceof sdk.HavenSigningError) {
|
|
658
|
+
return { success: false, code: err.code, message: err.message };
|
|
641
659
|
}
|
|
642
|
-
if (
|
|
643
|
-
|
|
660
|
+
if (err instanceof sdk.HavenApiError) {
|
|
661
|
+
return { success: false, code: err.code, message: err.message, statusCode: err.statusCode };
|
|
644
662
|
}
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
663
|
+
if (err instanceof sdk.HavenError) {
|
|
664
|
+
return {
|
|
665
|
+
success: false,
|
|
666
|
+
code: err.code,
|
|
667
|
+
message: err.message,
|
|
668
|
+
statusCode: err.statusCode,
|
|
669
|
+
paymentId: err.paymentId,
|
|
670
|
+
...err.code === sdk.AgentPaymentFailureCode.PaymentWindowExpired ? {
|
|
671
|
+
next_action: sdk.AgentPaymentNextAction.PaymentWindowExpired,
|
|
672
|
+
retry_with_new_quote: true,
|
|
673
|
+
suggested_tool: "haven_pay_mcp_tool"
|
|
674
|
+
} : {}
|
|
675
|
+
};
|
|
649
676
|
}
|
|
677
|
+
return {
|
|
678
|
+
success: false,
|
|
679
|
+
code: "UNKNOWN_ERROR",
|
|
680
|
+
message: err instanceof Error ? err.message : String(err)
|
|
681
|
+
};
|
|
650
682
|
}
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
const
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
683
|
+
|
|
684
|
+
// src/consent.ts
|
|
685
|
+
var SIGNER_ACK_ENV = "HAVEN_SIGNER_ACK";
|
|
686
|
+
function computeSignerConsentHash(input) {
|
|
687
|
+
const identity = [
|
|
688
|
+
input.delegateAddress.toLowerCase(),
|
|
689
|
+
(input.safeAddress ?? "").toLowerCase(),
|
|
690
|
+
input.agentId ?? "",
|
|
691
|
+
input.chainId ?? "",
|
|
692
|
+
input.network ?? ""
|
|
693
|
+
].join("|");
|
|
694
|
+
const toolCanonical = [...input.toolNames].sort().join(",");
|
|
695
|
+
return crypto.createHash("sha256").update(`${identity}
|
|
696
|
+
${toolCanonical}`).digest("hex").slice(0, 16);
|
|
660
697
|
}
|
|
661
|
-
function
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
698
|
+
function renderSignerConsentBlock(input, hash) {
|
|
699
|
+
const lines = [
|
|
700
|
+
"",
|
|
701
|
+
"------------------------------------------------------------",
|
|
702
|
+
"Haven edge signer - first-launch consent",
|
|
703
|
+
"------------------------------------------------------------",
|
|
704
|
+
"",
|
|
705
|
+
`Delegate address: ${input.delegateAddress}`
|
|
706
|
+
];
|
|
707
|
+
lines.push(`Haven wallet: ${input.safeAddress ?? "not provided to this signer"}`);
|
|
708
|
+
if (input.agentId) lines.push(`Agent ID: ${input.agentId}`);
|
|
709
|
+
if (typeof input.chainId === "number") lines.push(`Chain ID: ${input.chainId}`);
|
|
710
|
+
if (input.network) lines.push(`Network: ${input.network}`);
|
|
711
|
+
lines.push("");
|
|
712
|
+
lines.push("This local signer holds the delegate key on this machine and signs");
|
|
713
|
+
lines.push("payment payloads or x402 merchant headers for the delegate address above.");
|
|
714
|
+
lines.push("It does not call the Haven API, so it cannot show a live allowance summary.");
|
|
715
|
+
lines.push("On-chain Safe rules remain the real spend gate, and the wallet owner can");
|
|
716
|
+
lines.push("pause or revoke agent authority outside this signer.");
|
|
717
|
+
lines.push("");
|
|
718
|
+
lines.push("Tools this signer will expose to your agent runtime:");
|
|
719
|
+
for (const name of input.toolNames) {
|
|
720
|
+
lines.push(` - ${name}`);
|
|
721
|
+
lines.push(` ${toolDescriptions[name]}`);
|
|
682
722
|
}
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
723
|
+
lines.push("");
|
|
724
|
+
lines.push("A local audit entry is appended for every signing operation. Audit entries");
|
|
725
|
+
lines.push("record timestamp, tool, payload hash, and delegate address; never the key,");
|
|
726
|
+
lines.push("signature, or x402 payment header.");
|
|
727
|
+
lines.push("");
|
|
728
|
+
lines.push(`Consent hash: ${hash}`);
|
|
729
|
+
lines.push("");
|
|
730
|
+
lines.push("To acknowledge, EITHER:");
|
|
731
|
+
lines.push(` - set ${SIGNER_ACK_ENV}=${hash} in this process's environment, OR`);
|
|
732
|
+
lines.push(" - re-run with --ack to write the acknowledgement next to your");
|
|
733
|
+
lines.push(" credential file (sidecar <credentials>.signer-ack.json).");
|
|
734
|
+
lines.push("");
|
|
735
|
+
lines.push("------------------------------------------------------------");
|
|
736
|
+
lines.push("");
|
|
737
|
+
return lines.join("\n");
|
|
738
|
+
}
|
|
739
|
+
async function ensureSignerConsent(input, options = {}) {
|
|
740
|
+
const env = options.env ?? process.env;
|
|
741
|
+
const out = options.out ?? process.stderr;
|
|
742
|
+
const hash = computeSignerConsentHash(input);
|
|
743
|
+
const envAck = env[SIGNER_ACK_ENV];
|
|
744
|
+
if (typeof envAck === "string" && envAck.length > 0) {
|
|
745
|
+
if (envAck === hash) return { ok: true, hash, reason: "env_var_match" };
|
|
746
|
+
out.write(renderSignerConsentBlock(input, hash));
|
|
747
|
+
out.write(
|
|
748
|
+
`${SIGNER_ACK_ENV} was set but did not match the current signer consent hash.
|
|
749
|
+
Expected: ${hash}
|
|
750
|
+
Got: ${envAck}
|
|
751
|
+
Re-acknowledge with the new hash above, or run with --ack.
|
|
752
|
+
|
|
753
|
+
`
|
|
686
754
|
);
|
|
755
|
+
return { ok: false, hash, reason: "env_var_mismatch" };
|
|
687
756
|
}
|
|
688
|
-
const
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
merchantTo: expected.merchantTo,
|
|
693
|
-
amount: expected.amount,
|
|
694
|
-
asset: expected.asset,
|
|
695
|
-
network: expected.network,
|
|
696
|
-
expiresAt: expected.expiresAt,
|
|
697
|
-
typedDataHash: expected.typedDataHash
|
|
698
|
-
});
|
|
699
|
-
const expectedVersion = expected.typedDataHash ? 2 : 1;
|
|
700
|
-
if (expected.auth?.version !== expectedVersion || expected.auth.message !== message) {
|
|
701
|
-
throw new sdk.HavenSigningError("x402 expected context authentication message is invalid.");
|
|
702
|
-
}
|
|
703
|
-
if (!sameAddress(expected.auth.signer, trustedSigner)) {
|
|
704
|
-
throw new sdk.HavenSigningError("x402 expected context was not signed by the configured Haven signer.");
|
|
757
|
+
const ackPath = sidecarPath(options.credentialsPath);
|
|
758
|
+
if (ackPath) {
|
|
759
|
+
const stored = await readAckFile(ackPath);
|
|
760
|
+
if (stored?.ack === hash) return { ok: true, hash, reason: "ack_file_match" };
|
|
705
761
|
}
|
|
706
|
-
if (
|
|
707
|
-
|
|
762
|
+
if (options.writeAck && ackPath) {
|
|
763
|
+
out.write(renderSignerConsentBlock(input, hash));
|
|
764
|
+
await writeAckFile(ackPath, hash);
|
|
765
|
+
out.write(`Wrote acknowledgement to ${ackPath}
|
|
766
|
+
|
|
767
|
+
`);
|
|
768
|
+
return { ok: true, hash, reason: "wrote_ack_file" };
|
|
708
769
|
}
|
|
770
|
+
out.write(renderSignerConsentBlock(input, hash));
|
|
771
|
+
return { ok: false, hash, reason: "no_acknowledgement" };
|
|
709
772
|
}
|
|
710
|
-
function
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
773
|
+
function registeredSignerToolNames() {
|
|
774
|
+
return Object.keys(toolSchemas);
|
|
775
|
+
}
|
|
776
|
+
function sidecarPath(credentialsPath) {
|
|
777
|
+
if (!credentialsPath) return null;
|
|
778
|
+
return path.resolve(`${credentialsPath}.signer-ack.json`);
|
|
779
|
+
}
|
|
780
|
+
async function readAckFile(path) {
|
|
781
|
+
try {
|
|
782
|
+
const raw = await promises.readFile(path, "utf8");
|
|
783
|
+
const parsed = JSON.parse(raw);
|
|
784
|
+
return { ack: typeof parsed.ack === "string" ? parsed.ack : void 0 };
|
|
785
|
+
} catch {
|
|
786
|
+
return null;
|
|
723
787
|
}
|
|
724
788
|
}
|
|
725
|
-
function
|
|
726
|
-
|
|
789
|
+
async function writeAckFile(path$1, hash) {
|
|
790
|
+
await promises.mkdir(path.dirname(path$1), { recursive: true });
|
|
791
|
+
await promises.writeFile(
|
|
792
|
+
path$1,
|
|
793
|
+
JSON.stringify({ ack: hash, at: (/* @__PURE__ */ new Date()).toISOString() }, null, 2),
|
|
794
|
+
"utf8"
|
|
795
|
+
);
|
|
727
796
|
}
|
|
728
797
|
async function loadSignerCredentials(path = process.env.HAVEN_CREDENTIALS) {
|
|
729
798
|
if (path) return loadFromFile(path);
|
|
@@ -815,8 +884,9 @@ async function warnIfCredentialFilePermissive(path, log = (message) => process.s
|
|
|
815
884
|
|
|
816
885
|
// src/server.ts
|
|
817
886
|
var SIGNER_NAME = "@haven_ai/signer";
|
|
818
|
-
var SIGNER_VERSION = "0.1.
|
|
887
|
+
var SIGNER_VERSION = "0.1.19-alpha.0";
|
|
819
888
|
async function resolveSignerRuntime(options = {}) {
|
|
889
|
+
assertSupportedNodeVersion(options.nodeVersion);
|
|
820
890
|
if (options.delegateKey) {
|
|
821
891
|
return {
|
|
822
892
|
signer: createEdgeSigner(options.delegateKey, {
|
|
@@ -833,7 +903,13 @@ async function resolveSignerRuntime(options = {}) {
|
|
|
833
903
|
};
|
|
834
904
|
}
|
|
835
905
|
function buildSignerMcpServer(signer, options = {}) {
|
|
836
|
-
const server = new mcp_js.McpServer(
|
|
906
|
+
const server = new mcp_js.McpServer(
|
|
907
|
+
{ name: SIGNER_NAME, version: SIGNER_VERSION },
|
|
908
|
+
{
|
|
909
|
+
capabilities: signerCapabilityAdvertisement(),
|
|
910
|
+
instructions: signerInstructions()
|
|
911
|
+
}
|
|
912
|
+
);
|
|
837
913
|
const credentialsPath = options.credentials?.sourcePath;
|
|
838
914
|
const handlers = createToolHandlers(signer, {
|
|
839
915
|
audit: {
|
|
@@ -854,6 +930,14 @@ function buildSignerMcpServer(signer, options = {}) {
|
|
|
854
930
|
}
|
|
855
931
|
return server;
|
|
856
932
|
}
|
|
933
|
+
function assertSupportedNodeVersion(nodeVersion = process.versions.node) {
|
|
934
|
+
if (sdk.isSupportedNodeVersion(nodeVersion)) return;
|
|
935
|
+
const err = new Error(
|
|
936
|
+
sdk.unsupportedNodeVersionMessage({ subject: "The Haven signer", nodeVersion })
|
|
937
|
+
);
|
|
938
|
+
err.code = "HAVEN_SIGNER_UNSUPPORTED_NODE";
|
|
939
|
+
throw err;
|
|
940
|
+
}
|
|
857
941
|
async function runSignerStdioServer(options = {}) {
|
|
858
942
|
const { signer, credentials } = await resolveSignerRuntime(options);
|
|
859
943
|
if (!options.skipConsent) {
|