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 (
+
+ );
+}
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");
+ });
+});