@seedhape/local-wallet 0.2.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/agent-identity.d.ts +45 -0
- package/dist/agent-identity.d.ts.map +1 -0
- package/dist/agent-identity.js +300 -0
- package/dist/agent-identity.js.map +1 -0
- package/dist/hosted.d.ts +82 -0
- package/dist/hosted.d.ts.map +1 -0
- package/dist/hosted.js +180 -0
- package/dist/hosted.js.map +1 -0
- package/dist/identity-backup.d.ts +12 -0
- package/dist/identity-backup.d.ts.map +1 -0
- package/dist/identity-backup.js +130 -0
- package/dist/identity-backup.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +429 -0
- package/dist/index.js.map +1 -0
- package/dist/keychain.d.ts +30 -0
- package/dist/keychain.d.ts.map +1 -0
- package/dist/keychain.js +99 -0
- package/dist/keychain.js.map +1 -0
- package/dist/seedhape-identity.d.ts +87 -0
- package/dist/seedhape-identity.d.ts.map +1 -0
- package/dist/seedhape-identity.js +110 -0
- package/dist/seedhape-identity.js.map +1 -0
- package/dist/server.d.ts +43 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +160 -0
- package/dist/server.js.map +1 -0
- package/dist/web-bot-auth.d.ts +37 -0
- package/dist/web-bot-auth.d.ts.map +1 -0
- package/dist/web-bot-auth.js +87 -0
- package/dist/web-bot-auth.js.map +1 -0
- package/package.json +20 -0
- package/src/agent-identity.ts +293 -0
- package/src/hosted.ts +174 -0
- package/src/identity-backup.ts +134 -0
- package/src/index.ts +374 -0
- package/src/keychain.ts +114 -0
- package/src/seedhape-identity.ts +113 -0
- package/src/server.ts +187 -0
- package/src/web-bot-auth.ts +94 -0
- package/test/identity-backup.test.mjs +90 -0
- package/test/identity-fork.test.mjs +128 -0
- package/test/pay-flow.test.mjs +139 -0
- package/test/setup-flow.test.mjs +47 -0
- package/test/web-bot-auth.test.mjs +134 -0
- package/tsconfig.json +15 -0
package/src/server.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local MCP server for a self-custody agent wallet.
|
|
3
|
+
*
|
|
4
|
+
* The key never leaves the OS keychain, and this server is the only thing that
|
|
5
|
+
* signs with it. It does not keep its own rules: every payment is checked and
|
|
6
|
+
* reserved by the user's Seedhape account first (pause switch, spending rules,
|
|
7
|
+
* budget), and the outcome is reported back so it appears in their activity.
|
|
8
|
+
* If Seedhape can't be reached, nothing is paid.
|
|
9
|
+
*
|
|
10
|
+
* Everything else (finding services, budgets, rules, history, funding) lives in
|
|
11
|
+
* the hosted Seedhape MCP, which the agent should also have connected.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
15
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
16
|
+
import { createPublicClient, erc20Abi, http, type Address as ViemAddress } from "viem";
|
|
17
|
+
import { base, baseSepolia } from "viem/chains";
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
import { PaymentDeclinedError, X402Client, type AssetRef, type Eip712Signer, type OutboundRequestPolicy, type SettlementResponse } from "@seedhape/core";
|
|
20
|
+
import { SeedhapeAuthError, SeedhapeUnreachableError, authorizePayment, seedhapeUrl, settlePayment, whoAmI, type HostedRefusal } from "./hosted.js";
|
|
21
|
+
import type { StoredWebBotAuthKey } from "./keychain.js";
|
|
22
|
+
import { webBotAuthFetch, type RequestSigner } from "./web-bot-auth.js";
|
|
23
|
+
|
|
24
|
+
export interface LocalWalletDeps {
|
|
25
|
+
agentId: string;
|
|
26
|
+
address: `0x${string}` | null;
|
|
27
|
+
signer: Eip712Signer | null;
|
|
28
|
+
asset: AssetRef;
|
|
29
|
+
absoluteMaxPerPayment: bigint;
|
|
30
|
+
/** Signs every outgoing HTTPS request so merchants can identify this agent (Web Bot Auth). */
|
|
31
|
+
webBotAuth?: StoredWebBotAuthKey | null;
|
|
32
|
+
/** On-chain (ERC-8004) identity; preferred over webBotAuth when present. */
|
|
33
|
+
identity?: { identityRef: string; signatureAgent: string; keyId: string; signRequest: RequestSigner; refreshDirectory(): Promise<void> } | null;
|
|
34
|
+
/** Tests only: allow a merchant on a private/loopback address. */
|
|
35
|
+
outboundPolicy?: OutboundRequestPolicy;
|
|
36
|
+
/** Tests only: replace the network. */
|
|
37
|
+
fetchImpl?: typeof fetch;
|
|
38
|
+
/** First-use browser setup; returns credentials to install into this live server. */
|
|
39
|
+
finishSetup?: () => Promise<Pick<LocalWalletDeps, "address" | "signer" | "webBotAuth" | "identity">>;
|
|
40
|
+
setupNeeded?: boolean;
|
|
41
|
+
onSetupError?: (error: unknown) => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const text = (value: string) => ({ content: [{ type: "text" as const, text: value }] });
|
|
45
|
+
const usdc = (units: bigint | string, decimals: number) => (Number(units) / 10 ** decimals).toFixed(Math.min(decimals, 6));
|
|
46
|
+
|
|
47
|
+
function refusalText(refusal: Pick<HostedRefusal, "code" | "error" | "hint" | "recovery" | "approvalId">) {
|
|
48
|
+
// Waiting for the user: the reason already says how to finish once they approve.
|
|
49
|
+
if (refusal.approvalId) return `PAYMENT NOT MADE.\n\nReason: ${refusal.error}`;
|
|
50
|
+
return `PAYMENT NOT MADE.\n\nReason: ${refusal.error}\n\nError code: ${refusal.code}\nNext step: ${refusal.recovery?.tool ? `call ${refusal.recovery.tool} on the hosted Seedhape MCP. ` : ""}${refusal.hint}\n\nDo not repeat this exact call.`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function failureText(error: unknown) {
|
|
54
|
+
if (error instanceof SeedhapeAuthError || error instanceof SeedhapeUnreachableError) return `PAYMENT NOT MADE.\n\nReason: ${error.message}`;
|
|
55
|
+
return `PAYMENT NOT MADE.\n\nReason: ${error instanceof Error ? error.message : String(error)}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createServer(deps: LocalWalletDeps): McpServer {
|
|
59
|
+
const server = new McpServer({ name: "seedhape-local-wallet", version: "0.2.0" });
|
|
60
|
+
const { asset } = deps;
|
|
61
|
+
let setupInFlight: Promise<void> | undefined;
|
|
62
|
+
|
|
63
|
+
const ensureSetup = async () => {
|
|
64
|
+
if (!deps.setupNeeded || !deps.finishSetup) return false;
|
|
65
|
+
if (!setupInFlight) {
|
|
66
|
+
setupInFlight = deps.finishSetup().then((ready) => {
|
|
67
|
+
Object.assign(deps, ready);
|
|
68
|
+
deps.setupNeeded = false;
|
|
69
|
+
}).catch((error) => {
|
|
70
|
+
deps.onSetupError?.(error);
|
|
71
|
+
}).finally(() => { setupInFlight = undefined; });
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const client = (hooks: Pick<ConstructorParameters<typeof X402Client>[0], "authorise" | "onSettlement">) => new X402Client({
|
|
77
|
+
...(deps.signer ? { signer: deps.signer } : {}),
|
|
78
|
+
allowedNetworks: [asset.network],
|
|
79
|
+
allowedAssets: [asset.address],
|
|
80
|
+
absoluteMaxPerPayment: deps.absoluteMaxPerPayment,
|
|
81
|
+
...(deps.outboundPolicy ? { outboundPolicy: deps.outboundPolicy } : {}),
|
|
82
|
+
fetchImpl: webBotAuthFetch(deps.fetchImpl ?? fetch, deps.identity?.signRequest ?? deps.webBotAuth ?? null),
|
|
83
|
+
...hooks,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
server.tool(
|
|
87
|
+
"wallet_status",
|
|
88
|
+
"Show this computer's self-custody agent wallet: its agent id, address, USDC balance, and whether it is signed in to Seedhape. Read-only. To add funds or change spending rules, use the hosted Seedhape MCP or the Seedhape website.",
|
|
89
|
+
{},
|
|
90
|
+
async () => {
|
|
91
|
+
if (await ensureSetup()) return text("Finishing setup in your browser. When it’s done, try again.");
|
|
92
|
+
if (!deps.address) return text(`No local wallet exists for agent '${deps.agentId}'. Run \`seedhape setup\` in a terminal.`);
|
|
93
|
+
let balance = "unknown";
|
|
94
|
+
try {
|
|
95
|
+
const chain = asset.network === "eip155:8453" ? base : baseSepolia;
|
|
96
|
+
const raw = await createPublicClient({ chain, transport: http(process.env.SEEDHAPE_RPC_URL) }).readContract({ address: asset.address as ViemAddress, abi: erc20Abi, functionName: "balanceOf", args: [deps.address] });
|
|
97
|
+
balance = `${usdc(raw, asset.decimals)} ${asset.symbol}`;
|
|
98
|
+
} catch { /* balance stays unknown; the hosted wallet_balance tool can also read it */ }
|
|
99
|
+
const account = await whoAmI();
|
|
100
|
+
return text([
|
|
101
|
+
`Agent wallet: ${deps.agentId}`,
|
|
102
|
+
`Address: ${deps.address}`,
|
|
103
|
+
"Payment network: configured",
|
|
104
|
+
`Balance: ${balance}`,
|
|
105
|
+
`Seedhape account (${seedhapeUrl()}): ${account.detail}`,
|
|
106
|
+
`Agent identity: ${deps.identity ? `verified (${deps.identity.identityRef})` : deps.webBotAuth ? "registered signing key" : "not set up; run `seedhape setup`"}`,
|
|
107
|
+
`Key storage: operating system keychain (never leaves this computer)`,
|
|
108
|
+
].join("\n"));
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
server.tool(
|
|
113
|
+
"quote",
|
|
114
|
+
"Get the price of a paid URL without paying: amount, currency, and seller. Always quote first and show the user the price before calling pay.",
|
|
115
|
+
{ url: z.string().url(), method: z.enum(["GET", "POST"]).optional(), body: z.string().optional() },
|
|
116
|
+
async ({ url, method, body }) => {
|
|
117
|
+
if (await ensureSetup()) return text("Finishing setup in your browser. When it’s done, try again.");
|
|
118
|
+
const probe = await client({ authorise: async () => ({ approved: false, reason: "quote only" }) }).probe(url, { method: method ?? "GET", ...(body ? { body, headers: { "content-type": "application/json" } } : {}) });
|
|
119
|
+
if (!probe.challenge) return text(`${url} does not ask for payment (HTTP ${probe.response.status}).`);
|
|
120
|
+
const offer = probe.challenge.accepts.find((item) => item.network === asset.network && item.asset.toLowerCase() === asset.address.toLowerCase());
|
|
121
|
+
if (!offer) return text(`${url} asks for payment in a currency this wallet does not use. Ask the user to choose another wallet or payment option.`);
|
|
122
|
+
return text(`Price: ${usdc(offer.maxAmountRequired, asset.decimals)} ${asset.symbol}\nSeller: ${offer.payTo}\nResource: ${offer.resource || url}${offer.description ? `\nDescription: ${offer.description}` : ""}`);
|
|
123
|
+
},
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
server.tool(
|
|
127
|
+
"pay",
|
|
128
|
+
"Pay for a URL with this computer's self-custody wallet. Before signing, Seedhape checks the user's pause switch, spending limit, and budget and reserves the amount; if Seedhape refuses or can't be reached, nothing is paid. If the price is above what the user approves automatically, nothing is paid and the response has an approval link: show it to the user, and once they approve call pay again with the same url and the approval_id. On any other refusal the response names the next step; follow it instead of retrying. Set confirm_unusual only when the user explicitly approved a payment Seedhape flagged as unusual in this conversation.",
|
|
129
|
+
{
|
|
130
|
+
url: z.string().url(),
|
|
131
|
+
method: z.enum(["GET", "POST"]).optional(),
|
|
132
|
+
body: z.string().optional(),
|
|
133
|
+
confirm_unusual: z.boolean().optional(),
|
|
134
|
+
purchase_id: z.string().regex(/^[A-Za-z0-9_-]{10,80}$/).optional().describe("Stable id for this purchase so a retry never pays twice."),
|
|
135
|
+
approval_id: z.string().regex(/^apr_[A-Za-z0-9_-]{10,40}$/).optional().describe("From an earlier response that needed the user's approval. Pass it with the same url once the user has approved."),
|
|
136
|
+
},
|
|
137
|
+
async ({ url, method, body, confirm_unusual, purchase_id, approval_id }) => {
|
|
138
|
+
if (await ensureSetup()) return text("Finishing setup in your browser. When it’s done, try again.");
|
|
139
|
+
if (!deps.signer || !deps.address) return text(`PAYMENT NOT MADE.\n\nReason: No local wallet exists for agent '${deps.agentId}'. Run \`seedhape setup\` in a terminal.`);
|
|
140
|
+
const purchaseId = purchase_id ?? `lw_${randomUUID().replace(/-/g, "")}`;
|
|
141
|
+
// Merchants may look up this agent's key while verifying the request; make sure its directory is fresh.
|
|
142
|
+
await deps.identity?.refreshDirectory().catch(() => undefined);
|
|
143
|
+
let authorizationId: string | undefined;
|
|
144
|
+
let refusal: HostedRefusal | undefined;
|
|
145
|
+
let settled: { success: boolean; tx?: string } | undefined;
|
|
146
|
+
|
|
147
|
+
const x402 = client({
|
|
148
|
+
// Seedhape decides; this wallet only signs after an approval.
|
|
149
|
+
authorise: async ({ requirement }) => {
|
|
150
|
+
const decision = await authorizePayment({ agentId: deps.agentId, url, requirement: { ...requirement, maxAmountRequired: String(requirement.maxAmountRequired) }, purchaseId, method: method ?? "GET", ...(body ? { bodySha256: createHash("sha256").update(body).digest("base64url") } : {}), ...(approval_id ? { approvalId: approval_id } : {}), ...(confirm_unusual ? { confirmUnusual: true } : {}) });
|
|
151
|
+
if (!decision.ok) { refusal = decision; return { approved: false, reason: decision.error }; }
|
|
152
|
+
authorizationId = decision.authorizationId;
|
|
153
|
+
return { approved: true, spendId: decision.authorizationId };
|
|
154
|
+
},
|
|
155
|
+
onSettlement: async (_spendId, settlement: SettlementResponse | null, httpStatus: number) => {
|
|
156
|
+
settled = { success: Boolean(settlement?.success) && httpStatus < 400, ...(settlement?.transaction ? { tx: settlement.transaction } : {}) };
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const result = await x402.fetch(url, { method: method ?? "GET", ...(body ? { body, headers: { "content-type": "application/json" } } : {}) });
|
|
162
|
+
if (!result.paid) return text(`No payment was required.\n\n---\n\n${await result.response.text()}`);
|
|
163
|
+
const outcome = settled ?? { success: result.response.ok };
|
|
164
|
+
if (authorizationId) await settlePayment({ authorizationId, status: outcome.success ? "settled" : "failed", ...(outcome.tx ? { txHash: outcome.tx } : {}), ...(outcome.success ? {} : { error: `Merchant responded HTTP ${result.response.status}` }) }).catch(() => undefined);
|
|
165
|
+
const responseBody = await result.response.text();
|
|
166
|
+
if (!outcome.success) return text(`PAYMENT NOT COMPLETED.\n\nReason: the merchant responded HTTP ${result.response.status}. Your budget reservation was released.\nLedger reference: ${authorizationId}\n\n${responseBody.slice(0, 2000)}`);
|
|
167
|
+
return text(`Paid ${usdc(result.amount ?? 0n, asset.decimals)} ${asset.symbol}${outcome.tx ? `, tx ${outcome.tx}` : ""}\nLedger reference: ${authorizationId}\n\n---\n\n${responseBody}`);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (error instanceof SeedhapeAuthError) {
|
|
170
|
+
deps.setupNeeded = true;
|
|
171
|
+
if (await ensureSetup()) return text("Finishing setup in your browser. When it’s done, try again.");
|
|
172
|
+
}
|
|
173
|
+
if (refusal) return text(refusalText(refusal));
|
|
174
|
+
if (!authorizationId) return text(failureText(error));
|
|
175
|
+
// Declined before anything was signed or sent: safe to release the reservation.
|
|
176
|
+
if (error instanceof PaymentDeclinedError) {
|
|
177
|
+
await settlePayment({ authorizationId, status: "failed", error: error.message }).catch(() => undefined);
|
|
178
|
+
return text(failureText(error));
|
|
179
|
+
}
|
|
180
|
+
// The payment may have been sent. Leave it pending so it is reconciled, never paid twice.
|
|
181
|
+
return text(`PAYMENT OUTCOME UNKNOWN.\n\nReason: ${error instanceof Error ? error.message : String(error)}\nLedger reference: ${authorizationId}\n\nThe payment may have gone through. Do not pay again; check spend_history or purchase_receipt on the hosted Seedhape MCP.`);
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
return server;
|
|
187
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Bot Auth for a self-custody agent: an Ed25519 key, kept in the OS keychain,
|
|
3
|
+
* that signs outgoing HTTPS requests so merchants (for example UCP and ACP
|
|
4
|
+
* checkouts) can tell which agent is calling. Seedhape only ever receives the
|
|
5
|
+
* public key. Registration, rotation, and revocation go through the hosted
|
|
6
|
+
* Seedhape MCP, so the user never handles key files, thumbprints, or challenges.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, sign } from "node:crypto";
|
|
10
|
+
import { signWebBotAuth } from "@seedhape/core";
|
|
11
|
+
import type { StoredWebBotAuthKey } from "./keychain.js";
|
|
12
|
+
|
|
13
|
+
export type CallTool = (name: string, args: Record<string, unknown>) => Promise<string>;
|
|
14
|
+
|
|
15
|
+
/** RFC 7638 thumbprint; Seedhape requires it as the key id. */
|
|
16
|
+
export function ed25519Thumbprint(jwk: { crv: string; kty: string; x: string }): string {
|
|
17
|
+
return createHash("sha256").update(JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x })).digest("base64url");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The Signature-Agent URL identifying this agent. It lives under the user's
|
|
22
|
+
* Seedhape URL with a random suffix, so two agents with the same name never collide.
|
|
23
|
+
*/
|
|
24
|
+
export function defaultSignatureAgent(seedhapeUrl: string, agentId: string): string {
|
|
25
|
+
return new URL(`/agents/${encodeURIComponent(agentId)}-${randomBytes(4).toString("hex")}`, seedhapeUrl).toString();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function generateWebBotAuthKey(signatureAgent: string): StoredWebBotAuthKey {
|
|
29
|
+
const agent = new URL(signatureAgent);
|
|
30
|
+
if (agent.protocol !== "https:") throw new Error("The Web Bot Auth signature agent must be an HTTPS URL.");
|
|
31
|
+
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
|
|
32
|
+
const jwk = publicKey.export({ format: "jwk" }) as { kty: string; crv: string; x: string };
|
|
33
|
+
const publicJwk = { kty: "OKP" as const, crv: "Ed25519" as const, x: jwk.x };
|
|
34
|
+
return {
|
|
35
|
+
privateKeyPem: privateKey.export({ format: "pem", type: "pkcs8" }).toString(),
|
|
36
|
+
publicJwk,
|
|
37
|
+
keyId: ed25519Thumbprint(publicJwk),
|
|
38
|
+
signatureAgent: agent.toString(),
|
|
39
|
+
createdAt: Math.floor(Date.now() / 1000),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Prove control of the key over Seedhape's one-time challenge. */
|
|
44
|
+
export function signChallenge(key: StoredWebBotAuthKey, challenge: string): string {
|
|
45
|
+
return sign(null, Buffer.from(challenge), createPrivateKey(key.privateKeyPem)).toString("base64url");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parse(textResult: string): Record<string, unknown> {
|
|
49
|
+
try { return JSON.parse(textResult) as Record<string, unknown>; } catch { return { error: textResult }; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Register (or, when `rotate` is set, register as a replacement) this key with the user's Seedhape account. */
|
|
53
|
+
export async function registerWebBotAuthKey(callTool: CallTool, agentId: string, key: StoredWebBotAuthKey, rotate = false): Promise<void> {
|
|
54
|
+
const challenge = parse(await callTool("web_bot_auth_challenge", { agent_id: agentId, signature_agent: key.signatureAgent, key_id: key.keyId }));
|
|
55
|
+
if (typeof challenge.challenge !== "string") throw new Error(String(challenge.error ?? (challenge as { message?: string }).message ?? "Seedhape did not return a Web Bot Auth challenge."));
|
|
56
|
+
const result = parse(await callTool(rotate ? "rotate_web_bot_auth_key" : "register_web_bot_auth_key", {
|
|
57
|
+
agent_id: agentId, signature_agent: key.signatureAgent, key_id: key.keyId, public_jwk: key.publicJwk,
|
|
58
|
+
challenge: challenge.challenge, signature: signChallenge(key, challenge.challenge),
|
|
59
|
+
}));
|
|
60
|
+
if (result.registered !== true) throw new Error(String(result.error ?? "Seedhape did not register the Web Bot Auth key."));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function revokeWebBotAuthKey(callTool: CallTool, key: StoredWebBotAuthKey): Promise<void> {
|
|
64
|
+
const result = await callTool("revoke_web_bot_auth_key", { signature_agent: key.signatureAgent, key_id: key.keyId });
|
|
65
|
+
if (!/revoked\.?$/i.test(result.trim()) || /not revoked/i.test(result)) throw new Error(result || "Seedhape did not revoke the key.");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Wrap fetch so every outgoing HTTPS request carries a fresh Web Bot Auth
|
|
70
|
+
* signature. Each request gets its own nonce: merchants reject a reused one, and
|
|
71
|
+
* a paid purchase makes two requests (the price probe and the paid retry).
|
|
72
|
+
* Plain-HTTP URLs are sent unsigned, since the signature covers the HTTPS authority.
|
|
73
|
+
*/
|
|
74
|
+
export type RequestSigner = (url: string) => Promise<Record<string, string | undefined>>;
|
|
75
|
+
|
|
76
|
+
/** Per-request signer for a keychain Web Bot Auth key. */
|
|
77
|
+
export function keychainSigner(key: StoredWebBotAuthKey): RequestSigner {
|
|
78
|
+
const privateKey = createPrivateKey(key.privateKeyPem);
|
|
79
|
+
return async (url) => signWebBotAuth({ url, signatureAgent: key.signatureAgent, keyId: key.keyId, privateKey });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function webBotAuthFetch(base: typeof fetch, signerOrKey: RequestSigner | StoredWebBotAuthKey | null): typeof fetch {
|
|
83
|
+
if (!signerOrKey) return base;
|
|
84
|
+
const signRequest = typeof signerOrKey === "function" ? signerOrKey : keychainSigner(signerOrKey);
|
|
85
|
+
return (async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
|
86
|
+
const request = input instanceof Request ? input : null;
|
|
87
|
+
const url = request ? request.url : input.toString();
|
|
88
|
+
if (!url.startsWith("https://")) return base(input, init);
|
|
89
|
+
const signed = await signRequest(url);
|
|
90
|
+
const headers = new Headers(init?.headers ?? request?.headers);
|
|
91
|
+
for (const [name, value] of Object.entries(signed)) if (value) headers.set(name, value);
|
|
92
|
+
return base(input, { ...init, headers });
|
|
93
|
+
}) as typeof fetch;
|
|
94
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
4
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
|
|
8
|
+
import { FileAgentOperationalKeyStore } from "@seedhape/core";
|
|
9
|
+
import { restoreSelfCustodiedBackupPayload } from "../dist/agent-identity.js";
|
|
10
|
+
import { decryptIdentityBackup, encryptIdentityBackup } from "../dist/identity-backup.js";
|
|
11
|
+
|
|
12
|
+
function fixture() {
|
|
13
|
+
const controllerPrivateKey = generatePrivateKey();
|
|
14
|
+
const controller = privateKeyToAccount(controllerPrivateKey).address;
|
|
15
|
+
const identityRef = "eip155:84532:0x1111111111111111111111111111111111111111/7";
|
|
16
|
+
const identityHash = createHash("sha256").update(identityRef).digest("hex");
|
|
17
|
+
const keyStoreRef = "a".repeat(64);
|
|
18
|
+
const stateFile = `${createHash("sha256").update("local-primary").digest("hex")}.state.json`;
|
|
19
|
+
const encode = (value) => Buffer.from(typeof value === "string" ? value : JSON.stringify(value)).toString("base64url");
|
|
20
|
+
return {
|
|
21
|
+
version: 1,
|
|
22
|
+
accountId: "local-primary",
|
|
23
|
+
controllerPrivateKey,
|
|
24
|
+
keyStoreMasterKey: Buffer.alloc(32, 2).toString("base64url"),
|
|
25
|
+
files: {
|
|
26
|
+
[`state/${stateFile}`]: encode({ stage: "ready", controller, identityRef, keyStoreRef, keyId: "ed25519-test" }),
|
|
27
|
+
[`keys/${keyStoreRef}.json`]: encode("encrypted key fixture"),
|
|
28
|
+
[`metadata/${identityHash}.directory.json`]: encode({ identityRef, keyId: "ed25519-test" }),
|
|
29
|
+
[`metadata/${identityHash}.registration.json`]: encode({ services: [{ name: "Web Bot Auth", keyId: "ed25519-test" }] }),
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test("encrypts and round-trips all identity backup contents only with the passphrase", () => {
|
|
35
|
+
const payload = fixture();
|
|
36
|
+
const encoded = encryptIdentityBackup(payload, "correct horse battery staple");
|
|
37
|
+
assert.notEqual(encoded.includes(payload.controllerPrivateKey), true);
|
|
38
|
+
assert.deepEqual(decryptIdentityBackup(encoded, "correct horse battery staple"), payload);
|
|
39
|
+
assert.throws(() => decryptIdentityBackup(encoded, "incorrect passphrase"), /Could not decrypt identity backup/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("restores a full local identity tree atomically without overwriting an existing one", async () => {
|
|
43
|
+
const temporary = await mkdtemp(join(tmpdir(), "seedhape-identity-backup-test-"));
|
|
44
|
+
try {
|
|
45
|
+
const source = join(temporary, "source");
|
|
46
|
+
const destination = join(temporary, "restored");
|
|
47
|
+
const controllerPrivateKey = generatePrivateKey();
|
|
48
|
+
const controller = privateKeyToAccount(controllerPrivateKey).address;
|
|
49
|
+
const masterKey = randomBytes(32);
|
|
50
|
+
const keyStore = new FileAgentOperationalKeyStore(join(source, "keys"), masterKey);
|
|
51
|
+
const key = await keyStore.createOrGetEd25519("local-primary");
|
|
52
|
+
const identityRef = "eip155:84532:0x1111111111111111111111111111111111111111/9";
|
|
53
|
+
const identityHash = createHash("sha256").update(identityRef).digest("hex");
|
|
54
|
+
const stateHash = createHash("sha256").update("local-primary").digest("hex");
|
|
55
|
+
const state = { stage: "ready", controller, identityRef, keyStoreRef: key.keyRef, keyId: key.keyId };
|
|
56
|
+
const metadataDirectory = { identityRef, keyId: key.keyId, keyStoreRef: key.keyRef, publicJwk: key.publicJwk };
|
|
57
|
+
const registration = { services: [{ name: "Web Bot Auth", keyId: key.keyId }] };
|
|
58
|
+
const files = {
|
|
59
|
+
[`state/${stateHash}.state.json`]: Buffer.from(JSON.stringify(state)).toString("base64url"),
|
|
60
|
+
[`keys/${key.keyRef}.json`]: (await readFile(join(source, "keys", `${key.keyRef}.json`))).toString("base64url"),
|
|
61
|
+
[`metadata/${identityHash}.directory.json`]: Buffer.from(JSON.stringify(metadataDirectory)).toString("base64url"),
|
|
62
|
+
[`metadata/${identityHash}.registration.json`]: Buffer.from(JSON.stringify(registration)).toString("base64url"),
|
|
63
|
+
};
|
|
64
|
+
const payload = { version: 1, accountId: "local-primary", controllerPrivateKey, keyStoreMasterKey: masterKey.toString("base64url"), files };
|
|
65
|
+
const restored = await restoreSelfCustodiedBackupPayload(destination, payload, async () => undefined);
|
|
66
|
+
assert.deepEqual(restored, { controller, identityRef });
|
|
67
|
+
const restoredFiles = new FileAgentOperationalKeyStore(join(destination, "agent-identity", "keys"), masterKey);
|
|
68
|
+
const signer = await restoredFiles.getSigner(key.keyRef);
|
|
69
|
+
assert.equal(typeof signer.signRequest, "function");
|
|
70
|
+
await assert.rejects(() => restoreSelfCustodiedBackupPayload(destination, payload, async () => undefined), /already exists/);
|
|
71
|
+
} finally {
|
|
72
|
+
await rm(temporary, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("authenticates the encrypted bundle and rejects modified ciphertext", () => {
|
|
77
|
+
const encoded = encryptIdentityBackup(fixture(), "a sufficiently long backup phrase");
|
|
78
|
+
const parsed = JSON.parse(encoded);
|
|
79
|
+
parsed.ciphertext = `${parsed.ciphertext.slice(0, -1)}${parsed.ciphertext.endsWith("A") ? "B" : "A"}`;
|
|
80
|
+
assert.throws(() => decryptIdentityBackup(JSON.stringify(parsed), "a sufficiently long backup phrase"), /Could not decrypt identity backup/);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("rejects unsafe backup paths and incomplete identity backups", () => {
|
|
84
|
+
const payload = fixture();
|
|
85
|
+
payload.files["../outside"] = Buffer.from("no").toString("base64url");
|
|
86
|
+
assert.throws(() => encryptIdentityBackup(payload, "a sufficiently long backup phrase"), /invalid path/);
|
|
87
|
+
const incomplete = fixture();
|
|
88
|
+
delete incomplete.files[Object.keys(incomplete.files).find((path) => path.endsWith(".directory.json"))];
|
|
89
|
+
assert.throws(() => encryptIdentityBackup(incomplete, "a sufficiently long backup phrase"), /incomplete|all required/);
|
|
90
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Opt-in end-to-end test of the self-custody on-chain identity against a local
|
|
2
|
+
// fork of Base mainnet (the deployed ERC-8004 registry and AgentTrust contract).
|
|
3
|
+
// Run with: SEEDHAPE_FORK_TEST=1 node --test test/identity-fork.test.mjs
|
|
4
|
+
// Needs `anvil` (Foundry) on PATH and network access to https://mainnet.base.org.
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { createHash, createPublicKey } from "node:crypto";
|
|
9
|
+
import { createServer } from "node:http";
|
|
10
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { createPublicClient, http, parseAbi, verifyMessage } from "viem";
|
|
14
|
+
import { base } from "viem/chains";
|
|
15
|
+
import keytar from "keytar";
|
|
16
|
+
import { parseAgentIdentityRef, resolveAgentWebBotAuthKey, verifyWebBotAuth, verifyWebBotAuthDirectory } from "@seedhape/core";
|
|
17
|
+
|
|
18
|
+
const REGISTRY = "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432";
|
|
19
|
+
const TRUST = "0xa885daddd9cd398e39b0d34c941faf87761788e0";
|
|
20
|
+
const ORIGIN = "https://seedhape.test";
|
|
21
|
+
const enabled = process.env.SEEDHAPE_FORK_TEST === "1";
|
|
22
|
+
|
|
23
|
+
async function startAnvil(port) {
|
|
24
|
+
const anvil = spawn("anvil", ["--fork-url", process.env.SEEDHAPE_FORK_RPC ?? "https://mainnet.base.org", "--port", String(port), "--silent"], { stdio: "ignore" });
|
|
25
|
+
const rpcUrl = `http://127.0.0.1:${port}`;
|
|
26
|
+
for (let attempt = 0; attempt < 60; attempt++) {
|
|
27
|
+
try {
|
|
28
|
+
const response = await fetch(rpcUrl, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_chainId", params: [] }) });
|
|
29
|
+
if (response.ok) return { anvil, rpcUrl };
|
|
30
|
+
} catch { /* not up yet */ }
|
|
31
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
32
|
+
}
|
|
33
|
+
anvil.kill();
|
|
34
|
+
throw new Error("anvil did not start");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const rpc = (rpcUrl, method, params) => fetch(rpcUrl, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }) }).then((response) => response.json());
|
|
38
|
+
|
|
39
|
+
/** A fake Seedhape applying the same rules as the hosted /api/local-wallet/identity/* endpoints. */
|
|
40
|
+
async function fakeSeedhape(rpcUrl) {
|
|
41
|
+
const rows = new Map(); // metadata id -> { keyId, publicJwk, signature, signatureInput, expires, registration }
|
|
42
|
+
const calls = [];
|
|
43
|
+
const client = createPublicClient({ chain: base, transport: http(rpcUrl) });
|
|
44
|
+
const idFor = (ref) => createHash("sha256").update(ref).digest("hex");
|
|
45
|
+
const server = createServer(async (request, response) => {
|
|
46
|
+
let raw = ""; for await (const chunk of request) raw += chunk;
|
|
47
|
+
const body = raw ? JSON.parse(raw) : {};
|
|
48
|
+
const send = (status, value) => response.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(value));
|
|
49
|
+
calls.push(`${request.method} ${request.url}`);
|
|
50
|
+
if (request.headers.authorization !== "Bearer fork-token") return send(401, {});
|
|
51
|
+
if (request.url === "/api/local-wallet/identity") return send(200, { enabled: true, network: "base", chainId: 8453, identityRegistry: REGISTRY, trustContract: TRUST, rpcUrl, signatureAgent: ORIGIN, minimumControllerBalanceWei: "2000000000000000", gasSponsored: true });
|
|
52
|
+
if (request.url === "/api/local-wallet/identity/sponsor") {
|
|
53
|
+
await rpc(rpcUrl, "anvil_setBalance", [body.controller, "0x2386F26FC10000"]); // 0.01 ETH, standing in for the sponsor's top-up
|
|
54
|
+
return send(200, { outcome: "funded" });
|
|
55
|
+
}
|
|
56
|
+
if (request.url === "/api/local-wallet/identity/directory") {
|
|
57
|
+
const directoryUrl = `${ORIGIN}/.well-known/seedhape-agents/${idFor(body.identityRef)}/jwks.json`;
|
|
58
|
+
if (!verifyWebBotAuthDirectory({ url: directoryUrl, headers: { Signature: body.signature, "Signature-Input": body.signatureInput }, publicKey: body.publicJwk, expectedKeyId: body.keyId })) return send(409, { error: "bad directory signature" });
|
|
59
|
+
const existing = rows.get(idFor(body.identityRef));
|
|
60
|
+
if (!existing || existing.keyId !== body.keyId) {
|
|
61
|
+
const { agentId } = parseAgentIdentityRef(body.identityRef);
|
|
62
|
+
const owner = await client.readContract({ address: REGISTRY, abi: parseAbi(["function ownerOf(uint256) view returns (address)"]), functionName: "ownerOf", args: [agentId] });
|
|
63
|
+
const message = `Seedhape self-custody agent metadata\nidentity=${body.identityRef}\nkey=${body.keyId}`;
|
|
64
|
+
if (!body.ownerSignature || !(await verifyMessage({ address: owner, message, signature: body.ownerSignature }))) return send(409, { error: "owner signature invalid" });
|
|
65
|
+
}
|
|
66
|
+
rows.set(idFor(body.identityRef), { ...existing, keyId: body.keyId, publicJwk: body.publicJwk, signature: body.signature, signatureInput: body.signatureInput });
|
|
67
|
+
return send(200, { signatureAgent: ORIGIN, directoryUri: directoryUrl });
|
|
68
|
+
}
|
|
69
|
+
if (request.url === "/api/local-wallet/identity/registration") {
|
|
70
|
+
const row = rows.get(idFor(body.identityRef));
|
|
71
|
+
if (!row) return send(409, { error: "directory first" });
|
|
72
|
+
row.registration = body.registration;
|
|
73
|
+
return send(200, { registrationUri: `${ORIGIN}/.well-known/seedhape-agents/${idFor(body.identityRef)}/registration.json` });
|
|
74
|
+
}
|
|
75
|
+
send(404, {});
|
|
76
|
+
});
|
|
77
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
78
|
+
// What merchants fetch over HTTPS from Seedhape's public origin.
|
|
79
|
+
const publicFetch = async (input) => {
|
|
80
|
+
const url = new URL(String(input));
|
|
81
|
+
const match = /^\/\.well-known\/seedhape-agents\/([0-9a-f]{64})\/(jwks|registration)\.json$/.exec(url.pathname);
|
|
82
|
+
const row = match && rows.get(match[1]);
|
|
83
|
+
if (!row) return new Response("not found", { status: 404 });
|
|
84
|
+
if (match[2] === "registration") return new Response(JSON.stringify(row.registration), { headers: { "content-type": "application/json" } });
|
|
85
|
+
return new Response(JSON.stringify({ keys: [{ ...row.publicJwk, kid: row.keyId, use: "sig", alg: "EdDSA" }] }), { headers: { "content-type": "application/jwk-set+json", Signature: row.signature, "Signature-Input": row.signatureInput } });
|
|
86
|
+
};
|
|
87
|
+
return { server, url: `http://127.0.0.1:${server.address().port}`, publicFetch, calls, rows };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
test("setup creates an on-chain identity that merchants can verify, with nothing for the user to configure", { skip: !enabled && "set SEEDHAPE_FORK_TEST=1 (needs anvil + network)", timeout: 600_000 }, async () => {
|
|
91
|
+
const { anvil, rpcUrl } = await startAnvil(8599);
|
|
92
|
+
const dataDir = await mkdtemp(join(tmpdir(), "seedhape-fork-"));
|
|
93
|
+
const accountId = `fork-test-${process.pid}`;
|
|
94
|
+
process.env.SEEDHAPE_AGENT_IDENTITY_CONTROLLER_ID = accountId;
|
|
95
|
+
process.env.SEEDHAPE_TOKEN = "fork-token";
|
|
96
|
+
delete process.env.SEEDHAPE_AGENT_METADATA_ORIGIN;
|
|
97
|
+
const seedhape = await fakeSeedhape(rpcUrl);
|
|
98
|
+
process.env.SEEDHAPE_URL = seedhape.url;
|
|
99
|
+
try {
|
|
100
|
+
const { setupSeedhapeHostedAgent, loadSeedhapeHostedAgent } = await import("../dist/agent-identity.js");
|
|
101
|
+
const agent = await setupSeedhapeHostedAgent(dataDir, { name: "fork-agent", description: "Fork test agent" }, () => {}, { fetchImpl: seedhape.publicFetch });
|
|
102
|
+
assert.equal(agent.stage, "ready");
|
|
103
|
+
assert.match(agent.identityRef, new RegExp(`^eip155:8453:${REGISTRY}:\\d+$`, "i"));
|
|
104
|
+
assert.ok(seedhape.calls.includes("POST /api/local-wallet/identity/sponsor"), "gas was sponsored");
|
|
105
|
+
|
|
106
|
+
// A merchant resolves the key from the chain + Seedhape-hosted metadata, exactly as Seedhape's verifier does.
|
|
107
|
+
const trust = { network: "base", chainId: 8453, identityRegistry: REGISTRY, contractAddress: TRUST, rpcUrl };
|
|
108
|
+
const resolved = await resolveAgentWebBotAuthKey({ config: trust, identityRef: agent.identityRef, signatureAgent: ORIGIN, keyId: agent.webBotAuth.keyId, fetchImpl: seedhape.publicFetch });
|
|
109
|
+
assert.equal(resolved.keyId, agent.webBotAuth.keyId);
|
|
110
|
+
|
|
111
|
+
// The running wallet signs requests with Agent-Identity and keeps the directory fresh.
|
|
112
|
+
const loaded = await loadSeedhapeHostedAgent(dataDir);
|
|
113
|
+
try {
|
|
114
|
+
const headers = await loaded.signRequest("https://merchant.example/signal");
|
|
115
|
+
assert.equal(headers["Agent-Identity"], agent.identityRef);
|
|
116
|
+
await verifyWebBotAuth({ url: "https://merchant.example/signal", headers, publicKey: createPublicKey({ key: resolved.publicJwk, format: "jwk" }), expectedKeyId: resolved.keyId });
|
|
117
|
+
} finally { loaded.stop(); }
|
|
118
|
+
|
|
119
|
+
// Running setup again is idempotent: same identity, no new registration.
|
|
120
|
+
const again = await setupSeedhapeHostedAgent(dataDir, { name: "fork-agent", description: "Fork test agent" }, () => {}, { fetchImpl: seedhape.publicFetch });
|
|
121
|
+
assert.equal(again.identityRef, agent.identityRef);
|
|
122
|
+
} finally {
|
|
123
|
+
seedhape.server.close();
|
|
124
|
+
anvil.kill();
|
|
125
|
+
await rm(dataDir, { recursive: true, force: true });
|
|
126
|
+
for (const service of ["seedhape-agent-identity-controller", "seedhape-agent-identity-keystore"]) await keytar.deletePassword(service, accountId).catch(() => undefined);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// End-to-end check of the local wallet's pay flow against a fake merchant and a
|
|
2
|
+
// fake Seedhape account: the wallet must ask Seedhape before signing, send
|
|
3
|
+
// nothing when refused or offline, and report the outcome afterwards.
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { createServer as createHttp } from "node:http";
|
|
7
|
+
import { generatePrivateKey } from "viem/accounts";
|
|
8
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
9
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
10
|
+
import { LocalSessionKeySigner, USDC } from "@seedhape/core";
|
|
11
|
+
|
|
12
|
+
const asset = USDC["base-sepolia"];
|
|
13
|
+
const payTo = "0x1111111111111111111111111111111111111111";
|
|
14
|
+
|
|
15
|
+
function listen(handler) {
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
const server = createHttp(handler);
|
|
18
|
+
server.listen(0, "127.0.0.1", () => resolve({ server, url: `http://127.0.0.1:${server.address().port}` }));
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function readJson(request) {
|
|
23
|
+
let raw = "";
|
|
24
|
+
for await (const chunk of request) raw += chunk;
|
|
25
|
+
return raw ? JSON.parse(raw) : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Fake Seedhape: records calls; `decision` controls authorize's answer. */
|
|
29
|
+
async function fakeSeedhape(decision) {
|
|
30
|
+
const calls = { authorize: [], settle: [] };
|
|
31
|
+
const { server, url } = await listen(async (request, response) => {
|
|
32
|
+
const body = await readJson(request);
|
|
33
|
+
if (request.headers.authorization !== "Bearer test-token") { response.writeHead(401).end("{}"); return; }
|
|
34
|
+
if (request.url === "/api/local-wallet/authorize") {
|
|
35
|
+
calls.authorize.push(body);
|
|
36
|
+
const answer = decision === "approve"
|
|
37
|
+
? { ok: true, authorizationId: "auth-1", mandateHash: "0xabc", amount: body.requirement.maxAmountRequired, payTo: body.requirement.payTo, network: body.requirement.network, expiresAt: 0 }
|
|
38
|
+
: { ok: false, code: "SPENDING_PAUSED", error: "Spending is paused for this account.", hint: "The user has paused all agent spending.", recovery: { tool: "pause_spending" } };
|
|
39
|
+
response.writeHead(answer.ok ? 200 : 409, { "content-type": "application/json" }).end(JSON.stringify(answer));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (request.url === "/api/local-wallet/settle") {
|
|
43
|
+
calls.settle.push(body);
|
|
44
|
+
response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true, status: body.status }));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
response.writeHead(404).end("{}");
|
|
48
|
+
});
|
|
49
|
+
return { server, url, calls };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Fake merchant: 402 until a payment header arrives; `settles` controls whether payment succeeds. */
|
|
53
|
+
async function fakeMerchant(settles = true) {
|
|
54
|
+
const seen = { paidRequests: 0 };
|
|
55
|
+
const { server, url } = await listen((request, response) => {
|
|
56
|
+
const payment = request.headers["x-payment"] ?? request.headers["payment-signature"];
|
|
57
|
+
const challenge = { x402Version: 1, accepts: [{ scheme: "exact", network: "base-sepolia", maxAmountRequired: "10000", resource: `${url}/signal`, description: "Market signal", payTo, asset: asset.address, maxTimeoutSeconds: 60, extra: { name: "USDC", version: "2" } }] };
|
|
58
|
+
if (!payment) { response.writeHead(402, { "content-type": "application/json" }).end(JSON.stringify(challenge)); return; }
|
|
59
|
+
seen.paidRequests += 1;
|
|
60
|
+
if (!settles) { response.writeHead(402, { "content-type": "application/json" }).end(JSON.stringify({ ...challenge, error: "insufficient_funds" })); return; }
|
|
61
|
+
const settlement = Buffer.from(JSON.stringify({ success: true, transaction: `0x${"ab".repeat(32)}`, network: "base-sepolia" })).toString("base64");
|
|
62
|
+
response.writeHead(200, { "content-type": "text/plain", "x-payment-response": settlement }).end("signal: bullish");
|
|
63
|
+
});
|
|
64
|
+
return { server, url, seen };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function connectWallet() {
|
|
68
|
+
process.env.SEEDHAPE_TOKEN = "test-token";
|
|
69
|
+
const { createServer } = await import("../dist/server.js");
|
|
70
|
+
const key = generatePrivateKey();
|
|
71
|
+
const signer = new LocalSessionKeySigner(key);
|
|
72
|
+
const server = createServer({ agentId: "laptop", address: signer.address, signer, asset, absoluteMaxPerPayment: 1_000_000n, outboundPolicy: { allowPrivateNetworks: true } });
|
|
73
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
74
|
+
await server.connect(serverTransport);
|
|
75
|
+
const client = new Client({ name: "test", version: "1" });
|
|
76
|
+
await client.connect(clientTransport);
|
|
77
|
+
const pay = async (url) => (await client.callTool({ name: "pay", arguments: { url } })).content[0].text;
|
|
78
|
+
return { client, pay };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
test("an approved payment is authorized first, paid, then reported as settled", async () => {
|
|
82
|
+
const seedhape = await fakeSeedhape("approve");
|
|
83
|
+
const merchant = await fakeMerchant(true);
|
|
84
|
+
process.env.SEEDHAPE_URL = seedhape.url;
|
|
85
|
+
const { client, pay } = await connectWallet();
|
|
86
|
+
try {
|
|
87
|
+
const result = await pay(`${merchant.url}/signal`);
|
|
88
|
+
assert.match(result, /^Paid 0\.010000 USDC/);
|
|
89
|
+
assert.match(result, /signal: bullish/);
|
|
90
|
+
assert.equal(seedhape.calls.authorize.length, 1);
|
|
91
|
+
assert.equal(seedhape.calls.authorize[0].agent_id, "laptop");
|
|
92
|
+
assert.equal(seedhape.calls.authorize[0].requirement.maxAmountRequired, "10000");
|
|
93
|
+
assert.equal(merchant.seen.paidRequests, 1);
|
|
94
|
+
assert.deepEqual(seedhape.calls.settle.map((call) => call.status), ["settled"]);
|
|
95
|
+
assert.equal(seedhape.calls.settle[0].authorization_id, "auth-1");
|
|
96
|
+
} finally { await client.close(); seedhape.server.close(); merchant.server.close(); }
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("a refused payment never reaches the merchant and explains the next step", async () => {
|
|
100
|
+
const seedhape = await fakeSeedhape("refuse");
|
|
101
|
+
const merchant = await fakeMerchant(true);
|
|
102
|
+
process.env.SEEDHAPE_URL = seedhape.url;
|
|
103
|
+
const { client, pay } = await connectWallet();
|
|
104
|
+
try {
|
|
105
|
+
const result = await pay(`${merchant.url}/signal`);
|
|
106
|
+
assert.match(result, /^PAYMENT NOT MADE\./);
|
|
107
|
+
assert.match(result, /Spending is paused/);
|
|
108
|
+
assert.match(result, /Next step: call pause_spending/);
|
|
109
|
+
assert.equal(merchant.seen.paidRequests, 0, "no signed payment may be sent");
|
|
110
|
+
assert.equal(seedhape.calls.settle.length, 0);
|
|
111
|
+
} finally { await client.close(); seedhape.server.close(); merchant.server.close(); }
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("when Seedhape is unreachable, nothing is paid", async () => {
|
|
115
|
+
const merchant = await fakeMerchant(true);
|
|
116
|
+
const closed = await listen(() => {});
|
|
117
|
+
const deadUrl = closed.url;
|
|
118
|
+
await new Promise((resolve) => closed.server.close(resolve));
|
|
119
|
+
process.env.SEEDHAPE_URL = deadUrl;
|
|
120
|
+
const { client, pay } = await connectWallet();
|
|
121
|
+
try {
|
|
122
|
+
const result = await pay(`${merchant.url}/signal`);
|
|
123
|
+
assert.match(result, /^PAYMENT NOT MADE\./);
|
|
124
|
+
assert.match(result, /could not be reached/);
|
|
125
|
+
assert.equal(merchant.seen.paidRequests, 0);
|
|
126
|
+
} finally { await client.close(); merchant.server.close(); }
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("a payment the merchant rejects is reported as failed so the budget is released", async () => {
|
|
130
|
+
const seedhape = await fakeSeedhape("approve");
|
|
131
|
+
const merchant = await fakeMerchant(false);
|
|
132
|
+
process.env.SEEDHAPE_URL = seedhape.url;
|
|
133
|
+
const { client, pay } = await connectWallet();
|
|
134
|
+
try {
|
|
135
|
+
const result = await pay(`${merchant.url}/signal`);
|
|
136
|
+
assert.match(result, /PAYMENT NOT COMPLETED/);
|
|
137
|
+
assert.deepEqual(seedhape.calls.settle.map((call) => call.status), ["failed"]);
|
|
138
|
+
} finally { await client.close(); seedhape.server.close(); merchant.server.close(); }
|
|
139
|
+
});
|