diff --git a/.github/THREAT_MODEL.md b/.github/THREAT_MODEL.md index 6fb497fad..ea94b51d8 100644 --- a/.github/THREAT_MODEL.md +++ b/.github/THREAT_MODEL.md @@ -1,6 +1,6 @@ # Threat Model -Last updated: 2026-10-18 +Last updated: 2026-10-20 This is a lightweight, living threat model. It follows the OWASP four-question framework and the values of the Threat Modeling Manifesto — a maintained document over a one-time audit, design issues over checkbox compliance, action over ceremony. @@ -17,25 +17,26 @@ This is a lightweight, living threat model. It follows the OWASP four-question f What has monetary value, irreversibility, or trust significance in this system: -| Asset | Location | Value | -| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| ETH in GenImNFTv4 | On-chain | Accumulates mint fees until `withdraw()` is called | -| ETH in LLMv1 (deprecated) | On-chain | User prepaid balances; withdrawable by users or claimable by providers. **No serverless code path calls this contract anymore** — `sc_llm.ts`/`leaf_history.ts` were retired in favor of `/assistent`'s x402 batch-settlement USDC channels. Final decommission (contract-level retirement) is pending confirmation that all balances have been withdrawn. | -| Stablecoin settlements | x402_facilitator | Per-request payments routed through the facilitator: USDC (any supported network), EURC and EURe on Base. EURe (Monerium, no EIP-3009, 18 decimals) settles via Permit2 through the x402 exact Permit2 proxy | -| x402 exact Permit2 proxy + Uniswap Permit2 (external) | On-chain, Base and Base Sepolia (SDK-pinned addresses, not upgradeable by this project) | Third-party contracts the facilitator settles EURe through. A defect affects EURe settlement availability, not fund safety: `/verify` simulates `settleWithPermit` against the live proxy before anything is broadcast, and Permit2 enforces the buyer's signature over the witness on-chain | -| Batch-settlement channel escrow | On-chain, canonical x402 batch-settlement contract (Optimism + Base mainnet) | Buyer deposits held in the channel's own token — USDC, EURC or EURe on Base — until claimed against signed vouchers. Escrow is the buyer's: refundable via the buyer-owned `withdraw` path after the lock, and a stalled claim delays it rather than losing it. Every token's channels are separate (the token is part of `channelId`), so each needs its own claim sweep and its own fee allowance | -| EIP3009 Splitter contract (testnet) | On-chain, Optimism Sepolia only (`eip155:11155420`) | Routes USDC settlements via EIP-3009; owner-upgradeable. Not yet on mainnet — promotes to a live asset on mainnet deployment | -| NFT metadata integrity | On-chain (token URIs) | Authoritative record of what image each token represents | -| Owner EOA private key | Dedicated keystore account (`0x1af51D…fBB20`), separated from daily wallet since 2026-06 | Controls every upgradeable contract and owns the three ERC-8004 agent identities — the highest-value key in the system | -| ERC-8004 agent identities (3) | On-chain, Base Identity Registry `0x8004A169…a432` (agentIds 97598, 97599, 97600) | Owned by the owner EOA. Owner can repoint `agentURI`, transfer, or change the bound `agentWallet`; holds no funds, but the identity is what discovery and reputation attach to | -| Agent wallet private key | scw_js secrets | Can trigger `requestImageUpdate()` and receive mintPrice per call. Same address as the x402 `payTo` (`NFT_WALLET`), bound as the `agentWallet` of the three ERC-8004 agents | -| Receiver-authorizer private key | scw_js secrets | Signs batch-settlement channel-config and refund authorizations (EIP-712) for the `/assistent` sellers (LLM, search, fetch) via `receiverAuthorizerSigner`. Never sends transactions, never needs funding | -| Facilitator wallet private key | x402_facilitator secrets | Receives settlement fees (USDC; EURC and EURe on Base) | -| Scaleway secrets (SCW_SECRET_KEY) | Serverless secrets | Access to S3 image bucket, transactional email, and analytics counters | -| BFL / IONOS API keys | Serverless secrets | Image generation quota; no on-chain access | -| bunny.net account (incl. account-wide API key) | bunny.net dashboard; CLI profile at `~/.config/bunnynet.json` (mode 600) | Controls all three Edge Script twins (searchapi 97472, llm 97948, mcp 97949) — can read each script's plain env values, overwrite secrets, replace code, delete scripts. Broader than the per-script deploy keys below | -| Edge Script deploy keys (`BUNNYJS_`/`BUNNYLLM_`/`BUNNYMCP_DEPLOY_KEY`) | GitHub repo secrets in a public repo (Actions; the paired `*_SCRIPT_ID` secret names the target) | Each publishes arbitrary code to exactly its own script — code execution in a sandbox whose env holds a copy of the seller secret set. Three keys, one per twin; each is bounded to its script, and the workflow triggers stay push-to-`main`-only | -| Bunny Edge Script env secrets (searchapi 97472, llm 97948, mcp 97949) | bunny.net script configuration | Per script: the S3 credential pair (production channel store) and the receiver-authorizer private key on all three; `BRAVE_API_KEY` on searchapi + mcp, `MISTRAL_API_KEY` (metered inference) on llm + mcp. Each script's copy must be rotated together with `scw_js`'s — one shared secret set, now up to four copies across the two platforms | +| Asset | Location | Value | +| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ETH in GenImNFTv4 | On-chain | Accumulates mint fees until `withdraw()` is called | +| ETH in LLMv1 (deprecated) | On-chain | User prepaid balances; withdrawable by users or claimable by providers. **No serverless code path calls this contract anymore** — `sc_llm.ts`/`leaf_history.ts` were retired in favor of `/assistent`'s x402 batch-settlement USDC channels. Final decommission (contract-level retirement) is pending confirmation that all balances have been withdrawn. | +| Stablecoin settlements | x402_facilitator | Per-request payments routed through the facilitator: USDC (any supported network), EURC and EURe on Base. EURe (Monerium, no EIP-3009, 18 decimals) settles via Permit2 through the x402 exact Permit2 proxy | +| x402 exact Permit2 proxy + Uniswap Permit2 (external) | On-chain, Base and Base Sepolia (SDK-pinned addresses, not upgradeable by this project) | Third-party contracts the facilitator settles EURe through. A defect affects EURe settlement availability, not fund safety: `/verify` simulates `settleWithPermit` against the live proxy before anything is broadcast, and Permit2 enforces the buyer's signature over the witness on-chain | +| Batch-settlement channel escrow | On-chain, canonical x402 batch-settlement contract (Optimism + Base mainnet) | Buyer deposits held in the channel's own token — USDC, EURC or EURe on Base — until claimed against signed vouchers. Escrow is the buyer's: refundable via the buyer-owned `withdraw` path after the lock, and a stalled claim delays it rather than losing it. Every token's channels are separate (the token is part of `channelId`), so each needs its own claim sweep and its own fee allowance | +| EIP3009 Splitter contract (testnet) | On-chain, Optimism Sepolia only (`eip155:11155420`) | Routes USDC settlements via EIP-3009; owner-upgradeable. Not yet on mainnet — promotes to a live asset on mainnet deployment | +| NFT metadata integrity | On-chain (token URIs) | Authoritative record of what image each token represents | +| Owner EOA private key | Dedicated keystore account (`0x1af51D…fBB20`), separated from daily wallet since 2026-06 | Controls every upgradeable contract and owns the three ERC-8004 agent identities — the highest-value key in the system | +| ERC-8004 agent identities (3) | On-chain, Base Identity Registry `0x8004A169…a432` (agentIds 97598, 97599, 97600) | Owned by the owner EOA. Owner can repoint `agentURI`, transfer, or change the bound `agentWallet`; holds no funds, but the identity is what discovery and reputation attach to | +| Agent reputation records | On-chain, external ERC-8004 ReputationRegistry `0x8004BAa1…9b63` (Base mainnet) | Stars written by clients of the agents via `giveFeedback` (the `/assistent` rating widget; agentId 97599 always, 97600 when web tools ran). Third-party contract, permissionless by design (spec v1.1 removed pre-authorization): anyone can rate from any wallet, so the records are an **advisory** discovery signal — like the analytics counters, never an input to a security or financial decision (§5, reputation layer) | +| Agent wallet private key | scw_js secrets | Can trigger `requestImageUpdate()` and receive mintPrice per call. Same address as the x402 `payTo` (`NFT_WALLET`), bound as the `agentWallet` of the three ERC-8004 agents | +| Receiver-authorizer private key | scw_js secrets | Signs batch-settlement channel-config and refund authorizations (EIP-712) for the `/assistent` sellers (LLM, search, fetch) via `receiverAuthorizerSigner`. Never sends transactions, never needs funding | +| Facilitator wallet private key | x402_facilitator secrets | Receives settlement fees (USDC; EURC and EURe on Base) | +| Scaleway secrets (SCW_SECRET_KEY) | Serverless secrets | Access to S3 image bucket, transactional email, and analytics counters | +| BFL / IONOS API keys | Serverless secrets | Image generation quota; no on-chain access | +| bunny.net account (incl. account-wide API key) | bunny.net dashboard; CLI profile at `~/.config/bunnynet.json` (mode 600) | Controls all three Edge Script twins (searchapi 97472, llm 97948, mcp 97949) — can read each script's plain env values, overwrite secrets, replace code, delete scripts. Broader than the per-script deploy keys below | +| Edge Script deploy keys (`BUNNYJS_`/`BUNNYLLM_`/`BUNNYMCP_DEPLOY_KEY`) | GitHub repo secrets in a public repo (Actions; the paired `*_SCRIPT_ID` secret names the target) | Each publishes arbitrary code to exactly its own script — code execution in a sandbox whose env holds a copy of the seller secret set. Three keys, one per twin; each is bounded to its script, and the workflow triggers stay push-to-`main`-only | +| Bunny Edge Script env secrets (searchapi 97472, llm 97948, mcp 97949) | bunny.net script configuration | Per script: the S3 credential pair (production channel store) and the receiver-authorizer private key on all three; `BRAVE_API_KEY` on searchapi + mcp, `MISTRAL_API_KEY` (metered inference) on llm + mcp. Each script's copy must be rotated together with `scw_js`'s — one shared secret set, now up to four copies across the two platforms | Notably absent: user wallet keys (never touch the server), user PII (not collected). @@ -90,6 +91,8 @@ This is the attack-**surface** map: each arrow marks where data crosses from a l │ │ │ wagmi/viem ──────────────────────────► Blockchain RPC │ │ (public, no auth) │ +│ wagmi (ERC-8004 rating) ───────────► ReputationRegistry │ +│ (external registry) │ │ @x402/fetch ─────────────────────────► x402_facilitator │ │ (★ open CORS) │ │ useWalletAuth Bearer ────────────────► Growth API │ @@ -172,6 +175,16 @@ The assistant is itself an LLM agent with a paid tool loop, so its review surfac --- +### On-chain reputation layer — ERC-8004 feedback + +The `/assistent` rating widget writes stars to the external ERC-8004 ReputationRegistry (`0x8004BAa1…9b63`, Base mainnet) — one `giveFeedback` per agent that served the chat (97599 always, 97600 once its web tools ran), from the rater's own wallet and gas, confirm-gated and capped at one bundle per answer. It does not map onto the three catalogs above, so its techniques are tracked here: + +| Technique | Where it applies | Status | Tracked in | +| ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ----------------------------------- | +| Reputation manipulation / astroturfing (CWE-345) | ERC-8004 ReputationRegistry feedback: the registry authenticates nothing but "not the agent's owner/operator" (v1.1 removed pre-authorization) | Accepted | this table; §1 (reputation records) | + +The stance, and why it is Accepted: the registry's trust model assumes unauthenticated raters — anyone can rate from any wallet with or without the widget, so the product feature adds no capability an external actor lacks. Reputation scores are advisory discovery signals (the analytics-counter discipline): fine for ranking insight, never a security or financial input. Self-feedback seeding from a fresh wallet is on-chain indistinguishable from an organic rating — a known instance exists (tx `0x428ffc57…d2b9a0`, a deliberate first seed from a non-owner wallet). The residual exposure is bounded to the rater's own gas (~$0.001 per transaction, one bundle per answer) and to the agents' public profiles — no funds, no keys, no contract upgrade path. + ## 6. CIA Summary For blockchain systems, **Integrity** dominates. Confidentiality is generally low concern (code is public, transactions are public). Availability is partially guaranteed by the L2 itself; the serverless layer is the availability risk. **Repudiation** is a structural strength: all on-chain transactions are cryptographically signed and permanently recorded — no actor can deny having submitted a transaction. diff --git a/.github/workflows/deploy-bunny-js.yml b/.github/workflows/deploy-bunny-js.yml index 837797b87..132bc3e49 100644 --- a/.github/workflows/deploy-bunny-js.yml +++ b/.github/workflows/deploy-bunny-js.yml @@ -68,7 +68,7 @@ jobs: run: npm run build - name: Deploy searchapi twin to Bunny Edge Scripting - uses: BunnyWay/actions/deploy-script@main + uses: BunnyWay/actions/deploy-script@931a74b9c45ea9f9f01435fbbf1649830e67d3b3 # 2026-10-12 main; a mutable ref here is code-exec with the deploy keys — bump deliberately with: # BUNNYJS_SCRIPT_ID and BUNNYJS_DEPLOY_KEY are repo secrets; both come from the # bunny dashboard: Script -> Deployments -> Settings. The deploy key authorizes @@ -78,7 +78,7 @@ jobs: file: "bunny_js/dist/searchapi.js" - name: Deploy llm twin to Bunny Edge Scripting - uses: BunnyWay/actions/deploy-script@main + uses: BunnyWay/actions/deploy-script@931a74b9c45ea9f9f01435fbbf1649830e67d3b3 # 2026-10-12 main; a mutable ref here is code-exec with the deploy keys — bump deliberately with: # Same per-script secret pair pattern as searchapi, its own script id and its # own deploy key — narrower blast radius than an account-wide API key. @@ -87,7 +87,7 @@ jobs: file: "bunny_js/dist/llm.js" - name: Deploy mcp twin to Bunny Edge Scripting - uses: BunnyWay/actions/deploy-script@main + uses: BunnyWay/actions/deploy-script@931a74b9c45ea9f9f01435fbbf1649830e67d3b3 # 2026-10-12 main; a mutable ref here is code-exec with the deploy keys — bump deliberately with: # The mcp bundle embeds the llm and search tools (see mcp_tools.ts), so this one # script serves both web_search and chat_completion. diff --git a/website/components/AssistantChat.styles.ts b/website/components/AssistantChat.styles.ts index 2d58424e8..f26f048aa 100644 --- a/website/components/AssistantChat.styles.ts +++ b/website/components/AssistantChat.styles.ts @@ -381,6 +381,22 @@ export const researchNotes = css({ "& li": { marginBottom: "xs" }, }); +/** + * The in-stream rating row: five muted stars (the FeedbackWidget) plus — only when a web tool + * served this chat — the target toggle, sitting directly under the last completed answer like + * that answer's own cost line. Wraps on narrow screens rather than overflowing; muted chrome, + * because the answer is the content and the rating is an offer, not a call to action. + */ +export const rateBlock = css({ + display: "flex", + flexWrap: "wrap", + alignItems: "center", + gap: "sm", + marginTop: "xs", + fontSize: "xs", + color: "textMuted", +}); + export const loadingBubble = css({ // The 80% cap sits on `loadingColumn`, which holds this bubble and the research status row. maxWidth: "100%", diff --git a/website/components/AssistantChat.tsx b/website/components/AssistantChat.tsx index bb094f0b8..3ec396fb5 100644 --- a/website/components/AssistantChat.tsx +++ b/website/components/AssistantChat.tsx @@ -101,6 +101,7 @@ import { button } from "../styled-system/recipes"; import { PageHeader } from "./PageHeader"; import { FretchenLogo } from "./FretchenLogo"; import { GetFundsModal } from "./GetFundsModal"; +import { FeedbackWidget, FEEDBACK_TARGETS } from "./FeedbackWidget"; import { PaymentError, readChargedTotal } from "../utils/x402PaidFetch"; /** @@ -492,6 +493,11 @@ export function AssistantChat() { spent: bigint; notes: ResearchNote[]; } | null>(null); + // Whether any turn of this chat has paid the search agent (ERC-8004 agent 97600) — both + // `search_web` and `fetch_url` buy from it (tools/search.ts, tools/webFetch.ts). Latched for + // the session, not per turn: the search agent is only rated once it actually served this + // chat, bundled into the same star pick as the assistent rather than offered as a choice. + const [webToolsUsed, setWebToolsUsed] = useState(false); // Read by the loop before each hop. A ref, not state: the loop is already running inside an // event handler and must see the press without waiting for a re-render. `stopPressed` is only the // button's own feedback. @@ -1267,8 +1273,14 @@ export function AssistantChat() { shouldStop: () => stopRequestedRef.current, onPhase: (phase) => { setLoopPhase(phase); - if (phase.kind === "tool" && phase.name === searchWebTool.function.name) searches++; - if (phase.kind === "tool" && phase.name === fetchUrlTool.function.name) pages++; + if (phase.kind === "tool" && phase.name === searchWebTool.function.name) { + searches++; + setWebToolsUsed(true); + } + if (phase.kind === "tool" && phase.name === fetchUrlTool.function.name) { + pages++; + setWebToolsUsed(true); + } if (phase.kind === "tool" && phase.name === noteFindingsTool.function.name) { notes.push(...parseNotes(phase.args)); } @@ -1607,6 +1619,22 @@ export function AssistantChat() { )) )} + {/* In-stream rating, directly under the last completed answer — the same position + as that answer's own cost line, because the stars rate that answer. At rest it is + nothing but five muted glyphs; the disclosure appears only at the moment of + commitment (FeedbackWidget). One star pick rates every agent that served this + chat: the assistent always, the search agent once its tools ran — the user never + chooses a target, because they experienced one answer, not two services. */} + {!isLoading && messages.length > 0 && messages[messages.length - 1].role === "assistant" && ( +
+ +
+ )} + {currencyFallback && ( // One honest line when the send switched token: the deviation is announced, the // stored preference stays untouched (the hook never persists a fallback), and diff --git a/website/components/FeedbackWidget.tsx b/website/components/FeedbackWidget.tsx new file mode 100644 index 000000000..6c71f8880 --- /dev/null +++ b/website/components/FeedbackWidget.tsx @@ -0,0 +1,301 @@ +/** + * FeedbackWidget — an in-stream on-chain rating for ERC-8004 agents, mounted directly under + * the chat's last completed answer (`/assistent`). One `giveFeedback` transaction per agent + * that served the chat: the assistent (97599) always, the search agent (97600) once its tools + * ran — one star pick, sent to all targets in order. The user never chooses a target: they + * experienced one answer, not two services, so there is no target toggle to misunderstand. + * + * The interaction is quiet trigger → explain at the moment of commitment → confirm, the same + * grammar as the donate flow: at rest the widget is nothing but five muted glyphs, and the + * disclosure appears only once a star is picked — read exactly when it governs, instead of as + * standing boilerplate. The full details (registry, 8004scan) sit in a native
whose + * summary asks the question it answers. Stars stay in the muted chrome, not the orange "value + * exchange" hue: a rating moves no money, so the design system's support colour stays reserved + * for payments. + * + * The registry is external to this repo (github.com/erc-8004/erc-8004-contracts); the ABI + * fragment below is vendored from its `abis/ReputationRegistry.json` and must match that + * contract's `giveFeedback(uint256 agentId, int128 value, uint8 valueDecimals, string tag1, + * string tag2, string endpoint, string feedbackURI, bytes32 feedbackHash)`. + */ +import React, { useState } from "react"; +import { useAccount, useSwitchChain, useWriteContract } from "wagmi"; +import { css } from "../styled-system/css"; +import { button } from "../styled-system/recipes"; +import { useLocale } from "../hooks/useLocale"; +import { useWalletConnection } from "../hooks/useWalletConnection"; + +/** + * The one block to flip for a Base Sepolia rehearsal — chainId, registry and the explorer go to + * the testnet values (`84532`, `0x8004B663056A597Dffe9eCcC1965A193B7388713`, + * `https://sepolia.basescan.org`) and the ids in FEEDBACK_TARGETS go to 9547/9548 (from + * `eth/scripts/deployments/erc8004-agents-baseSepolia.json`). A local flip, never merged. + */ +export const FEEDBACK = { + chainId: 8453, + registry: "0x8004BAa17C55a88189AE136b182e5fdA19dE9b63", + explorer: "https://basescan.org", +} as const; + +/** + * What the chat actually paid, per rating target: the per-message completions go to the LLM + * agent (97599), the `search_web` and `fetch_url` tool calls to the search agent (97600) — see + * `tools/search.ts` and `tools/webFetch.ts`. Rating records the endpoint the rater used, so an + * indexer can correlate the rating with the service rather than the site. + */ +export const FEEDBACK_TARGETS = { + assistent: { agentId: 97599, endpoint: "https://llm-agent.fretchen.eu" }, + search: { agentId: 97600, endpoint: "https://web-agent.fretchen.eu" }, +} as const; + +/** One ERC-8004 agent a star pick is sent to. */ +export interface FeedbackTarget { + agentId: number; + endpoint: string; +} + +/** Vendored from erc-8004-contracts `abis/ReputationRegistry.json` — see the header comment. + * Exported so tests can assert the exact transaction shape (as FacilitatorApproval does for + * its ERC-20 fragment). */ +export const REPUTATION_REGISTRY_ABI = [ + { + type: "function", + name: "giveFeedback", + stateMutability: "nonpayable", + inputs: [ + { name: "agentId", type: "uint256" }, + { name: "value", type: "int128" }, + { name: "valueDecimals", type: "uint8" }, + { name: "tag1", type: "string" }, + { name: "tag2", type: "string" }, + { name: "endpoint", type: "string" }, + { name: "feedbackURI", type: "string" }, + { name: "feedbackHash", type: "bytes32" }, + ], + outputs: [], + }, +] as const; + +/** `feedbackHash` is optional per the spec; zero says "no content hash". */ +const ZERO_HASH = `0x${"0".repeat(64)}` as const; + +// ─── Styles ────────────────────────────────────────────────────────────────── + +const starsRow = css({ + display: "flex", + alignItems: "center", + gap: "xs", +}); + +const starButton = css({ + fontSize: "lg", + lineHeight: "1", + padding: "0", + border: "none", + background: "none", + color: "textMuted", + cursor: "pointer", +}); + +/** The confirm area: three stacked rows — disclosure, details, then the action row — so a + * long sentence never pushes the buttons onto a ragged line. Revealed together the moment a + * star is picked, so the decision is explained at the moment it is made. */ +const actionArea = css({ + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + gap: "xs", + marginTop: "xs", +}); + +/** Send and Cancel share one shape: same size, both bordered — hierarchy comes from the + * variant (primary vs secondary), never from a different silhouette. */ +const actionButtonsRow = css({ + display: "flex", + flexWrap: "wrap", + alignItems: "center", + gap: "sm", +}); + +const details = css({ + fontSize: "xs", + color: "textMuted", + "& summary": { cursor: "pointer" }, +}); + +const note = css({ + fontSize: "xs", + color: "textMuted", +}); + +// ─── Component ─────────────────────────────────────────────────────────────── + +interface FeedbackWidgetProps { + /** Every agent that served the chat; the pick is sent to each, in order. */ + targets: readonly FeedbackTarget[]; +} + +/** Single-line, truncated at 200 chars — a raw wallet error is unreadable and unbounded; the + * chat loop's `describeFailure` habit for the same reason. */ +function oneLine(message: string): string { + return message.length > 200 ? `${message.slice(0, 199)}…` : message; +} + +export function FeedbackWidget({ targets }: FeedbackWidgetProps) { + const { isConnected, connectWallet } = useWalletConnection(); + const { chainId } = useAccount(); + const { switchChainAsync } = useSwitchChain(); + const { writeContractAsync } = useWriteContract(); + const [stars, setStars] = useState(0); + const [detailsOpen, setDetailsOpen] = useState(false); + const [sending, setSending] = useState(false); + const [sentHashes, setSentHashes] = useState<`0x${string}`[]>([]); + const [failedLine, setFailedLine] = useState(null); + + const connectLabel = useLocale({ label: "assistent.rateConnect" }); + const switchLabel = useLocale({ label: "assistent.rateSwitch" }); + const starsLabel = useLocale({ label: "assistent.rateStars" }); + const disclosureLabel = useLocale({ label: "assistent.rateDisclosure" }); + const bundledDisclosureLabel = useLocale({ label: "assistent.rateDisclosureBundled" }); + const detailsSummaryLabel = useLocale({ label: "assistent.rateDetailsSummary" }); + const detailsBodyLabel = useLocale({ label: "assistent.rateDetailsBody" }); + const sendLabel = useLocale({ label: "assistent.rateSend" }); + const cancelLabel = useLocale({ label: "assistent.cancel" }); + const walletLabel = useLocale({ label: "assistent.rateWallet" }); + const doneLabel = useLocale({ label: "assistent.rateDone" }); + const failedLabel = useLocale({ label: "assistent.rateFailed" }); + const partialFailedLabel = useLocale({ label: "assistent.ratePartialFailed" }); + const selfBlockedLabel = useLocale({ label: "assistent.rateSelfBlocked" }); + + // One wallet prompt per target, in order: the registry has no batching path, so a bundled + // pick is a sequential loop. A rejection stops the loop where it stands — what already + // landed stays on chain and is shown as a partial result, nothing is retried silently. + // writeContractAsync resolves at SUBMISSION, not inclusion: a transaction that reverts at + // execution still reaches the settled view, which is why the copy says "submitted" and the + // basescan links are the status source. + const send = async () => { + if (stars === 0 || sending) return; + setSending(true); + setSentHashes([]); + setFailedLine(null); + const landed: `0x${string}`[] = []; + for (const target of targets) { + try { + const hash = await writeContractAsync({ + address: FEEDBACK.registry, + abi: REPUTATION_REGISTRY_ABI, + functionName: "giveFeedback", + chainId: FEEDBACK.chainId, + args: [BigInt(target.agentId), BigInt(stars), 0, "", "", target.endpoint, "", ZERO_HASH], + }); + landed.push(hash); + setSentHashes([...landed]); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + // The one revert the contract produces on purpose — its owner and operators rating + // their own agent — gets its own sentence; everything else stays one truncated line. + setFailedLine( + msg.includes("Self-feedback not allowed") ? selfBlockedLabel : failedLabel.replace("{error}", oneLine(msg)), + ); + break; + } + } + setSending(false); + }; + + const allSent = sentHashes.length === targets.length && targets.length > 0; + const partial = failedLine !== null && sentHashes.length > 0 && !sending; + const settled = !sending && (allSent || partial); + + return ( +
+
+ {[1, 2, 3, 4, 5].map((n) => ( + + ))} +
+ + {settled ? ( +
+

{allSent ? doneLabel : partialFailedLabel.replace("{error}", failedLine ?? "")}

+ {sentHashes.map((hash) => ( +

+ + ↗ + +

+ ))} +
+ ) : ( + stars > 0 && ( +
+ {!isConnected ? ( + + ) : chainId !== FEEDBACK.chainId ? ( + + ) : ( + <> +

{targets.length > 1 ? bundledDisclosureLabel : disclosureLabel}

+
{ + e.preventDefault(); + setDetailsOpen(!detailsOpen); + }} + > + {detailsSummaryLabel} +

{detailsBodyLabel.replace("{registry}", FEEDBACK.registry)}

+
+
+ + +
+ {sending &&

{walletLabel}

} + {failedLine &&

{failedLine}

} + + )} +
+ ) + )} +
+ ); +} diff --git a/website/locales/de.ts b/website/locales/de.ts index 45720a2a7..544f64090 100644 --- a/website/locales/de.ts +++ b/website/locales/de.ts @@ -190,6 +190,27 @@ export default { toolConfirmSizeLabel: "Größe", toolConfirmMintNotice: "Dies erzeugt ein NFT in deiner Wallet.", toolConfirmGenerate: "Generieren (0,07 $)", + // On-Chain-Bewertung (FeedbackWidget): 1–5 Sterne in die ERC-8004 ReputationRegistry. + // Im Ruhezustand zeigt das Widget nur Sterne; der Hinweis erscheint erst mit dem ersten + // Stern. Hat ein Web-Tool bedient, bewertet ein Klick beide Agenten (der gebündelte Text). + rateStars: "{n} von 5", + rateDisclosure: + "Öffentlich und dauerhaft auf Base, aus deiner Wallet gesendet. Kostet eine kleine Netzwerkgebühr in ETH (in der Regel unter 0,01 $).", + rateDisclosureBundled: + "Bewertet den Assistenten und die von ihm genutzte Websuche — öffentlich und dauerhaft auf Base, aus deiner Wallet gesendet. Kostet eine kleine Netzwerkgebühr in ETH (in der Regel unter 0,01 $).", + rateDetailsSummary: "Was passiert mit meiner Bewertung?", + rateDetailsBody: + "Deine Bewertung wird in die ERC-8004 Reputation Registry auf Base geschrieben ({registry}) und ist öffentlich lesbar — unter anderem im Agent-Profil auf 8004scan.io. Sie lässt sich danach nicht bearbeiten.", + rateSend: "Bewertung senden", + rateWallet: "In deiner Wallet bestätigen…", + rateDone: "Bewertung übermittelt — danke.", + rateFailed: "Bewertung fehlgeschlagen: {error}", + ratePartialFailed: + "Die Bewertung des Assistenten wurde übermittelt; die Bewertung der Websuche ist fehlgeschlagen: {error}", + rateSelfBlocked: + "Diese Wallet kann diesen Agenten nicht bewerten: Eigentümer und Operatoren des Agenten sind vertraglich ausgeschlossen.", + rateConnect: "Wallet zum Bewerten verbinden", + rateSwitch: "Zu Base wechseln", }, walletoptions: { connect: "Verbinden", diff --git a/website/locales/en.ts b/website/locales/en.ts index 364a5be95..85fa09aa6 100644 --- a/website/locales/en.ts +++ b/website/locales/en.ts @@ -181,6 +181,25 @@ export default { toolConfirmSizeLabel: "Size", toolConfirmMintNotice: "This will mint an NFT to your wallet.", toolConfirmGenerate: "Generate ($0.07)", + // On-chain rating (FeedbackWidget): 1–5 stars written to the ERC-8004 ReputationRegistry. + // At rest the widget shows stars only; the disclosure appears once a star is picked. When + // a web tool served the chat, one pick rates both agents (the bundled string). + rateStars: "{n} of 5", + rateDisclosure: + "Public and permanent on Base, sent from your wallet. Costs a small network fee in ETH (typically under $0.01).", + rateDisclosureBundled: + "Rates the assistant and the web search it used — public and permanent on Base, sent from your wallet. Costs a small network fee in ETH (typically under $0.01).", + rateDetailsSummary: "What happens with my rating?", + rateDetailsBody: + "Your rating is written to the ERC-8004 Reputation Registry on Base ({registry}), where anyone can read it — the agent's profile on 8004scan.io shows it too. It cannot be edited afterwards.", + rateSend: "Send rating", + rateWallet: "Confirm in your wallet…", + rateDone: "Rating submitted — thank you.", + rateFailed: "Rating failed: {error}", + ratePartialFailed: "The assistant's rating was submitted; the web search rating failed: {error}", + rateSelfBlocked: "This wallet cannot rate this agent: its owner and operators are excluded by the contract.", + rateConnect: "Connect your wallet to rate", + rateSwitch: "Switch to Base", }, walletoptions: { connect: "Connect", diff --git a/website/test/AssistantChat.test.tsx b/website/test/AssistantChat.test.tsx index 86d983fe7..b4d4e4f42 100644 --- a/website/test/AssistantChat.test.tsx +++ b/website/test/AssistantChat.test.tsx @@ -125,6 +125,23 @@ vi.mock("../components/AgentInfoPanel", () => ({ AgentInfoPanel: () => null, })); +// The widget's own behavior (per-target transaction shape, sequential send, partial failure) +// is FeedbackWidget.test.tsx. Here it is a stub that exposes the targets it was pointed at; +// the real FEEDBACK_TARGETS stay, so the assertions run against the actual agentIds and +// endpoints. +vi.mock("../components/FeedbackWidget", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + FeedbackWidget: (props: { targets: { agentId: number; endpoint: string }[] }) => ( +
`${t.agentId}:${t.endpoint}`).join(" ")} + /> + ), + }; +}); + // Only the two fetchers are stubbed; the real selectors run against the Step 1 fixtures, so // these tests cover the wiring *and* the actual projection rather than a hand-written stand-in. vi.mock("../tools/bundestakt", async (importOriginal) => { @@ -1468,6 +1485,74 @@ describe("AssistantChat", () => { }); }); + /** + * The in-stream rating row: where it appears and which ERC-8004 agents one star pick is + * sent to. The assistent is always rated; the search agent is bundled into the same pick — + * only once a web tool actually served this chat, never as a separate choice. + */ + describe("on-chain rating", () => { + const widget = () => screen.getByTestId("feedback-widget"); + const targetsOf = () => widget().getAttribute("data-targets"); + + it("rates the assistant alone under the last answer after a plain chat", async () => { + mockSendMessage.mockResolvedValueOnce(textResponse("Hello there.")); + + renderWithQuery(); + expect(screen.queryByTestId("feedback-widget")).not.toBeInTheDocument(); + + sendUserMessage("Hi"); + await waitFor(() => expect(screen.getByText("Hello there.")).toBeInTheDocument()); + + expect(targetsOf()).toBe("97599:https://llm-agent.fretchen.eu"); + }); + + it("hides the rating row while the next turn is in flight", async () => { + let releaseSecondTurn: (value: unknown) => void = () => {}; + mockSendMessage + .mockResolvedValueOnce(textResponse("First answer.")) + .mockImplementationOnce(() => new Promise((resolve) => (releaseSecondTurn = resolve))); + + renderWithQuery(); + sendUserMessage("One"); + await waitFor(() => expect(screen.getByText("First answer.")).toBeInTheDocument()); + expect(widget()).toBeInTheDocument(); + + sendUserMessage("Two"); + await waitFor(() => expect(screen.queryByTestId("feedback-widget")).not.toBeInTheDocument()); + + releaseSecondTurn(textResponse("Second answer.")); + await waitFor(() => expect(screen.getByTestId("feedback-widget")).toBeInTheDocument()); + }); + + it("bundles the search agent into the same star pick once a web tool ran", async () => { + mockPaidFetch.mockImplementation( + async () => new Response(JSON.stringify({ results: [{ url: "https://a.example", title: "A", text: "a" }] })), + ); + mockSendMessage + .mockResolvedValueOnce(toolCallResponse("search_web", { query: "cod stocks" })) + .mockResolvedValueOnce(textResponse("Answered from the web.")); + + renderWithQuery(); + sendUserMessage("Research cod stocks"); + + await waitFor(() => expect(screen.getByText("Answered from the web.")).toBeInTheDocument()); + expect(targetsOf()).toBe("97599:https://llm-agent.fretchen.eu 97600:https://web-agent.fretchen.eu"); + }); + + it("rates only the assistant when only non-web tools ran", async () => { + mockFetchSitzungen.mockResolvedValue(sitzungenFixture); + mockSendMessage + .mockResolvedValueOnce(toolCallResponse("get_sitzungen", {})) + .mockResolvedValueOnce(textResponse("Sessions listed.")); + + renderWithQuery(); + sendUserMessage("List sessions"); + + await waitFor(() => expect(screen.getByText("Sessions listed.")).toBeInTheDocument()); + expect(targetsOf()).toBe("97599:https://llm-agent.fretchen.eu"); + }); + }); + /** * The network and currency pickers. A channel is per (network, token, receiver) and each one * escrows 0.50, so "the choice sticks" is the assertion that actually protects the user's diff --git a/website/test/FeedbackWidget.test.tsx b/website/test/FeedbackWidget.test.tsx new file mode 100644 index 000000000..310f201a9 --- /dev/null +++ b/website/test/FeedbackWidget.test.tsx @@ -0,0 +1,250 @@ +/** + * FeedbackWidget tests. The wagmi hooks are the global setup.ts mocks, overridden per test; + * useLocale is the global key-passthrough mock, so locale strings are asserted by their keys + * (the AssistantChat tests document this habit). The interaction is quiet-trigger → explain at + * the moment of commitment → confirm: at rest the widget is five glyphs and nothing else, and + * the disclosure, details, Send and Cancel appear together once a star is picked. One star + * pick is sent as one giveFeedback transaction per target, in order — what matters here is + * that argument shape per agent, the sequential loop, and the honest partial-failure state. + */ + +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { screen, fireEvent, waitFor } from "@testing-library/react"; +import { useAccount, useWriteContract } from "wagmi"; +import { useLocale } from "../hooks/useLocale"; +import { useWalletConnection } from "../hooks/useWalletConnection"; +import { buildAccountData, mockSwitchChainAsync } from "./setup"; +import { renderWithQuery } from "./testUtils"; +import { + FeedbackWidget, + FEEDBACK, + FEEDBACK_TARGETS, + REPUTATION_REGISTRY_ABI, + type FeedbackTarget, +} from "../components/FeedbackWidget"; + +const mockConnectWallet = vi.fn(); +const mockWriteContractAsync = vi.fn(); + +vi.mock("../hooks/useWalletConnection", () => ({ + useWalletConnection: vi.fn(() => ({ + address: "0x1234567890123456789012345678901234567890" as const, + hasMounted: true, + isConnected: true, + connectWallet: mockConnectWallet, + })), +})); + +/** Builder pattern per setup.ts (`buildAccountData`): the literal is loose, the builder owns the + * cast to the wagmi hook's return type. */ +function writeContractData() { + return { + writeContract: vi.fn(), + writeContractAsync: mockWriteContractAsync, + isPending: false, + error: null, + data: undefined, + } as unknown as ReturnType; +} + +/** The connected, on-Base default every test starts from, with a wallet that confirms every + * transaction and returns a distinct hash per submission. */ +function connectOnBase() { + vi.mocked(useAccount).mockReturnValue(buildAccountData({ isConnected: true, chainId: 8453 })); + vi.mocked(useWriteContract).mockReturnValue(writeContractData()); + let n = 0; + mockWriteContractAsync.mockImplementation(async () => `0xhash${n++}` as `0x${string}`); +} + +const ZERO_HASH = `0x${"0".repeat(64)}`; + +const SINGLE: readonly FeedbackTarget[] = [FEEDBACK_TARGETS.assistent]; +const BUNDLED: readonly FeedbackTarget[] = [FEEDBACK_TARGETS.assistent, FEEDBACK_TARGETS.search]; + +function renderWidget(targets: readonly FeedbackTarget[] = SINGLE) { + return renderWithQuery(); +} + +const stars = () => screen.getAllByRole("button", { name: "assistent.rateStars" }); + +describe("FeedbackWidget", () => { + beforeEach(() => { + connectOnBase(); + }); + + it("shows nothing but five stars at rest — no disclosure, no buttons", () => { + renderWidget(); + + // All five star buttons share the mocked label key; the row is asserted by count, not by + // distinct names. + expect(stars()).toHaveLength(5); + expect(screen.queryByText("assistent.rateDisclosure")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "assistent.rateSend" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "assistent.cancel" })).not.toBeInTheDocument(); + expect(screen.queryByText("assistent.rateDetailsSummary")).not.toBeInTheDocument(); + }); + + it("reveals the disclosure, details question, Send and Cancel the moment a star is picked", () => { + renderWidget(); + + fireEvent.click(stars()[3]); + + expect(screen.getByText("assistent.rateDisclosure")).toBeInTheDocument(); + expect(screen.getByText("assistent.rateDetailsSummary")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "assistent.rateSend" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "assistent.cancel" })).toBeInTheDocument(); + + // The details stay one level deeper until the question is clicked. + fireEvent.click(screen.getByText("assistent.rateDetailsSummary")); + expect(screen.getByText("assistent.rateDetailsBody")).toBeInTheDocument(); + }); + + it("says the rating covers the web search too when a web tool served the chat", () => { + renderWidget(BUNDLED); + + fireEvent.click(stars()[3]); + + expect(screen.queryByText("assistent.rateDisclosure")).not.toBeInTheDocument(); + expect(screen.getByText("assistent.rateDisclosureBundled")).toBeInTheDocument(); + }); + + it("sends the exact giveFeedback transaction for a 4-star single-target rating", async () => { + renderWidget(SINGLE); + + fireEvent.click(stars()[3]); + fireEvent.click(screen.getByRole("button", { name: "assistent.rateSend" })); + + await waitFor(() => expect(screen.getByText("assistent.rateDone")).toBeInTheDocument()); + expect(mockWriteContractAsync).toHaveBeenCalledTimes(1); + expect(mockWriteContractAsync).toHaveBeenCalledWith({ + address: FEEDBACK.registry, + abi: REPUTATION_REGISTRY_ABI, + functionName: "giveFeedback", + chainId: FEEDBACK.chainId, + args: [97599n, 4n, 0, "", "", "https://llm-agent.fretchen.eu", "", ZERO_HASH], + }); + }); + + it("sends one transaction per bundled target, in order, with a link per submission", async () => { + renderWidget(BUNDLED); + + fireEvent.click(stars()[5 - 1]); + fireEvent.click(screen.getByRole("button", { name: "assistent.rateSend" })); + + await waitFor(() => expect(screen.getByText("assistent.rateDone")).toBeInTheDocument()); + expect(mockWriteContractAsync).toHaveBeenCalledTimes(2); + expect(mockWriteContractAsync).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + args: [97599n, 5n, 0, "", "", "https://llm-agent.fretchen.eu", "", ZERO_HASH], + }), + ); + expect(mockWriteContractAsync).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + args: [97600n, 5n, 0, "", "", "https://web-agent.fretchen.eu", "", ZERO_HASH], + }), + ); + + // One explorer link per submitted transaction, in order. Submission, not inclusion — + // the links are the status source (see the widget's send comment). + const links = screen.getAllByRole("link"); + expect(links).toHaveLength(2); + expect(links[0]).toHaveAttribute("href", "https://basescan.org/tx/0xhash0"); + expect(links[1]).toHaveAttribute("href", "https://basescan.org/tx/0xhash1"); + // The stars stay, filled to the picked value, and can no longer be changed. + expect(stars()[4].textContent).toBe("★"); + expect(stars()[0]).toBeDisabled(); + }); + + it("keeps what was submitted and says what failed when the second prompt is rejected", async () => { + mockWriteContractAsync + .mockResolvedValueOnce("0xhash0") + .mockRejectedValueOnce(new Error("User rejected the request")); + + renderWidget(BUNDLED); + fireEvent.click(stars()[3]); + fireEvent.click(screen.getByRole("button", { name: "assistent.rateSend" })); + + await waitFor(() => expect(screen.getByText(/assistent\.ratePartialFailed/)).toBeInTheDocument()); + // The assistent's transaction was submitted and is linked; the failed one is not retried. + expect(screen.getAllByRole("link")).toHaveLength(1); + expect(screen.getAllByRole("link")[0]).toHaveAttribute("href", "https://basescan.org/tx/0xhash0"); + expect(screen.queryByRole("button", { name: "assistent.rateSend" })).not.toBeInTheDocument(); + }); + + it("maps the self-feedback revert to its own sentence and offers no links", async () => { + mockWriteContractAsync.mockRejectedValueOnce(new Error("execution reverted: Self-feedback not allowed")); + + renderWidget(SINGLE); + fireEvent.click(stars()[3]); + fireEvent.click(screen.getByRole("button", { name: "assistent.rateSend" })); + + await waitFor(() => expect(screen.getByText("assistent.rateSelfBlocked")).toBeInTheDocument()); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + }); + + it("truncates any other error to one line", async () => { + // The passthrough mock returns the key, which contains no {error} placeholder — so this one + // test overrides it with the real English shape to exercise the replace-and-truncate path. + vi.mocked(useLocale).mockImplementation(({ label }) => + label === "assistent.rateFailed" ? "Rating failed: {error}" : (label as string), + ); + mockWriteContractAsync.mockRejectedValueOnce(new Error("e".repeat(300))); + + renderWidget(SINGLE); + fireEvent.click(stars()[3]); + fireEvent.click(screen.getByRole("button", { name: "assistent.rateSend" })); + + // 200-char cap: the 300-char message arrives as 199 chars plus the ellipsis. + await waitFor(() => expect(screen.getByText(/^Rating failed: e{199}…$/)).toBeInTheDocument()); + }); + + it("Cancel clears the confirm area and empties the stars again", () => { + renderWidget(); + + fireEvent.click(stars()[2]); + expect(screen.getByText("assistent.rateDisclosure")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "assistent.cancel" })); + + expect(screen.queryByText("assistent.rateDisclosure")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "assistent.rateSend" })).not.toBeInTheDocument(); + // The third star is empty again — the glyph is back to ☆. + expect(stars()[2].textContent).toBe("☆"); + }); + + it("offers a switch to Base once a star is picked on another chain", () => { + vi.mocked(useAccount).mockReturnValue(buildAccountData({ isConnected: true, chainId: 10 })); + renderWidget(); + + // Nothing asks for a switch at rest — the affordance is a reaction to intent. + expect(screen.queryByRole("button", { name: "assistent.rateSwitch" })).not.toBeInTheDocument(); + + fireEvent.click(stars()[4]); + fireEvent.click(screen.getByRole("button", { name: "assistent.rateSwitch" })); + + expect(mockSwitchChainAsync).toHaveBeenCalledWith({ chainId: 8453 }); + expect(mockWriteContractAsync).not.toHaveBeenCalled(); + }); + + it("asks to connect once a star is picked without a wallet", () => { + vi.mocked(useWalletConnection).mockReturnValue({ + address: undefined, + hasMounted: true, + isConnected: false, + connectWallet: mockConnectWallet, + }); + renderWidget(); + + // Stars are visible at rest even without a wallet — the prompt is a reaction to intent. + expect(stars()).toHaveLength(5); + expect(screen.queryByRole("button", { name: "assistent.rateConnect" })).not.toBeInTheDocument(); + + fireEvent.click(stars()[4]); + fireEvent.click(screen.getByRole("button", { name: "assistent.rateConnect" })); + + expect(mockConnectWallet).toHaveBeenCalledWith("feedback"); + }); +});