diff --git a/scw_js/README.md b/scw_js/README.md index 6c528392d..0ecf8db51 100644 --- a/scw_js/README.md +++ b/scw_js/README.md @@ -118,6 +118,18 @@ API for reviewing, editing, and approving AI-generated social media drafts. Used | ---------- | -------- | -------------------------------------------- | | GenImNFTv4 | Optimism | `0x80f95d330417a4acEfEA415FE9eE28db7A0A1Cdb` | +### RPC configuration + +Direct on-chain calls (image mint, legacy LLMv1 merkle settlement) use `getRpcUrl` from +`@fretchen/chain-utils`, falling back to each chain's public endpoint when unset — fine +for local dev, but the public endpoints are aggressively rate-limited under real traffic. +Set a dedicated provider (e.g. Alchemy) as a Scaleway secret for production: + +- `RPC_URL_EIP155_10` — Optimism mainnet +- `RPC_URL_EIP155_8453` — Base mainnet +- `RPC_URL_EIP155_11155420` — Optimism Sepolia +- `RPC_URL_EIP155_84532` — Base Sepolia + ## 🗄️ S3 Storage Layout & Data Classification All functions share the `my-imagestore` bucket (region `nl-ams`). Access is controlled **per object** (object ACL), independent of the bucket ACL. When writing, only publish what is meant to be public — the table below is the source of truth for whether a prefix is public. diff --git a/scw_js/genimg_x402_token.ts b/scw_js/genimg_x402_token.ts index 5897cf3c7..794146302 100644 --- a/scw_js/genimg_x402_token.ts +++ b/scw_js/genimg_x402_token.ts @@ -5,6 +5,7 @@ import { getUSDCConfig, isTestnet, loadPrivateKey, + getRpcUrl, } from "@fretchen/chain-utils"; import { parseJsonBody } from "./utils.js"; import { @@ -492,8 +493,11 @@ async function handle( console.log(`🔗 Using chain: ${viemChain.name} (${clientNetwork})`); const chain = viemChain as unknown as Chain; - const publicClient = createPublicClient({ chain, transport: http() }); - const walletClient = createWalletClient({ account, chain, transport: http() }); + // Falls back to the chain's public endpoint when unset — fine for testnets, but + // set RPC_URL_ for anything carrying real traffic (see getRpcUrl). + const rpcUrl = getRpcUrl(clientNetwork!); + const publicClient = createPublicClient({ chain, transport: http(rpcUrl) }); + const walletClient = createWalletClient({ account, chain, transport: http(rpcUrl) }); const contract = getContract({ address: contractAddress, diff --git a/scw_js/llm_service.ts b/scw_js/llm_service.ts index 0fa76bb3e..79b126695 100644 --- a/scw_js/llm_service.ts +++ b/scw_js/llm_service.ts @@ -1,16 +1,56 @@ import { getContract, createPublicClient, createWalletClient, http } from "viem"; import { getChain, getLLMv1ContractConfig } from "./getChain.js"; -import { loadPrivateKey } from "@fretchen/chain-utils"; +import { loadPrivateKey, getRpcUrl, toCAIP2 } from "@fretchen/chain-utils"; import { getS3Object, putS3Object } from "@fretchen/s3-utils"; import { StandardMerkleTree } from "@openzeppelin/merkle-tree"; import { privateKeyToAccount } from "viem/accounts"; import pino from "pino"; -const MODEL_NAME = "meta-llama/Llama-3.3-70B-Instruct"; -const ENDPOINT = "https://openai.inference.de-txl.ionos.com/v1/chat/completions"; const MERKLE_TREE_FILE = "merkle/trees.json"; const logger = pino({ level: process.env.LOG_LEVEL ?? "info" }); +interface LLMProviderConfig { + displayName: string; // for error messages/logs — e.g. "Could not reach IONOS: ..." + baseUrl: string; // no trailing "/chat/completions" — appended at call time + defaultModel: string; + apiKeyEnvVar: string; + // Price per 1,000,000 tokens, num/den to stay exact bigint math. USD for mistral; + // EUR for ionos (see convertTokensToUsdcCost's doc comment on the EUR/USDC simplification). + inputPricePerMillion: { num: bigint; den: bigint }; + outputPricePerMillion: { num: bigint; den: bigint }; +} + +const LLM_PROVIDERS: Record = { + ionos: { + displayName: "IONOS", + baseUrl: "https://openai.inference.de-txl.ionos.com/v1", + defaultModel: "meta-llama/Llama-3.3-70B-Instruct", + apiKeyEnvVar: "IONOS_API_TOKEN", + inputPricePerMillion: { num: 71n, den: 100n }, + outputPricePerMillion: { num: 71n, den: 100n }, // blended rate, unchanged — legacy sc_llm.ts path + }, + mistral: { + displayName: "Mistral", + baseUrl: "https://api.mistral.ai/v1", + defaultModel: "mistral-large-latest", + apiKeyEnvVar: "MISTRAL_API_KEY", + // Mistral Large 3, mistral.ai/pricing/api (fetched 2026-07-21) — re-verify before any + // mainnet cutover; Mistral has repriced materially before. + inputPricePerMillion: { num: 50n, den: 100n }, + outputPricePerMillion: { num: 150n, den: 100n }, + }, +}; + +function getLLMProviderConfig(provider: string): LLMProviderConfig { + const config = LLM_PROVIDERS[provider]; + if (!config) { + throw new Error( + `Unknown LLM provider: ${provider}. Valid providers: ${Object.keys(LLM_PROVIDERS).join(", ")}`, + ); + } + return config; +} + export interface LLMMessage { role: string; content: string; @@ -26,7 +66,11 @@ interface LLMResponse { model: string; } -export async function callLLMAPI(prompt: LLMMessage[], dummy = false): Promise { +export async function callLLMAPI( + prompt: LLMMessage[], + dummy = false, + provider = "ionos", +): Promise { if (dummy) { return { content: "I am a placeholder for the LLM response", @@ -34,11 +78,12 @@ export async function callLLMAPI(prompt: LLMMessage[], dummy = false): Promise { const activeChain = getChain(); - const publicClient = createPublicClient({ chain: activeChain, transport: http() }); + // Falls back to the chain's public endpoint when unset — fine for testnets, but + // set RPC_URL_ for anything carrying real traffic (see getRpcUrl). + const rpcUrl = getRpcUrl(toCAIP2(activeChain.id)); + const publicClient = createPublicClient({ chain: activeChain, transport: http(rpcUrl) }); const { address: contractAddress, abi: llmAbi } = getLLMv1ContractConfig(); const contract = getContract({ @@ -321,8 +395,15 @@ export async function processMerkleTree( const account = privateKeyToAccount(loadPrivateKey("NFT_WALLET_PRIVATE_KEY")); const activeChain = getChain(); - const publicClient = createPublicClient({ chain: activeChain, transport: http() }); - const walletClient = createWalletClient({ account, chain: activeChain, transport: http() }); + // Falls back to the chain's public endpoint when unset — fine for testnets, but + // set RPC_URL_ for anything carrying real traffic (see getRpcUrl). + const rpcUrl = getRpcUrl(toCAIP2(activeChain.id)); + const publicClient = createPublicClient({ chain: activeChain, transport: http(rpcUrl) }); + const walletClient = createWalletClient({ + account, + chain: activeChain, + transport: http(rpcUrl), + }); const { address: contractAddress, abi: llmAbi } = getLLMv1ContractConfig(); const llmContract = getContract({ diff --git a/scw_js/notebooks/sc_llm_x402_buyer.ipynb b/scw_js/notebooks/sc_llm_x402_buyer.ipynb index 4cdc833ad..a4a02a6f0 100644 --- a/scw_js/notebooks/sc_llm_x402_buyer.ipynb +++ b/scw_js/notebooks/sc_llm_x402_buyer.ipynb @@ -3,7 +3,50 @@ { "cell_type": "markdown", "metadata": {}, - "source": "# `sc_llm_x402` Buyer Notebook — real end-to-end chat payment (Deno/TypeScript)\n\nDrives the **real, locally-running** `sc_llm_x402.ts` batch-settlement chat handler as a buyer would —\nthe missing real-server verification for Phase B, and a literal, runnable blueprint for\n`website/hooks/useX402Chat.ts` (Phase C, not yet built). 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\ntalks 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\n1. **Deno Jupyter kernel** — `deno jupyter --install` (same global kernel as the sibling facilitator notebooks).\n2. **`scw_js/.env`** needs:\n - `TEST_WALLET_PRIVATE_KEY` — the buyer wallet, funded with Base Sepolia USDC (get some from\n https://faucet.circle.com/). Purely a notebook-testing convenience — no scw_js production code\n 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 - `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).\n3. **Start the local server** (separate terminal): `cd scw_js && npm run dev:llmx402` — listens on `:8085`,\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 Base Sepolia**.\n4. **Or skip step 3 entirely** and set `USE_DEPLOYED = true` in the network-selection cell below to hit\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.\n" + "source": [ + "# `sc_llm_x402` 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 Mainnet: a real, funded wallet). 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 server** (separate terminal): `cd scw_js && npm run dev:llmx402` — listens on `:8085`,\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 set `USE_DEPLOYED = true` in the network-selection cell below to hit\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", @@ -46,18 +89,71 @@ "source": [ "## Network selection\n", "\n", - "Base Sepolia only — the canonical `BATCH_SETTLEMENT_ADDRESS` has no deployment on Optimism Sepolia\n", - "(confirmed in the facilitator's own buyer spike). `getBatchSettlementNetworks()` on the server side\n", - "already restricts to Base Sepolia / Base mainnet / Optimism mainnet; this notebook only ever offers\n", - "the testnet.\n" + "`getBatchSettlementNetworks()` on the server side (`x402_server.ts`) only allows **Base** —\n", + "`[\"eip155:8453\", \"eip155:84532\"]`. Optimism is excluded entirely (a separate, pre-existing\n", + "`@x402/evm` `DEFAULT_STABLECOINS` gap, not a spike limitation — filed upstream, see\n", + "`x402_facilitator/upstream/`).\n", + "\n", + "| Network | CAIP-2 | Real Mistral call? |\n", + "|---|---|---|\n", + "| Base Sepolia (testnet) | `eip155:84532` | No — the server always mocks (`isTestnet`), regardless of what the client sends |\n", + "| Base Mainnet | `eip155:8453` | **Yes** — real USDC settlement + a real, billed Mistral completion |\n", + "\n", + "**One flag decides everything**: `USE_MAINNET` below selects the network — and since the server mocks\n", + "every testnet request unconditionally, choosing Mistral *is* choosing mainnet. There's no separate\n", + "confirmation step; flipping this one flag is the deliberate action.\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, - "outputs": [], - "source": "const NETWORK = \"eip155:84532\"; // Base Sepolia\n\n// Toggle between the local dev server and the real deployed Scaleway function — same code path\n// either way, since the channel is on-chain state keyed by (buyer, receiver, network, asset), not\n// by which server queried it. Local: `cd scw_js && npm run dev:llmx402` (see prerequisites above).\nconst USE_DEPLOYED = false;\nconst SERVICE_URL = USE_DEPLOYED\n ? \"https://mypersonaljscloudivnad9dy-llmx402.functions.fnc.fr-par.scw.cloud\"\n : \"http://localhost:8085\";\n\nconsole.log(`🧪 Base Sepolia (${NETWORK}) — service: ${SERVICE_URL}`);" + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "🚨 REAL MONEY on Base Mainnet\n", + " eip155:8453 • USDC USD Coin @ 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913\n", + " Service: http://localhost:8085\n" + ] + } + ], + "source": [ + "import { base, baseSepolia } from \"npm:viem@2/chains\";\n", + "\n", + "const USE_MAINNET = false; // ⚠️ true = REAL MONEY: mainnet USDC settlement + a real, billed Mistral call.\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", + " 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", + " explorer: \"https://basescan.org\", faucet: \"Bridge: https://bridge.base.org\",\n", + " },\n", + "};\n", + "const config = NETWORK_CONFIG[USE_MAINNET ? \"base-mainnet\" : \"base-testnet\"];\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 scw_js && npm run dev:llmx402` (see prerequisites above).\n", + "const USE_DEPLOYED = false;\n", + "const SERVICE_URL = USE_DEPLOYED\n", + " ? \"https://mypersonaljscloudivnad9dy-llmx402.functions.fnc.fr-par.scw.cloud\"\n", + " : \"http://localhost:8085\";\n", + "\n", + "console.log(USE_MAINNET ? `🚨 REAL MONEY on ${config.networkName}` : `🧪 ${config.networkName}`);\n", + "console.log(` ${NETWORK} • USDC ${config.usdcName} @ ${config.usdcAddress}`);\n", + "console.log(` Service: ${SERVICE_URL}`);" + ] }, { "cell_type": "markdown", @@ -78,17 +174,9 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "✅ buyer client ready, registered for eip155:84532\n" - ] - } - ], + "outputs": [], "source": [ "import { x402Client, wrapFetchWithPayment, x402HTTPClient } from \"npm:@x402/fetch@^2.17.0\";\n", "import { toClientEvmSigner } from \"npm:@x402/evm@^2.17.0\";\n", @@ -96,9 +184,9 @@ " BatchSettlementEvmScheme,\n", " type ClientChannelStorage,\n", " type BatchSettlementClientContext,\n", + " type BatchSettlementDepositStrategyContext,\n", "} from \"npm:@x402/evm@^2.17.0/batch-settlement/client\";\n", "import { createPublicClient, http } from \"npm:viem@2\";\n", - "import { baseSepolia } from \"npm:viem@2/chains\";\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", @@ -110,7 +198,9 @@ "// 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", - "const publicClient = createPublicClient({ chain: baseSepolia, transport: http() });\n", + "// chain comes from `config` (network-selection cell) — mainnet/testnet selects both the\n", + "// network AND which chain object the public client reads against.\n", + "const publicClient = createPublicClient({ chain: config.chain, transport: http() });\n", "const buyerSigner = toClientEvmSigner(\n", " { address: account.address, signTypedData: (a: any) => account.signTypedData(a) },\n", " publicClient,\n", @@ -133,7 +223,22 @@ "\n", "// Deno exposes the global `localStorage` (persists across kernel restarts). Browser: window.localStorage.\n", "const buyerStorage = new WebStorageClientChannelStorage(localStorage);\n", - "const buyerScheme = new BatchSettlementEvmScheme(buyerSigner, { storage: buyerStorage });\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 = 500_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", @@ -142,6 +247,55 @@ "console.log(\"✅ buyer client ready, registered for\", NETWORK);" ] }, + { + "cell_type": "markdown", + "id": "22d28231", + "metadata": {}, + "source": [ + "## Pre-flight — buyer USDC 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 USDC *before* message #1 tries to open a channel.\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": null, + "id": "aae724d6", + "metadata": {}, + "outputs": [], + "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", + "// 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 buyerUsdc = await publicClient.readContract({\n", + " address: config.usdcAddress,\n", + " abi: erc20BalanceOfAbi,\n", + " functionName: \"balanceOf\",\n", + " args: [account.address],\n", + "});\n", + "\n", + "console.log(`💵 Buyer USDC: ${formatUnits(buyerUsdc, 6)} (estimated deposit ≈ ${formatUnits(ESTIMATED_DEPOSIT_ATOMIC, 6)})`);\n", + "if (buyerUsdc < ESTIMATED_DEPOSIT_ATOMIC) {\n", + " console.log(` ⚠️ insufficient USDC on ${config.networkName} — ${config.faucet}`);\n", + "} else {\n", + " console.log(` ✅ enough USDC to open the channel`);\n", + "}" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -152,44 +306,58 @@ "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" + "`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": 4, + "execution_count": 5, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "📡 status=200 elapsed=11569ms\n", + "📡 status=200 elapsed=12901ms\n", "📨 body: {\n", - " \"content\": \"I am a placeholder for the LLM response\",\n", + " \"content\": \"The capital of France is **Paris**.\",\n", " \"usage\": {\n", - " \"prompt_tokens\": 5,\n", - " \"completion_tokens\": 15,\n", - " \"total_tokens\": 15\n", + " \"prompt_tokens\": 10,\n", + " \"total_tokens\": 19,\n", + " \"completion_tokens\": 9,\n", + " \"prompt_tokens_details\": {\n", + " \"cached_tokens\": 0\n", + " }\n", " },\n", - " \"model\": \"placeholder model\"\n", + " \"model\": \"mistral-large-latest\"\n", "}\n", "🧾 settlement receipt: {\n", " \"success\": true,\n", - " \"payer\": \"0x553179556fc2a39e535d65b921e01fa995e79101\",\n", - " \"transaction\": \"\",\n", - " \"network\": \"eip155:84532\",\n", - " \"amount\": \"\",\n", + " \"payer\": \"0x553179556FC2A39e535D65b921e01fA995E79101\",\n", + " \"transaction\": \"0xcf5f89bae9626c199793dd75fef91afc7b097050d674347bc1b7b851c19c10ff\",\n", + " \"network\": \"eip155:8453\",\n", " \"extra\": {\n", " \"channelState\": {\n", - " \"channelId\": \"0xdd9e576d5d30096bce8ed29916ee2d3faaf3a34269011b881eccfb0e082719d7\",\n", - " \"balance\": \"0\",\n", + " \"channelId\": \"0xbd47699c24d5fbd16eb316de548ab39e36931a953ee33aa30dc9b28ff5507446\",\n", + " \"balance\": \"15000\",\n", " \"totalClaimed\": \"0\",\n", " \"withdrawRequestedAt\": 0,\n", " \"refundNonce\": \"0\",\n", - " \"chargedCumulativeAmount\": \"2840\"\n", + " \"chargedCumulativeAmount\": \"18\"\n", " },\n", - " \"chargedAmount\": \"1420\"\n", + " \"chargedAmount\": \"18\"\n", " }\n", "}\n" ] @@ -209,12 +377,16 @@ " // `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", + " // No `useDummyData` field — matches the real client (useX402Chat.ts) exactly. Mock-vs-real\n", + " // is decided entirely server-side by NETWORK (see the network-selection cell): testnet always\n", + " // mocks, mainnet always calls the real Mistral API. There is nothing else to set here.\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({ data: { prompt: [{ role: \"user\", content }], useDummyData: true } }),\n", + " body: JSON.stringify({ data: { prompt: [{ role: \"user\", content }] } }),\n", " });\n", " } catch (err) {\n", " const elapsedMs = Math.round(performance.now() - started);\n", @@ -263,66 +435,62 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 6, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "📡 status=200 elapsed=9565ms\n", + "📡 status=200 elapsed=5345ms\n", "📨 body: {\n", - " \"content\": \"I am a placeholder for the LLM response\",\n", + " \"content\": \"The capital of Germany is **Berlin**. It has been the capital since the reunification of Germany in 1990, following the fall of the Berlin Wall in 1989. Before that, Bonn served as the capital of West Germany (Federal Republic of Germany) during the Cold War.\",\n", " \"usage\": {\n", - " \"prompt_tokens\": 5,\n", - " \"completion_tokens\": 15,\n", - " \"total_tokens\": 15\n", + " \"prompt_tokens\": 11,\n", + " \"total_tokens\": 74,\n", + " \"completion_tokens\": 63,\n", + " \"prompt_tokens_details\": {\n", + " \"cached_tokens\": 0\n", + " }\n", " },\n", - " \"model\": \"placeholder model\"\n", + " \"model\": \"mistral-large-latest\"\n", "}\n", "🧾 settlement receipt: {\n", " \"success\": true,\n", - " \"payer\": \"0x553179556FC2A39e535D65b921e01fA995E79101\",\n", - " \"transaction\": \"0x0b2e109339ae6f7995c086d53248697df90a24099683e20cf5d02866cfdec791\",\n", - " \"network\": \"eip155:84532\",\n", + " \"payer\": \"0x553179556fc2a39e535d65b921e01fa995e79101\",\n", + " \"transaction\": \"\",\n", + " \"network\": \"eip155:8453\",\n", + " \"amount\": \"\",\n", " \"extra\": {\n", " \"channelState\": {\n", - " \"channelId\": \"0xdd9e576d5d30096bce8ed29916ee2d3faaf3a34269011b881eccfb0e082719d7\",\n", - " \"balance\": \"21300\",\n", + " \"channelId\": \"0xbd47699c24d5fbd16eb316de548ab39e36931a953ee33aa30dc9b28ff5507446\",\n", + " \"balance\": \"0\",\n", " \"totalClaimed\": \"0\",\n", " \"withdrawRequestedAt\": 0,\n", " \"refundNonce\": \"0\",\n", - " \"chargedCumulativeAmount\": \"4260\"\n", + " \"chargedCumulativeAmount\": \"118\"\n", " },\n", - " \"chargedAmount\": \"1420\"\n", + " \"chargedAmount\": \"100\"\n", " }\n", "}\n", - "📡 status=200 elapsed=1403ms\n", + "📡 status=200 elapsed=23975ms\n", "📨 body: {\n", - " \"content\": \"I am a placeholder for the LLM response\",\n", - " \"usage\": {\n", - " \"prompt_tokens\": 5,\n", - " \"completion_tokens\": 15,\n", - " \"total_tokens\": 15\n", - " },\n", - " \"model\": \"placeholder model\"\n", - "}\n", + " \"content\": \"Italy is a fascinating country with a rich history, vibrant culture, and significant global influence. Here’s a quick overview of key aspects:\\n\\n### **1. Geography & Regions**\\n- **Location**: Southern Europe, shaped like a boot, surrounded by the Mediterranean Sea (Adriatic, Ionian, Tyrrhenian, and Ligurian Seas).\\n- **Regions**: 20 regions, including iconic ones like **Tuscany** (Florence, Siena), **Lombardy** (Milan), **Veneto** (Venice), **Lazio** (Rome), **Campania** (Naples, Pompeii), and **Sicily** (Palermo).\\n- **Landmarks**: The Alps (north), Apennine Mountains (spine\n", "🧾 settlement receipt: {\n", " \"success\": true,\n", - " \"payer\": \"0x553179556fc2a39e535d65b921e01fa995e79101\",\n", - " \"transaction\": \"\",\n", - " \"network\": \"eip155:84532\",\n", - " \"amount\": \"\",\n", + " \"payer\": \"0x553179556FC2A39e535D65b921e01fA995E79101\",\n", + " \"transaction\": \"0xebadfc6c97ee7988d69cb1c696649d96053f9a63f8da4ae7058ddf8ca798b673\",\n", + " \"network\": \"eip155:8453\",\n", " \"extra\": {\n", " \"channelState\": {\n", - " \"channelId\": \"0xdd9e576d5d30096bce8ed29916ee2d3faaf3a34269011b881eccfb0e082719d7\",\n", - " \"balance\": \"21300\",\n", + " \"channelId\": \"0xbd47699c24d5fbd16eb316de548ab39e36931a953ee33aa30dc9b28ff5507446\",\n", + " \"balance\": \"30000\",\n", " \"totalClaimed\": \"0\",\n", " \"withdrawRequestedAt\": 0,\n", " \"refundNonce\": \"0\",\n", - " \"chargedCumulativeAmount\": \"5680\"\n", + " \"chargedCumulativeAmount\": \"1871\"\n", " },\n", - " \"chargedAmount\": \"1420\"\n", + " \"chargedAmount\": \"1753\"\n", " }\n", "}\n" ] @@ -336,7 +504,27 @@ { "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" + "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" + ] } ], "metadata": { @@ -359,4 +547,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/scw_js/package.json b/scw_js/package.json index 4774a017e..d721feff1 100644 --- a/scw_js/package.json +++ b/scw_js/package.json @@ -16,6 +16,7 @@ "check": "tsc --noEmit && npm run lint && npm run format:check && npm run test:coverage", "dev:x402": "NODE_ENV=test npx tsx genimg_x402_token.ts", "dev:llmx402": "NODE_ENV=test npx tsx sc_llm_x402.ts", + "dev:llmx402cron": "NODE_ENV=test npx tsx llm_x402_cron.ts", "dev:growth": "NODE_ENV=test npx tsx growth_api.ts" }, "keywords": [], diff --git a/scw_js/sc_llm_x402.ts b/scw_js/sc_llm_x402.ts index 244007ca4..9a94990d9 100644 --- a/scw_js/sc_llm_x402.ts +++ b/scw_js/sc_llm_x402.ts @@ -9,6 +9,7 @@ import { extractPaymentPayload, createSettlementHeaders, getBatchSettlementNetworks, + LLM_MAX_TIMEOUT_SECONDS, } from "./x402_server.js"; import type { ScwEvent } from "./types.js"; @@ -34,9 +35,19 @@ const logger = pino({ level: process.env.LOG_LEVEL ?? "info" }); // the "authorize an upper bound, claim the real amount" pattern the SDK's // setSettlementOverrides() wraps for Express apps — we do it manually here since we call // settlePayment() directly. See getSettleAmount() below. +// This endpoint uses Mistral, not IONOS — see llm_service.ts's LLM_PROVIDERS. Legacy +// sc_llm.ts (merkle settlement) is untouched and stays on IONOS. +const LLM_PROVIDER = "mistral"; + const MAX_TOKENS_PER_MESSAGE = process.env.LLM_ESTIMATED_TOKENS_PER_MESSAGE ?? "2000"; +// No real prompt/completion split exists yet for the ceiling, so price the entire +// estimate as completion (output) tokens — the pricier of the two rates for a +// provider with an asymmetric split like Mistral's. This guarantees the ceiling is +// never an underestimate relative to whatever the real split turns out to be; +// getSettleAmount's cap below still protects the ceiling from ever being exceeded. const USDC_MAX_PRICE_PER_MESSAGE = convertTokensToUsdcCost( - BigInt(MAX_TOKENS_PER_MESSAGE), + { prompt_tokens: 0, completion_tokens: MAX_TOKENS_PER_MESSAGE }, + LLM_PROVIDER, ).toString(); /** @@ -46,8 +57,8 @@ const USDC_MAX_PRICE_PER_MESSAGE = convertTokensToUsdcCost( * as under-billing, not a fund-safety issue (the client is always protected by the * voucher's signed ceiling). */ -function getSettleAmount(totalTokens: number): string { - const actualCost = convertTokensToUsdcCost(totalTokens); +function getSettleAmount(usage: { prompt_tokens: number; completion_tokens: number }): string { + const actualCost = convertTokensToUsdcCost(usage, LLM_PROVIDER); const maxCost = BigInt(USDC_MAX_PRICE_PER_MESSAGE); return (actualCost > maxCost ? maxCost : actualCost).toString(); } @@ -90,7 +101,9 @@ export async function handle(event: ScwEvent, _context: unknown): Promise viem + # falls back to the public endpoint, which is aggressively rate-limited under real + # traffic. A real value here embeds an API key (e.g. an Alchemy URL) — secret, not env. + RPC_URL_EIP155_10: ${env:RPC_URL_EIP155_10, ''} + RPC_URL_EIP155_8453: ${env:RPC_URL_EIP155_8453, ''} + RPC_URL_EIP155_11155420: ${env:RPC_URL_EIP155_11155420, ''} + RPC_URL_EIP155_84532: ${env:RPC_URL_EIP155_84532, ''} plugins: - serverless-scaleway-functions diff --git a/scw_js/test/genimg_x402_token.test.ts b/scw_js/test/genimg_x402_token.test.ts index c20caaa64..7232b2168 100644 --- a/scw_js/test/genimg_x402_token.test.ts +++ b/scw_js/test/genimg_x402_token.test.ts @@ -797,6 +797,90 @@ describe("genimg_x402_token.js - x402 v2 Token Payment Tests", () => { ); }); + test("uses the configured RPC_URL_ endpoint for the mint client's network", async () => { + const rpcUrl = "https://opt-mainnet.g.alchemy.com/v2/test-key"; + setupTestEnvironment({ RPC_URL_EIP155_10: rpcUrl }); + setupSuccessfulMintingFlow(102); + + const mainnetPayment = { + x402Version: 2, + accepted: { + scheme: "exact", + network: "eip155:10", + amount: "1000", + asset: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", + payTo: "0xAAEBC1441323B8ad6Bdf6793A8428166b510239C", + }, + payload: { + authorization: { + from: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb", + to: "0xAAEBC1441323B8ad6Bdf6793A8428166b510239C", + value: "1000", + }, + }, + network: "eip155:10", + }; + + const event = { + httpMethod: "POST", + headers: { "x-payment": JSON.stringify(mainnetPayment) }, + body: JSON.stringify({ prompt: "Test RPC wiring" }), + path: "/genimg", + }; + + try { + const response = await handle(event, {}); + expect(response.statusCode).toBe(200); + + // Both the public and wallet clients must use the configured RPC endpoint, + // not viem's rate-limited public default. + expect(mockViemFunctions.http).toHaveBeenCalledWith(rpcUrl); + } finally { + // setupTestEnvironment's custom overrides aren't cleaned up by the shared + // afterEach (which only clears the base testEnvironment keys) — clear this + // one explicitly so it can't leak into the next test. + cleanupTestEnvironment(["RPC_URL_EIP155_10"]); + } + }); + + test("falls back to the public endpoint when no RPC_URL_ is configured", async () => { + // No RPC_URL_EIP155_10 set — default test env from setupTestEnvironment(). + setupSuccessfulMintingFlow(103); + + const mainnetPayment = { + x402Version: 2, + accepted: { + scheme: "exact", + network: "eip155:10", + amount: "1000", + asset: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", + payTo: "0xAAEBC1441323B8ad6Bdf6793A8428166b510239C", + }, + payload: { + authorization: { + from: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb", + to: "0xAAEBC1441323B8ad6Bdf6793A8428166b510239C", + value: "1000", + }, + }, + network: "eip155:10", + }; + + const event = { + httpMethod: "POST", + headers: { "x-payment": JSON.stringify(mainnetPayment) }, + body: JSON.stringify({ prompt: "Test RPC fallback" }), + path: "/genimg", + }; + + const response = await handle(event, {}); + expect(response.statusCode).toBe(200); + + // Unset RPC_URL_* => getRpcUrl returns undefined => viem's http() falls back + // to the chain's public default, same as before this change. + expect(mockViemFunctions.http).toHaveBeenCalledWith(undefined); + }); + test("should reject unsupported network (production only accepts Optimism/Base)", async () => { const unsupportedPayment = { x402Version: 2, diff --git a/scw_js/test/getChain.test.ts b/scw_js/test/getChain.test.ts index a0b2e4c9a..fd727dedc 100644 --- a/scw_js/test/getChain.test.ts +++ b/scw_js/test/getChain.test.ts @@ -27,6 +27,8 @@ import { getGenAiNFTTestnetNetworks, getViemChain, getUSDCConfig, + getRpcUrl, + toCAIP2, } from "@fretchen/chain-utils"; // USDC contracts expose name() and version() functions for EIP-712 domain @@ -233,9 +235,16 @@ describe("EIP-712 Domain Validation (On-Chain)", () => { * @returns {Promise<{name: string, version: string}>} */ async function readOnChainDomain(chain, contractAddress) { + // Uses RPC_URL_ when configured (see getRpcUrl in @fretchen/chain-utils) + // instead of viem's public default. This is the actual, concrete verification + // that a dedicated RPC provider (e.g. Alchemy) works end-to-end: set + // RPC_URL_EIP155_10 to a real Alchemy URL locally, leave SKIP_RPC_TESTS unset, + // and re-run this suite — the withRetry/backoff helper above exists precisely + // because the public endpoints rate-limit under repeated calls like these; a + // dedicated provider sidesteps that instead of just retrying through it. const client = createPublicClient({ chain, - transport: http(), + transport: http(getRpcUrl(toCAIP2(chain.id))), }); const name = await withRetry(() => diff --git a/scw_js/test/llm_service.test.ts b/scw_js/test/llm_service.test.ts index 94007036c..81bee8f37 100644 --- a/scw_js/test/llm_service.test.ts +++ b/scw_js/test/llm_service.test.ts @@ -127,6 +127,48 @@ describe("llm_service.js", () => { }), ); }); + + test("uses the Mistral endpoint/model/auth when provider is 'mistral'", async () => { + setupTestEnvironment({ MISTRAL_API_KEY: "test-mistral-key" }); + const prompt = [{ role: "user", content: "Test" }]; + + try { + await callLLMAPI(prompt, false, "mistral"); + + expect(global.fetch).toHaveBeenCalledWith( + "https://api.mistral.ai/v1/chat/completions", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + Authorization: expect.stringContaining("test-mistral-key"), + }), + body: JSON.stringify({ + model: "mistral-large-latest", + messages: [{ role: "user", content: "Test" }], + }), + }), + ); + } finally { + // setupTestEnvironment's custom overrides aren't cleared by the shared afterEach + // (base testEnvironment keys only) — clear this one explicitly. + cleanupTestEnvironment(["MISTRAL_API_KEY"]); + } + }); + + test("throws when MISTRAL_API_KEY is not set", async () => { + delete process.env.MISTRAL_API_KEY; + const prompt = [{ role: "user", content: "Test" }]; + await expect(callLLMAPI(prompt, false, "mistral")).rejects.toThrow( + "API token not found. Please configure the MISTRAL_API_KEY environment variable.", + ); + }); + + test("throws a friendly error for an unknown provider", async () => { + const prompt = [{ role: "user", content: "Test" }]; + await expect(callLLMAPI(prompt, false, "openai")).rejects.toThrow( + /Unknown LLM provider: openai/, + ); + }); }); const sampleLeaf: Leaf = { @@ -338,6 +380,35 @@ describe("checkWalletBalance — ETH deposit gate", () => { `Insufficient balance. Required: ${REQUIRED}, Current: 5000000000000`, ); }); + + // ===== RPC endpoint wiring (getChain().id -> CAIP-2 -> getRpcUrl -> http()) ===== + // getChain() is mocked above to return { id: 10, ... } (OP Mainnet), so the + // relevant env var is RPC_URL_EIP155_10. + + test("uses the configured RPC_URL_EIP155_10 endpoint when set", async () => { + const rpcUrl = "https://opt-mainnet.g.alchemy.com/v2/test-key"; + setupTestEnvironment({ RPC_URL_EIP155_10: rpcUrl }); + mockCheckBalance.mockResolvedValue(REQUIRED); + + try { + await checkWalletBalance(USER_ADDRESS, REQUIRED); + // Not viem's rate-limited public default. + expect(mockViemFunctions.http).toHaveBeenCalledWith(rpcUrl); + } finally { + // setupTestEnvironment's custom overrides aren't cleared by the shared + // afterEach (base testEnvironment keys only) — clear this one explicitly. + cleanupTestEnvironment(["RPC_URL_EIP155_10"]); + } + }); + + test("falls back to the public endpoint when RPC_URL_EIP155_10 is unset", async () => { + // No RPC_URL_EIP155_10 set — default test env from setupTestEnvironment(). + mockCheckBalance.mockResolvedValue(REQUIRED); + + await checkWalletBalance(USER_ADDRESS, REQUIRED); + + expect(mockViemFunctions.http).toHaveBeenCalledWith(undefined); + }); }); describe("convertTokensToCost — ETH wei conversion (regression check after parseTokenCount refactor)", () => { @@ -361,36 +432,106 @@ describe("convertTokensToCost — ETH wei conversion (regression check after par }); }); -describe("convertTokensToUsdcCost — direct EUR-to-USDC conversion (no ETH hop)", () => { - test("converts a known token count to the expected USDC atomic units", () => { - // 1,000,000 tokens * 0.71 EUR/USDC per 1M tokens = 710,000 atomic units ($0.71) - expect(convertTokensToUsdcCost(1_000_000n)).toBe(710_000n); - }); +describe("convertTokensToUsdcCost — per-provider, input/output-split USDC conversion", () => { + describe("ionos — blended rate (input === output), unchanged math", () => { + test("converts a known token split to the expected USDC atomic units", () => { + // 500,000 prompt + 500,000 completion = 1,000,000 tokens total, both priced at + // ionos's blended 0.71 EUR/USDC per 1M tokens = 710,000 atomic units ($0.71) — + // same total as the old single-rate formula, since input === output for ionos. + expect( + convertTokensToUsdcCost({ prompt_tokens: 500_000n, completion_tokens: 500_000n }, "ionos"), + ).toBe(710_000n); + }); - test("matches the estimated-tokens-per-message default used by sc_llm_x402.ts", () => { - // 2000 tokens * 71 / 100 = 1420 atomic units ($0.00142) - expect(convertTokensToUsdcCost(2000n)).toBe(1420n); + test("blended rate is split-independent — same total regardless of prompt/completion mix", () => { + const allPrompt = convertTokensToUsdcCost( + { prompt_tokens: 1_000_000n, completion_tokens: 0n }, + "ionos", + ); + const allCompletion = convertTokensToUsdcCost( + { prompt_tokens: 0n, completion_tokens: 1_000_000n }, + "ionos", + ); + expect(allPrompt).toBe(710_000n); + expect(allCompletion).toBe(710_000n); + }); + + test("returns zero for zero tokens", () => { + expect(convertTokensToUsdcCost({ prompt_tokens: 0n, completion_tokens: 0n }, "ionos")).toBe( + 0n, + ); + }); }); - test("accepts number and numeric-string inputs equivalently to bigint", () => { - const viaBigint = convertTokensToUsdcCost(1500n); - expect(convertTokensToUsdcCost(1500)).toBe(viaBigint); - expect(convertTokensToUsdcCost("1500")).toBe(viaBigint); + describe("mistral — asymmetric input/output rates ($0.50/M in, $1.50/M out)", () => { + test("matches the estimated-tokens-per-message ceiling convention used by sc_llm_x402.ts", () => { + // sc_llm_x402.ts prices the whole pre-auth estimate as completion (output) + // tokens (the pricier rate) since no real split exists yet for the ceiling. + // 2000 tokens * $1.50/M = 3000 atomic units ($0.003). + expect( + convertTokensToUsdcCost({ prompt_tokens: 0n, completion_tokens: 2000n }, "mistral"), + ).toBe(3000n); + }); + + test("prices input tokens at the input rate only", () => { + // 1,000,000 prompt tokens * $0.50/M = 500,000 atomic units. + expect( + convertTokensToUsdcCost({ prompt_tokens: 1_000_000n, completion_tokens: 0n }, "mistral"), + ).toBe(500_000n); + }); + + test("prices completion tokens at the (higher) output rate only", () => { + // 1,000,000 completion tokens * $1.50/M = 1,500,000 atomic units. + expect( + convertTokensToUsdcCost({ prompt_tokens: 0n, completion_tokens: 1_000_000n }, "mistral"), + ).toBe(1_500_000n); + }); + + test("sums input and output cost for a mixed split", () => { + // 500,000 * $0.50/M + 500,000 * $1.50/M = 250,000 + 750,000 = 1,000,000 atomic units. + expect( + convertTokensToUsdcCost( + { prompt_tokens: 500_000n, completion_tokens: 500_000n }, + "mistral", + ), + ).toBe(1_000_000n); + }); }); - test("returns zero for zero tokens", () => { - expect(convertTokensToUsdcCost(0n)).toBe(0n); + test("accepts number and numeric-string inputs equivalently to bigint", () => { + const viaBigint = convertTokensToUsdcCost( + { prompt_tokens: 1000n, completion_tokens: 500n }, + "mistral", + ); + expect( + convertTokensToUsdcCost({ prompt_tokens: 1000, completion_tokens: 500 }, "mistral"), + ).toBe(viaBigint); + expect( + convertTokensToUsdcCost({ prompt_tokens: "1000", completion_tokens: "500" }, "mistral"), + ).toBe(viaBigint); }); test("rejects a negative number", () => { - expect(() => convertTokensToUsdcCost(-5)).toThrow(TypeError); + expect(() => + convertTokensToUsdcCost({ prompt_tokens: -5, completion_tokens: 0 }, "mistral"), + ).toThrow(TypeError); }); test("rejects a non-finite number", () => { - expect(() => convertTokensToUsdcCost(Infinity)).toThrow(TypeError); + expect(() => + convertTokensToUsdcCost({ prompt_tokens: Infinity, completion_tokens: 0 }, "mistral"), + ).toThrow(TypeError); }); test("rejects a non-numeric string", () => { - expect(() => convertTokensToUsdcCost("abc")).toThrow(TypeError); + expect(() => + convertTokensToUsdcCost({ prompt_tokens: "abc", completion_tokens: 0 }, "mistral"), + ).toThrow(TypeError); + }); + + test("rejects an unknown provider", () => { + expect(() => + convertTokensToUsdcCost({ prompt_tokens: 100n, completion_tokens: 100n }, "openai"), + ).toThrow(/Unknown LLM provider: openai/); }); }); diff --git a/scw_js/test/sc_llm_x402.test.ts b/scw_js/test/sc_llm_x402.test.ts index 83d48a908..1289899e5 100644 --- a/scw_js/test/sc_llm_x402.test.ts +++ b/scw_js/test/sc_llm_x402.test.ts @@ -25,16 +25,32 @@ const { const mockEnhancePaymentRequirements = vi.fn().mockImplementation(async (base: unknown) => base); return { mockCallLLMAPI: vi.fn(), - // Real formula (matches llm_service.ts's actual convertTokensToUsdcCost: tokens * 71n / 100n), - // not a fixed stub — so tests can verify the settlement amount actually tracks whatever - // usage.total_tokens callLLMAPI returns, not just the flat ceiling. Called once at module - // load (for the ceiling, USDC_MAX_PRICE_PER_MESSAGE) — must work before beforeEach runs. - mockConvertTokensToUsdcCost: vi - .fn() - .mockImplementation((tokenCount: bigint | number | string) => { - const tc = typeof tokenCount === "bigint" ? tokenCount : BigInt(tokenCount); - return (tc * 71n) / 100n; - }), + // Real formula (matches llm_service.ts's actual convertTokensToUsdcCost: 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, + // USDC_MAX_PRICE_PER_MESSAGE) — 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). + mockConvertTokensToUsdcCost: vi.fn().mockImplementation( + ( + usage: { + prompt_tokens: bigint | number | string; + completion_tokens: bigint | number | string; + }, + provider: string, + ) => { + const RATES: Record = { + ionos: { in: 71n, out: 71n, den: 100n }, + mistral: { in: 50n, out: 150n, den: 100n }, + }; + const rate = RATES[provider]; + if (!rate) throw new Error(`Unknown LLM provider: ${provider}`); + const p = BigInt(usage.prompt_tokens); + const c = BigInt(usage.completion_tokens); + return (p * rate.in + c * rate.out) / rate.den; + }, + ), mockCreateLLMResourceServer: vi.fn(), mockCreateBatchSettlementPaymentRequirements: vi.fn(), mockCreate402Response: vi.fn(), @@ -67,6 +83,9 @@ vi.mock("../x402_server.js", () => ({ extractPaymentPayload: mockExtractPaymentPayload, createSettlementHeaders: mockCreateSettlementHeaders, getBatchSettlementNetworks: mockGetBatchSettlementNetworks, + // Real constant (not a mock fn) — imported by sc_llm_x402.ts for the verify-time + // maxTimeoutSeconds; keep in sync with x402_server.ts's exported value. + LLM_MAX_TIMEOUT_SECONDS: 120, })); vi.mock("@fretchen/chain-utils", () => ({ @@ -208,8 +227,10 @@ describe("sc_llm_x402", () => { it("returns a 402 built from createBatchSettlementPaymentRequirements", async () => { const res = await handle(makeEvent() as never, {}); expect(res.statusCode).toBe(402); + // Ceiling: the whole 2000-token estimate priced as completion (output) tokens + // at Mistral's $1.50/M rate — 2000 * 150 / 100 = 3000. expect(mockCreateBatchSettlementPaymentRequirements).toHaveBeenCalledWith( - expect.objectContaining({ payTo: VALID_ADDRESS, scheme: mockScheme, amount: "1420" }), + expect.objectContaining({ payTo: VALID_ADDRESS, scheme: mockScheme, amount: "3000" }), ); expect(mockCreate402Response).toHaveBeenCalled(); }); @@ -311,7 +332,8 @@ describe("sc_llm_x402", () => { mockExtractPaymentPayload.mockReturnValue(samplePaymentPayload); // eip155:84532 const res = await handle(makeEvent() as never, {}); expect(res.statusCode).toBe(200); - expect(mockCallLLMAPI).toHaveBeenCalledWith(expect.anything(), true); + // 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 () => { @@ -321,7 +343,59 @@ describe("sc_llm_x402", () => { }); const res = await handle(makeEvent() as never, {}); expect(res.statusCode).toBe(200); - expect(mockCallLLMAPI).toHaveBeenCalledWith(expect.anything(), false); + 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({ + data: { prompt: [{ 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({ + data: { prompt: [{ 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" }, + payload: { type: "voucher" }, + }); + const res = await handle( + makeEvent({ + body: JSON.stringify({ + data: { prompt: [{ role: "user", content: "hi" }], useDummyData: false }, + }), + }) as never, + {}, + ); + expect(res.statusCode).toBe(200); + expect(mockCallLLMAPI).toHaveBeenCalledWith(expect.anything(), false, "mistral"); }); }); @@ -370,7 +444,8 @@ describe("sc_llm_x402", () => { // ═══════════════════════════════════════════════════════════ it("settles for the LLM's actual token usage, not the ceiling", async () => { - // 1000 tokens -> 1000 * 71 / 100 = 710, well under the 1420 ceiling (2000 tokens). + // 200 prompt + 800 completion -> 0.5*200 + 1.5*800 = 100 + 1200 = 1300 (Mistral + // rates), well under the 3000 ceiling (2000 tokens, all priced as completion). mockCallLLMAPI.mockResolvedValue({ content: "answer", usage: { prompt_tokens: 200, completion_tokens: 800, total_tokens: 1000 }, @@ -380,24 +455,25 @@ describe("sc_llm_x402", () => { const res = await handle(makeEvent() as never, {}); expect(res.statusCode).toBe(200); - // verifyPayment must still see the pre-authorized ceiling (1420) — the client signed + // verifyPayment must still see the pre-authorized ceiling (3000) — 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: "1420" }), + expect.objectContaining({ amount: "3000" }), ); // settlePayment must see the real, usage-derived amount instead. expect(mockSettlePayment).toHaveBeenCalledWith( samplePaymentPayload, - expect.objectContaining({ ...enhancedRequirements, amount: "710" }), + expect.objectContaining({ ...enhancedRequirements, amount: "1300" }), ); }); it("caps the settlement amount at the ceiling when usage runs over the estimate", async () => { - // 3000 tokens -> 3000 * 71 / 100 = 2130, which exceeds the 1420 ceiling — must be - // capped there rather than settling for more than the client authorized (or aborting). + // 500 prompt + 2500 completion -> 0.5*500 + 1.5*2500 = 250 + 3750 = 4000, which + // exceeds the 3000 ceiling — must be capped there rather than settling for more + // than the client authorized (or aborting). mockCallLLMAPI.mockResolvedValue({ content: "answer", usage: { prompt_tokens: 500, completion_tokens: 2500, total_tokens: 3000 }, @@ -408,7 +484,7 @@ describe("sc_llm_x402", () => { expect(res.statusCode).toBe(200); expect(mockSettlePayment).toHaveBeenCalledWith( samplePaymentPayload, - expect.objectContaining({ amount: "1420" }), + expect.objectContaining({ amount: "3000" }), ); }); @@ -423,10 +499,10 @@ describe("sc_llm_x402", () => { const res = await handle(makeEvent() as never, {}); expect(res.statusCode).toBe(200); - // 15 * 71 / 100 = 10.65 -> 10 (integer division). + // 5 prompt + 15 completion -> 0.5*5 + 1.5*15 = 2.5 + 22.5 = 25 exactly. expect(mockSettlePayment).toHaveBeenCalledWith( samplePaymentPayload, - expect.objectContaining({ amount: "10" }), + expect.objectContaining({ amount: "25" }), ); }); }); diff --git a/scw_js/x402_server.ts b/scw_js/x402_server.ts index 1d2525e3f..efe5692f4 100644 --- a/scw_js/x402_server.ts +++ b/scw_js/x402_server.ts @@ -40,6 +40,21 @@ const ONCHAIN_STATE_TTL_MS = 5_000; // hourly interval at the time. 24h leaves the (now 12h) cron a 2x safety margin. const WITHDRAW_DELAY_SECONDS = Number(process.env.LLM_WITHDRAW_DELAY_SECONDS ?? "86400"); +// TTL for the batch-settlement per-channel "pendingRequest" lock. @x402/evm sets this lock +// in onBeforeVerify and clears it in onAfterSettle to serialize concurrent requests on one +// channel; a second request while the lock is live is rejected with `channel_busy`. +// maxTimeoutSeconds is the ONLY thing that drives this TTL in the batch-settlement scheme — +// it does not gate voucher freshness or payment expiry (verified against the SDK). The SDK +// clamps the derived TTL to [5s, 10min] (pendingExpiresAt), so a large value like the +// previous 3600 pinned it to the 10-minute ceiling: if a request is abandoned between verify +// and settle (tab close, network drop, notebook interrupt) the lock is orphaned and the +// channel stays busy for the full 10 minutes — and the client SDK does NOT auto-recover from +// `channel_busy`. 120s keeps ample headroom over the real verify + LLM + settle wall-clock +// (seconds, even for a slow mainnet completion + on-chain claim) while cutting the worst-case +// orphan lockout to 2 minutes. Must be used identically at 402-advertise time (below) AND at +// verify time (sc_llm_x402.ts) — the SDK treats maxTimeoutSeconds as immutable across the two. +export const LLM_MAX_TIMEOUT_SECONDS = 120; + export function getSupportedNetworks(): string[] { return SUPPORTED_NETWORKS; } @@ -131,7 +146,7 @@ export async function createBatchSettlementPaymentRequirements({ amount, asset: config.address, payTo, - maxTimeoutSeconds: 3600, + maxTimeoutSeconds: LLM_MAX_TIMEOUT_SECONDS, extra: { name: config.usdcName, version: config.usdcVersion }, }; return scheme.enhancePaymentRequirements( diff --git a/website/hooks/useX402Chat.test.ts b/website/hooks/useX402Chat.test.ts index 2a83df0a2..0920a2631 100644 --- a/website/hooks/useX402Chat.test.ts +++ b/website/hooks/useX402Chat.test.ts @@ -117,11 +117,36 @@ describe("useX402Chat", () => { expect.objectContaining({ storage: expect.any(WebStorageClientChannelStorage), voucherSigner: expect.objectContaining({ address: expect.stringMatching(/^0x[a-fA-F0-9]{40}$/) }), + depositStrategy: expect.any(Function), }), ); expect(mockRegister).toHaveBeenCalledWith(NETWORK, expect.anything()); }); + it("deposit strategy floors deposits/top-ups at $0.50, ignoring the SDK's smaller default", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(JSON.stringify({ content: "hi" }), { status: 200 })), + ); + + const { result } = renderHook(() => useX402Chat(NETWORK)); + await act(async () => { + await result.current.sendMessage([{ role: "user", content: "Hi" }]); + }); + + const { depositStrategy } = mockBatchSettlementEvmScheme.mock.calls[0][1] as { + depositStrategy: (ctx: { minimumDepositAmount: string }) => string; + }; + + // Below the floor (e.g. the SDK's own ~1-3 cent default): clamp up to $0.50. + expect(depositStrategy({ minimumDepositAmount: "15000" })).toBe("500000"); + // Above the floor (an unusually expensive top-up): the SDK requires >= this amount, + // so it must be respected, not clamped down. + expect(depositStrategy({ minimumDepositAmount: "600000" })).toBe("600000"); + // Exactly at the floor: either value is correct; assert it's still >= minimum. + expect(BigInt(depositStrategy({ minimumDepositAmount: "500000" }))).toBeGreaterThanOrEqual(500_000n); + }); + it("reuses the same delegated voucher signer across multiple messages", async () => { vi.stubGlobal( "fetch", @@ -240,6 +265,36 @@ describe("useX402Chat", () => { expect(result.current.status).toBe("error"); expect(result.current.error).toContain("402"); }); + + it("surfaces a friendly, actionable message for a channel_busy 402", async () => { + // The transient per-channel lock the server holds across verify→settle. The raw code + // is opaque and the client SDK does not auto-recover from it, so the hook maps it to + // a "wait and retry" line instead of dumping the reason code. + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: "invalid_batch_settlement_evm_channel_busy" }), { + status: 402, + }), + ), + ); + + const { result } = renderHook(() => useX402Chat(NETWORK)); + + let thrown: Error | undefined; + await act(async () => { + try { + await result.current.sendMessage([{ role: "user", content: "Hi" }]); + } catch (err) { + thrown = err as Error; + } + }); + + expect(thrown?.message).toMatch(/still being settled/i); + expect(thrown?.message).not.toContain("channel_busy"); + expect(result.current.status).toBe("error"); + expect(result.current.error).toMatch(/wait a few seconds/i); + }); }); describe("Reset Functionality", () => { diff --git a/website/hooks/useX402Chat.ts b/website/hooks/useX402Chat.ts index a9e74753c..1b9f813c1 100644 --- a/website/hooks/useX402Chat.ts +++ b/website/hooks/useX402Chat.ts @@ -18,7 +18,11 @@ import { privateKeyToAccount, generatePrivateKey } from "viem/accounts"; import { useConfiguredPublicClient } from "./useConfiguredPublicClient"; import type { X402ChatMessage, X402ChatResponse, X402PaymentReceipt, X402GenerationStatus } from "../types/x402"; // Type-only import — erased at compile time, so no @x402 runtime is pulled into SSR. -import type { ClientChannelStorage, BatchSettlementClientContext } from "@x402/evm/batch-settlement/client"; +import type { + ClientChannelStorage, + BatchSettlementClientContext, + BatchSettlementDepositStrategyContext, +} from "@x402/evm/batch-settlement/client"; // Endpoint of the batch-settlement chat function (override for local dev with // PUBLIC_ENV__LLM_X402_ENDPOINT=http://localhost:8085). @@ -78,6 +82,52 @@ function getOrCreateVoucherSigner(walletAddress: string) { return privateKeyToAccount(privateKey); } +// Floor for channel deposits/top-ups, in USDC atomic units (6 decimals) — $0.50. +// The SDK's own default (depositMultiplier x per-message ceiling) tracks whatever the +// ceiling happens to be, currently ~$0.003/message, so it sizes deposits at ~1-3 cents: +// enough for only ~5 messages worst-case before another on-chain top-up (a real tx, a +// real wallet-adjacent wait) is needed. $0.50 comfortably covers a full multi-message +// session (100s of messages even at worst-case per-message pricing) while keeping the +// number small on the two axes that actually matter for this app: it's the blast radius +// of the localStorage voucher-signer above if it ever leaks (bounded to this amount, +// never more), and the capital a user has locked up if the server stops cooperating and +// they have to wait out withdrawDelay to exit unilaterally. Both are trivial at $0.50; +// neither improves by going lower, so lower just buys more top-up friction for no benefit. +const MINIMUM_DEPOSIT_ATOMIC = 500_000n; + +/** + * Custom deposit sizing: always deposit/top-up to at least `MINIMUM_DEPOSIT_ATOMIC`, + * regardless of the SDK's default multiplier-of-ceiling formula — see the constant's + * comment for why a fixed floor is the right lever here, not `depositPolicy.depositMultiplier` + * (which would still scale with the ceiling rather than decoupling from it). + * `minimumDepositAmount` is the true minimum the SDK needs for the top-up in progress; the + * SDK requires the returned amount be >= it, so it's respected as a floor of its own. + */ +function depositStrategy(context: BatchSettlementDepositStrategyContext): string { + const required = BigInt(context.minimumDepositAmount); + return (required > MINIMUM_DEPOSIT_ATOMIC ? required : MINIMUM_DEPOSIT_ATOMIC).toString(); +} + +/** + * Turn a non-OK payment response into a user-facing message. Batch-settlement's + * `channel_busy` is a transient, self-healing per-channel lock — the server holds it across + * a single message's verify→settle to serialize requests on one channel, and the x402 client + * SDK does NOT auto-recover from it — so it warrants an actionable "wait and retry" line + * rather than dumping the raw reason code. Any other reason keeps the informative default. + */ +function describePaymentError(status: number, body: string): string { + let errorCode: string | undefined; + try { + errorCode = (JSON.parse(body) as { error?: string }).error; + } catch { + // Non-JSON body — fall through to the generic message. + } + if (errorCode?.includes("channel_busy")) { + return "Your previous message is still being settled on-chain. Please wait a few seconds and send it again."; + } + return `Request failed: ${status} - ${body}`; +} + export interface UseX402ChatResult { sendMessage: (prompt: X402ChatMessage[]) => Promise; status: X402GenerationStatus; @@ -144,7 +194,7 @@ export function useX402Chat(network: string): UseX402ChatResult { // Delegate voucher signing to a persisted local key so only the deposit/top-up // prompts the real wallet — see getOrCreateVoucherSigner's doc comment. const voucherSigner = getOrCreateVoucherSigner(walletClient.account.address); - const scheme = new BatchSettlementEvmScheme(signer, { storage, voucherSigner }); + const scheme = new BatchSettlementEvmScheme(signer, { storage, voucherSigner, depositStrategy }); const client = new x402Client(); client.register(network, scheme); @@ -192,7 +242,7 @@ export function useX402Chat(network: string): UseX402ChatResult { if (!response.ok) { const errorText = await response.text(); - throw new Error(`Request failed: ${response.status} - ${errorText}`); + throw new Error(describePaymentError(response.status, errorText)); } const result = (await response.json()) as X402ChatResponse; diff --git a/x402_facilitator/.env.example b/x402_facilitator/.env.example index e9e488342..a68b04c5e 100644 --- a/x402_facilitator/.env.example +++ b/x402_facilitator/.env.example @@ -9,10 +9,13 @@ SCW_DEFAULT_PROJECT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx FACILITATOR_WALLET_PRIVATE_KEY=0x... FACILITATOR_WALLET_PUBLIC_KEY=0x... -# Optional: Custom RPC endpoints (defaults provided in chain_utils.js) -# Set these for production to use dedicated RPC providers (Infura, Alchemy, Ankr, etc.) -# OPTIMISM_RPC_URL=https://opt-mainnet.g.alchemy.com/v2/YOUR_API_KEY -# OPTIMISM_SEPOLIA_RPC_URL=https://opt-sepolia.g.alchemy.com/v2/YOUR_API_KEY +# Optional: Custom RPC endpoints (see getRpcUrl in chain_utils.ts). Unset falls back to +# each chain's public endpoint. Set these for production to use a dedicated RPC provider +# (Infura, Alchemy, Ankr, etc.) — the public endpoints are aggressively rate-limited. +# RPC_URL_EIP155_10=https://opt-mainnet.g.alchemy.com/v2/YOUR_API_KEY +# RPC_URL_EIP155_8453=https://base-mainnet.g.alchemy.com/v2/YOUR_API_KEY +# RPC_URL_EIP155_84532=https://base-sepolia.g.alchemy.com/v2/YOUR_API_KEY +# RPC_URL_EIP155_11155420=https://opt-sepolia.g.alchemy.com/v2/YOUR_API_KEY # Local development NODE_ENV=test diff --git a/x402_facilitator/serverless.yml b/x402_facilitator/serverless.yml index 0b5829894..5dfead5a0 100644 --- a/x402_facilitator/serverless.yml +++ b/x402_facilitator/serverless.yml @@ -22,18 +22,19 @@ provider: # can never authorize real funds. Keep testnet-only wallets here. BATCH_SETTLEMENT_MANUAL_WHITELIST: ${env:BATCH_SETTLEMENT_MANUAL_WHITELIST, ''} BATCH_SETTLEMENT_TEST_WALLETS: ${env:BATCH_SETTLEMENT_TEST_WALLETS, ''} + # Secrets (set via Scaleway Console or CLI) + secret: + FACILITATOR_WALLET_PRIVATE_KEY: ${env:FACILITATOR_WALLET_PRIVATE_KEY} # Per-network RPC endpoints (see getRpcUrl in chain_utils.ts). Unset => viem falls # back to the chain's public endpoint, which is aggressively rate-limited: a single # batch-settlement deposit does a Multicall3 read batch and fails with "over rate # limit", surfacing as the generic ..._deposit_transaction_failed. Set these for any - # network carrying real traffic. + # network carrying real traffic. A real value embeds an API key (e.g. an Alchemy + # URL) — secret, not env. RPC_URL_EIP155_8453: ${env:RPC_URL_EIP155_8453, ''} RPC_URL_EIP155_10: ${env:RPC_URL_EIP155_10, ''} RPC_URL_EIP155_84532: ${env:RPC_URL_EIP155_84532, ''} RPC_URL_EIP155_11155420: ${env:RPC_URL_EIP155_11155420, ''} - # Secrets (set via Scaleway Console or CLI) - secret: - FACILITATOR_WALLET_PRIVATE_KEY: ${env:FACILITATOR_WALLET_PRIVATE_KEY} plugins: - serverless-scaleway-functions