From 71843476c409e66da31b28d4d13633f2c153dab0 Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:04:30 -0500 Subject: [PATCH 01/20] fix(errors): read the SDK's detail field, and stop calling a 501 a transient outage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @blockrun/llm 3.15.1 carries the gateway's own message — the field that names the cause and says '(payment NOT charged)' — under `detail`. extractErrorMessage only read message/hint, so the text was dropped a second time here and the 'not charged' branch never fired (#132). A 501 is the gateway declining to serve a route, not a blip; the advice to retry in a few minutes was wrong. The 'nothing was charged' claim is made only when the 501 arrived before payment. hasLabelledServerStatus is exported so route-specific formatters share one status rule. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg --- src/utils/errors.ts | 57 +++++++++++++++++++++++++++++++++------- test/errors.test.ts | 64 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 110 insertions(+), 11 deletions(-) diff --git a/src/utils/errors.ts b/src/utils/errors.ts index ea2634c..f451dc3 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -19,6 +19,13 @@ export function extractErrorMessage(err: unknown): string { // Common gateway error shape: { error, message, hint, missing_params? } const parts: string[] = []; if (typeof b.message === "string") parts.push(b.message); + // @blockrun/llm >= 3.15.1 (blockrun-llm-ts#39) keeps the gateway's own + // `message` — the field that names the cause AND says whether money moved, + // e.g. "Predexon 500: … (payment NOT charged)" — under `detail`, because + // the sanitizer already uses `message` for the top-level `error` string. + // Without this line that text is dropped a second time here, and the + // "not charged" branch in formatError() below never fires (blockrun-mcp#132). + if (typeof b.detail === "string" && b.detail !== b.message) parts.push(b.detail); if (typeof b.hint === "string") parts.push(`Hint: ${b.hint}`); if (Array.isArray(b.missing_params) && b.missing_params.length) { parts.push(`Missing: ${b.missing_params.join(", ")}`); @@ -47,6 +54,24 @@ export function isPaymentRejectionError(message: string): boolean { return m.includes("insufficient") || m.includes("balance") || m.includes("rejected"); } +/** + * True when `message` carries a 5xx that READS as an HTTP status. A bare + * three-digit match is far too loose: LLM errors are full of incidental + * 5xx-shaped numbers ("max_tokens 512 is above the limit", "embedding dimension + * 512"), and telling the user to wait out a temporary outage hides a real + * validation bug. Either the number is directly labelled as a status ("error + * 500", "status code 503", "http 502") — adjacency matters, so "context length + * 512 exceeded" does not qualify — or it carries a standard HTTP reason phrase. + * Shared with the route-specific formatters so they cannot drift looser. + */ +export function hasLabelledServerStatus(message: string): boolean { + const m = message.toLowerCase(); + // "payment" is a label too: the SDK's post-402 prefix is "API error after + // payment: 502", where the word before the number is "payment", not "error". + return /(?:status(?:\s*code)?|http|error|payment)\s*[:=]?\s*5[0-9]{2}(?:$|[^0-9.])/.test(m) || + /(?:^|[^0-9.])5[0-9]{2}:?\s+(?:internal|server error|bad gateway|service unavailable|gateway time)/.test(m); +} + /** * Format an error for return to the caller, appending actionable guidance for * the three common failure classes (upstream model unavailable, server blip, @@ -89,22 +114,28 @@ export function formatError(message: string, opts?: { altModels?: string }): str // actionable client errors, not transient server outages, so they no longer // get retry guidance. // - // A 5xx must LOOK like an HTTP status to count. A bare three-digit match is - // far too loose here: LLM errors are full of incidental 5xx-shaped numbers - // ("max_tokens 512 is above the limit", "embedding dimension 512"), and - // telling the user to wait out a temporary outage hides a real validation bug. - // Either the number is directly labelled as a status ("error 500", - // "status code 503", "http 502") — adjacency matters, so "context length 512 - // exceeded" does not qualify — or it carries a standard HTTP reason phrase. - const has5xxStatus = - /(?:status(?:\s*code)?|http|error)\s*[:=]?\s*5[0-9]{2}(?:$|[^0-9.])/.test(msgLower) || - /(?:^|[^0-9.])5[0-9]{2}:?\s+(?:internal|server error|bad gateway|service unavailable|gateway time)/.test(msgLower); + // See hasLabelledServerStatus: a 5xx must LOOK like an HTTP status to count. + const has5xxStatus = hasLabelledServerStatus(msgLower); // A post-payment failure with no parseable status is still an upstream // failure, not an empty wallet — without this it falls through to the // "payment" keyword branch and wrongly tells the user to fund. const isServerError = has5xxStatus || (msgLower.includes("api error after payment") && !isPostPaymentClientError); + // 501 is the gateway saying "we do not serve this" — the equity price/history + // routes have answered it before any payment since 2026-09-05 (licensing; + // blockrun#517). It is in the 5xx range but it is not an outage, so "try again + // in a few minutes" is wrong advice and hides that the product line is gone. + // Same labelling rule as has5xxStatus: the number must read as a status, so + // "batch of 501 items" does not qualify. The "nothing was charged" claim is + // only safe when the 501 arrived BEFORE payment: a post-payment 501 means the + // gateway settled and then upstream refused, and this formatter has no + // endpoint context to know whether the nonce was released. + const isNotServed = + /(?:status(?:\s*code)?|http|error|payment)\s*[:=]?\s*501(?:$|[^0-9.])/.test(msgLower) || + /(?:^|[^0-9.])501:?\s+not implemented/.test(msgLower); + const isNotServedPrePayment = isNotServed && !msgLower.includes("api error after payment"); + const altHint = opts?.altModels ? ` (e.g. ${opts.altModels})` : ""; let errorText = `Error: ${message}`; @@ -113,6 +144,12 @@ export function formatError(message: string, opts?: { altModels?: string }): str (opts?.altModels ? `. Try a different model${altHint} — it should work right away.` : `. Try a different model, or retry shortly.`); + } else if (isNotServed) { + errorText += `\n\nThe gateway does not serve this endpoint (501 Not Implemented). This is not a` + + `\ntransient outage — retrying will not help` + + (isNotServedPrePayment + ? `, and nothing was charged.` + : `. Check blockrun_wallet action:"report" to see whether this call settled.`); } else if (isServerError) { errorText += `\n\nThis is a temporary API issue. The API may be experiencing problems.` + `\nTry again in a few minutes` + diff --git a/test/errors.test.ts b/test/errors.test.ts index 2be8603..1127a70 100644 --- a/test/errors.test.ts +++ b/test/errors.test.ts @@ -1,7 +1,7 @@ // Run with: npm test (tsx --test) import { test } from "node:test"; import assert from "node:assert/strict"; -import { formatError, isPaymentRejectionError } from "../src/utils/errors.js"; +import { extractErrorMessage, formatError, isPaymentRejectionError } from "../src/utils/errors.js"; test("model-unavailable (token360) → steers to a sibling model, not a generic blip", () => { const msg = "Video generation failed: API error 500: token360 video submit failed: Model 'seedance-2.0-fast' not found or not active for requested provider"; @@ -114,3 +114,65 @@ test("isPaymentRejectionError matches settlement failures, not outage status tex assert.equal(isPaymentRejectionError('Unexpected response 500 (expected a 402 payment challenge): {"error":"bad gateway"}'), false); assert.equal(isPaymentRejectionError("Unexpected response 425 (expected a 402 payment challenge): liveness not finished"), false); }); + +// --- blockrun-mcp#132: the gateway said "payment NOT charged"; the tool said "after payment" --- + +class FakeAPIError extends Error { + constructor(message: string, public statusCode: number, public response: unknown) { + super(message); + } +} + +test("extractErrorMessage surfaces the SDK's `detail` field (blockrun-llm-ts#39)", () => { + // Post-#39 sanitizer output: `message` is the gateway's top-level `error`, + // `detail` is the gateway's own `message` — the cause + settlement status. + const err = new FakeAPIError("API error after payment: 502", 502, { + message: "Upstream provider error", + detail: "Predexon 500: An unexpected error occurred (payment NOT charged)", + }); + const msg = extractErrorMessage(err); + assert.match(msg, /Upstream provider error/); + assert.match(msg, /payment NOT charged/); + // …and formatError no longer tells the user to fund a wallet that was never charged. + const out = formatError(msg); + assert.doesNotMatch(out, /needs funding/); +}); + +test("extractErrorMessage does not repeat a detail identical to the message", () => { + const err = new FakeAPIError("API error: 400", 400, { message: "Bad request", detail: "Bad request" }); + assert.equal(extractErrorMessage(err).match(/Bad request/g)?.length, 1); +}); + +test("extractErrorMessage is unchanged for the pre-#39 shape (message + code only)", () => { + const err = new FakeAPIError("API error after payment: 502", 502, { message: "Request failed", code: "x" }); + assert.equal(extractErrorMessage(err), "API error after payment: 502\nRequest failed"); +}); + +test("a 501 is 'not served', not a temporary outage to retry", () => { + // Live 2026-09-08: GET /v1/stocks/us/price/AAPL → 501 before any 402. + const out = formatError("API error: 501\nUS Stock price is not available"); + assert.match(out, /does not serve this endpoint/); + assert.match(out, /nothing was charged/); + assert.doesNotMatch(out, /temporary API issue/); + assert.doesNotMatch(out, /needs funding/); +}); + +test("a 501-shaped number inside a message is not read as a status", () => { + const out = formatError("API error 500: batch of 501 items rejected"); + assert.match(out, /temporary API issue/); + assert.doesNotMatch(out, /does not serve this endpoint/); +}); + +test("a post-payment 501 does not claim nothing was charged", () => { + const out = formatError("API error after payment: 501\nRequest failed"); + assert.match(out, /does not serve this endpoint/); + assert.doesNotMatch(out, /nothing was charged/); + assert.match(out, /whether this call settled/); + assert.doesNotMatch(out, /temporary API issue/); +}); + +test("the SDK's post-payment prefix counts as a labelled status", () => { + // "API error after payment: 502" — the word before the number is "payment". + const out = formatError("API error after payment: 502\nRequest failed"); + assert.match(out, /temporary API issue/); +}); From 3a33f52f047be2675b79e89616179b7eb4d8eece Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:04:30 -0500 Subject: [PATCH 02/20] fix(markets): say sports/* is degraded upstream and that nothing was charged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four Predexon sports/* routes have returned an upstream 500 since 2026-08-04. The gateway marks them degraded, withdrew them from discovery and releases the payment nonce on upstream 5xx; this server still advertised them as live and rendered the failure as 'after payment … try again' (#132). The description now says the routes are degraded and points at markets + league=. A sports 5xx renders the outage, the date and 'nothing was charged'. No pre-payment block: the gateway decides whether Predexon has recovered. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg --- src/tools/markets.ts | 23 ++++++++++++------ src/utils/markets-validation.ts | 43 +++++++++++++++++++++++++++++++++ test/markets-validation.test.ts | 42 +++++++++++++++++++++++++++++++- 3 files changed, 99 insertions(+), 9 deletions(-) diff --git a/src/tools/markets.ts b/src/tools/markets.ts index 3695126..76f963b 100644 --- a/src/tools/markets.ts +++ b/src/tools/markets.ts @@ -10,7 +10,7 @@ import { extractErrorMessage, formatError } from "../utils/errors.js"; import { hasPathTraversal } from "../utils/path-safety.js"; import type { BudgetState } from "../types.js"; import { TOOL_ANNOTATIONS } from "../tool-annotations.js"; -import { validateMarketRequest } from "../utils/markets-validation.js"; +import { describeDegradedSportsFailure, isDegradedSportsPath, validateMarketRequest } from "../utils/markets-validation.js"; // What x402 CHARGES, which is not the 402's JSON `price` field. That field is the // BASE ($0.0075); the charge is base + a $0.002 flat transaction fee, and it lives @@ -67,11 +67,7 @@ WALLET IDENTITY & CLUSTERING (Tier 2) — cross-context labels + on-chain relati - polymarket/wallet/identities — POST { addresses: [...] } for bulk lookup (up to 200 wallets) - polymarket/wallet/:address/cluster — discover wallets connected via on-chain transfers + identity proofs -SPORTS (Tier 1): -- sports/categories — list available sports categories -- sports/markets — list sports markets grouped by game (filter ?league=, ?sport_type=, ?status=, ?venue=) -- sports/markets/:game_id — single sports game with all venue outcomes -- sports/outcomes/:predexon_id — equivalent sports outcomes across venues for a Predexon ID +SPORTS — sports/* (categories, markets, markets/:game_id, outcomes/:predexon_id) DEGRADED, do not call: Predexon 500 on every call since 2026-08-04; payment released, nothing charged. Use path "markets" with params { league } instead. KALSHI: kalshi/markets, kalshi/trades, kalshi/orderbooks LIMITLESS / OPINION / PREDICT.FUN: {platform}/markets, {platform}/orderbooks @@ -121,7 +117,13 @@ Pass query params via 'params' (GET). Use 'body' only for POST endpoints (e.g. p // Human-in-the-loop (BLOCKRUN_CONFIRM_SPEND=on): ask before signing. A // decline returns here — nothing is sent, and the finally releases the // reservation. No-ops when off, sub-threshold, or unsupported by the client. - const confirm = await confirmSpend(server, { usd: estimatedCost, label: `markets · ${path}` }); + // sports/* is reserved and confirmed like any other route: if Predexon + // recovers, the call WILL settle $0.0095, and an un-reserved settle is the + // worse failure. The label says why the prompt will probably be moot. + const confirm = await confirmSpend(server, { + usd: estimatedCost, + label: isDegradedSportsPath(path) ? `markets · ${path} (degraded upstream — likely fails, uncharged)` : `markets · ${path}`, + }); if (!confirm.ok) return { content: [{ type: "text", text: confirm.reason ?? "Charge cancelled." }] }; // rawGet/rawPost rather than the SDK's pm()/pmQuery(): those are one-line // wrappers over exactly `/v1/pm/${path}` on the same raw methods @@ -143,8 +145,13 @@ Pass query params via 'params' (GET). Use 'body' only for POST endpoints (e.g. p gate.release(); } } catch (err) { + const message = extractErrorMessage(err); + // A sports/* 5xx is the known Predexon outage, not a blip, and the + // gateway released the payment — say so instead of "after payment … + // try again in a few minutes" (blockrun-mcp#132). + const degraded = describeDegradedSportsFailure(path, message); return { - content: [{ type: "text", text: formatError(extractErrorMessage(err)) }], + content: [{ type: "text", text: degraded ?? formatError(message) }], isError: true, }; } diff --git a/src/utils/markets-validation.ts b/src/utils/markets-validation.ts index 3aff04d..a6fac6e 100644 --- a/src/utils/markets-validation.ts +++ b/src/utils/markets-validation.ts @@ -1,3 +1,4 @@ +import { hasLabelledServerStatus } from "./errors.js"; import { normalizeClassifyPath } from "./path-safety.js"; /** @@ -152,3 +153,45 @@ export function validateMarketRequest( return null; } + +// --------------------------------------------------------------------------- +// Degraded upstream routes — known to fail, NOT charged +// --------------------------------------------------------------------------- +// +// Every sports/* path has returned a consistent Predexon 500 ("An unexpected +// error occurred") since 2026-08-04 — re-verified live 2026-09-08 (#132). The +// gateway marks them `status: "degraded"` in its predexon.ts: still routed for +// anyone who knows the path, withheld from openapi.json and the x402 manifest, +// and on an upstream 5xx it releases the payment nonce, so nothing settles. +// +// Not a pre-payment block like markets/listings above: that one is a 410 sunset +// and settles before failing, this one is an upstream bug that may recover, and +// the gateway is the authority on whether it has. What we own is the wording. +// The SDK reduced the gateway's "(payment NOT charged)" body to +// `API error after payment: 502`, which asserts a charge that did not happen. +export const DEGRADED_SPORTS_SINCE = "2026-08-04"; + +export function isDegradedSportsPath(path: string): boolean { + // Same normalizer as every other rule here (query strip → decode → tab strip), + // so "sports%2Fcategories" gets the same wording as the plain path. + const clean = normalizeMarketPath(path); + return clean === "sports" || clean.startsWith("sports/"); +} + +/** + * Returns the full user-facing error text for a sports/* upstream failure, or + * null when the failure is not the known outage (a 4xx, or a non-sports path) + * so the caller falls back to the generic formatter. + */ +export function describeDegradedSportsFailure(path: string, message: string): string | null { + if (!isDegradedSportsPath(path)) return null; + // Same labelled-status rule as formatError, so "501 items" in an upstream + // 4xx body cannot be mistaken for the outage and sold as "not charged". + if (!hasLabelledServerStatus(message)) return null; + return `Error: ${message}\n\n` + + `Predexon's sports/* routes have returned an upstream 500 on every call since ${DEGRADED_SPORTS_SINCE}. ` + + `The gateway still routes them but no longer advertises them, and it releases the payment when upstream fails — ` + + `nothing was charged for this call.\n` + + `Retrying will not help until Predexon repairs the route. For sports odds use path "markets" with ` + + `params { league: "NBA" } (canonical cross-venue data) or "polymarket/events" instead.`; +} diff --git a/test/markets-validation.test.ts b/test/markets-validation.test.ts index 911032d..2c49244 100644 --- a/test/markets-validation.test.ts +++ b/test/markets-validation.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { validateMarketRequest } from "../src/utils/markets-validation.js"; +import { describeDegradedSportsFailure, isDegradedSportsPath, validateMarketRequest } from "../src/utils/markets-validation.js"; test("markets/listings is retired upstream and blocked before payment", () => { // Verified live 2026-07-29: settles payment, THEN returns 410 Gone. The @@ -136,3 +136,43 @@ test("a non-Polymarket candlestick route keeps its own interval format", () => { // would be a pure over-block. assert.equal(validateMarketRequest("binance/candles/BTCUSDT", { interval: "1h" }, undefined), null); }); + +// --- sports/* is degraded upstream (Predexon 500 since 2026-08-04), not charged --- + +test("sports paths are recognised, decorated or not", () => { + assert.equal(isDegradedSportsPath("sports/categories"), true); + assert.equal(isDegradedSportsPath("/sports/markets"), true); + assert.equal(isDegradedSportsPath("SPORTS/outcomes/abc"), true); + assert.equal(isDegradedSportsPath("sportsbook/markets"), false); + assert.equal(isDegradedSportsPath("polymarket/markets"), false); +}); + +test("a sports 5xx explains the outage and that nothing was charged", () => { + const out = describeDegradedSportsFailure("sports/categories", "API error after payment: 502\nRequest failed"); + assert.ok(out); + assert.match(out, /since 2026-08-04/); + assert.match(out, /nothing was charged/); + assert.match(out, /"markets"/); + assert.doesNotMatch(out, /temporary API issue/); +}); + +test("a sports 4xx is the caller's problem and falls through to the generic formatter", () => { + assert.equal(describeDegradedSportsFailure("sports/markets", "API error after payment: 400\nMissing league"), null); +}); + +test("a non-sports 5xx falls through to the generic formatter", () => { + assert.equal(describeDegradedSportsFailure("polymarket/markets", "API error after payment: 502\nRequest failed"), null); +}); + +test("sports paths are still routed — no pre-payment block, the gateway decides", () => { + assert.equal(validateMarketRequest("sports/categories", undefined, undefined), null); +}); + +test("an incidental 5xx-shaped number in a sports 4xx body is not the outage", () => { + assert.equal(describeDegradedSportsFailure("sports/markets", "API error after payment: 400\nbatch of 501 items rejected"), null); +}); + +test("a percent-encoded sports path gets the same wording as the plain one", () => { + assert.equal(isDegradedSportsPath("sports%2Fcategories"), true); + assert.equal(isDegradedSportsPath("sports/categories?league=NBA"), true); +}); From 66db658d2a635c91696ae65dc23aacab6296a5d1 Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:04:30 -0500 Subject: [PATCH 03/20] fix(price): answer equity price/history before the wallet is consulted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since 2026-09-05 the gateway returns a pre-payment 501 for every stocks/{market}/price|history call — 'We do not currently serve equity prices'. On the default Solana chain the Base-only guard fired first and told the user to switch chains to pay for a route that cannot succeed (#132). Paid stock calls now return the gateway's own answer up front: withdrawn on 2026-09-05, nothing charged, the ticker catalog is still free, contact for coverage. The description stops promising equity quotes. The paid-tool confirmation table drops the stock row with a note on how to re-add it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg --- src/tools/price.ts | 46 +++++++++++++++++++++++++---- test/confirm-spend-coverage.test.ts | 8 ++++- test/price-equity-preflight.test.ts | 25 ++++++++++++++++ 3 files changed, 72 insertions(+), 7 deletions(-) create mode 100644 test/price-equity-preflight.test.ts diff --git a/src/tools/price.ts b/src/tools/price.ts index 5ede051..971fb1a 100644 --- a/src/tools/price.ts +++ b/src/tools/price.ts @@ -1,8 +1,22 @@ // src/tools/price.ts // // Pyth-backed market data tool. Crypto, FX and commodity are fully free -// (price + history + list); stocks (`stocks/{market}` and the `usstock` -// legacy alias) charge $0.001 per price or history call. +// (price + history + list). +// +// Equity is catalog-only. Since 2026-09-05 the gateway answers every +// `stocks/{market}/price` and `/history` call (and the `usstock` alias) with a +// pre-payment 501 — "We do not currently serve equity prices" — after +// blockrun#517 moved the free tier onto licensed sources. `stocks/{market}/list` +// still serves the ticker catalog for free (the Solana gateway has no equity +// route at all — it answers with the site HTML). +// +// Paid stock price/history is therefore answered HERE, before the chain guard +// and before any network call: on the default Solana chain the Base-only guard +// used to fire first and tell the user to switch chains to pay for a route +// that 501s. The gateway's answer is a product decision with a contact +// address, not a transient fault, so pre-empting it loses nothing. Retire the +// pre-flight (and re-enable the paid path below it) once +// `curl https://blockrun.ai/v1/stocks/us/price/AAPL` answers 402 again. // // Supported markets: us, hk, jp, kr, gb, de, fr, nl, ie, lu, cn, ca. @@ -35,11 +49,25 @@ function isPaidPriceCall(action: "price" | "history" | "list", category: string) return action !== "list" && (category === "stocks" || category === "usstock"); } +/** + * What the gateway itself answers for equity price/history since 2026-09-05 + * (HTTP 501, verified live 2026-09-08), said before the wallet is consulted. + * Exported for the test; nothing here touches the network. + */ +export function equityNotServedMessage(action: string, category: string, market?: string): string { + const mkt = market ?? "us"; + return `Error: Equity ${action === "history" ? "history" : "quotes"} are not served (gateway 501 for category "${category}").\n\n` + + `The gateway withdrew equity price and history on 2026-09-05 — this is not an outage, retrying will not help, ` + + `and nothing was charged (the wallet was not asked to sign).\n` + + `The ticker catalog still works and is free: { action: "list", category: "stocks", market: "${mkt}" }.\n` + + `For realtime or global equity coverage, contact hello@blockrun.ai.`; +} + export function registerPriceTool(server: McpServer, budget: BudgetState): void { server.registerTool( "blockrun_price", { - description: `Realtime quotes and OHLC history for crypto, FX, commodities and 12 global stock markets (Pyth-backed). + description: `Realtime quotes and OHLC history for crypto, FX and commodities (Pyth-backed), plus the ticker catalog for 12 stock markets. - action="price" — realtime quote for a symbol - action="history" — OHLC bars between from/to (unix seconds) @@ -47,20 +75,20 @@ export function registerPriceTool(server: McpServer, budget: BudgetState): void Pricing: - crypto / fx / commodity: FREE across price, history and list -- stocks / usstock: $0.001 per price or history call (list free) +- stocks / usstock: list (ticker catalog) FREE; price/history NOT SERVED — gateway 501 before payment since 2026-09-05, nothing charged, do not retry Stocks markets: us, hk, jp, kr, gb, de, fr, nl, ie, lu, cn, ca (required when category="stocks"). Examples: - { action: "price", category: "crypto", symbol: "BTC-USD" } -- { action: "price", category: "stocks", symbol: "AAPL", market: "us" } +- { action: "price", category: "fx", symbol: "EUR-USD" } - { action: "history", category: "crypto", symbol: "ETH-USD", resolution: "D", from: 1700000000, to: 1710000000 } - { action: "list", category: "crypto", query: "sol" }`, annotations: TOOL_ANNOTATIONS.readOnlyOpenWorld, inputSchema: { action: ACTION.describe("Which endpoint to hit: price, history, or list."), category: CATEGORY.describe("Market category."), - symbol: z.string().optional().describe("Ticker (required for price+history). e.g. BTC-USD, AAPL, EUR-USD."), + symbol: z.string().optional().describe("Ticker (required for price+history). e.g. BTC-USD, EUR-USD, XAU-USD."), market: MARKET.optional().describe("Stock market code — required when category='stocks'."), session: SESSION.optional().describe("Equity session hint (pre/post/on); ignored for non-equity."), resolution: RESOLUTION.optional().describe("Bar resolution for history (default D)."), @@ -78,6 +106,12 @@ Examples: } const paid = isPaidPriceCall(action, category); + if (paid) { + return { + content: [{ type: "text", text: equityNotServedMessage(action, category, market) }], + isError: true, + }; + } const chainBlock = paid ? baseOnlyMessage("Paid stock price/history calls") : null; if (chainBlock) { return { content: [{ type: "text", text: formatError(chainBlock) }], isError: true }; diff --git a/test/confirm-spend-coverage.test.ts b/test/confirm-spend-coverage.test.ts index 09bde45..607a01c 100644 --- a/test/confirm-spend-coverage.test.ts +++ b/test/confirm-spend-coverage.test.ts @@ -111,7 +111,13 @@ const CASES: Array<{ tool: string; mod: string; register: string; args: Record { + const out = equityNotServedMessage("price", "stocks", "hk"); + assert.match(out, /2026-09-05/); + assert.match(out, /nothing was charged/); + assert.match(out, /action: "list", category: "stocks", market: "hk"/); + assert.match(out, /hello@blockrun\.ai/); + assert.doesNotMatch(out, /switch/i); + assert.doesNotMatch(out, /temporary API issue/); +}); + +test("usstock alias defaults the catalog hint to the US market", () => { + assert.match(equityNotServedMessage("history", "usstock", undefined), /market: "us"/); + assert.match(equityNotServedMessage("history", "usstock", undefined), /Equity history/); +}); From 939868a82d512da5acb7e0b33a37094f45d8fe45 Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:04:30 -0500 Subject: [PATCH 04/20] docs: stop promising equity quotes and sports routes; sync the schema-cost table README, four skills and the plugin manifest still sold paid stock quotes and the sports/* Predexon routes. Both now read as what the gateway serves. The context-cost profile table is updated to the measured totals after the description edits (12,992 full / 5,606 trading). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg --- .claude-plugin/marketplace.json | 2 +- README.md | 8 ++++---- skills/crypto-data/SKILL.md | 10 +++++----- skills/gentech-blockrun/SKILL.md | 6 +++--- skills/prediction-markets/SKILL.md | 19 ++++++++++++------- skills/surf/SKILL.md | 2 +- 6 files changed, 26 insertions(+), 21 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index c23f0d7..f7d44de 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -38,7 +38,7 @@ { "name": "crypto-data", "source": "./skills/crypto-data", - "description": "Use for any crypto data question — token prices, FX, commodities, stocks, OHLC history, DEX pairs, DeFi TVL and yields, on-chain SQL, wallet labels and net worth, social mindshare. Routes across five overlapping tools and says which one to use and which are free." + "description": "Use for any crypto data question — token prices, FX, commodities, stock ticker catalog, OHLC history, DEX pairs, DeFi TVL and yields, on-chain SQL, wallet labels and net worth, social mindshare. Routes across five overlapping tools and says which one to use and which are free." }, { "name": "surf", diff --git a/README.md b/README.md index 3c394ee..c49a73b 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ claude mcp add blockrun -s user -- npx -y @blockrun/mcp@latest
- Context cost: 12.9K tokens, 6% of a 200K context window, charged every turn whether or not you call a tool. 5.6K with --profile trading, 57% less. + Context cost: 13.0K tokens, 6% of a 200K context window, charged every turn whether or not you call a tool. 5.6K with --profile trading, 57% less.
@@ -207,8 +207,8 @@ Package managers have shown install size for decades. Almost no MCP server shows | Profile | Tools | Context | |---------|-------|---------| -| `full` *(default)* | 20 | 12,991 | -| `trading` | 9 | 5,605 | +| `full` *(default)* | 20 | 12,992 | +| `trading` | 9 | 5,606 | | `media` | 7 | 5,527 | | `research` | 6 | 3,075 | | `chat` | 3 | 1,975 | @@ -331,7 +331,7 @@ npx -y @blockrun/mcp@latest skills install --to ~/.codex/skills | `blockrun_realface` | Enroll a real person (phone liveness) or AI character (Virtual Portrait) as a `ta_xxxx` asset for Seedance 2.0 / 2.0-fast / 2.0-mini video (not 2.5) | free; $0.01 to enroll | | `blockrun_music` | MiniMax music generation | per track | | `blockrun_speech` | ElevenLabs TTS (Flash/Turbo/Multilingual/v3, 8 voices) + ByteDance Seed Audio (prompt-directed) + cinematic sound effects; free voice listing | $0.05–0.10/1k chars | -| `blockrun_price` | Pyth-backed realtime + OHLC — crypto / FX / commodity (free), 12 stock markets (paid) | free or $0.001/call | +| `blockrun_price` | Pyth-backed realtime + OHLC — crypto / FX / commodity, plus the ticker catalog for 12 equity markets (equity quotes withdrawn 2026-09-05) | free | | `blockrun_markets` | Polymarket (markets, candles, trades, orderbooks, leaderboards, smart-wallet PnL/clusters, UMA oracle), Kalshi, Limitless, Opinion, Predict.Fun, dFlow, Binance Futures, cross-platform search | $0.0095/query | | `blockrun_polymarket_read` | Read-only Polymarket positions/open orders plus executable live order previews, separated for MCP clients that enforce tool safety annotations | free | | `blockrun_polymarket` | **Trade on Polymarket** (CLOB V2): place/cancel real bets, positions, redeem winnings — signed locally, settled in pUSD from a gasless deposit wallet. Confirm-gated, $25/order default cap. [Details ↓](#-polymarket-trading) | free tool; bets are your funds | diff --git a/skills/crypto-data/SKILL.md b/skills/crypto-data/SKILL.md index 7d85cf4..94ef780 100644 --- a/skills/crypto-data/SKILL.md +++ b/skills/crypto-data/SKILL.md @@ -60,7 +60,7 @@ Five tools cover crypto data and they overlap. **Pick by cost first** — two of | **FX rate / commodity (gold, oil)** | `blockrun_price` category:"fx" or `"commodity"` | **FREE** | | **Which symbols exist?** | `blockrun_price` action:"list" | **FREE** | | **DEX pair, liquidity, volume, contract** | `blockrun_dex` | **FREE** | -| Stock quote / history (12 markets) | `blockrun_price` category:"stocks" | $0.0020 | +| Stock ticker catalog (12 markets) | `blockrun_price` action:"list" category:"stocks" | **FREE** — quotes/history withdrawn 2026-09-05 (501) | | Token price by contract address | `blockrun_defi` path:"prices/{coins}" | $0.0020 | | Raw JSON-RPC on 40 chains | `blockrun_rpc` | $0.0030 | | Protocol TVL, chain TVL, yields/APY | `blockrun_defi` | $0.0060 | @@ -71,11 +71,11 @@ Every price below is what x402 actually **charges** (the base plus the gateway's **The rule:** a plain crypto price or a DEX pair is free. Only reach for `blockrun_surf` when you need something the free tools genuinely do not have — labels, SQL, social, news, unlocks. -**Prediction markets are never a Surf question.** Surf carries `prediction-market/*` endpoints, but Predexon (`blockrun_markets`) serves the same Polymarket/Kalshi data at the **same $0.0085** — and adds wallet clustering, smart money, sports, UMA, and five more venues that Surf does not have at all. Price is no longer the argument (it was 7.5× cheaper before 2026-07-15); coverage is, and it is decisive. Route odds, positions and market history to [`skills/prediction-markets/SKILL.md`](../prediction-markets/SKILL.md). +**Prediction markets are never a Surf question.** Surf carries `prediction-market/*` endpoints, but Predexon (`blockrun_markets`) serves the same Polymarket/Kalshi data at the **same $0.0085** — and adds wallet clustering, smart money, sports (via `markets` + `league=` — the dedicated `sports/*` routes are degraded), UMA, and five more venues that Surf does not have at all. Price is no longer the argument (it was 7.5× cheaper before 2026-07-15); coverage is, and it is decisive. Route odds, positions and market history to [`skills/prediction-markets/SKILL.md`](../prediction-markets/SKILL.md). ## blockrun_price — quotes & history (Pyth-backed) -Free for crypto, FX and commodities. $0.0020 only for stocks. +Free for crypto, FX and commodities. Equity is catalog-only: since 2026-09-05 the gateway answers `stocks` price/history with a pre-payment 501 ("We do not currently serve equity prices") — nothing is charged, retrying does not help, and the user should contact hello@blockrun.ai for equity coverage. `action:"list"` still returns the ticker catalog for free. ```ts blockrun_price({ action: "price", category: "crypto", symbol: "BTC-USD" }) // FREE @@ -84,10 +84,10 @@ blockrun_price({ action: "history", category: "crypto", symbol: "ETH-USD", blockrun_price({ action: "price", category: "fx", symbol: "EUR-USD" }) // FREE blockrun_price({ action: "price", category: "commodity", symbol: "XAU-USD" }) // FREE — gold blockrun_price({ action: "list", category: "crypto" }) // FREE — discovery -blockrun_price({ action: "price", category: "stocks", symbol: "AAPL", market: "us" }) // $0.0020 +blockrun_price({ action: "list", category: "stocks", market: "us", query: "AAPL" }) // FREE — catalog only; price/history → 501 ``` -Stock markets: `us`, `hk`, `jp`, `kr`, `gb`, `de`, `fr`, `nl`, `ie`, `lu`, `cn`, `ca` — `market` is required when `category:"stocks"`. +Stock markets: `us`, `hk`, `jp`, `kr`, `gb`, `de`, `fr`, `nl`, `ie`, `lu`, `cn`, `ca` — `market` is required when `category:"stocks"`. Do not call `action:"price"` or `"history"` on `stocks`: the gateway does not serve equity quotes right now. ## blockrun_dex — DEX pairs & liquidity (DexScreener) diff --git a/skills/gentech-blockrun/SKILL.md b/skills/gentech-blockrun/SKILL.md index 6dc239c..0f427ce 100644 --- a/skills/gentech-blockrun/SKILL.md +++ b/skills/gentech-blockrun/SKILL.md @@ -66,7 +66,7 @@ blockrun_wallet(action="status") ### Pattern 1: Regular Price Checks (FREE) Crypto, FX and commodity quotes cost **nothing** — `blockrun_price` is free for those -categories (only `stocks`/`usstock` is paid, at $0.0020). Use them liberally, and do +categories (`stocks`/`usstock` quotes are not served since 2026-09-05 — the gateway 501s before payment; only the ticker catalog works). Use them liberally, and do not pay $0.0085 to `blockrun_surf` for a quote you can get for $0. ```python @@ -81,7 +81,7 @@ blockrun_price(action="price", category="crypto", symbol="SOL-USD") blockrun_price(action="list", category="crypto", query="sol") ``` -**Cost:** $0 — crypto/FX/commodity price *and* list calls are both free. Only `category:"stocks"` is paid ($0.0020). +**Cost:** $0 — crypto/FX/commodity price *and* list calls are both free. `category:"stocks"` price/history currently return 501 (equity quotes withdrawn 2026-09-05, nothing charged); its `list` catalog is free. ### Pattern 2: Token Research Pipeline (~$0.018) @@ -204,7 +204,7 @@ blockrun_video({ prompt: "animated data visualization", duration_seconds: 8 }) | `blockrun_dex` | FREE | unlimited | | `blockrun_rpc` | $0.0030 | on-chain reads (batch: $0.002/element + $0.001) | | `blockrun_wallet` (status/report) | FREE | before every session | -| `blockrun_price` (quote) | **FREE** | crypto/FX/commodity; stocks $0.0020 | +| `blockrun_price` (quote) | **FREE** | crypto/FX/commodity; stocks quotes not served (501 since 2026-09-05) | | `blockrun_defi` | $0.0060 | protocol/chain analysis ($0.0020 for prices/*) | | `blockrun_chat` (free mode) | $0 | NVIDIA-backed chat | | `blockrun_chat` (glm mode) | per-token | Zhipu GLM-5 coding — billed on tokens used, not a flat rate | diff --git a/skills/prediction-markets/SKILL.md b/skills/prediction-markets/SKILL.md index 4d3015d..b62d49a 100644 --- a/skills/prediction-markets/SKILL.md +++ b/skills/prediction-markets/SKILL.md @@ -102,7 +102,7 @@ Current parameter contracts that prevent paid 4xx responses: | Tier | Price | What | |---|---|---| -| **All endpoints** | $0.0085 | Market data, events, history, candles, orderbooks, trades, leaderboard, sports, UMA, wallet analytics, smart money, identity + clustering, cross-venue matching, Binance | +| **All endpoints** | $0.0085 | Market data, events, history, candles, orderbooks, trades, leaderboard, UMA, wallet analytics, smart money, identity + clustering, cross-venue matching, Binance (`sports/*` is degraded upstream — see below; a failed call is not charged) | Pass-through pricing, 0% BlockRun margin — settles straight to Predexon's Base treasury. @@ -150,10 +150,10 @@ Pass-through pricing, 0% BlockRun margin — settles straight to Predexon's Base | UMA status + timeline for a market | `polymarket/uma/market/{condition_id}` | 1 | | Kalshi markets | `kalshi/markets` | 1 | | Kalshi trades / orderbooks | `kalshi/trades`, `kalshi/orderbooks` | 1 | -| Sports categories | `sports/categories` | 1 | -| Sports markets by league | `sports/markets` | 1 | -| One game, all venue outcomes | `sports/markets/{game_id}` | 1 | -| Equivalent sports outcomes | `sports/outcomes/{predexon_id}` | 1 | +| ⚠ Sports categories — **degraded upstream since 2026-08-04, do not call** | `sports/categories` | 1 | +| ⚠ Sports markets by league — degraded, use `markets` + `league=` | `sports/markets` | 1 | +| ⚠ One game, all venue outcomes — degraded | `sports/markets/{game_id}` | 1 | +| ⚠ Equivalent sports outcomes — degraded | `sports/outcomes/{predexon_id}` | 1 | | Limitless / Opinion / Predict.Fun markets | `limitless/markets`, `opinion/markets`, `predictfun/markets` | 1 | | Their historical orderbook snapshots | `limitless/orderbooks`, `opinion/orderbooks`, `predictfun/orderbooks` | 1 | | Binance candles / ticks | `binance/candles/{symbol}`, `binance/ticks/{symbol}` | 2 | @@ -236,10 +236,15 @@ blockrun_markets({ path: "matching-markets/pairs" }) ### 8. "Who's ahead in tonight's NBA games?" ```ts -blockrun_markets({ path: "sports/markets", params: { league: "NBA", status: "open" } }) -blockrun_markets({ path: "sports/markets/GAME_ID" }) // every venue's price for that game +blockrun_markets({ path: "markets", params: { league: "NBA", status: "open" } }) // canonical cross-venue containers +blockrun_markets({ path: "outcomes/PREDEXON_ID" }) // every venue's listing for one outcome ``` +Do **not** route this to `sports/*`. All four `sports/*` paths have returned a Predexon 500 on every call since +2026-08-04 (re-verified 2026-09-08). The gateway still routes them but withdrew them from discovery, and it +releases the payment on upstream failure — so the call costs nothing and returns nothing. The tool now says so +in its error instead of "API error after payment"; the routes come back here the day Predexon repairs them. + ### 9. "Is this market about to resolve?" ```ts diff --git a/skills/surf/SKILL.md b/skills/surf/SKILL.md index 34df649..1165d2d 100644 --- a/skills/surf/SKILL.md +++ b/skills/surf/SKILL.md @@ -71,7 +71,7 @@ Predexon used to be 7.5× cheaper; since 2026-07-15 both bill the same flat rate |---|---|---| | Polymarket / Kalshi markets | $0.0085 | $0.0085 (same) | | Wallet clustering, smart money, leaderboards | ✅ | ❌ none | -| Limitless, Opinion, Predict.Fun, sports, UMA | ✅ | ❌ Polymarket + Kalshi only | +| Limitless, Opinion, Predict.Fun, sports (via `markets?league=`), UMA | ✅ | ❌ Polymarket + Kalshi only | The only Surf prediction-market endpoint with no Predexon equivalent is `prediction-market/category-metrics`. Everything else is a strictly worse buy. See [`skills/prediction-markets/SKILL.md`](../prediction-markets/SKILL.md). From 5073d1f083aeceb78bf05babb09bfc75598f793e Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:04:30 -0500 Subject: [PATCH 05/20] =?UTF-8?q?0.48.1=20=E2=80=94=20the=20error=20says?= =?UTF-8?q?=20whether=20money=20moved?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump @blockrun/llm to ^3.15.1 (the SDK half of #132) and package version to 0.48.1. Also ships the three fixes that landed on main after 0.48.0: image account-rail settled cost (#140), OpenClaw verification and DeepSeek repricing (#131), brand numbers (#141). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg --- CHANGELOG.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++ package-lock.json | 16 ++++++-------- package.json | 4 ++-- 3 files changed, 64 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0ab01b..9e9a31f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,61 @@ All notable changes to BlockRun MCP will be documented in this file. +## 0.48.1 + +**The error says whether money moved.** Issue #132 reported `blockrun_markets` +and `blockrun_price` failing with `API error after payment: 502 / Request failed` +while the wallet balance never changed. The balance was right and the words were +wrong, and the words were wrong on three layers at once. + +The gateway had said exactly what happened — `Predexon 500: An unexpected error +occurred (payment NOT charged)` — but `@blockrun/llm` kept only the top-level +`error` string when it sanitized the body, so the cause and the settlement status +never reached this server. That is fixed in `@blockrun/llm` 3.15.1 (PR #39), which +carries the gateway's message through as `detail`; this release depends on it and +reads the new field, because our own extractor only ever looked at `message` and +`hint` and would have dropped it a second time. The formatter's "not charged" +branch, which already existed, finally receives the string it was written for. + +**Sports is degraded upstream, and the tool now says so.** All four Predexon +`sports/*` routes have returned an upstream 500 on every call since 2026-08-04. +The gateway marks them degraded, withdrew them from discovery, and releases the +payment nonce on upstream failure, so a call costs nothing and returns nothing. +This server still advertised them as live. The description, the prediction-markets +skill and the crypto-data and surf skills now say the routes are degraded and +point at the canonical `markets` endpoint with a `league` filter instead; a +`sports/*` 5xx renders the outage, the date, and "nothing was charged" rather +than "temporary API issue, try again". The routes stay callable, because the +gateway is the authority on whether Predexon has recovered. + +**Equity quotes are not served, and the tool no longer sells them.** Since +2026-09-05 the gateway answers every `stocks/{market}/price` and `history` call +(and the `usstock` alias) with a pre-payment 501: "We do not currently serve +equity prices." This server's description, README and two skills still promised +paid stock quotes, and on the default Solana chain the Base-only guard fired first +and told the user to switch chains to pay for a route that cannot succeed. Paid +stock calls now return the gateway's own answer before the wallet is consulted: +withdrawn on 2026-09-05, nothing charged, the ticker catalog is still free, and +who to contact for equity coverage. `formatError` also stops labelling any 501 a +transient outage; it claims "nothing was charged" only when the 501 arrived +before payment. + +Also shipping, landed on `main` since 0.48.0: + +- **`blockrun_image` reads the settled cost on the account rail** instead of an + estimate that was high by the transaction fee the rail does not charge, and + stops labelling an exact figure "estimated" (#140). +- **OpenClaw is verified** end-to-end on 2026.8.2 with the published `npx` + package, with install notes on spend confirmation per chat surface; the + `deepseek/deepseek-v4-pro` rate follows the gateway's repricing (#131). +- Brand numbers refreshed from the canonical snapshot (#141). + +Two adversarial passes (a fresh-context Claude subagent and Codex) reviewed the +change; every finding was addressed, including the two that mattered: a +post-payment 501 must not claim nothing was charged, and the sports matcher must +use the same labelled-status rule as `formatError` so an incidental "501 items" +in a 4xx body is not sold as the outage. + ## 0.48.0 **A key can live in a file, not just an environment variable.** Write it to diff --git a/package-lock.json b/package-lock.json index 2ba618b..49cd4ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "@blockrun/mcp", - "version": "0.45.1", + "version": "0.48.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@blockrun/mcp", - "version": "0.45.1", + "version": "0.48.1", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.123.0", - "@blockrun/llm": "^3.14.3", + "@blockrun/llm": "^3.15.1", "@modelcontextprotocol/sdk": "^1.0.0", "@polymarket/builder-relayer-client": "^0.0.10", "@polymarket/builder-signing-sdk": "1.0.0", @@ -84,20 +84,18 @@ } }, "node_modules/@blockrun/llm": { - "version": "3.14.3", - "resolved": "https://registry.npmjs.org/@blockrun/llm/-/llm-3.14.3.tgz", - "integrity": "sha512-bJD4Fp8hiXuCcuQbPaSRO8eRtPPAdfGcjVoMQP457qjfAhhN/Xwz59uEiTIBnMyIx5hRW1UXepxSU3m4sMuUbA==", + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/@blockrun/llm/-/llm-3.15.1.tgz", + "integrity": "sha512-Q8IyUrqXPnBfIlGCyzJN3xj4ahgEoVXs6jq+K0qL89DR1zYUljDwic32Yr8uvK7bfkQ6EdY4mXBHcMDx7EaApg==", "license": "MIT", "dependencies": { + "@anthropic-ai/sdk": "^0.123.0", "bs58": "^6.0.0", "viem": "^2.56.3" }, "engines": { "node": ">=20" }, - "optionalDependencies": { - "@anthropic-ai/sdk": "^0.123.0" - }, "peerDependencies": { "@solana/spl-token": "^0.4.15", "@solana/web3.js": "^1.98.4" diff --git a/package.json b/package.json index 0e3d674..12f4225 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@blockrun/mcp", - "version": "0.48.0", + "version": "0.48.1", "mcpName": "io.github.BlockRunAI/blockrun-mcp", "description": "BlockRun MCP Server - Give your AI agent web search, deep research, prediction markets, and crypto data. Pay per call from a USDC wallet (Solana or Base) or a BlockRun API key.", "type": "module", @@ -58,7 +58,7 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.123.0", - "@blockrun/llm": "^3.14.3", + "@blockrun/llm": "^3.15.1", "@modelcontextprotocol/sdk": "^1.0.0", "@polymarket/builder-relayer-client": "^0.0.10", "@polymarket/builder-signing-sdk": "1.0.0", From 5f5777c3a8545a1c5d5e82c0fd7795eaea4f3b99 Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:11:47 -0500 Subject: [PATCH 06/20] docs: update project documentation for 0.48.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README troubleshooting and the blockrun-debug skill now carry the two issue-132 symptoms as symptom → cause → fix: the equity 501 from blockrun_price (withdrawn 2026-09-05, nothing charged, catalog still free) and the sports/* upstream outage behind blockrun_markets (degraded since 2026-08-04, payment released; older builds rendered it as "API error after payment: 502"). A third row covers the generic 501 "not served" wording and when it can and cannot claim nothing was charged. The debug skill's description and triggers pick up the new error strings. The cost-reporting table drops "paid blockrun_price" — no paid price path remains — and states blockrun_image's account-rail cost as settled (#140), with the estimate only as the fallback when no figure is returned. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg --- README.md | 4 +++- skills/blockrun-debug/SKILL.md | 9 ++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c49a73b..5e409a1 100644 --- a/README.md +++ b/README.md @@ -503,7 +503,7 @@ A blocked capability returns a message naming the fix, not a raw error. |---|---| | API key — most tools | **The amount actually settled**, read from the account API's per-call response | | API key — `blockrun_chat` | An estimate. Chat settles *after* the response by design, so no figure exists when the answer is sent | -| API key — `blockrun_image`, paid `blockrun_price` | An estimate, until the SDK surfaces the settled figure on those paths | +| API key — `blockrun_image` | **The amount actually settled** (since 0.48.1); only when the account API returns no figure does it fall back to the catalog estimate, marked `~` | | Wallet | The amount signed and settled on-chain, from the 402 quote | Anything estimated is printed with a `~` and says so. Estimates run **high** on @@ -610,6 +610,8 @@ The server runs a non-blocking npm registry check at startup and prints an `Upda - **`claude mcp list` doesn't show `blockrun`** → Check `node -v` (≥20.19). Clear the npx cache: `rm -rf ~/.npm/_npx`. Re-run the install. - **`fetch failed` / balance-check timeout** → Base RPC transient outage. The tool falls through 3 public RPCs; retry after 30s. Persistent = local proxy / firewall blocking outbound RPC. - **`Video`/`Music generation timed out`** → Upstream queue congestion. **No charge** (payment-on-completion). Retry, or pick a faster model. +- **`blockrun_price` says `Equity quotes are not served (gateway 501 …)`** → Equity price/history were withdrawn on 2026-09-05; not an outage, and **nothing was charged** (the wallet is never asked to sign). The ticker catalog (`action:"list" category:"stocks"`) is still free. Equity coverage: hello@blockrun.ai. +- **`blockrun_markets` on `sports/*` fails — before 0.48.1 as `API error after payment: 502` with no balance change** → Predexon's `sports/*` routes have been down upstream since 2026-08-04; the gateway releases the payment, so **nothing was charged**. Use `path:"markets"` with `params:{ league: "NBA" }` instead, and upgrade to ≥ 0.48.1 so the error says so itself. - **No spend-confirmation dialog although `BLOCKRUN_CONFIRM_SPEND=on`** → Your client doesn't support MCP elicitation (Windsurf, Codex, Gemini CLI); the server proceeds without asking by design. Use `BLOCKRUN_BUDGET_LIMIT` as the guard, or a client from the [support table](#%EF%B8%8F-human-in-the-loop-payments). - **Polymarket: neg-risk ("winner") market buy fails, or `redeem` reverts, though setup shows ready** → Re-run `action:"setup" confirm:true` once (grants the on-chain approvals a pre-upgrade deposit wallet may lack — including the collateral-adapter approvals `redeem` needs). See the [setup guide](docs/polymarket-trading-setup.md). diff --git a/skills/blockrun-debug/SKILL.md b/skills/blockrun-debug/SKILL.md index 0dd4431..61d4317 100644 --- a/skills/blockrun-debug/SKILL.md +++ b/skills/blockrun-debug/SKILL.md @@ -1,6 +1,6 @@ --- name: blockrun-debug -description: "Use when the BlockRun MCP server (@blockrun/mcp) is installed but misbehaving — 'Failed to connect', spawn npx ENOENT, blockrun missing from claude mcp list, HTTP 402 / Insufficient balance, fetch failed, video or music timeouts, spend-confirmation dialogs not appearing, or a Polymarket buy/redeem failing after funding. Symptom → cause → fix, plus what never to do." +description: "Use when the BlockRun MCP server (@blockrun/mcp) is installed but misbehaving — 'Failed to connect', spawn npx ENOENT, blockrun missing from claude mcp list, HTTP 402 / Insufficient balance, fetch failed, video or music timeouts, a 501 'not served' error or 'API error after payment' while the balance never moved, spend-confirmation dialogs not appearing, or a Polymarket buy/redeem failing after funding. Symptom → cause → fix, plus what never to do." triggers: - "blockrun failed to connect" - "blockrun not working" @@ -10,6 +10,10 @@ triggers: - "blockrun 402" - "fetch failed blockrun" - "video generation timed out" + - "api error after payment" + - "equity quotes are not served" + - "sports markets 500" + - "501 not implemented" - "polymarket buy failed" - "insufficient allowance" - "redeem reverts" @@ -59,6 +63,9 @@ re-added at user scope leaves a duplicate. Then, in the session: `blockrun_walle | Startup error "not a valid BlockRun API key" | `BLOCKRUN_API_KEY` is malformed. It deliberately fails loudly rather than silently spending USDC from a wallet instead. | Fix the value or unset it. | | `fetch failed` / balance-check timeout | Base RPC blip; the tool rotates through 3 public RPCs | Wait 30 s, retry once. Persistent → a local proxy/firewall is blocking outbound RPC. | | `Video`/`Music generation timed out` | Upstream queue. **Not charged** — payment settles on completion only. | Retry, or pick a faster model. Do not retry-loop; jobs take 60–180 s. | +| `blockrun_price` with `category:"stocks"` / `"usstock"` → `Equity quotes are not served (gateway 501 …)` | The gateway withdrew equity price/history on 2026-09-05 (licensing), and the tool answers before the wallet is consulted. Not an outage. **Not charged.** | Do not retry. `action:"list" category:"stocks" market:"us"` still returns the ticker catalog for free. Equity coverage: hello@blockrun.ai. | +| `blockrun_markets` on `sports/*` → `Predexon's sports/* routes have returned an upstream 500 … since 2026-08-04` (builds before 0.48.1: `API error after payment: 502 / Request failed` with no balance change) | Upstream Predexon outage since 2026-08-04. The gateway releases the payment on upstream failure, so the old wording asserted a charge that never happened. **Not charged.** | Use `path:"markets"` with `params:{ league:"NBA" }` or `polymarket/events`. Do not retry `sports/*`. Upgrade to ≥ 0.48.1 so the error says this itself. | +| Any tool → `The gateway does not serve this endpoint (501 Not Implemented)` | The route is withdrawn, not down. Before payment the message ends "nothing was charged"; after payment it tells you to check the ledger instead, because the formatter cannot know whether the nonce was released. | Do not retry. `blockrun_wallet action:"report"` shows whether the call settled. | | Model id 404s | Delisted upstream | `blockrun_models` for the live list. | | Startup prints `🚨 WALLET PRIVATE KEY DETECTED IN CONFIG FILE` | The key was pasted into `~/.claude.json` (old hosted-auth flow) | Treat the key as compromised: move funds to a new wallet, remove it from the config. | | No spend-confirmation dialog with `BLOCKRUN_CONFIRM_SPEND=on` | Client doesn't support MCP elicitation (Windsurf, Codex, Gemini CLI) — the server proceeds without asking, by design | Use `BLOCKRUN_BUDGET_LIMIT` / `blockrun_wallet action:"delegate"` as the guard, or use Claude Code / Cursor / VS Code where the dialog renders. | From 267a09977c3e457d89297ee71050794de32cfebd Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:35:34 -0500 Subject: [PATCH 07/20] fix(video,image): refuse a 402 far above the published rate before signing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify:prices found the Solana gateway — a separate deployment that can lag Base — does not know azure/sora-2 and quotes 'Seedance 2.0 Pro video generation (5s)' at $1.135480 in its place, 2.7x the published Sora rate for a different model. The only check on the 402 price was the budget cap, which lets that through on any wallet holding $2. assertQuoteNearEstimate (budget.ts) refuses, unsigned, a quote more than 1.5x and $0.02 above the estimate the model was shown. Wired into blockrun_video on both rails and blockrun_image on Solana; the Solana helper now passes the decoded 402 to onQuote so callers can name WHAT was quoted. Messages end with 'no charge was made' and say how to proceed (for Sora: switch to Base). Two Solana tests that used a 4x quote to exercise the re-reserve now use a 1.27x quote — the 4x case is the thing being refused. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg --- src/tools/image.ts | 11 +++++-- src/tools/video.ts | 40 +++++++++++++++++++++-- src/utils/budget.ts | 61 +++++++++++++++++++++++++++++++++++ src/utils/solana-402.ts | 14 +++++--- test/quote-guard.test.ts | 60 ++++++++++++++++++++++++++++++++++ test/video-money-path.test.ts | 29 +++++++++++++++++ test/video-solana.test.ts | 30 ++++++++++++++--- 7 files changed, 230 insertions(+), 15 deletions(-) create mode 100644 test/quote-guard.test.ts diff --git a/src/tools/image.ts b/src/tools/image.ts index da95ab7..ca46df3 100644 --- a/src/tools/image.ts +++ b/src/tools/image.ts @@ -3,7 +3,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { TOOL_ANNOTATIONS } from "../tool-annotations.js"; import { z } from "zod"; import { PaymentError } from "@blockrun/llm"; -import { reserveBudget, recordSpending, recordActualSpend, reReserveIfHigher, BudgetExceededError } from "../utils/budget.js"; +import { BudgetExceededError, assertQuoteNearEstimate, reReserveIfHigher, recordActualSpend, recordSpending, reserveBudget } from "../utils/budget.js"; import { withTxFee } from "../utils/tx-fee.js"; import { formatError } from "../utils/errors.js"; import { launchTopUp } from "../utils/onramp.js"; @@ -457,7 +457,14 @@ Source images and masks accept a base64 data URI, an http(s) URL, or a local fil // table, so the real quote can exceed what we reserved. Re-reserve // the true amount against the cap BEFORE the transfer is signed // (mirrors blockrun_video); throwing here aborts before any payment. - onQuote: (quotedUsd) => { + onQuote: (quotedUsd, quoteDetails) => { + // WHAT was quoted before how much — a substituted or repriced + // model is refused unsigned (see assertQuoteNearEstimate). + assertQuoteNearEstimate(quotedUsd, estimatedCost, { + what: `${selectedModel} image`, + quotedFor: quoteDetails?.resource?.description, + hint: `Retry on Base (blockrun_wallet action:"chain" chain:"base") or pick another model.`, + }); gate = reReserveIfHigher(budget, gate, agent_id, estimatedCost, quotedUsd); if (!gate.allowed) { throw new BudgetExceededError(`${gate.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.`); diff --git a/src/tools/video.ts b/src/tools/video.ts index 82e5c25..7ddbf99 100644 --- a/src/tools/video.ts +++ b/src/tools/video.ts @@ -2,7 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { TOOL_ANNOTATIONS } from "../tool-annotations.js"; import { z } from "zod"; -import { amountToUsd, reserveBudget, recordActualSpend } from "../utils/budget.js"; +import { amountToUsd, assertQuoteNearEstimate, reserveBudget, recordActualSpend } from "../utils/budget.js"; import { confirmSpend } from "../utils/confirm-spend.js"; import { withTxFee } from "../utils/tx-fee.js"; import { formatError, isPaymentRejectionError } from "../utils/errors.js"; @@ -274,6 +274,28 @@ export function estimateVideoCost(model: string, durationSeconds?: number, resol return withTxFee(VIDEO_BASE_PRICE_PER_SECOND[model] * seconds * VIDEO_MARGIN); } +/** + * Refuse a 402 whose price is far above what the estimator (and the description + * the model read) said this call costs. See assertQuoteNearEstimate for the + * rule; this adds the one hint that is video-specific today. Verified live + * 2026-09-08: sol.blockrun.ai quotes azure/sora-2 as "Seedance 2.0 Pro video + * generation (5s)" at $1.135480 against Base's $0.421001. + */ +export function assertVideoQuoteSane( + quotedUsd: number | null, + estimatedCost: number, + model: string, + chain: "base" | "solana", + quotedFor?: string, +): void { + const hint = chain === "solana" + ? (model === "azure/sora-2" + ? `The Solana gateway is a separate deployment and does not serve azure/sora-2 yet — it quotes Seedance 2.0 in its place. Switch to Base for Sora (blockrun_wallet action:"chain" chain:"base") or pick a Seedance model explicitly.` + : `Retry on Base (blockrun_wallet action:"chain" chain:"base") or pick another model.`) + : `Retry on Solana (blockrun_wallet action:"chain" chain:"solana") or pick another model.`; + assertQuoteNearEstimate(quotedUsd, estimatedCost, { what: `${model} video`, quotedFor, hint }); +} + export function registerVideoTool(server: McpServer, budget: BudgetState): void { server.registerTool( "blockrun_video", @@ -283,7 +305,7 @@ export function registerVideoTool(server: McpServer, budget: BudgetState): void Turns a text prompt (and optional seed image) into a short MP4 clip. The tool submits the job, then polls until the video is ready (typical total wall-time 60-180s; 9 min Base / 15 min Solana hard cap). Payment is settled only when upstream returns a finished video — if the job fails or we give up, you are not charged. Models. Every rate below is what you are CHARGED (margin and transaction fee included), at the 720p baseline Seedance renders by default with synced audio: -- azure/sora-2 (~$0.105/sec, 720p + synced audio, text-to-video) — OpenAI Sora 2 via Azure AI Foundry. duration_seconds must be 4, 8, or 12 (4s default -> ~$0.42/clip). No image_url / RealFace. +- azure/sora-2 (~$0.105/sec, 720p + synced audio, text-to-video) — OpenAI Sora 2 via Azure AI Foundry. duration_seconds must be 4, 8, or 12 (4s default -> ~$0.42/clip). No image_url / RealFace. Base only for now: the Solana gateway quotes it as Seedance 2.0 at $1.135 and the tool refuses that quote unsigned. - xai/grok-imagine-video ($0.05/sec at 480p default, $0.07/sec at 720p; 8s default -> $0.401/clip, 1-15s) — stylized, fast. 480p/720p only. - bytedance/seedance-1.5-pro (~$0.071/sec, 4-12s, 5s default -> ~$0.35/clip) — cheapest Seedance, token-priced upstream - bytedance/seedance-2.0-mini (~$0.080/sec, 4-15s, 5s default) — 2.0-generation quality at roughly half the 2.0-fast rate; 720p ceiling; supports RealFace and first/last-frame @@ -497,7 +519,10 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC body, { pollBudgetMs: SOLANA_VIDEO_TOTAL_BUDGET_MS, - onQuote: (quotedUsd) => { + onQuote: (quotedUsd, quoteDetails) => { + // WHAT was quoted, before how much: a substituted or repriced + // model is refused here, unsigned (QuoteMismatchError). + assertVideoQuoteSane(quotedUsd, estimatedCost, selectedModel, "solana", quoteDetails?.resource?.description); if (quotedUsd === null || quotedUsd <= estimatedCost) return; gate?.release(); gate = reserveBudget(budget, agent_id, quotedUsd); @@ -579,6 +604,15 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC }; } + // WHAT was quoted, before how much. The estimator tracks the live 402 to + // within a cent (verify:prices), so a quote far above it is a reprice or + // a substituted model — refuse it unsigned rather than re-reserve it. + try { + assertVideoQuoteSane(settledUsd, estimatedCost, selectedModel, "base", details.resource?.description); + } catch (err) { + return { content: [{ type: "text", text: formatError(err instanceof Error ? err.message : String(err)) }], isError: true }; + } + // The 402 carries the REAL price; Seedance/Sora are token-priced, so a // 1080p/4K render can far exceed the per-second estimate reserved at // the gate. Re-reserve against the cap BEFORE paying so a single high-res diff --git a/src/utils/budget.ts b/src/utils/budget.ts index b24beb1..617d920 100644 --- a/src/utils/budget.ts +++ b/src/utils/budget.ts @@ -205,3 +205,64 @@ export function parseBudgetLimitEnv(raw: string | undefined): number | null { const n = Number(raw.trim().replace(/^\$/, "")); return Number.isFinite(n) && n > 0 ? n : null; } + +// --------------------------------------------------------------------------- +// Quote sanity — pay what you were told, or nothing +// --------------------------------------------------------------------------- +// +// Every manual-402 tool estimates the charge from a published rate table, then +// reads the REAL price off the gateway's 402 before signing. Until now the only +// check on that real price was the budget cap: a quote above the estimate was +// re-reserved and paid. That is right for a token-priced 4K render that the +// table undershoots by a cent, and wrong for what verify:prices found on +// 2026-09-08: the Solana gateway (a separate deployment that can lag Base) +// does not know azure/sora-2 and quotes it as "Seedance 2.0 Pro video +// generation (5s)" at $1.135 — 2.7x the published Sora rate, for a different +// model. The budget cap would have let that through on any wallet with $2. +// +// So: a quote more than QUOTE_TOLERANCE_RATIO above the estimate (and more than +// QUOTE_TOLERANCE_FLOOR_USD above it, so a $0.003 quote against a $0.001 +// estimate is not a "3x") is refused before anything is signed. The estimators +// are verified against live 402s to within $0.001 (`npm run verify:prices`), so +// the honest cases live far inside 1.5x; a legitimate gateway reprice past it +// fails loud until the estimator is updated, which is the safe direction for +// money. Nothing here touches the ledger — a refused quote settles nothing. +export const QUOTE_TOLERANCE_RATIO = 1.5; +export const QUOTE_TOLERANCE_FLOOR_USD = 0.02; + +export class QuoteMismatchError extends Error { + readonly quotedUsd: number; + readonly estimateUsd: number; + constructor(message: string, quotedUsd: number, estimateUsd: number) { + super(message); + this.name = "QuoteMismatchError"; + this.quotedUsd = quotedUsd; + this.estimateUsd = estimateUsd; + } +} + +/** + * Throws QuoteMismatchError when the gateway's authoritative quote is far above + * what the caller told the user to expect. `null` quotes are not judged here — + * callers already fail closed on an unreadable amount. The message ends with + * "no charge was made" so formatError() does not append funding advice. + */ +export function assertQuoteNearEstimate( + quotedUsd: number | null | undefined, + estimateUsd: number, + opts: { what: string; quotedFor?: string; hint?: string }, +): void { + if (typeof quotedUsd !== "number" || !Number.isFinite(quotedUsd)) return; + if (!(estimateUsd > 0)) return; // a $0 estimate means "free": nothing to compare + const ratio = quotedUsd / estimateUsd; + if (ratio <= QUOTE_TOLERANCE_RATIO || quotedUsd - estimateUsd <= QUOTE_TOLERANCE_FLOOR_USD) return; + const labelled = opts.quotedFor ? ` — the gateway labels that quote "${opts.quotedFor}"` : ""; + throw new QuoteMismatchError( + `The gateway quoted $${quotedUsd.toFixed(4)} for ${opts.what}, but this tool expected about $${estimateUsd.toFixed(4)} ` + + `(${ratio.toFixed(1)}x the published rate)${labelled}. Refusing to sign it — no charge was made. ` + + `A gap this large means the gateway repriced the model or substituted a different one.` + + (opts.hint ? ` ${opts.hint}` : ""), + quotedUsd, + estimateUsd, + ); +} diff --git a/src/utils/solana-402.ts b/src/utils/solana-402.ts index e0c1f63..2213cac 100644 --- a/src/utils/solana-402.ts +++ b/src/utils/solana-402.ts @@ -109,8 +109,12 @@ export interface SolanaPaidAsyncPostOptions { resignIntervalMs?: number; /** Maximum reactive re-signs after a completed poll rejects a stale signature. Defaults to SOLANA_ASYNC_MAX_REACTIVE_RESIGNS. */ maxReactiveResigns?: number; - /** Called after the authoritative quote is parsed and before anything is signed. */ - onQuote?: (quotedUsd: number | null) => void; + /** + * Called after the authoritative quote is parsed and before anything is + * signed. `details` is the decoded 402 (amount, recipient, resource + * description) so a caller can check WHAT was quoted, not just how much. + */ + onQuote?: (quotedUsd: number | null, details: ReturnType) => void; } type SolanaPaymentContext = { @@ -190,7 +194,7 @@ export async function solanaPaidPost( * without paying — e.g. to re-check the real price against a budget cap when * the Solana gateway's marked-up amount exceeds the caller's estimate. */ - onQuote?: (quotedUsd: number | null) => void; + onQuote?: (quotedUsd: number | null, details: ReturnType) => void; }, ): Promise { // resolveSolanaKey, not the SDK's file-only loader: under @@ -225,7 +229,7 @@ export async function solanaPaidPost( // Hand the caller the REAL quoted price before we sign/pay, so it can re-check // the marked-up Solana amount against its budget cap and abort (by throwing) // if it would overshoot — the amount is only known now, after the quote. - opts?.onQuote?.(context.paidUsd); + opts?.onQuote?.(context.paidUsd, context.details); const paymentPayload = await signSolanaChallenge(context, url, privateKey); // Step 2: paid request. The signed SPL transaction embeds a recent blockhash @@ -308,7 +312,7 @@ export async function solanaPaidAsyncPost( if (original.paidUsd === null) { throw new PaymentError(`The gateway's Solana quote carried an unreadable amount (${JSON.stringify(original.details.amount)}); refusing to sign it. No charge was made.`); } - opts.onQuote?.(original.paidUsd); + opts.onQuote?.(original.paidUsd, original.details); // Stamp BEFORE signing: the blockhash is fetched inside the sign call, and a // slow submit afterwards must not make the tracked age lag the real one. diff --git a/test/quote-guard.test.ts b/test/quote-guard.test.ts new file mode 100644 index 0000000..6dc47d4 --- /dev/null +++ b/test/quote-guard.test.ts @@ -0,0 +1,60 @@ +// Run with: npm test (tsx --test) +// +// Pay what you were told, or nothing. The gateway's 402 is authoritative for the +// price, but the tool told the model a published rate first; when the two are +// far apart the right move is to refuse unsigned, not to re-reserve and pay. +// Found live 2026-09-08: sol.blockrun.ai quotes azure/sora-2 as "Seedance 2.0 +// Pro video generation (5s)" at $1.135480 where Base quotes $0.421001. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + QUOTE_TOLERANCE_FLOOR_USD, + QUOTE_TOLERANCE_RATIO, + QuoteMismatchError, + assertQuoteNearEstimate, +} from "../src/utils/budget.js"; +import { assertVideoQuoteSane } from "../src/tools/video.js"; + +test("a quote at or a little above the estimate passes (token-priced renders land within a cent)", () => { + assert.doesNotThrow(() => assertQuoteNearEstimate(0.421001, 0.422001, { what: "x" })); + assert.doesNotThrow(() => assertQuoteNearEstimate(1.13548, 1.13748, { what: "x" })); + assert.doesNotThrow(() => assertQuoteNearEstimate(0.62, 0.42, { what: "x" })); // 1.48x +}); + +test("the floor keeps a tiny quote from reading as a multiple", () => { + // 3x, but only $0.002 apart — well under the floor. + assert.doesNotThrow(() => assertQuoteNearEstimate(0.003, 0.001, { what: "x" })); + assert.ok(QUOTE_TOLERANCE_FLOOR_USD > 0.002); +}); + +test("null / free estimates are not judged here", () => { + assert.doesNotThrow(() => assertQuoteNearEstimate(null, 0.42, { what: "x" })); + assert.doesNotThrow(() => assertQuoteNearEstimate(undefined, 0.42, { what: "x" })); + assert.doesNotThrow(() => assertQuoteNearEstimate(5, 0, { what: "x" })); +}); + +test("the live Sora-on-Solana quote is refused, named, and says no charge was made", () => { + assert.throws( + () => assertQuoteNearEstimate(1.13548, 0.422001, { what: "azure/sora-2 video", quotedFor: "Seedance 2.0 Pro video generation (5s)", hint: "Switch to Base." }), + (err: unknown) => { + assert.ok(err instanceof QuoteMismatchError); + assert.equal(err.quotedUsd, 1.13548); + assert.match(err.message, /quoted \$1\.1355 for azure\/sora-2 video/); + assert.match(err.message, /expected about \$0\.4220/); + assert.match(err.message, /2\.7x/); + assert.match(err.message, /"Seedance 2\.0 Pro video generation \(5s\)"/); + assert.match(err.message, /no charge was made/); + assert.match(err.message, /Switch to Base\./); + return true; + }, + ); + assert.equal(QUOTE_TOLERANCE_RATIO, 1.5); +}); + +test("the video wrapper adds the Sora/Solana explanation only where it applies", () => { + assert.throws(() => assertVideoQuoteSane(1.13548, 0.422001, "azure/sora-2", "solana", "Seedance 2.0 Pro video generation (5s)"), + /does not serve azure\/sora-2 yet.*chain:"base"/); + assert.throws(() => assertVideoQuoteSane(3, 1, "bytedance/seedance-2.0", "solana"), /Retry on Base/); + assert.throws(() => assertVideoQuoteSane(3, 1, "bytedance/seedance-2.0", "base"), /Retry on Solana/); + assert.doesNotThrow(() => assertVideoQuoteSane(0.421001, 0.422001, "azure/sora-2", "base")); +}); diff --git a/test/video-money-path.test.ts b/test/video-money-path.test.ts index 7b14df0..3b41c45 100644 --- a/test/video-money-path.test.ts +++ b/test/video-money-path.test.ts @@ -170,3 +170,32 @@ test("upstream failure before completion books nothing (no charge per gateway co assert.equal(res.isError, true); assert.equal(budget.spent, 0, "failed jobs are not charged and must not be booked"); }); + +test("a 402 far above the published rate is refused BEFORE signing — nothing signed, nothing booked", async () => { + // Live 2026-09-08 shape: the Solana gateway quoted azure/sora-2 (4s, $0.4220 + // expected) as Seedance 2.0 Pro at $1.135480. Same guard on the Base rail. + script = [resp402]; fetchCalls = 0; paymentsSigned = 0; + quotedAmount = "1135480"; + const { call, budget } = makeHarness(); + const res = await call({ prompt: "a cube", model: "azure/sora-2" }); + const text = res.content.map((c: any) => c.text).join("\n"); + assert.equal(res.isError, true, text); + assert.match(text, /quoted \$1\.1355 for azure\/sora-2 video/); + assert.match(text, /expected about \$0\.4220/); + assert.match(text, /no charge was made/); + assert.doesNotMatch(text, /needs funding/, "a bad quote is not a funding problem"); + assert.equal(paymentsSigned, 0, "must not sign a quote it refused"); + assert.equal(fetchCalls, 1, "must stop after the quote — no paid submit"); + assert.equal(budget.spent, 0, "reservation must be fully released"); + quotedAmount = "400000"; +}); + +test("a quote inside the tolerance still re-reserves and pays (4K renders exceed the estimate by design)", async () => { + script = [resp402, respSubmit, () => respPoll({ status: "completed", data: [{ url: "https://blockrun.ai/media/vid_1.mp4", duration_seconds: 8 }] })]; + quotedAmount = "450000"; // $0.45 against a $0.40 estimate: 1.125x + const { call, budget } = makeHarness(); + const res = await call({ prompt: "a cube", model: "xai/grok-imagine-video" }); + assert.notEqual(res.isError, true, res.content?.[0]?.text); + assert.ok(Math.abs(budget.spent - 0.45) < 1e-9, `books the quote: spent=${budget.spent}`); + quotedAmount = "400000"; +}); diff --git a/test/video-solana.test.ts b/test/video-solana.test.ts index 7093e8c..41dbbaa 100644 --- a/test/video-solana.test.ts +++ b/test/video-solana.test.ts @@ -88,18 +88,22 @@ test("Solana image-to-video forwards image_url plus aspect_ratio like Base does }); test("a higher Solana quote within the cap swaps the reservation without double-booking", async () => { - quoteUsd = 5; + // 4s of seedance-2.5 estimates at $1.2643; $1.60 is 1.27x — a real token-priced + // overshoot, inside the 1.5x quote tolerance (a 4x quote is refused, see below). + quoteUsd = 1.6; const { call, budget } = makeHarness(10); const res = await call({ prompt: "a rainy alley", model: "bytedance/seedance-2.5", duration_seconds: 4 }); assert.notEqual(res.isError, true, res.content?.[0]?.text); - assert.equal(res.structuredContent.cost_usd, 5); - assert.equal(budget.spent, 5, "reservation released, actual booked exactly once"); + assert.equal(res.structuredContent.cost_usd, 1.6); + assert.equal(budget.spent, 1.6, "reservation released, actual booked exactly once"); }); test("the authoritative Solana quote is re-checked against the budget before signing", async () => { solanaCalls = 0; - quoteUsd = 5; - const { call, budget } = makeHarness(1); + // Estimate $1.2643 fits a $1.30 cap; the real $1.60 quote does not. The cap + // has to be enforced on the quote, not the estimate. + quoteUsd = 1.6; + const { call, budget } = makeHarness(1.3); const res = await call({ prompt: "a rainy alley", model: "bytedance/seedance-2.5", duration_seconds: 4 }); assert.equal(res.isError, true); assert.match(res.content[0].text, /budget|limit/i); @@ -117,3 +121,19 @@ test("a malformed completed Solana payload still books the settled charge", asyn assert.equal(budget.spent, 0.5); completedHasUrl = true; }); + +test("a Solana quote for a different product than requested is refused unsigned", async () => { + // sol.blockrun.ai does not know azure/sora-2 and quotes Seedance 2.0 Pro at + // $1.135480 in its place (live 2026-09-08). The 4s Sora estimate is $0.4220. + quoteUsd = 1.13548; + const { call, budget } = makeHarness(5); + const res = await call({ prompt: "a rainy alley", model: "azure/sora-2", duration_seconds: 4 }); + const text = res.content[0].text; + assert.equal(res.isError, true, text); + assert.match(text, /quoted \$1\.1355 for azure\/sora-2 video/); + assert.match(text, /does not serve azure\/sora-2 yet/); + assert.match(text, /chain:"base"/); + assert.match(text, /no charge was made/); + assert.doesNotMatch(text, /needs funding/i); + assert.equal(budget.spent, 0, "a refused quote settles nothing"); +}); From 98ce90322f7e504e030a3de3ffebb1732d99708f Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:35:34 -0500 Subject: [PATCH 08/20] chore(verify): classify a substituted Solana product as a gateway bug, not an under-reserve The 402 says what is being sold (Base under resource.description, Solana under accepts[0].extra.description). When the two gateways name different products for the same request, reserving the substitute's price would legitimise the substitution; since the tools now refuse such a quote before signing, it is reported loudly as a gateway bug and does not fail the release gate. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg --- scripts/verify-prices.ts | 44 +++++++++++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/scripts/verify-prices.ts b/scripts/verify-prices.ts index add76a0..e95c826 100644 --- a/scripts/verify-prices.ts +++ b/scripts/verify-prices.ts @@ -61,14 +61,16 @@ type Probe = { allowOver?: boolean; }; -async function quote(host: string, path: string, body?: unknown): Promise { +type Quote = { usd: number; description?: string }; + +async function quote(host: string, path: string, body?: unknown): Promise { const res = await fetch(host + path, { method: body === undefined ? "GET" : "POST", ...(body === undefined ? {} : { headers: { "content-type": "application/json" }, body: JSON.stringify(body) }), }); const header = res.headers.get("payment-required"); if (!header) return `no 402 (HTTP ${res.status})`; - let parsed: { accepts?: Array<{ amount?: string }> }; + let parsed: { accepts?: Array<{ amount?: string; extra?: { description?: string } }>; resource?: { description?: string } }; try { parsed = JSON.parse(Buffer.from(header.trim(), "base64").toString("utf8")); } catch { @@ -78,7 +80,16 @@ async function quote(host: string, path: string, body?: unknown): Promise genuine under-reserve, blo let solDearer = 0; // Solana dearer than Base but still covered -> policy note only let solCheaper = 0; // Solana charges LESS than Base -> safe, but the docs quote one number let solMissing = 0; // route not served on Solana at all +let solSubstituted = 0; // Solana quotes a DIFFERENT product than Base for the same request const solNotes: string[] = []; +const solSubstitutions: string[] = []; console.log(`Verifying ${PROBES.length} routes against live 402 quotes on BOTH gateways (free — no payment attached)\n`); for (const probe of PROBES) { // Both chains at once so adding the second gateway costs no wall-clock. - const [live, solLive] = await Promise.all([ + const [liveQ, solQ] = await Promise.all([ quote(BASE, probe.path, probe.body), quote(SOL, probe.path, probe.body), ]); + const live = typeof liveQ === "string" ? liveQ : liveQ.usd; + const solLive = typeof solQ === "string" ? solQ : solQ.usd; + const liveProduct = typeof liveQ === "string" ? undefined : product(liveQ.description); + const solProduct = typeof solQ === "string" ? undefined : product(solQ.description); // Compare the chains before judging the estimator, so a Solana-only problem is // still reported when the Base probe itself is unreachable. @@ -291,6 +308,18 @@ for (const probe of PROBES) { solMissing++; solTag = " [sol: not served]"; solNotes.push(`${probe.label}: Solana ${solLive}`); + } else if (typeof live === "number" && liveProduct && solProduct && liveProduct !== solProduct) { + // Not a price for the same thing. Found 2026-09-08: sol.blockrun.ai (a + // separate deployment that can lag Base) did not know azure/sora-2 and + // quoted "Seedance 2.0 Pro video generation (5s)" at $1.135480 in its + // place. That is a gateway bug to report, not an estimator gap to paper + // over by reserving the substitute's price — and since 0.48.1 every + // manual-402 tool refuses a quote this far off the published rate before + // signing (assertQuoteNearEstimate), nothing can be charged for it. Loud, + // but not a release blocker for this repo. + solSubstituted++; + solTag = ` [sol: quotes a DIFFERENT product — "${typeof solQ === "string" ? "" : solQ.description}" at $${solLive.toFixed(6)}; blockrun refuses it unsigned]`; + solSubstitutions.push(`${probe.label}: Base sells "${typeof liveQ === "string" ? "" : liveQ.description}" at $${live.toFixed(6)}, Solana sells "${typeof solQ === "string" ? "" : solQ.description}" at $${solLive.toFixed(6)}`); } else if (typeof live === "number") { const chainDelta = solLive - live; if (chainDelta > EPSILON) { @@ -346,8 +375,13 @@ console.log( if (unreachable) console.log("Unreachable routes were NOT verified — treat them as unknown, not as passing."); console.log( - `Solana: ${solShort} under-reserved (BLOCKER), ${solDearer} dearer than Base but covered, ${solCheaper} cheaper, ${solMissing} not served`, + `Solana: ${solShort} under-reserved (BLOCKER), ${solDearer} dearer than Base but covered, ${solCheaper} cheaper, ${solMissing} not served, ${solSubstituted} substituted`, ); +if (solSubstituted) { + console.log(" GATEWAY BUG — Solana quotes a different product than Base for the same request. The tools refuse"); + console.log(" such a quote before signing (assertQuoteNearEstimate), so no money moves; report it to the gateway owner:"); + for (const n of solSubstitutions) console.log(` ${n}`); +} if (solCheaper) { console.log( " Solana charges no transaction fee — DELIBERATE pricing (owner decision,\n" + From 1338df6c0a38a680e35290e142e5d3f99b1c1bbc Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:35:34 -0500 Subject: [PATCH 09/20] docs: the quote guard, and the context-cost card at 7% MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CHANGELOG paragraph for the refused-quote behaviour; a symptom → cause → fix row in the debug skill; README profile totals resynced (13,023 full / 5,558 media). The full profile crossed 13,000 tokens, which rounds the badge's share of a 200K window from 6% to 7%, so the card is regenerated. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg --- CHANGELOG.md | 14 ++++++++++++++ README.md | 6 +++--- assets/context-cost-dark.svg | 4 ++-- assets/context-cost.svg | 4 ++-- skills/blockrun-debug/SKILL.md | 3 +++ 5 files changed, 24 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e9a31f..dac557e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,20 @@ who to contact for equity coverage. `formatError` also stops labelling any 501 a transient outage; it claims "nothing was charged" only when the 501 arrived before payment. +**Pay what you were told, or nothing.** `npm run verify:prices` caught a third +layer while this release was being cut: the Solana gateway is a separate +deployment that can lag Base, and it does not know `azure/sora-2` — it quotes +"Seedance 2.0 Pro video generation (5s)" at $1.135 in Sora's place, 2.7x the +published rate, for a different model. The only check on the gateway's price was +the budget cap, which would have let that through on any wallet holding $2. +`blockrun_video` (both rails) and `blockrun_image` (Solana) now compare the 402 +against the estimate the model was shown and refuse, unsigned, anything more than +1.5x above it; the message names the quoted amount, what the gateway labelled it, +and how to proceed. The Solana helper hands callers the decoded 402 so they can +judge what was quoted, not just how much. The price verifier classifies a Solana +quote for a different product as a gateway bug to report rather than an +estimator gap to paper over. + Also shipping, landed on `main` since 0.48.0: - **`blockrun_image` reads the settled cost on the account rail** instead of an diff --git a/README.md b/README.md index 2a43f1d..d540fb8 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ claude mcp add blockrun -s user -- npx -y @blockrun/mcp@latest
- Context cost: 13.0K tokens, 6% of a 200K context window, charged every turn whether or not you call a tool. 5.6K with --profile trading, 57% less. + Context cost: 13.0K tokens, 7% of a 200K context window, charged every turn whether or not you call a tool. 5.6K with --profile trading, 57% less.
@@ -216,9 +216,9 @@ Package managers have shown install size for decades. Almost no MCP server shows | Profile | Tools | Context | |---------|-------|---------| -| `full` *(default)* | 20 | 12,992 | +| `full` *(default)* | 20 | 13,023 | | `trading` | 9 | 5,606 | -| `media` | 7 | 5,527 | +| `media` | 7 | 5,558 | | `research` | 6 | 3,075 | | `chat` | 3 | 1,975 | diff --git a/assets/context-cost-dark.svg b/assets/context-cost-dark.svg index 747163c..b839152 100644 --- a/assets/context-cost-dark.svg +++ b/assets/context-cost-dark.svg @@ -1,10 +1,10 @@ - + CONTEXT COST 13.0K tokens - 6% of a 200K context window · every turn, whether or not you call a tool + 7% of a 200K context window · every turn, whether or not you call a tool 5.6K with --profile trading — 57% less measured, not estimated diff --git a/assets/context-cost.svg b/assets/context-cost.svg index 5d4b729..db5f5bc 100644 --- a/assets/context-cost.svg +++ b/assets/context-cost.svg @@ -1,10 +1,10 @@ - + CONTEXT COST 13.0K tokens - 6% of a 200K context window · every turn, whether or not you call a tool + 7% of a 200K context window · every turn, whether or not you call a tool 5.6K with --profile trading — 57% less measured, not estimated diff --git a/skills/blockrun-debug/SKILL.md b/skills/blockrun-debug/SKILL.md index 61d4317..233b27f 100644 --- a/skills/blockrun-debug/SKILL.md +++ b/skills/blockrun-debug/SKILL.md @@ -14,6 +14,8 @@ triggers: - "equity quotes are not served" - "sports markets 500" - "501 not implemented" + - "refusing to sign it" + - "quoted a different price" - "polymarket buy failed" - "insufficient allowance" - "redeem reverts" @@ -65,6 +67,7 @@ re-added at user scope leaves a duplicate. Then, in the session: `blockrun_walle | `Video`/`Music generation timed out` | Upstream queue. **Not charged** — payment settles on completion only. | Retry, or pick a faster model. Do not retry-loop; jobs take 60–180 s. | | `blockrun_price` with `category:"stocks"` / `"usstock"` → `Equity quotes are not served (gateway 501 …)` | The gateway withdrew equity price/history on 2026-09-05 (licensing), and the tool answers before the wallet is consulted. Not an outage. **Not charged.** | Do not retry. `action:"list" category:"stocks" market:"us"` still returns the ticker catalog for free. Equity coverage: hello@blockrun.ai. | | `blockrun_markets` on `sports/*` → `Predexon's sports/* routes have returned an upstream 500 … since 2026-08-04` (builds before 0.48.1: `API error after payment: 502 / Request failed` with no balance change) | Upstream Predexon outage since 2026-08-04. The gateway releases the payment on upstream failure, so the old wording asserted a charge that never happened. **Not charged.** | Use `path:"markets"` with `params:{ league:"NBA" }` or `polymarket/events`. Do not retry `sports/*`. Upgrade to ≥ 0.48.1 so the error says this itself. | +| `blockrun_video` / `blockrun_image` → `The gateway quoted $X for , but this tool expected about $Y (N.Nx the published rate) … Refusing to sign it — no charge was made` | The 402 price is far above the published rate: the gateway repriced the model, or a lagging deployment substituted another one. Live 2026-09-08: `sol.blockrun.ai` does not know `azure/sora-2` and quotes Seedance 2.0 Pro at $1.135 in its place. **Not charged** — the tool refuses before signing. | For Sora: `blockrun_wallet action:"chain" chain:"base"`. Otherwise pick another model or chain, and report the quote (the message names what the gateway labelled it) so the estimator or the gateway gets fixed. | | Any tool → `The gateway does not serve this endpoint (501 Not Implemented)` | The route is withdrawn, not down. Before payment the message ends "nothing was charged"; after payment it tells you to check the ledger instead, because the formatter cannot know whether the nonce was released. | Do not retry. `blockrun_wallet action:"report"` shows whether the call settled. | | Model id 404s | Delisted upstream | `blockrun_models` for the live list. | | Startup prints `🚨 WALLET PRIVATE KEY DETECTED IN CONFIG FILE` | The key was pasted into `~/.claude.json` (old hosted-auth flow) | Treat the key as compromised: move funds to a new wallet, remove it from the config. | From be4d323ccb639cf1e6688fa382ecd8da804b353e Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:37:07 -0500 Subject: [PATCH 10/20] chore(verify): a different label is a substitution only when Solana is also materially dearer The Solana gateway writes longer descriptions for the same route (rpc/ethereum: 40 words against Base's 5, same $0.002), which the first cut read as a substituted product. Substitution now requires the label to differ AND the Solana price to sit >25% above Base; the per-row tag says whether the tool is guarded (video/image) or merely reserves the Base figure. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg --- scripts/verify-prices.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/scripts/verify-prices.ts b/scripts/verify-prices.ts index e95c826..c6feff6 100644 --- a/scripts/verify-prices.ts +++ b/scripts/verify-prices.ts @@ -308,7 +308,15 @@ for (const probe of PROBES) { solMissing++; solTag = " [sol: not served]"; solNotes.push(`${probe.label}: Solana ${solLive}`); - } else if (typeof live === "number" && liveProduct && solProduct && liveProduct !== solProduct) { + } else if ( + typeof live === "number" && liveProduct && solProduct && liveProduct !== solProduct && + // A different label alone is not a substitution — the Solana gateway writes + // longer marketing descriptions for the same route (rpc/ethereum: 40 words + // vs Base's 5, same $0.002). Substitution is a different label AND a price + // materially ABOVE Base for the same request; a cheaper Solana row falls + // through to the deliberate-pricing branch below. + solLive - live > Math.max(EPSILON, 0.25 * live) + ) { // Not a price for the same thing. Found 2026-09-08: sol.blockrun.ai (a // separate deployment that can lag Base) did not know azure/sora-2 and // quoted "Seedance 2.0 Pro video generation (5s)" at $1.135480 in its @@ -318,7 +326,8 @@ for (const probe of PROBES) { // signing (assertQuoteNearEstimate), nothing can be charged for it. Loud, // but not a release blocker for this repo. solSubstituted++; - solTag = ` [sol: quotes a DIFFERENT product — "${typeof solQ === "string" ? "" : solQ.description}" at $${solLive.toFixed(6)}; blockrun refuses it unsigned]`; + const guarded = /^(videos|images)\//.test(probe.path); + solTag = ` [sol: quotes a DIFFERENT product — "${typeof solQ === "string" ? "" : solQ.description}" at $${solLive.toFixed(6)}; ${guarded ? "the tool refuses it unsigned" : "NOT guarded — this tool reserves the Base figure"}]`; solSubstitutions.push(`${probe.label}: Base sells "${typeof liveQ === "string" ? "" : liveQ.description}" at $${live.toFixed(6)}, Solana sells "${typeof solQ === "string" ? "" : solQ.description}" at $${solLive.toFixed(6)}`); } else if (typeof live === "number") { const chainDelta = solLive - live; @@ -378,8 +387,8 @@ console.log( `Solana: ${solShort} under-reserved (BLOCKER), ${solDearer} dearer than Base but covered, ${solCheaper} cheaper, ${solMissing} not served, ${solSubstituted} substituted`, ); if (solSubstituted) { - console.log(" GATEWAY BUG — Solana quotes a different product than Base for the same request. The tools refuse"); - console.log(" such a quote before signing (assertQuoteNearEstimate), so no money moves; report it to the gateway owner:"); + console.log(" GATEWAY BUG — Solana quotes a different, dearer product than Base for the same request. blockrun_video"); + console.log(" and blockrun_image refuse such a quote before signing (assertQuoteNearEstimate); report it to the gateway owner:"); for (const n of solSubstitutions) console.log(` ${n}`); } if (solCheaper) { From e8daa4f6a050127c8c5b30d6e8fb4f6fd59b8f7c Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:07:58 -0500 Subject: [PATCH 11/20] fix(wallet): provision the Solana wallet keychain-aware, and never mint over a key we could not read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ensureBothWallets called the SDK's file-only getOrCreateSolanaWallet(). Under BLOCKRUN_KEYCHAIN=strict the .solana-session file is retired once the key is in the keychain, so the default blockrun_wallet status call saw an empty slate and minted keypair B; the next resolveSolanaKey() mirrored B over the funded key A with -U and deleted the file. A was then nowhere (audit 2026-09-08, P0). ensureSolanaWallet() is the twin of ensureEvmWallet(): env > file > keychain, via keychainRead so a FAILED read refuses to mint instead of orphaning the key. resolveSolanaKey() no longer memoises a miss, so a wallet provisioned later in the process (or by another process) is visible without a restart. Fresh install: Solana has been the default since 0.46.0 but nothing on that path minted a wallet — getWalletInfo() went through the SDK constructor, which threw 'Private key required' for status/setup/qr/deposit. getWalletInfo() now provisions via ensureSolanaWallet(), and buildSolanaClient() names the remedy instead of the symptom when there is still no key. getChainBalance('solana', address) queries the DISPLAYED address (the SDK's getBalance only ever read the client's own wallet), and returns null, not $0, when the RPC is unreachable. price.ts: the equity pre-flight now runs before the market-required check, and the dead Base-only chain guard behind it is removed with a note on re-enabling. The keychain module header no longer claims protection against same-user code; it protects the key at rest. A read-before-write guard in persistKey was tried and dropped: replacing the key file IS the documented rotation path. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg --- src/tools/price.ts | 17 +-- src/utils/keychain.ts | 19 ++-- src/utils/wallet.ts | 170 ++++++++++++++++++++++++------ test/keychain-precedence.test.ts | 58 ++++++++++ test/solana-fresh-install.test.ts | 79 ++++++++++++++ 5 files changed, 299 insertions(+), 44 deletions(-) create mode 100644 test/solana-fresh-install.test.ts diff --git a/src/tools/price.ts b/src/tools/price.ts index 971fb1a..1ba65bc 100644 --- a/src/tools/price.ts +++ b/src/tools/price.ts @@ -32,7 +32,7 @@ import { reserveBudget, recordSpending } from "../utils/budget.js"; import { confirmSpend } from "../utils/confirm-spend.js"; import { withTxFee } from "../utils/tx-fee.js"; import type { BudgetState } from "../types.js"; -import { baseOnlyMessage, getPriceClient } from "../utils/wallet.js"; +import { getPriceClient } from "../utils/wallet.js"; import { extractErrorMessage, formatError } from "../utils/errors.js"; import { TOOL_ANNOTATIONS } from "../tool-annotations.js"; @@ -101,10 +101,9 @@ Examples: }, async ({ action, category, symbol, market, session, resolution, from, to, query, limit, agent_id }) => { try { - if (category === "stocks" && !market) { - throw new Error("market is required when category='stocks'"); - } - + // Equity price/history first — before the market-required throw, so the + // most natural stocks call (no market) gets the real answer in one round + // trip instead of a validation error for a route that is not served. const paid = isPaidPriceCall(action, category); if (paid) { return { @@ -112,9 +111,11 @@ Examples: isError: true, }; } - const chainBlock = paid ? baseOnlyMessage("Paid stock price/history calls") : null; - if (chainBlock) { - return { content: [{ type: "text", text: formatError(chainBlock) }], isError: true }; + // Re-enable when the equity route returns (see the header): the paid + // path is Base-only, so restore `baseOnlyMessage("Paid stock price/history calls")` + // here ahead of the reservation. + if (category === "stocks" && !market) { + throw new Error("market is required when category='stocks'"); } // withTxFee: the gateway charges base + $0.002 (src/utils/tx-fee.ts), so a diff --git a/src/utils/keychain.ts b/src/utils/keychain.ts index f80383f..a04638c 100644 --- a/src/utils/keychain.ts +++ b/src/utils/keychain.ts @@ -3,12 +3,19 @@ // OS keychain storage for wallet private keys. // // Background: the wallet key lives at ~/.blockrun/.session as plaintext (mode -// 0600). File permissions stop other UNIX users, but not anything running as -// you — a malicious postinstall script, a backup agent that syncs the home -// directory to iCloud/Dropbox, or a leaky log collector all read it trivially. -// The same class of leak already bit us once through ~/.claude.json, which is -// why utils/key-leak-scanner.ts exists. The keychain moves the secret behind -// an OS-mediated API instead of a readable path. +// 0600). File permissions stop other UNIX users, but not anything that reads +// the home directory as data — a backup agent syncing to iCloud/Dropbox, a +// disk image, a dotfile or log slurper. The same class of leak already bit us +// once through ~/.claude.json, which is why utils/key-leak-scanner.ts exists. +// The keychain moves the secret behind an OS-mediated API instead of a +// readable path, so it protects the key AT REST. +// +// What it does NOT do: defend against code running as you. `security +// add-generic-password` without -T/-A grants the creating application (here, +// /usr/bin/security itself) access, and any process running as the user can +// run `security find-generic-password -w` / `secret-tool lookup` and read the +// value back — a malicious postinstall script included. Do not describe the +// keychain as protection against same-user code; it is not. // // Technique credit: the `security -i` approach below (and the 128-byte // truncation gotcha it avoids) is adapted from Circle's CLI, Apache-2.0. diff --git a/src/utils/wallet.ts b/src/utils/wallet.ts index 960f2c2..a086a13 100644 --- a/src/utils/wallet.ts +++ b/src/utils/wallet.ts @@ -9,8 +9,11 @@ import { SolanaLLMClient, AnthropicClient, getOrCreateWallet, - getOrCreateSolanaWallet, + createSolanaWallet, + saveSolanaWallet, + solanaPublicKey, loadSolanaWallet, + USDC_SOLANA, getPaymentLinks, formatWalletCreatedMessage, formatNeedsFundingMessage, @@ -246,10 +249,14 @@ export async function ensureBothWallets(): Promise<{ const chainBefore = readChainPreference() === null ? getChain() : null; const evm = ensureEvmWallet(); - const sol = await getOrCreateSolanaWallet(); - if (sol.isNew) { - console.error(formatWalletCreatedMessage(sol.address)); - } + // NOT the SDK's getOrCreateSolanaWallet(): that loader knows only the env var + // and the file. Under BLOCKRUN_KEYCHAIN=strict the file is retired once the + // key is in the keychain, so the SDK saw an empty slate, minted keypair B, + // wrote it to .solana-session — and the next resolveSolanaKey() mirrored B + // into the keychain with -U, over the funded key A, then deleted the file. + // A was then nowhere. ensureSolanaWallet() reads the keychain first and + // refuses to mint when the keychain could not be read (audit 2026-09-08). + const sol = await ensureSolanaWallet(); if (chainBefore !== null && getChain() !== chainBefore) { // writeAutoChain, NOT setChain: this is the machine preserving continuity, @@ -433,10 +440,20 @@ export function getOrCreateWalletKey(): `0x${string}` { return info.privateKey as `0x${string}`; } -// Resolved once per process. buildSolanaClient() is called per-request on the -// non-cached paths (blockrun_chat, modal), and a keychain read spawns a -// subprocess — fine once, not fine on every paid call. -let _solanaKey: string | null | undefined; +// Resolved once per process on a HIT. buildSolanaClient() is called +// per-request on the non-cached paths (blockrun_chat, modal), and a keychain +// read spawns a subprocess — fine once, not fine on every paid call. A MISS is +// deliberately not memoised: a wallet provisioned later in the same process (by +// ensureSolanaWallet, or by another process such as the CLI) must become +// visible without a restart — the old `null` cache made the first status call +// of a fresh install poison every later call. +let _solanaKey: string | undefined; + +type SolanaKeyResolution = { + key?: string; + /** Set when the keychain was consulted and the read FAILED (not "absent"). */ + keychainError?: string; +}; /** * Solana key precedence, mirroring the EVM path: @@ -445,30 +462,79 @@ let _solanaKey: string | null | undefined; * file is not silently undone by a stale keychain entry; the keychain carries * the key only once the file is gone (strict mode). A key found in the file is * mirrored into the keychain on the way past. + * + * Uses keychainRead, not keychainLoad: "absent" and "error" must stay apart, + * because ensureSolanaWallet() decides whether to CREATE a wallet on the + * difference — the exact rule keychain.ts states for the EVM path. */ -export function resolveSolanaKey(): string | undefined { - if (process.env.SOLANA_WALLET_KEY) return process.env.SOLANA_WALLET_KEY; - if (_solanaKey !== undefined) return _solanaKey ?? undefined; +function resolveSolanaKeyDetailed(): SolanaKeyResolution { + if (process.env.SOLANA_WALLET_KEY) return { key: process.env.SOLANA_WALLET_KEY }; + if (_solanaKey) return { key: _solanaKey }; + let keychainError: string | undefined; // Same precedence correction as the EVM path: an existing .solana-session is // the user's current intent, so it outranks whatever the keychain remembers. if (getKeychainMode() !== "off" && !fs.existsSync(SOLANA_WALLET_FILE_PATH)) { - const stored = keychainLoad(SOLANA_KEY_ACCOUNT); - if (stored) { - _solanaKey = stored; - return stored; + const read = keychainRead(SOLANA_KEY_ACCOUNT); + if (read.status === "found") { + _solanaKey = read.value; + return { key: read.value }; } + if (read.status === "error") keychainError = read.detail; } const fromFile = loadSolanaWallet(); - if (fromFile) persistKey(SOLANA_KEY_ACCOUNT, fromFile, SOLANA_WALLET_FILE_PATH); - _solanaKey = fromFile ?? null; - return fromFile ?? undefined; + if (fromFile) { + persistKey(SOLANA_KEY_ACCOUNT, fromFile, SOLANA_WALLET_FILE_PATH); + _solanaKey = fromFile; + return { key: fromFile }; + } + return { keychainError }; } -/** Drop the cached Solana key. Test seam, and used when the wallet is re-provisioned. */ +export function resolveSolanaKey(): string | undefined { + return resolveSolanaKeyDetailed().key; +} + +let _solanaWalletInfo: { address: string; privateKey: string; isNew: boolean } | null = null; + +/** + * The Solana twin of ensureEvmWallet(): return the existing wallet from + * whichever store holds it, and mint one ONLY when every store says "absent". + * A keychain read that FAILED is not a read that found nothing — the file is + * already gone in strict mode, so minting here would orphan a funded key that + * is very likely still sitting in a keychain we merely could not open. + */ +export async function ensureSolanaWallet(): Promise<{ address: string; privateKey: string; isNew: boolean }> { + if (_solanaWalletInfo) return _solanaWalletInfo; + const { key, keychainError } = resolveSolanaKeyDetailed(); + if (key) { + _solanaWalletInfo = { address: await solanaPublicKey(key), privateKey: key, isNew: false }; + return _solanaWalletInfo; + } + if (keychainError !== undefined) { + throw new Error( + `Could not read the Solana wallet key from the OS keychain (${keychainError}), and ` + + `~/.blockrun/.solana-session does not exist (BLOCKRUN_KEYCHAIN=strict retires it once the key is in the keychain). ` + + `Refusing to create a new Solana wallet — your existing one is most likely still in the keychain. ` + + `Unlock the keychain and retry, or set SOLANA_WALLET_KEY to your key. Nothing was charged.`, + ); + } + const created = await createSolanaWallet(); + saveSolanaWallet(created.privateKey); + // Mirror into the keychain; strict mode then retires the file after a + // verified read-back — the same sequence ensureEvmWallet() runs. + persistKey(SOLANA_KEY_ACCOUNT, created.privateKey, SOLANA_WALLET_FILE_PATH); + _solanaKey = created.privateKey; + _solanaWalletInfo = { address: created.address, privateKey: created.privateKey, isNew: true }; + console.error(formatWalletCreatedMessage(created.address)); + return _solanaWalletInfo; +} + +/** Drop the cached Solana key and wallet. Test seam, and used when the wallet is re-provisioned. */ export function resetSolanaKeyCache(): void { _solanaKey = undefined; + _solanaWalletInfo = null; } /** @@ -502,8 +568,18 @@ function buildSolanaClient(timeout?: number): SolanaLLMClient { return new SolanaLLMClient({ apiKey, ...(timeout ? { timeout } : {}) }); } const privateKey = resolveSolanaKey(); - const opts = { ...(privateKey ? { privateKey } : {}), ...(timeout ? { timeout } : {}) }; - return new SolanaLLMClient(Object.keys(opts).length ? opts : undefined); + if (!privateKey) { + // The SDK constructor would throw "Private key required. Pass privateKey in + // options or set SOLANA_WALLET_KEY" — true, and useless to someone on a + // fresh install where Solana is the default chain and nothing has minted a + // wallet yet. Provisioning is async (ensureSolanaWallet) and this factory + // is sync, so name the remedy instead of the symptom. + throw new Error( + `No Solana wallet on this machine yet. Run blockrun_wallet action:"setup" (or action:"chain" chain:"solana") to create one, ` + + `or set SOLANA_WALLET_KEY. Nothing was charged.`, + ); + } + return new SolanaLLMClient({ privateKey, ...(timeout ? { timeout } : {}) }); } export function getClient(): ApiClient { @@ -626,15 +702,18 @@ export async function getWalletInfo(): Promise { }; } if (getChain() === "solana") { - const client = getClient() as SolanaLLMClient; - const address = await client.getWalletAddress(); + // ensureSolanaWallet, not getClient(): on a fresh install (Solana is the + // default since 0.46.0) nothing had minted a wallet yet, so every + // status/setup/qr/deposit call died in the SDK constructor before the + // one action that creates wallets was reached. Mirrors the EVM branch. + const info = await ensureSolanaWallet(); return { - address, + address: info.address, network: "Solana" as const, chainId: null as number | null, currency: "USDC", - isNew: false, - explorerUrl: `https://solscan.io/account/${address}`, + isNew: info.isNew, + explorerUrl: `https://solscan.io/account/${info.address}`, fundingUrl: "https://sol.blockrun.ai", }; } @@ -653,9 +732,40 @@ export async function getWalletInfo(): Promise { export { formatNeedsFundingMessage }; -async function getSolanaUsdcBalance(): Promise { +const DEFAULT_SOLANA_RPC_URL = "https://sol.blockrun.ai/api/v1/solana/rpc"; + +/** + * USDC balance of an explicit Solana ADDRESS. The SDK's getBalance() only ever + * reads the client's own wallet and ignores which address the caller is + * displaying, so the status screen could print address B beside the balance + * of key A. Same RPC call the SDK makes, keyed on the address we show. Returns + * null (not 0) when the RPC cannot be reached — "unavailable" is honest, + * "$0.00" beside a funded address is not. + */ +async function getSolanaUsdcBalance(address: string): Promise { + const rpcUrl = process.env.SOLANA_RPC_URL || DEFAULT_SOLANA_RPC_URL; try { - return await buildSolanaClient().getBalance(); + const response = await fetch(rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "getTokenAccountsByOwner", + params: [address, { mint: USDC_SOLANA }, { encoding: "jsonParsed" }], + }), + signal: AbortSignal.timeout(8000), + }); + const data = await response.json() as { + result?: { value?: Array<{ account?: { data?: { parsed?: { info?: { tokenAmount?: { uiAmount?: number } } } } } }> }; + error?: unknown; + }; + if (data.error || !data.result) return null; + let total = 0; + for (const acct of data.result.value ?? []) { + total += acct.account?.data?.parsed?.info?.tokenAmount?.uiAmount ?? 0; + } + return total; } catch { return null; } } @@ -696,7 +806,7 @@ async function getBaseUsdcBalance(address: string): Promise { /** USDC balance for an explicit chain — used to show BOTH wallets at once. */ export async function getChainBalance(chain: "base" | "solana", address: string): Promise { - return chain === "solana" ? getSolanaUsdcBalance() : getBaseUsdcBalance(address); + return chain === "solana" ? getSolanaUsdcBalance(address) : getBaseUsdcBalance(address); } export async function getUsdcBalance(address: string): Promise { diff --git a/test/keychain-precedence.test.ts b/test/keychain-precedence.test.ts index 44fe97c..f0d6457 100644 --- a/test/keychain-precedence.test.ts +++ b/test/keychain-precedence.test.ts @@ -97,3 +97,61 @@ test("a failed keychain read refuses to mint a new wallet instead of orphaning t mode = "auto"; readAnswer = { status: "found", value: KEYCHAIN_KEY }; }); + +// --- the Solana twin (audit 2026-09-08, P0) --- +// +// ensureBothWallets used the SDK's file-only getOrCreateSolanaWallet(). Under +// strict mode the file is retired once the key is in the keychain, so a plain +// blockrun_wallet status call minted keypair B, and the next resolveSolanaKey() +// mirrored B over the funded key A with -U and deleted the file. A was gone. + +test("Solana: a failed keychain read with no session file refuses to mint (does not orphan the funded key)", async () => { + const { ensureSolanaWallet, ensureBothWallets, resetSolanaKeyCache } = await import("../src/utils/wallet.js"); + resetSolanaKeyCache(); + fs.rmSync(path.join(home, ".blockrun", ".solana-session"), { force: true }); + mode = "strict"; + readAnswer = { status: "error", detail: "security exit 51" }; + + await assert.rejects(ensureSolanaWallet(), /Refusing to create a new Solana wallet/); + await assert.rejects(ensureBothWallets(), /Refusing to create a new/); + assert.ok(!fs.existsSync(path.join(home, ".blockrun", ".solana-session")), "nothing may be minted on a failed read"); + + mode = "auto"; + readAnswer = { status: "found", value: KEYCHAIN_KEY }; +}); + +test("Solana: every store absent -> mint once, and the new key is visible without a cache reset", async () => { + const { ensureSolanaWallet, resolveSolanaKey, resetSolanaKeyCache } = await import("../src/utils/wallet.js"); + resetSolanaKeyCache(); + fs.rmSync(path.join(home, ".blockrun", ".solana-session"), { force: true }); + mode = "auto"; + readAnswer = { status: "absent" }; + + assert.equal(resolveSolanaKey(), undefined, "a miss before provisioning"); + const info = await ensureSolanaWallet(); + assert.equal(info.isNew, true); + assert.equal(fs.readFileSync(path.join(home, ".blockrun", ".solana-session"), "utf-8"), info.privateKey); + assert.equal(resolveSolanaKey(), info.privateKey, "the miss was not memoised"); + const again = await ensureSolanaWallet(); + assert.equal(again.address, info.address, "second call returns the same wallet, no second mint"); + + readAnswer = { status: "found", value: KEYCHAIN_KEY }; +}); + +test("Solana: an existing session file outranks a stale keychain entry", async () => { + const { createSolanaWallet, saveSolanaWallet, solanaPublicKey } = await import("@blockrun/llm"); + const { ensureSolanaWallet, resetSolanaKeyCache } = await import("../src/utils/wallet.js"); + const onDisk = await createSolanaWallet(); + const stale = await createSolanaWallet(); + saveSolanaWallet(onDisk.privateKey); + resetSolanaKeyCache(); + mode = "auto"; + readAnswer = { status: "found", value: stale.privateKey }; + + const info = await ensureSolanaWallet(); + assert.equal(info.isNew, false); + assert.equal(info.privateKey, onDisk.privateKey); + assert.equal(info.address, await solanaPublicKey(onDisk.privateKey)); + + readAnswer = { status: "found", value: KEYCHAIN_KEY }; +}); diff --git a/test/solana-fresh-install.test.ts b/test/solana-fresh-install.test.ts new file mode 100644 index 0000000..be76d8f --- /dev/null +++ b/test/solana-fresh-install.test.ts @@ -0,0 +1,79 @@ +// Run with: npm test (tsx --experimental-test-module-mocks --test) +// +// Solana has been the fresh-install default since 0.46.0, but nothing on that +// path minted a wallet: getWalletInfo() went through getClient() into the SDK +// constructor, which threw "Private key required" for every status/setup/qr/ +// deposit call — including the one the tool description tells a new user to +// run first. The EVM path never had this problem because it auto-creates. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +const home = fs.mkdtempSync(path.join(os.tmpdir(), "blockrun-fresh-")); +fs.mkdirSync(path.join(home, ".blockrun"), { recursive: true }); +const saved = { + HOME: process.env.HOME, + BLOCKRUN_KEYCHAIN: process.env.BLOCKRUN_KEYCHAIN, + SOLANA_WALLET_KEY: process.env.SOLANA_WALLET_KEY, + BLOCKRUN_WALLET_KEY: process.env.BLOCKRUN_WALLET_KEY, + BLOCKRUN_API_KEY: process.env.BLOCKRUN_API_KEY, + SOLANA_RPC_URL: process.env.SOLANA_RPC_URL, +}; +process.env.HOME = home; +process.env.BLOCKRUN_KEYCHAIN = "off"; +delete process.env.SOLANA_WALLET_KEY; +delete process.env.BLOCKRUN_WALLET_KEY; +delete process.env.BLOCKRUN_API_KEY; +process.env.SOLANA_RPC_URL = "https://rpc.test.invalid/solana"; + +const wallet = await import("../src/utils/wallet.js"); + +process.on("exit", () => { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + fs.rmSync(home, { recursive: true, force: true }); +}); + +test("a fresh install defaults to Solana and names the remedy instead of the SDK's constructor error", () => { + assert.equal(wallet.getChain(), "solana"); + assert.equal(wallet.resolveSolanaKey(), undefined); + assert.throws(() => wallet.getClient(), /No Solana wallet on this machine yet.*blockrun_wallet action:"setup".*Nothing was charged/); +}); + +test("getWalletInfo provisions the Solana wallet on a fresh install (mirrors the EVM branch)", async () => { + const info = await wallet.getWalletInfo(); + assert.equal(info.network, "Solana"); + assert.equal(info.isNew, true); + assert.ok(info.address && info.address.length > 30, `address: ${info.address}`); + assert.ok(fs.existsSync(path.join(home, ".blockrun", ".solana-session")), "the session file was written"); + assert.equal(wallet.resolveSolanaKey() !== undefined, true, "a miss was not memoised; the new key is visible"); + assert.doesNotThrow(() => wallet.getClient(), "a client can now be built"); +}); + +test("getChainBalance queries the DISPLAYED Solana address, not the client's own wallet", async () => { + const realFetch = globalThis.fetch; + const seen: unknown[] = []; + globalThis.fetch = (async (_url: unknown, init?: { body?: string }) => { + seen.push(JSON.parse(init?.body ?? "{}")); + return new Response(JSON.stringify({ result: { value: [ + { account: { data: { parsed: { info: { tokenAmount: { uiAmount: 1.25 } } } } } }, + { account: { data: { parsed: { info: { tokenAmount: { uiAmount: 0.5 } } } } } }, + ] } }), { status: 200 }); + }) as typeof fetch; + try { + const bal = await wallet.getChainBalance("solana", "SomeOtherAddress1111111111111111111111111111"); + assert.equal(bal, 1.75); + const req = seen[0] as { method: string; params: unknown[] }; + assert.equal(req.method, "getTokenAccountsByOwner"); + assert.equal(req.params[0], "SomeOtherAddress1111111111111111111111111111"); + + globalThis.fetch = (async () => { throw new Error("ECONNRESET"); }) as typeof fetch; + assert.equal(await wallet.getChainBalance("solana", "SomeOtherAddress1111111111111111111111111111"), null, "unreachable RPC reads as unavailable, never as $0"); + } finally { + globalThis.fetch = realFetch; + } +}); From 9dea539f59b9a82a7e9abae353505914d41b3a76 Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Wed, 9 Sep 2026 07:01:28 -0500 Subject: [PATCH 12/20] test(safety): pin the rail in image-cost, tighten the confirm-spend guard, prove the price pre-flight at the handler image-cost.test.ts mocked the paid client but blockrun_image picks its rail from the account key before asking for that client, so on a machine set up for account mode the suite posted to the gateway with the real key. HOME is now a temp dir with no key before the tool loads and the shared fetch helper is a trap. The confirm-spend static guard keyed on reserveBudget and skipped any file without one; it now classifies paid files by their imports and requires both a reservation and a confirm, with blockrun_image in the behavioural table. blockrun_price gets a handler-level test: equity price/history return the 501 text before the chain guard, the budget gate and the confirm dialog; stocks list and crypto price still reach the client. Also: a BLOCKRUN_BUDGET_LIMIT that does not parse now warns on stderr that the cap is OFF; the image-edit confirm label names the realpath of every local file about to leave the machine. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg --- src/mcp-handler.ts | 16 +- src/tools/image.ts | 56 +++++-- test/budget-limit-env-warning.test.ts | 74 ++++++++++ test/confirm-spend-coverage.test.ts | 125 +++++++++++++--- test/image-cost.test.ts | 49 ++++++- test/image-edit-label.test.ts | 194 ++++++++++++++++++++++++ test/price-behaviour.test.ts | 203 ++++++++++++++++++++++++++ test/price-equity-preflight.test.ts | 4 + 8 files changed, 691 insertions(+), 30 deletions(-) create mode 100644 test/budget-limit-env-warning.test.ts create mode 100644 test/image-edit-label.test.ts create mode 100644 test/price-behaviour.test.ts diff --git a/src/mcp-handler.ts b/src/mcp-handler.ts index 1177f68..063bcb1 100644 --- a/src/mcp-handler.ts +++ b/src/mcp-handler.ts @@ -48,8 +48,22 @@ export function initializeMcpServer( // ledger starts unlimited; the cap is in-memory and resets when the (npx-spawned) // process restarts, so an operator who wants a hard ceiling should set the env. const env = profileArgs?.env ?? process.env; + const rawLimit = env.BLOCKRUN_BUDGET_LIMIT; + const limit = parseBudgetLimitEnv(rawLimit); + // parseBudgetLimitEnv maps anything that is not a finite positive number to + // null — and null here means UNLIMITED. That contract is shared with + // BLOCKRUN_CONFIRM_THRESHOLD and stays; what must not stay is the silence. An + // operator who wrote "5,00", "5 USD", "0" or "-3" believes the hard stop is + // on. Say so once, on stderr (the MCP stdio log channel — stdout is the + // protocol). Unset or blank is the default, not a misconfiguration. + if (rawLimit?.trim() && limit === null) { + console.error( + `[BlockRun] BLOCKRUN_BUDGET_LIMIT="${rawLimit}" is not a positive USD amount — the spend cap is OFF (unlimited). ` + + `Write it as a plain number, e.g. BLOCKRUN_BUDGET_LIMIT=5 or BLOCKRUN_BUDGET_LIMIT=$2.50`, + ); + } const budget: BudgetState = { - limit: parseBudgetLimitEnv(env.BLOCKRUN_BUDGET_LIMIT), + limit, spent: 0, calls: 0, agents: new Map(), diff --git a/src/tools/image.ts b/src/tools/image.ts index ca46df3..8fe58b9 100644 --- a/src/tools/image.ts +++ b/src/tools/image.ts @@ -15,7 +15,7 @@ import { solanaPaidPost } from "../utils/solana-402.js"; import { isBlockedFetchHostResolved } from "../utils/ssrf.js"; import { shouldInline, buildInlineImageBlock } from "../utils/inline-image.js"; import { confirmSpend } from "../utils/confirm-spend.js"; -import { readFile, writeFile } from "node:fs/promises"; +import { readFile, realpath, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { randomBytes } from "node:crypto"; @@ -35,8 +35,28 @@ const IMAGE_EXT_MIME: Record = { webp: "image/webp", }; +/** A source image or mask, normalized for the gateway. */ +export interface ResolvedImageRef { + /** What goes in the request body. */ + dataUri: string; + /** + * The REAL filesystem path this data URI was read from — after + * fs.realpath, so a symlink is reported as its target — or undefined for a + * data: URI or an http(s) URL. Callers surface it wherever a human looks + * before the call leaves the machine (the confirmSpend label): the model + * names the file, and "edit ~/Pictures/IMG_1234.jpg" must not look exactly + * like any other $0.05 edit. + */ + localPath?: string; +} + +/** Data-URI form only; see resolveImageRef for the local path as well. */ export async function toImageDataUri(ref: string): Promise { - if (ref.startsWith("data:image/")) return ref; + return (await resolveImageRef(ref)).dataUri; +} + +export async function resolveImageRef(ref: string): Promise { + if (ref.startsWith("data:image/")) return { dataUri: ref }; if (/^https?:\/\//i.test(ref)) { const ctrl = new AbortController(); @@ -78,21 +98,26 @@ export async function toImageDataUri(ref: string): Promise { if (buffer.byteLength > REFERENCE_IMAGE_MAX_BYTES) { throw new Error(`image too large: ${(buffer.byteLength / 1e6).toFixed(1)}MB > ${REFERENCE_IMAGE_MAX_BYTES / 1e6}MB cap`); } - return `data:${mime};base64,${buffer.toString("base64")}`; + return { dataUri: `data:${mime};base64,${buffer.toString("base64")}` }; } finally { clearTimeout(timeout); } } - // Treat as a local file path. + // Treat as a local file path. Deliberately NOT restricted to cwd or tmpdir — + // "edit ~/Downloads/photo.png" from a Desktop session whose cwd is `/` is the + // documented use. What we do owe the user is the truth about which file is + // about to leave: resolve through realpath so the label carries the target + // of a symlink, not whatever innocent name it was given. const ext = ref.split(".").pop()?.toLowerCase() ?? ""; const mime = IMAGE_EXT_MIME[ext]; if (!mime) throw new Error(`unsupported image extension ".${ext}"; use png/jpg/jpeg/gif/webp`); - const buffer = await readFile(ref); + const localPath = await realpath(ref); + const buffer = await readFile(localPath); if (buffer.byteLength > REFERENCE_IMAGE_MAX_BYTES) { throw new Error(`image too large: ${(buffer.byteLength / 1e6).toFixed(1)}MB > ${REFERENCE_IMAGE_MAX_BYTES / 1e6}MB cap; resize or crop first`); } - return `data:${mime};base64,${buffer.toString("base64")}`; + return { dataUri: `data:${mime};base64,${buffer.toString("base64")}`, localPath }; } // Base (1024x1024) prices, mirroring the live /v1/images/models catalog. @@ -321,6 +346,9 @@ Source images and masks accept a base64 data URI, an http(s) URL, or a local fil // consumed at the shared charge site after the spend confirmation. let normalizedImage: string | string[] | undefined; let normalizedMask: string | undefined; + // Real paths of every local file read for this edit (sources, then the + // mask), for the confirm label — see resolveImageRef. + const localFiles: string[] = []; // Validate the edit action up front (before estimating/charging). if (action === "edit") { @@ -359,9 +387,15 @@ Source images and masks accept a base64 data URI, an http(s) URL, or a local fil } } try { - const dataUris = await Promise.all(sourceImages.map(toImageDataUri)); + const resolved = await Promise.all(sourceImages.map(resolveImageRef)); + const dataUris = resolved.map((r) => r.dataUri); normalizedImage = dataUris.length === 1 ? dataUris[0] : dataUris; - if (mask) normalizedMask = await toImageDataUri(mask); + for (const r of resolved) if (r.localPath) localFiles.push(r.localPath); + if (mask) { + const m = await resolveImageRef(mask); + normalizedMask = m.dataUri; + if (m.localPath) localFiles.push(m.localPath); + } } catch (e) { return { content: [{ type: "text", text: formatError(`Could not load source image: ${e instanceof Error ? e.message : String(e)}`) }], @@ -385,9 +419,13 @@ Source images and masks accept a base64 data URI, an http(s) URL, or a local fil // Confirm the spend before charging (elicitation; user can approve // once, approve all for the session, or decline to abort). No-ops on // clients without elicitation or when disabled via env. + // The label names every local file that is about to leave the + // machine in the request body — the dialog is the one moment a human + // sees the call before the bytes go, and a prompt-injected + // "edit ~/Pictures/IMG_1234.jpg" must not read like any other edit. const confirm = await confirmSpend(server, { usd: estimatedCost, - label: `${action === "edit" ? "image edit" : "image"} · ${selectedModel}`, + label: `${action === "edit" ? "image edit" : "image"} · ${selectedModel}${localFiles.length ? ` · reads ${localFiles.join(", ")}` : ""}`, }); if (!confirm.ok) { return { content: [{ type: "text", text: confirm.reason || "Charge cancelled." }] }; diff --git a/test/budget-limit-env-warning.test.ts b/test/budget-limit-env-warning.test.ts new file mode 100644 index 0000000..1932235 --- /dev/null +++ b/test/budget-limit-env-warning.test.ts @@ -0,0 +1,74 @@ +// Run with: npm test (tsx --test) +// +// BLOCKRUN_BUDGET_LIMIT is sold as the hard stop on every client — including +// the ones with no spend dialog, where it is the ONLY guard. parseBudgetLimitEnv +// maps anything that is not a finite positive number to null, and null means +// UNLIMITED. That contract is shared with BLOCKRUN_CONFIRM_THRESHOLD and stays; +// what must not stay is the silence. An operator who writes "5,00", "5 USD", +// "0" or "-3" gets an unlimited server that looks capped. +// +// The fix is a single stderr line at startup (stderr is the MCP stdio log +// channel; stdout is the protocol). It fires exactly when the env is set to +// something non-empty that parses to null, names the raw value, says the cap +// is OFF, and shows how to write it. It must NOT fire for a valid cap or for +// an unset/blank env — that is the default, not a misconfiguration. +import { test, mock } from "node:test"; +import assert from "node:assert/strict"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { initializeMcpServer } from "../src/mcp-handler.js"; + +// Same fake as apps.test.ts: registration is captured, nothing runs. +function init(env: NodeJS.ProcessEnv): string[] { + const fake = { + registerTool() {}, + registerResource() {}, + } as unknown as McpServer; + const spy = mock.method(console, "error", () => {}); + try { + initializeMcpServer(fake, { argv: ["--profile", "chat"], env }); + return spy.mock.calls.map((c) => c.arguments.map(String).join(" ")); + } finally { + spy.mock.restore(); + } +} + +const budgetLines = (lines: string[]) => lines.filter((l) => l.includes("BLOCKRUN_BUDGET_LIMIT")); + +for (const raw of ["5,00", "5 USD", "5$", "0", "-3", "abc", "NaN", "Infinity"]) { + test(`BLOCKRUN_BUDGET_LIMIT=${JSON.stringify(raw)} warns once that the cap is OFF and names the value`, () => { + const lines = budgetLines(init({ BLOCKRUN_BUDGET_LIMIT: raw })); + assert.equal(lines.length, 1, `expected exactly one warning, got: ${JSON.stringify(lines)}`); + const line = lines[0]; + assert.match(line, /^\[BlockRun\] /, "startup lines carry the [BlockRun] prefix"); + assert.ok(line.includes(`BLOCKRUN_BUDGET_LIMIT="${raw}"`), `the raw value is quoted back: ${line}`); + assert.match(line, /\bOFF\b/, "says the cap is OFF, not 'invalid'"); + assert.match(line, /unlimited/i, "spells out what OFF means for the ledger"); + assert.match(line, /BLOCKRUN_BUDGET_LIMIT=5\b/, "shows a correct spelling to copy"); + assert.match(line, /\$2\.50/, "and that a leading $ is accepted"); + }); +} + +for (const raw of ["5", "5.00", "$2.50", " 10 ", "0.001"]) { + test(`BLOCKRUN_BUDGET_LIMIT=${JSON.stringify(raw)} is a valid cap and stays silent`, () => { + assert.deepEqual(budgetLines(init({ BLOCKRUN_BUDGET_LIMIT: raw })), []); + }); +} + +test("an unset or blank BLOCKRUN_BUDGET_LIMIT is the default, not a misconfiguration — no warning", () => { + assert.deepEqual(budgetLines(init({})), []); + assert.deepEqual(budgetLines(init({ BLOCKRUN_BUDGET_LIMIT: "" })), []); + assert.deepEqual(budgetLines(init({ BLOCKRUN_BUDGET_LIMIT: " " })), []); +}); + +test("the warning does not read process.env when an explicit env is passed", () => { + // initializeMcpServer takes the env it is given; a test (or a host) that + // passes {} must not be judged on the developer's shell. + const saved = process.env.BLOCKRUN_BUDGET_LIMIT; + process.env.BLOCKRUN_BUDGET_LIMIT = "junk"; + try { + assert.deepEqual(budgetLines(init({})), []); + } finally { + if (saved === undefined) delete process.env.BLOCKRUN_BUDGET_LIMIT; + else process.env.BLOCKRUN_BUDGET_LIMIT = saved; + } +}); diff --git a/test/confirm-spend-coverage.test.ts b/test/confirm-spend-coverage.test.ts index 607a01c..e02bb6d 100644 --- a/test/confirm-spend-coverage.test.ts +++ b/test/confirm-spend-coverage.test.ts @@ -9,9 +9,13 @@ // // Two guards, because the failure mode is silence in both directions: // -// 1. STATIC — every src/tools/*.ts that reserves budget must also call -// confirmSpend. A new paid tool that copies the reserve/record shape but -// forgets the confirm would otherwise ship un-gated forever. +// 1. STATIC — every src/tools/*.ts that can PAY must reserve budget AND call +// confirmSpend. "Can pay" is read off the imports: a paid SDK client from +// utils/wallet.ts, or one of the hand-built rails (raw-call, api-key-call, +// solana-402). The guard used to key on reserveBudget alone and skip any +// file without it — so the worst possible offender, a tool that pays and +// never reserves, was the one it could not see. A file that reserves via +// some helper this list does not know is still held to the confirm. // 2. BEHAVIORAL — with confirmation on and a client that answers "decline", // every paid tool must return a non-error "declined" result, release its // reservation (budget.spent back to 0), and never reach the network. @@ -34,23 +38,89 @@ const TOOLS_DIR = join(ROOT, "src", "tools"); // --------------------------------------------------------------------------- // 1. Static guard // --------------------------------------------------------------------------- -test("every tool that reserves budget also asks the user (confirmSpend)", () => { +// The surfaces through which a tool file can move money. A file that imports +// any of these is a paid tool, whether or not it remembered to reserve. +const PAID_CLIENTS = /\b(getClient|getImageClient|buildClient|buildClientWithTimeout|getAnthropicClient|getPriceClient)\b/; +const PAID_HELPERS = /from "\.\.\/utils\/(raw-call|api-key-call|solana-402)\.js"/; + +// Paid-surface importers that genuinely never charge. Each entry is a claim +// that has to be re-made when the file changes; keep it short and say why. +const FREE_BY_DESIGN: Record = { + // getClient() only feeds loadModels()/listModels — the free catalogue GET. + "models.ts": "getClient feeds the free /v1/models catalogue read only", +}; + +/** + * What the static guard sees in one tool file. Exported shape, so the guard's + * own rules can be tested on fixtures below — a guard that cannot be shown to + * bite is only a comment. + */ +function classifyToolSource(file: string, src: string): { paid: boolean; reserves: number; confirms: number; imports: boolean; offence: string | null } { + const walletImport = /import\s*\{([^}]*)\}\s*from\s*"\.\.\/utils\/wallet\.js"/.exec(src)?.[1] ?? ""; + const paidSurface = PAID_CLIENTS.test(walletImport) || PAID_HELPERS.test(src); + const reserves = (src.match(/reserveBudget\(budget/g) ?? []).length; + const confirms = (src.match(/confirmSpend\(server/g) ?? []).length; + const imports = /from "\.\.\/utils\/confirm-spend\.js"/.test(src); + const paid = paidSurface || reserves > 0; + if (!paid) return { paid, reserves, confirms, imports, offence: null }; + if (paidSurface && reserves === 0 && file in FREE_BY_DESIGN) return { paid, reserves, confirms, imports, offence: null }; + // No parity requirement between reserves and confirms: speech, video and + // image legitimately RE-reserve inside a 402 onQuote after the one confirm. + let offence: string | null = null; + if (reserves === 0) offence = "pays but never reserves budget"; + else if (!imports || confirms === 0) offence = "reserves budget but never asks (confirmSpend)"; + return { paid, reserves, confirms, imports, offence }; +} + +test("every tool that can pay reserves budget AND asks the user (confirmSpend)", () => { const offenders: string[] = []; for (const file of readdirSync(TOOLS_DIR).filter((f) => f.endsWith(".ts"))) { - const src = readFileSync(join(TOOLS_DIR, file), "utf8"); - const reserves = (src.match(/reserveBudget\(budget/g) ?? []).length; - if (reserves === 0) continue; - const imports = /from "\.\.\/utils\/confirm-spend\.js"/.test(src); - const calls = (src.match(/confirmSpend\(server/g) ?? []).length; - if (!imports || calls === 0) offenders.push(`${file} (reserves=${reserves}, confirms=${calls})`); + const c = classifyToolSource(file, readFileSync(join(TOOLS_DIR, file), "utf8")); + if (c.offence) offenders.push(`${file}: ${c.offence} (reserves=${c.reserves}, confirms=${c.confirms})`); } assert.deepEqual( offenders, [], - `paid tools that charge without confirmSpend — they bypass BLOCKRUN_CONFIRM_SPEND:\n ${offenders.join("\n ")}`, + `paid tools that bypass the budget cap or BLOCKRUN_CONFIRM_SPEND:\n ${offenders.join("\n ")}`, ); }); +test("the FREE_BY_DESIGN allowlist names only files that still exist and still import a paid surface", () => { + // A stale entry is a hole: rename models.ts, add a paid call to the new + // file, and the old name would keep excusing nothing while the new one is + // judged normally — fine. But an entry whose file no longer imports a paid + // surface is dead weight that invites copy-paste, so it must go. + for (const file of Object.keys(FREE_BY_DESIGN)) { + const src = readFileSync(join(TOOLS_DIR, file), "utf8"); + const walletImport = /import\s*\{([^}]*)\}\s*from\s*"\.\.\/utils\/wallet\.js"/.exec(src)?.[1] ?? ""; + assert.ok(PAID_CLIENTS.test(walletImport) || PAID_HELPERS.test(src), `${file} no longer imports a paid surface — drop it from FREE_BY_DESIGN`); + assert.equal((src.match(/reserveBudget\(budget/g) ?? []).length, 0, `${file} now reserves budget — it is a paid tool, drop it from FREE_BY_DESIGN`); + } +}); + +test("the static guard bites: a tool that pays without reserving, or reserves without asking, is an offender", () => { + const RESERVE = "const gate = reserveBudget(budget, agent_id, 0.01);"; + const CONFIRM = 'import { confirmSpend } from "../utils/confirm-spend.js";\nconst c = await confirmSpend(server, { usd: 0.01, label: "x" });'; + const client = 'import { getClient } from "../utils/wallet.js";'; + const helper = 'import { apiKeyPost } from "../utils/api-key-call.js";'; + const freeWallet = 'import { getWalletInfo, getChain } from "../utils/wallet.js";'; + + // The hole this test closes: pays via a client, never reserves → was skipped. + assert.equal(classifyToolSource("new.ts", `${client}\n${CONFIRM}`).offence, "pays but never reserves budget"); + assert.equal(classifyToolSource("new.ts", `${helper}`).offence, "pays but never reserves budget"); + // The original rule, still enforced. + assert.equal(classifyToolSource("new.ts", `${client}\n${RESERVE}`).offence, "reserves budget but never asks (confirmSpend)"); + assert.equal(classifyToolSource("new.ts", `${RESERVE}`).offence, "reserves budget but never asks (confirmSpend)", "reserving via an unknown helper is still held to the confirm"); + // Compliant, including the legitimate re-reserve pattern (2 reserves, 1 confirm). + assert.equal(classifyToolSource("new.ts", `${client}\n${RESERVE}\n${CONFIRM}`).offence, null); + assert.equal(classifyToolSource("new.ts", `${helper}\n${RESERVE}\n${RESERVE}\n${CONFIRM}`).offence, null); + // Free tools: wallet-status imports and no rail are not paid at all. + assert.deepEqual(classifyToolSource("free.ts", `${freeWallet}`), { paid: false, reserves: 0, confirms: 0, imports: false, offence: null }); + // The allowlist excuses a paid-surface importer only under its own name. + assert.equal(classifyToolSource("models.ts", `${client}`).offence, null); + assert.equal(classifyToolSource("models-v2.ts", `${client}`).offence, "pays but never reserves budget"); +}); + // --------------------------------------------------------------------------- // 2. Behavioral guard // --------------------------------------------------------------------------- @@ -71,6 +141,11 @@ mock.module("../src/utils/wallet.js", { buildClientWithTimeout: () => trap, getPriceClient: () => trap, getAnthropicClient: () => trap, + // blockrun_image: its Base rail is the SDK ImageClient, and image.ts + // statically imports utils/solana-402.ts, which resolves the Solana key + // through wallet.ts (image-cost.test.ts documents the same two exports). + getImageClient: () => trap, + resolveSolanaKey: () => undefined, baseOnlyMessage: () => null, getOrCreateWalletKey: () => TEST_KEY, getWalletInfo: async () => ({ address: "0xTEST" }), @@ -107,18 +182,34 @@ const CASES: Array<{ tool: string; mod: string; register: string; args: Record { + if (realHome === undefined) delete process.env.HOME; else process.env.HOME = realHome; + if (savedApiKey === undefined) delete process.env.BLOCKRUN_API_KEY; else process.env.BLOCKRUN_API_KEY = savedApiKey; + fs.rmSync(home, { recursive: true, force: true }); +}); + // Mock the wallet module BEFORE importing the tool: force Base chain and hand // back a fake ImageClient whose generate/edit resolve to a hosted URL (no // network, no payment). @@ -27,8 +51,26 @@ mock.module("../src/utils/wallet.js", { resolveSolanaKey: () => undefined, }, }); +// Belt and braces: every rail that is not the mocked ImageClient (account +// apiKeyPost, Solana manual x402) bottoms out in this helper. If a future +// change routes past the pin above, the test fails HERE, for the right reason, +// instead of reaching the network. +let networkCalls = 0; +mock.module("../src/utils/http.js", { + namedExports: { + fetchWithTimeout: async () => { networkCalls++; throw new Error("network call escaped the mocks"); }, + isTimeoutError: () => false, + }, +}); const { registerImageTool, estimateCost } = await import("../src/tools/image.js"); +const { isApiKeyMode } = await import("../src/utils/auth.js"); + +test("the suite runs on the wallet rail whatever the developer's account setup", () => { + // If this fails, every handler test below is exercising the account rail — + // and without the pin, a real key. + assert.equal(isApiKeyMode(), false); +}); // Minimal McpServer stub: capture the handler registerImageTool installs. function makeHarness() { @@ -53,6 +95,7 @@ test("generate result includes a Cost line at the CHARGED price, not the catalog assert.match(text, /Cost: \$0\.0650/); // 0.06 catalog x 1.05 + $0.002 assert.equal(res.structuredContent.cost_usd, 0.065); assert.equal(res.isError, undefined); + assert.equal(networkCalls, 0, "the mocked ImageClient must be the only rail this suite touches"); }); test("large gpt-image-2 render is billed at the large-size CHARGED price", async () => { diff --git a/test/image-edit-label.test.ts b/test/image-edit-label.test.ts new file mode 100644 index 0000000..97f27fa --- /dev/null +++ b/test/image-edit-label.test.ts @@ -0,0 +1,194 @@ +// Run with: npm test (tsx --experimental-test-module-mocks --test) +// +// blockrun_image edit reads whatever local file the model names — that is the +// documented feature ("edit ~/Downloads/photo.png") — base64s it into the +// request body and ships it to the gateway. The confirmSpend dialog is the one +// moment a human sees the call before it leaves the machine, and its label used +// to say only `image edit · `: which file was about to leave was not on +// it. A prompt-injected reference to ~/Pictures/IMG_1234.jpg looked exactly like +// a normal $0.05 edit. +// +// Two properties are pinned here: +// - the local branch resolves through fs.realpath, so the label carries the +// REAL path — a symlink named innocently is shown as what it points at; +// - the label lists every local source and the mask, and says nothing about +// files for data: URIs or a plain generate. +// Nothing is restricted: cwd, tmpdir, home all still work. This is disclosure, +// not a sandbox. +process.env.BLOCKRUN_CONFIRM_SPEND = "on"; +process.env.BLOCKRUN_CONFIRM_THRESHOLD = "0"; + +import { test, mock } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { BudgetState } from "../src/types.js"; + +// Rail pin + traps, as in image-cost.test.ts. Every call below is DECLINED at +// the confirm dialog, so nothing past it may run — but the mocks make sure +// that if something did, it would fail here rather than pay. +const home = fs.mkdtempSync(path.join(os.tmpdir(), "blockrun-image-label-")); +const realHome = process.env.HOME; +const savedApiKey = process.env.BLOCKRUN_API_KEY; +process.env.HOME = home; +delete process.env.BLOCKRUN_API_KEY; +process.on("exit", () => { + if (realHome === undefined) delete process.env.HOME; else process.env.HOME = realHome; + if (savedApiKey === undefined) delete process.env.BLOCKRUN_API_KEY; else process.env.BLOCKRUN_API_KEY = savedApiKey; + fs.rmSync(home, { recursive: true, force: true }); +}); + +let networkCalls = 0; +const boom = () => { networkCalls++; throw new Error("UNEXPECTED_NETWORK_CALL"); }; +mock.module("../src/utils/wallet.js", { + namedExports: { + getApiBase: () => "https://blockrun.ai/api", + resolveGatewayUrl: (u: string) => u, + getChain: () => "base", + getImageClient: () => new Proxy({}, { get: () => boom }), + getOrCreateWalletKey: () => { throw new Error("label tests must not touch a wallet key"); }, + getWalletInfo: async () => ({ address: "0xTEST" }), + resolveSolanaKey: () => undefined, + }, +}); +mock.module("../src/utils/http.js", { + namedExports: { fetchWithTimeout: async () => boom(), isTimeoutError: () => false }, +}); + +const { registerImageTool, toImageDataUri, resolveImageRef } = await import("../src/tools/image.js"); + +// 1x1 transparent PNG +const PNG_BYTES = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC", + "base64", +); +const DATA_URI = `data:image/png;base64,${PNG_BYTES.toString("base64")}`; + +// Files live under a fresh tmp dir. On macOS os.tmpdir() is itself a symlink +// (/var -> /private/var), so realpath differs from the path we hand in — which +// is exactly the difference the label must show. +const dir = fs.mkdtempSync(path.join(os.tmpdir(), "blockrun-img-label-")); +const photo = path.join(dir, "photo.png"); +const logo = path.join(dir, "logo.jpg"); +const maskFile = path.join(dir, "mask.png"); +fs.writeFileSync(photo, PNG_BYTES); +fs.writeFileSync(logo, PNG_BYTES); +fs.writeFileSync(maskFile, PNG_BYTES); +const linkDir = fs.mkdtempSync(path.join(os.tmpdir(), "blockrun-img-link-")); +const innocentLink = path.join(linkDir, "cat.png"); +fs.symlinkSync(photo, innocentLink); +process.on("exit", () => { + fs.rmSync(dir, { recursive: true, force: true }); + fs.rmSync(linkDir, { recursive: true, force: true }); +}); +const real = (p: string) => fs.realpathSync(p); + +type Handler = (args: Record) => Promise<{ content: Array<{ type: string; text?: string }>; isError?: boolean }>; + +function harness() { + let handler: Handler | undefined; + const messages: string[] = []; + const server = { + registerTool: (_n: string, _c: unknown, h: Handler) => { handler = h; }, + server: { + getClientCapabilities: () => ({ elicitation: {} }), + elicitInput: async (req: { message: string }) => { messages.push(req.message); return { action: "decline" }; }, + }, + }; + const budget: BudgetState = { limit: null, spent: 0, calls: 0, agents: new Map() }; + registerImageTool(server as never, budget); + assert.ok(handler, "blockrun_image did not register a handler"); + networkCalls = 0; + return { + call: async (args: Record) => { + const res = await handler!(args); + return { res, text: res.content.map((p) => p.text ?? "").join("\n") }; + }, + budget, + messages, + // The confirm message's first line is "💸 BlockRun charge —