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/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" }, diff --git a/website/components/AgentInfoPanel.tsx b/website/components/AgentInfoPanel.tsx index 56c8f8936..6d406e298 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,44 @@ export function AgentInfoPanel({ service = "genimg", variant = "footer" }: Agent const isSidebar = variant === "sidebar"; + // 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}
+
{agentCard.origin}
+ {agentCard.payTo && ( +
+ pays → {agentCard.payTo.slice(0, 6)}…{agentCard.payTo.slice(-4)} +
+ )} + {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… +
+ ); + } + 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..ce24ad763 100644 --- a/website/components/AssistantChat.tsx +++ b/website/components/AssistantChat.tsx @@ -5,7 +5,7 @@ * 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 * as styles from "../layouts/styles"; import { useLocale } from "../hooks/useLocale"; @@ -13,9 +13,16 @@ 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 { 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; @@ -72,8 +79,22 @@ export function AssistantChat() { const { isConnected, connectWallet } = useWalletConnection(); const { network, switchIfNeeded, switchError } = useAutoNetwork(CHAT_NETWORKS); + 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"; if (isLoading) return "loading"; @@ -186,7 +207,7 @@ export function AssistantChat() { {/* Agent Info Section */}

Agent

- +
)} @@ -270,7 +291,7 @@ export function AssistantChat() { {/* Agent Info - Mobile Footer */} - {isMobile && } + {isMobile && } diff --git a/website/hooks/useX402Chat.ts b/website/hooks/useX402Chat.ts index 1b9f813c1..d247b0869 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 { @@ -24,11 +25,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. +export 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 +144,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 @@ -203,24 +209,14 @@ export function useX402Chat(network: string): UseX402ChatResult { 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( - `Server does not offer ${network}. Offered: ${offered.join(", ")}. ` + - `This could indicate a backend configuration error.`, - ); - } - } 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.`, + ); } } } @@ -232,7 +228,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 +263,7 @@ export function useX402Chat(network: string): UseX402ChatResult { throw err; } }, - [walletClient, publicClient, network], + [walletClient, publicClient, network, agentUrl], ); const reset = useCallback(() => { diff --git a/website/hooks/x402Discovery.ts b/website/hooks/x402Discovery.ts new file mode 100644 index 000000000..3a63c1e09 --- /dev/null +++ b/website/hooks/x402Discovery.ts @@ -0,0 +1,184 @@ +/** + * 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 } }; +} + +/** + * 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"`; + * 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/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", () => ({ diff --git a/website/hooks/useX402Chat.test.ts b/website/test/useX402Chat.test.ts similarity index 89% rename from website/hooks/useX402Chat.test.ts rename to website/test/useX402Chat.test.ts index 0920a2631..c08d2e82e 100644 --- a/website/hooks/useX402Chat.test.ts +++ b/website/test/useX402Chat.test.ts @@ -11,7 +11,7 @@ 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 { useX402Chat, WebStorageClientChannelStorage } from "../hooks/useX402Chat"; import type { X402ChatMessage } from "../types/x402"; const mockRegister = vi.fn(); @@ -297,6 +297,41 @@ describe("useX402Chat", () => { }); }); + 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); diff --git a/website/test/x402Discovery.test.ts b/website/test/x402Discovery.test.ts new file mode 100644 index 000000000..9e00cd39b --- /dev/null +++ b/website/test/x402Discovery.test.ts @@ -0,0 +1,111 @@ +/** + * 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); + }); +});