From 7f8d8f12e6129de6e3aaa41ef36adf09a0c389bc Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:05:18 -0500 Subject: [PATCH 01/37] fix(budget,errors): a per-agent cap that cannot refill itself, and account-rail errors that classify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three items verified by hand after the 0.49.0 audit, plus the test file its tail called out. - blockrun_wallet action:"delegate" wrote spent: 0 unconditionally, so an agent that had exhausted its allocation could re-delegate its own agent_id and start again — and delegate is a tool the model can call. The ledger now carries across a re-delegation and the response says what carried over; a limit is the operator's to raise, spend is not theirs to erase. The global cap was never bypassable this way, so the sub-cap simply meant nothing. - The status-code boundary excluded a following dot outright, which kept $402.50 from reading as a status but also meant every account-rail message ('BlockRun account API error: 502.', the SDK's own shape) fell through unclassified: the wallet rail got guidance, the account rail got none. A dot NOT followed by a digit is punctuation; a dot followed by one is a decimal point and still disqualifies. One shared STATUS_END constant now, so the three matchers cannot drift. - raw-call.ts — the rail switch for eight path-based tools — had no test on either rail. Eight now, covering which rail runs, that the other is never touched, and that paidUsd stays null (never 0) on a wallet call, since recordActualSpend books a real charge as free for 0. - docs/mcp-schema-overhead.md re-measured at 0.49.0 (19 tools, 12,657), with a line saying the README card is the source of truth and this page is dated prose. 654 tests, typecheck clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg --- docs/mcp-schema-overhead.md | 25 ++++-- node_modules | 1 + src/tools/wallet.ts | 33 +++++++- src/utils/errors.ts | 23 ++++- test/delegate-ledger.test.ts | 109 ++++++++++++++++++++++++ test/errors.test.ts | 52 +++++++++++- test/raw-call.test.ts | 160 +++++++++++++++++++++++++++++++++++ 7 files changed, 387 insertions(+), 16 deletions(-) create mode 120000 node_modules create mode 100644 test/delegate-ledger.test.ts create mode 100644 test/raw-call.test.ts diff --git a/docs/mcp-schema-overhead.md b/docs/mcp-schema-overhead.md index fab5cec..2593dbd 100644 --- a/docs/mcp-schema-overhead.md +++ b/docs/mcp-schema-overhead.md @@ -9,21 +9,28 @@ Harness: [`scripts/measure-tool-schema.mjs`](../scripts/measure-tool-schema.mjs) (`npm run measure:schema`). Guard: [`test/schema-tokens.test.ts`](../test/schema-tokens.test.ts), which fails the build when the README card disagrees with a live measurement. -Written 2026-09-01, verified against `@modelcontextprotocol/sdk` 1.29.0. +Written 2026-09-01, verified against `@modelcontextprotocol/sdk` 1.29.0. Numbers re-measured +2026-09-09 at 0.49.0. ## Our number | Profile | Tools | Context | |---------|-------|---------| -| `full` *(default)* | 20 | 12,900 | -| `trading` | 9 | 5,554 | -| `media` | 7 | 5,436 | -| `research` | 6 | 3,024 | -| `chat` | 3 | 1,924 | +| `full` *(default)* | 19 | 12,657 | +| `trading` | 8 | 5,160 | +| `media` | 7 | 5,603 | +| `research` | 5 | 2,635 | +| `chat` | 3 | 1,976 | -Descriptions are ~54% of it, input schemas ~41%. `--profile trading` costs 57% less than the +Descriptions are ~55% of it, input schemas ~40%. `--profile trading` costs 59% less than the default for the same workflow. +These figures move with every description edit, so they are not the source of truth — the README +card is, and `test/schema-tokens.test.ts` fails the build when it disagrees with a live +measurement. This page is dated prose; re-run `npm run measure:schema` before quoting it. (It was +20 tools and 12,900 tokens until 2026-09-06, when the gateway retired Surf and `blockrun_surf` +went with it.) + Measure it yourself, against us or anyone else: ```bash @@ -50,7 +57,7 @@ It is a JSON Schema *dialect declaration*, and as far as can be verified it does block the model carries on every turn, and the server author never wrote it and cannot see it in their source. -Cost: **~15 tokens per tool.** For us, 20 tools → 300 tokens. On a 49-tool server of the kind +Cost: **~15 tokens per tool.** For us, 19 tools → ~285 tokens. On a 49-tool server of the kind Uber cited, ~735 tokens. Nobody's tool budget is blown by this, but it is 100% waste, it is invisible from the source code, and it is in essentially every SDK-built server in the ecosystem. @@ -63,7 +70,7 @@ Stronger than inference, weaker than "every client ignores it". Verified: - **The SDK's bundled validator never receives it.** ajv (`validation/ajv-provider.js`) is invoked on `tool.outputSchema` and on elicitation `requestedSchema` — never on `inputSchema`. In the reference implementation the header is not even handed to a validator. -- **It is inert for ajv anyway.** Compiling all 20 tool schemas under the SDK's exact ajv config, +- **It is inert for ajv anyway.** Compiling all 20 tool schemas (the count at the time) under the SDK's exact ajv config, with and without the header, against 5 input samples each: identical verdicts on **100/100** pairs, 0 differences. diff --git a/node_modules b/node_modules new file mode 120000 index 0000000..1e3c233 --- /dev/null +++ b/node_modules @@ -0,0 +1 @@ +../blockrun-mcp/node_modules \ No newline at end of file diff --git a/src/tools/wallet.ts b/src/tools/wallet.ts index dbdf1c9..4312243 100644 --- a/src/tools/wallet.ts +++ b/src/tools/wallet.ts @@ -111,10 +111,37 @@ Do NOT call this for actual AI queries — use blockrun_chat for that.`, if (!agent_limit || agent_limit <= 0) { return { content: [{ type: "text", text: formatError("agent_limit (USD > 0) required for delegate action") }], isError: true }; } - budget.agents.set(agent_id, { limit: agent_limit, spent: 0, calls: 0 }); + // Carry the LEDGER across a re-delegation. This used to write + // `spent: 0` unconditionally, so an agent that had exhausted its cap + // could refill itself by calling delegate again with the same id — + // and delegate is a tool the model can call. A limit is a policy the + // operator may raise or lower at will; spend already happened and is + // not the operator's to erase. (The global BLOCKRUN_BUDGET_LIMIT was + // never bypassable this way — it is checked separately — so this was a + // per-agent sub-cap that quietly meant nothing.) + const prior = budget.agents.get(agent_id); + const spent = prior?.spent ?? 0; + const calls = prior?.calls ?? 0; + budget.agents.set(agent_id, { limit: agent_limit, spent, calls }); + // USDC has six decimals, and float subtraction does not: 1 - 0.9 is + // 0.09999999999999998, which would surface verbatim in the report and + // in structuredContent. Round the DERIVED figure; `spent` stays exact. + const remaining = Math.round(Math.max(0, agent_limit - spent) * 1e6) / 1e6; + const lines = [`Agent "${agent_id}" allocated $${agent_limit.toFixed(2)} budget.`]; + if (prior) { + lines.push( + `Carried over from the previous allocation: $${spent.toFixed(4)} spent across ${calls} call${calls === 1 ? "" : "s"} — ` + + `$${remaining.toFixed(4)} remains under the new limit.` + + (remaining === 0 ? ` This agent is already at its cap; raise agent_limit above $${spent.toFixed(4)} to give it room.` : ""), + ); + } + if (budget.limit !== null && agent_limit > budget.limit) { + lines.push(`Note: the session cap is $${budget.limit.toFixed(2)}, so this agent cannot actually spend more than that.`); + } + lines.push(`Pass agent_id: "${agent_id}" in any blockrun_* tool call to track and enforce this limit.`); return { - content: [{ type: "text", text: `Agent "${agent_id}" allocated $${agent_limit.toFixed(2)} budget.\nPass agent_id: "${agent_id}" in any blockrun_* tool call to track and enforce this limit.` }], - structuredContent: { agent_id, limit: agent_limit, spent: 0, calls: 0 }, + content: [{ type: "text", text: lines.join("\n") }], + structuredContent: { agent_id, limit: agent_limit, spent, calls, remaining }, }; } diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 9c6b2b1..87eacd3 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -55,6 +55,23 @@ export function isPaymentRejectionError(message: string): boolean { return m.includes("insufficient") || m.includes("balance") || m.includes("rejected"); } +/** + * Where a status code is allowed to END. + * + * End of string, or a character that is neither a digit nor a dot — that much + * is what keeps "$402.50" and "$1.4020" from reading as status codes, and it is + * load-bearing (both are pinned by tests). + * + * The third alternative is the fix for a real gap: the SDK's ACCOUNT client + * writes `BlockRun account API error: 502.` with a sentence-ending period + * (@blockrun/llm dist/index.js, `${response.status}.${hint}`), and a dot was + * excluded outright — so every account-rail 5xx fell through unclassified and + * the caller got no guidance at all, while the identical wallet-rail message + * ("API error: 502") got it. A dot NOT followed by a digit is punctuation; a + * dot followed by a digit is a decimal point and still disqualifies. + */ +const STATUS_END = "(?:$|[^0-9.]|\\.(?!\\d))"; + /** * 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 @@ -69,7 +86,7 @@ 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) || + return new RegExp(`(?:status(?:\\s*code)?|http|error|payment)\\s*[:=]?\\s*5[0-9]{2}${STATUS_END}`).test(m) || /(?:^|[^0-9.])5[0-9]{2}:?\s+(?:internal|server error|bad gateway|service unavailable|gateway time)/.test(m); } @@ -87,7 +104,7 @@ export function formatError(message: string, opts?: { altModels?: string }): str // characters", "$1.4020", or "$402.50" must not classify as 500/402 errors. // The trailing boundary excludes a following digit AND a following dot, so the // integer part of a decimal amount ($402.50) is not misread as a status code. - const hasStatus = (code: string) => new RegExp(`(^|[^0-9.])${code}($|[^0-9.])`).test(msgLower); + const hasStatus = (code: string) => new RegExp(`(^|[^0-9.])${code}${STATUS_END}`).test(msgLower); const isPostPaymentClientError = msgLower.includes("api error after payment") && /(^|[^0-9.])4[0-9]{2}($|[^0-9.])/.test(msgLower); @@ -133,7 +150,7 @@ export function formatError(message: string, opts?: { altModels?: string }): str // 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) || + new RegExp(`(?:status(?:\\s*code)?|http|error|payment)\\s*[:=]?\\s*501${STATUS_END}`).test(msgLower) || /(?:^|[^0-9.])501:?\s+not implemented/.test(msgLower); const isNotServedPrePayment = isNotServed && !msgLower.includes("api error after payment"); diff --git a/test/delegate-ledger.test.ts b/test/delegate-ledger.test.ts new file mode 100644 index 0000000..98eb503 --- /dev/null +++ b/test/delegate-ledger.test.ts @@ -0,0 +1,109 @@ +// Run with: npm test (tsx --experimental-test-module-mocks --test) +// +// A per-agent cap the agent can refill is not a cap. +// +// `delegate` wrote `spent: 0` unconditionally, so an agent that had exhausted +// its allocation could call blockrun_wallet action:"delegate" with its own +// agent_id and start again — and delegate is a tool the MODEL can call. The +// global BLOCKRUN_BUDGET_LIMIT was never bypassable this way (checkBudget +// tests it separately), so the damage was bounded: the sub-cap simply meant +// nothing once the agent noticed. A limit is the operator's to raise or lower; +// spend already happened and is not theirs to erase. +import { test, mock } from "node:test"; +import assert from "node:assert/strict"; +import type { BudgetState } from "../src/types.js"; + +mock.module("../src/utils/onramp.js", { + namedExports: { launchTopUp: async () => ({ opened: false, url: "", note: "" }) }, +}); +mock.module("../src/utils/wallet.js", { + namedExports: { + getApiBase: () => "https://blockrun.ai/api", + resolveGatewayUrl: (u: string) => u, + getWalletInfo: async () => ({ address: "0xTESTADDRESS" }), + getChain: () => "base", + getUsdcBalance: async () => 0, + setChain: () => {}, + ensureBothWallets: async () => ({ base: { address: "0xTESTADDRESS" }, solana: { address: "SOL" } }), + getChainBalance: async () => 0, + }, +}); +const { registerWalletTool } = await import("../src/tools/wallet.js"); +const { reserveBudget } = await import("../src/utils/budget.js"); + +function makeHarness(limit: number | null = null) { + let handler: ((a: Record) => Promise) | undefined; + const server = { registerTool: (_n: string, _c: unknown, h: any) => { handler = h; } } as any; + const budget: BudgetState = { limit, spent: 0, calls: 0, agents: new Map() }; + registerWalletTool(server, budget); + return { call: (a: Record) => handler!(a), budget }; +} + +test("re-delegating the same agent_id carries the spend, it does not refill the cap", async () => { + const { call, budget } = makeHarness(); + await call({ action: "delegate", agent_id: "worker-1", agent_limit: 1 }); + + // Spend it out through the real ledger, the way a paid tool does. + const gate = reserveBudget(budget, "worker-1", 0.9); + assert.equal(gate.allowed, true); + gate.release(); + budget.agents.get("worker-1")!.spent = 0.9; + + const blocked = reserveBudget(budget, "worker-1", 0.5); + assert.equal(blocked.allowed, false, "0.9 of a 1.0 cap leaves no room for 0.5"); + + // The refill attempt. + const res = await call({ action: "delegate", agent_id: "worker-1", agent_limit: 1 }); + assert.equal(budget.agents.get("worker-1")!.spent, 0.9, "spend must survive a re-delegation"); + assert.equal(res.structuredContent.spent, 0.9); + assert.equal(res.structuredContent.remaining, 0.1); + assert.match(res.content[0].text, /Carried over/); + assert.match(res.content[0].text, /\$0\.1000 remains/); + + const stillBlocked = reserveBudget(budget, "worker-1", 0.5); + assert.equal(stillBlocked.allowed, false, "the cap must still bite after re-delegation"); +}); + +test("raising the limit gives an exhausted agent room again — that is the operator's call", async () => { + const { call, budget } = makeHarness(); + await call({ action: "delegate", agent_id: "worker-2", agent_limit: 1 }); + budget.agents.get("worker-2")!.spent = 1; + + const res = await call({ action: "delegate", agent_id: "worker-2", agent_limit: 5 }); + assert.equal(budget.agents.get("worker-2")!.spent, 1, "still one dollar spent"); + assert.equal(res.structuredContent.remaining, 4); + assert.doesNotMatch(res.content[0].text, /already at its cap/); + assert.equal(reserveBudget(budget, "worker-2", 3).allowed, true); +}); + +test("a first delegation reports no carry-over, and revoke really clears the ledger", async () => { + const { call, budget } = makeHarness(); + const first = await call({ action: "delegate", agent_id: "fresh", agent_limit: 2 }); + assert.equal(first.structuredContent.spent, 0); + assert.doesNotMatch(first.content[0].text, /Carried over/); + + budget.agents.get("fresh")!.spent = 2; + await call({ action: "revoke", agent_id: "fresh" }); + assert.equal(budget.agents.has("fresh"), false, "revoke removes the allocation outright"); + + const again = await call({ action: "delegate", agent_id: "fresh", agent_limit: 2 }); + assert.equal(again.structuredContent.spent, 0, "a revoked id starts clean — that is what revoke is for"); +}); + +test("an agent_limit above the session cap says so instead of implying it can be spent", async () => { + const { call } = makeHarness(3); + const res = await call({ action: "delegate", agent_id: "greedy", agent_limit: 50 }); + assert.match(res.content[0].text, /session cap is \$3\.00/); +}); + +test("an agent whose spend has reached the limit is told so, not silently re-armed", async () => { + const { call, budget } = makeHarness(); + await call({ action: "delegate", agent_id: "spent-out", agent_limit: 1 }); + budget.agents.get("spent-out")!.spent = 1; + + const res = await call({ action: "delegate", agent_id: "spent-out", agent_limit: 1 }); + assert.equal(res.structuredContent.remaining, 0); + assert.match(res.content[0].text, /already at its cap/); + assert.match(res.content[0].text, /raise agent_limit above \$1\.0000/); + assert.equal(reserveBudget(budget, "spent-out", 0.01).allowed, false); +}); diff --git a/test/errors.test.ts b/test/errors.test.ts index c0a6abb..86cf0c6 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 { extractErrorMessage, formatError, isPaymentRejectionError } from "../src/utils/errors.js"; +import { extractErrorMessage, formatError, hasLabelledServerStatus, 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"; @@ -198,3 +198,53 @@ test("the SDK's post-payment prefix counts as a labelled status", () => { const out = formatError("API error after payment: 502\nRequest failed"); assert.match(out, /temporary API issue/); }); + +// --- the account rail's own error shape (audit round 2) --- +// +// @blockrun/llm's account client writes `BlockRun account API error: ${status}.${hint}` +// — with a sentence-ending period. The status boundary excluded a dot outright +// (so "$402.50" could not read as a status), which meant every account-rail +// status fell through unclassified: the identical wallet-rail message got +// guidance, the account one got none. + +test("an account-rail 5xx is classified like the wallet rail's", () => { + for (const msg of [ + "BlockRun account API error: 502.", + "BlockRun account API error: 503. Retry-After: 30", + "BlockRun account API error: 500. Check https://user.blockrun.ai/dashboard/activity", + ]) { + const out = formatError(msg); + assert.match(out, /temporary API issue/, msg); + assert.doesNotMatch(out, /needs funding/, msg); + } +}); + +test("an account-rail 402 still reads as a funding problem", () => { + const out = formatError("BlockRun account API error: 402. Insufficient credit"); + assert.match(out, /wallet needs funding|Insufficient/i); + assert.doesNotMatch(out, /temporary API issue/); +}); + +test("an account-rail 501 is 'not served', and says nothing was charged", () => { + const out = formatError("BlockRun account API error: 501."); + assert.match(out, /does not serve this endpoint/); + assert.match(out, /nothing was charged/); +}); + +test("a decimal amount is STILL not a status code after the boundary change", () => { + // The whole reason a dot was excluded. A dot followed by a digit is a decimal + // point; a dot not followed by one is punctuation. + for (const msg of ["Charged $402.50 for this render", "cost was $1.4020 total", "price 500.25 usd"]) { + const out = formatError(msg); + assert.doesNotMatch(out, /temporary API issue/, msg); + assert.doesNotMatch(out, /does not serve this endpoint/, msg); + assert.doesNotMatch(out, /wallet needs funding/, msg); + } +}); + +test("hasLabelledServerStatus agrees with formatError on the dotted shape", () => { + assert.equal(hasLabelledServerStatus("BlockRun account API error: 502."), true); + assert.equal(hasLabelledServerStatus("error 500. something"), true); + assert.equal(hasLabelledServerStatus("charged $500.25"), false); + assert.equal(hasLabelledServerStatus("batch of 501 items"), false); +}); diff --git a/test/raw-call.test.ts b/test/raw-call.test.ts new file mode 100644 index 0000000..6ff0737 --- /dev/null +++ b/test/raw-call.test.ts @@ -0,0 +1,160 @@ +// Run with: npm test (tsx --experimental-test-module-mocks --test) +// +// raw-call.ts is the rail switch for EIGHT path-based tools (search, exa, +// markets, rpc, defi, phone, modal, and formerly surf) and it had no test on +// either rail. It decides two things that are easy to get silently wrong: +// +// 1. WHICH rail runs — the SDK's 402 dance, or the account API's Bearer fetch. +// Pick wrong and the call is billed to the other payer. +// 2. What `paidUsd` means — the settled figure on the account rail, and null +// (never 0) on a wallet call, because recordActualSpend books the caller's +// estimate for null and would book a real charge as FREE for 0. +// +// Both rails are mocked here; nothing reaches the network or a wallet. +import { test, mock, beforeEach } from "node:test"; +import assert from "node:assert/strict"; + +let apiKeyMode = false; +mock.module("../src/utils/auth.js", { + namedExports: { + isApiKeyMode: () => apiKeyMode, + getApiKey: () => (apiKeyMode ? "br_test_key" : undefined), + apiAuthHeaders: () => ({ Authorization: "Bearer br_test_key" }), + getApiKeyBase: () => "https://api.blockrun.ai", + PORTAL_CREDITS_URL: "https://user.blockrun.ai/dashboard/credits", + }, +}); + +const accountCalls: Array<{ verb: string; endpoint: string; arg: unknown }> = []; +let accountResult: { data: unknown; paidUsd: number | null } = { data: { ok: "account" }, paidUsd: 0.0085 }; +let accountThrows: Error | null = null; +mock.module("../src/utils/api-key-call.js", { + namedExports: { + apiKeyGet: async (endpoint: string, params?: Record) => { + accountCalls.push({ verb: "GET", endpoint, arg: params }); + if (accountThrows) throw accountThrows; + return accountResult; + }, + apiKeyPost: async (endpoint: string, body: Record) => { + accountCalls.push({ verb: "POST", endpoint, arg: body }); + if (accountThrows) throw accountThrows; + return accountResult; + }, + }, +}); + +const { rawGet, rawPost } = await import("../src/utils/raw-call.js"); + +const walletCalls: Array<{ verb: string; endpoint: string; arg: unknown }> = []; +let walletThrows: Error | null = null; +const walletClient = { + getWithPaymentRaw: async (endpoint: string, params?: Record) => { + walletCalls.push({ verb: "GET", endpoint, arg: params }); + if (walletThrows) throw walletThrows; + return { ok: "wallet" }; + }, + requestWithPaymentRaw: async (endpoint: string, body: unknown) => { + walletCalls.push({ verb: "POST", endpoint, arg: body }); + if (walletThrows) throw walletThrows; + return { ok: "wallet" }; + }, +}; + +beforeEach(() => { + apiKeyMode = false; + accountCalls.length = 0; + walletCalls.length = 0; + accountThrows = null; + walletThrows = null; + accountResult = { data: { ok: "account" }, paidUsd: 0.0085 }; +}); + +test("wallet mode goes through the SDK and NEVER touches the account API", async () => { + const got = await rawGet(walletClient, "/v1/pm/polymarket/markets", { limit: "1" }); + const posted = await rawPost(walletClient, "/v1/exa/search", { query: "fed" }); + + assert.deepEqual(got.data, { ok: "wallet" }); + assert.deepEqual(posted.data, { ok: "wallet" }); + assert.equal(accountCalls.length, 0, "the account rail must not be consulted without a key"); + assert.deepEqual(walletCalls.map(c => c.verb + " " + c.endpoint), [ + "GET /v1/pm/polymarket/markets", + "POST /v1/exa/search", + ]); + assert.deepEqual(walletCalls[0].arg, { limit: "1" }); + assert.deepEqual(walletCalls[1].arg, { query: "fed" }); +}); + +test("a wallet call reports paidUsd null, never 0 — 0 would book a real charge as free", async () => { + const got = await rawGet(walletClient, "/v1/defillama/protocols"); + const posted = await rawPost(walletClient, "/v1/search", { query: "x" }); + assert.equal(got.paidUsd, null); + assert.equal(posted.paidUsd, null); + // The distinction recordActualSpend depends on: null means "unknown, use the + // estimate", 0 means "this was free". + assert.notEqual(got.paidUsd, 0); +}); + +test("account mode goes through api-key-call and NEVER touches the SDK client", async () => { + apiKeyMode = true; + const got = await rawGet(walletClient, "/v1/pm/kalshi/markets", { status: "open" }); + const posted = await rawPost(walletClient, "/v1/modal/sandbox/exec", { command: ["echo"] }); + + assert.deepEqual(got.data, { ok: "account" }); + assert.deepEqual(posted.data, { ok: "account" }); + assert.equal(walletCalls.length, 0, "the wallet must not be asked to sign in account mode"); + assert.deepEqual(accountCalls.map(c => c.verb + " " + c.endpoint), [ + "GET /v1/pm/kalshi/markets", + "POST /v1/modal/sandbox/exec", + ]); + assert.deepEqual(accountCalls[0].arg, { status: "open" }); +}); + +test("account mode surfaces the settled cost the header carried", async () => { + apiKeyMode = true; + accountResult = { data: { ok: "account" }, paidUsd: 0.012 }; + assert.equal((await rawGet(walletClient, "/v1/phone/lookup")).paidUsd, 0.012); + + // A gateway that priced nothing at response time hands back null, and null + // must survive: the caller then books its estimate rather than zero. + accountResult = { data: { ok: "account" }, paidUsd: null }; + assert.equal((await rawPost(walletClient, "/v1/search", { query: "x" })).paidUsd, null); + + // A genuinely free account route reports 0, and 0 must survive too. + accountResult = { data: { ok: "account" }, paidUsd: 0 }; + assert.equal((await rawPost(walletClient, "/v1/phone/numbers/release", {})).paidUsd, 0); +}); + +test("an undefined POST body reaches the account rail as {}, not as undefined", async () => { + // apiKeyPost types its body as an object; passing undefined through would + // JSON.stringify to "undefined" and 400 at the gateway. + apiKeyMode = true; + await rawPost(walletClient, "/v1/pm/markets/search", undefined); + assert.deepEqual(accountCalls[0].arg, {}); +}); + +test("an undefined POST body is passed through UNCHANGED on the wallet rail", async () => { + // The SDK distinguishes an absent body from an empty one; only the account + // rail needs the {} coercion, and coercing both would change wallet behaviour. + await rawPost(walletClient, "/v1/pm/markets/search", undefined); + assert.equal(walletCalls[0].arg, undefined); +}); + +test("each rail's failure propagates from that rail alone", async () => { + walletThrows = new Error("API error: 502"); + await assert.rejects(() => rawGet(walletClient, "/v1/exa/search"), /502/); + assert.equal(accountCalls.length, 0); + + apiKeyMode = true; + walletThrows = null; + accountThrows = new Error("BlockRun account API error: 402."); + await assert.rejects(() => rawGet(walletClient, "/v1/exa/search"), /402/); + assert.equal(walletCalls.length, 1, "only the wallet call from the first half"); +}); + +test("the rail is decided per call, so switching mid-process routes the next call correctly", async () => { + await rawGet(walletClient, "/v1/defillama/protocols"); + apiKeyMode = true; + await rawGet(walletClient, "/v1/defillama/protocols"); + assert.equal(walletCalls.length, 1); + assert.equal(accountCalls.length, 1); +}); From fa2809d3f94886bc78dea961f76120a48a6a5440 Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:19:29 -0500 Subject: [PATCH 02/37] fix(models,video,docs): key the model cache by rail and chain, poll to the route's own limit, and correct four stale claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six more from the audit tail, each read end to end first. - The model cache was a single shared list with no key, so blockrun_wallet action:"chain" left the previous gateway's catalogue in place for the rest of the 5-minute TTL — and the two gateways do not serve the same one (78 chat models on Base, 83 on Solana, measured 2026-09-09). Keyed by rail+chain now, with the in-flight fetch shared between the tool and the resource instead of both issuing it. - blockrun_models was annotated openWorldHint:false while fetching the live catalogue over the network. It is readOnlyOpenWorld: the hint describes whether the tool reaches outside the process, and this one does. - VIDEO_POLL_TIMEOUT_MS was 90s against a gateway poll route that declares maxDuration = 60, so 30s of the signed authorization's window was spent on a request the server had already abandoned. The invariant test now states the real unclamped worst case (budget + interval + timeout), which is what its own comment always said. - README FAQ said 'a few media/paid tools settle on Base only (noted above)': not media, and not noted. It names them — blockrun_defi, blockrun_modal and native Anthropic chat. - budget_action accepts 'check' and DEFAULTS to it; neither the examples nor the field description said so. - skills/blockrun claimed 'No API keys, no accounts' — the account rail has existed since 0.46.0. 654 tests, typecheck clean. README card and docs/mcp-schema-overhead.md re-measured (12,686 full / 5,189 trading). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg --- README.md | 12 ++++----- docs/mcp-schema-overhead.md | 10 +++---- skills/blockrun/SKILL.md | 2 +- src/mcp-handler.ts | 7 ++--- src/tools/models.ts | 12 ++++++--- src/tools/video.ts | 9 +++++-- src/tools/wallet.ts | 3 ++- src/utils/model-cache.ts | 52 +++++++++++++++++++++++++++++++------ test/video-models.test.ts | 10 +++++-- 9 files changed, 85 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index dc6d491..5b844db 100644 --- a/README.md +++ b/README.md @@ -218,11 +218,11 @@ Package managers have shown install size for decades. Almost no MCP server shows | Profile | Tools | Context | |---------|-------|---------| -| `full` *(default)* | 19 | 12,657 | -| `trading` | 8 | 5,160 | -| `media` | 7 | 5,603 | -| `research` | 5 | 2,635 | -| `chat` | 3 | 1,976 | +| `full` *(default)* | 19 | 12,686 | +| `trading` | 8 | 5,189 | +| `media` | 7 | 5,632 | +| `research` | 5 | 2,664 | +| `chat` | 3 | 2,005 | Running `--profile trading` instead of the default costs **59% less context** for the same trading workflow. If you only ever ask about markets, that is the single cheapest change you can make. @@ -660,7 +660,7 @@ Yes — `BLOCKRUN_CONFIRM_SPEND=on`. Every paid tool pauses with the estimated c Yes. `blockrun_polymarket` places real, USDC-settled orders on Polymarket's CLOB — confirm-gated and capped. Read the odds with `blockrun_markets`, place with `blockrun_polymarket`. **Base or Solana?** -Both. Switch instantly with `blockrun_wallet action:"chain"`. A few media/paid tools settle on Base only (noted above). +Both. Switch instantly with `blockrun_wallet action:"chain"`. Three things are Base-only, and each says so when you call them on Solana: `blockrun_defi` (DefiLlama) and `blockrun_modal`, which the Solana gateway does not serve, and native Anthropic `claude-*` chat. Media generation, markets, search and Polymarket all settle on either chain. --- diff --git a/docs/mcp-schema-overhead.md b/docs/mcp-schema-overhead.md index 2593dbd..5e4131b 100644 --- a/docs/mcp-schema-overhead.md +++ b/docs/mcp-schema-overhead.md @@ -16,11 +16,11 @@ Written 2026-09-01, verified against `@modelcontextprotocol/sdk` 1.29.0. Numbers | Profile | Tools | Context | |---------|-------|---------| -| `full` *(default)* | 19 | 12,657 | -| `trading` | 8 | 5,160 | -| `media` | 7 | 5,603 | -| `research` | 5 | 2,635 | -| `chat` | 3 | 1,976 | +| `full` *(default)* | 19 | 12,686 | +| `trading` | 8 | 5,189 | +| `media` | 7 | 5,632 | +| `research` | 5 | 2,664 | +| `chat` | 3 | 2,005 | Descriptions are ~55% of it, input schemas ~40%. `--profile trading` costs 59% less than the default for the same workflow. diff --git a/skills/blockrun/SKILL.md b/skills/blockrun/SKILL.md index 68dfa4e..17c06a6 100644 --- a/skills/blockrun/SKILL.md +++ b/skills/blockrun/SKILL.md @@ -2,7 +2,7 @@ name: blockrun description: | Pay-per-call access to AI models, real-time data, media generation and multi-chain RPC over - x402 micropayments (USDC on Base or Solana). No API keys, no accounts, no subscriptions. + x402 micropayments (USDC on Base or Solana), or a BlockRun account API key. No subscriptions. Start here when you have the BlockRun MCP installed and need to know WHICH tool answers a question, how the wallet works, or how to make a first call for free. TOOLS: blockrun_chat, blockrun_image, blockrun_video, blockrun_music, blockrun_speech, diff --git a/src/mcp-handler.ts b/src/mcp-handler.ts index 60caf61..ba55122 100644 --- a/src/mcp-handler.ts +++ b/src/mcp-handler.ts @@ -1,8 +1,9 @@ // src/mcp-handler.ts import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { BudgetState } from "./types.js"; -import { getClient, getWalletInfo } from "./utils/wallet.js"; -import { loadModels, type ModelCache } from "./utils/model-cache.js"; +import { getChain, getClient, getWalletInfo } from "./utils/wallet.js"; +import { loadModels, modelCacheKey, type ModelCache } from "./utils/model-cache.js"; +import { getAuthMode } from "./utils/auth.js"; import { parseBudgetLimitEnv } from "./utils/budget.js"; import { registerWalletTool } from "./tools/wallet.js"; @@ -125,7 +126,7 @@ export function initializeMcpServer( "blockrun://models", { description: "Available AI models with pricing", mimeType: "application/json" }, async () => { - const models = await loadModels(getClient(), modelCache); + const models = await loadModels(getClient(), modelCache, modelCacheKey(getAuthMode(), getChain())); return { contents: [{ uri: "blockrun://models", diff --git a/src/tools/models.ts b/src/tools/models.ts index d1ad0db..eab12f5 100644 --- a/src/tools/models.ts +++ b/src/tools/models.ts @@ -3,9 +3,10 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { TOOL_ANNOTATIONS } from "../tool-annotations.js"; import { z } from "zod"; import type { ImageModel, Model } from "@blockrun/llm"; -import { getClient } from "../utils/wallet.js"; +import { getChain, getClient } from "../utils/wallet.js"; +import { getAuthMode } from "../utils/auth.js"; import { extractErrorMessage, formatError } from "../utils/errors.js"; -import { loadModels, type ModelCache, type ModelEntry } from "../utils/model-cache.js"; +import { loadModels, modelCacheKey, type ModelCache, type ModelEntry } from "../utils/model-cache.js"; function getModelType(model: ModelEntry): "llm" | "image" { return model.type === "image" || "pricePerImage" in model ? "image" : "llm"; @@ -16,7 +17,10 @@ export function registerModelsTool(server: McpServer, modelCache: ModelCache): v "blockrun_models", { description: "List available AI models with pricing. Use to discover models and compare costs.", - annotations: TOOL_ANNOTATIONS.readOnly, + // readOnlyOpenWorld, not readOnly: this reads the LIVE gateway catalogue + // over the network. openWorldHint describes whether the tool reaches + // outside the process, and this one does. + annotations: TOOL_ANNOTATIONS.readOnlyOpenWorld, inputSchema: { category: z.enum(["all", "chat", "reasoning", "image", "embedding"]).optional().default("all").describe("Filter by category"), provider: z.string().optional().describe("Filter by provider (e.g., 'openai', 'anthropic')"), @@ -24,7 +28,7 @@ export function registerModelsTool(server: McpServer, modelCache: ModelCache): v }, async ({ category, provider }) => { try { - let models = await loadModels(getClient(), modelCache); + let models = await loadModels(getClient(), modelCache, modelCacheKey(getAuthMode(), getChain())); if (provider) { const p = provider.toLowerCase(); diff --git a/src/tools/video.ts b/src/tools/video.ts index d24b26c..9d35de8 100644 --- a/src/tools/video.ts +++ b/src/tools/video.ts @@ -37,8 +37,13 @@ import { // Without that clamp the true worst case is budget + interval + poll timeout, // which at 540s/5s/90s lands 35s PAST a 600s authorization. export const VIDEO_TOTAL_BUDGET_MS = 540_000; -const POLL_INTERVAL_MS = 5_000; -export const VIDEO_POLL_TIMEOUT_MS = 90_000; +export const POLL_INTERVAL_MS = 5_000; +// The gateway's poll route declares `export const maxDuration = 60` (blockrun +// src/app/api/v1/videos/generations/[id]/route.ts), so a poll that has not +// answered in 60s never will — waiting 90 was 30s of the signed authorization's +// window spent on a request the server had already abandoned. The Solana helper +// already capped its poll at the route's own limit; this matches it. +export const VIDEO_POLL_TIMEOUT_MS = 60_000; // Lifetime of the signed payment authorization, in seconds. Exported so the // margin above is asserted against the value the request actually sends, // rather than a literal restated in the test. diff --git a/src/tools/wallet.ts b/src/tools/wallet.ts index 4312243..86a9011 100644 --- a/src/tools/wallet.ts +++ b/src/tools/wallet.ts @@ -46,6 +46,7 @@ Actions: Budget controls: - budget + budget_action:"set" + budget_amount:1.00 → Set global spend cap +- budget + budget_action:"check" (the default) → Report the cap, spend and remaining - budget + budget_action:"clear" → Remove global spend cap Multi-agent orchestration: @@ -65,7 +66,7 @@ Do NOT call this for actual AI queries — use blockrun_chat for that.`, inputSchema: { action: z.enum(["status", "deposit", "setup", "qr", "chain", "budget", "delegate", "revoke", "report"]).optional().default("status").describe("What to do"), chain: z.enum(["base", "solana"]).optional().describe("Target chain for action='chain'. Omit to view the current active chain."), - budget_action: z.enum(["set", "check", "clear"]).optional().describe("Budget action (for action='budget')"), + budget_action: z.enum(["set", "check", "clear"]).optional().describe("Budget action (for action='budget'). Defaults to 'check', which only reports."), budget_amount: z.number().optional().describe("Budget limit in USD (for budget_action='set')"), agent_id: z.string().optional().describe("Agent identifier for delegate/revoke/report actions"), agent_limit: z.number().optional().describe("Budget limit in USD for this agent (required for delegate action)"), diff --git a/src/utils/model-cache.ts b/src/utils/model-cache.ts index b208ca2..b18da05 100644 --- a/src/utils/model-cache.ts +++ b/src/utils/model-cache.ts @@ -2,7 +2,13 @@ import type { ImageModel, Model } from "@blockrun/llm"; export type ModelEntry = Model | ImageModel; -export type ModelCache = { models: ModelEntry[] | null }; +export type ModelCache = { + models: ModelEntry[] | null; + /** Which rail+chain the cached list came from — see loadModels. */ + key?: string; + /** In-flight fetch, so concurrent callers share one request. */ + inflight?: Promise; +}; type ModelLister = { listModels: () => Promise; @@ -15,15 +21,45 @@ const CACHE_TTL_MS = 5 * 60 * 1000; // unref'd so it never keeps the stdio process alive after work is done. Both the // models tool and the models resource call through here so the fetch + TTL logic // lives in one place. -export async function loadModels(llm: ModelLister, cache: ModelCache): Promise { +export async function loadModels( + llm: ModelLister, + cache: ModelCache, + /** + * Which catalogue this list belongs to. The two gateways do NOT serve the + * same one (measured 2026-09-09: 78 chat models on Base, 83 on Solana), and + * the account rail is a third. Without a key, `blockrun_wallet action:"chain"` + * left the previous chain's catalogue in place for the rest of the 5-minute + * TTL, so blockrun_models answered for a gateway the user had just left. + * Callers pass the active rail+chain; a changed key re-fetches. + */ + key = "default", +): Promise { // Treat an empty array as "not loaded" too: `![]` is false, so a transient // empty upstream result would otherwise be pinned as a valid cache for the // whole TTL ("Models (0):") and never re-fetched even after recovery. - if (cache.models === null || cache.models.length === 0) { - cache.models = llm.listAllModels - ? await llm.listAllModels() - : await llm.listModels(); - setTimeout(() => { cache.models = null; }, CACHE_TTL_MS).unref(); + const stale = cache.models === null || cache.models.length === 0 || cache.key !== key; + if (!stale) return cache.models as ModelEntry[]; + + // Share one request between concurrent callers (the tool and the resource can + // both be answering at once). Keyed with the fetch so a chain switch mid-flight + // does not adopt the wrong catalogue. + if (!cache.inflight || cache.key !== key) { + cache.key = key; + cache.inflight = (llm.listAllModels ? llm.listAllModels() : llm.listModels()) + .then((models) => { + // Only publish if nobody switched rails while we were waiting. + if (cache.key === key) { + cache.models = models; + setTimeout(() => { if (cache.key === key) cache.models = null; }, CACHE_TTL_MS).unref(); + } + return models; + }) + .finally(() => { if (cache.key === key) cache.inflight = undefined; }); } - return cache.models; + return cache.inflight; +} + +/** The catalogue key for the active rail+chain. */ +export function modelCacheKey(mode: string, chain: string): string { + return mode === "api-key" ? "account" : `wallet:${chain}`; } diff --git a/test/video-models.test.ts b/test/video-models.test.ts index 32d3a84..d6c5d9a 100644 --- a/test/video-models.test.ts +++ b/test/video-models.test.ts @@ -52,6 +52,7 @@ const { SEEDANCE_RESOLUTIONS, VIDEO_TOTAL_BUDGET_MS, VIDEO_POLL_TIMEOUT_MS, + POLL_INTERVAL_MS, VIDEO_PAYMENT_AUTH_SECONDS, } = await import("../src/tools/video.js"); @@ -352,9 +353,14 @@ test("video polling allows slow 30s jobs while staying inside payment authorizat ); // The clamp is load-bearing, not cosmetic: unclamped, the real worst case is - // budget + interval + poll timeout, which overruns the authorization. + // budget + interval + poll timeout, which overruns the authorization. Stated + // with the interval included, because that is the actual worst case — a poll + // is ENTERED after the sleep, so the sleep is part of the overrun. (Written + // as > authMs against a 90s poll timeout, where the timeout alone cleared it; + // at the gateway route's own 60s cap it is the interval that carries the + // margin, which is exactly why the clamp cannot be dropped.) assert.ok( - VIDEO_TOTAL_BUDGET_MS + VIDEO_POLL_TIMEOUT_MS > authMs, + VIDEO_TOTAL_BUDGET_MS + POLL_INTERVAL_MS + VIDEO_POLL_TIMEOUT_MS > authMs, "unclamped worst case would overrun the authorization — do not remove pollTimeoutFor", ); }); From 1034bb95d9d0abf2eae9a35bea1645e6e649d0b4 Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:30:59 -0500 Subject: [PATCH 03/37] fix(polymarket): say what the approval is WORTH before it is signed, not only what it is for The setup prompt explained the purpose ('settle YOUR signed orders from the deposit wallet') and never the size: the default grants an UNLIMITED pUSD allowance to four collateral spenders plus all-or-nothing ERC-1155 operator rights to five contracts. That is standard for Polymarket and it is exactly the kind of thing to state before the signature rather than after. POLYMARKET_BOUNDED_APPROVALS has existed all along and nothing surfaced it at the moment of consent; the prompt now names the amount on both branches and points at the bound, with a test pinning both wordings. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg --- src/utils/polymarket/setup.ts | 22 +++++++++++++++- test/polymarket-setup-rotation.test.ts | 35 ++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/utils/polymarket/setup.ts b/src/utils/polymarket/setup.ts index 85630eb..99bfdac 100644 --- a/src/utils/polymarket/setup.ts +++ b/src/utils/polymarket/setup.ts @@ -358,6 +358,7 @@ async function runSetupDepositWallet(opts: { confirm: boolean }): Promise<{ text } const geo = await geoblockLine(); + const boundedApprovalUsd = getBoundedApprovalsUsd(); const ready = deployed && balance > 0 && !approvalsPending && credsReady; const lines = [ @@ -383,7 +384,26 @@ async function runSetupDepositWallet(opts: { confirm: boolean }): Promise<{ text ``, ` ${missing.length} approval(s) needed. This authorizes Polymarket's exchange`, ` contracts to settle YOUR signed orders from the deposit wallet (gasless`, - ` batch via the relayer). Re-run action:"setup" with confirm:true to sign.`, + ` batch via the relayer).`, + // Say the SIZE of the grant, not only its purpose. The default is an + // unlimited pUSD allowance to four spenders plus all-or-nothing + // ERC-1155 operator rights to five — standard for Polymarket, and + // exactly the kind of thing to state BEFORE the signature rather than + // after. POLYMARKET_BOUNDED_APPROVALS has existed all along; nothing + // surfaced it at the moment of consent. + ...(boundedApprovalUsd === null + ? [ + ` Amount: UNLIMITED pUSD allowance (the Polymarket default) to the four`, + ` collateral spenders, plus operator rights on your outcome tokens to five`, + ` contracts. Set POLYMARKET_BOUNDED_APPROVALS= to cap the pUSD side`, + ` instead (the ERC-1155 operator grant is all-or-nothing either way).`, + ] + : [ + ` Amount: pUSD allowance capped at $${boundedApprovalUsd.toFixed(2)} per spender`, + ` (POLYMARKET_BOUNDED_APPROVALS), plus operator rights on your outcome`, + ` tokens, which are all-or-nothing.`, + ]), + ` Re-run action:"setup" with confirm:true to sign.`, ] : []), ...(approvalsUnverified diff --git a/test/polymarket-setup-rotation.test.ts b/test/polymarket-setup-rotation.test.ts index e290437..0debaa8 100644 --- a/test/polymarket-setup-rotation.test.ts +++ b/test/polymarket-setup-rotation.test.ts @@ -134,3 +134,38 @@ test("same signer, same vault: the persisted flag is still trusted (no extra rel assert.ok(!saveStateCalls.some((p) => p.deployed === false), "the flag must not be reset for the same vault"); assert.equal(stateFile.deployed, true); }); + +// --- the size of the grant is stated before the signature, not after --- +// +// The prompt said what the approvals are FOR ("settle YOUR signed orders") and +// never what they are WORTH: the default is an unlimited pUSD allowance to four +// spenders. POLYMARKET_BOUNDED_APPROVALS could always cap it; nothing surfaced +// that at the moment of consent. + +test("the pending-approval prompt states the allowance amount and how to bound it", async () => { + const { getBoundedApprovalsUsd } = await import("../src/utils/polymarket/constants.js"); + const saved = process.env.POLYMARKET_BOUNDED_APPROVALS; + try { + delete process.env.POLYMARKET_BOUNDED_APPROVALS; + assert.equal(getBoundedApprovalsUsd(), null, "unset means unlimited — the default this text must disclose"); + + process.env.POLYMARKET_BOUNDED_APPROVALS = "250"; + assert.equal(getBoundedApprovalsUsd(), 250); + + // Garbage must not silently read as a bound the prompt would then claim. + for (const bad of ["0", "-5", "abc", ""]) { + process.env.POLYMARKET_BOUNDED_APPROVALS = bad; + assert.equal(getBoundedApprovalsUsd(), null, `"${bad}" must fall back to unlimited`); + } + } finally { + if (saved === undefined) delete process.env.POLYMARKET_BOUNDED_APPROVALS; + else process.env.POLYMARKET_BOUNDED_APPROVALS = saved; + } + + // The disclosure itself lives in the setup report; pin both branches' wording + // so a future edit cannot quietly drop the amount again. + const src = await import("node:fs").then(fs => fs.readFileSync(new URL("../src/utils/polymarket/setup.ts", import.meta.url), "utf8")); + assert.match(src, /UNLIMITED pUSD allowance/, "the unlimited branch must name it"); + assert.match(src, /POLYMARKET_BOUNDED_APPROVALS= to cap/, "and point at the bound"); + assert.match(src, /capped at \$\$\{boundedApprovalUsd\.toFixed\(2\)\} per spender/, "the bounded branch must state the cap"); +}); From 737e094310c8c8ef39296d73c5526becb04ce33f Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:45:59 -0500 Subject: [PATCH 04/37] fix(wallet): two regressions from 0.49.0's own Solana provisioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by the round-2 audit, which exists because each round must regression-hunt the last one's fixes. Both are about a funded wallet. - The continuity pin was written by asking getChain again after provisioning and comparing. Under BLOCKRUN_KEYCHAIN=strict that question cannot be answered at that point: minting stores the key and DELETES .solana-session, so the file check misses and the keychain probe returns the value memoised before the mint. It answered "base" both times, no pin was written, and the next start found the stored key and moved a funded Base user onto an empty Solana wallet — the 0.32.3 failure CHAIN_AUTO_FILE exists to prevent. The pin is now written off the provisioning FACT (did THIS call mint the other chain's wallet), which is local and cache-free, and the probes are dropped after a mint so a same-process reader is honest too. - ensureSolanaWallet assigned its cache only after awaiting createSolanaWallet, so two overlapping callers both minted; 0.49.0 made that reachable from two entry points at once (the blockrun://wallet resource and action:"setup"), and last-writer-wins means one caller gets a funding QR for an address whose key was discarded. Single-flighted — but the rejection is deliberately NOT cached, or unlocking a keychain and retrying would stay broken until restart, which is the poisoning 0.49.0 removed when it stopped memoising a miss. Both were untested: chain-precedence runs with the keychain off and keychain-precedence mocks persistKey to a no-op, so neither could see the strict delete. Six new tests cover the strict-mode pin, the fresh-install direction, the no-mint case, concurrent callers, and a rejection that must not stick. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg --- src/utils/wallet.ts | 55 ++++++- test/solana-provisioning-regressions.test.ts | 152 +++++++++++++++++++ 2 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 test/solana-provisioning-regressions.test.ts diff --git a/src/utils/wallet.ts b/src/utils/wallet.ts index a086a13..3cd5ebd 100644 --- a/src/utils/wallet.ts +++ b/src/utils/wallet.ts @@ -248,6 +248,13 @@ export async function ensureBothWallets(): Promise<{ // already wins in getChain() and must not be overwritten. const chainBefore = readChainPreference() === null ? getChain() : null; + // Whether THIS call provisions, not whether the cached object still carries + // the isNew flag from an earlier one. Both caches freeze isNew for the life of + // the process, so a second ensureBothWallets() would otherwise look like a + // second mint — and the pin below is written off exactly that fact. + const evmWasCached = _evmWalletInfo !== null; + const solWasCached = _solanaWalletInfo !== null; + const evm = ensureEvmWallet(); // 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 @@ -258,12 +265,31 @@ export async function ensureBothWallets(): Promise<{ // refuses to mint when the keychain could not be read (audit 2026-09-08). const sol = await ensureSolanaWallet(); - if (chainBefore !== null && getChain() !== chainBefore) { + // Pin on the PROVISIONING FACT, not on a re-derived getChain(). + // + // The old form asked getChain() again and wrote the pin only if the answer + // had moved. Under BLOCKRUN_KEYCHAIN=strict that question cannot be answered + // correctly at this point: minting the Solana wallet stores the key in the + // keychain and DELETES .solana-session, so getChain()'s file check misses and + // its keychain probe returns the value memoised before the mint. It answered + // "base" both times, no pin was written, and on the next start the probe + // re-ran, found the new key and moved a funded Base user onto an empty Solana + // wallet — the exact 0.32.3 failure CHAIN_AUTO_FILE exists to prevent. + // + // Minting the OTHER chain's wallet is the whole reason continuity is at risk, + // and that fact is local, cache-free and true on both platforms. + const solMinted = !solWasCached && sol.isNew; + const evmMinted = !evmWasCached && evm.isNew; + if (chainBefore !== null && ((solMinted && chainBefore === "base") || (evmMinted && chainBefore === "solana"))) { // writeAutoChain, NOT setChain: this is the machine preserving continuity, // not the user expressing a preference. The distinction is the whole fix — // see CHAIN_AUTO_FILE. writeAutoChain(chainBefore); } + // The probes were memoised before the mint, so at least one of them is now a + // lie for the rest of the process. The pin above already outranks them in + // getChain(); dropping them keeps a same-process reader honest anyway. + if (solMinted || evmMinted) resetKeychainProbeCache(); return { base: { address: evm.address, isNew: evm.isNew }, @@ -497,6 +523,7 @@ export function resolveSolanaKey(): string | undefined { } let _solanaWalletInfo: { address: string; privateKey: string; isNew: boolean } | null = null; +let _solanaWalletPromise: Promise<{ address: string; privateKey: string; isNew: boolean }> | null = null; /** * The Solana twin of ensureEvmWallet(): return the existing wallet from @@ -505,8 +532,31 @@ let _solanaWalletInfo: { address: string; privateKey: string; isNew: boolean } | * 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 }> { +export async function ensureSolanaWallet(): Promise { if (_solanaWalletInfo) return _solanaWalletInfo; + // Single-flight. The cache is only assigned AFTER `await createSolanaWallet()`, + // so two overlapping callers both saw null and both minted — and 0.49.0 made + // that reachable from two entry points at once: the read-only + // blockrun://wallet resource and blockrun_wallet action:"setup". Last writer + // wins in the file and the keychain, so one caller walks away with a funding + // QR for an address whose key was discarded. + // + // A rejection is deliberately NOT cached: memoising the keychain-error throw + // below would leave a user who unlocks their keychain and retries broken until + // restart — the same poisoning 0.49.0 removed when it stopped memoising a MISS + // (see resolveSolanaKeyDetailed, and the tests that pin it). + if (!_solanaWalletPromise) { + _solanaWalletPromise = provisionSolanaWallet().catch((err) => { + _solanaWalletPromise = null; + throw err; + }); + } + return _solanaWalletPromise; +} + +type SolanaWalletInfo = { address: string; privateKey: string; isNew: boolean }; + +async function provisionSolanaWallet(): Promise { const { key, keychainError } = resolveSolanaKeyDetailed(); if (key) { _solanaWalletInfo = { address: await solanaPublicKey(key), privateKey: key, isNew: false }; @@ -535,6 +585,7 @@ export async function ensureSolanaWallet(): Promise<{ address: string; privateKe export function resetSolanaKeyCache(): void { _solanaKey = undefined; _solanaWalletInfo = null; + _solanaWalletPromise = null; } /** diff --git a/test/solana-provisioning-regressions.test.ts b/test/solana-provisioning-regressions.test.ts new file mode 100644 index 0000000..277c592 --- /dev/null +++ b/test/solana-provisioning-regressions.test.ts @@ -0,0 +1,152 @@ +// Run with: npm test (tsx --experimental-test-module-mocks --test) +// +// Two regressions the 0.49.0 keychain-aware Solana provisioning introduced, +// found by the round-2 audit. Both are about a FUNDED wallet. +// +// 1. Under BLOCKRUN_KEYCHAIN=strict the mint stores the key and deletes +// .solana-session, so the post-provision getChain() sees neither the file +// nor a fresh keychain probe (it was memoised before the mint) and answers +// "base" twice. No .chain-auto pin was written, and the NEXT start found the +// stored key and moved a funded Base user onto an empty Solana wallet — the +// 0.32.3 failure CHAIN_AUTO_FILE exists to prevent. +// +// 2. The wallet cache is assigned after `await createSolanaWallet()`, so two +// overlapping callers both minted. 0.49.0 made that reachable from two +// entry points at once (the blockrun://wallet resource and action:"setup"), +// and last-writer-wins means one caller is handed a funding address whose +// key was thrown away. +import { test, mock, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const home = fs.mkdtempSync(path.join(os.tmpdir(), "br-sol-prov-")); +const blockrunDir = path.join(home, ".blockrun"); +fs.mkdirSync(blockrunDir, { recursive: true }); +const saved = { HOME: process.env.HOME, KC: process.env.BLOCKRUN_KEYCHAIN, SOL: process.env.SOLANA_WALLET_KEY, EVM: process.env.BLOCKRUN_WALLET_KEY, API: process.env.BLOCKRUN_API_KEY }; +process.env.HOME = home; +delete process.env.SOLANA_WALLET_KEY; +delete process.env.BLOCKRUN_API_KEY; + +// A keychain that behaves like the real one in strict mode: persistKey stores +// the secret AND deletes the plaintext file, which is the step that blinds +// getChain() to the wallet that was just created. +let mode = "strict"; +let readFails = false; +const store = new Map(); +mock.module("../src/utils/keychain.js", { + namedExports: { + EVM_KEY_ACCOUNT: "evm-wallet-key", + SOLANA_KEY_ACCOUNT: "solana-wallet-key", + getKeychainMode: () => mode, + keychainLoad: (a: string) => store.get(a) ?? null, + keychainRead: (a: string) => + readFails + ? { status: "error", detail: "security exit 51" } + : store.has(a) + ? { status: "found", value: store.get(a) } + : { status: "absent" }, + persistKey: (account: string, key: string, file?: string) => { + store.set(account, key); + if (mode === "strict" && file && fs.existsSync(file)) fs.rmSync(file, { force: true }); + }, + }, +}); + +const wallet = await import("../src/utils/wallet.js"); + +process.on("exit", () => { + for (const [k, v] of Object.entries({ HOME: saved.HOME, BLOCKRUN_KEYCHAIN: saved.KC, SOLANA_WALLET_KEY: saved.SOL, BLOCKRUN_WALLET_KEY: saved.EVM, BLOCKRUN_API_KEY: saved.API })) { + if (v === undefined) delete process.env[k]; else process.env[k] = v; + } + fs.rmSync(home, { recursive: true, force: true }); +}); + +beforeEach(() => { + for (const f of [".chain", ".chain-auto", ".solana-session", ".session"]) fs.rmSync(path.join(blockrunDir, f), { force: true }); + store.clear(); + mode = "strict"; + readFails = false; + wallet.resetSolanaKeyCache(); + wallet.resetEvmWalletCache(); + wallet.resetKeychainProbeCache(); +}); + +test("strict mode: provisioning Solana for a Base user writes the continuity pin", async () => { + // An existing Base-only user: a key file on disk, no chain preference. + fs.writeFileSync(path.join(blockrunDir, ".session"), "0x" + "11".repeat(32), { mode: 0o600 }); + assert.equal(wallet.getChain(), "base", "precondition: this user is on Base"); + + const both = await wallet.ensureBothWallets(); + assert.equal(both.solana.isNew, true, "precondition: the Solana wallet was minted here"); + + // The mint stored the key and (strict) deleted the file, so a later probe + // would see a Solana key and move the user. The pin has to outrank it. + assert.equal(fs.existsSync(path.join(blockrunDir, ".chain-auto")), true, "continuity pin must be written"); + wallet.resetKeychainProbeCache(); + wallet.resetSolanaKeyCache(); + assert.equal(wallet.getChain(), "base", "a funded Base user must NOT be moved by provisioning"); +}); + +test("a fresh install keeps its Solana default against the Base wallet it just minted", async () => { + // Both wallets are new. getChain() answered "solana" (the fresh-install + // default) BEFORE provisioning, and minting the EVM wallet is exactly what + // would flip it to "base" on the next start, so the pin belongs here too — + // the direction is just reversed. + const both = await wallet.ensureBothWallets(); + assert.equal(both.base.isNew, true); + assert.equal(both.solana.isNew, true); + assert.equal(fs.readFileSync(path.join(blockrunDir, ".chain-auto"), "utf-8").trim(), "solana"); + + wallet.resetKeychainProbeCache(); + wallet.resetSolanaKeyCache(); + wallet.resetEvmWalletCache(); + assert.equal(wallet.getChain(), "solana", "the default the user started on must survive provisioning"); +}); + +test("no pin when nothing was minted — a second run does not rewrite it", async () => { + fs.writeFileSync(path.join(blockrunDir, ".session"), "0x" + "11".repeat(32), { mode: 0o600 }); + await wallet.ensureBothWallets(); + fs.rmSync(path.join(blockrunDir, ".chain-auto"), { force: true }); + + // Everything already exists now, so the second call mints nothing and must + // not write a pin off a stale probe. + await wallet.ensureBothWallets(); + assert.equal(fs.existsSync(path.join(blockrunDir, ".chain-auto")), false, "nothing minted, nothing to preserve"); +}); + +test("an explicit chain preference is never overwritten by provisioning", async () => { + fs.writeFileSync(path.join(blockrunDir, ".chain"), "solana"); + await wallet.ensureBothWallets(); + assert.equal(fs.existsSync(path.join(blockrunDir, ".chain-auto")), false, "an explicit .chain already wins"); + assert.equal(wallet.getChain(), "solana"); +}); + +test("concurrent callers mint ONE Solana wallet, not one each", async () => { + const [a, b, c] = await Promise.all([ + wallet.ensureSolanaWallet(), + wallet.ensureSolanaWallet(), + wallet.ensureSolanaWallet(), + ]); + assert.equal(a.address, b.address); + assert.equal(b.address, c.address); + // And the address every caller was handed is the key the machine kept. + assert.equal(store.get("solana-wallet-key"), a.privateKey, "the stored key must be the one callers were shown"); + assert.equal(wallet.resolveSolanaKey(), a.privateKey); +}); + +test("a failed provisioning is NOT cached — unlocking the keychain and retrying works", async () => { + // Strict mode with an unreadable keychain and no file: refuse to mint, which + // is 0.49.0's rule. The rejection must not be memoised by the single-flight + // promise, or a user who unlocks and retries stays broken until restart. + readFails = true; + await assert.rejects(wallet.ensureSolanaWallet(), /Refusing to create a new Solana wallet/); + await assert.rejects(wallet.ensureSolanaWallet(), /Refusing to create a new Solana wallet/); + + // Unlock it: the very next call in the SAME process must succeed. + readFails = false; + const info = await wallet.ensureSolanaWallet(); + assert.equal(info.isNew, true); + assert.equal(store.get("solana-wallet-key"), info.privateKey); +}); From 350df272ef7602941ee43363a31eac4a1cd68d61 Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:54:56 -0500 Subject: [PATCH 05/37] fix(media,errors): the quote guard and the give-up booking reach the default chain too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.49.0 taught the media tools two things and taught them per-rail, so Solana — the default chain since 0.46.0 — ended up with neither. Round-2 audit. - blockrun_music (both wallet rails) and blockrun_speech (Base) signed whatever the 402 quoted: no sanity check, no re-reservation against the cap. Music's Solana call passed no onQuote at all, so the helper's guard hook fired against nobody and the SPL transfer was signed for whatever the quote said. Both now run assertQuoteNearEstimate and re-reserve the real amount before signing, the way video and image already did. - Giving up while a paid request may still be in flight booked the charge on Base (paidPollInFlight) and on the account rail (BilledJobError), and nothing on Solana — where the shared helper's own message says a poll in flight at the deadline can settle server-side. A settled Solana render therefore moved no budget at all and the caller was invited to pay for it again. Both tools now book the quote conservatively there and say the charge MAY have gone through, instead of the Base-only "No payment was taken". - formatError told users to fund their wallet on messages that say in the same breath that nothing was charged: the uncharged markers gated only the "payment" keyword clause, so a bare 402, "balance" or "insufficient" still earned the footer. Two of this repo's own messages did it — the video tool's unreadable-quote refusal (a wallet holding $1,000 told to top up) and RealFace's "No payment taken", which was not even in the marker list. The markers now cover every phrasing the four authors use and gate the whole branch. A genuinely empty wallet still gets the advice. Ten new tests: five for Solana rail parity, five for the funding footer, including negative controls on both sides. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg --- src/tools/music.ts | 52 ++++++++++++-- src/tools/speech.ts | 20 +++++- src/tools/video.ts | 27 +++++-- src/utils/errors.ts | 22 +++++- test/errors.test.ts | 39 ++++++++++ test/solana-rail-parity.test.ts | 123 ++++++++++++++++++++++++++++++++ 6 files changed, 270 insertions(+), 13 deletions(-) create mode 100644 test/solana-rail-parity.test.ts diff --git a/src/tools/music.ts b/src/tools/music.ts index 648d469..c9d3af0 100644 --- a/src/tools/music.ts +++ b/src/tools/music.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"; @@ -178,6 +178,23 @@ Returns a permanent BlockRun-hosted URL.`, const { solanaPaidAsyncPost } = await import("../utils/solana-402.js"); const { data, paidUsd, txHash } = await solanaPaidAsyncPost("/v1/audio/generations", body, { pollBudgetMs: MUSIC_POLL_BUDGET_MS, + // The helper offers this hook and music passed nothing, so the + // guard fired against no one and the SPL transfer was signed for + // whatever the quote said (audit round 2). + onQuote: (solQuotedUsd, quoteDetails) => { + // Captured for the give-up path: on Solana the quote is only ever + // seen inside the helper. + quotedUsd = solQuotedUsd; + assertQuoteNearEstimate(solQuotedUsd, MUSIC_COST, { + what: `${model} music`, + quotedFor: quoteDetails?.resource?.description, + hint: `Retry on Base (blockrun_wallet action:"chain" chain:"base"), or report the quote.`, + }); + if (solQuotedUsd === null || solQuotedUsd <= MUSIC_COST) return; + gate?.release(); + gate = reserveBudget(budget, agent_id, solQuotedUsd); + if (!gate.allowed) throw new Error(`${gate.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget. No charge was made.`); + }, }); // Book before validating the payload: a malformed completed body must // not make a settled charge vanish from the local ledger. @@ -211,6 +228,23 @@ Returns a permanent BlockRun-hosted URL.`, const details = extractPaymentDetails(paymentRequired); quotedUsd = amountToUsd(details.amount); + // WHAT was quoted, before how much. 0.49.0 added this to video (both + // rails) and image (Solana) and left the identical hand-rolled flows + // here unguarded — so a gateway that quotes a different product, the + // way sol.blockrun.ai quoted azure/sora-2 as Seedance at 2.7x, was + // signed unseen. Refusing costs nothing: nothing is signed yet. + assertQuoteNearEstimate(quotedUsd, MUSIC_COST, { + what: `${model} music`, + quotedFor: details.resource?.description, + hint: `Retry on Solana (blockrun_wallet action:"chain" chain:"solana"), or report the quote.`, + }); + // And the cap, against the REAL price rather than the estimate. + if (quotedUsd !== null && quotedUsd > MUSIC_COST) { + gate?.release(); + gate = reserveBudget(budget, agent_id, quotedUsd); + if (!gate.allowed) throw new Error(`${gate.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget. No charge was made.`); + } + // validBefore is counted from HERE, so the authorization deadline has to // be stamped here too — not after submit, which can burn up to 95s. const signedAt = Date.now(); @@ -420,10 +454,20 @@ Returns a permanent BlockRun-hosted URL.`, isError: true, }; } + // Solana gives up the same way: the shared helper's own message says + // a poll still in flight at the deadline can settle server-side. + // 0.49.0 booked that on Base and on the account rail and left the + // DEFAULT chain booking nothing (audit round 2). + if (!isApiKeyMode() && getChain() === "solana") { + recordActualSpend(budget, quotedUsd, MUSIC_COST, agent_id); + return { + content: [{ type: "text", text: `Music generation timed out on Solana. A poll still in flight at the deadline can settle server-side, so the charge MAY have gone through — check blockrun_wallet action:"report" or the wallet's recent transactions before retrying.${reclaim}\nError: ${errMsg}` }], + isError: true, + }; + } // On Base, settlement happens only on a response the gateway sends - // as settled; the last one was not, so nothing settled. The Solana - // helper describes its own money state in errMsg. - const base = !isApiKeyMode() && getChain() !== "solana"; + // as settled; the last one was not, so nothing settled. + const base = !isApiKeyMode(); return { content: [{ type: "text", text: `Music generation timed out.${base ? ` No payment was taken.${reclaim}` : ""}\nError: ${errMsg}` }], isError: true, diff --git a/src/tools/speech.ts b/src/tools/speech.ts index 27b8a47..82843e9 100644 --- a/src/tools/speech.ts +++ b/src/tools/speech.ts @@ -13,7 +13,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"; @@ -269,6 +269,24 @@ Returns a hosted audio URL — download immediately if you need to keep the file // server-side price change) over the local estimate for billing + display. billedUsd = amountToUsd(details.amount) ?? cost; + // WHAT was quoted, before how much. 0.49.0 added this to video (both + // rails) and image (Solana) and left the identical hand-rolled flows + // here unguarded — so a gateway that quotes a different product, the + // way sol.blockrun.ai quoted azure/sora-2 as Seedance at 2.7x, was + // signed unseen. Refusing costs nothing: nothing is signed yet. + assertQuoteNearEstimate(billedUsd, cost, { + what: `${model} speech`, + quotedFor: details.resource?.description, + hint: `Retry on Solana (blockrun_wallet action:"chain" chain:"solana"), or report the quote.`, + }); + // And the cap, against the REAL price rather than the estimate. + const quotedUsd = amountToUsd(details.amount); + if (quotedUsd !== null && quotedUsd > cost) { + gate?.release(); + gate = reserveBudget(budget, agent_id, quotedUsd); + if (!gate.allowed) throw new Error(`${gate.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget. No charge was made.`); + } + const paymentPayload = await createPaymentPayload( privateKey, account.address, diff --git a/src/tools/video.ts b/src/tools/video.ts index 9d35de8..6b1fb01 100644 --- a/src/tools/video.ts +++ b/src/tools/video.ts @@ -534,13 +534,17 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC body, { pollBudgetMs: SOLANA_VIDEO_TOTAL_BUDGET_MS, - onQuote: (quotedUsd, quoteDetails) => { + onQuote: (solQuotedUsd, quoteDetails) => { + // Capture for the give-up path below: on Solana the quote is + // only ever seen inside the helper, and the catch needs it to + // book conservatively. + quotedUsd = solQuotedUsd; // 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; + assertVideoQuoteSane(solQuotedUsd, estimatedCost, selectedModel, "solana", quoteDetails?.resource?.description); + if (solQuotedUsd === null || solQuotedUsd <= estimatedCost) return; gate?.release(); - gate = reserveBudget(budget, agent_id, quotedUsd); + gate = reserveBudget(budget, agent_id, solQuotedUsd); // Phrased so formatError's uncharged guard suppresses its // "fund your wallet" footer — the remedy is the budget, not USDC. if (!gate.allowed) throw new Error(`${gate.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget. No charge was made.`); @@ -876,10 +880,21 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC isError: true, }; } + // Solana gives up the same way Base does with a poll in flight: the + // helper's own message says "a poll still in flight at the deadline + // can settle server-side". 0.49.0 booked that case on Base and on the + // account rail and left the DEFAULT chain booking nothing — a settled + // Solana render then moved no budget at all (audit round 2). + if (!isApiKeyMode() && getChain() === "solana") { + recordActualSpend(budget, quotedUsd, estimatedCost, agent_id); + return { + content: [{ type: "text", text: `Video generation timed out on Solana. A poll still in flight at the deadline can settle server-side, so the charge MAY have gone through — check blockrun_wallet action:"report" or the wallet's recent transactions before retrying.${reclaim}\nError: ${errMsg}` }], + isError: true, + }; + } // On Base, settlement happens only on a poll the gateway answers // "completed"; the last one answered otherwise, so nothing settled. - // The Solana helper describes its own money state in errMsg. - const base = !isApiKeyMode() && getChain() !== "solana"; + const base = !isApiKeyMode(); return { content: [{ type: "text", text: `Video generation timed out.${base ? ` No payment was taken.${reclaim}` : ""}\nError: ${errMsg}` }], isError: true, diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 87eacd3..d73ced3 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -108,15 +108,33 @@ export function formatError(message: string, opts?: { altModels?: string }): str const isPostPaymentClientError = msgLower.includes("api error after payment") && /(^|[^0-9.])4[0-9]{2}($|[^0-9.])/.test(msgLower); + // Every way this repo and the gateway say "the money did not move". The list + // is longer than it looks because the sentence is written in five places by + // four authors: the gateway ("payment NOT charged"), the SDK, the manual-402 + // tools ("No payment taken", "no charge was made"), and the quote guard + // ("Refusing to sign it — no charge was made"). const explicitlyUncharged = msgLower.includes("no payment was made") || + msgLower.includes("no payment was taken") || + msgLower.includes("no payment taken") || msgLower.includes("no charge was made") || + msgLower.includes("nothing was charged") || msgLower.includes("not charged"); - const isPaymentError = !isPostPaymentClientError && ( + // …and it gates the WHOLE funding branch, not just the "payment" keyword. + // It used to gate only that sub-clause, so a message carrying a bare 402, the + // word "balance", or "insufficient" still earned "your wallet needs funding" + // while saying in the same breath that nothing was charged. Two of this + // repo's own messages did exactly that: the video tool's unreadable-quote + // refusal ("Refusing to sign a payment for an amount that could not be + // validated — no charge was made") matched the bare 402 and told a wallet + // holding $1,000 to top up, and RealFace's "No payment taken" was not even in + // the marker list. Telling someone to fund a wallet that was never debited is + // the same class of wrong as #132, pointed the other way. + const isPaymentError = !isPostPaymentClientError && !explicitlyUncharged && ( hasStatus("402") || msgLower.includes("balance") || msgLower.includes("insufficient") || - (msgLower.includes("payment") && !hasStatus("500") && !explicitlyUncharged) + (msgLower.includes("payment") && !hasStatus("500")) ); // Upstream model/provider availability, e.g. token360 returns diff --git a/test/errors.test.ts b/test/errors.test.ts index 86cf0c6..20304ba 100644 --- a/test/errors.test.ts +++ b/test/errors.test.ts @@ -248,3 +248,42 @@ test("hasLabelledServerStatus agrees with formatError on the dotted shape", () = assert.equal(hasLabelledServerStatus("charged $500.25"), false); assert.equal(hasLabelledServerStatus("batch of 501 items"), false); }); + +// --- "nothing was charged" must never carry "fund your wallet" (round 2) --- +// +// explicitlyUncharged gated only the `payment` keyword sub-clause, so a bare +// 402, "balance" or "insufficient" still earned the funding footer. Two of this +// repo's own messages did exactly that. + +test("the video tool's unreadable-quote refusal does not tell a funded wallet to top up", () => { + const out = formatError( + "The gateway's 402 quote carried an unreadable amount (\"garbage\"). Refusing to sign a payment " + + "for an amount that could not be validated — no charge was made. This is a gateway fault; retry, " + + "and report it if it persists.", + ); + assert.doesNotMatch(out, /needs funding/); + assert.doesNotMatch(out, /Send USDC/); +}); + +test("RealFace's 'No payment taken' is recognised as uncharged", () => { + const out = formatError("Portrait rejected — the image did not pass the liveness check. No payment taken."); + assert.doesNotMatch(out, /needs funding/); +}); + +test("the quote guard's own refusal does not read as a funding problem", () => { + const out = formatError( + "The gateway quoted $1.1355 for azure/sora-2 video, but this tool expected about $0.4220 " + + "(2.7x the published rate). Refusing to sign it — no charge was made.", + ); + assert.doesNotMatch(out, /needs funding/); +}); + +test("a genuine empty wallet STILL gets funding advice", () => { + for (const msg of [ + "API error: 402 Payment Required", + "Payment rejected: insufficient balance", + "insufficient funds for this call", + ]) { + assert.match(formatError(msg), /needs funding|Send USDC/, msg); + } +}); diff --git a/test/solana-rail-parity.test.ts b/test/solana-rail-parity.test.ts new file mode 100644 index 0000000..21fe5fd --- /dev/null +++ b/test/solana-rail-parity.test.ts @@ -0,0 +1,123 @@ +// Run with: npm test (tsx --experimental-test-module-mocks --test) +// +// 0.49.0 taught the media tools two things and taught them per-rail, which is +// how the DEFAULT chain ended up with neither: +// +// 1. A quote guard before signing (video both rails, image on Solana) — music +// and speech kept signing whatever the 402 said. +// 2. Conservative booking when we give up while a paid request may still be +// in flight (Base via paidPollInFlight, account rail via BilledJobError) — +// the Solana rail booked nothing, so a settled render moved no budget at +// all and the caller was invited to pay for it twice. +// +// Solana has been the default chain since 0.46.0. +import { test, mock, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import type { BudgetState } from "../src/types.js"; + +let quoteUsd: number | null = 0.5; +let giveUp = false; +let onQuoteSeen = 0; +mock.module("../src/utils/wallet.js", { + namedExports: { + getApiBase: () => "https://blockrun.ai/api", + resolveGatewayUrl: (u: string) => u, + getChain: () => "solana", + getOrCreateWalletKey: () => { throw new Error("Base wallet must not be touched on the Solana rail"); }, + getWalletInfo: async () => ({ address: "So1anaTest" }), + }, +}); +mock.module("../src/utils/auth.js", { + namedExports: { isApiKeyMode: () => false, getApiKey: () => undefined, apiAuthHeaders: () => ({}), getApiKeyBase: () => "", PORTAL_CREDITS_URL: "" }, +}); +mock.module("../src/utils/solana-402.js", { + namedExports: { + solanaPaidAsyncPost: async (_e: string, _b: unknown, opts: { onQuote?: (usd: number | null, d?: unknown) => void }) => { + onQuoteSeen++; + // The helper hands the caller the authoritative quote BEFORE signing. + opts.onQuote?.(quoteUsd, { resource: { description: "Seedance 2.0 Pro video generation (5s)" } }); + if (giveUp) { + throw new Error( + "Music generation did not complete within 900s (last status: processing). No settlement receipt was " + + "observed by this client; a poll still in flight at the deadline can settle server-side, so check the " + + "wallet's recent transactions before retrying.", + ); + } + return { data: { data: [{ url: "https://blockrun.ai/media/x.mp3", duration_seconds: 30 }] }, paidUsd: quoteUsd, txHash: "sol-tx" }; + }, + solanaPaidPost: async () => ({ data: {}, paidUsd: 0, txHash: "" }), + }, +}); +mock.module("../src/utils/http.js", { + namedExports: { + fetchWithTimeout: async () => { throw new Error("the Base HTTP route must not be reached"); }, + isTimeoutError: (e: unknown) => e instanceof Error && /did not complete within/.test(e.message), + }, +}); +mock.module("@blockrun/llm", { + namedExports: { createPaymentPayload: async () => "unused", parsePaymentRequired: () => ({}), extractPaymentDetails: () => ({}) }, +}); + +const { registerMusicTool } = await import("../src/tools/music.js"); + +function makeHarness(limit: number | null = null) { + let handler: ((a: Record) => Promise) | undefined; + const server = { registerTool: (_n: string, _c: unknown, h: any) => { handler = h; } } as any; + const budget: BudgetState = { limit, spent: 0, calls: 0, agents: new Map() }; + registerMusicTool(server, budget); + return { call: (a: Record) => handler!(a), budget }; +} +const ARGS = { prompt: "lofi", instrumental: true, model: "minimax/music-2.5+" }; + +beforeEach(() => { quoteUsd = 0.5; giveUp = false; onQuoteSeen = 0; }); + +test("music on Solana now hands the helper an onQuote — the guard fired against nobody before", async () => { + const { call } = makeHarness(); + await call(ARGS); + assert.equal(onQuoteSeen, 1, "the helper was called"); +}); + +test("a Solana quote far above the published rate is refused BEFORE the transfer is signed", async () => { + // The exact 2026-09-08 shape: sol.blockrun.ai quoting a different product. + quoteUsd = 1.135; + const { call, budget } = makeHarness(); + const res = await call(ARGS); + const text = res.content.map((c: any) => c.text).join("\n"); + assert.equal(res.isError, true, text); + assert.match(text, /quoted \$1\.1350/); + assert.match(text, /no charge was made/i); + assert.doesNotMatch(text, /needs funding/i, "a refused quote is not a funding problem"); + assert.equal(budget.spent, 0, "a refused quote settles nothing"); +}); + +test("a quote inside the tolerance still re-reserves against the cap before signing", async () => { + quoteUsd = 0.2; // 1.25x the $0.1595 estimate: real, and allowed + const { call, budget } = makeHarness(0.18); + const res = await call(ARGS); + const text = res.content.map((c: any) => c.text).join("\n"); + assert.equal(res.isError, true, text); + assert.match(text, /budget|limit/i, text); + assert.match(text, /No charge was made/i); + assert.equal(budget.spent, 0); +}); + +test("giving up on Solana books the charge conservatively instead of reporting a free failure", async () => { + quoteUsd = 0.2; + giveUp = true; + const { call, budget } = makeHarness(); + const res = await call(ARGS); + const text = res.content.map((c: any) => c.text).join("\n"); + assert.equal(res.isError, true); + assert.match(text, /MAY have gone through/); + assert.match(text, /action:"report"/); + assert.doesNotMatch(text, /No payment was taken/, "Solana cannot promise that"); + assert.ok(Math.abs(budget.spent - 0.2) < 1e-9, `the quote must be booked: spent=${budget.spent}`); +}); + +test("the happy path still books exactly the settled amount, once", async () => { + quoteUsd = 0.16; + const { call, budget } = makeHarness(); + const res = await call(ARGS); + assert.notEqual(res.isError, true, res.content?.[0]?.text); + assert.ok(Math.abs(budget.spent - 0.16) < 1e-9, `spent=${budget.spent}`); +}); From 776b4a710987f642ee70d6d9dcbf542acbce58e3 Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:02:50 -0500 Subject: [PATCH 06/37] fix(apps,chat,realface): three ways a paid call could be made twice or booked at zero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 audit, the last of the P2s. All three are the same shape: 0.49.0 taught one surface that "we do not know whether it landed" and left its siblings saying "nothing happened". - The order card's stale-amount guard wrote place.disabled on every input event, including while a submit was in flight, so nudging the amount up and back down during the CLOB round-trip re-enabled an ARMED button reading "Submitting…" — one more click placed a second identical real-money order with no confirmation. The card now tracks submitting and outcome-unknown explicitly, disables the amount field during a submit, and re-arms only when the failure says nothing was signed. A throw is transport-level, which is exactly when the order may already be live, so it now warns instead of inviting a retry; a declined consent prompt still restores the card, because that one really did sign nothing. - withSettledCost short-circuited the account rail with no try/catch, so onSettledThrow never fired there: a chat call the gateway accepted, billed and then dropped mid-stream booked $0 and read as a free failure, whose obvious next step is to pay for it again. The account rail now reports a billed failure with the amount unknown, the ledger books the estimate, and the note points at the dashboard for the exact figure. - blockrun_realface had no in-flight tracking on its paid POST on any of the three rails, so an abort after the gateway settled left a real charge unbooked and the reservation released. It gets the same flag and the same wording video and music already had. The order card's two post-failure predicates moved out of the DOM into apps/order-safety.ts and are unit-tested, replacing a test that grepped two string literals out of the minified bundle. 673 tests, typecheck and build clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg --- apps/order-preview.ts | 46 +++++++++++++++++++++++++++++++++----- apps/order-safety.ts | 35 +++++++++++++++++++++++++++++ src/tools/chat.ts | 52 ++++++++++++++++++++++++++++++------------- src/tools/realface.ts | 33 ++++++++++++++++++++++++--- test/apps.test.ts | 48 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 190 insertions(+), 24 deletions(-) create mode 100644 apps/order-safety.ts diff --git a/apps/order-preview.ts b/apps/order-preview.ts index 87a67ce..1248f7c 100644 --- a/apps/order-preview.ts +++ b/apps/order-preview.ts @@ -7,6 +7,7 @@ // prompt and the server's caps (POLYMARKET_MAX_BET_USD, session cap) are // unchanged; this card only replaces the model typing the call. import { $, autoSize, bootApp, el, resultText, setBusy, structured, usd, type ToolResult } from "./shared"; +import { declinedByUser, outcomeIsUnknown } from "./order-safety.js"; interface Preview { dryRun: true; @@ -184,16 +185,27 @@ function renderPreview(p: Preview): void { // allowed while the field still equals the quoted amount; a change disarms // and disables Place until Re-quote renders a fresh card. const quotedAmount = parseFloat(amountField.value); + // Submitting, or submitted-with-unknown-outcome. Either way this card must + // not offer Place again: the first is a duplicate in flight, the second is a + // duplicate bet on an order that may already be live at the CLOB. + let submitting = false; + let outcomeUnknown = false; const syncPlace = () => { const stale = parseFloat(amountField.value) !== quotedAmount; if (stale && armed) disarm(); - place.disabled = stale; - place.title = stale ? "Amount changed — Re-quote first" : ""; + // Never re-enable during or after a submit. The stale guard wrote + // `place.disabled = stale` unconditionally on every input event, so + // nudging the amount up and back down while the CLOB round-trip was + // outstanding re-enabled an ARMED button reading "Submitting…" — one more + // click placed a second identical real-money order with no confirmation. + place.disabled = stale || submitting || outcomeUnknown; + place.title = stale ? "Amount changed — Re-quote first" : outcomeUnknown ? "Outcome unknown — check your positions before retrying" : ""; if (stale) { note.className = "note"; note.textContent = "Amount changed — Re-quote first to refresh the price and notional before placing."; } }; amountField.addEventListener("input", syncPlace); place.addEventListener("click", async () => { + if (submitting || outcomeUnknown) return; if (parseFloat(amountField.value) !== quotedAmount) { syncPlace(); return; } if (!armed) { armed = true; @@ -204,12 +216,20 @@ function renderPreview(p: Preview): void { } const args = { action: p.action, ...currentArgs(), confirm: true }; delete (args as Record).side; + submitting = true; setBusy(place, true, "Submitting…"); setBusy(requote, true); cancel.hidden = true; + amountField.disabled = true; try { const r = (await app.callServerTool({ name: "blockrun_polymarket", arguments: args })) as ToolResult; + submitting = false; amountField.disabled = false; if (r.isError) { - note.className = "note err"; note.textContent = resultText(r); - disarm(); setBusy(place, false); setBusy(requote, false); + // A tool-level error is the server's own report, so it knows whether + // anything was signed — and it says so. Only re-arm when it tells us + // nothing landed; otherwise this card must not invite a second bet. + const text = resultText(r); + note.className = "note err"; note.textContent = text; + if (outcomeIsUnknown(text)) { outcomeUnknown = true; setBusy(place, false); setBusy(requote, false); syncPlace(); } + else { disarm(); setBusy(place, false); setBusy(requote, false); } return; } renderPlaced(p, structured(r) ?? {}, resultText(r)); @@ -218,8 +238,22 @@ function renderPreview(p: Preview): void { structuredContent: (r.structuredContent ?? {}) as Record, }).catch(() => {}); } catch (e) { - note.className = "note err"; note.textContent = String((e as Error).message ?? e); - disarm(); setBusy(place, false); setBusy(requote, false); + submitting = false; amountField.disabled = false; + const msg = String((e as Error).message ?? e); + note.className = "note err"; + // A THROW is transport-level — a host/SDK timeout, a dropped connection, + // a torn-down sandbox — which is exactly when the order may already be + // live at the CLOB. Re-arming here reads as "nothing happened, try + // again" and places a duplicate. A refusal at the consent prompt is the + // one case where nothing was signed, and it says so. + if (declinedByUser(msg)) { + note.textContent = msg; + disarm(); setBusy(place, false); setBusy(requote, false); + } else { + outcomeUnknown = true; + note.textContent = `${msg}\n\nThe request did not complete, so this order MAY already be live at the exchange. Check your positions before placing it again — this card will not re-submit it.`; + setBusy(place, false); setBusy(requote, false); syncPlace(); + } } }); } diff --git a/apps/order-safety.ts b/apps/order-safety.ts new file mode 100644 index 0000000..adb3237 --- /dev/null +++ b/apps/order-safety.ts @@ -0,0 +1,35 @@ +// apps/order-safety.ts +// +// The two questions the order card has to answer after a submit fails, split +// out of the DOM so they can be tested as logic rather than grepped out of a +// minified bundle. +// +// Both exist because of the same asymmetry: a card that re-arms after an +// ambiguous failure reads as "nothing happened, try again", and one more click +// is a second real-money order at the CLOB. Re-arming is only safe when we KNOW +// nothing was signed. + +/** + * Did the server's own error say the money did not move? + * + * The server knows whether it signed; when it does, it says so in the wording + * this repo standardised on ("no charge was made", "Nothing withdrawn", …). + * Anything else — a bare failure, a transport error, a timeout — is + * outcome-unknown and must NOT re-arm the card. + */ +export function outcomeIsUnknown(text: string): boolean { + const t = text.toLowerCase(); + const uncharged = + /no charge was made|no payment was made|no payment was taken|no payment taken|nothing was charged|not charged|nothing withdrawn|refusing to sign/.test(t); + return !uncharged; +} + +/** + * Did the user decline a consent prompt? That signs nothing, it is the + * commonest reason the submit throws, and it is the one throw that may safely + * restore the card to its pre-click state. + */ +export function declinedByUser(message: string): boolean { + const m = message.toLowerCase(); + return /declin|denied|cancell?ed|rejected by (the )?user|not approved|permission/.test(m); +} diff --git a/src/tools/chat.ts b/src/tools/chat.ts index c78e0a2..a2e7105 100644 --- a/src/tools/chat.ts +++ b/src/tools/chat.ts @@ -223,10 +223,22 @@ export function estimateChatCost( async function withSettledCost( client: ApiClient, run: () => Promise, - onSettledThrow?: (settledUsd: number) => void, + onSettledThrow?: (settledUsd: number | null) => void, ): Promise<{ result: T; settledUsd: number }> { if (isApiKeyMode()) { - return { result: await run(), settledUsd: 0 }; + // The account rail has no spending delta to read — but it bills the request + // when the gateway ACCEPTS it, so a failure after that point is a billed + // call whose amount this process cannot see. 0.49.0 wired onSettledThrow + // into all three chat paths and then short-circuited here with no + // try/catch, so on the rail where the money is least visible the note never + // fired: a billed-then-dropped stream booked $0 and read as a free failure, + // whose obvious next step is to pay for it again (audit round 2). + try { + return { result: await run(), settledUsd: 0 }; + } catch (error) { + onSettledThrow?.(null); + throw error; + } } const before = client.getSpending().totalUsd; try { @@ -257,11 +269,15 @@ async function withSettledCost( * text, the routing loop's version ended in "your wallet needs funding" — the * exact wrong advice for a call that just paid. */ -function settledThenFailedText(error: unknown, settledUsd: number, tail: string): string { - return ( - `${formatError(extractErrorMessage(error))}\n\nNote: payment had already settled when this failed, ` + - `so the charge stands ($${settledUsd.toFixed(6)}) and it has been recorded against your budget. ${tail}` - ); +function settledThenFailedText(error: unknown, settledUsd: number | null, tail: string): string { + // null = the account rail: billed, amount not visible to this process. + const what = settledUsd === null + ? `Note: the gateway had already accepted and billed this request when it failed, so the charge stands — ` + + `this rail does not report the amount to the client, so the estimate has been recorded against your budget ` + + `and https://user.blockrun.ai/dashboard/activity has the exact figure.` + : `Note: payment had already settled when this failed, ` + + `so the charge stands ($${settledUsd.toFixed(6)}) and it has been recorded against your budget.`; + return `${formatError(extractErrorMessage(error))}\n\n${what} ${tail}`; } const RETRY_CHARGES_AGAIN = 'Retrying will incur a second charge — check blockrun_wallet action:"report" first.'; @@ -397,7 +413,7 @@ Run blockrun_models to see all available models with pricing.`, { role: "user" as const, content: message }, ]; // USDC that left the wallet before the failure, if any (see settledThenFailedText). - let settledOnFailure = 0; + let settledOnFailure: number | null = 0; try { // The SDK types ChatMessage.content as string-only, but the gateway // forwards `messages` verbatim and accepts image_url content arrays @@ -428,6 +444,8 @@ Run blockrun_models to see all available models with pricing.`, }); return r.choices?.[0]?.message?.content || ""; }, (usd) => { + // usd === null: the account rail billed it and does not tell us how + // much. recordActualSpend already books the estimate for null. recordActualSpend(budget, usd, estimatedCost, agent_id); settledOnFailure = usd; }); @@ -441,7 +459,7 @@ Run blockrun_models to see all available models with pricing.`, return { content: [{ type: "text", - text: settledOnFailure > 0 + text: settledOnFailure === null || settledOnFailure > 0 ? settledThenFailedText(error, settledOnFailure, RETRY_CHARGES_AGAIN) : formatError(extractErrorMessage(error)), }], @@ -453,7 +471,7 @@ Run blockrun_models to see all available models with pricing.`, // If specific model provided, use it directly — streamed when the client // supports it (same 524 rationale as the multi-turn path above). if (model) { - let settledOnFailure = 0; + let settledOnFailure: number | null = 0; try { const { result: response, settledUsd } = await withSettledCost(llm(), async () => { const client = llm(); @@ -471,6 +489,8 @@ Run blockrun_models to see all available models with pricing.`, stop, }); }, (usd) => { + // usd === null: the account rail billed it and does not tell us how + // much. recordActualSpend already books the estimate for null. recordActualSpend(budget, usd, estimatedCost, agent_id); settledOnFailure = usd; }); @@ -480,7 +500,7 @@ Run blockrun_models to see all available models with pricing.`, return { content: [{ type: "text", - text: settledOnFailure > 0 + text: settledOnFailure === null || settledOnFailure > 0 ? settledThenFailedText(error, settledOnFailure, RETRY_CHARGES_AGAIN) : formatError(extractErrorMessage(error)), }], @@ -504,7 +524,7 @@ Run blockrun_models to see all available models with pricing.`, let lastError: unknown = null; let deadlineHit = false; // USDC that already left the wallet on a failed attempt in this loop. - let settledOnFailure = 0; + let settledOnFailure: number | null = 0; for (const m of models) { // Stop starting NEW attempts once the loop has burned its whole budget — // otherwise the bound would be per-model only and would grow with the list. @@ -533,7 +553,9 @@ Run blockrun_models to see all available models with pricing.`, }); }, (usd) => { // Settled, then failed. Book it and remember that this tool call has - // already cost the caller money — see the break below. + // already cost the caller money — see the break below. usd === null + // is the account rail: billed, amount not visible to this process, + // and recordActualSpend books the estimate for null. recordActualSpend(budget, usd, estimatedCost, agent_id); settledOnFailure = usd; }); @@ -552,7 +574,7 @@ Run blockrun_models to see all available models with pricing.`, // caller, and continuing would settle a second payment for the same // tool call under the same reserved amount, unbounded by the gate. // Free models settle $0, so mode:"free" still falls through as designed. - if (settledOnFailure > 0) break; + if (settledOnFailure === null || settledOnFailure > 0) break; continue; } } @@ -561,7 +583,7 @@ Run blockrun_models to see all available models with pricing.`, // stands and no fallback was attempted. An agent that reads "failed" as // "free" would retry in a loop and pay each time. (Free models settle $0, // so the deadline case below can never also be a settled one.) - if (settledOnFailure > 0) { + if (settledOnFailure === null || settledOnFailure > 0) { return { content: [{ type: "text", diff --git a/src/tools/realface.ts b/src/tools/realface.ts index 4206565..1eab261 100644 --- a/src/tools/realface.ts +++ b/src/tools/realface.ts @@ -32,6 +32,16 @@ const ENROLLMENT_PRICE_USD = withTxFee(0.01); // 402 (payment), 422 (image rejected, NOT charged) and 2xx apart, and collapsing // those into an exception loses the distinction that decides whether a refund // message is warranted. +/** + * Set for the whole window in which a request carrying a payment (a signature, + * or the account Bearer) is outstanding. The gateway does not stop settling + * because this client disconnected — the property video.ts and music.ts both + * guard with paidPollInFlight / paidRequestInFlight — so an abort here is not + * "no charge", and 0.49.0 gave realface neither the flag nor the wording + * (audit round 2). + */ +let paidRequestInFlight = false; + async function payAndPostJson( path: string, reqBody: string, @@ -39,11 +49,12 @@ async function payAndPostJson( ): Promise<{ status: number; data: Record; settledUsd: number | null }> { // ---- Rail 1: account API key. ---- if (isApiKeyMode()) { + paidRequestInFlight = true; const resp = await fetchWithTimeout(`${getApiBase()}${path}`, { method: "POST", headers: { "Content-Type": "application/json", ...apiAuthHeaders() }, body: reqBody, - }, 90_000); + }, 90_000).finally(() => { paidRequestInFlight = false; }); const data = await resp.json().catch(() => ({})) as Record; // settledUsd null: the account API returns no per-call cost, so callers fall // back to ENROLLMENT_PRICE_USD as an estimate. @@ -54,8 +65,10 @@ async function payAndPostJson( // (probed 2026-09-05: 400 on a missing `name`, i.e. the route is live). ---- if (getChain() === "solana") { const { solanaPaidPost } = await import("../utils/solana-402.js"); + paidRequestInFlight = true; try { - const r = await solanaPaidPost(path, JSON.parse(reqBody) as Record, 90_000); + const r = await solanaPaidPost(path, JSON.parse(reqBody) as Record, 90_000) + .finally(() => { paidRequestInFlight = false; }); return { status: 200, data: r.data as Record, settledUsd: r.paidUsd }; } catch (err) { // solanaPaidPost throws on a non-2xx terminal response. Recover the status @@ -104,6 +117,7 @@ async function payAndPostJson( } ); + paidRequestInFlight = true; const resp = await fetchWithTimeout(url, { method: "POST", headers: { @@ -111,7 +125,7 @@ async function payAndPostJson( "PAYMENT-SIGNATURE": paymentPayload, }, body: reqBody, - }, 90_000); + }, 90_000).finally(() => { paidRequestInFlight = false; }); const data = await resp.json().catch(() => ({})) as Record; return { status: resp.status, data, settledUsd: amountToUsd(details.amount) }; @@ -449,6 +463,19 @@ Privacy: BlockRun does not store face/liveness data — only the asset id, name, isError: true, }; } + if (paidRequestInFlight) { + // The request carrying the payment never answered. The gateway + // settles on its own clock, so this is not "no charge" — book the + // charge conservatively and say what we do and do not know. Same + // trade-off video and music already make: over-counting a request + // that settled nothing is recoverable, under-counting a real charge + // is not. + recordActualSpend(budget, null, ENROLLMENT_PRICE_USD, agent_id); + return { + content: [{ type: "text", text: `RealFace ${action} did not answer while a request carrying the payment was still in flight, so the gateway MAY have settled the charge after this client gave up — check blockrun_wallet action:"report" or the wallet's recent transactions before retrying.\nError: ${errMsg}` }], + isError: true, + }; + } return { content: [{ type: "text", text: formatError(`RealFace ${action} failed: ${errMsg}`) }], isError: true }; } finally { gate?.release(); diff --git a/test/apps.test.ts b/test/apps.test.ts index 0cd27a7..f5bd66b 100644 --- a/test/apps.test.ts +++ b/test/apps.test.ts @@ -98,3 +98,51 @@ test("the order card refuses to place a stale amount and shows the worst fill", assert.ok(order.includes("Re-quote first"), "order card: stale-amount guard text"); assert.ok(order.includes("worstFillPrice"), "order card: renders the server's worst-fill bound"); }); + +// --- the card's post-failure logic, as logic (round 2) --- +// +// These two predicates decide whether the card may offer Place again. Grepping +// a minified bundle cannot test that, and getting it wrong costs a duplicate +// real-money order, so they live outside the DOM. +test("a failure the server says signed nothing may re-arm; anything else may not", async () => { + const { outcomeIsUnknown } = await import("../apps/order-safety.js"); + + // The server knows, and says so, in the wording this repo standardised on. + for (const known of [ + "Insufficient balance. No charge was made.", + "to_address must be a valid 0x… Base address. Nothing withdrawn.", + "The gateway quoted $1.14 … Refusing to sign it — no charge was made.", + "Predexon 500 … (payment NOT charged)", + ]) assert.equal(outcomeIsUnknown(known), false, known); + + // Everything else is ambiguous: the order may be live at the CLOB. + for (const unknown of [ + "MCP error -32001: Request timed out", + "fetch failed", + "socket hang up", + "Order submission failed", + "", + ]) assert.equal(outcomeIsUnknown(unknown), true, unknown); +}); + +test("only a declined consent prompt restores the card to its pre-click state", async () => { + const { declinedByUser } = await import("../apps/order-safety.js"); + for (const declined of [ + "User declined the request", + "Permission denied by the user", + "Request cancelled", + "rejected by user", + ]) assert.equal(declinedByUser(declined), true, declined); + + for (const notDeclined of [ + "MCP error -32001: Request timed out", + "fetch failed", + "CLOB rejected the order: insufficient allowance", + ]) assert.equal(declinedByUser(notDeclined), false, notDeclined); +}); + +test("the built card carries the duplicate-submit guards, not just the stale-amount one", () => { + const order = readAppHtml("orderPreview"); + assert.ok(order.includes("MAY already be live at the exchange"), "ambiguous failure must warn, not re-arm"); + assert.ok(order.includes("Outcome unknown"), "the disabled Place button explains itself"); +}); From e6fc90260e3c6fba25fbc081152ac71a3f763452 Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:15:49 -0400 Subject: [PATCH 07/37] fix(budget): book what the gateway charges, not what we reserved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tx-fee.ts has stated the rule since 0.40.1 — reserve high, book accurate, and do not collapse the two numbers — and exactly one file honoured it. Every path-based tool passed its RESERVE (base + TRANSACTION_FEE_USD, $0.002, rounded against us on purpose) as recordActualSpend's fallback, and on the wallet rail there is never a settled figure to override it, so the ledger booked the reserve on every call. Measured with unauthenticated 402 probes, no payment header: route reserved Base Solana rpc/ethereum (single) $0.0040 $0.0030 $0.0020 pm/* $0.0095 $0.0085 $0.0075 phone/lookup $0.0120 $0.0110 $0.0100 search (max_results=10) $0.2645 $0.2635 $0.2625 On Solana, the default chain since 0.46.0, the gateway charges no transaction fee at all — so that is $0.002 of invented spend per call. An agent delegated a $1.00 cap making only rpc calls was cut off after 250 of them having actually spent $0.50, and action:"report" showed $1.00. The same inflated figure is what the spend-confirmation dialog showed the human. ledgerFallback() in raw-call.ts, next to the rail switch it depends on, converts a reserve into the observed charge for the seven tools that route through it. The gate is untouched and still reserves the higher figure; the result is clamped so it can never exceed the reserve. Found by the round-2 audit's completeness critic, which noted that both prior rounds walked past it. 678 tests, typecheck clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg --- src/tools/defi.ts | 4 +-- src/tools/exa.ts | 4 +-- src/tools/markets.ts | 4 +-- src/tools/modal.ts | 4 +-- src/tools/phone.ts | 4 +-- src/tools/rpc.ts | 4 +-- src/tools/search.ts | 4 +-- src/utils/raw-call.ts | 37 +++++++++++++++++++++++++ test/raw-call.test.ts | 64 +++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 115 insertions(+), 14 deletions(-) diff --git a/src/tools/defi.ts b/src/tools/defi.ts index dca4415..036e3d0 100644 --- a/src/tools/defi.ts +++ b/src/tools/defi.ts @@ -12,7 +12,7 @@ import { reserveBudget, recordSpending, recordActualSpend } from "../utils/budge import { confirmSpend } from "../utils/confirm-spend.js"; import { withTxFee } from "../utils/tx-fee.js"; import { baseOnlyMessage, getClient } from "../utils/wallet.js"; -import { type RawClient, rawGet } from "../utils/raw-call.js"; +import { ledgerFallback, rawGet, type RawClient } from "../utils/raw-call.js"; import { formatError, extractErrorMessage } from "../utils/errors.js"; import { hasPathTraversal } from "../utils/path-safety.js"; import type { BudgetState } from "../types.js"; @@ -85,7 +85,7 @@ Use blockrun_price (free) for plain spot quotes, blockrun_dex (free) for DEX pai if (!confirm.ok) return { content: [{ type: "text", text: confirm.reason ?? "Charge cancelled." }] }; const client = getClient() as unknown as RawClient; const { data: result, paidUsd } = await rawGet(client, `/v1/defillama/${cleanPath}`); - recordActualSpend(budget, paidUsd, estimatedCost, agent_id); + recordActualSpend(budget, paidUsd, ledgerFallback(estimatedCost), agent_id); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], structuredContent: (typeof result === "object" && result !== null && !Array.isArray(result) diff --git a/src/tools/exa.ts b/src/tools/exa.ts index 43b422d..728f8ac 100644 --- a/src/tools/exa.ts +++ b/src/tools/exa.ts @@ -12,7 +12,7 @@ import { confirmSpend } from "../utils/confirm-spend.js"; import { withTxFee } from "../utils/tx-fee.js"; import { asStructuredContent, coerceBody } from "../utils/body.js"; import { getClient } from "../utils/wallet.js"; -import { type RawClient, rawPost } from "../utils/raw-call.js"; +import { ledgerFallback, rawPost, type RawClient } from "../utils/raw-call.js"; import { formatError, extractErrorMessage } from "../utils/errors.js"; import { hasPathTraversal, normalizeClassifyPath } from "../utils/path-safety.js"; import type { BudgetState } from "../types.js"; @@ -84,7 +84,7 @@ Full request/response shapes + worked research workflows in the \`exa-research\` const client = getClient() as unknown as RawClient; const endpoint = `/v1/exa/${cleanPath}`; const { data: result, paidUsd } = await rawPost(client, endpoint, body ?? {}); - recordActualSpend(budget, paidUsd, estimatedCost, agent_id); + recordActualSpend(budget, paidUsd, ledgerFallback(estimatedCost), agent_id); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], structuredContent: asStructuredContent(result), diff --git a/src/tools/markets.ts b/src/tools/markets.ts index 5f6ceea..ba0cae7 100644 --- a/src/tools/markets.ts +++ b/src/tools/markets.ts @@ -5,7 +5,7 @@ import { reserveBudget, recordActualSpend } from "../utils/budget.js"; import { confirmSpend } from "../utils/confirm-spend.js"; import { asStructuredContent, coerceBody } from "../utils/body.js"; import { getClient } from "../utils/wallet.js"; -import { type RawClient, rawGet, rawPost } from "../utils/raw-call.js"; +import { ledgerFallback, rawGet, rawPost, type RawClient } from "../utils/raw-call.js"; import { extractErrorMessage, formatError } from "../utils/errors.js"; import { hasPathTraversal } from "../utils/path-safety.js"; import type { BudgetState } from "../types.js"; @@ -129,7 +129,7 @@ Pass query params via 'params' (GET). Use 'body' only for POST endpoints (e.g. p const { data: result, paidUsd } = body !== undefined ? await rawPost(llm, endpoint, body) : await rawGet(llm, endpoint, params); - recordActualSpend(budget, paidUsd, estimatedCost, agent_id); + recordActualSpend(budget, paidUsd, ledgerFallback(estimatedCost), agent_id); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], diff --git a/src/tools/modal.ts b/src/tools/modal.ts index 7c384e5..724b16e 100644 --- a/src/tools/modal.ts +++ b/src/tools/modal.ts @@ -12,7 +12,7 @@ import { confirmSpend } from "../utils/confirm-spend.js"; import { withTxFee } from "../utils/tx-fee.js"; import { asStructuredContent, coerceBody } from "../utils/body.js"; import { baseOnlyMessage, buildClientWithTimeout } from "../utils/wallet.js"; -import { type RawClient, rawPost } from "../utils/raw-call.js"; +import { ledgerFallback, rawPost, type RawClient } from "../utils/raw-call.js"; import { formatError, extractErrorMessage } from "../utils/errors.js"; import { normalizeClassifyPath } from "../utils/path-safety.js"; import { hasPathTraversal } from "../utils/path-safety.js"; @@ -170,7 +170,7 @@ Full pricing tables + GPU details in the \`modal\` skill.`, const client = buildClientWithTimeout(modalTimeoutMs(body)) as unknown as RawClient; const endpoint = `/v1/modal/${cleanPath}`; const { data: result, paidUsd } = await rawPost(client, endpoint, body ?? {}); - recordActualSpend(budget, paidUsd, estimatedCost, agent_id); + recordActualSpend(budget, paidUsd, ledgerFallback(estimatedCost), agent_id); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], structuredContent: asStructuredContent(result), diff --git a/src/tools/phone.ts b/src/tools/phone.ts index e7f4383..2202506 100644 --- a/src/tools/phone.ts +++ b/src/tools/phone.ts @@ -13,7 +13,7 @@ import { confirmSpend } from "../utils/confirm-spend.js"; import { withTxFee } from "../utils/tx-fee.js"; import { asStructuredContent, coerceBody } from "../utils/body.js"; import { getClient } from "../utils/wallet.js"; -import { type RawClient, rawPost, rawGet } from "../utils/raw-call.js"; +import { ledgerFallback, rawGet, rawPost, type RawClient } from "../utils/raw-call.js"; import { formatError, extractErrorMessage } from "../utils/errors.js"; import { hasPathTraversal, normalizeClassifyPath } from "../utils/path-safety.js"; import type { BudgetState } from "../types.js"; @@ -125,7 +125,7 @@ Voice call flow + voice preset details + full body shapes in the \`phone\` skill // Free phone reads estimate $0 and must stay free; a settled figure // from the account rail is authoritative for everything else. if (estimatedCost > 0 || (paidUsd ?? 0) > 0) { - recordActualSpend(budget, paidUsd, estimatedCost, agent_id); + recordActualSpend(budget, paidUsd, ledgerFallback(estimatedCost), agent_id); } return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], diff --git a/src/tools/rpc.ts b/src/tools/rpc.ts index 92509ab..9c92813 100644 --- a/src/tools/rpc.ts +++ b/src/tools/rpc.ts @@ -15,7 +15,7 @@ import { confirmSpend } from "../utils/confirm-spend.js"; import { withTxFee } from "../utils/tx-fee.js"; import { coerceBody } from "../utils/body.js"; import { getClient } from "../utils/wallet.js"; -import { type RawClient, rawPost } from "../utils/raw-call.js"; +import { ledgerFallback, rawPost, type RawClient } from "../utils/raw-call.js"; import { formatError, extractErrorMessage } from "../utils/errors.js"; import { isValidNetworkSlug } from "../utils/path-safety.js"; import type { BudgetState } from "../types.js"; @@ -96,7 +96,7 @@ Prefer blockrun_price (free quotes) or blockrun_dex (free DEX data) when they co if (!confirm.ok) return { content: [{ type: "text", text: confirm.reason ?? "Charge cancelled." }] }; const client = getClient() as unknown as RawClient; const { data: result, paidUsd } = await rawPost(client, `/v1/rpc/${cleanNetwork}`, body); - recordActualSpend(budget, paidUsd, estimatedCost, agent_id); + recordActualSpend(budget, paidUsd, ledgerFallback(estimatedCost), agent_id); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], structuredContent: (typeof result === "object" && result !== null && !Array.isArray(result) diff --git a/src/tools/search.ts b/src/tools/search.ts index 35ffcfc..9b4e055 100644 --- a/src/tools/search.ts +++ b/src/tools/search.ts @@ -12,7 +12,7 @@ import { reserveBudget, recordSpending, recordActualSpend } from "../utils/budge import { confirmSpend } from "../utils/confirm-spend.js"; import { asStructuredContent, coerceBody } from "../utils/body.js"; import { getClient } from "../utils/wallet.js"; -import { type RawClient, rawPost } from "../utils/raw-call.js"; +import { ledgerFallback, rawPost, type RawClient } from "../utils/raw-call.js"; import { formatError, extractErrorMessage } from "../utils/errors.js"; import { hasPathTraversal } from "../utils/path-safety.js"; import type { BudgetState } from "../types.js"; @@ -105,7 +105,7 @@ Full request shape + worked examples in the \`search\` skill (\`skills/search/SK const client = getClient() as unknown as RawClient; const endpoint = cleanPath ? `/v1/search/${cleanPath}` : "/v1/search"; const { data: result, paidUsd } = await rawPost(client, endpoint, body ?? {}); - recordActualSpend(budget, paidUsd, estimatedCost, agent_id); + recordActualSpend(budget, paidUsd, ledgerFallback(estimatedCost), agent_id); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], structuredContent: asStructuredContent(result), diff --git a/src/utils/raw-call.ts b/src/utils/raw-call.ts index 67b73d8..b83f97a 100644 --- a/src/utils/raw-call.ts +++ b/src/utils/raw-call.ts @@ -17,6 +17,8 @@ import { isApiKeyMode } from "./auth.js"; import { apiKeyGet, apiKeyPost } from "./api-key-call.js"; +import { getChain } from "./wallet.js"; +import { OBSERVED_GATEWAY_TX_FEE_USD, TRANSACTION_FEE_USD } from "./tx-fee.js"; /** The two raw methods every path-based tool already depends on. */ export type RawClient = { @@ -63,3 +65,38 @@ export async function rawPost( } return { data: await client.requestWithPaymentRaw(endpoint, body), paidUsd: null }; } + +/** + * What to BOOK when the rail reports no settled figure. + * + * The gate and the ledger are deliberately different numbers — tx-fee.ts says + * so at length — and until now exactly one file honoured it. Every path tool + * passed its RESERVE (base + TRANSACTION_FEE_USD, 0.002, rounded against us on + * purpose) as recordActualSpend's fallback, and on the wallet rail there is + * never a settled figure to override it, so the ledger booked the reserve. + * + * Measured with unauthenticated 402 probes on 2026-09-09, no payment header: + * + * route reserved Base charge Solana charge + * rpc/ethereum (single) $0.0040 $0.0030 $0.0020 + * pm/* $0.0095 $0.0085 $0.0075 + * phone/lookup $0.0120 $0.0110 $0.0100 + * search (max_results=10) $0.2645 $0.2635 $0.2625 + * + * On Solana — the default chain since 0.46.0 — that is $0.002 of invented spend + * per call: an agent capped at $1.00 making only rpc calls was cut off after 250 + * of them having actually spent $0.50, and action:"report" showed $1.00. The + * account rail bills the base with no fee at all. + * + * Reserving high stays. This only converts a reserve into what the gateway is + * observed to charge, for the LEDGER. + */ +export function ledgerFallback(reservedUsd: number): number { + if (!(reservedUsd > 0)) return 0; + // withTxFee() adds exactly one fee, whatever the route's per-element maths. + const base = Math.max(0, reservedUsd - TRANSACTION_FEE_USD); + if (isApiKeyMode() || getChain() === "solana") return base; + // Never book more than was reserved. Structural, not incidental: a reserve + // smaller than one fee would otherwise book a fee with no base under it. + return Math.min(reservedUsd, base + OBSERVED_GATEWAY_TX_FEE_USD); +} diff --git a/test/raw-call.test.ts b/test/raw-call.test.ts index 6ff0737..64cebc9 100644 --- a/test/raw-call.test.ts +++ b/test/raw-call.test.ts @@ -15,6 +15,14 @@ import { test, mock, beforeEach } from "node:test"; import assert from "node:assert/strict"; let apiKeyMode = false; +let chain: "base" | "solana" = "base"; +mock.module("../src/utils/wallet.js", { + namedExports: { + getChain: () => chain, + getApiBase: () => "https://blockrun.ai/api", + resolveGatewayUrl: (u: string) => u, + }, +}); mock.module("../src/utils/auth.js", { namedExports: { isApiKeyMode: () => apiKeyMode, @@ -67,6 +75,7 @@ beforeEach(() => { accountThrows = null; walletThrows = null; accountResult = { data: { ok: "account" }, paidUsd: 0.0085 }; + chain = "base"; }); test("wallet mode goes through the SDK and NEVER touches the account API", async () => { @@ -158,3 +167,58 @@ test("the rail is decided per call, so switching mid-process routes the next cal assert.equal(walletCalls.length, 1); assert.equal(accountCalls.length, 1); }); + +// --- the ledger books the observed charge, not the reserve (round 2) --- +// +// tx-fee.ts states the rule at length and one file honoured it. Every path tool +// passed its RESERVE (base + $0.002, rounded against us on purpose) as +// recordActualSpend's fallback, and on the wallet rail there is never a settled +// figure to override it — so the ledger booked the reserve. On Solana, where +// the gateway charges no fee at all, that is $0.002 of invented spend per call. + +test("a Solana wallet call books the BASE, not the reserve", async () => { + const { ledgerFallback } = await import("../src/utils/raw-call.js"); + apiKeyMode = false; + chain = "solana"; + // rpc single: reserve $0.004, Solana charges $0.002. + assert.ok(Math.abs(ledgerFallback(0.004) - 0.002) < 1e-9); + // pm/*: reserve $0.0095, Solana charges $0.0075. + assert.ok(Math.abs(ledgerFallback(0.0095) - 0.0075) < 1e-9); +}); + +test("a Base wallet call books the base plus the fee the gateway actually charges", async () => { + const { ledgerFallback } = await import("../src/utils/raw-call.js"); + apiKeyMode = false; + chain = "base"; + assert.ok(Math.abs(ledgerFallback(0.004) - 0.003) < 1e-9, "rpc single: $0.003 on Base"); + assert.ok(Math.abs(ledgerFallback(0.0095) - 0.0085) < 1e-9, "pm/*: $0.0085 on Base"); +}); + +test("the account rail books the base — it charges no transaction fee", async () => { + const { ledgerFallback } = await import("../src/utils/raw-call.js"); + apiKeyMode = true; + assert.ok(Math.abs(ledgerFallback(0.012) - 0.010) < 1e-9, "phone/lookup: $0.010 on the account rail"); +}); + +test("free stays free, and the reserve is never converted into a negative", async () => { + const { ledgerFallback } = await import("../src/utils/raw-call.js"); + assert.equal(ledgerFallback(0), 0); + assert.equal(ledgerFallback(-1), 0); + // A reserve smaller than one fee has no base under it; the result floors at + // zero and is clamped to the reserve, never below zero and never above it. + chain = "solana"; + assert.equal(ledgerFallback(0.001), 0); + chain = "base"; + assert.equal(ledgerFallback(0.001), 0.001); +}); + +test("the ledger figure is never ABOVE the reserve — the gate stays the conservative one", async () => { + const { ledgerFallback } = await import("../src/utils/raw-call.js"); + for (const chainUnderTest of ["base", "solana"] as const) { + chain = chainUnderTest; + apiKeyMode = false; + for (const reserve of [0.003, 0.004, 0.0095, 0.012, 0.2645, 192.002]) { + assert.ok(ledgerFallback(reserve) <= reserve, `${chainUnderTest} ${reserve}`); + } + } +}); From 83f469bf2d489e170031613019999e33a4dd30d8 Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:37:12 -0400 Subject: [PATCH 08/37] test(rails): the rail-parity matrix, and the four gaps it found on its first run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 2's regressions had one fingerprint: six agents fixed 0.49.0 in parallel and each hardened the rail it was looking at. The quote guard landed on video and image but not music and speech; the in-flight booking on Base and the account rail but not Solana, the default chain; music's Solana call passed no onQuote at all, so the helper's guard hook fired against nobody. Every one was a money path and every one passed CI. So this is the table the completeness critic asked for instead of a round 3: every paid tool, every rail it serves, every treatment a paid call needs — quote guard, re-reserve at the real price, in-flight booking, honest give-up wording, ledger figure. A cell is a claim about the source, and adding a rail-specific guard without filling in its siblings turns the file red. It also asserts the DIVISION is deliberate: the seven tools whose 402 the SDK owns must NOT grow a quote guard, because they cannot see the quote, and it fails if a paid tool is missing from the table altogether. It found four gaps on its first run, all now fixed: - realface had no quote check on any rail and never re-reserved at the quoted price — it read the 402 amount and signed it five lines later. payAndPostJson takes an onQuote hook, wired on both the Base and Solana rails and to both enrollment calls; the account rail has no 402 to check and says so. - speech booked nothing when it gave up, though its own message already said a charge MAY have settled. It books conservatively now, and only when the timeout happened AFTER the signature went out — a timeout on the unpaid 402 probe charges nothing and must not invent spend. - image had no in-flight tracking, so an abort after the gateway settled left a real charge unbooked and released the reservation. 686 tests, typecheck and build clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg --- src/tools/image.ts | 20 +++++- src/tools/realface.ts | 46 +++++++++++++- src/tools/speech.ts | 22 ++++++- test/rail-parity.test.ts | 134 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 215 insertions(+), 7 deletions(-) create mode 100644 test/rail-parity.test.ts diff --git a/src/tools/image.ts b/src/tools/image.ts index 8fe58b9..fe34e23 100644 --- a/src/tools/image.ts +++ b/src/tools/image.ts @@ -3,6 +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 { isTimeoutError } from "../utils/http.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"; @@ -339,6 +340,11 @@ Source images and masks accept a base64 data URI, an http(s) URL, or a local fil }, }, async ({ prompt, action, model, image, mask, size, quality, inline, agent_id }) => { + // Hoisted for the outer catch: a timeout after the payment was sent has + // to be booked, and the catch needs both the reserve and whether a paid + // request was outstanding. + let estimatedCostForCatch = 0; + let paidRequestInFlight = false; try { const selectedModel = model || "openai/gpt-image-2"; @@ -408,6 +414,7 @@ Source images and masks accept a base64 data URI, an http(s) URL, or a local fil // record the real cost — releasing the reservation in finally on every // path (including a decline, which charges nothing). const estimatedCost = estimateCost(selectedModel, size); + estimatedCostForCatch = estimatedCost; let gate = reserveBudget(budget, agent_id, estimatedCost); if (!gate.allowed) { return { @@ -470,7 +477,9 @@ Source images and masks accept a base64 data URI, an http(s) URL, or a local fil image: normalizedImage, mask: normalizedMask, }); - const r = await apiKeyPost(endpoint, body, { timeoutMs: SOLANA_IMAGE_TIMEOUT_MS }); + paidRequestInFlight = true; + const r = await apiKeyPost(endpoint, body, { timeoutMs: SOLANA_IMAGE_TIMEOUT_MS }) + .finally(() => { paidRequestInFlight = false; }); // paidUsd null is "the rail settled nothing at response time", NOT // "free" — fall back to the estimate and say so, never book $0. billedUsd = r.paidUsd ?? estimatedCost; @@ -490,6 +499,7 @@ Source images and masks accept a base64 data URI, an http(s) URL, or a local fil image: normalizedImage, mask: normalizedMask, }); + paidRequestInFlight = true; const { data, paidUsd } = await solanaPaidPost(endpoint, body, SOLANA_IMAGE_TIMEOUT_MS, { // The Solana gateway prices carry a markup over the Base estimate // table, so the real quote can exceed what we reserved. Re-reserve @@ -509,6 +519,7 @@ Source images and masks accept a base64 data URI, an http(s) URL, or a local fil } }, }); + paidRequestInFlight = false; recordActualSpend(budget, paidUsd, estimatedCost, agent_id); billedUsd = paidUsd ?? estimatedCost; costIsEstimate = paidUsd === null; @@ -566,6 +577,13 @@ Source images and masks accept a base64 data URI, an http(s) URL, or a local fil if (err instanceof BudgetExceededError) { return { content: [{ type: "text", text: errMsg }], isError: true }; } + if (paidRequestInFlight && isTimeoutError(err)) { + recordActualSpend(budget, null, estimatedCostForCatch, agent_id); + return { + content: [{ type: "text", text: `Image generation timed out while a request carrying the payment was still in flight, so the gateway MAY have settled the charge after this client gave up — it has been booked against your budget; check blockrun_wallet action:"report" before retrying.\nError: ${errMsg}` }], + isError: true, + }; + } if (err instanceof PaymentError) { return { content: [{ type: "text", text: `Image generation needs USDC — your wallet is out of funds. ${(await launchTopUp()).note}\nError: ${errMsg}` }], diff --git a/src/tools/realface.ts b/src/tools/realface.ts index 1eab261..ab14b8e 100644 --- a/src/tools/realface.ts +++ b/src/tools/realface.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, recordActualSpend, reserveBudget } 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"; @@ -46,9 +46,19 @@ async function payAndPostJson( path: string, reqBody: string, fallbackDescription: string, + /** + * Called with the authoritative quote BEFORE anything is signed, on whichever + * rail is active. Throwing aborts unpaid. realface was the one manual-402 + * tool with no such hook: it read the 402 amount and signed it five lines + * later, so a gateway quoting a different product (as sol.blockrun.ai did for + * azure/sora-2 on 2026-09-08) was paid without a word. + */ + onQuote?: (quotedUsd: number | null, quotedFor?: string) => void, ): Promise<{ status: number; data: Record; settledUsd: number | null }> { // ---- Rail 1: account API key. ---- if (isApiKeyMode()) { + // No 402 on this rail: one POST, billed by the account. There is no quote + // to sanity-check, which is why onQuote is not called here. paidRequestInFlight = true; const resp = await fetchWithTimeout(`${getApiBase()}${path}`, { method: "POST", @@ -67,8 +77,9 @@ async function payAndPostJson( const { solanaPaidPost } = await import("../utils/solana-402.js"); paidRequestInFlight = true; try { - const r = await solanaPaidPost(path, JSON.parse(reqBody) as Record, 90_000) - .finally(() => { paidRequestInFlight = false; }); + const r = await solanaPaidPost(path, JSON.parse(reqBody) as Record, 90_000, { + onQuote: (quotedUsd, quoteDetails) => onQuote?.(quotedUsd, quoteDetails?.resource?.description), + }).finally(() => { paidRequestInFlight = false; }); return { status: 200, data: r.data as Record, settledUsd: r.paidUsd }; } catch (err) { // solanaPaidPost throws on a non-2xx terminal response. Recover the status @@ -103,6 +114,7 @@ async function payAndPostJson( const paymentRequired = parsePaymentRequired(prHeader); const details = extractPaymentDetails(paymentRequired); + onQuote?.(amountToUsd(details.amount), details.resource?.description); const paymentPayload = await createPaymentPayload( privateKey, account.address, @@ -339,6 +351,20 @@ Privacy: BlockRun does not store face/liveness data — only the asset id, name, "/v1/portrait/enroll", JSON.stringify({ name, image_url }), "BlockRun Virtual Portrait enrollment", + (quotedUsd, quotedFor) => { + // Same rule as video, music, image and speech: refuse a quote far + // above the published rate before signing, then re-check the cap + // at the REAL price. + assertQuoteNearEstimate(quotedUsd, ENROLLMENT_PRICE_USD, { + what: "portrait enrollment", + quotedFor, + hint: `Report the quote — the published rate is $${ENROLLMENT_PRICE_USD.toFixed(4)}.`, + }); + if (quotedUsd === null || quotedUsd <= ENROLLMENT_PRICE_USD) return; + gate?.release(); + gate = reserveBudget(budget, agent_id, quotedUsd); + if (!gate.allowed) throw new Error(`${gate.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget. No charge was made.`); + }, ); if (status === 402) { @@ -410,6 +436,20 @@ Privacy: BlockRun does not store face/liveness data — only the asset id, name, "/v1/realface/enroll", JSON.stringify({ name, image_url, group_id }), "BlockRun RealFace enrollment", + (quotedUsd, quotedFor) => { + // Same rule as video, music, image and speech: refuse a quote far + // above the published rate before signing, then re-check the cap + // at the REAL price. + assertQuoteNearEstimate(quotedUsd, ENROLLMENT_PRICE_USD, { + what: "RealFace enrollment", + quotedFor, + hint: `Report the quote — the published rate is $${ENROLLMENT_PRICE_USD.toFixed(4)}.`, + }); + if (quotedUsd === null || quotedUsd <= ENROLLMENT_PRICE_USD) return; + gate?.release(); + gate = reserveBudget(budget, agent_id, quotedUsd); + if (!gate.allowed) throw new Error(`${gate.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget. No charge was made.`); + }, ); if (status === 402) { diff --git a/src/tools/speech.ts b/src/tools/speech.ts index 82843e9..1e01b71 100644 --- a/src/tools/speech.ts +++ b/src/tools/speech.ts @@ -145,6 +145,14 @@ Returns a hosted audio URL — download immediately if you need to keep the file // Reserve the estimate up front so concurrent calls can't each pass a // stale budget; release in finally once the call settles or fails. let gate: ReturnType | undefined; + // Hoisted for the catch: a timeout after the signature was sent has to be + // booked, and it needs both the reserve and whatever the 402 quoted. + let reservedCost = 0; + let quotedCost: number | null = null; + // Set only while a request carrying the payment is outstanding. A timeout + // on the unpaid 402 probe charges nothing, and booking it would invent + // spend; a timeout after the signature went out may well have settled. + let paidRequestInFlight = false; try { if (action === "voices") { return await listVoices(); @@ -197,6 +205,7 @@ Returns a hosted audio URL — download immediately if you need to keep the file cost = speechCost(model, input); } + reservedCost = cost; gate = reserveBudget(budget, agent_id, cost); if (!gate.allowed) { return { @@ -268,6 +277,8 @@ Returns a hosted audio URL — download immediately if you need to keep the file // Prefer the exact 402-quoted price (handles sound-effect duration and any // server-side price change) over the local estimate for billing + display. billedUsd = amountToUsd(details.amount) ?? cost; + quotedCost = amountToUsd(details.amount); + paidRequestInFlight = true; // WHAT was quoted, before how much. 0.49.0 added this to video (both // rails) and image (Solana) and left the identical hand-rolled flows @@ -309,7 +320,7 @@ Returns a hosted audio URL — download immediately if you need to keep the file "PAYMENT-SIGNATURE": paymentPayload, }, body: JSON.stringify(body), - }, SPEECH_TIMEOUT); + }, SPEECH_TIMEOUT).finally(() => { paidRequestInFlight = false; }); if (resp.status === 402) { throw new Error("Payment rejected. Check your wallet balance."); @@ -373,9 +384,14 @@ Returns a hosted audio URL — download immediately if you need to keep the file isError: true, }; } - if (isTimeoutError(err)) { + if (paidRequestInFlight && isTimeoutError(err)) { + // The wording was already right and the LEDGER entry was missing: a + // charge the message says MAY have settled was booked nowhere, and + // the finally released the reservation. Book it conservatively, the + // way video, music and realface do (audit round 2, rail-parity). + recordActualSpend(budget, quotedCost, reservedCost, agent_id); return { - content: [{ type: "text", text: `Speech generation timed out after ${SPEECH_TIMEOUT / 1000}s. The payment signature had already been sent, so a charge MAY have settled without returning audio — check blockrun_wallet action:"report" before retrying.\nError: ${errMsg}` }], + content: [{ type: "text", text: `Speech generation timed out after ${SPEECH_TIMEOUT / 1000}s. The payment signature had already been sent, so a charge MAY have settled without returning audio — it has been booked against your budget; check blockrun_wallet action:"report" before retrying.\nError: ${errMsg}` }], isError: true, }; } diff --git a/test/rail-parity.test.ts b/test/rail-parity.test.ts new file mode 100644 index 0000000..d1fb8bb --- /dev/null +++ b/test/rail-parity.test.ts @@ -0,0 +1,134 @@ +// Run with: npm test (tsx --test) +// +// THE RAIL-PARITY MATRIX. +// +// Round 1 of the 0.49.0 audit was fixed by six agents working in parallel, one +// per area, and round 2's regressions had a single fingerprint: each agent +// hardened the rail it was looking at and left its siblings alone. The quote +// guard landed on video and image but not music and speech; the in-flight +// booking on Base and the account rail but not Solana, the default chain; +// music's Solana call passed no onQuote at all, so the guard hook fired against +// nobody. Every one of those was a real money path, and every one passed CI. +// +// So this is not another audit. It is the table the round-2 completeness critic +// asked for: every paid tool, every rail it serves, every treatment a paid call +// needs. A cell is a claim about the source, and adding a rail-specific guard +// without filling in its siblings turns this file red. +// +// It is deliberately a STATIC check. Driving all 13 tools across 3 rails +// through their handlers would need a mock harness per tool, and the failure it +// is guarding against is structural — "this file never mentions the thing" — +// which reading the source proves directly and cheaply. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readdirSync, readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const TOOLS = join(dirname(fileURLToPath(import.meta.url)), "..", "src", "tools"); +const src = (f: string) => readFileSync(join(TOOLS, f), "utf8"); + +/** A paid tool that performs its OWN 402 (reads the quote, signs the payment). */ +const MANUAL_402 = ["video.ts", "music.ts", "speech.ts", "image.ts", "realface.ts"] as const; + +/** Paid tools that hand the whole 402 to the SDK or the account helper. */ +const DELEGATED_402 = ["markets.ts", "exa.ts", "defi.ts", "rpc.ts", "search.ts", "phone.ts", "modal.ts"] as const; + +test("every manual-402 tool checks the quote before it signs", () => { + // The gateway can quote a different product than the one asked for — proven + // on 2026-09-08, when sol.blockrun.ai answered azure/sora-2 with "Seedance + // 2.0 Pro video generation (5s)" at 2.7x the published rate. A tool that + // signs whatever arrives cannot notice. + for (const f of MANUAL_402) { + const s = src(f); + assert.match(s, /assertQuoteNearEstimate|assertVideoQuoteSane/, `${f}: no quote-sanity check before signing`); + } +}); + +test("every manual-402 tool re-reserves against the cap at the REAL price", () => { + // The estimate is what the gate approved and what the human was shown; the + // 402 is what will actually be taken. A quote above the estimate has to be + // re-checked against the cap before anything is signed. + for (const f of MANUAL_402) { + const s = src(f); + assert.match( + s, + /reserveBudget\(budget, agent_id, (quotedUsd|solQuotedUsd|settledUsd|billedUsd)|reReserveIfHigher\(/, + `${f}: never re-reserves at the quoted price`, + ); + } +}); + +test("every rail a manual-402 tool serves gets its quote checked, not just the first one", () => { + // music's Solana call passed only pollBudgetMs, so the helper's onQuote hook + // — which exists for exactly this — fired against nothing while the SPL + // transfer was signed for whatever the quote said. + for (const f of MANUAL_402) { + const s = src(f); + if (!/solanaPaid(Post|AsyncPost)\(/.test(s)) continue; + assert.match(s, /onQuote:/, `${f}: calls the Solana helper without an onQuote guard`); + } +}); + +test("every tool that can give up while a paid request is outstanding books the charge", () => { + // The gateway settles on its own clock and does not stop because the client + // disconnected. A give-up that books nothing tells the caller a real charge + // was free, and the obvious next step pays for it twice. + for (const f of MANUAL_402) { + const s = src(f); + assert.match( + s, + /paidPollInFlight|paidRequestInFlight|BilledJobError/, + `${f}: no in-flight tracking, so an abort after settlement books nothing`, + ); + } +}); + +test("a tool that gives up on Solana never claims 'No payment was taken'", () => { + // Base can promise it (settlement happens only on a poll the gateway answers + // "completed"); Solana and the account rail cannot. The promise used to be + // gated on `getChain() !== "solana"` in a way that fell through to silence + // rather than to an honest sentence. + for (const f of ["video.ts", "music.ts"] as const) { + const s = src(f); + assert.match(s, /getChain\(\) === "solana"[\s\S]{0,600}MAY have gone through/, `${f}: the Solana give-up must say the charge may stand`); + } +}); + +test("every delegated-402 tool books the observed charge, not its reserve", () => { + // The reserve rounds against us on purpose ($0.002 fee where the gateway + // charges $0.001 on Base and nothing on Solana). Booking it inflates recorded + // spend on every call and trips caps early — up to 2x on the default chain. + for (const f of DELEGATED_402) { + const s = src(f); + assert.match(s, /ledgerFallback\(/, `${f}: books its reserve as settled spend`); + } +}); + +test("no delegated-402 tool pretends to check a quote it cannot see", () => { + // The SDK owns their 402 and does not surface the amount, so a guard there + // would be theatre. This asserts the DIVISION is deliberate: if one of these + // ever grows a quote check, it has moved rails and this table must say so. + for (const f of DELEGATED_402) { + const s = src(f); + assert.doesNotMatch(s, /assertQuoteNearEstimate|assertVideoQuoteSane/, `${f}: grew a quote guard — update the matrix`); + } +}); + +test("the matrix covers every paid tool in the directory", () => { + // The point of a table is that nothing is missing from it. A new paid tool + // must be classified, not silently skipped. + const classified = new Set([...MANUAL_402, ...DELEGATED_402]); + const unclassified: string[] = []; + for (const f of readdirSync(TOOLS).filter((n) => n.endsWith(".ts"))) { + if (classified.has(f)) continue; + const s = src(f); + // Free tools and the wallet/chat tools are out of scope by construction: + // chat settles per token through the SDK and has its own settled-cost + // wrapper; wallet, models, dex and polymarket_read take no payment here. + if (!/reserveBudget\(budget/.test(s)) continue; + if (["chat.ts", "chat-anthropic.ts", "polymarket.ts", "price.ts", "wallet.ts"].includes(f)) continue; + unclassified.push(f); + } + assert.deepEqual(unclassified, [], `paid tools missing from the rail-parity matrix: ${unclassified.join(", ")}`); +}); From 783cfb3d83aa200b9f407665d59c220b54385ecf Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:41:32 -0400 Subject: [PATCH 09/37] fix(wallet,docs): a locked keychain is not a missing wallet, and five stale claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 P3s. - getSolanaUsdcBalance took the balance query off the SDK in 0.49.0 and dropped SOLANA_RPC_HEADERS with it, so a private RPC that authenticates by header answered 401 and every balance read as "unavailable". Same parse the SDK does, same failure mode on malformed JSON. - resolveSolanaKey collapses "absent" and "the keychain would not open" into undefined, so buildSolanaClient told a user with a funded, locked wallet to run setup — advice that invites a second wallet. ensureSolanaWallet already refuses to mint on that distinction; solanaKeyUnavailableReason() exposes it so the sync callers can say the same thing. - blockrun_video and blockrun_realface told account-rail users their "wallet is out of funds" and offered a card top-up for a wallet that is not paying. Music and speech got the isApiKeyMode branch in 0.49.0; these two did not. - The Stanford demo preflight asserted a nine-tool trading profile including surf, so it now always fails; skills/rpc still routed agents to a removed tool; index.ts's own comment cited the pre-removal counts; and blockrun_markets described polymarket/wallets/profiles as a POST batch route, which 404s — GET returns the 402 (probed). Not changed: the completeness critic filed phone.ts's unknown-reserve comment as citing a route that no longer exists. /v1/phone/numbers/search answers 402 today, so the comment is accurate and the finding is refuted. 686 tests. Context figures resynced after the description edits (12,698 full). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg --- README.md | 4 +-- docs/mcp-schema-overhead.md | 4 +-- skills/rpc/SKILL.md | 2 +- skills/signal-to-trade-demo/SKILL.md | 6 ++-- src/index.ts | 6 ++-- src/tools/markets.ts | 2 +- src/tools/realface.ts | 6 ++-- src/tools/video.ts | 6 ++-- src/utils/wallet.ts | 41 +++++++++++++++++++++++++++- 9 files changed, 62 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 5b844db..6dded9a 100644 --- a/README.md +++ b/README.md @@ -218,8 +218,8 @@ Package managers have shown install size for decades. Almost no MCP server shows | Profile | Tools | Context | |---------|-------|---------| -| `full` *(default)* | 19 | 12,686 | -| `trading` | 8 | 5,189 | +| `full` *(default)* | 19 | 12,698 | +| `trading` | 8 | 5,201 | | `media` | 7 | 5,632 | | `research` | 5 | 2,664 | | `chat` | 3 | 2,005 | diff --git a/docs/mcp-schema-overhead.md b/docs/mcp-schema-overhead.md index 5e4131b..54f390c 100644 --- a/docs/mcp-schema-overhead.md +++ b/docs/mcp-schema-overhead.md @@ -16,8 +16,8 @@ Written 2026-09-01, verified against `@modelcontextprotocol/sdk` 1.29.0. Numbers | Profile | Tools | Context | |---------|-------|---------| -| `full` *(default)* | 19 | 12,686 | -| `trading` | 8 | 5,189 | +| `full` *(default)* | 19 | 12,698 | +| `trading` | 8 | 5,201 | | `media` | 7 | 5,632 | | `research` | 5 | 2,664 | | `chat` | 3 | 2,005 | diff --git a/skills/rpc/SKILL.md b/skills/rpc/SKILL.md index f494803..e9362d7 100644 --- a/skills/rpc/SKILL.md +++ b/skills/rpc/SKILL.md @@ -1,6 +1,6 @@ --- name: rpc -description: Use when the user needs raw blockchain JSON-RPC access — contract reads (eth_call), native balances, blocks, transactions, logs, gas estimates, or any chain-native RPC method across 40 chains. One endpoint per chain via BlockRun's Tatum-backed gateway, $0.0030 per call, no node, no API key. Prefer blockrun_price / blockrun_dex / blockrun_surf when they already cover the question. +description: Use when the user needs raw blockchain JSON-RPC access — contract reads (eth_call), native balances, blocks, transactions, logs, gas estimates, or any chain-native RPC method across 40 chains. One endpoint per chain via BlockRun's Tatum-backed gateway, $0.0030 per call, no node, no API key. Prefer blockrun_price / blockrun_dex / the paid tools when they already cover the question. triggers: - "rpc" - "json-rpc" diff --git a/skills/signal-to-trade-demo/SKILL.md b/skills/signal-to-trade-demo/SKILL.md index 3a312aa..7cae43a 100644 --- a/skills/signal-to-trade-demo/SKILL.md +++ b/skills/signal-to-trade-demo/SKILL.md @@ -33,8 +33,10 @@ or preparing a fallback. ## 1. Private operator preflight -- Confirm the Trading profile exposes nine tools and no image/video/media tool: - wallet, price, dex, markets, surf, defi, rpc, polymarket_read, polymarket. +- Confirm the Trading profile exposes eight tools and no image/video/media tool: + wallet, price, dex, markets, defi, rpc, polymarket_read, polymarket. + (It was nine until 2026-09-06, when the gateway retired Surf and + `blockrun_surf` went with it — a preflight that still counts nine fails.) - Before screen sharing, the human operator may check `blockrun_wallet`, run setup, and inspect positions/orders. Never include those raw calls in the presentation conversation. diff --git a/src/index.ts b/src/index.ts index d18646f..d0d749a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -105,8 +105,10 @@ async function main() { const transport = new StdioServerTransport(); await server.connect(transport); // resolveTools falls back to "full" for a name it does not know. Say so: - // a user who typed `--profile tradng` wanted 9 tools and got 20, and the - // "20 tools" startup line alone reads as if the flag was honoured. + // a user who typed `--profile tradng` wanted the trading set and got the full + // one, and a startup line that states only the count reads as if the flag was + // honoured. (The numbers moved with the Surf removal; the point did not, so + // this says which profile rather than how many tools.) const requestedProfile = resolveProfileName(); if (requestedProfile !== profile) { console.error( diff --git a/src/tools/markets.ts b/src/tools/markets.ts index ba0cae7..8dd21d1 100644 --- a/src/tools/markets.ts +++ b/src/tools/markets.ts @@ -54,7 +54,7 @@ POLYMARKET (Tier 2 — wallet/smart-money analytics): - polymarket/wallet/:wallet — full smart-wallet profile - polymarket/wallet/:wallet/markets, .../similar - polymarket/wallet/pnl/:wallet, .../positions/:wallet, .../volume-chart/:wallet -- polymarket/wallets/profiles, polymarket/wallets/filter — batch + AND/OR filter +- polymarket/wallets/profiles — batch profiles, GET with ?addresses= (POST 404s); polymarket/wallets/filter — AND/OR filter - polymarket/market/:condition_id/smart-money, polymarket/markets/smart-activity WALLET IDENTITY & CLUSTERING (Tier 2) — cross-context labels + on-chain relationship graph: diff --git a/src/tools/realface.ts b/src/tools/realface.ts index ab14b8e..ac521a7 100644 --- a/src/tools/realface.ts +++ b/src/tools/realface.ts @@ -9,7 +9,7 @@ import { formatError, isPaymentRejectionError } from "../utils/errors.js"; import { fetchWithTimeout } from "../utils/http.js"; import type { BudgetState } from "../types.js"; import { getApiBase, getChain, getOrCreateWalletKey } from "../utils/wallet.js"; -import { apiAuthHeaders, isApiKeyMode, requireWalletMode } from "../utils/auth.js"; +import { PORTAL_CREDITS_URL, apiAuthHeaders, isApiKeyMode, requireWalletMode } from "../utils/auth.js"; import { generateUrlQrPng, openQrInViewer } from "../utils/qr.js"; import { launchTopUp } from "../utils/onramp.js"; import { privateKeyToAccount } from "viem/accounts"; @@ -499,7 +499,9 @@ Privacy: BlockRun does not store face/liveness data — only the asset id, name, const errMsg = err instanceof Error ? err.message : String(err); if (isPaymentRejectionError(errMsg)) { return { - content: [{ type: "text", text: `RealFace enrollment needs USDC — your wallet is out of funds. ${(await launchTopUp()).note}\nError: ${errMsg}` }], + content: [{ type: "text", text: isApiKeyMode() + ? `RealFace enrollment was refused for lack of credit on your BlockRun account — top it up at ${PORTAL_CREDITS_URL}.\nError: ${errMsg}` + : `RealFace enrollment needs USDC — your wallet is out of funds. ${(await launchTopUp()).note}\nError: ${errMsg}` }], isError: true, }; } diff --git a/src/tools/video.ts b/src/tools/video.ts index 6b1fb01..382770a 100644 --- a/src/tools/video.ts +++ b/src/tools/video.ts @@ -11,7 +11,7 @@ import { fetchWithTimeout, isTimeoutError } from "../utils/http.js"; import { pollTimeoutFor } from "../utils/poll.js"; import type { BudgetState } from "../types.js"; import { getApiBase, getChain, getOrCreateWalletKey, resolveGatewayUrl } from "../utils/wallet.js"; -import { isApiKeyMode } from "../utils/auth.js"; +import { PORTAL_CREDITS_URL, isApiKeyMode } from "../utils/auth.js"; import { apiKeyAsyncPost, BilledJobError } from "../utils/api-key-call.js"; import { isBlockedFetchHostResolved } from "../utils/ssrf.js"; import { privateKeyToAccount } from "viem/accounts"; @@ -862,7 +862,9 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC } if (isPaymentRejectionError(errMsg)) { return { - content: [{ type: "text", text: `Video generation needs USDC — your wallet is out of funds. ${(await launchTopUp()).note}\nError: ${errMsg}` }], + content: [{ type: "text", text: isApiKeyMode() + ? `Video generation was refused for lack of credit on your BlockRun account — top it up at ${PORTAL_CREDITS_URL}.\nError: ${errMsg}` + : `Video generation needs USDC — your wallet is out of funds. ${(await launchTopUp()).note}\nError: ${errMsg}` }], isError: true, }; } diff --git a/src/utils/wallet.ts b/src/utils/wallet.ts index 3cd5ebd..9e3b9e3 100644 --- a/src/utils/wallet.ts +++ b/src/utils/wallet.ts @@ -522,6 +522,23 @@ export function resolveSolanaKey(): string | undefined { return resolveSolanaKeyDetailed().key; } +/** + * Why there is no key, when there is no key. + * + * resolveSolanaKey() collapses "absent" and "the keychain would not open" into + * undefined, and every caller then says "no Solana wallet yet — run setup", + * which for a locked keychain is both wrong and destructive advice: the wallet + * exists and is funded. ensureSolanaWallet already refuses to mint on that + * distinction; this exposes it so the sync callers can say the same thing. + */ +export function solanaKeyUnavailableReason(): string | undefined { + const { key, keychainError } = resolveSolanaKeyDetailed(); + if (key) return undefined; + return keychainError === undefined + ? undefined + : `the OS keychain could not be read (${keychainError})`; +} + let _solanaWalletInfo: { address: string; privateKey: string; isNew: boolean } | null = null; let _solanaWalletPromise: Promise<{ address: string; privateKey: string; isNew: boolean }> | null = null; @@ -620,6 +637,15 @@ function buildSolanaClient(timeout?: number): SolanaLLMClient { } const privateKey = resolveSolanaKey(); if (!privateKey) { + const locked = solanaKeyUnavailableReason(); + if (locked) { + // NOT "no wallet yet": the wallet may well exist and be funded, and + // telling this user to run setup invites a second one. + throw new Error( + `Cannot reach your Solana wallet key — ${locked}. Your existing wallet is most likely still in the keychain: ` + + `unlock it and retry, or set SOLANA_WALLET_KEY. Nothing was charged.`, + ); + } // 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 @@ -795,10 +821,23 @@ const DEFAULT_SOLANA_RPC_URL = "https://sol.blockrun.ai/api/v1/solana/rpc"; */ async function getSolanaUsdcBalance(address: string): Promise { const rpcUrl = process.env.SOLANA_RPC_URL || DEFAULT_SOLANA_RPC_URL; + // The SDK reads SOLANA_RPC_HEADERS alongside SOLANA_RPC_URL (resolveRpcConfig + // in @blockrun/llm), and taking the balance query off the SDK dropped it — + // so a private RPC that authenticates by header answered 401 and the balance + // read as "unavailable". Same parse, same failure mode on bad JSON: ignore it. + let rpcHeaders: Record | undefined; + if (process.env.SOLANA_RPC_HEADERS) { + try { + const parsed = JSON.parse(process.env.SOLANA_RPC_HEADERS) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + rpcHeaders = Object.fromEntries(Object.entries(parsed as Record).map(([k, v]) => [String(k), String(v)])); + } + } catch { /* malformed: fall through unauthenticated, as the SDK does */ } + } try { const response = await fetch(rpcUrl, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...(rpcHeaders ?? {}) }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, From c82f196d683045bfc3e56420f6f4af1167d39b84 Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:45:30 -0400 Subject: [PATCH 10/37] fix(ci,apps,solana): pin the registry publisher, and four honesty fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 P3s. - publish.yml curled `releases/latest` of a third-party binary straight into tar and then into /usr/local/bin, in the job that holds the npm publish token. Its contents could change between two runs of the same commit with nothing here recording it. Pinned to v1.8.1 and checksum-verified against the same release: pinning is the part that matters, the checksum only proves the download is intact. - solanaPaidPost parsed the settled 200 body unguarded, so a truncated or aborted payload threw AFTER the money moved and the caller reported a failure with nothing booked — the one direction that must never happen. The 200 is the settlement; the charge is handed back whether or not the body parsed. - The wallet card's primary CTA read "Buy USDC with card" on Solana, where card top-up is Base-only: the user clicked, watched "Minting link…", and landed on a plain-text refusal. It now says what the active chain can actually do. - The order card labelled the worst fill "signed max/min", which reads as a guarantee about the order about to be placed. Place re-walks a fresh book on the server, so the bound is from THIS quote and the label now says so. - blockrun_image's quote guard was untested: deleting it left every test green. Two tests now pin the refusal, the tolerance, and that image calls the guard on the one rail that surfaces a 402 amount. 686 tests, typecheck and build clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg --- .github/workflows/publish.yml | 23 +++++++++++++++++++++-- apps/order-preview.ts | 6 +++++- apps/wallet.ts | 14 +++++++++++--- src/utils/solana-402.ts | 9 ++++++++- test/quote-guard.test.ts | 32 ++++++++++++++++++++++++++++++++ 5 files changed, 77 insertions(+), 7 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 47b4a5f..cfa24d6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -81,12 +81,31 @@ jobs: run: echo "npm already at ${{ steps.v.outputs.pkg }} — skipping npm publish" # ---- MCP registry ---- + # PINNED, and checksum-verified against the same release. + # + # This used to curl `releases/latest` straight into `tar -xz` and then into + # /usr/local/bin — an unpinned third-party binary, executed in a job that + # holds the npm publish token, whose contents could change between two runs + # of the same commit with nothing in this repo recording it. Pinning is the + # part that matters: the version moves when someone here changes this line. + # The checksum file ships from the same release, so it proves the download + # is intact, not that the release is trustworthy — that is what pinning is + # for. Bump deliberately; the registry's own releases page lists the tag. - name: Install mcp-publisher if: steps.v.outputs.pkg != steps.v.outputs.reg + env: + MCP_PUBLISHER_VERSION: v1.8.1 run: | - curl -sL "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_amd64.tar.gz" \ - | tar -xz mcp-publisher + set -euo pipefail + os=$(uname -s | tr '[:upper:]' '[:lower:]') + tarball="mcp-publisher_${os}_amd64.tar.gz" + base="https://github.com/modelcontextprotocol/registry/releases/download/${MCP_PUBLISHER_VERSION}" + curl -fsSL -o "$tarball" "${base}/${tarball}" + curl -fsSL -o checksums.txt "${base}/registry_${MCP_PUBLISHER_VERSION#v}_checksums.txt" + grep " ${tarball}$" checksums.txt | sha256sum -c - + tar -xzf "$tarball" mcp-publisher sudo mv mcp-publisher /usr/local/bin/ + rm -f "$tarball" checksums.txt - name: Stamp + publish server.json to MCP registry if: steps.v.outputs.pkg != steps.v.outputs.reg diff --git a/apps/order-preview.ts b/apps/order-preview.ts index 1248f7c..2bca4c4 100644 --- a/apps/order-preview.ts +++ b/apps/order-preview.ts @@ -120,7 +120,11 @@ function renderPreview(p: Preview): void { // Market orders are SIGNED at this bound (the server walks the book), so // the fill can never be worse than the number shown here. ...(!isLimit && typeof p.worstFillPrice === "number" - ? [kv(isBuy ? "Worst fill (signed max)" : "Worst fill (signed min)", `${(p.worstFillPrice * 100).toFixed(1)}¢ · ${p.worstFillPrice.toFixed(3)}`)] + // "signed max/min" read as a guarantee about the order about to be + // placed. It is the bound from THIS quote; Place re-walks a fresh book on + // the server and signs that one, so the figure can move if the book does. + // Say "at this quote" and let the server's own result be the record. + ? [kv(isBuy ? "Worst fill at this quote (max)" : "Worst fill at this quote (min)", `${(p.worstFillPrice * 100).toFixed(1)}¢ · ${p.worstFillPrice.toFixed(3)}`)] : []), kv("Shares", shares !== undefined ? `${isLimit ? "" : "≈ "}${shares.toFixed(4)}` : "—"), kv("Max payout if right", isBuy && shares !== undefined ? usd(shares) : "—"), diff --git a/apps/wallet.ts b/apps/wallet.ts index 7c841aa..c7fe436 100644 --- a/apps/wallet.ts +++ b/apps/wallet.ts @@ -105,11 +105,19 @@ function renderStatus(s: Status): void { ); }; - const buy = el("button", { class: "primary" }, "Buy USDC with card") as HTMLButtonElement; + // Card top-up is Base-only (utils/onramp.ts returns address+QR guidance on + // Solana, with no link). The primary CTA read "Buy USDC with card" on both + // chains, so a Solana user clicked it, watched "Minting link…", and landed on + // a plain-text fallback explaining it is not available. Say what this chain + // can actually do. + const onSolana = s.activeChain === "solana"; + const buyLabel = onSolana ? "Fund with USDC (SPL)" : "Buy USDC with card"; + const buy = el("button", { class: "primary" }, buyLabel) as HTMLButtonElement; + if (onSolana) buy.title = "Card top-up is Base-only — this shows your Solana address and QR to send USDC (SPL) to."; buy.addEventListener("click", async () => { - setBusy(buy, true, "Minting link…"); + setBusy(buy, true, onSolana ? "Fetching address…" : "Minting link…"); try { render(await call({ action: "deposit" })); } catch (e) { note.hidden = false; note.className = "note err"; note.textContent = String((e as Error).message ?? e); } - finally { setBusy(buy, false, "Buy USDC with card"); } + finally { setBusy(buy, false, buyLabel); } }); const explorer = el("button", { class: "small" }, s.explorerLabel || "Explorer") as HTMLButtonElement; explorer.addEventListener("click", () => { void app.openLink({ url: s.explorerUrl }); }); diff --git a/src/utils/solana-402.ts b/src/utils/solana-402.ts index 2213cac..43f4a69 100644 --- a/src/utils/solana-402.ts +++ b/src/utils/solana-402.ts @@ -249,7 +249,14 @@ export async function solanaPaidPost( throw new Error(`API error ${resp.status}: ${JSON.stringify(errBody)}`); } - const data = await resp.json() as Record; + // The 200 IS the settlement — the money moved before this body was read. An + // unguarded .json() on a truncated or aborted response threw here, and the + // caller's catch then reported a failure with nothing booked, which is the + // one direction that must never happen. Hand back what we know instead: the + // charge is real whether or not the payload parsed. + const data = await resp.json().catch(() => ({ + error: "The paid response could not be parsed. The 200 means the payment settled — the charge stands.", + })) as Record; return { data, paidUsd: context.paidUsd }; } diff --git a/test/quote-guard.test.ts b/test/quote-guard.test.ts index 6dc47d4..09a5684 100644 --- a/test/quote-guard.test.ts +++ b/test/quote-guard.test.ts @@ -58,3 +58,35 @@ test("the video wrapper adds the Sora/Solana explanation only where it applies", assert.throws(() => assertVideoQuoteSane(3, 1, "bytedance/seedance-2.0", "base"), /Retry on Solana/); assert.doesNotThrow(() => assertVideoQuoteSane(0.421001, 0.422001, "azure/sora-2", "base")); }); + +// --- blockrun_image's quote guard, which nothing exercised (round 2) --- +// +// The guard was added to image.ts in 0.49.0 and deleting it left every test +// green. These pin the two things the Solana onQuote hook must do — refuse a +// quote far above the published rate, and let a real one through — using the +// same figures the gateway quotes. +test("the image quote guard refuses a substituted product and passes a real one", async () => { + const { assertQuoteNearEstimate, QuoteMismatchError } = await import("../src/utils/budget.js"); + // nano-banana at 1024: $0.01675 estimate. A 2.7x substitution is refused. + assert.throws( + () => assertQuoteNearEstimate(0.0452, 0.01675, { what: "google/nano-banana image", quotedFor: "Seedance 2.0 Pro video generation (5s)" }), + (err: unknown) => { + assert.ok(err instanceof QuoteMismatchError); + assert.match((err as Error).message, /google\/nano-banana image/); + assert.match((err as Error).message, /no charge was made/); + return true; + }, + ); + // A real size-tier difference stays inside the tolerance. + assert.doesNotThrow(() => assertQuoteNearEstimate(0.0177, 0.01675, { what: "google/nano-banana image" })); + // And the floor keeps a cheap image from reading as a multiple. + assert.doesNotThrow(() => assertQuoteNearEstimate(0.03, 0.01675, { what: "x" })); +}); + +test("image.ts actually calls the guard on the rail that has a quote", async () => { + const { readFileSync } = await import("node:fs"); + const src = readFileSync(new URL("../src/tools/image.ts", import.meta.url), "utf8"); + // The Solana helper is the only image rail that surfaces a 402 amount. + assert.match(src, /solanaPaidPost\([\s\S]{0,400}onQuote:/, "image must guard the Solana quote"); + assert.match(src, /assertQuoteNearEstimate\(/, "image must call the shared guard"); +}); From 758a949b6446eac05076fc600da4c8b3f54f8fc2 Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:48:25 -0400 Subject: [PATCH 11/37] fix(relayer,verify): arm the double-send guard only when the outcome is unknown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last two round-2 P3s. - sendWalletBatch had `await getRelayClient()` inside its try. That call derives CLOB credentials and creates a builder key — real network calls that happen before the RelayClient exists, so they cannot have signed or posted anything — and their failures armed the double-send guard, wedging the user behind a five-minute deadline for a transfer nothing had signed. It is hoisted out. The 4xx detector also read only the JSON shape, while the CLOB SDK's ApiError puts its code on a `.status` property and leaves the message bare, so every definite refusal it raises looked ambiguous; it now reads the property, the JSON shape, and a bare "HTTP 403" in the text. - The catalogue sweep claimed to check every FREE_CHAT_MODELS member against the live price, and could only check the ones the catalogue returns: six are absent from both gateways today, and two more were skipped by the `available === false` guard. A free member is now checked even when marked unavailable — "retired today" does not promise "still free when it returns" — and one the catalogue does not list is reported as UNVERIFIED rather than counted as checked. Delisting is not death here (gpt-oss-120b is the gateway's own free fallback and answers while absent from the catalogue), so settling one costs a real POST and stays a human's call. The comment in constants.ts that overclaimed the coverage now says exactly this. 690 tests, typecheck clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg --- scripts/verify-prices.ts | 22 ++++++++++++++--- src/utils/constants.ts | 11 ++++++++- src/utils/polymarket/relayer.ts | 20 +++++++++++++-- test/polymarket-relayer-batch.test.ts | 35 +++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 7 deletions(-) diff --git a/scripts/verify-prices.ts b/scripts/verify-prices.ts index 0220db3..404740d 100644 --- a/scripts/verify-prices.ts +++ b/scripts/verify-prices.ts @@ -471,7 +471,11 @@ for (const [name, host] of [["Base", BASE], ["Solana", SOL]] as const) { if (typeof input !== "number" || typeof output !== "number") continue; // Base marks retired rows `available:false`; Solana omits the field // entirely, and an omitted flag is a served model, not an unknown one. - if (m.available === false) continue; + // + // A model FREE_CHAT_MODELS claims is free is checked even when unavailable: + // the dangerous direction is a $0 reserve for a call that costs money, and + // "retired today" does not promise "still free when it comes back". + if (m.available === false && !FREE_CHAT_MODELS.has(m.id)) continue; checked++; listed.add(m.id); const isFree = FREE_CHAT_MODELS.has(m.id); @@ -503,10 +507,20 @@ for (const [name, host] of [["Base", BASE], ["Solana", SOL]] as const) { catalogueNotes.push(`${name}: ${m.id} is billed $0 but FREE_CHAT_MODELS does not list it — an explicit call reserves the default, and an exhausted budget refuses a free call`); } } - for (const id of MODEL_TIERS.free) { - if (!listed.has(id)) catalogueNotes.push(`${name}: free[] routes ${id}, which the catalogue does not list — not a death certificate (gpt-oss-120b is hidden-alive); probe with a realistic POST before removing`); + // Every id we reserve $0 for, not just the routing tier — FREE_CHAT_MODELS is + // the classifier estimateChatCost actually consults, and it has members the + // tier list does not. An unlisted one is UNVERIFIED, not verified-free: the + // sweep can only price what the catalogue reports. + let unverifiedFree = 0; + for (const id of FREE_CHAT_MODELS) { + if (listed.has(id)) continue; + unverifiedFree++; + catalogueNotes.push(`${name}: reserves $0 for ${id}, which the catalogue does not list — UNVERIFIED, not confirmed free (delisting is not death: gpt-oss-120b is hidden-alive). Probe with a realistic POST before trusting or removing it`); } - console.log(` ${gaps ? "✗" : "✓"} ${name.padEnd(26)} ${checked} chat models checked, ${gaps} would settle above the reserve`); + console.log( + ` ${gaps ? "✗" : "✓"} ${name.padEnd(26)} ${checked} chat models checked, ${gaps} would settle above the reserve` + + (unverifiedFree ? `, ${unverifiedFree} free-list members unverified (not in the catalogue)` : ""), + ); } for (const g of catalogueGaps) console.log(` ✗ ${g}`); for (const n of catalogueNotes) console.log(` ! ${n}`); diff --git a/src/utils/constants.ts b/src/utils/constants.ts index f4191c6..c3be38d 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -189,7 +189,16 @@ export type RoutingMode = keyof typeof MODEL_TIERS; * The dangerous direction is the other one: a member that STARTS costing money * gets a $0 reserve for a paid call, which is the total gate bypass this file * spends so many words preventing. So `npm run verify:prices` checks every - * member against the live catalogue and fails if one is priced. A Set needs no + * member the CATALOGUE REPORTS against its live price and fails if one is + * priced — including one marked unavailable, since "retired today" does not + * promise "still free when it returns". + * + * What that check cannot cover, and does not pretend to: a member the catalogue + * does not list at all. Six of these are in that state on both gateways today, + * and delisting is not death — gpt-oss-120b is the gateway's own free fallback + * and answers for itself while absent from GET /v1/models. The sweep names each + * one as UNVERIFIED rather than counting it as checked; the only way to settle + * one is a realistic POST, which costs a call and so is a human's decision. A Set needs no * hasOwn guard — there are no prototype keys to leak through `.has`. * * Not every member is routed: MODEL_TIERS.free wants a both-chain, latency- diff --git a/src/utils/polymarket/relayer.ts b/src/utils/polymarket/relayer.ts index b5f0ee5..ca3c9c6 100644 --- a/src/utils/polymarket/relayer.ts +++ b/src/utils/polymarket/relayer.ts @@ -149,8 +149,15 @@ export async function sendWalletBatch( ): Promise<{ transactionHash?: string }> { const deadlineSec = Math.floor(Date.now() / 1000) + BATCH_DEADLINE_SECS; let response: Awaited>; + // Getting the client is NOT part of the send. getRelayClient derives CLOB + // credentials and creates a builder key — real network calls that happen + // before the RelayClient exists, so they cannot have signed or posted the + // batch. Leaving them inside the try armed the double-send guard for a + // failure that provably moved nothing, wedging the user behind a deadline + // for a transfer that was never signed. + const relay = await getRelayClient(); try { - response = await (await getRelayClient()).executeDepositWalletBatch(calls, depositWallet, String(deadlineSec)); + response = await relay.executeDepositWalletBatch(calls, depositWallet, String(deadlineSec)); } catch (err) { // The SDK signs, THEN posts. A lost response (proxy 502/504, reset — the // SDK surfaces these as `{"error":"connection error"}` or a 5xx "request @@ -164,7 +171,16 @@ export async function sendWalletBatch( // clear it (withdraw.ts). Untracked batches (approvals, wrap) are safe to // retry and rethrow as before. const message = err instanceof Error ? err.message : String(err); - const definitelyRejected = /"status":4\d\d/.test(message); + // The CLOB SDK's ApiError carries its code on a `.status` PROPERTY and + // leaves the message as the bare error string, so matching only the JSON + // shape missed every definite 4xx it raises — the guard armed on rejections + // that were unambiguous. Read the property first, then fall back to the + // shapes that only appear in text. + const status = (err as { status?: unknown })?.status; + const definitelyRejected = + (typeof status === "number" && status >= 400 && status < 500) || + /"status":4\d\d/.test(message) || + /\b(?:HTTP|status(?:\s*code)?)\s*[:=]?\s*4\d\d\b/i.test(message); if (opts?.trackPendingWithdraw && !definitelyRejected) { saveState({ pendingWithdraw: { transactionID: "unknown", deadline: deadlineSec } }); throw new Error( diff --git a/test/polymarket-relayer-batch.test.ts b/test/polymarket-relayer-batch.test.ts index 01f0355..d5095a0 100644 --- a/test/polymarket-relayer-batch.test.ts +++ b/test/polymarket-relayer-batch.test.ts @@ -14,11 +14,13 @@ let getTransactionThrows = false; // lost response is `{"error":"connection error"}`, for a rejection // `{"error":"request error","status":4xx,...}` (http-helpers/index.js). let submitThrows: string | undefined; +let submitThrowsError: Error | undefined; let stateFile: Record = {}; const saveStateCalls: Array> = []; class FakeRelayClient { async executeDepositWalletBatch() { + if (submitThrowsError) throw submitThrowsError; if (submitThrows) throw new Error(submitThrows); return { transactionID: "batch-1", @@ -72,6 +74,7 @@ function reset() { txnState = undefined; getTransactionThrows = false; submitThrows = undefined; + submitThrowsError = undefined; stateFile = {}; saveStateCalls.length = 0; } @@ -180,3 +183,35 @@ test("an untracked batch (approvals/wrap) that loses its submit response writes assert.equal(saveStateCalls.length, 0, "only withdrawals are double-send-tracked"); assert.equal(stateFile.pendingWithdraw, undefined); }); + +// --- the double-send guard must arm ONLY when the outcome is unknown (round 2) --- + +test("a CLOB ApiError carries its 4xx on a PROPERTY — that is still a definite rejection", async () => { + // The SDK sets `.status` and leaves the message bare, so a matcher that only + // read the JSON shape armed the lock on an unambiguous refusal and wedged the + // user behind a 5-minute deadline for a transfer nothing had signed. + reset(); + submitThrowsError = Object.assign(new Error("request rejected"), { status: 403 }); + await assert.rejects( + sendWalletBatch(CALLS, DEPOSIT, "Withdraw", { trackPendingWithdraw: true }), + (err: Error) => { + assert.match(err.message, /request rejected/); + assert.doesNotMatch(err.message, /Do NOT retry/); + return true; + }, + ); + assert.equal(stateFile.pendingWithdraw, undefined, "a definite 4xx signed nothing"); +}); + +test("a bare HTTP 403 in the message is a definite rejection too", async () => { + reset(); + submitThrows = "CLOB credential derivation failed: HTTP 403 (forbidden)"; + await assert.rejects( + sendWalletBatch(CALLS, DEPOSIT, "Withdraw", { trackPendingWithdraw: true }), + (err: Error) => { + assert.doesNotMatch(err.message, /Do NOT retry/); + return true; + }, + ); + assert.equal(stateFile.pendingWithdraw, undefined); +}); From 8d0dcb4faed51c18ff96579f7efd493e4c7decb9 Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:52:03 -0400 Subject: [PATCH 12/37] feat(polymarket): enforce the previewed worst fill across the confirm, not just inside one call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Signed at the worst fill you saw" held within ONE call: the book walk that produced the preview also set the signed limit. But the preview and the confirm are two calls, and the confirm re-walks a fresh book — so a book that moved in between was signed at a price the card never displayed, which is the direction it moves exactly when it matters. blockrun_polymarket takes an optional max_fill_price for market orders: the worst fill the caller was shown. A walk that comes out worse is refused before anything is signed, with the two prices named and nothing charged. Buy is a ceiling, sell a floor. Absent, behaviour is unchanged and the walk stands on its own — this is a bound the caller opts into, not a new failure mode for callers who do not pass it. The order card carries its own displayed figure automatically, so the guarantee now holds for the surface that makes it. Four tests: a book that moved against the quote, one that moved in the user's favour (a better price is not a reason to refuse), the sell side's inverted comparison, and the unchanged no-bound path. 694 tests. Context figures resynced (12,767 full). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg --- README.md | 6 +-- apps/order-preview.ts | 9 +++- assets/context-cost-dark.svg | 6 +-- assets/context-cost.svg | 6 +-- docs/mcp-schema-overhead.md | 4 +- src/tools/polymarket.ts | 2 + src/utils/polymarket/orders.ts | 31 +++++++++++++ test/polymarket-trade-gating.test.ts | 69 ++++++++++++++++++++++++++++ 8 files changed, 120 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 6dded9a..6ba957e 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.7K tokens, 6% of a 200K context window, charged every turn whether or not you call a tool. 5.2K with --profile trading, 59% less. + Context cost: 12.8K tokens, 6% of a 200K context window, charged every turn whether or not you call a tool. 5.3K with --profile trading, 59% less.
@@ -218,8 +218,8 @@ Package managers have shown install size for decades. Almost no MCP server shows | Profile | Tools | Context | |---------|-------|---------| -| `full` *(default)* | 19 | 12,698 | -| `trading` | 8 | 5,201 | +| `full` *(default)* | 19 | 12,767 | +| `trading` | 8 | 5,270 | | `media` | 7 | 5,632 | | `research` | 5 | 2,664 | | `chat` | 3 | 2,005 | diff --git a/apps/order-preview.ts b/apps/order-preview.ts index 2bca4c4..8814e03 100644 --- a/apps/order-preview.ts +++ b/apps/order-preview.ts @@ -218,8 +218,13 @@ function renderPreview(p: Preview): void { cancel.hidden = false; return; } - const args = { action: p.action, ...currentArgs(), confirm: true }; - delete (args as Record).side; + // Carry the bound the user was actually shown into the confirm. Without it + // the server re-walks a fresh book and signs THAT worst fill, so a book + // that moved between the quote and the click filled at a price this card + // never displayed. With it, a worse walk is refused unsigned. + const args: Record = { action: p.action, ...currentArgs(), confirm: true }; + delete args.side; + if (typeof p.worstFillPrice === "number") args.max_fill_price = p.worstFillPrice; submitting = true; setBusy(place, true, "Submitting…"); setBusy(requote, true); cancel.hidden = true; amountField.disabled = true; diff --git a/assets/context-cost-dark.svg b/assets/context-cost-dark.svg index 015e3e4..f4d25d2 100644 --- a/assets/context-cost-dark.svg +++ b/assets/context-cost-dark.svg @@ -1,10 +1,10 @@ - + CONTEXT COST - 12.7K tokens + 12.8K tokens 6% of a 200K context window · every turn, whether or not you call a tool - 5.2K with --profile trading — 59% less + 5.3K with --profile trading — 59% less measured, not estimated diff --git a/assets/context-cost.svg b/assets/context-cost.svg index 665256a..505530b 100644 --- a/assets/context-cost.svg +++ b/assets/context-cost.svg @@ -1,10 +1,10 @@ - + CONTEXT COST - 12.7K tokens + 12.8K tokens 6% of a 200K context window · every turn, whether or not you call a tool - 5.2K with --profile trading — 59% less + 5.3K with --profile trading — 59% less measured, not estimated diff --git a/docs/mcp-schema-overhead.md b/docs/mcp-schema-overhead.md index 54f390c..8db21b0 100644 --- a/docs/mcp-schema-overhead.md +++ b/docs/mcp-schema-overhead.md @@ -16,8 +16,8 @@ Written 2026-09-01, verified against `@modelcontextprotocol/sdk` 1.29.0. Numbers | Profile | Tools | Context | |---------|-------|---------| -| `full` *(default)* | 19 | 12,698 | -| `trading` | 8 | 5,201 | +| `full` *(default)* | 19 | 12,767 | +| `trading` | 8 | 5,270 | | `media` | 7 | 5,632 | | `research` | 5 | 2,664 | | `chat` | 3 | 2,005 | diff --git a/src/tools/polymarket.ts b/src/tools/polymarket.ts index 96433e5..2d7385c 100644 --- a/src/tools/polymarket.ts +++ b/src/tools/polymarket.ts @@ -56,6 +56,8 @@ Prices are probabilities 0–1 on the market's tick grid. token_id comes from bl .describe("pUSD dollars — to spend (market buy) or to cash out (withdraw; default full balance)"), order_type: z.enum(["GTC", "GTD", "FOK", "FAK"]).optional() .describe("Default: GTC for limit orders, FOK for market orders"), + max_fill_price: z.number().gt(0).lt(1).optional() + .describe("Market orders only: the worst fill you accept (0-1). Carry the preview's worst-fill figure into the confirm and a book that moved in between is refused rather than signed at the new price. Buy = ceiling, sell = floor."), expires_at: z.number().int().positive().optional() .describe("Unix seconds expiry (GTD only, ≥ ~3 min in the future)"), post_only: z.boolean().optional() diff --git a/src/utils/polymarket/orders.ts b/src/utils/polymarket/orders.ts index b4f0fe4..a8e806b 100644 --- a/src/utils/polymarket/orders.ts +++ b/src/utils/polymarket/orders.ts @@ -355,6 +355,18 @@ export interface TradeInput { post_only?: boolean; confirm?: boolean; agent_id?: string; + /** + * The worst fill the CALLER was shown, carried from a dry-run preview into + * the confirm. Market orders are signed at the worst level this call's own + * book walk consumes, so "signed at the worst fill you saw" holds within one + * call — and the preview and the confirm are two calls. The book can move in + * between, and it moves against you exactly when it matters. + * + * When present, a walk that comes out worse than this is REFUSED before + * anything is signed, rather than silently signed at the new number. Absent, + * behaviour is unchanged: the walk stands on its own. + */ + max_fill_price?: number; } export interface ToolResult { @@ -438,6 +450,25 @@ export async function executeTrade(input: TradeInput): Promise { ? roundToTick(walk.worstPrice ?? (quote as number), tickSize, input.action) : undefined; + // The preview's bound, enforced. Buy: a worse fill is a HIGHER price; + // sell: a worse fill is a LOWER one. + if (!isLimit && input.max_fill_price !== undefined && worstFillPrice !== undefined) { + const worse = input.action === "buy" + ? worstFillPrice > input.max_fill_price + : worstFillPrice < input.max_fill_price; + if (worse) { + const dir = input.action === "buy" ? "above" : "below"; + return { + text: + `The book moved since that quote: this order would fill at ${worstFillPrice} (${(worstFillPrice * 100).toFixed(1)}¢), ` + + `${dir} the ${input.max_fill_price} (${(input.max_fill_price * 100).toFixed(1)}¢) you were shown. Nothing was signed and nothing was charged. ` + + `Re-quote to see the current price, or pass max_fill_price yourself to set the bound you will accept.`, + isError: true, + structured: { refused: "worse_than_quoted", worstFillPrice, maxFillPrice: input.max_fill_price, action: input.action }, + }; + } + } + const notional = isLimit ? (price as number) * (size as number) : input.action === "buy" diff --git a/test/polymarket-trade-gating.test.ts b/test/polymarket-trade-gating.test.ts index 7feccb2..8fa9353 100644 --- a/test/polymarket-trade-gating.test.ts +++ b/test/polymarket-trade-gating.test.ts @@ -334,3 +334,72 @@ test("a FOK market sell the bid book cannot absorb is refused pre-sign, like the mock.restoreAll(); } }); + +// --- the previewed bound, enforced across the preview→confirm boundary --- +// +// "Signed at the worst fill you saw" held within ONE call: the walk that +// produced the preview also set the signed limit. The preview and the confirm +// are two calls, and the confirm re-walks a fresh book — so a book that moved +// in between was signed at a price the card never displayed. It moves against +// you exactly when it matters. + +test("a book that moved against the quote is refused, unsigned, when the bound is carried", async () => { + mock.method(fakeClob, "getOrderBook", async () => ({ + tick_size: "0.01", neg_risk: false, min_order_size: "5", + asks: [{ price: "0.40", size: "25" }], bids: [{ price: "0.39", size: "100" }], + })); + const preview = await executeTrade({ action: "buy", token_id: "111", amount_usd: 5 }); + const quoted = (preview.structured as { worstFillPrice: number }).worstFillPrice; + assert.equal(quoted, 0.4); + + // The cheap level is gone by the time the user clicks Confirm. + mock.method(fakeClob, "getOrderBook", async () => ({ + tick_size: "0.01", neg_risk: false, min_order_size: "5", + asks: [{ price: "0.55", size: "25" }], bids: [{ price: "0.39", size: "100" }], + })); + const before = calls.length; + const res = await executeTrade({ action: "buy", token_id: "111", amount_usd: 5, confirm: true, max_fill_price: quoted }); + assert.equal(res.isError, true, res.text); + assert.match(res.text, /book moved/); + assert.match(res.text, /nothing was charged/i); + assert.equal((res.structured as { refused?: string }).refused, "worse_than_quoted"); + assert.equal(calls.length, before, "nothing may be signed"); +}); + +test("a book that moved in the user's FAVOUR still places", async () => { + mock.method(fakeClob, "getOrderBook", async () => ({ + tick_size: "0.01", neg_risk: false, min_order_size: "5", + asks: [{ price: "0.30", size: "25" }], bids: [{ price: "0.29", size: "100" }], + })); + const before = calls.length; + const res = await executeTrade({ action: "buy", token_id: "111", amount_usd: 5, confirm: true, max_fill_price: 0.4 }); + assert.equal(res.isError, undefined, res.text); + assert.equal(calls.length, before + 1, "a better price is not a reason to refuse"); +}); + +test("a sell is bounded the other way — a LOWER fill is the worse one", async () => { + mock.method(fakeClob, "getOrderBook", async () => ({ + tick_size: "0.01", neg_risk: false, min_order_size: "5", + asks: [{ price: "0.60", size: "100" }], bids: [{ price: "0.45", size: "100" }], + })); + const before = calls.length; + const worse = await executeTrade({ action: "sell", token_id: "111", size: 10, confirm: true, max_fill_price: 0.5 }); + assert.equal(worse.isError, true, worse.text); + assert.match(worse.text, /book moved/); + assert.equal(calls.length, before, "nothing signed on a sell below the floor"); + + const ok = await executeTrade({ action: "sell", token_id: "111", size: 10, confirm: true, max_fill_price: 0.4 }); + assert.equal(ok.isError, undefined, ok.text); + assert.equal(calls.length, before + 1); +}); + +test("without the bound, behaviour is unchanged — the walk stands on its own", async () => { + mock.method(fakeClob, "getOrderBook", async () => ({ + tick_size: "0.01", neg_risk: false, min_order_size: "5", + asks: [{ price: "0.55", size: "25" }], bids: [{ price: "0.39", size: "100" }], + })); + const before = calls.length; + const res = await executeTrade({ action: "buy", token_id: "111", amount_usd: 5, confirm: true }); + assert.equal(res.isError, undefined, res.text); + assert.equal(calls.length, before + 1); +}); From 084971a79eea7625e083c4dd01dedd0a01c77ed0 Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:54:08 -0400 Subject: [PATCH 13/37] =?UTF-8?q?0.50.0=20=E2=80=94=20a=20second=20audit?= =?UTF-8?q?=20round,=20aimed=20at=20the=20first=20one?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.49.0's thirty-seven fixes were written by six agents in parallel, and this round went looking for what that costs. It found the shape immediately: each agent had hardened the rail it was looking at. Thirty findings survived adversarial verification and NOT ONE was a P0 or a P1 — 0.49.0's own list had one of each — so the general search is spent. What is not spent is that class, so the last change here is a table rather than a fix: test/rail-parity.test.ts states which treatments every paid tool needs on every rail, and turns red when a rail-specific guard lands without its siblings. It found four more gaps on its first run. Minor, not patch: max_fill_price is a new input, the ledger now books the observed charge rather than the reserve, and the order card refuses submits it used to allow. 694 tests, typecheck, build and verify:prices all green (0 under-reserving on either chain; the catalogue sweep now names the free-list members it cannot verify instead of counting them as checked). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg --- CHANGELOG.md | 96 +++++++++++++++++++++++++++++++++++++++++++++++ package-lock.json | 4 +- package.json | 2 +- 3 files changed, 99 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d57e4c..5504cf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,102 @@ All notable changes to BlockRun MCP will be documented in this file. +## 0.50.0 + +**A second audit round, aimed at the first one.** 0.49.0's thirty-seven fixes +were written by six agents working in parallel, and this round went looking for +what that costs. It found the shape immediately: each agent had hardened the +rail it was looking at. The quote guard landed on video and image but not music +and speech. The in-flight booking landed on Base and the account rail but not +Solana, the default chain. Music's Solana call passed no `onQuote` at all, so +the guard hook fired against nobody while the transfer was signed. Every one of +those was a money path and every one passed CI. + +Thirty findings survived adversarial verification, and **not one was a P0 or a +P1** — 0.49.0's own list had one of each. That is the honest headline: the +general search is spent. What is not spent is the class above, so the last +change here is not a fix but a table. + +**The rail-parity matrix.** `test/rail-parity.test.ts` states, per paid tool and +per rail, which treatments a paid call needs: a quote checked before signing, a +re-reservation at the real price, in-flight booking, honest give-up wording, and +the right ledger figure. A cell is a claim about the source, so adding a +rail-specific guard without filling in its siblings turns the file red. It also +pins the division that is deliberate — the seven tools whose 402 the SDK owns +must NOT grow a quote guard, because they cannot see the quote — and fails when +a paid tool is missing from the table altogether. It found four more gaps on its +first run. + +### The money paths + +- **A strict-keychain wallet could be moved off its funded chain by reading its + own status.** 0.49.0's own P0 fix stored the new Solana key and deleted the + session file, which is exactly what `getChain()` keys on — so the continuity + pin was never written and the next start moved a funded Base user onto an + empty Solana wallet. The pin is now written off the provisioning fact rather + than re-derived from caches the mint just invalidated. +- **Two concurrent callers minted two Solana wallets.** The cache was assigned + after the await, and 0.49.0 made that reachable from two entry points at once, + so one caller could be handed a funding QR for an address whose key was thrown + away. Single-flighted — and the rejection is deliberately not cached, or + unlocking a keychain and retrying would stay broken until restart. +- **The order card could submit the same order twice.** The stale-amount guard + re-enabled an armed Place button mid-submit, and its catch treated every + transport failure as "nothing happened". Both now distinguish "we know nothing + was signed" from "we do not know", which is the difference between a retry and + a duplicate bet. +- **The previewed worst fill is now enforced across the confirm**, not just + inside one call: `max_fill_price` refuses a book that moved against the quote + before anything is signed, and the card carries its own displayed figure. +- **A chat call the account rail billed and then dropped booked $0** and read as + a free failure, whose obvious next step is to pay for it again. +- **`blockrun_music`, `blockrun_speech` and `blockrun_realface` signed whatever + the 402 quoted**, with no sanity check and no re-reservation. **Giving up on + Solana booked nothing** in video and music, and **speech, image and realface + had no in-flight tracking at all**, so an abort after the gateway settled left + a real charge unbooked. +- **The ledger booked the reserve, not the charge.** `tx-fee.ts` has said since + 0.40.1 that the gate and the ledger are different numbers, and one file + honoured it. On Solana, where the gateway charges no transaction fee, an agent + capped at $1.00 making only `blockrun_rpc` calls was cut off after 250 of them + having actually spent $0.50 — and `action:"report"` said $1.00. +- **A per-agent cap could refill itself.** `delegate` wrote `spent: 0` + unconditionally, and `delegate` is a tool the model can call. A limit is the + operator's to raise; spend already happened and is not theirs to erase. +- **The Polymarket approval prompt never said what it was worth**: the default + grants an unlimited pUSD allowance to four spenders. It says so now, before + the signature, and names `POLYMARKET_BOUNDED_APPROVALS`. +- **The relayer's double-send guard armed on failures that signed nothing** — + credential derivation happens before the batch exists — and its 4xx detector + never saw a CLOB `ApiError`'s status, so definite refusals looked ambiguous + and wedged the user behind a deadline for a transfer that was never made. + +### Saying the true thing + +- **"Your wallet needs funding" no longer appears on messages that say nothing + was charged.** The uncharged markers gated one keyword clause, so a bare 402 + or the word "balance" still earned the footer — including on this repo's own + unreadable-quote refusal, which told a wallet holding $1,000 to top up. +- **Account-rail errors are classified at all.** The status boundary excluded a + following dot, which is what keeps `$402.50` from reading as a status — and + also what made every `BlockRun account API error: 502.` fall through silently + while the identical wallet-rail message got guidance. +- **A locked keychain is not a missing wallet**, and telling that user to run + setup invites a second one. +- **`blockrun_video` and `blockrun_realface`** stopped offering a card top-up + for a wallet that is not paying on the account rail, and the wallet card + stopped offering card top-up on Solana, where it is not available. + +### Around the edges + +The model catalogue cache is keyed by rail and chain (the two gateways do not +serve the same one), `blockrun_models` is annotated as reaching the network +because it does, the video poll timeout matches the gateway route's own 60s +limit, `SOLANA_RPC_HEADERS` is honoured again so a private RPC works, a settled +Solana response whose body will not parse still books the charge, and the MCP +registry publisher is pinned and checksum-verified instead of curled from +`releases/latest` into the job that holds the npm token. + ## 0.49.0 **The error says whether money moved.** Issue #132 reported `blockrun_markets` diff --git a/package-lock.json b/package-lock.json index 7dd42a7..241b3a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@blockrun/mcp", - "version": "0.49.0", + "version": "0.50.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@blockrun/mcp", - "version": "0.49.0", + "version": "0.50.0", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.123.0", diff --git a/package.json b/package.json index d88d833..256ef5f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@blockrun/mcp", - "version": "0.49.0", + "version": "0.50.0", "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", From 90a8ad460256785d46d508e1825fefc796643b12 Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:52:05 -0400 Subject: [PATCH 14/37] fix(wallet): an empty .session must not overwrite a funded key in the keychain The gate deciding whether to consult the OS keychain asked `existsSync`. The loaders on the far side of it -- the SDK's resolveFromFiles() and loadSolanaWallet() -- both `.trim()` the file and treat whitespace as NO KEY. So a zero-byte session file read as PRESENT to the gate and ABSENT to the loader, and the two disagreed in the one direction that costs money: the keychain was skipped, a brand new wallet was minted, and the persistKey() call immediately after overwrote the keychain entry that still held the funded key. No delete call is involved and nothing is printed. saveWallet() is a plain non-atomic writeFileSync, so an interrupted write, a full disk, a restore tool's placeholder or a stray shell redirect all leave exactly this file. Both rails now ask whether the file HOLDS a key. getChain() already asked it that way in two places, each with a comment explaining why existsSync is the wrong question; these were the two callers that had not. An unreadable file counts as present: we cannot tell whether it holds a key, and guessing toward the keychain is how a stale entry shadows a live wallet -- the loader then hits the same unreadable file and fails loudly, which is the outcome we want. Also fixes keychainDelete's platform asymmetry. It documents "true when the entry is gone, including was never there" and only macOS honoured that; LINUX_ITEM_NOT_FOUND was defined in the file and unused, so `secret-tool clear` on a miss reported the key as still in the keychain when it was not. It had zero callers, which is why nothing caught it. Tests: both empty-file cases are red before this change and green after (verified by reverting each gate in turn); the file-outranks-keychain precedence test stays green in both directions, so rotation by replacing ~/.blockrun/.session is unaffected. keychain-delete.test.ts covers both backends without spawning a real helper. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg --- CHANGELOG.md | 14 +++- src/utils/keychain.ts | 17 ++++- src/utils/wallet.ts | 41 ++++++++++-- test/keychain-delete.test.ts | 111 +++++++++++++++++++++++++++++++ test/keychain-precedence.test.ts | 73 ++++++++++++++++++++ 5 files changed, 249 insertions(+), 7 deletions(-) create mode 100644 test/keychain-delete.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5504cf7..a267ffc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,15 @@ first run. before anything is signed, and the card carries its own displayed figure. - **A chat call the account rail billed and then dropped booked $0** and read as a free failure, whose obvious next step is to pay for it again. +- **An empty `~/.blockrun/.session` overwrote a funded key in the keychain.** + The gate that decides whether to consult the keychain asked `existsSync`; the + loaders on the far side of it trim the file and treat whitespace as no key. + A zero-byte session file therefore read as present to the gate and absent to + the loader, so the keychain was skipped, a new wallet was minted, and the + mirror-back overwrote the entry that still held the funded key. `saveWallet` + is a plain non-atomic write, so an interrupted one is enough to produce that + file. Both rails now ask whether the file HOLDS a key, which is what + `getChain()` already asked, twice, with comments saying why. - **`blockrun_music`, `blockrun_speech` and `blockrun_realface` signed whatever the 402 quoted**, with no sanity check and no re-reservation. **Giving up on Solana booked nothing** in video and music, and **speech, image and realface @@ -96,7 +105,10 @@ because it does, the video poll timeout matches the gateway route's own 60s limit, `SOLANA_RPC_HEADERS` is honoured again so a private RPC works, a settled Solana response whose body will not parse still books the charge, and the MCP registry publisher is pinned and checksum-verified instead of curled from -`releases/latest` into the job that holds the npm token. +`releases/latest` into the job that holds the npm token. `keychainDelete` now +answers the same on both backends: it documents "gone, including was never +there", and only macOS honoured that, so a Linux miss reported the key as still +in the keychain when it was not. ## 0.49.0 diff --git a/src/utils/keychain.ts b/src/utils/keychain.ts index a04638c..3511ea5 100644 --- a/src/utils/keychain.ts +++ b/src/utils/keychain.ts @@ -278,7 +278,20 @@ export function keychainLoad(account: string): string | null { } } -/** Delete a secret. Returns true when the entry is gone (including "was never there"). */ +/** + * Delete a secret. Returns true when the entry is gone, including when it was + * never there — callers care about the end state, not about who removed it. + * + * Both backends have to spell that out, and only the macOS branch used to. + * `secret-tool clear` is not consistent across versions about whether a miss + * exits 0 or 1, so accept LINUX_ITEM_NOT_FOUND alongside success; the entry is + * absent either way. Returning false there would tell a caller the key is + * still in the keychain when it is not — the exact direction that turns a + * cleanup into a retry loop or a refusal to re-provision. + * + * A platform with no keychain returns false: nothing was deleted and nothing + * can be, which keychainAvailable() already reports the same way. + */ export function keychainDelete(account: string): boolean { const platform = os.platform(); try { @@ -297,7 +310,7 @@ export function keychainDelete(account: string): boolean { ["clear", "app", KEYCHAIN_SERVICE, "account", account], { timeout: TIMEOUT_MS, encoding: "utf-8" }, ); - return result.status === 0; + return result.status === 0 || result.status === LINUX_ITEM_NOT_FOUND; } return false; diff --git a/src/utils/wallet.ts b/src/utils/wallet.ts index 9e3b9e3..6f5ba33 100644 --- a/src/utils/wallet.ts +++ b/src/utils/wallet.ts @@ -114,6 +114,38 @@ function hasKeychainEvmKey(): boolean { return _keychainEvmKeyPresent; } +/** + * Does this key file actually HOLD a key? + * + * `existsSync` alone is the wrong question at a keychain gate. The loaders on + * the far side of that gate — the SDK's resolveFromFiles() and + * loadSolanaWallet() — both `.trim()` the file and treat whitespace as NO KEY. + * So a zero-byte session file reads as "present" to the gate and "absent" to + * the loader, and the two disagree in the one direction that costs money: + * the gate skips the keychain, the loader mints a BRAND NEW wallet, and the + * persistKey() call right after it overwrites the keychain entry that still + * held the funded key. Silent, unrecoverable, and reachable without anyone + * calling a delete — saveWallet() is a plain non-atomic writeFileSync, so an + * interrupted write, a full disk, a restore tool's placeholder or a stray + * shell redirect all leave exactly this file behind. + * + * getChain() already asks the question this way (twice, with comments saying + * why). These are the two callers that did not. + * + * A file we cannot READ counts as present. We have no idea whether it holds a + * key, and consulting the keychain on that guess is how a stale entry shadows + * a live wallet; the loader then hits the same unreadable file and fails + * loudly, which is the outcome we want. + */ +function keyFileHasKey(file: string): boolean { + try { + if (!fs.existsSync(file)) return false; + return fs.readFileSync(file, "utf-8").trim() !== ""; + } catch { + return true; + } +} + /** * Does this machine already hold a BASE wallet the user may have funded? * @@ -410,7 +442,7 @@ function ensureEvmWallet() { if ( !process.env.BLOCKRUN_WALLET_KEY && getKeychainMode() !== "off" && - !fs.existsSync(WALLET_FILE_PATH) + !keyFileHasKey(WALLET_FILE_PATH) ) { const read = keychainRead(EVM_KEY_ACCOUNT); @@ -498,9 +530,10 @@ function resolveSolanaKeyDetailed(): SolanaKeyResolution { 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)) { + // Same precedence correction as the EVM path: a .solana-session that HOLDS a + // key is the user's current intent, so it outranks whatever the keychain + // remembers. An empty one holds no intent — see keyFileHasKey. + if (getKeychainMode() !== "off" && !keyFileHasKey(SOLANA_WALLET_FILE_PATH)) { const read = keychainRead(SOLANA_KEY_ACCOUNT); if (read.status === "found") { _solanaKey = read.value; diff --git a/test/keychain-delete.test.ts b/test/keychain-delete.test.ts new file mode 100644 index 0000000..e9dc83c --- /dev/null +++ b/test/keychain-delete.test.ts @@ -0,0 +1,111 @@ +// Run with: npm test (tsx --experimental-test-module-mocks --test) +// +// keychainDelete's contract, on both backends. +// +// It documents "true when the entry is gone, INCLUDING was never there", and +// the macOS branch honoured that by accepting errSecItemNotFound. The Linux +// branch accepted only exit 0, so `secret-tool clear` on a miss reported the +// entry as still present — the one direction that misleads a caller, since it +// says a key is in the keychain when it is not. LINUX_ITEM_NOT_FOUND was +// already defined in the file and simply unused here. +// +// Nothing below spawns a real keychain helper: node:child_process is mocked, +// so `security` and `secret-tool` are never invoked and the login keychain is +// never touched. +import { test, mock, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import realOs from "node:os"; + +let platform = "darwin"; +let result: { status: number | null; stdout: string } = { status: 0, stdout: "" }; +let calls: Array<{ bin: string; args: string[] }> = []; + +mock.module("node:os", { + defaultExport: { ...realOs, platform: () => platform }, +}); + +mock.module("node:child_process", { + namedExports: { + spawnSync: (bin: string, args: string[]) => { + calls.push({ bin, args }); + return result; + }, + }, +}); + +const { keychainDelete, KEYCHAIN_SERVICE } = await import("../src/utils/keychain.js"); + +beforeEach(() => { + calls = []; +}); + +test("macOS: a successful delete reports the entry gone", () => { + platform = "darwin"; + result = { status: 0, stdout: "" }; + assert.equal(keychainDelete("evm-wallet-key"), true); + assert.equal(calls[0].bin, "/usr/bin/security"); + assert.deepEqual(calls[0].args, [ + "delete-generic-password", + "-s", + KEYCHAIN_SERVICE, + "-a", + "evm-wallet-key", + ]); +}); + +test("macOS: 'was never there' (errSecItemNotFound) is also gone", () => { + platform = "darwin"; + result = { status: 44, stdout: "" }; + assert.equal(keychainDelete("evm-wallet-key"), true); +}); + +test("Linux: a successful clear reports the entry gone", () => { + platform = "linux"; + result = { status: 0, stdout: "" }; + assert.equal(keychainDelete("solana-wallet-key"), true); + assert.equal(calls[0].bin, "/usr/bin/secret-tool"); + assert.deepEqual(calls[0].args, [ + "clear", + "app", + KEYCHAIN_SERVICE, + "account", + "solana-wallet-key", + ]); +}); + +test("Linux: 'was never there' is gone too, matching macOS and the documented contract", () => { + platform = "linux"; + result = { status: 1, stdout: "" }; + assert.equal( + keychainDelete("solana-wallet-key"), + true, + "returning false here claims the key is still in the keychain when it is not", + ); +}); + +test("a real failure stays false on both backends", () => { + platform = "darwin"; + result = { status: 51, stdout: "" }; + assert.equal(keychainDelete("evm-wallet-key"), false, "authorization denied is not a delete"); + + platform = "linux"; + result = { status: 2, stdout: "" }; + assert.equal(keychainDelete("solana-wallet-key"), false); +}); + +test("a timeout (status null) is never mistaken for a delete", () => { + platform = "darwin"; + result = { status: null, stdout: "" }; + assert.equal(keychainDelete("evm-wallet-key"), false); + + platform = "linux"; + result = { status: null, stdout: "" }; + assert.equal(keychainDelete("solana-wallet-key"), false); +}); + +test("a platform with no keychain deletes nothing and says so", () => { + platform = "win32"; + result = { status: 0, stdout: "" }; + assert.equal(keychainDelete("evm-wallet-key"), false); + assert.equal(calls.length, 0, "no helper may be spawned on an unsupported platform"); +}); diff --git a/test/keychain-precedence.test.ts b/test/keychain-precedence.test.ts index f0d6457..7144886 100644 --- a/test/keychain-precedence.test.ts +++ b/test/keychain-precedence.test.ts @@ -155,3 +155,76 @@ test("Solana: an existing session file outranks a stale keychain entry", async ( readAnswer = { status: "found", value: KEYCHAIN_KEY }; }); + +// --- the empty-file gap (audit round 3) --- +// +// Both gates above asked `existsSync`. The loaders on the far side of them +// (the SDK's resolveFromFiles / loadSolanaWallet) `.trim()` the file and treat +// whitespace as no key. A zero-byte session file therefore read as PRESENT to +// the gate and ABSENT to the loader: the keychain was skipped, a brand new +// wallet was minted, and persistKey() then overwrote the keychain entry still +// holding the funded key. saveWallet() is a plain non-atomic writeFileSync, so +// an interrupted write or a full disk is enough to produce that file. + +test("EVM: an EMPTY session file does not shadow the funded key in the keychain", async () => { + const { resetEvmWalletCache } = await import("../src/utils/wallet.js"); + resetEvmWalletCache(); + + const session = path.join(home, ".blockrun", ".session"); + fs.writeFileSync(session, " \n", { mode: 0o600 }); + mode = "auto"; + readAnswer = { status: "found", value: KEYCHAIN_KEY }; + + const resolved = getOrCreateWalletKey(); + + assert.equal( + resolved, + KEYCHAIN_KEY, + "a file holding no key must fall through to the keychain, not mint over it", + ); + assert.equal( + fs.readFileSync(session, "utf-8").trim(), + "", + "nothing may be minted and saved while a funded key sits in the keychain", + ); + + fs.rmSync(session, { force: true }); + resetEvmWalletCache(); +}); + +test("Solana: an EMPTY session file does not shadow the funded key in the keychain", async () => { + const { createSolanaWallet, solanaPublicKey } = await import("@blockrun/llm"); + const { ensureSolanaWallet, resetSolanaKeyCache } = await import("../src/utils/wallet.js"); + const funded = await createSolanaWallet(); + + const session = path.join(home, ".blockrun", ".solana-session"); + fs.writeFileSync(session, "\n", { mode: 0o600 }); + resetSolanaKeyCache(); + mode = "auto"; + readAnswer = { status: "found", value: funded.privateKey }; + + const info = await ensureSolanaWallet(); + + assert.equal(info.isNew, false, "minting here orphans the key the keychain still holds"); + assert.equal(info.privateKey, funded.privateKey); + assert.equal(info.address, await solanaPublicKey(funded.privateKey)); + + fs.rmSync(session, { force: true }); + resetSolanaKeyCache(); + readAnswer = { status: "found", value: KEYCHAIN_KEY }; +}); + +test("a file that HOLDS a key still outranks the keychain (the empty-file fix did not invert precedence)", async () => { + const { resetEvmWalletCache } = await import("../src/utils/wallet.js"); + resetEvmWalletCache(); + + const session = path.join(home, ".blockrun", ".session"); + fs.writeFileSync(session, FILE_KEY + "\n", { mode: 0o600 }); + mode = "auto"; + readAnswer = { status: "found", value: KEYCHAIN_KEY }; + + assert.equal(getOrCreateWalletKey(), FILE_KEY, "rotation by replacing the file must keep working"); + + fs.rmSync(session, { force: true }); + resetEvmWalletCache(); +}); From ace5f2e1c87c6944d1c39b28117a7468a5c8d6a8 Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:55:57 -0400 Subject: [PATCH 15/37] docs(polymarket): stop l1-auth-1271 from telling the next maintainer to delete the live credential path The file header described the ERC-7739 wrapped L1 signature as the workaround in force and signed off with "if fixed upstream, delete this module and use client.createOrDeriveApiKey()". Both halves are now wrong, and following the second one breaks trading. The wrap was the wrong diagnosis of clob-client-v2#65: the CLOB rejects the wrapped envelope with "Invalid L1 Request headers", L2 creds are bound to the owner EOA even in POLY_1271 mode (matching rs-clob-client-v2 src/auth.rs), and both call sites pass sigType 0. The long note at the buildClobClient() call site in client.ts has said so since; the module header never caught up. And the module has meanwhile become the home of deriveApiCreds(), which every Polymarket action needs -- so deleting it on a version bump removes credential derivation entirely. Header rewritten to say what is true: the wrapped path is a tested reference implementation of the envelope, correct about the bytes and wrong about what the server wants, reachable by no caller. deriveApiCreds's own doc no longer offers sigType 3 as if it were a working alternative. Also adds test/axios-scope.test.ts. applyClobProxyOnce() sets axios.defaults.httpsAgent process-wide, which is unavoidable -- clob-client-v2 reaches for the hoisted axios itself, so there is no instance to scope. The whole safety argument is that only Polymarket shares that axios, and it lived in a comment. Now a non-Polymarket axios import turns the suite red instead of silently routing that module's traffic through an operator's POLYMARKET_CLOB_PROXY. Verified by adding an import to utils/http.ts and watching it fail. The second case pins that the Finland default is a HOST, not a proxy, so axios.defaults stays untouched unless an operator opts in. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg --- CHANGELOG.md | 13 ++++++ src/utils/polymarket/l1-auth-1271.ts | 57 ++++++++++++++++------- test/axios-scope.test.ts | 69 ++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 16 deletions(-) create mode 100644 test/axios-scope.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a267ffc..379048a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -110,6 +110,19 @@ answers the same on both backends: it documents "gone, including was never there", and only macOS honoured that, so a Linux miss reported the key as still in the keychain when it was not. +Two comments were retired for saying things the code no longer does. +`l1-auth-1271.ts` still opened by describing the ERC-7739 wrapped L1 signature +as the workaround in force and closed by telling a future maintainer to delete +the module once the upstream issue is fixed. The wrap was the wrong diagnosis +-- the CLOB answers "Invalid L1 Request headers", both call sites derive as a +plain EOA -- and the module now holds `deriveApiCreds`, so following that +instruction would remove credential derivation and stop all trading. And the +argument that `applyClobProxyOnce`'s process-wide `axios.defaults` mutation is +safe (every axios importer is a Polymarket one, everything else uses fetch) +lived only in a comment, which cannot fail; `test/axios-scope.test.ts` now +fails the day a non-Polymarket module imports axios and would start routing +through an operator's `POLYMARKET_CLOB_PROXY` unasked. + ## 0.49.0 **The error says whether money moved.** Issue #132 reported `blockrun_markets` diff --git a/src/utils/polymarket/l1-auth-1271.ts b/src/utils/polymarket/l1-auth-1271.ts index 270bde8..2e54a94 100644 --- a/src/utils/polymarket/l1-auth-1271.ts +++ b/src/utils/polymarket/l1-auth-1271.ts @@ -1,24 +1,44 @@ // src/utils/polymarket/l1-auth-1271.ts // -// Workaround for https://github.com/Polymarket/clob-client-v2/issues/65 +// CLOB L1 authentication and API-credential derivation. +// +// READ THIS BEFORE DELETING ANYTHING HERE. The file is named for a hypothesis +// that turned out to be wrong, and the function every Polymarket action +// depends on now lives in it: deriveApiCreds(). Removing the module removes +// credential derivation, and all trading stops. +// +// The hypothesis was https://github.com/Polymarket/clob-client-v2/issues/65 // (open as of v1.0.8, 2026-07): the SDK's createApiKey()/createL1Headers() // signs the L1 ClobAuth attestation as a PLAIN EOA signature bound to the EOA -// address, while POLY_1271 orders set order.signer = deposit wallet — so the -// CLOB rejects every order with 400 "the order signer address has to be the -// address of the API KEY". The SDK's ORDER signing does wrap correctly -// (ExchangeOrderBuilderV2.buildOrderSignature); only L1 auth lacks the wrap. +// address, while POLY_1271 orders set order.signer = deposit wallet, and the +// CLOB rejects those orders with 400 "the order signer address has to be the +// address of the API KEY". The obvious reading was that L1 auth needed the +// same ERC-7739 wrap the SDK already applies to orders, and +// buildWrapped1271Headers() below implements exactly that. +// +// It is not the fix. The CLOB rejects the wrapped envelope outright with +// "Invalid L1 Request headers". L2 credentials are ALWAYS bound to the owner +// EOA even in POLY_1271 mode, matching the reference Rust client +// (rs-clob-client-v2 src/auth.rs): L1/L2 auth uses the owner's plain ECDSA +// signature, and only the ORDER carries signer/maker = deposit wallet, checked +// on-chain by that wallet's ERC-1271 isValidSignature. See the long note at +// the buildClobClient() call site in client.ts. // -// This module applies the SAME ERC-7739 TypedDataSign envelope the SDK uses -// for orders to the L1 ClobAuth message, with POLY_ADDRESS = the deposit -// wallet, so the derived API creds are bound to the deposit wallet: +// So the live path is buildPlainL1Headers(), and both call sites +// (client.ts, relayer.ts) pass sigType 0. The wrapped path below is kept as a +// tested reference implementation of the ERC-7739 envelope for ClobAuth -- +// it is correct about the envelope and wrong about what the server wants -- +// and test/polymarket-l1-auth.test.ts pins its byte layout. Nothing calls it +// with sigType 3. // // contentsHash = hashStruct(ClobAuth message) (app = ClobAuthDomain v1) // innerSig = eth_signTypedData(TypedDataSign{contents, DepositWallet domain}) -// envelope = innerSig ‖ appDomainSeparator ‖ contentsHash -// ‖ typeString(ClobAuth) ‖ uint16(len(typeString)) +// envelope = innerSig | appDomainSeparator | contentsHash +// | typeString(ClobAuth) | uint16(len(typeString)) // -// Re-check issue #65 when bumping @polymarket/clob-client-v2 — if fixed -// upstream, delete this module and use client.createOrDeriveApiKey(). +// When bumping @polymarket/clob-client-v2, what to re-check is whether the SDK +// can now derive creds itself (client.createOrDeriveApiKey()) -- not whether +// issue #65 is closed, since its premise was already wrong. import axios from "axios"; import { encodeAbiParameters, @@ -177,10 +197,15 @@ async function buildPlainL1Headers(account: PrivateKeyAccount): Promise { + const offenders: string[] = []; + for (const file of walk(SRC)) { + const source = readFileSync(file, "utf-8"); + if (!/(^|\n)\s*import[^;]*from\s+["']axios["']/.test(source) && + !/require\(\s*["']axios["']\s*\)/.test(source)) continue; + const rel = path.relative(SRC, file); + if (!rel.startsWith(POLYMARKET_DIR)) offenders.push(rel); + } + + assert.deepEqual( + offenders, + [], + "applyClobProxyOnce() mutates axios.defaults process-wide; a non-Polymarket " + + "axios caller would silently route through POLYMARKET_CLOB_PROXY. Use fetch, " + + "or give this module its own axios instance with an explicit agent.", + ); +}); + +test("the proxy is only installed when the operator asks for one", async () => { + const saved = process.env.POLYMARKET_CLOB_PROXY; + delete process.env.POLYMARKET_CLOB_PROXY; + const { getClobProxy } = await import("../src/utils/polymarket/constants.js"); + + assert.equal( + getClobProxy(), + undefined, + "the Finland default is a HOST (POLYMARKET_CLOB_HOST), not a proxy — if a " + + "default ever lands here, axios.defaults gets mutated for every user", + ); + + if (saved === undefined) delete process.env.POLYMARKET_CLOB_PROXY; + else process.env.POLYMARKET_CLOB_PROXY = saved; +}); From 4e357af65a2483cfff75b6d363b4ecc3cacbb973 Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:59:36 -0400 Subject: [PATCH 16/37] fix(scripts): the live e2e scripts printed the wallet address they promised to hide Three of the four Polymarket e2e scripts state in their own doc comment that wallet addresses and transaction ids are never printed. Each implemented that with a different regex: e2e-readonly 0x[hex]{40} -> addresses only e2e-approve 0x[hex]{40,} -> both, unlabelled e2e-live 0x[hex]{64} -> hashes only e2e-withdraw 0x[hex]{64} -> hashes only The last two are the ones that move real money, and {64} does not match a 40-hex address. withdraw.ts interpolates the bridge response into its error text and that response carries `address.evm`, so the address printed in full. Only the isError branch was guarded at all. A thrown exception -- a network failure inside fetchPositions(), a viem revert -- bypassed redaction entirely and Node printed the raw message and stack to stderr. One redaction now, in scripts/redact.ts, longest-match-first in a single pass so a hash cannot be half-eaten by the address rule, applied on every exit path including uncaught throws. A 32-byte private key comes out as : mislabelled, but not printed, which is the direction that matters. test/scripts-redaction.test.ts covers the helper and fails if any e2e script grows its own 0x[a-fA-F0-9]{...} regex again. scripts/ is now in tsconfig include. These files import from src/ and were typechecked by nothing, so a changed signature in withdrawFunds or redeemPosition would first show up while running against a funded wallet. Adding them surfaced no errors. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg --- CHANGELOG.md | 13 ++++++ scripts/polymarket-e2e-approve.ts | 5 +- scripts/polymarket-e2e-live.ts | 11 ++++- scripts/polymarket-e2e-readonly.ts | 3 +- scripts/polymarket-e2e-withdraw.ts | 13 ++++-- scripts/redact.ts | 38 +++++++++++++++ test/scripts-redaction.test.ts | 75 ++++++++++++++++++++++++++++++ tsconfig.json | 3 +- 8 files changed, 151 insertions(+), 10 deletions(-) create mode 100644 scripts/redact.ts create mode 100644 test/scripts-redaction.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 379048a..13781cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -123,6 +123,19 @@ lived only in a comment, which cannot fail; `test/axios-scope.test.ts` now fails the day a non-Polymarket module imports axios and would start routing through an operator's `POLYMARKET_CLOB_PROXY` unasked. +**The live e2e scripts printed the wallet address they promised to hide.** Three +of the four say in their own header that wallet addresses and transaction ids +are never printed, and each implemented it with a different regex. The one in +the two scripts that actually move money matched 64-hex only, which is a +transaction hash; a 40-hex address went through untouched, and the withdrawal +path really does interpolate a bridge response carrying an address into its +error text. Worse, only the `isError` branch was ever redacted — a thrown +exception printed the raw message and stack. There is now one redaction, +covering every exit path, and a test that fails if a script grows its own regex +again. `scripts/` is also in the typecheck now: these files import from `src` +and were checked by nothing, so a signature change surfaced when someone ran +them against a funded wallet. + ## 0.49.0 **The error says whether money moved.** Issue #132 reported `blockrun_markets` diff --git a/scripts/polymarket-e2e-approve.ts b/scripts/polymarket-e2e-approve.ts index 8ce8159..6ffbd5f 100644 --- a/scripts/polymarket-e2e-approve.ts +++ b/scripts/polymarket-e2e-approve.ts @@ -5,6 +5,7 @@ * transaction id. From @KillerQueen-Z's #66. */ import { runSetup } from "../src/utils/polymarket/setup.js"; +import { failRedacted } from "./redact.js"; try { const submitted = await runSetup({ confirm: true }); @@ -16,7 +17,5 @@ try { approvalsPending: verified.structured.approvalsPending, }, null, 2)); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(JSON.stringify({ failed: true, error: message.replace(/0x[a-fA-F0-9]{40,}/g, "") })); - process.exitCode = 1; + failRedacted("", error); } diff --git a/scripts/polymarket-e2e-live.ts b/scripts/polymarket-e2e-live.ts index 56ea147..c3df870 100644 --- a/scripts/polymarket-e2e-live.ts +++ b/scripts/polymarket-e2e-live.ts @@ -4,10 +4,17 @@ * moving more than $2.00. Wallet addresses and transaction IDs are never * printed. From @KillerQueen-Z's #66; success check updated for the tri-state * redeem statuses main ships (only status:"redeemed" counts). + * + * The redaction covers every exit path, thrown errors included — see + * ./redact.ts for why that is not the same regex it used to be. */ import { fetchPositions, getFundsAddress } from "../src/utils/polymarket/positions.js"; import { redeemPosition } from "../src/utils/polymarket/redeem.js"; import { withdrawFunds } from "../src/utils/polymarket/withdraw.js"; +import { failRedacted, redactChainValues } from "./redact.js"; + +process.on("uncaughtException", (error) => failRedacted("", error)); +process.on("unhandledRejection", (error) => failRedacted("", error)); const owner = getFundsAddress(); const positions = await fetchPositions(owner); @@ -23,12 +30,12 @@ if (!target?.conditionId) { const redeem = await redeemPosition({ condition_id: target.conditionId, confirm: true }); if (redeem.isError || redeem.structured?.status !== "redeemed") { - throw new Error(`Redeem verification did not complete cleanly: ${redeem.text.replace(/0x[a-fA-F0-9]{64}/g, "")}`); + failRedacted("Redeem verification did not complete cleanly: ", redactChainValues(redeem.text)); } const withdrawal = await withdrawFunds({ amount_usd: 2, confirm: true }); if (withdrawal.isError) { - throw new Error(`Withdrawal submission failed: ${withdrawal.text.replace(/0x[a-fA-F0-9]{64}/g, "")}`); + failRedacted("Withdrawal submission failed: ", redactChainValues(withdrawal.text)); } console.log(JSON.stringify({ diff --git a/scripts/polymarket-e2e-readonly.ts b/scripts/polymarket-e2e-readonly.ts index e2adc94..3d9ea85 100644 --- a/scripts/polymarket-e2e-readonly.ts +++ b/scripts/polymarket-e2e-readonly.ts @@ -5,6 +5,7 @@ */ import { listPositions } from "../src/utils/polymarket/positions.js"; import { withdrawFunds } from "../src/utils/polymarket/withdraw.js"; +import { redactChainValues } from "./redact.js"; const [positionsResult, withdrawalResult] = await Promise.all([ listPositions(), @@ -34,7 +35,7 @@ const positions = ((positionsResult.structured as { console.log(JSON.stringify({ positions, withdrawalPreview: withdrawalResult.isError - ? withdrawalResult.text.replace(/0x[a-fA-F0-9]{40}/g, "") + ? redactChainValues(withdrawalResult.text) : { dryRun: withdrawalResult.structured?.dryRun, amountUsd: withdrawalResult.structured?.amountUsd, diff --git a/scripts/polymarket-e2e-withdraw.ts b/scripts/polymarket-e2e-withdraw.ts index 855d428..5e9c1b2 100644 --- a/scripts/polymarket-e2e-withdraw.ts +++ b/scripts/polymarket-e2e-withdraw.ts @@ -1,9 +1,16 @@ -/** Bounded live withdrawal check ($2 cap), independent of the approval flow. From #66. */ +/** + * Bounded live withdrawal check ($2 cap), independent of the approval flow. + * Wallet addresses and transaction ids are never printed, on any exit path. + * From #66. + */ import { withdrawFunds } from "../src/utils/polymarket/withdraw.js"; +import { failRedacted, redactChainValues } from "./redact.js"; -const result = await withdrawFunds({ amount_usd: 2, confirm: true }); +const result = await withdrawFunds({ amount_usd: 2, confirm: true }).catch((error) => + failRedacted("Withdrawal submission threw: ", error), +); if (result.isError) { - throw new Error(result.text.replace(/0x[a-fA-F0-9]{64}/g, "")); + failRedacted("", redactChainValues(result.text)); } console.log(JSON.stringify({ submitted: true, diff --git a/scripts/redact.ts b/scripts/redact.ts new file mode 100644 index 0000000..7eaad78 --- /dev/null +++ b/scripts/redact.ts @@ -0,0 +1,38 @@ +/** + * One redaction for every live Polymarket e2e script. + * + * These scripts run against a real funded wallet and their output goes into + * terminals, CI logs and issue comments. Three of them promised in their own + * doc comment that "wallet addresses and transaction IDs are never printed", + * and each implemented a different regex: `{64}` (transaction hashes only, so + * a 40-hex ADDRESS printed in full), `{40}` (addresses only), and `{40,}` + * (both, but labelled `` either way). The first is the one that + * broke the promise, and withdraw.ts really does interpolate a bridge response + * carrying an address into its error text. + * + * Longest-match-first in a single pass, so there is no ordering trap: a + * transaction hash cannot be half-eaten by the address rule. A private key is + * also 32 bytes and comes out as `` — mislabelled but redacted, which is + * the direction that matters. + */ +export function redactChainValues(text: string): string { + return text.replace(/0x[a-fA-F0-9]{40,}/g, (match) => { + if (match.length === 66) return ""; + if (match.length === 42) return ""; + return ""; + }); +} + +/** + * Redact whatever a script is about to die with. + * + * The `isError` branch of a tool result was the only path any of these guarded. + * A thrown exception — a network failure inside fetchPositions(), a viem revert + * — bypassed it entirely and Node printed the raw message and stack. Install + * this and every exit path is covered. + */ +export function failRedacted(prefix: string, error: unknown): never { + const message = error instanceof Error ? error.message : String(error); + console.error(JSON.stringify({ failed: true, error: redactChainValues(`${prefix}${message}`) })); + process.exit(1); +} diff --git a/test/scripts-redaction.test.ts b/test/scripts-redaction.test.ts new file mode 100644 index 0000000..13c6243 --- /dev/null +++ b/test/scripts-redaction.test.ts @@ -0,0 +1,75 @@ +// Run with: npm test (tsx --experimental-test-module-mocks --test) +// +// The live Polymarket e2e scripts run against a real funded wallet, and their +// output goes into terminals, CI logs and issue comments. Three of them state +// in their own doc comment that wallet addresses and transaction ids are never +// printed. Each had implemented that promise with a DIFFERENT regex, and the +// one used by the two scripts that actually move money matched `{64}` only — +// so a 40-hex address printed in full, and withdraw.ts really does interpolate +// a bridge response carrying an address into its error text. +// +// Nothing here runs a script. The first tests exercise the shared helper; the +// last reads the scripts as text and fails if one grows its own regex again. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { redactChainValues } from "../scripts/redact.js"; + +const SCRIPTS = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "scripts"); + +const WALLET = "0x" + "ab".repeat(20); +const TX = "0x" + "cd".repeat(32); + +test("a wallet address is redacted, not just a transaction hash", () => { + const out = redactChainValues(`Bridge did not return an address (got: ${WALLET}).`); + assert.equal(out.includes(WALLET), false, "the address must not survive"); + assert.match(out, //); +}); + +test("a transaction hash is labelled as one and never half-eaten by the address rule", () => { + const out = redactChainValues(`submitted ${TX}`); + assert.equal(out, "submitted "); + assert.equal(out.includes("cd"), false, "no tail of the hash may leak past a 40-char match"); +}); + +test("both in one string, in either order", () => { + assert.equal( + redactChainValues(`from ${WALLET} tx ${TX} back to ${WALLET}`), + "from tx back to ", + ); + assert.equal(redactChainValues(`${TX} ${WALLET}`), " "); +}); + +test("a 32-byte private key comes out redacted (mislabelled is fine, printed is not)", () => { + const key = "0x" + "11".repeat(32); + assert.equal(redactChainValues(`key=${key}`).includes("11"), false); +}); + +test("anything else long and hex is redacted rather than passed through", () => { + const odd = "0x" + "ef".repeat(25); + const out = redactChainValues(`blob ${odd}`); + assert.equal(out.includes(odd), false); + assert.match(out, //); +}); + +test("short hex is left alone — a token id or a selector is not a secret", () => { + assert.equal(redactChainValues("selector 0xdeadbeef"), "selector 0xdeadbeef"); +}); + +test("no e2e script carries its own address/hash regex", () => { + const offenders: string[] = []; + for (const name of readdirSync(SCRIPTS)) { + if (!name.startsWith("polymarket-e2e-")) continue; + const source = readFileSync(path.join(SCRIPTS, name), "utf-8"); + if (/0x\[a-fA-F0-9\]\{/.test(source)) offenders.push(name); + } + assert.deepEqual( + offenders, + [], + "redact via scripts/redact.ts — four hand-rolled regexes is how the {64}-only " + + "one shipped in the two scripts that move real money", + ); +}); diff --git a/tsconfig.json b/tsconfig.json index 6aa671a..a45cf2f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,8 @@ "include": [ "src/**/*", "test/**/*", - "apps/**/*" + "apps/**/*", + "scripts/**/*" ], "exclude": [ "node_modules", From 937c4c581ff45bcb725167322c5dd8056cdc4745 Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:02:30 -0400 Subject: [PATCH 17/37] fix(scripts): smoke-speech charged the wallet for being run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `npx tsx scripts/smoke-speech.ts` registered the real blockrun_speech handler and immediately spent from the machine-global wallet at ~/.blockrun/.session. No flag, no prompt, and `limit: null` so nothing capped it. Its header said "real $0.001 speak"; the run ends with a $0.0525 sound effect, fifty times that, and about $0.054 total. This repo has already lost $0.42 to a paid handler that was run because it looked like a read. Nothing sitting in scripts/ should spend money by being run. It now refuses without --confirm (or BLOCKRUN_SMOKE_CONFIRM=1), names the real total in the refusal, and sets a $0.15 budget cap as a second backstop for a moved price or a doubled retry. Verified: a bare run exits 1 having charged nothing. test/scripts-spend-gate.test.ts pins this for the next script like it — anything in scripts/ that calls register…Tool() must carry a confirm gate ahead of its first paid call and a numeric budget limit. The assertions are static on purpose: a test that proved the gate by running the script would charge the wallet the day the gate regressed, which is the failure it exists to catch. Verified by setting limit back to null and watching it fail. The polymarket e2e scripts are deliberately out of scope and the test says so: they reach paid paths through utils/ rather than a handler, run only as explicitly named npm targets, and carry their own bounds. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg --- CHANGELOG.md | 8 ++++ scripts/smoke-speech.ts | 32 +++++++++++++-- test/scripts-spend-gate.test.ts | 69 +++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 test/scripts-spend-gate.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 13781cc..b233fb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -136,6 +136,14 @@ again. `scripts/` is also in the typecheck now: these files import from `src` and were checked by nothing, so a signature change surfaced when someone ran them against a funded wallet. +**`scripts/smoke-speech.ts` charged the wallet for being run.** No flag, no +prompt, `limit: null` so nothing capped it, under a header advertising "real +$0.001 speak" while the run ends with a $0.0525 sound effect. It now refuses +without `--confirm`, states the real total, and carries its own budget cap. A +static test holds the line for the next script like it: the check is +deliberately not behavioural, since a test that proved the gate by running the +script would charge the wallet on the day the gate broke. + ## 0.49.0 **The error says whether money moved.** Issue #132 reported `blockrun_markets` diff --git a/scripts/smoke-speech.ts b/scripts/smoke-speech.ts index 2a019c3..10f1141 100644 --- a/scripts/smoke-speech.ts +++ b/scripts/smoke-speech.ts @@ -1,15 +1,41 @@ -// One-off smoke test for blockrun_speech. Run: npx tsx scripts/smoke-speech.ts -// Exercises: voices (fallback path), over-length free-fail, real $0.001 speak. +/** + * One-off smoke test for blockrun_speech. SPENDS REAL USDC — about $0.054 per + * run, from the machine-global wallet at ~/.blockrun/.session. + * + * Run: npx tsx scripts/smoke-speech.ts --confirm + * + * The flag is not ceremony. The header used to say "real $0.001 speak" while + * the run ends with a $0.0525 sound effect, fifty times that, and a bare + * `npx tsx scripts/smoke-speech.ts` charged for both immediately. This repo + * has already lost $0.42 to a subagent that ran a paid handler because it + * looked like a read. Nothing in scripts/ should spend money by being run. + * + * The budget limit below is a second backstop: if a price moves or a retry + * doubles a call, the run stops instead of draining the wallet. + * + * Exercises: voices (fallback path), over-length free-fail, speak ($0.001), + * sound_effect ($0.0525). + */ import { registerSpeechTool } from "../src/tools/speech.js"; import type { BudgetState } from "../src/types.js"; +const SPEND_CAP_USD = 0.15; + +if (!process.argv.includes("--confirm") && process.env.BLOCKRUN_SMOKE_CONFIRM !== "1") { + console.error( + "smoke-speech spends about $0.054 of real USDC (speak $0.001 + sound_effect $0.0525).\n" + + "Re-run with --confirm, or set BLOCKRUN_SMOKE_CONFIRM=1, to authorise the charge.", + ); + process.exit(1); +} + type Handler = (args: Record) => Promise<{ content: Array<{ text: string }>; isError?: boolean }>; let handler: Handler; const fakeServer = { registerTool: (_name: string, _cfg: unknown, h: Handler) => { handler = h; }, } as never; -const budget: BudgetState = { limit: null, spent: 0, calls: 0, agents: new Map() }; +const budget: BudgetState = { limit: SPEND_CAP_USD, spent: 0, calls: 0, agents: new Map() }; registerSpeechTool(fakeServer, budget); async function run(label: string, args: Record) { diff --git a/test/scripts-spend-gate.test.ts b/test/scripts-spend-gate.test.ts new file mode 100644 index 0000000..83657cc --- /dev/null +++ b/test/scripts-spend-gate.test.ts @@ -0,0 +1,69 @@ +// Run with: npm test (tsx --experimental-test-module-mocks --test) +// +// A script that registers a real tool handler spends real USDC from the +// machine-global wallet the moment it is run. scripts/smoke-speech.ts did +// exactly that on a bare `npx tsx scripts/smoke-speech.ts`, with `limit: null` +// so nothing capped it, under a header advertising "real $0.001 speak" while +// the run ends with a $0.0525 sound effect. This repo has already lost $0.42 +// to a paid handler that was run because it looked like a read. +// +// These assertions are STATIC on purpose. A test that proved the gate by +// running the script would charge the wallet the day the gate regressed, +// which is the failure it is supposed to catch. +// +// The polymarket e2e scripts are deliberately not covered: they reach paid +// paths through utils/, not through a tool handler, they are exposed only as +// explicitly named `npm run e2e:polymarket:live`-style targets, and each +// carries its own bound ($2 withdrawal cap, redeem restricted to a position +// worth <= $0.001). +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const SCRIPTS = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "scripts"); + +function scriptsRegisteringTools(): Array<{ name: string; source: string }> { + const found: Array<{ name: string; source: string }> = []; + for (const name of readdirSync(SCRIPTS)) { + if (!name.endsWith(".ts")) continue; + const source = readFileSync(path.join(SCRIPTS, name), "utf-8"); + // Importing a pure estimator from src/tools is free — verify-prices.ts does + // exactly that. Calling register…Tool() is what wires up a handler that pays. + if (/register[A-Za-z]*Tool\s*\(/.test(source)) found.push({ name, source }); + } + return found; +} + +test("the set of scripts that register a paid tool handler is known", () => { + assert.deepEqual( + scriptsRegisteringTools().map((s) => s.name).sort(), + ["smoke-speech.ts"], + "a new script here spends real USDC when run — give it a confirm gate and a budget cap", + ); +}); + +test("every such script refuses to spend without an explicit confirmation", () => { + for (const { name, source } of scriptsRegisteringTools()) { + assert.match( + source, + /--confirm|BLOCKRUN_SMOKE_CONFIRM/, + `${name} charges a real wallet on a bare run with no way to say no`, + ); + const gate = source.search(/process\.exit\(1\)/); + const firstCall = source.search(/await run\(|await handler/); + assert.ok(gate > -1 && (firstCall === -1 || gate < firstCall), `${name}: the gate must come before the first paid call`); + } +}); + +test("every such script caps its own spend", () => { + for (const { name, source } of scriptsRegisteringTools()) { + assert.equal( + /limit:\s*null/.test(source), + false, + `${name} runs with an uncapped budget — a moved price or a retry drains the wallet`, + ); + assert.match(source, /limit:\s*[A-Z_]+|limit:\s*0\.\d+/, `${name} must set a numeric budget limit`); + } +}); From efb9e2efad3768404051bc8b22032d52aa025f72 Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:09:37 -0400 Subject: [PATCH 18/37] fix(scripts): the release automation could publish wrong numbers without failing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four scripts that had never been audited, each able to produce confident output from a failure. measure-tool-schema.mjs - rpc() resolved on any reply with a matching id and never looked at msg.error. Callers destructure `{ result }`, so an error reply handed them undefined -> `result?.tools ?? []` -> a measurement of ZERO tokens across ZERO tools, printed as a real figure at exit 0. With --svg that reaches assets/context-cost*.svg as "0.0K tokens" and, since `cut` divides by the total, a literal "NaN% less". A server answering initialize with an error -- the foreign-server case this tool advertises, when the package wants auth -- is all it takes. Now rejects, naming the method and the error. - stdout was decoded per chunk (`buf += chunk` on a Buffer), so a multi-byte character split across a boundary became U+FFFD. JSON.parse still succeeds, so only the number comes out wrong. The payload is ~50KB against a 16KB pipe buffer and these descriptions are full of em dashes. The in-process test that pins these figures uses InMemoryTransport and never exercises this reader, so it would have surfaced as an unexplainable README diff. setEncoding("utf8"). - a profile that lists no tools is now a failure, not a measurement of zero. stamp-server-json.mjs stamped nothing when no package entry matched pkg.name, left the template's "0.0.0-template" (valid semver, so mcp-publisher validate passes it), and printed "Stamped server.json → …" regardless. publish.yml would then point PulseMCP, Glama and the rest at an npm version that does not exist. The input was guarded against a placeholder; the output was not. Verified by renaming the identifier and watching it exit 1. changelog-section.mjs and measure-tool-schema.mjs both compared a realpath'd import.meta.url against a non-realpath'd process.argv[1]. Node realpaths the ESM main entry, so from any checkout reached through a symlink (macOS /tmp -> /private/tmp, npm link) the CLI half silently did nothing at exit 0. publish.yml guards on `if ! node scripts/changelog-section.mjs "$VERSION"`, so exit 0 with empty stdout skips the generic fallback and publishes a release with an EMPTY body -- the one thing that file's header promises cannot happen. Verified: through a symlinked path it printed nothing and exited 0 before, prints the section now. sync-brand-numbers.mjs rendered remote values with String(value) and interpolated them into src="…" and alt="…" with no escaping. Those files are README.md, CONTRIBUTING.md and skills/*/SKILL.md, and brand-sync.yml commits and pushes them to the default branch weekly, unattended, with contents:write. A value carrying a quote or an angle bracket closed the attribute and injected markup into every consuming repo; write access to the awesome-blockrun mirror was enough to reach them. Rendered values are now checked at the point of use (a finite number or a short plain label) and escaped on top. Checked at use rather than over the whole artifact because the payload legitimately carries prose fields we never render. Verified with a quote-carrying value: exit 1, nothing written. Its --check also no longer lists stale fenced markers and then declares everything up to date, and "keys in use" no longer counts mcp.tools and mcp.tools@badge as two. Documentation claims nothing was watching: README said "same 20 tools either way" two lines under a marker rendering 19 (now the marker, so the sync keeps it right), and docs/mcp-schema-overhead.md kept a second copy of the profile-cost table that no test pinned. Both covered now, plus the profile list itself, hardcoded in the script and the test, which would have left a newly added profile measured and pinned by neither. Found by a read-only audit subagent; every finding re-verified here before fixing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg --- CHANGELOG.md | 35 +++++++++++++++++++ README.md | 2 +- scripts/changelog-section.mjs | 13 +++++-- scripts/measure-tool-schema.mjs | 51 ++++++++++++++++++++++++--- scripts/stamp-server-json.mjs | 20 ++++++++++- scripts/sync-brand-numbers.mjs | 61 ++++++++++++++++++++++++++++++--- test/schema-tokens.test.ts | 22 ++++++++++++ 7 files changed, 191 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b233fb7..6ee8e19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -144,6 +144,41 @@ static test holds the line for the next script like it: the check is deliberately not behavioural, since a test that proved the gate by running the script would charge the wallet on the day the gate broke. +**The release automation could publish wrong numbers without failing.** Four +scripts nobody had audited, each able to produce confident output from a +failure. `measure-tool-schema.mjs` ignored JSON-RPC errors, so a server that +answered `tools/list` with an error was measured as zero tokens across zero +tools and printed as a real figure at exit 0 — with `--svg` that reached the +context-cost cards as "0.0K tokens" and a literal "NaN% less". It also decoded +the child's stdout per chunk, so an em dash split across a 16KB pipe boundary +became a replacement character and quietly changed the count, which the +in-process test could never catch because it measures through +`InMemoryTransport`. `stamp-server-json.mjs` stamped nothing when no package +entry matched, left the template's `0.0.0-template` in place (valid semver, so +validation passes) and printed a success line claiming it had stamped — +pointing every registry consumer at an npm version that does not exist. And +`changelog-section.mjs` compared a realpath'd module URL against a +non-realpath'd `argv[1]`, so from any checkout reached through a symlink it +exited 0 with empty stdout, which in `publish.yml` skips the fallback and +publishes a release with an empty body. All four now fail instead. + +**The brand-number sync wrote unvalidated remote JSON into the README.** Values +fetched from blockrun.ai were rendered with `String(value)` and interpolated +into `src="…"` and `alt="…"` with no escaping, then committed and pushed to the +default branch weekly by an unattended bot with `contents: write`. A value +carrying a quote or an angle bracket closed the attribute and injected markup +into every consuming repo. Rendered values are now checked at the point of use +— a number or a short plain label, nothing else — and escaped on top of that. +Its `--check` also no longer prints the stale markers it found and then +declares everything up to date. + +Two documentation claims that nothing was watching: the README said "same 20 +tools either way" two lines below a marker rendering 19, and +`docs/mcp-schema-overhead.md` kept a second copy of the profile-cost table that +no test pinned. Both are now covered, along with the profile list itself, which +was hardcoded in two places and would have left a newly added profile measured +by neither. + ## 0.49.0 **The error says whether money moved.** Issue #132 reported `blockrun_markets` diff --git a/README.md b/README.md index 6ba957e..0f90498 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ claude mcp add blockrun -s user -- npx -y @blockrun/mcp@latest > **BlockRun MCP** is an open-source [Model Context Protocol](https://modelcontextprotocol.io) server that gives Claude — and any MCP-compatible agent — 19 tools for real-time data and real actions: 78 LLMs, image & video generation, prediction-market data, live web/X search, on-chain queries across 40 chains, and **the ability to place real, USDC-settled bets on Polymarket**. -You pay per call, and you choose how. **Wallet mode** authenticates with a signature and settles each call in USDC via the [x402](https://x402.org) protocol — no account, no credit card, no subscription, on Solana or Base. **Account mode** authenticates with a BlockRun API key (`brk_live_…`) from [user.blockrun.ai](https://user.blockrun.ai) and bills prepaid credit at exact usage — for teams that can't hand a wallet to an agent. Same 20 tools either way. MIT licensed. +You pay per call, and you choose how. **Wallet mode** authenticates with a signature and settles each call in USDC via the [x402](https://x402.org) protocol — no account, no credit card, no subscription, on Solana or Base. **Account mode** authenticates with a BlockRun API key (`brk_live_…`) from [user.blockrun.ai](https://user.blockrun.ai) and bills prepaid credit at exact usage — for teams that can't hand a wallet to an agent. Same 19 tools either way. MIT licensed. ## 🏆 First of its kind — the signal → trade loop in Claude Code diff --git a/scripts/changelog-section.mjs b/scripts/changelog-section.mjs index b1348c8..567b66c 100644 --- a/scripts/changelog-section.mjs +++ b/scripts/changelog-section.mjs @@ -6,7 +6,7 @@ // Usage: node scripts/changelog-section.mjs 0.32.2 // Exits 1 with nothing on stdout when the version has no section, so the // workflow can fall back to a generic note instead of publishing an empty one. -import { readFileSync } from "node:fs"; +import { readFileSync, realpathSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; @@ -40,7 +40,16 @@ export function extractSection(changelog, version) { } // Only run as a CLI when invoked directly, so the test can import it. -if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { +// +// realpath BOTH sides. Node realpaths the ESM main entry but leaves +// process.argv[1] as resolve(cwd, arg), so a checkout reached through a +// symlink (macOS /tmp -> /private/tmp, npm link, a symlinked working dir) made +// these differ and this block silently did nothing at exit 0. publish.yml +// guards on `if ! node scripts/changelog-section.mjs "$VERSION" > notes.md`, +// so exit 0 with empty stdout skips the generic fallback and publishes a +// release with an EMPTY body -- the one thing the header above promises +// cannot happen. +if (process.argv[1] && fileURLToPath(import.meta.url) === realpathSync(process.argv[1])) { const version = process.argv[2]; if (!version) { console.error("usage: node scripts/changelog-section.mjs "); diff --git a/scripts/measure-tool-schema.mjs b/scripts/measure-tool-schema.mjs index dd42c80..00be9e5 100755 --- a/scripts/measure-tool-schema.mjs +++ b/scripts/measure-tool-schema.mjs @@ -34,7 +34,7 @@ * percent higher on JSON, so every number here is a slight UNDER-count. */ import { spawn } from "node:child_process"; -import { writeFileSync } from "node:fs"; +import { writeFileSync, realpathSync } from "node:fs"; import { fileURLToPath, pathToFileURL } from "node:url"; import { encode } from "gpt-tokenizer/encoding/o200k_base"; @@ -79,9 +79,26 @@ export function listTools(cmd, { timeoutMs = 120_000 } = {}) { }; const send = (m) => child.stdin.write(`${JSON.stringify(m)}\n`); + // Reject on a JSON-RPC error rather than resolving with a message that has + // no `result`. Callers destructure `{ result }`, so swallowing an error + // handed them `undefined` -> `result?.tools ?? []` -> a measurement of ZERO + // tokens across ZERO tools, printed as a real figure with exit 0. With + // --svg that reaches the cards as "0.0K tokens" and, because `cut` divides + // by the total, a literal "NaN% less". A server answering `initialize` + // with an error -- the foreign-server case this tool advertises, when the + // package wants auth -- is all it takes. const rpc = (method, params) => - new Promise((res) => { - pending.set(++id, res); + new Promise((res, rej) => { + pending.set(++id, (msg) => { + if (msg.error) { + rej(new Error( + `${cmd.join(" ")} answered ${method} with JSON-RPC error ` + + `${msg.error.code}: ${msg.error.message ?? "(no message)"}`, + )); + return; + } + res(msg); + }); send({ jsonrpc: "2.0", id, method, params }); }); @@ -103,6 +120,15 @@ export function listTools(cmd, { timeoutMs = 120_000 } = {}) { // EPIPE rather than an unhandled crash when the child is already gone. child.stdin.on("error", () => {}); + // Decode as a STREAM. Without this each Buffer is toString()'d on its own, + // so a multi-byte character split across a chunk boundary becomes U+FFFD. + // JSON.parse still succeeds (U+FFFD is a legal string char) and only the + // token count comes out wrong -- and the tool descriptions this measures + // are full of em dashes. The payload is ~50KB against a 16KB pipe buffer, + // so multi-chunk delivery is the normal case, and the in-process test that + // pins these numbers uses InMemoryTransport and never exercises this + // reader. The disagreement would surface as an unexplainable README diff. + child.stdout.setEncoding("utf8"); child.stdout.on("data", (chunk) => { buf += chunk; let i; @@ -198,7 +224,11 @@ export function renderCard({ total, tradingTotal, cut, dark }) { // Importable: test/schema-tokens.test.ts reuses measure()/asK() to pin the // published number, so the CLI half must not run on import. -const isCli = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; +// realpath BOTH sides. Node realpaths the ESM main entry but leaves +// process.argv[1] as resolve(cwd, arg), so any checkout reached through a +// symlink (macOS /tmp -> /private/tmp, npm link, a symlinked working dir) made +// these differ and the CLI half silently did nothing at exit 0. +const isCli = process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href; if (!isCli) { /* library use */ } else await main(); async function main() { @@ -207,7 +237,18 @@ for (const profile of PROFILES) { const cmd = userCmd.length ? userCmd : ["node", SERVER, ...(profile && profile !== "full" ? ["--profile", profile] : [])]; - results[profile ?? userCmd.join(" ")] = measure(await listTools(cmd), PREFIX); + const tools = await listTools(cmd); + // A profile that projects nothing is a broken measurement, not a small one. + // Every figure downstream (the README table, the badge, the percentage) is + // derived from these totals, and printing 0 with exit 0 publishes a wrong + // number rather than reporting a failure. + if (tools.length === 0) { + throw new Error( + `${cmd.join(" ")} listed no tools for profile "${profile ?? "custom"}" — ` + + `refusing to report a measurement of zero.`, + ); + } + results[profile ?? userCmd.join(" ")] = measure(tools, PREFIX); } if (flags.has("--svg")) { diff --git a/scripts/stamp-server-json.mjs b/scripts/stamp-server-json.mjs index 283c7fd..5b4cda7 100644 --- a/scripts/stamp-server-json.mjs +++ b/scripts/stamp-server-json.mjs @@ -20,9 +20,27 @@ if (!version || version.includes("template")) { const manifest = JSON.parse(readFileSync(join(root, "server.template.json"), "utf8")); manifest.version = version; +let stamped = 0; for (const p of manifest.packages ?? []) { // Pin the registry entry to the exact npm version we publish. - if (p.identifier === pkg.name) p.version = version; + if (p.identifier === pkg.name) { p.version = version; stamped++; } +} + +// The input was guarded against a placeholder; the OUTPUT was not. With no +// matching entry this loop did nothing, the template's own "0.0.0-template" +// survived into server.json -- valid semver, so `mcp-publisher validate` +// passes it -- and the success line below announced a stamp that never +// happened. publish.yml would then point PulseMCP, Glama and the rest of the +// registry consumers at an npm version that does not exist. One rename of the +// package, one typo in the template, or one registry schema change away. +if (stamped === 0) { + console.error( + `Refusing to stamp: no package entry in server.template.json has ` + + `identifier "${pkg.name}" (found: ` + + `${(manifest.packages ?? []).map((p) => JSON.stringify(p.identifier)).join(", ") || "none"}). ` + + `server.json would ship the template's placeholder version.`, + ); + process.exit(1); } writeFileSync(join(root, "server.json"), JSON.stringify(manifest, null, 2) + "\n"); diff --git a/scripts/sync-brand-numbers.mjs b/scripts/sync-brand-numbers.mjs index dee309b..90d66e2 100644 --- a/scripts/sync-brand-numbers.mjs +++ b/scripts/sync-brand-numbers.mjs @@ -101,15 +101,58 @@ function flatten(obj, prefix = "") { * Renderers are registered under the FULL marker name so a badge's label is * written out rather than guessed from the key. */ +/** + * What a brand value is allowed to be, checked at the moment it is USED. + * + * These values arrive over the network from blockrun.ai (or the + * awesome-blockrun mirror) and are written verbatim into README.md, + * CONTRIBUTING.md and skills/*\/SKILL.md, which `.github/workflows/brand-sync.yml` + * then commits and pushes to the default branch weekly, unattended, with + * `contents: write`. Rendering was `String(value)` and the badge renderer + * interpolated straight into `src="..."` and `alt="..."`, so a value carrying + * a quote or an angle bracket closed the attribute and injected markup into + * every consuming repo's README. Write access to one mirror repo was enough. + * + * Checked here rather than over the whole artifact on purpose: the payload + * legitimately carries prose fields we never render (`savings.baselineModel` + * is a string), and refusing those would break the sync on an unrelated + * addition upstream. + */ +const SAFE_TEXT = /^[\p{L}\p{N} .,%+/·—–-]{1,64}$/u; + +function assertRenderable(marker, value) { + const what = () => `${marker} = ${JSON.stringify(value)}`; + if (typeof value === "number") { + if (!Number.isFinite(value)) fail(`brand-numbers: refusing to render ${what()} — not a finite number`); + return value; + } + if (typeof value === "string") { + if (!SAFE_TEXT.test(value)) { + fail( + `brand-numbers: refusing to render ${what()} — a rendered value must be ` + + `a number or a short plain label. This value would be written verbatim ` + + `into README/CONTRIBUTING/SKILL.md and pushed by the brand-sync bot.`, + ); + } + return value; + } + fail(`brand-numbers: refusing to render ${what()} — expected a number or a string, got ${Array.isArray(value) ? "an array" : typeof value}`); +} + +/** Escape for an HTML attribute. Belt to assertRenderable's braces. */ +const escAttr = (v) => + String(v).replace(/&/g, "&").replace(//g, ">") + .replace(/"/g, """).replace(/'/g, "'"); + const badge = (label) => (n) => - `${n} ${label}`; + `${escAttr(n)} ${label}`; const RENDER = { "mcp.tools@badge": badge("tools"), "models.totalVisible@badge": badge("models"), "models.chatVisible@badge": badge("models"), }; -const render = (marker, value) => (RENDER[marker] ?? String)(value); +const render = (marker, value) => (RENDER[marker] ?? String)(assertRenderable(marker, value)); /** `mcp.tools@badge` looks up `mcp.tools`. Unmodified markers are unaffected. */ const keyOf = (marker) => marker.split("@")[0]; @@ -308,7 +351,8 @@ const everUsed = new Set(); for (const file of walk(ROOT)) { const { before, after, changed, used } = syncFile(file, numbers, problems, skipped); - used.forEach((k) => everUsed.add(k)); + // keyOf: mcp.tools and mcp.tools@badge are ONE key in use, not two. + used.forEach((k) => everUsed.add(keyOf(k))); if (!changed) continue; drifted.push({ file: relative(ROOT, file), before, after }); if (!check) writeFileSync(file, after); @@ -332,7 +376,16 @@ if (problems.length) { if (check) { if (drifted.length === 0) { - console.log(`brand-numbers: up to date (${everUsed.size} keys in use)`); + // Do not say "up to date" straight after listing markers known to be + // stale. The skip stays non-fatal for the reason above, but a CI log that + // prints the stale ones and then declares everything current is a log + // nobody reads twice. + console.log( + skipped.length + ? `brand-numbers: no drift outside code fences (${everUsed.size} keys in use), ` + + `but ${skipped.length} fenced marker(s) listed above are stale — add @live to sync them` + : `brand-numbers: up to date (${everUsed.size} keys in use)`, + ); process.exit(0); } console.error("brand-numbers: these files disagree with brand-numbers.json\n"); diff --git a/test/schema-tokens.test.ts b/test/schema-tokens.test.ts index ad39247..a5caa97 100644 --- a/test/schema-tokens.test.ts +++ b/test/schema-tokens.test.ts @@ -28,6 +28,13 @@ import { initializeMcpServer } from "../src/mcp-handler.js"; import { measure, asK, listTools } from "../scripts/measure-tool-schema.mjs"; const README = readFileSync(new URL("../README.md", import.meta.url), "utf8"); +// docs/mcp-schema-overhead.md carries a SECOND copy of the profile-cost table. +// Only the README copy was pinned, so the doc could sit at a stale number +// indefinitely while the README stayed correct. +const OVERHEAD_DOC = readFileSync( + new URL("../docs/mcp-schema-overhead.md", import.meta.url), + "utf8", +); const PREFIX = "mcp__blockrun__"; /** Same projection the CLI harness measures, over an in-process handshake. */ @@ -80,9 +87,24 @@ test("the profile table states each profile's measured cost", async () => { ); assert.match(README, row, `README row for ${profile} should read ${tools} tools / ${total.toLocaleString()} tokens`); + assert.match(OVERHEAD_DOC, row, + `docs/mcp-schema-overhead.md row for ${profile} should read ${tools} tools / ${total.toLocaleString()} tokens`); } }); +test("every profile in src/profiles.ts is measured, not just the five in the table", async () => { + // measure-tool-schema.mjs hardcodes its profile list and this file hardcodes + // the same one. A profile added to src/profiles.ts would be measured by + // neither and pinned by neither, so it could ship advertising nothing. + const { PROFILES } = await import("../src/profiles.js"); + assert.deepEqual( + Object.keys(PROFILES).sort(), + ["chat", "full", "media", "research", "trading"], + "a new profile needs a row in the README table, in docs/mcp-schema-overhead.md, " + + "and in PROFILES in scripts/measure-tool-schema.mjs", + ); +}); + test("the advertised profile saving is the one measured", async () => { const full = await measureProfile("full"); const trading = await measureProfile("trading"); From 3168f70a426520fd595dd28a45f9a3924aaea076 Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:23:07 -0400 Subject: [PATCH 19/37] fix(docs): CONTRIBUTING told new contributors to copy a deleted file and call the SDK directly Two defects, one of which produces a broken tool. The dead references. `src/tools/surf.ts` went away with the tool in 0.49.0, and CONTRIBUTING still named it four times: as the template to copy in step 1 of "Adding a new MCP tool", as the reference example of the path-based pattern, and as the example for the sync payment call. Step 1 was literally uncopyable. `skills/surf/SKILL.md` was offered as the structural template for a new skill; it is now a retirement map for a removed tool, which is the one thing a new skill should not be modelled on. The one that costs money. The x402 section documented `client.getWithPaymentRaw(endpoint, params)` and `client.requestWithPaymentRaw(endpoint, body)` as how a tool makes a paid call. No tool in src/ has called those directly for some time, and for a reason raw-call.ts states in its own header: there are THREE payment rails and the SDK knows two. On the account rail requestWithPaymentRaw degrades to a plain Bearer fetch and throws away the x-blockrun-cost-usd response header, so a tool built that way silently does not support API-key users and cannot say what the call cost. utils/raw-call.ts is the single entry point that exists so no tool chooses a rail for itself -- and per-tool rail divergence is the exact fingerprint of every money bug rounds 1 through 3 found. Now documented as rawGet/rawPost with the SDK-direct path called out as wrong and the ledgerFallback() note, since the gate and the ledger are deliberately different numbers and Solana has no gateway tx fee. Also repoints the examples at src/tools/markets.ts and skills/prediction-markets/SKILL.md, both of which exist and both of which demonstrate what the surrounding sentence claims, and drops the retired surf from raw-call.ts's own list of its callers. test/doc-file-refs.test.ts is the guard: every `src|test|skills|scripts|apps| assets`-rooted path named in README, CONTRIBUTING or docs/ must exist; the raw-call guidance must stay in CONTRIBUTING; and every path-based tool must actually import raw-call and must not call the SDK raw methods directly. Verified against the pre-fix CONTRIBUTING: tests 1 and 2 both fail. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg --- CHANGELOG.md | 14 ++++++++ CONTRIBUTING.md | 16 +++++---- src/utils/raw-call.ts | 4 +-- test/doc-file-refs.test.ts | 74 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 9 deletions(-) create mode 100644 test/doc-file-refs.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ee8e19..9be643d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -179,6 +179,20 @@ no test pinned. Both are now covered, along with the profile list itself, which was hardcoded in two places and would have left a newly added profile measured by neither. +**CONTRIBUTING told new contributors to call the SDK directly.** Step 1 of +"Adding a new MCP tool" was "copy `src/tools/surf.ts`", a file deleted with the +tool in 0.49.0, and the payment section documented +`client.getWithPaymentRaw` / `requestWithPaymentRaw` as the way to make a paid +call. There are three payment rails and the SDK knows two: on the account rail +it degrades to a plain Bearer fetch and discards the `x-blockrun-cost-usd` +header, so a tool written from those instructions cannot report what it cost +and books the wrong ledger figure. `src/utils/raw-call.ts` exists precisely so +no tool picks a rail for itself, and every rail-parity bug this project has +shipped came from one doing so. The steps now name files that exist and the +helper that handles all three rails. `test/doc-file-refs.test.ts` fails when a +doc names a repo path that is not there, and asserts that every path-based tool +really does route through `raw-call`. + ## 0.49.0 **The error says whether money moved.** Issue #132 reported `blockrun_markets` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 17e3900..272fa54 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,19 +37,19 @@ Two ways to expose a new API. Pick the right one: - The API is core to BlockRun value-prop (chat, image, wallet) **Add a skill** when: -- The API is path-based passthrough (mirrors `blockrun_markets` / `blockrun_surf`) +- The API is path-based passthrough (mirrors `blockrun_markets` / `blockrun_exa`) - The endpoint catalog is large (≥10 endpoints) and benefits from a Quick Decision Table - LLM routing needs trigger keywords beyond what tool descriptions can carry Reference examples in the repo: -- Path-based tool: `src/tools/surf.ts` — 57 lines, exposes 84 endpoints behind it -- Long-tail skill: `skills/surf/SKILL.md` — full endpoint catalog + 7 worked examples +- Path-based tool: `src/tools/markets.ts` — 154 lines, one `path` parameter in front of the whole Predexon catalog +- Long-tail skill: `skills/prediction-markets/SKILL.md` — endpoint catalog, Quick Decision Table, worked examples - Async payment-on-completion: `src/tools/video.ts` — submit + poll + settle-on-complete - Typed structured tool: `src/tools/chat.ts` — multi-mode routing + budget gating + multi-turn ## Adding a new MCP tool -1. Copy `src/tools/surf.ts` as a starting template +1. Copy `src/tools/markets.ts` as a starting template for a path-based tool, or `src/tools/exa.ts` for a single-endpoint one 2. Use `getClient()` from `src/utils/wallet.ts` — it auto-routes Base vs Solana 3. Keep tool description ≤ 30 lines. Long endpoint catalogs belong in `skills//SKILL.md`, not in the tool description 4. Register in `src/mcp-handler.ts` (one import + one `register*Tool()` call) @@ -70,8 +70,8 @@ Reference examples in the repo: --- ``` -2. Mirror the structure of `skills/surf/SKILL.md`: Quick Decision Table → Worked Examples → Full Reference (organized by category) -3. Triggers should cover the long tail. Users won't always say "Surf" or "BlockRun" — they'll say "wallet labels", "on-chain SQL", "mindshare". Cover the synonyms +2. Mirror the structure of `skills/prediction-markets/SKILL.md`: Quick Decision Table → Worked Examples → Full Reference (organized by category). `skills/surf/SKILL.md` is NOT a template — it is a retirement map for a removed tool +3. Triggers should cover the long tail. Users won't always say the vendor's name or "BlockRun" — for prediction markets they'll say "odds", "will X happen", "betting line". Cover the synonyms ## Chain-aware code @@ -83,7 +83,9 @@ Never hardcode chain ID, RPC URL, or address format. Use: Two flavors. Pick the right one: -- **Sync, single-call**: use `client.getWithPaymentRaw(endpoint, params)` (GET) or `client.requestWithPaymentRaw(endpoint, body)` (POST). One signature, one settlement. See `src/tools/surf.ts`, `src/tools/exa.ts`. +- **Sync, single-call**: use `rawGet(client, endpoint, params)` (GET) or `rawPost(client, endpoint, body)` (POST) from `src/utils/raw-call.ts`. See `src/tools/markets.ts`, `src/tools/exa.ts`. + + Do **not** reach for `client.getWithPaymentRaw` / `client.requestWithPaymentRaw` directly. There are three payment rails (Base wallet, Solana wallet, account API key) and the SDK only knows about the two wallet ones — on the account rail it degrades to a plain Bearer fetch and throws away the `x-blockrun-cost-usd` header, so the call cannot report what it cost. `raw-call.ts` exists so no tool picks a rail for itself, and book the ledger with its `ledgerFallback()` rather than the reserved amount: the gate and the ledger are deliberately different numbers, and Solana has no gateway transaction fee at all. - **Async, payment-on-completion**: copy the `src/tools/video.ts` pattern — submit → 402 → sign → poll the same URL with the same `PAYMENT-SIGNATURE` header → settle on the first `completed` response. Upstream failures or client-side timeout = no charge. See `src/tools/music.ts` for the simpler synchronous-blocking variant. ## CHANGELOG diff --git a/src/utils/raw-call.ts b/src/utils/raw-call.ts index b83f97a..dbe7499 100644 --- a/src/utils/raw-call.ts +++ b/src/utils/raw-call.ts @@ -1,7 +1,7 @@ // src/utils/raw-call.ts // -// One entry point for the path-based tools (search, exa, surf, markets, rpc, -// defi, phone, modal), so each of them stops choosing a rail for itself. +// One entry point for the path-based tools (search, exa, markets, rpc, defi, +// phone, modal), so each of them stops choosing a rail for itself. // // WHY THIS EXISTS RATHER THAN "just call the SDK". On the account rail there is // no x402 to perform: no quote to read, nothing to sign, no retry-after-payment. diff --git a/test/doc-file-refs.test.ts b/test/doc-file-refs.test.ts new file mode 100644 index 0000000..f350c36 --- /dev/null +++ b/test/doc-file-refs.test.ts @@ -0,0 +1,74 @@ +// Run with: npm test (tsx --experimental-test-module-mocks --test) +// +// CONTRIBUTING.md pointed contributors at `src/tools/surf.ts` four times — +// as the starting template to copy, as the reference example of the +// path-based pattern, and as the example of the sync payment call. That file +// was deleted with the tool in 0.49.0. Step 1 of "Adding a new MCP tool" was +// literally uncopyable, and nothing failed, because prose naming a path is +// invisible to a compiler. +// +// So: every repo path our docs name has to exist. This is the cheap half of +// the problem. The expensive half — whether the file still demonstrates what +// the sentence claims — is not checkable here; see the raw-call assertion at +// the bottom for the one case that costs money to get wrong. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); +const DOCS = ["README.md", "CONTRIBUTING.md", "docs/mcp-schema-overhead.md"]; + +// A backticked path into the repo's own source, skills or scripts. Deliberately +// narrow: it must start at a directory we own, so prose like `blockrun_chat` +// or a URL never matches. +const REPO_PATH = /`((?:src|test|skills|scripts|apps|assets)\/[A-Za-z0-9_./-]+)`/g; + +test("every repo file path named in the docs exists", () => { + const missing: string[] = []; + for (const doc of DOCS) { + const text = readFileSync(path.join(ROOT, doc), "utf-8"); + for (const [, rel] of text.matchAll(REPO_PATH)) { + // A trailing colon-line-number ("wallet.ts:24") points INTO a file. + const bare = rel.replace(/:\d+$/, ""); + if (!existsSync(path.join(ROOT, bare))) missing.push(`${doc} → ${rel}`); + } + } + assert.deepEqual( + missing, + [], + "the docs name files that are not in the repo — a contributor told to copy one cannot", + ); +}); + +test("CONTRIBUTING sends new path-based tools through raw-call, not straight at the SDK", () => { + // Not pedantry about naming. There are three payment rails and the SDK knows + // two: on the account rail requestWithPaymentRaw degrades to a Bearer fetch + // and discards the x-blockrun-cost-usd header, so a tool written that way + // cannot say what it cost and books the wrong ledger figure. raw-call.ts is + // the single entry point that exists so no tool picks a rail for itself, and + // every rail-parity bug this repo has shipped came from one doing so. + const text = readFileSync(path.join(ROOT, "CONTRIBUTING.md"), "utf-8"); + assert.match(text, /rawGet\(client, endpoint/, "the GET helper should be the documented one"); + assert.match(text, /rawPost\(client, endpoint/, "the POST helper should be the documented one"); + assert.match( + text, + /Do \*\*not\*\* reach for `client\.getWithPaymentRaw`/, + "the SDK-direct path must stay called out as the wrong one", + ); +}); + +test("every path-based tool actually goes through raw-call", () => { + // The claim above is only worth documenting if it is true of the code. + const PATH_BASED = ["search", "exa", "markets", "rpc", "defi", "phone", "modal"]; + const offenders: string[] = []; + for (const name of PATH_BASED) { + const file = path.join(ROOT, "src", "tools", `${name}.ts`); + assert.ok(existsSync(file), `src/tools/${name}.ts should exist — raw-call.ts names it`); + const source = readFileSync(file, "utf-8"); + if (!/from "\.\.\/utils\/raw-call\.js"/.test(source)) offenders.push(`${name}.ts`); + if (/client\.(get|request)WithPaymentRaw\(/.test(source)) offenders.push(`${name}.ts (calls the SDK directly)`); + } + assert.deepEqual(offenders, [], "a path-based tool that skips raw-call.ts breaks the account rail"); +}); From 25b5825ef33d43147432ad02397b96b2db7aeabc Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:30:57 -0400 Subject: [PATCH 20/37] docs(changelog): the release says the search was spent, then round 3 found a P0 The 0.50.0 entry opened as "a second audit round" and stated that the general search was spent. Two more rounds have since landed in the same unreleased version, and round 3 found a path that destroys a funded wallet key -- so the release now contradicts its own headline. Reframed honestly: the general SWEEP was spent, and another pass over the same files would have returned docs and cosmetics. What was not spent was the surfaces no sweep had opened, and the class where a comment states a contract the code does not honour on some branch or platform. Comments cannot fail a test, which is why nothing had ever checked them and why four of round 3's five findings were that shape. Version stays 0.50.0: it is unreleased (npm latest is 0.49.0), so these are additional contents of the same release, not a new one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg --- CHANGELOG.md | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9be643d..acb532a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to BlockRun MCP will be documented in this file. ## 0.50.0 -**A second audit round, aimed at the first one.** 0.49.0's thirty-seven fixes +**Three audit rounds, each aimed at the one before it.** 0.49.0's thirty-seven fixes were written by six agents working in parallel, and this round went looking for what that costs. It found the shape immediately: each agent had hardened the rail it was looking at. The quote guard landed on video and image but not music @@ -14,9 +14,24 @@ the guard hook fired against nobody while the transfer was signed. Every one of those was a money path and every one passed CI. Thirty findings survived adversarial verification, and **not one was a P0 or a -P1** — 0.49.0's own list had one of each. That is the honest headline: the -general search is spent. What is not spent is the class above, so the last -change here is not a fix but a table. +P1** — 0.49.0's own list had one of each. The read at the time was that the +general search was spent, so the next change was not a fix but a table. + +That read was half right, and the half it got wrong is worth stating plainly. +The general SWEEP was spent: another pass over the same files would have +returned docs and cosmetics. What was not spent was the surfaces no sweep had +opened. Round 3 went at the four the critic named and found a path that +destroys a funded wallet key: an empty `~/.blockrun/.session` made the keychain +gate and the loader behind it disagree, and the disagreement minted a new +wallet over the funded one. Round 4 went at the surfaces still unread — the CI +and publish workflows, `verify-prices`, the Apps UI, the protocol entry — and +at the class that produced four of round 3's five findings: a comment stating a +contract the code does not honour on some branch, platform or early return. +Comments cannot fail a test, so nothing had ever checked them. + +The durable output of all three rounds is the same shape: where an assumption +was load-bearing and lived only in prose, it is now a test. `rail-parity`, +`axios-scope`, `scripts-redaction`, `scripts-spend-gate`, `doc-file-refs`. **The rail-parity matrix.** `test/rail-parity.test.ts` states, per paid tool and per rail, which treatments a paid call needs: a quote checked before signing, a From 76a75b9c3e783bea0dc8624992c968281bdf5618 Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:10:07 -0500 Subject: [PATCH 21/37] docs(readme): document account API payments --- README.md | 80 +++++++++++++++++++++++++++---------------------------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index fcd210b..f8e1ee1 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,10 @@

Real-time data — and real trades — for Claude and any AI agent.

-

Agents can't sign up for accounts. Agents can't enter credit cards.
-Agents can only sign transactions.

+

Agents can pay like agents: sign transactions from a wallet.
+Teams can pay like teams: get a BlockRun API key at user.blockrun.ai, add credit by card or wire, and let the MCP call api.blockrun.ai.

BlockRun MCP gives your agent 19 tools — markets, research, web search, images, video, on-chain data, and live Polymarket trading — paid per call.

-Two ways to pay, same tools: a self-custody wallet (USDC on Solana or Base, no account needed) — or a BlockRun API key for teams that can't run wallets. Sign up at user.blockrun.ai →

+Two ways to pay, same tools: a self-custody wallet (USDC on Solana or Base, no account needed) — or a BlockRun API key backed by account credit. Sign up at user.blockrun.ai →

Read the odds and place the bet, from one self-custody wallet.


@@ -16,7 +16,7 @@ Agents can only sign transactions.

Agent native  Wallet or API key  Read and trade Polymarket  -x402 USDC  +Card credit or USDC  Open source [![npm version](https://img.shields.io/npm/v/@blockrun/mcp.svg?style=flat-square&color=cb3837)](https://www.npmjs.com/package/@blockrun/mcp) @@ -39,7 +39,7 @@ Agents can only sign transactions.

claude mcp add blockrun -s user -- npx -y @blockrun/mcp@latest ``` -
Wallet auto-created on first run. Fund with $5 USDC — or set BLOCKRUN_API_KEY and skip the wallet entirely. Ask Claude anything.
+
Wallet auto-created on first run. Fund with $5 USDC — or get a key at user.blockrun.ai, top up by card, and call through api.blockrun.ai. Ask Claude anything.
@@ -54,7 +54,7 @@ claude mcp add blockrun -s user -- npx -y @blockrun/mcp@latest > **BlockRun MCP** is an open-source [Model Context Protocol](https://modelcontextprotocol.io) server that gives Claude — and any MCP-compatible agent — 19 tools for real-time data and real actions: 78 LLMs, image & video generation, prediction-market data, live web/X search, on-chain queries across 40 chains, and **the ability to place real, USDC-settled bets on Polymarket**. -You pay per call, and you choose how. **Wallet mode** authenticates with a signature and settles each call in USDC via the [x402](https://x402.org) protocol — no account, no credit card, no subscription, on Solana or Base. **Account mode** authenticates with a BlockRun API key (`brk_live_…`) from [user.blockrun.ai](https://user.blockrun.ai) and bills prepaid credit at exact usage — for teams that can't hand a wallet to an agent. Same 19 tools either way. MIT licensed. +You pay per call, and you choose how. **Wallet mode** authenticates with a signature and settles each call in USDC via the [x402](https://x402.org) protocol — no account, no credit card, no subscription, on Solana or Base. **Account mode** authenticates with a BlockRun API key (`brk_live_…`) from [user.blockrun.ai](https://user.blockrun.ai), routes service calls through [api.blockrun.ai](https://api.blockrun.ai), and draws down card- or wire-funded account credit at exact usage. Same 19 tools either way. MIT licensed. ## 🏆 First of its kind — the signal → trade loop in Claude Code @@ -66,11 +66,12 @@ Read live Polymarket odds *and* place the bet, from one self-custody wallet, pay Every other data integration was built for **human developers** — create an account, copy an API key into `.env`, add a credit card, repeat for every vendor. -**Agents can't do any of that.** BlockRun MCP is built for the agent-first world: +**BlockRun gives you both payment rails without rebuilding the integration.** Use a wallet when the agent should self-custody funds, or use an account key when a team wants card-funded credits and a dashboard. - **One wallet, every source** — 19 tools behind a single self-custody wallet. No per-vendor signups. -- **No API key required** — your wallet signature *is* authentication. (One is available at [user.blockrun.ai](https://user.blockrun.ai) for teams who need an invoice instead of a keypair.) -- **No credit cards** — pay per request in USDC via [x402](https://x402.org), fractions of a cent each. +- **One account key, every source** — mint a key at [user.blockrun.ai](https://user.blockrun.ai), top up by card or wire, then the MCP calls [api.blockrun.ai](https://api.blockrun.ai) with that key. +- **No API key required in wallet mode** — your wallet signature *is* authentication. +- **No credit card required in wallet mode** — pay per request in USDC via [x402](https://x402.org), fractions of a cent each. - **Starts free** — the free tier (`blockrun_chat mode:"free"`, `blockrun_dex`, crypto `blockrun_price`, `blockrun_models`) costs $0. - **Reads *and* acts** — most tools deliver data; `blockrun_polymarket` places real, confirm-gated trades. - **Human-in-the-loop payments** — turn on `BLOCKRUN_CONFIRM_SPEND=on` and the agent pauses before any paid call above your threshold; nothing is signed until you approve. [Details ↓](#%EF%B8%8F-human-in-the-loop-payments) @@ -83,17 +84,17 @@ Every other data integration was built for **human developers** — create an ac | | Raw provider APIs | Typical single-vendor MCP | **BlockRun MCP** | | ------------------- | -------------------------------- | ------------------------- | ----------------------------------------- | -| **Setup** | Account + API key *per vendor* | Account/key for 1 vendor | **Wallet auto-created — or one key for everything** | -| **Payment** | Credit card, monthly minimums | Credit card / vendor plan | **USDC per-call via x402, or prepaid credit** | +| **Setup** | Account + API key *per vendor* | Account/key for 1 vendor | **Wallet auto-created — or one BlockRun key for everything** | +| **Payment** | Credit card, monthly minimums | Credit card / vendor plan | **USDC per-call via x402, or card/wire-funded account credit** | | **Data sources** | One per integration | One vendor | **19 tools — LLMs, media, markets, chain**| | **Place real bets** | Build it yourself | Rare | **Yes — Polymarket CLOB, confirm-gated** | -| **Pay-chain** | — | — | **Solana + Base (or no chain at all)** | +| **Pay-chain** | — | — | **Solana + Base, or `api.blockrun.ai` with no chain at all** | | **Agent budgets** | Manual | — | **Built-in per-agent delegation** | | **Spend approval** | — | — | **Ask-before-pay dialog (MCP elicitation)** | | **Generative UI** | — | Rare | **Order card + wallet panel (MCP Apps)** | | **Open source** | Varies | Varies | **Yes (MIT)** | -✓ One wallet · ✓ Pay-per-call · ✓ Reads **and** trades · ✓ Multi-chain · ✓ Agent-ready · ✓ Open source +✓ One wallet or one account key · ✓ Pay-per-call · ✓ Reads **and** trades · ✓ Multi-chain · ✓ Agent-ready · ✓ Open source --- @@ -107,7 +108,7 @@ Before BlockRun, Claude can't answer: - *"What's the 24h volume on the PEPE/ETH pair on Uniswap?"* - *"Polymarket has the Fed holding at 73% — put $2 on it."* ← and now it can **place the trade**, not just read the odds. -After BlockRun, it can. Each query costs fractions of a cent — billed from a local USDC wallet, or from prepaid credit on a [BlockRun account](https://user.blockrun.ai). No subscriptions, no per-vendor signups. +After BlockRun, it can. Each query costs fractions of a cent — billed from a local USDC wallet, or from card-funded credit on a [BlockRun account](https://user.blockrun.ai) through [api.blockrun.ai](https://api.blockrun.ai). No subscriptions, no per-vendor signups. --- @@ -117,14 +118,14 @@ After BlockRun, it can. Each query costs fractions of a cent — billed from a l | | **Wallet** *(default)* | **API key** | |---|---|---| -| Setup | Nothing — a wallet is created on first run | Sign in at [user.blockrun.ai](https://user.blockrun.ai), mint a key | -| Funding | Send USDC (Solana or Base) | Card / wire → prepaid credit | -| Billing | Per call, settled on-chain, + $0.001 network fee | Post-paid at **exact** usage, no per-call fee, no minimum | -| Identity | A keypair on your machine | An account with members and an invoice | +| Setup | Nothing — a wallet is created on first run | Sign in at [user.blockrun.ai](https://user.blockrun.ai), mint a key, use it against [api.blockrun.ai](https://api.blockrun.ai) | +| Funding | Send USDC (Solana or Base) | Credit card / wire → account credit | +| Billing | Per call, settled on-chain, + $0.001 network fee | Exact-usage account credit, no per-call network fee, no minimum | +| Identity | A keypair on your machine | An account with members, credits, and a usage ledger | | Best for | Agents, solo devs, anything self-custody | Teams, companies, anyone who can't run a wallet | | Trade on Polymarket | ✅ | ❌ — needs a keypair to sign | -Both modes reach the same 19 tools. You can switch at any time; setting `BLOCKRUN_API_KEY` takes priority over a wallet, and unsetting it hands the wallet back. +Both modes reach the same 19 tools. Account mode sends service calls to `https://api.blockrun.ai` by default. You can switch at any time; setting `BLOCKRUN_API_KEY` takes priority over a wallet, and unsetting it hands the wallet back. ### 1. Install @@ -291,11 +292,11 @@ with the [Stanford runbook](docs/stanford-trading-demo.md). ### 3. Add funds -**Option A — API key (no wallet).** Sign in at **[user.blockrun.ai](https://user.blockrun.ai)** with Google, then: +**Option A — API key + account credit (no wallet).** Sign in at **[user.blockrun.ai](https://user.blockrun.ai)** with Google. This is the dashboard for keys, credits, and activity; the MCP uses the key to call **[api.blockrun.ai](https://api.blockrun.ai)** for the actual services. 1. **[Dashboard → Keys](https://user.blockrun.ai/dashboard/keys)** — mint a key. It looks like `brk_live_…` and is shown once. -2. **[Dashboard → Credits](https://user.blockrun.ai/dashboard/credits)** — top up by card or wire. -3. Point the server at it: +2. **[Dashboard → Credits](https://user.blockrun.ai/dashboard/credits)** — top up by credit card or wire. +3. Point the server at it. By default, account-mode calls go to `https://api.blockrun.ai`: ```bash claude mcp add blockrun -s user -e BLOCKRUN_API_KEY=brk_live_… -- npx -y @blockrun/mcp@latest @@ -314,13 +315,12 @@ balance, and what this session has spent: ``` Paying with: BlockRun account API key (no wallet, no chain) - Account: acme (ungated) - Spent to date: $4.5239 (invoiced account — no prepaid ceiling) + Account: acme (gated) + Credit remaining: $12.5000 of $50.00 granted Top up: https://user.blockrun.ai/dashboard/credits ``` -A prepaid account shows `Credit remaining: $12.50 of $50.00 granted` instead. If -the account is blocked, status says so and why *before* you spend a call finding out. +Invoiced accounts show `Spent to date: $4.5239 (invoiced account — no prepaid ceiling)` instead. If the account is blocked, status says so and why *before* you spend a call finding out. **Option B — wallet (no account).** Run `blockrun_wallet` to see your addresses. New installs default to **Solana**; send USDC (SPL) on Solana from Coinbase (pick "Solana"), Phantom, Solflare, or Backpack. To pay on Base instead: `blockrun_wallet action:"chain" chain:"base"`, then send USDC on Base. Full instructions: [Fund your wallet](#fund-your-wallet). @@ -330,7 +330,7 @@ the account is blocked, status says so and why *before* you spend a call finding > *"What's Polymarket saying about the next Fed decision? If 'hold' is above 70%, put $2 on it."* -Claude reads the odds with `blockrun_markets` and — with your confirmation — places the trade with `blockrun_polymarket`. One wallet. Gasless. Confirm-gated. +Claude reads the odds with `blockrun_markets`. In wallet mode, and only after your confirmation, it can also place the trade with `blockrun_polymarket`. In API-key mode, the data/media/research calls run through `api.blockrun.ai`; Polymarket trading still requires a local keypair to sign. ### 5. Install the agent skills (optional) @@ -492,7 +492,7 @@ On hosts that support the [MCP Apps extension](https://modelcontextprotocol.io/e ## Fund your wallet -> Paying with an API key instead? There is no wallet to fund — top up credit at **[user.blockrun.ai/dashboard/credits](https://user.blockrun.ai/dashboard/credits)** and skip this section. +> Paying with an API key instead? There is no wallet to fund — top up credit by card or wire at **[user.blockrun.ai/dashboard/credits](https://user.blockrun.ai/dashboard/credits)**. The MCP will use that key against **[api.blockrun.ai](https://api.blockrun.ai)** and skip the wallet rail entirely. The server keeps **two** wallets — one on Solana, one on Base — and pays from one at a time. Run `blockrun_wallet` to see both addresses, balances, and which is active. @@ -553,7 +553,7 @@ A blocked capability returns a message naming the fix, not a raw error. Anything estimated is printed with a `~` and says so. Estimates run **high** on the account rail — they add a transaction fee it does not charge — so a budget -cap trips early rather than late. The invoice is always +cap trips early rather than late. The source of truth is always [Dashboard → Activity](https://user.blockrun.ai/dashboard/activity). --- @@ -573,7 +573,7 @@ cap trips early rather than late. The invoice is always ## Showcase -Posters generated through `blockrun_image` with `openai/gpt-image-2` — each a single API call routed through BlockRun, paid in USDC on Base. +Posters generated through `blockrun_image` with `openai/gpt-image-2` — each a single API call routed through BlockRun, paid from either account credit or a USDC wallet.

gpt-5.5 — now live on BlockRun. Pay per call. No subscription. No keys. @@ -592,12 +592,12 @@ Prompts and a worked example are in [`skills/image-prompting/SKILL.md`](skills/i | | Direct APIs | BlockRun | |---|---|---| -| Exa | Sign up, $20/mo minimum | $0.011/call on Base ($0.01 + fee), no subscription | -| Polymarket | Undocumented, rate-limited | $0.0085/call on Base ($0.0075 + fee), clean JSON — plus you can **trade** | -| DefiLlama | Free tier, rate-limited, no SLA | $0.006/call on Base ($0.005 + fee), same JSON, one wallet | -| Multiple sources | 3 accounts, 3 API keys, 3 billing pages | **1 wallet** | +| Exa | Sign up, $20/mo minimum | $0.011/call on Base ($0.01 + fee), or exact account usage via `api.blockrun.ai` | +| Polymarket | Undocumented, rate-limited | $0.0085/call on Base ($0.0075 + fee), or exact account usage for reads — plus wallet mode can **trade** | +| DefiLlama | Free tier, rate-limited, no SLA | $0.006/call on Base ($0.005 + fee), or exact account usage via `api.blockrun.ai` | +| Multiple sources | 3 accounts, 3 API keys, 3 billing pages | **1 wallet, or 1 BlockRun account key** | -One wallet. All sources. No dashboards. +One wallet, or one dashboard-backed API key. All sources. --- @@ -608,9 +608,9 @@ One wallet. All sources. No dashboards. | Variable / File | Default | Effect | |---|---|---| -| `BLOCKRUN_API_KEY` | unset | A BlockRun account key (`brk_live_…`) from [user.blockrun.ai/dashboard/keys](https://user.blockrun.ai/dashboard/keys). **Set → account billing: no wallet is created, read or used, and no chain applies.** Takes priority over every wallet setting below. A malformed value is a startup error, never a silent fall back to the wallet. | +| `BLOCKRUN_API_KEY` | unset | A BlockRun account key (`brk_live_…`) from [user.blockrun.ai/dashboard/keys](https://user.blockrun.ai/dashboard/keys). **Set → account billing through `api.blockrun.ai`: no wallet is created, read or used, and no chain applies.** Takes priority over every wallet setting below. A malformed value is a startup error, never a silent fall back to the wallet. | | `~/.blockrun/.api-key` | not created | The same key on disk, for clients that make env vars awkward. Read only when `BLOCKRUN_API_KEY` is unset; an empty or unreadable file falls through to wallet mode. | -| `BLOCKRUN_API_BASE_URL` | `https://api.blockrun.ai` | Account API base, for staging. Accepts the OpenAI-style `…/v1` form too. | +| `BLOCKRUN_API_BASE_URL` | `https://api.blockrun.ai` | Account API service endpoint used after you get a key at `user.blockrun.ai`. Override only for staging. Accepts the OpenAI-style `…/v1` form too. | | `~/.blockrun/.session` | auto-created on first run | EVM private key (0x…). File exists → use Base. Also the Polymarket signer (unless `BLOCKRUN_WALLET_KEY` or an agent `wallet.json` takes precedence). | | `BLOCKRUN_WALLET_KEY` | unset | Env override of the EVM key — takes precedence over `.session` / `wallet.json` as the Base + Polymarket signer. | | `~/.blockrun/.chain` | unset | Explicit chain preference: `base` or `solana`. Written only by `blockrun_wallet action:"chain"` — i.e. only when you choose. | @@ -669,13 +669,13 @@ An open-source MCP server that gives Claude and other agents 78 models, <1ms local routing, USDC on Base & Solana. - **🤖 [BRCC](https://blockrun.ai/brcc.md)** — BlockRun for Claude Code: smart routing + x402 payments, purpose-built for Claude Code. From b2e8a2f959a7ee96736e871ccb7a87f2e123d399 Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Tue, 15 Sep 2026 06:45:11 -0500 Subject: [PATCH 22/37] fix(chat,errors): every chat path streams, classifies its failure by what the wire said, and books what the rail bills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three OpenAI-compat chat paths and the native claude-* path now share one question on a throw — did money move? — answered from evidence, not from the words in the message: settlementOnThrow returns none/unknown/settled from the error's type, status, prefix and transport cause. Before, the account rail booked the full reserve for every refusal (a typo'd model id exhausted a delegated cap at $0 real spend — C5/C18/C23/C31), the wallet rails read a payment sent-and-never-answered as $0 and let the routing loop pay a second model (C19), and the native path booked nothing at all after a settled 524 (C20). - Solana chat streams via SolanaLLMClient.stream() — the 60s non-streaming abort on the default chain, after the SPL payment was sent, is gone. - The native path streams too (maxRetries 0): the SDK refused non-streaming thinking budgets above ~21k tokens (D51) and retried a settled 5xx with a fresh x402 payment each time (C20). - The account rail books exact usage at the model's rate, no fee, no floor (accountLedgerUsd / anthropicAccountLedgerUsd) instead of the gate reserve that ran 3-50x high (C33/D53/D58); a settled x-blockrun-cost-usd, zero included, wins when present. - served_model, finish_reason and truncated_output ride on every reply; partial text survives a mid-stream failure (D55/D57). - claude-* without `thinking` is allowed on Solana through the compat path (D5); the refusal now names the one thing the compat path cannot carry. - formatError: an account-rail 402 says "out of credit", never "run setup" (C28); a 5xx or transport failure after the payment left says the charge MAY stand instead of "try again in a few minutes" (C38); the Base-only replay-nonce reading of "Payment was rejected" is a shared hedge. - The free tier is re-swept: six of eleven routed ids answered as another model on both chains, so the tier keeps only the five that echo their own name; the rest stay $0 in FREE_CHAT_MODELS. - anthropic/claude-opus-4 gets a price row: the account sheet bills the hidden-but-served id at $15/$75 and it was reserving the $5/$30 default. Co-Authored-By: Claude Opus 5 (1M context) --- src/tools/chat-anthropic.ts | 199 +++++++++- src/tools/chat.ts | 507 +++++++++++++++---------- src/utils/api-key-call.ts | 19 +- src/utils/chat-stream.ts | 471 +++++++++++++++++++---- src/utils/constants.ts | 90 +++-- src/utils/errors.ts | 149 +++++++- test/chat-account-rail.test.ts | 319 ++++++++++++++++ test/chat-anthropic-account.test.ts | 101 +++++ test/chat-anthropic-settled.test.ts | 191 ++++++++++ test/chat-free-deadline.test.ts | 8 +- test/chat-settled-on-error.test.ts | 8 +- test/chat-stream.test.ts | 156 +++++++- test/chat-wallet-after-payment.test.ts | 356 +++++++++++++++++ test/chat.test.ts | 48 ++- test/errors-rails.test.ts | 211 ++++++++++ test/errors.test.ts | 43 ++- 16 files changed, 2529 insertions(+), 347 deletions(-) create mode 100644 test/chat-account-rail.test.ts create mode 100644 test/chat-anthropic-account.test.ts create mode 100644 test/chat-anthropic-settled.test.ts create mode 100644 test/chat-wallet-after-payment.test.ts create mode 100644 test/errors-rails.test.ts diff --git a/src/tools/chat-anthropic.ts b/src/tools/chat-anthropic.ts index de242dc..fdaf3d0 100644 --- a/src/tools/chat-anthropic.ts +++ b/src/tools/chat-anthropic.ts @@ -16,8 +16,10 @@ import type Anthropic from "@anthropic-ai/sdk"; import { extractErrorMessage, formatError } from "../utils/errors.js"; import { recordActualSpend } from "../utils/budget.js"; +import { isApiKeyMode } from "../utils/auth.js"; import { OBSERVED_GATEWAY_TX_FEE_USD } from "../utils/tx-fee.js"; import { CHAT_PRICE_PER_MTOKEN, GATEWAY_CHARS_PER_TOKEN_OBSERVED } from "../utils/constants.js"; +import { AcceptedThenFailedError, settlementOnThrow, settledCostFromHeaders } from "../utils/chat-stream.js"; import type { BudgetState } from "../types.js"; /** @@ -120,9 +122,143 @@ export function anthropicCallCost( return Math.ceil(charged * 1e6) / 1e6; // the gateway settles in whole micro-USDC } +/** + * The ACCOUNT rail's ledger entry for a native call, when the response carried + * no `x-blockrun-cost-usd` (chat settles after the response, so it never does). + * + * Not anthropicCallCost: that is the x402 QUOTE the wallet rails settle — + * output at 0.1x max_tokens, a $0.001 floor, the observed transaction fee — + * and api.blockrun.ai settles none of it. It bills exact usage, base rate with + * no fee and no floor (reconciled against the dashboard 2026-09-05). Booking + * the quote formula here added $0.001 to every call on a rail that charges no + * fee — a $1 delegate cut off at 500 haiku calls that had cost $0.50 (D58). + * + * `usage` is the response's own input/output token counts when the call + * completed; on a failure after acceptance there is none, so the prompt at the + * observed chars/token and the full max_tokens stand in — the conservative + * side for a call that reported nothing. Null when the model has no row, so + * the caller falls back to the pre-call estimate. + */ +export function anthropicAccountLedgerUsd( + model: string, + promptChars: number, + maxTokens: number, + usage: { input_tokens: number; output_tokens: number } | null, +): number | null { + const id = catalogueKeyForEcho(model); + const rate = Object.hasOwn(CHAT_PRICE_PER_MTOKEN, id) ? CHAT_PRICE_PER_MTOKEN[id] : undefined; + if (!rate) return null; + const inputTokens = usage?.input_tokens ?? Math.ceil(promptChars / GATEWAY_CHARS_PER_TOKEN_OBSERVED) + MESSAGE_TOKEN_OVERHEAD; + const outputTokens = usage?.output_tokens ?? maxTokens; + const usd = (inputTokens / 1_000_000) * rate.input + (outputTokens / 1_000_000) * rate.output; + return Math.ceil(usd * 1e6 - 1e-6) / 1e6; // whole micro-dollars, float noise excluded +} + // AnthropicClient.messages is typed as the official SDK's Messages resource. type AnthropicLike = { messages: Anthropic["messages"] }; +/** + * Nothing streamed for this long means the connection is dead, not slow: the + * API sends `ping` events every few seconds while a long thinking budget runs, + * and the gateway forwards them. Same figure as the OpenAI-compat assembler. + */ +const NATIVE_IDLE_TIMEOUT_MS = 120_000; + +/** + * Run the native call as a STREAM and assemble the final Message. + * + * Streaming, not create(): two reasons, both money or reach. + * + * 1. @anthropic-ai/sdk refuses a non-streaming request whose max_tokens + * could run past ten minutes — `calculateNonstreamingTimeout` throws + * "Streaming is required for operations that may take longer than 10 + * minutes" above 21,333 tokens — and effectiveMax is budget_tokens + 1024, + * so every thinking budget from 20,310 up to the schema's 100,000 died in + * this process with an error that blamed the caller (D51). The check is + * skipped when `stream` is set. + * 2. A non-streaming request moves zero bytes while Claude thinks, and the + * edge in front of the gateway 524s the idle connection at ~100s — after + * the gateway verified the payment. Streaming keeps bytes flowing (pings, + * thinking deltas), the same fix chat-stream.ts made for the compat paths. + * + * `maxRetries: 0`, always. @blockrun/llm builds the official SDK at its default + * of two retries with a fetch that signs a FRESH x402 payment on every 402 it + * sees — the PAYMENT-SIGNATURE header lives on a local copy, never on the + * SDK's request init — so a 5xx/524/timeout after settlement was retried up to + * twice more, each retry a new USDC settlement for an undelivered answer, none + * of it visible to the ledger (C20). The gateway already saw the payment; a + * retry is a second purchase, and the routing loop's one-settlement rule + * belongs here too. (The account rail's client is built with maxRetries 0 by + * the SDK itself; passing it per request covers both.) + * + * The SDK's MessageStream accumulates thinking and signature deltas, so the + * assembled Message carries the same verbatim thinking blocks the non-streaming + * response did — the test against the real SDK pins that. + * + * `accepted` is whether the stream CONNECTED (the SDK emits `connect` once the + * 2xx is in, before the first event). A throw after that point is wrapped as + * AcceptedThenFailedError: the payment settled (wallet) or the request is + * billed (account), and the caller books it. + */ +async function streamNativeMessage( + client: AnthropicLike, + params: Anthropic.MessageStreamParams, +): Promise<{ message: Anthropic.Message; costHeaderUsd: number | null }> { + // The @blockrun/llm proxy wraps every messages.* call in an async function, + // so the MessageStream arrives behind a promise; the SDK returns it directly. + const stream = await client.messages.stream(params, { maxRetries: 0 }); + let accepted = false; + stream.on("connect", () => { accepted = true; }); + + // Idle guard, reset on every event. The SDK has no per-event deadline of its + // own — its request timeout ends at the headers — and the fetch underneath + // clears its abort timer at the same point. + let idleTimer: NodeJS.Timeout | undefined; + let stalled: (err: Error) => void = () => undefined; + const stall = new Promise((_, reject) => { stalled = reject; }); + const armIdle = () => { + clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + stalled(new AcceptedThenFailedError(`stream stalled: no data from the gateway for ${Math.round(NATIVE_IDLE_TIMEOUT_MS / 1000)}s`)); + stream.abort(); + }, NATIVE_IDLE_TIMEOUT_MS); + }; + stream.on("streamEvent", armIdle); + armIdle(); + try { + const message = await Promise.race([stream.finalMessage(), stall]); + return { message, costHeaderUsd: settledCostFromHeaders(stream.response?.headers) }; + } catch (error) { + if (error instanceof AcceptedThenFailedError) throw error; + if (accepted) { + throw new AcceptedThenFailedError(error instanceof Error ? error.message : String(error), "", { cause: error }); + } + throw error; + } finally { + clearTimeout(idleTimer); + } +} + +/** + * The note for a native call that cost money — or may have — and then failed. + * Same voice as chat.ts's settledThenFailedText; the two paths book the same + * way and must read the same way to the agent acting on them. + */ +function nativeFailedText(error: unknown, usd: number, certainty: "settled" | "unknown"): string { + const amount = `$${usd.toFixed(6)}`; + const what = isApiKeyMode() + ? certainty === "settled" + ? `Note: the gateway had accepted this request (HTTP 200) before it failed, so it is billed to your BlockRun account at exact usage — ` + + `an estimated ~${amount} has been recorded against your budget; https://user.blockrun.ai/dashboard/activity has the exact figure.` + : `Note: this request MAY have been billed to your BlockRun account — no response was observed, so this process cannot tell. ` + + `An estimated ~${amount} has been recorded against your budget as a precaution; https://user.blockrun.ai/dashboard/activity has the truth.` + : certainty === "settled" + ? `Note: payment had already settled when this failed, so the charge stands (~${amount}, the reconstructed quote) and it has been recorded against your budget.` + : `Note: the payment for this call had been signed and sent before it failed, and this process cannot tell whether the gateway settled it — ` + + `it may have settled after the connection dropped. The reconstructed quote (${amount}) has been recorded against your budget as a precaution.`; + return `${formatError(extractErrorMessage(error))}\n\n${what} Retrying will incur a second charge — check blockrun_wallet action:"report" first.`; +} + type TextPart = { type: "text"; text: string }; type ImagePart = { type: "image_url"; image_url: { url: string } }; type ContentPart = TextPart | ImagePart; @@ -264,7 +400,7 @@ export async function handleAnthropicNative(args: AnthropicNativeArgs): Promise< ? m.content.length : JSON.stringify(m.content ?? "").length), 0); - const params: Anthropic.MessageCreateParamsNonStreaming = { + const params: Anthropic.MessageStreamParams = { model, max_tokens: effectiveMax, messages: apiMessages, @@ -280,23 +416,52 @@ export async function handleAnthropicNative(args: AnthropicNativeArgs): Promise< params.temperature = Math.max(0, Math.min(1, temperature)); } + // What a failure after the money moved is booked at. The wallet rails settle + // the quote (anthropicCallCost); the account rail bills exact usage, which a + // failed call never reports, so its ledger figure is the model's rate over + // the prompt and the full max_tokens. Either falls back to the reserve when + // the model has no row. + const failedLedgerUsd = () => (isApiKeyMode() + ? anthropicAccountLedgerUsd(model, anthropicPromptChars, effectiveMax, null) + : anthropicCallCost(model, anthropicPromptChars, effectiveMax)) ?? estimatedCost; + let native: Anthropic.Message; + let costHeaderUsd: number | null; try { - native = await client.messages.create(params); + ({ message: native, costHeaderUsd } = await streamNativeMessage(client, params)); } catch (error) { - return { content: [{ type: "text", text: formatError(extractErrorMessage(error)) }], isError: true }; + // Until audit round 3 this returned formatError and booked nothing, on + // both rails — the settled-then-failed machinery the OpenAI-compat paths + // gained in 0.40.1/0.49.0/0.50.0 never reached here, so a 524 after the + // gateway settled read as "temporary API issue, try again" and the agent + // paid again (C20). Same classifier as those paths: a 4xx before the + // stream connected (the gateway's own refusal, or the SDK's) and a + // payment the wallet could not make are not money; a failure after the + // 2xx is; an origin that never answered may be. + const verdict = settlementOnThrow(error, { rail: isApiKeyMode() ? "account" : "wallet", estimateUsd: estimatedCost, transparentPayment: true }); + if (verdict === "none") { + return { content: [{ type: "text", text: formatError(extractErrorMessage(error)) }], isError: true }; + } + const usd = failedLedgerUsd(); + recordActualSpend(budget, usd, estimatedCost, agentId); + return { content: [{ type: "text", text: nativeFailedText(error, usd, verdict) }], isError: true }; } - // Book what the gateway actually charged (the quote it settled), not the flat - // estimate and not a token reconstruction at Anthropic's list prices. + // Book what the gateway actually charged: on the wallet rails the quote it + // settled (anthropicCallCost) — not the flat estimate and not a token + // reconstruction at Anthropic's list prices; on the account rail the + // response's settled cost when it carried one, else exact usage at the + // model's rate (anthropicAccountLedgerUsd), labelled as the estimate it is. // effectiveMax is the max_tokens the request was sent with — including the // auto-raise for a thinking budget, which is what the quote was priced on. - recordActualSpend( - budget, - anthropicCallCost(native.model, anthropicPromptChars, effectiveMax), - estimatedCost, - agentId, - ); + const bookedUsd = isApiKeyMode() + ? (costHeaderUsd ?? anthropicAccountLedgerUsd(native.model, anthropicPromptChars, effectiveMax, native.usage)) + : anthropicCallCost(native.model, anthropicPromptChars, effectiveMax); + const costIsEstimate = isApiKeyMode() && costHeaderUsd === null; + recordActualSpend(budget, bookedUsd, estimatedCost, agentId); + const costLine = costIsEstimate && bookedUsd !== null && bookedUsd > 0 + ? `\n\n(Cost: ~$${bookedUsd.toFixed(4)}, estimated — billed to your BlockRun account at exact usage; https://user.blockrun.ai/dashboard/activity has the figure.)` + : ""; const thinkingBlocks = native.content.filter(isThinkingBlock); const textBlocks = native.content.filter(isTextBlock); @@ -310,7 +475,14 @@ export async function handleAnthropicNative(args: AnthropicNativeArgs): Promise< if (raisedMaxTokens) headerBits.push(`max_tokens→${effectiveMax}`); const header = `[${headerBits.join(" | ")}]`; - const content: { type: "text"; text: string }[] = [{ type: "text", text: `${header}\n\n${answerText}` }]; + // stop_reason "max_tokens" is the native spelling of a reply cut short; + // surfaced in the text as the compat paths do, not only in structuredContent. + const truncated = native.stop_reason === "max_tokens" + ? `\n\n⚠️ TRUNCATED OUTPUT: the reply hit max_tokens=${effectiveMax} and stopped mid-way (stop_reason "max_tokens"). ` + + `Raise max_tokens to get the rest — thinking tokens count against it too.` + : ""; + + const content: { type: "text"; text: string }[] = [{ type: "text", text: `${header}\n\n${answerText}${truncated}${costLine}` }]; if (thinkingText) { content.push({ type: "text", text: `🧠 Thinking (signature ${signaturePresent ? "present" : "absent"}):\n${thinkingText}` }); } @@ -328,7 +500,10 @@ export async function handleAnthropicNative(args: AnthropicNativeArgs): Promise< thinking_blocks: thinkingBlocks, signature_present: signaturePresent, stop_reason: native.stop_reason, + ...(native.stop_reason === "max_tokens" ? { truncated_output: true } : {}), usage: native.usage, + cost_usd: bookedUsd ?? estimatedCost, + cost_is_estimate: costIsEstimate || bookedUsd === null, native, }, }; diff --git a/src/tools/chat.ts b/src/tools/chat.ts index a2e7105..5035ba0 100644 --- a/src/tools/chat.ts +++ b/src/tools/chat.ts @@ -4,7 +4,14 @@ import { TOOL_ANNOTATIONS } from "../tool-annotations.js"; import { z } from "zod"; import { buildClient, buildClientWithTimeout, getAnthropicClient, baseOnlyMessage } from "../utils/wallet.js"; import { isApiKeyMode } from "../utils/auth.js"; -import { streamChatText, supportsStreaming, type StreamChatMessage } from "../utils/chat-stream.js"; +import { + completeChat, + settlementOnThrow, + AcceptedThenFailedError, + type ChatOutcome, + type ChatUsage, + type StreamChatMessage, +} from "../utils/chat-stream.js"; import { handleAnthropicNative, isAnthropicModel } from "./chat-anthropic.js"; import { extractErrorMessage, formatError } from "../utils/errors.js"; import { @@ -18,6 +25,7 @@ import { canonicalChatModel, TIER_WORST_PRICE, GATEWAY_CHARS_PER_TOKEN, + GATEWAY_CHARS_PER_TOKEN_OBSERVED, type RoutingMode, } from "../utils/constants.js"; import { reserveBudget, recordActualSpend } from "../utils/budget.js"; @@ -187,81 +195,154 @@ export function estimateChatCost( } /** - * Settled cost of the LLMClient call that ran inside `run`, measured as the - * delta of the client's own cumulative spend counter (getSpending().totalUsd), - * which the SDK increments with the REAL on-chain amount per call. Returns the - * result plus the booked cost so callers can record actual spend, falling back - * to the estimate when the delta is unavailable (0/NaN). + * What the ACCOUNT rail is booked at when the response carried no settled cost. * - * A THROW DOES NOT MEAN A REFUND. x402 settles when the gateway answers 200 — - * the SDK increments its counter at that moment, before the body is read — and - * every paid path here STREAMS, so the call can still fail afterwards: a - * mid-stream error event, an idle stall, an empty completion. Until 0.40.1 the - * delta was computed only after `run()` resolved, so on that path the USDC left - * the wallet, `budget.spent` never moved, and the `finally` released the - * reservation — the ledger recorded a free call. `onSettledThrow` is how the - * charge still gets booked; it fires only when the delta is real (> 0), so an - * ordinary pre-payment failure (400, timeout, refusal) still books nothing. + * api.blockrun.ai bills chat at exact usage, after the response is sent — which + * is why `x-blockrun-cost-usd` is absent on chat by design (api-key-call.ts). + * Until audit round 3 the three OpenAI-compat paths then booked the GATE + * reserve: the most expensive member of the tier, input at 2 chars/token, + * output at the full max_tokens, plus a $0.002 transaction fee this rail never + * charges — 3-6x the real charge on mode:"balanced", up to ~50x on "powerful" + * with a short reply, so BLOCKRUN_BUDGET_LIMIT tripped at a fraction of real + * spend and action:"report" overstated it (C33/D53). 0.50.0's ledgerFallback + * fixed the same reserve-as-ledger pattern for the path tools; this is chat's. * - * ON THE ACCOUNT RAIL THERE IS NO COUNTER TO READ. getSpending() does not return - * zero for an API-key client — it THROWS: + * The model's own rate — the SERVED model's when it has a row (the gateway + * aliases retired ids, and the bill follows what answered), else the requested + * one's; a free REQUEST is free whatever answered it (the $0 probes show every + * alias of a free id served without a payment header) — input and output at + * the token counts the stream reported when it did, else the prompt at the + * observed chars/token and the full max_tokens (the conservative side, for a + * failure that reported nothing). No fee, no floor: reconciled 2026-09-05, the + * account rail charges base × margin and nothing else. Still an estimate — the + * caller labels it as one. + */ +export function accountLedgerUsd( + requestedModel: string, + servedModel: string | null, + promptChars: number, + maxTokens: number, + usage: ChatUsage | null, +): number { + const requested = canonicalChatModel(requestedModel); + if (FREE_CHAT_MODELS.has(requested)) return 0; + const served = servedModel ? canonicalChatModel(servedModel) : null; + const rate = served && Object.hasOwn(CHAT_PRICE_PER_MTOKEN, served) ? CHAT_PRICE_PER_MTOKEN[served] + : Object.hasOwn(CHAT_PRICE_PER_MTOKEN, requested) ? CHAT_PRICE_PER_MTOKEN[requested] + : DEFAULT_CHAT_PRICE; + const inTokens = usage?.promptTokens ?? Math.ceil(promptChars / GATEWAY_CHARS_PER_TOKEN_OBSERVED); + const outTokens = usage?.completionTokens ?? maxTokens; + const usd = (inTokens / 1_000_000) * rate.input + (outTokens / 1_000_000) * rate.output; + // Whole micro-dollars, rounded up as the gateway bills; the epsilon keeps a + // binary-float 4500.0000000001 from ceiling to 4501. + return Math.ceil(usd * 1e6 - 1e-6) / 1e6; +} + +/** What a failed call cost, as far as this process can tell. */ +type FailedBooking = { + usd: number; + /** "settled": the charge is certain. "unknown": it may have happened; booked as a precaution. */ + certainty: "settled" | "unknown"; +}; + +/** + * Settled cost of the chat call that ran inside `run`. * - * "Account usage is available at https://user.blockrun.ai/dashboard; - * getSpending() tracks x402 settlements only." + * ON THE WALLET RAILS it is the delta of the client's own cumulative spend + * counter (getSpending().totalUsd), which the SDK increments with the REAL + * on-chain amount once the PAID response comes back OK. A THROW DOES NOT MEAN A + * REFUND: x402 settles on that 200, before the body is read, and the call can + * still fail afterwards — a mid-stream error event, an idle stall, an empty + * completion. Until 0.40.1 the delta was computed only after `run()` resolved, + * so the USDC left the wallet, `budget.spent` never moved, and the `finally` + * released the reservation — the ledger recorded a free call. `onSettledThrow` + * is how the charge still gets booked. * - * and it is called three times here, on the very first line, outside the try. - * Left alone, setting BLOCKRUN_API_KEY would not degrade blockrun_chat, it would - * break every single call before the request was even sent. So account mode - * skips the counter entirely and reports settledUsd 0, which every caller - * already handles as "delta unavailable — fall back to the estimate". + * The counter has a blind spot, and it is on the DEFAULT chain. The SDK counts + * only after the paid retry was OK — SolanaLLMClient runs assertPaid() before + * recordSettlement(), LLMClient throws "API error after payment" before its + * increment, and a fetch timeout on the paid retry (60s on Solana) throws with + * no increment at all. The payment had been signed and SENT in every one of + * those; the gateway settles them after the client is gone. Until audit round + * 3 that read as a $0 delta: nothing booked, "temporary API issue — try again", + * and the routing loop signed a second payment for the next model under the + * same reservation (C19). settlementOnThrow classifies those as "unknown": the + * reserve is booked as a precaution, the loop stops, the note says MAY. An + * unpaid first-response 4xx, a refused payment, or a 4xx on the paid retry (the + * gateway's own refusal, before settlement starts) still books nothing. * - * The estimate is genuinely all we have: the account API returns no per-call - * cost header, and its dashboard is cookie-authenticated, so there is nothing - * this process could read back. Callers label the number accordingly rather - * than printing an estimate that looks like a settlement. + * ON THE ACCOUNT RAIL THERE IS NO COUNTER TO READ — getSpending() THROWS for an + * API-key client. What there is instead: the SDK's account transport throws an + * APIError carrying the status of the FIRST response, before any body, for + * every refusal (400 unknown model, 401, 402 out of credit, 429), so none of + * those is billed; a failure after the 2xx (AcceptedThenFailedError) is a + * billed call at exact usage; an origin that never answered is "unknown". The + * 0.50.0 fix for "a billed-then-dropped stream booked $0" caught EVERY + * rejection here and booked the full reserve for it with "the charge stands" + * — five typo'd model ids exhausted a delegated cap at $0 real spend, a 402 + * out-of-credit read as billed, and mode:"free" died on its first timeout + * (C5/C18/C23/C31). On success the response may carry `x-blockrun-cost-usd`; + * when it does that is the entry (a settled zero included), and when it does + * not, accountLedgerUsd is — labelled as the estimate it is. */ -async function withSettledCost( +async function withSettledCost( client: ApiClient, + estimateUsd: number, + accountLedger: (outcome: T | null) => number, run: () => Promise, - onSettledThrow?: (settledUsd: number | null) => void, -): Promise<{ result: T; settledUsd: number }> { + onSettledThrow: (booking: FailedBooking) => void, +): Promise<{ result: T; settledUsd: number; costIsEstimate: boolean }> { if (isApiKeyMode()) { - // The account rail has no spending delta to read — but it bills the request - // when the gateway ACCEPTS it, so a failure after that point is a billed - // call whose amount this process cannot see. 0.49.0 wired onSettledThrow - // into all three chat paths and then short-circuited here with no - // try/catch, so on the rail where the money is least visible the note never - // fired: a billed-then-dropped stream booked $0 and read as a free failure, - // whose obvious next step is to pay for it again (audit round 2). try { - return { result: await run(), settledUsd: 0 }; + const result = await run(); + return result.settledUsd === null + ? { result, settledUsd: accountLedger(result), costIsEstimate: true } + : { result, settledUsd: result.settledUsd, costIsEstimate: false }; } catch (error) { - onSettledThrow?.(null); + const verdict = settlementOnThrow(error, { rail: "account", estimateUsd }); + if (verdict !== "none") onSettledThrow({ usd: accountLedger(null), certainty: verdict }); throw error; } } const before = client.getSpending().totalUsd; + const delta = () => { + const d = client.getSpending().totalUsd - before; + return Number.isFinite(d) && d >= 0 ? d : null; + }; try { const result = await run(); - return { result, settledUsd: client.getSpending().totalUsd - before }; + // null = the counter could not be read: book the reserve rather than $0. + const d = delta(); + return { result, settledUsd: d ?? estimateUsd, costIsEstimate: d === null }; } catch (error) { - const settledUsd = client.getSpending().totalUsd - before; - if (Number.isFinite(settledUsd) && settledUsd > 0) onSettledThrow?.(settledUsd); + const d = delta(); + if (d !== null && d > 0) { + onSettledThrow({ usd: d, certainty: "settled" }); + } else if (settlementOnThrow(error, { rail: "wallet", estimateUsd }) === "unknown") { + onSettledThrow({ usd: estimateUsd, certainty: "unknown" }); + } + // A counter that says $0 after a 2xx is a call the gateway served without + // charging (its free fallback); nothing to book. throw error; } } /** - * The error text for a call that SETTLED and then failed. + * The error text for a call that cost money — or may have — and then failed. * * x402 settles on the 200, before the body is read, and every paid path streams, * so a stall or an in-band error event arrives with the money already gone. * withSettledCost books it (onSettledThrow); this is the sentence that tells the * CALLER. Without it the text was "Error: stream stalled: no data from the * gateway for 120s" — indistinguishable from a free failure, so the obvious next - * step (retry) settled a second payment. The routing loop has said this since - * 0.40.1; the explicit-model and multi-turn paths, which by construction fail - * only after settlement, never did. + * step (retry) settled a second payment. + * + * The wording tracks the certainty, because the text is what an agent acts on. + * "The charge stands" is said only when it is known to (a counter delta, or an + * account request the gateway accepted with a 2xx). When the payment was sent + * and no verdict came back, the note says MAY, names the booked reserve as a + * precaution, and points at action:"report" — it does not assert a charge it + * cannot see, and it does not invite a retry that would pay again. * * formatError runs on the BARE error and the note is appended afterwards, on * purpose: formatError classifies on keywords, and this note contains the word @@ -269,18 +350,74 @@ async function withSettledCost( * text, the routing loop's version ended in "your wallet needs funding" — the * exact wrong advice for a call that just paid. */ -function settledThenFailedText(error: unknown, settledUsd: number | null, tail: string): string { - // null = the account rail: billed, amount not visible to this process. - const what = settledUsd === null - ? `Note: the gateway had already accepted and billed this request when it failed, so the charge stands — ` + - `this rail does not report the amount to the client, so the estimate has been recorded against your budget ` + - `and https://user.blockrun.ai/dashboard/activity has the exact figure.` - : `Note: payment had already settled when this failed, ` + - `so the charge stands ($${settledUsd.toFixed(6)}) and it has been recorded against your budget.`; - return `${formatError(extractErrorMessage(error))}\n\n${what} ${tail}`; +function settledThenFailedText(error: unknown, booking: FailedBooking, tail: string): string { + const usd = `$${booking.usd.toFixed(6)}`; + let what: string; + if (isApiKeyMode()) { + what = booking.certainty === "settled" + ? `Note: the gateway had accepted this request (HTTP 200) before it failed, so it is billed to your BlockRun account at exact usage — ` + + `an estimated ~${usd} has been recorded against your budget; https://user.blockrun.ai/dashboard/activity has the exact figure.` + : `Note: this request MAY have been billed to your BlockRun account — no response was observed, so this process cannot tell. ` + + `An estimated ~${usd} has been recorded against your budget as a precaution; https://user.blockrun.ai/dashboard/activity has the truth.`; + } else { + what = booking.certainty === "settled" + ? `Note: payment had already settled when this failed, so the charge stands (${usd}) and it has been recorded against your budget.` + : `Note: the payment for this call had been signed and sent before it failed, and this process cannot tell whether the gateway settled it — ` + + `it may have settled after the connection dropped. The reserved ${usd} has been recorded against your budget as a precaution.`; + } + const partial = error instanceof AcceptedThenFailedError && error.partialText + ? `\n\nPartial response received before the failure (${error.partialText.length.toLocaleString("en-US")} chars):\n${error.partialText}` + : ""; + return `${formatError(extractErrorMessage(error))}\n\n${what} ${tail}${partial}`; } const RETRY_CHARGES_AGAIN = 'Retrying will incur a second charge — check blockrun_wallet action:"report" first.'; +/** + * Notes appended to a SUCCESSFUL reply about what the gateway said of it. + * + * `served_model`: constants.ts documents that the gateway aliases retired ids + * onto a live model instead of 404ing, and that "only the response's `model` + * field" tells you. The text and structuredContent used to echo the REQUESTED + * id, so an agent asking a stale id for "Kimi's opinion" presented another + * model's answer as Kimi's, paid for a model nobody chose (D55). The $0 probe + * on 2026-09-13 showed eight of eleven free[] ids answering as another model. + * + * `truncated_output`: finish_reason "length" is the model stopping at + * max_tokens mid-sentence (or mid-JSON, with response_format json_object). It + * was read and dropped, so a cut reply came back looking complete — the silent + * truncation shape the free-tier prompt note exists to make loud (D57). + */ +function servedNotes(requested: string, outcome: ChatOutcome, maxTokens: number | undefined): { text: string; fields: Record } { + const fields: Record = {}; + let text = ""; + const served = outcome.servedModel; + if (served) fields.served_model = served; + if (served && canonicalChatModel(served) !== canonicalChatModel(requested)) { + text += `\n\n(Served by ${served} — the gateway answered the requested id ${requested} with this model instead: retired, aliased, or at capacity.)`; + } + if (outcome.finishReason) fields.finish_reason = outcome.finishReason; + if (outcome.finishReason === "length" && outcome.text) { + fields.truncated_output = true; + text += `\n\n⚠️ TRUNCATED OUTPUT: the reply hit max_tokens=${maxTokens ?? 1024} and stopped mid-way (finish_reason "length"). ` + + `Raise max_tokens to get the rest — reasoning tokens count against it too.`; + } + return { text, fields }; +} + +/** The cost line for a reply, and its structuredContent fields. */ +function costNotes(settledUsd: number, costIsEstimate: boolean): { text: string; fields: Record } { + const fields = { cost_usd: settledUsd, cost_is_estimate: costIsEstimate }; + // Only an ESTIMATE is worth a line in the reply: a settled wallet delta is + // already in action:"report", and the account rail's exact figure lives on + // the dashboard. Saying "~" is what keeps the CHANGELOG's promise that an + // estimated figure never looks like a settlement. + if (!costIsEstimate || !(settledUsd > 0)) return { text: "", fields }; + return { + text: `\n\n(Cost: ~$${settledUsd.toFixed(4)}, estimated${isApiKeyMode() ? " — billed to your BlockRun account at exact usage; https://user.blockrun.ai/dashboard/activity has the figure" : ""}.)`, + fields, + }; +} + export function registerChatTool(server: McpServer, budget: BudgetState): void { server.registerTool( "blockrun_chat", @@ -364,20 +501,36 @@ Run blockrun_models to see all available models with pricing.`, const confirm = await confirmSpend(server, { usd: estimatedCost, label: `chat · ${model ?? mode ?? "auto"}` }); if (!confirm.ok) return { content: [{ type: "text", text: confirm.reason ?? "Charge cancelled." }] }; - // Native Anthropic passthrough (EVM/Base only). + // Native Anthropic passthrough (Base wallet and the account rail). // An explicit anthropic/claude-* model goes DIRECT to the gateway's // /v1/messages endpoint, which forwards to api.anthropic.com VERBATIM: // zero model substitution, no cost routing, no fallback, and the real // native response — type:"thinking" blocks with their original signature. // This takes priority over mode/routing precisely because the requirement // is "claude-* must be verbatim, never routed". The OpenAI-compat paths - // below cannot carry thinking signatures, so claude never falls through. + // below cannot carry thinking signatures. + // + // ON SOLANA the AnthropicClient cannot pay (it signs EVM x402 only), and + // until audit round 3 every explicit claude-* id was refused there with + // "switch to Base" — while mode:"powerful"/"reasoning"/"coding" sent the + // very same ids through /v1/chat/completions on sol.blockrun.ai one + // branch lower, and a fresh install's Base wallet is empty. Verified + // 2026-09-13 with an unpaid POST: sol.blockrun.ai quotes claude-opus-5 + // and claude-sonnet-5 on chat/completions (402, "Claude Opus 5 API + // call"), so the route exists. The refusal now applies only when + // `thinking` is requested — the one thing the compat path cannot carry — + // and a plain claude-* call on Solana takes the explicit-model path + // below like any other id (D5). Its price there is the Solana quote: + // output at full max_tokens, not Base's 0.1x — see D52 in the audit — + // which the reserve already covers. if (model && isAnthropicModel(model)) { - const solanaBlock = baseOnlyMessage("Native Anthropic (claude-*) calls"); - if (solanaBlock) { + // Non-null only on a Solana wallet (null in API-key mode and on Base). + const solanaBlock = baseOnlyMessage("Native Anthropic (claude-*) calls with `thinking`"); + if (solanaBlock && thinking) { return { content: [{ type: "text", text: solanaBlock }], isError: true }; } - return await handleAnthropicNative({ + // Solana without `thinking` falls through to the explicit-model path. + if (!solanaBlock) return await handleAnthropicNative({ client: getAnthropicClient(), model, message, @@ -404,6 +557,63 @@ Run blockrun_models to see all available models with pricing.`, // Callers wanting a cheap model should pass mode:"cheap"/"glm" or an explicit // model — both resolve here, with no router. + // One paid attempt, whichever path asked for it: run it, book what it + // cost, and hand back the outcome with the notes a reply carries. + // + // Paid calls STREAM and assemble (see utils/chat-stream.ts): a slow + // reasoning model generating for minutes over a non-streaming request + // moves zero bytes, and the edge in front of the gateway 524s the idle + // connection AFTER the x402 payment settled — charged, no reply (observed + // live with moonshot/kimi-k3, 2026-07-21). That includes Solana since + // audit round 3: the SDK's stream() pays and records the settlement + // before the first frame, where the old non-streaming path aborted at + // the Solana client's 60s default with the payment already sent. + // + // The SDK types ChatMessage.content as string-only, but the gateway + // forwards `messages` verbatim and accepts image_url content arrays for + // vision-capable models — so a multimodal array is runtime-valid. + // (claude-* with history is already handled by the native branch above.) + const attempt = async ( + client: ApiClient, + targetModel: string, + fullMessages: StreamChatMessage[], + stream: boolean, + onFailedBooking: (booking: FailedBooking) => void, + ) => { + const { result, settledUsd, costIsEstimate } = await withSettledCost( + client, + estimatedCost, + (outcome) => accountLedgerUsd(targetModel, outcome?.servedModel ?? null, promptChars, max_tokens ?? 1024, outcome?.usage ?? null), + () => completeChat(client, targetModel, fullMessages, { maxTokens: max_tokens, temperature, responseFormat, stop }, { stream }), + (booking) => { + recordActualSpend(budget, booking.usd, estimatedCost, agent_id); + onFailedBooking(booking); + }, + ); + recordActualSpend(budget, settledUsd, estimatedCost, agent_id); + const served = servedNotes(targetModel, result, max_tokens); + const cost = costNotes(settledUsd, costIsEstimate); + const prompt = freeTierTruncationNote(promptChars, result.servedModel ?? targetModel); + return { + reply: result.text, + notes: `${served.text}${cost.text}${prompt ?? ""}`, + fields: { ...served.fields, ...cost.fields, ...(prompt ? { truncated: true } : {}) }, + }; + }; + const failedText = (error: unknown, booking: FailedBooking | null, tail: string) => { + const partial = error instanceof AcceptedThenFailedError && error.partialText ? error.partialText : ""; + // Nothing booked (a free model, or a served-free call that died): the + // partial text is still the caller's, so it still rides along. + const text = booking + ? settledThenFailedText(error, booking, tail) + : `${formatError(extractErrorMessage(error))}${partial ? `\n\nPartial response received before the failure (${partial.length.toLocaleString("en-US")} chars):\n${partial}` : ""}`; + return { + content: [{ type: "text" as const, text }], + ...(partial ? { structuredContent: { partial_response: partial } } : {}), + isError: true as const, + }; + }; + // Multi-turn conversation if (messages && messages.length > 0) { const targetModel = model || MODEL_TIERS[(mode ?? "balanced") as RoutingMode]?.[0] || "openai/gpt-5.6-terra"; @@ -411,101 +621,34 @@ Run blockrun_models to see all available models with pricing.`, ...(system ? [{ role: "system" as const, content: system }] : []), ...messages, { role: "user" as const, content: message }, - ]; - // USDC that left the wallet before the failure, if any (see settledThenFailedText). - let settledOnFailure: number | null = 0; + ] as StreamChatMessage[]; + // What a failed attempt cost, if anything (see settledThenFailedText). + let failedBooking: FailedBooking | null = null; try { - // The SDK types ChatMessage.content as string-only, but the gateway - // forwards `messages` verbatim and accepts image_url content arrays - // for vision-capable models — so a multimodal array is runtime-valid. - // (claude-* with history is already handled by the native branch above.) - // - // Paid calls STREAM and assemble (see utils/chat-stream.ts): a slow - // reasoning model generating for minutes over a non-streaming request - // moves zero bytes, and the edge in front of the gateway 524s the idle - // connection AFTER the x402 payment settled — charged, no reply - // (observed live with moonshot/kimi-k3, 2026-07-21). Solana clients - // have no streaming API and keep the old path. - const { result: reply, settledUsd } = await withSettledCost(llm(), async () => { - const client = llm(); - if (supportsStreaming(client)) { - return streamChatText(client, targetModel, fullMessages as unknown as StreamChatMessage[], { - maxTokens: max_tokens, - temperature, - responseFormat, - stop, - }); - } - const r = await client.chatCompletion(targetModel, fullMessages as unknown as Parameters["chatCompletion"]>[1], { - maxTokens: max_tokens, - temperature, - responseFormat, - stop, - }); - return r.choices?.[0]?.message?.content || ""; - }, (usd) => { - // usd === null: the account rail billed it and does not tell us how - // much. recordActualSpend already books the estimate for null. - recordActualSpend(budget, usd, estimatedCost, agent_id); - settledOnFailure = usd; - }); - recordActualSpend(budget, settledUsd, estimatedCost, agent_id); - const note = freeTierTruncationNote(promptChars, targetModel); + const { reply, notes, fields } = await attempt(llm(), targetModel, fullMessages, true, (b) => { failedBooking = b; }); return { - content: [{ type: "text", text: `[${targetModel} | ${fullMessages.length} msgs]\n\n${reply}${note ?? ""}` }], - structuredContent: { model_used: targetModel, response: reply, message_count: fullMessages.length, ...(note ? { truncated: true } : {}) }, + content: [{ type: "text", text: `[${targetModel} | ${fullMessages.length} msgs]\n\n${reply}${notes}` }], + structuredContent: { model_used: targetModel, response: reply, message_count: fullMessages.length, ...fields }, }; } catch (error) { - return { - content: [{ - type: "text", - text: settledOnFailure === null || settledOnFailure > 0 - ? settledThenFailedText(error, settledOnFailure, RETRY_CHARGES_AGAIN) - : formatError(extractErrorMessage(error)), - }], - isError: true, - }; + return failedText(error, failedBooking, RETRY_CHARGES_AGAIN); } } - // If specific model provided, use it directly — streamed when the client - // supports it (same 524 rationale as the multi-turn path above). + // If specific model provided, use it directly. if (model) { - let settledOnFailure: number | null = 0; + let failedBooking: FailedBooking | null = null; try { - const { result: response, settledUsd } = await withSettledCost(llm(), async () => { - const client = llm(); - if (supportsStreaming(client)) { - return streamChatText(client, model, [ - ...(system ? [{ role: "system" as const, content: system }] : []), - { role: "user" as const, content: message }, - ], { maxTokens: max_tokens, temperature, responseFormat, stop }); - } - return client.chat(model, message, { - system, - maxTokens: max_tokens, - temperature, - responseFormat, - stop, - }); - }, (usd) => { - // usd === null: the account rail billed it and does not tell us how - // much. recordActualSpend already books the estimate for null. - recordActualSpend(budget, usd, estimatedCost, agent_id); - settledOnFailure = usd; - }); - recordActualSpend(budget, settledUsd, estimatedCost, agent_id); - return { content: [{ type: "text", text: `${response}${freeTierTruncationNote(promptChars, model) ?? ""}` }] }; - } catch (error) { + const { reply, notes, fields } = await attempt(llm(), model, [ + ...(system ? [{ role: "system" as const, content: system }] : []), + { role: "user" as const, content: message }, + ], true, (b) => { failedBooking = b; }); return { - content: [{ - type: "text", - text: settledOnFailure === null || settledOnFailure > 0 - ? settledThenFailedText(error, settledOnFailure, RETRY_CHARGES_AGAIN) - : formatError(extractErrorMessage(error)), - }], - isError: true, + content: [{ type: "text", text: `${reply}${notes}` }], + structuredContent: { model_used: model, response: reply, ...fields }, }; + } catch (error) { + return failedText(error, failedBooking, RETRY_CHARGES_AGAIN); } } @@ -515,7 +658,7 @@ Run blockrun_models to see all available models with pricing.`, // Only the free tier gets a deadline. Paid tiers are frontier/reasoning // models where a multi-minute completion is the job, not a fault; free - // models fail by crawling and there are seven of them to fall through. + // models fail by crawling and there are several of them to fall through. // See FREE_MODEL_TIMEOUT_MS for the measurements behind the numbers. const freeClient = routingMode === "free" ? buildClientWithTimeout(FREE_MODEL_TIMEOUT_MS) : null; const routingClient = freeClient ?? llm(); @@ -523,8 +666,8 @@ Run blockrun_models to see all available models with pricing.`, let lastError: unknown = null; let deadlineHit = false; - // USDC that already left the wallet on a failed attempt in this loop. - let settledOnFailure: number | null = 0; + // What a failed attempt in this loop already cost — or may have. + let failedBooking: FailedBooking | null = null; for (const m of models) { // Stop starting NEW attempts once the loop has burned its whole budget — // otherwise the bound would be per-model only and would grow with the list. @@ -536,61 +679,37 @@ Run blockrun_models to see all available models with pricing.`, // Paid tiers stream (frontier primaries can generate for minutes — // same 524 class as the explicit-model path). The free tier stays on // the non-streaming client whose short timeout the deadline loop - // depends on to fail fast through its seven candidates. - const { result: response, settledUsd } = await withSettledCost(routingClient, async () => { - if (!freeClient && supportsStreaming(routingClient)) { - return streamChatText(routingClient, m, [ - ...(system ? [{ role: "system" as const, content: system }] : []), - { role: "user" as const, content: message }, - ], { maxTokens: max_tokens, temperature, responseFormat, stop }); - } - return routingClient.chat(m, message, { - system, - maxTokens: max_tokens, - temperature, - responseFormat, - stop, - }); - }, (usd) => { - // Settled, then failed. Book it and remember that this tool call has - // already cost the caller money — see the break below. usd === null - // is the account rail: billed, amount not visible to this process, - // and recordActualSpend books the estimate for null. - recordActualSpend(budget, usd, estimatedCost, agent_id); - settledOnFailure = usd; - }); - recordActualSpend(budget, settledUsd, estimatedCost, agent_id); - const note = freeTierTruncationNote(promptChars, m); + // depends on to fail fast through its candidates. + const { reply, notes, fields } = await attempt(routingClient, m, [ + ...(system ? [{ role: "system" as const, content: system }] : []), + { role: "user" as const, content: message }, + ], !freeClient, (b) => { failedBooking = b; }); return { - content: [{ type: "text", text: `[${m}]\n\n${response}${note ?? ""}` }], - structuredContent: { model_used: m, response, ...(note ? { truncated: true } : {}) }, + content: [{ type: "text", text: `[${m}]\n\n${reply}${notes}` }], + structuredContent: { model_used: m, response: reply, ...fields }, }; } catch (error) { lastError = error; // ONE RESERVATION MEANS ONE SETTLEMENT. The fallback loop exists for - // models that refuse before taking payment (400, refusal, timeout) — - // there, trying the next model costs nothing and is the whole point. - // But a model that settled and THEN failed has already charged the - // caller, and continuing would settle a second payment for the same - // tool call under the same reserved amount, unbounded by the gate. - // Free models settle $0, so mode:"free" still falls through as designed. - if (settledOnFailure === null || settledOnFailure > 0) break; + // models that refuse before taking payment (400, refusal, an unpaid + // timeout) — there, trying the next model costs nothing and is the + // whole point. But a model that settled and THEN failed has already + // charged the caller, and one whose payment was sent and never + // answered MAY have — continuing would settle a second payment for + // the same tool call under the same reserved amount, unbounded by + // the gate. Free models reserve $0, so mode:"free" always falls + // through, on every rail. + if (failedBooking) break; continue; } } - // Say it plainly: the payment settled before the failure, so the charge - // stands and no fallback was attempted. An agent that reads "failed" as - // "free" would retry in a loop and pay each time. (Free models settle $0, - // so the deadline case below can never also be a settled one.) - if (settledOnFailure === null || settledOnFailure > 0) { - return { - content: [{ - type: "text", - text: settledThenFailedText(lastError, settledOnFailure, "No fallback model was tried — retrying will incur a second charge."), - }], - isError: true, - }; + // Say it plainly: the payment settled (or may have) before the failure, + // so no fallback was attempted. An agent that reads "failed" as "free" + // would retry in a loop and pay each time. (Free models reserve $0, so + // the deadline case below can never also be a booked one.) + if (failedBooking) { + return failedText(lastError, failedBooking, "No fallback model was tried — retrying will incur a second charge."); } // Distinguish "every model rejected" from "we ran out of time" — they need // different things from the caller (retry vs. pick a paid model), and a bare diff --git a/src/utils/api-key-call.ts b/src/utils/api-key-call.ts index 710ff9f..7f3eca6 100644 --- a/src/utils/api-key-call.ts +++ b/src/utils/api-key-call.ts @@ -181,8 +181,25 @@ function statusErrorMessage(response: Response, what: string, body: Record { - throw new Error(statusErrorMessage(response, what, await readJson(response))); + throw new AccountApiError(statusErrorMessage(response, what, await readJson(response)), response.status); } /** POST an endpoint that answers inline. `endpoint` is rooted, e.g. "/v1/audio/speech". */ diff --git a/src/utils/chat-stream.ts b/src/utils/chat-stream.ts index b1a2804..a6841b9 100644 --- a/src/utils/chat-stream.ts +++ b/src/utils/chat-stream.ts @@ -12,9 +12,21 @@ // // The SDK's fetchWithTimeout clears its abort timer once response HEADERS // arrive, so reading the body has no client-side deadline — the idle guard -// here (readWithIdleTimeout) is therefore the ONLY thing standing between a -// stalled stream and hanging forever. +// here (withIdleTimeout, around every read) is therefore the ONLY thing +// standing between a stalled stream and hanging forever. +// +// Two clients, one assembler. LLMClient (Base wallet, and the account rail) +// exposes chatCompletionStream() and hands back the raw Response — headers +// included, which is where the account rail's `x-blockrun-cost-usd` lives. +// SolanaLLMClient has no chatCompletionStream, but since @blockrun/llm 3.15.1 +// it ships stream(path, body), which pays the 402, records the settlement, and +// yields each decoded SSE frame. Until audit round 3 the comment here said the +// Solana client "cannot" stream, and every paid chat on the DEFAULT chain ran +// the non-streaming path with the SDK's 60s Solana timeout — so a generation +// over a minute was aborted client-side after the SPL payment was sent (C19). +// Both shapes now feed the same accumulator (assembleChatFrames). import type { ApiClient } from "./wallet.js"; +import { parseCostHeader } from "./api-key-call.js"; /** Chat message shape the gateway accepts (content may be multimodal parts). */ export interface StreamChatMessage { @@ -29,62 +41,162 @@ export interface StreamChatOptions { stop?: string[]; } -/** Narrow an ApiClient to one that can stream (SolanaLLMClient cannot). */ -export function supportsStreaming( - client: ApiClient, -): client is ApiClient & { chatCompletionStream: (model: string, messages: unknown, options?: unknown) => Promise } { +/** Token counts the gateway reports for the call, when it does. */ +export interface ChatUsage { + promptTokens: number; + completionTokens: number; +} + +/** + * Everything a chat call comes back with that the caller has to act on — not + * just the text. `servedModel` is the id the GATEWAY says answered; constants.ts + * documents that retired ids are silently aliased onto a live model and that + * only this field tells you (D55). `finishReason` "length" means the reply was + * cut at max_tokens (D57). `settledUsd` is `x-blockrun-cost-usd` when the rail + * sent it — a settled zero included — and null when it did not. + */ +export interface ChatOutcome { + text: string; + servedModel: string | null; + finishReason: string | null; + usage: ChatUsage | null; + settledUsd: number | null; +} + +/** + * A failure that arrived AFTER the gateway had answered 2xx: a mid-stream error + * event, an idle stall, an empty-length completion, an unreadable body. The + * distinction is money. On the wallet rails x402 settles on the 200, and on the + * account rail the request is billed once accepted — so this class is the + * evidence a caller needs to book the charge, where a pre-acceptance throw + * (a 4xx from the first response, a refused payment) books nothing. + * + * `partialText` is whatever had streamed before the failure. The caller paid + * for those tokens; discarding them turned "900 tokens then an error frame" + * into a bare error (D57). + */ +export class AcceptedThenFailedError extends Error { + readonly partialText: string; + constructor(message: string, partialText = "", options?: { cause?: unknown }) { + super(message, options); + this.name = "AcceptedThenFailedError"; + this.partialText = partialText; + } +} + +type StreamingClient = ApiClient & { chatCompletionStream: (model: string, messages: unknown, options?: unknown) => Promise }; +type FrameStreamingClient = ApiClient & { stream: (path: string, body: Record) => AsyncGenerator }; + +/** Narrow an ApiClient to one whose stream call returns the raw Response (LLMClient). */ +export function supportsStreaming(client: ApiClient): client is StreamingClient { return typeof (client as { chatCompletionStream?: unknown }).chatCompletionStream === "function"; } -async function readWithIdleTimeout( - reader: ReadableStreamDefaultReader, - ms: number, -): Promise> { +/** Narrow an ApiClient to one whose stream call yields decoded frames (SolanaLLMClient). */ +export function supportsFrameStreaming(client: ApiClient): client is FrameStreamingClient { + return typeof (client as { stream?: unknown }).stream === "function"; +} + +/** + * The account rail's settled figure off a response, when it sent one. Absent + * is "unknown" (chat settles after the response by design), never "free"; an + * explicit 0.000000 is a settled zero. parseCostHeader draws that line. + */ +export function settledCostFromHeaders(headers: { get(name: string): string | null } | null | undefined): number | null { + return parseCostHeader(headers?.get("x-blockrun-cost-usd")); +} + +function stallError(ms: number): AcceptedThenFailedError { + return new AcceptedThenFailedError(`stream stalled: no data from the gateway for ${Math.round(ms / 1000)}s`); +} + +async function withIdleTimeout(read: () => Promise, ms: number): Promise { let timer: NodeJS.Timeout | undefined; const stall = new Promise((_, reject) => { - timer = setTimeout( - () => reject(new Error(`stream stalled: no data from the gateway for ${Math.round(ms / 1000)}s`)), - ms, - ); + timer = setTimeout(() => reject(stallError(ms)), ms); }); try { - return await Promise.race([reader.read(), stall]); + return await Promise.race([read(), stall]); } finally { clearTimeout(timer); } } /** - * Assemble the complete reply from an OpenAI-compatible SSE body. + * One decoded OpenAI-compatible chunk folded into the running result. * * - Concatenates `choices[0].delta.content`; `reasoning_content` is collected * separately and used only as a fallback when no content ever arrives, so a * provider that splits reasoning out doesn't produce an empty answer. * - An `error` object mid-stream throws (the gateway reports upstream failures - * in-band once headers are already 200). - * - A JSON parse failure on a single data line skips that line; SSE comment/ - * keepalive lines (":…") and blank lines are ignored by the data: filter. + * in-band once headers are already 200) — with the partial text attached. + * - `model` is taken from any chunk that carries it (every chunk does, on both + * gateways); `usage` from the final choices-less chunk. + */ +class ChatAccumulator { + text = ""; + reasoning = ""; + finishReason: string | null = null; + servedModel: string | null = null; + usage: ChatUsage | null = null; + + fold(event: unknown): void { + const ev = event as { + error?: { message?: string } | string; + model?: unknown; + usage?: { prompt_tokens?: unknown; completion_tokens?: unknown }; + choices?: Array<{ + delta?: { content?: unknown; reasoning_content?: unknown }; + message?: { content?: unknown }; + finish_reason?: string | null; + }>; + }; + if (ev?.error) { + const msg = typeof ev.error === "string" ? ev.error : ev.error.message ?? JSON.stringify(ev.error); + throw new AcceptedThenFailedError(`upstream error mid-stream: ${msg}`, this.result().text); + } + if (typeof ev?.model === "string" && ev.model) this.servedModel = ev.model; + const u = ev?.usage; + if (u && typeof u.prompt_tokens === "number" && typeof u.completion_tokens === "number") { + this.usage = { promptTokens: u.prompt_tokens, completionTokens: u.completion_tokens }; + } + const choice = ev?.choices?.[0]; + // Some providers put the final text in `message` on the last chunk + // instead of a delta; treat both, delta first. + const content = choice?.delta?.content ?? choice?.message?.content; + if (typeof content === "string") this.text += content; + const rc = choice?.delta?.reasoning_content; + if (typeof rc === "string") this.reasoning += rc; + if (choice?.finish_reason) this.finishReason = choice.finish_reason; + } + + result(): Omit { + return { text: this.text || this.reasoning, finishReason: this.finishReason, servedModel: this.servedModel, usage: this.usage }; + } +} + +/** + * Assemble the complete reply from an OpenAI-compatible SSE body. + * + * A JSON parse failure on a single data line skips that line; SSE comment/ + * keepalive lines (":…") and blank lines are ignored by the data: filter. * * Exported for tests. */ export async function assembleSseChatStream( resp: { body: ReadableStream | null }, idleTimeoutMs = 120_000, -): Promise<{ text: string; finishReason: string | null }> { - if (!resp.body) throw new Error("streaming response had no body"); +): Promise> { + if (!resp.body) throw new AcceptedThenFailedError("streaming response had no body"); const reader = resp.body.getReader(); const decoder = new TextDecoder(); + const acc = new ChatAccumulator(); let buffer = ""; - let text = ""; - let reasoning = ""; - let finishReason: string | null = null; - - const finish = () => ({ text: text || reasoning, finishReason }); try { for (;;) { - const chunk = await readWithIdleTimeout(reader, idleTimeoutMs); - if (chunk.done) return finish(); + const chunk = await withIdleTimeout(() => reader.read(), idleTimeoutMs); + if (chunk.done) return acc.result(); buffer += decoder.decode(chunk.value, { stream: true }); let nl: number; while ((nl = buffer.indexOf("\n")) !== -1) { @@ -92,33 +204,14 @@ export async function assembleSseChatStream( buffer = buffer.slice(nl + 1); if (!line.startsWith("data:")) continue; const payload = line.slice(5).trim(); - if (payload === "[DONE]") return finish(); + if (payload === "[DONE]") return acc.result(); let event: unknown; try { event = JSON.parse(payload); } catch { continue; // malformed single line — never abandon the whole stream for it } - const ev = event as { - error?: { message?: string } | string; - choices?: Array<{ - delta?: { content?: unknown; reasoning_content?: unknown }; - message?: { content?: unknown }; - finish_reason?: string | null; - }>; - }; - if (ev?.error) { - const msg = typeof ev.error === "string" ? ev.error : ev.error.message ?? JSON.stringify(ev.error); - throw new Error(`upstream error mid-stream: ${msg}`); - } - const choice = ev?.choices?.[0]; - // Some providers put the final text in `message` on the last chunk - // instead of a delta; treat both, delta first. - const content = choice?.delta?.content ?? choice?.message?.content; - if (typeof content === "string") text += content; - const rc = choice?.delta?.reasoning_content; - if (typeof rc === "string") reasoning += rc; - if (choice?.finish_reason) finishReason = choice.finish_reason; + acc.fold(event); } } } catch (err) { @@ -129,37 +222,267 @@ export async function assembleSseChatStream( } /** - * Streamed equivalent of `client.chat(...)` / `client.chatCompletion(...)`: - * returns the assembled reply text. The caller decides WHEN to stream - * (paid EVM paths); this decides HOW. + * The same assembly over already-decoded frames — what SolanaLLMClient.stream() + * yields. The SDK's generator has no idle guard of its own (its fetch deadline + * ends at the headers, like LLMClient's), so the stall timer wraps each next(). + * + * Exported for tests. + */ +export async function assembleChatFrames( + frames: AsyncIterator | AsyncIterable, + idleTimeoutMs = 120_000, + acc = new ChatAccumulator(), +): Promise> { + const it: AsyncIterator = Symbol.asyncIterator in (frames as object) + ? (frames as AsyncIterable)[Symbol.asyncIterator]() + : (frames as AsyncIterator); + try { + for (;;) { + const next = await withIdleTimeout(() => it.next(), idleTimeoutMs); + if (next.done) return acc.result(); + acc.fold(next.value); + } + } catch (err) { + // Ask the generator to finish so the SDK releases its reader lock. NOT + // awaited: a generator suspended inside `await reader.read()` (the stall + // case) only honours return() once that read resolves, which is never — + // awaiting it here would turn the stall guard back into a hang. + void it.return?.(undefined).catch(() => undefined); + throw err; + } +} + +/** + * Reasoning models stream their hidden thinking as empty-content keepalive + * chunks, and those tokens COUNT toward max_tokens. A hard task with a small + * budget can burn the whole budget reasoning and emit zero visible text — + * finish_reason "length" with an empty answer (measured live: kimi-k3, + * 4000 max_tokens, 125s of keepalives, 0 chars). Returning "" would be + * indistinguishable from success; say what happened and what to change. + */ +function rejectEmptyLength(model: string, out: Omit): void { + if (!out.text && out.finishReason === "length") { + throw new AcceptedThenFailedError( + `${model} spent the entire max_tokens budget on internal reasoning and produced no visible answer. ` + + `Raise max_tokens (reasoning tokens count against it) or simplify the request.`, + ); + } +} + +/** + * Anything thrown once a 2xx is in hand is a post-acceptance failure, whatever + * its type: a body-read error, a JSON parse error on a non-SSE answer, the + * assembler's own throws. Wrap it so the caller can tell it from a refusal. + */ +async function afterAccept(run: () => Promise): Promise { + try { + return await run(); + } catch (err) { + if (err instanceof AcceptedThenFailedError) throw err; + const msg = err instanceof Error ? err.message : String(err); + throw new AcceptedThenFailedError(msg, "", { cause: err }); + } +} + +/** + * Streamed equivalent of `client.chat(...)` / `client.chatCompletion(...)` over + * LLMClient: returns the assembled reply plus what the gateway said about it. + * The caller decides WHEN to stream; this decides HOW. */ export async function streamChatText( - client: ApiClient & { chatCompletionStream: (model: string, messages: unknown, options?: unknown) => Promise }, + client: StreamingClient, model: string, messages: StreamChatMessage[], options: StreamChatOptions, -): Promise { + idleTimeoutMs?: number, +): Promise { + // The SDK throws on any non-OK response before returning, so a Response here + // IS the acceptance — money has moved (wallet) or will be billed (account). const resp = await client.chatCompletionStream(model, messages, options); - // A provider/route that ignores `stream:true` answers with a plain JSON - // completion. Feeding that to the SSE parser would "succeed" with an empty - // string — the silent-truncation failure shape this module must never add. - const contentType = (resp.headers?.get?.("content-type") ?? "").toLowerCase(); - if (!contentType.includes("text/event-stream")) { - const data = (await resp.json()) as { choices?: Array<{ message?: { content?: string } }> }; - return data.choices?.[0]?.message?.content ?? ""; + const settledUsd = settledCostFromHeaders(resp.headers); + return afterAccept(async () => { + // A provider/route that ignores `stream:true` answers with a plain JSON + // completion. Feeding that to the SSE parser would "succeed" with an empty + // string — the silent-truncation failure shape this module must never add. + const contentType = (resp.headers?.get?.("content-type") ?? "").toLowerCase(); + if (!contentType.includes("text/event-stream")) { + const data = await resp.json(); + const acc = new ChatAccumulator(); + acc.fold(data); + const out = acc.result(); + rejectEmptyLength(model, out); + return { ...out, settledUsd }; + } + const out = await assembleSseChatStream(resp, idleTimeoutMs); + rejectEmptyLength(model, out); + return { ...out, settledUsd }; + }); +} + +/** + * One chat call, whichever client this is, as a ChatOutcome. + * + * - LLMClient (Base wallet, account rail): chatCompletionStream when `stream`. + * - SolanaLLMClient: stream("/v1/chat/completions", { …, stream: true }) when + * `stream` — settlement is recorded before the first frame, so the idle guard + * and the post-acceptance class apply exactly as on Base. + * - Otherwise, or when `stream` is false (the free tier, whose short per-model + * timeout the deadline loop depends on): the non-streaming chatCompletion, + * whose parsed body still carries `model`, `finish_reason` and `usage`. + */ +export async function completeChat( + client: ApiClient, + model: string, + messages: StreamChatMessage[], + options: StreamChatOptions, + opts: { stream: boolean; idleTimeoutMs?: number }, +): Promise { + if (opts.stream && supportsStreaming(client)) { + return streamChatText(client, model, messages, options, opts.idleTimeoutMs); } - const { text, finishReason } = await assembleSseChatStream(resp); - // Reasoning models stream their hidden thinking as empty-content keepalive - // chunks, and those tokens COUNT toward max_tokens. A hard task with a small - // budget can burn the whole budget reasoning and emit zero visible text — - // finish_reason "length" with an empty answer (measured live: kimi-k3, - // 4000 max_tokens, 125s of keepalives, 0 chars). Returning "" would be - // indistinguishable from success; say what happened and what to change. - if (!text && finishReason === "length") { - throw new Error( - `${model} spent the entire max_tokens budget on internal reasoning and produced no visible answer. ` + - `Raise max_tokens (reasoning tokens count against it) or simplify the request.`, - ); + if (opts.stream && supportsFrameStreaming(client)) { + // The SDK does not inject stream:true ("silently rewriting a caller's body + // is how you end up debugging a request you did not send"); the body is + // the same shape LLMClient.chatCompletionStream builds. + const body: Record = { model, messages, max_tokens: options.maxTokens ?? 1024, stream: true }; + if (options.temperature !== undefined) body.temperature = options.temperature; + if (options.responseFormat !== undefined) body.response_format = options.responseFormat; + if (options.stop !== undefined) body.stop = options.stop; + const gen = client.stream("/v1/chat/completions", body); + // openPaidStream pays and records the settlement before the first frame is + // handed out, so the very first next() is where a pre-acceptance throw + // (unpaid 4xx, refused payment, the SDK's abort on the paid retry) surfaces + // — outside afterAccept, unwrapped, for settlementOnThrow to read. A STALL + // waiting for that first frame is the one case this module cannot place: + // the paid request may still be in flight (payment sent, no verdict) or + // the 200 may be in and the upstream silent. Either way the counter says + // what was recorded, so it is rethrown as a plain timeout — "unknown" to + // the classifier, which books the reserve — rather than as accepted. + const first = await withIdleTimeout(() => gen.next(), opts.idleTimeoutMs ?? 120_000).catch((err: unknown) => { + if (err instanceof AcceptedThenFailedError) throw new Error(`timeout: ${err.message}, before the first frame`, { cause: err }); + throw err; + }); + return afterAccept(async () => { + const acc = new ChatAccumulator(); + if (!first.done) acc.fold(first.value); + const out = first.done ? acc.result() : await assembleChatFrames(gen, opts.idleTimeoutMs, acc); + rejectEmptyLength(model, out); + return { ...out, settledUsd: null }; + }); } - return text; + const r = await client.chatCompletion(model, messages as unknown as Parameters[1], options); + return afterAccept(async () => { + const acc = new ChatAccumulator(); + acc.fold(r); + const out = acc.result(); + rejectEmptyLength(model, out); + return { ...out, settledUsd: null }; + }); +} + +// --------------------------------------------------------------------------- +// Did money move? — classifying a chat call that THREW +// --------------------------------------------------------------------------- + +/** + * "none": nothing was accepted, so nothing was charged — book $0, and a + * fallback loop may go on to the next model. + * "unknown": a payment was signed and sent (wallet) or the request reached + * the gateway (account) and no verdict came back — a timeout on the + * paid retry, an edge 502/504/52x, a reset mid-flight. The gateway + * settles those after the client has given up. Book the reserve as a + * precaution and never pay a second model for the same call. + * "settled": the gateway answered 2xx and the failure came after — the charge + * is certain (AcceptedThenFailedError). + */ +export type SettlementVerdict = "none" | "unknown" | "settled"; + +// Statuses an edge or a load balancer returns when the ORIGIN did not answer in +// time — the origin may still be running the request and settle it afterwards +// (the Cloud Run route documents that a client disconnect is never propagated +// to a non-streaming handler). Everything else in the 4xx/5xx range is the +// gateway itself answering, which it does before settlement starts. +const ORIGIN_DID_NOT_ANSWER = new Set([408, 502, 504, 520, 521, 522, 523, 524, 525, 526, 527, 529, 530]); + +// A fetch rejection that proves the request never left this machine. +const NEVER_CONNECTED = new Set(["ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED", "ENETUNREACH", "EHOSTUNREACH", "EADDRNOTAVAIL"]); + +// Transport failures where the request may have been in flight when it died. +const IN_FLIGHT_TRANSPORT = /aborted|timeout|timed out|fetch failed|socket hang up|ECONNRESET|ETIMEDOUT|EPIPE|terminated|network/i; + +function statusOf(error: unknown): number | undefined { + const e = error as { statusCode?: unknown; status?: unknown } | undefined; + if (typeof e?.statusCode === "number") return e.statusCode; // @blockrun/llm APIError + if (typeof e?.status === "number") return e.status; // @anthropic-ai/sdk APIError + return undefined; +} + +function messageOf(error: unknown): string { + if (error instanceof Error) { + const cause = (error as { cause?: unknown }).cause; + const causeMsg = cause instanceof Error ? cause.message : typeof cause === "string" ? cause : ""; + return `${error.message} ${causeMsg}`; + } + return String(error); +} + +export function settlementOnThrow( + error: unknown, + opts: { + rail: "wallet" | "account"; + estimateUsd: number; + /** + * The native claude-* path: @blockrun/llm's AnthropicClient pays the 402 + * INSIDE its fetch, so the status the SDK surfaces is already the paid + * retry's — there is no "after payment" prefix to read. A 4xx there is the + * gateway's refusal before settlement (its /v1/messages route passes 4xx + * through and does not settle); anything the origin did not answer, or a + * 5xx, may have settled. + */ + transparentPayment?: boolean; + }, +): SettlementVerdict { + // A $0 estimate is a free model: whatever happened, it cannot have cost + // anything, and mode:"free" must keep walking its candidates. + if (!(opts.estimateUsd > 0)) return "none"; + if (error instanceof AcceptedThenFailedError) return "settled"; + + const name = (error as { name?: unknown } | undefined)?.name; + const msg = messageOf(error); + const lower = msg.toLowerCase(); + + // The wallet could not or would not pay: the SDK's PaymentError, or the same + // sentence surfaced through the Anthropic SDK's connection-error wrapper. + if (name === "PaymentError" || /payment was rejected|no payment requirements|insufficient|check your .*balance/i.test(msg)) return "none"; + // Refused by this process before anything was sent. + if (name === "BudgetExceededError" || name === "QuoteMismatchError") return "none"; + + const cause = (error as { cause?: { code?: unknown } } | undefined)?.cause; + if (typeof cause?.code === "string" && NEVER_CONNECTED.has(cause.code)) return "none"; + + const status = statusOf(error); + if (status !== undefined) { + if (opts.rail === "wallet") { + // "API error: N" is the UNPAID first response — the gateway wants a + // payment it never got, or refused the request outright. No money. + // "API error after payment: N" is the paid retry: a 4xx there is the + // gateway's own refusal before settlement starts (its streaming route + // says so in as many words); an edge timeout may hide a settle. + if (!opts.transparentPayment && !lower.includes("after payment")) return "none"; + return ORIGIN_DID_NOT_ANSWER.has(status) ? "unknown" : status >= 500 ? "unknown" : "none"; + } + // Account rail: every non-OK first response throws with its status before + // any body exists — 400 unknown model, 401, 402 out of credit, 429 — and + // none of those is billed. Only an origin that did not answer is ambiguous. + return ORIGIN_DID_NOT_ANSWER.has(status) ? "unknown" : "none"; + } + + // No status: a transport failure. Before headers on a paid call the payment + // has already been signed and sent (the unpaid 402 comes back in + // milliseconds; the paid retry is the one that runs long), so an abort here + // is exactly the settle-after-disconnect shape. Anything else without a + // status — an SDK validation throw, a programming error — never reached + // the wire. + if (name === "AbortError" || IN_FLIGHT_TRANSPORT.test(msg)) return "unknown"; + return "none"; } diff --git a/src/utils/constants.ts b/src/utils/constants.ts index c3be38d..0e8551a 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -89,7 +89,9 @@ export const BASE_RPC_URLS = [ // nemotron-nano-12b-v2-vl (vision). // Also live but UNLISTED (hidden from GET /v1/models, still served): gpt-oss-120b // — the gateway's own free fallback — and gpt-oss-20b (both re-probed -// 2026-08-12, ~0.6-2.5s, serving themselves). +// 2026-08-12, ~0.6-2.5s, serving themselves). NO LONGER TRUE OF 120b: the +// 2026-09-13 sweep saw it answer as nano-omni on Base and 3.5-lightning on +// Solana — see the free[] note; 20b still serves itself on both. // DEAD SINCE THE JULY SWEEP, removed 2026-08-12: deepseek-v4-flash and // seed-oss-36b now report `"model": "nvidia/gpt-oss-120b"` on BOTH chains — // the aliasing trap from the note above, caught again. Keeping them routed @@ -134,8 +136,11 @@ export const MODEL_TIERS = { // it is the single most load-bearing free model there is. Re-probed 2026-07-21 // on BOTH chains with a realistic ~1.5K-token prompt: gpt-oss-120b 3.5s, // gpt-oss-20b 3.7s; re-confirmed 2026-08-12 (0.6-2.5s, serving themselves). - // Absence from the public catalogue is a listing decision, not a health - // signal — check the behaviour. But the 2026-08-12 sweep also showed the + // (Since 2026-09-13 gpt-oss-120b is on the OTHER side of that line — the + // alias target became an alias — which is why it left the tier. Its bare + // spelling stays in the tool description as the example free id; it is + // still $0.) Absence from the public catalogue is a listing decision, not a + // health signal — check the behaviour. But the 2026-08-12 sweep also showed the // CONVERSE playing out: two other delisted entries (deepseek-v4-flash, // seed-oss-36b) turned out to be alias-dead, not hidden-alive. Delisting // tells you NOTHING either way; only the response's `model` field does. @@ -148,25 +153,44 @@ export const MODEL_TIERS = { // is the trap the gateway's own probe script added a --real mode for. Never // health-check a free model with a 16-token ping. // - // 2026-09-08 order, three bands, from the live catalogue (listing evidence - // only — see the NVIDIA note above for what was and was not probed): - // 1. gpt-oss-120b — hidden-alive, the gateway's own free fallback; and - // nemotron-3-nano-omni — listed, available, served itself on 2026-08-12. - // 2. Listed billing_mode:"free" on BOTH chains and available: the two new - // NVIDIA entries and the first two non-NVIDIA free models. Unprobed for - // latency, so they sit behind the proven pair, not ahead of it. - // 3. The four delisted entries. Delisting tells you nothing either way; - // each is bounded by FREE_MODEL_TIMEOUT_MS and the loop by - // FREE_TIER_DEADLINE_MS, so a dead tail costs time, never money. - // Remove them only on a POST probe that shows aliasing or a crawl. - // Skipped on purpose: nemotron-3.5-lightning (available:false on Base), - // muse-glimmer-30b and gemma-4-31b (Solana catalogue only) — routing has to - // hold on both chains. They are still in FREE_CHAT_MODELS, so an explicit - // call to one reserves $0 like any other free id. + // 2026-09-13 SWEEP — the realistic-prompt POST probe the 09-08 note asked + // for: ~3,000 characters, max_tokens 32, no payment header, both gateways, + // the response's `model` field read on every 200. It found the tier was + // mostly ALIASES: six of the eleven routed ids answered as another model on + // BOTH chains — + // gpt-oss-120b -> nemotron-3-nano-omni (Base, 6.4s) / nemotron-3.5-lightning (sol, 21.9s) + // nemotron-3-ultra-550b -> nemotron-3-super-120b (Base) / nemotron-3.5-lightning (sol, 63.9s) + // step-3.7-flash -> nemotron-3-super-120b (Base) / no answer in 90s (sol) + // mistral-nemotron -> nemotron-3-nano-omni (Base) / nemotron-3-super-120b (sol) + // nemotron-nano-12b-v2-vl, nemotron-nano-9b-v2 -> nano-omni on both + // — and the TARGET moves between probes (the 09-13 finder saw gpt-oss-120b + // land on nemotron-3-super-120b hours earlier). "The gateway's own free + // fallback serves itself", the premise free[0] rested on since July, is + // gone: the fallback now serves whatever has capacity. So the tier walked + // the same saturated backend under five names inside the 150s deadline and + // reported "did not answer" having tried ONE model. Removed, per this list's + // own rule (a POST probe that shows aliasing). They stay in FREE_CHAT_MODELS: + // still $0, and an explicit call must still reserve nothing. + // + // What is left is every id that echoed ITS OWN NAME on both chains, fastest + // first — distinct backends, which is the only thing a fallback rung is for: + // gpt-oss-20b (0.7s sol / 1.7s Base), north-mini-code (1.0s / 3.0s), + // nemotron-3-nano-omni (2.3s as "…-nim" on sol / 3.6s), then two that + // served themselves on Solana and were merely unavailable on Base that day + // (laguna-xs-2.1: 429 "capacity exhausted" on Base, 0.7s on sol; + // llama-3.2-11b-vision: no answer in 90s on Base, 9.8s on sol) — last, so + // a bad day on one chain costs the loop time at the tail, never at the head. + // Since audit round 3 the reply names the served model whenever it differs + // from the requested id (see chat.ts servedNotes), so the next alias is + // visible in the tool output instead of in a sweep months later. + // + // Skipped on purpose: nemotron-3.5-lightning (429 on Base, no answer on + // sol), muse-glimmer-30b and gemma-4-31b (400 "Unknown model" on Base; + // gemma crawled 79.8s on sol) — routing has to hold on both chains. They are + // still in FREE_CHAT_MODELS, so an explicit call to one reserves $0. free: [ - "nvidia/gpt-oss-120b", "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", - "nvidia/llama-3.2-11b-vision", "nvidia/nemotron-3-ultra-550b", "cohere/north-mini-code", "poolside/laguna-xs-2.1", - "nvidia/step-3.7-flash", "nvidia/mistral-nemotron", "nvidia/gpt-oss-20b", "nvidia/nemotron-nano-12b-v2-vl", "nvidia/nemotron-nano-9b-v2", + "nvidia/gpt-oss-20b", "cohere/north-mini-code", "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", + "poolside/laguna-xs-2.1", "nvidia/llama-3.2-11b-vision", ], coding: ["anthropic/claude-opus-5", "openai/gpt-5.3-codex", "moonshot/kimi-k3", "xai/grok-build-0.1", "zai/glm-5.2", "qwen/qwen3.7-max", "anthropic/claude-sonnet-5"], glm: ["zai/glm-5", "zai/glm-5.2", "zai/glm-5.1", "zai/glm-5-turbo"], @@ -206,10 +230,19 @@ export type RoutingMode = keyof typeof MODEL_TIERS; */ export const FREE_CHAT_MODELS: ReadonlySet = new Set([ ...MODEL_TIERS.free, + // Still $0 on both gateways (200 without a payment header, 2026-09-13) but + // answering as ANOTHER model — pulled from the routing tier, kept here so an + // explicit call reserves $0. See the free[] sweep note. + "nvidia/gpt-oss-120b", + "nvidia/nemotron-3-ultra-550b", + "nvidia/step-3.7-flash", + "nvidia/mistral-nemotron", + "nvidia/nemotron-nano-12b-v2-vl", + "nvidia/nemotron-nano-9b-v2", // Live billing_mode:"free" on 2026-09-08 but deliberately not routed. - "nvidia/nemotron-3.5-lightning", // available:false on Base that day - "nvidia/muse-glimmer-30b", // Solana catalogue only - "nvidia/gemma-4-31b", // Solana catalogue only + "nvidia/nemotron-3.5-lightning", // 429 capacity on Base, no answer on sol (09-13) + "nvidia/muse-glimmer-30b", // Solana catalogue only (400 on Base) + "nvidia/gemma-4-31b", // Solana catalogue only (400 on Base; 79.8s crawl on sol) ]); /** @@ -264,6 +297,15 @@ export const CHAT_PRICE_PER_MTOKEN: Record