Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 2 additions & 2 deletions src/actions/shared.ts
Original file line number Diff line number Diff line change
@@ -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<ActionResult> {
await callback?.({ text: message });
Expand All @@ -17,7 +17,7 @@ export function paymentLine(payment: PaidResult<unknown>["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<unknown>): 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;
Expand Down
5 changes: 3 additions & 2 deletions src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<IAgentRuntime, Promise<FizzlContext>>();
Expand Down Expand Up @@ -44,6 +44,7 @@ export function getContext(runtime: IAgentRuntime): Promise<FizzlContext> {
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,
},
};
})();
Expand Down
31 changes: 28 additions & 3 deletions src/receipt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SignedService, string> = { 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<string, unknown>, recovered: string, authority: string, service: string): Promise<boolean> {
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 };
Expand All @@ -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<ReceiptCheck> {
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<ReceiptCheck> {
const b = body as { receipt?: Record<string, unknown> } | null;
const r = b?.receipt;
if (!r || typeof r.signature !== "string") return { valid: false, reason: "no signed receipt" };
Expand All @@ -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" };
}
Expand Down
41 changes: 40 additions & 1 deletion test/actions.test.mjs
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -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;
}
});
6 changes: 3 additions & 3 deletions test/fixtures.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) }) } };
}

Expand Down Expand Up @@ -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));
};
}
Expand Down
Loading