diff --git a/.github/THREAT_MODEL.md b/.github/THREAT_MODEL.md index 68698f1ba..6fb497fad 100644 --- a/.github/THREAT_MODEL.md +++ b/.github/THREAT_MODEL.md @@ -17,25 +17,25 @@ 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 the Edge Script twin — can read the script's plain env values, overwrite secrets, replace code, delete scripts. Broader than the per-script deploy key below | -| Edge Script deploy key (`BUNNYJS_DEPLOY_KEY`) | GitHub repo secret in a public repo (Actions; `BUNNYJS_SCRIPT_ID` names the target) | Publishes arbitrary code to the twin — code execution in a sandbox whose env holds a second copy of the seller secret set | -| Bunny Edge Script env secrets (script 97472) | bunny.net script configuration | Second copy of: S3 credential pair (production channel store), receiver-authorizer private key, Brave API key. The two copies must be rotated together with `scw_js`'s | +| 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 | Notably absent: user wallet keys (never touch the server), user PII (not collected). @@ -53,8 +53,8 @@ If a component is fully compromised, what else falls with it: | **Claim path** (the cron's per-token sweep + the seller's per-token fee allowance) | Claims for one token stall — a token missing from the sweep's enumeration leaves that token's escrow unclaimed indefinitely, and an exhausted fee allowance stalls claims with `insufficient_fee_allowance`. The sweep's refund half also garbage-collects fully-claimed records: a wrongly-guarded delete would destroy the stored voucher of a payment that raced the sweep, making that charge permanently unclaimable | Buyer impact is delay, not loss — the `withdraw` path after the lock is the buyer's own. Revenue delayed; no fund redirection. The GC delete is CAS-guarded and only commits when the record still shows `balance <= totalClaimed` and still idle, so a raced payment's voucher survives; a blocked delete self-heals next run | | **Receiver-authorizer key** (scw_js) | Refund authorizations signable: buyer escrow returned early, claims stalled or unwound — revenue denial and channel DoS | No fund redirection (claims pay the channel's fixed receiver); no access to other contracts or upgrade paths | | **SCW_SECRET_KEY** | S3 bucket writable; email notifications spoofable | Generated images replaceable; no on-chain impact | -| **bunny.net account** | Twin's code replaced and env secrets read/overwritten; scripts deletable | Channel-store records (production payment records) writable; revenue denial via the authorizer key. No on-chain fund access — the same shape as the SCW_SECRET_KEY row, one platform over | -| **Edge Script deploy key** | Arbitrary code published to the twin | Runs with the twin's env — the bunny-account blast radius for that one script. Bounded today by the workflow's push-to-main-only trigger (no `pull_request`, no `workflow_dispatch`); any future trigger widening is a security review, not a CI tweak | +| **bunny.net account** | All three twins' code replaced and env secrets read/overwritten; scripts deletable | Channel-store records (production payment records) writable; revenue denial via the authorizer key; metered Brave/Mistral spend via the API keys. No on-chain fund access — the same shape as the SCW_SECRET_KEY row, one platform over | +| **Edge Script deploy key** (one per twin) | Arbitrary code published to that one script | Runs with that twin's env — the bunny-account blast radius for that one script. Bounded today by the workflow's push-to-main-only trigger (no `pull_request`, no `workflow_dispatch`); any future trigger widening is a security review, not a CI tweak. Three keys widen the count, not the per-key shape | | **BFL / IONOS key** | Image generation quota consumed | No on-chain or financial impact to users | | **x402_facilitator service** | Payment settlements halt | Image generation feature unavailable; already-signed USDC authorizations expire unused | | **LLMv1 authorized provider** (deprecated path — see §1) | Can submit fraudulent Merkle batches; drains user balances up to available ETH | Only affects LLMv1 user balances; no access to other contracts | @@ -121,7 +121,7 @@ The `mcp` function (`mcp_server.ts`, served at `mcp-agent.fretchen.eu/mcp`) is a ★ The x402 facilitator is an intentionally open public service (any merchant on Base or Optimism). `Access-Control-Allow-Origin: *` is required by the open-protocol design. The blast radius of a cross-origin attack is bounded by EIP-712/EIP-3009 cryptography: a malicious page can trigger settlement requests but cannot redirect funds (the signature binds recipient, amount, and nonce) or forge signatures. The residual risk (gas drain via `/settle` spam) is accepted: garbage requests fail before any on-chain transaction is attempted, L2 gas per settlement is negligible (~$0.001), and no realistic threat actor has financial incentive to drain a facilitator wallet whose ETH goes to miners, not the attacker. -The bunny.net Edge Script twin (`bunny_js/`, script 97472 at `bunnyjs-vlyol.bunny.run`) is a fifth public inbound surface and a new deployment actor. Inbound: browser → bunny pull zone (a real CDN, which the Scaleway functions never had) → twin; the CDN cannot replay paid responses because the twin sends `Cache-Control: no-store`, the platform normalizes it to `no-cache`, and the responses carry no validators — every hit revalidates at origin (verified live 2026-10-18). Deployment: GitHub Actions on the public repo (`BUNNYJS_DEPLOY_KEY` + `BUNNYJS_SCRIPT_ID` secrets, push-to-main only) → deploy key → script env (S3 channel-store credentials, receiver-authorizer key, Brave key) → the same S3 bucket and facilitator as the Scaleway sellers — one shared channel store, one shared channel tuple, claimed by the same `llmx402cron` on Scaleway. Trial-phase posture: the Scaleway `searchapi` function stays live and authoritative at `web-agent.fretchen.eu`; the twin carries no buyer-key or owner key, and the platform's isolate has no metadata service, which weakens the classic SSRF payoff the `/fetch` defence was written against (the private-range block remains enforced regardless). Bunny.net platform compromise is out of scope on the same terms as Scaleway infrastructure compromise (§3). +The bunny.net Edge Script twins (`bunny_js/` — three scripts: searchapi 97472 at `bunnyjs-vlyol.bunny.run`, llm 97948 at `fretchen-llm-2dirg.bunny.run`, mcp 97949 at `fretchen-mcp-53eoc.bunny.run`) are fifth/sixth/seventh public inbound surfaces and a new deployment actor. Inbound: browser → bunny pull zone (a real CDN, which the Scaleway functions never had) → twin; the CDN cannot replay paid responses because every twin sends `Cache-Control: no-store`, the platform normalizes it to `no-cache`, and the responses carry no validators — every hit revalidates at origin (verified live 2026-10-18). Deployment: GitHub Actions on the public repo (per-script `*_DEPLOY_KEY` + `*_SCRIPT_ID` secret pairs — `BUNNYJS_*`, `BUNNYLLM_*`, `BUNNYMCP_*` — push-to-main only) → per-script deploy key → script env (S3 channel-store credentials and receiver-authorizer key on all three, Brave key on searchapi + mcp, Mistral key on llm + mcp) → the same S3 bucket and facilitator as the Scaleway sellers — one shared channel store, one shared channel tuple, claimed by the same `llmx402cron` on Scaleway. The mcp twin embeds both tool routers in-process (the bunny port of the scw relay design — payment, pricing, and cost policy stay in the real handlers), and its JSON-RPC wrapper reads POST bodies capped at 1 MB before parsing (the scw function leaned on the platform's request cap; the edge has none). Trial-phase posture: the Scaleway functions stay live and authoritative at their `*.fretchen.eu` origins; no twin carries a buyer key or owner key, and the platform's isolate has no metadata service, which weakens the classic SSRF payoff the `/fetch` defence was written against (the private-range block remains enforced regardless). Bunny.net platform compromise is out of scope on the same terms as Scaleway infrastructure compromise (§3). The analytics service accepts anonymous, unauthenticated `POST /hit` writes behind the same origin whitelist as `comment_service`. Note that the whitelist is defence-in-depth, **not** a write control here: CORS is browser-enforced only (any script or `curl` ignores it), and the pageview beacon is a `sendBeacon` simple request (`text/plain`), so no preflight occurs and a cross-origin write would land regardless of the header. Write abuse is bounded instead by path validation and the 200-entry `pages` cap (§5, API4). The counters themselves are written **private** (no public-read ACL) and the read path is authorized — see §6. @@ -191,11 +191,11 @@ For blockchain systems, **Integrity** dominates. Confidentiality is generally lo Detailed code-level findings live alongside the code they describe: -| Component | Findings document | Open issues | -| ---------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Smart contracts | [eth/SECURITY.md](../eth/SECURITY.md) | CollectorNFT price manipulation (medium), single-owner upgrade path (medium governance) | -| Serverless & frontend | [scw_js/SECURITY.md](../scw_js/SECURITY.md) | LLM pre-charge balance gate (medium) and genimg deliver-before-settle (medium) — see scw_js/SECURITY.md; x402_facilitator open CORS (intentional; gas-drain risk accepted — negligible on L2, no attacker incentive), comment_service sends `SCW_SECRET_KEY` as the email API `X-Auth-Token` header (medium), client-side-only image validation (low) | -| Bunny Edge Script twin | [bunny_js/SECURITY.md](../bunny_js/SECURITY.md) | Deploy-key blast radius (bounded by trigger hygiene; rotate-on-suspicion), deployed-runtime SigV4/DNS verification pending, undici-specific error matching on the edge (all fail-closed) | +| Component | Findings document | Open issues | +| ----------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Smart contracts | [eth/SECURITY.md](../eth/SECURITY.md) | CollectorNFT price manipulation (medium), single-owner upgrade path (medium governance) | +| Serverless & frontend | [scw_js/SECURITY.md](../scw_js/SECURITY.md) | LLM pre-charge balance gate (medium) and genimg deliver-before-settle (medium) — see scw_js/SECURITY.md; x402_facilitator open CORS (intentional; gas-drain risk accepted — negligible on L2, no attacker incentive), comment_service sends `SCW_SECRET_KEY` as the email API `X-Auth-Token` header (medium), client-side-only image validation (low) | +| Bunny Edge Script twins | [bunny_js/SECURITY.md](../bunny_js/SECURITY.md) | Per-script deploy-key blast radius (bounded by trigger hygiene; rotate-on-suspicion), deployed-runtime SigV4/DNS verification pending, undici-specific error matching on the edge (all fail-closed), secret-set copies now span two platforms and three scripts (rotation coupling) | --- @@ -203,12 +203,12 @@ Detailed code-level findings live alongside the code they describe: Active mitigations and where they live: -| Area | Document | What it covers | -| -------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------- | -| Contract-level findings | [eth/SECURITY.md](../eth/SECURITY.md) | Open issues, known vulnerabilities, fixed CVEs | -| Serverless findings | [scw_js/SECURITY.md](../scw_js/SECURITY.md) | Open serverless issues (rough; full detail tracked privately via GHSA) | -| Bunny Edge Script findings | [bunny_js/SECURITY.md](../bunny_js/SECURITY.md) | Twin-specific open issues (deploy-key blast radius, runtime verification, error-matching drift) | -| Dependency CVEs | [CVE_TRIAGE.md](CVE_TRIAGE.md) | Threat-model-driven triage framework for Dependabot alerts; `/cve-triage` skill operationalizes it | +| Area | Document | What it covers | +| -------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| Contract-level findings | [eth/SECURITY.md](../eth/SECURITY.md) | Open issues, known vulnerabilities, fixed CVEs | +| Serverless findings | [scw_js/SECURITY.md](../scw_js/SECURITY.md) | Open serverless issues (rough; full detail tracked privately via GHSA) | +| Bunny Edge Script findings | [bunny_js/SECURITY.md](../bunny_js/SECURITY.md) | Twin-specific open issues (deploy-key blast radius, runtime verification, error-matching drift, secret-copy rotation) | +| Dependency CVEs | [CVE_TRIAGE.md](CVE_TRIAGE.md) | Threat-model-driven triage framework for Dependabot alerts; `/cve-triage` skill operationalizes it | **Review triggers** — revisit this document when any of the following occur: diff --git a/.github/workflows/deploy-bunny-js.yml b/.github/workflows/deploy-bunny-js.yml index c4f096776..837797b87 100644 --- a/.github/workflows/deploy-bunny-js.yml +++ b/.github/workflows/deploy-bunny-js.yml @@ -1,13 +1,22 @@ -name: Deploy Bunny JS searchapi twin +name: Deploy Bunny JS twins -# Deploys the bunny Edge Script twin of the searchapi seller. Coexists with manual CLI -# deploys by design: bunny publishes whichever deployment happened last, so a manual -# `bunny scripts deploy` from a laptop stays live only until the next push to main. +# Deploys the bunny Edge Script twins of the x402 sellers: searchapi (web-agent), llm +# (chat REST), and mcp (the MCP wrapper around them — its bundle embeds both tools, so +# mcp.js serves web_search and chat_completion on its own). All three coexist with +# manual CLI deploys by design: bunny publishes whichever deployment happened last, so a +# manual `bunny scripts deploy` from a laptop stays live only until the next push to main. # -# This workflow ships CODE only. The script's environment (BRAVE_API_KEY, the receiver -# authorizer key, the S3 channel-store credentials, RPC URLs, SEARCH_SERVICE_URL) is -# configured on the script itself — bunny dashboard or `bunny scripts env set` — and is -# deliberately not part of CI: a repo leak must not be a secrets leak. +# This workflow ships CODE only. Each script's environment (BRAVE_API_KEY, +# MISTRAL_API_KEY, the receiver authorizer key, the S3 channel-store credentials, RPC +# URLs, SEARCH_SERVICE_URL) is configured on the script itself — bunny dashboard or +# `bunny scripts env set` — and is deliberately not part of CI: a repo leak must not be +# a secrets leak. +# +# Prerequisite per script (one-time, manual): the remote script must already exist — +# `bunny scripts create --type standalone --no-link` — and its id and per-script +# deploy key (dashboard: Script -> Deployments -> Settings) must be the matching repo +# secrets, or the deploy step below fails. The deploy key authorizes exactly that one +# script, not the account. permissions: contents: read @@ -58,7 +67,7 @@ jobs: working-directory: ./bunny_js run: npm run build - - name: Deploy script to Bunny Edge Scripting + - name: Deploy searchapi twin to Bunny Edge Scripting uses: BunnyWay/actions/deploy-script@main with: # BUNNYJS_SCRIPT_ID and BUNNYJS_DEPLOY_KEY are repo secrets; both come from the @@ -66,4 +75,22 @@ jobs: # exactly this one script, not the account. script_id: ${{ secrets.BUNNYJS_SCRIPT_ID }} deploy_key: ${{ secrets.BUNNYJS_DEPLOY_KEY }} - file: "bunny_js/dist/index.js" + file: "bunny_js/dist/searchapi.js" + + - name: Deploy llm twin to Bunny Edge Scripting + uses: BunnyWay/actions/deploy-script@main + 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. + script_id: ${{ secrets.BUNNYLLM_SCRIPT_ID }} + deploy_key: ${{ secrets.BUNNYLLM_DEPLOY_KEY }} + file: "bunny_js/dist/llm.js" + + - name: Deploy mcp twin to Bunny Edge Scripting + uses: BunnyWay/actions/deploy-script@main + 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. + script_id: ${{ secrets.BUNNYMCP_SCRIPT_ID }} + deploy_key: ${{ secrets.BUNNYMCP_DEPLOY_KEY }} + file: "bunny_js/dist/mcp.js" diff --git a/bunny_js/README.md b/bunny_js/README.md index eaff313ba..3c964e2bf 100644 --- a/bunny_js/README.md +++ b/bunny_js/README.md @@ -23,13 +23,16 @@ the bunny-provided URL until a formal switch. ## Commands -- `npm run build` — tsdown bundle to a **single** `dist/index.js` (bunny deploys one file). -- `npm run dev` — local serve via Deno on 127.0.0.1:8080. Pass real env with - `--env-file=.env` for the paid-path machinery (the unpaid 402 path needs - `NFT_WALLET_PUBLIC_KEY` + `RECEIVER_AUTHORIZER_PRIVATE_KEY`). +- `npm run build` — tsdown bundle to a **single** `dist/searchapi.js` (bunny deploys one file). +- `npm run dev:searchapi` / `dev:llm` / `dev:mcp` — local serve of one twin via Deno on + 127.0.0.1 (from source, no build needed; `--sloppy-imports` covers the shared packages' + extensionless tsc output, and `--env-file=.env` is baked in — a missing `.env` only warns). + The paid path needs `NFT_WALLET_PUBLIC_KEY` + `RECEIVER_AUTHORIZER_PRIVATE_KEY` in `.env`; + extra args go after `--` (`npm run dev:mcp -- --port 8081`-style), because npm eats + unknown flags like `--env-file` itself. - `npm test` — vitest (165 tests: 108 ported + the translated routes suite (incl. cache-control)). - `npm run check` — typecheck + lint + format + tests. -- `npm run deploy` — build, then `bunny scripts deploy dist/index.js` (needs the bunny CLI, +- `npm run deploy` — build, then `bunny scripts deploy dist/searchapi.js` (needs the bunny CLI, installed and logged in). Ordering (repo rule): `npm run build` in `shared/chain-utils` and `shared/s3-utils` first — @@ -61,7 +64,7 @@ it is inert without another credential, plain — and plain stays inspectable wh ## Known limits / decisions -- **Single-file bundle, 1 MB limit.** tsdown emits one `dist/index.js` with `codeSplitting: +- **Single-file bundle, 1 MB limit.** tsdown emits one `dist/searchapi.js` with `codeSplitting: false`; Node builtins stay external and MUST be imported through the `node:` prefix (bunny's Deno resolves them; the bundler plugin rewrites bare ones). Measured: **~397 KB**. - **The platform rewrites Cache-Control: no-store to no-cache.** The twin sends `no-store` @@ -99,7 +102,7 @@ Budget: ≤ ~$0.10 of real purchases. Tasks 1-5 of the port plan are done (scaffold, bundle gate, SigV4 spike, verbatim stack + tests, native routing + tests). Task 6 (deploy + trials) is pending user-side setup: bunny CLI -install + login (or dashboard paste of `dist/index.js`), and the secrets above configured on +install + login (or dashboard paste of `dist/searchapi.js`), and the secrets above configured on the script. The formal switch (moving `web-agent.fretchen.eu` off Scaleway) is out of scope. ## Deploy paths (both can coexist) @@ -108,18 +111,24 @@ Bunny publishes whichever deployment happened **last**, so a manual CLI deploy a GitHub Action interoperate by design — deploy from the laptop for an experiment, and the next push to `main` overwrites it back to the committed state. -1. **Manual (CLI):** `npm run deploy` (= build + `bunny scripts deploy dist/index.js`), +1. **Manual (CLI):** `npm run deploy` (= build + `bunny scripts deploy dist/searchapi.js`), needs the bunny CLI — `curl -fsSL https://cli.bunny.net/install.sh | sh` (single binary, independent of the nvm-managed Node that a global npm install would bind it to) — then - `bunny login`. + `bunny login`. The llm and mcp twins deploy by naming their bundle and script id + explicitly: `bunny scripts deploy dist/llm.js ` and + `bunny scripts deploy dist/mcp.js `. 2. **GitHub Actions:** `.github/workflows/deploy-bunny-js.yml` — on push to `main` touching `bunny_js/**` or the shared libs, builds (with the repo's shared-deps-first ordering) and - publishes `dist/index.js` via the official `BunnyWay/actions/deploy-script` action. - Requires two repo secrets, both from the bunny dashboard - (**Script -> Deployments -> Settings**): - - `BUNNYJS_SCRIPT_ID` — the script's id - - `BUNNYJS_DEPLOY_KEY` — the per-script deploy key (deliberately chosen over the - account-wide API key the CLI-in-CI variant would need — narrower blast radius) + publishes all three bundles via the official `BunnyWay/actions/deploy-script` action: + `dist/searchapi.js`, `dist/llm.js`, `dist/mcp.js`. Requires two repo secrets per script, + both from the bunny dashboard (**Script -> Deployments -> Settings**): + - `BUNNYJS_SCRIPT_ID` / `BUNNYJS_DEPLOY_KEY` — the searchapi script + - `BUNNYLLM_SCRIPT_ID` / `BUNNYLLM_DEPLOY_KEY` — the llm script + - `BUNNYMCP_SCRIPT_ID` / `BUNNYMCP_DEPLOY_KEY` — the mcp script + + (Per-script deploy keys are deliberately chosen over the account-wide API key a + CLI-in-CI variant would need — narrower blast radius. A step whose secrets are missing + fails; the searchapi step runs first, so its deploy is never blocked by the others.) The workflow ships code only; the script's environment variables and secrets are configured on the script itself (dashboard or `bunny scripts env set`), so a repo-side leak cannot leak @@ -132,17 +141,28 @@ and the `.bunny/script.json` link comes out of the create step. One-time sequenc 1. `bunny scripts create --type standalone` — creates the remote script and links this directory (writes `.bunny/script.json`, gitignored — machine-local, like the docs treat it). + For the SECOND and THIRD script, add `--no-link` — the directory's link belongs to + searchapi, and `create` without it would silently repoint `.bunny/script.json`; the + other twins take their id from the create output (or `bunny scripts list`) instead. 2. Upload the env from `.env` with `bunny scripts env set`, marking the sensitive keys with - `--secret` (RECEIVER_AUTHORIZER_PRIVATE_KEY, BRAVE_API_KEY, SCW_ACCESS_KEY, SCW_SECRET_KEY, - RPC_URL_*). Skip `TEST_WALLET_PRIVATE_KEY` — buyer-side only, the server must never see it. - Verify with `bunny scripts env list`. -3. `npm run deploy` — builds, uploads, publishes; prints the live `*.bunny.run` URL. -4. `bunny scripts env set SEARCH_SERVICE_URL https://.bunny.run` and redeploy, so - the 402 advertises the trial origin instead of the parity default. + `--secret` (RECEIVER_AUTHORIZER_PRIVATE_KEY, BRAVE_API_KEY, MISTRAL_API_KEY, + SCW_ACCESS_KEY, SCW_SECRET_KEY, RPC_URL_*). Skip `TEST_WALLET_PRIVATE_KEY` — buyer-side + only, the server must never see it. Verify with `bunny scripts env list`. Per script: + - **searchapi** — the channel stack + `BRAVE_API_KEY` (web_search/fetch) + `SCW_S3_*`. + - **llm** — the channel stack + `MISTRAL_API_KEY` (chat_completion) + `SCW_S3_*`. + - **mcp** — the union: both `BRAVE_API_KEY` and `MISTRAL_API_KEY` (its bundle embeds + both tools) + `SCW_S3_*`. +3. `npm run deploy` (searchapi) / `bunny scripts deploy dist/llm.js ` / + `bunny scripts deploy dist/mcp.js ` — publishes each; the output prints the live + `*.bunny.run` URL. +4. `bunny scripts env set SEARCH_SERVICE_URL https://.bunny.run` on the searchapi + script and redeploy, so the 402 advertises the trial origin instead of the parity default. + (llm/mcp have no equivalent — their 402s quote the spec's committed server URL.) 5. Unpaid smoke: `curl -D - 'https://.bunny.run/search?q=x402'` → 402 + `Payment-Required` header. -6. For the GitHub Action: `bunny scripts show` gives the script id; the deploy key is in the - dashboard (Script -> Deployments -> Settings). Add both as the `BUNNYJS_*` repo secrets. +6. For the GitHub Action: `bunny scripts show` (or `list`) gives each script id; the deploy + keys are in the dashboard (Script -> Deployments -> Settings). Add each pair as the + `BUNNYJS_*` / `BUNNYLLM_*` / `BUNNYMCP_*` repo secrets. After the first deploy, the Action and manual `npm run deploy` are interchangeable — last deployment wins. diff --git a/bunny_js/SECURITY.md b/bunny_js/SECURITY.md index ceac42c74..32d57dab2 100644 --- a/bunny_js/SECURITY.md +++ b/bunny_js/SECURITY.md @@ -10,23 +10,41 @@ Report privately via [GitHub Security Advisories](https://github.com/fretchen/fr ## Scope -The bunny.net Edge Script twin of the `searchapi` seller: `bunny_js/` code, the deployed -script (id 97472, `bunnyjs-vlyol.bunny.run`), its script env, the GitHub Action -(`.github/workflows/deploy-bunny-js.yml`), and the bunny CLI credential on the dev machine. +The bunny.net Edge Script twins of the x402 sellers — all of `bunny_js/` code and the three +deployed scripts it builds to: + +| Twin | Script id | URL | Tools served | +| --------- | --------- | ------------------------------ | ----------------------------------- | +| searchapi | 97472 | `bunnyjs-vlyol.bunny.run` | `/search` + `/fetch` (Brave) | +| llm | 97948 | `fretchen-llm-2dirg.bunny.run` | chat completions (Mistral) | +| mcp | 97949 | `fretchen-mcp-53eoc.bunny.run` | both, over stateless JSON-RPC (MCP) | + +Plus each script's env, the GitHub Action +(`.github/workflows/deploy-bunny-js.yml` — one deploy step per script, one per-script +deploy-key secret pair), and the bunny CLI credential on the dev machine. See [../.github/THREAT_MODEL.md](../.github/THREAT_MODEL.md) for the asset inventory and blast radius — the bunny surface has its own rows there (assets, blast radius, trust -boundary, 2026-10-18 audit). +boundary, 2026-10-18 audit, three-script update 2026-10-20). --- ## Known Open Issues -| Area | Severity | Status | -| ------------------------------------------ | -------- | ---------------------------------------------------------------------------------------- | -| Deploy-key blast radius | Medium | Bounded — trigger hygiene (push-to-main only) + rotate-on-suspicion; see THREAT_MODEL §2 | -| Deployed-runtime SigV4 + DNS verification | Low | Pending — local Deno PASS; both failure branches verified fail-closed; see below | -| Undici-specific error matching on the edge | Low | Watch item — unreachable hosts may 500 instead of 400 on bunny's runtime; see below | -| `.env` copy drift vs `scw_js/.env` | Low | Accepted for the trial — the two secret sets must be rotated/copied together | +| Area | Severity | Status | +| ------------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| Deploy-key blast radius (×3, one per twin) | Medium | Bounded — trigger hygiene (push-to-main only) + rotate-on-suspicion; see THREAT_MODEL §2 | +| Secret-set copies across two platforms | Medium | Accepted for the trial — scw_js + up to three script envs carry the S3 pair and authorizer key; every copy must rotate together (see below) | +| Deployed-runtime SigV4 + DNS verification | Low | Pending — local Deno PASS; both failure branches verified fail-closed; see below | +| Undici-specific error matching on the edge | Low | Watch item — unreachable hosts may 500 instead of 400 on bunny's runtime; see below | +| `.env` copy drift vs `scw_js/.env` | Low | Accepted for the trial — the two secret sets must be rotated/copied together | + +**Secret-copy rotation coupling (2026-10-20, three-script update).** The channel-store S3 +credential pair and the receiver-authorizer private key exist in `scw_js`'s Scaleway secrets +and in each twin's script env — searchapi, llm, and mcp all charge the same S3 channel +store. A rotation that touches any one copy must touch all of them in the same change, or +the twins and Scaleway diverge on what "the same channel tuple" means. `BRAVE_API_KEY` +couples searchapi + mcp; `MISTRAL_API_KEY` couples llm + mcp. No buyer key or owner key +exists in any script env. **Deployed-runtime verification (2026-10-18 audit).** `node:crypto` (SigV4) and `node:dns/promises` (the `/fetch` SSRF defence) are unverified on bunny's Deno 1.x — the @@ -64,6 +82,21 @@ matcher with Deno's error shapes on first observation, not speculatively. - Repo hygiene: `.env` untracked and gitignored in both packages; tracked files contain only published test keys, public addresses, and the public ownership proof. +## Fixed / Verified (2026-10-20 review, llm + mcp twins) + +- `structuredClone` on the llm twin's `GET /openapi.json` path was replaced with a JSON + round-trip — an unverified Node-global on bunny's Deno 1.x, and lossless for a JSON + document by construction. +- The MCP wrapper's POST body is read capped at 1 MB (declared `Content-Length` checked up + front, the stream read refused mid-body past the cap) — the edge imposes no + platform-level request-size cap the way Scaleway did, so the twin encodes its own. A + streamed-oversize test pins the 413. +- Payment flows on the new twins verified against the review checklist: settle-after-success + and lock release on upstream failure hold in `llm_routes.ts` (the 40 s completion cap + releases the lock explicitly; the advertised lock TTL is scw's 120), the mcp relay + duplicates no pricing (it calls the real handlers in-process), and every response path + spreads `CORS_HEADERS` (which carries the `no-store` pin). + --- ## Mitigations in Place diff --git a/bunny_js/notebooks/llm_mcp_buyer.ipynb b/bunny_js/notebooks/llm_mcp_buyer.ipynb new file mode 100644 index 000000000..9c473df95 --- /dev/null +++ b/bunny_js/notebooks/llm_mcp_buyer.ipynb @@ -0,0 +1,894 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# `chat_completion` MCP Buyer Notebook — bunny twin — the x402-over-MCP workflow for the chat tool (Deno/TypeScript)\n", + "\n", + "Drives the **real, locally-running** mcp twin (`cd bunny_js && npm run dev:mcp`, port 8080)\n", + "as the official client stack would: the MCP SDK's `StreamableHTTPClientTransport` against our\n", + "hand-rolled stateless dispatcher, wrapped by `@x402/mcp`'s `createx402MCPClient` for payment —\n", + "no custom protocol code anywhere.\n", + "\n", + "Focused on `chat_completion`, the only tool payable on testnet: the MCP endpoint is a **pure\n", + "translation layer** over the real handlers, so it inherits their cost rules — `web_search`\n", + "is mainnet-only by design (testnet coins must never buy metered Brave queries) and is left\n", + "to a mainnet run. `chat_completion` offers testnet and the handler **mocks completions there**,\n", + "so the whole paid walk below costs no real Mistral spend — only the channel deposits.\n", + "\n", + "The spike questions this notebook answers:\n", + "\n", + "1. Does the **official MCP client** tolerate our stateless-JSON server (405 on GET/DELETE, no sessions)?\n", + "2. Is our payment-required result the **x402-over-MCP convention** — does the official wrapper\n", + " extract the demands and pay without custom code?\n", + "3. Do **batch-settlement channel deposits** work through the MCP wrapper (first paid call)?\n", + "4. Does the **EURe Permit2** path survive the MCP flow (the currency-variant cell)?\n", + "5. Does a paid `chat_completion` settle **usage-derived**, not the advertised ceiling (the chat's\n", + " native authorize-ceiling/claim-usage split, inherited via the translation layer)?\n", + "6. (Deployed run) Point the SERVER selector at the deployed twin — the payoff measurement\n", + " is the cold `initialize`: ~0.3 s on bunny vs the ~26-27 s measured on the scw function.\n", + "\n", + "**Prerequisites:** the Deno kernel; `npm run dev:mcp` running (from `bunny_js/`, with\n", + "`--env-file=.env` for the paid path); `TEST_WALLET_PRIVATE_KEY` in\n", + "`scw_js/.env` funded with Base Sepolia USDC, EURC and EURe (plus a little ETH for the channel\n", + "deposits' gas). Same wallet/channel state as the other buyer notebooks — see the channel-reuse\n", + "note below." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "668896f9", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "llm_mcp buyer notebook (bunny twin)\n", + " Buyer (payer): 0x553179556FC2A39e535D65b921e01fA995E79101\n", + " Receiver : 0xAAEBC1441323B8ad6Bdf6793A8428166b510239C\n" + ] + } + ], + "source": [ + "// Setup: imports + env\n", + "import { load } from \"https://deno.land/std@0.224.0/dotenv/mod.ts\";\n", + "import { privateKeyToAccount } from \"npm:viem@2/accounts\";\n", + "\n", + "// bunny_js's own .env (copied from scw_js's), one level up — the buyer key and receiver\n", + "// address; the twin reads the same file when run locally with --env-file.\n", + "const env = await load({ envPath: \"../.env\", examplePath: null, export: true });\n", + "\n", + "const PRIVATE_KEY = env.TEST_WALLET_PRIVATE_KEY;\n", + "if (!PRIVATE_KEY) {\n", + " throw new Error(\n", + " \"TEST_WALLET_PRIVATE_KEY missing from bunny_js/.env — add a key funded with Base Sepolia \" +\n", + " \"USDC, EURC and EURe, plus a little ETH for the channel deposits' gas.\",\n", + " );\n", + "}\n", + "const account = privateKeyToAccount(`0x${PRIVATE_KEY.replace(/^0x/, \"\")}`);\n", + "\n", + "console.log(\"llm_mcp buyer notebook (bunny twin)\");\n", + "console.log(` Buyer (payer): ${account.address}`);\n", + "console.log(` Receiver : ${env.NFT_WALLET_PUBLIC_KEY ?? \"(not set — server will 500)\"}`);" + ] + }, + { + "cell_type": "markdown", + "id": "8cbd32cf", + "metadata": {}, + "source": [ + "## Network selection\n", + "\n", + "`chat_completion` offers Base Sepolia (the chat's own testnet offer — completions mock there),\n", + "so the default testnet run below pays with worthless coins and settles mocked completions.\n", + "A mainnet run is REAL MONEY: real Mistral billing per completion." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "ddc4ab97", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "REAL MONEY on Base Mainnet\n", + " eip155:8453 • USDC USD Coin @ 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913\n", + " MCP endpoint: http://localhost:8080/mcp\n" + ] + } + ], + "source": [ + "import { base, baseSepolia, optimism } from \"npm:viem@2/chains\";\n", + "\n", + "const USE_MAINNET = true; // true = REAL MONEY: mainnet settlement + billed Mistral calls.\n", + "const USE_BASE = true; // Optimism is mainnet-only for batch-settlement, see guard below.\n", + "\n", + "if (!USE_BASE && !USE_MAINNET) {\n", + " throw new Error(\n", + " \"Optimism Sepolia is unsupported for batch-settlement (no canonical contract). \" +\n", + " \"Use Base Sepolia (USE_BASE = true) or an Optimism mainnet run (USE_MAINNET = true).\",\n", + " );\n", + "}\n", + "\n", + "const NETWORK_CONFIG = {\n", + " \"base-testnet\": {\n", + " caip2Network: \"eip155:84532\" as const, networkName: \"Base Sepolia (Testnet)\",\n", + " chain: baseSepolia, usdcName: \"USDC\",\n", + " usdcAddress: \"0x036CbD53842c5426634e7929541eC2318f3dCF7e\" as `0x${string}`,\n", + " eurcAddress: \"0x808456652fdb597867f38412077A9182bf77359F\" as `0x${string}` | undefined,\n", + " // EURe (Monerium, 18 decimals, Permit2 + EIP-2612 permit — no approve(), no ETH for approval).\n", + " eureAddress: \"0x29F37F6adCa168B79B8d9567eab9BE3fBF21db85\" as `0x${string}` | undefined,\n", + " rpcUrl: undefined as string | undefined,\n", + " explorer: \"https://sepolia.basescan.org\", faucet: \"https://faucet.circle.com/\",\n", + " },\n", + " \"base-mainnet\": {\n", + " caip2Network: \"eip155:8453\" as const, networkName: \"Base Mainnet\",\n", + " chain: base, usdcName: \"USD Coin\",\n", + " usdcAddress: \"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913\" as `0x${string}`,\n", + " eurcAddress: \"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42\" as `0x${string}` | undefined,\n", + " eureAddress: \"0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C\" as `0x${string}` | undefined,\n", + " rpcUrl: \"https://base-rpc.publicnode.com\",\n", + " explorer: \"https://basescan.org\", faucet: \"Bridge: https://bridge.base.org\",\n", + " },\n", + " \"optimism-mainnet\": {\n", + " caip2Network: \"eip155:10\" as const, networkName: \"Optimism Mainnet\",\n", + " chain: optimism, usdcName: \"USD Coin\",\n", + " usdcAddress: \"0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85\" as `0x${string}`,\n", + " // No EURC on Optimism (Circle has no deployment there).\n", + " eurcAddress: undefined as `0x${string}` | undefined,\n", + " eureAddress: undefined as `0x${string}` | undefined,\n", + " rpcUrl: \"https://optimism-rpc.publicnode.com\",\n", + " explorer: \"https://optimistic.etherscan.io\", faucet: \"Bridge: https://app.optimism.io/bridge\",\n", + " },\n", + "};\n", + "const configKey = USE_MAINNET ? \"base-mainnet\" : \"base-testnet\";\n", + "const config = NETWORK_CONFIG[configKey];\n", + "const NETWORK = config.caip2Network;\n", + "\n", + "// Local: the mcp twin (`npm run dev:mcp` from bunny_js/, src/mcp.ts on 127.0.0.1:8080).\n", + "// Deployed: the mcp function's custom domain (serverless.yml). Orthogonal to USE_MAINNET —\n", + "// the channel is on-chain state keyed by (buyer, receiver, network, asset), not by which\n", + "// server talked to it, so a channel opened here also pays the deployed endpoint, and vice\n", + "// versa (same property the search buyer notebook relies on to share the chat's channel).\n", + "// bunny-local: the twin on 127.0.0.1:8080 (mcp bundle). bunny-trial: the deployed twin.\n", + "// scw-deployed: the Scaleway mcp function — the ~26-27 s cold-start A/B.\n", + "const SERVER = \"bunny-local\" as \"bunny-local\" | \"bunny-trial\" | \"scw-deployed\";\n", + "const TRIAL_URL = \"https://CHANGE-ME.bunny.run\"; // <- set when SERVER = \"bunny-trial\"\n", + "const MCP_URL =\n", + " SERVER === \"bunny-local\" ? \"http://localhost:8080/mcp\"\n", + " : SERVER === \"bunny-trial\" ? TRIAL_URL + \"/mcp\"\n", + " : \"https://mcp-agent.fretchen.eu/mcp\";\n", + "\n", + "console.log(USE_MAINNET ? `REAL MONEY on ${config.networkName}` : `${config.networkName} (testnet)`);\n", + "console.log(` ${NETWORK} • USDC ${config.usdcName} @ ${config.usdcAddress}`);\n", + "console.log(` MCP endpoint: ${MCP_URL}`);" + ] + }, + { + "cell_type": "markdown", + "id": "6c618172", + "metadata": {}, + "source": [ + "## Channel reset — deterministic spike state\n", + "\n", + "The `x402-channel:` contexts in `localStorage` are shared by **every** buyer notebook and\n", + "survived **every** server iteration of this session (the old wrapper-based MCP server, the\n", + "aborted runs, the REST buyers). The client's channel bookkeeping can drift from the on-chain\n", + "channel: a voucher's `maxClaimableAmount` is the client's **all-time cumulative**, checked\n", + "against the channel's **on-chain balance** (`@x402/evm` batch-settlement's\n", + "`verifyVoucherLocally`) — a stale context signs a bound the chain can't cover, and every paid\n", + "call fails with `invalid_batch_settlement_evm_cumulative_exceeds_balance` while the client\n", + "*skips the deposit* (it trusts its own balance figure). The corrective flow resyncs the\n", + "cumulative on the next call, but a wrong balance figure keeps refusing — observed live in\n", + "this notebook's own first run. Clearing forces fresh channels: one clean deposit each, and\n", + "the walk verifies against clean on-chain state. Flip to `false` to deliberately test channel\n", + "reuse with prior chat/search state (the sharing itself is a feature — same prefix, same\n", + "receiver)." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "bd400582", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "cleared 0 channel context(s) — the next paid call opens fresh channels (one deposit each)\n" + ] + } + ], + "source": [ + "const RESET_CHANNELS = true; // false = keep prior channel state (reuse run)\n", + "\n", + "if (RESET_CHANNELS) {\n", + " let removed = 0;\n", + " for (let i = localStorage.length - 1; i >= 0; i--) {\n", + " const key = localStorage.key(i)!;\n", + " if (key.startsWith(\"x402-channel:\")) {\n", + " localStorage.removeItem(key);\n", + " removed++;\n", + " }\n", + " }\n", + " console.log(`cleared ${removed} channel context(s) — the next paid call opens fresh channels (one deposit each)`);\n", + "} else {\n", + " console.log(`keeping ${channelKeyCount()} channel context(s) — reuse run`);\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "f85739f2", + "metadata": {}, + "source": [ + "## Buyer setup — the channel scheme, same recipe as the REST buyers\n", + "\n", + "The translation layer relays to `sc_llm_x402.handle`, which verifies and settles through the\n", + "same batch-settlement stack as the chat — so the client side is the recipe\n", + "`sc_llm_x402_buyer.ipynb` proved: `toClientEvmSigner` (with a `readContract`-capable public\n", + "client — the corrective-402 recovery path needs it), `ClientChannelStorage` backed by\n", + "`localStorage`, and a deposit floor of $0.50 so one channel covers the session." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "49e5a604", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " Channel contexts in localStorage: 0\n" + ] + } + ], + "source": [ + "import { toClientEvmSigner } from \"npm:@x402/evm@2.28.0\";\n", + "import {\n", + " BatchSettlementEvmScheme,\n", + " type ClientChannelStorage,\n", + " type BatchSettlementClientContext,\n", + " type BatchSettlementDepositStrategyContext,\n", + "} from \"npm:@x402/evm@2.28.0/batch-settlement/client\";\n", + "import { createPublicClient, http } from \"npm:viem@2\";\n", + "\n", + "const publicClient = createPublicClient({\n", + " chain: config.chain,\n", + " transport: http(config.rpcUrl),\n", + "});\n", + "const buyerSigner = toClientEvmSigner(\n", + " { address: account.address, signTypedData: (a: unknown) => account.signTypedData(a as never) },\n", + " publicClient,\n", + ");\n", + "\n", + "class WebStorageClientChannelStorage implements ClientChannelStorage {\n", + " constructor(private backend: Storage, private prefix = \"x402-channel:\") {}\n", + " private keyFor(key: string) { return `${this.prefix}${key.toLowerCase()}`; }\n", + " async get(key: string) {\n", + " const raw = this.backend.getItem(this.keyFor(key));\n", + " return raw ? (JSON.parse(raw) as BatchSettlementClientContext) : undefined;\n", + " }\n", + " async set(key: string, context: BatchSettlementClientContext) {\n", + " this.backend.setItem(this.keyFor(key), JSON.stringify(context));\n", + " }\n", + " async delete(key: string) { this.backend.removeItem(this.keyFor(key)); }\n", + "}\n", + "\n", + "// Same prefix as the other buyer notebooks: a channel here IS the chat's channel — a browser\n", + "// that has already chatted would pay chat_completion from the same voucher state.\n", + "const buyerStorage = new WebStorageClientChannelStorage(localStorage);\n", + "\n", + "// Floor for channel deposits/top-ups, $0.50 in USDC atomic units — same value and same\n", + "// reasoning as useX402Chat.ts's MINIMUM_DEPOSIT_ATOMIC. EURe deposits are the SDK's own\n", + "// multiplier figure (its minimum exceeds the floor in EURe's 18-decimal units).\n", + "const MINIMUM_DEPOSIT_ATOMIC = 400_000n;\n", + "\n", + "function depositStrategy(context: BatchSettlementDepositStrategyContext): string {\n", + " const required = BigInt(context.minimumDepositAmount);\n", + " return (required > MINIMUM_DEPOSIT_ATOMIC ? required : MINIMUM_DEPOSIT_ATOMIC).toString();\n", + "}\n", + "\n", + "const buyerScheme = new BatchSettlementEvmScheme(buyerSigner, { storage: buyerStorage, depositStrategy });\n", + "\n", + "// How many channel contexts exist right now — the channel-reuse assertions below count these.\n", + "function channelKeyCount(): number {\n", + " let n = 0;\n", + " for (let i = 0; i < localStorage.length; i++) {\n", + " if (localStorage.key(i)!.startsWith(\"x402-channel:\")) n++;\n", + " }\n", + " return n;\n", + "}\n", + "const channelsBefore = channelKeyCount();\n", + "console.log(` Channel contexts in localStorage: ${channelsBefore}`);" + ] + }, + { + "cell_type": "markdown", + "id": "a3177122", + "metadata": {}, + "source": [ + "## The x402 MCP client — two instances, two purposes\n", + "\n", + "Both are the official `createx402MCPClient` — **no custom client code anywhere**:\n", + "\n", + "- **observer** — `autoPayment: false`: an unpaid `tools/call` must throw the client's\n", + " payment error (`code: 402`, demands on `err.paymentRequired`) — the convention check;\n", + "- **payer** — `autoPayment: true` with an `onPaymentRequested` logger: the wrapper must\n", + " extract the demands, open/deposit the channel, pay in `_meta`, retry, and attach the\n", + " settlement receipt (spike questions #2 and #3).\n", + "\n", + "EURC and EURe are not in the SDK's default spend-control registry (USDC is), so both clients\n", + "allow-list them. The selector is pinned to USDC with the REAL signature\n", + "`(x402Version, filteredReqs)` — the x402 guide's one-arg example is out of date for\n", + "@x402/core 2.28.0." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "417b43b9", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "initialize handshake passed (stateless JSON accepted)\n", + "tools/list: [\"chat_completion\",\"web_search\"]\n", + "ok: server advertises chat_completion\n", + "ok: server advertises web_search too (not driven here)\n" + ] + } + ], + "source": [ + "import { StreamableHTTPClientTransport } from \"npm:@modelcontextprotocol/sdk@1.12.1/client/streamableHttp.js\";\n", + "import { createx402MCPClient } from \"npm:@x402/mcp@2.28.0\";\n", + "\n", + "function assert(cond: unknown, msg: string): asserts cond {\n", + " if (!cond) throw new Error(`FAILED: ${msg}`);\n", + " console.log(`ok: ${msg}`);\n", + "}\n", + "\n", + "const allowedAssets = [\n", + " ...(config.eurcAddress ? [{ network: NETWORK, asset: config.eurcAddress }] : []),\n", + " ...(config.eureAddress ? [{ network: NETWORK, asset: config.eureAddress }] : []),\n", + "];\n", + "\n", + "const logDemand = async (ctx: { toolName: string; paymentRequired: { accepts: unknown[] } }) => {\n", + " console.log(` payment demanded for ${ctx.toolName} (${ctx.paymentRequired.accepts.length} accepts):`);\n", + " for (const a of ctx.paymentRequired.accepts as Array<{ network: string; asset: string; amount: string; payTo: string }>) {\n", + " console.log(` ${a.network} ${a.asset} amount=${a.amount} payTo=${a.payTo}`);\n", + " }\n", + " return true;\n", + "};\n", + "\n", + "// Real selector signature (@x402/core 2.28.0): (x402Version, filteredReqs) — the reqs are\n", + "// ALREADY filtered to payable accepts; the return is used verbatim (no SDK fallback), so a\n", + "// miss must not return undefined: warn and fall back to the first payable accept.\n", + "function pinToAsset(asset: `0x${string}` | undefined) {\n", + " return (_version: number, reqs: Array<{ asset?: string }>) => {\n", + " const pinned = reqs.find((r) => (r.asset ?? \"\").toLowerCase() === (asset ?? \"\").toLowerCase());\n", + " if (!pinned) {\n", + " console.warn(` pinned asset not payable; falling back to ${reqs[0]?.asset}`);\n", + " return reqs[0];\n", + " }\n", + " return pinned;\n", + " };\n", + "}\n", + "\n", + "// connect() = initialize handshake. Spike question #1 lives here: our server is stateless\n", + "// JSON (405 on GET/DELETE); if the official transport chokes on that, it fails right here.\n", + "const observer = createx402MCPClient({\n", + " name: \"llm-mcp-buyer-notebook\", version: \"1.0.0\",\n", + " schemes: [{ network: NETWORK, client: buyerScheme }],\n", + " spendControls: { allowedAssets },\n", + " autoPayment: false,\n", + "});\n", + "await observer.connect(new StreamableHTTPClientTransport(new URL(MCP_URL)));\n", + "console.log(\"initialize handshake passed (stateless JSON accepted)\");\n", + "\n", + "const tools = await observer.listTools();\n", + "const toolNames = tools.tools.map((t: { name: string }) => t.name).sort();\n", + "console.log(`tools/list: ${JSON.stringify(toolNames)}`);\n", + "assert(toolNames.includes(\"chat_completion\"), \"server advertises chat_completion\");\n", + "// The endpoint also serves web_search — mainnet-only by the handler's own rule; this\n", + "// notebook drives chat_completion only (the payable testnet walk).\n", + "assert(toolNames.includes(\"web_search\"), \"server advertises web_search too (not driven here)\");" + ] + }, + { + "cell_type": "markdown", + "id": "32c406c8", + "metadata": {}, + "source": [ + "## Unpaid call — the convention check (spike question #2)\n", + "\n", + "`autoPayment: false`, so nothing pays. The client's contract (verified from source): an\n", + "unpaid demand never comes back as a plain `callTool` return value — the wrapper extracts the\n", + "challenge from our payment-required result and **throws** (`code: 402`, demands on\n", + "`err.paymentRequired`). Reading the demands the clean way is the client's own probe,\n", + "`getToolPaymentRequirements`. NOTE: the package's `isPaymentRequiredError()` guard is NOT\n", + "applicable to the thrown error — it checks `error.data` (a JSON-RPC error shape), which this\n", + "client error does not carry." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "c33b37d4", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "ok: unpaid demand throws with code 402 (the client-side form of the payment convention)\n", + "ok: payment demands ride on err.paymentRequired through the official client\n", + "ok: getToolPaymentRequirements returns the server's demands\n", + " offered networks: [\"eip155:10\",\"eip155:8453\",\"eip155:84532\"]\n", + "ok: the llm challenge offers this network (eip155:8453)\n" + ] + } + ], + "source": [ + "let thrown: unknown;\n", + "try {\n", + " await observer.callTool(\"chat_completion\", {\n", + " model: \"mistral-large-latest\",\n", + " messages: [{ role: \"user\", content: \"hi\" }],\n", + " });\n", + " throw new Error(\"expected the unpaid call to throw a payment-required error\");\n", + "} catch (e) {\n", + " thrown = e;\n", + "}\n", + "\n", + "assert((thrown as { code?: unknown }).code === 402,\n", + " \"unpaid demand throws with code 402 (the client-side form of the payment convention)\");\n", + "const thrownDemand = (thrown as { paymentRequired?: { accepts: Array<{ network: string }> } }).paymentRequired;\n", + "assert(Array.isArray(thrownDemand?.accepts) && thrownDemand.accepts.length > 0,\n", + " \"payment demands ride on err.paymentRequired through the official client\");\n", + "\n", + "const demand = await observer.getToolPaymentRequirements(\"chat_completion\", {\n", + " model: \"mistral-large-latest\",\n", + " messages: [{ role: \"user\", content: \"hi\" }],\n", + "});\n", + "assert(demand !== null, \"getToolPaymentRequirements returns the server's demands\");\n", + "const networks = demand!.accepts.map((a: { network: string }) => a.network);\n", + "console.log(` offered networks: ${JSON.stringify([...new Set(networks)])}`);\n", + "// The intentional asymmetry, served by the REAL chat handler: llm offers testnet\n", + "// (completions mock there), unlike search (mainnet-only).\n", + "assert(networks.includes(NETWORK), `the llm challenge offers this network (${NETWORK})`);" + ] + }, + { + "cell_type": "markdown", + "id": "f9a4f240", + "metadata": {}, + "source": [ + "## Paid walk — first call opens the channel (spike questions #2 and #3)\n", + "\n", + "The payer's first `chat_completion` must: read the demand, **deposit a real on-chain channel on\n", + "Base Sepolia** (a real transaction — the big unknown: do channel deposits work through the\n", + "MCP wrapper?), pay, retry with the payment in `params._meta[\"x402/payment\"]`, and get the\n", + "settlement receipt back. On testnet the completion is the handler's own mock — no Mistral\n", + "spend. The second call must reuse the same channel — no new deposit." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "350f6cb0", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " payment demanded for chat_completion (7 accepts):\n", + " eip155:10 0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85 amount=30000 payTo=0xAAEBC1441323B8ad6Bdf6793A8428166b510239C\n", + " eip155:8453 0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C amount=30000000000000000 payTo=0xAAEBC1441323B8ad6Bdf6793A8428166b510239C\n", + " eip155:8453 0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42 amount=30000 payTo=0xAAEBC1441323B8ad6Bdf6793A8428166b510239C\n", + " eip155:8453 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 amount=30000 payTo=0xAAEBC1441323B8ad6Bdf6793A8428166b510239C\n", + " eip155:84532 0x29F37F6adCa168B79B8d9567eab9BE3fBF21db85 amount=30000000000000000 payTo=0xAAEBC1441323B8ad6Bdf6793A8428166b510239C\n", + " eip155:84532 0x808456652fdb597867f38412077A9182bf77359F amount=30000 payTo=0xAAEBC1441323B8ad6Bdf6793A8428166b510239C\n", + " eip155:84532 0x036CbD53842c5426634e7929541eC2318f3dCF7e amount=30000 payTo=0xAAEBC1441323B8ad6Bdf6793A8428166b510239C\n", + "chat_completion paid in 11617ms (first call: deposit + verify + settle)\n", + "first call — isError: undefined | paymentMade: true\n", + " raw content[0]: {\"id\":\"526c619f698d457985a05fa244901db8\",\"object\":\"chat.completion\",\"created\":1792484143,\"model\":\"mistral-large-latest\",\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"An **x402 payment channel** is a proposed HTTP status code (or protocol extension) designed to enable **asynchronous,\n", + " paymentResponse: {\"success\":true,\"payer\":\"0x553179556FC2A39e535D65b921e01fA995E79101\",\"transaction\":\"0x5e133dee2ffb41e599325e651729725173f78714afa653c8364b2738c3f999b3\",\"network\":\"eip155:8453\",\"extra\":{\"channelState\":{\"channelId\":\"0xbd47\n", + "ok: paid chat_completion returns a completion\n", + " channel contexts: 0 -> 1\n", + " payment demanded for chat_completion (7 accepts):\n", + " eip155:10 0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85 amount=30000 payTo=0xAAEBC1441323B8ad6Bdf6793A8428166b510239C\n", + " eip155:8453 0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C amount=30000000000000000 payTo=0xAAEBC1441323B8ad6Bdf6793A8428166b510239C\n", + " eip155:8453 0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42 amount=30000 payTo=0xAAEBC1441323B8ad6Bdf6793A8428166b510239C\n", + " eip155:8453 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 amount=30000 payTo=0xAAEBC1441323B8ad6Bdf6793A8428166b510239C\n", + " eip155:84532 0x29F37F6adCa168B79B8d9567eab9BE3fBF21db85 amount=30000000000000000 payTo=0xAAEBC1441323B8ad6Bdf6793A8428166b510239C\n", + " eip155:84532 0x808456652fdb597867f38412077A9182bf77359F amount=30000 payTo=0xAAEBC1441323B8ad6Bdf6793A8428166b510239C\n", + " eip155:84532 0x036CbD53842c5426634e7929541eC2318f3dCF7e amount=30000 payTo=0xAAEBC1441323B8ad6Bdf6793A8428166b510239C\n", + "second call — isError: undefined | paymentMade: true\n", + " raw content[0]: {\"id\":\"671b088d69b94833b21910812fe2577b\",\"object\":\"chat.completion\",\"created\":1792484152,\"model\":\"mistral-large-latest\",\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"In one word: **\\\"Succinctly.\\\"** 😊\\n\\n(Or, if you meant a single-word answer to something else, let me know—I’m happ\n", + " paymentResponse: {\"success\":true,\"payer\":\"0x553179556fc2a39e535d65b921e01fa995e79101\",\"transaction\":\"\",\"network\":\"eip155:8453\",\"amount\":\"\",\"extra\":{\"channelState\":{\"channelId\":\"0xbd47699c24d5fbd16eb316de548ab39e36931a953ee33aa30dc9b28ff5\n", + "ok: second call opened no new channel (1 -> 1)\n", + "ok: second paid chat_completion returns a completion\n" + ] + } + ], + "source": [ + "const payer = createx402MCPClient({\n", + " name: \"llm-mcp-buyer-notebook\", version: \"1.0.0\",\n", + " schemes: [{ network: NETWORK, client: buyerScheme }],\n", + " spendControls: { allowedAssets },\n", + " autoPayment: true,\n", + " onPaymentRequested: logDemand,\n", + " paymentRequirementsSelector: pinToAsset(config.usdcAddress),\n", + "});\n", + "await payer.connect(new StreamableHTTPClientTransport(new URL(MCP_URL)));\n", + "\n", + "function printEvidence(label: string, r: { isError?: boolean; paymentMade?: boolean; content?: Array<{ text?: string }>; paymentResponse?: unknown }) {\n", + " console.log(`${label} — isError: ${r.isError} | paymentMade: ${r.paymentMade}`);\n", + " console.log(` raw content[0]: ${String(r.content?.[0]?.text ?? \"(empty)\").slice(0, 300)}`);\n", + " console.log(` paymentResponse: ${JSON.stringify(r.paymentResponse)?.slice(0, 220)}`);\n", + "}\n", + "\n", + "const t0 = performance.now();\n", + "const first = await payer.callTool(\"chat_completion\", {\n", + " model: \"mistral-large-latest\",\n", + " messages: [{ role: \"user\", content: \"In one sentence, what is an x402 payment channel?\" }],\n", + "});\n", + "console.log(`chat_completion paid in ${Math.round(performance.now() - t0)}ms (first call: deposit + verify + settle)`);\n", + "printEvidence(\"first call\", first);\n", + "\n", + "const llm = JSON.parse(String(first.content?.[0]?.text ?? \"{}\"));\n", + "assert(llm.choices?.[0]?.message?.content, \"paid chat_completion returns a completion\");\n", + "const channelsAfterFirst = channelKeyCount();\n", + "console.log(` channel contexts: ${channelsBefore} -> ${channelsAfterFirst}`);\n", + "\n", + "// Second call: same channel, no deposit — the MCP wrapper must reuse the client-side\n", + "// channel context exactly like wrapFetchWithPayment does in the REST buyers.\n", + "const second = await payer.callTool(\"chat_completion\", {\n", + " model: \"mistral-large-latest\",\n", + " messages: [{ role: \"user\", content: \"And in one word?\" }],\n", + "});\n", + "printEvidence(\"second call\", second);\n", + "const channelsAfterSecond = channelKeyCount();\n", + "assert(channelsAfterSecond === channelsAfterFirst,\n", + " `second call opened no new channel (${channelsAfterFirst} -> ${channelsAfterSecond})`);\n", + "const llm2 = JSON.parse(String(second.content?.[0]?.text ?? \"{}\"));\n", + "assert(llm2.choices?.[0]?.message?.content, \"second paid chat_completion returns a completion\");" + ] + }, + { + "cell_type": "markdown", + "id": "a1ae9561", + "metadata": {}, + "source": [ + "## Usage-derived settlement (spike question #5)\n", + "\n", + "The translation layer has no payment logic — the chat handler settles the real, usage-derived\n", + "amount natively (authorize the per-message ceiling, claim the actual tokens). The receipt is\n", + "the top-level `paymentResponse` field. Compare it against the completion's `usage` with the\n", + "Mistral rate card ($0.50/M input, $1.50/M output, 6 decimals for USDC — the same formula\n", + "`test/mcp_server.test.ts` pins). On testnet the completion is the mock and its usage is the\n", + "placeholder — a tiny claim IS the parity behavior: bill what it actually cost." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "dde5f77d", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " usage: {\"prompt_tokens\":17,\"completion_tokens\":67,\"total_tokens\":84,\"prompt_tokens_details\":{\"cached_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0},\"service_tier\":\"standard\"}\n", + " expected USDC claim for this usage: 109 atomic (0.000109 USD)\n", + " -> compare against paymentResponse / the channel's claimed delta in the findings cell\n", + "ok: the paid call carries a settlement receipt\n" + ] + } + ], + "source": [ + "const u = llm.usage as { prompt_tokens: number; completion_tokens: number } | undefined;\n", + "console.log(` usage: ${JSON.stringify(llm.usage)}`);\n", + "if (u) {\n", + " const expectedUsdc = (BigInt(u.prompt_tokens) * 50n + BigInt(u.completion_tokens) * 150n) / 100n;\n", + " console.log(` expected USDC claim for this usage: ${expectedUsdc} atomic ` +\n", + " `(${expectedUsdc / 1_000_000n}.${(expectedUsdc % 1_000_000n).toString().padStart(6, \"0\")} USD)`);\n", + " console.log(\" -> compare against paymentResponse / the channel's claimed delta in the findings cell\");\n", + "}\n", + "// A small usage that settled for the full advertised ceiling would be a parity bug —\n", + "// on testnet the mock's placeholder usage settles near-zero, which is the same rule.\n", + "assert(first.paymentResponse !== undefined, \"the paid call carries a settlement receipt\");" + ] + }, + { + "cell_type": "markdown", + "id": "0bcab1b8", + "metadata": {}, + "source": [ + "## Currency variants — pinning the accept explicitly (spike question #4)\n", + "\n", + "The default walk paid USDC (the pinned selector). This cell pins EURC and EURe — each opens\n", + "its own channel (channels are per buyer/receiver/network/asset), and the EURe run is the\n", + "**Permit2 path** over the MCP flow: the permit rides the handler's challenge extensions, the\n", + "deposit is executed server-side and gasless.\n", + "\n", + "**Pre-flight first** (the pattern `sc_llm_x402_buyer` uses): a fresh channel's first payment\n", + "is a DEPOSIT payload, and the server-side deposit simulation reverts when the wallet holds\n", + "none of the token — surfacing as the cryptic\n", + "`invalid_batch_settlement_evm_deposit_simulation_failed` 402. Checking the balance here turns\n", + "that into \"fund the wallet and re-run\". Needed ≈ 11x the per-asset ceiling (the SDK's deposit\n", + "multiplier over the ceiling, plus the claim itself) — deliberately conservative." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "06c3bb8a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " EURC: wallet holds 0 EURC\n", + " EURC: SKIPPED — the fresh-channel deposit needs ~0.33 EURC (deposit multiplier over the 0.03 per-message ceiling, plus the claim). Fund the wallet and re-run this cell.\n", + " EURe: wallet holds 0.417 EURe\n", + "EURe — isError: undefined | paymentMade: true\n", + " raw content[0]: {\"id\":\"e74d158fac494f87811576c3bd2f0b73\",\"object\":\"chat.completion\",\"created\":1792484197,\"model\":\"mistral-large-latest\",\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"Here’s a concise, EURe-paid reply for you:\\n\\n**\\\"Your payment of €0.01 in EURe is confirmed—thank you!\\\"** *(Adjust \n", + " paymentResponse: {\"success\":true,\"payer\":\"0x553179556FC2A39e535D65b921e01fA995E79101\",\"transaction\":\"0x604f55179853f4eb6d48d9c9d127ed22b2d690cba2f9abd416577037e4f44e61\",\"network\":\"eip155:8453\",\"extra\":{\"channelState\":{\"channelId\":\"0x282e\n", + "ok: EURe variant paid and returned a completion\n", + "channel contexts after variants: 2\n" + ] + } + ], + "source": [ + "import { formatUnits } from \"npm:viem@2\";\n", + "\n", + "const erc20BalanceOfAbi = [{\n", + " inputs: [{ name: \"account\", type: \"address\" }],\n", + " name: \"balanceOf\",\n", + " outputs: [{ name: \"\", type: \"uint256\" }],\n", + " stateMutability: \"view\",\n", + " type: \"function\",\n", + "}] as const;\n", + "\n", + "async function payWithAsset(label: string, asset: `0x${string}` | undefined) {\n", + " if (!asset) { console.log(` ${label}: not deployed on this network — skipped`); return; }\n", + "\n", + " // Pre-flight: enough token for the fresh-channel deposit? The ceiling comes from the\n", + " // demand the unpaid walk already probed (in the token's own atomic units).\n", + " const balance: bigint = await publicClient.readContract({\n", + " address: asset,\n", + " abi: erc20BalanceOfAbi,\n", + " functionName: \"balanceOf\",\n", + " args: [account.address],\n", + " });\n", + " const ceiling = (demand!.accepts as Array<{ asset?: string; amount: string }>)\n", + " .find((a) => (a.asset ?? \"\").toLowerCase() === asset.toLowerCase())?.amount;\n", + " const needed = ceiling ? BigInt(ceiling) * 11n : 0n;\n", + " const decimals = BigInt(ceiling ?? \"0\") >= 10n ** 15n ? 18 : 6; // EURe is 18-dec, EURC/USDC 6\n", + " console.log(` ${label}: wallet holds ${formatUnits(balance, decimals)} ${label}`);\n", + " if (needed > 0n && balance < needed) {\n", + " console.log(` ${label}: SKIPPED — the fresh-channel deposit needs ~${formatUnits(needed, decimals)} ${label} ` +\n", + " `(deposit multiplier over the ${formatUnits(BigInt(ceiling!), decimals)} per-message ceiling, plus the claim). ` +\n", + " `Fund the wallet and re-run this cell.`);\n", + " return;\n", + " }\n", + "\n", + " const c = createx402MCPClient({\n", + " name: \"llm-mcp-buyer-notebook\", version: \"1.0.0\",\n", + " schemes: [{ network: NETWORK, client: buyerScheme }],\n", + " spendControls: { allowedAssets },\n", + " paymentRequirementsSelector: pinToAsset(asset),\n", + " });\n", + " await c.connect(new StreamableHTTPClientTransport(new URL(MCP_URL)));\n", + " const r = await c.callTool(\"chat_completion\", {\n", + " model: \"mistral-large-latest\",\n", + " messages: [{ role: \"user\", content: `A one-sentence reply paid in ${label}.` }],\n", + " });\n", + " printEvidence(label, r);\n", + " const parsed = JSON.parse(String(r.content?.[0]?.text ?? \"{}\"));\n", + " assert(parsed.choices?.[0]?.message?.content, `${label} variant paid and returned a completion`);\n", + " await c.close();\n", + "}\n", + "\n", + "await payWithAsset(\"EURC\", config.eurcAddress);\n", + "await payWithAsset(\"EURe\", config.eureAddress);\n", + "console.log(`channel contexts after variants: ${channelKeyCount()}`);" + ] + }, + { + "cell_type": "markdown", + "id": "495fe563", + "metadata": {}, + "source": [ + "## EURe deposit diagnostics — the chain's state (read-only)\n", + "\n", + "The refused EURe deposit is a two-transaction bundle simulated by the server's sponsor: the\n", + "EIP-2612 **permit** (granting Permit2 the EURe allowance) followed by the **Permit2 witness\n", + "deposit** into the fresh channel. Both consume *sequential nonces* that live on-chain, are\n", + "shared across ALL channels of this (owner, token, spender) triple, and survive the localStorage\n", + "reset — while the client's tracking did not. These reads show the chain's truth; no transactions\n", + "are sent. Interpretation guide is in the cell's output." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5ff0d2d0", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " EURe.nonces(owner) = 7 (one-arg EIP-2612 style)\n", + " EURe.allowance(owner, Permit2) = 0 EURe\n", + " Permit2.allowance(EURe, owner, DepositCollector):\n", + " amount=0 EURe | expiration=0 | nonce=0\n", + " -> Nonces > 0: prior EURe deposits consumed them (shared across ALL channels,\n", + " surviving the localStorage reset) — a fresh-context client signing a stale nonce\n", + " reverts the [permit, deposit] simulation exactly as observed. Nonces at 0: the\n", + " suspect moves to the 2.28 deposit code path itself — then run sc_llm_x402_buyer\n", + " (now also pinned 2.28.0, REST client) against the same server: failing there too\n", + " = client-line or chain state; failing only here = the MCP client path.\n" + ] + } + ], + "source": [ + "// Addresses from @x402/evm batch-settlement constants (PERMIT2_ADDRESS, the canonical\n", + "// Uniswap Permit2; PERMIT2_DEPOSIT_COLLECTOR_ADDRESS, the CREATE2 deposit collector).\n", + "const PERMIT2 = \"0x000000000022D473030F116dDEE9F6B43aC78BA3\" as `0x${string}`;\n", + "const PERMIT2_DEPOSIT_COLLECTOR = \"0x4020425FAf3B746C082C2f942b4E5159887B0005\" as `0x${string}`;\n", + "\n", + "const erc20AllowanceAbi = [{\n", + " inputs: [{ name: \"owner\", type: \"address\" }, { name: \"spender\", type: \"address\" }],\n", + " name: \"allowance\", outputs: [{ name: \"\", type: \"uint256\" }],\n", + " stateMutability: \"view\", type: \"function\",\n", + "}] as const;\n", + "const erc20Nonces2Abi = [{\n", + " inputs: [{ name: \"owner\", type: \"address\" }, { name: \"spender\", type: \"address\" }],\n", + " name: \"nonces\", outputs: [{ name: \"\", type: \"uint256\" }],\n", + " stateMutability: \"view\", type: \"function\",\n", + "}] as const;\n", + "const erc20Nonces1Abi = [{\n", + " inputs: [{ name: \"owner\", type: \"address\" }],\n", + " name: \"nonces\", outputs: [{ name: \"\", type: \"uint256\" }],\n", + " stateMutability: \"view\", type: \"function\",\n", + "}] as const;\n", + "const permit2AllowanceAbi = [{\n", + " inputs: [{ name: \"token\", type: \"address\" }, { name: \"owner\", type: \"address\" },\n", + " { name: \"spender\", type: \"address\" }],\n", + " name: \"allowance\",\n", + " outputs: [{ name: \"amount\", type: \"uint160\" }, { name: \"expiration\", type: \"uint48\" },\n", + " { name: \"nonce\", type: \"uint48\" }],\n", + " stateMutability: \"view\", type: \"function\",\n", + "}] as const;\n", + "\n", + "const eure = config.eureAddress!;\n", + "\n", + "// 1. EIP-2612 permit nonce on the token — consumed permits for the Permit2 spender.\n", + "try {\n", + " const n = await publicClient.readContract({\n", + " address: eure, abi: erc20Nonces2Abi, functionName: \"nonces\", args: [account.address, PERMIT2],\n", + " });\n", + " console.log(` EURe.nonces(owner, Permit2) = ${n} (EIP-2612 permits consumed)`);\n", + "} catch {\n", + " const n = await publicClient.readContract({\n", + " address: eure, abi: erc20Nonces1Abi, functionName: \"nonces\", args: [account.address],\n", + " });\n", + " console.log(` EURe.nonces(owner) = ${n} (one-arg EIP-2612 style)`);\n", + "}\n", + "\n", + "// 2. Token-level allowance Permit2 currently holds — what an executed permit granted.\n", + "const tokAllowance = await publicClient.readContract({\n", + " address: eure, abi: erc20AllowanceAbi, functionName: \"allowance\", args: [account.address, PERMIT2],\n", + "});\n", + "console.log(` EURe.allowance(owner, Permit2) = ${formatUnits(tokAllowance, 18)} EURe`);\n", + "\n", + "// 3. Permit2-level witness state for the deposit collector — the (amount, expiration, nonce) triple.\n", + "const [p2Amount, p2Expiration, p2Nonce] = await publicClient.readContract({\n", + " address: PERMIT2, abi: permit2AllowanceAbi, functionName: \"allowance\",\n", + " args: [eure, account.address, PERMIT2_DEPOSIT_COLLECTOR],\n", + "});\n", + "console.log(` Permit2.allowance(EURe, owner, DepositCollector):`);\n", + "console.log(` amount=${formatUnits(p2Amount, 18)} EURe | expiration=${p2Expiration} | nonce=${p2Nonce}`);\n", + "console.log(\" -> Nonces > 0: prior EURe deposits consumed them (shared across ALL channels,\");\n", + "console.log(\" surviving the localStorage reset) — a fresh-context client signing a stale nonce\");\n", + "console.log(\" reverts the [permit, deposit] simulation exactly as observed. Nonces at 0: the\");\n", + "console.log(\" suspect moves to the 2.28 deposit code path itself — then run sc_llm_x402_buyer\");\n", + "console.log(\" (now also pinned 2.28.0, REST client) against the same server: failing there too\");\n", + "console.log(\" = client-line or chain state; failing only here = the MCP client path.\");" + ] + }, + { + "cell_type": "markdown", + "id": "bbc1d681", + "metadata": {}, + "source": [ + "## Teardown\n", + "\n", + "`close()` sends a `DELETE /mcp` — which a stateless server answers with 405. The SDK must\n", + "tolerate that silently (spike question #1, the DELETE half)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "29bcd1ec", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "closed both clients (DELETE answered 405 and the SDK tolerated it)\n" + ] + } + ], + "source": [ + "await observer.close();\n", + "await payer.close();\n", + "console.log(\"closed both clients (DELETE answered 405 and the SDK tolerated it)\");" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Findings\n", + "\n", + "Fill in after a real run (testnet first, then flip `USE_DEPLOYED` for the gateway check):\n", + "\n", + "- [ ] **Stateless server tolerated** — initialize + listTools + teardown passed against\n", + " `http://127.0.0.1:8086/mcp` with no session, GET/DELETE answered 405\n", + "- [ ] **Payment convention verified** — the official client read the demands out of the\n", + " unpaid result (code 402 + `err.paymentRequired`) and paid with zero custom code\n", + "- [ ] **Channel deposit over MCP works** — the first paid call deposited a real channel on Base Sepolia\n", + "- [ ] **EURe Permit2 over MCP works** — the EURe variant paid without an approval transaction\n", + "- [ ] **Usage-derived settlement** — the claim matched the completion's `usage` (near-zero for\n", + " the testnet mock's placeholder usage), not the advertised ceiling\n", + "- [ ] **Deployed twin run** — the same walk passed against the bunny trial URL; cold `initialize` ≈ 0.3 s (vs ~26-27 s on scw)\n", + "\n", + "Channel reuse: `channelsBefore -> after first call -> after second -> after variants`:\n", + "`___ -> ___ -> ___ -> ___` (expected: one new channel per asset paid — USDC in the walk, EURC\n", + "and EURe in the variants — and zero on the second call).\n", + "\n", + "`web_search` is deliberately not driven here: the handler's rule is mainnet-only, so a paid\n", + "search is a mainnet run (real Brave, $0.01/call)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Deno", + "language": "typescript", + "name": "deno" + }, + "language_info": { + "codemirror_mode": "typescript", + "file_extension": ".ts", + "mimetype": "text/x.typescript", + "name": "typescript", + "nbconvert_exporter": "script", + "pygments_lexer": "typescript", + "version": "6.0.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/bunny_js/notebooks/llm_x402_buyer.ipynb b/bunny_js/notebooks/llm_x402_buyer.ipynb new file mode 100644 index 000000000..4b7f5fd0a --- /dev/null +++ b/bunny_js/notebooks/llm_x402_buyer.ipynb @@ -0,0 +1,1012 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# `bunny_js` llm twin — Buyer Notebook — real end-to-end chat payment (Deno/TypeScript)\n", + "\n", + "Drives the **real, locally-running** `sc_llm_x402.ts` batch-settlement chat handler as a buyer would —\n", + "the missing real-server verification for Phase B, and the runnable blueprint\n", + "`website/hooks/useX402Chat.ts` was built from. Unlike\n", + "`x402_facilitator/notebooks/x402_batch_settlement_buyer.ipynb` (which hits the facilitator's raw\n", + "`/verify`+`/settle` endpoints directly with hand-built payment requirements), this notebook only ever\n", + "talks to the **resource server** over plain HTTP via `wrapFetchWithPayment` — exactly what a real client\n", + "(browser or otherwise) does. The server decides and advertises everything else.\n", + "\n", + "**Terminology — x402 SDK role → this repo's package:**\n", + "\n", + "| SDK role | Repo package | Role in this repo |\n", + "|---|---|---|\n", + "| **Client** (buyer / payer) | `website/` *(this notebook stands in for it)* | Browser wallet: signs the deposit + per-message vouchers |\n", + "| **Server** (seller / merchant) | `scw_js/` (`sc_llm_x402.ts`) | Verifies vouchers, serves the LLM response, commits the charge |\n", + "| **Facilitator** | `x402_facilitator/` | Neither buyer nor seller — executes on-chain deposit/claim/settle |\n", + "\n", + "## ⚠️ Prerequisites\n", + "\n", + "1. **Deno Jupyter kernel** — `deno jupyter --install` (same global kernel as the sibling facilitator notebooks).\n", + "2. **`scw_js/.env`** needs:\n", + " - `TEST_WALLET_PRIVATE_KEY` — the buyer wallet, funded with USDC on whichever network you select\n", + " below (Base Sepolia: https://faucet.circle.com/; Base or Optimism Mainnet: a real, funded\n", + " wallet — plus a little ETH on that chain for deposit gas). Purely a\n", + " notebook-testing convenience — no scw_js production code reads this key.\n", + " - `NFT_WALLET_PUBLIC_KEY` — the receiver address (the server reads the same file).\n", + " - `RECEIVER_AUTHORIZER_PRIVATE_KEY` — required for `createLLMResourceServer()` to construct at all.\n", + " A pure off-chain signer, no funding needed. See `assistent_plan.md` §Backlog F for why this isn't\n", + " delegated to the facilitator (yet).\n", + " - `MISTRAL_API_KEY` — required once `USE_MAINNET` is armed below (the **server**, not this notebook,\n", + " reads it). Real Mistral completions are billed to this key — see `llm_service.ts`'s `LLM_PROVIDERS`.\n", + " - `SCW_ACCESS_KEY` / `SCW_SECRET_KEY` — the server's `S3ChannelStorage` writes real objects under\n", + " `channels/` in the production bucket when this runs (private ACL, harmless, but real).\n", + "3. **Start the local twin** (separate terminal): `cd bunny_js && npm run build:llm && deno run -A --env-file=.env dist/llm.js` — listens on `:8080`,\n", + " and internally talks to the **real deployed facilitator** (`https://facilitator.fretchen.eu` by default)\n", + " unless `FACILITATOR_URL` is overridden. This means the first cell that opens a channel submits a\n", + " **real on-chain transaction on the selected network**.\n", + "4. **Or skip step 3 entirely** and point the SERVER selector below at the deployed trial URL, or at\n", + " the real deployed `llmx402` Scaleway function instead of a local server — same S3 channel storage\n", + " bucket and same facilitator either way, so results are directly comparable. The deployed function\n", + " scales to zero when idle, so the very first request after a while may take several extra seconds\n", + " (cold start) before the usual settlement timing shown in this notebook's past runs." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "bunny_js llm twin buyer notebook\n", + " Buyer (payer): 0x553179556FC2A39e535D65b921e01fA995E79101\n", + " Receiver : 0xAAEBC1441323B8ad6Bdf6793A8428166b510239C\n" + ] + } + ], + "source": [ + "// Setup: imports + env\n", + "import { load } from \"https://deno.land/std@0.224.0/dotenv/mod.ts\";\n", + "import { privateKeyToAccount } from \"npm:viem@2/accounts\";\n", + "\n", + "// bunny_js's own .env (copied from scw_js's), one level up — the buyer key and receiver\n", + "// address; the twin reads the same file when run locally with --env-file.\n", + "const env = await load({ envPath: \"../.env\", examplePath: null, export: true });\n", + "\n", + "const PRIVATE_KEY = env.TEST_WALLET_PRIVATE_KEY;\n", + "if (!PRIVATE_KEY) {\n", + " throw new Error(\"TEST_WALLET_PRIVATE_KEY missing from bunny_js/.env — add a key funded with Base Sepolia USDC.\");\n", + "}\n", + "const account = privateKeyToAccount(`0x${PRIVATE_KEY.replace(/^0x/, \"\")}`);\n", + "\n", + "console.log(\"bunny_js llm twin buyer notebook\");\n", + "console.log(` Buyer (payer): ${account.address}`);\n", + "console.log(` Receiver : ${env.NFT_WALLET_PUBLIC_KEY ?? \"(not set — server will 500)\"}`);" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Network selection\n", + "\n", + "Two flags: **`USE_MAINNET`** picks mainnet vs. testnet, **`USE_BASE`** picks the chain — the same\n", + "pair the sibling `x402_facilitator/notebooks/x402_batch_settlement_buyer.ipynb` uses.\n", + "\n", + "| `USE_MAINNET` | `USE_BASE` | Network | CAIP-2 | Real Mistral call? |\n", + "|---|---|---|---|---|\n", + "| `false` | `true` | Base Sepolia | `eip155:84532` | No — the server always mocks (`isTestnet`), whatever the client sends |\n", + "| `false` | `false` | — | — | **Unsupported**: Optimism Sepolia has no batch-settlement contract (the cell throws) |\n", + "| `true` | `true` | Base Mainnet | `eip155:8453` | **Yes** — real settlement (USDC or EURC, see below) + a real, billed Mistral completion |\n", + "| `true` | `false` | Optimism Mainnet | `eip155:10` | **Yes** — same, on Optimism (USDC only — no EURC there) |\n", + "\n", + "Optimism mainnet was unusable until `@x402/evm` 2.20: `enhancePaymentRequirements()` resolved the\n", + "asset from the SDK's `DEFAULT_STABLECOINS` registry, which has no `eip155:10` entry, so the server\n", + "threw while building the 402 (upstream\n", + "[x402#2910](https://github.com/x402-foundation/x402/issues/2910), fixed in 2.20 — it now honours\n", + "the caller's `asset`). The registry gap itself remains, which is why anything falling back to it\n", + "still needs the token spelled out (see `llm_x402_cron.ts`).\n", + "\n", + "**Mainnet is the only deliberate action.** Since the server mocks every testnet request\n", + "unconditionally, choosing a real Mistral completion *is* choosing mainnet — there's no separate\n", + "confirmation flag. Flipping `USE_MAINNET` spends real settlement funds *and* real Mistral credit.\n", + "\n", + "**Which token pays is negotiated, not chosen here.** Both Base networks offer EURC and list it\n", + "before USDC (`scw_js/stablecoin_pricing.ts`), and the buyer-setup cell allowlists EURC — so the\n", + "default run (`USE_BASE = true`) pays EURC, exactly as a real client would. USDC and EURC are two\n", + "separate price lists on the server (no exchange rate): the chat is priced on Mistral's USD card for\n", + "a USDC channel and on its EUR card for a EURC channel. Optimism has no EURC, so it pays USDC." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " Service: http://localhost:8080\n" + ] + } + ], + "source": [ + "import { base, baseSepolia, optimism } from \"npm:viem@2/chains\";\n", + "\n", + "const USE_MAINNET = true; // ⚠️ true = REAL MONEY: mainnet settlement + a real, billed Mistral call.\n", + "const USE_BASE = true; // true = Base, false = Optimism. (Optimism is mainnet-only, see guard below.)\n", + "\n", + "// Guard: Optimism Sepolia has no batch-settlement contract deployed, so the server's\n", + "// getBatchSettlementNetworks() (x402_server.ts) deliberately excludes it — a channel could\n", + "// never be opened there. Fail loudly here rather than at a confusing 402 three cells later.\n", + "if (!USE_BASE && !USE_MAINNET) {\n", + " throw new Error(\n", + " \"Optimism Sepolia is unsupported for batch-settlement (no canonical contract). \" +\n", + " \"Use Base Sepolia (USE_BASE = true) or an Optimism mainnet run (USE_MAINNET = true).\",\n", + " );\n", + "}\n", + "\n", + "const NETWORK_CONFIG = {\n", + " \"base-testnet\": {\n", + " caip2Network: \"eip155:84532\" as const, networkName: \"Base Sepolia (Testnet)\",\n", + " chain: baseSepolia, usdcName: \"USDC\",\n", + " usdcAddress: \"0x036CbD53842c5426634e7929541eC2318f3dCF7e\" as `0x${string}`,\n", + " // EURC on Base Sepolia — see shared/chain-utils/src/addresses.ts and scw_js/README.md's\n", + " // EURC domain-name table.\n", + " eurcAddress: \"0x808456652fdb597867f38412077A9182bf77359F\" as `0x${string}` | undefined,\n", + " // EURe (Monerium, 18 decimals, Permit2 + EIP-2612 permit — no approve(), no ETH needed). eureAddress: \"0x29F37F6adCa168B79B8d9567eab9BE3fBF21db85\" as `0x${string}` | undefined, // Testnet traffic is light enough that the chain's default endpoint is fine.\n", + " rpcUrl: undefined as string | undefined,\n", + " explorer: \"https://sepolia.basescan.org\", faucet: \"https://faucet.circle.com/\",\n", + " },\n", + " \"base-mainnet\": {\n", + " caip2Network: \"eip155:8453\" as const, networkName: \"Base Mainnet\",\n", + " chain: base, usdcName: \"USD Coin\",\n", + " // EURe (Monerium, 18 decimals, Permit2 + EIP-2612 permit — no approve(), no ETH needed). eureAddress: \"0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C\" as `0x${string}` | undefined, usdcAddress: \"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913\" as `0x${string}`,\n", + " eurcAddress: \"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42\" as `0x${string}` | undefined,\n", + " rpcUrl: \"https://base-rpc.publicnode.com\",\n", + " explorer: \"https://basescan.org\", faucet: \"Bridge: https://bridge.base.org\",\n", + " },\n", + " \"optimism-mainnet\": {\n", + " caip2Network: \"eip155:10\" as const, networkName: \"Optimism Mainnet\",\n", + " chain: optimism, usdcName: \"USD Coin\",\n", + " usdcAddress: \"0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85\" as `0x${string}`,\n", + " // No EURC on Optimism (Circle has no deployment there).\n", + " eurcAddress: undefined as `0x${string}` | undefined,\n", + " rpcUrl: \"https://optimism-rpc.publicnode.com\",\n", + " explorer: \"https://optimistic.etherscan.io\", faucet: \"Bridge: https://app.optimism.io/bridge\",\n", + " },\n", + "};\n", + "// Mainnet RPCs are pinned to publicnode rather than viem's bare default (mainnet.base.org /\n", + "// mainnet.optimism.io): opening a channel does a Multicall3 read batch, which those defaults\n", + "// have already rate-limited in this repo — surfacing as the generic\n", + "// ..._deposit_transaction_failed. Same endpoints website/wagmi.config.ts settled on.\n", + "const configKey = USE_MAINNET ? (USE_BASE ? \"base-mainnet\" : \"optimism-mainnet\") : \"base-testnet\";\n", + "const config = NETWORK_CONFIG[configKey];\n", + "const NETWORK = config.caip2Network;\n", + "\n", + "// Toggle between the local dev server and the real deployed Scaleway function — orthogonal to\n", + "// USE_MAINNET, same code path either way, since the channel is on-chain state keyed by\n", + "// (buyer, receiver, network, asset), not by which server queried it.\n", + "// Local: `cd bunny_js && npm run dev:llm` (see prerequisites above; add --env-file=.env).\n", + "// bunny-local: the twin on 127.0.0.1:8080. bunny-trial: the deployed twin's URL.\n", + "// scw-deployed: the Scaleway original, for A/B. The channel is the same in all three\n", + "// (on-chain state keyed by buyer/receiver/token/network) — but the TWIN advertises a\n", + "// 40 s completion cap (bunny's edge kills requests at ~59 s), so very long completions\n", + "// fail on the twin where they would succeed on Scaleway.\n", + "const SERVER = \"bunny-local\" as \"bunny-local\" | \"bunny-trial\" | \"scw-deployed\";\n", + "const TRIAL_URL = \"https://CHANGE-ME.bunny.run\"; // <- set when SERVER = \"bunny-trial\"\n", + "const SERVICE_URL =\n", + " SERVER === \"bunny-local\" ? \"http://localhost:8080\"\n", + " : SERVER === \"bunny-trial\" ? TRIAL_URL\n", + " : \"https://llm-agent.fretchen.eu\";\n", + "console.log(` Service: ${SERVICE_URL}`);" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Buyer setup — the shape `useX402Chat.ts` will need\n", + "\n", + "Unlike the `exact` scheme (`registerExactEvmScheme` helper), batch-settlement has **no client-register\n", + "helper** — the scheme is constructed and registered manually, same as the facilitator's buyer spike.\n", + "\n", + "For channel storage this notebook uses a tiny `WebStorageClientChannelStorage` adapter over Deno's\n", + "**global `localStorage`** — the same Web Storage API a browser exposes, and (unlike\n", + "`InMemoryClientChannelStorage`) it persists across kernel restarts, so an open channel is reused on a\n", + "re-run exactly as it must survive a page reload in production. `useX402Chat.ts` reuses this *same* class,\n", + "passing `window.localStorage` instead of Deno's global (per `assistent_plan.md` Phase C's plan).\n", + "Everything else below is copy-paste-equivalent to what the hook needs.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "_x402Client {\n", + " registeredClientSchemes: Map(1) {\n", + " \u001b[33m2\u001b[39m => Map(1) {\n", + " \u001b[32m\"eip155:8453\"\u001b[39m => Map(1) {\n", + " \u001b[32m\"batch-settlement\"\u001b[39m => BatchSettlementEvmScheme {\n", + " signer: \u001b[36m[Object]\u001b[39m,\n", + " scheme: \u001b[32m\"batch-settlement\"\u001b[39m,\n", + " findDefaultAsset: \u001b[36m[Function: findDefaultAsset]\u001b[39m,\n", + " storage: \u001b[36m[WebStorageClientChannelStorage]\u001b[39m,\n", + " depositPolicy: \u001b[90mundefined\u001b[39m,\n", + " depositStrategy: \u001b[36m[Function: depositStrategy]\u001b[39m,\n", + " salt: \u001b[32m\"0x0000000000000000000000000000000000000000000000000000000000000000\"\u001b[39m,\n", + " payerAuthorizer: \u001b[90mundefined\u001b[39m,\n", + " voucherSigner: \u001b[90mundefined\u001b[39m,\n", + " extensionRpcOptions: \u001b[90mundefined\u001b[39m,\n", + " schemeHooks: \u001b[36m[Object]\u001b[39m\n", + " }\n", + " }\n", + " }\n", + " },\n", + " schemeClientHookAdapters: Map(1) {\n", + " \u001b[33m2\u001b[39m => Map(1) {\n", + " \u001b[32m\"eip155:8453\"\u001b[39m => Map(1) {\n", + " \u001b[32m\"batch-settlement\"\u001b[39m => { onPaymentResponse: \u001b[36m[AsyncFunction: onPaymentResponse]\u001b[39m }\n", + " }\n", + " }\n", + " },\n", + " policies: [],\n", + " registeredExtensions: Map(0) {},\n", + " spendControls: {\n", + " allowedAssets: [\n", + " {\n", + " network: \u001b[32m\"eip155:8453\"\u001b[39m,\n", + " asset: \u001b[32m\"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42\"\u001b[39m\n", + " }\n", + " ]\n", + " },\n", + " beforePaymentCreationHooks: [],\n", + " afterPaymentCreationHooks: [],\n", + " onPaymentCreationFailureHooks: [],\n", + " paymentResponseHooks: [],\n", + " paymentRequirementsSelector: \u001b[36m[Function (anonymous)]\u001b[39m\n", + "}" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import { x402Client, wrapFetchWithPayment, x402HTTPClient } from \"npm:@x402/fetch@2.28.0\";\n", + "import { toClientEvmSigner } from \"npm:@x402/evm@2.28.0\";\n", + "import {\n", + " BatchSettlementEvmScheme,\n", + " type ClientChannelStorage,\n", + " type BatchSettlementClientContext,\n", + " type BatchSettlementDepositStrategyContext,\n", + "} from \"npm:@x402/evm@2.28.0/batch-settlement/client\";\n", + "import { createPublicClient, http } from \"npm:viem@2\";\n", + "\n", + "// readContract is documented as \"optional\" on ClientEvmSigner (only \"required for extension\n", + "// enrichment\" per the SDK's own JSDoc), but batch-settlement's corrective-402 recovery\n", + "// (processCorrectivePaymentRequired, triggered on invalid_batch_settlement_evm_cumulative_amount_mismatch\n", + "// and _below_claimed) unconditionally needs it too — both recoverFromSignature and\n", + "// recoverFromOnChainState bail out immediately with `if (!deps.signer.readContract) return false`.\n", + "// Without it, a client/server cumulative-total desync (e.g. from an old channel reused across\n", + "// notebook/kernel restarts via localStorage) surfaces as a hard 402 instead of self-healing.\n", + "// toClientEvmSigner() is the SDK's own helper for composing a full signer from a plain\n", + "// account + a public client. In useX402Chat.ts: swap `account`/`publicClient` here for a wagmi\n", + "// WalletClient adapter + `usePublicClient()` (see useX402ImageGeneration.ts's signer shape).\n", + "// chain + rpcUrl come from `config` (network-selection cell) — the flags there select the\n", + "// network, which chain object the public client reads against, AND its RPC endpoint (mainnets\n", + "// are pinned away from viem's rate-limited defaults; see that cell).\n", + "const publicClient = createPublicClient({\n", + " chain: config.chain,\n", + " transport: http(config.rpcUrl),\n", + "});\n", + "const buyerSigner = toClientEvmSigner(\n", + " { address: account.address, signTypedData: (a: any) => account.signTypedData(a) },\n", + " publicClient,\n", + ");\n", + "\n", + "// ClientChannelStorage backed by the Web Storage API. Channel context is all strings, so\n", + "// plain JSON round-trips. In useX402Chat.ts, construct this SAME class with window.localStorage.\n", + "class WebStorageClientChannelStorage implements ClientChannelStorage {\n", + " constructor(private backend: Storage, private prefix = \"x402-channel:\") {}\n", + " private keyFor(key: string) { return `${this.prefix}${key.toLowerCase()}`; }\n", + " async get(key: string) {\n", + " const raw = this.backend.getItem(this.keyFor(key));\n", + " return raw ? (JSON.parse(raw) as BatchSettlementClientContext) : undefined;\n", + " }\n", + " async set(key: string, context: BatchSettlementClientContext) {\n", + " this.backend.setItem(this.keyFor(key), JSON.stringify(context));\n", + " }\n", + " async delete(key: string) { this.backend.removeItem(this.keyFor(key)); }\n", + "}\n", + "\n", + "// Deno exposes the global `localStorage` (persists across kernel restarts). Browser: window.localStorage.\n", + "const buyerStorage = new WebStorageClientChannelStorage(localStorage);\n", + "\n", + "// Floor for channel deposits/top-ups, in USDC atomic units (6 decimals) — $0.50. Same value\n", + "// and same reasoning as useX402Chat.ts's MINIMUM_DEPOSIT_ATOMIC: the SDK's own default\n", + "// (depositMultiplier x per-message ceiling) tracks whatever the ceiling happens to be\n", + "// (currently ~$0.003/message), sizing deposits at only ~1-3 cents — enough for ~5 messages\n", + "// worst-case before another on-chain top-up (a real tx + wait) is needed. $0.50 comfortably\n", + "// covers a full session while keeping the number small on the axis that actually matters for\n", + "// this app: it's the blast radius if a delegate voucher-signer key ever leaks.\n", + "const MINIMUM_DEPOSIT_ATOMIC = 400_000n;\n", + "\n", + "function depositStrategy(context: BatchSettlementDepositStrategyContext): string {\n", + " const required = BigInt(context.minimumDepositAmount);\n", + " return (required > MINIMUM_DEPOSIT_ATOMIC ? required : MINIMUM_DEPOSIT_ATOMIC).toString();\n", + "}\n", + "\n", + "const buyerScheme = new BatchSettlementEvmScheme(buyerSigner, { storage: buyerStorage, depositStrategy });\n", + "\n", + "const client = new x402Client();\n", + "client.register(NETWORK, buyerScheme);\n", + "\n", + "// USDC already passes the SDK's default spend-control asset registry on every network this\n", + "// notebook uses. EURC and EURe are not in that registry at all, so without these entries they\n", + "// would be dropped from the offer rather than paid. With them, the SDK takes the first offered\n", + "// entry — EURe on Base, which the server now prefers.\n", + "client.setSpendControls({\n", + " allowedAssets: [\n", + " ...(config.eurcAddress ? [{ network: NETWORK, asset: config.eurcAddress }] : []),\n", + " ...(config.eureAddress ? [{ network: NETWORK, asset: config.eureAddress }] : []),\n", + " ],\n", + "});\n", + "\n", + "const fetchWithPayment = wrapFetchWithPayment(fetch, client);\n" + ] + }, + { + "cell_type": "markdown", + "id": "22d28231", + "metadata": {}, + "source": [ + "## Pre-flight — buyer balance vs. estimated cost\n", + "\n", + "Neither the network flag above nor the warnings below change the fact that arming `USE_MAINNET`\n", + "spends real money. Check the buyer has enough of whatever token will actually be used *before*\n", + "message #1 tries to open a channel — on Base that may be EURC, not USDC (see network selection).\n", + "The deposit is governed by the `depositStrategy` set up in the previous cell — a fixed $0.50 floor\n", + "(see `MINIMUM_DEPOSIT_ATOMIC`), not the SDK's own smaller multiplier-of-ceiling default — chosen so\n", + "a full multi-message session doesn't need a mid-conversation on-chain top-up.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "aae724d6", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "📋 offered by the server (7 entries, in its preference order):\n", + " eip155:10 USD Coin ceiling 0.03 0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85\n", + " eip155:8453 Monerium EURe ceiling 30000000000 0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C\n", + " eip155:8453 EURC ceiling 0.03 0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42\n", + " eip155:8453 USD Coin ceiling 0.03 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913\n", + " eip155:84532 Monerium EURe ceiling 30000000000 0x29F37F6adCa168B79B8d9567eab9BE3fBF21db85\n", + " eip155:84532 EURC ceiling 0.03 0x808456652fdb597867f38412077A9182bf77359F\n", + " eip155:84532 USDC ceiling 0.03 0x036CbD53842c5426634e7929541eC2318f3dCF7e\n", + "💵 Buyer EURC: 0 (estimated deposit ≈ 0.4 EURC)\n", + " ⚠️ insufficient EURC on Base Mainnet — Bridge: https://bridge.base.org\n" + ] + } + ], + "source": [ + "import { formatUnits } from \"npm:viem@2\";\n", + "\n", + "const erc20BalanceOfAbi = [{\n", + " inputs: [{ name: \"account\", type: \"address\" }],\n", + " name: \"balanceOf\",\n", + " outputs: [{ name: \"\", type: \"uint256\" }],\n", + " stateMutability: \"view\",\n", + " type: \"function\",\n", + "}] as const;\n", + "\n", + "// Which token will actually be used: ask the server rather than assume. Listing the whole offer\n", + "// first is the diagnostic — \"EURC is not in the list\" and \"EURC is listed but was not chosen\" are\n", + "// different problems, and only the full list tells them apart.\n", + "const probeResponse = await fetch(SERVICE_URL, {\n", + " method: \"POST\",\n", + " headers: { \"Content-Type\": \"application/json\" },\n", + " body: JSON.stringify({ model: \"mistral-large-latest\", messages: [{ role: \"user\", content: \"ping\" }] }),\n", + "});\n", + "const probe = (await probeResponse.json()) as {\n", + " accepts?: Array<{ network: string; asset: string; amount: string; extra?: { name?: string } }>;\n", + "};\n", + "console.log(`📋 offered by the server (${probe.accepts?.length ?? 0} entries, in its preference order):`);\n", + "for (const a of probe.accepts ?? []) {\n", + " console.log(` ${a.network.padEnd(15)} ${(a.extra?.name ?? \"?\").padEnd(9)} ceiling ${formatUnits(BigInt(a.amount), 6)} ${a.asset}`);\n", + "}\n", + "\n", + "// The entry the SDK will pick: the first one on NETWORK that this client allows (USDC via the\n", + "// SDK's registry, EURC via the allowlist in the buyer-setup cell).\n", + "const allowed = [config.usdcAddress, config.eurcAddress]\n", + " .filter((a): a is `0x${string}` => a !== undefined)\n", + " .map((a) => a.toLowerCase());\n", + "const priced = probe.accepts?.find((a) => a.network === NETWORK && allowed.includes(a.asset.toLowerCase()));\n", + "if (!priced) {\n", + " throw new Error(`Server offered nothing this client can pay on ${NETWORK} — got ${JSON.stringify(probe.accepts)}`);\n", + "}\n", + "const depositAsset = priced.asset as `0x${string}`;\n", + "const depositAssetName = priced.extra?.name ?? depositAsset;\n", + "\n", + "// Matches the depositStrategy cell above — the actual deposit floor, not the SDK's default.\n", + "const ESTIMATED_DEPOSIT_ATOMIC = MINIMUM_DEPOSIT_ATOMIC;\n", + "\n", + "const buyerBalance = await publicClient.readContract({\n", + " address: depositAsset,\n", + " abi: erc20BalanceOfAbi,\n", + " functionName: \"balanceOf\",\n", + " args: [account.address],\n", + "});\n", + "\n", + "console.log(`💵 Buyer ${depositAssetName}: ${formatUnits(buyerBalance, 6)} (estimated deposit ≈ ${formatUnits(ESTIMATED_DEPOSIT_ATOMIC, 6)} ${depositAssetName})`);\n", + "if (buyerBalance < ESTIMATED_DEPOSIT_ATOMIC) {\n", + " console.log(` ⚠️ insufficient ${depositAssetName} on ${config.networkName} — ${config.faucet}`);\n", + "} else {\n", + " console.log(` ✅ enough ${depositAssetName} to open the channel`);\n", + "}" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Message #1 — opens the channel (real on-chain deposit tx)\n", + "\n", + "The first call to `fetchWithPayment` against a resource it hasn't paid for yet: an initial bare request\n", + "gets `402`, the SDK builds a **deposit** payload (open the channel + first voucher) and signs it, the\n", + "retry carries the payment header, and the server verifies + settles (the deposit — the one real on-chain\n", + "tx per channel lifetime, confirmed in the B0 spike). Extracting the settlement receipt mirrors\n", + "`useX402ImageGeneration.ts`'s `x402HTTPClient.getPaymentSettleResponse(...)` call exactly.\n", + "\n", + "> ⚠️ Sending a message performs a **real on-chain USDC settlement** on the selected network, and —\n", + "> when `USE_MAINNET` is armed — a **real, billed Mistral API call**. Both cost real money the moment\n", + "> `USE_MAINNET = true`.\n", + "\n", + "> ℹ️ If a send is **interrupted** between verify and settle (kernel interrupt, network drop), the\n", + "> server leaves a short-lived per-channel lock, and the next send on that channel returns\n", + "> `invalid_batch_settlement_evm_channel_busy`. This is **intentional and self-healing** — the lock\n", + "> serializes requests on one channel and expires on its own (≤ `LLM_MAX_TIMEOUT_SECONDS`, currently\n", + "> 120s; the client SDK does not auto-recover from it). Just wait a couple of minutes and re-run. The\n", + "> website surfaces this as a friendly \"wait a few seconds and try again\" message\n", + "> (`useX402Chat.ts::describePaymentError`)." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "📡 status=402 elapsed=1380ms\n", + "📨 reply: {\n", + " \"x402Version\": 2,\n", + " \"error\": \"invalid_batch_settlement_evm_insufficient_balance\",\n", + " \"resource\": {\n", + " \"url\": \"https://llm-agent.fretchen.eu/\",\n", + " \"description\": \"AI Assistant chat message\",\n", + " \"mimeType\": \"application/json\"\n", + " },\n", + " \"accepts\": [\n", + " {\n", + " \"scheme\": \"batch-settlement\",\n", + " \"network\": \"eip155:8453\",\n", + " \"amount\": \"30000\",\n", + " \"asset\": \"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42\",\n", + " \"payTo\": \"0xAAEBC1441323B8ad6Bdf6793A8428166b510239C\",\n", + " \"maxTimeoutSeconds\": 40,\n", + " \"extra\": {\n", + " \"name\": \"EURC\",\n", + " \"version\": \"2\",\n", + " \"receiverAuthorizer\": \"0x\n", + "🧾 no settlement receipt: Payment response header not found\n" + ] + }, + { + "ename": "Error", + "evalue": "msg1: expected HTTP 200, got 402", + "output_type": "error", + "traceback": [ + "Error: msg1: expected HTTP 200, got 402", + " at assertOk (:86:11)", + " at :98:1" + ] + } + ], + "source": [ + "async function sendChatMessage(content: string) {\n", + " const started = performance.now();\n", + "\n", + " // FIXED (x402_facilitator PR #545, confirmed deployed — /supported now reports the real\n", + " // signer address, shipped in the same deploy): x402_facilitator's settlePayment() used to\n", + " // rebuild its own SettleResult and drop the SDK's `extra` (incl. channelState.channelId),\n", + " // so a real, correctly-settled payment's response reached the client missing channelId and\n", + " // crashed processSettleResponse() on channelId.toLowerCase(). That crash — while it was still\n", + " // live — could leave a channel settled server-side but never persisted client-side, which is\n", + " // the likely origin of any lingering cumulative-amount desync on a reused channel (see the\n", + " // `toClientEvmSigner` fix in the previous cell for why that no longer surfaces as a hard\n", + " // failure either way). Keeping this try/catch as defense-in-depth, not because the crash is\n", + " // expected anymore.\n", + " //\n", + " // OpenAI chat-completions request body ({ model, messages }) — matches the real client\n", + " // (useX402Chat.ts) exactly. `model` must be one the server advertises (mistral-large-latest).\n", + " // No `useDummyData` field: mock-vs-real is decided entirely server-side by NETWORK (see the\n", + " // network-selection cell): testnet always mocks, mainnet always calls the real Mistral API.\n", + " // The response is a standard OpenAI chat.completion — the reply is body.choices[0].message.content.\n", + " let response: Response;\n", + " try {\n", + " response = await fetchWithPayment(SERVICE_URL, {\n", + " method: \"POST\",\n", + " headers: { \"Content-Type\": \"application/json\" },\n", + " body: JSON.stringify({ model: \"mistral-large-latest\", messages: [{ role: \"user\", content }] }),\n", + " });\n", + " } catch (err) {\n", + " const elapsedMs = Math.round(performance.now() - started);\n", + " console.log(`📡 fetchWithPayment THREW after ${elapsedMs}ms:`, (err as Error).message);\n", + " console.log(\" (server may have settled successfully anyway — check the server's own log)\");\n", + " return { response: null, body: null, receipt: null, elapsedMs, threw: (err as Error).message };\n", + " }\n", + " const elapsedMs = Math.round(performance.now() - started);\n", + "\n", + " const bodyText = await response.text();\n", + " let body: any;\n", + " try { body = JSON.parse(bodyText); } catch { body = bodyText; }\n", + "\n", + " console.log(`📡 status=${response.status} elapsed=${elapsedMs}ms`);\n", + " console.log(\"📨 reply:\", body?.choices?.[0]?.message?.content ?? JSON.stringify(body, null, 2).slice(0, 600));\n", + "\n", + " let receipt: unknown = null;\n", + " try {\n", + " const httpClient = new x402HTTPClient(client);\n", + " receipt = httpClient.getPaymentSettleResponse((name: string) => response.headers.get(name));\n", + " console.log(\"🧾 settlement receipt:\", JSON.stringify(receipt, null, 2));\n", + " const settledAsset = (receipt as any)?.extensions?.facilitatorFees?.info?.asset;\n", + " if (settledAsset) {\n", + " console.log(` settled asset: ${settledAsset}`);\n", + " }\n", + " } catch (err) {\n", + " console.log(\"🧾 no settlement receipt:\", (err as Error).message);\n", + " }\n", + " return { response, body, receipt, elapsedMs, threw: null };\n", + "}\n", + "\n", + "// Minimal viable check (not a full test suite — a notebook `throw` fails the cell loudly).\n", + "// Asserts the three things this notebook otherwise only eyeballs: (1) the x402 flow succeeded\n", + "// (HTTP 200, not a 402/500), (2) the message round-tripped (a non-empty reply came back), and\n", + "// (3) the response is the correct OpenAI chat.completion shape with `usage` present (the field\n", + "// settlement is priced from). On testnet the reply is the server's placeholder mock, so this\n", + "// proves shape + transmission + payment, NOT real inference quality (that costs mainnet money).\n", + "function assertOk(label: string, r: { response: Response | null; body: any }) {\n", + " if (!r.response || r.response.status !== 200) {\n", + " throw new Error(`${label}: expected HTTP 200, got ${r.response?.status ?? \"no response\"}`);\n", + " }\n", + " const reply = r.body?.choices?.[0]?.message?.content;\n", + " if (typeof reply !== \"string\" || reply.length === 0) {\n", + " throw new Error(`${label}: no OpenAI-shaped reply (choices[0].message.content) in body`);\n", + " }\n", + " if (!r.body?.usage) {\n", + " throw new Error(`${label}: response missing 'usage' (batch-settlement pricing depends on it)`);\n", + " }\n", + " console.log(`✅ ${label}: OpenAI reply received (${reply.length} chars), usage present`);\n", + "}\n", + "\n", + "const msg1 = await sendChatMessage(\"What is the capital of France?\");\n", + "assertOk(\"msg1\", msg1);" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Messages #2 and #3 — same channel, the actual spike question\n", + "\n", + "**What we don't know yet, and this cell exists to find out:** does `fetchWithPayment` (via\n", + "`client.createPaymentPayload()`, called internally on every `402`) automatically notice\n", + "`buyerStorage` already holds an open channel for this `(receiver, network, asset)` and sign a plain\n", + "**voucher** against it (zero on-chain tx — the actual batching payoff) — or does it try to open a\n", + "**second, wasteful deposit**? The facilitator's buyer spike only demonstrated channel reuse via the\n", + "low-level `signVoucher` helper called *manually*, never through the automatic `fetchWithPayment` path —\n", + "so this genuinely hasn't been observed yet. Whatever happens here is what `useX402Chat.ts` needs to\n", + "account for.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "📡 status=200 elapsed=1991ms\n", + "📨 reply: **Placeholder response**\n", + "\n", + "This is a *mock* answer used for local/testnet testing. It includes:\n", + "\n", + "- a list item\n", + "- some `inline code`\n", + "- a [link](https://example.com)\n", + "\n", + "```js\n", + "console.log('mock code block');\n", + "```\n", + "🧾 settlement receipt: {\n", + " \"success\": true,\n", + " \"payer\": \"0x553179556fc2a39e535d65b921e01fa995e79101\",\n", + " \"transaction\": \"\",\n", + " \"network\": \"eip155:84532\",\n", + " \"amount\": \"\",\n", + " \"extra\": {\n", + " \"channelState\": {\n", + " \"channelId\": \"0x1746f66a4d05837a1cf41e4d80f032ff51e15f4668a2863ea591458932a1db97\",\n", + " \"balance\": \"400216\",\n", + " \"totalClaimed\": \"216\",\n", + " \"withdrawRequestedAt\": 0,\n", + " \"refundNonce\": \"3\",\n", + " \"chargedCumulativeAmount\": \"264\"\n", + " },\n", + " \"chargedAmount\": \"24\"\n", + " }\n", + "}\n", + "✅ msg2: OpenAI reply received (205 chars), usage present\n", + "📡 status=200 elapsed=2092ms\n", + "📨 reply: **Placeholder response**\n", + "\n", + "This is a *mock* answer used for local/testnet testing. It includes:\n", + "\n", + "- a list item\n", + "- some `inline code`\n", + "- a [link](https://example.com)\n", + "\n", + "```js\n", + "console.log('mock code block');\n", + "```\n", + "🧾 settlement receipt: {\n", + " \"success\": true,\n", + " \"payer\": \"0x553179556fc2a39e535d65b921e01fa995e79101\",\n", + " \"transaction\": \"\",\n", + " \"network\": \"eip155:84532\",\n", + " \"amount\": \"\",\n", + " \"extra\": {\n", + " \"channelState\": {\n", + " \"channelId\": \"0x1746f66a4d05837a1cf41e4d80f032ff51e15f4668a2863ea591458932a1db97\",\n", + " \"balance\": \"400216\",\n", + " \"totalClaimed\": \"216\",\n", + " \"withdrawRequestedAt\": 0,\n", + " \"refundNonce\": \"3\",\n", + " \"chargedCumulativeAmount\": \"288\"\n", + " },\n", + " \"chargedAmount\": \"24\"\n", + " }\n", + "}\n", + "✅ msg3: OpenAI reply received (205 chars), usage present\n" + ] + } + ], + "source": [ + "const msg2 = await sendChatMessage(\"And what is the capital of Germany?\");\n", + "assertOk(\"msg2\", msg2);\n", + "\n", + "const msg3 = await sendChatMessage(\"And Italy?\");\n", + "assertOk(\"msg3\", msg3);" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Tool calling — the `llm/v1` capability\n", + "\n", + "Tool calling is advertised as `x-capabilities: [\"tools\"]`, deliberately **outside** the interop floor:\n", + "a plain-chat `llm/v1` agent that ignores `tools` is still fully conformant. The two hops below are the\n", + "loop `website/hooks/useX402Chat.ts` needs, and each is a **separate metered voucher against the same\n", + "channel** opened above — no new deposit.\n", + "\n", + "1. Send `tools` → the model answers with `finish_reason: \"tool_calls\"`, `content: null`, and a\n", + " `tool_calls` array.\n", + "2. Execute the tool yourself, send the result back as a `role: \"tool\"` message carrying the matching\n", + " `tool_call_id` → the model answers with text.\n", + "\n", + "**The endpoint never calls a tool itself**, and accepts no callback or MCP server URL — tool\n", + "*definitions* in, tool *calls* out. Anything else would turn a paid public endpoint into an outbound\n", + "fetcher for unauthenticated payers.\n", + "\n", + "The `generate_image` definition below is verbatim what `website/tools/` will send\n", + "(`imagegen-in-chat-plan_1.md` §2), so this exercises the real payload. **The tool itself is stubbed** —\n", + "we return the compact `{ status, network }` result shape from §5 rather than actually generating an\n", + "image. genimg is a different scheme (`exact`) with a different signer; `genimg_x402_buyer.ipynb` covers\n", + "it.\n", + "\n", + "> ℹ️ **This section runs on testnet.** The mock upstream (`llm_service.ts`) is tool-aware: offered a\n", + "> tool with no result yet, it asks for it; once a `role: \"tool\"` message is present, it answers with\n", + "> text. So the whole loop — and the endpoint's validation, forwarding and settlement around it — is\n", + "> exercised for free and deterministically. A `USE_MAINNET` run additionally confirms that *real\n", + "> Mistral* returns this shape, which the mock cannot prove.\n", + "\n", + "> ⚠️ On `USE_MAINNET` these are two more **real billed Mistral calls** and two more real settlements.\n", + "\n", + "The `tools` caps (8 definitions, 8 KB serialized) and `tool_choice` being limited to `auto`/`none` are\n", + "covered by `test/sc_llm_x402.test.ts`, not re-tested here.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "📡 status=200 elapsed=3138ms\n", + "✅ hop1: generate_image({}), content: null, usage present\n", + "🧾 hop1 receipt: {\n", + " \"success\": true,\n", + " \"payer\": \"0x553179556fc2a39e535d65b921e01fa995e79101\",\n", + " \"transaction\": \"\",\n", + " \"network\": \"eip155:84532\",\n", + " \"amount\": \"\",\n", + " \"extra\": {\n", + " \"channelState\": {\n", + " \"channelId\": \"0x1746f66a4d05837a1cf41e4d80f032ff51e15f4668a2863ea591458932a1db97\",\n", + " \"balance\": \"400216\",\n", + " \"totalClaimed\": \"216\",\n", + " \"withdrawRequestedAt\": 0,\n", + " \"refundNonce\": \"3\",\n", + " \"chargedCumulativeAmount\": \"312\"\n", + " },\n", + " \"chargedAmount\": \"24\"\n", + " }\n", + "}\n" + ] + } + ], + "source": [ + "// The exact tool definition website/tools/ will send — imagegen-in-chat-plan_1.md §2.\n", + "// `network` is deliberately absent: the frontend fills it from useAutoNetwork, and it is not the\n", + "// model's business (an omitted network is how a testnet run would pay real money).\n", + "const generateImageTool = {\n", + " type: \"function\",\n", + " function: {\n", + " name: \"generate_image\",\n", + " description:\n", + " \"Generate an image from a text prompt. Costs $0.07 USDC and requires the \" +\n", + " \"user to approve a wallet signature. Only call when the user has clearly \" +\n", + " \"asked for an image.\",\n", + " parameters: {\n", + " type: \"object\",\n", + " properties: {\n", + " prompt: { type: \"string\", description: \"Detailed English image prompt\" },\n", + " size: { type: \"string\", enum: [\"1024x1024\", \"1792x1024\"] },\n", + " },\n", + " required: [\"prompt\"],\n", + " },\n", + " },\n", + "} as const;\n", + "\n", + "// Mirrors sendChatMessage above, but takes a whole request body — the tool loop needs to vary\n", + "// `messages` and add `tools`, and it sends twice. A real client has one such function\n", + "// (useX402Chat.ts::payAndSend).\n", + "async function sendChatBody(requestBody: Record) {\n", + " const started = performance.now();\n", + " let response: Response;\n", + " try {\n", + " response = await fetchWithPayment(SERVICE_URL, {\n", + " method: \"POST\",\n", + " headers: { \"Content-Type\": \"application/json\" },\n", + " body: JSON.stringify(requestBody),\n", + " });\n", + " } catch (err) {\n", + " console.log(\"📡 fetchWithPayment THREW:\", (err as Error).message);\n", + " return { response: null, body: null, receipt: null };\n", + " }\n", + " const bodyText = await response.text();\n", + " let body: any;\n", + " try { body = JSON.parse(bodyText); } catch { body = bodyText; }\n", + " console.log(`📡 status=${response.status} elapsed=${Math.round(performance.now() - started)}ms`);\n", + "\n", + " let receipt: unknown = null;\n", + " try {\n", + " receipt = new x402HTTPClient(client).getPaymentSettleResponse((n: string) => response.headers.get(n));\n", + " } catch (err) {\n", + " console.log(\"🧾 no settlement receipt:\", (err as Error).message);\n", + " }\n", + " return { response, body, receipt };\n", + "}\n", + "\n", + "// assertOk (above) requires a string content, which a tool-call turn does not have — hence a\n", + "// sibling for the content: null case.\n", + "function assertToolCall(label: string, r: any, expectedFn: string) {\n", + " if (r.response?.status !== 200) {\n", + " throw new Error(`${label}: expected HTTP 200, got ${r.response?.status ?? \"no response\"}`);\n", + " }\n", + " const choice = r.body?.choices?.[0];\n", + " if (choice?.finish_reason !== \"tool_calls\") {\n", + " throw new Error(`${label}: expected finish_reason \"tool_calls\", got ${JSON.stringify(choice?.finish_reason)}`);\n", + " }\n", + " const call = choice?.message?.tool_calls?.[0];\n", + " if (call?.function?.name !== expectedFn) {\n", + " throw new Error(`${label}: expected a call to ${expectedFn}, got ${JSON.stringify(choice?.message?.tool_calls)}`);\n", + " }\n", + " if (choice?.message?.content !== null) {\n", + " throw new Error(`${label}: expected content: null on a tool-call turn, got ${JSON.stringify(choice?.message?.content)}`);\n", + " }\n", + " if (!r.body?.usage) {\n", + " throw new Error(`${label}: response missing 'usage' (batch-settlement pricing depends on it)`);\n", + " }\n", + " console.log(`✅ ${label}: ${expectedFn}(${call.function.arguments}), content: null, usage present`);\n", + " return call;\n", + "}\n", + "\n", + "const USER_ASK = \"Please draw me a picture of a red bicycle leaning on a palm tree at the beach.\";\n", + "\n", + "const hop1 = await sendChatBody({\n", + " model: \"mistral-large-latest\",\n", + " messages: [{ role: \"user\", content: USER_ASK }],\n", + " tools: [generateImageTool],\n", + " tool_choice: \"auto\",\n", + "});\n", + "const toolCall = assertToolCall(\"hop1\", hop1, \"generate_image\");\n", + "console.log(\"🧾 hop1 receipt:\", JSON.stringify(hop1.receipt, null, 2));\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "🔧 tool executed (stub): {\"status\":\"ok\",\"network\":\"eip155:84532\"}\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "📡 status=200 elapsed=3759ms\n", + "✅ hop2: OpenAI reply received (115 chars), usage present\n", + "🧾 hop2 receipt: {\n", + " \"success\": true,\n", + " \"payer\": \"0x553179556fc2a39e535d65b921e01fa995e79101\",\n", + " \"transaction\": \"\",\n", + " \"network\": \"eip155:84532\",\n", + " \"amount\": \"\",\n", + " \"extra\": {\n", + " \"channelState\": {\n", + " \"channelId\": \"0x1746f66a4d05837a1cf41e4d80f032ff51e15f4668a2863ea591458932a1db97\",\n", + " \"balance\": \"400216\",\n", + " \"totalClaimed\": \"216\",\n", + " \"withdrawRequestedAt\": 0,\n", + " \"refundNonce\": \"3\",\n", + " \"chargedCumulativeAmount\": \"336\"\n", + " },\n", + " \"chargedAmount\": \"24\"\n", + " }\n", + "}\n" + ] + } + ], + "source": [ + "// Execute the tool. Stubbed here — the compact result shape from imagegen-in-chat-plan_1.md §5.\n", + "// Deliberately no image_url and no token_id: the frontend renders the image from its own state, and\n", + "// telling the model about an NFT it must not mention is how it ends up congratulating someone on a\n", + "// token that does not exist.\n", + "const toolResult = { status: \"ok\", network: NETWORK };\n", + "console.log(\"🔧 tool executed (stub):\", JSON.stringify(toolResult));\n", + "\n", + "// Replay the conversation: the original ask, the assistant's tool-call turn *verbatim* (content:\n", + "// null and all), then the result keyed by tool_call_id. Sending the assistant turn back unchanged is\n", + "// what the handler's widened message check had to allow.\n", + "const convo = [\n", + " { role: \"user\", content: USER_ASK },\n", + " hop1.body.choices[0].message,\n", + " { role: \"tool\", tool_call_id: toolCall.id, content: JSON.stringify(toolResult) },\n", + "];\n", + "\n", + "const hop2 = await sendChatBody({\n", + " model: \"mistral-large-latest\",\n", + " messages: convo,\n", + " tools: [generateImageTool],\n", + " tool_choice: \"auto\",\n", + "});\n", + "\n", + "// A normal text turn closes the loop, so assertOk (defined in the channel-reuse section) fits.\n", + "assertOk(\"hop2\", hop2);\n", + "const hop2Finish = hop2.body.choices[0].finish_reason;\n", + "if (hop2Finish !== \"stop\") {\n", + " // Not fatal: a real model may legitimately decide to call the tool again. Worth seeing, though.\n", + " console.log(`ℹ️ hop2 finish_reason = ${hop2Finish} (expected \"stop\")`);\n", + "}\n", + "console.log(\"🧾 hop2 receipt:\", JSON.stringify(hop2.receipt, null, 2));\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Findings (observed 2026-07-16, real run against local server + real Base Sepolia facilitator)\n", + "\n", + "- [x] Message #1: no fresh deposit tx this run (`transaction: \"\"`) — this run reused a channel already\n", + " open from earlier testing (persisted in Deno's `localStorage`), not a brand-new one.\n", + " `chargedCumulativeAmount` started at `\"2840\"` (= 2× the per-message price) rather than `\"1420\"`,\n", + " confirming the channel already carried state from a prior session.\n", + "- [x] `chargedCumulativeAmount` progressed correctly across all three messages: `2840 → 4260 → 5680`\n", + " — a consistent `+1420` per message, matching `USDC_PRICE_PER_MESSAGE`\n", + " (`convertTokensToUsdcCost(LLM_ESTIMATED_TOKENS_PER_MESSAGE)`). No `invalid_batch_settlement_evm_cumulative_amount_mismatch`\n", + " anywhere in the run — both the client-side `readContract` fix and the server-side\n", + " `createPaymentRequiredResponse` enrichment fix hold up end-to-end.\n", + "- [x] Messages #2/#3: `fetchWithPayment` **did** reuse the channel automatically — no second deposit,\n", + " no extra 402 round-trip visible in the logs beyond the corrective flow. Message #2's settlement\n", + " receipt included a real on-chain transaction hash (`0x0b2e1093…`), confirming an actual claim/settle\n", + " landed on Base Sepolia (`balance` jumped from `\"0\"` to `\"21300\"`); message #3 was a pure off-chain\n", + " voucher (`transaction: \"\"`, `balance` unchanged).\n", + "- [x] No `useX402Chat.ts` workaround needed for channel reuse — the default `fetchWithPayment` +\n", + " `WebStorageClientChannelStorage` behavior already does the right thing automatically.\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Tool calling — findings\n", + "\n", + "Two different questions, and only the first is free to answer:\n", + "\n", + "**Structural — answered on testnet, against the tool-aware mock.** Everything the *endpoint* does\n", + "around a tool call:\n", + "\n", + "- [ ] hop1 came back `finish_reason: \"tool_calls\"` with `content: null` and a `generate_image` call.\n", + "- [ ] `tools` / `tool_choice` reached the upstream at all — they are declared in the request schema\n", + " *and* forwarded (`FORWARDED_OWN_KEYS`), unlike every other declared key, which is stripped.\n", + "- [ ] hop2's `role: \"tool\"` message and the replayed null-content assistant turn passed request\n", + " validation.\n", + "- [ ] Both hops settled as ordinary metered vouchers on the **same** channel — no new deposit, no\n", + " tool-specific path in verify/settle. Record the `chargedCumulativeAmount` progression.\n", + "\n", + "**Upstream contract — needs `USE_MAINNET = true`.** What the mock cannot prove:\n", + "\n", + "- [ ] *Real Mistral* returns `finish_reason: \"tool_calls\"` with `content: null` and a `tool_calls`\n", + " array, i.e. the shape `UpstreamChatCompletionSchema` was loosened for is the shape that\n", + " actually arrives.\n", + "- [ ] `tool_calls` survives `callLLMAPI`'s field-by-field envelope rebuild on a real payload.\n", + "- [ ] Record the `arguments` JSON the real model wrote — the mock always sends `\"{}\"`.\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Deno", + "language": "typescript", + "name": "deno" + }, + "language_info": { + "codemirror_mode": "typescript", + "file_extension": ".ts", + "mimetype": "text/x.typescript", + "name": "typescript", + "nbconvert_exporter": "script", + "pygments_lexer": "typescript", + "version": "6.0.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/bunny_js/package.json b/bunny_js/package.json index dd9190707..42bcabae0 100644 --- a/bunny_js/package.json +++ b/bunny_js/package.json @@ -4,7 +4,7 @@ "description": "Bunny Edge Script twins of the scw_js x402 sellers. First port: searchapi (web-agent).", "type": "module", "scripts": { - "build": "tsdown", + "build": "npm run build:searchapi && npm run build:llm && npm run build:mcp", "test": "NODE_ENV=test vitest run", "test:watch": "NODE_ENV=test vitest", "test:coverage": "NODE_ENV=test vitest run --coverage", @@ -13,8 +13,14 @@ "format": "prettier --write .", "format:check": "prettier --check .", "check": "tsc --noEmit && npm run lint && npm run format:check && npm run test", - "dev": "deno run -A src/index.ts", - "deploy": "npm run build && bunny scripts deploy dist/index.js" + "dev": "npm run dev:searchapi", + "deploy": "npm run build && bunny scripts deploy dist/searchapi.js", + "build:searchapi": "tsdown src/searchapi.ts", + "build:llm": "tsdown src/llm.ts", + "build:mcp": "tsdown src/mcp.ts", + "dev:searchapi": "deno run -A --sloppy-imports --env-file=.env src/searchapi.ts", + "dev:llm": "deno run -A --sloppy-imports --env-file=.env src/llm.ts", + "dev:mcp": "deno run -A --sloppy-imports --env-file=.env src/mcp.ts" }, "keywords": [ "bunny.net", diff --git a/bunny_js/src/llm.ts b/bunny_js/src/llm.ts new file mode 100644 index 000000000..37db4da13 --- /dev/null +++ b/bunny_js/src/llm.ts @@ -0,0 +1,12 @@ +/** + * The llmx402 twin's entry — the AI Assistant chat seller (OpenAI chat-completions shape, + * paid via x402 batch-settlement), trialed alongside the searchapi twin. + * + * The runtime shims must import first: the ported scw_js code assumes the Node `Buffer` and + * `process` globals, which bunny's Deno isolate does not guarantee. + */ +import "./runtime-shims.js"; +import * as BunnySDK from "@bunny.net/edgescript-sdk"; +import { handleLLMRequest } from "./llm_routes.js"; + +BunnySDK.net.http.serve(handleLLMRequest); diff --git a/bunny_js/src/llm_routes.ts b/bunny_js/src/llm_routes.ts new file mode 100644 index 000000000..d3024fb02 --- /dev/null +++ b/bunny_js/src/llm_routes.ts @@ -0,0 +1,468 @@ +/** + * The llmx402 seller's routing, natively against web-standard Request/Response — the bunny + * twin of scw_js/sc_llm_x402.ts. The payment and service layers underneath are ported + * verbatim; this file mirrors the Scaleway handler's route table, ordering rules, and the + * payment/settlement comments (challenge-before-validation, ceiling-then-usage settle, + * testnet-mock gate), with THREE deliberate deviations: + * + * 1. **The 40-second completion cap.** bunny's edge kills a request at ~59 s wall clock + * (measured 2026-10-18, 504 at 59.2 s), so the twin races the upstream call at + * LLM_COMPLETION_BOUND_SECONDS = 40. If the edge ever killed an in-flight completion, + * the client would get a 504 while verify-settle never finished — an orphaned channel + * lock; answering a timeout BEFORE the ceiling (lock released, nothing settled) is the + * only safe shape. The ADVERTISED maxTimeoutSeconds stays 120, scw parity + * (LLM_MAX_TIMEOUT_SECONDS, imported from the ported x402_server.js): the SDK derives + * the EIP-3009 deposit's validBefore from it (now + maxTimeoutSeconds), and that window + * must cover signing → transit → verify → the upstream call (up to 40 s) → settle + * broadcast → inclusion. 40 s was measured too tight on a real Base-mainnet USDC + * deposit — the facilitator's deposit() reverted with "FiatTokenV2: authorization is + * expired" (2026-10-20). maxTimeoutSeconds is per-route, not part of channelId, so + * channels funded against the scw chat's 120 s advertisement remain usable here. + * 2. **The upstream call is raced.** scw_js's callLLMAPI has no timeout of its own — the + * Scaleway platform ceiling bounded it. This router adds the race explicitly. + * 3. **openapi.json is served with the live price ceiling** — identical to scw (kept, since + * it is behavior, not platform). + */ +import { callLLMAPI, resolveModel, advertisedModelIds, type LLMMessage } from "./llm_service.js"; +import { parseJsonBody, CORS_HEADERS } from "./utils.js"; +import { z } from "zod"; +import { + LLMChatRequestSchema, + LLMChatMessageSchema, + LLMToolsSchema, + REJECTED_PARAMS, + FORWARDED_OWN_KEYS, + MAX_MESSAGES_BYTES, + MAX_PRICE_ATOMIC, + getSettleAmount, +} from "./llm_schemas.js"; +import { isTestnet } from "@fretchen/chain-utils"; +import { logger } from "./logger.js"; +import { offeredStablecoins, resolvePaidStablecoin } from "./stablecoin_pricing.js"; +import { toResponse } from "./response.js"; +import { + createLLMResourceServer, + createBatchSettlementPaymentRequirements, + create402Response, + extractPaymentPayload, + createSettlementHeaders, + getBatchSettlementNetworks, + LLM_MAX_TIMEOUT_SECONDS, + formatUsdcAtomicAsDecimalUsd, + releaseLock, + type SdkPaymentPayload, + type SdkPaymentRequirements, +} from "./x402_server.js"; +import { FAVICON_DISCOVERY_HTML, wantsHtml } from "./discovery.js"; +import { AGENT_REGISTRATION_PATH, buildAgentRegistration } from "./agent_registration.js"; +import { faviconBase64, faviconContentType } from "./favicon.js"; +import { buildLlmBazaarExtension } from "./bazaar_discovery.js"; +import openapiSpec from "./openapi.llm.json" with { type: "json" }; + +/** + * The completion bound for the twin — see the header's deviation 1. Bounds the upstream + * race only; it is never advertised or verified against. + */ +export const LLM_COMPLETION_BOUND_SECONDS = 40; + +/** The favicon as raw bytes — web Response bodies are bytes, not Scaleway base64 strings. */ +const FAVICON_BYTES = Uint8Array.from(atob(faviconBase64), (c) => c.charCodeAt(0)); + +function jsonError(statusCode: number, error: string): Response { + return new Response(JSON.stringify({ error }), { status: statusCode, headers: CORS_HEADERS }); +} + +/** + * OpenAI-shaped error body ({ error: { message, type, code } }) — same contract as scw_js's + * openAiError, so callers reusing OpenAI response types parse our errors too. + */ +function openAiError( + statusCode: number, + message: string, + type: string, + code: string | null = null, + param?: string, +): Response { + return new Response(JSON.stringify({ error: { message, type, code, ...(param && { param }) } }), { + status: statusCode, + headers: CORS_HEADERS, + }); +} + +function isHexAddress(addr: unknown): addr is `0x${string}` { + return typeof addr === "string" && /^0x[a-fA-F0-9]{40}$/.test(addr); +} + +// The request fields this endpoint consumes rather than passes on — derived from the +// schema's own shape rather than a hand-written list, exactly as in scw_js (see there). +const STRIPPED_REQUEST_KEYS = new Set( + Object.keys(LLMChatRequestSchema.shape).filter((key) => !FORWARDED_OWN_KEYS.has(key)), +); + +export async function handleLLMRequest(request: Request): Promise { + if (request.method === "OPTIONS") { + return new Response(null, { status: 200, headers: CORS_HEADERS }); + } + + const method = request.method; + const path = (new URL(request.url).pathname ?? "").replace(/^\/+/, ""); + const headers = Object.fromEntries(request.headers) as Record; + const isGetOrHead = method === "GET" || method === "HEAD"; + const isHead = method === "HEAD"; + + if (isGetOrHead && path === AGENT_REGISTRATION_PATH) { + return new Response( + isHead + ? null + : JSON.stringify( + buildAgentRegistration(openapiSpec, "llm", process.env.NFT_WALLET_PUBLIC_KEY), + ), + { status: 200, headers: { ...CORS_HEADERS, "Content-Type": "application/json" } }, + ); + } + + if (method === "GET" && path === "openapi.json") { + // The static file's x-payment-info.price.max is a documentation-only baseline — + // MAX_PRICE_ATOMIC is the real, live ceiling, so override it here rather than let the + // served discovery doc silently drift from the actual runtime 402 behavior (scw parity). + // DEVIATION from scw: the clone is a JSON round-trip, not structuredClone — the spec is + // a JSON document (lossless by construction), and structuredClone is a Node-global the + // bunny Deno 1.x isolate has not been verified to expose. + const spec = JSON.parse(JSON.stringify(openapiSpec)) as typeof openapiSpec; + spec.paths["/"].post["x-payment-info"].price.max = formatUsdcAtomicAsDecimalUsd( + MAX_PRICE_ATOMIC.USDC, + ); + return new Response(JSON.stringify(spec), { + status: 200, + headers: { ...CORS_HEADERS, "Content-Type": "application/json" }, + }); + } + + if (isGetOrHead && path === "favicon.png") { + return new Response(isHead ? null : FAVICON_BYTES, { + status: 200, + headers: { ...CORS_HEADERS, "Content-Type": faviconContentType }, + }); + } + + if (isGetOrHead && path === "" && wantsHtml(headers)) { + return new Response(isHead ? null : FAVICON_DISCOVERY_HTML, { + status: 200, + headers: { ...CORS_HEADERS, "Content-Type": "text/html; charset=utf-8" }, + }); + } + + if (method !== "POST") { + return jsonError(400, "Only POST requests are supported"); + } + + const body = parseJsonBody(await request.text()); + if (!body) { + return openAiError(400, "Invalid JSON body", "invalid_request_error"); + } + + // ─── Payment challenge comes BEFORE request validation ─── (scw parity: an unpaid request + // is answered with the 402 whatever its body says — the llm/v1 contract; a paid request + // still runs the full validation below before any voucher is verified or settled.) + const receiverAddressForChallenge = process.env.NFT_WALLET_PUBLIC_KEY; + if (!receiverAddressForChallenge || !isHexAddress(receiverAddressForChallenge)) { + return jsonError( + 500, + "Service provider address not configured or invalid. Set NFT_WALLET_PUBLIC_KEY to a 0x-prefixed 40-hex-char address.", + ); + } + const paymentPayloadEarly = extractPaymentPayload(headers) ?? body["payment"]; + if (!paymentPayloadEarly) { + logger.info("No payment provided, returning 402"); + let challengeScheme: ReturnType["scheme"]; + try { + ({ scheme: challengeScheme } = createLLMResourceServer(receiverAddressForChallenge)); + } catch (err) { + logger.error({ err }, "Failed to configure batch-settlement resource server"); + return jsonError(500, "Server configuration error"); + } + const paymentRequirements = await createBatchSettlementPaymentRequirements({ + // Absolute, from the committed spec's server URL — bazaar validation rejects relative. + resourceUrl: `${openapiSpec.servers[0].url}/`, + description: "AI Assistant chat message", + mimeType: "application/json", + price: MAX_PRICE_ATOMIC, + payTo: receiverAddressForChallenge, + scheme: challengeScheme, + networks: getBatchSettlementNetworks(), + maxTimeoutSeconds: LLM_MAX_TIMEOUT_SECONDS, + extensions: buildLlmBazaarExtension(), + serviceName: "Fretchen AI Assistant", + tags: ["llm", "chat", "x402"], + iconUrl: `${openapiSpec.servers[0].url}/favicon.png`, + }); + return toResponse(create402Response(paymentRequirements)); + } + + // OpenAI chat-completions request body — everything we do not consume is FORWARDED to the + // upstream model (Mistral owns the chat-param contract). The rejected list below covers + // exactly the params that move cost past the fixed per-message ceiling (scw parity; the + // list lives in llm_schemas.ts so the published 400s are built from the same entries). + for (const param of REJECTED_PARAMS) { + if (param.predicate(body[param.name])) { + return openAiError(400, param.message, "invalid_request_error", param.code, param.errorParam); + } + } + + // Capped rather than refused, like scw: tool definitions are input tokens on every hop; + // validated with the published schema so the caps a caller reads are the caps enforced. + if (body["tools"] !== undefined) { + const tools = LLMToolsSchema.safeParse(body["tools"]); + if (!tools.success) { + return openAiError( + 400, + `Invalid 'tools': ${z.prettifyError(tools.error)}`, + "invalid_request_error", + "unsupported_value", + "tools", + ); + } + } + + const toolChoice = body["tool_choice"]; + if (toolChoice !== undefined && toolChoice !== "auto" && toolChoice !== "none") { + return openAiError( + 400, + 'Only tool_choice "auto" or "none" is supported by this endpoint; a forced or named choice is not.', + "invalid_request_error", + "unsupported_value", + "tool_choice", + ); + } + + const messages = body["messages"]; + if (!Array.isArray(messages) || messages.length === 0) { + return openAiError(400, "'messages' must be a non-empty array.", "invalid_request_error"); + } + // Capped before verifyPayment: an over-limit request must be rejected without being charged. + const messagesBytes = Buffer.byteLength(JSON.stringify(messages), "utf8"); + if (messagesBytes > MAX_MESSAGES_BYTES) { + return openAiError( + 400, + `'messages' must serialize to at most ${MAX_MESSAGES_BYTES} bytes (received ${messagesBytes}). Each message is metered against a fixed per-message price ceiling, which a larger conversation would exceed.`, + "invalid_request_error", + "unsupported_value", + "messages", + ); + } + const parsedMessages = z.array(LLMChatMessageSchema).min(1).safeParse(messages); + if (!parsedMessages.success) { + return openAiError( + 400, + `Invalid 'messages': ${z.prettifyError(parsedMessages.error)}`, + "invalid_request_error", + ); + } + const prompt = parsedMessages.data as LLMMessage[]; + + const requestedModel = body["model"]; + if (typeof requestedModel !== "string" || !requestedModel) { + return openAiError(400, "'model' is required.", "invalid_request_error"); + } + const resolved = resolveModel(requestedModel); + if (!resolved) { + return openAiError( + 404, + `The model '${requestedModel}' does not exist or is not served by this endpoint. Available: ${advertisedModelIds().join(", ")}.`, + "invalid_request_error", + "model_not_found", + ); + } + + // `useDummyData` is the vendor extension (testnet mock control) — deliberately left + // undefined when absent, so the testnet reject check can distinguish "not sent". + let useDummyData: boolean | undefined; + if (body["useDummyData"] !== undefined) { + if (typeof body["useDummyData"] !== "boolean") { + return openAiError(400, "Invalid useDummyData flag.", "invalid_request_error"); + } + useDummyData = body["useDummyData"] as boolean; + } + + const receiverAddress = process.env.NFT_WALLET_PUBLIC_KEY; + if (!receiverAddress || !isHexAddress(receiverAddress)) { + return jsonError( + 500, + "Service provider address not configured or invalid. Set NFT_WALLET_PUBLIC_KEY to a 0x-prefixed 40-hex-char address.", + ); + } + + let resourceServer: ReturnType["resourceServer"]; + let scheme: ReturnType["scheme"]; + try { + ({ resourceServer, scheme } = createLLMResourceServer(receiverAddress)); + } catch (err) { + logger.error({ err }, "Failed to configure batch-settlement resource server"); + return jsonError(500, "Server configuration error"); + } + + // Non-null: the unpaid case already returned the 402 challenge above, before validation. + const paymentPayload = paymentPayloadEarly; + + const clientNetwork = (paymentPayload as Record)["accepted"] + ? (((paymentPayload as Record)["accepted"] as Record)[ + "network" + ] as string | undefined) + : undefined; + + if (!clientNetwork || !getBatchSettlementNetworks().includes(clientNetwork)) { + return jsonError(402, "Unsupported or missing network for batch-settlement payment"); + } + + // SECURITY: testnet must never reach the real Mistral API (scw parity). An explicit + // `useDummyData: false` on a testnet network is always a caller error — reject loudly. + if (isTestnet(clientNetwork) && useDummyData === false) { + return jsonError( + 400, + "Real inference is not available on testnet — this network always returns a mocked LLM response. Omit useDummyData or set it to true, or use a mainnet network for a real completion.", + ); + } + // Never spend real inference budget on a valueless testnet payment (scw parity). + const useMock = useDummyData === true || isTestnet(clientNetwork); + + // Price the stablecoin the buyer's channel is in, not a fixed one (scw parity). + const accepted = (paymentPayload as Record)["accepted"] as + Record | undefined; + const coin = resolvePaidStablecoin(clientNetwork, accepted?.["asset"]); + if (!coin) { + logger.warn({ clientNetwork, asset: accepted?.["asset"] }, "Payment asset not offered"); + return jsonError( + 402, + `Unsupported payment asset on ${clientNetwork} — offered: ${offeredStablecoins(clientNetwork) + .map((offered) => `${offered.symbol} ${offered.address}`) + .join(", ")}`, + ); + } + const baseRequirements: SdkPaymentRequirements = { + scheme: "batch-settlement", + network: clientNetwork as `${string}:${string}`, + amount: MAX_PRICE_ATOMIC[coin.symbol], + asset: coin.address, + payTo: receiverAddress, + maxTimeoutSeconds: LLM_MAX_TIMEOUT_SECONDS, + extra: { name: coin.name, version: coin.version }, + }; + // Must be the SAME enhanced requirements the client signed its channelConfig against — + // a raw object here makes the facilitator reject every deposit with + // receiver_authorizer_mismatch (scw parity; confirmed via a real Base Sepolia run). + const paymentRequirements = await scheme.enhancePaymentRequirements( + baseRequirements, + { + x402Version: 2, + scheme: "batch-settlement", + network: clientNetwork as `${string}:${string}`, + extra: baseRequirements.extra, + }, + [], + ); + + let verification: { + isValid: boolean; + invalidReason?: string; + invalidMessage?: string; + payer?: string; + }; + try { + verification = await resourceServer.verifyPayment( + paymentPayload as SdkPaymentPayload, + paymentRequirements, + ); + } catch (error) { + logger.error({ err: error }, "Payment verification error"); + return jsonError(402, `Payment verification failed: ${(error as Error).message}`); + } + + if (!verification.isValid) { + // Through createPaymentRequiredResponse (not a hand-rolled body) so the SDK's corrective + // enrichment runs — channelState/voucherState for cumulative mismatches, which the client + // SDK's processCorrectivePaymentRequired needs to resync and retry (scw parity). + logger.warn( + { reason: verification.invalidReason, message: verification.invalidMessage }, + "Payment verification failed", + ); + const paymentRequired = await resourceServer.createPaymentRequiredResponse( + [paymentRequirements], + { + url: `${openapiSpec.servers[0].url}/`, + description: "AI Assistant chat message", + mimeType: "application/json", + }, + verification.invalidReason, + verification.payer ? { payer: verification.payer } : undefined, + undefined, + paymentPayload as SdkPaymentPayload, + ); + return toResponse(create402Response(paymentRequired)); + } + + let llmData: Awaited>; + try { + if (useMock) { + logger.info({ network: clientNetwork }, "Using mock LLM response (test mode)"); + } + logger.debug({ prompt }, "Generating answer for prompt"); + // Forward everything we do not consume (scw parity; STRIPPED_REQUEST_KEYS is derived + // from the schema, keeping this self-maintaining). + const forwardedParams = Object.fromEntries( + Object.entries(body).filter(([key]) => !STRIPPED_REQUEST_KEYS.has(key)), + ); + // DEVIATION from scw: race the upstream call at the twin's 40 s cap — bunny's edge + // kills the request at ~59 s wall clock, and an in-flight kill would strand the + // channel lock. callLLMAPI itself is unbounded (the scw platform ceiling bound it). + llmData = await Promise.race([ + callLLMAPI(prompt, useMock, resolved.provider, forwardedParams), + new Promise((_resolve, reject) => + setTimeout( + () => + reject( + new Error( + `LLM completion exceeded the twin's ${LLM_COMPLETION_BOUND_SECONDS} s bound (the bunny edge kills requests at ~59 s)`, + ), + ), + LLM_COMPLETION_BOUND_SECONDS * 1000, + ), + ), + ]); + } catch (error) { + logger.error({ err: error }, "Error during answer generation"); + // Verified but never settled: release the channel lock, or the next message waits out + // LLM_MAX_TIMEOUT_SECONDS (the lock TTL) behind `channel_busy` for an answer that was + // never charged. + await releaseLock(resourceServer, paymentPayload as SdkPaymentPayload, paymentRequirements); + return jsonError(500, (error as Error).message); + } + + // Settling with a DIFFERENT (smaller, real-usage) amount than verifyPayment() used is + // intentional (scw parity — see getSettleAmount() in llm_schemas.ts): handleBeforeSettle + // only checks against the voucher's signed ceiling, not against what verify saw. + const settleAmount = getSettleAmount(llmData.usage, coin.symbol); + const settleRequirements = { ...paymentRequirements, amount: settleAmount }; + + try { + const settlement = await resourceServer.settlePayment( + paymentPayload as SdkPaymentPayload, + settleRequirements, + ); + if (!settlement.success) { + logger.error({ settlement }, "Settlement failed"); + return jsonError(402, `Settlement failed: ${settlement.errorReason ?? "unknown"}`); + } + + logger.info( + { usage: llmData.usage, model: llmData.model, settleAmount }, + "Answer generated and settled", + ); + return new Response(JSON.stringify(llmData), { + status: 200, + headers: { ...CORS_HEADERS, ...createSettlementHeaders(settlement) }, + }); + } catch (error) { + logger.error({ err: error }, "Settlement error"); + return jsonError(402, `Settlement failed: ${(error as Error).message}`); + } +} diff --git a/bunny_js/src/mcp.ts b/bunny_js/src/mcp.ts new file mode 100644 index 000000000..d7a159987 --- /dev/null +++ b/bunny_js/src/mcp.ts @@ -0,0 +1,12 @@ +/** + * The MCP twin's entry — stateless JSON-RPC over POST, relaying tools/call in-process to the + * searchapi and llm twins. The twin that replaces the scw mcp function's 26-27 s cold start. + * + * The runtime shims must import first: the ported scw_js code assumes the Node `Buffer` and + * `process` globals, which bunny's Deno isolate does not guarantee. + */ +import "./runtime-shims.js"; +import * as BunnySDK from "@bunny.net/edgescript-sdk"; +import { handleMCPRequest } from "./mcp_routes.js"; + +BunnySDK.net.http.serve(handleMCPRequest); diff --git a/bunny_js/src/mcp_routes.ts b/bunny_js/src/mcp_routes.ts new file mode 100644 index 000000000..81f79d39d --- /dev/null +++ b/bunny_js/src/mcp_routes.ts @@ -0,0 +1,261 @@ +/** + * Stateless MCP endpoint for the twin sellers: JSON-RPC 2.0 over a single POST route + * (Streamable HTTP transport, JSON-response mode) — the bunny port of scw_js/mcp_server.ts. + * Same dispatch surface (everything else -32601), same 202-for-notifications, same + * no-sessions/no-streams design, with one DECIDED deviation from the scw original: + * + * GET is answered like a normal origin instead of the scw function's blanket 405. The scw + * original 405s every GET ("stateless server, the spec allows it"), which generic scanners + * (x402scan-style icon resolution, plain health checks) read as a dead origin — the root + * HTML and /favicon.png now resolve, and paths with no content answer 404, the honest + * "not here" rather than "method broken". What is deliberately NOT served: + * /.well-known/agent-registration.json and /openapi.json — this origin holds no ERC-8004 + * identity and no REST spec of its own (it relays to the twins that have both), so serving + * either would fabricate identity. DELETE stays 405 — session termination is the one thing + * this stateless server genuinely does not do, per the MCP spec's session-less allowance. + */ +import { CORS_HEADERS } from "./utils.js"; +import { FAVICON_DISCOVERY_HTML, wantsHtml } from "./discovery.js"; +import { faviconBase64, faviconContentType } from "./favicon.js"; +import { MCP_TOOLS } from "./mcp_tools.js"; + +/** The latest protocol revision this server implements. */ +const DEFAULT_PROTOCOL_VERSION = "2025-06-18"; +/** Revisions a client may request and be answered in kind; anything else gets the default. */ +const SUPPORTED_PROTOCOL_VERSIONS = new Set(["2025-06-18", "2025-03-26"]); + +export const SERVER_INFO = { name: "fretchen-mcp", version: "1.0.0" } as const; + +export type JsonRpcResponse = + | { jsonrpc: "2.0"; id: unknown; result: Record } + | { jsonrpc: "2.0"; id: unknown; error: { code: number; message: string; data?: unknown } }; + +function jsonRpcResult(id: unknown, result: Record): Response { + return new Response(JSON.stringify({ jsonrpc: "2.0", id, result }), { + status: 200, + headers: { ...CORS_HEADERS }, + }); +} + +function jsonRpcError(id: unknown, code: number, message: string, data?: unknown): Response { + return new Response( + JSON.stringify({ + jsonrpc: "2.0", + id, + error: { code, message, ...(data !== undefined ? { data } : {}) }, + }), + { status: 200, headers: { ...CORS_HEADERS } }, + ); +} + +/** 202 for notifications: no id means no response object may be sent, only the status. */ +function accepted(): Response { + return new Response(null, { status: 202, headers: { ...CORS_HEADERS } }); +} + +interface JsonRpcMessage { + jsonrpc: "2.0"; + id?: unknown; + method: string; + params?: Record; +} + +function parseMessage(rawBody: string): JsonRpcMessage | "parse-error" | "invalid" { + let message: unknown; + try { + message = JSON.parse(rawBody); + } catch { + return "parse-error"; + } + if ( + message === null || + typeof message !== "object" || + Array.isArray(message) || // a batch — this server speaks single messages only + (message as { jsonrpc?: unknown }).jsonrpc !== "2.0" || + typeof (message as { method?: unknown }).method !== "string" + ) { + return "invalid"; + } + return message as JsonRpcMessage; +} + +/** + * Cap on one JSON-RPC message — bounds the wrapper itself. The tools' handlers impose their + * own, smaller caps on the fields they charge for (the llm router meters messages at + * MAX_MESSAGES_BYTES = 192 KB); this only stops an oversized envelope from being fully + * materialized in the 128 MB isolate. 1 MB is generous headroom over the message cap plus + * the JSON-RPC envelope and the payment _meta. + */ +const MAX_RPC_BYTES = 1_000_000; + +/** + * Reads the POST body up to MAX_RPC_BYTES and refuses beyond it, so an oversized body is + * rejected without ever being fully buffered. The Content-Length check refuses a declared + * oversize up front; the stream read catches a chunked body that lies about or omits it. + * (Deviation from scw: the Scaleway function leaned on the platform's request-size cap; the + * edge imposes no comparable one, so the twin encodes its own.) + */ +async function readCappedBody(request: Request): Promise { + const declared = Number(request.headers.get("content-length")); + if (Number.isFinite(declared) && declared > MAX_RPC_BYTES) { + return "too-large"; + } + if (!request.body) { + return ""; + } + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + total += value.byteLength; + if (total > MAX_RPC_BYTES) { + await reader.cancel(); + return "too-large"; + } + chunks.push(value); + } + const body = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder().decode(body); +} + +async function dispatch( + method: string, + id: unknown, + params: Record | undefined, +): Promise { + switch (method) { + case "initialize": { + const requested = typeof params?.protocolVersion === "string" ? params.protocolVersion : ""; + const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.has(requested) + ? requested + : DEFAULT_PROTOCOL_VERSION; + return jsonRpcResult(id, { + protocolVersion, + capabilities: { tools: {} }, + serverInfo: { ...SERVER_INFO }, + }); + } + case "tools/list": { + return jsonRpcResult(id, { + tools: MCP_TOOLS.map(({ name, description, inputSchema }) => ({ + name, + description, + inputSchema, + })), + }); + } + case "tools/call": { + const name = typeof params?.name === "string" ? params.name : ""; + const tool = MCP_TOOLS.find((t) => t.name === name); + if (!tool) { + return jsonRpcError(id, -32602, `Unknown tool '${name}'`, { + available: MCP_TOOLS.map((t) => t.name), + }); + } + const args = (params?.arguments ?? {}) as Record; + const meta = (params?._meta ?? undefined) as Record | undefined; + // The tool relays to the real handler, which owns the unpaid path: its 402 comes + // back as a tool result with isError + the payment demands in structuredContent — + // a valid tool result, not a JSON-RPC error, and passes through. + const result = await tool.call(args, meta); + return jsonRpcResult(id, result); + } + default: + return jsonRpcError(id, -32601, `Method not found: ${method}`); + } +} + +export async function handleMCPRequest(request: Request): Promise { + if (request.method === "OPTIONS") { + return new Response(null, { status: 200, headers: { ...CORS_HEADERS } }); + } + + const method = request.method; + const path = (new URL(request.url).pathname ?? "").replace(/^\/+/, ""); + + // The DECIDED deviation (see the header comment): GET answers like a normal origin — + // root HTML + favicon resolve for crawlers, everything else is an honest 404 — instead + // of the scw original's blanket 405. + if (method === "GET" || method === "HEAD") { + const isHead = method === "HEAD"; + if (path === "" && wantsHtml(Object.fromEntries(request.headers))) { + return new Response(isHead ? null : FAVICON_DISCOVERY_HTML, { + status: 200, + headers: { ...CORS_HEADERS, "Content-Type": "text/html; charset=utf-8" }, + }); + } + if (path === "favicon.png") { + const bytes = Uint8Array.from(atob(faviconBase64), (c) => c.charCodeAt(0)); + return new Response(isHead ? null : bytes, { + status: 200, + headers: { ...CORS_HEADERS, "Content-Type": faviconContentType }, + }); + } + return new Response( + JSON.stringify({ + error: + "This MCP server is stateless and serves JSON-RPC 2.0 over POST only. No sessions, no server-initiated streams; this origin holds no registration document or REST spec of its own.", + }), + { status: 404, headers: { ...CORS_HEADERS } }, + ); + } + + // Stateless Streamable HTTP: DELETE (session termination) has nothing to do without a + // session; 405 is the spec-legal answer and the one behavior scw parity keeps. + if (method === "DELETE") { + return new Response( + JSON.stringify({ + error: + "Only POST is supported for JSON-RPC. This MCP server is stateless: no sessions to terminate.", + }), + { status: 405, headers: { ...CORS_HEADERS, Allow: "POST" } }, + ); + } + + if (method !== "POST") { + return new Response(JSON.stringify({ error: "Only POST is supported." }), { + status: 405, + headers: { ...CORS_HEADERS, Allow: "POST" }, + }); + } + + // The body is read capped (see readCappedBody): an oversized message is refused with 413 + // before anything is parsed, locked, or paid for. + const rawBody = await readCappedBody(request); + if (rawBody === "too-large") { + return new Response(JSON.stringify({ error: `Request body exceeds 1 MB.` }), { + status: 413, + headers: { ...CORS_HEADERS }, + }); + } + + const message = parseMessage(rawBody); + if (message === "parse-error") { + return jsonRpcError(null, -32700, "Parse error"); + } + if (message === "invalid") { + return jsonRpcError(null, -32600, "Invalid Request: expected a single JSON-RPC 2.0 message"); + } + + // A notification (no id) is answered with 202 only — whatever its method says. + if (message.id === undefined) { + return accepted(); + } + + try { + return await dispatch(message.method, message.id, message.params); + } catch (err) { + // Tool setup failure (missing wallet env) or an unexpected tool crash — protocol-level, + // not a tool-level error, so it is a JSON-RPC error rather than an isError result. + return jsonRpcError(message.id, -32603, `Internal error: ${(err as Error).message}`); + } +} diff --git a/bunny_js/src/mcp_tools.ts b/bunny_js/src/mcp_tools.ts new file mode 100644 index 000000000..2ae12d789 --- /dev/null +++ b/bunny_js/src/mcp_tools.ts @@ -0,0 +1,133 @@ +/** + * The MCP tools: a pure translation layer over the twin's REAL handlers — the bunny port of + * scw_js/mcp_tools.ts. No payment, pricing, or cost policy lives here; each tool maps an MCP + * `tools/call` onto an in-process invocation of the twin's native routers with a synthetic + * Request, so the MCP surface serves the same code path, prices, and channels as the REST + * twins and cannot drift from them (scw parity: MCP wraps the API; it does not reimplement). + * + * Payment rides the handlers' native protocol, translated both ways (scw parity): + * client `_meta["x402/payment"]` -> base64 `payment-signature` header (what every + * REST buyer sends; the handler decodes it with + * its own extractPaymentPayload) + * handler 402 (body IS the PaymentRequired JSON) -> MCP payment-required result (isError + + * structuredContent) — the official client's + * extractPaymentRequiredFromResult form + * handler `Payment-Response` header -> settlement receipt in + * `_meta["x402/payment-response"]` + */ +import { z } from "zod"; +import { SearchQuerySchema, DESCRIPTION } from "./search_schemas.js"; +import { LLMChatRequestSchema } from "./llm_schemas.js"; +import { handleRequest } from "./searchapi_routes.js"; +import { handleLLMRequest } from "./llm_routes.js"; + +/** + * The two meta keys the official x402 MCP client puts in `_meta`. Inlined as literals + * rather than imported from `@x402/mcp`: that package's only export is its root entry, + * which pulls the full `@modelcontextprotocol/sdk` (and its top-level `import "ajv"` + * side effects — unresolved in the edge bundle, a load-time crash). These two strings are + * the entire surface the twin uses from that package (values verified against + * @x402/mcp 2.28.0 dist; the mcp test's paymentMeta fixture pins them behaviorally). + */ +const MCP_PAYMENT_META_KEY = "x402/payment"; +const MCP_PAYMENT_RESPONSE_META_KEY = "x402/payment-response"; + +/** What `mcp_routes.ts` dispatches against: one entry per tool. */ +export interface McpTool { + name: string; + description: string; + inputSchema: Record; + /** `meta` is the request's `_meta` — where the x402 payment travels. */ + call( + args: Record, + meta: Record | undefined, + ): Promise>; +} + +/** The client's payment, re-encoded into the header every REST buyer sends. */ +function paymentHeader(meta: Record | undefined): Record { + const payment = meta?.[MCP_PAYMENT_META_KEY]; + if (typeof payment !== "object" || payment === null) { + return {}; + } + return { "payment-signature": Buffer.from(JSON.stringify(payment)).toString("base64") }; +} + +function asObject(json: string): Record | undefined { + try { + const parsed: unknown = JSON.parse(json); + return typeof parsed === "object" && parsed !== null + ? (parsed as Record) + : undefined; + } catch { + return undefined; + } +} + +/** Read the base64 settlement receipt off a native Response, as a JSON object. */ +function receiptFrom(res: Response): Record | undefined { + const header = res.headers.get("payment-response"); + if (!header) { + return undefined; + } + return asObject(Buffer.from(header, "base64").toString("utf-8")); +} + +/** Translate one native handler response into an MCP tool result. */ +async function toToolResult(res: Response): Promise> { + const text = await res.text(); + const structured = asObject(text); + if (res.ok) { + const receipt = receiptFrom(res); + return { + content: [{ type: "text", text }], + ...(structured !== undefined ? { structuredContent: structured } : {}), + ...(receipt !== undefined ? { _meta: { [MCP_PAYMENT_RESPONSE_META_KEY]: receipt } } : {}), + }; + } + // 402: an unpaid challenge (body = PaymentRequired) or the handler's own payment-path + // rejection; anything else is the handler's error response. All three are tool errors — + // the official client extracts the demands from structuredContent when present. + return { + content: [{ type: "text", text }], + ...(structured !== undefined ? { structuredContent: structured } : {}), + isError: true, + }; +} + +export const MCP_TOOLS: McpTool[] = [ + { + name: "web_search", + description: `${DESCRIPTION.search}. Paid per call via x402.`, + inputSchema: z.toJSONSchema(SearchQuerySchema) as Record, + call: async (args, meta) => { + const url = new URL("https://web-agent.fretchen.eu/search"); + // The handler's own parseArgs validates the query; the tool schema only advertises it. + for (const [key, value] of Object.entries(args)) { + url.searchParams.set(key, String(value)); + } + return toToolResult( + await handleRequest(new Request(url, { method: "GET", headers: paymentHeader(meta) })), + ); + }, + }, + { + name: "chat_completion", + description: + "AI Assistant chat completion — the same llm/v1 API as the chat endpoint. Paid per call via x402; testnet calls return a mock completion.", + inputSchema: z.toJSONSchema(LLMChatRequestSchema) as Record, + call: async (args, meta) => { + // "/" is the chat route (openapi.llm.json paths["/"]); the handler's GET discovery + // routes are not reachable through this POST path (scw parity). + return toToolResult( + await handleLLMRequest( + new Request("https://llm-agent.fretchen.eu/", { + method: "POST", + headers: { "Content-Type": "application/json", ...paymentHeader(meta) }, + body: JSON.stringify(args), + }), + ), + ); + }, + }, +]; diff --git a/bunny_js/src/openapi.llm.json b/bunny_js/src/openapi.llm.json new file mode 100644 index 000000000..0ef4f1480 --- /dev/null +++ b/bunny_js/src/openapi.llm.json @@ -0,0 +1,364 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Fretchen AI Assistant (LLM) Service", + "description": "AI chat assistant, paid via x402 batch-settlement payment channels in USDC, EURC or EURe on Base (USDC everywhere; the euro tokens on Base).", + "version": "1.0.0", + "x-guidance": "OpenAI chat-completions body. POST / with { model, messages: [{ role, content }, ...] } and no payment header to receive a 402 with x402 batch-settlement payment requirements (accepts[]). Open/top up a payment channel per the requirements, retry with the payment header, and the service returns a standard OpenAI chat.completion object. Streaming (stream: true) is not supported. Each message is metered and settled up to a per-message price ceiling; the real cost is usage-derived and typically lower. Other standard OpenAI chat params (temperature, top_p, stop, seed, the penalties, response_format) are forwarded to the upstream model and work as normal; they are not enumerated here because the upstream owns that contract. The exceptions are stream, n and max_tokens, which are rejected with a 400 rather than ignored, because each would move cost past the fixed per-message ceiling this endpoint meters against. Tool calling is supported (see x-capabilities): send OpenAI-shaped tools, and a tool call comes back as choices[].message.tool_calls with finish_reason: \"tool_calls\" and content: null. Execute it yourself and send the result back as a role:\"tool\" message with the matching tool_call_id — this endpoint never calls a tool on your behalf, and accepts no callback or MCP server URL. Note: payment uses x402 batch-settlement (a stateful channel), so a stock OpenAI SDK cannot pay this endpoint without batch-settlement client wiring — the OpenAI shape is for body legibility, not drop-in SDK use.", + "contact": { + "name": "fretchen", + "url": "https://www.fretchen.eu", + "email": "fretchen.dev@proton.me" + } + }, + "x-discovery": { + "ownershipProofs": [ + "0x08fd2874a7a85b7250830bf6be396953c108e197739ccc758e373036b2fe78a71ddcaf48183690b5f1d2a0049eb0af93068fb35eaaf59d266168eaeea1df357d1c" + ] + }, + "x-service-type": "llm/v1", + "x-interop-floor": "A compatible llm/v1 agent MUST advertise at least one accepts[] entry with asset USDC on network Optimism (eip155:10) or Base (eip155:8453), scheme batch-settlement. Request/response schema is defined by this document's LLMChatRequest/LLMChatResponse. Tool calling is NOT part of the floor — see x-capabilities; a plain-chat agent that ignores tools is fully conformant. See README.md.", + "x-capabilities": ["tools"], + "servers": [ + { + "url": "https://llm-agent.fretchen.eu" + } + ], + "tags": [ + { + "name": "LLM", + "description": "AI chat assistant / text completion" + }, + { + "name": "Chat", + "description": "Multi-turn conversational messages" + }, + { + "name": "x402", + "description": "Paid via x402 batch-settlement payment channels in USDC, EURC or EURe on Base" + } + ], + "paths": { + "/": { + "post": { + "operationId": "llmX402", + "summary": "Chat with the AI assistant (x402 batch-settlement payment)", + "description": "Sends a prompt to the LLM and returns its response. Payment is settled per message via an x402 batch-settlement payment channel (USDC, EURC or EURe on Base), capped at a per-message price ceiling.", + "tags": ["LLM", "Chat", "x402"], + "security": [], + "x-payment-info": { + "protocols": ["x402"], + "price": { + "mode": "dynamic", + "currency": "USD", + "min": "0", + "max": "0.03" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LLMChatRequest" + } + } + } + }, + "responses": { + "200": { + "description": "LLM response generated and payment settled successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LLMChatResponse" + } + } + } + }, + "400": { + "description": "Request validation failed — a missing or unserved model, an empty messages array, or one of the metered params past its ceiling: stream:true, n other than 1, or any max_tokens. Body is { error: { message, type, code } }." + }, + "402": { + "description": "Payment required. Body/headers (Payment-Required, X-Payment) carry the x402 batch-settlement payment requirements to satisfy and retry with." + } + } + } + }, + "/openapi.json": { + "get": { + "operationId": "openapiSpec", + "summary": "This document", + "tags": ["x402"], + "responses": { + "200": { + "description": "This OpenAPI document.", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "LLMChatRequest": { + "type": "object", + "properties": { + "model": { + "type": "string", + "enum": ["mistral-large-latest"], + "description": "The model to use. Only the advertised model id(s) are served; others return model_not_found." + }, + "messages": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "role": { + "type": "string", + "description": "The speaker's role. Forwarded to the upstream model as sent — deliberately not restricted to the OpenAI \"system\"/\"user\"/\"assistant\" set, so a role the upstream adds later (e.g. \"tool\") works without a schema change, matching how the handler validates it." + }, + "content": { + "description": "The message text. Required except on an assistant turn that carries tool_calls, where it is null.", + "type": ["string", "null"] + }, + "tool_calls": { + "description": "Present when replaying an assistant turn that requested tool calls.", + "anyOf": [ + { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Echo this back as tool_call_id on the matching tool result message." + }, + "type": { + "type": "string", + "const": "function" + }, + "function": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "arguments": { + "type": "string", + "description": "JSON-encoded arguments, as a string." + } + }, + "required": ["name", "arguments"], + "additionalProperties": {} + } + }, + "required": ["id", "type", "function"], + "additionalProperties": {} + } + }, + { + "type": "null" + } + ] + }, + "tool_call_id": { + "description": "On a role:'tool' message, the id of the call this message answers.", + "type": "string" + } + }, + "required": ["role"], + "additionalProperties": {}, + "description": "One turn of the conversation." + }, + "description": "The conversation so far. At most 196608 bytes serialized — conversation turns are input tokens charged on every hop, and this endpoint meters each message against a fixed price ceiling, so an uncapped conversation would be billed far below what it costs to serve." + }, + "stream": { + "description": "Streaming is not supported. Each message settles on its final token usage, which requires the whole completion, so stream:true is rejected rather than silently buffered.", + "type": "boolean", + "const": false + }, + "n": { + "description": "Only n=1 is supported. Each message is metered against a fixed per-message price ceiling, and additional completions multiply output tokens past it.", + "type": "number", + "const": 1 + }, + "max_tokens": { + "description": "Not supported. Output length is bounded by the per-message price ceiling this endpoint meters against, not by a caller-supplied limit.", + "not": {} + }, + "tools": { + "description": "Tool definitions offered to the model, OpenAI shape. At most 8192 bytes serialized — tool definitions are input tokens charged on every hop, so an uncapped array inflates the metered cost. A tool call comes back as choices[].message.tool_calls with finish_reason: \"tool_calls\"; execute it and send the result back as a role:\"tool\" message. This endpoint never calls a tool itself.", + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "function" + }, + "function": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "parameters": { + "type": "object", + "properties": {}, + "additionalProperties": {} + } + }, + "required": ["name"], + "additionalProperties": {} + } + }, + "required": ["type", "function"], + "additionalProperties": {} + } + }, + "tool_choice": { + "description": "Whether the model may call a tool. Only \"auto\" and \"none\" are served; a forced or named choice is not.", + "type": "string", + "enum": ["auto", "none"] + }, + "useDummyData": { + "description": "Vendor extension (not OpenAI): forces a mock completion. Testnet networks always mock regardless. The mock is tool-aware — offered a tool with no result in the conversation yet it returns a tool_calls turn, and once a role:'tool' message is present it answers with text — so the whole tool loop can be exercised without a billed completion.", + "type": "boolean" + }, + "payment": { + "description": "Vendor extension: x402 payment payload, as a body fallback for clients that cannot set the PAYMENT-SIGNATURE / X-PAYMENT header. Passed through as received; validated by @x402/evm, not here." + } + }, + "required": ["model", "messages"], + "additionalProperties": {}, + "description": "OpenAI chat-completions request body, plus this agent's vendor extensions. Unlisted fields are forwarded to the upstream model rather than dropped or rejected — except stream, n and max_tokens, which are rejected with a 400 because they move cost past the metered per-message ceiling." + }, + "LLMChatResponse": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "object": { + "type": "string", + "const": "chat.completion" + }, + "created": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "description": "Unix timestamp (seconds)." + }, + "model": { + "type": "string" + }, + "choices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "index": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "message": { + "type": "object", + "properties": { + "role": { + "type": "string" + }, + "content": { + "description": "The generated reply, or null when the model is requesting a tool call.", + "type": ["string", "null"] + }, + "tool_calls": { + "description": "Present only when finish_reason is tool_calls. Execute them and send the results back as role:'tool' messages carrying the matching tool_call_id.", + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Echo this back as tool_call_id on the matching tool result message." + }, + "type": { + "type": "string", + "const": "function" + }, + "function": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "arguments": { + "type": "string", + "description": "JSON-encoded arguments, as a string." + } + }, + "required": ["name", "arguments"], + "additionalProperties": {} + } + }, + "required": ["id", "type", "function"], + "additionalProperties": {} + } + } + }, + "required": ["role", "content"], + "additionalProperties": false + }, + "finish_reason": { + "type": ["string", "null"] + } + }, + "required": ["index", "message", "finish_reason"], + "additionalProperties": false + } + }, + "usage": { + "type": "object", + "properties": { + "prompt_tokens": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "completion_tokens": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "total_tokens": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": ["prompt_tokens", "completion_tokens", "total_tokens"], + "additionalProperties": false, + "description": "Token usage; the settled charge is derived from it." + } + }, + "required": ["id", "object", "created", "model", "choices", "usage"], + "additionalProperties": false, + "description": "OpenAI chat.completion object." + } + } + } +} diff --git a/bunny_js/src/response.ts b/bunny_js/src/response.ts new file mode 100644 index 000000000..5cc52039d --- /dev/null +++ b/bunny_js/src/response.ts @@ -0,0 +1,36 @@ +/** + * Response-conversion helpers shared by every router in this package. The ported x402 + * helpers still speak the {statusCode, headers, body} envelope from scw_js — these convert + * it to web-standard Responses and harden the two things the edge platform demands. + */ +import type { HttpResponse } from "./x402_server.js"; + +/** + * A web-standard Response cannot carry non-latin1 header bytes (the Headers API rejects them + * as "not a valid ByteString"), and the 402's X-Payment / the 200's X-Payment-Response are + * raw JSON whose bazaar metadata contains em-dashes, arrows and ellipses. JSON \uXXXX escapes + * decode to the exact same string, so escaping non-latin1 codepoints keeps the header's + * meaning identical for any JSON-parsing client — the base64 Payment-Required / + * Payment-Response headers are ASCII and pass through untouched. + */ +export function byteStringHeaderValue(value: string): string { + return value.replace(/[\u0100-\uFFFF]/g, (ch) => { + return `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`; + }); +} + +/** + * The ported x402 helpers still speak the {statusCode, headers, body} envelope. + * Cache-Control: no-store is pinned here: the pull zone in front of this script is a real + * CDN, and a cached paid 200 would replay a purchased result — with its Payment-Response + * receipt — to the next caller. (The platform normalizes no-store to no-cache, which is + * equally safe: no validators on these responses, so every hit revalidates at origin.) + */ +export function toResponse(http: HttpResponse): Response { + const headers: Record = {}; + for (const [key, value] of Object.entries(http.headers)) { + headers[key] = byteStringHeaderValue(value); + } + headers["Cache-Control"] ??= "no-store"; + return new Response(http.body, { status: http.statusCode, headers }); +} diff --git a/bunny_js/src/index.ts b/bunny_js/src/searchapi.ts similarity index 69% rename from bunny_js/src/index.ts rename to bunny_js/src/searchapi.ts index ca7545b7a..29021098c 100644 --- a/bunny_js/src/index.ts +++ b/bunny_js/src/searchapi.ts @@ -1,5 +1,5 @@ /** - * bunny_js entry point: the searchapi x402 seller (Brave /search + /fetch, paid via x402 + * searchapi twin's entry (one of three: searchapi_entry / llm_entry / mcp_entry): the searchapi x402 seller (Brave /search + /fetch, paid via x402 * batch-settlement) as a Bunny Edge Script — a twin of scw_js/search_api.ts, trialed at the * bunny-provided URL before any formal switch away from the Scaleway function. * @@ -8,6 +8,6 @@ */ import "./runtime-shims.js"; import * as BunnySDK from "@bunny.net/edgescript-sdk"; -import { handleRequest } from "./routes.js"; +import { handleRequest } from "./searchapi_routes.js"; BunnySDK.net.http.serve(handleRequest); diff --git a/bunny_js/src/routes.ts b/bunny_js/src/searchapi_routes.ts similarity index 91% rename from bunny_js/src/routes.ts rename to bunny_js/src/searchapi_routes.ts index 5ece6f0db..4d9574ddc 100644 --- a/bunny_js/src/routes.ts +++ b/bunny_js/src/searchapi_routes.ts @@ -29,6 +29,7 @@ import { AGENT_REGISTRATION_PATH, buildAgentRegistration } from "./agent_registr import { faviconBase64, faviconContentType } from "./favicon.js"; import openapiSpec from "./openapi.search.json" with { type: "json" }; import { buildSearchBazaarExtension } from "./bazaar_discovery.js"; +import { toResponse } from "./response.js"; import { create402Response, createBatchSettlementPaymentRequirements, @@ -36,7 +37,6 @@ import { createSettlementHeaders, extractPaymentPayload, releaseLock, - type HttpResponse, type SdkPaymentPayload, type SdkPaymentRequirements, } from "./x402_server.js"; @@ -74,34 +74,6 @@ function jsonResponse(statusCode: number, body: unknown): Response { return new Response(JSON.stringify(body), { status: statusCode, headers: CORS_HEADERS }); } -/** - * A web-standard Response cannot carry non-latin1 header bytes (the Headers API rejects them - * as "not a valid ByteString"), and the 402's X-Payment / the 200's X-Payment-Response are - * raw JSON whose bazaar metadata contains em-dashes, arrows and ellipses. JSON \uXXXX escapes - * decode to the exact same string, so escaping non-latin1 codepoints keeps the header's - * meaning identical for any JSON-parsing client — the base64 Payment-Required / - * Payment-Response headers are ASCII and pass through untouched. - */ -function byteStringHeaderValue(value: string): string { - return value.replace(/[\u0100-\uFFFF]/g, (ch) => { - return `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`; - }); -} - -/** The ported x402 helpers still speak the {statusCode, headers, body} envelope. */ -function toResponse(http: HttpResponse): Response { - const headers: Record = {}; - for (const [key, value] of Object.entries(http.headers)) { - headers[key] = byteStringHeaderValue(value); - } - // The pull zone in front of this script is a real CDN; a cached paid 200 would replay a - // purchased result — with its Payment-Response receipt — to the next caller. The ported - // create402Response emits no Cache-Control (the Scaleway function had no CDN), so the - // conversion layer pins it. CORS_HEADERS covers the responses routes.ts builds directly. - headers["Cache-Control"] ??= "no-store"; - return new Response(http.body, { status: http.statusCode, headers }); -} - function parsePath(rawPath: string): string { return rawPath.replace(/^\/+/, ""); } diff --git a/bunny_js/test/llm.test.ts b/bunny_js/test/llm.test.ts new file mode 100644 index 000000000..9e61fdbd1 --- /dev/null +++ b/bunny_js/test/llm.test.ts @@ -0,0 +1,1351 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// ===== Mocks (vi.hoisted ensures these are available when vi.mock factories run) ===== + +const { + mockCallLLMAPI, + mockTokensToCost, + mockCreateLLMResourceServer, + mockCreateBatchSettlementPaymentRequirements, + mockCreate402Response, + mockExtractPaymentPayload, + mockCreateSettlementHeaders, + mockGetBatchSettlementNetworks, + mockIsTestnet, + mockVerifyPayment, + mockSettlePayment, + mockCreatePaymentRequiredResponse, + mockEnhancePaymentRequirements, + mockScheme, + mockReleaseLock, +} = vi.hoisted(() => { + // enhancePaymentRequirements() is called for real verify/settle calls (not just the + // 402-building path) — see sc_llm_x402.ts's paymentRequirements construction. Echo the + // base requirements back unchanged, which is enough for the handler-logic tests here. + const mockEnhancePaymentRequirements = vi.fn().mockImplementation(async (base: unknown) => base); + return { + mockReleaseLock: vi.fn(), + mockCallLLMAPI: vi.fn(), + // Real formula (matches llm_service.ts's actual tokensToCost: separate + // input/output rates per provider — see LLM_PROVIDERS there), not a fixed stub — so + // tests can verify the settlement amount actually tracks whatever usage callLLMAPI + // returns, not just the flat ceiling. Called once at module load (for the ceiling, + // MAX_PRICE_ATOMIC, one per currency) — must work before beforeEach runs. Simplified to a + // single shared denominator (valid since both providers below have inDen === outDen + // === 100n today; the real implementation cross-multiplies to not assume that). + mockTokensToCost: vi.fn().mockImplementation( + ( + usage: { + prompt_tokens: bigint | number | string; + completion_tokens: bigint | number | string; + }, + provider: string, + symbol: "USDC" | "EURC" | "EURe", + decimals: number, + ) => { + // One rate card per currency, as in llm_service.ts: $0.50/$1.50 and €0.44/€1.50 — + // EURe shares the EUR card, rescaled to its 18 decimals like the real tokensToCost. + const RATES: Record> = { + mistral: { + USDC: { in: 50n, out: 150n, den: 100n }, + EURC: { in: 44n, out: 150n, den: 100n }, + EURe: { in: 44n, out: 150n, den: 100n }, + }, + }; + const rate = RATES[provider]?.[symbol]; + if (!rate) throw new Error(`Unknown LLM provider: ${provider}`); + const p = BigInt(usage.prompt_tokens); + const c = BigInt(usage.completion_tokens); + const base = (p * rate.in + c * rate.out) / rate.den; + return decimals === 6 ? base : base * 10n ** BigInt(decimals - 6); + }, + ), + mockCreateLLMResourceServer: vi.fn(), + mockCreateBatchSettlementPaymentRequirements: vi.fn(), + mockCreate402Response: vi.fn(), + mockExtractPaymentPayload: vi.fn(), + mockCreateSettlementHeaders: vi.fn(), + mockGetBatchSettlementNetworks: vi.fn(), + // Testnet networks (Base/Optimism Sepolia) must take the mock-LLM path — see the + // useMock gate in sc_llm_x402.ts. Default matches chain-utils' TESTNET_NETWORKS. + mockIsTestnet: vi + .fn() + .mockImplementation((net: string) => net === "eip155:84532" || net === "eip155:11155420"), + mockVerifyPayment: vi.fn(), + mockSettlePayment: vi.fn(), + mockCreatePaymentRequiredResponse: vi.fn(), + mockEnhancePaymentRequirements, + mockScheme: { enhancePaymentRequirements: mockEnhancePaymentRequirements }, + }; +}); + +vi.mock("../src/llm_service.js", () => ({ + callLLMAPI: mockCallLLMAPI, + tokensToCost: mockTokensToCost, + // Real, tiny implementations (keep in sync with llm_service.ts): the handler validates the + // request `model` against these, so a mock that omitted them would fail every request. + resolveModel: (modelId: string) => + modelId === "mistral-large-latest" ? { provider: "mistral" } : null, + advertisedModelIds: () => ["mistral-large-latest"], +})); + +vi.mock("../src/x402_server.js", () => ({ + createLLMResourceServer: mockCreateLLMResourceServer, + createBatchSettlementPaymentRequirements: mockCreateBatchSettlementPaymentRequirements, + create402Response: mockCreate402Response, + extractPaymentPayload: mockExtractPaymentPayload, + createSettlementHeaders: mockCreateSettlementHeaders, + getBatchSettlementNetworks: mockGetBatchSettlementNetworks, + releaseLock: mockReleaseLock, + // Real value (the scw-verbatim advertised maxTimeoutSeconds / lock TTL), imported by + // llm_routes.ts — see the constant's twin of this comment there. + LLM_MAX_TIMEOUT_SECONDS: 120, + // Real implementation (pure bigint math, no side effects) — keep in sync with + // x402_server.ts's actual formatUsdcAtomicAsDecimalUsd. + formatUsdcAtomicAsDecimalUsd: (atomicAmount: string) => { + const atomic = BigInt(atomicAmount); + const divisor = 1_000_000n; + const whole = atomic / divisor; + const fraction = (atomic % divisor).toString().padStart(6, "0"); + const trimmedFraction = fraction.replace(/0+$/, ""); + return trimmedFraction ? `${whole}.${trimmedFraction}` : whole.toString(); + }, +})); + +// The real stablecoin registry — which asset a seller prices is the thing under test here. +vi.mock("@fretchen/chain-utils", async (importOriginal) => ({ + ...(await importOriginal()), + isTestnet: mockIsTestnet, +})); + +// ===== Import after mocks ===== + +import { handleLLMRequest } from "../src/llm_routes.js"; + +/** + * Legacy view over the native Response, so the assertions ported from scw_js stay + * shape-identical: statusCode, a flat lowercase headers map, and the body as text. + */ +const handle = async (request: Request, _context?: unknown) => { + const res = await handleLLMRequest(request); + const headers: Record = {}; + res.headers.forEach((value, key) => { + headers[key] = value; + }); + return { statusCode: res.status, headers, body: await res.text() }; +}; +import { USDC_ADDRESSES, EURC_ADDRESSES } from "@fretchen/chain-utils"; + +// ===== Helpers ===== + +const VALID_ADDRESS = "0x1234567890abcdef1234567890abcdef12345678"; + +// The endpoint's request body is the OpenAI chat-completions shape. +const TEST_MODEL = "mistral-large-latest"; + +/** A minimal well-formed tool definition, for the tools cap and forwarding tests. */ +const TEST_TOOL = { + type: "function", + function: { + name: "generate_image", + description: "Generate an image from a prompt.", + parameters: { type: "object", properties: { prompt: { type: "string" } } }, + }, +}; + +// callLLMAPI now resolves to an OpenAI chat.completion object; build one for the mock. +function openAiCompletion( + content: string, + usage = { prompt_tokens: 5, completion_tokens: 7, total_tokens: 12 }, +) { + return { + id: "chatcmpl-test", + object: "chat.completion" as const, + created: 0, + model: "test-model", + choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }], + usage, + }; +} + +const LLM_ORIGIN = "https://llm-agent.fretchen.eu"; + +function makeEvent(overrides: Record = {}): Request { + const { + httpMethod = "POST", + headers = {}, + body = JSON.stringify({ model: TEST_MODEL, messages: [{ role: "user", content: "hi" }] }), + path = "/", + ...rest + } = overrides as Record; + if (Object.keys(rest).length > 0) { + throw new Error("makeEvent got an unhandled override key: " + Object.keys(rest).join(",")); + } + const pathStr = path as string; + const joined = pathStr.startsWith("/") ? pathStr : "/" + pathStr; + return new Request(LLM_ORIGIN + joined, { + method: httpMethod as string, + headers: headers as Record, + body: httpMethod === "GET" || httpMethod === "HEAD" ? undefined : (body as string), + }); +} + +const samplePaymentPayload = { + accepted: { + network: "eip155:84532", + scheme: "batch-settlement", + asset: USDC_ADDRESSES["eip155:84532"], + }, + payload: { type: "voucher" }, +}; + +// ===== Tests ===== + +describe("sc_llm_x402", () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.NFT_WALLET_PUBLIC_KEY = VALID_ADDRESS; + + mockCreateLLMResourceServer.mockReturnValue({ + resourceServer: { + verifyPayment: mockVerifyPayment, + settlePayment: mockSettlePayment, + createPaymentRequiredResponse: mockCreatePaymentRequiredResponse, + }, + scheme: mockScheme, + }); + mockGetBatchSettlementNetworks.mockReturnValue(["eip155:10", "eip155:8453", "eip155:84532"]); + mockExtractPaymentPayload.mockReturnValue(null); + mockCreateBatchSettlementPaymentRequirements.mockResolvedValue({ + x402Version: 2, + resource: { url: "/llmx402", description: "", mimeType: "application/json" }, + accepts: [], + }); + mockCreate402Response.mockReturnValue({ + statusCode: 402, + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + mockCreatePaymentRequiredResponse.mockResolvedValue({ + x402Version: 2, + error: "cap_exceeded", + resource: { url: "/llmx402", description: "", mimeType: "application/json" }, + accepts: [], + }); + mockCreateSettlementHeaders.mockReturnValue({ "Payment-Response": "encoded" }); + mockCallLLMAPI.mockResolvedValue(openAiCompletion("answer")); + mockVerifyPayment.mockResolvedValue({ isValid: true, payer: "0xpayer" }); + mockSettlePayment.mockResolvedValue({ + success: true, + transaction: "", + network: "eip155:84532", + }); + }); + + describe("basic request validation", () => { + it("returns 200 for OPTIONS preflight", async () => { + const res = await handle(makeEvent({ httpMethod: "OPTIONS" }) as never, {}); + expect(res.statusCode).toBe(200); + }); + + it("includes GET in the OPTIONS preflight allow-list", async () => { + // Regression guard: GET must stay allowed so GET /openapi.json passes preflight + // for browser-based x402 discovery crawlers. + const res = await handle(makeEvent({ httpMethod: "OPTIONS" }) as never, {}); + expect(res.headers["access-control-allow-methods"]).toContain("GET"); + }); + + it("exposes Payment-Response on the OPTIONS preflight", async () => { + // Without this the browser cannot read the settlement header, the client SDK's cached + // channel balance never advances, and every message signs a fresh $0.50 deposit. + const res = await handle(makeEvent({ httpMethod: "OPTIONS" }) as never, {}); + expect(res.headers["access-control-expose-headers"]).toContain("Payment-Response"); + }); + + it("returns 400 for a non-POST method", async () => { + const res = await handle(makeEvent({ httpMethod: "GET" }) as never, {}); + expect(res.statusCode).toBe(400); + }); + + it("serves the OpenAPI discovery document on GET /openapi.json", async () => { + const res = await handle( + makeEvent({ httpMethod: "GET", path: "/openapi.json" }) as never, + {}, + ); + expect(res.statusCode).toBe(200); + expect(res.headers["content-type"]).toBe("application/json"); + expect(res.headers["access-control-allow-origin"]).toBe("*"); + + const body = JSON.parse(res.body); + expect(body.openapi).toBe("3.1.0"); + expect(body.info.title).toBeTruthy(); + // Declares the llm/v1 service contract so discovery clients can classify it. + expect(body["x-service-type"]).toBe("llm/v1"); + expect(body.paths["/"].post["x-payment-info"]).toEqual({ + protocols: ["x402"], + price: { mode: "dynamic", currency: "USD", min: "0", max: "0.03" }, + }); + expect(body.paths["/"].post.responses["402"]).toBeDefined(); + }); + + it("serves the ERC-8004 registration file on GET and HEAD /.well-known/agent-registration.json", async () => { + const res = await handle( + makeEvent({ httpMethod: "GET", path: "/.well-known/agent-registration.json" }) as never, + {}, + ); + expect(res.statusCode).toBe(200); + expect(res.headers["content-type"]).toBe("application/json"); + expect(res.headers["access-control-allow-origin"]).toBe("*"); + expect(JSON.parse(res.body).services[0].endpoint).toBe( + "https://llm-agent.fretchen.eu/openapi.json", + ); + expect(JSON.parse(res.body).services).toContainEqual({ + name: "agentWallet", + endpoint: `eip155:8453:${VALID_ADDRESS}`, + }); + + const head = await handle( + makeEvent({ httpMethod: "HEAD", path: "/.well-known/agent-registration.json" }) as never, + {}, + ); + expect(head.statusCode).toBe(200); + expect(head.body).toBe(""); + }); + + it("serves openapi.json without a leading slash in the path too", async () => { + const res = await handle(makeEvent({ httpMethod: "GET", path: "openapi.json" }) as never, {}); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).openapi).toBe("3.1.0"); + }); + + // /favicon.ico is swallowed by the Scaleway gateway before the function runs, so we + // serve the icon at /favicon.png (a COMMON_FAVICON_PATHS fallback that reaches us). + // The Scaleway twin served base64 + isBase64Encoded; the native edge serves raw bytes. + it("serves the favicon as raw bytes on GET /favicon.png", async () => { + const res = await handleLLMRequest(makeEvent({ httpMethod: "GET", path: "/favicon.png" })); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("image/jpeg"); + const bytes = new Uint8Array(await res.arrayBuffer()); + expect(bytes.length).toBeGreaterThan(0); + expect(bytes[0]).toBe(0xff); + expect(bytes[1]).toBe(0xd8); + }); + + it("answers a HEAD /favicon.png probe with 200 and an image content-type", async () => { + const res = await handle( + makeEvent({ httpMethod: "HEAD", path: "/favicon.png" }) as never, + {}, + ); + expect(res.statusCode).toBe(200); + expect(res.headers["content-type"]).toBe("image/jpeg"); + expect(res.body).toBe(""); + }); + + // x402scan fetches the origin root and parses before probing any + // favicon path. An HTML-accepting GET on "/" must return that discovery HTML. + it("serves favicon-discovery HTML on GET / when the client accepts HTML", async () => { + const res = await handle( + makeEvent({ + httpMethod: "GET", + path: "/", + headers: { accept: "text/html,application/xhtml+xml" }, + }) as never, + {}, + ); + expect(res.statusCode).toBe(200); + expect(res.headers["content-type"]).toContain("text/html"); + expect(res.body).toContain('rel="icon"'); + expect(res.body).toContain("/favicon.png"); + }); + + // An x402 payment client GETs / without an HTML Accept header — it must fall through + // to the normal "only POST" rejection, not receive HTML. + it("does NOT serve HTML on GET / without an HTML Accept header", async () => { + const res = await handle( + makeEvent({ httpMethod: "GET", path: "/", headers: {} }) as never, + {}, + ); + expect(res.statusCode).toBe(400); + }); + + // Regression guard: x-payment-info.price.max must track the real, live price + // ceiling (USDC_MAX_PRICE_PER_MESSAGE), not a value hardcoded in openapi.llm.json — + // otherwise the served discovery doc silently drifts from actual 402 behavior if + // LLM_ESTIMATED_TOKENS_PER_MESSAGE or the provider's rate card ever changes. Proves + // this by re-importing the module with a different token estimate and checking the + // served price.max changes accordingly, rather than coincidentally matching a literal. + it("serves a price.max that tracks LLM_ESTIMATED_TOKENS_PER_MESSAGE, not a hardcoded value", async () => { + const originalEstimate = process.env.LLM_ESTIMATED_TOKENS_PER_MESSAGE; + process.env.LLM_ESTIMATED_TOKENS_PER_MESSAGE = "4000"; + vi.resetModules(); + try { + const mod = await import("../src/llm_routes.js"); + const res = await mod.handleLLMRequest( + makeEvent({ httpMethod: "GET", path: "/openapi.json" }), + ); + expect(res.status).toBe(200); + const body = JSON.parse(await res.text()); + // 4000 tokens * 150n/100n (mistral output rate, mocked to match llm_service.ts) = 6000n + // atomic units = "0.006" decimal USD -- distinct from the static file's "0.03". + expect(body.paths["/"].post["x-payment-info"].price.max).toBe("0.006"); + } finally { + if (originalEstimate === undefined) { + delete process.env.LLM_ESTIMATED_TOKENS_PER_MESSAGE; + } else { + process.env.LLM_ESTIMATED_TOKENS_PER_MESSAGE = originalEstimate; + } + vi.resetModules(); + } + }); + + it("returns 400 for malformed JSON body", async () => { + const res = await handle(makeEvent({ body: "{ not valid" }) as never, {}); + expect(res.statusCode).toBe(400); + }); + + /** + * Request validation runs on the PAID path only — an unpaid request gets the 402 + * challenge whatever its body says (see the "payment challenge precedes validation" + * block below for why). These therefore attach a payment first; the property that + * matters is that a malformed *paid* request is rejected before it is ever charged, + * which each case asserts via `mockVerifyPayment` never being called. + */ + function paidEvent(bodyFields: Record) { + mockExtractPaymentPayload.mockReturnValue(samplePaymentPayload); + return makeEvent({ body: JSON.stringify(bodyFields) }); + } + + it("returns 400 (OpenAI-shaped) when messages is missing or empty", async () => { + const res = await handle(paidEvent({ model: TEST_MODEL, messages: [] }) as never, {}); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).error.type).toBe("invalid_request_error"); + expect(mockVerifyPayment).not.toHaveBeenCalled(); + }); + + it("returns 404 model_not_found for an unadvertised model", async () => { + const res = await handle( + paidEvent({ model: "gpt-4o", messages: [{ role: "user", content: "hi" }] }) as never, + {}, + ); + expect(res.statusCode).toBe(404); + expect(JSON.parse(res.body).error.code).toBe("model_not_found"); + expect(mockVerifyPayment).not.toHaveBeenCalled(); + }); + + it("rejects stream: true with an OpenAI-shaped error", async () => { + const res = await handle( + paidEvent({ + model: TEST_MODEL, + messages: [{ role: "user", content: "hi" }], + stream: true, + }) as never, + {}, + ); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).error.code).toBe("stream_unsupported"); + expect(mockVerifyPayment).not.toHaveBeenCalled(); + }); + + it("returns 400 for an invalid useDummyData flag", async () => { + const res = await handle( + paidEvent({ + model: TEST_MODEL, + messages: [{ role: "user", content: "hi" }], + useDummyData: "yes", + }) as never, + {}, + ); + expect(res.statusCode).toBe(400); + expect(mockVerifyPayment).not.toHaveBeenCalled(); + }); + + /** + * The llm/v1 contract this repo publishes — and asks other agent builders to implement + * via checkLlmV1Agent / the /agent-onboarding guide — requires that an unpaid POST be + * answered with the payment challenge. Validating the body first broke that: a client + * discovering the payment terms (which network, which asset) had to already know a + * valid model id to be told, and this agent failed its own compatibility checker. + * The website's network negotiation reads exactly this 402, so these are load-bearing. + */ + describe("payment challenge precedes request validation", () => { + it.each([ + ["an empty messages array", { model: "probe", messages: [] }], + ["an unknown model", { model: "probe", messages: [{ role: "user", content: "hi" }] }], + ["no fields at all", {}], + [ + "stream: true", + { model: TEST_MODEL, messages: [{ role: "user", content: "hi" }], stream: true }, + ], + ])("challenges an unpaid request with %s", async (_label, body) => { + const res = await handle(makeEvent({ body: JSON.stringify(body) }) as never, {}); + expect(res.statusCode).toBe(402); + expect(mockCreate402Response).toHaveBeenCalled(); + expect(mockVerifyPayment).not.toHaveBeenCalled(); + }); + }); + + it("returns 500 when NFT_WALLET_PUBLIC_KEY is missing", async () => { + delete process.env.NFT_WALLET_PUBLIC_KEY; + const res = await handle(makeEvent() as never, {}); + expect(res.statusCode).toBe(500); + }); + + it("returns 500 when NFT_WALLET_PUBLIC_KEY is not a valid hex address", async () => { + process.env.NFT_WALLET_PUBLIC_KEY = "not-an-address"; + const res = await handle(makeEvent() as never, {}); + expect(res.statusCode).toBe(500); + }); + + it("returns 500 when the resource server fails to configure", async () => { + mockCreateLLMResourceServer.mockImplementation(() => { + throw new Error("RECEIVER_AUTHORIZER_PRIVATE_KEY not configured"); + }); + const res = await handle(makeEvent() as never, {}); + expect(res.statusCode).toBe(500); + }); + }); + + describe("missing payment", () => { + it("returns a 402 built from createBatchSettlementPaymentRequirements", async () => { + const res = await handle(makeEvent() as never, {}); + expect(res.statusCode).toBe(402); + // Ceiling: the whole 20000-token estimate priced as completion (output) tokens + // at Mistral's $1.50/M rate — 20000 * 150 / 100 = 30000. + expect(mockCreateBatchSettlementPaymentRequirements).toHaveBeenCalledWith( + expect.objectContaining({ + payTo: VALID_ADDRESS, + scheme: mockScheme, + // Absolute resource URL — the bazaar discovery validation rejects a relative one + // ("resource must start with 'https://'"), which would keep the route unindexable. + resourceUrl: "https://llm-agent.fretchen.eu/", + // 20000 tokens at the output rate: $1.50/M and €1.50/M give 30000 in 6 decimals, + // and 30000 ×10¹² for EURe's 18 — the same ceiling in each token's own units. + price: { USDC: "30000", EURC: "30000", EURe: "30000000000000000" }, + // Bazaar discovery metadata (docs.x402.org/extensions/bazaar) rides along on the + // challenge; metadata-only, the payment terms are untouched. + extensions: expect.objectContaining({ + bazaar: expect.objectContaining({ + info: expect.objectContaining({ + input: expect.objectContaining({ method: "POST" }), + }), + }), + }), + serviceName: "Fretchen AI Assistant", + tags: ["llm", "chat", "x402"], + iconUrl: "https://llm-agent.fretchen.eu/favicon.png", + }), + ); + expect(mockCreate402Response).toHaveBeenCalled(); + }); + }); + + describe("network validation", () => { + it("returns 402 when the payload has no accepted network", async () => { + mockExtractPaymentPayload.mockReturnValue({ payload: { type: "voucher" } }); + const res = await handle(makeEvent() as never, {}); + expect(res.statusCode).toBe(402); + expect(mockVerifyPayment).not.toHaveBeenCalled(); + }); + + it("returns 402 when the network is not a batch-settlement network", async () => { + mockExtractPaymentPayload.mockReturnValue({ + accepted: { network: "eip155:11155420" }, + payload: { type: "voucher" }, + }); + const res = await handle(makeEvent() as never, {}); + expect(res.statusCode).toBe(402); + expect(mockVerifyPayment).not.toHaveBeenCalled(); + }); + }); + + describe("payment verification", () => { + beforeEach(() => { + mockExtractPaymentPayload.mockReturnValue(samplePaymentPayload); + }); + + it("returns 402 when verification is invalid", async () => { + mockVerifyPayment.mockResolvedValue({ + isValid: false, + invalidReason: "cap_exceeded", + payer: "0xpayer", + }); + const res = await handle(makeEvent() as never, {}); + expect(res.statusCode).toBe(402); + expect(mockCallLLMAPI).not.toHaveBeenCalled(); + + // The failed paymentPayload must be passed through — that's what triggers the SDK's + // response-time scheme enrichment (e.g. cumulative-amount-mismatch channelState) for + // the client's corrective-retry flow to have anything to recover from. + expect(mockCreatePaymentRequiredResponse).toHaveBeenCalledWith( + [expect.objectContaining({ scheme: "batch-settlement" })], + expect.objectContaining({ mimeType: "application/json" }), + "cap_exceeded", + { payer: "0xpayer" }, + undefined, + samplePaymentPayload, + ); + expect(mockCreate402Response).toHaveBeenCalledWith( + await mockCreatePaymentRequiredResponse.mock.results[0].value, + ); + }); + + it("returns 402 when verification is invalid and omits payer from extensions if absent", async () => { + mockVerifyPayment.mockResolvedValue({ isValid: false, invalidReason: "cap_exceeded" }); + const res = await handle(makeEvent() as never, {}); + expect(res.statusCode).toBe(402); + expect(mockCreatePaymentRequiredResponse).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + "cap_exceeded", + undefined, + undefined, + samplePaymentPayload, + ); + }); + + it("returns 402 when verifyPayment throws", async () => { + mockVerifyPayment.mockRejectedValue(new Error("facilitator unreachable")); + const res = await handle(makeEvent() as never, {}); + expect(res.statusCode).toBe(402); + expect(mockCallLLMAPI).not.toHaveBeenCalled(); + }); + }); + + describe("LLM call", () => { + beforeEach(() => { + mockExtractPaymentPayload.mockReturnValue(samplePaymentPayload); + }); + + /** + * The endpoint forwards the caller's chat params to the upstream model rather than dropping + * them — Mistral owns that contract, the way @x402/evm owns the payment-payload contract in + * x402_facilitator. Silently dropping a `temperature` the caller set would mean charging for a + * request not fulfilled as asked; rejecting it would make this a worse API than the one it + * imitates. See llm_schemas.ts. + */ + describe("chat params are forwarded upstream", () => { + it("passes an unlisted param through to callLLMAPI", async () => { + const res = await handle( + makeEvent({ + body: JSON.stringify({ + model: TEST_MODEL, + messages: [{ role: "user", content: "hi" }], + temperature: 0, + top_p: 0.1, + }), + }) as never, + {}, + ); + + expect(res.statusCode).toBe(200); + // 4th arg is the forwarded-params bag. + expect(mockCallLLMAPI).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + { temperature: 0, top_p: 0.1 }, + ); + }); + + it("never forwards the keys we own", async () => { + await handle( + makeEvent({ + body: JSON.stringify({ + model: TEST_MODEL, + messages: [{ role: "user", content: "hi" }], + useDummyData: true, + payment: { some: "payload" }, + temperature: 0.5, + }), + }) as never, + {}, + ); + + const forwarded = mockCallLLMAPI.mock.calls[0][3]; + // model/messages are set by us downstream; useDummyData and payment are ours entirely and + // must never reach the upstream model. + expect(forwarded).toEqual({ temperature: 0.5 }); + }); + + it("forwards an empty bag when the caller sends nothing extra", async () => { + // Guards the byte-identical-to-before case: a plain request must not gain stray keys. + await handle(makeEvent() as never, {}); + expect(mockCallLLMAPI.mock.calls[0][3]).toEqual({}); + }); + + it("forwards tools and tool_choice, which are declared but not stripped", async () => { + // The direct counterpart to "accepts n=1, which is what we actually serve" below: every + // other declared key is consumed here, but tools/tool_choice are declared (so the spec + // names them and the caps apply) *and* forwarded, because the model is what must see + // them. Dropping them would charge for a completion that ignored the caller's tools. + await handle( + makeEvent({ + body: JSON.stringify({ + model: TEST_MODEL, + messages: [{ role: "user", content: "draw a cat" }], + tools: [TEST_TOOL], + tool_choice: "auto", + }), + }) as never, + {}, + ); + + expect(mockCallLLMAPI.mock.calls[0][3]).toEqual({ + tools: [TEST_TOOL], + tool_choice: "auto", + }); + }); + + it("accepts a non-standard message role and forwards it verbatim", async () => { + // `role` is not a cost-mover, so the handler does not restrict it to the OpenAI three and + // the published schema must not either (LLMChatMessageSchema.role is z.string()). A role + // the upstream adds later — e.g. "tool" — has to reach Mistral untouched. + const res = await handle( + makeEvent({ + body: JSON.stringify({ + model: TEST_MODEL, + messages: [ + { role: "user", content: "hi" }, + { role: "tool", content: "result" }, + ], + }), + }) as never, + {}, + ); + + expect(res.statusCode).toBe(200); + const forwardedPrompt = mockCallLLMAPI.mock.calls[0][0]; + expect(forwardedPrompt).toEqual([ + { role: "user", content: "hi" }, + { role: "tool", content: "result" }, + ]); + }); + }); + + /** + * The three exceptions to forwarding: params that move cost past the fixed per-message + * ceiling. Rejected rather than ignored, so a caller learns their limit instead of being + * quietly billed for something else. + */ + describe("cost-moving params are rejected", () => { + const cases = [ + { param: "n", body: { n: 5 } }, + { param: "max_tokens", body: { max_tokens: 8000 } }, + ]; + + for (const { param, body: extra } of cases) { + it(`rejects ${param} with a message naming the ceiling`, async () => { + const res = await handle( + makeEvent({ + body: JSON.stringify({ + model: TEST_MODEL, + messages: [{ role: "user", content: "hi" }], + ...extra, + }), + }) as never, + {}, + ); + + expect(res.statusCode).toBe(400); + const parsed = JSON.parse(res.body); + expect(parsed.error.param).toBe(param); + expect(parsed.error.message).toMatch(/ceiling/i); + // Rejected before the upstream is called, so nothing is spent. + expect(mockCallLLMAPI).not.toHaveBeenCalled(); + expect(mockSettlePayment).not.toHaveBeenCalled(); + }); + } + + it("accepts n=1, which is what we actually serve", async () => { + const res = await handle( + makeEvent({ + body: JSON.stringify({ + model: TEST_MODEL, + messages: [{ role: "user", content: "hi" }], + n: 1, + }), + }) as never, + {}, + ); + + expect(res.statusCode).toBe(200); + // n is ours to control, so it is not forwarded even when valid. + expect(mockCallLLMAPI.mock.calls[0][3]).toEqual({}); + }); + }); + + describe("tools are capped rather than refused", () => { + // Unlike the params above, tools are a feature — but tool definitions are input tokens + // billed on every hop, so an uncapped array inflates the metered cost for free. The byte cap + // must bite before verifyPayment, or a caller could make us pay for the inference. + function toolsEvent(tools: unknown) { + return makeEvent({ + body: JSON.stringify({ + model: TEST_MODEL, + messages: [{ role: "user", content: "hi" }], + tools, + }), + }) as never; + } + + it("does not cap the tool count — only bytes are billed", async () => { + const res = await handle(toolsEvent(Array(12).fill(TEST_TOOL)), {}); + expect(res.statusCode).toBe(200); + }); + + it("rejects a tools array over the byte cap before any payment is verified", async () => { + // Two tools, but one carries a huge description — bytes, not count, are what is billed. + const fat = { + ...TEST_TOOL, + function: { ...TEST_TOOL.function, description: "x".repeat(9000) }, + }; + const res = await handle(toolsEvent([TEST_TOOL, fat]), {}); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).error.param).toBe("tools"); + expect(mockVerifyPayment).not.toHaveBeenCalled(); + expect(mockCallLLMAPI).not.toHaveBeenCalled(); + }); + + it("rejects a malformed tool definition", async () => { + const res = await handle(toolsEvent([{ type: "function", function: { name: "" } }]), {}); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).error.param).toBe("tools"); + }); + + it("measures the byte cap in UTF-8 bytes, not UTF-16 code units", async () => { + // ~3200 three-byte chars: ~3200 UTF-16 units (under the 8192 cap by that measure) but + // ~9600 UTF-8 bytes (over it). Must be rejected — tool definitions are billed by byte. + const wide = { + ...TEST_TOOL, + function: { ...TEST_TOOL.function, description: "あ".repeat(3200) }, + }; + const res = await handle(toolsEvent([wide]), {}); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).error.param).toBe("tools"); + expect(mockVerifyPayment).not.toHaveBeenCalled(); + }); + }); + + describe("messages are capped by serialized size", () => { + // The same reasoning as the tools cap above, applied to the far larger input: conversation + // turns are input tokens billed on every hop, while the charge is capped at a fixed + // per-message ceiling. Without this, one request filling the model's context window cost + // several times what it settled for, repeatably. + function messagesEvent(messages: unknown) { + return makeEvent({ + body: JSON.stringify({ model: TEST_MODEL, messages }), + }) as never; + } + + it("rejects a conversation over the byte cap before any payment is verified", async () => { + const res = await handle( + messagesEvent([{ role: "user", content: "x".repeat(200_000) }]), + {}, + ); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).error.param).toBe("messages"); + expect(JSON.parse(res.body).error.message).toMatch(/ceiling/i); + expect(mockVerifyPayment).not.toHaveBeenCalled(); + expect(mockCallLLMAPI).not.toHaveBeenCalled(); + expect(mockSettlePayment).not.toHaveBeenCalled(); + }); + + it("counts the whole conversation, not the largest single turn", async () => { + // Twenty turns, each comfortably small on its own, summing to over the cap. + const messages = Array.from({ length: 20 }, () => ({ + role: "user", + content: "y".repeat(10_000), + })); + const res = await handle(messagesEvent(messages), {}); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).error.param).toBe("messages"); + expect(mockVerifyPayment).not.toHaveBeenCalled(); + }); + + it("measures the cap in UTF-8 bytes, not UTF-16 code units", async () => { + // ~70k three-byte chars: ~70k UTF-16 units (under the 196608 cap by that measure) but + // ~210k UTF-8 bytes (over it). Billed by byte, so it must be rejected. + const res = await handle( + messagesEvent([{ role: "user", content: "あ".repeat(70_000) }]), + {}, + ); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).error.param).toBe("messages"); + expect(mockVerifyPayment).not.toHaveBeenCalled(); + }); + + it("accepts a conversation just under the cap", async () => { + const res = await handle( + messagesEvent([{ role: "user", content: "z".repeat(190_000) }]), + {}, + ); + expect(res.statusCode).toBe(200); + }); + }); + + describe("tool_choice is restricted to the published enum", () => { + function toolChoiceEvent(tool_choice: unknown) { + return makeEvent({ + body: JSON.stringify({ + model: TEST_MODEL, + messages: [{ role: "user", content: "hi" }], + tools: [TEST_TOOL], + tool_choice, + }), + }) as never; + } + + it.each([ + ["a forced string", "required"], + ["a named function", { type: "function", function: { name: "generate_image" } }], + ])("rejects %s before any payment is verified", async (_label, choice) => { + const res = await handle(toolChoiceEvent(choice), {}); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).error.param).toBe("tool_choice"); + expect(mockVerifyPayment).not.toHaveBeenCalled(); + expect(mockCallLLMAPI).not.toHaveBeenCalled(); + }); + + it('forwards tool_choice: "none"', async () => { + const res = await handle(toolChoiceEvent("none"), {}); + expect(res.statusCode).toBe(200); + expect(mockCallLLMAPI.mock.calls[0][3]).toEqual({ + tools: [TEST_TOOL], + tool_choice: "none", + }); + }); + }); + + describe("tool-call conversation turns", () => { + it("accepts an assistant turn with null content and tool_calls", async () => { + // The shape a caller replays on the second hop. The old check required a string content + // and rejected this with a 400, making a tool loop impossible to continue. + const res = await handle( + makeEvent({ + body: JSON.stringify({ + model: TEST_MODEL, + messages: [ + { role: "user", content: "draw a cat" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "generate_image", arguments: "{}" }, + }, + ], + }, + { role: "tool", tool_call_id: "call_1", content: '{"status":"ok"}' }, + ], + }), + }) as never, + {}, + ); + + expect(res.statusCode).toBe(200); + // The whole turn, tool_call_id included, must reach the model untouched. + expect(mockCallLLMAPI.mock.calls[0][0]).toHaveLength(3); + expect(mockCallLLMAPI.mock.calls[0][0][2]).toEqual({ + role: "tool", + tool_call_id: "call_1", + content: '{"status":"ok"}', + }); + }); + + it("rejects a message whose tool_calls entries are malformed", async () => { + const res = await handle( + makeEvent({ + body: JSON.stringify({ + model: TEST_MODEL, + messages: [{ role: "assistant", content: null, tool_calls: [{ id: "call_1" }] }], + }), + }) as never, + {}, + ); + + expect(res.statusCode).toBe(400); + expect(mockVerifyPayment).not.toHaveBeenCalled(); + }); + + it("rejects a message with an empty tool_calls array", async () => { + // content: null is only allowed when the turn actually carries a call. An empty array + // would otherwise reach Mistral, 400 there, and surface as an opaque 500. + const res = await handle( + makeEvent({ + body: JSON.stringify({ + model: TEST_MODEL, + messages: [{ role: "assistant", content: null, tool_calls: [] }], + }), + }) as never, + {}, + ); + + expect(res.statusCode).toBe(400); + expect(mockVerifyPayment).not.toHaveBeenCalled(); + }); + + it("rejects a message with a non-string content", async () => { + const res = await handle( + makeEvent({ + body: JSON.stringify({ + model: TEST_MODEL, + messages: [{ role: "user", content: 42 }], + }), + }) as never, + {}, + ); + + expect(res.statusCode).toBe(400); + expect(mockVerifyPayment).not.toHaveBeenCalled(); + }); + + it("still rejects a message with neither content nor tool_calls", async () => { + const res = await handle( + makeEvent({ + body: JSON.stringify({ + model: TEST_MODEL, + messages: [{ role: "user" }], + }), + }) as never, + {}, + ); + + expect(res.statusCode).toBe(400); + expect(mockVerifyPayment).not.toHaveBeenCalled(); + }); + + it("still rejects an explicit tool_calls: null with no content", async () => { + const res = await handle( + makeEvent({ + body: JSON.stringify({ + model: TEST_MODEL, + messages: [{ role: "assistant", content: null, tool_calls: null }], + }), + }) as never, + {}, + ); + + expect(res.statusCode).toBe(400); + expect(mockVerifyPayment).not.toHaveBeenCalled(); + }); + + it("accepts a replayed assistant turn with content and an explicit tool_calls: null", async () => { + // Real-world shape: an llm/v1 caller that forwards an upstream's own message verbatim + // gets tool_calls: null (not omitted) on a plain turn — see upstream_schemas.ts for the + // production incident. The published request schema must accept the same thing it just + // learned to accept from the upstream, or a well-behaved caller gets a 400 for it. + const res = await handle( + makeEvent({ + body: JSON.stringify({ + model: TEST_MODEL, + messages: [ + { role: "user", content: "draw a cat" }, + { role: "assistant", content: "Here you go!", tool_calls: null }, + { role: "user", content: "thanks" }, + ], + }), + }) as never, + {}, + ); + + expect(res.statusCode).toBe(200); + }); + }); + + it("does not settle when the LLM API token is missing", async () => { + // Our own misconfiguration, not the caller's fault: a 500, and above all no charge. + mockCallLLMAPI.mockRejectedValue( + new Error( + "API token not found. Please configure the MISTRAL_API_KEY environment variable.", + ), + ); + const res = await handle(makeEvent() as never, {}); + expect(res.statusCode).toBe(500); + expect(mockSettlePayment).not.toHaveBeenCalled(); + // Verified but never settled: the channel lock is released rather than left to block the + // next message for LLM_MAX_TIMEOUT_SECONDS (the lock TTL). + expect(mockReleaseLock).toHaveBeenCalledOnce(); + }); + + it("returns 500 for other LLM API errors", async () => { + mockCallLLMAPI.mockRejectedValue(new Error("upstream exploded")); + const res = await handle(makeEvent() as never, {}); + expect(res.statusCode).toBe(500); + }); + + it("forces the mock LLM path on a testnet payment (no real inference spend)", async () => { + // Base Sepolia payment is valueless, so callLLMAPI must be invoked with dummy=true + // even though the request did not ask for it — mirrors genimg's useMockImage. + mockExtractPaymentPayload.mockReturnValue(samplePaymentPayload); // eip155:84532 + const res = await handle(makeEvent() as never, {}); + expect(res.statusCode).toBe(200); + // Third arg is the fixed provider — this endpoint always uses Mistral, live or mock. + expect(mockCallLLMAPI).toHaveBeenCalledWith(expect.anything(), true, "mistral", {}); + }); + + it("uses the real LLM path on a mainnet payment", async () => { + mockExtractPaymentPayload.mockReturnValue({ + accepted: { + network: "eip155:8453", + scheme: "batch-settlement", + asset: USDC_ADDRESSES["eip155:8453"], + }, + payload: { type: "voucher" }, + }); + const res = await handle(makeEvent() as never, {}); + expect(res.statusCode).toBe(200); + expect(mockCallLLMAPI).toHaveBeenCalledWith(expect.anything(), false, "mistral", {}); + }); + + // ═══════════════════════════════════════════════════════════ + // SECURITY: testnet must never reach the real Mistral API — see the guard + // in sc_llm_x402.ts. Absent/true stay mocked (unaffected, both real callers + // — website + notebook — never send useDummyData at all); an explicit + // false is a caller error, rejected outright rather than silently downgraded. + // ═══════════════════════════════════════════════════════════ + + it("rejects an explicit useDummyData=false on a testnet network", async () => { + const res = await handle( + makeEvent({ + body: JSON.stringify({ + model: TEST_MODEL, + messages: [{ role: "user", content: "hi" }], + useDummyData: false, + }), + }) as never, + {}, + ); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).error).toMatch(/Real inference is not available on testnet/); + expect(mockVerifyPayment).not.toHaveBeenCalled(); + expect(mockCallLLMAPI).not.toHaveBeenCalled(); + }); + + it("allows an explicit useDummyData=true on a testnet network (still mocks)", async () => { + const res = await handle( + makeEvent({ + body: JSON.stringify({ + model: TEST_MODEL, + messages: [{ role: "user", content: "hi" }], + useDummyData: true, + }), + }) as never, + {}, + ); + expect(res.statusCode).toBe(200); + expect(mockCallLLMAPI).toHaveBeenCalledWith(expect.anything(), true, "mistral", {}); + }); + + it("allows an explicit useDummyData=false on a mainnet network (real path proceeds)", async () => { + mockExtractPaymentPayload.mockReturnValue({ + accepted: { + network: "eip155:8453", + scheme: "batch-settlement", + asset: USDC_ADDRESSES["eip155:8453"], + }, + payload: { type: "voucher" }, + }); + const res = await handle( + makeEvent({ + body: JSON.stringify({ + model: TEST_MODEL, + messages: [{ role: "user", content: "hi" }], + useDummyData: false, + }), + }) as never, + {}, + ); + expect(res.statusCode).toBe(200); + expect(mockCallLLMAPI).toHaveBeenCalledWith(expect.anything(), false, "mistral", {}); + }); + }); + + describe("settlement", () => { + beforeEach(() => { + mockExtractPaymentPayload.mockReturnValue(samplePaymentPayload); + }); + + /** + * The regression that cost ~$7.40 of locked escrow: the settled 200 sent + * `Payment-Response` but never exposed it, so cross-origin browsers read null, the + * client SDK's cached channel record never advanced, and each message re-deposited + * $0.50. Asserting only the OPTIONS preflight would have passed the whole time — the + * settled 200 builds its headers from CORS_HEADERS separately, and that is the path + * that was broken. Assert the header is BOTH present and readable. + */ + it("exposes Payment-Response on the settled 200, not just sends it", async () => { + const res = await handle(makeEvent() as never, {}); + expect(res.statusCode).toBe(200); + expect(res.headers["payment-response"]).toBe("encoded"); + expect(res.headers["access-control-expose-headers"]).toContain("Payment-Response"); + }); + + it("returns 200 with the LLM response and settlement headers on success", async () => { + const res = await handle(makeEvent() as never, {}); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.choices[0].message.content).toBe("answer"); + expect(body.object).toBe("chat.completion"); + expect(body.usage).toBeDefined(); + expect(res.headers["payment-response"]).toBe("encoded"); + expect(mockSettlePayment).toHaveBeenCalledTimes(1); + + // The requirements passed to verify/settle must go through the scheme's own + // enhancePaymentRequirements() — a raw, un-enhanced object is missing + // extra.receiverAuthorizer/withdrawDelay, which makes the facilitator reject every + // real deposit with receiver_authorizer_mismatch (found via a live Base Sepolia run). + expect(mockEnhancePaymentRequirements).toHaveBeenCalledWith( + expect.objectContaining({ scheme: "batch-settlement", network: "eip155:84532" }), + expect.objectContaining({ scheme: "batch-settlement", network: "eip155:84532" }), + [], + ); + const enhancedRequirements = await mockEnhancePaymentRequirements.mock.results[0]?.value; + expect(mockVerifyPayment).toHaveBeenCalledWith(samplePaymentPayload, enhancedRequirements); + }); + + it("returns 402 when settlement reports success:false", async () => { + mockSettlePayment.mockResolvedValue({ success: false, errorReason: "cap_exceeded" }); + const res = await handle(makeEvent() as never, {}); + expect(res.statusCode).toBe(402); + expect(JSON.parse(res.body).error).toMatch(/cap_exceeded/); + }); + + it("returns 402 when settlePayment throws", async () => { + mockSettlePayment.mockRejectedValue(new Error("channel_busy")); + const res = await handle(makeEvent() as never, {}); + expect(res.statusCode).toBe(402); + expect(JSON.parse(res.body).error).toMatch(/channel_busy/); + }); + + // ═══════════════════════════════════════════════════════════ + // Usage-based billing: settlement amount must track the LLM's real token + // usage, not the flat ceiling advertised at 402/verify time. + // ═══════════════════════════════════════════════════════════ + + it("settles for the LLM's actual token usage, not the ceiling", async () => { + // 200 prompt + 800 completion -> 0.5*200 + 1.5*800 = 100 + 1200 = 1300 (Mistral + // rates), well under the 30000 ceiling (20000 tokens, all priced as completion). + mockCallLLMAPI.mockResolvedValue( + openAiCompletion("answer", { + prompt_tokens: 200, + completion_tokens: 800, + total_tokens: 1000, + }), + ); + + const res = await handle(makeEvent() as never, {}); + expect(res.statusCode).toBe(200); + + // verifyPayment must still see the pre-authorized ceiling (30000) — the client signed + // its voucher against that, and handleBeforeVerify requires an exact match. + const enhancedRequirements = await mockEnhancePaymentRequirements.mock.results[0]?.value; + expect(mockVerifyPayment).toHaveBeenCalledWith( + samplePaymentPayload, + expect.objectContaining({ amount: "30000" }), + ); + + // settlePayment must see the real, usage-derived amount instead. + expect(mockSettlePayment).toHaveBeenCalledWith( + samplePaymentPayload, + expect.objectContaining({ ...enhancedRequirements, amount: "1300" }), + ); + }); + + it("caps the settlement amount at the ceiling when usage runs over the estimate", async () => { + // 1000 prompt + 20000 completion -> 0.5*1000 + 1.5*20000 = 500 + 30000 = 30500, which + // exceeds the 30000 ceiling — must be capped there rather than settling for more + // than the client authorized (or aborting). + mockCallLLMAPI.mockResolvedValue( + openAiCompletion("answer", { + prompt_tokens: 1000, + completion_tokens: 20000, + total_tokens: 21000, + }), + ); + + const res = await handle(makeEvent() as never, {}); + expect(res.statusCode).toBe(200); + expect(mockSettlePayment).toHaveBeenCalledWith( + samplePaymentPayload, + expect.objectContaining({ amount: "30000" }), + ); + }); + + it("settles for zero when the mock LLM path reports its placeholder usage", async () => { + // Matches genimg's useMockImage convention (never real inference spend) — a testnet/ + // dummy response should bill for what it actually cost: effectively nothing. + mockCallLLMAPI.mockResolvedValue({ + content: "I am a placeholder for the LLM response", + usage: { prompt_tokens: 5, completion_tokens: 15, total_tokens: 15 }, + model: "placeholder model", + }); + + const res = await handle(makeEvent() as never, {}); + expect(res.statusCode).toBe(200); + // 5 prompt + 15 completion -> 0.5*5 + 1.5*15 = 2.5 + 22.5 = 25 exactly. + expect(mockSettlePayment).toHaveBeenCalledWith( + samplePaymentPayload, + expect.objectContaining({ amount: "25" }), + ); + }); + }); + + describe("stablecoin choice", () => { + const EURC = EURC_ADDRESSES["eip155:84532"]; + const eurcPayload = { + accepted: { network: "eip155:84532", scheme: "batch-settlement", asset: EURC }, + payload: { type: "voucher" }, + }; + + it("prices a EURC channel on the EUR rate card, with EURC's own domain", async () => { + mockExtractPaymentPayload.mockReturnValue(eurcPayload); + // 200 prompt + 800 completion: 1300 on the USD card (the settle tests above), 1288 on the + // EUR card (200 × €0.44/M + 800 × €1.50/M). Distinct on purpose — it shows which card priced it. + mockCallLLMAPI.mockResolvedValue( + openAiCompletion("answer", { + prompt_tokens: 200, + completion_tokens: 800, + total_tokens: 1000, + }), + ); + + const res = await handle(makeEvent() as never, {}); + expect(res.statusCode).toBe(200); + + // Ceiling: 20000 tokens × €1.50/M = 30000. Settle: 88 + 1200 = 1288. + expect(mockVerifyPayment).toHaveBeenCalledWith( + eurcPayload, + expect.objectContaining({ + asset: EURC, + amount: "30000", + extra: expect.objectContaining({ name: "EURC" }), + }), + ); + expect(mockSettlePayment).toHaveBeenCalledWith( + eurcPayload, + expect.objectContaining({ asset: EURC, amount: "1288" }), + ); + }); + + it("refuses a token that is not a stablecoin on that network", async () => { + mockExtractPaymentPayload.mockReturnValue({ + ...eurcPayload, + accepted: { ...eurcPayload.accepted, asset: "0x0000000000000000000000000000000000000001" }, + }); + + const res = await handle(makeEvent() as never, {}); + + expect(res.statusCode).toBe(402); + expect(mockVerifyPayment).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/bunny_js/test/mcp.test.ts b/bunny_js/test/mcp.test.ts new file mode 100644 index 000000000..001262806 --- /dev/null +++ b/bunny_js/test/mcp.test.ts @@ -0,0 +1,471 @@ +/** + * Pins the stateless MCP endpoint (docs.x402.org → MCP; modelcontextprotocol.io spec): + * the JSON-RPC surface a no-session server must answer, and the translation layer that + * relays tools/call to the REAL twin routers — so these tests exercise the actual challenge + * configs, cost rules, and payment paths of `searchapi_routes.ts` / `llm_routes.ts`, not a + * parallel implementation. The x402 SDK surface is mocked the way the handlers' own + * suites mock it (S3 and secrets stay out); everything between the MCP request and the + * SDK — event synthesis, the payment-header encoding, the 402/200 translation — is REAL. + */ +import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest"; + +const USDC_TESTNET = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"; +const USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; +/** The seller's own address (`NFT_WALLET_PUBLIC_KEY`) — Anvil account #2, as elsewhere. */ +const RECEIVER = "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"; + +const { + mockVerifyPayment, + mockSettlePayment, + mockCreatePaymentRequiredResponse, + mockEnhancePaymentRequirements, + mockCreateLLMResourceServer, + mockReleaseLock, + mockCallLLMAPI, + mockSearchWeb, +} = vi.hoisted(() => ({ + mockVerifyPayment: vi.fn(), + mockSettlePayment: vi.fn(), + mockCreatePaymentRequiredResponse: vi.fn(), + // Echoes the base requirements back, as the handlers' own suites do — enough for the + // REAL createBatchSettlementPaymentRequirements to build accepts from the real config. + mockEnhancePaymentRequirements: vi.fn(), + mockCreateLLMResourceServer: vi.fn(), + mockReleaseLock: vi.fn(), + mockCallLLMAPI: vi.fn(), + mockSearchWeb: vi.fn(), +})); + +// The seller SDK surface is mocked exactly as far as the handlers need (the resource +// server wants S3 and a receiver-authorizer key); everything else in x402_server.ts — +// createBatchSettlementPaymentRequirements, create402Response, extractPaymentPayload, +// createSettlementHeaders — runs REAL, so the challenge configs that flow through the +// translation are the production ones. +vi.mock("../src/x402_server.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createLLMResourceServer: mockCreateLLMResourceServer, + releaseLock: mockReleaseLock, + }; +}); + +vi.mock("../src/llm_service.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + callLLMAPI: mockCallLLMAPI, + // Real formula (matches llm_service.ts's actual tokensToCost — see + // test/sc_llm_x402.test.ts's mock for the rationale): the settlement-amount tests + // depend on it computing, not on a fixed stub. + tokensToCost: ( + usage: { + prompt_tokens: bigint | number | string; + completion_tokens: bigint | number | string; + }, + provider: string, + symbol: "USDC" | "EURC" | "EURe", + decimals: number, + ) => { + const RATES: Record> = { + mistral: { + USDC: { in: 50n, out: 150n, den: 100n }, + EURC: { in: 44n, out: 150n, den: 100n }, + EURe: { in: 44n, out: 150n, den: 100n }, + }, + }; + const rate = RATES[provider]?.[symbol]; + if (!rate) throw new Error(`Unknown LLM provider: ${provider}`); + const p = BigInt(usage.prompt_tokens); + const c = BigInt(usage.completion_tokens); + const base = (p * rate.in + c * rate.out) / rate.den; + return decimals === 6 ? base : base * 10n ** BigInt(decimals - 6); + }, + }; +}); + +vi.mock("../src/search_service.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, searchWeb: mockSearchWeb }; +}); + +type JsonRpcResponse = import("../src/mcp_routes.js").JsonRpcResponse; + +describe("mcp_routes (stateless paid MCP endpoint)", () => { + let handle: typeof import("../src/mcp_routes.js").handleMCPRequest; + + beforeAll(async () => { + process.env.NFT_WALLET_PUBLIC_KEY = RECEIVER; + ({ handleMCPRequest: handle } = await import("../src/mcp_routes.js")); + }); + + beforeEach(() => { + vi.clearAllMocks(); + applyMocks(); + }); + + /** POST one JSON-RPC message through the native handler. */ + async function rpc(message: unknown, meta?: Record): Promise { + const body = + typeof message === "string" + ? message + : JSON.stringify( + typeof meta === "object" && message !== null && typeof message === "object" + ? { ...message, params: { ...(message as { params?: object }).params, _meta: meta } } + : message, + ); + const res = await handle( + new Request("https://mcp-agent.fretchen.eu/mcp", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }), + ); + expect(res.status).toBe(200); + return JSON.parse(await res.text()) as JsonRpcResponse; + } + + const INIT = { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "t", version: "0" }, + }, + }; + + describe("transport surface (stateless Streamable HTTP)", () => { + it("answers OPTIONS with CORS", async () => { + const res = await handle( + new Request("https://mcp-agent.fretchen.eu/mcp", { method: "OPTIONS" }), + ); + expect(res.status).toBe(200); + expect(res.headers.get("access-control-allow-origin")).toBe("*"); + }); + + /** + * The DECIDED deviation from the scw original: GET answers like a normal origin + * instead of a blanket 405 — 404 with the honest stateless notice for paths with no + * content, 200 discovery HTML at the root when the caller accepts HTML, and DELETE + * keeps 405 (session termination is genuinely not a thing here, per the MCP spec). + */ + it("answers GET like a normal origin (404 + notice), not the scw blanket 405", async () => { + const res = await handle(new Request("https://mcp-agent.fretchen.eu/mcp", { method: "GET" })); + expect(res.status).toBe(404); + const body = JSON.parse(await res.text()) as { error: string }; + expect(body.error).toContain("POST only"); + }); + + it("serves discovery HTML at the origin root when the caller accepts HTML", async () => { + const res = await handle( + new Request("https://mcp-agent.fretchen.eu/", { + method: "GET", + headers: { Accept: "text/html" }, + }), + ); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + expect(await res.text()).toContain('rel="icon"'); + }); + + it("answers DELETE with 405 Allow: POST — no sessions to terminate", async () => { + const res = await handle( + new Request("https://mcp-agent.fretchen.eu/mcp", { method: "DELETE" }), + ); + expect(res.status).toBe(405); + expect(res.headers.get("allow")).toBe("POST"); + }); + + it("answers a parse failure with JSON-RPC error -32700", async () => { + const res = await rpc("not json{"); + if (!("error" in res)) throw new Error("expected error response"); + expect(res.error.code).toBe(-32700); + expect(res.id).toBeNull(); + }); + + /** + * The wrapper reads the POST body capped: an oversized message is refused with 413 + * before it is fully buffered, parsed, or paid for. This exercises the STREAM path + * (no Content-Length, so the per-chunk accounting is what trips the cap). + */ + it("refuses a streamed POST body over the 1 MB cap with 413, CORS intact", async () => { + const chunk = new TextEncoder().encode("x".repeat(64 * 1024)); + const stream = new ReadableStream({ + start(controller) { + for (let i = 0; i < 17; i++) { + // 17 x 64 KB = 1,088,000 bytes — past the 1,000,000-byte cap mid-stream. + controller.enqueue(chunk); + } + controller.close(); + }, + }); + const res = await handle( + new Request("https://mcp-agent.fretchen.eu/mcp", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: stream, + // undici requires it for a streamed body; the edge runtime does not send one. + duplex: "half", + } as RequestInit), + ); + expect(res.status).toBe(413); + expect(res.headers.get("access-control-allow-origin")).toBe("*"); + }); + + it("rejects JSON-RPC batches — this server speaks single messages", async () => { + const res = await rpc([{ jsonrpc: "2.0", id: 1, method: "ping" }]); + if (!("error" in res)) throw new Error("expected error response"); + expect(res.error.code).toBe(-32600); + }); + + it("answers a notification with 202 and an empty body", async () => { + const res = await handle( + new Request("https://mcp-agent.fretchen.eu/mcp", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }), + }), + ); + expect(res.status).toBe(202); + expect(await res.text()).toBe(""); + }); + }); + + describe("initialize", () => { + it("negotiates a supported protocol version and announces tool capability", async () => { + const res = await rpc(INIT); + if ("error" in res) throw new Error("expected result"); + expect(res.result.protocolVersion).toBe("2025-06-18"); + expect(res.result.capabilities).toEqual({ tools: {} }); + expect(res.result.serverInfo).toEqual({ name: "fretchen-mcp", version: "1.0.0" }); + }); + + it("falls back to its own version for an unknown client version", async () => { + const res = await rpc({ ...INIT, params: { ...INIT.params, protocolVersion: "1999-01-01" } }); + if ("error" in res) throw new Error("expected result"); + expect(res.result.protocolVersion).toBe("2025-06-18"); + }); + }); + + describe("tools/list", () => { + it("lists chat_completion and web_search with the real API's input schemas", async () => { + const res = await rpc({ jsonrpc: "2.0", id: 2, method: "tools/list" }); + if ("error" in res) throw new Error("expected result"); + const tools = res.result.tools as Array<{ + name: string; + inputSchema: Record; + }>; + expect(tools.map((t) => t.name).sort()).toEqual(["chat_completion", "web_search"]); + const llm = tools.find((t) => t.name === "chat_completion"); + const search = tools.find((t) => t.name === "web_search"); + expect(Object.keys((llm?.inputSchema.properties ?? {}) as object)).toContain("messages"); + expect(Object.keys((search?.inputSchema.properties ?? {}) as object)).toEqual(["q"]); + }); + }); + + // ════════════════════════════════════════════════════════════════════════════ + // The translation layer: tools/call relays to the REAL handlers, so the security and + // cost rules below are the handlers' own, flowing through the real challenge configs. + // ════════════════════════════════════════════════════════════════════════════ + + describe("tools/call — the payment gate (no free path, by the real handlers)", () => { + it("answers an unpaid web_search with the real mainnet-only demands and never touches Brave", async () => { + const res = await rpc({ + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { name: "web_search", arguments: { q: "x402" } }, + }); + if ("error" in res) + throw new Error(`expected result, got: ${JSON.stringify(res).slice(0, 300)}`); + const result = res.result as { + isError: boolean; + structuredContent: { x402Version: number; accepts: Array<{ network: string }> }; + }; + expect(result.isError).toBe(true); + expect(result.structuredContent.x402Version).toBe(2); + expect(result.structuredContent.accepts.length).toBeGreaterThan(0); + // The security pin, on the real challenge path: search is mainnet-only — testnet + // coins must never buy metered Brave queries. A translation layer that minted its + // own accepts broke this once; it cannot anymore. + for (const accept of result.structuredContent.accepts) { + expect(["eip155:10", "eip155:8453"]).toContain(accept.network); + } + expect(mockSearchWeb).not.toHaveBeenCalled(); + }); + + it("answers an unpaid chat_completion with demands that DO include testnet", async () => { + const res = await rpc({ + jsonrpc: "2.0", + id: 4, + method: "tools/call", + params: { + name: "chat_completion", + arguments: { model: "mistral-large-latest", messages: [{ role: "user", content: "hi" }] }, + }, + }); + if ("error" in res) + throw new Error(`expected result, got: ${JSON.stringify(res).slice(0, 300)}`); + const result = res.result as { + isError: boolean; + structuredContent: { accepts: Array<{ network: string }> }; + }; + expect(result.isError).toBe(true); + // The intentional asymmetry: the chat offers testnet because completions mock there. + expect(result.structuredContent.accepts.map((a) => a.network)).toContain("eip155:84532"); + expect(mockCallLLMAPI).not.toHaveBeenCalled(); + }); + + it("runs and settles a paid chat_completion on testnet via the handler's own mock rule", async () => { + const res = await rpc( + { + jsonrpc: "2.0", + id: 5, + method: "tools/call", + params: { + name: "chat_completion", + arguments: { + model: "mistral-large-latest", + messages: [{ role: "user", content: "hi" }], + }, + }, + }, + paymentMeta("eip155:84532", USDC_TESTNET), + ); + if ("error" in res) + throw new Error(`expected result, got: ${JSON.stringify(res).slice(0, 300)}`); + expect(mockVerifyPayment).toHaveBeenCalledTimes(1); + expect(mockSettlePayment).toHaveBeenCalledTimes(1); + // The REAL testnet-mock rule, exercised through MCP: a worthless-coin payment + // must buy a mock completion, never real Mistral spend. + expect(mockCallLLMAPI.mock.calls[0][1]).toBe(true); + // And the REAL usage-derived settlement: the mock completion's usage (5 prompt + + // 7 completion tokens) settles for (5*50 + 7*150)/100 = 13 atomic USDC, not the + // advertised ceiling — the parity the REST chat has, inherited by construction. + const settleRequirements = mockSettlePayment.mock.calls[0][1] as { amount: string }; + expect(settleRequirements.amount).toBe("13"); + const result = res.result as { + isError?: boolean; + structuredContent: { choices: Array<{ message: { content: string } }> }; + _meta: Record; + }; + expect(result.isError).not.toBe(true); + expect(result.structuredContent.choices[0].message.content).toBeDefined(); + // The settlement receipt, decoded from the handler's Payment-Response header. + expect(result._meta["x402/payment-response"]).toMatchObject({ success: true }); + }); + + it("rejects a paid web_search on testnet with the handler's own network refusal", async () => { + const res = await rpc( + { + jsonrpc: "2.0", + id: 6, + method: "tools/call", + params: { name: "web_search", arguments: { q: "x402" } }, + }, + paymentMeta("eip155:84532", USDC_TESTNET), + ); + if ("error" in res) + throw new Error(`expected result, got: ${JSON.stringify(res).slice(0, 300)}`); + const result = res.result as { + isError: boolean; + structuredContent: { error: string }; + }; + expect(result.isError).toBe(true); + expect(result.structuredContent.error).toContain("Unsupported or missing network"); + // Refused before anything metered runs. + expect(mockSearchWeb).not.toHaveBeenCalled(); + expect(mockVerifyPayment).not.toHaveBeenCalled(); + }); + + it("runs and settles a paid web_search on mainnet and returns the receipt", async () => { + const res = await rpc( + { + jsonrpc: "2.0", + id: 7, + method: "tools/call", + params: { name: "web_search", arguments: { q: "x402" } }, + }, + paymentMeta("eip155:8453", USDC_BASE), + ); + if ("error" in res) + throw new Error(`expected result, got: ${JSON.stringify(res).slice(0, 300)}`); + expect(mockVerifyPayment).toHaveBeenCalledTimes(1); + expect(mockSearchWeb).toHaveBeenCalledWith("x402"); + expect(mockSettlePayment).toHaveBeenCalledTimes(1); + const result = res.result as { + isError?: boolean; + structuredContent: { results: unknown[] }; + _meta: Record; + }; + expect(result.isError).not.toBe(true); + expect(Array.isArray(result.structuredContent.results)).toBe(true); + expect(result._meta["x402/payment-response"]).toMatchObject({ success: true }); + }); + + it("answers a call for an unknown tool with -32602", async () => { + const res = await rpc({ + jsonrpc: "2.0", + id: 8, + method: "tools/call", + params: { name: "no_such_tool", arguments: {} }, + }); + if (!("error" in res)) throw new Error("expected error"); + expect(res.error.code).toBe(-32602); + }); + }); +}); + +/** The canonical PaymentPayload shape, as the official client puts it in `_meta` and the + * REST buyers put in the PAYMENT-SIGNATURE header (accepted at the top level — see the + * handlers' own `payment()` fixtures). */ +function paymentMeta(network: string, asset: string): Record { + return { + "x402/payment": { + x402Version: 2, + accepted: { network, asset }, + payload: { type: "voucher", voucher: "0xsigned" }, + }, + }; +} + +/** Re-applies every mock implementation clearAllMocks() wipes. */ +function applyMocks(): void { + mockVerifyPayment.mockImplementation(async () => ({ + isValid: true, + payer: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb7", + })); + mockSettlePayment.mockImplementation(async () => ({ success: true, transaction: "0xsettled" })); + mockCreatePaymentRequiredResponse.mockImplementation((accepts: unknown[]) => ({ + x402Version: 2, + resource: { url: "test://x" }, + accepts, + error: "Payment verification failed", + })); + mockEnhancePaymentRequirements.mockImplementation(async (base: unknown) => base); + mockCreateLLMResourceServer.mockImplementation(() => ({ + resourceServer: { + verifyPayment: mockVerifyPayment, + settlePayment: mockSettlePayment, + createPaymentRequiredResponse: mockCreatePaymentRequiredResponse, + createPaymentCancellationDispatcher: vi.fn(() => ({ cancel: async () => ({}) })), + }, + scheme: { enhancePaymentRequirements: mockEnhancePaymentRequirements }, + schemeFor: vi.fn(), + claimSchemeFor: vi.fn(), + })); + mockCallLLMAPI.mockImplementation(async () => ({ + id: "chatcmpl-test", + object: "chat.completion", + created: 1_760_000_000, + model: "mistral-large-latest", + choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + usage: { prompt_tokens: 5, completion_tokens: 7, total_tokens: 12 }, + })); + mockSearchWeb.mockImplementation(async (q: string) => ({ + results: [{ title: q, url: "https://example.com/", content: "…" }], + })); +} +applyMocks(); diff --git a/bunny_js/test/routes.test.ts b/bunny_js/test/routes.test.ts index e933d56b2..ecdd29d3f 100644 --- a/bunny_js/test/routes.test.ts +++ b/bunny_js/test/routes.test.ts @@ -123,7 +123,7 @@ async function handle(request: Request) { return { statusCode: res.status, headers, body: await res.text() }; } -let handleRequest: typeof import("../src/routes.js").handleRequest; +let handleRequest: typeof import("../src/searchapi_routes.js").handleRequest; let validAuth: string; beforeEach(async () => { @@ -190,7 +190,7 @@ beforeEach(async () => { "fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => braveResponse() }), ); - handleRequest = (await import("../src/routes.js")).handleRequest; + handleRequest = (await import("../src/searchapi_routes.js")).handleRequest; }); afterEach(() => { diff --git a/bunny_js/tsdown.config.ts b/bunny_js/tsdown.config.ts index b2fc7c8b6..84b4c3d71 100644 --- a/bunny_js/tsdown.config.ts +++ b/bunny_js/tsdown.config.ts @@ -44,7 +44,7 @@ const nodeBuiltinPlugin = { }; export default defineConfig({ - entry: ["src/index.ts"], + entry: ["src/searchapi.ts"], format: "esm", // The bundle runs in bunny's Deno isolate, not Node: "neutral" keeps Node builtins // external (resolved by Deno at runtime via the plugin above) instead of letting the @@ -52,6 +52,11 @@ export default defineConfig({ platform: "neutral", target: "es2022", outDir: "dist", + // The three build:* scripts invoke tsdown once per entry (rolldown cannot emit multiple + // single-file bundles in one pass), and each invocation cleans outDir by default — without + // this, `npm run build` would end with only the LAST entry's bundle in dist/, and the + // deploy steps for the other two would ship stale or missing files. + clean: false, fixedExtension: false, dts: false, // Not deployed (the script upload is dist/index.js alone) but kept next to it so a