From 23f0739fdd63d9d4dfe61b1fc101d16aeb33909d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 09:39:01 +0000 Subject: [PATCH] Accept signing keys certified by the payout wallet (rotation without an update, 0.10.0) The plugin pinned each service's signing key, so a new key needed a new release. Both services now carry a certificate in every receipt: the Fizzl payout wallet's personal_sign over "fizzl receipt signer / service / signer / valid_from". The plugin accepts a signer that is pinned or certified by that wallet for the right service (signatures on or after valid_from). FIZZL_RECEIPT_AUTHORITY overrides the wallet; "none" keeps pinned-only. Also: the canonical JSON regex used a literal U+FFFF character; it is an escape sequence again (same behaviour). Tests: a rotated Doctor key and a rotated presign-guard key with a valid certificate are trusted; a certificate for the other service, by another wallet, or with the authority switched off is not. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TsD5haPKxeWjYmErHzvU48 --- README.md | 5 ++++- package-lock.json | 4 ++-- package.json | 2 +- src/actions/shared.ts | 4 ++-- src/context.ts | 5 +++-- src/receipt.ts | 31 ++++++++++++++++++++++++++++--- test/actions.test.mjs | 41 ++++++++++++++++++++++++++++++++++++++++- test/fixtures.mjs | 6 +++--- 8 files changed, 83 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index f96946c..68d4480 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,8 @@ export const character = { | `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). | +| `FIZZL_DOCTOR_SIGNERS`, `FIZZL_PRESIGN_SIGNERS` | no | the published signers | Comma-separated pinned signer addresses (for another deployment). | +| `FIZZL_RECEIPT_AUTHORITY` | no | the Fizzl payout wallet | Wallet whose certificates also make a signer trusted, so a rotated key keeps working; `none` accepts only the pinned signers. | 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 @@ -84,6 +85,8 @@ x402 Doctor and presign-guard sign every paid answer (EIP-191 over canonical JSO - 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). +**Key rotation:** a service can change its signing key without a plugin update. The Fizzl payout wallet (`0x6B0F4651eD42893ab58139938175E4a69f175F25`, the `payTo` of every payment) certifies each key, and the certificate travels inside the receipt; the plugin accepts a key that is pinned or certified for that service. + 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 diff --git a/package-lock.json b/package-lock.json index 5c6555a..6f92078 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "plugin-fizzl", - "version": "0.9.0", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "plugin-fizzl", - "version": "0.9.0", + "version": "0.10.0", "license": "MIT", "dependencies": { "@solana/kit": "^8.3.0", diff --git a/package.json b/package.json index 259d575..4fdc0a7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plugin-fizzl", - "version": "0.9.0", + "version": "0.10.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/shared.ts b/src/actions/shared.ts index 58d8009..03c845f 100644 --- a/src/actions/shared.ts +++ b/src/actions/shared.ts @@ -1,7 +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"; +import { verifyReceipt, SERVICE_NAMES, type SignedService } from "../receipt.js"; export async function failure(callback: HandlerCallback | undefined, message: string): Promise { await callback?.({ text: message }); @@ -17,7 +17,7 @@ export function paymentLine(payment: PaidResult["payment"]): string { // 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 check = await verifyReceipt(result.data, { signers: ctx.receipts[service], route: result.request?.route, input: result.request?.input, authority: ctx.receipts.authority, service: SERVICE_NAMES[service] }); 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; diff --git a/src/context.ts b/src/context.ts index 2fdbc9a..63d5653 100644 --- a/src/context.ts +++ b/src/context.ts @@ -4,13 +4,13 @@ import type { IAgentRuntime } from "@elizaos/core"; import { FizzlClient } from "./client.js"; import { DEFAULT_URLS, type ServiceUrls } from "./services.js"; -import { SIGNERS } from "./receipt.js"; +import { SIGNERS, AUTHORITY } 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[] }; + receipts: { mode: "require" | "off"; doctor: string[]; presign: string[]; authority: string | null }; } const contexts = new WeakMap>(); @@ -44,6 +44,7 @@ export function getContext(runtime: IAgentRuntime): Promise { 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], + authority: setting(runtime, "FIZZL_RECEIPT_AUTHORITY") === "none" ? null : setting(runtime, "FIZZL_RECEIPT_AUTHORITY") ?? AUTHORITY, }, }; })(); diff --git a/src/receipt.ts b/src/receipt.ts index 29998ac..4b3d27f 100644 --- a/src/receipt.ts +++ b/src/receipt.ts @@ -21,8 +21,32 @@ export const SIGNERS = { export type SignedService = keyof typeof SIGNERS; +// The payout wallet (the payTo of every Fizzl payment) certifies signing keys: +// a key it authorised for the service is accepted too, so a rotated key keeps +// working without a plugin update. The certificate travels inside the receipt +// (receipt.cert) as a personal_sign over certMessage. +export const AUTHORITY = "0x6B0F4651eD42893ab58139938175E4a69f175F25"; +export const SERVICE_NAMES: Record = { doctor: "x402-doctor", presign: "presign-guard" }; + +export function certMessage(c: { service: string; signer: string; valid_from: string }): string { + return `fizzl receipt signer\nservice: ${c.service}\nsigner: ${c.signer}\nvalid_from: ${c.valid_from}`; +} + +async function certified(r: Record, recovered: string, authority: string, service: string): Promise { + const c = r.cert as { service?: string; signer?: string; valid_from?: string; authority?: string; signature?: string } | undefined; + if (!c || c.service !== service || String(c.signer).toLowerCase() !== recovered.toLowerCase()) return false; + if (String(c.authority).toLowerCase() !== authority.toLowerCase()) return false; + if (!(String(r.signed_at).slice(0, 10) >= String(c.valid_from))) return false; + try { + const by = await recoverMessageAddress({ message: certMessage(c as { service: string; signer: string; valid_from: string }), signature: c.signature as Hex }); + return by.toLowerCase() === authority.toLowerCase(); + } catch { + return false; + } +} + 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 ascii = (s: string) => JSON.stringify(s).replace(/[\u007f-\uffff]/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 }; @@ -44,7 +68,7 @@ export const inputHash = (route: string, input: unknown): string => 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 { +export async function verifyReceipt(body: unknown, { signers, route, input, authority = AUTHORITY, service }: { signers: readonly string[]; route?: string; input?: unknown; authority?: string | null; service?: string }): 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" }; @@ -56,7 +80,8 @@ export async function verifyReceipt(body: unknown, { signers, route, input }: { 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" }; + const pinned = signers.some((s) => s.toLowerCase() === recovered.toLowerCase()); + if (!pinned && !(authority && service && (await certified(r, recovered, authority, service)))) 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" }; } diff --git a/test/actions.test.mjs b/test/actions.test.mjs index fb79ab1..c8e3032 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, signerKeys } from "./fixtures.mjs"; +import { startFixtures, closeAll, fakeRuntime, message, newSolanaKey, newEvmKey, SOLANA, BASE, signerKeys, signAnswer } from "./fixtures.mjs"; const action = (name) => plugin.actions.find((a) => a.name === name); const ICHIMOKU = action("FIZZL_ICHIMOKU_SIGNAL"); @@ -434,3 +434,42 @@ test("signed verdicts: real production receipts from x402 Doctor and presign-gua 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"); }); + +test("key rotation: a new key certified by the payout wallet is trusted without a plugin update", async () => { + const { certMessage, AUTHORITY } = await import("../dist/receipt.js"); + assert.equal(AUTHORITY, "0x6B0F4651eD42893ab58139938175E4a69f175F25"); + const { privateKeyToAccount, generatePrivateKey } = await import("viem/accounts"); + const payout = privateKeyToAccount(generatePrivateKey()); + const rotated = privateKeyToAccount(generatePrivateKey()); + const certBy = async (key, service) => { + const c = { service, signer: rotated.address, valid_from: "2026-09-01", authority: payout.address }; + return { ...c, signature: await key.signMessage({ message: certMessage(c) }) }; + }; + // The service re-signs with its new key and carries the certificate; the plugin pins only the old keys. + const resignWith = (cert) => async ({ receipt, ...body }, req) => signAnswer(body, rotated, req.route, req.input, cert); + const rt = (extra = {}) => runtimeWith({ EVM_PRIVATE_KEY: evm.secret, FIZZL_RECEIPT_AUTHORITY: payout.address, ...extra }); + try { + fx.state.tamper = resignWith(await certBy(payout, "x402-doctor")); + const ok = await run(PREFLIGHT, rt(), "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 ✓/); + + fx.state.tamper = resignWith(await certBy(payout, "presign-guard")); + const wrongService = await run(PREFLIGHT, rt(), "Is it safe to pay https://api.example.com/paid/1? Max $0.05"); + assert.equal(wrongService.result.success, false, "a presign-guard certificate does not cover Doctor"); + + fx.state.tamper = resignWith(await certBy(privateKeyToAccount(generatePrivateKey()), "x402-doctor")); + const foreign = await run(PREFLIGHT, rt(), "Is it safe to pay https://api.example.com/paid/1? Max $0.05"); + assert.match(foreign.replies[0].text, /signed by an unknown key/); + + fx.state.tamper = resignWith(await certBy(payout, "x402-doctor")); + const off = await run(PREFLIGHT, rt({ FIZZL_RECEIPT_AUTHORITY: "none" }), "Is it safe to pay https://api.example.com/paid/1? Max $0.05"); + assert.equal(off.result.success, false, "FIZZL_RECEIPT_AUTHORITY=none accepts only pinned keys"); + + fx.state.tamper = resignWith(await certBy(payout, "presign-guard")); + const presign = await run(PRESIGN, rt(), `Is it safe to sign this? ${PERMIT}`); + assert.match(presign.replies[0].text, /Signed by presign-guard ✓/, "the same works for presign-guard"); + } finally { + fx.state.tamper = null; + } +}); diff --git a/test/fixtures.mjs b/test/fixtures.mjs index 0e9cdea..618258a 100644 --- a/test/fixtures.mjs +++ b/test/fixtures.mjs @@ -11,8 +11,8 @@ 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" }; +export async function signAnswer(body, key, route, input, cert = null) { + const receipt = { request_id: "3f2a9c10-0000-4000-8000-000000000000", route, input_sha256: inputHash(route, input), ...(cert && { cert }), 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 }) }) } }; } @@ -119,7 +119,7 @@ function paidRoute({ accepts, resourceUrl, respond, state, sign }) { 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); + if (state.tamper) out = await state.tamper(out, sign && { route: sign.route(req), input: sign.input(req, input) }); res.end(JSON.stringify(out)); }; }