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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
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.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",
Expand Down
9 changes: 6 additions & 3 deletions src/actions/presign-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -34,14 +34,17 @@ export const presignCheckAction: Action = {
const input = parsePresignInput(message.content?.text ?? "", (options ?? {}) as Record<string, unknown>);
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,
Expand Down
13 changes: 13 additions & 0 deletions src/actions/shared.ts
Original file line number Diff line number Diff line change
@@ -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<ActionResult> {
await callback?.({ text: message });
Expand All @@ -10,3 +12,14 @@ export function paymentLine(payment: PaidResult<unknown>["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<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 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)})` : ""}` };
}
16 changes: 11 additions & 5 deletions src/actions/x402-doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -50,11 +50,14 @@ export const diagnoseX402Action: Action = {
const input = parseDiagnoseInput(message.content?.text ?? "", (options ?? {}) as Record<string, unknown>);
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,
Expand Down Expand Up @@ -84,11 +87,14 @@ export const preflightX402Action: Action = {
const input = parsePreflightInput(message.content?.text ?? "", (options ?? {}) as Record<string, unknown>);
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,
Expand Down
2 changes: 2 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export interface PaidResult<T> {
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 {
Expand Down
10 changes: 10 additions & 0 deletions src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<IAgentRuntime, Promise<FizzlContext>>();

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);
Expand All @@ -35,6 +40,11 @@ export function getContext(runtime: IAgentRuntime): Promise<FizzlContext> {
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);
Expand Down
64 changes: 64 additions & 0 deletions src/receipt.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<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" };
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 };
}
11 changes: 7 additions & 4 deletions src/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,23 +246,26 @@ export function explainApproval(client: FizzlClient, urls: ServiceUrls, data: Re

export function diagnoseX402(client: FizzlClient, urls: ServiceUrls, input: DiagnoseInput): Promise<PaidResult<Diagnosis>> {
const query = new URLSearchParams({ url: input.url, ...(input.method ? { method: input.method } : {}) });
return client.request<Diagnosis>(`${trim(urls.doctor)}/api/v1/diagnose?${query}`);
return client.request<Diagnosis>(`${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<PaidResult<Preflight>> {
const query = new URLSearchParams({ url: input.url });
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<Preflight>(`${trim(urls.doctor)}/api/v1/preflight?${query}`);
return client.request<Preflight>(`${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<PaidResult<PresignVerdict>> {
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<PresignVerdict>(`${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) } }));
}
Loading
Loading