From a34370f39aee39f30727e512cccd5e2725cbf145 Mon Sep 17 00:00:00 2001 From: fretchen Date: Tue, 28 Jul 2026 08:27:30 +0200 Subject: [PATCH 1/6] clean first step --- open_agent_platform_plan.md | 135 ++++++++++++++++++++++++++++++++ scw_js/README.md | 2 + scw_js/openapi.llm.json | 2 + scw_js/sc_llm_x402.ts | 9 ++- scw_js/test/sc_llm_x402.test.ts | 2 + 5 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 open_agent_platform_plan.md diff --git a/open_agent_platform_plan.md b/open_agent_platform_plan.md new file mode 100644 index 000000000..6c2bf1e64 --- /dev/null +++ b/open_agent_platform_plan.md @@ -0,0 +1,135 @@ +# Open Agent Platform Plan — LLM MVP + +Decouple `/assistent` from its single hardcoded LLM backend so that **any standard x402 batch-settlement (or `exact`) LLM agent** can serve it. Scope is deliberately narrowed to the LLM service only; image generation is out of scope for this iteration (it carries NFT coupling that needs separate treatment, and minting is entirely out of the picture here). + +**The reverse direction is already done.** Third parties discovering and consuming *our* LLM endpoint works today — we are wire-standard (stock `@x402/evm` SDK) and discoverable + ownership-proofed on x402scan (prior PR). This MVP therefore builds only the missing direction: **the website consuming a second/third-party LLM agent.** + +Structure: [Motivation](#motivation) → [Goals / Non-goals](#goals--non-goals) → [Key finding: we are already standard](#key-finding-we-are-already-standard) → [Where the coupling is today](#where-the-coupling-is-today) → [Architecture](#target-architecture) → [Phases](#phases) → [Design record](#design-record) → [Open questions](#open-questions). + +--- + +## Motivation + +`/assistent` is a thin UI bolted onto one specific serverless function whose URL is compiled into `useX402Chat.ts`. That was the right shape while proving x402 payment rails. It is the wrong shape for a platform, for three reasons: + +1. **The payment layer is already open; the application layer is not.** x402's `accepts[]` is self-describing — price, `payTo`, scheme, network, asset all arrive in the 402. A client that *reads* `accepts[]` instead of assuming can pay any compatible endpoint. Today the frontend knows exactly one seller. +2. **Own the format, not the directory.** Depend on the open x402 protocol for payment and on our own published, machine-readable contract for discovery; treat every external directory (x402scan, etc.) as an optional listing, never a dependency. +3. **Agents, not humans, are the larger consumer base.** If the contract is machine-first, the website becomes one client among several with no privileged path, and the same artifacts power third-party integration for free. + +--- + +## Goals / Non-goals + +### Goals +- **G1 — Bring your own LLM agent.** Any operator of a standard x402 batch-settlement (or `exact`) LLM endpoint satisfying the `llm/v1` contract can be used by `/assistent`, without permission and without code changes on our side. +- **G2 — Machine-first contract.** `llm/v1` is defined by a stable, versioned, publicly fetchable OpenAPI document consumed identically by our frontend and third-party agents. No private path for our own UI. +- **G3 — No directory lock-in.** Nothing in the payment or discovery path may *require* any external service. A custom-URL escape hatch is the concrete proof. +- **G4 — Honestly labelled provenance.** The user always sees which `payTo` address and operator they are paying before they pay. +- **G5 — Zero-cost interop.** A compatible agent needs to do nothing we don't already do with the stock SDK — because we use the stock SDK (see next section). + +### Non-goals (this iteration) +- **Not** image generation. Deferred — NFT coupling needs its own design. +- **Not** a reputation/dispute system. We do liveness + format checks, not quality guarantees. x402 has no refund primitive. +- **Not** on-chain ERC-8004 registration. The registration-file *format* may be reused, but nothing is minted. +- **Not** a full multi-agent registry yet. The MVP proves the contract + decoupling with a **custom-URL escape hatch**; a curated/automated registry list is a later phase. +- **Not** publishing a batch-settlement client helper for third parties — see [Key finding](#key-finding-we-are-already-standard) (it is not needed for interop) and Q2. + +--- + +## Key finding: we are already standard + +An audit of the current code against the installed `@x402/evm` SDK (v2.18.0) established that **our batch-settlement implementation has no wire-protocol deviation**. This reframes the whole plan — "make it compatible" is largely already done. + +- **Server** (`scw_js/x402_server.ts`, `sc_llm_x402.ts`): the 402 `accepts[]` is built entirely by the SDK's `BatchSettlementEvmScheme.enhancePaymentRequirements`; the scheme string is the SDK's literal `"batch-settlement"`; verify/settle use the SDK's `resourceServer.verifyPayment` / `settlePayment`. All `extra` fields (`receiverAuthorizer`, `withdrawDelay`, `name`, `version`, `assetTransferMethod`) are SDK-injected. No custom payload fields. +- **Client** (`website/hooks/useX402Chat.ts`): all payment logic is the SDK (`BatchSettlementEvmScheme`, `@x402/fetch`'s `wrapFetchWithPayment`, `toClientEvmSigner`). The "manual" wiring the file comments mention is **just** `client.register(network, scheme)` — the SDK ships `registerExactEvmScheme` for the `exact` scheme but **no `registerBatchSettlementEvmScheme`** in 2.18.0, so those ~2 lines are unavoidable today and are a stock SDK call, not a reimplementation. The hand-written pieces (`WebStorageClientChannelStorage`, deposit strategy, delegate voucher-signer) all implement *documented SDK extension points*. + +**Implication:** a third-party agent using stock `@x402/evm` batch-settlement already interoperates with our frontend and server in both directions. G5 is met at the protocol level today. + +**Two honest caveats (constrain, do not break, interop):** +- **Network is Base-only** (`BATCH_SETTLEMENT_NETWORKS`, `x402_server.ts:26`) — the SDK's `DEFAULT_STABLECOINS` registry lacks Optimism mainnet, so `enhancePaymentRequirements` throws for `eip155:10`. A Base agent interoperates; an Optimism-only one would find no matching `accepts[]`. This is an SDK-registry limit, not our deviation. +- **One inaccurate comment to fix:** `sc_llm_x402.ts:40-42` claims the ceiling→actual settlement split uses the SDK's `setSettlementOverrides()`. That API does **not exist** in the installed SDK. The split works (verify demands the exact ceiling; settle enforces only `<= voucher.maxClaimableAmount`, so passing a smaller `amount` to `settlePayment` is legal), but the comment misattributes the mechanism. Correct the comment; no behavior change. + +--- + +## Where the coupling is today (LLM only) + +| # | Coupling | Location | Consequence | +|---|---|---|---| +| C1 | Endpoint URL is a module constant | `useX402Chat.ts` (`X402_LLM_URL`) | Env-overridable but single-valued — one seller | +| C2 | Request/response contract is implicit | `useX402Chat.ts` sends `{ data: { prompt } }`; expects `{ message, usage }` | "Compatible agent" is undefined until the shape is blessed as `llm/v1` | +| C3 | Provenance shown from stale single-tenant file | `useAgentInfo.ts` → `/agent-registration.json` (describes retired Merkle design) | The panel that should show "who you pay" reads an out-of-date self-description | +| C4 | Onboarding narrates a whitelisting flow being retired | `agent-onboarding/+Page.tsx` | Advertises manual GitHub whitelisting; not machine-usable | + +Note: the wire *protocol* is not a coupling (see Key finding). The coupling is entirely at the application layer — URL, request shape, and provenance display. + +--- + +## Target architecture + +``` + openapi.llm.json (the llm/v1 contract — already served at + llm-agent origin /openapi.json, versioned, CORS-open, ownership-proofed) + │ + ┌───────────┴────────────┐ + │ │ + website /assistent third-party agent + (one client of many) (already interoperable — stock SDK) + │ │ + └───────────┬────────────┘ + │ reads request/response schema + interop floor + ▼ + LLM endpoint (402 → accepts[] → pay via SDK → serve) +``` + +**One artifact, not three.** The service contract *is* the OpenAPI file we already publish — `LLMChatRequest` / `LLMChatResponse` are the request/response schema; there is no second JSON-Schema document to author (that was duplication). What must be *added* to promote it from "our endpoint's docs" to "the `llm/v1` standard": + +1. A `serviceType` marker in the OpenAPI (`x-service-type: "llm/v1"`). +2. The **interop floor**, stated alongside the contract (it is a runtime-402 rule, not an OpenAPI field): *a compatible `llm/v1` agent MUST advertise at least one `accepts[]` entry with asset USDC on network Base (`eip155:8453`), scheme `batch-settlement` or `exact`.* Since our batch-settlement is stock-standard, the floor names it directly. + +No registry document, no agent-card indirection layer for the MVP — the OpenAPI at the agent's own origin *is* the card. A multi-agent registry list is a later phase; the MVP uses a custom-URL escape hatch to prove openness. + +--- + +## Phases + +### Phase 1 — Bless the `llm/v1` contract (backend-only, low risk) +- [ ] Add `x-service-type: "llm/v1"` to `scw_js/openapi.llm.json`. +- [ ] Write the interop floor into the OpenAPI's `x-guidance` / a short `docs/llm-v1.md`: USDC on Base, scheme `batch-settlement` or `exact`. +- [ ] Optionally tighten `LLMChatRequest` (mandatory roles, message ordering) — low priority. +- [ ] Fix the inaccurate `setSettlementOverrides` comment in `sc_llm_x402.ts:40-42`. +- [ ] Confirm the legacy Merkle `sc_llm.ts` path is not reachable from `/assistent` (separate function; verify no shared coupling). + +### Phase 2 — Decouple the chat hook (the core MVP change) +- [ ] `useX402Chat` takes an `agentUrl` (or `agent` object) instead of the `X402_LLM_URL` constant (fixes C1). Default stays our own endpoint. +- [ ] Optional-but-recommended: if the target agent advertises only `exact` (no batch-settlement in `accepts[]`), fall back to `exact` (wallet-popup-per-message). `exact` already has `registerExactEvmScheme`. This is the one place scheme-awareness is worth it — a third-party chat agent may not run batch-settlement. Keep it minimal: read `accepts[]`, pick batch-settlement if offered else `exact`, else fail with a clear message. +- [ ] `AssistantChat` passes the selected agent's URL through. + +### Phase 3 — Provenance + escape hatch (proves G3/G4) +- [ ] Retarget provenance display off the stale `/agent-registration.json`: show operator + `payTo` + price + network parsed from the target agent's `/openapi.json` and its live 402 `accepts[]` (fixes C3). Reuse/replace `useAgentInfo`. +- [ ] Custom-URL input on `/assistent`: paste any `llm/v1` endpoint, no listing required (proves G3). +- [ ] Pre-payment disclosure: operator, `payTo`, price, network shown before the first paid message (G4). + +### Phase 4 (deferred, not this PR) — Registry + automated listing +A `registry.json` of multiple vetted agents, automated permissionless listing (ownership-proof + contract-validation + liveness probe). Explicitly out of scope for the MVP; the escape hatch stands in for it. Prepare toward it, do not build it yet. + +### Onboarding page (Option B, small, can ride Phase 3) +Demote `agent-onboarding/+Page.tsx` to **pure documentation**: strip the GitHub-issue generator and client-side JSON builder (the retired whitelisting flow, C4); keep the plain-English service explanation, the payment-flow diagram, and the request/response shapes — but **link to the live `openapi.llm.json`** instead of hardcoding them. Retitle from "onboarding" to "For agents / integration." It graduates to the registry's human face when Phase 4 lands. + +--- + +## Design record + +- **D1 — The OpenAPI file *is* the service contract.** No separate JSON-Schema document. `LLMChatRequest`/`LLMChatResponse` are the schema; a `serviceType` marker + the interop floor promote it to the `llm/v1` standard. Authoring a parallel schema would be duplication. +- **D2 — Interop floor, not lock-in (`D3` in the prior draft).** Each contract mandates a client-fulfillable minimum: ≥1 `accepts[]` entry, USDC, Base, scheme `batch-settlement` or `exact`. This lives in the runtime 402, stated beside the OpenAPI. Facilitator choice remains the seller's business; the client never talks to it directly. +- **D3 — We are already standard; do not re-engineer the protocol.** The only protocol-adjacent work is (a) fix the `setSettlementOverrides` comment, (b) adopt `registerBatchSettlementEvmScheme` if/when the SDK ships it. Neither blocks the MVP. +- **D4 — MVP is exactly one direction: the website consumes any agent.** The reverse direction (third parties discovering + consuming *our* endpoint) is **already done** — we are wire-standard (stock SDK) *and* discoverable/ownership-proofed on x402scan from the prior PR. An external consumer can find us and pay+call us today with nothing further from us. So the MVP builds only the consuming side: decouple the hook + escape hatch. A registry is a later phase; the escape hatch proves openness for the MVP. +- **D5 — ERC-8004 as format only, if at all.** Nothing minted, no gas, no on-chain lookup in the critical path. +- **D6 — External directories are listings, never dependencies.** + +--- + +## Open questions + +- **Q1 — Batch-settlement vs `exact` for third-party chat agents.** Batch-settlement has no public client register helper (SDK gap) but *we already use it fine* via the stock class. A third-party chat agent will likely advertise `exact` (wallet popup per message). Phase 2's fallback handles this. Is per-message-popup `exact` an acceptable MVP experience for third-party agents, with our own agent keeping the batch-settlement (popup-free) advantage? (Recommended: yes — honest, cheap, defers no protocol work.) +- **Q2 — Failure economics.** With BYO agents, a user pays an unknown `payTo` and may get nothing usable; x402 has no refund. For the MVP the escape hatch is explicitly user-initiated (they paste the URL), which bounds the risk to intentional use. Is a liveness pre-check needed before allowing a pasted URL, or is "you pasted it, at your own risk" sufficient for MVP? +- **Q3 — Does `/assistent` need multi-turn context across a changed agent?** If the user switches agents mid-conversation, channel state (per-origin in `localStorage`) is already scoped correctly, but conversation history semantics across a switch are undefined. Probably out of scope; confirm. diff --git a/scw_js/README.md b/scw_js/README.md index f1714ec28..d8f1b7579 100644 --- a/scw_js/README.md +++ b/scw_js/README.md @@ -68,6 +68,8 @@ Generates AI images using Black Forest Labs API with USDC payment via x402 proto LLM chat paid via x402 batch-settlement USDC payment channels — no bearer token, the payment voucher itself proves wallet control. `llmx402` handles chat requests (deposit/voucher/402 flow); `llmx402cron` claims and settles accumulated channels on a 12h schedule. See [`assistent_plan.md`](../assistent_plan.md) at the repo root for the full design record (deposit/voucher/claim/settle lifecycle, pricing model, gotchas). +**`llm/v1` contract.** [`openapi.llm.json`](./openapi.llm.json) declares `x-service-type: "llm/v1"` — the interchangeable-agent contract. It is defined entirely by that document: the request/response schema (`LLMChatRequest`/`LLMChatResponse`) plus the `x-interop-floor` (a compatible agent must advertise ≥1 `accepts[]` entry with USDC on Base `eip155:8453`, scheme `batch-settlement`). A `v2` aligning to the OpenAI chat-completions shape is the intended evolution, not yet built. + ### `growth_api.ts` - Growth Agent Draft Approval API for reviewing, editing, and approving AI-generated social media drafts. Used by the Growth Agent notebooks and cron job. diff --git a/scw_js/openapi.llm.json b/scw_js/openapi.llm.json index 5e965402f..bb3a75ef3 100644 --- a/scw_js/openapi.llm.json +++ b/scw_js/openapi.llm.json @@ -16,6 +16,8 @@ "0x08fd2874a7a85b7250830bf6be396953c108e197739ccc758e373036b2fe78a71ddcaf48183690b5f1d2a0049eb0af93068fb35eaaf59d266168eaeea1df357d1c" ] }, + "x-service-type": "llm/v1", + "x-interop-floor": "A compatible llm/v1 agent MUST advertise at least one accepts[] entry with asset USDC on network Base (eip155:8453), scheme batch-settlement. Request/response schema is defined by this document's LLMChatRequest/LLMChatResponse. See README.md.", "servers": [{ "url": "https://llm-agent.fretchen.eu" }], "tags": [ { "name": "LLM", "description": "AI chat assistant / text completion" }, diff --git a/scw_js/sc_llm_x402.ts b/scw_js/sc_llm_x402.ts index d97d41551..15053dd9f 100644 --- a/scw_js/sc_llm_x402.ts +++ b/scw_js/sc_llm_x402.ts @@ -36,10 +36,11 @@ const logger = pino({ level: process.env.LOG_LEVEL ?? "info" }); // The REAL, usage-derived charge is computed after the LLM call and passed to // settlePayment() as a *separate*, smaller requirements.amount — handleBeforeSettle only // enforces chargedCumulativeAmount + requirements.amount <= voucher.maxClaimableAmount (a -// ceiling, not equality), so verify and settle are free to use different amounts. This is -// the "authorize an upper bound, claim the real amount" pattern the SDK's -// setSettlementOverrides() wraps for Express apps — we do it manually here since we call -// settlePayment() directly. See getSettleAmount() below. +// ceiling, not equality), so verify and settle are free to use different amounts. We +// implement this "authorize an upper bound, claim the real amount" split manually: verify +// with the ceiling amount, then pass a smaller usage-derived amount to settlePayment(). +// (The installed @x402/evm — 2.18.0 — exposes no higher-level helper for this; we call the +// resourceServer verify/settle primitives directly.) See getSettleAmount() below. // This endpoint uses Mistral, not IONOS — see llm_service.ts's LLM_PROVIDERS. Legacy // sc_llm.ts (merkle settlement) is untouched and stays on IONOS. const LLM_PROVIDER = "mistral"; diff --git a/scw_js/test/sc_llm_x402.test.ts b/scw_js/test/sc_llm_x402.test.ts index df7ab91ce..2710ad1ea 100644 --- a/scw_js/test/sc_llm_x402.test.ts +++ b/scw_js/test/sc_llm_x402.test.ts @@ -208,6 +208,8 @@ describe("sc_llm_x402", () => { const body = JSON.parse(res.body); expect(body.openapi).toBe("3.1.0"); expect(body.info.title).toBeTruthy(); + // Declares the llm/v1 service contract so discovery clients can classify it. + expect(body["x-service-type"]).toBe("llm/v1"); expect(body.paths["/"].post["x-payment-info"]).toEqual({ protocols: ["x402"], price: { mode: "dynamic", currency: "USD", min: "0", max: "0.003" }, From 0afcd92af37dc483b3598564f001bbbfb1fa0250 Mon Sep 17 00:00:00 2001 From: fretchen Date: Tue, 28 Jul 2026 08:38:19 +0200 Subject: [PATCH 2/6] Decouple useX402Chat to target any llm/v1 agent URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useX402Chat now takes an optional agentUrl (defaulting to fretchen's own endpoint) instead of hardcoding X402_LLM_URL, so /assistent can pay and call any llm/v1 batch-settlement agent. Reconcile the default to the clean llm-agent.fretchen.eu origin now advertised in openapi.llm.json. Channel state in localStorage is already keyed per-origin, so switching agents is isolated. No scheme change — still stock batch-settlement. Co-Authored-By: Claude Opus 4.8 --- website/hooks/useX402Chat.ts | 25 +++++---- website/test/useX402Chat.test.ts | 95 ++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 10 deletions(-) create mode 100644 website/test/useX402Chat.test.ts diff --git a/website/hooks/useX402Chat.ts b/website/hooks/useX402Chat.ts index 1b9f813c1..2e39fb069 100644 --- a/website/hooks/useX402Chat.ts +++ b/website/hooks/useX402Chat.ts @@ -24,11 +24,13 @@ import type { BatchSettlementDepositStrategyContext, } from "@x402/evm/batch-settlement/client"; -// Endpoint of the batch-settlement chat function (override for local dev with -// PUBLIC_ENV__LLM_X402_ENDPOINT=http://localhost:8085). -const X402_LLM_URL = - (import.meta.env.PUBLIC_ENV__LLM_X402_ENDPOINT as string | undefined) ?? - "https://mypersonaljscloudivnad9dy-llmx402.functions.fnc.fr-par.scw.cloud"; +// Default batch-settlement chat agent — fretchen's own llm/v1 endpoint (the origin +// advertised in scw_js/openapi.llm.json). Override for local dev with +// PUBLIC_ENV__LLM_X402_ENDPOINT=http://localhost:8085. Callers may also pass an explicit +// agentUrl to useX402Chat to target any other llm/v1 agent (see the open-agent-platform +// work) — this constant is only the fallback when none is given. +const DEFAULT_LLM_AGENT_URL = + (import.meta.env.PUBLIC_ENV__LLM_X402_ENDPOINT as string | undefined) ?? "https://llm-agent.fretchen.eu"; /** * Client-side `ClientChannelStorage` backed by the Web Storage API. Persists channel @@ -141,8 +143,11 @@ export interface UseX402ChatResult { * @param network - CAIP-2 network the channel operates on (e.g. "eip155:84532"). * Determines the public client used for on-chain channel reads and the scheme * registration network. + * @param agentUrl - The llm/v1 agent endpoint to pay and call. Defaults to fretchen's + * own endpoint (`DEFAULT_LLM_AGENT_URL`); pass any other llm/v1 agent to target it. + * Channel state in localStorage is keyed per-origin, so switching agents is isolated. */ -export function useX402Chat(network: string): UseX402ChatResult { +export function useX402Chat(network: string, agentUrl: string = DEFAULT_LLM_AGENT_URL): UseX402ChatResult { const { data: walletClient } = useWalletClient(); const { isConnected } = useAccount(); // A readContract-capable client is required: batch-settlement's corrective-402 @@ -212,8 +217,8 @@ export function useX402Chat(network: string): UseX402ChatResult { const offered = decoded.accepts?.map((a) => a.network).filter(Boolean) as string[] | undefined; if (offered && offered.length > 0 && !offered.includes(network)) { throw new Error( - `Server does not offer ${network}. Offered: ${offered.join(", ")}. ` + - `This could indicate a backend configuration error.`, + `Agent ${agentUrl} does not offer ${network}. Offered: ${offered.join(", ")}. ` + + `Pick a network this agent supports, or choose a different agent.`, ); } } catch (parseError) { @@ -232,7 +237,7 @@ export function useX402Chat(network: string): UseX402ChatResult { // First bare request → 402 → SDK opens channel (deposit) or signs a voucher → retries. // Wrapped in try/catch as defense-in-depth (see notebook: a client-side crash could // once mask a successful settlement; the underlying facilitator bug is fixed). - const response = await fetchWithPayment(X402_LLM_URL, { + const response = await fetchWithPayment(agentUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ data: { prompt } }), @@ -267,7 +272,7 @@ export function useX402Chat(network: string): UseX402ChatResult { throw err; } }, - [walletClient, publicClient, network], + [walletClient, publicClient, network, agentUrl], ); const reset = useCallback(() => { diff --git a/website/test/useX402Chat.test.ts b/website/test/useX402Chat.test.ts new file mode 100644 index 000000000..aba1317f9 --- /dev/null +++ b/website/test/useX402Chat.test.ts @@ -0,0 +1,95 @@ +/** + * useX402Chat Hook Tests + * + * Focuses on the agent-URL decoupling (the open-agent-platform change): the hook must POST + * to the `agentUrl` it is given, and fall back to the default fretchen endpoint when none is + * passed. The batch-settlement SDK is mocked at the `@x402/fetch` seam so the paid-fetch URL + * can be captured without standing up the real channel machinery. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useWalletClient, useAccount } from "wagmi"; + +// Capture the URL the SDK-wrapped fetch is called with. +const paidFetchSpy = vi.fn(async () => ({ + ok: true, + status: 200, + headers: { get: () => null }, + json: async () => ({ message: "hi", usage: { prompt_tokens: 1, completion_tokens: 1 } }), + text: async () => "", +})); + +vi.mock("@x402/fetch", () => ({ + x402Client: class { + register() {} + }, + x402HTTPClient: class { + getPaymentSettleResponse() { + return null; + } + }, + // wrapFetchWithPayment(fetchImpl, client) normally returns a fetch that pays on 402; + // here it just returns our spy so we can assert the URL the hook targets. + wrapFetchWithPayment: () => paidFetchSpy, +})); + +vi.mock("@x402/evm", () => ({ toClientEvmSigner: () => ({}) })); +vi.mock("@x402/evm/batch-settlement/client", () => ({ + BatchSettlementEvmScheme: class {}, +})); + +vi.mock("wagmi", () => ({ + useWalletClient: vi.fn(), + useAccount: vi.fn(), +})); + +vi.mock("../hooks/useConfiguredPublicClient", () => ({ + useConfiguredPublicClient: () => ({}), +})); + +import { useX402Chat } from "../hooks/useX402Chat"; + +const NETWORK = "eip155:84532"; + +function connectWallet() { + vi.mocked(useWalletClient).mockReturnValue({ + data: { + account: { address: "0x1234567890123456789012345678901234567890" }, + signTypedData: vi.fn(), + }, + } as unknown as ReturnType); + vi.mocked(useAccount).mockReturnValue({ + isConnected: true, + address: "0x1234567890123456789012345678901234567890", + } as ReturnType); +} + +describe("useX402Chat agent-URL decoupling", () => { + beforeEach(() => { + vi.clearAllMocks(); + connectWallet(); + }); + + it("POSTs to the provided agentUrl", async () => { + const agentUrl = "https://someone-elses-agent.example"; + const { result } = renderHook(() => useX402Chat(NETWORK, agentUrl)); + + await act(async () => { + await result.current.sendMessage([{ role: "user", content: "hi" }]); + }); + + expect(paidFetchSpy).toHaveBeenCalledTimes(1); + expect(paidFetchSpy.mock.calls[0][0]).toBe(agentUrl); + }); + + it("falls back to the default fretchen endpoint when no agentUrl is given", async () => { + const { result } = renderHook(() => useX402Chat(NETWORK)); + + await act(async () => { + await result.current.sendMessage([{ role: "user", content: "hi" }]); + }); + + expect(paidFetchSpy).toHaveBeenCalledTimes(1); + expect(paidFetchSpy.mock.calls[0][0]).toBe("https://llm-agent.fretchen.eu"); + }); +}); From d0bc9c52996f6d68a4209094e4870d13be660872 Mon Sep 17 00:00:00 2001 From: fretchen Date: Tue, 28 Jul 2026 08:46:40 +0200 Subject: [PATCH 3/6] Clean hook --- website/hooks/useX402Chat.ts | 27 ++---- website/hooks/x402Discovery.ts | 138 +++++++++++++++++++++++++++++ website/test/x402Discovery.test.ts | 113 +++++++++++++++++++++++ 3 files changed, 260 insertions(+), 18 deletions(-) create mode 100644 website/hooks/x402Discovery.ts create mode 100644 website/test/x402Discovery.test.ts diff --git a/website/hooks/useX402Chat.ts b/website/hooks/useX402Chat.ts index 2e39fb069..f0332e361 100644 --- a/website/hooks/useX402Chat.ts +++ b/website/hooks/useX402Chat.ts @@ -16,6 +16,7 @@ import { useState, useCallback } from "react"; import { useWalletClient, useAccount } from "wagmi"; import { privateKeyToAccount, generatePrivateKey } from "viem/accounts"; import { useConfiguredPublicClient } from "./useConfiguredPublicClient"; +import { decodePaymentRequired } from "./x402Discovery"; import type { X402ChatMessage, X402ChatResponse, X402PaymentReceipt, X402GenerationStatus } from "../types/x402"; // Type-only import — erased at compile time, so no @x402 runtime is pulled into SSR. import type { @@ -208,24 +209,14 @@ export function useX402Chat(network: string, agentUrl: string = DEFAULT_LLM_AGEN const validatingFetch: typeof fetch = async (input, init) => { const response = await fetch(input, init); if (response.status === 402) { - const paymentRequiredHeader = response.headers.get("Payment-Required"); - if (paymentRequiredHeader) { - try { - const decoded = JSON.parse(atob(paymentRequiredHeader)) as { - accepts?: Array<{ network?: string }>; - }; - const offered = decoded.accepts?.map((a) => a.network).filter(Boolean) as string[] | undefined; - if (offered && offered.length > 0 && !offered.includes(network)) { - throw new Error( - `Agent ${agentUrl} does not offer ${network}. Offered: ${offered.join(", ")}. ` + - `Pick a network this agent supports, or choose a different agent.`, - ); - } - } catch (parseError) { - if (parseError instanceof Error && parseError.message.includes("does not offer")) { - throw parseError; - } - // Silently continue if header parsing fails — the request will proceed + const accepts = decodePaymentRequired(response.headers.get("Payment-Required")); + if (accepts) { + const offered = accepts.map((a) => a.network).filter(Boolean) as string[]; + if (offered.length > 0 && !offered.includes(network)) { + throw new Error( + `Agent ${agentUrl} does not offer ${network}. Offered: ${offered.join(", ")}. ` + + `Pick a network this agent supports, or choose a different agent.`, + ); } } } diff --git a/website/hooks/x402Discovery.ts b/website/hooks/x402Discovery.ts new file mode 100644 index 000000000..585600474 --- /dev/null +++ b/website/hooks/x402Discovery.ts @@ -0,0 +1,138 @@ +/** + * x402 / llm/v1 discovery utilities. + * + * Shared helpers for reading an llm/v1 agent's published contract (`/openapi.json`) and its + * live `402` payment requirements, so the frontend can (a) show provenance before paying and + * (b) pre-check a pasted custom agent before enabling the chat box. See the open-agent-platform + * design in `open_agent_platform_plan.md` and the contract in `scw_js/README.md` / `openapi.llm.json`. + */ + +/** A single entry of the x402 402-response `accepts[]` array (the fields we read). */ +export interface AcceptsEntry { + scheme?: string; + network?: string; + amount?: string; + asset?: string; + payTo?: string; + extra?: { name?: string; version?: string }; +} + +/** The `llm/v1` interop floor: the scheme/network a client here must be able to fulfil. */ +export const LLM_V1_FLOOR = { + network: "eip155:8453", // Base mainnet + scheme: "batch-settlement", +} as const; + +/** + * Decode the base64 `Payment-Required` header of a 402 response into its `accepts[]` array. + * Returns `null` if the header is absent or unparseable (callers treat that as "unknown"). + */ +export function decodePaymentRequired(headerValue: string | null): AcceptsEntry[] | null { + if (!headerValue) return null; + try { + const decoded = JSON.parse(atob(headerValue)) as { accepts?: AcceptsEntry[] }; + return decoded.accepts ?? null; + } catch { + return null; + } +} + +/** True if any `accepts[]` entry satisfies the llm/v1 interop floor (Base + batch-settlement). */ +export function meetsLlmV1Floor(accepts: AcceptsEntry[] | null): boolean { + if (!accepts) return false; + return accepts.some((a) => a.network === LLM_V1_FLOOR.network && a.scheme === LLM_V1_FLOOR.scheme); +} + +/** Normalise an agent URL to its origin (scheme + host, no path/trailing slash). */ +export function agentOrigin(agentUrl: string): string { + return new URL(agentUrl).origin; +} + +/** Provenance derived from an agent's OpenAPI doc + live 402, for pre-payment disclosure. */ +export interface AgentCard { + origin: string; + title: string | null; + operator: string | null; + contactUrl: string | null; + /** The receiving address from the floor-matching accepts[] entry, if seen. */ + payTo: string | null; + /** Price in USDC atomic units (6 decimals) from x-payment-info, best-effort. */ + network: string | null; +} + +export interface PreCheckResult { + ok: boolean; + reason?: string; + card?: AgentCard; +} + +interface OpenApiDoc { + "x-service-type"?: string; + info?: { title?: string; contact?: { name?: string; url?: string } }; +} + +/** + * Liveness + contract pre-check for a (possibly third-party) llm/v1 agent URL: + * 1. fetch `/openapi.json`, require `x-service-type === "llm/v1"`; + * 2. send a bare POST and require a 402 whose accepts[] meets the interop floor. + * On success, returns the provenance card for disclosure. Never throws — failures come back + * as `{ ok: false, reason }` so the caller can show a clear message and keep the box disabled. + */ +export async function precheckLlmV1Agent(agentUrl: string): Promise { + let origin: string; + try { + origin = agentOrigin(agentUrl); + } catch { + return { ok: false, reason: "That does not look like a valid URL." }; + } + + // 1. Contract doc. + let doc: OpenApiDoc; + try { + const res = await fetch(`${origin}/openapi.json`, { method: "GET" }); + if (!res.ok) return { ok: false, reason: `No OpenAPI document at ${origin}/openapi.json (${res.status}).` }; + doc = (await res.json()) as OpenApiDoc; + } catch { + return { ok: false, reason: `Could not fetch ${origin}/openapi.json.` }; + } + if (doc["x-service-type"] !== "llm/v1") { + return { ok: false, reason: `${origin} is not an llm/v1 agent (missing x-service-type: "llm/v1").` }; + } + + // 2. Live 402 meeting the interop floor. + let accepts: AcceptsEntry[] | null; + try { + const res = await fetch(agentUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ data: { prompt: [] } }), + }); + if (res.status !== 402) { + return { ok: false, reason: `Expected a 402 payment challenge, got ${res.status}.` }; + } + accepts = decodePaymentRequired(res.headers.get("Payment-Required")); + } catch { + return { ok: false, reason: `Could not reach ${agentUrl} for a payment challenge.` }; + } + if (!meetsLlmV1Floor(accepts)) { + return { + ok: false, + reason: `This agent does not offer the required payment option (USDC on Base, batch-settlement).`, + }; + } + + const match = accepts!.find( + (a) => a.network === LLM_V1_FLOOR.network && a.scheme === LLM_V1_FLOOR.scheme, + ); + return { + ok: true, + card: { + origin, + title: doc.info?.title ?? null, + operator: doc.info?.contact?.name ?? null, + contactUrl: doc.info?.contact?.url ?? null, + payTo: match?.payTo ?? null, + network: match?.network ?? null, + }, + }; +} diff --git a/website/test/x402Discovery.test.ts b/website/test/x402Discovery.test.ts new file mode 100644 index 000000000..f97ca5efb --- /dev/null +++ b/website/test/x402Discovery.test.ts @@ -0,0 +1,113 @@ +/** + * Tests for the llm/v1 discovery helpers: the Payment-Required decode, the interop-floor + * check, and the agent pre-check (contract doc + live 402 probe). + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + decodePaymentRequired, + meetsLlmV1Floor, + precheckLlmV1Agent, + type AcceptsEntry, +} from "../hooks/x402Discovery"; + +const floorEntry: AcceptsEntry = { + scheme: "batch-settlement", + network: "eip155:8453", + asset: "0xusdc", + payTo: "0xAAEBC1441323B8ad6Bdf6793A8428166b510239C", +}; + +function paymentRequiredHeader(accepts: AcceptsEntry[]): string { + return btoa(JSON.stringify({ accepts })); +} + +describe("decodePaymentRequired", () => { + it("returns null for a missing header", () => { + expect(decodePaymentRequired(null)).toBeNull(); + }); + it("returns null for an unparseable header", () => { + expect(decodePaymentRequired("not-base64-json")).toBeNull(); + }); + it("decodes the accepts array", () => { + expect(decodePaymentRequired(paymentRequiredHeader([floorEntry]))).toEqual([floorEntry]); + }); +}); + +describe("meetsLlmV1Floor", () => { + it("accepts Base + batch-settlement", () => { + expect(meetsLlmV1Floor([floorEntry])).toBe(true); + }); + it("rejects a non-Base or non-batch-settlement offer", () => { + expect(meetsLlmV1Floor([{ scheme: "exact", network: "eip155:8453" }])).toBe(false); + expect(meetsLlmV1Floor([{ scheme: "batch-settlement", network: "eip155:10" }])).toBe(false); + expect(meetsLlmV1Floor(null)).toBe(false); + }); +}); + +describe("precheckLlmV1Agent", () => { + beforeEach(() => vi.restoreAllMocks()); + + function mockFetch(handlers: { + openapi?: { status: number; body?: unknown }; + probe?: { status: number; accepts?: AcceptsEntry[] }; + }) { + vi.stubGlobal( + "fetch", + vi.fn(async (input: string) => { + const url = String(input); + if (url.endsWith("/openapi.json")) { + const h = handlers.openapi ?? { status: 200, body: { "x-service-type": "llm/v1" } }; + return { + ok: h.status >= 200 && h.status < 300, + status: h.status, + json: async () => h.body ?? {}, + }; + } + // The bare POST probe. + const p = handlers.probe ?? { status: 402, accepts: [floorEntry] }; + return { + status: p.status, + headers: { get: () => (p.accepts ? paymentRequiredHeader(p.accepts) : null) }, + }; + }) as unknown as typeof fetch, + ); + } + + it("passes a well-formed llm/v1 agent and returns its provenance card", async () => { + mockFetch({ + openapi: { status: 200, body: { "x-service-type": "llm/v1", info: { title: "T", contact: { name: "fretchen", url: "https://x" } } } }, + probe: { status: 402, accepts: [floorEntry] }, + }); + const res = await precheckLlmV1Agent("https://agent.example"); + expect(res.ok).toBe(true); + expect(res.card?.operator).toBe("fretchen"); + expect(res.card?.payTo).toBe(floorEntry.payTo); + expect(res.card?.origin).toBe("https://agent.example"); + }); + + it("fails when x-service-type is not llm/v1", async () => { + mockFetch({ openapi: { status: 200, body: { "x-service-type": "something-else" } } }); + const res = await precheckLlmV1Agent("https://agent.example"); + expect(res.ok).toBe(false); + expect(res.reason).toMatch(/not an llm\/v1 agent/); + }); + + it("fails when the 402 does not meet the interop floor", async () => { + mockFetch({ probe: { status: 402, accepts: [{ scheme: "exact", network: "eip155:8453" }] } }); + const res = await precheckLlmV1Agent("https://agent.example"); + expect(res.ok).toBe(false); + expect(res.reason).toMatch(/required payment option/); + }); + + it("fails when the endpoint does not return a 402", async () => { + mockFetch({ probe: { status: 200 } }); + const res = await precheckLlmV1Agent("https://agent.example"); + expect(res.ok).toBe(false); + expect(res.reason).toMatch(/Expected a 402/); + }); + + it("fails cleanly for a malformed URL", async () => { + const res = await precheckLlmV1Agent("not a url"); + expect(res.ok).toBe(false); + }); +}); From a1f35dc32964054a7875353d8d1edbdf9c5137bf Mon Sep 17 00:00:00 2001 From: fretchen Date: Tue, 28 Jul 2026 08:53:12 +0200 Subject: [PATCH 4/6] Add more logical concerning agent connection --- website/components/AgentInfoPanel.tsx | 23 +- website/components/AgentSelector.tsx | 105 +++++++ website/components/AssistantChat.tsx | 67 +++- website/hooks/useX402Chat.test.ts | 359 ---------------------- website/hooks/x402Discovery.ts | 4 +- website/test/useX402Chat.test.ts | 425 ++++++++++++++++++++++---- website/test/x402Discovery.test.ts | 12 +- 7 files changed, 559 insertions(+), 436 deletions(-) create mode 100644 website/components/AgentSelector.tsx delete mode 100644 website/hooks/useX402Chat.test.ts diff --git a/website/components/AgentInfoPanel.tsx b/website/components/AgentInfoPanel.tsx index 56c8f8936..57835a376 100644 --- a/website/components/AgentInfoPanel.tsx +++ b/website/components/AgentInfoPanel.tsx @@ -20,15 +20,19 @@ import { useAgentInfo } from "../hooks/useAgentInfo"; import { useLocale } from "../hooks/useLocale"; import { useAutoNetwork } from "../hooks/useAutoNetwork"; import { getGenAiNFTAddress, GENAI_NFT_NETWORKS } from "@fretchen/chain-utils"; +import type { AgentCard } from "../hooks/x402Discovery"; interface AgentInfoPanelProps { // Service context (for display purposes) service?: "genimg" | "llm"; // Layout variant variant?: "footer" | "sidebar"; + // When set (llm escape hatch), show this pre-checked agent's provenance instead of the + // default single-tenant registration file. Derived live from the agent's own /openapi.json. + agentCard?: AgentCard | null; } -export function AgentInfoPanel({ service = "genimg", variant = "footer" }: AgentInfoPanelProps) { +export function AgentInfoPanel({ service = "genimg", variant = "footer", agentCard = null }: AgentInfoPanelProps) { const [isExpanded, setIsExpanded] = useState(false); const { agent, isLoading, error } = useAgentInfo(); @@ -40,6 +44,23 @@ export function AgentInfoPanel({ service = "genimg", variant = "footer" }: Agent const isSidebar = variant === "sidebar"; + // Escape-hatch agent selected: render its live provenance (operator + payTo + origin) + // rather than the default registration file. Kept intentionally compact. + if (agentCard) { + return ( +
+
{agentCard.operator ?? agentCard.origin}
+
{agentCard.origin}
+ {agentCard.payTo && ( +
+ pays → {agentCard.payTo.slice(0, 6)}…{agentCard.payTo.slice(-4)} +
+ )} +
third-party agent · at your own risk
+
+ ); + } + if (isLoading) { return (
diff --git a/website/components/AgentSelector.tsx b/website/components/AgentSelector.tsx new file mode 100644 index 000000000..925bd3c41 --- /dev/null +++ b/website/components/AgentSelector.tsx @@ -0,0 +1,105 @@ +/** + * AgentSelector — the /assistent escape hatch (open-agent-platform G3/G4). + * + * Lets the user point the chat at any llm/v1 agent by URL. The URL is pre-checked + * (`precheckLlmV1Agent`) before it can be selected, and the resulting provenance + * (operator + payTo + origin) is shown so the user knows who they are about to pay. + * State lives in the parent (AssistantChat) because the selected URL also feeds useX402Chat. + */ +import React from "react"; +import { css } from "../styled-system/css"; +import * as styles from "../layouts/styles"; +import type { AgentCard } from "../hooks/x402Discovery"; + +export interface AgentSelectorProps { + /** The pasted URL (controlled input). */ + customUrlInput: string; + onCustomUrlInputChange: (value: string) => void; + /** Provenance of the currently selected custom agent, or null when on the default. */ + customCard: AgentCard | null; + checkState: "idle" | "checking" | "error"; + checkError: string | null; + onTryCustomAgent: () => void; + onUseDefaultAgent: () => void; +} + +const rowStyle = css({ display: "flex", gap: "2", flexWrap: "wrap", alignItems: "center", mt: "2" }); +const monoStyle = css({ fontFamily: "mono", fontSize: "xs", color: "gray.600", wordBreak: "break-all" }); +const errorStyle = css({ fontSize: "xs", color: "red.600", mt: "1" }); +const inputStyle = css({ + width: "100%", + fontSize: "xs", + px: "2", + py: "1", + border: "1px solid", + borderColor: "gray.300", + borderRadius: "md", + _focus: { outline: "none", borderColor: "brand" }, +}); + +export function AgentSelector({ + customUrlInput, + onCustomUrlInputChange, + customCard, + checkState, + checkError, + onTryCustomAgent, + onUseDefaultAgent, +}: AgentSelectorProps) { + const checking = checkState === "checking"; + + return ( +
+
+ {customCard ? "Custom agent (at your own risk)" : "Use a different agent"} +
+ + {customCard ? ( + // Pre-payment disclosure for the selected custom agent (G4/G6). +
+
{customCard.operator ?? customCard.origin}
+ {customCard.payTo && ( +
+ pays → {customCard.payTo.slice(0, 6)}…{customCard.payTo.slice(-4)} +
+ )} +
+ +
+
+ ) : ( +
+ onCustomUrlInputChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + onTryCustomAgent(); + } + }} + disabled={checking} + className={inputStyle} + /> +
+ +
+ {checkState === "error" && checkError &&
{checkError}
} +
+ )} +
+ ); +} + +export default AgentSelector; diff --git a/website/components/AssistantChat.tsx b/website/components/AssistantChat.tsx index 275323ebf..7aded3cec 100644 --- a/website/components/AssistantChat.tsx +++ b/website/components/AssistantChat.tsx @@ -7,6 +7,7 @@ import React, { useState, useMemo } from "react"; import { AgentInfoPanel } from "./AgentInfoPanel"; +import { AgentSelector } from "./AgentSelector"; import * as styles from "../layouts/styles"; import { useLocale } from "../hooks/useLocale"; import { useUmami } from "../hooks/useUmami"; @@ -14,6 +15,7 @@ import { css } from "../styled-system/css"; import { useWalletConnection } from "../hooks/useWalletConnection"; import { useAutoNetwork } from "../hooks/useAutoNetwork"; import { useX402Chat } from "../hooks/useX402Chat"; +import { precheckLlmV1Agent, type AgentCard } from "../hooks/x402Discovery"; import { getViemChain } from "@fretchen/chain-utils"; interface ChatMessage { @@ -72,7 +74,44 @@ export function AssistantChat() { const { isConnected, connectWallet } = useWalletConnection(); const { network, switchIfNeeded, switchError } = useAutoNetwork(CHAT_NETWORKS); - const { sendMessage: payAndSend, paymentReceipt } = useX402Chat(network); + + // Custom agent (escape hatch, G3): when the user pastes a URL that passes the llm/v1 + // pre-check, chat is served by that agent instead of the default fretchen endpoint. While + // no custom agent is selected, `selectedAgentUrl` is undefined and useX402Chat falls back + // to its own default (proving the default has no privileged path). + const [selectedAgentUrl, setSelectedAgentUrl] = useState(undefined); + const [customUrlInput, setCustomUrlInput] = useState(""); + const [checkState, setCheckState] = useState<"idle" | "checking" | "error">("idle"); + const [checkError, setCheckError] = useState(null); + const [customCard, setCustomCard] = useState(null); + + const { sendMessage: payAndSend, paymentReceipt } = useX402Chat(network, selectedAgentUrl); + + const tryCustomAgent = async () => { + const url = customUrlInput.trim(); + if (!url) return; + setCheckState("checking"); + setCheckError(null); + const result = await precheckLlmV1Agent(url); + if (result.ok && result.card) { + setSelectedAgentUrl(url); + setCustomCard(result.card); + setCheckState("idle"); + setMessages([]); // fresh conversation on the new agent + } else { + setCheckState("error"); + setCheckError(result.reason ?? "This agent could not be verified."); + } + }; + + const useDefaultAgent = () => { + setSelectedAgentUrl(undefined); + setCustomCard(null); + setCustomUrlInput(""); + setCheckState("idle"); + setCheckError(null); + setMessages([]); + }; const buttonState = useMemo(() => { if (!isConnected) return "connect"; @@ -186,7 +225,16 @@ export function AssistantChat() { {/* Agent Info Section */}

Agent

- + + void tryCustomAgent()} + onUseDefaultAgent={useDefaultAgent} + />
)} @@ -270,7 +318,20 @@ export function AssistantChat() { {/* Agent Info - Mobile Footer */} - {isMobile && } + {isMobile && ( + <> + + void tryCustomAgent()} + onUseDefaultAgent={useDefaultAgent} + /> + + )} diff --git a/website/hooks/useX402Chat.test.ts b/website/hooks/useX402Chat.test.ts deleted file mode 100644 index 0920a2631..000000000 --- a/website/hooks/useX402Chat.test.ts +++ /dev/null @@ -1,359 +0,0 @@ -/** - * useX402Chat Hook Tests - * - * Unlike useX402ImageGeneration.test.ts (which routes around the dynamic @x402/* - * imports entirely), this mocks @x402/fetch, @x402/evm, and - * @x402/evm/batch-settlement/client so sendMessage()'s real pay-and-fetch logic - * runs end-to-end against a mocked SDK — closing the SDK-mocking gap the sibling - * test leaves open. - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { renderHook, act } from "@testing-library/react"; -import { useWalletClient, useAccount } from "wagmi"; -import { useX402Chat, WebStorageClientChannelStorage } from "./useX402Chat"; -import type { X402ChatMessage } from "../types/x402"; - -const mockRegister = vi.fn(); -const mockGetPaymentSettleResponse = vi.fn(); -const mockBatchSettlementEvmScheme = vi.fn(); -const mockToClientEvmSigner = vi.fn((signer: unknown) => signer); - -vi.mock("../hooks/useConfiguredPublicClient", () => ({ - useConfiguredPublicClient: vi.fn(() => ({ readContract: vi.fn() })), -})); - -vi.mock("@x402/fetch", () => ({ - // vi.fn() needs a real `function`, not an arrow, to remain usable via `new`. - x402Client: vi.fn().mockImplementation(function MockX402Client() { - return { register: mockRegister }; - }), - // Pass the caller's fetch straight through — lets us drive the real - // validatingFetch → global fetch path from the hook without a real SDK. - wrapFetchWithPayment: vi.fn((fetchFn: typeof fetch) => fetchFn), - x402HTTPClient: vi.fn().mockImplementation(function MockX402HTTPClient() { - return { getPaymentSettleResponse: mockGetPaymentSettleResponse }; - }), -})); - -vi.mock("@x402/evm", () => ({ - toClientEvmSigner: (...args: unknown[]) => mockToClientEvmSigner(...args), -})); - -vi.mock("@x402/evm/batch-settlement/client", () => ({ - BatchSettlementEvmScheme: mockBatchSettlementEvmScheme, -})); - -const NETWORK = "eip155:84532"; -const mockWalletClient = { - account: { address: "0x1234567890123456789012345678901234567890" }, - signTypedData: vi.fn(), -}; - -describe("useX402Chat", () => { - beforeEach(() => { - vi.clearAllMocks(); - window.localStorage.clear(); - mockGetPaymentSettleResponse.mockReturnValue({ - success: true, - transaction: "0xdeposit", - network: NETWORK, - }); - }); - - describe("Initial State", () => { - it("should not be ready when wallet not connected", () => { - vi.mocked(useWalletClient).mockReturnValue({ data: undefined } as ReturnType); - vi.mocked(useAccount).mockReturnValue({ isConnected: false } as ReturnType); - - const { result } = renderHook(() => useX402Chat(NETWORK)); - - expect(result.current.status).toBe("idle"); - expect(result.current.isReady).toBe(false); - }); - - it("should be ready when wallet is connected", () => { - vi.mocked(useWalletClient).mockReturnValue({ data: mockWalletClient } as ReturnType); - vi.mocked(useAccount).mockReturnValue({ isConnected: true } as ReturnType); - - const { result } = renderHook(() => useX402Chat(NETWORK)); - - expect(result.current.isReady).toBe(true); - }); - }); - - describe("Error Handling", () => { - it("should throw when sendMessage called without a wallet", async () => { - vi.mocked(useWalletClient).mockReturnValue({ data: undefined } as ReturnType); - vi.mocked(useAccount).mockReturnValue({ isConnected: false } as ReturnType); - - const { result } = renderHook(() => useX402Chat(NETWORK)); - const prompt: X402ChatMessage[] = [{ role: "user", content: "Hi" }]; - - await expect(result.current.sendMessage(prompt)).rejects.toThrow("Wallet not connected"); - }); - }); - - describe("Paid request (mocked SDK)", () => { - beforeEach(() => { - vi.mocked(useWalletClient).mockReturnValue({ data: mockWalletClient } as ReturnType); - vi.mocked(useAccount).mockReturnValue({ isConnected: true } as ReturnType); - }); - - it("registers the batch-settlement scheme on the requested network", async () => { - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue(new Response(JSON.stringify({ content: "hi" }), { status: 200 })), - ); - - const { result } = renderHook(() => useX402Chat(NETWORK)); - await act(async () => { - await result.current.sendMessage([{ role: "user", content: "Hi" }]); - }); - - expect(mockToClientEvmSigner).toHaveBeenCalled(); - expect(mockBatchSettlementEvmScheme).toHaveBeenCalledWith( - expect.objectContaining({ address: mockWalletClient.account.address }), - expect.objectContaining({ - storage: expect.any(WebStorageClientChannelStorage), - voucherSigner: expect.objectContaining({ address: expect.stringMatching(/^0x[a-fA-F0-9]{40}$/) }), - depositStrategy: expect.any(Function), - }), - ); - expect(mockRegister).toHaveBeenCalledWith(NETWORK, expect.anything()); - }); - - it("deposit strategy floors deposits/top-ups at $0.50, ignoring the SDK's smaller default", async () => { - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue(new Response(JSON.stringify({ content: "hi" }), { status: 200 })), - ); - - const { result } = renderHook(() => useX402Chat(NETWORK)); - await act(async () => { - await result.current.sendMessage([{ role: "user", content: "Hi" }]); - }); - - const { depositStrategy } = mockBatchSettlementEvmScheme.mock.calls[0][1] as { - depositStrategy: (ctx: { minimumDepositAmount: string }) => string; - }; - - // Below the floor (e.g. the SDK's own ~1-3 cent default): clamp up to $0.50. - expect(depositStrategy({ minimumDepositAmount: "15000" })).toBe("500000"); - // Above the floor (an unusually expensive top-up): the SDK requires >= this amount, - // so it must be respected, not clamped down. - expect(depositStrategy({ minimumDepositAmount: "600000" })).toBe("600000"); - // Exactly at the floor: either value is correct; assert it's still >= minimum. - expect(BigInt(depositStrategy({ minimumDepositAmount: "500000" }))).toBeGreaterThanOrEqual(500_000n); - }); - - it("reuses the same delegated voucher signer across multiple messages", async () => { - vi.stubGlobal( - "fetch", - vi - .fn() - .mockImplementation(() => Promise.resolve(new Response(JSON.stringify({ content: "hi" }), { status: 200 }))), - ); - - const { result } = renderHook(() => useX402Chat(NETWORK)); - - await act(async () => { - await result.current.sendMessage([{ role: "user", content: "First" }]); - }); - const firstVoucherSigner = mockBatchSettlementEvmScheme.mock.calls[0][1].voucherSigner as { - address: string; - }; - - await act(async () => { - await result.current.sendMessage([{ role: "user", content: "Second" }]); - }); - const secondVoucherSigner = mockBatchSettlementEvmScheme.mock.calls[1][1].voucherSigner as { - address: string; - }; - - // Same delegate key both times — proves it's persisted (localStorage), not regenerated - // per call. A fresh key each call would silently open a brand-new channel every message. - expect(secondVoucherSigner.address).toBe(firstVoucherSigner.address); - }); - - it("returns the parsed response and sets status through success", async () => { - vi.stubGlobal( - "fetch", - vi - .fn() - .mockResolvedValue( - new Response(JSON.stringify({ content: "Paris is the capital of France." }), { status: 200 }), - ), - ); - - const { result } = renderHook(() => useX402Chat(NETWORK)); - - let response: { content: string } | undefined; - await act(async () => { - response = await result.current.sendMessage([{ role: "user", content: "Capital of France?" }]); - }); - - expect(response?.content).toBe("Paris is the capital of France."); - expect(result.current.status).toBe("success"); - expect(result.current.error).toBeNull(); - }); - - it("extracts the settlement receipt from the response", async () => { - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue(new Response(JSON.stringify({ content: "hi" }), { status: 200 })), - ); - - const { result } = renderHook(() => useX402Chat(NETWORK)); - await act(async () => { - await result.current.sendMessage([{ role: "user", content: "Hi" }]); - }); - - expect(result.current.paymentReceipt).toEqual({ transaction: "0xdeposit", network: NETWORK }); - }); - - it("keeps the deposit receipt after a later voucher-only message returns an empty transaction", async () => { - vi.stubGlobal( - "fetch", - vi - .fn() - .mockImplementation(() => Promise.resolve(new Response(JSON.stringify({ content: "hi" }), { status: 200 }))), - ); - - const { result } = renderHook(() => useX402Chat(NETWORK)); - - // First message: channel deposit, real tx hash. - mockGetPaymentSettleResponse.mockReturnValueOnce({ - success: true, - transaction: "0xdeposit", - network: NETWORK, - }); - await act(async () => { - await result.current.sendMessage([{ role: "user", content: "First" }]); - }); - expect(result.current.paymentReceipt).toEqual({ transaction: "0xdeposit", network: NETWORK }); - - // Second message: voucher-only settlement — real server returns transaction: "". - mockGetPaymentSettleResponse.mockReturnValueOnce({ - success: true, - transaction: "", - network: NETWORK, - }); - await act(async () => { - await result.current.sendMessage([{ role: "user", content: "Second" }]); - }); - - // The deposit receipt must survive — it's still the valid, open channel's tx. - expect(result.current.paymentReceipt).toEqual({ transaction: "0xdeposit", network: NETWORK }); - }); - - it("sets status to error and rethrows when the server responds with a failure", async () => { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("payment failed", { status: 402 }))); - - const { result } = renderHook(() => useX402Chat(NETWORK)); - - let thrown: Error | undefined; - await act(async () => { - try { - await result.current.sendMessage([{ role: "user", content: "Hi" }]); - } catch (err) { - thrown = err as Error; - } - }); - - expect(thrown?.message).toContain("402"); - expect(result.current.status).toBe("error"); - expect(result.current.error).toContain("402"); - }); - - it("surfaces a friendly, actionable message for a channel_busy 402", async () => { - // The transient per-channel lock the server holds across verify→settle. The raw code - // is opaque and the client SDK does not auto-recover from it, so the hook maps it to - // a "wait and retry" line instead of dumping the reason code. - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue( - new Response(JSON.stringify({ error: "invalid_batch_settlement_evm_channel_busy" }), { - status: 402, - }), - ), - ); - - const { result } = renderHook(() => useX402Chat(NETWORK)); - - let thrown: Error | undefined; - await act(async () => { - try { - await result.current.sendMessage([{ role: "user", content: "Hi" }]); - } catch (err) { - thrown = err as Error; - } - }); - - expect(thrown?.message).toMatch(/still being settled/i); - expect(thrown?.message).not.toContain("channel_busy"); - expect(result.current.status).toBe("error"); - expect(result.current.error).toMatch(/wait a few seconds/i); - }); - }); - - describe("Reset Functionality", () => { - it("should reset state to initial values", () => { - vi.mocked(useWalletClient).mockReturnValue({ data: mockWalletClient } as ReturnType); - vi.mocked(useAccount).mockReturnValue({ isConnected: true } as ReturnType); - - const { result } = renderHook(() => useX402Chat(NETWORK)); - - act(() => { - result.current.reset(); - }); - - expect(result.current.status).toBe("idle"); - expect(result.current.error).toBeNull(); - expect(result.current.paymentReceipt).toBeNull(); - }); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); -}); - -describe("WebStorageClientChannelStorage", () => { - const backend = window.localStorage; - - afterEach(() => { - backend.clear(); - }); - - it("returns undefined for a channel that was never stored", async () => { - const storage = new WebStorageClientChannelStorage(backend); - await expect(storage.get("0xabc")).resolves.toBeUndefined(); - }); - - it("round-trips a channel context through get/set", async () => { - const storage = new WebStorageClientChannelStorage(backend); - const context = { chargedCumulativeAmount: "1420", balance: "7100" }; - - await storage.set("0xABC", context); - - await expect(storage.get("0xabc")).resolves.toEqual(context); - }); - - it("lowercases the channel id so lookups are case-insensitive", async () => { - const storage = new WebStorageClientChannelStorage(backend); - await storage.set("0xAbCdEf", { chargedCumulativeAmount: "1420" }); - - expect(backend.getItem("x402-channel:0xabcdef")).not.toBeNull(); - await expect(storage.get("0xABCDEF")).resolves.toEqual({ chargedCumulativeAmount: "1420" }); - }); - - it("removes a stored channel on delete", async () => { - const storage = new WebStorageClientChannelStorage(backend); - await storage.set("0xabc", { chargedCumulativeAmount: "1420" }); - - await storage.delete("0xabc"); - - await expect(storage.get("0xabc")).resolves.toBeUndefined(); - }); -}); diff --git a/website/hooks/x402Discovery.ts b/website/hooks/x402Discovery.ts index 585600474..a12215296 100644 --- a/website/hooks/x402Discovery.ts +++ b/website/hooks/x402Discovery.ts @@ -121,9 +121,7 @@ export async function precheckLlmV1Agent(agentUrl: string): Promise a.network === LLM_V1_FLOOR.network && a.scheme === LLM_V1_FLOOR.scheme, - ); + const match = accepts!.find((a) => a.network === LLM_V1_FLOOR.network && a.scheme === LLM_V1_FLOOR.scheme); return { ok: true, card: { diff --git a/website/test/useX402Chat.test.ts b/website/test/useX402Chat.test.ts index aba1317f9..c08d2e82e 100644 --- a/website/test/useX402Chat.test.ts +++ b/website/test/useX402Chat.test.ts @@ -1,95 +1,394 @@ /** * useX402Chat Hook Tests * - * Focuses on the agent-URL decoupling (the open-agent-platform change): the hook must POST - * to the `agentUrl` it is given, and fall back to the default fretchen endpoint when none is - * passed. The batch-settlement SDK is mocked at the `@x402/fetch` seam so the paid-fetch URL - * can be captured without standing up the real channel machinery. + * Unlike useX402ImageGeneration.test.ts (which routes around the dynamic @x402/* + * imports entirely), this mocks @x402/fetch, @x402/evm, and + * @x402/evm/batch-settlement/client so sendMessage()'s real pay-and-fetch logic + * runs end-to-end against a mocked SDK — closing the SDK-mocking gap the sibling + * test leaves open. */ -import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { renderHook, act } from "@testing-library/react"; import { useWalletClient, useAccount } from "wagmi"; +import { useX402Chat, WebStorageClientChannelStorage } from "../hooks/useX402Chat"; +import type { X402ChatMessage } from "../types/x402"; -// Capture the URL the SDK-wrapped fetch is called with. -const paidFetchSpy = vi.fn(async () => ({ - ok: true, - status: 200, - headers: { get: () => null }, - json: async () => ({ message: "hi", usage: { prompt_tokens: 1, completion_tokens: 1 } }), - text: async () => "", -})); +const mockRegister = vi.fn(); +const mockGetPaymentSettleResponse = vi.fn(); +const mockBatchSettlementEvmScheme = vi.fn(); +const mockToClientEvmSigner = vi.fn((signer: unknown) => signer); -vi.mock("@x402/fetch", () => ({ - x402Client: class { - register() {} - }, - x402HTTPClient: class { - getPaymentSettleResponse() { - return null; - } - }, - // wrapFetchWithPayment(fetchImpl, client) normally returns a fetch that pays on 402; - // here it just returns our spy so we can assert the URL the hook targets. - wrapFetchWithPayment: () => paidFetchSpy, +vi.mock("../hooks/useConfiguredPublicClient", () => ({ + useConfiguredPublicClient: vi.fn(() => ({ readContract: vi.fn() })), })); -vi.mock("@x402/evm", () => ({ toClientEvmSigner: () => ({}) })); -vi.mock("@x402/evm/batch-settlement/client", () => ({ - BatchSettlementEvmScheme: class {}, +vi.mock("@x402/fetch", () => ({ + // vi.fn() needs a real `function`, not an arrow, to remain usable via `new`. + x402Client: vi.fn().mockImplementation(function MockX402Client() { + return { register: mockRegister }; + }), + // Pass the caller's fetch straight through — lets us drive the real + // validatingFetch → global fetch path from the hook without a real SDK. + wrapFetchWithPayment: vi.fn((fetchFn: typeof fetch) => fetchFn), + x402HTTPClient: vi.fn().mockImplementation(function MockX402HTTPClient() { + return { getPaymentSettleResponse: mockGetPaymentSettleResponse }; + }), })); -vi.mock("wagmi", () => ({ - useWalletClient: vi.fn(), - useAccount: vi.fn(), +vi.mock("@x402/evm", () => ({ + toClientEvmSigner: (...args: unknown[]) => mockToClientEvmSigner(...args), })); -vi.mock("../hooks/useConfiguredPublicClient", () => ({ - useConfiguredPublicClient: () => ({}), +vi.mock("@x402/evm/batch-settlement/client", () => ({ + BatchSettlementEvmScheme: mockBatchSettlementEvmScheme, })); -import { useX402Chat } from "../hooks/useX402Chat"; - const NETWORK = "eip155:84532"; +const mockWalletClient = { + account: { address: "0x1234567890123456789012345678901234567890" }, + signTypedData: vi.fn(), +}; -function connectWallet() { - vi.mocked(useWalletClient).mockReturnValue({ - data: { - account: { address: "0x1234567890123456789012345678901234567890" }, - signTypedData: vi.fn(), - }, - } as unknown as ReturnType); - vi.mocked(useAccount).mockReturnValue({ - isConnected: true, - address: "0x1234567890123456789012345678901234567890", - } as ReturnType); -} - -describe("useX402Chat agent-URL decoupling", () => { +describe("useX402Chat", () => { beforeEach(() => { vi.clearAllMocks(); - connectWallet(); + window.localStorage.clear(); + mockGetPaymentSettleResponse.mockReturnValue({ + success: true, + transaction: "0xdeposit", + network: NETWORK, + }); }); - it("POSTs to the provided agentUrl", async () => { - const agentUrl = "https://someone-elses-agent.example"; - const { result } = renderHook(() => useX402Chat(NETWORK, agentUrl)); + describe("Initial State", () => { + it("should not be ready when wallet not connected", () => { + vi.mocked(useWalletClient).mockReturnValue({ data: undefined } as ReturnType); + vi.mocked(useAccount).mockReturnValue({ isConnected: false } as ReturnType); + + const { result } = renderHook(() => useX402Chat(NETWORK)); + + expect(result.current.status).toBe("idle"); + expect(result.current.isReady).toBe(false); + }); + + it("should be ready when wallet is connected", () => { + vi.mocked(useWalletClient).mockReturnValue({ data: mockWalletClient } as ReturnType); + vi.mocked(useAccount).mockReturnValue({ isConnected: true } as ReturnType); + + const { result } = renderHook(() => useX402Chat(NETWORK)); - await act(async () => { - await result.current.sendMessage([{ role: "user", content: "hi" }]); + expect(result.current.isReady).toBe(true); }); + }); + + describe("Error Handling", () => { + it("should throw when sendMessage called without a wallet", async () => { + vi.mocked(useWalletClient).mockReturnValue({ data: undefined } as ReturnType); + vi.mocked(useAccount).mockReturnValue({ isConnected: false } as ReturnType); + + const { result } = renderHook(() => useX402Chat(NETWORK)); + const prompt: X402ChatMessage[] = [{ role: "user", content: "Hi" }]; - expect(paidFetchSpy).toHaveBeenCalledTimes(1); - expect(paidFetchSpy.mock.calls[0][0]).toBe(agentUrl); + await expect(result.current.sendMessage(prompt)).rejects.toThrow("Wallet not connected"); + }); }); - it("falls back to the default fretchen endpoint when no agentUrl is given", async () => { - const { result } = renderHook(() => useX402Chat(NETWORK)); + describe("Paid request (mocked SDK)", () => { + beforeEach(() => { + vi.mocked(useWalletClient).mockReturnValue({ data: mockWalletClient } as ReturnType); + vi.mocked(useAccount).mockReturnValue({ isConnected: true } as ReturnType); + }); + + it("registers the batch-settlement scheme on the requested network", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(JSON.stringify({ content: "hi" }), { status: 200 })), + ); - await act(async () => { - await result.current.sendMessage([{ role: "user", content: "hi" }]); + const { result } = renderHook(() => useX402Chat(NETWORK)); + await act(async () => { + await result.current.sendMessage([{ role: "user", content: "Hi" }]); + }); + + expect(mockToClientEvmSigner).toHaveBeenCalled(); + expect(mockBatchSettlementEvmScheme).toHaveBeenCalledWith( + expect.objectContaining({ address: mockWalletClient.account.address }), + expect.objectContaining({ + storage: expect.any(WebStorageClientChannelStorage), + voucherSigner: expect.objectContaining({ address: expect.stringMatching(/^0x[a-fA-F0-9]{40}$/) }), + depositStrategy: expect.any(Function), + }), + ); + expect(mockRegister).toHaveBeenCalledWith(NETWORK, expect.anything()); }); - expect(paidFetchSpy).toHaveBeenCalledTimes(1); - expect(paidFetchSpy.mock.calls[0][0]).toBe("https://llm-agent.fretchen.eu"); + it("deposit strategy floors deposits/top-ups at $0.50, ignoring the SDK's smaller default", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(JSON.stringify({ content: "hi" }), { status: 200 })), + ); + + const { result } = renderHook(() => useX402Chat(NETWORK)); + await act(async () => { + await result.current.sendMessage([{ role: "user", content: "Hi" }]); + }); + + const { depositStrategy } = mockBatchSettlementEvmScheme.mock.calls[0][1] as { + depositStrategy: (ctx: { minimumDepositAmount: string }) => string; + }; + + // Below the floor (e.g. the SDK's own ~1-3 cent default): clamp up to $0.50. + expect(depositStrategy({ minimumDepositAmount: "15000" })).toBe("500000"); + // Above the floor (an unusually expensive top-up): the SDK requires >= this amount, + // so it must be respected, not clamped down. + expect(depositStrategy({ minimumDepositAmount: "600000" })).toBe("600000"); + // Exactly at the floor: either value is correct; assert it's still >= minimum. + expect(BigInt(depositStrategy({ minimumDepositAmount: "500000" }))).toBeGreaterThanOrEqual(500_000n); + }); + + it("reuses the same delegated voucher signer across multiple messages", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockImplementation(() => Promise.resolve(new Response(JSON.stringify({ content: "hi" }), { status: 200 }))), + ); + + const { result } = renderHook(() => useX402Chat(NETWORK)); + + await act(async () => { + await result.current.sendMessage([{ role: "user", content: "First" }]); + }); + const firstVoucherSigner = mockBatchSettlementEvmScheme.mock.calls[0][1].voucherSigner as { + address: string; + }; + + await act(async () => { + await result.current.sendMessage([{ role: "user", content: "Second" }]); + }); + const secondVoucherSigner = mockBatchSettlementEvmScheme.mock.calls[1][1].voucherSigner as { + address: string; + }; + + // Same delegate key both times — proves it's persisted (localStorage), not regenerated + // per call. A fresh key each call would silently open a brand-new channel every message. + expect(secondVoucherSigner.address).toBe(firstVoucherSigner.address); + }); + + it("returns the parsed response and sets status through success", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ content: "Paris is the capital of France." }), { status: 200 }), + ), + ); + + const { result } = renderHook(() => useX402Chat(NETWORK)); + + let response: { content: string } | undefined; + await act(async () => { + response = await result.current.sendMessage([{ role: "user", content: "Capital of France?" }]); + }); + + expect(response?.content).toBe("Paris is the capital of France."); + expect(result.current.status).toBe("success"); + expect(result.current.error).toBeNull(); + }); + + it("extracts the settlement receipt from the response", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(JSON.stringify({ content: "hi" }), { status: 200 })), + ); + + const { result } = renderHook(() => useX402Chat(NETWORK)); + await act(async () => { + await result.current.sendMessage([{ role: "user", content: "Hi" }]); + }); + + expect(result.current.paymentReceipt).toEqual({ transaction: "0xdeposit", network: NETWORK }); + }); + + it("keeps the deposit receipt after a later voucher-only message returns an empty transaction", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockImplementation(() => Promise.resolve(new Response(JSON.stringify({ content: "hi" }), { status: 200 }))), + ); + + const { result } = renderHook(() => useX402Chat(NETWORK)); + + // First message: channel deposit, real tx hash. + mockGetPaymentSettleResponse.mockReturnValueOnce({ + success: true, + transaction: "0xdeposit", + network: NETWORK, + }); + await act(async () => { + await result.current.sendMessage([{ role: "user", content: "First" }]); + }); + expect(result.current.paymentReceipt).toEqual({ transaction: "0xdeposit", network: NETWORK }); + + // Second message: voucher-only settlement — real server returns transaction: "". + mockGetPaymentSettleResponse.mockReturnValueOnce({ + success: true, + transaction: "", + network: NETWORK, + }); + await act(async () => { + await result.current.sendMessage([{ role: "user", content: "Second" }]); + }); + + // The deposit receipt must survive — it's still the valid, open channel's tx. + expect(result.current.paymentReceipt).toEqual({ transaction: "0xdeposit", network: NETWORK }); + }); + + it("sets status to error and rethrows when the server responds with a failure", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("payment failed", { status: 402 }))); + + const { result } = renderHook(() => useX402Chat(NETWORK)); + + let thrown: Error | undefined; + await act(async () => { + try { + await result.current.sendMessage([{ role: "user", content: "Hi" }]); + } catch (err) { + thrown = err as Error; + } + }); + + expect(thrown?.message).toContain("402"); + expect(result.current.status).toBe("error"); + expect(result.current.error).toContain("402"); + }); + + it("surfaces a friendly, actionable message for a channel_busy 402", async () => { + // The transient per-channel lock the server holds across verify→settle. The raw code + // is opaque and the client SDK does not auto-recover from it, so the hook maps it to + // a "wait and retry" line instead of dumping the reason code. + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: "invalid_batch_settlement_evm_channel_busy" }), { + status: 402, + }), + ), + ); + + const { result } = renderHook(() => useX402Chat(NETWORK)); + + let thrown: Error | undefined; + await act(async () => { + try { + await result.current.sendMessage([{ role: "user", content: "Hi" }]); + } catch (err) { + thrown = err as Error; + } + }); + + expect(thrown?.message).toMatch(/still being settled/i); + expect(thrown?.message).not.toContain("channel_busy"); + expect(result.current.status).toBe("error"); + expect(result.current.error).toMatch(/wait a few seconds/i); + }); + }); + + describe("Agent URL targeting (open-agent-platform)", () => { + beforeEach(() => { + vi.mocked(useWalletClient).mockReturnValue({ data: mockWalletClient } as ReturnType); + vi.mocked(useAccount).mockReturnValue({ isConnected: true } as ReturnType); + }); + + it("POSTs to the provided agentUrl", async () => { + const fetchSpy = vi.fn().mockResolvedValue(new Response(JSON.stringify({ content: "hi" }), { status: 200 })); + vi.stubGlobal("fetch", fetchSpy); + const agentUrl = "https://someone-elses-agent.example"; + + const { result } = renderHook(() => useX402Chat(NETWORK, agentUrl)); + await act(async () => { + await result.current.sendMessage([{ role: "user", content: "Hi" }]); + }); + + expect(fetchSpy).toHaveBeenCalledWith(agentUrl, expect.objectContaining({ method: "POST" })); + }); + + it("falls back to the default fretchen endpoint when no agentUrl is given", async () => { + const fetchSpy = vi.fn().mockResolvedValue(new Response(JSON.stringify({ content: "hi" }), { status: 200 })); + vi.stubGlobal("fetch", fetchSpy); + + const { result } = renderHook(() => useX402Chat(NETWORK)); + await act(async () => { + await result.current.sendMessage([{ role: "user", content: "Hi" }]); + }); + + expect(fetchSpy).toHaveBeenCalledWith( + "https://llm-agent.fretchen.eu", + expect.objectContaining({ method: "POST" }), + ); + }); + }); + + describe("Reset Functionality", () => { + it("should reset state to initial values", () => { + vi.mocked(useWalletClient).mockReturnValue({ data: mockWalletClient } as ReturnType); + vi.mocked(useAccount).mockReturnValue({ isConnected: true } as ReturnType); + + const { result } = renderHook(() => useX402Chat(NETWORK)); + + act(() => { + result.current.reset(); + }); + + expect(result.current.status).toBe("idle"); + expect(result.current.error).toBeNull(); + expect(result.current.paymentReceipt).toBeNull(); + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); +}); + +describe("WebStorageClientChannelStorage", () => { + const backend = window.localStorage; + + afterEach(() => { + backend.clear(); + }); + + it("returns undefined for a channel that was never stored", async () => { + const storage = new WebStorageClientChannelStorage(backend); + await expect(storage.get("0xabc")).resolves.toBeUndefined(); + }); + + it("round-trips a channel context through get/set", async () => { + const storage = new WebStorageClientChannelStorage(backend); + const context = { chargedCumulativeAmount: "1420", balance: "7100" }; + + await storage.set("0xABC", context); + + await expect(storage.get("0xabc")).resolves.toEqual(context); + }); + + it("lowercases the channel id so lookups are case-insensitive", async () => { + const storage = new WebStorageClientChannelStorage(backend); + await storage.set("0xAbCdEf", { chargedCumulativeAmount: "1420" }); + + expect(backend.getItem("x402-channel:0xabcdef")).not.toBeNull(); + await expect(storage.get("0xABCDEF")).resolves.toEqual({ chargedCumulativeAmount: "1420" }); + }); + + it("removes a stored channel on delete", async () => { + const storage = new WebStorageClientChannelStorage(backend); + await storage.set("0xabc", { chargedCumulativeAmount: "1420" }); + + await storage.delete("0xabc"); + + await expect(storage.get("0xabc")).resolves.toBeUndefined(); }); }); diff --git a/website/test/x402Discovery.test.ts b/website/test/x402Discovery.test.ts index f97ca5efb..9e00cd39b 100644 --- a/website/test/x402Discovery.test.ts +++ b/website/test/x402Discovery.test.ts @@ -3,12 +3,7 @@ * check, and the agent pre-check (contract doc + live 402 probe). */ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { - decodePaymentRequired, - meetsLlmV1Floor, - precheckLlmV1Agent, - type AcceptsEntry, -} from "../hooks/x402Discovery"; +import { decodePaymentRequired, meetsLlmV1Floor, precheckLlmV1Agent, type AcceptsEntry } from "../hooks/x402Discovery"; const floorEntry: AcceptsEntry = { scheme: "batch-settlement", @@ -75,7 +70,10 @@ describe("precheckLlmV1Agent", () => { it("passes a well-formed llm/v1 agent and returns its provenance card", async () => { mockFetch({ - openapi: { status: 200, body: { "x-service-type": "llm/v1", info: { title: "T", contact: { name: "fretchen", url: "https://x" } } } }, + openapi: { + status: 200, + body: { "x-service-type": "llm/v1", info: { title: "T", contact: { name: "fretchen", url: "https://x" } } }, + }, probe: { status: 402, accepts: [floorEntry] }, }); const res = await precheckLlmV1Agent("https://agent.example"); From 54d167cc750f4f11080500c25ccb7f046c9f84c5 Mon Sep 17 00:00:00 2001 From: fretchen Date: Tue, 28 Jul 2026 11:16:57 +0200 Subject: [PATCH 5/6] Simplify /assistent sidebar to honest chat provenance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The multi-agent escape hatch surfaced an ecosystem that doesn't exist yet (we're the only llm/v1-compatible agent today), so it confused more than it helped. Simplify the visible sidebar: - Show honest provenance for the agent actually serving the chat (operator + payTo + origin), read live from its own /openapi.json + 402 via a new fetchAgentCard() helper — replacing the stale /agent-registration.json read. - Drop the "Become a provider" link (pointed at the dropped onboarding page) and the "EIP-8004 JSON" link (a machine artifact of no value to chat users) from the llm sidebar path. genimg path untouched. - Stop mounting AgentSelector; remove the custom-agent escape-hatch state from AssistantChat. - Fix the "Clear chat" button so it reads as an actionable control, not a permanently-greyed one. AgentSelector.tsx, x402Discovery's precheckLlmV1Agent, and useX402Chat's agentUrl param are kept and tested, ready to re-enable when llm/v2 (OpenAI chat shape) makes third-party agents actually compatible — documented in open_agent_platform_plan.md. Co-Authored-By: Claude Opus 4.8 --- open_agent_platform_plan.md | 8 ++- website/components/AgentInfoPanel.tsx | 27 +++++++- website/components/AssistantChat.tsx | 90 ++++++++------------------- website/hooks/useX402Chat.ts | 2 +- website/hooks/x402Discovery.ts | 48 ++++++++++++++ website/layouts/styles.ts | 8 ++- website/test/AssistantChat.test.tsx | 5 ++ 7 files changed, 115 insertions(+), 73 deletions(-) diff --git a/open_agent_platform_plan.md b/open_agent_platform_plan.md index 6c2bf1e64..5ce7b40af 100644 --- a/open_agent_platform_plan.md +++ b/open_agent_platform_plan.md @@ -105,9 +105,11 @@ No registry document, no agent-card indirection layer for the MVP — the OpenAP - [ ] `AssistantChat` passes the selected agent's URL through. ### Phase 3 — Provenance + escape hatch (proves G3/G4) -- [ ] Retarget provenance display off the stale `/agent-registration.json`: show operator + `payTo` + price + network parsed from the target agent's `/openapi.json` and its live 402 `accepts[]` (fixes C3). Reuse/replace `useAgentInfo`. -- [ ] Custom-URL input on `/assistent`: paste any `llm/v1` endpoint, no listing required (proves G3). -- [ ] Pre-payment disclosure: operator, `payTo`, price, network shown before the first paid message (G4). +- [x] Retarget provenance display off the stale `/agent-registration.json`: operator + `payTo` + origin parsed from the agent's own `/openapi.json` and its live 402 `accepts[]` (fixes C3), via `fetchAgentCard` in `website/hooks/x402Discovery.ts`. `AgentInfoPanel`'s llm path now renders from this card and no longer shows the retired "Become a provider" / EIP-8004 JSON links. +- [x] Custom-URL escape hatch **built** (`website/components/AgentSelector.tsx` + `precheckLlmV1Agent`) and unit-tested — but see the deferral note below. +- [x] Pre-payment disclosure (operator + `payTo` + origin) shown in the sidebar for the agent actually serving the chat (G4/G6). + +> **Deferred rendering — the escape hatch is built but NOT shown yet.** The custom-URL picker (`AgentSelector`) is intentionally not mounted in `AssistantChat`. Today the only `llm/v1`-compatible agent is ours (our `{ data: { prompt } }` shape + Base batch-settlement), so a "use a different agent" box would point at an empty room and only confuse users. **Re-enable trigger:** when `llm/v2` (the OpenAI chat-completions shape — see [Design record D-shape]) ships, thousands of existing agents become compatible and the picker becomes motivated. Until then it stays in the codebase, ready: `AgentSelector.tsx`, `x402Discovery.ts`'s `precheckLlmV1Agent`, and `useX402Chat(network, agentUrl?)`'s agent-URL parameter are all retained and tested; re-mount `AgentSelector` and pass the selected URL into `useX402Chat` to turn it back on. ### Phase 4 (deferred, not this PR) — Registry + automated listing A `registry.json` of multiple vetted agents, automated permissionless listing (ownership-proof + contract-validation + liveness probe). Explicitly out of scope for the MVP; the escape hatch stands in for it. Prepare toward it, do not build it yet. diff --git a/website/components/AgentInfoPanel.tsx b/website/components/AgentInfoPanel.tsx index 57835a376..6d406e298 100644 --- a/website/components/AgentInfoPanel.tsx +++ b/website/components/AgentInfoPanel.tsx @@ -44,9 +44,17 @@ export function AgentInfoPanel({ service = "genimg", variant = "footer", agentCa const isSidebar = variant === "sidebar"; - // Escape-hatch agent selected: render its live provenance (operator + payTo + origin) - // rather than the default registration file. Kept intentionally compact. + // llm path: render honest provenance (operator + origin + payTo) read live from the agent's + // own /openapi.json + 402 — who the user actually pays. Third-party agents (not a *.fretchen.eu + // origin) additionally carry an at-your-own-risk note. The multi-agent picker that would make + // a third-party card appear here isn't rendered yet (see AssistantChat / the plan). if (agentCard) { + let isThirdParty = true; + try { + isThirdParty = !new URL(agentCard.origin).hostname.endsWith("fretchen.eu"); + } catch { + // Unparseable origin — treat as third-party (safer disclosure). + } return (
{agentCard.operator ?? agentCard.origin}
@@ -56,7 +64,20 @@ export function AgentInfoPanel({ service = "genimg", variant = "footer", agentCa pays → {agentCard.payTo.slice(0, 6)}…{agentCard.payTo.slice(-4)}
)} -
third-party agent · at your own risk
+ {isThirdParty && ( +
third-party agent · at your own risk
+ )} + + ); + } + + // The llm service is card-driven (above). Before the card resolves, show a compact + // placeholder rather than falling through to the legacy registration-file rendering (which + // carries the retired "Become a provider" / EIP-8004 JSON links we don't want on /assistent). + if (service === "llm") { + return ( +
+ Loading agent…
); } diff --git a/website/components/AssistantChat.tsx b/website/components/AssistantChat.tsx index 7aded3cec..ce24ad763 100644 --- a/website/components/AssistantChat.tsx +++ b/website/components/AssistantChat.tsx @@ -5,19 +5,24 @@ * off-chain voucher signatures reusing the open channel. */ -import React, { useState, useMemo } from "react"; +import React, { useState, useMemo, useEffect } from "react"; import { AgentInfoPanel } from "./AgentInfoPanel"; -import { AgentSelector } from "./AgentSelector"; import * as styles from "../layouts/styles"; import { useLocale } from "../hooks/useLocale"; import { useUmami } from "../hooks/useUmami"; import { css } from "../styled-system/css"; import { useWalletConnection } from "../hooks/useWalletConnection"; import { useAutoNetwork } from "../hooks/useAutoNetwork"; -import { useX402Chat } from "../hooks/useX402Chat"; -import { precheckLlmV1Agent, type AgentCard } from "../hooks/x402Discovery"; +import { useX402Chat, DEFAULT_LLM_AGENT_URL } from "../hooks/useX402Chat"; +import { fetchAgentCard, type AgentCard } from "../hooks/x402Discovery"; import { getViemChain } from "@fretchen/chain-utils"; +// The multi-agent picker / custom-URL escape hatch (AgentSelector) is intentionally NOT +// rendered yet — see open_agent_platform_plan.md. Today the only llm/v1-compatible agent is +// ours, so a "use a different agent" box would point at an empty room. AgentSelector.tsx, +// x402Discovery's precheckLlmV1Agent, and useX402Chat's agentUrl param are kept ready for +// when llm/v2 (OpenAI chat shape) makes third-party agents actually compatible. + interface ChatMessage { role: "user" | "assistant"; content: string; @@ -75,43 +80,20 @@ export function AssistantChat() { const { isConnected, connectWallet } = useWalletConnection(); const { network, switchIfNeeded, switchError } = useAutoNetwork(CHAT_NETWORKS); - // Custom agent (escape hatch, G3): when the user pastes a URL that passes the llm/v1 - // pre-check, chat is served by that agent instead of the default fretchen endpoint. While - // no custom agent is selected, `selectedAgentUrl` is undefined and useX402Chat falls back - // to its own default (proving the default has no privileged path). - const [selectedAgentUrl, setSelectedAgentUrl] = useState(undefined); - const [customUrlInput, setCustomUrlInput] = useState(""); - const [checkState, setCheckState] = useState<"idle" | "checking" | "error">("idle"); - const [checkError, setCheckError] = useState(null); - const [customCard, setCustomCard] = useState(null); - - const { sendMessage: payAndSend, paymentReceipt } = useX402Chat(network, selectedAgentUrl); - - const tryCustomAgent = async () => { - const url = customUrlInput.trim(); - if (!url) return; - setCheckState("checking"); - setCheckError(null); - const result = await precheckLlmV1Agent(url); - if (result.ok && result.card) { - setSelectedAgentUrl(url); - setCustomCard(result.card); - setCheckState("idle"); - setMessages([]); // fresh conversation on the new agent - } else { - setCheckState("error"); - setCheckError(result.reason ?? "This agent could not be verified."); - } - }; - - const useDefaultAgent = () => { - setSelectedAgentUrl(undefined); - setCustomCard(null); - setCustomUrlInput(""); - setCheckState("idle"); - setCheckError(null); - setMessages([]); - }; + const { sendMessage: payAndSend, paymentReceipt } = useX402Chat(network); + + // Provenance of the agent actually serving this chat (operator + payTo + origin), read + // live from its own /openapi.json + 402 so the sidebar can honestly show who the user pays. + const [agentCard, setAgentCard] = useState(null); + useEffect(() => { + let cancelled = false; + void fetchAgentCard(DEFAULT_LLM_AGENT_URL).then((card) => { + if (!cancelled) setAgentCard(card); + }); + return () => { + cancelled = true; + }; + }, []); const buttonState = useMemo(() => { if (!isConnected) return "connect"; @@ -225,16 +207,7 @@ export function AssistantChat() { {/* Agent Info Section */}

Agent

- - void tryCustomAgent()} - onUseDefaultAgent={useDefaultAgent} - /> +
)} @@ -318,20 +291,7 @@ export function AssistantChat() { {/* Agent Info - Mobile Footer */} - {isMobile && ( - <> - - void tryCustomAgent()} - onUseDefaultAgent={useDefaultAgent} - /> - - )} + {isMobile && } diff --git a/website/hooks/useX402Chat.ts b/website/hooks/useX402Chat.ts index f0332e361..d247b0869 100644 --- a/website/hooks/useX402Chat.ts +++ b/website/hooks/useX402Chat.ts @@ -30,7 +30,7 @@ import type { // PUBLIC_ENV__LLM_X402_ENDPOINT=http://localhost:8085. Callers may also pass an explicit // agentUrl to useX402Chat to target any other llm/v1 agent (see the open-agent-platform // work) — this constant is only the fallback when none is given. -const DEFAULT_LLM_AGENT_URL = +export const DEFAULT_LLM_AGENT_URL = (import.meta.env.PUBLIC_ENV__LLM_X402_ENDPOINT as string | undefined) ?? "https://llm-agent.fretchen.eu"; /** diff --git a/website/hooks/x402Discovery.ts b/website/hooks/x402Discovery.ts index a12215296..3a63c1e09 100644 --- a/website/hooks/x402Discovery.ts +++ b/website/hooks/x402Discovery.ts @@ -71,6 +71,54 @@ interface OpenApiDoc { info?: { title?: string; contact?: { name?: string; url?: string } }; } +/** + * Best-effort provenance for display (no pass/fail gate): reads the agent's `/openapi.json` + * for operator/title and probes once for the live `payTo`/network from the 402. Returns + * `null` only if the origin is unusable; otherwise returns whatever it could read (fields it + * couldn't resolve are left null). Use this for the sidebar's "who you pay" disclosure of the + * agent already in use — `precheckLlmV1Agent` is the stricter gate for *adding* a new agent. + */ +export async function fetchAgentCard(agentUrl: string): Promise { + let origin: string; + try { + origin = agentOrigin(agentUrl); + } catch { + return null; + } + + let doc: OpenApiDoc = {}; + try { + const res = await fetch(`${origin}/openapi.json`, { method: "GET" }); + if (res.ok) doc = (await res.json()) as OpenApiDoc; + } catch { + // Leave doc empty — still return a card with the origin so the UI shows something. + } + + let match: AcceptsEntry | undefined; + try { + const res = await fetch(agentUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ data: { prompt: [] } }), + }); + if (res.status === 402) { + const accepts = decodePaymentRequired(res.headers.get("Payment-Required")); + match = accepts?.find((a) => a.network === LLM_V1_FLOOR.network && a.scheme === LLM_V1_FLOOR.scheme); + } + } catch { + // No live 402 — payTo/network stay null. + } + + return { + origin, + title: doc.info?.title ?? null, + operator: doc.info?.contact?.name ?? null, + contactUrl: doc.info?.contact?.url ?? null, + payTo: match?.payTo ?? null, + network: match?.network ?? null, + }; +} + /** * Liveness + contract pre-check for a (possibly third-party) llm/v1 agent URL: * 1. fetch `/openapi.json`, require `x-service-type === "llm/v1"`; diff --git a/website/layouts/styles.ts b/website/layouts/styles.ts index e31c98175..0390d6c89 100644 --- a/website/layouts/styles.ts +++ b/website/layouts/styles.ts @@ -2470,8 +2470,14 @@ export const actionButton = css({ }, }); +// Secondary variant of actionButton (which already supplies border/padding/hover-bg). Uses a +// muted-but-readable text colour that darkens on hover, so the button reads as an actionable +// control rather than looking permanently disabled. export const actionButtonSecondary = css({ - color: "#666", + color: "#555", + _hover: { + color: "#111", + }, }); // Chat area diff --git a/website/test/AssistantChat.test.tsx b/website/test/AssistantChat.test.tsx index 0efc6868a..7fa303c68 100644 --- a/website/test/AssistantChat.test.tsx +++ b/website/test/AssistantChat.test.tsx @@ -24,6 +24,11 @@ vi.mock("../hooks/useX402Chat", () => ({ reset: vi.fn(), isReady: true, })), + DEFAULT_LLM_AGENT_URL: "https://llm-agent.fretchen.eu", +})); + +vi.mock("../hooks/x402Discovery", () => ({ + fetchAgentCard: vi.fn(() => Promise.resolve(null)), })); vi.mock("../hooks/useWalletConnection", () => ({ From b3c5bbcb7db2f8f9138af16beba3fad7ffd36827 Mon Sep 17 00:00:00 2001 From: fretchen Date: Tue, 28 Jul 2026 11:21:12 +0200 Subject: [PATCH 6/6] clean some old documentations --- collect-ux-layers-1-3.md | 107 ---------------------------- open_agent_platform_plan.md | 137 ------------------------------------ 2 files changed, 244 deletions(-) delete mode 100644 collect-ux-layers-1-3.md delete mode 100644 open_agent_platform_plan.md diff --git a/collect-ux-layers-1-3.md b/collect-ux-layers-1-3.md deleted file mode 100644 index 6fc402472..000000000 --- a/collect-ux-layers-1-3.md +++ /dev/null @@ -1,107 +0,0 @@ -# Collect/Mint UX — Problems & Mitigations (Layers 1–3) - -Reference note for a "collect / mint" button. Scope: **payment currency, wallet, gas, chain** friction. -Out of scope here: fiat on-ramps, Apple Pay, the full x402 protocol. -Principle: **KISS** — each layer lists the *smallest fix that removes the failure* first, then the fuller option. - -## The core idea - -"L2 complexity" is not one problem. It is a stack of frictions, and a user can fail at any one. Fix them independently. The payment-currency choice (Layer 0) sits underneath the rest — decide it first, because it changes everything above it. - -| Layer | What the user hits | Failure mode | -|---|---|---| -| 0. Currency | "The price is some tiny ETH fraction" | Confused before starting | -| 1. Wallet | "I have to install something / manage a seed phrase" | Never starts | -| 2. Gas | "I have no ETH for fees on this chain" | Connected, can't pay | -| 3. Chain | "Which network? Wrong network." | Connected, funded, wrong place | - ---- - -## Layer 0 — Payment currency (cross-cutting; decide this first) - -**Problem:** Pricing/paying in native ETH means unreadable prices ("0.00031 ETH"), forces users to hold the gas token, and blocks clean chain abstraction. - -**Mitigation (KISS):** Denominate the collect in a **regulated stablecoin** — USDC, and/or **EURC** for a European audience. This one decision improves Layers 2 and 3 at the same time. - -**Why it's the keystone:** USDC/EURC are *programmable*. They support **EIP-3009** (`receiveWithAuthorization`): the user signs one off-chain authorization, the contract pulls the exact amount, no persistent allowance, bound to a specific recipient. That is the gasless, single-signature payment primitive the layers below build on. It is also the clean fix for the approve/`transferFrom` "spender" security warnings. - -**Cross-chain bonus:** USDC has native burn-and-mint transfer via **Circle CCTP V2** (Fast Transfer ~8–20s, Hooks for same-transaction destination logic) — no wrapped tokens, no bridge liquidity pools. This is why "pay from any chain" becomes near-trivial once you are USDC-denominated. - -**Honest trade-off:** USDC/EURC carry a Circle **freeze/blacklist** function; native ETH does not. You trade a slice of censorship-resistance for a large UX gain. Usually an easy trade for a creative-collect button — but name it, don't discover it later. - -**Note:** x402 is one productized, backend-settled way to consume EIP-3009. Out of scope for this doc, but the EIP-3009 primitive is available to you with or without it. - ---- - -## Layer 1 — Wallet & keys - -**Problem:** Requires an injected wallet (MetaMask etc.) and a seed phrase. Non-crypto users bounce immediately. - -**Root cause:** The flow gates on a connected EOA from a browser-extension wallet. - -**Mitigation (KISS):** Offer a passkey smart wallet as an *additional* entry point (FaceID / no seed phrase), keeping "connect existing wallet" for crypto-natives. -- ERC-4337 → new smart-contract accounts (passkey sign-in, social recovery). -- ERC-7702 → upgrades an *existing* EOA in place (live since Pectra, May 2025). - -**Smallest first step:** Don't remove wallet-connect. Add one passkey option beside it. - -**Openness / trade-off:** Account contracts are open-source (Safe, Coinbase Smart Wallet). You depend on a swappable bundler + paymaster service (Pimlico / ZeroDev / Alchemy / self-host). Infra dependency, not vendor lock-in. - -*Unchanged by the USDC move — key management is orthogonal to payment currency.* - ---- - -## Layer 2 — Gas (highest leverage — and USDC makes it nearly free) - -**Problem:** User must hold native ETH on the exact L2 to pay gas. The most common dead-end. - -**Root cause:** User pays their own gas, and the mint is priced in native ETH. - -**Mitigation (KISS):** With Layer 0 in place, the payment is a **signature**, and someone else submits the transaction and pays gas: -- A relayer / paymaster fronts gas → user needs zero ETH. -- EIP-3009 means no separate "approve" transaction either — one signature covers everything. - -So "pay in USDC" delivers "gasless" almost for free; you stop solving gas as a separate problem. (A plain sponsoring paymaster on an ETH-priced flow still works if you delay Layer 0, but it's strictly less clean.) - -**Smallest first step:** EIP-3009 authorization relayed by your backend/facilitator, or an AA paymaster. User signs once, holds no ETH. - -**Openness / trade-off:** ERC-4337 and EIP-3009 are open and vendor-neutral. You fund the relayer/paymaster (real cost). Cap / allowlist to avoid drain. - ---- - -## Layer 3 — Chains - -**Problem:** User must know the right L2 and switch networks. Even technical users get confused. - -**Root cause:** Network-switching logic puts chain selection on the user; funds may sit on another chain. - -**Mitigation (KISS), simplest → fullest:** -1. **Single chain, no switching.** Commit to one L2, remove the switcher. Simplest fix; removes a whole decision. -2. **Accept-from-any-chain.** Once USDC-denominated, this is well-supported: - - **CCTP V2** for native USDC burn-and-mint from other chains. - - **ERC-7683 intents** (Across, Uniswap, CoW, Eco) or **Daimo Pay** (non-custodial, open contracts) to accept any coin / any chain and settle USDC on yours. - -**Watch-out (now inverted):** Previously, an ETH-denominated bonding curve broke stablecoin routers. **USDC denomination removes that** — "settle as USDC on your chain" now maps directly onto your contract. The former obstacle has become the enabler. - -**Openness / trade-off:** CCTP is permissionless Circle infra. Intents / Daimo = open contracts + non-custodial, but rely on hosted solver/routing (leaveable; self-runnable). - ---- - -## KISS priority — do these in order - -1. **Move to USDC/EURC + EIP-3009 (Layer 0).** The keystone. Retires ETH-fraction pricing, enables gasless, and unblocks chain abstraction — one decision, three wins. -2. **Gasless via relayer/paymaster (Layer 2).** Falls out of Layer 0 almost for free. -3. **Drop the chain switcher (Layer 3, option 1).** Commit to one L2. -4. **Passkey option (Layer 1).** Widen the door for non-crypto users. -5. **Full chain abstraction (Layer 3, option 2)** — only if users genuinely arrive with funds on other chains. CCTP / intents make this cheap once USDC-denominated. - -## The one decision to make first - -- **Trustless path:** contract pulls USDC via `receiveWithAuthorization`; user signs one gasless auth; relayer / AA submits. Direct-to-contract, max decentralization. -- **Backend-settled path:** a facilitator settles the USDC payment, your backend mints. Reuses payment infra; adds a trust assumption on your backend. - -**USDC + EIP-3009 narrows the UX gap between these to almost nothing** — both are a single gasless signature. So decide on **trust / decentralization grounds**, not UX. Given a coupling-averse stance, the trustless path is now cheap enough to keep. - -## Contract-side reality check - -Moving to USDC is a **UUPS upgrade**: accept an ERC-20 via `receiveWithAuthorization`, re-denominate the bonding curve in USDC's **6 decimals** (cleaner than 18-decimal wei), and use **native USDC** on Optimism, not bridged `USDC.e`. Feasible on a proxy setup, but it touches the mint signature and price storage — the one place that needs care. diff --git a/open_agent_platform_plan.md b/open_agent_platform_plan.md deleted file mode 100644 index 5ce7b40af..000000000 --- a/open_agent_platform_plan.md +++ /dev/null @@ -1,137 +0,0 @@ -# Open Agent Platform Plan — LLM MVP - -Decouple `/assistent` from its single hardcoded LLM backend so that **any standard x402 batch-settlement (or `exact`) LLM agent** can serve it. Scope is deliberately narrowed to the LLM service only; image generation is out of scope for this iteration (it carries NFT coupling that needs separate treatment, and minting is entirely out of the picture here). - -**The reverse direction is already done.** Third parties discovering and consuming *our* LLM endpoint works today — we are wire-standard (stock `@x402/evm` SDK) and discoverable + ownership-proofed on x402scan (prior PR). This MVP therefore builds only the missing direction: **the website consuming a second/third-party LLM agent.** - -Structure: [Motivation](#motivation) → [Goals / Non-goals](#goals--non-goals) → [Key finding: we are already standard](#key-finding-we-are-already-standard) → [Where the coupling is today](#where-the-coupling-is-today) → [Architecture](#target-architecture) → [Phases](#phases) → [Design record](#design-record) → [Open questions](#open-questions). - ---- - -## Motivation - -`/assistent` is a thin UI bolted onto one specific serverless function whose URL is compiled into `useX402Chat.ts`. That was the right shape while proving x402 payment rails. It is the wrong shape for a platform, for three reasons: - -1. **The payment layer is already open; the application layer is not.** x402's `accepts[]` is self-describing — price, `payTo`, scheme, network, asset all arrive in the 402. A client that *reads* `accepts[]` instead of assuming can pay any compatible endpoint. Today the frontend knows exactly one seller. -2. **Own the format, not the directory.** Depend on the open x402 protocol for payment and on our own published, machine-readable contract for discovery; treat every external directory (x402scan, etc.) as an optional listing, never a dependency. -3. **Agents, not humans, are the larger consumer base.** If the contract is machine-first, the website becomes one client among several with no privileged path, and the same artifacts power third-party integration for free. - ---- - -## Goals / Non-goals - -### Goals -- **G1 — Bring your own LLM agent.** Any operator of a standard x402 batch-settlement (or `exact`) LLM endpoint satisfying the `llm/v1` contract can be used by `/assistent`, without permission and without code changes on our side. -- **G2 — Machine-first contract.** `llm/v1` is defined by a stable, versioned, publicly fetchable OpenAPI document consumed identically by our frontend and third-party agents. No private path for our own UI. -- **G3 — No directory lock-in.** Nothing in the payment or discovery path may *require* any external service. A custom-URL escape hatch is the concrete proof. -- **G4 — Honestly labelled provenance.** The user always sees which `payTo` address and operator they are paying before they pay. -- **G5 — Zero-cost interop.** A compatible agent needs to do nothing we don't already do with the stock SDK — because we use the stock SDK (see next section). - -### Non-goals (this iteration) -- **Not** image generation. Deferred — NFT coupling needs its own design. -- **Not** a reputation/dispute system. We do liveness + format checks, not quality guarantees. x402 has no refund primitive. -- **Not** on-chain ERC-8004 registration. The registration-file *format* may be reused, but nothing is minted. -- **Not** a full multi-agent registry yet. The MVP proves the contract + decoupling with a **custom-URL escape hatch**; a curated/automated registry list is a later phase. -- **Not** publishing a batch-settlement client helper for third parties — see [Key finding](#key-finding-we-are-already-standard) (it is not needed for interop) and Q2. - ---- - -## Key finding: we are already standard - -An audit of the current code against the installed `@x402/evm` SDK (v2.18.0) established that **our batch-settlement implementation has no wire-protocol deviation**. This reframes the whole plan — "make it compatible" is largely already done. - -- **Server** (`scw_js/x402_server.ts`, `sc_llm_x402.ts`): the 402 `accepts[]` is built entirely by the SDK's `BatchSettlementEvmScheme.enhancePaymentRequirements`; the scheme string is the SDK's literal `"batch-settlement"`; verify/settle use the SDK's `resourceServer.verifyPayment` / `settlePayment`. All `extra` fields (`receiverAuthorizer`, `withdrawDelay`, `name`, `version`, `assetTransferMethod`) are SDK-injected. No custom payload fields. -- **Client** (`website/hooks/useX402Chat.ts`): all payment logic is the SDK (`BatchSettlementEvmScheme`, `@x402/fetch`'s `wrapFetchWithPayment`, `toClientEvmSigner`). The "manual" wiring the file comments mention is **just** `client.register(network, scheme)` — the SDK ships `registerExactEvmScheme` for the `exact` scheme but **no `registerBatchSettlementEvmScheme`** in 2.18.0, so those ~2 lines are unavoidable today and are a stock SDK call, not a reimplementation. The hand-written pieces (`WebStorageClientChannelStorage`, deposit strategy, delegate voucher-signer) all implement *documented SDK extension points*. - -**Implication:** a third-party agent using stock `@x402/evm` batch-settlement already interoperates with our frontend and server in both directions. G5 is met at the protocol level today. - -**Two honest caveats (constrain, do not break, interop):** -- **Network is Base-only** (`BATCH_SETTLEMENT_NETWORKS`, `x402_server.ts:26`) — the SDK's `DEFAULT_STABLECOINS` registry lacks Optimism mainnet, so `enhancePaymentRequirements` throws for `eip155:10`. A Base agent interoperates; an Optimism-only one would find no matching `accepts[]`. This is an SDK-registry limit, not our deviation. -- **One inaccurate comment to fix:** `sc_llm_x402.ts:40-42` claims the ceiling→actual settlement split uses the SDK's `setSettlementOverrides()`. That API does **not exist** in the installed SDK. The split works (verify demands the exact ceiling; settle enforces only `<= voucher.maxClaimableAmount`, so passing a smaller `amount` to `settlePayment` is legal), but the comment misattributes the mechanism. Correct the comment; no behavior change. - ---- - -## Where the coupling is today (LLM only) - -| # | Coupling | Location | Consequence | -|---|---|---|---| -| C1 | Endpoint URL is a module constant | `useX402Chat.ts` (`X402_LLM_URL`) | Env-overridable but single-valued — one seller | -| C2 | Request/response contract is implicit | `useX402Chat.ts` sends `{ data: { prompt } }`; expects `{ message, usage }` | "Compatible agent" is undefined until the shape is blessed as `llm/v1` | -| C3 | Provenance shown from stale single-tenant file | `useAgentInfo.ts` → `/agent-registration.json` (describes retired Merkle design) | The panel that should show "who you pay" reads an out-of-date self-description | -| C4 | Onboarding narrates a whitelisting flow being retired | `agent-onboarding/+Page.tsx` | Advertises manual GitHub whitelisting; not machine-usable | - -Note: the wire *protocol* is not a coupling (see Key finding). The coupling is entirely at the application layer — URL, request shape, and provenance display. - ---- - -## Target architecture - -``` - openapi.llm.json (the llm/v1 contract — already served at - llm-agent origin /openapi.json, versioned, CORS-open, ownership-proofed) - │ - ┌───────────┴────────────┐ - │ │ - website /assistent third-party agent - (one client of many) (already interoperable — stock SDK) - │ │ - └───────────┬────────────┘ - │ reads request/response schema + interop floor - ▼ - LLM endpoint (402 → accepts[] → pay via SDK → serve) -``` - -**One artifact, not three.** The service contract *is* the OpenAPI file we already publish — `LLMChatRequest` / `LLMChatResponse` are the request/response schema; there is no second JSON-Schema document to author (that was duplication). What must be *added* to promote it from "our endpoint's docs" to "the `llm/v1` standard": - -1. A `serviceType` marker in the OpenAPI (`x-service-type: "llm/v1"`). -2. The **interop floor**, stated alongside the contract (it is a runtime-402 rule, not an OpenAPI field): *a compatible `llm/v1` agent MUST advertise at least one `accepts[]` entry with asset USDC on network Base (`eip155:8453`), scheme `batch-settlement` or `exact`.* Since our batch-settlement is stock-standard, the floor names it directly. - -No registry document, no agent-card indirection layer for the MVP — the OpenAPI at the agent's own origin *is* the card. A multi-agent registry list is a later phase; the MVP uses a custom-URL escape hatch to prove openness. - ---- - -## Phases - -### Phase 1 — Bless the `llm/v1` contract (backend-only, low risk) -- [ ] Add `x-service-type: "llm/v1"` to `scw_js/openapi.llm.json`. -- [ ] Write the interop floor into the OpenAPI's `x-guidance` / a short `docs/llm-v1.md`: USDC on Base, scheme `batch-settlement` or `exact`. -- [ ] Optionally tighten `LLMChatRequest` (mandatory roles, message ordering) — low priority. -- [ ] Fix the inaccurate `setSettlementOverrides` comment in `sc_llm_x402.ts:40-42`. -- [ ] Confirm the legacy Merkle `sc_llm.ts` path is not reachable from `/assistent` (separate function; verify no shared coupling). - -### Phase 2 — Decouple the chat hook (the core MVP change) -- [ ] `useX402Chat` takes an `agentUrl` (or `agent` object) instead of the `X402_LLM_URL` constant (fixes C1). Default stays our own endpoint. -- [ ] Optional-but-recommended: if the target agent advertises only `exact` (no batch-settlement in `accepts[]`), fall back to `exact` (wallet-popup-per-message). `exact` already has `registerExactEvmScheme`. This is the one place scheme-awareness is worth it — a third-party chat agent may not run batch-settlement. Keep it minimal: read `accepts[]`, pick batch-settlement if offered else `exact`, else fail with a clear message. -- [ ] `AssistantChat` passes the selected agent's URL through. - -### Phase 3 — Provenance + escape hatch (proves G3/G4) -- [x] Retarget provenance display off the stale `/agent-registration.json`: operator + `payTo` + origin parsed from the agent's own `/openapi.json` and its live 402 `accepts[]` (fixes C3), via `fetchAgentCard` in `website/hooks/x402Discovery.ts`. `AgentInfoPanel`'s llm path now renders from this card and no longer shows the retired "Become a provider" / EIP-8004 JSON links. -- [x] Custom-URL escape hatch **built** (`website/components/AgentSelector.tsx` + `precheckLlmV1Agent`) and unit-tested — but see the deferral note below. -- [x] Pre-payment disclosure (operator + `payTo` + origin) shown in the sidebar for the agent actually serving the chat (G4/G6). - -> **Deferred rendering — the escape hatch is built but NOT shown yet.** The custom-URL picker (`AgentSelector`) is intentionally not mounted in `AssistantChat`. Today the only `llm/v1`-compatible agent is ours (our `{ data: { prompt } }` shape + Base batch-settlement), so a "use a different agent" box would point at an empty room and only confuse users. **Re-enable trigger:** when `llm/v2` (the OpenAI chat-completions shape — see [Design record D-shape]) ships, thousands of existing agents become compatible and the picker becomes motivated. Until then it stays in the codebase, ready: `AgentSelector.tsx`, `x402Discovery.ts`'s `precheckLlmV1Agent`, and `useX402Chat(network, agentUrl?)`'s agent-URL parameter are all retained and tested; re-mount `AgentSelector` and pass the selected URL into `useX402Chat` to turn it back on. - -### Phase 4 (deferred, not this PR) — Registry + automated listing -A `registry.json` of multiple vetted agents, automated permissionless listing (ownership-proof + contract-validation + liveness probe). Explicitly out of scope for the MVP; the escape hatch stands in for it. Prepare toward it, do not build it yet. - -### Onboarding page (Option B, small, can ride Phase 3) -Demote `agent-onboarding/+Page.tsx` to **pure documentation**: strip the GitHub-issue generator and client-side JSON builder (the retired whitelisting flow, C4); keep the plain-English service explanation, the payment-flow diagram, and the request/response shapes — but **link to the live `openapi.llm.json`** instead of hardcoding them. Retitle from "onboarding" to "For agents / integration." It graduates to the registry's human face when Phase 4 lands. - ---- - -## Design record - -- **D1 — The OpenAPI file *is* the service contract.** No separate JSON-Schema document. `LLMChatRequest`/`LLMChatResponse` are the schema; a `serviceType` marker + the interop floor promote it to the `llm/v1` standard. Authoring a parallel schema would be duplication. -- **D2 — Interop floor, not lock-in (`D3` in the prior draft).** Each contract mandates a client-fulfillable minimum: ≥1 `accepts[]` entry, USDC, Base, scheme `batch-settlement` or `exact`. This lives in the runtime 402, stated beside the OpenAPI. Facilitator choice remains the seller's business; the client never talks to it directly. -- **D3 — We are already standard; do not re-engineer the protocol.** The only protocol-adjacent work is (a) fix the `setSettlementOverrides` comment, (b) adopt `registerBatchSettlementEvmScheme` if/when the SDK ships it. Neither blocks the MVP. -- **D4 — MVP is exactly one direction: the website consumes any agent.** The reverse direction (third parties discovering + consuming *our* endpoint) is **already done** — we are wire-standard (stock SDK) *and* discoverable/ownership-proofed on x402scan from the prior PR. An external consumer can find us and pay+call us today with nothing further from us. So the MVP builds only the consuming side: decouple the hook + escape hatch. A registry is a later phase; the escape hatch proves openness for the MVP. -- **D5 — ERC-8004 as format only, if at all.** Nothing minted, no gas, no on-chain lookup in the critical path. -- **D6 — External directories are listings, never dependencies.** - ---- - -## Open questions - -- **Q1 — Batch-settlement vs `exact` for third-party chat agents.** Batch-settlement has no public client register helper (SDK gap) but *we already use it fine* via the stock class. A third-party chat agent will likely advertise `exact` (wallet popup per message). Phase 2's fallback handles this. Is per-message-popup `exact` an acceptable MVP experience for third-party agents, with our own agent keeping the batch-settlement (popup-free) advantage? (Recommended: yes — honest, cheap, defers no protocol work.) -- **Q2 — Failure economics.** With BYO agents, a user pays an unknown `payTo` and may get nothing usable; x402 has no refund. For the MVP the escape hatch is explicitly user-initiated (they paste the URL), which bounds the risk to intentional use. Is a liveness pre-check needed before allowing a pasted URL, or is "you pasted it, at your own risk" sufficient for MVP? -- **Q3 — Does `/assistent` need multi-turn context across a changed agent?** If the user switches agents mid-conversation, channel state (per-origin in `localStorage`) is already scoped correctly, but conversation history semantics across a switch are undefined. Probably out of scope; confirm.