diff --git a/.github/workflows/brand-sync.yml b/.github/workflows/brand-sync.yml index 43926f8..c6c69be 100644 --- a/.github/workflows/brand-sync.yml +++ b/.github/workflows/brand-sync.yml @@ -12,6 +12,19 @@ # branch is protected the push fails and the fallback opens a PR instead. # No third-party actions beyond actions/checkout — supply-chain surface stays # at one GitHub-owned action. +# +# In THIS repo main is protected (required check `test`), so the direct push +# always fails, and GitHub Actions is not permitted to open PRs in this org +# (PR #141: "it pushed brand-sync/20260905 and left it for a manual PR"). The +# fallback's `|| echo …` then swallowed that too, and the run went GREEN having +# landed nothing — a stale count sat in the README for 26 days (#123) behind a +# green badge, which is the exact failure the header above promises cannot +# happen. A drift this job cannot land is a FAILURE: the run is red until a +# human opens the PR, and the branch name is fixed (`brand-sync`, force-pushed) +# so a month of Mondays does not leave a month of orphan branches. +# To make the fallback land on its own: enable "Allow GitHub Actions to create +# and approve pull requests" in the org/repo Actions settings, or give the step +# a PAT with pull-requests:write. name: brand-sync on: schedule: @@ -41,11 +54,21 @@ jobs: git commit -m "chore: sync brand numbers from blockrun.ai/brand/numbers.json" if git push origin "HEAD:${GITHUB_REF_NAME}"; then echo "pushed to ${GITHUB_REF_NAME}" - else - BR="brand-sync/$(date +%Y%m%d)" - git push -f origin "HEAD:${BR}" - gh pr create --head "$BR" \ + exit 0 + fi + # One fixed branch, force-pushed: the rewrite is regenerated from the + # artifact every run, so the previous week's branch has nothing worth + # keeping and a dated name per run only accumulates orphans. + BR="brand-sync" + git push -f origin "HEAD:${BR}" + if gh pr create --head "$BR" \ --title "chore: sync brand numbers" \ - --body "Automated marker refresh from https://blockrun.ai/brand/numbers.json (scripts/sync-brand-numbers.mjs --refresh). Opened as a PR because the default branch is protected." \ - || echo "PR creation unavailable — branch ${BR} pushed, needs manual PR" + --body "Automated marker refresh from https://blockrun.ai/brand/numbers.json (scripts/sync-brand-numbers.mjs --refresh). Opened as a PR because the default branch is protected."; then + echo "opened a PR from ${BR}" + exit 0 fi + # Not a warning. The numbers drifted, this job could not land the fix, + # and a green run here is how a stale count sat in the README for 26 + # days. Red until a human opens the PR from the pushed branch. + echo "::error::brand numbers drifted and could not be landed: push to ${GITHUB_REF_NAME} was rejected and PR creation is unavailable to GITHUB_TOKEN. Branch ${BR} is pushed — open the PR by hand (or enable 'Allow GitHub Actions to create and approve pull requests')." + exit 1 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 47b4a5f..45f682a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -37,7 +37,21 @@ jobs: id: v run: | PKG=$(node -p "require('./package.json').version") - NPM=$(npm view @blockrun/mcp version 2>/dev/null || echo "none") + # "none" ONLY on npm's own E404 (the package has never been published). + # Any other failure — a registry 5xx, a network error, a bad token — + # is "unknown", which the version gate step refuses. Spelling every + # failure as "none" made the gate pass on the one input where it must + # not: a package.json below latest during a registry blip, which would + # publish and downgrade `latest`. + if NPM=$(npm view @blockrun/mcp version 2>npm-view.err); then + : + elif grep -q "E404" npm-view.err; then + NPM=none + else + echo "::warning::npm view failed and it was not an E404:"; cat npm-view.err + NPM=unknown + fi + rm -f npm-view.err # `version=latest` is load-bearing. Without it the search endpoint pages # at 30 results with every isLatest=false, so the old `|| a[a.length-1]` # fallback returned the last row of PAGE ONE — 0.32.8, frozen since July — @@ -55,15 +69,33 @@ jobs: # latest, isLatest=true. REG=$(curl -s "https://registry.modelcontextprotocol.io/v0/servers?search=io.github.BlockRunAI/blockrun-mcp&version=latest" \ | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{const j=JSON.parse(s);const a=j.servers||[];console.log(a[0]?.server?.version||'none')}catch{console.log('none')}})") + # The tag is the third target, and it needs its OWN guard computed + # here, not derived from the npm one. Ask the remote: actions/checkout + # fetches no tags, so a local lookup always says "missing". + if git ls-remote --exit-code --tags origin "refs/tags/v${PKG}" >/dev/null 2>&1; then + TAG_MISSING=false + else + TAG_MISSING=true + fi echo "pkg=$PKG" >> "$GITHUB_OUTPUT" echo "npm=$NPM" >> "$GITHUB_OUTPUT" echo "reg=$REG" >> "$GITHUB_OUTPUT" - echo "package.json=$PKG | npm=$NPM | registry=$REG" + echo "tag_missing=$TAG_MISSING" >> "$GITHUB_OUTPUT" + echo "package.json=$PKG | npm=$NPM | registry=$REG | tag v$PKG missing=$TAG_MISSING" + + # `pkg != npm` is inequality, not order. npm publish does not compare + # semver and points `latest` at whatever it just published, so a + # package.json LOWER than npm latest (a typo'd 0.5.1 for 0.51.0, or a + # number proposed off a stale VERSION file) would ship and downgrade + # every `npx -y @blockrun/mcp@latest` user. Plain Node: runs before npm ci. + - name: Refuse a version below npm latest + run: node scripts/version-gate.mjs "${{ steps.v.outputs.pkg }}" "${{ steps.v.outputs.npm }}" - name: Install dependencies run: npm ci - name: Build, typecheck, test + id: build run: | npm run build npm run typecheck @@ -71,6 +103,7 @@ jobs: # ---- npm ---- - name: Publish to npm + id: npm if: steps.v.outputs.pkg != steps.v.outputs.npm run: npm publish --provenance --access public env: @@ -81,12 +114,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 @@ -108,12 +160,30 @@ jobs: # Same independent-guard shape as the two publishes above — it checks # whether the tag already exists rather than assuming, so re-runs and # workflow_dispatch are safe. - # Gated on the same version diff as the npm publish above. Without it, a - # push that only touched publish.yml (or a workflow_dispatch) would tag - # HEAD and title the release from an unrelated commit subject — precisely - # in the "release is missing" state this automation exists to repair. + # + # INDEPENDENT means its own guard, not npm's. This step used to be gated + # on `pkg != npm`, which is false the moment npm has published — so a + # job that went red AFTER npm (the mcp-publisher download, a registry + # 400, the tag push itself) could never be repaired by re-running it: the + # re-run found npm == pkg, skipped this step by its `if:`, and went green + # with no tag and no release. The in-step `gh release view` / `git + # ls-remote` checks that make re-runs safe never executed, because the + # outer condition short-circuited them. That is the drift this step was + # written to end, reported as success. + # + # So the gate is: the tag is ABSENT (resolved against origin, up front), + # and npm carries this version — either this run published it, or it was + # already there. A push that only touched publish.yml still cannot tag + # HEAD under a stale number, because then the tag already exists. A run + # whose build or npm publish FAILED does not tag either: `!cancelled()` + # lets this step run past a registry failure, and the outcome checks + # keep it from running past a real one. - name: Tag + GitHub release - if: steps.v.outputs.pkg != steps.v.outputs.npm + if: >- + !cancelled() + && steps.v.outputs.tag_missing == 'true' + && steps.build.outcome == 'success' + && (steps.npm.outcome == 'success' || steps.v.outputs.pkg == steps.v.outputs.npm) env: GH_TOKEN: ${{ github.token }} VERSION: ${{ steps.v.outputs.pkg }} diff --git a/.gitignore b/.gitignore index 3e67ea3..fd36f80 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -node_modules/ +node_modules dist/ *.log .DS_Store diff --git a/AGENTS.md b/AGENTS.md index 13456fd..f25daf0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # BlockRun MCP -MCP server giving Codex real-time data — markets, research, X/Twitter, crypto. Pay per call with USDC. +MCP server giving Codex real-time data — markets, research, web search, crypto. Pay per call with USDC. ## Commands diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d57e4c..cc39624 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,395 @@ All notable changes to BlockRun MCP will be documented in this file. +## 0.51.0 + +**Round four went at round three.** 0.50.0's changelog said the general sweep +was spent and the surfaces no sweep had opened were where the money was. Round +three opened them — it is the fifty-nine hundred lines behind this release, nine +commits that replaced every hand-copied `paidRequestInFlight` boolean with one +per-call tracker, made every chat path stream and classify its failure by what +the wire said, sealed `BLOCKRUN_BUDGET_LIMIT` as a ceiling the session cannot +raise, and closed four ways a funded key could be lost. Round four then audited +round three with the same adversarial loop, and found the pattern the loop has +found every time: a fix that reaches one rail and not its siblings, a comment +that states a contract one branch does not honour, and a fix whose own shape +is the next bug. Fourteen findings survived two verifiers each — one P0, three +P1 — and every one of them is in this release with the test that pins it. + +### The money paths + +- **A settled chat call is booked at what the rail bills, on every rail.** The + account rail books exact usage at the model's rate — no fee, no floor — + instead of the gate reserve that ran 3–50x high and tripped + `BLOCKRUN_BUDGET_LIMIT` at a fraction of real spend; a settled + `x-blockrun-cost-usd`, zero included, wins when present. The wallet rails + read the SDK's counter and, where the counter cannot see (a payment signed + and sent, no verdict back), book the reserve as a precaution and stop the + routing loop instead of paying a second model under the same reservation. + Before this, five typo'd model ids exhausted a delegated cap at $0 real + spend on the account rail, and a 524 after the gateway settled read as + "temporary API issue, try again" on the native claude-* path — with the SDK + retrying the settled request twice more, each retry a fresh x402 payment. +- **Solana chat streams.** The default chain ran every paid chat through the + SDK's non-streaming path with a 60s abort, so a generation over a minute was + cancelled client-side after the SPL payment was sent. The native claude-* + path streams too (`maxRetries: 0`): the SDK refused non-streaming thinking + budgets above ~21k tokens with an error that blamed the caller. +- **One in-flight tracker, per call, on every rail** (`utils/in-flight.ts`). + 0.50.0's copies were wrong in five different ways — cleared in a `.finally` + that runs before the catch, set on Base only, module-global so one call's + outstanding payment booked a phantom charge against another call's failure, + armed before the unpaid quote probe. And round four found the tracker's own + shape: a helper that inspects the response and THROWS on it leaves the + tracker armed with an answer in hand, so `blockrun_image` on the account + rail booked a whole render for a `not_charged` job whose upstream text read + "aborted due to timeout" and told the user the charge may have gone through. + The verdict now comes off the error — a status, a typed job verdict, the + gateway's own uncharged marker — never its prose. The rail-parity matrix + gained the cell. +- **Settled-at-submit routes are billed on every escape.** `sol.blockrun.ai` + settles the audio route optimistically at POST (the 202 says + `settled_optimistic`); the helper assumed payment-on-completion for every + route and said "No payment was taken" for a failed track the gateway had + already charged. Round three typed the failed-job, poll-error and deadline + exits; round four found the reactive re-sign path still throwing "No charge + was made" — every post-submit throw on that model is now a certain charge. + Music's Solana submit timeout is the 95s the route needs, not the 30s video + default that aborted every track slower than 30s after the money had moved. +- **The seven path tools book a payment the origin never answered.** They did + `formatError(extractErrorMessage(err))` and nothing else, so a call whose + payment left and whose origin never answered booked $0 and read "try again + in a few minutes". The rule is narrower than chat's on purpose — the search + route's bare 500 is pre-settle, and booking it would invent $0.26 of phantom + spend per Grok blip. And a throw AFTER the SDK counted the settlement (a + non-JSON 200: no status, no transport words) now books the counted amount as + a certain charge instead of "none". +- **`BLOCKRUN_BUDGET_LIMIT` is a ceiling the session cannot raise.** It seeded + the same mutable cap that `blockrun_wallet action:"budget"` writes, so the + agent it constrained could clear or raise it in one free tool call — and the + denial text at the cap pointed it at exactly that tool. `set` clamps to it, + `clear` restores it, `delegate` clamps the child cap to it, and every reply + says when and why it clamped. A revoked agent keeps its ledger — revoke + + delegate was still a refill, and round four found the last form of it: a + call that settled while its id was revoked landed on the global ledger + only, and the next delegate carried a ledger that had forgotten it. +- **Unknown is never a plain error on Polymarket.** A submit that threw with no + 4xx behind it (dropped socket, relay 502/504) released the reservation and + rendered as a plain error, steering the agent into a second real order + while the session cap saw one. The notional stays booked as unconfirmed, the + message says what to check, the balance-cache retry never re-submits on top + of a possibly-live order, and the order card keeps its lock across Re-quote. + `fund` arms a `pendingFund` guard for the 300s a lost EIP-3009 authorization + stays executable, so a retry cannot double-send; its $0.01 gateway fee is + reserved and booked like any paid call — round four found the ledger had + never been handed to the tool, so that booking existed only in the unit + test. A bare `confirm:true` on a market order is held to the worst fill of + the last preview, and `BLOCKRUN_CONFIRM_SPEND=on` finally asks the human + before buy/sell/fund/withdraw sign — a $0.004 rpc call got the dialog while + a $25 bet did not. +- **Three pre-payment guards for paid mistakes.** `blockrun_modal` trims `gpu` + the way the gateway prices it — `" H100 "` quoted $8.001 against a $0.102 + reserve, and $192 against $2.40 at 24h, past the cap and the confirm dialog + — and refuses a tier the gateway would 400. `blockrun_search` refuses the + X/Twitter source the gateway removed on 2026-07-05 and this tool advertised + for two months. `blockrun_markets` refuses a `?` in `path`, which bypassed + every params-based check. And `hasPathTraversal` decodes per segment: one + malformed `%` after a `#` blinded the whole-string decode, and + `%2e%2e/phone/numbers/buy#%` priced as a $0.003 modal op and POSTed the + $5.001 number purchase. + +### The keys + +- **A `secret-tool` exit 1 is read by stderr.** libsecret returns 1 for a miss + and for a fault alike; only the fault prints why. Round three's tri-state + keychain read was macOS-only, so on Linux a locked collection, a missing + D-Bus session or a dismissed unlock prompt all read as "absent" — and under + `BLOCKRUN_KEYCHAIN=strict` both provisioners minted over the funded wallet + the process merely could not open. A silent exit 1 is a miss; one that said + something is an error, with the tool's reason in the detail. +- **The legacy `wallet.key` ranks below the keychain.** Strict mode retires + `.session` and never `wallet.key`, so a stale legacy file from an older + install outranked the keychain the moment `.session` was gone, and its key + was stored over the funded one with `-U`. `.session` stays the rotation + seam; the legacy file is consulted only after the keychain says "absent". +- **An env key is a signer override, not a wallet this machine owns.** It is + never mirrored into the keychain and never retires the file — one run with + a different key in the environment used to leave the funded wallet, also + the Polymarket deposit signer, in no store at all. `BASE_CHAIN_WALLET_KEY`, + the SDK's own spelling, is honoured by the gates that read only + `BLOCKRUN_WALLET_KEY`. +- **Two servers on a fresh machine could both mint.** Claude Code, Cursor and + Desktop are commonly all installed `-s user`; the last writer won on disk + and USDC sent to the loser's address was unrecoverable once that process + exited. The mint is published exclusively and the loser adopts the winner's + key; an empty placeholder is claimed, not overwritten. +- **A locked keychain never routes a Base user to Solana.** The chain + selector's probes collapsed a read error into "absent" and memoised it for + the process. "Unknown" is never memoised and never a Solana signal; the + Base path fails loudly with its unlock message. +- **The onramp's $0 quote is enforced**, not commented: the code signed + whatever the 402 said, on the path that runs when something has already + gone wrong. A keychain replacement of a DIFFERENT key is announced on + stderr with the address being replaced, so a rotation nobody meant is + visible the run it happens. + +### Saying the true thing + +- **`served_model`, `finish_reason` and `truncated_output` ride on every chat + reply**, and partial text survives a mid-stream failure. The free tier was + re-swept with a realistic prompt: six of eleven routed ids answered as + another model on both chains, so the tier keeps the five that echo their + own name. `anthropic/claude-opus-4` — hidden from every listing, billed + $15/$75 on the account rail — has a price row instead of reserving the + $5/$30 default. +- **`blockrun_speech` says when the charge stands** — a settled 200 whose body + is unusable, on all three rails — the step video and music got in round + three. `blockrun_realface` reads the status off the Solana helper's error + instead of a regex that turned any "402" in a quote fault into "out of + funds" plus a top-up page, and `action:"list"` asks the active chain's + gateway with the active chain's address instead of minting an EVM key on a + Solana install. `blockrun_image` polls the 202 the account rail hands back + for any render past its 30s window instead of reporting "No image URL", and + drops the `quality` parameter the gateway 400s for every listed model — the + zod default of `"standard"` was failing every Base generate. +- **`formatError` knows the rail.** An account-rail 402 says "out of credit", + never "run setup"; a 5xx or transport failure after the payment left says + the charge MAY stand instead of "try again in a few minutes"; the Base-only + replay-nonce reading of "Payment was rejected" is a shared hedge, so a + 30-second Exa blip stops telling a $50 wallet to top up. +- **`action:"status"` reports this session's spend and cap next to the + balance**, and `action:"report"` lists revoked agents with their kept spend. + Polymarket `setup` no longer prints "🎯 Ready to trade" two lines under + "❌ Region: BLOCKED", and the relayer SDK's progress lines go to stderr + instead of the JSON-RPC channel. + +### The release machinery + +`publish.yml` refuses a `package.json` below npm latest (npm publish does not +compare semver; a typo'd `0.5.1` would have become `latest` and downgraded +every `npx` user) — and only npm's own E404 is "none": a registry failure is +"unknown" and refused, because that is the one input where the two differ and +the downgrade goes through. The tag/release step is gated on the tag being +absent, so a job that went red after the npm publish can be repaired by a +re-run. `brand-sync` goes red when it cannot land a drift — a green run behind +a 26-day-stale README was the exact failure it promised could not happen. +`verify:prices` exits 2 when it could not look and reads the account rail's +public pricing sheet as a third catalogue. The live Polymarket e2e scripts +require `--confirm` before signing anything; every one installs the redacted +exit handler. The test suite pins `HOME` and every rail-selecting variable for +every file — ten mocked-handler suites were running their Base assertions on +the account rail on any machine with `~/.blockrun/.api-key` — and a +`node_modules` symlink that pointed at itself is no longer tracked. + +Context cost re-measured: 13.0K tokens for the full profile, `--profile +trading` 58% less. + +## 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/CLAUDE.md b/CLAUDE.md index fed4bfb..2c204a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # BlockRun MCP -MCP server giving Claude real-time data — markets, research, X/Twitter, crypto. Pay per call with USDC. +MCP server giving Claude real-time data — markets, research, web search, crypto. Pay per call with USDC. ## Commands 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..6973257 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,10 @@

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

-

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

+

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

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

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

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

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


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

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

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

gpt-5.5 — now live on BlockRun. Pay per call. No subscription. No keys. @@ -592,12 +592,12 @@ Prompts and a worked example are in [`skills/image-prompting/SKILL.md`](skills/i | | Direct APIs | BlockRun | |---|---|---| -| Exa | Sign up, $20/mo minimum | $0.011/call on Base ($0.01 + fee), no subscription | -| Polymarket | Undocumented, rate-limited | $0.0085/call on Base ($0.0075 + fee), clean JSON — plus you can **trade** | -| DefiLlama | Free tier, rate-limited, no SLA | $0.006/call on Base ($0.005 + fee), same JSON, one wallet | -| Multiple sources | 3 accounts, 3 API keys, 3 billing pages | **1 wallet** | +| Exa | Sign up, $20/mo minimum | $0.011/call on Base ($0.01 + fee), or exact account usage via `api.blockrun.ai` | +| Polymarket | Undocumented, rate-limited | $0.0085/call on Base ($0.0075 + fee), or exact account usage for reads — plus wallet mode can **trade** | +| DefiLlama | Free tier, rate-limited, no SLA | $0.006/call on Base ($0.005 + fee), or exact account usage via `api.blockrun.ai` | +| Multiple sources | 3 accounts, 3 API keys, 3 billing pages | **1 wallet, or 1 BlockRun account key** | -One wallet. All sources. No dashboards. +One wallet, or one dashboard-backed API key. All sources. --- @@ -608,9 +608,9 @@ One wallet. All sources. No dashboards. | Variable / File | Default | Effect | |---|---|---| -| `BLOCKRUN_API_KEY` | unset | A BlockRun account key (`brk_live_…`) from [user.blockrun.ai/dashboard/keys](https://user.blockrun.ai/dashboard/keys). **Set → account billing: no wallet is created, read or used, and no chain applies.** Takes priority over every wallet setting below. A malformed value is a startup error, never a silent fall back to the wallet. | +| `BLOCKRUN_API_KEY` | unset | A BlockRun account key (`brk_live_…`) from [user.blockrun.ai/dashboard/keys](https://user.blockrun.ai/dashboard/keys). **Set → account billing through `api.blockrun.ai`: no wallet is created, read or used, and no chain applies.** Takes priority over every wallet setting below. A malformed value is a startup error, never a silent fall back to the wallet. | | `~/.blockrun/.api-key` | not created | The same key on disk, for clients that make env vars awkward. Read only when `BLOCKRUN_API_KEY` is unset; an empty or unreadable file falls through to wallet mode. | -| `BLOCKRUN_API_BASE_URL` | `https://api.blockrun.ai` | Account API base, for staging. Accepts the OpenAI-style `…/v1` form too. | +| `BLOCKRUN_API_BASE_URL` | `https://api.blockrun.ai` | Account API service endpoint used after you get a key at `user.blockrun.ai`. Override only for staging. Accepts the OpenAI-style `…/v1` form too. | | `~/.blockrun/.session` | auto-created on first run | EVM private key (0x…). File exists → use Base. Also the Polymarket signer (unless `BLOCKRUN_WALLET_KEY` or an agent `wallet.json` takes precedence). | | `BLOCKRUN_WALLET_KEY` | unset | Env override of the EVM key — takes precedence over `.session` / `wallet.json` as the Base + Polymarket signer. | | `~/.blockrun/.chain` | unset | Explicit chain preference: `base` or `solana`. Written only by `blockrun_wallet action:"chain"` — i.e. only when you choose. | @@ -669,13 +669,13 @@ An open-source MCP server that gives Claude and other agents 78 models, <1ms local routing, USDC on Base & Solana. - **🤖 [BRCC](https://blockrun.ai/brcc.md)** — BlockRun for Claude Code: smart routing + x402 payments, purpose-built for Claude Code. diff --git a/VERSION b/VERSION index 9ed317f..c5d4cee 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.41.1 +0.51.0 diff --git a/apps/order-preview.ts b/apps/order-preview.ts index 87a67ce..d278fa7 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; @@ -52,6 +53,29 @@ const body = $("body"); /** The arguments the model passed to blockrun_polymarket_read (we re-use them to re-quote). */ let toolArgs: Record = {}; + +/** + * Orders whose submit ended with an UNKNOWN outcome, keyed by token+side, with + * the message the user saw. This must outlive the card that set it: the lock + * used to be a `let` inside renderPreview, so Re-quote — which the unknown + * path re-enables so the user can see the new price — rendered a fresh card + * with Place enabled, and one more click was a second real order on top of + * one that may already be resting at the CLOB. A fresh preview for the same + * token+side now renders locked until the user has checked positions/orders + * (a new session clears it: the card has no state beyond this page). + */ +const unknownOutcomes = new Map(); +const outcomeKey = (p: { tokenId: string; action: string }) => `${p.tokenId}:${p.action}`; + +/** + * Tell the model what happened on the card. The success path already does + * this; the unknown-outcome path did not, so the conversation had no record + * that an order may be live and the agent could re-place it from the text + * path with no idea the card had already tried. + */ +function tellModel(text: string, structuredContent: Record = {}): void { + void app.updateModelContext({ content: [{ type: "text", text }], structuredContent }).catch(() => {}); +} app.ontoolinput = (p) => { toolArgs = { ...(p.arguments ?? {}) }; }; app.ontoolresult = (r) => render(r as ToolResult); @@ -119,7 +143,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 +212,45 @@ 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. The + // unknown flag is read from module scope so it survives Re-quote. + let submitting = false; + let outcomeUnknown = unknownOutcomes.has(outcomeKey(p)); + const lockUnknown = (message: string, structuredContent?: Record) => { + outcomeUnknown = true; + unknownOutcomes.set(outcomeKey(p), message); + tellModel( + `Order card: the ${p.action} of ${usd(p.notionalUsd)} on ${p.outcome ?? p.tokenId} did NOT complete and its outcome is UNKNOWN — ` + + `the order MAY already be live at the exchange. Check blockrun_polymarket_read action:"orders" (limit) / action:"positions" (market) ` + + `before placing it again. Card message: ${message}`, + { outcome: "unknown", action: p.action, tokenId: p.tokenId, notionalUsd: p.notionalUsd, ...(structuredContent ?? {}) }, + ); + }; 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); + if (outcomeUnknown) { + // Re-rendered (Re-quote) after an unknown outcome: keep the lock and the + // warning on the fresh card, so the new price is visible but not placeable. + note.className = "note err"; + note.textContent = `${unknownOutcomes.get(outcomeKey(p)) ?? "A previous submit of this order did not complete."}\n\nThis order MAY already be live at the exchange. Check your positions/orders before placing it again — this card will not re-submit it.`; + syncPlace(); + } place.addEventListener("click", async () => { + if (submitting || outcomeUnknown) return; if (parseFloat(amountField.value) !== quotedAmount) { syncPlace(); return; } if (!armed) { armed = true; @@ -202,24 +259,48 @@ 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)) { lockUnknown(text, (r.structuredContent ?? {}) as Record); setBusy(place, false); setBusy(requote, false); syncPlace(); } + else { disarm(); setBusy(place, false); setBusy(requote, false); } return; } renderPlaced(p, structured(r) ?? {}, resultText(r)); - void app.updateModelContext({ - content: [{ type: "text", text: `User placed the order from the order card: ${resultText(r)}` }], - structuredContent: (r.structuredContent ?? {}) as Record, - }).catch(() => {}); + tellModel(`User placed the order from the order card: ${resultText(r)}`, (r.structuredContent ?? {}) as Record); } 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 { + lockUnknown(msg); + 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..4ed5a10 100644 --- a/assets/context-cost-dark.svg +++ b/assets/context-cost-dark.svg @@ -1,10 +1,10 @@ - + CONTEXT COST - 12.7K tokens - 6% of a 200K context window · every turn, whether or not you call a tool - 5.2K with --profile trading — 59% less + 13.0K tokens + 7% of a 200K context window · every turn, whether or not you call a tool + 5.4K with --profile trading — 58% less measured, not estimated diff --git a/assets/context-cost.svg b/assets/context-cost.svg index 665256a..948669e 100644 --- a/assets/context-cost.svg +++ b/assets/context-cost.svg @@ -1,10 +1,10 @@ - + CONTEXT COST - 12.7K tokens - 6% of a 200K context window · every turn, whether or not you call a tool - 5.2K with --profile trading — 59% less + 13.0K tokens + 7% of a 200K context window · every turn, whether or not you call a tool + 5.4K with --profile trading — 58% less measured, not estimated diff --git a/docs/mcp-schema-overhead.md b/docs/mcp-schema-overhead.md index fab5cec..c506af1 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 | 13,006 | +| `trading` | 8 | 5,411 | +| `media` | 7 | 5,790 | +| `research` | 5 | 2,752 | +| `chat` | 3 | 2,079 | -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 58% 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/package-lock.json b/package-lock.json index 7dd42a7..055b772 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@blockrun/mcp", - "version": "0.49.0", + "version": "0.51.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@blockrun/mcp", - "version": "0.49.0", + "version": "0.51.0", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.123.0", diff --git a/package.json b/package.json index d88d833..a968162 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@blockrun/mcp", - "version": "0.49.0", + "version": "0.51.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", @@ -20,7 +20,7 @@ "dev": "tsx watch src/index.ts", "start": "node dist/index.js", "typecheck": "tsc --noEmit", - "test": "tsx --experimental-test-module-mocks --test test/*.test.ts", + "test": "tsx --experimental-test-module-mocks --import ./test/_setup.ts --test test/*.test.ts", "prepublishOnly": "npm run build", "verify:prices": "tsx scripts/verify-prices.ts", "measure:schema": "node scripts/measure-tool-schema.mjs", 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/e2e-confirm.ts b/scripts/e2e-confirm.ts new file mode 100644 index 0000000..eb5c9f4 --- /dev/null +++ b/scripts/e2e-confirm.ts @@ -0,0 +1,46 @@ +/** + * The confirm gate for every script under scripts/ that moves real funds. + * + * npx tsx scripts/polymarket-e2e-live.ts --confirm + * POLYMARKET_E2E_CONFIRM=1 npm run e2e:polymarket:withdraw + * + * Nothing in scripts/ may spend by being run. smoke-speech.ts got its gate + * after a bare invocation charged for a sound effect it advertised as a + * $0.001 speak; the three Polymarket scripts that submit a $2 withdrawal or + * sign the unlimited approval batch were carved out of that rule because they + * go through utils/ rather than a tool handler — a distinction that matters + * to a test and not at all to the wallet. This repo has already lost $0.42 to + * an agent that ran a paid path because it looked like a read. + * + * Its own switch, not smoke-speech's: BLOCKRUN_SMOKE_CONFIRM exported for a + * $0.06 smoke run must not have pre-authorised a $2 bridge. + * + * Injectable argv/env/exit so test/e2e-confirm.test.ts can prove the refusal + * without running a script. + */ +export const CONFIRM_FLAG = "--confirm"; +export const CONFIRM_ENV = "POLYMARKET_E2E_CONFIRM"; + +type Io = { + argv?: string[]; + env?: NodeJS.ProcessEnv; + stderr?: (line: string) => void; + exit?: (code: number) => never; +}; + +/** + * Print what the script is about to do with real money and stop, unless the + * caller said --confirm (or POLYMARKET_E2E_CONFIRM=1). Call it before the + * first await: a gate that comes after the withdrawal is a receipt. + */ +export function requireLiveConfirm(wouldMove: string[], io: Io = {}): void { + const argv = io.argv ?? process.argv.slice(2); + const env = io.env ?? process.env; + if (argv.includes(CONFIRM_FLAG) || env[CONFIRM_ENV] === "1") return; + const stderr = io.stderr ?? ((line: string) => console.error(line)); + const exit = io.exit ?? ((code: number) => process.exit(code)); + stderr("This script moves REAL funds from the machine-global wallet. Without confirmation it would have:"); + for (const line of wouldMove) stderr(` - ${line}`); + stderr(`Nothing was submitted. Re-run with ${CONFIRM_FLAG}, or set ${CONFIRM_ENV}=1, to authorise it.`); + exit(1); +} 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..b31473c 100644 --- a/scripts/polymarket-e2e-approve.ts +++ b/scripts/polymarket-e2e-approve.ts @@ -3,8 +3,18 @@ * batch (including the two collateral-adapter operators redeem requires), * then re-reads the resulting on-chain state. Prints no wallet address or * transaction id. From @KillerQueen-Z's #66. + * + * Signs on-chain approvals with the real wallet, so it refuses to run without + * --confirm (or POLYMARKET_E2E_CONFIRM=1) — see ./e2e-confirm.ts. */ import { runSetup } from "../src/utils/polymarket/setup.js"; +import { requireLiveConfirm } from "./e2e-confirm.js"; +import { failRedacted, installRedactedExit } from "./redact.js"; + +installRedactedExit(); +requireLiveConfirm([ + "sign and submit the Polymarket operator approval batch (unlimited allowances unless POLYMARKET_BOUNDED_APPROVALS is set) from the funded wallet", +]); try { const submitted = await runSetup({ confirm: true }); @@ -16,7 +26,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..0ac6af4 100644 --- a/scripts/polymarket-e2e-live.ts +++ b/scripts/polymarket-e2e-live.ts @@ -4,10 +4,26 @@ * 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. + * + * Moves real funds, so it refuses to run without --confirm (or + * POLYMARKET_E2E_CONFIRM=1) — see ./e2e-confirm.ts. `npm run + * e2e:polymarket:live -- --confirm`. */ 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 { requireLiveConfirm } from "./e2e-confirm.js"; +import { failRedacted, installRedactedExit, redactChainValues } from "./redact.js"; + +installRedactedExit(); +// Gate first: a check that runs after the redeem is a receipt. +requireLiveConfirm([ + "redeem one resolved position worth <= $0.001 (burns the shares)", + "withdraw $2.00 USDC from the Polymarket deposit wallet through the bridge", +]); const owner = getFundsAddress(); const positions = await fetchPositions(owner); @@ -23,12 +39,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..741759c 100644 --- a/scripts/polymarket-e2e-readonly.ts +++ b/scripts/polymarket-e2e-readonly.ts @@ -1,17 +1,30 @@ /** * Read-only local-wallet preflight for Polymarket redeem/withdraw changes. * Never supplies confirm:true; never emits a wallet address, private key, or - * transaction identifier. From @KillerQueen-Z's #66. + * transaction identifier — on any exit path, a thrown RPC error included + * (installRedactedExit below; the header promise used to hold only for the + * isError branch). + * + * An error is reported AS an error. listPositions() catches every failure + * and returns `{ text, isError: true }` with no `structured`, and this script + * used to read `structured?.positions ?? []` and print `positions: []` under + * a success-shaped JSON — so a Data-API outage looked like an empty wallet, + * and an operator concluded there was nothing to redeem. Now either side + * failing prints the redacted message in its place and exits 1. + * From @KillerQueen-Z's #66. */ import { listPositions } from "../src/utils/polymarket/positions.js"; import { withdrawFunds } from "../src/utils/polymarket/withdraw.js"; +import { installRedactedExit, redactChainValues } from "./redact.js"; + +installRedactedExit(); const [positionsResult, withdrawalResult] = await Promise.all([ listPositions(), withdrawFunds({}), ]); -const positions = ((positionsResult.structured as { +const positions = positionsResult.isError ? { error: redactChainValues(positionsResult.text) } : ((positionsResult.structured as { positions?: Array<{ title?: string; outcome?: string; @@ -31,10 +44,12 @@ const positions = ((positionsResult.structured as { condition: position.conditionId ? `${position.conditionId.slice(0, 10)}…` : undefined, })); +const failed = Boolean(positionsResult.isError || withdrawalResult.isError); console.log(JSON.stringify({ + ok: !failed, 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, @@ -44,3 +59,4 @@ console.log(JSON.stringify({ toChainId: withdrawalResult.structured?.toChainId, }, }, null, 2)); +if (failed) process.exit(1); diff --git a/scripts/polymarket-e2e-verify-approvals.ts b/scripts/polymarket-e2e-verify-approvals.ts index e70c3ba..a9d9ec4 100644 --- a/scripts/polymarket-e2e-verify-approvals.ts +++ b/scripts/polymarket-e2e-verify-approvals.ts @@ -1,5 +1,13 @@ -/** Read-only: report the on-chain approval state for the local wallet. */ +/** + * Read-only: report the on-chain approval state for the local wallet. Never + * supplies confirm:true. Prints no wallet address on any exit path — the + * on-chain reads go through a Polygon RPC, and a viem error interpolates the + * owner address into its message, so the redacted exit is installed first. + */ import { runSetup } from "../src/utils/polymarket/setup.js"; +import { installRedactedExit } from "./redact.js"; + +installRedactedExit(); const result = await runSetup({ confirm: false }); const approvals = result.structured.approvals as Array<{ label: string; granted: boolean }>; diff --git a/scripts/polymarket-e2e-withdraw.ts b/scripts/polymarket-e2e-withdraw.ts index 855d428..e6f5eb5 100644 --- a/scripts/polymarket-e2e-withdraw.ts +++ b/scripts/polymarket-e2e-withdraw.ts @@ -1,9 +1,23 @@ -/** 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. + * + * Moves real funds, so it refuses to run without --confirm (or + * POLYMARKET_E2E_CONFIRM=1) — see ./e2e-confirm.ts. + */ import { withdrawFunds } from "../src/utils/polymarket/withdraw.js"; +import { requireLiveConfirm } from "./e2e-confirm.js"; +import { failRedacted, installRedactedExit, redactChainValues } from "./redact.js"; -const result = await withdrawFunds({ amount_usd: 2, confirm: true }); +installRedactedExit(); +requireLiveConfirm(["withdraw $2.00 USDC from the Polymarket deposit wallet through the bridge"]); + +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..e217d9b --- /dev/null +++ b/scripts/redact.ts @@ -0,0 +1,54 @@ +/** + * 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); +} + +/** + * Route every uncaught exception and unhandled rejection through failRedacted. + * + * Call it before the first await. Only -live.ts had these two lines; the + * other four scripts either wrapped one call in try/catch (fine until a + * second call is added outside it) or had nothing at all — and the read-only + * preflight, whose header promises it never emits a wallet address, would + * have printed one on the first RPC failure. One installer means the promise + * is kept by every script the same way, and test/redact-exit.test.ts checks + * that each one calls it. + */ +export function installRedactedExit(): void { + process.on("uncaughtException", (error) => failRedacted("", error)); + process.on("unhandledRejection", (error) => failRedacted("", error)); +} diff --git a/scripts/smoke-speech-plan.ts b/scripts/smoke-speech-plan.ts new file mode 100644 index 0000000..ae120c9 --- /dev/null +++ b/scripts/smoke-speech-plan.ts @@ -0,0 +1,36 @@ +/** + * What scripts/smoke-speech.ts is about to charge, computed from the same + * estimator the tool reserves with — never typed by hand. + * + * The header used to say "$0.001 speak" and "$0.054 total". The 51-character + * speak on flash-v2.5 reserves (51/1000) x $0.05 x 1.05 = $0.0026775 plus the + * tx fee, ceiled: $0.004678 — and the run is $0.059179, not $0.054. The 0.50.0 + * changelog claimed the script "states the real total" while the figures it + * asked the operator to authorise were the old ones. A confirm gate that + * quotes the wrong price is the thing the gate exists to prevent. + * + * Kept out of smoke-speech.ts so a test can import it: importing the script + * itself runs the paid calls. + */ +import { speechCost } from "../src/tools/speech.js"; +import { withTxFee } from "../src/utils/tx-fee.js"; + +export const SPEAK_MODEL = "elevenlabs/flash-v2.5"; +export const SPEAK_INPUT = "Hello from BlockRun. Pay per call, no subscription."; +export const SOUND_EFFECT_INPUT = "soft rain on a tin roof with distant thunder"; + +/** + * Mirrors SOUND_EFFECT_COST in src/tools/speech.ts, which is not exported: + * $0.05 base x 1.05 margin, plus the tx fee, ceiled to the micro — the + * live-verified 54501 micro. If speech.ts ever exports its constant, import + * it here instead of restating the formula. + */ +export const SOUND_EFFECT_USD = withTxFee(0.05 * 1.05); + +export function smokeSpeechPlan(): { speakUsd: number; soundEffectUsd: number; totalUsd: number } { + const speakUsd = speechCost(SPEAK_MODEL, SPEAK_INPUT); + const soundEffectUsd = SOUND_EFFECT_USD; + return { speakUsd, soundEffectUsd, totalUsd: Math.round((speakUsd + soundEffectUsd) * 1e6) / 1e6 }; +} + +export const usd = (n: number) => `$${n.toFixed(6).replace(/0+$/, "").replace(/\.$/, ".0")}`; diff --git a/scripts/smoke-speech.ts b/scripts/smoke-speech.ts index 2a019c3..f268384 100644 --- a/scripts/smoke-speech.ts +++ b/scripts/smoke-speech.ts @@ -1,7 +1,43 @@ -// 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 six cents + * per run (the exact figure is printed by the gate, computed from the + * estimator), 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 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 figures are NOT typed here. The gate once quoted "$0.001 speak, $0.054 + * total" for a run the estimator reserves $0.004678 + $0.054501 = $0.059179 + * for — a confirm gate that understates the charge is the defect it exists + * to prevent. ./smoke-speech-plan.ts computes them from speechCost() and + * test/smoke-speech-plan.test.ts pins them; this file may not contain a + * dollar literal other than the cap. + * + * 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, + * sound_effect. + */ import { registerSpeechTool } from "../src/tools/speech.js"; import type { BudgetState } from "../src/types.js"; +import { SOUND_EFFECT_INPUT, SPEAK_INPUT, SPEAK_MODEL, smokeSpeechPlan, usd } from "./smoke-speech-plan.js"; + +const SPEND_CAP_USD = 0.15; +const plan = smokeSpeechPlan(); + +if (!process.argv.includes("--confirm") && process.env.BLOCKRUN_SMOKE_CONFIRM !== "1") { + console.error( + `smoke-speech spends about ${usd(plan.totalUsd)} of real USDC (speak ${usd(plan.speakUsd)} + sound_effect ${usd(plan.soundEffectUsd)}).\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; @@ -9,7 +45,7 @@ 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) { @@ -22,7 +58,7 @@ async function run(label: string, args: Record) { await run("voices", { action: "voices" }); await run("over-length free-fail", { action: "speak", input: "x".repeat(6000), model: "elevenlabs/v3" }); // Pass defaults explicitly — the stub bypasses the MCP SDK's zod parsing. -await run("real speak ($0.001)", { action: "speak", input: "Hello from BlockRun. Pay per call, no subscription.", voice: "sarah", model: "elevenlabs/flash-v2.5", response_format: "mp3" }); +await run(`real speak (${usd(plan.speakUsd)})`, { action: "speak", input: SPEAK_INPUT, voice: "sarah", model: SPEAK_MODEL, response_format: "mp3" }); console.log(`\nBudget spent: $${budget.spent.toFixed(4)} across ${budget.calls} calls`); -await run("real sound_effect ($0.0525)", { action: "sound_effect", input: "soft rain on a tin roof with distant thunder", duration_seconds: 4, response_format: "mp3" }); +await run(`real sound_effect (${usd(plan.soundEffectUsd)})`, { action: "sound_effect", input: SOUND_EFFECT_INPUT, duration_seconds: 4, response_format: "mp3" }); console.log(`\nFinal budget: $${budget.spent.toFixed(4)} across ${budget.calls} calls`); 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..bd4ab15 100644 --- a/scripts/sync-brand-numbers.mjs +++ b/scripts/sync-brand-numbers.mjs @@ -18,6 +18,19 @@ * Markers look like: 66 * and wrap the WHOLE token, so a badge URL, its alt text and the prose number * can all regenerate from one key. + * + * THIS COPY IS AHEAD OF THE SOURCE. blockrun's `brand-script-sync` CI job + * diffs every consumer against brand/sync-brand-numbers.mjs and its printed + * remediation is "copy the source over the consumer" — twice that overwrote a + * fix made here (#84, #128). What this copy carries that the source does not, + * as of 2026-09-13: assertRenderable + escAttr (a value from the mirror is + * refused, and attribute-escaped, before it is written into a README that the + * brand-sync bot then pushes unattended with contents:write), keyOf() on the + * keys-in-use count, and the --check summary that does not say "up to date" + * under a list of stale fenced markers. Resync source <- consumer: land THIS + * file in blockrun/brand and fan it out; do not copy the source over it. + * test/brand-sync-script.test.ts fails on a copy without the guard, so a + * consumer <- source resync cannot pass this repo's required `test` check. */ import { execFileSync } from "node:child_process"; import { existsSync, lstatSync, readFileSync, writeFileSync, readdirSync } from "node:fs"; @@ -101,15 +114,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 +364,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 +389,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-verdict.ts b/scripts/verify-prices-verdict.ts new file mode 100644 index 0000000..564de62 --- /dev/null +++ b/scripts/verify-prices-verdict.ts @@ -0,0 +1,91 @@ +// scripts/verify-prices-verdict.ts — the exit decision of `npm run verify:prices`. +// +// Split out of verify-prices.ts so the decision can be tested without probing +// the network (the script itself is top-level `await` over sixty live 402 +// probes and cannot be imported by a test). Pure: tallies in, exit code and +// summary lines out. +// +// WHY THIS IS ITS OWN DECISION: only a confirmed under-reserve failed the run. +// `unreachable` — no 402, an undecodable header, a missing `amount` — printed a +// warning and exit 0, so the day the gateway renamed a header field every row +// printed `?` and the release gate went green having verified nothing. A gate +// that cannot tell "checked and fine" from "could not check" is not a gate. +// +// Exit codes are deliberately distinct so a caller can tell them apart: +// 0 every probe and every catalogue was read, nothing under-reserves +// 1 a CONFIRMED under-reserve — an estimator, or the price table, reserves +// less than a gateway charges. Fix the estimator before publishing. +// 2 the run could not verify enough to say: more than UNREACHABLE_FRACTION +// of the routes, or any catalogue, could not be read. Fix the probe (or +// wait out the outage) and run again. NEVER read as pass. +// When both apply, 1 wins — money is the more urgent message — but the +// unverified rows are still named so a partial run is never mistaken for a +// complete one. + +export type Tally = { + /** Routes the script tried to probe. */ + probes: number; + /** Base charges more than the estimator reserves. */ + short: number; + /** Solana charges more than the estimator reserves. */ + solShort: number; + /** Probes that produced no usable quote (no 402, bad header, no `amount`). */ + unreachable: number; + /** Live chat models the price table under-reserves (any catalogue). */ + catalogueGaps: number; + /** Catalogues the script tried to read (Base, Solana, the account-rail sheet). */ + catalogues: number; + /** Catalogues that could not be read. */ + catalogueUnreachable: number; +}; + +export type Verdict = { code: 0 | 1 | 2; lines: string[] }; + +/** + * A quarter. A single transient miss (one route mid-deploy, one 5xx) should + * not block a release — the row prints `?` and the operator re-runs. A quarter + * of the matrix is not transient: at that point the probe method or the + * network is broken, and "verified" would be a lie about 15+ estimators. + */ +export const UNREACHABLE_FRACTION = 0.25; + +export function verdict(t: Tally): Verdict { + const lines: string[] = []; + + const unverified = t.probes === 0 || t.unreachable > t.probes * UNREACHABLE_FRACTION; + if (t.probes === 0) { + lines.push("no routes were probed — the probe list is empty, so nothing was verified."); + } else if (unverified) { + lines.push( + `${t.unreachable} of ${t.probes} routes could not be verified (above the ${UNREACHABLE_FRACTION * 100}% tolerance). ` + + "Their estimators are NOT verified — fix the probe (has the 402 header shape changed?) or the network, then run again.", + ); + } else if (t.unreachable) { + lines.push( + `${t.unreachable} unreachable route${t.unreachable === 1 ? "" : "s"} were NOT verified — treat them as unknown, not as passing.`, + ); + } + if (t.catalogueUnreachable) { + lines.push( + `${t.catalogueUnreachable} of ${t.catalogues} price catalogues could not be read. ` + + "The catalogue sweep is the only check that sees a model the price table does NOT list — an unread catalogue is NOT verified.", + ); + } + + const money = t.short || t.solShort || t.catalogueGaps; + if (money) { + const why = [ + t.short || t.solShort ? `an estimator reserves less than the gateway charges${t.solShort ? " (on Solana)" : ""}` : "", + t.catalogueGaps + ? `${t.catalogueGaps} live chat model${t.catalogueGaps === 1 ? "" : "s"} disagree${t.catalogueGaps === 1 ? "s" : ""} with the price table in a direction that costs money` + : "", + ].filter(Boolean).join("; "); + lines.push(`FAIL: ${why}. Fix it before publishing.`); + return { code: 1, lines }; + } + if (unverified || t.catalogueUnreachable) { + lines.push("UNVERIFIED: this run could not check enough to pass. Exit 2 — not a pass, not a confirmed under-reserve."); + return { code: 2, lines }; + } + return { code: 0, lines }; +} diff --git a/scripts/verify-prices.ts b/scripts/verify-prices.ts index 0220db3..e48b69e 100644 --- a/scripts/verify-prices.ts +++ b/scripts/verify-prices.ts @@ -24,6 +24,25 @@ // The header is x402Version 2, so the field is `amount` (micro-USDC). v1's // `maxAmountRequired` is absent; read that key and you get `null`, which reads // as "free" rather than raising. Hence the explicit check below. +// +// THE ACCOUNT RAIL (api.blockrun.ai, Bearer key, no tx fee) CANNOT BE PROBED +// PER ROUTE. Probed 2026-09-13: with no credential, or an invalid one, every +// path answers 401 `invalid_api_key` — including GET /v1/models — and there is +// no dry-run or quote-only header. With a VALID key a paid route bills; that is +// the rail's whole design, and a script that attaches a real key to sixty +// routes is a script that spends. So the per-route rows below stay wallet- +// only, and the account rail is covered where it CAN be, for free: it prices +// chat "from this sheet by design" (blockrun's src/lib/models.ts), and the +// sheet is public at blockrun.ai/api/pricing. The catalogue sweep reads it as +// a third source. What this does NOT cover on the account rail: media, pm, +// modal, search, exa, phone — every non-chat estimator is verified on the +// wallet gateways only, and the account rail's ledger books whatever +// x-blockrun-cost-usd says (an overrun past the reserve is visible there, after +// the fact, not here). That is a known gap, not an oversight. +// +// EXIT CODES: 0 verified clean; 1 a confirmed under-reserve; 2 the run could +// not verify enough to say (see ./verify-prices-verdict.ts). 2 exists because +// this used to exit 0 with every row printing `?`. import { estimateModalCost } from "../src/tools/modal.js"; import { estimatePhoneCost } from "../src/tools/phone.js"; import { estimateSearchCost } from "../src/tools/search.js"; @@ -34,6 +53,7 @@ import { estimateVideoCost } from "../src/tools/video.js"; import { MARKETS_PRICE_USD } from "../src/tools/markets.js"; import { withTxFee } from "../src/utils/tx-fee.js"; import { CHAT_PRICE_PER_MTOKEN, DEFAULT_CHAT_PRICE, FREE_CHAT_MODELS, MODEL_TIERS } from "../src/utils/constants.js"; +import { verdict } from "./verify-prices-verdict.js"; // TWO gateways, and they do not agree. Base and Solana are separate deployments // with separate env, and TRANSACTION_FEE_USD is env-overridable in the gateway — @@ -52,6 +72,11 @@ import { CHAT_PRICE_PER_MTOKEN, DEFAULT_CHAT_PRICE, FREE_CHAT_MODELS, MODEL_TIER // users, which is a release blocker exactly like a Base shortfall. const BASE = "https://blockrun.ai/api/v1/"; const SOL = "https://sol.blockrun.ai/api/v1/"; +// The public price sheet the ACCOUNT rail bills chat from (see the header). +// Free, unauthenticated, and not the /v1/models population: it carries the +// hidden-but-served SKUs that the catalogue omits, which is exactly the set +// api.blockrun.ai settled at $0 for a week because nothing priced them (#516). +const ACCOUNT_SHEET = "https://blockrun.ai/api/pricing"; type Probe = { label: string; @@ -62,6 +87,11 @@ type Probe = { // which model a tier will settle on until after the call). For those, over- // reserving is the design, not drift — but under-reserving is still a bug. allowOver?: boolean; + // A combination the CLIENT refuses before payment, probed to check that the + // gateway refuses it too. An unpaid 4xx with no 402 is the expected answer + // and counts as verified, not unreachable; a quote is the finding — the + // gateway sells a tier this client's guard says it cannot render. + expectRefused?: boolean; }; type Quote = { usd: number; description?: string }; @@ -179,13 +209,15 @@ const PROBES: Probe[] = [ ["bytedance/seedance-2.5", 30, undefined], // A combination the client-side guard REFUSES, probed anyway because a // guard is a claim about the gateway and this is the only thing checking - // it: the gateway still QUOTES 2.5@1080p ($3.55) even though token360 - // rejects it at submit (probed 2026-08-07) — a known gateway defect, fixed - // by blockrun PR #353. Once that deploys, this probe reports `no 402` and - // the guard is vindicated. (2.0@360p left the matrix: 360p is out of the - // schema for every model and out of this client's enum, so the estimator - // now throws on it — it can no longer even be expressed from here.) - ["bytedance/seedance-2.5", undefined, "1080p"], + // it. The gateway used to QUOTE 2.5@1080p ($3.55) even though token360 + // rejected it at submit (probed 2026-08-07); blockrun PR #353 deployed and + // since 2026-09-15 the probe answers 400 with no 402 — the guard is + // vindicated, and the row is marked so that answer reads as verified + // rather than as an unreachable route. (2.0@360p left the matrix: 360p is + // out of the schema for every model and out of this client's enum, so the + // estimator now throws on it — it can no longer even be expressed from + // here.) + ["bytedance/seedance-2.5", undefined, "1080p", true], // Each model probed at (or near) its ceiling and floor tier, so a gateway // whose capability surface diverges from SEEDANCE_RESOLUTIONS — in either // direction — shows up here as `no 402` or a price mismatch. 1.5-pro@1080p @@ -195,9 +227,10 @@ const PROBES: Probe[] = [ ["bytedance/seedance-2.0-fast", undefined, "480p"], ["bytedance/seedance-2.5", undefined, "480p"], ["bytedance/seedance-2.0", undefined, "480p"], - ] as Array<[string, number | undefined, string | undefined]>).map(([model, seconds, resolution]) => ({ + ] as Array<[string, number | undefined, string | undefined, boolean?]>).map(([model, seconds, resolution, expectRefused]) => ({ label: `video ${model.split("/")[1]}${seconds ? ` ${seconds}s` : ""}${resolution ? ` ${resolution}` : ""}`, path: "videos/generations", + ...(expectRefused ? { expectRefused: true } : {}), body: { model, prompt: "a cube rotating", @@ -299,6 +332,21 @@ for (const probe of PROBES) { quote(BASE, probe.path, probe.body), quote(SOL, probe.path, probe.body), ]); + if (probe.expectRefused) { + // The guard says this cannot render; the gateway is expected to refuse + // it unpaid. Either side quoting it is the finding, on whichever chain. + const refused = (q: Quote | string) => typeof q === "string" && /no 402 \(HTTP 4\d\d\)/.test(q); + if (refused(liveQ) && refused(solQ)) { + console.log(` ✓ ${probe.label.padEnd(26)} refused unpaid on both gateways, as the client-side guard expects`); + } else { + const sold = [ + !refused(liveQ) ? `Base ${typeof liveQ === "string" ? liveQ : `quotes $${liveQ.usd.toFixed(6)}`}` : "", + !refused(solQ) ? `Solana ${typeof solQ === "string" ? solQ : `quotes $${solQ.usd.toFixed(6)}`}` : "", + ].filter(Boolean).join("; "); + console.log(` ! ${probe.label.padEnd(26)} the client refuses this tier but the gateway does not: ${sold} — the guard still blocks it before payment`); + } + continue; + } const live = typeof liveQ === "string" ? liveQ : liveQ.usd; const solLive = typeof solQ === "string" ? solQ : solQ.usd; const liveProduct = typeof liveQ === "string" ? undefined : product(liveQ.description); @@ -384,7 +432,8 @@ console.log( `\n${short} under-reserving, ${over} over-reserving, ${unreachable} unreachable, ` + `${PROBES.length - short - over - unreachable} exact`, ); -if (unreachable) console.log("Unreachable routes were NOT verified — treat them as unknown, not as passing."); +// The unreachable verdict is printed once, at the end, by verdict() — with the +// exit code it now carries. console.log( `Solana: ${solShort} under-reserved (BLOCKER), ${solDearer} dearer than Base but covered, ${solCheaper} cheaper, ${solMissing} not served, ${solSubstituted} substituted`, @@ -450,12 +499,43 @@ async function catalogue(host: string): Promise { } } +// The account rail's sheet, reshaped to the catalogue's row so one loop judges +// all three sources. `inputPricePerMillion` is the billed rate (margin already +// applied — chatMarginPercent is 0 today, but the field is the one that would +// move if that changed); `inputPrice` is the pre-margin figure and is NOT what +// settles. Long-context ladders (a model repricing above N input tokens) are +// on the sheet too and are deliberately not compared: no estimator in this +// repo models them, so they are a separate finding, not a row here. +type SheetModel = { id: string; available?: boolean; billingMode?: string; inputPricePerMillion?: unknown; outputPricePerMillion?: unknown }; + +async function accountSheet(): Promise { + try { + const res = await fetch(ACCOUNT_SHEET); + if (!res.ok) return `HTTP ${res.status}`; + const body = (await res.json()) as { models?: unknown }; + if (!Array.isArray(body.models)) return "no `models` array in the response"; + return (body.models as SheetModel[]).map((m) => ({ + id: m.id, + available: m.available, + pricing: { input: m.inputPricePerMillion, output: m.outputPricePerMillion }, + })); + } catch (err) { + return err instanceof Error ? err.message : String(err); + } +} + const catalogueGaps: string[] = []; // fail const catalogueNotes: string[] = []; // report only let catalogueUnreachable = 0; +const CATALOGUES: Array<[string, () => Promise]> = [ + ["Base", () => catalogue(BASE)], + ["Solana", () => catalogue(SOL)], + ["Account (pricing sheet)", accountSheet], +]; console.log("\nCatalogue sweep: every live chat model must be covered by its price row, by the default, or by FREE_CHAT_MODELS"); -for (const [name, host] of [["Base", BASE], ["Solana", SOL]] as const) { - const models = await catalogue(host); +console.log(" (Base and Solana: GET /v1/models. Account rail: the public sheet api.blockrun.ai bills chat from — its other routes cannot be quoted without a key that would be charged.)"); +for (const [name, read] of CATALOGUES) { + const models = await read(); if (typeof models === "string") { console.log(` ? ${name.padEnd(26)} ${models}`); catalogueUnreachable++; @@ -471,7 +551,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,14 +587,23 @@ 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}`); -if (catalogueUnreachable) console.log(" A catalogue that could not be read was NOT verified — treat it as unknown, not as passing."); // Under-reserving is a release blocker: it means the budget cap is a lie. That is // true per CHAIN — an estimator built off Base is a lie on Solana the moment @@ -518,11 +611,19 @@ if (catalogueUnreachable) console.log(" A catalogue that could not be read was // true for a catalogue model the table does not know: the gate reserves the // default for it, and the default is a claim about the catalogue. // Over-reserving only blocks affordable calls, so it warns without failing. -if (short || solShort || catalogueGaps.length) { - const why = [ - short || solShort ? `an estimator reserves less than the gateway charges${solShort ? " (on Solana)" : ""}` : "", - catalogueGaps.length ? `${catalogueGaps.length} live chat model${catalogueGaps.length === 1 ? "" : "s"} disagree${catalogueGaps.length === 1 ? "s" : ""} with the price table in a direction that costs money` : "", - ].filter(Boolean).join("; "); - console.log(`\nFAIL: ${why}. Fix it before publishing.`); - process.exit(1); -} +// +// And a run that could not LOOK is not a run that passed: too many `?` rows, or +// a catalogue that would not read, exits 2. The decision lives in +// verify-prices-verdict.ts so a test can pin it without the network. +const result = verdict({ + probes: PROBES.length, + short, + solShort, + unreachable, + catalogueGaps: catalogueGaps.length, + catalogues: CATALOGUES.length, + catalogueUnreachable, +}); +if (result.lines.length) console.log(""); +for (const line of result.lines) console.log(line); +process.exit(result.code); diff --git a/scripts/version-gate.mjs b/scripts/version-gate.mjs new file mode 100644 index 0000000..cb1d287 --- /dev/null +++ b/scripts/version-gate.mjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node +// Refuse to publish a version that is not ABOVE what npm already serves. +// +// node scripts/version-gate.mjs +// +// publish.yml's npm step was gated on `pkg != npm` — inequality, not order. +// `npm publish` does not compare semver: without `--tag` it points `latest` at +// whatever it just published, so a release PR that wrote 0.5.1 for 0.51.0 +// would have shipped and downgraded every `npx -y @blockrun/mcp@latest` user +// to a build with 0.5-era price tables. That number is not hypothetical: +// VERSION sat nine minors behind package.json, and the release tooling that +// computes the next version reads VERSION. +// +// Plain Node on purpose — this runs before `npm ci`, so there is no `semver` +// package to lean on. Exported for test/version-gate.test.ts; the CLI is what +// the workflow calls. +// +// "none" (nothing on npm yet — npm's E404) passes; "unknown" (npm could not be +// read at all) is refused; equal passes (that is the re-run of an +// already-published version, which the workflow skips on its own); prereleases +// and anything that is not X.Y.Z are refused, because the workflow has no +// dist-tag path for them and a malformed string must not compare as 0.0.0. + +const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/; + +/** -1 / 0 / 1, numerically per component. Throws on anything that is not X.Y.Z. */ +export function compareSemver(a, b) { + const pa = a.match(SEMVER); + const pb = b.match(SEMVER); + if (!pa) throw new Error(`"${a}" is not a bare X.Y.Z version`); + if (!pb) throw new Error(`"${b}" is not a bare X.Y.Z version`); + for (let i = 1; i <= 3; i++) { + const d = Number(pa[i]) - Number(pb[i]); + if (d !== 0) return d < 0 ? -1 : 1; + } + return 0; +} + +/** { ok: true } or { ok: false, reason } — never throws; a bad input is a refusal. */ +export function gate({ pkg, npm }) { + if (!SEMVER.test(pkg)) return { ok: false, reason: `package.json version "${pkg}" is not a bare X.Y.Z version — refusing to publish it` }; + if (npm === "none") return { ok: true, reason: `nothing on npm yet — ${pkg} will be the first publish` }; + // The workflow spells a registry/network failure "unknown" (only npm's own + // E404 is "none"). A gate that could not look has not passed: refuse, and + // say re-run — the one input where "none" and "unknown" differ is a + // package.json below latest during a registry blip, which is exactly the + // downgrade this script exists to stop. + if (npm === "unknown") return { ok: false, reason: `npm latest could not be read (registry or network failure) — cannot order ${pkg} against it, refusing; re-run the job` }; + if (!SEMVER.test(npm)) return { ok: false, reason: `npm latest "${npm}" is not a bare X.Y.Z version — cannot order ${pkg} against it, refusing` }; + const order = compareSemver(pkg, npm); + if (order < 0) { + return { + ok: false, + reason: + `package.json ${pkg} is LOWER than npm latest ${npm}. npm publish would accept it and it would become \`latest\`, ` + + `downgrading every \`npx -y @blockrun/mcp@latest\` user. Bump package.json (and VERSION) above ${npm}.`, + }; + } + if (order === 0) return { ok: true, reason: `package.json ${pkg} == npm latest — already published, the workflow skips npm` }; + return { ok: true, reason: `package.json ${pkg} > npm latest ${npm}` }; +} + +const isMain = process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href; +if (isMain) { + const [pkg, npm] = process.argv.slice(2); + if (!pkg || !npm) { + console.error("usage: node scripts/version-gate.mjs "); + process.exit(2); + } + const r = gate({ pkg, npm }); + console[r.ok ? "log" : "error"](`version-gate: ${r.reason}`); + process.exit(r.ok ? 0 : 1); +} 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/search/SKILL.md b/skills/search/SKILL.md index 47504db..dad6e37 100644 --- a/skills/search/SKILL.md +++ b/skills/search/SKILL.md @@ -32,7 +32,7 @@ blockrun_search({ body: { | Field | Required | Type | Notes | |---|---|---|---| | `query` | yes | string | Natural-language search query | -| `sources` | no | string[] | Subset of `["web","news"]`. Default: both. Does NOT multiply price. | +| `sources` | no | string[] | Subset of `["web","news"]`. Default: `["web"]` — pass both for news coverage. There is no X/Twitter source (removed upstream 2026-07-05; asking for `"x"` is refused before payment). Does NOT multiply price. | | `max_results` | no | number | 1–50, default 10. **Drives the price** — ~$0.0263 charged per source. Pass a small number to cap spend; the gateway prices the raw value and does not floor fractions. | | `from_date` | no | string | `YYYY-MM-DD` lower bound on result date | | `to_date` | no | string | `YYYY-MM-DD` upper bound | @@ -42,9 +42,9 @@ blockrun_search({ body: { | User intent | `sources` setting | |---|---| | Breaking news / today's headlines | `["news"]` | -| What's the CT / KOL sentiment on X | `["x"]` | +| Social / X sentiment | not served — there is no X source; use `["news","web"]` and say so, or `blockrun_exa` for a targeted crawl | | Backgrounder / explainer / docs | `["web"]` | -| General "find current info" question | omit — defaults to all three | +| General "find current info" question | `["web","news"]` (omitting it searches the web only) | ## Worked Examples @@ -55,12 +55,14 @@ blockrun_search({ body: { query: "Ethereum ETF approval SEC", sources: ["news"," ``` **Cost: ~$0.2110** (8 sources: $0.025 × 8 × 1.05 + $0.001). -### 2. "What is X saying about Solana's latest outage?" +### 2. "What happened in Solana's latest outage?" ```ts -blockrun_search({ body: { query: "Solana outage today", sources: ["x"], max_results: 15 } }) +blockrun_search({ body: { query: "Solana outage today", sources: ["news","web"], max_results: 15 } }) ``` -**Cost: ~$0.3948** (15 sources: $0.025 × 15 × 1.05 + $0.001). +**Cost: ~$0.3948** (15 sources: $0.025 × 15 × 1.05 + $0.001). There is no +X/Twitter source: `sources: ["x"]` is refused before payment with the live +list, so do not promise "what X is saying" — say what the news and web say. ### 3. "Background on Pectra upgrade, last 90 days only" 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..27f7523 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"; @@ -92,7 +93,10 @@ export function initializeMcpServer( rpc: () => registerRpcTool(server, budget), defi: () => registerDefiTool(server, budget), polymarket_read: () => registerPolymarketReadTool(server), - polymarket: () => registerPolymarketTool(server), + // The budget too: fund's $0.01 gateway fee is Base-wallet API spend, and the + // registrar books it like any paid call — without the ledger it was + // neither reserved nor booked outside the unit test (audit round 4). + polymarket: () => registerPolymarketTool(server, budget), }; for (const [name, register] of Object.entries(registrars) as [ToolName, () => void][]) { @@ -125,7 +129,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-anthropic.ts b/src/tools/chat-anthropic.ts index de242dc..fdaf3d0 100644 --- a/src/tools/chat-anthropic.ts +++ b/src/tools/chat-anthropic.ts @@ -16,8 +16,10 @@ import type Anthropic from "@anthropic-ai/sdk"; import { extractErrorMessage, formatError } from "../utils/errors.js"; import { recordActualSpend } from "../utils/budget.js"; +import { isApiKeyMode } from "../utils/auth.js"; import { OBSERVED_GATEWAY_TX_FEE_USD } from "../utils/tx-fee.js"; import { CHAT_PRICE_PER_MTOKEN, GATEWAY_CHARS_PER_TOKEN_OBSERVED } from "../utils/constants.js"; +import { AcceptedThenFailedError, settlementOnThrow, settledCostFromHeaders } from "../utils/chat-stream.js"; import type { BudgetState } from "../types.js"; /** @@ -120,9 +122,143 @@ export function anthropicCallCost( return Math.ceil(charged * 1e6) / 1e6; // the gateway settles in whole micro-USDC } +/** + * The ACCOUNT rail's ledger entry for a native call, when the response carried + * no `x-blockrun-cost-usd` (chat settles after the response, so it never does). + * + * Not anthropicCallCost: that is the x402 QUOTE the wallet rails settle — + * output at 0.1x max_tokens, a $0.001 floor, the observed transaction fee — + * and api.blockrun.ai settles none of it. It bills exact usage, base rate with + * no fee and no floor (reconciled against the dashboard 2026-09-05). Booking + * the quote formula here added $0.001 to every call on a rail that charges no + * fee — a $1 delegate cut off at 500 haiku calls that had cost $0.50 (D58). + * + * `usage` is the response's own input/output token counts when the call + * completed; on a failure after acceptance there is none, so the prompt at the + * observed chars/token and the full max_tokens stand in — the conservative + * side for a call that reported nothing. Null when the model has no row, so + * the caller falls back to the pre-call estimate. + */ +export function anthropicAccountLedgerUsd( + model: string, + promptChars: number, + maxTokens: number, + usage: { input_tokens: number; output_tokens: number } | null, +): number | null { + const id = catalogueKeyForEcho(model); + const rate = Object.hasOwn(CHAT_PRICE_PER_MTOKEN, id) ? CHAT_PRICE_PER_MTOKEN[id] : undefined; + if (!rate) return null; + const inputTokens = usage?.input_tokens ?? Math.ceil(promptChars / GATEWAY_CHARS_PER_TOKEN_OBSERVED) + MESSAGE_TOKEN_OVERHEAD; + const outputTokens = usage?.output_tokens ?? maxTokens; + const usd = (inputTokens / 1_000_000) * rate.input + (outputTokens / 1_000_000) * rate.output; + return Math.ceil(usd * 1e6 - 1e-6) / 1e6; // whole micro-dollars, float noise excluded +} + // AnthropicClient.messages is typed as the official SDK's Messages resource. type AnthropicLike = { messages: Anthropic["messages"] }; +/** + * Nothing streamed for this long means the connection is dead, not slow: the + * API sends `ping` events every few seconds while a long thinking budget runs, + * and the gateway forwards them. Same figure as the OpenAI-compat assembler. + */ +const NATIVE_IDLE_TIMEOUT_MS = 120_000; + +/** + * Run the native call as a STREAM and assemble the final Message. + * + * Streaming, not create(): two reasons, both money or reach. + * + * 1. @anthropic-ai/sdk refuses a non-streaming request whose max_tokens + * could run past ten minutes — `calculateNonstreamingTimeout` throws + * "Streaming is required for operations that may take longer than 10 + * minutes" above 21,333 tokens — and effectiveMax is budget_tokens + 1024, + * so every thinking budget from 20,310 up to the schema's 100,000 died in + * this process with an error that blamed the caller (D51). The check is + * skipped when `stream` is set. + * 2. A non-streaming request moves zero bytes while Claude thinks, and the + * edge in front of the gateway 524s the idle connection at ~100s — after + * the gateway verified the payment. Streaming keeps bytes flowing (pings, + * thinking deltas), the same fix chat-stream.ts made for the compat paths. + * + * `maxRetries: 0`, always. @blockrun/llm builds the official SDK at its default + * of two retries with a fetch that signs a FRESH x402 payment on every 402 it + * sees — the PAYMENT-SIGNATURE header lives on a local copy, never on the + * SDK's request init — so a 5xx/524/timeout after settlement was retried up to + * twice more, each retry a new USDC settlement for an undelivered answer, none + * of it visible to the ledger (C20). The gateway already saw the payment; a + * retry is a second purchase, and the routing loop's one-settlement rule + * belongs here too. (The account rail's client is built with maxRetries 0 by + * the SDK itself; passing it per request covers both.) + * + * The SDK's MessageStream accumulates thinking and signature deltas, so the + * assembled Message carries the same verbatim thinking blocks the non-streaming + * response did — the test against the real SDK pins that. + * + * `accepted` is whether the stream CONNECTED (the SDK emits `connect` once the + * 2xx is in, before the first event). A throw after that point is wrapped as + * AcceptedThenFailedError: the payment settled (wallet) or the request is + * billed (account), and the caller books it. + */ +async function streamNativeMessage( + client: AnthropicLike, + params: Anthropic.MessageStreamParams, +): Promise<{ message: Anthropic.Message; costHeaderUsd: number | null }> { + // The @blockrun/llm proxy wraps every messages.* call in an async function, + // so the MessageStream arrives behind a promise; the SDK returns it directly. + const stream = await client.messages.stream(params, { maxRetries: 0 }); + let accepted = false; + stream.on("connect", () => { accepted = true; }); + + // Idle guard, reset on every event. The SDK has no per-event deadline of its + // own — its request timeout ends at the headers — and the fetch underneath + // clears its abort timer at the same point. + let idleTimer: NodeJS.Timeout | undefined; + let stalled: (err: Error) => void = () => undefined; + const stall = new Promise((_, reject) => { stalled = reject; }); + const armIdle = () => { + clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + stalled(new AcceptedThenFailedError(`stream stalled: no data from the gateway for ${Math.round(NATIVE_IDLE_TIMEOUT_MS / 1000)}s`)); + stream.abort(); + }, NATIVE_IDLE_TIMEOUT_MS); + }; + stream.on("streamEvent", armIdle); + armIdle(); + try { + const message = await Promise.race([stream.finalMessage(), stall]); + return { message, costHeaderUsd: settledCostFromHeaders(stream.response?.headers) }; + } catch (error) { + if (error instanceof AcceptedThenFailedError) throw error; + if (accepted) { + throw new AcceptedThenFailedError(error instanceof Error ? error.message : String(error), "", { cause: error }); + } + throw error; + } finally { + clearTimeout(idleTimer); + } +} + +/** + * The note for a native call that cost money — or may have — and then failed. + * Same voice as chat.ts's settledThenFailedText; the two paths book the same + * way and must read the same way to the agent acting on them. + */ +function nativeFailedText(error: unknown, usd: number, certainty: "settled" | "unknown"): string { + const amount = `$${usd.toFixed(6)}`; + const what = isApiKeyMode() + ? certainty === "settled" + ? `Note: the gateway had accepted this request (HTTP 200) before it failed, so it is billed to your BlockRun account at exact usage — ` + + `an estimated ~${amount} has been recorded against your budget; https://user.blockrun.ai/dashboard/activity has the exact figure.` + : `Note: this request MAY have been billed to your BlockRun account — no response was observed, so this process cannot tell. ` + + `An estimated ~${amount} has been recorded against your budget as a precaution; https://user.blockrun.ai/dashboard/activity has the truth.` + : certainty === "settled" + ? `Note: payment had already settled when this failed, so the charge stands (~${amount}, the reconstructed quote) and it has been recorded against your budget.` + : `Note: the payment for this call had been signed and sent before it failed, and this process cannot tell whether the gateway settled it — ` + + `it may have settled after the connection dropped. The reconstructed quote (${amount}) has been recorded against your budget as a precaution.`; + return `${formatError(extractErrorMessage(error))}\n\n${what} Retrying will incur a second charge — check blockrun_wallet action:"report" first.`; +} + type TextPart = { type: "text"; text: string }; type ImagePart = { type: "image_url"; image_url: { url: string } }; type ContentPart = TextPart | ImagePart; @@ -264,7 +400,7 @@ export async function handleAnthropicNative(args: AnthropicNativeArgs): Promise< ? m.content.length : JSON.stringify(m.content ?? "").length), 0); - const params: Anthropic.MessageCreateParamsNonStreaming = { + const params: Anthropic.MessageStreamParams = { model, max_tokens: effectiveMax, messages: apiMessages, @@ -280,23 +416,52 @@ export async function handleAnthropicNative(args: AnthropicNativeArgs): Promise< params.temperature = Math.max(0, Math.min(1, temperature)); } + // What a failure after the money moved is booked at. The wallet rails settle + // the quote (anthropicCallCost); the account rail bills exact usage, which a + // failed call never reports, so its ledger figure is the model's rate over + // the prompt and the full max_tokens. Either falls back to the reserve when + // the model has no row. + const failedLedgerUsd = () => (isApiKeyMode() + ? anthropicAccountLedgerUsd(model, anthropicPromptChars, effectiveMax, null) + : anthropicCallCost(model, anthropicPromptChars, effectiveMax)) ?? estimatedCost; + let native: Anthropic.Message; + let costHeaderUsd: number | null; try { - native = await client.messages.create(params); + ({ message: native, costHeaderUsd } = await streamNativeMessage(client, params)); } catch (error) { - return { content: [{ type: "text", text: formatError(extractErrorMessage(error)) }], isError: true }; + // Until audit round 3 this returned formatError and booked nothing, on + // both rails — the settled-then-failed machinery the OpenAI-compat paths + // gained in 0.40.1/0.49.0/0.50.0 never reached here, so a 524 after the + // gateway settled read as "temporary API issue, try again" and the agent + // paid again (C20). Same classifier as those paths: a 4xx before the + // stream connected (the gateway's own refusal, or the SDK's) and a + // payment the wallet could not make are not money; a failure after the + // 2xx is; an origin that never answered may be. + const verdict = settlementOnThrow(error, { rail: isApiKeyMode() ? "account" : "wallet", estimateUsd: estimatedCost, transparentPayment: true }); + if (verdict === "none") { + return { content: [{ type: "text", text: formatError(extractErrorMessage(error)) }], isError: true }; + } + const usd = failedLedgerUsd(); + recordActualSpend(budget, usd, estimatedCost, agentId); + return { content: [{ type: "text", text: nativeFailedText(error, usd, verdict) }], isError: true }; } - // Book what the gateway actually charged (the quote it settled), not the flat - // estimate and not a token reconstruction at Anthropic's list prices. + // Book what the gateway actually charged: on the wallet rails the quote it + // settled (anthropicCallCost) — not the flat estimate and not a token + // reconstruction at Anthropic's list prices; on the account rail the + // response's settled cost when it carried one, else exact usage at the + // model's rate (anthropicAccountLedgerUsd), labelled as the estimate it is. // effectiveMax is the max_tokens the request was sent with — including the // auto-raise for a thinking budget, which is what the quote was priced on. - recordActualSpend( - budget, - anthropicCallCost(native.model, anthropicPromptChars, effectiveMax), - estimatedCost, - agentId, - ); + const bookedUsd = isApiKeyMode() + ? (costHeaderUsd ?? anthropicAccountLedgerUsd(native.model, anthropicPromptChars, effectiveMax, native.usage)) + : anthropicCallCost(native.model, anthropicPromptChars, effectiveMax); + const costIsEstimate = isApiKeyMode() && costHeaderUsd === null; + recordActualSpend(budget, bookedUsd, estimatedCost, agentId); + const costLine = costIsEstimate && bookedUsd !== null && bookedUsd > 0 + ? `\n\n(Cost: ~$${bookedUsd.toFixed(4)}, estimated — billed to your BlockRun account at exact usage; https://user.blockrun.ai/dashboard/activity has the figure.)` + : ""; const thinkingBlocks = native.content.filter(isThinkingBlock); const textBlocks = native.content.filter(isTextBlock); @@ -310,7 +475,14 @@ export async function handleAnthropicNative(args: AnthropicNativeArgs): Promise< if (raisedMaxTokens) headerBits.push(`max_tokens→${effectiveMax}`); const header = `[${headerBits.join(" | ")}]`; - const content: { type: "text"; text: string }[] = [{ type: "text", text: `${header}\n\n${answerText}` }]; + // stop_reason "max_tokens" is the native spelling of a reply cut short; + // surfaced in the text as the compat paths do, not only in structuredContent. + const truncated = native.stop_reason === "max_tokens" + ? `\n\n⚠️ TRUNCATED OUTPUT: the reply hit max_tokens=${effectiveMax} and stopped mid-way (stop_reason "max_tokens"). ` + + `Raise max_tokens to get the rest — thinking tokens count against it too.` + : ""; + + const content: { type: "text"; text: string }[] = [{ type: "text", text: `${header}\n\n${answerText}${truncated}${costLine}` }]; if (thinkingText) { content.push({ type: "text", text: `🧠 Thinking (signature ${signaturePresent ? "present" : "absent"}):\n${thinkingText}` }); } @@ -328,7 +500,10 @@ export async function handleAnthropicNative(args: AnthropicNativeArgs): Promise< thinking_blocks: thinkingBlocks, signature_present: signaturePresent, stop_reason: native.stop_reason, + ...(native.stop_reason === "max_tokens" ? { truncated_output: true } : {}), usage: native.usage, + cost_usd: bookedUsd ?? estimatedCost, + cost_is_estimate: costIsEstimate || bookedUsd === null, native, }, }; diff --git a/src/tools/chat.ts b/src/tools/chat.ts index c78e0a2..5035ba0 100644 --- a/src/tools/chat.ts +++ b/src/tools/chat.ts @@ -4,7 +4,14 @@ import { TOOL_ANNOTATIONS } from "../tool-annotations.js"; import { z } from "zod"; import { buildClient, buildClientWithTimeout, getAnthropicClient, baseOnlyMessage } from "../utils/wallet.js"; import { isApiKeyMode } from "../utils/auth.js"; -import { streamChatText, supportsStreaming, type StreamChatMessage } from "../utils/chat-stream.js"; +import { + completeChat, + settlementOnThrow, + AcceptedThenFailedError, + type ChatOutcome, + type ChatUsage, + type StreamChatMessage, +} from "../utils/chat-stream.js"; import { handleAnthropicNative, isAnthropicModel } from "./chat-anthropic.js"; import { extractErrorMessage, formatError } from "../utils/errors.js"; import { @@ -18,6 +25,7 @@ import { canonicalChatModel, TIER_WORST_PRICE, GATEWAY_CHARS_PER_TOKEN, + GATEWAY_CHARS_PER_TOKEN_OBSERVED, type RoutingMode, } from "../utils/constants.js"; import { reserveBudget, recordActualSpend } from "../utils/budget.js"; @@ -187,69 +195,154 @@ export function estimateChatCost( } /** - * Settled cost of the LLMClient call that ran inside `run`, measured as the - * delta of the client's own cumulative spend counter (getSpending().totalUsd), - * which the SDK increments with the REAL on-chain amount per call. Returns the - * result plus the booked cost so callers can record actual spend, falling back - * to the estimate when the delta is unavailable (0/NaN). + * What the ACCOUNT rail is booked at when the response carried no settled cost. * - * A THROW DOES NOT MEAN A REFUND. x402 settles when the gateway answers 200 — - * the SDK increments its counter at that moment, before the body is read — and - * every paid path here STREAMS, so the call can still fail afterwards: a - * mid-stream error event, an idle stall, an empty completion. Until 0.40.1 the - * delta was computed only after `run()` resolved, so on that path the USDC left - * the wallet, `budget.spent` never moved, and the `finally` released the - * reservation — the ledger recorded a free call. `onSettledThrow` is how the - * charge still gets booked; it fires only when the delta is real (> 0), so an - * ordinary pre-payment failure (400, timeout, refusal) still books nothing. + * api.blockrun.ai bills chat at exact usage, after the response is sent — which + * is why `x-blockrun-cost-usd` is absent on chat by design (api-key-call.ts). + * Until audit round 3 the three OpenAI-compat paths then booked the GATE + * reserve: the most expensive member of the tier, input at 2 chars/token, + * output at the full max_tokens, plus a $0.002 transaction fee this rail never + * charges — 3-6x the real charge on mode:"balanced", up to ~50x on "powerful" + * with a short reply, so BLOCKRUN_BUDGET_LIMIT tripped at a fraction of real + * spend and action:"report" overstated it (C33/D53). 0.50.0's ledgerFallback + * fixed the same reserve-as-ledger pattern for the path tools; this is chat's. * - * ON THE ACCOUNT RAIL THERE IS NO COUNTER TO READ. getSpending() does not return - * zero for an API-key client — it THROWS: + * The model's own rate — the SERVED model's when it has a row (the gateway + * aliases retired ids, and the bill follows what answered), else the requested + * one's; a free REQUEST is free whatever answered it (the $0 probes show every + * alias of a free id served without a payment header) — input and output at + * the token counts the stream reported when it did, else the prompt at the + * observed chars/token and the full max_tokens (the conservative side, for a + * failure that reported nothing). No fee, no floor: reconciled 2026-09-05, the + * account rail charges base × margin and nothing else. Still an estimate — the + * caller labels it as one. + */ +export function accountLedgerUsd( + requestedModel: string, + servedModel: string | null, + promptChars: number, + maxTokens: number, + usage: ChatUsage | null, +): number { + const requested = canonicalChatModel(requestedModel); + if (FREE_CHAT_MODELS.has(requested)) return 0; + const served = servedModel ? canonicalChatModel(servedModel) : null; + const rate = served && Object.hasOwn(CHAT_PRICE_PER_MTOKEN, served) ? CHAT_PRICE_PER_MTOKEN[served] + : Object.hasOwn(CHAT_PRICE_PER_MTOKEN, requested) ? CHAT_PRICE_PER_MTOKEN[requested] + : DEFAULT_CHAT_PRICE; + const inTokens = usage?.promptTokens ?? Math.ceil(promptChars / GATEWAY_CHARS_PER_TOKEN_OBSERVED); + const outTokens = usage?.completionTokens ?? maxTokens; + const usd = (inTokens / 1_000_000) * rate.input + (outTokens / 1_000_000) * rate.output; + // Whole micro-dollars, rounded up as the gateway bills; the epsilon keeps a + // binary-float 4500.0000000001 from ceiling to 4501. + return Math.ceil(usd * 1e6 - 1e-6) / 1e6; +} + +/** What a failed call cost, as far as this process can tell. */ +type FailedBooking = { + usd: number; + /** "settled": the charge is certain. "unknown": it may have happened; booked as a precaution. */ + certainty: "settled" | "unknown"; +}; + +/** + * Settled cost of the chat call that ran inside `run`. * - * "Account usage is available at https://user.blockrun.ai/dashboard; - * getSpending() tracks x402 settlements only." + * ON THE WALLET RAILS it is the delta of the client's own cumulative spend + * counter (getSpending().totalUsd), which the SDK increments with the REAL + * on-chain amount once the PAID response comes back OK. A THROW DOES NOT MEAN A + * REFUND: x402 settles on that 200, before the body is read, and the call can + * still fail afterwards — a mid-stream error event, an idle stall, an empty + * completion. Until 0.40.1 the delta was computed only after `run()` resolved, + * so the USDC left the wallet, `budget.spent` never moved, and the `finally` + * released the reservation — the ledger recorded a free call. `onSettledThrow` + * is how the charge still gets booked. * - * and it is called three times here, on the very first line, outside the try. - * Left alone, setting BLOCKRUN_API_KEY would not degrade blockrun_chat, it would - * break every single call before the request was even sent. So account mode - * skips the counter entirely and reports settledUsd 0, which every caller - * already handles as "delta unavailable — fall back to the estimate". + * The counter has a blind spot, and it is on the DEFAULT chain. The SDK counts + * only after the paid retry was OK — SolanaLLMClient runs assertPaid() before + * recordSettlement(), LLMClient throws "API error after payment" before its + * increment, and a fetch timeout on the paid retry (60s on Solana) throws with + * no increment at all. The payment had been signed and SENT in every one of + * those; the gateway settles them after the client is gone. Until audit round + * 3 that read as a $0 delta: nothing booked, "temporary API issue — try again", + * and the routing loop signed a second payment for the next model under the + * same reservation (C19). settlementOnThrow classifies those as "unknown": the + * reserve is booked as a precaution, the loop stops, the note says MAY. An + * unpaid first-response 4xx, a refused payment, or a 4xx on the paid retry (the + * gateway's own refusal, before settlement starts) still books nothing. * - * The estimate is genuinely all we have: the account API returns no per-call - * cost header, and its dashboard is cookie-authenticated, so there is nothing - * this process could read back. Callers label the number accordingly rather - * than printing an estimate that looks like a settlement. + * ON THE ACCOUNT RAIL THERE IS NO COUNTER TO READ — getSpending() THROWS for an + * API-key client. What there is instead: the SDK's account transport throws an + * APIError carrying the status of the FIRST response, before any body, for + * every refusal (400 unknown model, 401, 402 out of credit, 429), so none of + * those is billed; a failure after the 2xx (AcceptedThenFailedError) is a + * billed call at exact usage; an origin that never answered is "unknown". The + * 0.50.0 fix for "a billed-then-dropped stream booked $0" caught EVERY + * rejection here and booked the full reserve for it with "the charge stands" + * — five typo'd model ids exhausted a delegated cap at $0 real spend, a 402 + * out-of-credit read as billed, and mode:"free" died on its first timeout + * (C5/C18/C23/C31). On success the response may carry `x-blockrun-cost-usd`; + * when it does that is the entry (a settled zero included), and when it does + * not, accountLedgerUsd is — labelled as the estimate it is. */ -async function withSettledCost( +async function withSettledCost( client: ApiClient, + estimateUsd: number, + accountLedger: (outcome: T | null) => number, run: () => Promise, - onSettledThrow?: (settledUsd: number) => void, -): Promise<{ result: T; settledUsd: number }> { + onSettledThrow: (booking: FailedBooking) => void, +): Promise<{ result: T; settledUsd: number; costIsEstimate: boolean }> { if (isApiKeyMode()) { - return { result: await run(), settledUsd: 0 }; + try { + const result = await run(); + return result.settledUsd === null + ? { result, settledUsd: accountLedger(result), costIsEstimate: true } + : { result, settledUsd: result.settledUsd, costIsEstimate: false }; + } catch (error) { + const verdict = settlementOnThrow(error, { rail: "account", estimateUsd }); + if (verdict !== "none") onSettledThrow({ usd: accountLedger(null), certainty: verdict }); + throw error; + } } const before = client.getSpending().totalUsd; + const delta = () => { + const d = client.getSpending().totalUsd - before; + return Number.isFinite(d) && d >= 0 ? d : null; + }; try { const result = await run(); - return { result, settledUsd: client.getSpending().totalUsd - before }; + // null = the counter could not be read: book the reserve rather than $0. + const d = delta(); + return { result, settledUsd: d ?? estimateUsd, costIsEstimate: d === null }; } catch (error) { - const settledUsd = client.getSpending().totalUsd - before; - if (Number.isFinite(settledUsd) && settledUsd > 0) onSettledThrow?.(settledUsd); + const d = delta(); + if (d !== null && d > 0) { + onSettledThrow({ usd: d, certainty: "settled" }); + } else if (settlementOnThrow(error, { rail: "wallet", estimateUsd }) === "unknown") { + onSettledThrow({ usd: estimateUsd, certainty: "unknown" }); + } + // A counter that says $0 after a 2xx is a call the gateway served without + // charging (its free fallback); nothing to book. throw error; } } /** - * The error text for a call that SETTLED and then failed. + * The error text for a call that cost money — or may have — and then failed. * * x402 settles on the 200, before the body is read, and every paid path streams, * so a stall or an in-band error event arrives with the money already gone. * withSettledCost books it (onSettledThrow); this is the sentence that tells the * CALLER. Without it the text was "Error: stream stalled: no data from the * gateway for 120s" — indistinguishable from a free failure, so the obvious next - * step (retry) settled a second payment. The routing loop has said this since - * 0.40.1; the explicit-model and multi-turn paths, which by construction fail - * only after settlement, never did. + * step (retry) settled a second payment. + * + * The wording tracks the certainty, because the text is what an agent acts on. + * "The charge stands" is said only when it is known to (a counter delta, or an + * account request the gateway accepted with a 2xx). When the payment was sent + * and no verdict came back, the note says MAY, names the booked reserve as a + * precaution, and points at action:"report" — it does not assert a charge it + * cannot see, and it does not invite a retry that would pay again. * * formatError runs on the BARE error and the note is appended afterwards, on * purpose: formatError classifies on keywords, and this note contains the word @@ -257,14 +350,74 @@ async function withSettledCost( * text, the routing loop's version ended in "your wallet needs funding" — the * exact wrong advice for a call that just paid. */ -function settledThenFailedText(error: unknown, settledUsd: number, 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, booking: FailedBooking, tail: string): string { + const usd = `$${booking.usd.toFixed(6)}`; + let what: string; + if (isApiKeyMode()) { + what = booking.certainty === "settled" + ? `Note: the gateway had accepted this request (HTTP 200) before it failed, so it is billed to your BlockRun account at exact usage — ` + + `an estimated ~${usd} has been recorded against your budget; https://user.blockrun.ai/dashboard/activity has the exact figure.` + : `Note: this request MAY have been billed to your BlockRun account — no response was observed, so this process cannot tell. ` + + `An estimated ~${usd} has been recorded against your budget as a precaution; https://user.blockrun.ai/dashboard/activity has the truth.`; + } else { + what = booking.certainty === "settled" + ? `Note: payment had already settled when this failed, so the charge stands (${usd}) and it has been recorded against your budget.` + : `Note: the payment for this call had been signed and sent before it failed, and this process cannot tell whether the gateway settled it — ` + + `it may have settled after the connection dropped. The reserved ${usd} has been recorded against your budget as a precaution.`; + } + const partial = error instanceof AcceptedThenFailedError && error.partialText + ? `\n\nPartial response received before the failure (${error.partialText.length.toLocaleString("en-US")} chars):\n${error.partialText}` + : ""; + return `${formatError(extractErrorMessage(error))}\n\n${what} ${tail}${partial}`; } const RETRY_CHARGES_AGAIN = 'Retrying will incur a second charge — check blockrun_wallet action:"report" first.'; +/** + * Notes appended to a SUCCESSFUL reply about what the gateway said of it. + * + * `served_model`: constants.ts documents that the gateway aliases retired ids + * onto a live model instead of 404ing, and that "only the response's `model` + * field" tells you. The text and structuredContent used to echo the REQUESTED + * id, so an agent asking a stale id for "Kimi's opinion" presented another + * model's answer as Kimi's, paid for a model nobody chose (D55). The $0 probe + * on 2026-09-13 showed eight of eleven free[] ids answering as another model. + * + * `truncated_output`: finish_reason "length" is the model stopping at + * max_tokens mid-sentence (or mid-JSON, with response_format json_object). It + * was read and dropped, so a cut reply came back looking complete — the silent + * truncation shape the free-tier prompt note exists to make loud (D57). + */ +function servedNotes(requested: string, outcome: ChatOutcome, maxTokens: number | undefined): { text: string; fields: Record } { + const fields: Record = {}; + let text = ""; + const served = outcome.servedModel; + if (served) fields.served_model = served; + if (served && canonicalChatModel(served) !== canonicalChatModel(requested)) { + text += `\n\n(Served by ${served} — the gateway answered the requested id ${requested} with this model instead: retired, aliased, or at capacity.)`; + } + if (outcome.finishReason) fields.finish_reason = outcome.finishReason; + if (outcome.finishReason === "length" && outcome.text) { + fields.truncated_output = true; + text += `\n\n⚠️ TRUNCATED OUTPUT: the reply hit max_tokens=${maxTokens ?? 1024} and stopped mid-way (finish_reason "length"). ` + + `Raise max_tokens to get the rest — reasoning tokens count against it too.`; + } + return { text, fields }; +} + +/** The cost line for a reply, and its structuredContent fields. */ +function costNotes(settledUsd: number, costIsEstimate: boolean): { text: string; fields: Record } { + const fields = { cost_usd: settledUsd, cost_is_estimate: costIsEstimate }; + // Only an ESTIMATE is worth a line in the reply: a settled wallet delta is + // already in action:"report", and the account rail's exact figure lives on + // the dashboard. Saying "~" is what keeps the CHANGELOG's promise that an + // estimated figure never looks like a settlement. + if (!costIsEstimate || !(settledUsd > 0)) return { text: "", fields }; + return { + text: `\n\n(Cost: ~$${settledUsd.toFixed(4)}, estimated${isApiKeyMode() ? " — billed to your BlockRun account at exact usage; https://user.blockrun.ai/dashboard/activity has the figure" : ""}.)`, + fields, + }; +} + export function registerChatTool(server: McpServer, budget: BudgetState): void { server.registerTool( "blockrun_chat", @@ -348,20 +501,36 @@ Run blockrun_models to see all available models with pricing.`, const confirm = await confirmSpend(server, { usd: estimatedCost, label: `chat · ${model ?? mode ?? "auto"}` }); if (!confirm.ok) return { content: [{ type: "text", text: confirm.reason ?? "Charge cancelled." }] }; - // Native Anthropic passthrough (EVM/Base only). + // Native Anthropic passthrough (Base wallet and the account rail). // An explicit anthropic/claude-* model goes DIRECT to the gateway's // /v1/messages endpoint, which forwards to api.anthropic.com VERBATIM: // zero model substitution, no cost routing, no fallback, and the real // native response — type:"thinking" blocks with their original signature. // This takes priority over mode/routing precisely because the requirement // is "claude-* must be verbatim, never routed". The OpenAI-compat paths - // below cannot carry thinking signatures, so claude never falls through. + // below cannot carry thinking signatures. + // + // ON SOLANA the AnthropicClient cannot pay (it signs EVM x402 only), and + // until audit round 3 every explicit claude-* id was refused there with + // "switch to Base" — while mode:"powerful"/"reasoning"/"coding" sent the + // very same ids through /v1/chat/completions on sol.blockrun.ai one + // branch lower, and a fresh install's Base wallet is empty. Verified + // 2026-09-13 with an unpaid POST: sol.blockrun.ai quotes claude-opus-5 + // and claude-sonnet-5 on chat/completions (402, "Claude Opus 5 API + // call"), so the route exists. The refusal now applies only when + // `thinking` is requested — the one thing the compat path cannot carry — + // and a plain claude-* call on Solana takes the explicit-model path + // below like any other id (D5). Its price there is the Solana quote: + // output at full max_tokens, not Base's 0.1x — see D52 in the audit — + // which the reserve already covers. if (model && isAnthropicModel(model)) { - const solanaBlock = baseOnlyMessage("Native Anthropic (claude-*) calls"); - if (solanaBlock) { + // Non-null only on a Solana wallet (null in API-key mode and on Base). + const solanaBlock = baseOnlyMessage("Native Anthropic (claude-*) calls with `thinking`"); + if (solanaBlock && thinking) { return { content: [{ type: "text", text: solanaBlock }], isError: true }; } - return await handleAnthropicNative({ + // Solana without `thinking` falls through to the explicit-model path. + if (!solanaBlock) return await handleAnthropicNative({ client: getAnthropicClient(), model, message, @@ -388,6 +557,63 @@ Run blockrun_models to see all available models with pricing.`, // Callers wanting a cheap model should pass mode:"cheap"/"glm" or an explicit // model — both resolve here, with no router. + // One paid attempt, whichever path asked for it: run it, book what it + // cost, and hand back the outcome with the notes a reply carries. + // + // Paid calls STREAM and assemble (see utils/chat-stream.ts): a slow + // reasoning model generating for minutes over a non-streaming request + // moves zero bytes, and the edge in front of the gateway 524s the idle + // connection AFTER the x402 payment settled — charged, no reply (observed + // live with moonshot/kimi-k3, 2026-07-21). That includes Solana since + // audit round 3: the SDK's stream() pays and records the settlement + // before the first frame, where the old non-streaming path aborted at + // the Solana client's 60s default with the payment already sent. + // + // The SDK types ChatMessage.content as string-only, but the gateway + // forwards `messages` verbatim and accepts image_url content arrays for + // vision-capable models — so a multimodal array is runtime-valid. + // (claude-* with history is already handled by the native branch above.) + const attempt = async ( + client: ApiClient, + targetModel: string, + fullMessages: StreamChatMessage[], + stream: boolean, + onFailedBooking: (booking: FailedBooking) => void, + ) => { + const { result, settledUsd, costIsEstimate } = await withSettledCost( + client, + estimatedCost, + (outcome) => accountLedgerUsd(targetModel, outcome?.servedModel ?? null, promptChars, max_tokens ?? 1024, outcome?.usage ?? null), + () => completeChat(client, targetModel, fullMessages, { maxTokens: max_tokens, temperature, responseFormat, stop }, { stream }), + (booking) => { + recordActualSpend(budget, booking.usd, estimatedCost, agent_id); + onFailedBooking(booking); + }, + ); + recordActualSpend(budget, settledUsd, estimatedCost, agent_id); + const served = servedNotes(targetModel, result, max_tokens); + const cost = costNotes(settledUsd, costIsEstimate); + const prompt = freeTierTruncationNote(promptChars, result.servedModel ?? targetModel); + return { + reply: result.text, + notes: `${served.text}${cost.text}${prompt ?? ""}`, + fields: { ...served.fields, ...cost.fields, ...(prompt ? { truncated: true } : {}) }, + }; + }; + const failedText = (error: unknown, booking: FailedBooking | null, tail: string) => { + const partial = error instanceof AcceptedThenFailedError && error.partialText ? error.partialText : ""; + // Nothing booked (a free model, or a served-free call that died): the + // partial text is still the caller's, so it still rides along. + const text = booking + ? settledThenFailedText(error, booking, tail) + : `${formatError(extractErrorMessage(error))}${partial ? `\n\nPartial response received before the failure (${partial.length.toLocaleString("en-US")} chars):\n${partial}` : ""}`; + return { + content: [{ type: "text" as const, text }], + ...(partial ? { structuredContent: { partial_response: partial } } : {}), + isError: true as const, + }; + }; + // Multi-turn conversation if (messages && messages.length > 0) { const targetModel = model || MODEL_TIERS[(mode ?? "balanced") as RoutingMode]?.[0] || "openai/gpt-5.6-terra"; @@ -395,97 +621,34 @@ Run blockrun_models to see all available models with pricing.`, ...(system ? [{ role: "system" as const, content: system }] : []), ...messages, { role: "user" as const, content: message }, - ]; - // USDC that left the wallet before the failure, if any (see settledThenFailedText). - let settledOnFailure = 0; + ] as StreamChatMessage[]; + // What a failed attempt cost, if anything (see settledThenFailedText). + let failedBooking: FailedBooking | null = null; try { - // The SDK types ChatMessage.content as string-only, but the gateway - // forwards `messages` verbatim and accepts image_url content arrays - // for vision-capable models — so a multimodal array is runtime-valid. - // (claude-* with history is already handled by the native branch above.) - // - // Paid calls STREAM and assemble (see utils/chat-stream.ts): a slow - // reasoning model generating for minutes over a non-streaming request - // moves zero bytes, and the edge in front of the gateway 524s the idle - // connection AFTER the x402 payment settled — charged, no reply - // (observed live with moonshot/kimi-k3, 2026-07-21). Solana clients - // have no streaming API and keep the old path. - const { result: reply, settledUsd } = await withSettledCost(llm(), async () => { - const client = llm(); - if (supportsStreaming(client)) { - return streamChatText(client, targetModel, fullMessages as unknown as StreamChatMessage[], { - maxTokens: max_tokens, - temperature, - responseFormat, - stop, - }); - } - const r = await client.chatCompletion(targetModel, fullMessages as unknown as Parameters["chatCompletion"]>[1], { - maxTokens: max_tokens, - temperature, - responseFormat, - stop, - }); - return r.choices?.[0]?.message?.content || ""; - }, (usd) => { - recordActualSpend(budget, usd, estimatedCost, agent_id); - settledOnFailure = usd; - }); - recordActualSpend(budget, settledUsd, estimatedCost, agent_id); - const note = freeTierTruncationNote(promptChars, targetModel); + const { reply, notes, fields } = await attempt(llm(), targetModel, fullMessages, true, (b) => { failedBooking = b; }); return { - content: [{ type: "text", text: `[${targetModel} | ${fullMessages.length} msgs]\n\n${reply}${note ?? ""}` }], - structuredContent: { model_used: targetModel, response: reply, message_count: fullMessages.length, ...(note ? { truncated: true } : {}) }, + content: [{ type: "text", text: `[${targetModel} | ${fullMessages.length} msgs]\n\n${reply}${notes}` }], + structuredContent: { model_used: targetModel, response: reply, message_count: fullMessages.length, ...fields }, }; } catch (error) { - return { - content: [{ - type: "text", - text: settledOnFailure > 0 - ? settledThenFailedText(error, settledOnFailure, RETRY_CHARGES_AGAIN) - : formatError(extractErrorMessage(error)), - }], - isError: true, - }; + return failedText(error, failedBooking, RETRY_CHARGES_AGAIN); } } - // If specific model provided, use it directly — streamed when the client - // supports it (same 524 rationale as the multi-turn path above). + // If specific model provided, use it directly. if (model) { - let settledOnFailure = 0; + let failedBooking: FailedBooking | null = null; try { - const { result: response, settledUsd } = await withSettledCost(llm(), async () => { - const client = llm(); - if (supportsStreaming(client)) { - return streamChatText(client, model, [ - ...(system ? [{ role: "system" as const, content: system }] : []), - { role: "user" as const, content: message }, - ], { maxTokens: max_tokens, temperature, responseFormat, stop }); - } - return client.chat(model, message, { - system, - maxTokens: max_tokens, - temperature, - responseFormat, - stop, - }); - }, (usd) => { - recordActualSpend(budget, usd, estimatedCost, agent_id); - settledOnFailure = usd; - }); - recordActualSpend(budget, settledUsd, estimatedCost, agent_id); - return { content: [{ type: "text", text: `${response}${freeTierTruncationNote(promptChars, model) ?? ""}` }] }; - } catch (error) { + const { reply, notes, fields } = await attempt(llm(), model, [ + ...(system ? [{ role: "system" as const, content: system }] : []), + { role: "user" as const, content: message }, + ], true, (b) => { failedBooking = b; }); return { - content: [{ - type: "text", - text: settledOnFailure > 0 - ? settledThenFailedText(error, settledOnFailure, RETRY_CHARGES_AGAIN) - : formatError(extractErrorMessage(error)), - }], - isError: true, + content: [{ type: "text", text: `${reply}${notes}` }], + structuredContent: { model_used: model, response: reply, ...fields }, }; + } catch (error) { + return failedText(error, failedBooking, RETRY_CHARGES_AGAIN); } } @@ -495,7 +658,7 @@ Run blockrun_models to see all available models with pricing.`, // Only the free tier gets a deadline. Paid tiers are frontier/reasoning // models where a multi-minute completion is the job, not a fault; free - // models fail by crawling and there are seven of them to fall through. + // models fail by crawling and there are several of them to fall through. // See FREE_MODEL_TIMEOUT_MS for the measurements behind the numbers. const freeClient = routingMode === "free" ? buildClientWithTimeout(FREE_MODEL_TIMEOUT_MS) : null; const routingClient = freeClient ?? llm(); @@ -503,8 +666,8 @@ Run blockrun_models to see all available models with pricing.`, let lastError: unknown = null; let deadlineHit = false; - // USDC that already left the wallet on a failed attempt in this loop. - let settledOnFailure = 0; + // What a failed attempt in this loop already cost — or may have. + let failedBooking: FailedBooking | null = null; for (const m of models) { // Stop starting NEW attempts once the loop has burned its whole budget — // otherwise the bound would be per-model only and would grow with the list. @@ -516,59 +679,37 @@ Run blockrun_models to see all available models with pricing.`, // Paid tiers stream (frontier primaries can generate for minutes — // same 524 class as the explicit-model path). The free tier stays on // the non-streaming client whose short timeout the deadline loop - // depends on to fail fast through its seven candidates. - const { result: response, settledUsd } = await withSettledCost(routingClient, async () => { - if (!freeClient && supportsStreaming(routingClient)) { - return streamChatText(routingClient, m, [ - ...(system ? [{ role: "system" as const, content: system }] : []), - { role: "user" as const, content: message }, - ], { maxTokens: max_tokens, temperature, responseFormat, stop }); - } - return routingClient.chat(m, message, { - system, - maxTokens: max_tokens, - temperature, - responseFormat, - stop, - }); - }, (usd) => { - // Settled, then failed. Book it and remember that this tool call has - // already cost the caller money — see the break below. - recordActualSpend(budget, usd, estimatedCost, agent_id); - settledOnFailure = usd; - }); - recordActualSpend(budget, settledUsd, estimatedCost, agent_id); - const note = freeTierTruncationNote(promptChars, m); + // depends on to fail fast through its candidates. + const { reply, notes, fields } = await attempt(routingClient, m, [ + ...(system ? [{ role: "system" as const, content: system }] : []), + { role: "user" as const, content: message }, + ], !freeClient, (b) => { failedBooking = b; }); return { - content: [{ type: "text", text: `[${m}]\n\n${response}${note ?? ""}` }], - structuredContent: { model_used: m, response, ...(note ? { truncated: true } : {}) }, + content: [{ type: "text", text: `[${m}]\n\n${reply}${notes}` }], + structuredContent: { model_used: m, response: reply, ...fields }, }; } catch (error) { lastError = error; // ONE RESERVATION MEANS ONE SETTLEMENT. The fallback loop exists for - // models that refuse before taking payment (400, refusal, timeout) — - // there, trying the next model costs nothing and is the whole point. - // But a model that settled and THEN failed has already charged the - // caller, and continuing would settle a second payment for the same - // tool call under the same reserved amount, unbounded by the gate. - // Free models settle $0, so mode:"free" still falls through as designed. - if (settledOnFailure > 0) break; + // models that refuse before taking payment (400, refusal, an unpaid + // timeout) — there, trying the next model costs nothing and is the + // whole point. But a model that settled and THEN failed has already + // charged the caller, and one whose payment was sent and never + // answered MAY have — continuing would settle a second payment for + // the same tool call under the same reserved amount, unbounded by + // the gate. Free models reserve $0, so mode:"free" always falls + // through, on every rail. + if (failedBooking) break; continue; } } - // Say it plainly: the payment settled before the failure, so the charge - // stands and no fallback was attempted. An agent that reads "failed" as - // "free" would retry in a loop and pay each time. (Free models settle $0, - // so the deadline case below can never also be a settled one.) - if (settledOnFailure > 0) { - return { - content: [{ - type: "text", - text: settledThenFailedText(lastError, settledOnFailure, "No fallback model was tried — retrying will incur a second charge."), - }], - isError: true, - }; + // Say it plainly: the payment settled (or may have) before the failure, + // so no fallback was attempted. An agent that reads "failed" as "free" + // would retry in a loop and pay each time. (Free models reserve $0, so + // the deadline case below can never also be a booked one.) + if (failedBooking) { + return failedText(lastError, failedBooking, "No fallback model was tried — retrying will incur a second charge."); } // Distinguish "every model rejected" from "we ran out of time" — they need // different things from the caller (retry vs. pick a paid model), and a bare diff --git a/src/tools/defi.ts b/src/tools/defi.ts index dca4415..282f334 100644 --- a/src/tools/defi.ts +++ b/src/tools/defi.ts @@ -12,8 +12,9 @@ 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 { formatError, extractErrorMessage } from "../utils/errors.js"; +import { ledgerFallback, rawGet, type RawClient } from "../utils/raw-call.js"; +import { formatError } from "../utils/errors.js"; +import { pathToolFailure } from "../utils/path-tool-catch.js"; import { hasPathTraversal } from "../utils/path-safety.js"; import type { BudgetState } from "../types.js"; @@ -52,6 +53,9 @@ Use blockrun_price (free) for plain spot quotes, blockrun_dex (free) for DEX pai }, }, async ({ path, agent_id }) => { + // The reserve of the paid request in flight, for the catch: 0 until the + // line before rawGet, so nothing thrown earlier can book a charge. + let sentUsd = 0; try { // sol.blockrun.ai does not serve /v1/defillama/* at all — it 404s, which // reaches the agent as a bare "Not Found" with nothing to act on. Probed @@ -84,8 +88,9 @@ Use blockrun_price (free) for plain spot quotes, blockrun_dex (free) for DEX pai const confirm = await confirmSpend(server, { usd: estimatedCost, label: `defi · ${cleanPath}` }); if (!confirm.ok) return { content: [{ type: "text", text: confirm.reason ?? "Charge cancelled." }] }; const client = getClient() as unknown as RawClient; + sentUsd = estimatedCost; 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) @@ -96,10 +101,13 @@ Use blockrun_price (free) for plain spot quotes, blockrun_dex (free) for DEX pai gate.release(); } } catch (err) { - return { - content: [{ type: "text", text: formatError(extractErrorMessage(err)) }], - isError: true, - }; + // Books the reserve when the payment went out and no origin answer came + // back (utils/path-tool-catch.ts). The gateway's defillama route, like + // exa's, answers an upstream 5xx with "Payment was NOT charged" WITHOUT + // releasing the payment nonce, so on Base the SDK's same-header retry is + // refused as a replay and surfaces as "Payment was rejected. Check your + // wallet balance." — replayUpstream adds the second reading on Base only. + return pathToolFailure(err, { budget, agentId: agent_id, sentUsd, replayUpstream: "DefiLlama" }); } } ); diff --git a/src/tools/exa.ts b/src/tools/exa.ts index 43b422d..2166ed6 100644 --- a/src/tools/exa.ts +++ b/src/tools/exa.ts @@ -12,8 +12,9 @@ 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 { formatError, extractErrorMessage } from "../utils/errors.js"; +import { ledgerFallback, rawPost, type RawClient } from "../utils/raw-call.js"; +import { formatError } from "../utils/errors.js"; +import { pathToolFailure } from "../utils/path-tool-catch.js"; import { hasPathTraversal, normalizeClassifyPath } from "../utils/path-safety.js"; import type { BudgetState } from "../types.js"; @@ -61,6 +62,9 @@ Full request/response shapes + worked research workflows in the \`exa-research\` }, }, async ({ path, body, agent_id }) => { + // The reserve of the paid request in flight, for the catch: 0 until the + // line before rawPost, so nothing thrown earlier can book a charge. + let sentUsd = 0; try { body = coerceBody(body); const cleanPath = path.replace(/^\/+/, "").replace(/^v1\/exa\//, ""); @@ -83,8 +87,9 @@ Full request/response shapes + worked research workflows in the \`exa-research\` if (!confirm.ok) return { content: [{ type: "text", text: confirm.reason ?? "Charge cancelled." }] }; const client = getClient() as unknown as RawClient; const endpoint = `/v1/exa/${cleanPath}`; + sentUsd = estimatedCost; 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), @@ -93,7 +98,14 @@ Full request/response shapes + worked research workflows in the \`exa-research\` gate.release(); } } catch (err) { - return { content: [{ type: "text", text: formatError(extractErrorMessage(err)) }], isError: true }; + // Books the reserve when the payment went out and no origin answer came + // back (utils/path-tool-catch.ts). replayUpstream: the gateway's exa + // route answers an Exa 5xx with "Payment was NOT charged" WITHOUT + // releasing the payment nonce, so on Base the SDK's same-header retry + // is refused as a replay and surfaces as the SDK's "Payment was + // rejected. Check your wallet balance." — the hedge in utils/errors.ts + // adds that second reading on Base only. + return pathToolFailure(err, { budget, agentId: agent_id, sentUsd, replayUpstream: "Exa" }); } } ); diff --git a/src/tools/image.ts b/src/tools/image.ts index 8fe58b9..abe639d 100644 --- a/src/tools/image.ts +++ b/src/tools/image.ts @@ -3,14 +3,16 @@ 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 { BudgetExceededError, assertQuoteNearEstimate, reReserveIfHigher, recordActualSpend, recordSpending, reserveBudget } from "../utils/budget.js"; +import { BudgetExceededError, assertQuoteNearEstimate, reReserveIfHigher, recordActualSpend, reserveBudget } from "../utils/budget.js"; import { withTxFee } from "../utils/tx-fee.js"; import { formatError } from "../utils/errors.js"; import { launchTopUp } from "../utils/onramp.js"; +import { sendPaid, settleGiveUp, trackPaidRequest } from "../utils/in-flight.js"; import type { BudgetState } from "../types.js"; import { getChain, getImageClient } from "../utils/wallet.js"; import { isApiKeyMode } from "../utils/auth.js"; -import { apiKeyPost } from "../utils/api-key-call.js"; +import { apiKeyAsyncPost, BilledJobError } from "../utils/api-key-call.js"; +import { ledgerFallback } from "../utils/raw-call.js"; import { solanaPaidPost } from "../utils/solana-402.js"; import { isBlockedFetchHostResolved } from "../utils/ssrf.js"; import { shouldInline, buildInlineImageBlock } from "../utils/inline-image.js"; @@ -230,6 +232,12 @@ function isLargerThanBase(model: string, size: string): boolean { // micro SHORT. So: do NOT use withTxFee() here (it rounds), and do not // pre-round the buffer. Same shape as the gateway's // usdToMicroUsdc(addTransactionFee(price)). +// +// This is the RESERVE. The gateway's fee has since dropped to $0.001 (the +// same 402 probe quoted cogview-4 at $0.016750 on 2026-09-13) and has +// flip-flopped before, so the gate keeps reserving the higher figure on +// purpose (utils/tx-fee.ts) and the Base ledger books ledgerFallback() of it — +// the two are different numbers by design; see the Base branch below. const IMAGE_QUOTE_BUFFER = 1.05; const IMAGE_TX_FEE_USD = 0.002; @@ -247,6 +255,15 @@ export function estimateCost(model: string, size: string): number { // paid request's timeout must cover the whole render — not just a round-trip. const SOLANA_IMAGE_TIMEOUT_MS = 300_000; +// The account rail answers inline when the render fits its 30s window and +// otherwise 202 + poll_url (the vendored gateway route; "the account is +// charged on completion"). gpt-image-2 — this tool's default — routinely takes +// longer, and every edit does. The same total as the Solana render timeout, +// in 5s polls that the account helper clamps to what is left of it. +const ACCOUNT_IMAGE_POLL_BUDGET_MS = SOLANA_IMAGE_TIMEOUT_MS; +const ACCOUNT_IMAGE_POLL_INTERVAL_MS = 5_000; +const ACCOUNT_IMAGE_POLL_TIMEOUT_MS = 60_000; + /** * The image2image routes (both gateways) ship the provider's output verbatim — * google/nano-banana returns a multi-megabyte base64 data URI, not a hosted @@ -257,6 +274,9 @@ export async function materializeImageUrl(imageUrl: string): Promise { if (!imageUrl.startsWith("data:image/")) return imageUrl; const m = /^data:image\/([a-z0-9.+-]+);base64,(.+)$/is.exec(imageUrl); if (!m) return imageUrl; // undecodable — better to return the paid result verbatim than drop it + // The subtype is upstream output, but the capture above admits no `/` or + // `\`, so it can only ever be the last segment of a name INSIDE tmpdir — + // see test/image-materialize.test.ts, which pins that property. const ext = m[1].toLowerCase() === "jpeg" ? "jpg" : m[1].toLowerCase(); const file = join(tmpdir(), `blockrun-image-${Date.now()}-${randomBytes(4).toString("hex")}.${ext}`); await writeFile(file, Buffer.from(m[2], "base64")); @@ -264,14 +284,19 @@ export async function materializeImageUrl(imageUrl: string): Promise { } /** - * Endpoint + body for a Solana-gateway image call. The gateway's zod schema - * takes quality as low|medium|high|auto (the OpenAI latency knob), not this - * tool's standard|hd — map hd→high and drop standard (the gateway default) - * so the request isn't rejected with a 400. + * Endpoint + body for a gateway image call on the Solana and account rails. + * + * No `quality` key, ever. Since gateway commit 397e5d1c (live 2026-09-11) + * /v1/images/generations refuses ANY quality value for every model this tool + * lists — only the two gpt-image-2.5 ids accept one — and it refuses it + * BEFORE the 402, so the old standard|hd knob could only ever turn a paid call + * into a 400 (unpaid probes 2026-09-13: quality "standard", "hd" and "high" + * all 400 on Base and the account rail; the same body without it quotes). The + * parameter is gone from the schema for the same reason. */ export function buildSolanaImageRequest( action: "generate" | "edit", - params: { model: string; prompt: string; size: string; quality?: string; image?: string | string[]; mask?: string }, + params: { model: string; prompt: string; size: string; image?: string | string[]; mask?: string }, ): { endpoint: string; body: Record } { if (action === "edit") { return { @@ -293,7 +318,6 @@ export function buildSolanaImageRequest( prompt: params.prompt, size: params.size, n: 1, - ...(params.quality === "hd" ? { quality: "high" } : {}), }, }; } @@ -333,12 +357,26 @@ Source images and masks accept a base64 data URI, an http(s) URL, or a local fil .describe("Source image(s) for edit action: a base64 data URI, an http(s) URL, or a local file path (auto-encoded to a data URI) — or an array of 2–4 to fuse into one render (e.g. subject + layout guide, or reference + brand logo). openai/* accepts up to 4, google/* up to 3; a mask cannot be combined with multiple images."), mask: z.string().optional().describe("Inpaint mask for edit action (openai/gpt-image-* only): a base64 data URI, http(s) URL, or local file path. Transparent areas of the mask are regenerated. Cannot be combined with multiple source images."), size: z.string().optional().default("1024x1024").describe("Image size. Common values: 1024x1024 (all models), 1536x1024 / 1024x1536 (gpt-image-*), 2048x2048 / 4096x4096 (nano-banana-pro), 1280x720 / 2048x1024 / 2048x2048 / 2848x1600 (seedream-5-pro)"), - quality: z.enum(["standard", "hd"]).optional().default("standard"), + // There is deliberately NO `quality` parameter. The gateway refuses + // every quality value for every model listed here, before the 402 + // (see buildSolanaImageRequest), so the old standard|hd knob — and its + // zod default of "standard", which the Base SDK path forwarded + // verbatim — 400'd every Base generate. A caller that still sends one + // has it stripped by the schema and nothing here reads it. inline: z.boolean().optional().describe("Return a small inline image preview (thumbnail) the client can render in-conversation, in addition to the full-resolution URL. Defaults to the BLOCKRUN_INLINE_IMAGES env setting (off unless set). Rich clients (e.g. the VS Code extension) render it; plain terminals ignore it. Off keeps responses lightweight."), agent_id: z.string().optional().describe("Agent identifier for budget tracking and enforcement."), }, }, - async ({ prompt, action, model, image, mask, size, quality, inline, agent_id }) => { + async ({ prompt, action, model, image, mask, size, inline, agent_id }) => { + // Hoisted for the outer catch: a timeout after the payment was sent has + // to be booked, and the catch needs the reserve when no quote was seen. + let estimatedCostForCatch = 0; + // Armed only while a request carrying the payment is outstanding, on + // every rail — settled by a response, per call. 0.50.0's boolean was + // cleared in a `.finally` the catch never observed on the account rail, + // never set on Base, and set BEFORE the unpaid quote probe on Solana, so + // a 15s probe timeout booked a whole render (audit round 3). + const paid = trackPaidRequest(); try { const selectedModel = model || "openai/gpt-image-2"; @@ -408,6 +446,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 { @@ -434,15 +473,16 @@ Source images and masks accept a base64 data URI, an http(s) URL, or a local fil let imageUrl: string | undefined; // Actual USDC charged, surfaced in the result footer so the user always // sees the price without relying on the plugin's announce-cost skill. - // Base has no billed-amount in the SDK response, so the catalog estimate - // (which mirrors the live price table) is the best available figure; - // Solana returns the real 402-quoted amount. + // Base has no billed-amount in the SDK response, so the observed + // charge (ledgerFallback of the reserve — see the Base branch) is the + // best available figure; Solana returns the real 402-quoted amount + // and the account rail its settled cost header. let billedUsd = estimatedCost; // Whether `billedUsd` is still our own guess rather than a figure the - // rail settled. Seeded to the previous behaviour — the Base SDK rail - // reports the catalog price and has always been labelled exact — so - // this change only affects the account rail, which is the one that can - // now do better. + // rail settled. The Base SDK rail's observed charge is reconstructed + // from a live-verified rate table, not read off a response, and has + // always been labelled exact; the account rail flips this when its + // cost header is present, Solana when its 402 amount parses. let costIsEstimate = isApiKeyMode(); // isApiKeyMode() first: on the account rail getChain() can still say // "solana" (it is the default for a machine with no wallet), and this @@ -459,18 +499,34 @@ Source images and masks accept a base64 data URI, an http(s) URL, or a local fil // 2026-09-05 — a nano-banana image settles at $0.052500 and returns // both the header and `price.amount`, against a $0.0535 estimate. // - // apiKeyPost is the same helper music, speech, video and realface - // already use; image was the odd one out only because an SDK client - // existed for it. + // apiKeyAsyncPost, not apiKeyPost: the gateway answers 202 + + // poll_url for any render past its 30s inline window, and the + // single-POST helper handed that envelope back as if it were the + // image — "No image URL in response", job id and poll_url dropped, + // the render orphaned, and the agent's retry submitting another + // (audit round 3; a regression of #140, which replaced the SDK's + // polling ImageClient on this rail). The async helper handles the + // inline 200 and the 202 alike, and its BilledJobError carries + // the job id for the catch below. const { endpoint, body } = buildSolanaImageRequest(action, { model: selectedModel, prompt, size, - quality, image: normalizedImage, mask: normalizedMask, }); - const r = await apiKeyPost(endpoint, body, { timeoutMs: SOLANA_IMAGE_TIMEOUT_MS }); + // + // Not wrapped in sendPaid, like video and music: this rail bills + // at SUBMIT and the helper classifies every post-submit exit + // itself — BilledJobError for a billed or unknown outcome, + // JobFailedError for a not_charged one. Arming the tracker around + // it made a not_charged failure whose upstream text said + // "timeout" read as "MAY have settled" (audit round 4). + const r = await apiKeyAsyncPost(endpoint, body, { + pollBudgetMs: ACCOUNT_IMAGE_POLL_BUDGET_MS, + pollIntervalMs: ACCOUNT_IMAGE_POLL_INTERVAL_MS, + pollTimeoutMs: ACCOUNT_IMAGE_POLL_TIMEOUT_MS, + }); // 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; @@ -486,11 +542,18 @@ Source images and masks accept a base64 data URI, an http(s) URL, or a local fil model: selectedModel, prompt, size, - quality, image: normalizedImage, mask: normalizedMask, }); + // The quote, captured for the tracker: armed at the helper's + // onPaidRequest (the line before the signed POST leaves) and + // settled at onPaidResponse (any status), so the unpaid probe + // and the signing step are outside the window and an answered + // 5xx is never a maybe. + let solQuotedUsd: number | null = null; const { data, paidUsd } = await solanaPaidPost(endpoint, body, SOLANA_IMAGE_TIMEOUT_MS, { + onPaidRequest: () => paid.arm(solQuotedUsd), + onPaidResponse: () => paid.settle(), // The Solana gateway prices carry a markup over the Base estimate // table, so the real quote can exceed what we reserved. Re-reserve // the true amount against the cap BEFORE the transfer is signed @@ -505,8 +568,11 @@ Source images and masks accept a base64 data URI, an http(s) URL, or a local fil }); gate = reReserveIfHigher(budget, gate, agent_id, estimatedCost, quotedUsd); if (!gate.allowed) { - throw new BudgetExceededError(`${gate.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.`); + // Says so, like the other four manual-402 tools: nothing + // has been signed at this point. + throw new BudgetExceededError(`${gate.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget. No charge was made.`); } + solQuotedUsd = quotedUsd; }, }); recordActualSpend(budget, paidUsd, estimatedCost, agent_id); @@ -514,14 +580,34 @@ Source images and masks accept a base64 data URI, an http(s) URL, or a local fil costIsEstimate = paidUsd === null; imageUrl = (data as { data?: Array<{ url?: string }> }).data?.[0]?.url; } else { - const response = action === "edit" - ? await getImageClient().edit(prompt, normalizedImage!, { + // ---- Base rail: the SDK's ImageClient owns the 402. ---- + // + // No quote is visible here, so the ledger gets what the gateway is + // observed to charge rather than the reserve: the reserve carries + // the $0.002 tx fee (rounded against us on purpose — see + // estimateCost) where a live 402 probe of the Base route on + // 2026-09-13 quoted cogview-4 at $0.016750, i.e. base x 1.05 + + // $0.001. Booking the reserve verbatim tripped caps early on + // every Base image (audit round 3); ledgerFallback is the same + // reconstruction the path tools use. + const observedUsd = ledgerFallback(estimatedCost); + // The SDK signs and sends the payment inside this call, so the + // whole call is the paid window. Its unpaid 402 probe is inside + // it too — the SDK offers no seam between the two — so a probe + // timeout here is booked as well; that is the conservative + // direction, and the gateway answers the probe in well under a + // second, so it is a far narrower window than the render. + const response = await sendPaid(paid, () => action === "edit" + ? getImageClient().edit(prompt, normalizedImage!, { model: selectedModel, size, ...(normalizedMask ? { mask: normalizedMask } : {}), }) - : await getImageClient().generate(prompt, { model: selectedModel, size, quality: quality as "standard" | "hd" }); - recordSpending(budget, estimatedCost, agent_id); + // No quality option: the SDK forwards any truthy value and the + // gateway 400s all of them for these models (see the schema). + : getImageClient().generate(prompt, { model: selectedModel, size }), observedUsd); + recordActualSpend(budget, null, observedUsd, agent_id); + billedUsd = observedUsd; imageUrl = response.data?.[0]?.url; } @@ -534,12 +620,13 @@ Source images and masks accept a base64 data URI, an http(s) URL, or a local fil const delivered = await materializeImageUrl(imageUrl); const savedLocally = delivered !== imageUrl; - // On the wallet rails billedUsd is what was actually signed and settled - // (from the 402 quote). On the account rail nothing comes back to read, - // so it is this server's own estimate — and it is estimated HIGH, since - // estimateCost adds the $0.001 transaction fee that account billing - // does not charge. Printing that unlabelled invites someone to - // reconcile an invoice against a number we invented. + // On Solana billedUsd is what was actually signed and settled (from + // the 402 quote); on Base it is the observed charge the ledger books; + // on the account rail it is the settled cost header when the gateway + // sent one, else this server's own estimate — and that is estimated + // HIGH, since estimateCost adds a transaction fee that account + // billing does not charge. Printing that unlabelled invites someone + // to reconcile an invoice against a number we invented. // Label what the number IS, not which rail produced it. Calling a // settled amount "estimated" is as misleading as the reverse, and it // invites someone to discount a figure that reconciles exactly. @@ -566,6 +653,27 @@ 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 }; } + // The account rail's async path: the gateway accepted the job (or may + // have — a submit that never answered), and it is charged when the + // render completes whether or not this client is still polling. Book + // it and name the job; the one thing not to do is submit again. + // Checked before the in-flight tracker, whose sentence would say + // "signature" for a rail that has none. + if (err instanceof BilledJobError) { + recordActualSpend(budget, err.paidUsd, estimatedCostForCatch, agent_id); + const what = err.billing === "billed" + ? `Image generation did not return an image, but the gateway accepted the render${err.jobId ? ` (job ${err.jobId})` : ""} and the account is charged when it completes.` + : `Image generation got no answer to its submit, so the render MAY have been accepted and billed to the BlockRun account.`; + return { + content: [{ type: "text", text: `${what} $${(err.paidUsd ?? estimatedCostForCatch).toFixed(4)} has been booked against your budget; check https://user.blockrun.ai/dashboard/activity before doing anything else — a new blockrun_image call starts and bills a second render.\nError: ${errMsg}` }], + isError: true, + }; + } + // A paid request that never answered on the wallet rails (or an SDK + // call that dropped mid-payment on Base): the gateway settles on its + // own clock, so this is booked and said out loud. + const giveUp = settleGiveUp(paid, err, { budget, agentId: agent_id, estimateUsd: estimatedCostForCatch, what: "Image generation" }); + if (giveUp) return { content: [{ type: "text", text: giveUp.text }], 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..1bee934 100644 --- a/src/tools/markets.ts +++ b/src/tools/markets.ts @@ -5,8 +5,9 @@ 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 { extractErrorMessage, formatError } from "../utils/errors.js"; +import { ledgerFallback, rawGet, rawPost, type RawClient } from "../utils/raw-call.js"; +import { formatError } from "../utils/errors.js"; +import { pathToolFailure } from "../utils/path-tool-catch.js"; import { hasPathTraversal } from "../utils/path-safety.js"; import type { BudgetState } from "../types.js"; import { TOOL_ANNOTATIONS } from "../tool-annotations.js"; @@ -37,7 +38,7 @@ export function registerMarketsTool(server: McpServer, budget: BudgetState): voi POLYMARKET (Tier 1): - polymarket/events, polymarket/markets — list events/markets (filter, sort, paginate) -- polymarket/markets/keyset, polymarket/events/keyset — same data, cursor-based keyset pagination (use ?pagination_key=) +- polymarket/markets/keyset, polymarket/events/keyset — same data, cursor-based keyset pagination (params: { pagination_key }) - polymarket/crypto-updown — crypto up/down markets - polymarket/market-price/:token_id — current/historical price - polymarket/candlesticks/:condition_id — OHLCV by market @@ -54,7 +55,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 params: { 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: @@ -78,16 +79,23 @@ REQUEST CONTRACTS: - polymarket/orderbooks requires token_id plus start_time/end_time in Unix milliseconds. - Smart-money needs a smart-wallet CRITERION (min_trades, min_volume, min_roi, min_*_pnl, min_win_rate, min_profit_factor). "window" only scopes time and is NOT sufficient on its own. Default: { window: "30d", min_trades: "100" }. -Pass query params via 'params' (GET). Use 'body' only for POST endpoints (e.g. polymarket/wallet/identities).`, +Pass query params via 'params' (GET) — a '?' in 'path' is refused before payment, because path-carried values bypass the pre-payment checks above. Use 'body' only for POST endpoints (e.g. polymarket/wallet/identities).`, annotations: TOOL_ANNOTATIONS.readOnlyOpenWorld, inputSchema: { - path: z.string().describe("Endpoint path, e.g. 'polymarket/events', 'kalshi/markets/KXBTC-25MAR14', 'polymarket/wallet/0xabc...', 'markets/search'"), + // Bare route only. 'kalshi/markets/KXBTC-25MAR14' used to be the example + // here; the registry has only the 2-segment 'kalshi/markets' and the + // gateway 404s on a segment-count mismatch (probed unauthenticated + // 2026-09-13), so copying it cost a wasted turn. Filters go in params. + path: z.string().describe("Endpoint path, no query string, e.g. 'polymarket/events', 'kalshi/markets' (filter via params: { ticker: 'KXBTC-25MAR14' }), 'polymarket/wallet/0xabc...', 'markets/search'"), params: z.record(z.string(), z.string()).optional().describe("Query parameters for GET requests (e.g. markets/search uses { q: 'Bitcoin', status: 'open', venue: 'polymarket', limit: '20' })"), body: z.any().optional().describe("JSON body for POST queries (triggers pmQuery — most endpoints are GET)"), agent_id: z.string().optional().describe("Agent identifier for budget tracking and enforcement."), }, }, async ({ path, params, body, agent_id }) => { + // The reserve of the paid request in flight, for the catch: 0 until the + // line before rawGet/rawPost, so nothing thrown earlier can book a charge. + let sentUsd = 0; try { body = coerceBody(body); // `path` is forwarded verbatim into /v1/pm/${path}; a `..` segment would @@ -126,10 +134,11 @@ Pass query params via 'params' (GET). Use 'body' only for POST endpoints (e.g. p // settled `x-blockrun-cost-usd` instead of discarding the response. const llm = getClient() as unknown as RawClient; const endpoint = `/v1/pm/${path}`; + sentUsd = estimatedCost; 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) }], @@ -139,15 +148,17 @@ Pass query params via 'params' (GET). Use 'body' only for POST endpoints (e.g. p gate.release(); } } catch (err) { - const message = extractErrorMessage(err); - // A sports/* 5xx is the known Predexon outage, not a blip, and the - // gateway released the payment — say so instead of "after payment … - // try again in a few minutes" (blockrun-mcp#132). - const degraded = describeDegradedSportsFailure(path, message); - return { - content: [{ type: "text", text: degraded ?? formatError(message) }], - isError: true, - }; + // Books the reserve when the payment went out and no origin answer came + // back (utils/path-tool-catch.ts). A sports/* 5xx is the known Predexon + // outage, not a blip, and the gateway released the payment — say so + // instead of "after payment … try again in a few minutes" + // (blockrun-mcp#132); the bespoke text replaces formatError's only. + return pathToolFailure(err, { + budget, + agentId: agent_id, + sentUsd, + describe: (message) => describeDegradedSportsFailure(path, message), + }); } } ); diff --git a/src/tools/modal.ts b/src/tools/modal.ts index 7c384e5..77b6bfd 100644 --- a/src/tools/modal.ts +++ b/src/tools/modal.ts @@ -12,8 +12,9 @@ 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 { formatError, extractErrorMessage } from "../utils/errors.js"; +import { ledgerFallback, rawPost, type RawClient } from "../utils/raw-call.js"; +import { formatError } from "../utils/errors.js"; +import { pathToolFailure } from "../utils/path-tool-catch.js"; import { normalizeClassifyPath } from "../utils/path-safety.js"; import { hasPathTraversal } from "../utils/path-safety.js"; import type { BudgetState } from "../types.js"; @@ -35,8 +36,9 @@ import type { BudgetState } from "../types.js"; // { timeout: 300 } -> charged $0.0120 // // A $1 agent cap could settle $192 of non-refundable spend. Keep these tables in -// step with the gateway's; an unknown gpu string falls back to the CPU rate there, -// so it does here too. +// step with the gateway's. The gateway's CreateRequestSchema 400s BEFORE payment +// on any gpu string outside its five tiers (case-sensitive: "h100" is refused), +// so the CPU rate is only ever what an ABSENT gpu pays. const MODAL_FLAT_RATE_MAX_SECONDS = 300; const MODAL_DEFAULT_CREATE_TIMEOUT_SECONDS = 300; const MODAL_CREATE_PRICE_USD = 0.01; @@ -56,6 +58,47 @@ const MODAL_CPU_HOURLY_PRICE_USD = 0.1; const MODAL_GPU_HOURLY_PRICE_USD = new Map([ ["T4", 1.5], ["L4", 2.0], ["A10G", 2.5], ["A100", 4.0], ["H100", 8.0], ]); +const MODAL_GPU_TIERS = [...MODAL_GPU_HOURLY_PRICE_USD.keys()]; + +// NORMALISE THE BODY THE WAY THE GATEWAY DOES, BEFORE PRICING AND BEFORE +// SENDING. The gateway's CreateRequestSchema declares `gpu: z.string().trim()` +// and runs its allow-list check and getModalCreatePricing on the TRIMMED value, +// so `" H100 "`, `"H100\n"` and an NBSP-padded `"H100"` are all accepted and +// billed as H100. This estimator looked the raw string up in the Map, missed, +// and priced the CPU rate — the path-classification bug below, on the body +// field that carries the largest single charge this server can make. Unpaid +// 402 probe 2026-09-13: `{ timeout: 3600, gpu: " H100 " }` quotes 8001000 +// micro ($8.001) against a $0.102 reserve; at 24h that is $192.002 against +// $2.402 — past a $5 cap, past the confirm dialog, and booked as $2.40 on the +// Base ledger. Trim (String.prototype.trim, same as zod's) and keep the case: +// the gateway is case-sensitive and 400s "h100" before payment, so folding +// case would turn a free refusal into a paid H100. +// +// The trimmed body is also what gets SENT, so the reserve and the wire agree by +// construction rather than by a second normalisation on the far side. +export function normalizeModalCreateBody(body: unknown): unknown { + if (!body || typeof body !== "object" || Array.isArray(body)) return body; + const o = body as Record; + if (typeof o.gpu !== "string") return body; + return { ...o, gpu: o.gpu.trim() }; +} + +/** + * The refusal for a gpu the gateway would 400 before payment — every string + * outside the five tiers, including lowercase and the empty string. Returns + * null when the body is fine. Refusing here costs nothing (the gateway would + * refuse the same call unpaid) and saves the round-trip; naming the tiers + * matters because "Unsupported GPU type" alone sends a model guessing again. + * Only sandbox/create carries a priced gpu, so callers gate on the route. + */ +export function unsupportedModalGpu(body: unknown): string | null { + if (!body || typeof body !== "object") return null; + const gpu = (body as { gpu?: unknown }).gpu; + if (gpu === undefined || gpu === null) return null; + if (typeof gpu === "string" && MODAL_GPU_HOURLY_PRICE_USD.has(gpu)) return null; + return `Unsupported GPU type ${JSON.stringify(gpu)}. Allowed: ${MODAL_GPU_TIERS.join(", ")} (case-sensitive), or omit gpu for a CPU sandbox. ` + + `The gateway rejects any other value before payment, so nothing would have been served. No payment was made.`; +} /** Exported for tests. Returns what x402 will CHARGE (base + the flat tx fee). */ export function estimateModalCost(path: string, body?: unknown): number { @@ -71,7 +114,10 @@ export function estimateModalCost(path: string, body?: unknown): number { if (!normalizeClassifyPath(path).includes("sandbox/create")) return withTxFee(MODAL_OPERATION_PRICE_USD); const o = body && typeof body === "object" ? (body as { gpu?: unknown; timeout?: unknown }) : {}; - const gpu = typeof o.gpu === "string" ? o.gpu : undefined; + // Trimmed, as the gateway prices it — see normalizeModalCreateBody. The + // handler sends a body normalised the same way; this is belt-and-braces so + // the estimator is right even for a caller that skipped the handler. + const gpu = typeof o.gpu === "string" ? o.gpu.trim() : undefined; // A non-numeric/absent timeout defaults to 300s upstream — the flat tier. const seconds = typeof o.timeout === "number" && Number.isFinite(o.timeout) && o.timeout > 0 @@ -79,8 +125,11 @@ export function estimateModalCost(path: string, body?: unknown): number { : MODAL_DEFAULT_CREATE_TIMEOUT_SECONDS; if (seconds > MODAL_FLAT_RATE_MAX_SECONDS) { - // An unknown or empty gpu falls back to the CPU rate — same as the gateway's - // `opts.gpu && opts.gpu in TABLE ? TABLE[gpu] : CPU_RATE`. + // An absent gpu is the CPU rate — same as the gateway's + // `opts.gpu && opts.gpu in TABLE ? TABLE[gpu] : CPU_RATE`, which runs on + // the trimmed value. An unknown gpu also falls back here, but only so the + // estimator stays total: the handler refuses it before the reserve + // (unsupportedModalGpu), and the gateway 400s it before payment. const hourly = gpu !== undefined ? MODAL_GPU_HOURLY_PRICE_USD.get(gpu) : undefined; return withTxFee((hourly ?? MODAL_CPU_HOURLY_PRICE_USD) * (seconds / 3600)); } @@ -132,6 +181,9 @@ Full pricing tables + GPU details in the \`modal\` skill.`, }, }, async ({ path, body, agent_id }) => { + // The reserve of the paid request in flight, for the catch: 0 until the + // line before rawPost, so nothing thrown earlier can book a charge. + let sentUsd = 0; try { // sol.blockrun.ai returns 503 for every /v1/modal/* route — the sandbox // backend is Base-only. Probed 2026-08-07 by the dual-chain sweep in @@ -150,7 +202,18 @@ Full pricing tables + GPU details in the \`modal\` skill.`, if (hasPathTraversal(cleanPath)) { return { content: [{ type: "text", text: formatError(`Invalid path '${path}'.`) }], isError: true }; } - // Pass the body: sandbox/create is priced from gpu + timeout, not the path. + // sandbox/create is priced from gpu + timeout. Normalise the gpu the way + // the gateway will (trim) BEFORE estimating and BEFORE sending, so the + // reserve, the confirm dialog, the ledger and the wire all describe the + // same tier — and refuse a tier the gateway would 400 unpaid, so the + // message names the five that exist instead of "API error: 400". + if (normalizeClassifyPath(cleanPath).includes("sandbox/create")) { + body = normalizeModalCreateBody(body); + const badGpu = unsupportedModalGpu(body); + if (badGpu) { + return { content: [{ type: "text", text: formatError(badGpu) }], isError: true }; + } + } const estimatedCost = estimateModalCost(cleanPath, body); const gate = reserveBudget(budget, agent_id, estimatedCost); if (!gate.allowed) { @@ -169,8 +232,9 @@ Full pricing tables + GPU details in the \`modal\` skill.`, // lengthening the 60s timeout the shared getClient() gives every other tool. const client = buildClientWithTimeout(modalTimeoutMs(body)) as unknown as RawClient; const endpoint = `/v1/modal/${cleanPath}`; + sentUsd = estimatedCost; 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), @@ -179,7 +243,11 @@ Full pricing tables + GPU details in the \`modal\` skill.`, gate.release(); } } catch (err) { - return { content: [{ type: "text", text: formatError(extractErrorMessage(err)) }], isError: true }; + // Books the reserve when the payment went out and no origin answer came + // back (utils/path-tool-catch.ts) — for sandbox/create that is the full + // reserve, which is the point: a create that timed out client-side may + // well be running and billed. + return pathToolFailure(err, { budget, agentId: agent_id, sentUsd }); } } ); 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..865dd71 100644 --- a/src/tools/music.ts +++ b/src/tools/music.ts @@ -2,13 +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, 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"; +import { formatError, hasLabelledServerStatus } from "../utils/errors.js"; import { launchTopUp } from "../utils/onramp.js"; import { fetchWithTimeout, isTimeoutError } from "../utils/http.js"; -import { pollDeadline, pollTimeoutFor } from "../utils/poll.js"; +import { JobFailedError, pollDeadline, pollTimeoutFor } from "../utils/poll.js"; +import { sendPaid, settleGiveUp, trackPaidRequest } from "../utils/in-flight.js"; import type { BudgetState } from "../types.js"; import { getApiBase, getChain, getOrCreateWalletKey, resolveGatewayUrl } from "../utils/wallet.js"; import { isApiKeyMode } from "../utils/auth.js"; @@ -28,6 +29,16 @@ const MUSIC_COST = withTxFee(0.1575); const MUSIC_POLL_INTERVAL_MS = 5_000; export const MUSIC_POLL_BUDGET_MS = 240_000; // 4 min polling budget, measured from submit export const MUSIC_POLL_TIMEOUT_MS = 90_000; +// The paid submit on EVERY rail. The audio route races generation against a +// 60s inline window (blockrun src/app/api/v1/audio/generations/route.ts, +// inlineGenTimeoutMs) and only then answers 202 + poll_url; fast tracks come +// back 200 inline, after the GCS backup. The Solana route does the same and +// settles the SPL transfer at POST regardless of what the client does next — +// so its 30s helper default (sized for the always-202 video route) aborted +// every track slower than 30s after the money had moved, with no job id to +// reclaim it by (audit round 3, C15). One constant, shared, so the rails +// cannot drift again; music-cost.test.ts pins it against the auth window. +export const MUSIC_SUBMIT_TIMEOUT_MS = 95_000; // Lifetime of the signed payment authorization, in seconds, counted from the // moment createPaymentPayload() signs. The polling budget above is measured // from a LATER instant (after submit), so the two are not directly comparable — @@ -92,10 +103,13 @@ export function registerMusicTool(server: McpServer, budget: BudgetState): void description: `Generate music tracks via BlockRun x402 (async, client-polled). Generates a full-length ~3 minute MP3 track. Takes 1-3 minutes to complete. The -tool submits the job and, for slower tracks, polls until it is ready; payment -settles only when a finished track is returned — if it fails you are not -charged; if this client gives up while a paid request is still in flight the -gateway may still settle, and the error text says so. +tool submits the job and, for slower tracks, polls until it is ready. On Base +and the account rail payment settles only when a finished track is returned — +if the job fails you are not charged; if this client gives up while a paid +request is still in flight the gateway may still settle, and the error text +says so. On Solana the gateway settles the payment when it ACCEPTS the job, so +a job that later fails or outlives the poll budget is still charged — the error +text says so and names the job, which stays claimable for ~48h. Model: minimax/music-2.5+ ($0.1575/track, up to ~4 min) @@ -123,11 +137,23 @@ Returns a permanent BlockRun-hosted URL.`, // flight can still settle server-side. Every give-up also names the job. let jobId: string | undefined; let quotedUsd: number | null = null; - // True while a Base request carrying the payment header — the submit, - // which can settle inline, or a poll — has been issued and has not - // answered. A poll that rejects leaves it true: that request may still be - // settling on the gateway, which does not stop on disconnect. - let paidRequestInFlight = false; + // Whether a request carrying the payment is outstanding, per call. Armed + // the moment a signed request is about to leave — the submit, which can + // settle inline, or a poll — never around the unpaid quote; settled on + // every answer. utils/in-flight.ts explains why the hand-rolled boolean + // this replaces was wrong on every rail. + const paid = trackPaidRequest(); + // Set once a request carrying the payment has left at all (formatError's + // afterPayment: a 5xx that came back is an answer, not a verdict). + let paidRequestSent = false; + // The amount booked once settlement was OBSERVED. Read by the catch: an + // error after this point is a real charge with an unusable result, and + // the message must say the charge stands rather than "failed" (D13). + let bookedUsd: number | null = null; + const book = (paidUsd: number | null) => { + recordActualSpend(budget, paidUsd, MUSIC_COST, agent_id); + bookedUsd = paidUsd ?? MUSIC_COST; + }; try { // NO CHAIN GUARD. This tool refused every Solana call until 2026-09-05 // ("settles on Base only"), which stopped being true well before that: @@ -161,12 +187,18 @@ Returns a permanent BlockRun-hosted URL.`, // ---- Rail 1: account API key. No quote, no signature, no expiry. ---- if (isApiKeyMode()) { + // Not wrapped in sendPaid: this rail bills at SUBMIT and the helper + // classifies every post-submit exit as a BilledJobError itself. + // Arming here would make a not_charged terminal failure whose + // upstream text says "timeout" read as "may have settled". + paidRequestSent = true; const { data, paidUsd, txHash } = await apiKeyAsyncPost("/v1/audio/generations", body, { pollBudgetMs: MUSIC_POLL_BUDGET_MS, pollIntervalMs: MUSIC_POLL_INTERVAL_MS, + submitTimeoutMs: MUSIC_SUBMIT_TIMEOUT_MS, pollTimeoutMs: MUSIC_POLL_TIMEOUT_MS, }); - recordActualSpend(budget, paidUsd, MUSIC_COST, agent_id); + book(paidUsd); const t = (data as { data?: Array<{ url: string; duration_seconds?: number; lyrics?: string }> }).data?.[0]; if (!t?.url) throw new Error("Completed response missing track URL"); // Estimated only when the rail gave us nothing to settle against. @@ -174,14 +206,52 @@ Returns a permanent BlockRun-hosted URL.`, } // ---- Rail 2: Solana wallet. Same reusable helper blockrun_video uses. ---- + // sol.blockrun.ai settles this route OPTIMISTICALLY at POST (the 202 + // says payment_status "settled_optimistic"); the helper reads that + // from the wire and turns every later failure into a BilledJobError, + // which the catch books as a certain charge. if (getChain() === "solana") { const { solanaPaidAsyncPost } = await import("../utils/solana-402.js"); - const { data, paidUsd, txHash } = await solanaPaidAsyncPost("/v1/audio/generations", body, { + const { data, paidUsd, txHash, jobId: solJobId } = await solanaPaidAsyncPost("/v1/audio/generations", body, { pollBudgetMs: MUSIC_POLL_BUDGET_MS, + submitTimeoutMs: MUSIC_SUBMIT_TIMEOUT_MS, + // The helper's messages name their caller; without these a music + // give-up said "Video generation did not complete" and told the + // agent that re-running blockrun_video would charge a new job. + what: "Music generation", + tool: "blockrun_music", + // 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) { + 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.`); + } + // Last: nothing can refuse the quote past this line and the + // helper signs next (see video.ts for the residual window). + paid.arm(solQuotedUsd); + }, + // Exact edges of every signed request — submit and each poll — so + // a deadline reached with the last poll answered books nothing and + // one reached with a poll in flight books the quote (C32/C37). + onPaidRequest: () => { paidRequestSent = true; paid.arm(quotedUsd); }, + onPaidResponse: () => paid.settle(), }); + paid.settle(); + jobId = solJobId; // Book before validating the payload: a malformed completed body must // not make a settled charge vanish from the local ledger. - recordActualSpend(budget, paidUsd, MUSIC_COST, agent_id); + book(paidUsd); const t = (data as { data?: Array<{ url: string; duration_seconds?: number; lyrics?: string }> }).data?.[0]; if (!t?.url) throw new Error("Completed Solana response missing track URL"); return musicResult(t, (data as { model?: string }).model || model, paidUsd ?? MUSIC_COST, txHash, false); @@ -211,6 +281,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(); @@ -236,19 +323,23 @@ Returns a permanent BlockRun-hosted URL.`, // Step 2: submit with payment. Fast tracks complete inline (200); slower // ones (MiniMax music is 1-3 min) return 202 + poll_url — the server // verified the payment but does NOT settle until a completed poll. - paidRequestInFlight = true; - const submitResp = await fetchWithTimeout(url, { + // Armed for the round trip: an inline 200 IS a settlement, so a submit + // that leaves with the signature and never answers is not "no charge". + paidRequestSent = true; + const submitResp = await sendPaid(paid, () => fetchWithTimeout(url, { method: "POST", headers: { "Content-Type": "application/json", "PAYMENT-SIGNATURE": paymentPayload, }, body: JSON.stringify(body), - }, 95_000); - paidRequestInFlight = false; + }, MUSIC_SUBMIT_TIMEOUT_MS), quotedUsd); if (submitResp.status === 402) { - throw new Error("Payment rejected. Check your wallet balance."); + // The one answer that IS a funding problem. Named like the SDK's + // class so the catch classifies it by type, not by its words. + await submitResp.json().catch(() => ({})); + throw Object.assign(new Error("Payment rejected. Check your wallet balance."), { name: "PaymentError" }); } if (!submitResp.ok && submitResp.status !== 202) { const errBody = await submitResp.json().catch(() => ({ error: "Request failed" })) as Record; @@ -295,23 +386,21 @@ Returns a permanent BlockRun-hosted URL.`, if (pollTimeoutMs === 0) break; let pollResp: Response; - paidRequestInFlight = true; try { - pollResp = await fetchWithTimeout(pollAbsoluteUrl, { + pollResp = await sendPaid(paid, () => fetchWithTimeout(pollAbsoluteUrl, { method: "GET", headers: { "PAYMENT-SIGNATURE": paymentPayload }, - }, pollTimeoutMs); + }, pollTimeoutMs), quotedUsd); } catch { // Polling is idempotent and settlement has not been observed. A // transient disconnect is safe to retry inside the existing // deadline (the EIP-3009 nonce is single-use, so re-sending the // same header after a lost-in-flight settlement cannot settle - // twice), and one reset must not abandon a paid job. - // paidRequestInFlight stays true: the request that never answered - // may still be settling server-side. + // twice), and one reset must not abandon a paid job. The tracker + // stays armed: the request that never answered may still be + // settling server-side. continue; } - paidRequestInFlight = false; const pollData = await pollResp.json().catch(() => ({})) as { status?: string; @@ -329,12 +418,15 @@ Returns a permanent BlockRun-hosted URL.`, // a real charge the ledger never saw (the fix video.ts got in // 0.39.1, which music did not). if (lastStatus === "completed" && !spendBooked) { - recordActualSpend(budget, quotedUsd, MUSIC_COST, agent_id); + book(quotedUsd); spendBooked = true; } if (pollResp.status === 202 && (lastStatus === "queued" || lastStatus === "in_progress")) continue; - if (lastStatus === "failed") throw new Error(`Upstream generation failed: ${pollData.error || "unknown"}. No payment taken.`); + // Typed: the upstream text rides along verbatim and is not a + // verdict on the money; the gateway's contract is that a failed + // job on this route is not charged. + if (lastStatus === "failed") throw new JobFailedError(`Upstream generation failed: ${pollData.error || "unknown"}. No payment taken.`, { jobId }); if (pollResp.ok && lastStatus === "completed") { const t = pollData.data?.[0]; if (!t?.url) throw new Error("Completed poll missing track URL"); @@ -349,8 +441,8 @@ Returns a permanent BlockRun-hosted URL.`, // 504 on poll = transient upstream poll timeout — retry. } if (!track) { - // Whether money moved depends on paidRequestInFlight, which the - // catch reads; the message here states only what was observed. + // Whether money moved depends on the tracker, which the catch + // reads; the message here states only what was observed. throw new Error(`Music generation did not complete within ${Math.round(MUSIC_POLL_BUDGET_MS / 1000)}s (last status: ${lastStatus}).`); } } else { @@ -359,7 +451,7 @@ Returns a permanent BlockRun-hosted URL.`, // before reading the body (speech.ts does the same): a truncated body // or a stripped receipt header must not un-record money that moved. txHash = submitResp.headers.get("X-Payment-Receipt") || submitResp.headers.get("x-payment-receipt"); - recordActualSpend(budget, quotedUsd, MUSIC_COST, agent_id); + book(quotedUsd); spendBooked = true; const data = await submitResp.json().catch(() => null) as { data?: Array<{ url: string; duration_seconds?: number; lyrics?: string }>; model?: string } | null; track = data?.data?.[0]; @@ -373,64 +465,79 @@ Returns a permanent BlockRun-hosted URL.`, const billedUsd = quotedUsd ?? MUSIC_COST; // Backstop only — every reachable path here has already booked at the // moment settlement was observed. - if (!spendBooked) recordActualSpend(budget, quotedUsd, MUSIC_COST, agent_id); + if (!spendBooked) book(quotedUsd); return musicResult(track, modelReturned || model, billedUsd, txHash, false); } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); - // The account rail bills at SUBMIT. A failure after that — deadline, - // poll error, terminal failure — leaves a charge the ledger must carry - // (finally releases the reservation, so without this booking the cap - // silently rises by the track price), and the one thing the caller must - // not do is "try again": that submits and bills a second job. Checked - // before isTimeoutError, which matches the deadline message and would - // glue retry advice onto a note saying the job was billed. + const reclaim = jobId ? ` The finished job stays claimable on the gateway for ~48h (job ${jobId}); re-running blockrun_music would start and charge a new job.` : ""; + // Same classification, same order, on all three rails as video.ts — + // every step is something the code observed, never a word in the + // message (audit round 3, C13/C32/C36/C37). + // + // 1. Settlement was observed and booked, then the result could not be + // used. The charge stands; do not run the tool again (D13). + if (bookedUsd !== null) { + return { + content: [{ type: "text", text: `Music generation completed and the charge stands — $${bookedUsd.toFixed(4)} was settled${jobId ? ` for job ${jobId}` : ""} and is booked against your budget — but the result could not be used: ${errMsg}${reclaim || " Re-running blockrun_music would start and charge a new job."}\nCheck blockrun_wallet action:"report" before doing anything else.` }], + isError: true, + }; + } + // 2. Billed at SUBMIT — the account rail always, and the Solana audio + // route (settled optimistically at POST) via the shared helper — and + // the failure came after. The ledger must carry it, and the one + // thing the caller must not do is "try again". if (err instanceof BilledJobError) { recordActualSpend(budget, err.paidUsd, MUSIC_COST, agent_id); + const account = isApiKeyMode(); + const billedTo = account ? "the BlockRun account" : "the Solana wallet"; const what = err.billing === "billed" - ? `Music generation did not return a track, but the job was billed to the BlockRun account when the gateway accepted it${err.jobId ? ` (job ${err.jobId})` : ""}.` - : `Music generation got no answer to its submit, so the job MAY have been accepted and billed to the BlockRun account.`; + ? `Music generation did not return a track, but the job was billed to ${billedTo} when the gateway accepted it${err.jobId ? ` (job ${err.jobId})` : ""}.` + : `Music generation got no answer to its submit, so the job MAY have been accepted and billed to ${billedTo}.`; + const where = account ? "https://user.blockrun.ai/dashboard/activity" : `blockrun_wallet action:"report" or the wallet's recent transactions`; return { - content: [{ type: "text", text: `${what} Check https://user.blockrun.ai/dashboard/activity before doing anything else — a new blockrun_music call starts and bills a second job.\nError: ${errMsg}` }], + content: [{ type: "text", text: `${what} Check ${where} before doing anything else — a new blockrun_music call starts and bills a second job.\nError: ${errMsg}` }], isError: true, }; } - // "Fund your wallet" is the wrong remedy on the account rail — there is - // no wallet, and launchTopUp() would try to provision one to send a card - // onramp to. apiKeyAsyncPost already returns the correct message for a - // 402 there (top up credit at the portal), so let it through untouched. - if (isPaymentRejectionError(errMsg) && !isApiKeyMode()) { + // 3. The wallet refused to pay: a 402 on the signed request, by type. + // "Fund your wallet" is the wrong remedy on the account rail — there + // is no wallet — and apiKeyAsyncPost words its own 402, so this is + // never reached there (the helper throws no PaymentError). + if (err instanceof Error && err.name === "PaymentError") { return { content: [{ type: "text", text: `Music generation needs USDC — your wallet is out of funds. ${(await launchTopUp()).note}\nError: ${errMsg}` }], isError: true, }; } + // 4. A request carrying the payment was outstanding and no answer was + // observed: an inline submit can settle (200) and the gateway + // settles a completed poll whether or not we are still connected. + // Booked and said out loud; the tracker decides, not the chain. + const giveUp = settleGiveUp(paid, err, { budget, agentId: agent_id, estimateUsd: MUSIC_COST, what: "Music generation", note: reclaim.trim() || undefined }); + if (giveUp) return { content: [{ type: "text", text: giveUp.text }], isError: true }; + // 5. The gateway said the job failed and nothing was charged — whatever + // the upstream text says (MiniMax's is "aborted due to timeout"). + if (err instanceof JobFailedError) { + return { content: [{ type: "text", text: formatError(`Music generation failed: ${errMsg}`) }], isError: true }; + } + // 6. A labelled 5xx is an answer, not a timeout, however it reads. + if (hasLabelledServerStatus(errMsg)) { + return { content: [{ type: "text", text: formatError(`Music generation failed: ${errMsg}`, { afterPayment: paidRequestSent }) + reclaim }], isError: true }; + } + // 7. A timeout with nothing outstanding: the unpaid quote probe, a + // signing failure, or a deadline after the last poll was answered — + // settlement needs a signed request the gateway answers as settled, + // and the last one was not. if (isTimeoutError(err)) { - const reclaim = jobId ? ` The finished job stays claimable on the gateway for ~48h (job ${jobId}); re-running blockrun_music would start and charge a new job.` : ""; - if (paidRequestInFlight) { - // A submit can settle inline (200) and the gateway settles a - // completed poll regardless of whether we are still connected, so - // a paid request that never answered is not "no charge". Book the - // quote conservatively — over-counting a slow request that settled - // nothing is the documented trade-off; under-counting a real charge - // is not. - recordActualSpend(budget, quotedUsd, MUSIC_COST, agent_id); - return { - content: [{ type: "text", text: `Music generation timed out while a request carrying the payment signature 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.${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"; + const base = !isApiKeyMode(); return { content: [{ type: "text", text: `Music generation timed out.${base ? ` No payment was taken.${reclaim}` : ""}\nError: ${errMsg}` }], isError: true, }; } return { - content: [{ type: "text", text: formatError(`Music generation failed: ${errMsg}`) }], + content: [{ type: "text", text: formatError(`Music generation failed: ${errMsg}`, { afterPayment: paidRequestSent }) }], isError: true, }; } finally { diff --git a/src/tools/phone.ts b/src/tools/phone.ts index e7f4383..cc6d777 100644 --- a/src/tools/phone.ts +++ b/src/tools/phone.ts @@ -13,8 +13,9 @@ 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 { formatError, extractErrorMessage } from "../utils/errors.js"; +import { ledgerFallback, rawGet, rawPost, type RawClient } from "../utils/raw-call.js"; +import { formatError } from "../utils/errors.js"; +import { pathToolFailure } from "../utils/path-tool-catch.js"; import { hasPathTraversal, normalizeClassifyPath } from "../utils/path-safety.js"; import type { BudgetState } from "../types.js"; @@ -82,6 +83,9 @@ Voice call flow + voice preset details + full body shapes in the \`phone\` skill }, }, async ({ path, body, agent_id }) => { + // The reserve of the paid request in flight, for the catch: 0 until the + // line before rawGet/rawPost, so nothing thrown earlier can book a charge. + let sentUsd = 0; try { body = coerceBody(body); const cleanPath = path.replace(/^\/+/, "").replace(/^v1\//, ""); @@ -119,13 +123,14 @@ Voice call flow + voice preset details + full body shapes in the \`phone\` skill if (!confirm.ok) return { content: [{ type: "text", text: confirm.reason ?? "Charge cancelled." }] }; const client = getClient() as unknown as RawClient; const endpoint = `/v1/${cleanPath}`; + sentUsd = estimatedCost; const { data: result, paidUsd } = body !== undefined ? await rawPost(client, endpoint, body) : await rawGet(client, endpoint); // 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) }], @@ -135,7 +140,9 @@ Voice call flow + voice preset details + full body shapes in the \`phone\` skill gate.release(); } } catch (err) { - return { content: [{ type: "text", text: formatError(extractErrorMessage(err)) }], isError: true }; + // Books the reserve when the payment went out and no origin answer came + // back (utils/path-tool-catch.ts). A free poll reserves $0 and books $0. + return pathToolFailure(err, { budget, agentId: agent_id, sentUsd }); } } ); diff --git a/src/tools/polymarket.ts b/src/tools/polymarket.ts index 96433e5..f112e23 100644 --- a/src/tools/polymarket.ts +++ b/src/tools/polymarket.ts @@ -3,7 +3,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { asStructuredContent } from "../utils/body.js"; import { extractErrorMessage } from "../utils/errors.js"; -import { executeTrade, listOpenOrders, cancelOrdersAction, getSessionLedger, type ToolResult } from "../utils/polymarket/orders.js"; +import { confirmSpend } from "../utils/confirm-spend.js"; +import { executeTrade, listOpenOrders, cancelOrdersAction, getSessionLedger, type SpendGate, type ToolResult } from "../utils/polymarket/orders.js"; import { listPositions } from "../utils/polymarket/positions.js"; import { redeemPosition } from "../utils/polymarket/redeem.js"; import { runSetup } from "../utils/polymarket/setup.js"; @@ -11,6 +12,7 @@ import { withdrawFunds } from "../utils/polymarket/withdraw.js"; import { fundVault } from "../utils/polymarket/fund.js"; import { TOOL_ANNOTATIONS } from "../tool-annotations.js"; import { appToolMeta } from "../apps.js"; +import type { BudgetState } from "../types.js"; /** * Trading is intentionally NOT gated on the x402 budget ledger: that ledger @@ -18,8 +20,12 @@ import { appToolMeta } from "../apps.js"; * user's own pUSD on Polygon — mixing them would corrupt both. The guardrails * here are confirm:true (hard-required to sign anything), the per-order * POLYMARKET_MAX_BET_USD cap, and the optional session cap (see orders.ts). + * + * The one exception is action:"fund"'s $0.01 gateway fee: that IS Base-wallet + * API spend, so when the registrar hands over the budget (mcp-handler.ts) fund + * reserves and books it like any paid call. Without it, legacy behaviour. */ -export function registerPolymarketTool(server: McpServer): void { +export function registerPolymarketTool(server: McpServer, budget?: BudgetState): void { server.registerTool( "blockrun_polymarket", { @@ -56,6 +62,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). Defaults to the worst fill of this session's last preview for the same token+side, so a bare confirm:true is already held to what was quoted — a book that moved past it is refused, not signed. Pass this to widen or tighten that bound. 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() @@ -71,6 +79,13 @@ Prices are probabilities 0–1 on the market's tick grid. token_id comes from bl }, }, async (args) => { + // Human-in-the-loop (BLOCKRUN_CONFIRM_SPEND=on): the money-moving + // actions ask the user at the dialog the other paid tools use, with the + // real notional, right before they sign. `confirm:true` is a boolean the + // model supplies — it is the required floor, not the human's answer. A + // no-op when the flag is off or the client cannot elicit, so confirm:true + // alone still places, exactly as before. + const askUser: SpendGate = (usd, label) => confirmSpend(server, { usd, label }); try { let result: ToolResult; switch (args.action) { @@ -78,11 +93,11 @@ Prices are probabilities 0–1 on the market's tick grid. token_id comes from bl result = await runSetup({ confirm: args.confirm === true }); break; case "fund": - result = await fundVault({ amount_usd: args.amount_usd, confirm: args.confirm }); + result = await fundVault({ amount_usd: args.amount_usd, confirm: args.confirm, askUser, budget, agent_id: args.agent_id }); break; case "buy": case "sell": - result = await executeTrade({ ...args, action: args.action }); + result = await executeTrade({ ...args, action: args.action, askUser }); break; case "orders": result = await listOpenOrders({ condition_id: args.condition_id }); @@ -97,7 +112,7 @@ Prices are probabilities 0–1 on the market's tick grid. token_id comes from bl result = await redeemPosition({ condition_id: args.condition_id, confirm: args.confirm }); break; case "withdraw": - result = await withdrawFunds({ amount_usd: args.amount_usd, to_address: args.to_address, confirm: args.confirm }); + result = await withdrawFunds({ amount_usd: args.amount_usd, to_address: args.to_address, confirm: args.confirm, askUser }); break; } if (result.isError) { diff --git a/src/tools/realface.ts b/src/tools/realface.ts index 4206565..eb95e50 100644 --- a/src/tools/realface.ts +++ b/src/tools/realface.ts @@ -2,14 +2,16 @@ 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 { sendPaid, settleGiveUp, trackPaidRequest, type PaidRequest } from "../utils/in-flight.js"; +import { parseCostHeader } from "../utils/api-key-call.js"; import type { BudgetState } from "../types.js"; -import { getApiBase, getChain, getOrCreateWalletKey } from "../utils/wallet.js"; -import { apiAuthHeaders, isApiKeyMode, requireWalletMode } from "../utils/auth.js"; +import { getApiBase, getChain, getOrCreateWalletKey, resolveSolanaKey } from "../utils/wallet.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"; @@ -36,18 +38,42 @@ async function payAndPostJson( path: string, reqBody: string, fallbackDescription: string, + /** + * The caller's per-call tracker for "a request carrying a payment is + * outstanding". Armed here on every rail the moment the paid request is + * about to go out, settled the moment a response arrives; the handler's + * catch reads it to book a give-up. Handed in rather than kept here because + * the MCP SDK dispatches calls concurrently and this function is shared: a + * module-level flag (0.50.0) made one call's outstanding payment book a + * phantom charge against another call's unrelated failure (audit round 3). + */ + paid: PaidRequest, + /** + * 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()) { - const resp = await fetchWithTimeout(`${getApiBase()}${path}`, { + // 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. The Bearer is + // the payment, so the POST is armed like a signed one. + const resp = await sendPaid(paid, () => fetchWithTimeout(`${getApiBase()}${path}`, { method: "POST", headers: { "Content-Type": "application/json", ...apiAuthHeaders() }, body: reqBody, - }, 90_000); + }, 90_000)); 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. - return { status: resp.status, data, settledUsd: null }; + // The account API reports what it settled in x-blockrun-cost-usd (since + // 2026-09-05 — see utils/api-key-call.ts). Absent reads as null, and the + // callers then fall back to ENROLLMENT_PRICE_USD; that estimate carries + // the $0.002 tx fee this rail does not charge, so booking it for a + // settled $0.010 over-counted every enrolment by 20%. + return { status: resp.status, data, settledUsd: parseCostHeader(resp.headers.get("x-blockrun-cost-usd")) }; } // ---- Rail 2: Solana wallet. sol.blockrun.ai serves both enroll routes @@ -55,15 +81,33 @@ async function payAndPostJson( if (getChain() === "solana") { const { solanaPaidPost } = await import("../utils/solana-402.js"); try { - const r = await solanaPaidPost(path, JSON.parse(reqBody) as Record, 90_000); + // The quote, captured for the tracker: armed at the helper's + // onPaidRequest (the line before the signed POST leaves) and settled at + // onPaidResponse (any status), so a refused quote (thrown from onQuote, + // nothing signed) never books and an answered 5xx is never a maybe. + let solQuotedUsd: number | null = null; + const r = await solanaPaidPost(path, JSON.parse(reqBody) as Record, 90_000, { + onPaidRequest: () => paid.arm(solQuotedUsd), + onPaidResponse: () => paid.settle(), + onQuote: (quotedUsd, quoteDetails) => { + onQuote?.(quotedUsd, quoteDetails?.resource?.description); + solQuotedUsd = quotedUsd; + }, + }); return { status: 200, data: r.data as Record, settledUsd: r.paidUsd }; } catch (err) { - // solanaPaidPost throws on a non-2xx terminal response. Recover the status - // when it is one the callers branch on, so a 422 still reads as "rejected, - // not charged" rather than as an opaque failure. - const msg = err instanceof Error ? err.message : String(err); - const m = /\b(4\d\d|5\d\d)\b/.exec(msg); - if (m) return { status: Number(m[1]), data: { error: msg }, settledUsd: null }; + // solanaPaidPost throws on a non-2xx answer to the PAID request with the + // status on the error (`statusCode`, the SDK's shape): a 422 still reads + // as "rejected, not charged" and a 402 as the wallet's refusal. Read + // the property, never the prose — the regex this replaces turned any + // quote fault whose text mentioned "402" (an unreadable amount, a + // missing feePayer) into "out of funds" plus a top-up page, for a call + // where nothing had been signed (audit round 4). + const status = (err as { statusCode?: unknown } | undefined)?.statusCode; + if (typeof status === "number") { + const msg = err instanceof Error ? err.message : String(err); + return { status, data: { error: msg }, settledUsd: null }; + } throw err; } } @@ -90,6 +134,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,14 +149,16 @@ async function payAndPostJson( } ); - const resp = await fetchWithTimeout(url, { + // Armed for exactly this fetch — the signature is on it — and settled by a + // response of any status before the status is read. + const resp = await sendPaid(paid, () => fetchWithTimeout(url, { method: "POST", headers: { "Content-Type": "application/json", "PAYMENT-SIGNATURE": paymentPayload, }, body: reqBody, - }, 90_000); + }, 90_000), amountToUsd(details.amount)); const data = await resp.json().catch(() => ({})) as Record; return { status: resp.status, data, settledUsd: amountToUsd(details.amount) }; @@ -152,6 +199,9 @@ Privacy: BlockRun does not store face/liveness data — only the asset id, name, // 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; + // THIS call's outstanding-payment state, read by the catch below. Per + // call on purpose — see payAndPostJson. + const paid = trackPaidRequest(); try { // ---- init (free) ---- if (action === "init") { @@ -254,7 +304,39 @@ Privacy: BlockRun does not store face/liveness data — only the asset id, name, // getOrCreateWalletKey() below from minting one to ask with. const listBlock = requireWalletMode('blockrun_realface action:"list"'); if (listBlock) return { content: [{ type: "text", text: listBlock }], isError: true }; - const account = privateKeyToAccount(getOrCreateWalletKey()); + // The ACTIVE chain's payer address against the ACTIVE chain's + // gateway. Assets are stored under whichever wallet paid the + // enrolment, and sol.blockrun.ai's /v1/wallet/{address} routes + // accept base58 only (probed 2026-09-13: an 0x address is a 400, + // "expected Solana base58"). 0.46.0 pointed this URL at getApiBase() + // and left the address EVM, so on the default chain every list + // failed — and minted an EVM keypair on a Solana-only install just + // to ask (audit round 3). On Solana the key is READ, never minted: + // a free listing must not provision a wallet. + const chain = getChain(); + let address: string; + if (chain === "solana") { + const solanaKey = resolveSolanaKey(); + if (!solanaKey) { + // Dynamic so the handler suites that mock utils/wallet.js by + // name (and predate this branch) keep linking; the same reason + // the Solana helper below is imported lazily. + const { solanaKeyUnavailableReason } = await import("../utils/wallet.js"); + const why = solanaKeyUnavailableReason?.(); + return { + content: [{ type: "text", text: formatError(why + ? `Cannot list RealFace assets: ${why}. Unlock the keychain and retry.` + : `No Solana wallet yet, so there is nothing enrolled to list. Run blockrun_wallet action:"setup" to provision one, or switch to Base (blockrun_wallet action:"chain" chain:"base") to list assets paid from the Base wallet.`) }], + isError: true, + }; + } + const { solanaPublicKey } = await import("@blockrun/llm"); + address = await solanaPublicKey(solanaKey); + } else { + address = privateKeyToAccount(getOrCreateWalletKey()).address; + } + const account = { address }; + const chainLabel = chain === "solana" ? "Solana" : "Base"; const [rfResp, vpResp] = await Promise.all([ fetchWithTimeout(`${getApiBase()}/v1/wallet/${account.address}/realfaces`, { method: "GET" }, 30_000), fetchWithTimeout(`${getApiBase()}/v1/wallet/${account.address}/portraits`, { method: "GET" }, 30_000) @@ -284,13 +366,13 @@ Privacy: BlockRun does not store face/liveness data — only the asset id, name, ? `\n⚠️ The Virtual Portrait lookup failed, so this covers RealFace only — do NOT re-enroll a portrait on the strength of this listing; retry first.` : ""; return { - content: [{ type: "text", text: `No RealFace${portraitsUnavailable ? "" : " or Virtual Portrait"} assets enrolled for ${account.address}.${caveat}\nEnroll one: blockrun_realface action:"init" name:"…" (real person) or action:"portrait" name:"…" image_url:"https://…" (AI character).` }], - structuredContent: { wallet: account.address, realfaces: [], portraits: [], count: 0, portraitsUnavailable }, + content: [{ type: "text", text: `No RealFace${portraitsUnavailable ? "" : " or Virtual Portrait"} assets enrolled for ${account.address} (${chainLabel} wallet — assets are per paying wallet and per chain).${caveat}\nEnroll one: blockrun_realface action:"init" name:"…" (real person) or action:"portrait" name:"…" image_url:"https://…" (AI character).` }], + structuredContent: { wallet: account.address, chain, realfaces: [], portraits: [], count: 0, portraitsUnavailable }, }; } const first = faces[0] ?? portraits[0]; const lines = [ - `Assets for ${account.address} (${faces.length} RealFace, ${portraits.length} Virtual Portrait):`, + `Assets for ${account.address} on ${chainLabel} (${faces.length} RealFace, ${portraits.length} Virtual Portrait; assets are per paying wallet and per chain):`, ...faces.map((f) => ` • ${f.assetId} — "${f.name}" [realface]${f.createdAt ? ` (${f.createdAt})` : ""}`), ...portraits.map((p) => ` • ${p.assetId} — "${p.name}" [portrait]${p.createdAt ? ` (${p.createdAt})` : ""}`), ``, @@ -298,7 +380,7 @@ Privacy: BlockRun does not store face/liveness data — only the asset id, name, ]; return { content: [{ type: "text", text: lines.join("\n") }], - structuredContent: { wallet: account.address, realfaces: faces, portraits, count: faces.length + portraits.length }, + structuredContent: { wallet: account.address, chain, realfaces: faces, portraits, count: faces.length + portraits.length }, }; } @@ -325,6 +407,21 @@ 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", + paid, + (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) { @@ -352,7 +449,7 @@ Privacy: BlockRun does not store face/liveness data — only the asset id, name, `✅ Virtual Portrait enrolled!`, `Asset ID: ${assetId}`, `Name: ${data.name || name}`, - `Cost: $${ENROLLMENT_PRICE_USD.toFixed(2)} USDC`, + `Cost: $${(settledUsd ?? ENROLLMENT_PRICE_USD).toFixed(4)} USDC`, ...(txHash ? [`Tx: ${txHash}`] : []), ``, `Use it: blockrun_video model:"bytedance/seedance-2.0" real_face_asset_id:"${assetId}" prompt:"…".`, @@ -364,7 +461,7 @@ Privacy: BlockRun does not store face/liveness data — only the asset id, name, group_id: data.group_id, name: data.name || name, image_url: data.image_url, - price_usd: ENROLLMENT_PRICE_USD, + price_usd: settledUsd ?? ENROLLMENT_PRICE_USD, ...(txHash ? { txHash } : {}), }, }; @@ -396,6 +493,21 @@ 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", + paid, + (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) { @@ -423,7 +535,7 @@ Privacy: BlockRun does not store face/liveness data — only the asset id, name, `✅ RealFace enrolled!`, `Asset ID: ${assetId}`, `Name: ${data.name || name}`, - `Cost: $${ENROLLMENT_PRICE_USD.toFixed(2)} USDC`, + `Cost: $${(settledUsd ?? ENROLLMENT_PRICE_USD).toFixed(4)} USDC`, ...(txHash ? [`Tx: ${txHash}`] : []), ``, `Use it: blockrun_video model:"bytedance/seedance-2.0" real_face_asset_id:"${assetId}" prompt:"…".`, @@ -434,7 +546,7 @@ Privacy: BlockRun does not store face/liveness data — only the asset id, name, asset_id: assetId, group_id: data.group_id || group_id, name: data.name || name, - price_usd: ENROLLMENT_PRICE_USD, + price_usd: settledUsd ?? ENROLLMENT_PRICE_USD, ...(txHash ? { txHash } : {}), }, }; @@ -445,10 +557,21 @@ 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, }; } + // The request carrying the payment never answered. The gateway + // settles on its own clock, so this is not "no charge" — book it + // conservatively (the quote where one was seen, else the reserve) and + // say what is and is not known. Same trade-off video and music make: + // over-counting a request that settled nothing is recoverable, + // under-counting a real charge is not. `paid` is this call's own + // tracker, so another call's outstanding payment cannot land here. + const giveUp = settleGiveUp(paid, err, { budget, agentId: agent_id, estimateUsd: ENROLLMENT_PRICE_USD, what: `RealFace ${action}` }); + if (giveUp) return { content: [{ type: "text", text: giveUp.text }], isError: true }; return { content: [{ type: "text", text: formatError(`RealFace ${action} failed: ${errMsg}`) }], isError: true }; } finally { gate?.release(); diff --git a/src/tools/rpc.ts b/src/tools/rpc.ts index 92509ab..ff84456 100644 --- a/src/tools/rpc.ts +++ b/src/tools/rpc.ts @@ -15,13 +15,14 @@ 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 { formatError, extractErrorMessage } from "../utils/errors.js"; +import { ledgerFallback, rawPost, type RawClient } from "../utils/raw-call.js"; +import { formatError } from "../utils/errors.js"; +import { pathToolFailure } from "../utils/path-tool-catch.js"; import { isValidNetworkSlug } from "../utils/path-safety.js"; import type { BudgetState } from "../types.js"; -const RPC_PRICE_USD = 0.002; +export const RPC_PRICE_USD = 0.002; export function registerRpcTool(server: McpServer, budget: BudgetState): void { server.registerTool( @@ -51,6 +52,9 @@ Prefer blockrun_price (free quotes) or blockrun_dex (free DEX data) when they co }, }, async ({ network, method, params, body, agent_id }) => { + // The reserve of the paid request in flight, for the catch: 0 until the + // line before rawPost, so nothing thrown earlier can book a charge. + let sentUsd = 0; try { body = coerceBody(body); if (body === undefined) { @@ -95,8 +99,9 @@ Prefer blockrun_price (free quotes) or blockrun_dex (free DEX data) when they co const confirm = await confirmSpend(server, { usd: estimatedCost, label: `rpc · ${cleanNetwork}` }); if (!confirm.ok) return { content: [{ type: "text", text: confirm.reason ?? "Charge cancelled." }] }; const client = getClient() as unknown as RawClient; + sentUsd = estimatedCost; 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) @@ -107,10 +112,9 @@ Prefer blockrun_price (free quotes) or blockrun_dex (free DEX data) when they co gate.release(); } } catch (err) { - return { - content: [{ type: "text", text: formatError(extractErrorMessage(err)) }], - isError: true, - }; + // Books the reserve when the payment went out and no origin answer came + // back (utils/path-tool-catch.ts). + return pathToolFailure(err, { budget, agentId: agent_id, sentUsd }); } } ); diff --git a/src/tools/search.ts b/src/tools/search.ts index 35ffcfc..9fe99ab 100644 --- a/src/tools/search.ts +++ b/src/tools/search.ts @@ -12,8 +12,9 @@ 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 { formatError, extractErrorMessage } from "../utils/errors.js"; +import { ledgerFallback, rawPost, type RawClient } from "../utils/raw-call.js"; +import { formatError } from "../utils/errors.js"; +import { pathToolFailure } from "../utils/path-tool-catch.js"; import { hasPathTraversal } from "../utils/path-safety.js"; import type { BudgetState } from "../types.js"; @@ -62,16 +63,43 @@ export function estimateSearchCost(body: unknown): number { return reserve(max); } +// The gateway's `sources` enum. X/Twitter was dropped upstream on 2026-07-05 +// (blockrun commit edefa8eb, `z.array(z.enum(["web","news"])).optional() +// .default(["web"])`) and this tool went on advertising `["web","x","news"]` +// as its Common shape for two months. Following it to the letter reserved +// $0.2645, sat through the confirm dialog and came back as `API error: 400 / +// Invalid request body` — the SDK strips the zod issues, so the field was +// never named and the agent had no way to self-correct. Unpaid probe +// 2026-09-13: `["web","x","news"]` 400s on both gateways, `["web","news"]` +// quotes a 402. Only the NAMES are ours to check; a non-array `sources` is a +// shape error the gateway reports unpaid. +const SEARCH_SOURCES = ["web", "news"] as const; + +/** Exported for tests. The refusal for a `sources` entry the gateway no longer serves, or null. */ +export function unsupportedSearchSource(body: unknown): string | null { + if (!body || typeof body !== "object") return null; + const sources = (body as { sources?: unknown }).sources; + if (!Array.isArray(sources)) return null; + const bad = sources.filter((s) => !(SEARCH_SOURCES as readonly unknown[]).includes(s)); + if (bad.length === 0) return null; + const xTwitter = bad.some((s) => typeof s === "string" && /^(x|twitter)$/i.test(s)); + return `body.sources ${JSON.stringify(bad)} is not served: the gateway accepts only ["web","news"] (default ["web"]). ` + + (xTwitter + ? `The X/Twitter source was removed upstream on 2026-07-05 and there is no live X route in this server — do not retry with "x". ` + : "") + + `Retry with sources: ["web","news"] or omit it. No payment was made.`; +} + export function registerSearchTool(server: McpServer, budget: BudgetState): void { server.registerTool( "blockrun_search", { - description: `Grok Live Search — real-time web + X/Twitter + news with AI-summarized results and citations. PRICED PER SOURCE and expensive by default: $0.025 × max_results, +5% gateway buffer — default max_results=10 settles ~$0.26 (max_results=50 → ~$1.31). Pass a smaller max_results to cap spend; for a plain fact, 3 sources (~$0.08) is usually enough. + description: `Grok Live Search — real-time web + news with AI-summarized results and citations. PRICED PER SOURCE and expensive by default: $0.025 × max_results, +5% gateway buffer — default max_results=10 settles ~$0.26 (max_results=50 → ~$1.31). Pass a smaller max_results to cap spend; for a plain fact, 3 sources (~$0.08) is usually enough. Common shape: -- body: { query: "...", sources: ["web","x","news"], max_results: 10, from_date: "YYYY-MM-DD", to_date: "YYYY-MM-DD" } +- body: { query: "...", sources: ["web","news"], max_results: 10, from_date: "YYYY-MM-DD", to_date: "YYYY-MM-DD" } -\`sources\` accepts any subset of ["web","x","news"] (defaults to all three). For tweet-only searches, use ["x"]. \`max_results\` is 1–50 (default 10) and drives the price — pass a smaller value if you want to cap spend. +\`sources\` accepts any subset of ["web","news"] (default ["web"] — pass both for news coverage). There is no X/Twitter source (removed upstream 2026-07-05; asking for it is refused before payment). \`max_results\` is 1–50 (default 10) and drives the price — pass a smaller value if you want to cap spend. Full request shape + worked examples in the \`search\` skill (\`skills/search/SKILL.md\`).`, annotations: TOOL_ANNOTATIONS.readOnlyOpenWorld, @@ -82,12 +110,22 @@ Full request shape + worked examples in the \`search\` skill (\`skills/search/SK }, }, async ({ path, body, agent_id }) => { + // The reserve of the paid request in flight, for the catch: 0 until the + // line before rawPost, so nothing thrown earlier can book a charge. + let sentUsd = 0; try { body = coerceBody(body); const cleanPath = (path ?? "").replace(/^\/+/, "").replace(/^v1\/search\/?/, ""); if (hasPathTraversal(cleanPath)) { return { content: [{ type: "text", text: formatError(`Invalid path '${path}'.`) }], isError: true }; } + // Before the reserve and the confirm dialog: a source the gateway no + // longer serves would 400 unpaid anyway, but as an opaque "Invalid + // request body" — name the field and the live values instead. + const badSource = unsupportedSearchSource(body); + if (badSource) { + return { content: [{ type: "text", text: formatError(badSource) }], isError: true }; + } const estimatedCost = estimateSearchCost(body); const gate = reserveBudget(budget, agent_id, estimatedCost); if (!gate.allowed) { @@ -104,8 +142,9 @@ Full request shape + worked examples in the \`search\` skill (\`skills/search/SK if (!confirm.ok) return { content: [{ type: "text", text: confirm.reason ?? "Charge cancelled." }] }; const client = getClient() as unknown as RawClient; const endpoint = cleanPath ? `/v1/search/${cleanPath}` : "/v1/search"; + sentUsd = estimatedCost; 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), @@ -114,7 +153,10 @@ Full request shape + worked examples in the \`search\` skill (\`skills/search/SK gate.release(); } } catch (err) { - return { content: [{ type: "text", text: formatError(extractErrorMessage(err)) }], isError: true }; + // Books the reserve only when the payment went out and no ORIGIN answer + // came back (utils/path-tool-catch.ts). The search route calls Grok + // BEFORE it settles, so its bare 500 is pre-settle and books nothing. + return pathToolFailure(err, { budget, agentId: agent_id, sentUsd }); } } ); diff --git a/src/tools/speech.ts b/src/tools/speech.ts index 27b8a47..3b57c39 100644 --- a/src/tools/speech.ts +++ b/src/tools/speech.ts @@ -13,12 +13,13 @@ 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"; import { launchTopUp } from "../utils/onramp.js"; -import { fetchWithTimeout, isTimeoutError } from "../utils/http.js"; +import { fetchWithTimeout } from "../utils/http.js"; +import { sendPaid, settleGiveUp, trackPaidRequest } from "../utils/in-flight.js"; import type { BudgetState } from "../types.js"; import { getApiBase, getChain, getOrCreateWalletKey } from "../utils/wallet.js"; import { apiAuthHeaders, isApiKeyMode } from "../utils/auth.js"; @@ -145,6 +146,29 @@ 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 the booking needs the reserve when no quote was captured. + let reservedCost = 0; + // Armed only while a request carrying the payment is outstanding, on + // every rail. A timeout on the unpaid 402 probe charges nothing, and + // booking it would invent spend; a timeout after the signature (or the + // account Bearer) went out may well have settled. Per call, and settled + // only by a response — 0.50.0's boolean was cleared in a `.finally` the + // catch could never observe, and set on Base alone (audit round 3). + const paid = trackPaidRequest(); + // The amount booked once settlement was OBSERVED, on any rail. Read by + // the catch: an error after this point — a body that aborted mid-read, + // a payload with no URL — is a real charge with an unusable result, and + // the message has to say the charge stands rather than "failed" with + // retry advice that pays again (the D13 shape video and music got in + // round 3; speech did not — audit round 4). + let bookedUsd: number | null = null; + // Hoisted with it: the receipt, so the charge-stands sentence can name it. + let txHash: string | null | undefined; + const book = (paidUsd: number | null, reserve: number) => { + recordActualSpend(budget, paidUsd, reserve, agent_id); + bookedUsd = paidUsd ?? reserve; + }; try { if (action === "voices") { return await listVoices(); @@ -197,6 +221,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 { @@ -212,36 +237,57 @@ Returns a hosted audio URL — download immediately if you need to keep the file let data: { data?: Array<{ url: string; format?: string; characters?: number; duration_seconds?: number }>; model?: string }; let billedUsd: number; - let txHash: string | null | undefined; let estimated = false; // ---- Rail 1: account API key. One POST, no quote, no signature. ---- if (isApiKeyMode()) { - const r = await apiKeyPost(path, body, { timeoutMs: SPEECH_TIMEOUT }); + // The Bearer IS the payment on this rail: a POST that never answers + // may still be billed, so it is armed like a signed one. + const r = await sendPaid(paid, () => apiKeyPost(path, body, { timeoutMs: SPEECH_TIMEOUT })); data = r.data as typeof data; // The settled figure when the rail reports one, the local estimate // otherwise — and `estimated` says which, so the two are never mixed up. billedUsd = r.paidUsd ?? cost; estimated = r.paidUsd === null; txHash = r.txHash; - recordActualSpend(budget, r.paidUsd, cost, agent_id); + book(r.paidUsd, cost); } else if (getChain() === "solana") { // ---- Rail 2: Solana wallet, via the shared manual-x402 helper. ---- const { solanaPaidPost } = await import("../utils/solana-402.js"); + // The quote, captured for the tracker: armed at the helper's + // onPaidRequest (the line before the signed POST leaves) and settled + // at onPaidResponse (any status), so the unpaid probe and the + // signing step are outside the window and an answered 5xx is never + // a maybe. + let solQuotedUsd: number | null = null; const r = await solanaPaidPost(path, body, SPEECH_TIMEOUT, { - onQuote: (quotedUsd) => { - // Re-check the REAL price against the budget before signing: the - // Solana gateway quotes independently of our local estimate. - if (quotedUsd === null || quotedUsd <= cost) 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.`); + onPaidRequest: () => paid.arm(solQuotedUsd), + onPaidResponse: () => paid.settle(), + onQuote: (quotedUsd, quoteDetails) => { + // WHAT was quoted, before how much. 350df27 put this guard on the + // Base rail only, so a substituted or repriced product on + // sol.blockrun.ai — the DEFAULT chain — was signed unseen while + // Base refused it (audit round 3). Refusing costs nothing: the + // helper has not signed yet. + assertQuoteNearEstimate(quotedUsd, cost, { + what: `${action === "sound_effect" ? "sound effect" : model} speech`, + quotedFor: quoteDetails?.resource?.description, + hint: `Retry on Base (blockrun_wallet action:"chain" chain:"base"), or report the quote.`, + }); + // Then the cap, against the REAL price: the Solana gateway quotes + // independently of our local estimate. + 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.`); + } + solQuotedUsd = quotedUsd; }, }); data = r.data as typeof data; billedUsd = r.paidUsd ?? cost; txHash = r.txHash; - recordActualSpend(budget, r.paidUsd, cost, agent_id); + book(r.paidUsd, cost); } else { // ---- Rail 3: Base wallet. The original EIP-3009 402 flow. ---- const endpoint = `${getApiBase()}${path}`; @@ -269,6 +315,24 @@ Returns a hosted audio URL — download immediately if you need to keep the file // server-side price change) over the local estimate for billing + display. billedUsd = amountToUsd(details.amount) ?? cost; + // WHAT was quoted, before how much. 0.49.0 added this to video (both + // rails) and image (Solana) and left the identical hand-rolled flows + // here unguarded — so a gateway that quotes a different product, the + // way sol.blockrun.ai quoted azure/sora-2 as Seedance at 2.7x, was + // signed unseen. Refusing costs nothing: nothing is signed yet. + assertQuoteNearEstimate(billedUsd, cost, { + what: `${model} speech`, + quotedFor: details.resource?.description, + hint: `Retry on Solana (blockrun_wallet action:"chain" chain:"solana"), or report the quote.`, + }); + // And the cap, against the REAL price rather than the estimate. + const quotedUsd = amountToUsd(details.amount); + if (quotedUsd !== null && quotedUsd > cost) { + gate?.release(); + gate = reserveBudget(budget, agent_id, quotedUsd); + if (!gate.allowed) throw new Error(`${gate.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget. No charge was made.`); + } + const paymentPayload = await createPaymentPayload( privateKey, account.address, @@ -283,15 +347,17 @@ Returns a hosted audio URL — download immediately if you need to keep the file } ); - // Step 2: synthesize with payment (settlement happens after generation) - const resp = await fetchWithTimeout(endpoint, { + // Step 2: synthesize with payment (settlement happens after generation). + // Armed for exactly this fetch: the signature is on it, and a response + // of any status settles the tracker before it is inspected. + const resp = await sendPaid(paid, () => fetchWithTimeout(endpoint, { method: "POST", headers: { "Content-Type": "application/json", "PAYMENT-SIGNATURE": paymentPayload, }, body: JSON.stringify(body), - }, SPEECH_TIMEOUT); + }, SPEECH_TIMEOUT), quotedUsd); if (resp.status === 402) { throw new Error("Payment rejected. Check your wallet balance."); @@ -306,7 +372,7 @@ Returns a hosted audio URL — download immediately if you need to keep the file // the charge NOW, before reading the body — a truncated/unreadable body // below must not un-record a spend that already left the wallet. txHash = resp.headers.get("X-Payment-Receipt") || resp.headers.get("x-payment-receipt"); - recordActualSpend(budget, billedUsd, cost, agent_id); + book(billedUsd, cost); data = await resp.json() as { data: Array<{ url: string; format?: string; characters?: number; duration_seconds?: number }>; @@ -347,20 +413,33 @@ Returns a hosted audio URL — download immediately if you need to keep the file }; } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); - // Account mode has no wallet to fund — apiKeyPost already returns the - // right remedy (top up credit at the portal), so don't overwrite it. - if (isPaymentRejectionError(errMsg) && !isApiKeyMode()) { + // 1. Settlement was observed and booked, then the result could not be + // used. The charge stands; the one thing not to do is run the + // tool again. + // (Widened: TS narrows a closure-assigned `let` to never here.) + const booked = bookedUsd as number | null; + if (booked !== null) { + const where = isApiKeyMode() ? "https://user.blockrun.ai/dashboard/activity" : `blockrun_wallet action:"report"`; return { - content: [{ type: "text", text: `Speech generation needs USDC — your wallet is out of funds. ${(await launchTopUp()).note}\nError: ${errMsg}` }], + content: [{ type: "text", text: `Speech generation settled and the charge stands — $${booked.toFixed(4)} was charged${txHash ? ` (tx ${txHash})` : ""} and is booked against your budget — but the result could not be used: ${errMsg} +Check ${where} before doing anything else; re-running blockrun_speech would charge again.` }], isError: true, }; } - if (isTimeoutError(err)) { + // Account mode has no wallet to fund — apiKeyPost already returns the + // right remedy (top up credit at the portal), so don't overwrite it. + if (isPaymentRejectionError(errMsg) && !isApiKeyMode()) { 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 needs USDC — your wallet is out of funds. ${(await launchTopUp()).note}\nError: ${errMsg}` }], isError: true, }; } + // A paid request that never answered on ANY rail: the gateway settles + // on its own clock, so this is booked (the quote where one was seen, + // else the reserve) and said out loud — the finally below releases the + // reservation, and a bare "failed" invites a retry that pays twice. + const giveUp = settleGiveUp(paid, err, { budget, agentId: agent_id, estimateUsd: reservedCost, what: "Speech generation" }); + if (giveUp) return { content: [{ type: "text", text: giveUp.text }], isError: true }; return { content: [{ type: "text", text: formatError(`Speech generation failed: ${errMsg}`) }], isError: true, diff --git a/src/tools/video.ts b/src/tools/video.ts index d24b26c..6130017 100644 --- a/src/tools/video.ts +++ b/src/tools/video.ts @@ -5,10 +5,11 @@ import { z } from "zod"; 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"; +import { formatError, hasLabelledServerStatus } from "../utils/errors.js"; import { launchTopUp } from "../utils/onramp.js"; import { fetchWithTimeout, isTimeoutError } from "../utils/http.js"; -import { pollTimeoutFor } from "../utils/poll.js"; +import { JobFailedError, pollTimeoutFor } from "../utils/poll.js"; +import { sendPaid, settleGiveUp, trackPaidRequest } from "../utils/in-flight.js"; import type { BudgetState } from "../types.js"; import { getApiBase, getChain, getOrCreateWalletKey, resolveGatewayUrl } from "../utils/wallet.js"; import { isApiKeyMode } from "../utils/auth.js"; @@ -37,8 +38,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. @@ -296,6 +302,24 @@ export function assertVideoQuoteSane( assertQuoteNearEstimate(quotedUsd, estimatedCost, { what: `${model} video`, quotedFor, hint }); } +/** + * The wallet refused to pay: a 402 that came back on a request CARRYING the + * signature. The SDK's PaymentError (and the helpers' own) set `name` to + * "PaymentError"; the Base rail below throws the same shape. Checked by name + * rather than `instanceof` so the module does not need a static import of the + * SDK class — every handler suite that mocks @blockrun/llm lists its exports + * by hand (chat-stream.ts classifies the same way). + * + * This replaces isPaymentRejectionError's substring match on "insufficient" / + * "balance" / "rejected": an upstream safety filter's "Your request was + * rejected …" arrived through the same catch and was reported as an empty + * wallet — with a Coinbase top-up page opened on Base — while the message + * itself said no payment was taken (audit round 3, C36). + */ +function isPaymentRefusal(err: unknown): boolean { + return err instanceof Error && err.name === "PaymentError"; +} + export function registerVideoTool(server: McpServer, budget: BudgetState): void { server.registerTool( "blockrun_video", @@ -305,7 +329,7 @@ export function registerVideoTool(server: McpServer, budget: BudgetState): void Turns a text prompt (and optional seed image) into a short MP4 clip. The tool submits the job, then polls until the video is ready (typical total wall-time 60-180s; 9 min Base / 15 min Solana hard cap). Payment is settled only when upstream returns a finished video — if the job fails you are not charged; if this client gives up while a paid poll is still in flight the gateway may still settle, and the error text says so. Models. Every rate below is what you are CHARGED (margin and transaction fee included), at the 720p baseline Seedance renders by default with synced audio: -- azure/sora-2 (~$0.105/sec, 720p + synced audio, text-to-video) — OpenAI Sora 2 via Azure AI Foundry. duration_seconds must be 4, 8, or 12 (4s default -> ~$0.42/clip). No image_url / RealFace. Base only for now: the Solana gateway quotes it as Seedance 2.0 at $1.135 and the tool refuses that quote unsigned. +- azure/sora-2 (~$0.105/sec, 720p + synced audio, text- or image-to-video) — OpenAI Sora 2 via Azure AI Foundry. duration_seconds must be 4, 8, or 12 (4s default -> ~$0.42/clip). image_url takes a NON-HUMAN reference image (faces are rejected upstream by moderation — use Seedance + RealFace for real people); same price as text-to-video. No RealFace, no last_frame_url. Base only for now: the Solana gateway quotes it as Seedance 2.0 at $1.135 and the tool refuses that quote unsigned. - xai/grok-imagine-video ($0.05/sec at 480p default, $0.07/sec at 720p; 8s default -> $0.401/clip, 1-15s) — stylized, fast. 480p/720p only. - bytedance/seedance-1.5-pro (~$0.071/sec, 4-12s, 5s default -> ~$0.35/clip) — cheapest Seedance, token-priced upstream - bytedance/seedance-2.0-mini (~$0.080/sec, 4-15s, 5s default) — 2.0-generation quality at roughly half the 2.0-fast rate; 720p ceiling; supports RealFace and first/last-frame @@ -342,10 +366,28 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC let estimatedCost = 0; let quotedUsd: number | null = null; let jobId: string | undefined; - // True while a Base poll carrying the payment header has been issued and - // has not answered. A poll that rejects leaves it true: that request may - // still be settling on the gateway, which does not stop on disconnect. - let paidPollInFlight = false; + // Whether a request carrying the payment is outstanding, per call (the + // MCP SDK dispatches tool calls concurrently). Armed the moment a signed + // request is about to leave — never around the unpaid quote — and + // settled on every answer; utils/in-flight.ts explains why the + // hand-rolled boolean this replaces was wrong on every rail. + const paid = trackPaidRequest(); + // Set once a request carrying the payment has left at all, answered or + // not. formatError's afterPayment: a 5xx that came back on a signed + // request is an answer (the tracker settles), but it is not a verdict + // on the money — the gateway's catch-all 500 does not release the nonce. + let paidRequestSent = false; + // The amount booked as settled, once settlement was OBSERVED (a + // completed poll, an inline 200, the helper returning). Read by the + // catch: an error after this point — a payload with no URL — is a real + // charge with an unusable result, and the message has to say the charge + // stands and the job is claimable, not "failed" with model advice that + // invites paying again (audit round 3, D13). + let bookedUsd: number | null = null; + const book = (paidUsd: number | null) => { + recordActualSpend(budget, paidUsd, estimatedCost, agent_id); + bookedUsd = paidUsd ?? estimatedCost; + }; try { const selectedModel = model || "xai/grok-imagine-video"; @@ -479,12 +521,18 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC // ---- Rail 1: account API key. No quote, no signature, no expiry. ---- if (isApiKeyMode()) { + // Not wrapped in sendPaid: this rail bills at SUBMIT and the helper + // already classifies every post-submit exit as a BilledJobError + // (certain or unknown). Arming the tracker here would make a + // not_charged terminal failure whose upstream text says "timeout" + // read as "may have settled" — the C13 shape on a third rail. + paidRequestSent = true; const { data, paidUsd, txHash } = await apiKeyAsyncPost("/v1/videos/generations", body, { pollBudgetMs: VIDEO_TOTAL_BUDGET_MS, pollIntervalMs: POLL_INTERVAL_MS, pollTimeoutMs: VIDEO_POLL_TIMEOUT_MS, }); - recordActualSpend(budget, paidUsd, estimatedCost, agent_id); + book(paidUsd); const clip = (data as { data?: Array<{ url?: string; source_url?: string; duration_seconds?: number; request_id?: string; backed_up?: boolean }> }).data?.[0]; if (!clip?.url) throw new Error("Completed video response missing video URL"); const modelOut = (data as { model?: string }).model || selectedModel; @@ -524,28 +572,53 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC // installations never need to initialize SVM payment dependencies. const { solanaPaidAsyncPost } = await import("../utils/solana-402.js"); - const { data, paidUsd, txHash } = await solanaPaidAsyncPost( + const { data, paidUsd, txHash, jobId: solJobId } = await solanaPaidAsyncPost( "/v1/videos/generations", body, { pollBudgetMs: SOLANA_VIDEO_TOTAL_BUDGET_MS, - onQuote: (quotedUsd, quoteDetails) => { + what: "Video generation", + tool: "blockrun_video", + 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; - gate?.release(); - gate = reserveBudget(budget, agent_id, quotedUsd); - // 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.`); + assertVideoQuoteSane(solQuotedUsd, estimatedCost, selectedModel, "solana", quoteDetails?.resource?.description); + if (solQuotedUsd !== null && solQuotedUsd > estimatedCost) { + gate?.release(); + 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.`); + } + // Last: after the guard and the re-reserve, nothing can refuse + // the quote any more and the helper signs next. Armed here + // rather than at the paid request alone so a signing-time + // failure still books conservatively (the narrow residual + // window utils/in-flight.ts documents); the hooks below keep + // the tracker exact from the first signed request onwards. + paid.arm(solQuotedUsd); }, + // The edges of every request carrying PAYMENT-SIGNATURE — the + // submit and each poll. A poll that drops leaves the tracker + // armed; a poll that answers "in_progress" settles it, so a + // deadline reached with nothing outstanding books nothing, the + // way Base's loop already behaved. Before these hooks the rail + // booked the estimate on ANY timeout, including the unpaid + // quote probe aborting (C32/C37). + onPaidRequest: () => { paidRequestSent = true; paid.arm(quotedUsd); }, + onPaidResponse: () => paid.settle(), }, ); + paid.settle(); + jobId = solJobId; // A terminal response means the gateway has already settled. Book it // before validating the payload so a malformed completed body cannot // make a real Solana charge disappear from the local ledger. - recordActualSpend(budget, paidUsd, estimatedCost, agent_id); + book(paidUsd); const clip = (data as { data?: Array<{ url?: string; source_url?: string; duration_seconds?: number; request_id?: string; backed_up?: boolean }>; model?: string }).data?.[0]; if (!clip?.url) throw new Error("Completed Solana video response missing video URL"); const billedUsd = paidUsd ?? estimatedCost; @@ -657,18 +730,26 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC ); // Step 2: submit job with payment — server verifies (does not settle) - // and returns { id, poll_url, status: "queued" } in ~3-20s. - const submitResp = await fetchWithTimeout(submitUrl, { + // and returns { id, poll_url, status: "queued" } in ~3-20s. Armed for + // the round trip: the gateway does not settle on submit here, but it + // burns the nonce and enqueues, and a submit that never answers is + // still a signed request the gateway may have accepted. + paidRequestSent = true; + const submitResp = await sendPaid(paid, () => fetchWithTimeout(submitUrl, { method: "POST", headers: { "Content-Type": "application/json", "PAYMENT-SIGNATURE": paymentPayload, }, body: JSON.stringify(body), - }, 30_000); + }, 30_000), settledUsd); if (submitResp.status === 402) { - throw new Error("Payment rejected. Check your wallet balance."); + // The one answer that IS a funding problem: the gateway refused the + // signed request. Named like the SDK's class so the catch classifies + // it by type, not by the words in it. + await submitResp.json().catch(() => ({})); + throw Object.assign(new Error("Payment rejected. Check your wallet balance."), { name: "PaymentError" }); } if (!submitResp.ok && submitResp.status !== 202) { const errBody = await submitResp.json().catch(() => ({ error: "Submit failed" })) as Record; @@ -720,23 +801,21 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC if (pollTimeoutMs === 0) break; let pollResp: Response; - paidPollInFlight = true; try { - pollResp = await fetchWithTimeout(pollAbsoluteUrl, { + pollResp = await sendPaid(paid, () => fetchWithTimeout(pollAbsoluteUrl, { method: "GET", headers: { "PAYMENT-SIGNATURE": paymentPayload }, - }, pollTimeoutMs); + }, pollTimeoutMs), settledUsd); } catch { // Polling is idempotent and settlement has not been observed. A // transient disconnect is safe to retry inside the existing // deadline (the EIP-3009 nonce is single-use, so re-sending the // same header after a lost-in-flight settlement cannot settle // twice), and one reset must not abandon a nine-minute render. - // paidPollInFlight stays true: the request that never answered may + // The tracker stays armed: the request that never answered may // still be settling server-side. continue; } - paidPollInFlight = false; const pollData = await pollResp.json().catch(() => ({})) as { status?: string; @@ -761,7 +840,7 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC // finally released the reservation — a real charge the ledger never // saw, silently raising the cap by the lost amount. if (lastStatus === "completed" && !spendBooked) { - recordActualSpend(budget, settledUsd, estimatedCost, agent_id); + book(settledUsd); spendBooked = true; } @@ -770,7 +849,11 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC } if (lastStatus === "failed") { - throw new Error(`Upstream generation failed: ${pollData.error || "unknown"}. No payment taken.`); + // Typed: the upstream text rides along verbatim and can say + // anything — "rejected", "timeout" — none of which is a verdict + // on the money. The gateway's contract is that a failed job on + // this route is not charged. + throw new JobFailedError(`Upstream generation failed: ${pollData.error || "unknown"}. No payment taken.`, { jobId }); } if (pollResp.ok && lastStatus === "completed") { @@ -796,8 +879,8 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC } if (!completed) { - // Whether money moved depends on paidPollInFlight, which the catch - // reads; the message here states only what was observed. + // Whether money moved depends on the tracker, which the catch reads; + // the message here states only what was observed. throw new Error(`Video generation did not complete within ${Math.round(VIDEO_TOTAL_BUDGET_MS / 1000)}s (last status: ${lastStatus}).`); } @@ -817,7 +900,7 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC ]; // Backstop only — every reachable path here has already booked at the // poll site the moment "completed" was observed. - if (!spendBooked) recordActualSpend(budget, settledUsd, estimatedCost, agent_id); + if (!spendBooked) book(settledUsd); return { content: [{ type: "text", text: lines.join("\n") }], @@ -834,54 +917,100 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC }; } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); - // The account rail bills at SUBMIT. A failure after that — deadline, - // poll error, terminal failure — leaves a charge the ledger must carry - // (finally releases the reservation, so without this booking the cap - // silently rises by the clip price), and the one thing the caller must - // not do is "try again": that submits and bills a second job. Checked - // before isTimeoutError, which matches the deadline message and would - // glue retry advice onto a note saying the job was billed. + const reclaim = jobId ? ` The finished job stays claimable on the gateway for ~48h (job ${jobId}); re-running blockrun_video would start and charge a new job.` : ""; + // The order below is the classification, and it is the same on all + // three rails. Each step is a fact the code observed — a booking, a + // typed error, the tracker — never a word in the message: the message + // carries upstream text verbatim, and "rejected", "timeout" and + // "balance" have all arrived in it for reasons that had nothing to do + // with the money (audit round 3, C13/C32/C36/C37). + // + // 1. Settlement was observed and booked, then the result could not be + // used (no URL in a completed payload). The charge stands; the one + // thing not to do is run the tool again, which pays for a second + // render of a clip that is claimable for ~48h (D13). + if (bookedUsd !== null) { + return { + content: [{ type: "text", text: `Video generation completed and the charge stands — $${bookedUsd.toFixed(4)} was settled${jobId ? ` for job ${jobId}` : ""} and is booked against your budget — but the result could not be used: ${errMsg}${reclaim || " Re-running blockrun_video would start and charge a new job."}\nCheck blockrun_wallet action:"report" before doing anything else.` }], + isError: true, + }; + } + // 2. The gateway billed the job at SUBMIT — the account rail always, + // the Solana audio route (settled optimistically at POST) via the + // shared helper — and the failure came after: deadline, poll + // error, terminal failure, or a submit that never answered + // ("unknown"). The ledger must carry it (finally releases the + // reservation, so without this booking the cap silently rises by + // the clip price), and the one thing the caller must not do is + // "try again": that submits and bills a second job. if (err instanceof BilledJobError) { recordActualSpend(budget, err.paidUsd, estimatedCost, agent_id); + const account = isApiKeyMode(); + const billedTo = account ? "the BlockRun account" : "the Solana wallet"; const what = err.billing === "billed" - ? `Video generation did not return a clip, but the job was billed to the BlockRun account when the gateway accepted it${err.jobId ? ` (job ${err.jobId})` : ""}.` - : `Video generation got no answer to its submit, so the job MAY have been accepted and billed to the BlockRun account.`; + ? `Video generation did not return a clip, but the job was billed to ${billedTo} when the gateway accepted it${err.jobId ? ` (job ${err.jobId})` : ""}.` + : `Video generation got no answer to its submit, so the job MAY have been accepted and billed to ${billedTo}.`; + const where = account ? "https://user.blockrun.ai/dashboard/activity" : `blockrun_wallet action:"report" or the wallet's recent transactions`; return { - content: [{ type: "text", text: `${what} Check https://user.blockrun.ai/dashboard/activity before doing anything else — a new blockrun_video call starts and bills a second job.\nError: ${errMsg}` }], + content: [{ type: "text", text: `${what} Check ${where} before doing anything else — a new blockrun_video call starts and bills a second job.\nError: ${errMsg}` }], isError: true, }; } - if (isPaymentRejectionError(errMsg)) { + // 3. The wallet refused to pay: a 402 that came back on the signed + // request. The only branch that may say "out of funds" or open a + // top-up page — and it is reached by type, never by the words in + // an upstream failure (C36). The account rail's 402 is worded by + // apiKeyAsyncPost itself ("out of credit"), so nothing to add. + if (isPaymentRefusal(err)) { return { content: [{ type: "text", text: `Video generation needs USDC — your wallet is out of funds. ${(await launchTopUp()).note}\nError: ${errMsg}` }], isError: true, }; } + // 4. A request carrying the payment was outstanding and no answer was + // observed — the last signed poll dropped, or the submit did. The + // gateway's poll route settles a "completed" job whether or not we + // are still connected, so this is booked (the quote where one was + // seen, else the reserve) and said out loud. The tracker, not the + // chain, decides: 0.50.0 booked on the Solana rail for ANY timeout, + // including the unpaid quote probe aborting before anything was + // signed (C32/C37). + const giveUp = settleGiveUp(paid, err, { budget, agentId: agent_id, estimateUsd: estimatedCost, what: "Video generation", note: reclaim.trim() || undefined }); + if (giveUp) return { content: [{ type: "text", text: giveUp.text }], isError: true }; + // 5. The gateway answered a poll with "failed" on a route that charges + // on completion: nothing was charged, whatever the upstream text + // says (MiniMax's is "The operation was aborted due to timeout" — + // C13). No reclaim note: there is no finished job. + if (err instanceof JobFailedError) { + return { + content: [{ type: "text", text: formatError(`Video generation failed: ${errMsg}`, { altModels: "bytedance/seedance-2.0, azure/sora-2" }) }], + isError: true, + }; + } + // 6. A labelled 5xx is an ANSWER, not a timeout, however its text + // reads ("504 Gateway Timeout"). formatError says what a 5xx after + // a signed request means for the money; a bare "timed out" here + // would promise "no payment was taken" on a settled-at-submit route. + if (hasLabelledServerStatus(errMsg)) { + return { + content: [{ type: "text", text: formatError(`Video generation failed: ${errMsg}`, { altModels: "bytedance/seedance-2.0, azure/sora-2", afterPayment: paidRequestSent }) + reclaim }], + isError: true, + }; + } + // 7. A timeout with nothing outstanding: the unpaid quote probe + // aborted, signing failed before anything was sent, or the deadline + // passed after the last poll was ANSWERED — on the wallet rails + // settlement needs a signed poll to observe "completed", so nothing + // settled. (On the account rail every post-submit exit is step 2.) if (isTimeoutError(err)) { - const reclaim = jobId ? ` The finished job stays claimable on the gateway for ~48h (job ${jobId}); re-running blockrun_video would start and charge a new job.` : ""; - if (paidPollInFlight) { - // The gateway's poll route passes the request signal only to the - // upstream check; on "completed" it backs up the clip and settles - // regardless of whether we are still connected. Book the quote - // conservatively — over-counting a slow poll that settled nothing - // is the documented trade-off; under-counting a real charge is not. - recordActualSpend(budget, quotedUsd, estimatedCost, agent_id); - return { - content: [{ type: "text", text: `Video generation timed out while a poll carrying the payment signature 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.${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, }; } return { - content: [{ type: "text", text: formatError(`Video generation failed: ${errMsg}`, { altModels: "bytedance/seedance-2.0, azure/sora-2" }) }], + content: [{ type: "text", text: formatError(`Video generation failed: ${errMsg}`, { altModels: "bytedance/seedance-2.0, azure/sora-2", afterPayment: paidRequestSent }) }], isError: true, }; } finally { diff --git a/src/tools/wallet.ts b/src/tools/wallet.ts index dbdf1c9..3c5d153 100644 --- a/src/tools/wallet.ts +++ b/src/tools/wallet.ts @@ -8,10 +8,22 @@ import { describeBlock, formatCredit, getAccountCredit } from "../utils/account. import { generateQrPng, openQrInViewer } from "../utils/qr.js"; import { launchTopUp } from "../utils/onramp.js"; import { formatError } from "../utils/errors.js"; +import { delegateAgent, listRevokedAgents, revokeAgent, sealOperatorCeiling } from "../utils/budget.js"; import { TOOL_ANNOTATIONS } from "../tool-annotations.js"; import { appToolMeta } from "../apps.js"; export function registerWalletTool(server: McpServer, budget: BudgetState): void { + // The limit this ledger was BORN with is the operator's BLOCKRUN_BUDGET_LIMIT + // (initializeMcpServer seeds it from the env and registers tools right + // after). It is sealed here, before the model can call anything, and the + // budget/delegate branches below treat it as a ceiling the session may + // lower but never clear or exceed — see budget.ts for the failure this + // prevents. Null means the server started unlimited and set/clear keep + // their original, unrestricted meaning. + const ceiling = sealOperatorCeiling(budget); + const ceilingStr = ceiling !== null ? `$${ceiling.toFixed(2)}` : null; + const restartHint = `Only a restart with a higher BLOCKRUN_BUDGET_LIMIT raises it.`; + server.registerTool( "blockrun_wallet", { @@ -46,11 +58,15 @@ Actions: Budget controls: - budget + budget_action:"set" + budget_amount:1.00 → Set global spend cap -- budget + budget_action:"clear" → Remove global spend cap +- budget + budget_action:"check" (the default) → Report the cap, spend and remaining +- budget + budget_action:"clear" → Remove a cap set here +If the operator started the server with BLOCKRUN_BUDGET_LIMIT, that value is a +ceiling this tool can only lower: set above it is clamped, clear restores it, +and agent_limit is clamped to it. Only a restart with a new env raises it. Multi-agent orchestration: - delegate + agent_id:"research" + agent_limit:2.00 → Allocate $2 to a child agent -- revoke + agent_id:"research" → Remove a child agent's budget +- revoke + agent_id:"research" → Remove a child agent's cap (its spend is kept; re-delegating the id carries it) - report → See per-agent spending breakdown Usage pattern for multi-agent systems: @@ -65,7 +81,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)"), @@ -75,6 +91,13 @@ Do NOT call this for actual AI queries — use blockrun_chat for that.`, // Handle budget action if (action === "budget") { const budgetAct = budget_action || "check"; + // What the call did, in the model's own terms. A clamp or a restored + // ceiling is NOT an error — the state changed, just not to what was + // asked — but the reason has to be in the text, because the next thing + // an agent does after "Set to $2.00" when it asked for $1000 is ask + // again. + let outcome = ""; + let clamped = false; if (budgetAct === "set") { if (budget_amount === undefined || budget_amount <= 0) { @@ -83,9 +106,24 @@ Do NOT call this for actual AI queries — use blockrun_chat for that.`, isError: true, }; } - budget.limit = budget_amount; + if (ceiling !== null && budget_amount > ceiling) { + clamped = true; + budget.limit = ceiling; + outcome = ` | Requested $${budget_amount.toFixed(2)}, clamped to ${ceilingStr}: the operator set BLOCKRUN_BUDGET_LIMIT=${ceilingStr} as this process's ceiling and it cannot be raised from inside the session. ${restartHint}`; + } else { + budget.limit = budget_amount; + outcome = ` | Set to $${budget_amount.toFixed(2)}`; + } } else if (budgetAct === "clear") { - budget.limit = null; + if (ceiling !== null) { + budget.limit = ceiling; + outcome = ` | Restored to the operator ceiling ${ceilingStr} (BLOCKRUN_BUDGET_LIMIT); that cap cannot be removed from inside the session. ${restartHint}`; + } else { + budget.limit = null; + outcome = " | Limit removed"; + } + } else if (ceiling !== null) { + outcome = ` | Ceiling: ${ceilingStr} (BLOCKRUN_BUDGET_LIMIT, operator-set; this tool can only lower it)`; } const remaining = budget.limit !== null ? budget.limit - budget.spent : null; @@ -93,9 +131,11 @@ Do NOT call this for actual AI queries — use blockrun_chat for that.`, const remainingStr = remaining !== null ? `$${remaining.toFixed(4)}` : "N/A"; return { - content: [{ type: "text", text: `Session Budget: ${limitStr} | Spent: $${budget.spent.toFixed(4)} | Calls: ${budget.calls} | Remaining: ${remainingStr}${budgetAct === "set" ? ` | Set to $${budget_amount?.toFixed(2)}` : ""}${budgetAct === "clear" ? " | Limit removed" : ""}` }], + content: [{ type: "text", text: `Session Budget: ${limitStr} | Spent: $${budget.spent.toFixed(4)} | Calls: ${budget.calls} | Remaining: ${remainingStr}${outcome}` }], structuredContent: { limit: budget.limit, + ceiling, + clamped, spent: budget.spent, calls: budget.calls, remaining, @@ -111,20 +151,60 @@ 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 }); + // A child's cap cannot exceed the operator's. The global cap would + // stop the spend anyway; what a $50 allocation under a $2 ceiling + // gets wrong is the REPORT — an agent told it has $48 remaining plans + // for $48. + const requested = agent_limit; + const limit = ceiling !== null && agent_limit > ceiling ? ceiling : agent_limit; + // Carry the LEDGER across a re-delegation (and across a revoke — see + // delegateAgent). 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 entry is mutated in place rather than replaced: a paid call + // that reserved against it before this re-delegation must release + // against the same object afterwards, or the estimate is stranded on + // the ledger for the rest of the process. + const { entry, carried } = delegateAgent(budget, agent_id, limit); + const { spent, calls } = entry; + // 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, limit - spent) * 1e6) / 1e6; + const lines = [`Agent "${agent_id}" allocated $${limit.toFixed(2)} budget.`]; + if (limit !== requested) { + lines.push( + `Requested $${requested.toFixed(2)}, clamped to ${ceilingStr}: the operator set BLOCKRUN_BUDGET_LIMIT=${ceilingStr} ` + + `as this process's ceiling and no agent can be allocated more than that. ${restartHint}`, + ); + } + if (carried) { + 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 && 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, spent, calls, remaining }, }; } - // Revoke: remove an agent's budget allocation + // Revoke: remove an agent's cap. The ledger is kept — revoke + delegate + // is two model-callable calls, and letting them reset `spent` would be + // the refill the carry-over above exists to prevent. if (action === "revoke") { if (!agent_id) { return { content: [{ type: "text", text: formatError("agent_id required for revoke action") }], isError: true }; } - const existed = budget.agents.has(agent_id); - budget.agents.delete(agent_id); + const existed = revokeAgent(budget, agent_id); return { content: [{ type: "text", text: existed ? `Agent "${agent_id}" budget revoked.` : `Agent "${agent_id}" had no budget entry.` }], structuredContent: { agent_id, revoked: existed }, @@ -133,7 +213,7 @@ Do NOT call this for actual AI queries — use blockrun_chat for that.`, // Report: show spending breakdown by agent if (action === "report") { - const agentRows: Record = {}; + const agentRows: Record = {}; for (const [id, ab] of budget.agents.entries()) { agentRows[id] = { limit: ab.limit, @@ -142,18 +222,26 @@ Do NOT call this for actual AI queries — use blockrun_chat for that.`, remaining: Math.max(0, ab.limit - ab.spent), }; } + // Revoked ids keep their ledger (the next delegate carries it); the + // report shows it, because "its spend is kept" is what the + // description promises and a tombstone nobody can read is not kept. + for (const [id, ab] of listRevokedAgents(budget)) { + if (!(id in agentRows)) agentRows[id] = { limit: null, spent: ab.spent, calls: ab.calls, remaining: null, revoked: true }; + } const agentLines = Object.entries(agentRows).map( - ([id, ab]) => ` ${id}: $${ab.spent.toFixed(4)}/$${ab.limit.toFixed(2)} (${ab.calls} calls, $${ab.remaining.toFixed(4)} remaining)` + ([id, ab]) => ab.revoked + ? ` ${id}: $${ab.spent.toFixed(4)} (${ab.calls} calls, revoked — no cap; re-delegating carries this spend)` + : ` ${id}: $${ab.spent.toFixed(4)}/$${(ab.limit as number).toFixed(2)} (${ab.calls} calls, $${(ab.remaining as number).toFixed(4)} remaining)` ); const lines = [ - `Global: $${budget.spent.toFixed(4)} spent${budget.limit ? ` / $${budget.limit.toFixed(2)} limit` : " (no limit)"} — ${budget.calls} calls`, + `Global: $${budget.spent.toFixed(4)} spent${budget.limit ? ` / $${budget.limit.toFixed(2)} limit` : " (no limit)"} — ${budget.calls} calls${ceiling !== null ? ` (operator ceiling ${ceilingStr} via BLOCKRUN_BUDGET_LIMIT)` : ""}`, ``, `Per-agent budgets (${budget.agents.size} active):`, ...(agentLines.length > 0 ? agentLines : [" (none delegated)"]), ]; return { content: [{ type: "text", text: lines.join("\n") }], - structuredContent: { global: { limit: budget.limit, spent: budget.spent, calls: budget.calls }, agents: agentRows }, + structuredContent: { global: { limit: budget.limit, ceiling, spent: budget.spent, calls: budget.calls }, agents: agentRows }, }; } @@ -404,6 +492,12 @@ SECURITY: Private key stored at ~/.blockrun/.session by default (never leaves yo const envNote = envIgnored ? `\n\n⚠️ SOLANA_WALLET_KEY is set but the active chain is BASE — a stored chain preference outranks it. Run action:"chain" chain:"solana" to switch (that also clears the stored preference).` : ""; + // The description promises "session spending" from status, and tells + // the model to check here before an expensive call. The balance alone + // answers "can the wallet pay?" — not "am I still inside my cap?", which + // is the question an agent on a $1 allotment is actually asking. Same + // line and fields as the api-key branch, so a client renders one shape. + const session = `$${budget.spent.toFixed(4)}${budget.limit ? ` / $${budget.limit.toFixed(2)} local cap` : ""} — ${budget.calls} calls`; const text = `Active chain: ${chain.toUpperCase()} (switch with action:"chain" chain:"base"|"solana") ${mark("base")} Base: ${both.base.address} @@ -411,6 +505,7 @@ ${mark("base")} Base: ${both.base.address} ${mark("solana")} Solana: ${both.solana.address} ${fmt(solBal)}${solBal !== null && solBal < 1 ? " (low)" : ""} +This session: ${session} Paying on ${chain} | View active: ${info.explorerUrl}${info.isNew ? "\nNEW WALLET on active chain — run action:'setup' for funding instructions" : ""}${envNote}`; return { @@ -424,6 +519,9 @@ Paying on ${chain} | View active: ${info.explorerUrl}${info.isNew ? "\nNEW WALLE isNew: info.isNew, explorerUrl: info.explorerUrl, explorerLabel, + sessionSpend: budget.spent, + calls: budget.calls, + limit: budget.limit, wallets: { base: { address: both.base.address, balance: baseBal }, solana: { address: both.solana.address, balance: solBal }, diff --git a/src/utils/api-key-call.ts b/src/utils/api-key-call.ts index 710ff9f..d283b88 100644 --- a/src/utils/api-key-call.ts +++ b/src/utils/api-key-call.ts @@ -16,7 +16,7 @@ // it needs its own module rather than a flag threaded through the 402 code. import { fetchWithTimeout } from "./http.js"; -import { pollTimeoutFor } from "./poll.js"; +import { JobFailedError, pollTimeoutFor } from "./poll.js"; import { apiAuthHeaders } from "./auth.js"; import { getApiBase, resolveGatewayUrl } from "./wallet.js"; @@ -181,8 +181,25 @@ function statusErrorMessage(response: Response, what: string, body: Record { - throw new Error(statusErrorMessage(response, what, await readJson(response))); + throw new AccountApiError(statusErrorMessage(response, what, await readJson(response)), response.status); } /** POST an endpoint that answers inline. `endpoint` is rooted, e.g. "/v1/audio/speech". */ @@ -293,7 +310,17 @@ export async function apiKeyAsyncPost( return { data: submitted, paidUsd: costFrom(submit), txHash: receiptFrom(submit), jobId }; } if (!pollUrl) { - throw new Error(`Async submit missing poll_url: ${JSON.stringify(submitted)}`); + // A 202 IS the acceptance, and this rail bills on acceptance. A malformed + // envelope is a billed job this client cannot poll — typed as such, so the + // tool books it and names the job, instead of the plain Error that the + // callers' comments ("every post-submit exit is a BilledJobError") were + // wrong about until audit round 4. + throw new BilledJobError( + `Async submit answered 202 without a poll_url (${JSON.stringify(submitted)}), so the job cannot be polled from here. ` + + `It has already been billed to the account${jobId ? `; job id ${jobId}` : ""} — ` + + `check https://user.blockrun.ai/dashboard/activity before submitting again.`, + { paidUsd: submitCost, jobId, billing: "billed" }, + ); } const absolutePollUrl = resolveGatewayUrl(pollUrl); @@ -339,7 +366,9 @@ export async function apiKeyAsyncPost( const note = typeof data.note === "string" ? data.note : undefined; const failed = `Upstream generation failed: ${String(data.error ?? "unknown")}.`; if (paymentStatus === "not_charged") { - throw new Error(`${failed} ${note ?? "No payment was taken."}`); + // Typed: the upstream text rides along verbatim and can say "timeout" + // or "aborted" — none of it is a verdict on the money. The type is. + throw new JobFailedError(`${failed} ${note ?? "No payment was taken."}`, { jobId }); } // Anything short of an observed refund is bookable: an explicit charged // status is certain, an absent one is unknown — and unknown books too, diff --git a/src/utils/budget.ts b/src/utils/budget.ts index 617d920..e5196ed 100644 --- a/src/utils/budget.ts +++ b/src/utils/budget.ts @@ -1,5 +1,5 @@ // src/utils/budget.ts -import type { BudgetState } from "../types.js"; +import type { AgentBudget, BudgetState } from "../types.js"; const EPSILON = 1e-9; @@ -149,12 +149,17 @@ export function recordSpending(budget: BudgetState, cost: number, agentId?: stri budget.calls += 1; if (agentId) { - const agentBudget = budget.agents.get(agentId); + // The live entry, or the tombstone a revoke left behind: a call that + // settles while its id is revoked still spent that agent's money, and the + // next delegate of the id carries the ledger back. Until audit round 4 + // this looked only at the live map, so revoke → settle → delegate forgot + // the call — a per-agent refill, one in-flight call at a time. + const agentBudget = budget.agents.get(agentId) ?? revokedLedgers.get(budget)?.get(agentId); if (agentBudget) { agentBudget.spent += cost; agentBudget.calls += 1; } - // If no budget entry for this agent, spending is tracked globally only + // If no entry for this agent anywhere, spending is tracked globally only } } @@ -180,6 +185,17 @@ export function amountToUsd(amount: unknown): number | null { * real on-chain spend: the old path recorded a flat estimate, so a frontier * chat or high-resolution video could settle for orders of magnitude more than * was booked, silently blowing past the cap. + * + * ZERO IS A SETTLED FIGURE, NOT AN ABSENT ONE. The account rail writes + * `x-blockrun-cost-usd: 0.000000` for a charge that really resolved to nothing, + * and parseCostHeader preserves it as 0 for exactly this call; the wallet rails + * report a genuinely free model as a 0 counter delta. Until audit round 3 this + * treated 0 like null (`actualUsd > 0`) and booked the ESTIMATE for it, so a + * free-priced account call was recorded at the reserve and a $0.05 delegate + * was cut off after seven of them having spent nothing (D33/D40). "Unknown" is + * spelled null/undefined; NaN and a negative are estimator bugs and fall back + * the same way. Callers that cannot tell free from unknown must pass null — + * amountToUsd already does, mapping a missing or "0" x402 amount to null. */ export function recordActualSpend( budget: BudgetState, @@ -188,7 +204,7 @@ export function recordActualSpend( agentId?: string, ): void { const cost = - typeof actualUsd === "number" && Number.isFinite(actualUsd) && actualUsd > 0 + typeof actualUsd === "number" && Number.isFinite(actualUsd) && actualUsd >= 0 ? actualUsd : Math.max(0, estimate); recordSpending(budget, cost, agentId); @@ -206,6 +222,123 @@ export function parseBudgetLimitEnv(raw: string | undefined): number | null { return Number.isFinite(n) && n > 0 ? n : null; } +// --------------------------------------------------------------------------- +// The operator's ceiling — the one number the session cannot raise +// --------------------------------------------------------------------------- +// +// BLOCKRUN_BUDGET_LIMIT is documented as the hard stop for clients that cannot +// render the spend dialog: on those, it is the ONLY guard. It used to seed the +// same mutable `budget.limit` that blockrun_wallet action:"budget" writes, so +// the agent it constrained could clear it (limit = null) or raise it (any +// positive number) in one free, non-destructive, un-elicited tool call — and +// the denial text it received at the cap pointed it at exactly that tool. +// +// The env value is therefore remembered separately, keyed by the ledger it +// seeded, and the wallet tool treats it as a ceiling: `set` may lower the +// session cap or raise it back UP TO the ceiling, `clear` restores the ceiling +// rather than lifting it, and a delegated per-agent cap is clamped to it. +// Without the env the ceiling is null and the tool keeps its old contract — +// set/clear are then the operator's own session controls, and there is no one +// to protect them from. +// +// A WeakMap rather than a field on BudgetState: the ledger is constructed in +// one place and handed by reference to every tool, and the ceiling is a fact +// about how that ledger was BORN, not state the tools update. Sealing happens +// at wallet-tool registration, which initializeMcpServer runs right after the +// env seed — so "the limit the server started with" and "the env value" are +// the same number, and a cap the model sets later in the session is not +// mistaken for one. +const operatorCeilings = new WeakMap(); + +/** + * Record the current `budget.limit` as the operator ceiling for this ledger, + * once. Later calls return the sealed value and do not re-read `limit`, so a + * session `set` cannot become the ceiling by being sealed after the fact. + */ +export function sealOperatorCeiling(budget: BudgetState): number | null { + if (!operatorCeilings.has(budget)) operatorCeilings.set(budget, budget.limit); + return operatorCeilings.get(budget) ?? null; +} + +/** The sealed operator ceiling, or null when the server started unlimited. */ +export function getOperatorCeiling(budget: BudgetState): number | null { + return operatorCeilings.get(budget) ?? null; +} + +// --------------------------------------------------------------------------- +// Per-agent allocations — the ledger follows the agent_id, not the Map entry +// --------------------------------------------------------------------------- +// +// Two bugs shared a root: `delegate` REPLACED the Map entry and `revoke` +// DELETED it, while the reservation closure in reserveBudget() holds the entry +// object it was given. So a re-delegation two minutes into a $1 render carried +// `spent` into a new object, the actual was booked on the new one and the +// estimate released from the old — the agent was over-counted by the estimate +// for the rest of the process. And revoke + delegate (two calls, both the +// model's to make) started the id at zero, which is the refill 0.50.0 said it +// had closed. +// +// So the entry object is never replaced: re-delegation mutates its `limit`, +// and revoke moves the SAME object into a per-ledger tombstone map that the +// next delegate of that id restores. A reservation taken before either +// operation releases against the object that is live after it. Spend made +// while an id is revoked is tracked globally only, as before — a revoked id +// has no per-agent cap, and a cap it does not have cannot be charged against. +const revokedLedgers = new WeakMap>(); + +/** + * Allocate (or re-allocate) a per-agent cap. Returns the live entry and + * whether it carried a prior ledger — from a live entry or a revoked one — so + * the caller can say so. Never resets `spent`/`calls`. + */ +export function delegateAgent( + budget: BudgetState, + agentId: string, + limit: number, +): { entry: AgentBudget; carried: boolean } { + const live = budget.agents.get(agentId); + if (live) { + live.limit = limit; + return { entry: live, carried: true }; + } + const tombs = revokedLedgers.get(budget); + const revoked = tombs?.get(agentId); + if (revoked) { + tombs!.delete(agentId); + revoked.limit = limit; + budget.agents.set(agentId, revoked); + return { entry: revoked, carried: true }; + } + const entry: AgentBudget = { limit, spent: 0, calls: 0 }; + budget.agents.set(agentId, entry); + return { entry, carried: false }; +} + +/** + * The ledgers revoke left behind, for action:"report": the tool description + * promises "its spend is kept", and kept spend nobody can read is not kept. + */ +export function listRevokedAgents(budget: BudgetState): Array<[string, AgentBudget]> { + return [...(revokedLedgers.get(budget)?.entries() ?? [])]; +} + +/** + * Remove an agent's cap. Its ledger is kept (tombstoned) so a later delegate + * of the same id carries the spend. Returns false when there was no entry. + */ +export function revokeAgent(budget: BudgetState, agentId: string): boolean { + const live = budget.agents.get(agentId); + if (!live) return false; + budget.agents.delete(agentId); + let tombs = revokedLedgers.get(budget); + if (!tombs) { + tombs = new Map(); + revokedLedgers.set(budget, tombs); + } + tombs.set(agentId, live); + return true; +} + // --------------------------------------------------------------------------- // Quote sanity — pay what you were told, or nothing // --------------------------------------------------------------------------- diff --git a/src/utils/chat-stream.ts b/src/utils/chat-stream.ts index b1a2804..a6841b9 100644 --- a/src/utils/chat-stream.ts +++ b/src/utils/chat-stream.ts @@ -12,9 +12,21 @@ // // The SDK's fetchWithTimeout clears its abort timer once response HEADERS // arrive, so reading the body has no client-side deadline — the idle guard -// here (readWithIdleTimeout) is therefore the ONLY thing standing between a -// stalled stream and hanging forever. +// here (withIdleTimeout, around every read) is therefore the ONLY thing +// standing between a stalled stream and hanging forever. +// +// Two clients, one assembler. LLMClient (Base wallet, and the account rail) +// exposes chatCompletionStream() and hands back the raw Response — headers +// included, which is where the account rail's `x-blockrun-cost-usd` lives. +// SolanaLLMClient has no chatCompletionStream, but since @blockrun/llm 3.15.1 +// it ships stream(path, body), which pays the 402, records the settlement, and +// yields each decoded SSE frame. Until audit round 3 the comment here said the +// Solana client "cannot" stream, and every paid chat on the DEFAULT chain ran +// the non-streaming path with the SDK's 60s Solana timeout — so a generation +// over a minute was aborted client-side after the SPL payment was sent (C19). +// Both shapes now feed the same accumulator (assembleChatFrames). import type { ApiClient } from "./wallet.js"; +import { parseCostHeader } from "./api-key-call.js"; /** Chat message shape the gateway accepts (content may be multimodal parts). */ export interface StreamChatMessage { @@ -29,62 +41,162 @@ export interface StreamChatOptions { stop?: string[]; } -/** Narrow an ApiClient to one that can stream (SolanaLLMClient cannot). */ -export function supportsStreaming( - client: ApiClient, -): client is ApiClient & { chatCompletionStream: (model: string, messages: unknown, options?: unknown) => Promise } { +/** Token counts the gateway reports for the call, when it does. */ +export interface ChatUsage { + promptTokens: number; + completionTokens: number; +} + +/** + * Everything a chat call comes back with that the caller has to act on — not + * just the text. `servedModel` is the id the GATEWAY says answered; constants.ts + * documents that retired ids are silently aliased onto a live model and that + * only this field tells you (D55). `finishReason` "length" means the reply was + * cut at max_tokens (D57). `settledUsd` is `x-blockrun-cost-usd` when the rail + * sent it — a settled zero included — and null when it did not. + */ +export interface ChatOutcome { + text: string; + servedModel: string | null; + finishReason: string | null; + usage: ChatUsage | null; + settledUsd: number | null; +} + +/** + * A failure that arrived AFTER the gateway had answered 2xx: a mid-stream error + * event, an idle stall, an empty-length completion, an unreadable body. The + * distinction is money. On the wallet rails x402 settles on the 200, and on the + * account rail the request is billed once accepted — so this class is the + * evidence a caller needs to book the charge, where a pre-acceptance throw + * (a 4xx from the first response, a refused payment) books nothing. + * + * `partialText` is whatever had streamed before the failure. The caller paid + * for those tokens; discarding them turned "900 tokens then an error frame" + * into a bare error (D57). + */ +export class AcceptedThenFailedError extends Error { + readonly partialText: string; + constructor(message: string, partialText = "", options?: { cause?: unknown }) { + super(message, options); + this.name = "AcceptedThenFailedError"; + this.partialText = partialText; + } +} + +type StreamingClient = ApiClient & { chatCompletionStream: (model: string, messages: unknown, options?: unknown) => Promise }; +type FrameStreamingClient = ApiClient & { stream: (path: string, body: Record) => AsyncGenerator }; + +/** Narrow an ApiClient to one whose stream call returns the raw Response (LLMClient). */ +export function supportsStreaming(client: ApiClient): client is StreamingClient { return typeof (client as { chatCompletionStream?: unknown }).chatCompletionStream === "function"; } -async function readWithIdleTimeout( - reader: ReadableStreamDefaultReader, - ms: number, -): Promise> { +/** Narrow an ApiClient to one whose stream call yields decoded frames (SolanaLLMClient). */ +export function supportsFrameStreaming(client: ApiClient): client is FrameStreamingClient { + return typeof (client as { stream?: unknown }).stream === "function"; +} + +/** + * The account rail's settled figure off a response, when it sent one. Absent + * is "unknown" (chat settles after the response by design), never "free"; an + * explicit 0.000000 is a settled zero. parseCostHeader draws that line. + */ +export function settledCostFromHeaders(headers: { get(name: string): string | null } | null | undefined): number | null { + return parseCostHeader(headers?.get("x-blockrun-cost-usd")); +} + +function stallError(ms: number): AcceptedThenFailedError { + return new AcceptedThenFailedError(`stream stalled: no data from the gateway for ${Math.round(ms / 1000)}s`); +} + +async function withIdleTimeout(read: () => Promise, ms: number): Promise { let timer: NodeJS.Timeout | undefined; const stall = new Promise((_, reject) => { - timer = setTimeout( - () => reject(new Error(`stream stalled: no data from the gateway for ${Math.round(ms / 1000)}s`)), - ms, - ); + timer = setTimeout(() => reject(stallError(ms)), ms); }); try { - return await Promise.race([reader.read(), stall]); + return await Promise.race([read(), stall]); } finally { clearTimeout(timer); } } /** - * Assemble the complete reply from an OpenAI-compatible SSE body. + * One decoded OpenAI-compatible chunk folded into the running result. * * - Concatenates `choices[0].delta.content`; `reasoning_content` is collected * separately and used only as a fallback when no content ever arrives, so a * provider that splits reasoning out doesn't produce an empty answer. * - An `error` object mid-stream throws (the gateway reports upstream failures - * in-band once headers are already 200). - * - A JSON parse failure on a single data line skips that line; SSE comment/ - * keepalive lines (":…") and blank lines are ignored by the data: filter. + * in-band once headers are already 200) — with the partial text attached. + * - `model` is taken from any chunk that carries it (every chunk does, on both + * gateways); `usage` from the final choices-less chunk. + */ +class ChatAccumulator { + text = ""; + reasoning = ""; + finishReason: string | null = null; + servedModel: string | null = null; + usage: ChatUsage | null = null; + + fold(event: unknown): void { + const ev = event as { + error?: { message?: string } | string; + model?: unknown; + usage?: { prompt_tokens?: unknown; completion_tokens?: unknown }; + choices?: Array<{ + delta?: { content?: unknown; reasoning_content?: unknown }; + message?: { content?: unknown }; + finish_reason?: string | null; + }>; + }; + if (ev?.error) { + const msg = typeof ev.error === "string" ? ev.error : ev.error.message ?? JSON.stringify(ev.error); + throw new AcceptedThenFailedError(`upstream error mid-stream: ${msg}`, this.result().text); + } + if (typeof ev?.model === "string" && ev.model) this.servedModel = ev.model; + const u = ev?.usage; + if (u && typeof u.prompt_tokens === "number" && typeof u.completion_tokens === "number") { + this.usage = { promptTokens: u.prompt_tokens, completionTokens: u.completion_tokens }; + } + const choice = ev?.choices?.[0]; + // Some providers put the final text in `message` on the last chunk + // instead of a delta; treat both, delta first. + const content = choice?.delta?.content ?? choice?.message?.content; + if (typeof content === "string") this.text += content; + const rc = choice?.delta?.reasoning_content; + if (typeof rc === "string") this.reasoning += rc; + if (choice?.finish_reason) this.finishReason = choice.finish_reason; + } + + result(): Omit { + return { text: this.text || this.reasoning, finishReason: this.finishReason, servedModel: this.servedModel, usage: this.usage }; + } +} + +/** + * Assemble the complete reply from an OpenAI-compatible SSE body. + * + * A JSON parse failure on a single data line skips that line; SSE comment/ + * keepalive lines (":…") and blank lines are ignored by the data: filter. * * Exported for tests. */ export async function assembleSseChatStream( resp: { body: ReadableStream | null }, idleTimeoutMs = 120_000, -): Promise<{ text: string; finishReason: string | null }> { - if (!resp.body) throw new Error("streaming response had no body"); +): Promise> { + if (!resp.body) throw new AcceptedThenFailedError("streaming response had no body"); const reader = resp.body.getReader(); const decoder = new TextDecoder(); + const acc = new ChatAccumulator(); let buffer = ""; - let text = ""; - let reasoning = ""; - let finishReason: string | null = null; - - const finish = () => ({ text: text || reasoning, finishReason }); try { for (;;) { - const chunk = await readWithIdleTimeout(reader, idleTimeoutMs); - if (chunk.done) return finish(); + const chunk = await withIdleTimeout(() => reader.read(), idleTimeoutMs); + if (chunk.done) return acc.result(); buffer += decoder.decode(chunk.value, { stream: true }); let nl: number; while ((nl = buffer.indexOf("\n")) !== -1) { @@ -92,33 +204,14 @@ export async function assembleSseChatStream( buffer = buffer.slice(nl + 1); if (!line.startsWith("data:")) continue; const payload = line.slice(5).trim(); - if (payload === "[DONE]") return finish(); + if (payload === "[DONE]") return acc.result(); let event: unknown; try { event = JSON.parse(payload); } catch { continue; // malformed single line — never abandon the whole stream for it } - const ev = event as { - error?: { message?: string } | string; - choices?: Array<{ - delta?: { content?: unknown; reasoning_content?: unknown }; - message?: { content?: unknown }; - finish_reason?: string | null; - }>; - }; - if (ev?.error) { - const msg = typeof ev.error === "string" ? ev.error : ev.error.message ?? JSON.stringify(ev.error); - throw new Error(`upstream error mid-stream: ${msg}`); - } - const choice = ev?.choices?.[0]; - // Some providers put the final text in `message` on the last chunk - // instead of a delta; treat both, delta first. - const content = choice?.delta?.content ?? choice?.message?.content; - if (typeof content === "string") text += content; - const rc = choice?.delta?.reasoning_content; - if (typeof rc === "string") reasoning += rc; - if (choice?.finish_reason) finishReason = choice.finish_reason; + acc.fold(event); } } } catch (err) { @@ -129,37 +222,267 @@ export async function assembleSseChatStream( } /** - * Streamed equivalent of `client.chat(...)` / `client.chatCompletion(...)`: - * returns the assembled reply text. The caller decides WHEN to stream - * (paid EVM paths); this decides HOW. + * The same assembly over already-decoded frames — what SolanaLLMClient.stream() + * yields. The SDK's generator has no idle guard of its own (its fetch deadline + * ends at the headers, like LLMClient's), so the stall timer wraps each next(). + * + * Exported for tests. + */ +export async function assembleChatFrames( + frames: AsyncIterator | AsyncIterable, + idleTimeoutMs = 120_000, + acc = new ChatAccumulator(), +): Promise> { + const it: AsyncIterator = Symbol.asyncIterator in (frames as object) + ? (frames as AsyncIterable)[Symbol.asyncIterator]() + : (frames as AsyncIterator); + try { + for (;;) { + const next = await withIdleTimeout(() => it.next(), idleTimeoutMs); + if (next.done) return acc.result(); + acc.fold(next.value); + } + } catch (err) { + // Ask the generator to finish so the SDK releases its reader lock. NOT + // awaited: a generator suspended inside `await reader.read()` (the stall + // case) only honours return() once that read resolves, which is never — + // awaiting it here would turn the stall guard back into a hang. + void it.return?.(undefined).catch(() => undefined); + throw err; + } +} + +/** + * Reasoning models stream their hidden thinking as empty-content keepalive + * chunks, and those tokens COUNT toward max_tokens. A hard task with a small + * budget can burn the whole budget reasoning and emit zero visible text — + * finish_reason "length" with an empty answer (measured live: kimi-k3, + * 4000 max_tokens, 125s of keepalives, 0 chars). Returning "" would be + * indistinguishable from success; say what happened and what to change. + */ +function rejectEmptyLength(model: string, out: Omit): void { + if (!out.text && out.finishReason === "length") { + throw new AcceptedThenFailedError( + `${model} spent the entire max_tokens budget on internal reasoning and produced no visible answer. ` + + `Raise max_tokens (reasoning tokens count against it) or simplify the request.`, + ); + } +} + +/** + * Anything thrown once a 2xx is in hand is a post-acceptance failure, whatever + * its type: a body-read error, a JSON parse error on a non-SSE answer, the + * assembler's own throws. Wrap it so the caller can tell it from a refusal. + */ +async function afterAccept(run: () => Promise): Promise { + try { + return await run(); + } catch (err) { + if (err instanceof AcceptedThenFailedError) throw err; + const msg = err instanceof Error ? err.message : String(err); + throw new AcceptedThenFailedError(msg, "", { cause: err }); + } +} + +/** + * Streamed equivalent of `client.chat(...)` / `client.chatCompletion(...)` over + * LLMClient: returns the assembled reply plus what the gateway said about it. + * The caller decides WHEN to stream; this decides HOW. */ export async function streamChatText( - client: ApiClient & { chatCompletionStream: (model: string, messages: unknown, options?: unknown) => Promise }, + client: StreamingClient, model: string, messages: StreamChatMessage[], options: StreamChatOptions, -): Promise { + idleTimeoutMs?: number, +): Promise { + // The SDK throws on any non-OK response before returning, so a Response here + // IS the acceptance — money has moved (wallet) or will be billed (account). const resp = await client.chatCompletionStream(model, messages, options); - // A provider/route that ignores `stream:true` answers with a plain JSON - // completion. Feeding that to the SSE parser would "succeed" with an empty - // string — the silent-truncation failure shape this module must never add. - const contentType = (resp.headers?.get?.("content-type") ?? "").toLowerCase(); - if (!contentType.includes("text/event-stream")) { - const data = (await resp.json()) as { choices?: Array<{ message?: { content?: string } }> }; - return data.choices?.[0]?.message?.content ?? ""; + const settledUsd = settledCostFromHeaders(resp.headers); + return afterAccept(async () => { + // A provider/route that ignores `stream:true` answers with a plain JSON + // completion. Feeding that to the SSE parser would "succeed" with an empty + // string — the silent-truncation failure shape this module must never add. + const contentType = (resp.headers?.get?.("content-type") ?? "").toLowerCase(); + if (!contentType.includes("text/event-stream")) { + const data = await resp.json(); + const acc = new ChatAccumulator(); + acc.fold(data); + const out = acc.result(); + rejectEmptyLength(model, out); + return { ...out, settledUsd }; + } + const out = await assembleSseChatStream(resp, idleTimeoutMs); + rejectEmptyLength(model, out); + return { ...out, settledUsd }; + }); +} + +/** + * One chat call, whichever client this is, as a ChatOutcome. + * + * - LLMClient (Base wallet, account rail): chatCompletionStream when `stream`. + * - SolanaLLMClient: stream("/v1/chat/completions", { …, stream: true }) when + * `stream` — settlement is recorded before the first frame, so the idle guard + * and the post-acceptance class apply exactly as on Base. + * - Otherwise, or when `stream` is false (the free tier, whose short per-model + * timeout the deadline loop depends on): the non-streaming chatCompletion, + * whose parsed body still carries `model`, `finish_reason` and `usage`. + */ +export async function completeChat( + client: ApiClient, + model: string, + messages: StreamChatMessage[], + options: StreamChatOptions, + opts: { stream: boolean; idleTimeoutMs?: number }, +): Promise { + if (opts.stream && supportsStreaming(client)) { + return streamChatText(client, model, messages, options, opts.idleTimeoutMs); } - const { text, finishReason } = await assembleSseChatStream(resp); - // Reasoning models stream their hidden thinking as empty-content keepalive - // chunks, and those tokens COUNT toward max_tokens. A hard task with a small - // budget can burn the whole budget reasoning and emit zero visible text — - // finish_reason "length" with an empty answer (measured live: kimi-k3, - // 4000 max_tokens, 125s of keepalives, 0 chars). Returning "" would be - // indistinguishable from success; say what happened and what to change. - if (!text && finishReason === "length") { - throw new Error( - `${model} spent the entire max_tokens budget on internal reasoning and produced no visible answer. ` + - `Raise max_tokens (reasoning tokens count against it) or simplify the request.`, - ); + if (opts.stream && supportsFrameStreaming(client)) { + // The SDK does not inject stream:true ("silently rewriting a caller's body + // is how you end up debugging a request you did not send"); the body is + // the same shape LLMClient.chatCompletionStream builds. + const body: Record = { model, messages, max_tokens: options.maxTokens ?? 1024, stream: true }; + if (options.temperature !== undefined) body.temperature = options.temperature; + if (options.responseFormat !== undefined) body.response_format = options.responseFormat; + if (options.stop !== undefined) body.stop = options.stop; + const gen = client.stream("/v1/chat/completions", body); + // openPaidStream pays and records the settlement before the first frame is + // handed out, so the very first next() is where a pre-acceptance throw + // (unpaid 4xx, refused payment, the SDK's abort on the paid retry) surfaces + // — outside afterAccept, unwrapped, for settlementOnThrow to read. A STALL + // waiting for that first frame is the one case this module cannot place: + // the paid request may still be in flight (payment sent, no verdict) or + // the 200 may be in and the upstream silent. Either way the counter says + // what was recorded, so it is rethrown as a plain timeout — "unknown" to + // the classifier, which books the reserve — rather than as accepted. + const first = await withIdleTimeout(() => gen.next(), opts.idleTimeoutMs ?? 120_000).catch((err: unknown) => { + if (err instanceof AcceptedThenFailedError) throw new Error(`timeout: ${err.message}, before the first frame`, { cause: err }); + throw err; + }); + return afterAccept(async () => { + const acc = new ChatAccumulator(); + if (!first.done) acc.fold(first.value); + const out = first.done ? acc.result() : await assembleChatFrames(gen, opts.idleTimeoutMs, acc); + rejectEmptyLength(model, out); + return { ...out, settledUsd: null }; + }); } - return text; + const r = await client.chatCompletion(model, messages as unknown as Parameters[1], options); + return afterAccept(async () => { + const acc = new ChatAccumulator(); + acc.fold(r); + const out = acc.result(); + rejectEmptyLength(model, out); + return { ...out, settledUsd: null }; + }); +} + +// --------------------------------------------------------------------------- +// Did money move? — classifying a chat call that THREW +// --------------------------------------------------------------------------- + +/** + * "none": nothing was accepted, so nothing was charged — book $0, and a + * fallback loop may go on to the next model. + * "unknown": a payment was signed and sent (wallet) or the request reached + * the gateway (account) and no verdict came back — a timeout on the + * paid retry, an edge 502/504/52x, a reset mid-flight. The gateway + * settles those after the client has given up. Book the reserve as a + * precaution and never pay a second model for the same call. + * "settled": the gateway answered 2xx and the failure came after — the charge + * is certain (AcceptedThenFailedError). + */ +export type SettlementVerdict = "none" | "unknown" | "settled"; + +// Statuses an edge or a load balancer returns when the ORIGIN did not answer in +// time — the origin may still be running the request and settle it afterwards +// (the Cloud Run route documents that a client disconnect is never propagated +// to a non-streaming handler). Everything else in the 4xx/5xx range is the +// gateway itself answering, which it does before settlement starts. +const ORIGIN_DID_NOT_ANSWER = new Set([408, 502, 504, 520, 521, 522, 523, 524, 525, 526, 527, 529, 530]); + +// A fetch rejection that proves the request never left this machine. +const NEVER_CONNECTED = new Set(["ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED", "ENETUNREACH", "EHOSTUNREACH", "EADDRNOTAVAIL"]); + +// Transport failures where the request may have been in flight when it died. +const IN_FLIGHT_TRANSPORT = /aborted|timeout|timed out|fetch failed|socket hang up|ECONNRESET|ETIMEDOUT|EPIPE|terminated|network/i; + +function statusOf(error: unknown): number | undefined { + const e = error as { statusCode?: unknown; status?: unknown } | undefined; + if (typeof e?.statusCode === "number") return e.statusCode; // @blockrun/llm APIError + if (typeof e?.status === "number") return e.status; // @anthropic-ai/sdk APIError + return undefined; +} + +function messageOf(error: unknown): string { + if (error instanceof Error) { + const cause = (error as { cause?: unknown }).cause; + const causeMsg = cause instanceof Error ? cause.message : typeof cause === "string" ? cause : ""; + return `${error.message} ${causeMsg}`; + } + return String(error); +} + +export function settlementOnThrow( + error: unknown, + opts: { + rail: "wallet" | "account"; + estimateUsd: number; + /** + * The native claude-* path: @blockrun/llm's AnthropicClient pays the 402 + * INSIDE its fetch, so the status the SDK surfaces is already the paid + * retry's — there is no "after payment" prefix to read. A 4xx there is the + * gateway's refusal before settlement (its /v1/messages route passes 4xx + * through and does not settle); anything the origin did not answer, or a + * 5xx, may have settled. + */ + transparentPayment?: boolean; + }, +): SettlementVerdict { + // A $0 estimate is a free model: whatever happened, it cannot have cost + // anything, and mode:"free" must keep walking its candidates. + if (!(opts.estimateUsd > 0)) return "none"; + if (error instanceof AcceptedThenFailedError) return "settled"; + + const name = (error as { name?: unknown } | undefined)?.name; + const msg = messageOf(error); + const lower = msg.toLowerCase(); + + // The wallet could not or would not pay: the SDK's PaymentError, or the same + // sentence surfaced through the Anthropic SDK's connection-error wrapper. + if (name === "PaymentError" || /payment was rejected|no payment requirements|insufficient|check your .*balance/i.test(msg)) return "none"; + // Refused by this process before anything was sent. + if (name === "BudgetExceededError" || name === "QuoteMismatchError") return "none"; + + const cause = (error as { cause?: { code?: unknown } } | undefined)?.cause; + if (typeof cause?.code === "string" && NEVER_CONNECTED.has(cause.code)) return "none"; + + const status = statusOf(error); + if (status !== undefined) { + if (opts.rail === "wallet") { + // "API error: N" is the UNPAID first response — the gateway wants a + // payment it never got, or refused the request outright. No money. + // "API error after payment: N" is the paid retry: a 4xx there is the + // gateway's own refusal before settlement starts (its streaming route + // says so in as many words); an edge timeout may hide a settle. + if (!opts.transparentPayment && !lower.includes("after payment")) return "none"; + return ORIGIN_DID_NOT_ANSWER.has(status) ? "unknown" : status >= 500 ? "unknown" : "none"; + } + // Account rail: every non-OK first response throws with its status before + // any body exists — 400 unknown model, 401, 402 out of credit, 429 — and + // none of those is billed. Only an origin that did not answer is ambiguous. + return ORIGIN_DID_NOT_ANSWER.has(status) ? "unknown" : "none"; + } + + // No status: a transport failure. Before headers on a paid call the payment + // has already been signed and sent (the unpaid 402 comes back in + // milliseconds; the paid retry is the one that runs long), so an abort here + // is exactly the settle-after-disconnect shape. Anything else without a + // status — an SDK validation throw, a programming error — never reached + // the wire. + if (name === "AbortError" || IN_FLIGHT_TRANSPORT.test(msg)) return "unknown"; + return "none"; } diff --git a/src/utils/constants.ts b/src/utils/constants.ts index f4191c6..0e8551a 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -89,7 +89,9 @@ export const BASE_RPC_URLS = [ // nemotron-nano-12b-v2-vl (vision). // Also live but UNLISTED (hidden from GET /v1/models, still served): gpt-oss-120b // — the gateway's own free fallback — and gpt-oss-20b (both re-probed -// 2026-08-12, ~0.6-2.5s, serving themselves). +// 2026-08-12, ~0.6-2.5s, serving themselves). NO LONGER TRUE OF 120b: the +// 2026-09-13 sweep saw it answer as nano-omni on Base and 3.5-lightning on +// Solana — see the free[] note; 20b still serves itself on both. // DEAD SINCE THE JULY SWEEP, removed 2026-08-12: deepseek-v4-flash and // seed-oss-36b now report `"model": "nvidia/gpt-oss-120b"` on BOTH chains — // the aliasing trap from the note above, caught again. Keeping them routed @@ -134,8 +136,11 @@ export const MODEL_TIERS = { // it is the single most load-bearing free model there is. Re-probed 2026-07-21 // on BOTH chains with a realistic ~1.5K-token prompt: gpt-oss-120b 3.5s, // gpt-oss-20b 3.7s; re-confirmed 2026-08-12 (0.6-2.5s, serving themselves). - // Absence from the public catalogue is a listing decision, not a health - // signal — check the behaviour. But the 2026-08-12 sweep also showed the + // (Since 2026-09-13 gpt-oss-120b is on the OTHER side of that line — the + // alias target became an alias — which is why it left the tier. Its bare + // spelling stays in the tool description as the example free id; it is + // still $0.) Absence from the public catalogue is a listing decision, not a + // health signal — check the behaviour. But the 2026-08-12 sweep also showed the // CONVERSE playing out: two other delisted entries (deepseek-v4-flash, // seed-oss-36b) turned out to be alias-dead, not hidden-alive. Delisting // tells you NOTHING either way; only the response's `model` field does. @@ -148,25 +153,44 @@ export const MODEL_TIERS = { // is the trap the gateway's own probe script added a --real mode for. Never // health-check a free model with a 16-token ping. // - // 2026-09-08 order, three bands, from the live catalogue (listing evidence - // only — see the NVIDIA note above for what was and was not probed): - // 1. gpt-oss-120b — hidden-alive, the gateway's own free fallback; and - // nemotron-3-nano-omni — listed, available, served itself on 2026-08-12. - // 2. Listed billing_mode:"free" on BOTH chains and available: the two new - // NVIDIA entries and the first two non-NVIDIA free models. Unprobed for - // latency, so they sit behind the proven pair, not ahead of it. - // 3. The four delisted entries. Delisting tells you nothing either way; - // each is bounded by FREE_MODEL_TIMEOUT_MS and the loop by - // FREE_TIER_DEADLINE_MS, so a dead tail costs time, never money. - // Remove them only on a POST probe that shows aliasing or a crawl. - // Skipped on purpose: nemotron-3.5-lightning (available:false on Base), - // muse-glimmer-30b and gemma-4-31b (Solana catalogue only) — routing has to - // hold on both chains. They are still in FREE_CHAT_MODELS, so an explicit - // call to one reserves $0 like any other free id. + // 2026-09-13 SWEEP — the realistic-prompt POST probe the 09-08 note asked + // for: ~3,000 characters, max_tokens 32, no payment header, both gateways, + // the response's `model` field read on every 200. It found the tier was + // mostly ALIASES: six of the eleven routed ids answered as another model on + // BOTH chains — + // gpt-oss-120b -> nemotron-3-nano-omni (Base, 6.4s) / nemotron-3.5-lightning (sol, 21.9s) + // nemotron-3-ultra-550b -> nemotron-3-super-120b (Base) / nemotron-3.5-lightning (sol, 63.9s) + // step-3.7-flash -> nemotron-3-super-120b (Base) / no answer in 90s (sol) + // mistral-nemotron -> nemotron-3-nano-omni (Base) / nemotron-3-super-120b (sol) + // nemotron-nano-12b-v2-vl, nemotron-nano-9b-v2 -> nano-omni on both + // — and the TARGET moves between probes (the 09-13 finder saw gpt-oss-120b + // land on nemotron-3-super-120b hours earlier). "The gateway's own free + // fallback serves itself", the premise free[0] rested on since July, is + // gone: the fallback now serves whatever has capacity. So the tier walked + // the same saturated backend under five names inside the 150s deadline and + // reported "did not answer" having tried ONE model. Removed, per this list's + // own rule (a POST probe that shows aliasing). They stay in FREE_CHAT_MODELS: + // still $0, and an explicit call must still reserve nothing. + // + // What is left is every id that echoed ITS OWN NAME on both chains, fastest + // first — distinct backends, which is the only thing a fallback rung is for: + // gpt-oss-20b (0.7s sol / 1.7s Base), north-mini-code (1.0s / 3.0s), + // nemotron-3-nano-omni (2.3s as "…-nim" on sol / 3.6s), then two that + // served themselves on Solana and were merely unavailable on Base that day + // (laguna-xs-2.1: 429 "capacity exhausted" on Base, 0.7s on sol; + // llama-3.2-11b-vision: no answer in 90s on Base, 9.8s on sol) — last, so + // a bad day on one chain costs the loop time at the tail, never at the head. + // Since audit round 3 the reply names the served model whenever it differs + // from the requested id (see chat.ts servedNotes), so the next alias is + // visible in the tool output instead of in a sweep months later. + // + // Skipped on purpose: nemotron-3.5-lightning (429 on Base, no answer on + // sol), muse-glimmer-30b and gemma-4-31b (400 "Unknown model" on Base; + // gemma crawled 79.8s on sol) — routing has to hold on both chains. They are + // still in FREE_CHAT_MODELS, so an explicit call to one reserves $0. free: [ - "nvidia/gpt-oss-120b", "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", - "nvidia/llama-3.2-11b-vision", "nvidia/nemotron-3-ultra-550b", "cohere/north-mini-code", "poolside/laguna-xs-2.1", - "nvidia/step-3.7-flash", "nvidia/mistral-nemotron", "nvidia/gpt-oss-20b", "nvidia/nemotron-nano-12b-v2-vl", "nvidia/nemotron-nano-9b-v2", + "nvidia/gpt-oss-20b", "cohere/north-mini-code", "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", + "poolside/laguna-xs-2.1", "nvidia/llama-3.2-11b-vision", ], coding: ["anthropic/claude-opus-5", "openai/gpt-5.3-codex", "moonshot/kimi-k3", "xai/grok-build-0.1", "zai/glm-5.2", "qwen/qwen3.7-max", "anthropic/claude-sonnet-5"], glm: ["zai/glm-5", "zai/glm-5.2", "zai/glm-5.1", "zai/glm-5-turbo"], @@ -189,7 +213,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- @@ -197,10 +230,19 @@ export type RoutingMode = keyof typeof MODEL_TIERS; */ export const FREE_CHAT_MODELS: ReadonlySet = new Set([ ...MODEL_TIERS.free, + // Still $0 on both gateways (200 without a payment header, 2026-09-13) but + // answering as ANOTHER model — pulled from the routing tier, kept here so an + // explicit call reserves $0. See the free[] sweep note. + "nvidia/gpt-oss-120b", + "nvidia/nemotron-3-ultra-550b", + "nvidia/step-3.7-flash", + "nvidia/mistral-nemotron", + "nvidia/nemotron-nano-12b-v2-vl", + "nvidia/nemotron-nano-9b-v2", // Live billing_mode:"free" on 2026-09-08 but deliberately not routed. - "nvidia/nemotron-3.5-lightning", // available:false on Base that day - "nvidia/muse-glimmer-30b", // Solana catalogue only - "nvidia/gemma-4-31b", // Solana catalogue only + "nvidia/nemotron-3.5-lightning", // 429 capacity on Base, no answer on sol (09-13) + "nvidia/muse-glimmer-30b", // Solana catalogue only (400 on Base) + "nvidia/gemma-4-31b", // Solana catalogue only (400 on Base; 79.8s crawl on sol) ]); /** @@ -255,6 +297,15 @@ export const CHAT_PRICE_PER_MTOKEN: Record