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/CHANGELOG.md b/CHANGELOG.md index 9d57e4c..acb532a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,212 @@ All notable changes to BlockRun MCP will be documented in this file. +## 0.50.0 + +**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 +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. 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 +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. +- **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 + 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. `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. + +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. + +**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. + +**`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. + +**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. + +**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/README.md b/README.md index e144737..fcd210b 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.
@@ -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 @@ -251,11 +251,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,767 | +| `trading` | 8 | 5,270 | +| `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. @@ -693,7 +693,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/apps/order-preview.ts b/apps/order-preview.ts index 87a67ce..8814e03 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; @@ -119,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) : "—"), @@ -184,16 +189,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; @@ -202,14 +218,27 @@ 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; 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 +247,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/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/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 fab5cec..8db21b0 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,767 | +| `trading` | 8 | 5,270 | +| `media` | 7 | 5,632 | +| `research` | 5 | 2,664 | +| `chat` | 3 | 2,005 | -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/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", 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/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/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/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/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/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/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/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/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/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/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/markets.ts b/src/tools/markets.ts index 5f6ceea..8dd21d1 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"; @@ -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: @@ -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/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/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/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/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/tools/realface.ts b/src/tools/realface.ts index 4206565..ac521a7 100644 --- a/src/tools/realface.ts +++ b/src/tools/realface.ts @@ -2,14 +2,14 @@ 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"; 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"; @@ -32,18 +32,39 @@ 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, 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", 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 +75,11 @@ 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, { + 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 @@ -90,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, @@ -104,6 +129,7 @@ async function payAndPostJson( } ); + paidRequestInFlight = true; const resp = await fetchWithTimeout(url, { method: "POST", headers: { @@ -111,7 +137,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) }; @@ -325,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) { @@ -396,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) { @@ -445,7 +499,22 @@ 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, + }; + } + 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, }; } 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/tools/speech.ts b/src/tools/speech.ts index 27b8a47..1e01b71 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"; @@ -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,26 @@ 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 + // 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, @@ -291,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."); @@ -355,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/src/tools/video.ts b/src/tools/video.ts index d24b26c..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"; @@ -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. @@ -529,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.`); @@ -853,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, }; } @@ -871,10 +882,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/tools/wallet.ts b/src/tools/wallet.ts index dbdf1c9..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)"), @@ -111,10 +112,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/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/errors.ts b/src/utils/errors.ts index 9c6b2b1..d73ced3 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,19 +104,37 @@ 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); + // 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 @@ -133,7 +168,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/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/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/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 { ? 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/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/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/src/utils/raw-call.ts b/src/utils/raw-call.ts index 67b73d8..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. @@ -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/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/src/utils/wallet.ts b/src/utils/wallet.ts index a086a13..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? * @@ -248,6 +280,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 +297,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 }, @@ -384,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); @@ -472,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; @@ -496,7 +555,25 @@ 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; /** * The Solana twin of ensureEvmWallet(): return the existing wallet from @@ -505,8 +582,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 +635,7 @@ export async function ensureSolanaWallet(): Promise<{ address: string; privateKe export function resetSolanaKeyCache(): void { _solanaKey = undefined; _solanaWalletInfo = null; + _solanaWalletPromise = null; } /** @@ -569,6 +670,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 @@ -744,10 +854,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, 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"); +}); diff --git a/test/axios-scope.test.ts b/test/axios-scope.test.ts new file mode 100644 index 0000000..b5309bf --- /dev/null +++ b/test/axios-scope.test.ts @@ -0,0 +1,69 @@ +// Run with: npm test (tsx --experimental-test-module-mocks --test) +// +// applyClobProxyOnce() sets axios.defaults.httpsAgent PROCESS-WIDE. That is +// not a style choice: @polymarket/clob-client-v2 reaches for the hoisted axios +// itself, so there is no instance to scope the agent to without forking it. +// +// The safety argument is entirely about WHO ELSE shares that axios. Today the +// answer is "only Polymarket": every axios importer in src/ is under +// utils/polymarket/, @blockrun/llm and the rest of the tools use fetch (which +// ignores axios defaults), and the relayer carries its own axios 0.27 copy +// that relayer.ts hands the agent to explicitly. +// +// That argument lives in a comment, which cannot fail. This can. The day a +// non-Polymarket module imports axios, its traffic starts going through an +// operator's POLYMARKET_CLOB_PROXY the moment a trade is placed -- a silent +// egress change nobody asked for -- and this test goes red first. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const SRC = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "src"); +const POLYMARKET_DIR = path.join("utils", "polymarket"); + +function walk(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + const full = path.join(dir, entry); + if (statSync(full).isDirectory()) out.push(...walk(full)); + else if (entry.endsWith(".ts")) out.push(full); + } + return out; +} + +test("only src/utils/polymarket may import axios", () => { + 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; +}); 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/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"); +}); diff --git a/test/errors.test.ts b/test/errors.test.ts index c0a6abb..20304ba 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,92 @@ 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); +}); + +// --- "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/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(); +}); 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); +}); 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"); +}); 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); +}); 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"); +}); 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(", ")}`); +}); diff --git a/test/raw-call.test.ts b/test/raw-call.test.ts new file mode 100644 index 0000000..64cebc9 --- /dev/null +++ b/test/raw-call.test.ts @@ -0,0 +1,224 @@ +// 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; +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, + 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 }; + chain = "base"; +}); + +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); +}); + +// --- 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}`); + } + } +}); 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"); 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/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`); + } +}); 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); +}); 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}`); +}); 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", ); }); 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",