From 2b6f2185704f96a73a08247d64c5f109298325cf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 09:25:37 +0000 Subject: [PATCH] Check signed receipts on x402 Doctor and presign-guard answers (0.9.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both services now sign every paid answer (EIP-191 over canonical JSON, request inside the signed body). The preflight, diagnose and presign actions verify it before the agent uses a verdict: signed by the service's pinned signer and bound to exactly the request sent. A valid answer ends with "Signed by ... ✓"; a changed, unsigned or foreign answer, or one for another request, is not used ("Do not pay on it" / "Do not sign until it can be checked"). - src/receipt.ts: canonical JSON, input hash, verifyReceipt (viem), SIGNERS. - Services attach the signed route and input to each result. - Settings: FIZZL_VERIFY_RECEIPTS (require|off), FIZZL_DOCTOR_SIGNERS, FIZZL_PRESIGN_SIGNERS. - Tests: fixtures sign like the live services; tampered, unsigned and foreign answers are refused; the off switch; pinned signers; real production receipts from both services verify. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TsD5haPKxeWjYmErHzvU48 --- README.md | 11 +++++++ package-lock.json | 4 +-- package.json | 2 +- src/actions/presign-guard.ts | 9 +++-- src/actions/shared.ts | 13 ++++++++ src/actions/x402-doctor.ts | 16 ++++++--- src/client.ts | 2 ++ src/context.ts | 10 ++++++ src/receipt.ts | 64 ++++++++++++++++++++++++++++++++++++ src/services.ts | 11 ++++--- test/actions.test.mjs | 62 +++++++++++++++++++++++++++++++++- test/fixtures.mjs | 19 +++++++++-- 12 files changed, 205 insertions(+), 18 deletions(-) create mode 100644 src/receipt.ts diff --git a/README.md b/README.md index e97577c..f96946c 100644 --- a/README.md +++ b/README.md @@ -70,11 +70,22 @@ export const character = { | `FIZZL_MAX_PAYMENT_USD` | no | `0.50` | The agent refuses any single payment above this (trade setups cost $0.50; set `0.25` to allow everything else). | | `SOLANA_RPC_URL` | no | public mainnet RPC | RPC used to build Solana payments; the public one rate-limits. | | `ICHIMOKU_SIGNAL_URL`, `PLAINTEXT_URL`, `X402_DOCTOR_URL`, `PRESIGN_GUARD_URL` | no | live services | Point at another deployment. | +| `FIZZL_VERIFY_RECEIPTS` | no | `require` | Check the signed receipt on every x402 Doctor and presign-guard answer; `off` skips it. | +| `FIZZL_DOCTOR_SIGNERS`, `FIZZL_PRESIGN_SIGNERS` | no | the published signers | Comma-separated accepted signer addresses (another deployment, or a key rotation). | With both keys configured, services that accept both are paid on Solana. presign-guard is paid on Base only, so it needs `EVM_PRIVATE_KEY`. Use a **dedicated wallet** holding only what the agent may spend; the spend cap limits a single payment, not the total. +## Signed verdicts + +x402 Doctor and presign-guard sign every paid answer (EIP-191 over canonical JSON, with the request inside the signed body). The preflight, diagnose and presign actions check that signature before the agent uses the verdict: + +- signed by the service's published signer, pinned in the plugin: x402 Doctor `0xAaE66eF9Ee234397df33901568c8FBc36d43277d`, presign-guard `0xf084Ea47Ca4D99BB4De3ECB0332b316bE6521EaE`; +- for exactly the request the agent sent (endpoint and budget, or the transaction or signature to check). + +A valid answer ends with "Signed by x402 Doctor ✓" (or presign-guard). A changed, unsigned or foreign answer, or one for another request, is not used: the action fails with "Do not pay on it" or "Do not sign until it can be checked". + ## What the agent understands - “What does the Ichimoku cloud say for SOL/USDT on the 4h?” → `SOL-USDT`, `4h` diff --git a/package-lock.json b/package-lock.json index 3a915c9..5c6555a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "plugin-fizzl", - "version": "0.8.0", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "plugin-fizzl", - "version": "0.8.0", + "version": "0.9.0", "license": "MIT", "dependencies": { "@solana/kit": "^8.3.0", diff --git a/package.json b/package.json index 83ff2a0..259d575 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plugin-fizzl", - "version": "0.8.0", + "version": "0.9.0", "description": "ElizaOS plugin: 10 paid tools for crypto agents, paid per call in USDC over x402 on Solana or Base (no API keys). Trading signals (ranked trade setups with entry, stop and targets across 148 coins, Ichimoku, 6-indicator confluence, price levels, a market scan), wallet-approval risk checks, pre-sign checks and x402 endpoint checks.", "type": "module", "main": "dist/index.js", diff --git a/src/actions/presign-guard.ts b/src/actions/presign-guard.ts index 4c22323..f574722 100644 --- a/src/actions/presign-guard.ts +++ b/src/actions/presign-guard.ts @@ -2,7 +2,7 @@ import type { Action, ActionResult } from "@elizaos/core"; import { getContext } from "../context.js"; import { parsePresignInput } from "../parse.js"; import { presignCheck, type PresignVerdict } from "../services.js"; -import { failure, paymentLine } from "./shared.js"; +import { failure, paymentLine, receiptCheck } from "./shared.js"; const ASK = /\b(sign\w*|safe|risky?|check\w*|approv\w*|permit\w*|transaction|tx|drain\w*|phish\w*|scam\w*)\b/i; const VERDICT = { @@ -34,14 +34,17 @@ export const presignCheckAction: Action = { const input = parsePresignInput(message.content?.text ?? "", (options ?? {}) as Record); if (!input) return failure(callback, "Send what you are about to sign: the EIP-712 typed data, or the transaction { to, data, value, chainId }."); - const { client, urls } = await getContext(runtime); + const ctx = await getContext(runtime); + const { client, urls } = ctx; if (client.canPay && !client.wallets.base) { return failure(callback, "presign-guard is paid in USDC on Base only. Set EVM_PRIVATE_KEY to a Base wallet with a little USDC."); } const result = await presignCheck(client, urls, input); if (!result.ok || !result.data) return failure(callback, `Could not check this before signing: ${result.error}. Do not sign until it can be checked.`); + const signed = await receiptCheck(ctx, "presign", result); + if (!signed.ok) return failure(callback, `Got a verdict, but ${signed.reason}. Do not sign until it can be checked.`); - const text = `${formatPresign(result.data)}${paymentLine(result.payment)}`; + const text = `${formatPresign(result.data)}${signed.line}${paymentLine(result.payment)}`; await callback?.({ text, actions: ["FIZZL_PRESIGN_CHECK"], source: message.content?.source }); return { success: true, diff --git a/src/actions/shared.ts b/src/actions/shared.ts index a1084f9..58d8009 100644 --- a/src/actions/shared.ts +++ b/src/actions/shared.ts @@ -1,5 +1,7 @@ import type { ActionResult, HandlerCallback } from "@elizaos/core"; import type { PaidResult } from "../client.js"; +import type { FizzlContext } from "../context.js"; +import { verifyReceipt, type SignedService } from "../receipt.js"; export async function failure(callback: HandlerCallback | undefined, message: string): Promise { await callback?.({ text: message }); @@ -10,3 +12,14 @@ export function paymentLine(payment: PaidResult["payment"]): string { if (!payment) return ""; return `\nPaid via x402${payment.explorer ? `: ${payment.explorer}` : ` (tx ${payment.transaction})`}`; } + +// Checks the signed receipt on a Doctor or presign-guard answer (receipt.ts). +// ok = trusted (or checks off); line = what to show; reason = why not trusted. +export async function receiptCheck(ctx: FizzlContext, service: SignedService, result: PaidResult): Promise<{ ok: boolean; line: string; reason?: string }> { + if (ctx.receipts.mode === "off") return { ok: true, line: "" }; + const check = await verifyReceipt(result.data, { signers: ctx.receipts[service], route: result.request?.route, input: result.request?.input }); + const name = service === "doctor" ? "x402 Doctor" : "presign-guard"; + if (!check.valid) return { ok: false, line: "", reason: `the answer is not provably from ${name} (${check.reason})` }; + const id = (result.data as { receipt?: { request_id?: string } }).receipt?.request_id; + return { ok: true, line: `\nSigned by ${name} ✓${id ? ` (receipt ${id.slice(0, 8)})` : ""}` }; +} diff --git a/src/actions/x402-doctor.ts b/src/actions/x402-doctor.ts index df493ee..464cc98 100644 --- a/src/actions/x402-doctor.ts +++ b/src/actions/x402-doctor.ts @@ -2,7 +2,7 @@ import type { Action, ActionResult } from "@elizaos/core"; import { getContext } from "../context.js"; import { parseDiagnoseInput, parsePreflightInput } from "../parse.js"; import { diagnoseX402, preflightX402, type Diagnosis, type Preflight } from "../services.js"; -import { failure, paymentLine } from "./shared.js"; +import { failure, paymentLine, receiptCheck } from "./shared.js"; const TRIGGER = /\b(x402|402|paywall|payment[- ]required|paid (?:api|endpoint))\b/i; const ASK = /\b(diagnos\w*|check\w*|debug\w*|test\w*|broken|work(?:s|ing)?|why|fix|doctor|validat\w*|audit\w*)\b/i; @@ -50,11 +50,14 @@ export const diagnoseX402Action: Action = { const input = parseDiagnoseInput(message.content?.text ?? "", (options ?? {}) as Record); if (!input) return failure(callback, "Send the URL of the x402 endpoint to diagnose (https://…)."); - const { client, urls } = await getContext(runtime); + const ctx = await getContext(runtime); + const { client, urls } = ctx; const result = await diagnoseX402(client, urls, input); if (!result.ok || !result.data) return failure(callback, `Could not diagnose ${input.url}: ${result.error}`); + const signed = await receiptCheck(ctx, "doctor", result); + if (!signed.ok) return failure(callback, `Got a diagnosis for ${input.url}, but ${signed.reason}. Not using it.`); - const text = `${formatDiagnosis(result.data)}${paymentLine(result.payment)}`; + const text = `${formatDiagnosis(result.data)}${signed.line}${paymentLine(result.payment)}`; await callback?.({ text, actions: ["FIZZL_DIAGNOSE_X402"], source: message.content?.source }); return { success: true, @@ -84,11 +87,14 @@ export const preflightX402Action: Action = { const input = parsePreflightInput(message.content?.text ?? "", (options ?? {}) as Record); if (!input) return failure(callback, "Send the URL of the x402 endpoint you want to pay (https://…), optionally with a budget like max $0.05."); - const { client, urls } = await getContext(runtime); + const ctx = await getContext(runtime); + const { client, urls } = ctx; const result = await preflightX402(client, urls, input); if (!result.ok || !result.data) return failure(callback, `Could not check ${input.url}: ${result.error}`); + const signed = await receiptCheck(ctx, "doctor", result); + if (!signed.ok) return failure(callback, `Got a verdict for ${input.url}, but ${signed.reason}. Do not pay on it.`); - const text = `${formatPreflight(result.data)}${paymentLine(result.payment)}`; + const text = `${formatPreflight(result.data)}${signed.line}${paymentLine(result.payment)}`; await callback?.({ text, actions: ["FIZZL_PREFLIGHT_X402"], source: message.content?.source }); return { success: true, diff --git a/src/client.ts b/src/client.ts index 8dadfc9..c9b5ae6 100644 --- a/src/client.ts +++ b/src/client.ts @@ -28,6 +28,8 @@ export interface PaidResult { error?: string; /** Settlement transaction and network, when the payment went through. */ payment?: { network: string; transaction: string; explorer?: string }; + /** What was asked, as the service signs it in its receipt (route and input). */ + request?: { route: string; input: unknown }; } function explorerUrl(network: string, tx: string): string | undefined { diff --git a/src/context.ts b/src/context.ts index 13e01f9..2fdbc9a 100644 --- a/src/context.ts +++ b/src/context.ts @@ -4,14 +4,19 @@ import type { IAgentRuntime } from "@elizaos/core"; import { FizzlClient } from "./client.js"; import { DEFAULT_URLS, type ServiceUrls } from "./services.js"; +import { SIGNERS } from "./receipt.js"; export interface FizzlContext { client: FizzlClient; urls: ServiceUrls; + /** Signed-verdict checks: "require" (default) or "off", and the accepted signers per service. */ + receipts: { mode: "require" | "off"; doctor: string[]; presign: string[] }; } const contexts = new WeakMap>(); +const list = (value: string | undefined) => (value ? value.split(",").map((s) => s.trim()).filter(Boolean) : undefined); + function setting(runtime: IAgentRuntime, key: string): string | undefined { const value = runtime.getSetting?.(key); return value === undefined || value === null || value === "" ? undefined : String(value); @@ -35,6 +40,11 @@ export function getContext(runtime: IAgentRuntime): Promise { doctor: setting(runtime, "X402_DOCTOR_URL") ?? DEFAULT_URLS.doctor, presign: setting(runtime, "PRESIGN_GUARD_URL") ?? DEFAULT_URLS.presign, }, + receipts: { + mode: setting(runtime, "FIZZL_VERIFY_RECEIPTS") === "off" ? "off" : "require", + doctor: list(setting(runtime, "FIZZL_DOCTOR_SIGNERS")) ?? [...SIGNERS.doctor], + presign: list(setting(runtime, "FIZZL_PRESIGN_SIGNERS")) ?? [...SIGNERS.presign], + }, }; })(); contexts.set(runtime, ctx); diff --git a/src/receipt.ts b/src/receipt.ts new file mode 100644 index 0000000..29998ac --- /dev/null +++ b/src/receipt.ts @@ -0,0 +1,64 @@ +// Signed verdicts: x402 Doctor and presign-guard sign every paid answer with a +// `receipt` (EIP-191 personal_sign over canonical JSON of the whole answer +// without receipt.signature; request id, route and a SHA-256 of the request +// input inside the signed body). The actions check it before an agent acts on +// a verdict: a changed, unsigned or foreign answer, or one for another +// request, is reported as untrusted instead of used. +// +// Canonical JSON: keys sorted at every level, no whitespace, non-ASCII as +// \uXXXX (the same bytes as Python's json.dumps(sort_keys=True, +// separators=(",", ":"), ensure_ascii=True)). + +import { createHash } from "node:crypto"; +import { recoverMessageAddress, type Hex } from "viem"; + +// The published signers, pinned: a list fetched from the service it vouches for +// would not protect against that service being compromised. +export const SIGNERS = { + doctor: ["0xAaE66eF9Ee234397df33901568c8FBc36d43277d"], + presign: ["0xf084Ea47Ca4D99BB4De3ECB0332b316bE6521EaE"], +} as const; + +export type SignedService = keyof typeof SIGNERS; + +export function canonicalJson(value: unknown): string { + const ascii = (s: string) => JSON.stringify(s).replace(/[\u007f-￿]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`); + const walk = (v: unknown): string | undefined => { + if (v === null || typeof v !== "object") return v === undefined ? undefined : typeof v === "string" ? ascii(v) : JSON.stringify(v); + const maybe = v as { toJSON?: () => unknown }; + if (typeof maybe.toJSON === "function") return walk(maybe.toJSON()); + if (Array.isArray(v)) return `[${v.map((x) => walk(x) ?? "null").join(",")}]`; + const obj = v as Record; + const parts = Object.keys(obj).sort().flatMap((k) => { + const inner = walk(obj[k]); + return inner === undefined ? [] : [`${ascii(k)}:${inner}`]; + }); + return `{${parts.join(",")}}`; + }; + return walk(value) ?? "null"; +} + +export const inputHash = (route: string, input: unknown): string => + createHash("sha256").update(canonicalJson({ route, input: input ?? {} })).digest("hex"); + +export interface ReceiptCheck { valid: boolean; signer?: string; reason?: string } + +/** Checks a signed answer: signed by one of `signers`, and (with route and input) for exactly that request. */ +export async function verifyReceipt(body: unknown, { signers, route, input }: { signers: readonly string[]; route?: string; input?: unknown }): Promise { + const b = body as { receipt?: Record } | null; + const r = b?.receipt; + if (!r || typeof r.signature !== "string") return { valid: false, reason: "no signed receipt" }; + const { signature, ...rest } = r; + let recovered: string; + try { + recovered = await recoverMessageAddress({ message: canonicalJson({ ...b, receipt: rest }), signature: signature as Hex }); + } catch { + return { valid: false, reason: "the signature does not parse" }; + } + if (recovered.toLowerCase() !== String(r.signer).toLowerCase()) return { valid: false, reason: "the signature does not match: the answer was changed" }; + if (!signers.some((s) => s.toLowerCase() === recovered.toLowerCase())) return { valid: false, signer: recovered, reason: "signed by an unknown key" }; + if (route !== undefined && input !== undefined && inputHash(route, input) !== r.input_sha256) { + return { valid: false, signer: recovered, reason: "signed for a different request" }; + } + return { valid: true, signer: recovered }; +} diff --git a/src/services.ts b/src/services.ts index b169030..5919c6a 100644 --- a/src/services.ts +++ b/src/services.ts @@ -246,7 +246,8 @@ export function explainApproval(client: FizzlClient, urls: ServiceUrls, data: Re export function diagnoseX402(client: FizzlClient, urls: ServiceUrls, input: DiagnoseInput): Promise> { const query = new URLSearchParams({ url: input.url, ...(input.method ? { method: input.method } : {}) }); - return client.request(`${trim(urls.doctor)}/api/v1/diagnose?${query}`); + return client.request(`${trim(urls.doctor)}/api/v1/diagnose?${query}`) + .then((r) => ({ ...r, request: { route: "GET /api/v1/diagnose", input: Object.fromEntries(query) } })); } export function preflightX402(client: FizzlClient, urls: ServiceUrls, input: PreflightInput): Promise> { @@ -254,15 +255,17 @@ export function preflightX402(client: FizzlClient, urls: ServiceUrls, input: Pre if (input.method) query.set("method", input.method); if (input.maxUsd !== undefined) query.set("max_usd", String(input.maxUsd)); if (input.network) query.set("network", input.network); - return client.request(`${trim(urls.doctor)}/api/v1/preflight?${query}`); + return client.request(`${trim(urls.doctor)}/api/v1/preflight?${query}`) + .then((r) => ({ ...r, request: { route: "GET /api/v1/preflight", input: Object.fromEntries(query) } })); } export function presignCheck(client: FizzlClient, urls: ServiceUrls, input: PresignInput): Promise> { const path = input.explain ? "/v1/check/explain" : "/v1/check"; const body = input.explain ? { ...input.request, lang: input.lang } : input.request; + const json = JSON.stringify(body); return client.request(`${trim(urls.presign)}${path}`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }); + body: json, + }).then((r) => ({ ...r, request: { route: `POST ${path}`, input: JSON.parse(json) } })); } diff --git a/test/actions.test.mjs b/test/actions.test.mjs index 419109e..fb79ab1 100644 --- a/test/actions.test.mjs +++ b/test/actions.test.mjs @@ -1,7 +1,7 @@ import { test, before, after } from "node:test"; import assert from "node:assert/strict"; import plugin from "../dist/index.js"; -import { startFixtures, closeAll, fakeRuntime, message, newSolanaKey, newEvmKey, SOLANA, BASE } from "./fixtures.mjs"; +import { startFixtures, closeAll, fakeRuntime, message, newSolanaKey, newEvmKey, SOLANA, BASE, signerKeys } from "./fixtures.mjs"; const action = (name) => plugin.actions.find((a) => a.name === name); const ICHIMOKU = action("FIZZL_ICHIMOKU_SIGNAL"); @@ -37,6 +37,8 @@ function runtimeWith(keys) { X402_DOCTOR_URL: fx.doctorUrl, PRESIGN_GUARD_URL: fx.presignUrl, SOLANA_RPC_URL: fx.rpc, + FIZZL_DOCTOR_SIGNERS: signerKeys.doctor.address, + FIZZL_PRESIGN_SIGNERS: signerKeys.presign.address, ...keys, }); } @@ -374,3 +376,61 @@ test("spend cap: FIZZL_MAX_PAYMENT_USD below $0.50 refuses trade setups before s assert.match(result.error, /maxAmountPerPayment|spend/i); assert.equal(fx.state.payments.length, 0, "nothing was signed"); }); + +test("signed verdicts: a valid Doctor receipt shows as signed; a changed verdict is not used", async () => { + fx.state.payments.length = 0; + const ok = await run(PREFLIGHT, runtimeWith({ EVM_PRIVATE_KEY: evm.secret }), "Is it safe to pay https://api.example.com/paid/1? Max $0.05"); + assert.equal(ok.result.success, true, ok.result.error); + assert.match(ok.replies[0].text, /Signed by x402 Doctor ✓ \(receipt 3f2a9c10\)/); + + fx.state.tamper = (b) => ({ ...b, verdict: b.verdict === "go" ? "no_go" : "go" }); + try { + const bad = await run(PREFLIGHT, runtimeWith({ EVM_PRIVATE_KEY: evm.secret }), "should I pay https://api.example.com/paid/1 with a budget of $0.01"); + assert.equal(bad.result.success, false); + assert.match(bad.replies[0].text, /not provably from x402 Doctor \(the signature does not match: the answer was changed\)\. Do not pay on it\./); + } finally { + fx.state.tamper = null; + } +}); + +test("signed verdicts: presign-guard answers are checked too; unsigned means do not sign", async () => { + fx.state.tamper = ({ receipt, ...b }) => b; + try { + const bad = await run(PRESIGN, runtimeWith({ EVM_PRIVATE_KEY: evm.secret }), `Is it safe to sign this? ${PERMIT}`); + assert.equal(bad.result.success, false); + assert.match(bad.replies[0].text, /not provably from presign-guard \(no signed receipt\)\. Do not sign until it can be checked\./); + } finally { + fx.state.tamper = null; + } + const good = await run(PRESIGN, runtimeWith({ EVM_PRIVATE_KEY: evm.secret }), `Is it safe to sign this? ${PERMIT}`); + assert.match(good.replies[0].text, /Signed by presign-guard ✓/); +}); + +test("signed verdicts: an answer signed by someone else is refused; FIZZL_VERIFY_RECEIPTS=off skips the check", async () => { + const stranger = await run(PREFLIGHT, runtimeWith({ EVM_PRIVATE_KEY: evm.secret, FIZZL_DOCTOR_SIGNERS: "0x0000000000000000000000000000000000000001" }), "Is it safe to pay https://api.example.com/paid/1? Max $0.05"); + assert.equal(stranger.result.success, false); + assert.match(stranger.replies[0].text, /signed by an unknown key/); + fx.state.tamper = ({ receipt, ...b }) => b; + try { + const off = await run(PREFLIGHT, runtimeWith({ EVM_PRIVATE_KEY: evm.secret, FIZZL_VERIFY_RECEIPTS: "off" }), "Is it safe to pay https://api.example.com/paid/1? Max $0.05"); + assert.equal(off.result.success, true, off.result.error); + assert.doesNotMatch(off.replies[0].text, /Signed by/); + } finally { + fx.state.tamper = null; + } +}); + +test("signed verdicts: the published signers are pinned by default", async () => { + const { SIGNERS } = await import("../dist/receipt.js"); + assert.deepEqual(SIGNERS, { doctor: ["0xAaE66eF9Ee234397df33901568c8FBc36d43277d"], presign: ["0xf084Ea47Ca4D99BB4De3ECB0332b316bE6521EaE"] }); +}); + +test("signed verdicts: real production receipts from x402 Doctor and presign-guard verify (27 Sep 2026)", async () => { + const { verifyReceipt, SIGNERS } = await import("../dist/receipt.js"); + const doctor = {"url": "https://ichimoku-signal.onrender.com/signal/BTC-USDT", "method": "GET", "verdict": "go", "safe_to_pay": true, "summary": "OK to pay: $0.02 on Base.", "recommended_option": 0, "options": [{"index": 0, "network": "eip155:8453", "network_name": "Base", "testnet": false, "scheme": "exact", "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", "asset_symbol": "USDC", "amount": "20000", "usd": 0.02, "pay_to": "0x6B0F4651eD42893ab58139938175E4a69f175F25", "payable": true, "problems": []}, {"index": 1, "network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "network_name": "Solana", "testnet": false, "scheme": "exact", "asset": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "asset_symbol": "USDC", "amount": "20000", "usd": 0.02, "pay_to": "ATWJ82T8nRdQwZnaysB68N5EpaSvLRsQP4h6eWmaJBH9", "payable": true, "problems": []}], "signals": {"https": true, "advertised_price_usd": 0.02, "listed_in_cdp_bazaar": false, "origin_in_cdp_bazaar": true, "track_record": null, "x402_version": 2}, "reasons": [], "checked_at": "2026-09-27T07:47:00.700Z", "cached": false, "receipt": {"request_id": "edddd0ee-ff3a-4470-80bb-b92a82e17d96", "route": "GET /api/v1/preflight", "input_sha256": "2da6ecd53282b9699f961b5095b926a46c9044ecc5d6cf88080d65eaabe63af1", "signed_at": "2026-09-27T07:47:00.700Z", "signer": "0xAaE66eF9Ee234397df33901568c8FBc36d43277d", "algorithm": "eip191-canonical-json-v1", "signature": "0xdcad07dfb5f05ee688bc62144759d928f7527c2157437485b0868969c1f118250e2677d910a81ee91be55bc8ac1fbf1c7487910ec5ab0d890f8475aa49821a481c"}}; + const presign = {"version": "1", "verdict": "green", "grade": "SAFE", "one_liner": "SAFE: no red flags, $133k liquidity, 2.7 years old", "reasons": [{"code": "TOKEN_MINTABLE", "severity": "info"}, {"code": "LP_NOT_LOCKED", "severity": "info", "details": {"lpLockedPct": 0}}], "token": {"chain": "base", "address": "0x4ed4e862860bed51a9570b96d89af5e1b0efefed", "name": "Degen", "symbol": "DEGEN"}, "market": {"name": "Degen", "symbol": "DEGEN", "priceUsd": 0.001096, "liquidityUsd": 133299, "marketCapUsd": 39589675, "volume24hUsd": 87850, "pairs": 1, "firstPairAt": "2024-01-07T19:30:45.000Z", "ageSeconds": 85837656, "dex": "uniswap", "url": "https://dexscreener.com/base/0xc9034c3e7f58003e6ae0c8438e7c8f4598d5acaa", "socials": 4}, "sources": ["goplus", "dexscreener"], "checkedAt": "2026-09-27T07:18:21.473Z", "disclaimer": "Automated on-chain and market checks, not financial advice. Green means no known red flags, not that the token will hold its value.", "receipt": {"request_id": "dbcf3165-c07c-4f96-9603-d25e6cee6438", "route": "GET /v1/token", "input_sha256": "7a79177b86e29e5d31407820b3cc6a944d3c10b950c2b9bae8eeb0030d371793", "signed_at": "2026-09-27T07:18:21.712Z", "signer": "0xf084Ea47Ca4D99BB4De3ECB0332b316bE6521EaE", "algorithm": "eip191-canonical-json-v1", "signature": "0x7f2e0cf2d7fa1f2a7f4a7397648396e8bc6867759f0ac12b687cec3fa25b472e343382aa49033b8e35535f9bcfadec9a6796fd4a2f2e586b82854e364d0b142a1c"}}; + assert.equal((await verifyReceipt(doctor, { signers: SIGNERS.doctor, route: "GET /api/v1/preflight", input: { url: "https://ichimoku-signal.onrender.com/signal/BTC-USDT", max_usd: "0.05" } })).valid, true); + assert.equal((await verifyReceipt(presign, { signers: SIGNERS.presign, route: "GET /v1/token", input: { chain: "base", address: "0x4ed4e862860bed51a9570b96d89af5e1b0efefed" } })).valid, true); + assert.equal((await verifyReceipt({ ...presign, verdict: "red" }, { signers: SIGNERS.presign })).valid, false); + assert.equal((await verifyReceipt(doctor, { signers: SIGNERS.presign })).valid, false, "Doctor's key is not presign-guard's"); +}); diff --git a/test/fixtures.mjs b/test/fixtures.mjs index 5090c7f..0e9cdea 100644 --- a/test/fixtures.mjs +++ b/test/fixtures.mjs @@ -7,6 +7,14 @@ import http from "node:http"; import * as kit from "@solana/kit"; import { verifyTypedData } from "viem"; import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; +import { canonicalJson, inputHash } from "../dist/receipt.js"; + +// Stand-ins for Doctor's and presign-guard's signers: answers are signed like the live services sign them. +export const signerKeys = { doctor: privateKeyToAccount(generatePrivateKey()), presign: privateKeyToAccount(generatePrivateKey()) }; +export async function signAnswer(body, key, route, input) { + const receipt = { request_id: "3f2a9c10-0000-4000-8000-000000000000", route, input_sha256: inputHash(route, input), signed_at: "2026-09-27T10:00:00.000Z", signer: key.address, algorithm: "eip191-canonical-json-v1" }; + return { ...body, receipt: { ...receipt, signature: await key.signMessage({ message: canonicalJson({ ...body, receipt }) }) } }; +} export const SOLANA = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"; export const BASE = "eip155:8453"; @@ -86,7 +94,7 @@ async function verifyEvmPayment(payload, requirements) { } // A paid route: 402 with the challenge, or verify the payment and answer. -function paidRoute({ accepts, resourceUrl, respond, state }) { +function paidRoute({ accepts, resourceUrl, respond, state, sign }) { return async (req, res, body) => { const header = req.headers["payment-signature"]; const challenge = { x402Version: 2, error: "Payment required", resource: { url: resourceUrl(req), mimeType: "application/json" }, accepts }; @@ -108,7 +116,11 @@ function paidRoute({ accepts, resourceUrl, respond, state }) { const tx = payload.accepted.network === SOLANA ? "5SolanaSettlementTx" : "0xbasesettlementtx"; res.setHeader("PAYMENT-RESPONSE", b64({ success: true, transaction: tx, network: payload.accepted.network, payer: verdict.payer })); res.setHeader("content-type", "application/json"); - res.end(JSON.stringify(respond(req, body ? JSON.parse(body) : null))); + const input = body ? JSON.parse(body) : null; + let out = respond(req, input); + if (sign) out = await signAnswer(out, sign.key, sign.route(req), sign.input(req, input)); + if (state.tamper) out = state.tamper(out); + res.end(JSON.stringify(out)); }; } @@ -261,6 +273,7 @@ export async function startFixtures() { accepts: [baseOption("1000"), solanaOption("1000")], resourceUrl: (r) => `${doctorUrl}${r.url}`, state, + sign: { key: signerKeys.doctor, route: () => "GET /api/v1/preflight", input: () => Object.fromEntries(url.searchParams) }, respond: () => { const maxUsd = url.searchParams.get("max_usd"); const overBudget = maxUsd !== null && Number(maxUsd) < 0.02; @@ -289,6 +302,7 @@ export async function startFixtures() { accepts: [baseOption("10000"), solanaOption("10000")], resourceUrl: (r) => `${doctorUrl}${r.url}`, state, + sign: { key: signerKeys.doctor, route: () => "GET /api/v1/diagnose", input: () => Object.fromEntries(url.searchParams) }, respond: () => ({ url: url.searchParams.get("url"), method: url.searchParams.get("method") || "GET", @@ -315,6 +329,7 @@ export async function startFixtures() { accepts: [baseOption(prices[req.url])], resourceUrl: (r) => `${presignUrl}${r.url}`, state, + sign: { key: signerKeys.presign, route: (r) => `POST ${r.url}`, input: (_r, input) => input }, respond: (_r, input) => { state.lastPresign = { path: req.url, input }; const red = input.typedData?.primaryType === "Permit";