diff --git a/.github/workflows/brand-sync.yml b/.github/workflows/brand-sync.yml index c6c69be..69a6e09 100644 --- a/.github/workflows/brand-sync.yml +++ b/.github/workflows/brand-sync.yml @@ -61,6 +61,14 @@ jobs: # keeping and a dated name per run only accumulates orphans. BR="brand-sync" git push -f origin "HEAD:${BR}" + # The PR a human opened last time is still open: the force-push just + # refreshed it, and `gh pr create` would fail with "already exists" — + # which the fallback below used to report as "PR creation is + # unavailable, open it by hand" (round 4b). + if EXISTING=$(gh pr list --head "$BR" --state open --json number -q '.[0].number') && [ -n "$EXISTING" ]; then + echo "refreshed the open PR #${EXISTING} from ${BR}" + exit 0 + fi 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."; then diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 45f682a..2bc272a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -21,6 +21,12 @@ concurrency: jobs: publish: + # main only, for workflow_dispatch as much as for push: the dispatch form + # accepts any ref, and every guard below is ref-agnostic (the version gate + # only ORDERS pkg against npm), so a write collaborator could have shipped + # a feature branch as `latest` — tagged at an unmerged commit that never + # passed the required check (round 4b). + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest permissions: contents: write # required to push the tag + create the GitHub release @@ -67,8 +73,16 @@ jobs: # # With the parameter the endpoint returns exactly one row, the real # 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')}})") + # Same none/unknown split as npm above: "none" only when the registry + # ANSWERED and lists no server; a non-2xx (curl -f), an unparsable + # body or a missing `servers` field is "unknown" and stops the job + # before mcp-publisher runs blind (round 4b). + if REG_BODY=$(curl -fsS "https://registry.modelcontextprotocol.io/v0/servers?search=io.github.BlockRunAI/blockrun-mcp&version=latest"); then + REG=$(printf '%s' "$REG_BODY" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{const j=JSON.parse(s);if(!Array.isArray(j.servers)){console.log('unknown');return}console.log(j.servers[0]?.server?.version||'none')}catch{console.log('unknown')}})") + else + echo "::warning::registry lookup failed (non-2xx or network) — treating as unknown" + REG=unknown + fi # 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". @@ -91,6 +105,12 @@ jobs: - name: Refuse a version below npm latest run: node scripts/version-gate.mjs "${{ steps.v.outputs.pkg }}" "${{ steps.v.outputs.npm }}" + - name: Refuse to run against an unknown registry state + if: steps.v.outputs.reg == 'unknown' + run: | + echo "::error::the MCP registry could not be read, so whether ${{ steps.v.outputs.pkg }} is already there is unknown — re-run the job" + exit 1 + - name: Install dependencies run: npm ci @@ -178,10 +198,16 @@ jobs: # 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. + # + # NOT gated on `tag_missing`: once `git push origin $TAG` has succeeded, + # a re-run after `gh release create` failed resolves tag_missing=false + # and would skip the step — the in-step `gh release view` guard is what + # makes THAT re-run safe, and it can only run if the step runs. The + # `git ls-remote` guard inside keeps a stale HEAD from being re-tagged; + # tag_missing is still computed in Resolve for the log (round 4b). - name: Tag + GitHub release 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: diff --git a/CHANGELOG.md b/CHANGELOG.md index cc39624..fa23a7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,93 @@ All notable changes to BlockRun MCP will be documented in this file. +## 0.51.1 + +**Round four, second half.** 0.51.0 shipped with seven of round four's ten +finders still unread — they had stalled — and this is what they found once +they finished, plus a regression hunt over 0.51.0's own fixes. Forty-six +findings survived two verifiers each: two P0, five P1, and three of the P1s +were introduced by 0.51.0. That is the pattern every round has produced, and +it is why the rounds continue. + +### The money paths + +- **Two more ways the URL parser and the price classifier disagreed** (P0). + The parser strips trailing control-or-space from the whole URL — and the + slug is its tail on every rail — so `phone/numbers/buy ` (an ordinary + tokenisation slip) reserved $0.012 and bought the $5.001 route. And for + https the parser reads a literal `\` as `/`, so `sandbox\create` priced as + a $0.003 op, skipped the gpu/timeout normalisation, and the gateway served a + $192 sandbox. Both guards share the cut now. +- **0.51.0's settled-then-failed evidence was reading a shared counter** (P1). + The SDK's spend counter is per client, `getClient()` was a cached singleton + per rail, and tool calls run concurrently — so a concurrent call's + settlement landed inside a failing call's window and was booked to it as + "the charge stands", then booked again by the call that paid. Every path + tool builds its own client, as chat always has. +- **An edge status is a maybe on every rail** (P1). 0.51.0 read every numeric + status as the gateway's verdict, which undid, for the media tools alone, the + rule chat and the path tools apply to the same status: 408/502/504/52x say + only that the origin did not answer in time and may still be settling. A + `blockrun_speech` answered 504 by the edge while TTS finished and billed at + the origin read "failed — try again", i.e. pay twice. One shared set now, + and the tracker remembers that a paid request LEFT even after a response + settled it. +- **0.51.0's placeholder claim could delete a peer's freshly published key** + (P1). The claim renamed whatever was at the session file's name after a read + that saw it empty; a peer that claimed the same placeholder and linked its + key in the gap had it renamed aside and deleted. The aside is inspected and a + key found there is adopted. +- **A DNS failure on the native claude-* path booked the quote and forbade a + retry** (P1). The Anthropic SDK nests undici's errno two causes deep; the + classifier read one. The chain is walked; a connect timeout is + never-connected too. An idle stall before the stream connects is a maybe, + not "settled"; partial text survives on the native path; and the Solana + frame path refuses a stream that ended with no frames instead of returning + a paid, empty success. +- **Only the POST of a signed Polymarket order can be unknown.** The SDK's + helpers read the network before they sign, and a relay 502 on any of those + reads was wrapped as a phantom "possibly live" order that never released. + The creds retry never re-submits an unknown outcome; the reservation is + taken before the spend dialog so two waiting confirms cannot overshoot the + session cap; the EOA withdraw gets the double-send guard the relayer path + had; a throw from the relayer's poll is "may still land"; an unreadable + state file is a refusal, not an empty state; and every definite refusal + says nothing was placed, so the order card re-arms on it. +- **The Linux keychain fix locked out fresh installs with no secrets service** + (a regression of 0.51.0). secret-tool prints a D-Bus or no-provider message + and exits 1 on hosts that have the binary and nothing behind it; that read + as a fault, and every paid tool refused to mint. A keychain that does not + exist is absent; a locked one is still an error. +- `blockrun_image` and `blockrun_realface` say when the charge stands (a + settled 2xx with no URL / no asset id, a temp file that would not write); the + account rail's unreadable settled body is the same typed error the wallet + rails throw; `BASE_RPC_URL` is honoured on the Base balance read. + +### The release machinery + +`publish.yml` runs on `main` only — a `workflow_dispatch` from a bumped branch +would have shipped it as `latest`, tagged at an unmerged commit. The +tag/release step is no longer gated on the tag being absent, so a re-run after +`gh release create` failed can actually create the release (the very failure +that hit 0.51.0's first run). The MCP-registry lookup gets the npm lookup's +none/unknown split. `brand-sync` refreshes an already-open fallback PR instead +of going red. `verify:prices` tallies a probe that throws as unreachable +(bounded by 30s) instead of exiting with the under-reserve code, and +`version-gate.mjs` realpaths its `isMain` check so a symlinked checkout cannot +fail open. + +### Saying the true thing + +"Timeout = no charge, retry" was wrong on Solana music and every account-rail +media job; the setup and debug skills told the user to back up only +`.session` when the funded key on a new install is `.solana-session`; +spend-confirmation.md said Polymarket is not behind the dialog; four docs said +native claude-* is Base-only when only `thinking` is; the gentech skill said +music, speech and realface need Base; CONTRIBUTING's async pattern said +timeouts never charge. All corrected, and the music/video tool descriptions +say which rails bill at submit. + ## 0.51.0 **Round four went at round three.** 0.50.0's changelog said the general sweep diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 272fa54..16936fd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -86,7 +86,7 @@ Two flavors. Pick the right one: - **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. +- **Async**: 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, with the catch classifying by FACT (a booked settlement, a typed `BilledJobError` / `JobFailedError`, the in-flight tracker), never by the words in the message. Whether a give-up cost anything is per rail: the Base wallet settles on completion (upstream failure or client timeout = no charge); Solana music and every account-rail media job are billed at submit, and `utils/solana-402.ts` / `utils/api-key-call.ts` hand you a `BilledJobError` for those. `src/tools/music.ts` is the same shape (its submit can also settle inline), and `src/tools/speech.ts` the single-POST one. ## CHANGELOG diff --git a/README.md b/README.md index 6973257..a11faa5 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ claude mcp add blockrun -s user -- npx -y @blockrun/mcp@latest
- Context cost: 13.0K tokens, 7% of a 200K context window, charged every turn whether or not you call a tool. 5.4K with --profile trading, 58% 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, 59% less.
@@ -252,13 +252,13 @@ Package managers have shown install size for decades. Almost no MCP server shows | Profile | Tools | Context | |---------|-------|---------| -| `full` *(default)* | 19 | 13,006 | +| `full` *(default)* | 19 | 13,044 | | `trading` | 8 | 5,411 | -| `media` | 7 | 5,790 | +| `media` | 7 | 5,828 | | `research` | 5 | 2,752 | | `chat` | 3 | 2,079 | -Running `--profile trading` instead of the default costs **58% less context** for the same trading +Running `--profile trading` instead of the default costs **59% less context** for the same trading workflow. If you only ever ask about markets, that is the single cheapest change you can make. Measure it yourself — against us, or against any other stdio MCP server: @@ -611,11 +611,11 @@ One wallet, or one dashboard-backed API key. All sources. | `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 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/.session` | created on the first `blockrun_wallet` call (with `.solana-session`) | EVM private key (0x…). Which chain pays is the priority list below, not this file's existence. 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. | | `~/.blockrun/.chain-auto` | written on first run | Automatic pin: the chain you were already on when your second wallet was provisioned. Keeps a Base user on Base once a Solana session exists, and is outranked by `SOLANA_WALLET_KEY`. Cleared whenever you set a chain explicitly. | -| `~/.blockrun/.solana-session` | not created | Solana private key. File exists → Solana unless `.chain` says `base`. | +| `~/.blockrun/.solana-session` | created on the first `blockrun_wallet` call (with `.session`) | Solana private key — the funded wallet on a new install. Which chain pays is the priority list below. | | `SOLANA_WALLET_KEY` | unset | Env override of `.solana-session`. Set → use Solana. | | `BLOCKRUN_KEYCHAIN` | `auto` | Key storage. `auto` — mirror the key into the OS keychain (macOS Keychain / Linux `secret-tool`) and keep the plaintext file, which stays authoritative so other BlockRun tools keep working and so replacing it still rotates your wallet. `off` — file only. `strict` — also delete `~/.blockrun/.session` once a read-back proves the keychain holds the same key; **this breaks other tools that read that file directly**. | | `BLOCKRUN_MCP_PROFILE` | `full` | Tool profile (`media` / `trading` / `research` / `chat`). | @@ -653,7 +653,7 @@ The server runs a non-blocking npm registry check at startup and prints an `Upda Then restart Claude Code. Or pin absolute paths (`which npx`). - **`claude mcp list` doesn't show `blockrun`** → Check `node -v` (≥20.19). Clear the npx cache: `rm -rf ~/.npm/_npx`. Re-run the install. - **`fetch failed` / balance-check timeout** → Base RPC transient outage. The tool falls through 3 public RPCs; retry after 30s. Persistent = local proxy / firewall blocking outbound RPC. -- **`Video`/`Music generation timed out`** → Upstream queue congestion. **No charge** (payment-on-completion). Retry, or pick a faster model. +- **`Video`/`Music generation timed out`** → Upstream queue congestion. Whether it cost anything depends on the rail, and the error says which: on the **Base wallet** payment settles on completion, so it is **not charged** and a retry is safe; on **Solana music** and **every account-rail media job** the gateway bills at submit, so the job **is charged**, the error names it, and re-running bills a second one — check `blockrun_wallet action:"report"` instead. - **`blockrun_price` says `Equity quotes are not served (gateway 501 …)`** → Equity price/history were withdrawn on 2026-09-05; not an outage, and **nothing was charged** (the wallet is never asked to sign). The ticker catalog (`action:"list" category:"stocks"`) is still free. Equity coverage: hello@blockrun.ai. - **`blockrun_markets` on `sports/*` fails — before 0.49.0 as `API error after payment: 502` with no balance change** → Predexon's `sports/*` routes have been down upstream since 2026-08-04; the gateway releases the payment on that upstream 500, so the call is **not charged** (the error says so when the gateway's "payment NOT charged" confirmation is in the response; otherwise it tells you to check `blockrun_wallet action:"report"`). For sports odds use `path:"markets/search"` with `params:{ q: "NBA" }`, or `polymarket/events` with `params:{ search: "NBA" }` — the bare `markets` route and its `league` filter were removed upstream on 2026-08-04 and 404 before payment. Upgrade to ≥ 0.49.0 so the error says all of this itself. - **No spend-confirmation dialog although `BLOCKRUN_CONFIRM_SPEND=on`** → Your client doesn't support MCP elicitation (Windsurf, Codex, Gemini CLI); the server proceeds without asking by design. Use `BLOCKRUN_BUDGET_LIMIT` as the guard, or a client from the [support table](#%EF%B8%8F-human-in-the-loop-payments). @@ -693,7 +693,7 @@ Yes — `BLOCKRUN_CONFIRM_SPEND=on`. Every paid tool pauses with the estimated c Yes. `blockrun_polymarket` places real, USDC-settled orders on Polymarket's CLOB — confirm-gated and capped. Read the odds with `blockrun_markets`, place with `blockrun_polymarket`. **Base or Solana?** -Both. Switch instantly with `blockrun_wallet action:"chain"`. Three things are Base-only, and each says so when you call them on Solana: `blockrun_defi` (DefiLlama) and `blockrun_modal`, which the Solana gateway does not serve, and native Anthropic `claude-*` chat. Media generation, markets, search and Polymarket all settle on either chain. +Both. Switch instantly with `blockrun_wallet action:"chain"`. Three things are Base-only, and each says so when you call them on Solana: `blockrun_defi` (DefiLlama) and `blockrun_modal`, which the Solana gateway does not serve, and `claude-*` chat with `thinking` (the native `/v1/messages` path pays on Base; a plain `claude-*` call takes the compat path on Solana). Media generation, markets, search and Polymarket all settle on either chain. --- diff --git a/VERSION b/VERSION index c5d4cee..1e0c609 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.51.0 +0.51.1 diff --git a/assets/context-cost-dark.svg b/assets/context-cost-dark.svg index 4ed5a10..aa771f2 100644 --- a/assets/context-cost-dark.svg +++ b/assets/context-cost-dark.svg @@ -1,10 +1,10 @@ - + CONTEXT COST 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 + 5.4K with --profile trading — 59% less measured, not estimated diff --git a/assets/context-cost.svg b/assets/context-cost.svg index 948669e..585212b 100644 --- a/assets/context-cost.svg +++ b/assets/context-cost.svg @@ -1,10 +1,10 @@ - + CONTEXT COST 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 + 5.4K with --profile trading — 59% less measured, not estimated diff --git a/docs/mcp-schema-overhead.md b/docs/mcp-schema-overhead.md index c506af1..64e34a5 100644 --- a/docs/mcp-schema-overhead.md +++ b/docs/mcp-schema-overhead.md @@ -16,13 +16,13 @@ Written 2026-09-01, verified against `@modelcontextprotocol/sdk` 1.29.0. Numbers | Profile | Tools | Context | |---------|-------|---------| -| `full` *(default)* | 19 | 13,006 | +| `full` *(default)* | 19 | 13,044 | | `trading` | 8 | 5,411 | -| `media` | 7 | 5,790 | +| `media` | 7 | 5,828 | | `research` | 5 | 2,752 | | `chat` | 3 | 2,079 | -Descriptions are ~55% of it, input schemas ~40%. `--profile trading` costs 58% less than the +Descriptions are ~55% of it, input schemas ~40%. `--profile trading` costs 59% less than the default for the same workflow. These figures move with every description edit, so they are not the source of truth — the README diff --git a/docs/spend-confirmation.md b/docs/spend-confirmation.md index 5519059..218a221 100644 --- a/docs/spend-confirmation.md +++ b/docs/spend-confirmation.md @@ -39,9 +39,11 @@ Free calls never prompt: `blockrun_chat mode:"free"`, crypto/FX/commodity `block `blockrun_dex`, `blockrun_models`, `blockrun_wallet`, and the free `blockrun_phone` and `blockrun_realface` actions. -`blockrun_polymarket` is not behind this gate. Bets are real funds on Polygon, not x402 -fees, and they already require an explicit `confirm:true` on every order, approval and -redemption — a per-order contract that is stronger than a session-wide dialog. +`blockrun_polymarket` is behind this gate too since 0.51.0: `buy`, `sell`, `fund` and +`withdraw` ask at the same dialog with the real notional, right before they sign. +`confirm:true` is still required underneath — it is the model's floor, not the human's +answer — and a `$0.004` rpc call getting the dialog while a `$25` bet did not was the +reason the gate was extended. ## Enable it diff --git a/package-lock.json b/package-lock.json index 055b772..30fbad8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@blockrun/mcp", - "version": "0.51.0", + "version": "0.51.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@blockrun/mcp", - "version": "0.51.0", + "version": "0.51.1", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.123.0", diff --git a/package.json b/package.json index a968162..118e335 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@blockrun/mcp", - "version": "0.51.0", + "version": "0.51.1", "mcpName": "io.github.BlockRunAI/blockrun-mcp", "description": "BlockRun MCP Server - Give your AI agent web search, deep research, prediction markets, and crypto data. Pay per call from a USDC wallet (Solana or Base) or a BlockRun API key.", "type": "module", diff --git a/scripts/verify-prices.ts b/scripts/verify-prices.ts index e48b69e..f7dfa8c 100644 --- a/scripts/verify-prices.ts +++ b/scripts/verify-prices.ts @@ -96,11 +96,25 @@ type Probe = { type Quote = { usd: number; description?: string }; +// A probe that THROWS (reset, DNS, TLS, a gateway that accepts the socket and +// never answers) is an unreachable ROW, not a crash: uncaught it rejected the +// top-level await and Node exited 1 — the "confirmed under-reserve" code — with +// none of the verdict lines printed (round 4b). Bounded so a hung gateway +// cannot stall the release gate for undici's five-minute headers timeout. +const PROBE_TIMEOUT_MS = 30_000; + async function quote(host: string, path: string, body?: unknown): Promise { - const res = await fetch(host + path, { - method: body === undefined ? "GET" : "POST", - ...(body === undefined ? {} : { headers: { "content-type": "application/json" }, body: JSON.stringify(body) }), - }); + let res: Response; + try { + res = await fetch(host + path, { + method: body === undefined ? "GET" : "POST", + ...(body === undefined ? {} : { headers: { "content-type": "application/json" }, body: JSON.stringify(body) }), + signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), + }); + } catch (err) { + const why = err instanceof Error ? (err.name === "TimeoutError" ? `no answer in ${PROBE_TIMEOUT_MS / 1000}s` : err.message) : String(err); + return `unreachable (${why})`; + } const header = res.headers.get("payment-required"); if (!header) return `no 402 (HTTP ${res.status})`; let parsed: { accepts?: Array<{ amount?: string; extra?: { description?: string } }>; resource?: { description?: string } }; @@ -336,7 +350,14 @@ for (const probe of PROBES) { // 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)) { + // A 5xx, an undecodable header or a thrown probe is neither a refusal nor + // a quote: the row was not verified. Tally it as such rather than as "the + // gateway sells it" (round 4b). + const unverified = (q: Quote | string) => typeof q === "string" && !refused(q); + if (unverified(liveQ) || unverified(solQ)) { + console.log(` ? ${probe.label.padEnd(26)} ${[unverified(liveQ) ? `Base ${liveQ}` : "", unverified(solQ) ? `Solana ${solQ}` : ""].filter(Boolean).join("; ")}`); + unreachable++; + } else 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 = [ diff --git a/scripts/version-gate.mjs b/scripts/version-gate.mjs index cb1d287..17667ce 100644 --- a/scripts/version-gate.mjs +++ b/scripts/version-gate.mjs @@ -21,6 +21,9 @@ // 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. +import { realpathSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/; /** -1 / 0 / 1, numerically per component. Throws on anything that is not X.Y.Z. */ @@ -60,7 +63,17 @@ export function gate({ pkg, 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; +// realpath BOTH sides, as changelog-section.mjs and measure-tool-schema.mjs +// do: Node realpaths the ESM main entry but leaves process.argv[1] as the +// path it was invoked by, so through a symlinked checkout the two differed, +// isMain was false, and the CLI exited 0 on a downgrade — the exact fail-open +// those two scripts already fixed (round 4b). +let isMain = false; +try { + isMain = Boolean(process.argv[1]) && fileURLToPath(import.meta.url) === realpathSync(process.argv[1]); +} catch { + isMain = false; +} if (isMain) { const [pkg, npm] = process.argv.slice(2); if (!pkg || !npm) { diff --git a/skills/blockrun-debug/SKILL.md b/skills/blockrun-debug/SKILL.md index c7feb6e..d7da8cd 100644 --- a/skills/blockrun-debug/SKILL.md +++ b/skills/blockrun-debug/SKILL.md @@ -61,14 +61,14 @@ re-added at user scope leaves a duplicate. Then, in the session: `blockrun_walle | `claude mcp list` → `blockrun … ✗ Failed to connect`, or logs show `spawn npx ENOENT` | The client's launcher can't find `node`/`npx` — nvm, Homebrew, fnm, volta. Interactive shell has them; the spawner does not. | `claude mcp remove blockrun -s user` then `claude mcp add blockrun -s user -e PATH="$PATH" -- npx -y @blockrun/mcp@latest`. Quit and relaunch the client. Codex: `codex mcp add blockrun --env PATH="$PATH" -- …`. JSON clients (Claude Desktop / Cursor / Windsurf): set `"command"` to the output of `which npx`; file paths are in the `blockrun-setup` skill. | | `blockrun` absent from `claude mcp list` | Wrong scope, or Node < 20.19, or a corrupt npx cache | `node -v` (≥ 20.19). `claude mcp get blockrun`. `rm -rf ~/.npm/_npx`. Re-run the install line. | | Connects, but every paid tool → `HTTP 402` / `Insufficient balance` | Wallet is empty (or on the wrong chain for this tool) | `blockrun_wallet` → read balance + chain. `blockrun_wallet action:"setup"` → address + QR. Fund with USDC **on Base** (or switch to Solana). Then retry **once**. | -| 402 although balance shows funds | Chain mismatch: a Base-only tool (`defi`, `modal`, native `claude-*` chat) while active chain is Solana. Everything else — music, speech, RealFace, price, media — now pays on either chain. | The error names it. `blockrun_wallet action:"chain" chain:"base"`. | +| 402 although balance shows funds | Chain mismatch: a Base-only tool (`defi`, `modal`, `claude-*` chat with `thinking`) while active chain is Solana. Everything else — plain `claude-*` chat, music, speech, RealFace, price, media — now pays on either chain. | The error names it. `blockrun_wallet action:"chain" chain:"base"`. | | 402 from `api.blockrun.ai` | Not a wallet problem: the server is on an API key and the ACCOUNT is out of credit. | Top up at . Do not look for a wallet. | | 401 from `api.blockrun.ai` | `BLOCKRUN_API_KEY` rejected. | Check the key at . | | "needs wallet mode" on Polymarket / wallet / realface list | Keypair-only capability while `BLOCKRUN_API_KEY` is set. | Unset `BLOCKRUN_API_KEY` and restart to use a wallet. | | Startup error "not a valid BlockRun API key" | `BLOCKRUN_API_KEY` is malformed. It deliberately fails loudly rather than silently spending USDC from a wallet instead. | Fix the value or unset it. | | Grok (or any client with a startup timeout) shows `blockrun [unavailable]`, and `mcp doctor` says `server timed out (no response within 30s)` | NOT broken: a cold `npx -y` is still downloading the package and its dependency tree when the client gives up. Grok's `startup_timeout_sec` defaults to 30; the cold start measured 17s on a fast connection and 42-46s on a slower box. Nothing is charged — the server never started. | Raise it in `~/.grok/config.toml` (`[mcp_servers.blockrun]` … `startup_timeout_sec = 120`), or `npm install -g @blockrun/mcp@latest` and point the client at `blockrun-mcp`. Confirm the package itself is fine first: `npx -y @blockrun/mcp@latest` in a terminal answers an `initialize` line. Only the first run is slow — npx caches by exact spec. | | `fetch failed` / balance-check timeout | Base RPC blip; the tool rotates through 3 public RPCs | Wait 30 s, retry once. Persistent → a local proxy/firewall is blocking outbound RPC. | -| `Video`/`Music generation timed out` | Upstream queue. **Not charged** — payment settles on completion only. | Retry, or pick a faster model. Do not retry-loop; jobs take 60–180 s. | +| `Video`/`Music generation timed out` | Upstream queue. **Base wallet: not charged** — payment settles on completion. **Solana music / account rail: charged at submit** — the error names the job. | Read the error: on Base retry or pick a faster model; on the other rails do NOT re-run (that bills a second job) — check `blockrun_wallet action:"report"`. Jobs take 60–180 s. | | `blockrun_price` with `category:"stocks"` / `"usstock"` → `Equity quotes are not served (gateway 501 …)` | The gateway withdrew equity price/history on 2026-09-05 (licensing), and the tool answers before the wallet is consulted. Not an outage. **Not charged.** | Do not retry. `action:"list" category:"stocks" market:"us"` still returns the ticker catalog for free. Equity coverage: hello@blockrun.ai. | | `blockrun_markets` on `sports/*` → `Predexon's sports/* routes have returned an upstream 500 … since 2026-08-04` (builds before 0.49.0: `API error after payment: 502 / Request failed` with no balance change) | Upstream Predexon outage since 2026-08-04. The gateway releases the payment on the upstream 500, so the old wording asserted a charge that never happened. **Not charged** when the response carries the gateway's `(payment NOT charged)` confirmation — the error then says so; without it the error tells you to check `blockrun_wallet action:"report"`. | Use `path:"markets/search"` with `params:{ q:"NBA" }` or `polymarket/events` with `params:{ search:"NBA" }`. Not `markets` + `league` — removed upstream 2026-08-04, 404s before payment. Do not retry `sports/*`. Upgrade to ≥ 0.49.0 so the error says this itself. | | A config names `blockrun_surf` → the client reports an unknown tool | The tool was REMOVED in 0.49.0: the gateway has answered every Surf path with `410 endpoint_retired` since 2026-09-06, so it could only ever error. **Nothing is charged.** | Use `blockrun_price` / `blockrun_dex` / `blockrun_defi` / `blockrun_markets` / `blockrun_rpc`; the `surf` skill maps each former endpoint. Drop `blockrun_surf` from any allowlist. | diff --git a/skills/blockrun-setup/SKILL.md b/skills/blockrun-setup/SKILL.md index 1c28b23..d17a839 100644 --- a/skills/blockrun-setup/SKILL.md +++ b/skills/blockrun-setup/SKILL.md @@ -107,10 +107,13 @@ install works. Do not "test" with a paid tool. ## 4. The wallet and how paying works -- The server **creates a wallet on first run**: an EVM key in `~/.blockrun/.session` - (`0600`). On macOS/Linux it is also mirrored into the OS keychain, but the file stays - authoritative unless the user opts into `BLOCKRUN_KEYCHAIN=strict`. Tell the user to - **back that file up** — it is the only copy of the key; BlockRun cannot recover it. +- The server **creates the wallets on first use**: an EVM key in `~/.blockrun/.session` + and a Solana key in `~/.blockrun/.solana-session` (both `0600`). On a new install the + **funded one is the Solana key** — that is where the USDC goes by default. On macOS/Linux + both are also mirrored into the OS keychain, but the files stay authoritative unless the + user opts into `BLOCKRUN_KEYCHAIN=strict` (then the keychain entries are the copies to + export). Tell the user to **back up both files** — they are the only copies of the keys; + BlockRun cannot recover them. - Payment is **per call**, and there are two ways to pay. Free tools (`blockrun_wallet`, `blockrun_models`, `blockrun_dex`, crypto `blockrun_price`, `blockrun_chat mode:"free"`) need neither. @@ -122,7 +125,7 @@ install works. Do not "test" with a paid tool. — Coinbase (pick "Solana"), Phantom, Solflare or Backpack. $5 covers hundreds of calls. - To use Base instead: `blockrun_wallet action:"chain" chain:"base"` then `action:"setup"`, and send USDC on the Base network. No restart either way. -- Only `blockrun_defi`, `blockrun_modal` and native `claude-*` chat are Base-only; they say +- Only `blockrun_defi`, `blockrun_modal` and `claude-*` chat with `thinking` are Base-only; they say so rather than charging. **API key (for teams who cannot hand a wallet to an agent).** Ask the user whether they want diff --git a/skills/gentech-blockrun/SKILL.md b/skills/gentech-blockrun/SKILL.md index 11c5247..fce91a1 100644 --- a/skills/gentech-blockrun/SKILL.md +++ b/skills/gentech-blockrun/SKILL.md @@ -259,8 +259,11 @@ if float(balance["usdcBalance"]) < 0.50: ```python # For async operations (video, music): -# They auto-poll until complete. If timeout, no charge. -# Retry with same parameters. +# They auto-poll until complete. On a timeout READ THE ERROR: on the Base +# wallet payment settles on completion, so a timeout is not charged and a +# retry is safe; Solana music and every account-rail media job are billed +# at submit, so the timed-out job IS charged, the error names it, and a +# retry bills a second job. Check blockrun_wallet action:"report" first. ``` --- @@ -291,7 +294,7 @@ blockrun_wallet(action="chain", chain="base") blockrun_wallet(action="chain") ``` -**Note:** Base is required for music, speech, and realface. Image and video pay on either chain. Solana works for price, wallet, dex, rpc, markets, etc. +**Note:** Every paid media tool (image, video, music, speech, realface) pays on either chain. Base-only: `blockrun_modal`, `blockrun_defi`, `claude-*` chat with `thinking` (a plain claude-* call takes the compat path on Solana), and Polymarket funding. Solana works for price, wallet, dex, rpc, markets, etc. --- diff --git a/src/tools/chat-anthropic.ts b/src/tools/chat-anthropic.ts index fdaf3d0..31b4a4c 100644 --- a/src/tools/chat-anthropic.ts +++ b/src/tools/chat-anthropic.ts @@ -203,12 +203,18 @@ const NATIVE_IDLE_TIMEOUT_MS = 120_000; async function streamNativeMessage( client: AnthropicLike, params: Anthropic.MessageStreamParams, + idleTimeoutMs = NATIVE_IDLE_TIMEOUT_MS, ): 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; }); + // What streamed before a failure: the caller paid for those tokens, and the + // compat assembler hands them back (D57); the native path dropped them + // until round 4b. The SDK's `text` event carries the running snapshot. + let partialText = ""; + stream.on("text", (_delta: string, snapshot: string) => { partialText = snapshot; }); // 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 @@ -219,9 +225,14 @@ async function streamNativeMessage( 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`)); + // A stall AFTER the 2xx is a settled call that went quiet; a stall + // BEFORE it is the paid request still in flight — a maybe, the same + // verdict the compat path gives its own "before the first frame" + // stall — never "the charge stands" (round 4b). + const what = `stream stalled: no data from the gateway for ${Math.round(idleTimeoutMs / 1000)}s`; + stalled(accepted ? new AcceptedThenFailedError(what, partialText) : new Error(`timeout: ${what}, before the stream connected`)); stream.abort(); - }, NATIVE_IDLE_TIMEOUT_MS); + }, idleTimeoutMs); }; stream.on("streamEvent", armIdle); armIdle(); @@ -231,7 +242,7 @@ async function streamNativeMessage( } catch (error) { if (error instanceof AcceptedThenFailedError) throw error; if (accepted) { - throw new AcceptedThenFailedError(error instanceof Error ? error.message : String(error), "", { cause: error }); + throw new AcceptedThenFailedError(error instanceof Error ? error.message : String(error), partialText, { cause: error }); } throw error; } finally { @@ -281,6 +292,8 @@ export interface AnthropicNativeArgs { budget: BudgetState; agentId?: string; estimatedCost: number; + /** Test seam for the idle guard; production uses NATIVE_IDLE_TIMEOUT_MS. */ + idleTimeoutMs?: number; } type McpResult = { @@ -428,7 +441,7 @@ export async function handleAnthropicNative(args: AnthropicNativeArgs): Promise< let native: Anthropic.Message; let costHeaderUsd: number | null; try { - ({ message: native, costHeaderUsd } = await streamNativeMessage(client, params)); + ({ message: native, costHeaderUsd } = await streamNativeMessage(client, params, args.idleTimeoutMs)); } catch (error) { // Until audit round 3 this returned formatError and booked nothing, on // both rails — the settled-then-failed machinery the OpenAI-compat paths @@ -444,7 +457,12 @@ export async function handleAnthropicNative(args: AnthropicNativeArgs): Promise< } const usd = failedLedgerUsd(); recordActualSpend(budget, usd, estimatedCost, agentId); - return { content: [{ type: "text", text: nativeFailedText(error, usd, verdict) }], isError: true }; + const partial = error instanceof AcceptedThenFailedError && error.partialText ? error.partialText : ""; + return { + content: [{ type: "text", text: nativeFailedText(error, usd, verdict) + (partial ? `\n\nPartial response received before the failure (${partial.length.toLocaleString("en-US")} chars):\n${partial}` : "") }], + ...(partial ? { structuredContent: { partial_response: partial } } : {}), + isError: true, + }; } // Book what the gateway actually charged: on the wallet rails the quote it diff --git a/src/tools/defi.ts b/src/tools/defi.ts index 282f334..65f19b6 100644 --- a/src/tools/defi.ts +++ b/src/tools/defi.ts @@ -11,7 +11,7 @@ import { z } from "zod"; import { reserveBudget, recordSpending, recordActualSpend } from "../utils/budget.js"; import { confirmSpend } from "../utils/confirm-spend.js"; import { withTxFee } from "../utils/tx-fee.js"; -import { baseOnlyMessage, getClient } from "../utils/wallet.js"; +import { baseOnlyMessage, buildClient } from "../utils/wallet.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"; @@ -87,7 +87,14 @@ Use blockrun_price (free) for plain spot quotes, blockrun_dex (free) for DEX pai // reservation. No-ops when off, sub-threshold, or unsupported by the client. 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; + // A FRESH client per call, never the shared singleton: rawGet/rawPost + // read the SDK's cumulative spend counter around the call to tell a + // settled-then-failed request from a free refusal, and the MCP SDK + // dispatches tool calls concurrently — on a shared client a + // concurrent call's settlement landed inside this call's window and + // was booked to it as "the charge stands" (audit round 4b). Same + // reason blockrun_chat builds its own. + const client = buildClient() as unknown as RawClient; sentUsd = estimatedCost; const { data: result, paidUsd } = await rawGet(client, `/v1/defillama/${cleanPath}`); recordActualSpend(budget, paidUsd, ledgerFallback(estimatedCost), agent_id); diff --git a/src/tools/exa.ts b/src/tools/exa.ts index 2166ed6..b918e5c 100644 --- a/src/tools/exa.ts +++ b/src/tools/exa.ts @@ -11,7 +11,7 @@ import { reserveBudget, recordSpending, recordActualSpend } from "../utils/budge 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 { buildClient } from "../utils/wallet.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"; @@ -85,7 +85,14 @@ Full request/response shapes + worked research workflows in the \`exa-research\` // reservation. No-ops when off, sub-threshold, or unsupported by the client. const confirm = await confirmSpend(server, { usd: estimatedCost, label: `exa · ${cleanPath}` }); if (!confirm.ok) return { content: [{ type: "text", text: confirm.reason ?? "Charge cancelled." }] }; - const client = getClient() as unknown as RawClient; + // A FRESH client per call, never the shared singleton: rawGet/rawPost + // read the SDK's cumulative spend counter around the call to tell a + // settled-then-failed request from a free refusal, and the MCP SDK + // dispatches tool calls concurrently — on a shared client a + // concurrent call's settlement landed inside this call's window and + // was booked to it as "the charge stands" (audit round 4b). Same + // reason blockrun_chat builds its own. + const client = buildClient() as unknown as RawClient; const endpoint = `/v1/exa/${cleanPath}`; sentUsd = estimatedCost; const { data: result, paidUsd } = await rawPost(client, endpoint, body ?? {}); diff --git a/src/tools/image.ts b/src/tools/image.ts index abe639d..504fad4 100644 --- a/src/tools/image.ts +++ b/src/tools/image.ts @@ -377,6 +377,25 @@ Source images and masks accept a base64 data URI, an http(s) URL, or a local fil // 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(); + // The amount booked once settlement was OBSERVED, on any rail. Read by + // the catch: a failure after this point — no URL in the body, a temp + // file that would not write — is a real charge with an unusable result, + // and the message has to say the charge stands rather than "failed" + // with alt-model advice that runs a second paid render (round 4b: the + // D13 step video, music and speech got; image had not). + let bookedUsd: number | null = null; + const book = (paidUsd: number | null, fallback: number) => { + recordActualSpend(budget, paidUsd, fallback, agent_id); + bookedUsd = paidUsd ?? fallback; + }; + const chargeStands = (why: string) => { + const booked = bookedUsd as number | null; + const where = isApiKeyMode() ? "https://user.blockrun.ai/dashboard/activity" : `blockrun_wallet action:"report"`; + return { + content: [{ type: "text" as const, text: `Image generation settled and the charge stands — $${(booked ?? 0).toFixed(4)} is booked against your budget — but the result could not be used: ${why}\nCheck ${where} before doing anything else; re-running blockrun_image would charge a second render.` }], + isError: true as const, + }; + }; try { const selectedModel = model || "openai/gpt-image-2"; @@ -531,7 +550,7 @@ Source images and masks accept a base64 data URI, an http(s) URL, or a local fil // "free" — fall back to the estimate and say so, never book $0. billedUsd = r.paidUsd ?? estimatedCost; costIsEstimate = r.paidUsd === null; - recordActualSpend(budget, r.paidUsd, estimatedCost, agent_id); + book(r.paidUsd, estimatedCost); imageUrl = (r.data as { data?: Array<{ url?: string }> }).data?.[0]?.url; } else if (getChain() === "solana") { // Solana: manual x402 against the sol.blockrun.ai gateway (the SDK's @@ -575,7 +594,7 @@ Source images and masks accept a base64 data URI, an http(s) URL, or a local fil solQuotedUsd = quotedUsd; }, }); - recordActualSpend(budget, paidUsd, estimatedCost, agent_id); + book(paidUsd, estimatedCost); billedUsd = paidUsd ?? estimatedCost; costIsEstimate = paidUsd === null; imageUrl = (data as { data?: Array<{ url?: string }> }).data?.[0]?.url; @@ -606,16 +625,14 @@ Source images and masks accept a base64 data URI, an http(s) URL, or a local fil // 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); + book(null, observedUsd); billedUsd = observedUsd; imageUrl = response.data?.[0]?.url; } if (!imageUrl) { - return { - content: [{ type: "text", text: formatError("No image URL in response") }], - isError: true, - }; + // A settled 2xx with no URL in it: the charge stands. + return chargeStands("No image URL in response"); } const delivered = await materializeImageUrl(imageUrl); @@ -653,6 +670,10 @@ 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 }; } + // 1. Settlement was observed and booked, then the result could not be + // used (a temp file that would not write, a body that would not + // materialise). The charge stands; do not run the tool again. + if ((bookedUsd as number | null) !== null) return chargeStands(errMsg); // 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 diff --git a/src/tools/markets.ts b/src/tools/markets.ts index 1bee934..4eaefe5 100644 --- a/src/tools/markets.ts +++ b/src/tools/markets.ts @@ -4,7 +4,7 @@ import { z } from "zod"; 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 { buildClient } from "../utils/wallet.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"; @@ -132,7 +132,14 @@ Pass query params via 'params' (GET) — a '?' in 'path' is refused before payme // (client.ts:1534, 1552), so the wallet rail is byte-identical — but the // account rail then goes through utils/api-key-call.ts, which reads the // settled `x-blockrun-cost-usd` instead of discarding the response. - const llm = getClient() as unknown as RawClient; + // A FRESH client per call, never the shared singleton: rawGet/rawPost + // read the SDK's cumulative spend counter around the call to tell a + // settled-then-failed request from a free refusal, and the MCP SDK + // dispatches tool calls concurrently — on a shared client a + // concurrent call's settlement landed inside this call's window and + // was booked to it as "the charge stands" (audit round 4b). Same + // reason blockrun_chat builds its own. + const llm = buildClient() as unknown as RawClient; const endpoint = `/v1/pm/${path}`; sentUsd = estimatedCost; const { data: result, paidUsd } = body !== undefined diff --git a/src/tools/music.ts b/src/tools/music.ts index 865dd71..02e2c9b 100644 --- a/src/tools/music.ts +++ b/src/tools/music.ts @@ -103,13 +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. 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. +tool submits the job and, for slower tracks, polls until it is ready. On the +Base wallet 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 and on the account rail the job is billed when the gateway ACCEPTS it, +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) diff --git a/src/tools/phone.ts b/src/tools/phone.ts index cc6d777..64c5601 100644 --- a/src/tools/phone.ts +++ b/src/tools/phone.ts @@ -12,7 +12,7 @@ import { reserveBudget, recordSpending, recordActualSpend } from "../utils/budge import { confirmSpend } from "../utils/confirm-spend.js"; import { withTxFee } from "../utils/tx-fee.js"; import { asStructuredContent, coerceBody } from "../utils/body.js"; -import { getClient } from "../utils/wallet.js"; +import { buildClient } from "../utils/wallet.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"; @@ -121,7 +121,14 @@ Voice call flow + voice preset details + full body shapes in the \`phone\` skill // reservation. No-ops when off, sub-threshold, or unsupported by the client. const confirm = await confirmSpend(server, { usd: estimatedCost, label: `phone · ${cleanPath}` }); if (!confirm.ok) return { content: [{ type: "text", text: confirm.reason ?? "Charge cancelled." }] }; - const client = getClient() as unknown as RawClient; + // A FRESH client per call, never the shared singleton: rawGet/rawPost + // read the SDK's cumulative spend counter around the call to tell a + // settled-then-failed request from a free refusal, and the MCP SDK + // dispatches tool calls concurrently — on a shared client a + // concurrent call's settlement landed inside this call's window and + // was booked to it as "the charge stands" (audit round 4b). Same + // reason blockrun_chat builds its own. + const client = buildClient() as unknown as RawClient; const endpoint = `/v1/${cleanPath}`; sentUsd = estimatedCost; const { data: result, paidUsd } = body !== undefined diff --git a/src/tools/realface.ts b/src/tools/realface.ts index eb95e50..9d173a9 100644 --- a/src/tools/realface.ts +++ b/src/tools/realface.ts @@ -202,6 +202,16 @@ Privacy: BlockRun does not store face/liveness data — only the asset id, name, // THIS call's outstanding-payment state, read by the catch below. Per // call on purpose — see payAndPostJson. const paid = trackPaidRequest(); + // The amount booked once settlement was OBSERVED. Read by the catch: a + // settled 2xx whose body carried no asset id is a real charge with an + // unusable result, and the message must say so and point at + // action:"list" — not "failed", which invites a second paid enrolment + // (round 4b: the D13 step the other media tools have). + let bookedUsd: number | null = null; + const book = (settledUsd: number | null) => { + recordActualSpend(budget, settledUsd, ENROLLMENT_PRICE_USD, agent_id); + bookedUsd = settledUsd ?? ENROLLMENT_PRICE_USD; + }; try { // ---- init (free) ---- if (action === "init") { @@ -431,7 +441,8 @@ Privacy: BlockRun does not store face/liveness data — only the asset id, name, return { content: [{ type: "text", text: formatError(`Portrait rejected — ${data.hint || data.message || "use a clear front-facing character image"}. No payment taken.`) }], isError: true }; } if (status < 200 || status >= 300) { - throw new Error(`Portrait enroll error ${status}: ${data.error || JSON.stringify(data)}`); + // Status on the error, as the realface branch below (round 4b). + throw Object.assign(new Error(`Portrait enroll error ${status}: ${data.error || JSON.stringify(data)}`), { statusCode: status }); } // The gateway answers 2xx only AFTER settling, so the charge is real @@ -439,7 +450,7 @@ Privacy: BlockRun does not store face/liveness data — only the asset id, name, // a truncated or asset-less body used to throw first, the catch // formatted a failure, and finally released the reservation — a real // charge the ledger never saw (same ordering video.ts and speech.ts fixed). - recordActualSpend(budget, settledUsd, ENROLLMENT_PRICE_USD, agent_id); + book(settledUsd); const assetId: string | undefined = data.asset_id; if (!assetId) throw new Error(`Portrait response missing asset_id: ${JSON.stringify(data)}`); @@ -520,12 +531,15 @@ Privacy: BlockRun does not store face/liveness data — only the asset id, name, return { content: [{ type: "text", text: formatError(`Face match failed — ${data.hint || "use a clearer front-facing photo of the same person"}. No payment taken.`) }], isError: true }; } if (status < 200 || status >= 300) { - throw new Error(`Enroll error ${status}: ${data.error || JSON.stringify(data)}`); + // With the status on it, so the catch can tell an edge 504 (the + // origin may still be enrolling and settling — a maybe) from the + // gateway's own refusal (round 4b). + throw Object.assign(new Error(`Enroll error ${status}: ${data.error || JSON.stringify(data)}`), { statusCode: status }); } // Book before validating the payload — see the portrait action above: // a settled 2xx with a malformed body must not un-record the charge. - recordActualSpend(budget, settledUsd, ENROLLMENT_PRICE_USD, agent_id); + book(settledUsd); const assetId: string | undefined = data.asset_id; if (!assetId) throw new Error(`Enroll response missing asset_id: ${JSON.stringify(data)}`); @@ -555,6 +569,16 @@ Privacy: BlockRun does not store face/liveness data — only the asset id, name, return { content: [{ type: "text", text: formatError(`Unknown action: ${action}`) }], isError: true }; } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); + // 1. Settlement was observed and booked, then the body could not be + // used. First, before any word-based branch can misread it. + 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: `RealFace ${action} settled and the charge stands — $${booked.toFixed(4)} is booked against your budget — but the response could not be used: ${errMsg}\nRun blockrun_realface action:"list" to find the asset before enrolling again; check ${where} first.` }], + isError: true, + }; + } if (isPaymentRejectionError(errMsg)) { return { content: [{ type: "text", text: isApiKeyMode() diff --git a/src/tools/rpc.ts b/src/tools/rpc.ts index ff84456..c3c4bcb 100644 --- a/src/tools/rpc.ts +++ b/src/tools/rpc.ts @@ -14,7 +14,7 @@ import { reserveBudget, recordSpending, recordActualSpend } from "../utils/budge 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 { buildClient } from "../utils/wallet.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"; @@ -98,7 +98,14 @@ Prefer blockrun_price (free quotes) or blockrun_dex (free DEX data) when they co // reservation. No-ops when off, sub-threshold, or unsupported by the client. 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; + // A FRESH client per call, never the shared singleton: rawGet/rawPost + // read the SDK's cumulative spend counter around the call to tell a + // settled-then-failed request from a free refusal, and the MCP SDK + // dispatches tool calls concurrently — on a shared client a + // concurrent call's settlement landed inside this call's window and + // was booked to it as "the charge stands" (audit round 4b). Same + // reason blockrun_chat builds its own. + const client = buildClient() as unknown as RawClient; sentUsd = estimatedCost; const { data: result, paidUsd } = await rawPost(client, `/v1/rpc/${cleanNetwork}`, body); recordActualSpend(budget, paidUsd, ledgerFallback(estimatedCost), agent_id); diff --git a/src/tools/search.ts b/src/tools/search.ts index 9fe99ab..3e63638 100644 --- a/src/tools/search.ts +++ b/src/tools/search.ts @@ -11,7 +11,7 @@ import { z } from "zod"; import { reserveBudget, recordSpending, 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 { buildClient } from "../utils/wallet.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"; @@ -75,7 +75,7 @@ export function estimateSearchCost(body: unknown): number { // 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. */ +/** The refusal for a `sources` entry the gateway no longer serves, or null. Pinned directly in test/search-sources.test.ts. */ export function unsupportedSearchSource(body: unknown): string | null { if (!body || typeof body !== "object") return null; const sources = (body as { sources?: unknown }).sources; @@ -140,7 +140,14 @@ Full request shape + worked examples in the \`search\` skill (\`skills/search/SK // reservation. No-ops when off, sub-threshold, or unsupported by the client. const confirm = await confirmSpend(server, { usd: estimatedCost, label: `search · ${cleanPath || "search"}` }); if (!confirm.ok) return { content: [{ type: "text", text: confirm.reason ?? "Charge cancelled." }] }; - const client = getClient() as unknown as RawClient; + // A FRESH client per call, never the shared singleton: rawGet/rawPost + // read the SDK's cumulative spend counter around the call to tell a + // settled-then-failed request from a free refusal, and the MCP SDK + // dispatches tool calls concurrently — on a shared client a + // concurrent call's settlement landed inside this call's window and + // was booked to it as "the charge stands" (audit round 4b). Same + // reason blockrun_chat builds its own. + const client = buildClient() as unknown as RawClient; const endpoint = cleanPath ? `/v1/search/${cleanPath}` : "/v1/search"; sentUsd = estimatedCost; const { data: result, paidUsd } = await rawPost(client, endpoint, body ?? {}); diff --git a/src/tools/video.ts b/src/tools/video.ts index 6130017..eab48cf 100644 --- a/src/tools/video.ts +++ b/src/tools/video.ts @@ -326,7 +326,7 @@ export function registerVideoTool(server: McpServer, budget: BudgetState): void { description: `Generate short AI videos via BlockRun x402 on the active Base or Solana chain (async, client-polled). -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. +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). On the wallet rails payment settles 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. On the account rail the job is billed when the gateway accepts it, so a job that fails or outlives the poll budget is still charged — the error names it. 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- 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. diff --git a/src/utils/api-key-call.ts b/src/utils/api-key-call.ts index d283b88..7449ad3 100644 --- a/src/utils/api-key-call.ts +++ b/src/utils/api-key-call.ts @@ -17,6 +17,8 @@ import { fetchWithTimeout } from "./http.js"; import { JobFailedError, pollTimeoutFor } from "./poll.js"; +import { ORIGIN_DID_NOT_ANSWER } from "./uncharged.js"; +import { RawCallSettledError } from "./settled-error.js"; import { apiAuthHeaders } from "./auth.js"; import { getApiBase, resolveGatewayUrl } from "./wallet.js"; @@ -153,6 +155,22 @@ async function readJson(response: Response): Promise> { return (await response.json().catch(() => ({}))) as Record; } +/** + * The body of a SETTLED 2xx. This rail bills on the response, and the cost + * header is already on it, so a body that will not parse is a charge with an + * unusable result — a typed error the path tools' catch books and says the + * charge stands for, as the wallet rails do (round 4b). Until then it was + * returned as a successful `{}`. + */ +async function readSettledJson(response: Response, what: string): Promise> { + try { + return (await response.json()) as Record; + } catch (err) { + const why = err instanceof Error ? err.message : String(err); + throw new RawCallSettledError(`${what} answered ${response.status} but the body could not be read: ${why}`, costFrom(response), { cause: err }); + } +} + /** The message for a non-ok response whose body has already been read. */ function statusErrorMessage(response: Response, what: string, body: Record): string { // A 402 on this rail is not a quote to pay — it means the ACCOUNT is out of @@ -218,7 +236,7 @@ export async function apiKeyPost( opts.timeoutMs ?? 120_000, ); if (!response.ok) await throwForStatus(response, `POST ${endpoint}`); - return { data: await readJson(response), paidUsd: costFrom(response), txHash: receiptFrom(response) }; + return { data: await readSettledJson(response, `POST ${endpoint}`), paidUsd: costFrom(response), txHash: receiptFrom(response) }; } /** @@ -241,7 +259,7 @@ export async function apiKeyGet( opts.timeoutMs ?? 120_000, ); if (!response.ok) await throwForStatus(response, `GET ${endpoint}`); - return { data: await readJson(response), paidUsd: costFrom(response), txHash: receiptFrom(response) }; + return { data: await readSettledJson(response, `GET ${endpoint}`), paidUsd: costFrom(response), txHash: receiptFrom(response) }; } /** @@ -299,7 +317,21 @@ export async function apiKeyAsyncPost( { paidUsd: null, billing: "unknown" }, ); } - if (!submit.ok && submit.status !== 202) await throwForStatus(submit, `POST ${endpoint}`); + if (!submit.ok && submit.status !== 202) { + // An EDGE status is not the gateway's answer: the origin may have accepted + // the job and be billing it while the load balancer gave up waiting. Same + // "unknown" as a submit that never returned (round 4b). + if (ORIGIN_DID_NOT_ANSWER.has(submit.status)) { + const body = await readJson(submit); + throw new BilledJobError( + `POST ${endpoint} was answered ${submit.status} by the edge, not by the gateway (${JSON.stringify(body)}). The request reached the ` + + `gateway and this rail bills a job the moment it is accepted, so the job MAY have been accepted and billed to the account — ` + + `check https://user.blockrun.ai/dashboard/activity before submitting again.`, + { paidUsd: null, billing: "unknown" }, + ); + } + await throwForStatus(submit, `POST ${endpoint}`); + } const submitted = await readJson(submit); const submitCost = costFrom(submit); diff --git a/src/utils/budget.ts b/src/utils/budget.ts index e5196ed..3ca7528 100644 --- a/src/utils/budget.ts +++ b/src/utils/budget.ts @@ -282,8 +282,10 @@ export function getOperatorCeiling(budget: BudgetState): number | null { // 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. +// while an id is revoked is credited to the tombstone as well as the global +// ledger (recordSpending) — a revoked id has no per-agent CAP to refuse +// against, but the money it spent must come back with the id, or revoke → +// settle → delegate is a refill one in-flight call at a time (round 4). const revokedLedgers = new WeakMap>(); /** diff --git a/src/utils/chat-stream.ts b/src/utils/chat-stream.ts index a6841b9..5548803 100644 --- a/src/utils/chat-stream.ts +++ b/src/utils/chat-stream.ts @@ -27,6 +27,7 @@ // Both shapes now feed the same accumulator (assembleChatFrames). import type { ApiClient } from "./wallet.js"; import { parseCostHeader } from "./api-key-call.js"; +import { ORIGIN_DID_NOT_ANSWER } from "./uncharged.js"; /** Chat message shape the gateway accepts (content may be multimodal parts). */ export interface StreamChatMessage { @@ -367,6 +368,14 @@ export async function completeChat( if (!first.done) acc.fold(first.value); const out = first.done ? acc.result() : await assembleChatFrames(gen, opts.idleTimeoutMs, acc); rejectEmptyLength(model, out); + // The SDK's frame reader yields only `data:` lines. A route that ignored + // stream:true and answered a plain JSON body yields NOTHING — and the + // settlement was already recorded — so an empty result here would come + // back as a paid, successful, empty reply: the silent-truncation shape + // the Response path guards with its content-type check (round 4b). + if (!out.text && !out.finishReason && !out.servedModel) { + throw new AcceptedThenFailedError(`${model}: the paid stream ended with no frames — the route answered a streaming request with a body this client could not read`); + } return { ...out, settledUsd: null }; }); } @@ -397,19 +406,29 @@ export async function completeChat( */ 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]); +// ORIGIN_DID_NOT_ANSWER (utils/uncharged.ts): the edge statuses that are not +// an answer. Shared with the path tools and the media tools' tracker. // A fetch rejection that proves the request never left this machine. -const NEVER_CONNECTED = new Set(["ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED", "ENETUNREACH", "EHOSTUNREACH", "EADDRNOTAVAIL"]); +// UND_ERR_CONNECT_TIMEOUT too: undici gave up before the TCP connection +// existed, so nothing was sent — on Solana that is the shape of the blockhash +// RPC dying inside the SDK's signing step, which round 4b found reading as +// "unknown" and booking the tier reserve for a payment that was never signed. +const NEVER_CONNECTED = new Set(["ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED", "ENETUNREACH", "EHOSTUNREACH", "EADDRNOTAVAIL", "UND_ERR_CONNECT_TIMEOUT"]); // 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; +/** True when an errno anywhere in the (bounded) cause chain proves the request never left this machine. */ +function neverConnectedCode(error: unknown): boolean { + let e = error as { code?: unknown; cause?: unknown } | undefined; + for (let depth = 0; e && depth < 5; depth++) { + if (typeof e.code === "string" && NEVER_CONNECTED.has(e.code)) return true; + e = e.cause as { code?: unknown; cause?: unknown } | undefined; + } + return false; +} + 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 @@ -457,8 +476,13 @@ export function settlementOnThrow( // 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"; + // The cause CHAIN, bounded: undici puts the errno on `cause.code`, and the + // Anthropic SDK wraps that TypeError once more (APIConnectionError → cause + // TypeError("fetch failed") → cause { code }), so a depth-one read found + // nothing and the "fetch failed" text tripped the transport regex — a DNS + // failure on the native path booked the reconstructed quote and forbade a + // retry, while the same outage on the compat path read "none" (round 4b). + if (neverConnectedCode(error)) return "none"; const status = statusOf(error); if (status !== undefined) { diff --git a/src/utils/in-flight.ts b/src/utils/in-flight.ts index 07709c2..9bd48c2 100644 --- a/src/utils/in-flight.ts +++ b/src/utils/in-flight.ts @@ -40,7 +40,7 @@ import type { BudgetState } from "../types.js"; import { recordActualSpend } from "./budget.js"; import { isTimeoutError } from "./http.js"; import { isApiKeyMode } from "./auth.js"; -import { isExplicitlyUncharged } from "./uncharged.js"; +import { isExplicitlyUncharged, ORIGIN_DID_NOT_ANSWER } from "./uncharged.js"; // Literal rather than PORTAL_ACTIVITY_URL: every handler test that mocks // utils/auth.js lists its named exports by hand, and the one name this module @@ -95,7 +95,12 @@ const NEVER_CONNECTED = new Set(["ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED", "ENET */ function noResponseObserved(err: unknown): boolean { if (isAnswer(err)) return false; - const cause = (err as { cause?: { code?: unknown } } | undefined)?.cause; + const e = err as { cause?: { code?: unknown } } | undefined; + // An edge status on the paid request: the request left, the origin did not + // answer — the same "no verdict" as a dropped socket, and booked the same. + const status = statusOf(err); + if (status !== undefined && ORIGIN_DID_NOT_ANSWER.has(status)) return true; + const cause = e?.cause; const code = typeof cause?.code === "string" ? cause.code : ""; if (NEVER_CONNECTED.has(code)) return false; if (isTimeoutError(err)) return true; @@ -121,18 +126,35 @@ function noResponseObserved(err: unknown): boolean { * job verdict, or the gateway's own uncharged marker. */ function isAnswer(err: unknown): boolean { - const e = err as { statusCode?: unknown; status?: unknown; name?: unknown; message?: unknown } | undefined; - if (typeof e?.statusCode === "number" || typeof e?.status === "number") return true; - if (e?.name === "JobFailedError" || e?.name === "BilledJobError" || e?.name === "AccountApiError") return true; - return typeof e?.message === "string" && isExplicitlyUncharged(e.message); + const e = err as { name?: unknown; message?: unknown } | undefined; + if (e?.name === "JobFailedError" || e?.name === "BilledJobError") return true; + if (typeof e?.message === "string" && isExplicitlyUncharged(e.message)) return true; + const status = statusOf(err); + // A status is the gateway's verdict — unless it is an EDGE status, which + // says only that the origin did not answer in time: it may still be running + // the request and settling it. Round 4 read every number as an answer and + // undid, for the media tools alone, the rule chat and the path tools apply + // to the same status on the same rails (round 4b, P1). + return status !== undefined && !ORIGIN_DID_NOT_ANSWER.has(status); +} + +/** The status on an error, when it carries one (SDK APIError / AccountApiError `statusCode`, Anthropic SDK `status`). */ +function statusOf(err: unknown): number | undefined { + const e = err as { statusCode?: unknown; status?: unknown } | undefined; + return typeof e?.statusCode === "number" ? e.statusCode : typeof e?.status === "number" ? e.status : undefined; } export function trackPaidRequest(): PaidRequest { let armed = false; + // Once true, a request carrying the payment has LEFT at least once. Kept + // apart from `armed`: an edge status settles the tracker (a response did + // arrive) and still means the origin may be running the request. + let sent = false; let quoted: number | null = null; return { arm(quotedUsd) { armed = true; + sent = true; if (typeof quotedUsd === "number" && Number.isFinite(quotedUsd) && quotedUsd > 0) quoted = quotedUsd; }, settle() { @@ -145,6 +167,14 @@ export function trackPaidRequest(): PaidRequest { return quoted; }, mayHaveSettled(err) { + // An edge status on a request that carried the payment is a maybe + // whether or not the tracker was settled by its arrival — the + // gateway's own uncharged marker is the only thing that overrules it. + const status = statusOf(err); + if (sent && status !== undefined && ORIGIN_DID_NOT_ANSWER.has(status)) { + const msg = err instanceof Error ? err.message : String(err); + return !isExplicitlyUncharged(msg); + } return armed && noResponseObserved(err); }, }; @@ -165,7 +195,10 @@ export function trackPaidRequest(): PaidRequest { * exact edges of every request carrying the signature — so arm() and * settle() from those hooks and capture the quote in onQuote for arm() to * book. (Round 3 armed at onQuote, one step early: a signing-time RPC - * failure read as "may have settled". Round 4 moved every tool to the hooks.) + * failure read as "may have settled". Round 4 moved image, speech and + * realface to the hooks alone; video and music arm at BOTH — onQuote for the + * async helper's signing window and the hooks for every later request — and + * accept that residual window on purpose, documented in video.ts.) * * NOT for a helper that bills on its own and classifies its own exits * (apiKeyAsyncPost: BilledJobError / JobFailedError). Wrapping one leaves diff --git a/src/utils/keychain.ts b/src/utils/keychain.ts index a7185a8..16119d2 100644 --- a/src/utils/keychain.ts +++ b/src/utils/keychain.ts @@ -60,9 +60,28 @@ const MACOS_ITEM_NOT_FOUND = 44; */ const LINUX_ITEM_NOT_FOUND = 1; -/** A secret-tool exit 1 that printed nothing is a miss; one that said why is a fault. */ -function linuxLookupMissed(result: { status: number | null; stderr?: string | null }): boolean { - return result.status === LINUX_ITEM_NOT_FOUND && !(result.stderr ?? "").trim(); +/** + * secret-tool's stderr when there is NO secrets service to talk to at all — no + * D-Bus session (SSH, containers, systemd units), or a session bus with no + * keyring provider (Fedora and Arch ship secret-tool in the base libsecret + * package). That is a keychain that does not exist, not one that would not + * open: nothing funded can be in it. Round 4 read every stderr as a fault, + * and a fresh install on such a host could never mint a wallet — every paid + * tool refused with "your existing one is most likely still in the keychain" + * until the user found BLOCKRUN_KEYCHAIN=off (round 4b). + */ +const LINUX_NO_SECRETS_SERVICE = /cannot autolaunch d-bus|was not provided by any \.service files|could not connect|failed to connect to socket|no such interface|org\.freedesktop\.secrets|name is not activatable|dbus/i; + +/** + * What a secret-tool exit 1 meant: a miss printed nothing; a service that is + * not there printed one of the lines above; anything else it printed (a locked + * collection, a dismissed unlock prompt) is a fault a funded key may sit behind. + */ +function linuxLookupVerdict(result: { status: number | null; stderr?: string | null }): "miss" | "unavailable" | "fault" { + if (result.status !== LINUX_ITEM_NOT_FOUND) return "fault"; + const said = (result.stderr ?? "").trim(); + if (!said) return "miss"; + return LINUX_NO_SECRETS_SERVICE.test(said) ? "unavailable" : "fault"; } const warned = new Set(); @@ -258,7 +277,7 @@ export function keychainRead(account: string): KeychainRead { const value = result.stdout.trim(); return value ? { status: "found", value } : { status: "absent" }; } - if (linuxLookupMissed(result)) return { status: "absent" }; + if (linuxLookupVerdict(result) !== "fault") return { status: "absent" }; if (binaryMissing(result)) return { status: "absent" }; const said = (result.stderr ?? "").trim().split("\n")[0]; return { status: "error", detail: `secret-tool exit ${result.status ?? "timeout"}${said ? `: ${said}` : ""}` }; @@ -308,7 +327,7 @@ export function keychainLoad(account: string): string | null { { timeout: TIMEOUT_MS, encoding: "utf-8" }, ); if (result.status === 0) return result.stdout.trim() || null; - if (!linuxLookupMissed(result) && !binaryMissing(result)) { + if (linuxLookupVerdict(result) === "fault" && !binaryMissing(result)) { const said = (result.stderr ?? "").trim().split("\n")[0]; warnOnce( `OS keychain read failed (secret-tool exit ${result.status}${said ? `: ${said}` : ""}) — falling back to ~/.blockrun/.session.`, diff --git a/src/utils/path-safety.ts b/src/utils/path-safety.ts index ef87fc6..c803fcc 100644 --- a/src/utils/path-safety.ts +++ b/src/utils/path-safety.ts @@ -114,7 +114,17 @@ function isDotSegment(seg: string): boolean { * discipline as everything else in this file. */ function cutQueryAndControls(path: string): string { - return path.replace(/[\t\n\r]/g, "").replace(/[?#][\s\S]*$/, ""); + // The parser's FIRST step, before even the tab strip: leading and trailing + // C0-control-or-space are removed from the whole input. The slug is the + // tail of `${base}${endpoint}` on every rail, so TRAILING applies to it — + // `phone/numbers/buy ` left the machine as /v1/phone/numbers/buy and served + // the $5.001 route while the exact-match price row missed it and $0.012 + // was reserved (audit round 4b, P0). Only when nothing follows the slug: + // with a query or fragment present the trailing bytes belong to those, and + // a space BEFORE the `?` is inside the path, which the parser keeps + // percent-encoded (the gateway then 404s it unpaid — the safe direction). + const tailStripped = /[?#]/.test(path) ? path : path.replace(/[\u0000-\u0020]+$/, ""); + return tailStripped.replace(/[\t\n\r]/g, "").replace(/[?#][\s\S]*$/, ""); } /** @@ -161,7 +171,13 @@ export function normalizeClassifyPath(path: string): string { // The query cut itself lives in cutQueryAndControls, with hasPathTraversal — // its `[?#].*$` predecessor did not cross a line terminator, so // `phone/numbers/buy?\n` kept its `?` and priced as the $0.012 unknown. - const asSent = cutQueryAndControls(path); + // A literal `\` is `/` to the parser for https (hasPathTraversal already + // splits on both); an ENCODED %5C is not — it stays a literal backslash in + // the segment and the gateway's exact match 404s it unpaid. So the swap + // happens on the raw slug, before the decode. Without it `sandbox\create` + // classified as the $0.003 op, skipped the handler's gpu/timeout + // normalisation, and the gateway served a $192 sandbox/create (round 4b). + const asSent = cutQueryAndControls(path).replace(/\\/g, "/"); let decoded = asSent; try { decoded = decodeURIComponent(asSent); } catch { /* malformed %: classify as-sent */ } return decoded diff --git a/src/utils/path-tool-catch.ts b/src/utils/path-tool-catch.ts index 8aac16b..0a1da89 100644 --- a/src/utils/path-tool-catch.ts +++ b/src/utils/path-tool-catch.ts @@ -35,13 +35,9 @@ import { recordActualSpend } from "./budget.js"; import { settlementOnThrow } from "./chat-stream.js"; import { isApiKeyMode } from "./auth.js"; import { ledgerFallback, RawCallSettledError } from "./raw-call.js"; -import { basePaymentReplayHedge, extractErrorMessage, formatError, isExplicitlyUncharged } from "./errors.js"; +import { basePaymentReplayHedge, extractErrorMessage, formatError } from "./errors.js"; +import { isExplicitlyUncharged, ORIGIN_DID_NOT_ANSWER } from "./uncharged.js"; -// 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 after -// the client is gone. Mirrors ORIGIN_DID_NOT_ANSWER in utils/chat-stream.ts -// (not exported there); keep the two in step. -const ORIGIN_DID_NOT_ANSWER = new Set([408, 502, 504, 520, 521, 522, 523, 524, 525, 526, 527, 529, 530]); function statusOf(err: unknown): number | undefined { const e = err as { statusCode?: unknown; status?: unknown } | undefined; @@ -86,13 +82,15 @@ export function pathToolFailure(err: unknown, opts: PathToolFailureOpts): PathTo // The SDK's counter moved before the throw: a CERTAIN charge at a known // amount (see RawCallSettledError), not a maybe at the reserve. if (err instanceof RawCallSettledError) { - recordActualSpend(opts.budget, err.settledUsd, opts.sentUsd, opts.agentId); + // The counted amount where the rail reported one; the reserve otherwise. + const booked = err.settledUsd ?? ledgerFallback(opts.sentUsd); + recordActualSpend(opts.budget, err.settledUsd, booked, opts.agentId); return { content: [{ type: "text", text: `Error: ${err.message} -The payment settled before this failure, so the charge stands — $${err.settledUsd.toFixed(4)} is booked against your budget. ` + +The payment settled before this failure, so the charge stands — $${booked.toFixed(4)} is booked against your budget. ` + `The gateway answered but the response could not be read; retrying pays again. Check blockrun_wallet action:"report" first.`, }], isError: true, diff --git a/src/utils/polymarket/creds.ts b/src/utils/polymarket/creds.ts index 08037af..9028163 100644 --- a/src/utils/polymarket/creds.ts +++ b/src/utils/polymarket/creds.ts @@ -66,13 +66,27 @@ export interface PolymarketState { pendingFund?: { amountUsd: number; deadline: number }; } +/** + * Thrown when a state file EXISTS but cannot be read or parsed. Distinct from + * absent on purpose: the fund/withdraw double-send guards live in this file, + * and a loader that answered "nothing pending" for a file it could not read + * let a second full authorization be signed inside the window — and the next + * save then merged onto {} and overwrote the evidence (round 4b). + */ +export class StateUnreadableError extends Error { + constructor(file: string, cause: unknown) { + super(`${file} exists but could not be read: ${cause instanceof Error ? cause.message : String(cause)}. Fix or move the file before signing anything — it holds the trading credentials and the double-send guards.`); + this.name = "StateUnreadableError"; + } +} + function readJsonFile(file: string): T | null { + if (!fs.existsSync(file)) return null; try { - if (!fs.existsSync(file)) return null; const raw = fs.readFileSync(file, "utf-8").trim(); return raw ? (JSON.parse(raw) as T) : null; - } catch { - return null; + } catch (err) { + throw new StateUnreadableError(file, err); } } diff --git a/src/utils/polymarket/orders.ts b/src/utils/polymarket/orders.ts index c34549a..a1b6090 100644 --- a/src/utils/polymarket/orders.ts +++ b/src/utils/polymarket/orders.ts @@ -229,7 +229,7 @@ function bestQuote(book: OrderBookSummary, side: "buy" | "sell"): number | null * level for FOK, the top-of-array level for FAK), so a book that thinned * between preview and confirm was signed far from the "best ask" the user * consented to. Books are not guaranteed sorted; sort explicitly. - * Exported for tests. + * Exported for the direct cases in test/polymarket-walk-book.test.ts. */ export function walkBook( book: OrderBookSummary, @@ -372,6 +372,10 @@ async function withCredsRetry(fn: (clob: ClobClient) => Promise): Promise< try { return await fn(clob); } catch (err) { + // An unknown outcome carries the raw transport text inside its message, + // and that text can contain "unauthorized"; re-running the whole trade on + // it would sign a second order on top of one that may be live (round 4b). + if (err instanceof OrderOutcomeUnknownError) throw err; if (!isCredsMismatchError(err as { message?: string; status?: number; data?: unknown })) throw err; const { address, sigType } = bindAddressForCreds(); if (address) invalidateL2Creds(address, sigType); @@ -413,7 +417,25 @@ export interface TradeInput { export type { ToolResult, SpendGate } from "./transactions.js"; +/** + * The sentence every DEFINITE refusal from executeTrade ends with, so the + * order card (apps/order-safety.ts outcomeIsUnknown) can tell it from an + * unknown outcome. The card keys on the repo's uncharged wording; of + * executeTrade's refusals only two carried it, so a per-order-cap refusal or + * a definite CLOB 4xx locked the card as "outcome UNKNOWN" and told the model + * to check positions for an order the server provably never placed (round 4b). + */ +const NOT_PLACED = " The order was not placed and nothing was charged."; + export async function executeTrade(input: TradeInput): Promise { + const result = await executeTradeInner(input); + if (!result.isError) return result; + const unknown = (result.structured as { outcome?: unknown } | undefined)?.outcome === "unknown"; + if (unknown || /nothing was charged|no charge was made|nothing was signed/i.test(result.text)) return result; + return { ...result, text: `${result.text}${NOT_PLACED}`, structured: { ...(result.structured ?? {}), outcome: "rejected" } }; +} + +async function executeTradeInner(input: TradeInput): Promise { const side = input.action === "buy" ? Side.BUY : Side.SELL; const isLimit = input.price !== undefined; @@ -636,11 +658,21 @@ export async function executeTrade(input: TradeInput): Promise { const options = { tickSize: tickSize as never, negRisk }; - // The signed submit, factored out so it can be retried after a CLOB - // balance-cache refresh (below) without rebuilding/re-signing anything else. - const submitOrder = (): Promise => + // SIGN and POST are two steps on purpose. The SDK's createAndPost* + // helpers make several network reads BEFORE they sign (GET /version on + // the first order, the market's condition id and tick size on the first + // order per token, builder fees), and a relay 502 or a dropped socket + // on any of those has no 4xx behind it — so wrapping the whole helper + // in the unknown-outcome classifier booked a phantom "possibly live" + // order that never released, and told the agent not to retry, when + // nothing had been signed (round 4b). Only the POST of the signed order + // can have an unknown outcome; a pre-sign failure is a plain error. + const orderType = isLimit + ? (orderKind === "GTD" ? OrderType.GTD : OrderType.GTC) + : (orderKind === "FAK" ? OrderType.FAK : OrderType.FOK); + const signOrder = () => isLimit - ? clob.createAndPostOrder( + ? clob.createOrder( { tokenID: token.tokenId, price: price as number, @@ -649,10 +681,8 @@ export async function executeTrade(input: TradeInput): Promise { ...(orderKind === "GTD" && input.expires_at ? { expiration: input.expires_at } : {}), }, options, - orderKind === "GTD" ? OrderType.GTD : OrderType.GTC, - input.post_only ?? false, ) - : clob.createAndPostMarketOrder( + : clob.createMarketOrder( { tokenID: token.tokenId, amount: input.action === "buy" ? (input.amount_usd as number) : (size as number), @@ -665,8 +695,9 @@ export async function executeTrade(input: TradeInput): Promise { price: worstFillPrice as number, }, options, - orderKind === "FAK" ? OrderType.FAK : OrderType.FOK, ); + const postSigned = (signed: Awaited>): Promise => + clob.postOrder(signed, orderType, isLimit ? (input.post_only ?? false) : undefined); // The SDK signs and posts; a throw with no 4xx behind it (dropped socket, // client timeout, relay 502/504) arrives AFTER the order may have been @@ -690,26 +721,42 @@ export async function executeTrade(input: TradeInput): Promise { ); }; const submitOrUnknown = async (): Promise => { + // A throw here — the SDK's pre-sign reads, or the signing itself — + // moved nothing: it propagates plain, and the reservation is released. + const signed = await signOrder(); try { - return await submitOrder(); + return await postSigned(signed); } catch (err) { throw isDefiniteRejection(err) ? err : outcomeUnknown(err); } }; + // Reserve now — before the dialog and before the await — so a + // concurrent order sees this spend; rolled back if the user declines or + // the submit is definitely rejected, so neither consumes budget. The + // dialog used to sit between the cap check and this reservation, and + // two confirms waiting on it together could overshoot + // POLYMARKET_MAX_SESSION_USD (round 4b). + reserveBet(notional, input.agent_id); + // The last word before a signature is the user's, when the operator // asked for one (BLOCKRUN_CONFIRM_SPEND=on). Every guard above has - // passed, nothing is reserved yet, so a decline leaves no residue. + // passed; a decline releases the reservation and leaves no residue. if (input.askUser) { const what = `${input.action} ${token.outcome ? `"${token.outcome}"` : `token ${token.tokenId.slice(0, 12)}…`}`; - const gate = await input.askUser(notional, `polymarket · ${what} · ${isLimit ? `limit ${orderKind}` : `market ${orderKind}`}`); - if (!gate.ok) return declinedResult(`the ${what} order`); + let gate: { ok: boolean; reason?: string }; + try { + gate = await input.askUser(notional, `polymarket · ${what} · ${isLimit ? `limit ${orderKind}` : `market ${orderKind}`}`); + } catch (err) { + releaseBet(notional, input.agent_id); + throw err; + } + if (!gate.ok) { + releaseBet(notional, input.agent_id); + return declinedResult(`the ${what} order`); + } } - // Reserve now (before the await) so a concurrent order sees this spend; - // roll back if the submit is definitely rejected so a failed order - // doesn't consume budget. - reserveBet(notional, input.agent_id); let response: unknown; try { try { diff --git a/src/utils/polymarket/relayer.ts b/src/utils/polymarket/relayer.ts index 1633963..e7f446b 100644 --- a/src/utils/polymarket/relayer.ts +++ b/src/utils/polymarket/relayer.ts @@ -263,7 +263,18 @@ export async function sendWalletBatch( if (opts?.trackPendingWithdraw) { saveState({ pendingWithdraw: { transactionID: response.transactionID, deadline: deadlineSec } }); } - const confirmed = await quietStdout(() => response.wait()); + // wait() → pollUntilState → HttpClient.send THROWS on a transport failure + // mid-poll (`{"error":"connection error"}`, a relay 5xx) — the batch was + // ACCEPTED and may land; that is the same case as wait() resolving + // undefined, not a plain error to hand back verbatim with no anti-retry + // wording (round 4b). + let confirmed: Awaited> | undefined; + let waitFailure: string | undefined; + try { + confirmed = await quietStdout(() => response.wait()); + } catch (err) { + waitFailure = err instanceof Error ? err.message : String(err); + } if (!confirmed) { const state = await getRelayerTransactionState(response.transactionID); if (state && TERMINAL_FAILURE_STATES.includes(state)) { @@ -279,7 +290,7 @@ export async function sendWalletBatch( // no "failed"/"revert" wording (this is not a revert), and deliberately // anti-retry advice — pendingWithdraw stays persisted when tracked. throw new Error( - `${description}: relayer batch did not confirm within the polling window ` + + `${description}: relayer batch ${waitFailure ? `polling failed (${waitFailure})` : "did not confirm within the polling window"} ` + `(tx ${response.transactionID}, relayer state: ${state ?? "unreachable"}). It may still land — the signed ` + `batch stays executable until its ${BATCH_DEADLINE_SECS / 60}-minute deadline. Do NOT retry yet: wait for ` + `the deadline to pass, then ${opts?.guidance ?? 're-run action:"setup" to re-check state'}.`, diff --git a/src/utils/polymarket/withdraw.ts b/src/utils/polymarket/withdraw.ts index 9a72cf7..16bdef1 100644 --- a/src/utils/polymarket/withdraw.ts +++ b/src/utils/polymarket/withdraw.ts @@ -167,8 +167,21 @@ export async function withdrawFunds(input: WithdrawInput): Promise { // see relayer.ts sendWalletBatch). There is nothing to look up, and the // signed batch may still land — block until the deadline passes. const idUnknown = pending.transactionID === "unknown"; - const state = idUnknown ? undefined : await getRelayerTransactionState(pending.transactionID); - if (state === "STATE_MINED" || state === "STATE_CONFIRMED" || state === "STATE_FAILED" || state === "STATE_INVALID") { + // An EOA (sigType 0) withdrawal is a plain Polygon transaction, not a + // relayer batch: "eoa:" is looked up by receipt, a bare "eoa" + // (the send itself never answered) blocks until the deadline. Round 4b: + // this rail had no guard at all, so a receipt timeout after the + // broadcast invited a second full transfer with a fresh nonce. + const eoaHash = pending.transactionID.startsWith("eoa:") ? pending.transactionID.slice(4) : undefined; + let settled = false; + if (eoaHash) { + try { + const receipt = await getPublicClient().getTransactionReceipt({ hash: eoaHash as Hex }); + settled = Boolean(receipt); + } catch { settled = false; } + } + const state = idUnknown || pending.transactionID.startsWith("eoa") ? undefined : await getRelayerTransactionState(pending.transactionID); + if (settled || state === "STATE_MINED" || state === "STATE_CONFIRMED" || state === "STATE_FAILED" || state === "STATE_INVALID") { saveState({ pendingWithdraw: undefined }); } else { const waitSecs = pending.deadline + graceSec - Math.floor(Date.now() / 1000); @@ -289,18 +302,31 @@ export async function withdrawFunds(input: WithdrawInput): Promise { } else { const account = getPolymarketAccount(); const wallet = createWalletClient({ account, chain: polygon, transport: http(POLYGON_WRITE_RPC_URL) }); - txHash = await wallet.sendTransaction({ to: PUSD_COLLATERAL as Hex, data, chain: polygon, account }); - // viem does NOT throw on a reverted tx — it resolves with status:"reverted". - // Discarding the receipt meant a REVERTED pUSD transfer still printed - // "✅ Withdrawal submitted … the bridge delivers USDC to Base" with a link - // to the failed tx and no isError, so the user waited for money that was - // never sent and blamed the bridge. redeem.ts and setup.ts both assert - // status; this path was the one that did not. - assertTransactionSucceeded( - await getPublicClient().waitForTransactionReceipt({ hash: txHash as Hex }), - "pUSD transfer", - txHash, - ); + // The same double-send guard the relayer path keeps: armed before the + // broadcast (a send that never answers may still have reached the + // node), the hash recorded once known, cleared only on a receipt. + const eoaDeadline = Math.floor(Date.now() / 1000) + 300; + saveState({ pendingWithdraw: { transactionID: "eoa", deadline: eoaDeadline } }); + try { + txHash = await wallet.sendTransaction({ to: PUSD_COLLATERAL as Hex, data, chain: polygon, account }); + saveState({ pendingWithdraw: { transactionID: `eoa:${txHash}`, deadline: eoaDeadline } }); + // viem does NOT throw on a reverted tx — it resolves with status:"reverted". + // Discarding the receipt meant a REVERTED pUSD transfer still printed + // "✅ Withdrawal submitted … the bridge delivers USDC to Base" with a link + // to the failed tx and no isError, so the user waited for money that was + // never sent and blamed the bridge. redeem.ts and setup.ts both assert + // status; this path was the one that did not. + const receipt = await getPublicClient().waitForTransactionReceipt({ hash: txHash as Hex }); + saveState({ pendingWithdraw: undefined }); + assertTransactionSucceeded(receipt, "pUSD transfer", txHash); + } catch (err) { + if (err instanceof Error && /reverted/i.test(err.message)) throw err; // a receipt was read: definite + const msg = err instanceof Error ? err.message : String(err); + throw new Error( + `Withdraw: the pUSD transfer ${txHash ? `(tx ${txHash}) ` : ""}did not confirm (${msg}). It may still land — ` + + `a broadcast transaction is not un-sent by a client timeout. Do NOT retry yet: wait for the guard window to pass, then ${WITHDRAW_GUIDANCE}.`, + ); + } } return { diff --git a/src/utils/raw-call.ts b/src/utils/raw-call.ts index d3d8a3d..47ebc36 100644 --- a/src/utils/raw-call.ts +++ b/src/utils/raw-call.ts @@ -17,6 +17,7 @@ import { isApiKeyMode } from "./auth.js"; import { apiKeyGet, apiKeyPost } from "./api-key-call.js"; +import { RawCallSettledError } from "./settled-error.js"; import { getChain } from "./wallet.js"; import { OBSERVED_GATEWAY_TX_FEE_USD, TRANSACTION_FEE_USD } from "./tx-fee.js"; @@ -28,22 +29,7 @@ export type RawClient = { getSpending?: () => { totalUsd: number }; }; -/** - * The SDK call threw AFTER its own counter recorded a settlement. Both wallet - * clients count on the paid retry's 2xx and only then read the body - * (SolanaLLMClient.requestWithPaymentRaw: assertPaid → recordSettlement → - * json()), so a non-JSON 200 surfaces as a bare SyntaxError — no status, no - * transport words, "none" to settlementOnThrow — for a call that was paid. - * The counter is the evidence, and this carries it to the tool's catch. - */ -export class RawCallSettledError extends Error { - readonly settledUsd: number; - constructor(message: string, settledUsd: number, options?: { cause?: unknown }) { - super(message, options); - this.name = "RawCallSettledError"; - this.settledUsd = settledUsd; - } -} +export { RawCallSettledError }; function counted(client: RawClient): number | undefined { try { diff --git a/src/utils/settled-error.ts b/src/utils/settled-error.ts new file mode 100644 index 0000000..e32fdd0 --- /dev/null +++ b/src/utils/settled-error.ts @@ -0,0 +1,25 @@ +// src/utils/settled-error.ts +// +// A leaf module (no imports): raw-call.ts and api-key-call.ts both throw this +// and raw-call imports api-key-call, so the class lives below both. + +/** + * The call SETTLED and then failed to hand back a usable body. On the wallet + * rails the SDK counts the settlement on the paid retry's 2xx and only then + * reads the body (SolanaLLMClient.requestWithPaymentRaw: assertPaid → + * recordSettlement → json()), so a non-JSON 200 surfaces as a bare + * SyntaxError — no status, no transport words, "none" to settlementOnThrow — + * for a call that was paid; the SDK's counter delta is the evidence and rides + * here as `settledUsd`. On the account rail a 2xx carries `x-blockrun-cost-usd` + * and the same unreadable body used to be returned as a successful `{}` + * (round 4b); the header's figure rides here instead, or null when the + * response carried none — the tool then books its reserve. + */ +export class RawCallSettledError extends Error { + readonly settledUsd: number | null; + constructor(message: string, settledUsd: number | null, options?: { cause?: unknown }) { + super(message, options); + this.name = "RawCallSettledError"; + this.settledUsd = settledUsd; + } +} diff --git a/src/utils/uncharged.ts b/src/utils/uncharged.ts index 91ffb97..82378b4 100644 --- a/src/utils/uncharged.ts +++ b/src/utils/uncharged.ts @@ -26,3 +26,19 @@ export function isExplicitlyUncharged(message: string): boolean { m.includes("not charged"); } + +/** + * 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 after + * the client is gone (the Cloud Run route documents that a client disconnect + * is never propagated to a non-streaming handler), so a paid request that + * came back with one of these is NOT an answer — it is the same "no verdict" + * as a dropped socket. Everything else in the 4xx/5xx range is the gateway + * itself answering, which it does before settlement starts. One set, shared + * by chat (settlementOnThrow), the path tools (pathToolFailure) and the media + * tools (in-flight isAnswer): round 4b found the media copy missing, so a + * blockrun_speech answered 504 by the edge while TTS finished and billed at + * the origin read "failed — try again", i.e. pay twice, on the same rail where + * blockrun_exa booked the same status as a precaution. + */ +export const ORIGIN_DID_NOT_ANSWER: ReadonlySet = new Set([408, 502, 504, 520, 521, 522, 523, 524, 525, 526, 527, 529, 530]); diff --git a/src/utils/wallet.ts b/src/utils/wallet.ts index 01bde15..b39d7fa 100644 --- a/src/utils/wallet.ts +++ b/src/utils/wallet.ts @@ -566,37 +566,57 @@ function publishMintedKey(file: string, key: string): string { } } }; + const readFile = (): string | null => { + try { return fs.readFileSync(file, "utf-8").trim(); } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err; + return null; + } + }; try { fs.writeFileSync(tmp, key, { mode: 0o600 }); - for (let attempt = 0; attempt < 4; attempt++) { - if (tryPublish()) break; + for (let attempt = 0; attempt < 8; attempt++) { + if (tryPublish()) { + // Ours is the file — unless a peer's claim (below) moved it in the + // gap before this read; then theirs is, and the loop adopts it. + const now = readFile(); + if (now === key) return key; + if (now) return now; + continue; + } // Someone published before us. Their key is the wallet every store // will hold from here on; ours exists only in this heap and must not // be shown. - let theirs = ""; - try { theirs = fs.readFileSync(file, "utf-8").trim(); } catch (err) { - if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err; - continue; // gone between the link and the read: try the link again - } - if (theirs) break; - // A stale empty placeholder. Claim it by renaming it away — the one - // process whose rename succeeds is the one that gets to publish; the - // others see ENOENT here, retry the link, lose to the claimant's key - // and adopt it on the next pass. + const theirs = readFile(); + if (theirs === null) continue; // gone between the link and the read + if (theirs) return theirs; + // A stale empty placeholder. Claim it by renaming it away — exactly + // one process's rename of a given name succeeds — then retry the link. + // The rename is by NAME and the file may have changed since the read: + // a peer that claimed first and linked its key in the gap would have + // that key renamed aside here. So the aside is INSPECTED, never + // discarded blind — a key found there is linked back under the + // exclusive name and adopted (round 4b: round 4's rm-after-rename + // deleted a peer's freshly published key). const aside = `${tmp}.placeholder`; try { fs.renameSync(file, aside); } catch (err) { if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err; + continue; + } + let moved = ""; + try { moved = fs.readFileSync(aside, "utf-8").trim(); } catch { /* treat as empty */ } + if (moved) { + try { fs.linkSync(aside, file); } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; + } + fs.rmSync(aside, { force: true }); + continue; // adopt whatever now holds the name } fs.rmSync(aside, { force: true }); } - // What is on disk is the wallet, whoever put it there. (Four passes of - // link/read/claim without a file at the end is not a race any more, it - // is a filesystem that will not hold one; write plainly and say so.) - let published = ""; - try { published = fs.readFileSync(file, "utf-8").trim(); } catch { /* fall through */ } - if (published) return published; - fs.writeFileSync(file, key, { mode: 0o600 }); - return key; + // Eight passes of link/read/claim without settling is not a race any + // more, it is a filesystem that will not hold the file: say so rather + // than write plainly over a name a peer may own. + throw new Error(`Could not publish the wallet key to ${file}: the file kept changing under this process. Retry, or set the key in the environment.`); } finally { fs.rmSync(tmp, { force: true }); } @@ -1163,7 +1183,13 @@ async function getBaseUsdcBalance(address: string): Promise { params: [{ to: USDC_ADDRESS, data: `0x70a08231000000000000000000000000${address.slice(2)}` }, "latest"], id: 1, }; - for (const rpcUrl of BASE_RPC_URLS) { + // BASE_RPC_URL first when set — the SDK's own getBalance honours it, and + // the Solana read honours its SOLANA_RPC_* siblings; this read was the one + // that ignored the operator's endpoint (round 4b). The public fallbacks + // stay behind it. + const configured = (process.env.BASE_RPC_URL ?? "").trim(); + const rpcUrls = configured ? [configured, ...BASE_RPC_URLS.filter((u) => u !== configured)] : BASE_RPC_URLS; + for (const rpcUrl of rpcUrls) { try { const response = await fetch(rpcUrl, { method: "POST", diff --git a/test/chat-anthropic-settled.test.ts b/test/chat-anthropic-settled.test.ts index fb50d34..44632a0 100644 --- a/test/chat-anthropic-settled.test.ts +++ b/test/chat-anthropic-settled.test.ts @@ -189,3 +189,80 @@ test("real @anthropic-ai/sdk: create() refuses 33k max_tokens non-streaming, str assert.equal(thinking?.signature, "sig123", "the signature survives streaming assembly"); assert.equal((final.content.find((b) => b.type === "text") as Anthropic.TextBlock).text, "42"); }); + +// Round 4b (CH-1): the Anthropic SDK wraps a never-connected fetch TWO levels +// deep — APIConnectionError("Connection error.") → cause TypeError("fetch +// failed") → cause { code: "ENOTFOUND" } — and the classifier read cause.code +// one level down, found nothing, and the "fetch failed" text tripped the +// transport regex: a DNS failure booked the reconstructed quote and forbade a +// retry, for a request that never left the machine. The compat path saw the +// raw undici error and said "none". Rail parity, in the direction that +// over-books. +test("a never-connected fetch nested two causes deep (the Anthropic SDK's shape) books nothing", async () => { + for (const code of ["ENOTFOUND", "ECONNREFUSED", "EAI_AGAIN"]) { + const budget = newBudget(); + const undici = Object.assign(new TypeError("fetch failed"), { cause: { code } }); + const client = fakeClient({ connect: false, result: sdkError(undefined, "Connection error.", undici) }); + const res = await handleAnthropicNative({ client: client as never, ...baseArgs, budget }); + assert.equal(res.isError, true); + assert.equal(budget.spent, 0, `${code}: booked ${budget.spent}`); + assert.doesNotMatch(res.content[0].text, /may have|second charge|charge stands/i, `${code}: ${res.content[0].text}`); + } +}); + +// Round 4b (CH-3): an idle stall BEFORE the stream connected is not a settled +// call. The 2xx never arrived, so the SDK's counter recorded nothing; the +// payment may have been sent (Base: the paid retry runs long) — a maybe, the +// same verdict the compat Solana path gives its own "before the first frame" +// stall — not "the charge stands". +test("an idle stall before the stream connects is a MAYBE, not a settled charge", async () => { + const budget = newBudget(); + const client = { + messages: { + create: async () => { throw new Error("must stream"); }, + stream: () => { + const self = { on() { return self; }, off() { return self; }, abort() {}, response: null, finalMessage: () => new Promise(() => {}) }; + return self; + }, + }, + }; + const { handleAnthropicNative: h } = await import("../src/tools/chat-anthropic.js"); + const res = await h({ client: client as never, ...baseArgs, budget, idleTimeoutMs: 20 } as never); + assert.equal(res.isError, true); + assert.ok(budget.spent > 0, "the payment may have gone out — booked as a precaution"); + assert.match(res.content[0].text, /cannot tell whether|MAY have been billed/i, res.content[0].text); + assert.doesNotMatch(res.content[0].text, /charge stands/, res.content[0].text); +}); + +// Round 4b (CH-5): the compat assembler hands back the partial text that +// streamed before a post-acceptance failure (the caller paid for those +// tokens); the native path discarded it. +test("partial text streamed before a post-acceptance failure rides along on the native path", async () => { + const budget = newBudget(); + const client = { + messages: { + create: async () => { throw new Error("must stream"); }, + stream: () => { + const listeners: Record void>> = {}; + const self = { + on(ev: string, cb: (...a: unknown[]) => void) { (listeners[ev] ??= []).push(cb); return self; }, + off() { return self; }, abort() {}, response: null, + finalMessage: async () => { + await Promise.resolve(); + for (const cb of listeners.connect ?? []) cb(); + for (const cb of listeners.text ?? []) cb("The first half of the ans", "The first half of the ans"); + for (const cb of listeners.streamEvent ?? []) cb({ type: "content_block_delta" }); + throw Object.assign(new Error("Connection error."), { cause: new Error("terminated") }); + }, + }; + return self; + }, + }, + }; + const res = await handleAnthropicNative({ client: client as never, ...baseArgs, budget }); + assert.equal(res.isError, true); + assert.match(res.content[0].text, /charge stands/); + assert.match(res.content[0].text, /Partial response received before the failure/); + assert.match(res.content[0].text, /The first half of the ans/); + assert.equal((res.structuredContent as { partial_response?: string } | undefined)?.partial_response, "The first half of the ans"); +}); diff --git a/test/chat-stream.test.ts b/test/chat-stream.test.ts index 4377d8a..1f49922 100644 --- a/test/chat-stream.test.ts +++ b/test/chat-stream.test.ts @@ -288,3 +288,20 @@ test("settlementOnThrow: the native path (payment inside fetch) judges the statu assert.equal(n(sdk(undefined, "Connection error.", new Error("Payment was rejected. Check your wallet balance."))), "none"); assert.equal(n(sdk(undefined, "Request timed out.")), "unknown"); }); + +// Round 4b (CH-2): the Solana frame path had no non-SSE guard. The SDK's +// reader yields only `data:` lines, so a route that ignored stream:true and +// answered a plain JSON body yielded nothing — and completeChat resolved +// {text: ""} as a paid success. The Response path refuses that shape by +// content-type; the frame path now refuses it by emptiness. +test("Solana frame path: a stream that ends with no frames is a post-acceptance failure, not an empty success", async () => { + const { completeChat, AcceptedThenFailedError } = await import("../src/utils/chat-stream.js"); + const client = { + stream: async function* () { /* the route answered JSON: no data: lines */ }, + chatCompletion: async () => { throw new Error("must stream"); }, + }; + await assert.rejects( + completeChat(client as never, "nvidia/gpt-oss-20b", [{ role: "user", content: "hi" }], {}, { stream: true }), + (err: Error) => err instanceof AcceptedThenFailedError && /no frames/.test(err.message), + ); +}); diff --git a/test/image-account-cost.test.ts b/test/image-account-cost.test.ts index 8e9a5a6..86ceefe 100644 --- a/test/image-account-cost.test.ts +++ b/test/image-account-cost.test.ts @@ -243,8 +243,8 @@ test("a not_charged terminal failure whose text says 'timeout' books nothing and assert.doesNotMatch(res.content[0].text, /booked against your budget/); }); -test("a 504 that ARRIVED on submit is an answer, not a maybe — nothing is booked as 'may have settled'", async () => { - script = [poll(504, { error: "Upstream timeout" })]; +test("a 500 that ARRIVED on submit is the gateway's answer — nothing is booked as 'may have settled'", async () => { + script = [poll(500, { error: "Upstream timeout" })]; const { call, budget } = makeHarness(); const res = await call({ prompt: "a cube", model: "google/nano-banana", size: "1024x1024" }); assert.equal(res.isError, true); @@ -255,6 +255,20 @@ test("a 504 that ARRIVED on submit is an answer, not a maybe — nothing is book assert.doesNotMatch(res.content[0].text, /got no answer/); }); +// Round 4b: an EDGE 504 on the submit is not the gateway's answer — the +// origin may have accepted the render and be billing it. The account helper +// classifies it as an unknown billing, and the tool books the estimate and +// says so. +test("a 504 from the EDGE on submit is an unknown billing: booked, and the text says MAY", async () => { + script = [poll(504, { error: "upstream request timeout" })]; + const { call, budget } = makeHarness(); + const res = await call({ prompt: "a cube", model: "google/nano-banana", size: "1024x1024" }); + assert.equal(res.isError, true); + assert.ok(budget.spent > 0, `spent=${budget.spent}`); + assert.match(res.content[0].text, /MAY have been accepted/); + assert.match(res.content[0].text, /dashboard\/activity/); +}); + test("a 202 whose body carries no poll_url is a BILLED job, not a plain error", async () => { script = [() => ({ status: 202, ok: true, headers: headers({ "x-blockrun-cost-usd": "0.052500" }), json: async () => ({ id: "img_43", status: "queued" }) })]; const { call, budget } = makeHarness(); diff --git a/test/in-flight.test.ts b/test/in-flight.test.ts index 4e33f46..bd0f53d 100644 --- a/test/in-flight.test.ts +++ b/test/in-flight.test.ts @@ -143,16 +143,37 @@ test("a non-positive or unreadable quote is not captured — the reserve stays t // booked a whole render on image's account rail and said "MAY have gone // through" — the C13 shape, on the rail whose tools had just documented // why it must not happen. -test("an error carrying a status is an answer, not a maybe — even when its body says 'timeout'", async () => { +test("an error carrying a GATEWAY status is an answer, not a maybe — even when its body says 'timeout'", async () => { const paid = trackPaidRequest(); - const answered = Object.assign(new Error('API error 504: {"error":"Upstream timeout"}'), { statusCode: 504, name: "AccountApiError" }); + const answered = Object.assign(new Error('API error 500: {"error":"Upstream timeout"}'), { statusCode: 500, name: "AccountApiError" }); await assert.rejects(sendPaid(paid, async () => { throw answered; }, 0.05)); assert.equal(paid.outstanding, true, "sendPaid itself cannot know a response arrived"); assert.equal(paid.mayHaveSettled(answered), false, "a statusCode proves the gateway answered"); - const sdkShape = Object.assign(new Error("API error after payment: 502"), { statusCode: 502 }); + const sdkShape = Object.assign(new Error("API error after payment: 503"), { statusCode: 503 }); assert.equal(paid.mayHaveSettled(sdkShape), false); - const anthropicShape = Object.assign(new Error("Request timed out"), { status: 408 }); - assert.equal(paid.mayHaveSettled(anthropicShape), false); + const refused = Object.assign(new Error("API error: 400"), { status: 400 }); + assert.equal(paid.mayHaveSettled(refused), false); +}); + +// Round 4b: an EDGE status is not an answer. 408/502/504/52x say only that +// the origin did not answer in time — it may still be running the request +// and settling it — and chat and the path tools already book that status as +// a precaution on the same rails. Round 4 read every number as a verdict and +// a speech request the edge answered 504 while TTS finished and billed read +// "failed — try again": pay twice. +test("an EDGE status (504/502/408/52x) on the paid request is a maybe, like a dropped socket", async () => { + const paid = trackPaidRequest(); + for (const status of [408, 502, 504, 522, 524]) { + const edge = Object.assign(new Error(`API error ${status}: upstream request timeout`), { statusCode: status, name: "AccountApiError" }); + await assert.rejects(sendPaid(paid, async () => { throw edge; }, 0.05)); + assert.equal(paid.mayHaveSettled(edge), true, `${status} is not a verdict`); + const anthropicShape = Object.assign(new Error("Request timed out"), { status }); + assert.equal(paid.mayHaveSettled(anthropicShape), true, `status ${status}`); + } + // ...but the gateway's own uncharged marker still overrules it. + const marked = Object.assign(new Error("API error 502: Upstream provider error (payment NOT charged)"), { statusCode: 502 }); + await assert.rejects(sendPaid(paid, async () => { throw marked; }, 0.05)); + assert.equal(paid.mayHaveSettled(marked), false); }); test("the gateway's own not-charged verdict outranks a timeout in the upstream text", async () => { diff --git a/test/keychain-linux-exit1.test.ts b/test/keychain-linux-exit1.test.ts index ee2c72a..e9276f5 100644 --- a/test/keychain-linux-exit1.test.ts +++ b/test/keychain-linux-exit1.test.ts @@ -46,16 +46,34 @@ test("exit 1 with nothing on stderr is a miss: absent", () => { }); for (const [why, stderr] of [ - ["no D-Bus session", "secret-tool: Cannot autolaunch D-Bus without X11 $DISPLAY\n"], ["locked collection / dismissed prompt", "secret-tool: The unlock prompt was dismissed\n"], - ["service unavailable", "secret-tool: Error calling StartServiceByName for org.freedesktop.secrets: Timeout was reached\n"], + ["locked collection", "secret-tool: Object does not exist at path \"/org/freedesktop/secrets/collection/login\"\n"], ] as const) { test(`exit 1 with a message on stderr (${why}) is a FAULT: error, never absent`, () => { spawnResult = { status: 1, stdout: "", stderr }; const read = keychain.keychainRead("evm-wallet-key"); assert.equal(read.status, "error", `${why}: ${JSON.stringify(read)}`); assert.match((read as { detail: string }).detail, /secret-tool/); - assert.match((read as { detail: string }).detail, new RegExp(stderr.trim().slice(13, 30).replace(/[$()]/g, "\\$&")), "the detail carries the tool's own reason"); + assert.match((read as { detail: string }).detail, /dismissed|does not exist/, "the detail carries the tool's own reason"); + }); +} + +// Round 4b: a keychain that does not EXIST is not one that would not open. +// secret-tool is installed on hosts with no secrets service at all (SSH +// sessions, containers, Fedora/Arch base installs), and it exits 1 with a +// D-Bus / service message. Round 4 read that as a fault, and a fresh install +// on such a host could never mint a wallet. Nothing funded can be in a +// keychain that is not there: absent. +for (const [why, stderr] of [ + ["no D-Bus session", "secret-tool: Cannot autolaunch D-Bus without X11 $DISPLAY\n"], + ["no keyring provider on the bus", "secret-tool: The name org.freedesktop.secrets was not provided by any .service files\n"], + ["service start timeout", "secret-tool: Error calling StartServiceByName for org.freedesktop.secrets: Timeout was reached\n"], + ["no bus socket", "secret-tool: Could not connect: No such file or directory\n"], +] as const) { + test(`exit 1 saying there is no secrets service (${why}) is ABSENT — a fresh install can still mint`, () => { + spawnResult = { status: 1, stdout: "", stderr }; + assert.deepEqual(keychain.keychainRead("evm-wallet-key"), { status: "absent" }, why); + assert.equal(keychain.keychainLoad("solana-wallet-key"), null); }); } @@ -73,7 +91,7 @@ test("keychainLoad warns on a fault and stays quiet on a miss", async () => { assert.equal(keychain.keychainLoad("solana-wallet-key"), null); assert.deepEqual(lines, [], "a miss is not worth a warning"); keychain._resetKeychainWarnings(); - spawnResult = { status: 1, stdout: "", stderr: "secret-tool: Cannot autolaunch D-Bus without X11 $DISPLAY\n" }; + spawnResult = { status: 1, stdout: "", stderr: "secret-tool: The unlock prompt was dismissed\n" }; assert.equal(keychain.keychainLoad("solana-wallet-key"), null); assert.equal(lines.length, 1, "a fault is"); assert.match(lines[0], /keychain read failed/i); diff --git a/test/path-safety.test.ts b/test/path-safety.test.ts index 46b31e1..55ac308 100644 --- a/test/path-safety.test.ts +++ b/test/path-safety.test.ts @@ -284,3 +284,38 @@ test("hasPathTraversal survives a tab splitting a dot-escape (namespace escape)" assert.equal(hasPathTraversal(raw), true, raw); } }); + +// Audit round 4b (two P0s, same class as the tab-in-escape hole): the WHATWG +// parser does two more things to the slug before routing that the classifier +// did not. It strips TRAILING C0-control-or-space from the whole URL input — +// and the slug is the tail of `${base}${endpoint}` on every rail — so +// `phone/numbers/buy ` (an ordinary tokenisation slip) leaves the machine as +// /v1/phone/numbers/buy and the gateway serves the $5.001 route while the +// exact-match price row missed and $0.012 was reserved. And for special +// schemes it treats a literal `\` as `/`, so `sandbox\create` classified as +// the $0.003 op while the gateway served a sandbox/create of up to $192 — +// and the handler's gpu/timeout normalisation block was skipped with it. +test("normalizeClassifyPath strips trailing C0/space the way the parser strips the URL's tail", () => { + assert.equal(normalizeClassifyPath("phone/numbers/buy "), "phone/numbers/buy"); + assert.equal(normalizeClassifyPath("voice/call" + String.fromCharCode(0)), "voice/call"); + assert.equal(normalizeClassifyPath("contents " + String.fromCharCode(31) + " "), "contents"); + // A trailing space BEFORE a query is inside the path and the parser keeps + // it (percent-encoded), so the gateway 404s unpaid — the safe direction; the + // classifier must not turn it into the real route either. + assert.equal(normalizeClassifyPath("phone/numbers/buy ?x=1"), "phone/numbers/buy "); +}); + +test("normalizeClassifyPath reads a literal backslash as a slash, as the parser does for https", () => { + assert.equal(normalizeClassifyPath("sandbox\\create"), "sandbox/create"); + assert.equal(normalizeClassifyPath("phone/numbers\\buy"), "phone/numbers/buy"); + assert.equal(normalizeClassifyPath("phone\\lookup\\fraud"), "phone/lookup/fraud"); + // An ENCODED %5C stays a literal backslash inside the segment: the parser + // leaves it and the gateway's exact match 404s unpaid. + assert.equal(normalizeClassifyPath("sandbox%5Ccreate"), "sandbox\\create"); +}); + +test("hasPathTraversal sees a `..` last segment with trailing C0/space, which the parser resolves", () => { + assert.equal(hasPathTraversal("phone/.. "), true); + assert.equal(hasPathTraversal("phone/.." + String.fromCharCode(0)), true); + assert.equal(hasPathTraversal("phone/%2e%2e "), true); +}); diff --git a/test/path-tools-settle-on-throw.test.ts b/test/path-tools-settle-on-throw.test.ts index 74b0d11..bc80916 100644 --- a/test/path-tools-settle-on-throw.test.ts +++ b/test/path-tools-settle-on-throw.test.ts @@ -273,3 +273,48 @@ test("a path tool renders a settled-then-failed call as a charge that stands, bo assert.match(t, /\$0\.0030/); assert.doesNotMatch(t, /MAY have gone through|Try again in a few minutes/); }); + +// Audit round 4b (P1, a regression of the RawCallSettledError fix): the SDK's +// spend counter is per CLIENT, getClient() is a cached singleton per rail, and +// tool calls run concurrently — so a concurrent call's settlement landed +// inside a failing call's before/after window and was booked to it as "the +// charge stands" (and booked again by the call that actually paid). Every +// path tool now builds its own client, like blockrun_chat always did. +test("every shared-client path tool builds a fresh client per call, never the singleton", async () => { + const { readFileSync } = await import("node:fs"); + for (const f of ["search", "exa", "markets", "rpc", "defi", "phone"]) { + const src = readFileSync(new URL(`../src/tools/${f}.ts`, import.meta.url), "utf8"); + assert.doesNotMatch(src, /\bgetClient\(\)/, `${f}.ts must not read the shared client — its spend counter is shared too`); + assert.match(src, /\bbuildClient\(\)/, `${f}.ts builds a per-call client`); + } +}); + +test("a concurrent settlement on the SAME client would be misattributed — which is why the client is per call", async () => { + const { rawPost } = await import("../src/utils/raw-call.js"); + let total = 0; + const shared = { + getSpending: () => ({ totalUsd: total }), + requestWithPaymentRaw: async () => { + total += 0.2645; // a concurrent call settles while this one is in flight... + throw Object.assign(new Error("API error: 400"), { statusCode: 400 }); // ...and this one is refused unpaid + }, + }; + // The helper cannot tell the two apart on a shared counter; this pins that + // the ONLY defence is the per-call client asserted above. + await assert.rejects(rawPost(shared as never, "/v1/x", {}), (e: Error) => e.name === "RawCallSettledError"); +}); + +// Round 4b (RP-5): the account rail returned a settled 2xx whose body would +// not parse as a SUCCESS with data `{}` — money booked, no hint the body was +// unreadable — while the wallet rails say the charge stands. Same verdict now. +test("account rail: a settled 2xx with an unreadable body is a charge that stands, booked at the cost header", async () => { + rail = "account"; + accountFetch = async () => new Response("not json", { status: 200, headers: { "content-type": "text/html", "x-blockrun-cost-usd": "0.012000" } }); + const { call, budget } = harness(registerRpcTool as Register); + const res = await call({ network: "ethereum", method: "eth_blockNumber" }); + const t = textOf(res); + assert.equal(res.isError, true, t); + assert.match(t, /charge stands/); + assert.match(t, /\$0\.0120/); + assert.ok(Math.abs(budget.spent - 0.012) < 1e-9, `spent=${budget.spent}`); +}); diff --git a/test/polymarket-balance-retry.test.ts b/test/polymarket-balance-retry.test.ts index 275c181..ef63b55 100644 --- a/test/polymarket-balance-retry.test.ts +++ b/test/polymarket-balance-retry.test.ts @@ -27,6 +27,16 @@ const fakeClob = { updateBalanceAllowance: async () => { refreshCalls += 1; }, + // Round 4b: the tool signs (createOrder / createMarketOrder — the SDK's + // pre-sign network reads live there) and POSTs the signed order separately, + // so only the POST can have an unknown outcome. These three route the + // split calls through the createAndPost* behaviour each test scripts. + createOrder: async (order: Record, options: Record) => ({ signedOf: "limit", order, options }), + createMarketOrder: async (order: Record, options: Record) => ({ signedOf: "market", order, options }), + postOrder: async (signed: { signedOf: string; order: Record; options: Record }, orderType: unknown, postOnly?: boolean) => + signed.signedOf === "limit" + ? (fakeClob as any).createAndPostOrder(signed.order, signed.options, orderType, postOnly) + : (fakeClob as any).createAndPostMarketOrder(signed.order, signed.options, orderType), createAndPostMarketOrder: async () => { submitAttempts += 1; const healed = refreshHeals && refreshCalls > 0; diff --git a/test/polymarket-confirm-spend.test.ts b/test/polymarket-confirm-spend.test.ts index 5e84f1f..6420ee8 100644 --- a/test/polymarket-confirm-spend.test.ts +++ b/test/polymarket-confirm-spend.test.ts @@ -32,6 +32,16 @@ const fakeClob = { tick_size: "0.01", neg_risk: false, min_order_size: "5", asks: [{ price: "0.40", size: "100" }], bids: [{ price: "0.39", size: "100" }], }), + // Round 4b: the tool signs (createOrder / createMarketOrder — the SDK's + // pre-sign network reads live there) and POSTs the signed order separately, + // so only the POST can have an unknown outcome. These three route the + // split calls through the createAndPost* behaviour each test scripts. + createOrder: async (order: Record, options: Record) => ({ signedOf: "limit", order, options }), + createMarketOrder: async (order: Record, options: Record) => ({ signedOf: "market", order, options }), + postOrder: async (signed: { signedOf: string; order: Record; options: Record }, orderType: unknown, postOnly?: boolean) => + signed.signedOf === "limit" + ? (fakeClob as any).createAndPostOrder(signed.order, signed.options, orderType, postOnly) + : (fakeClob as any).createAndPostMarketOrder(signed.order, signed.options, orderType), createAndPostOrder: async () => { orderSubmits++; return { success: true, orderID: "0xORDER", status: "live" }; }, createAndPostMarketOrder: async () => { orderSubmits++; return { success: true, orderID: "0xMKT", status: "matched" }; }, }; @@ -244,3 +254,21 @@ test("read-only/free actions never prompt", async () => { await call({ action: "positions" }); assert.equal(prompts.length, 0); }); + +// Round 4b (PM-1): the cap check and the reservation were split by the +// awaited dialog, so two confirms waiting on it together could overshoot +// POLYMARKET_MAX_SESSION_USD. The reservation is taken before the dialog and +// released on a decline. +test("a decline at the dialog leaves no reservation behind, and the reservation is held while the dialog is open", async () => { + const { executeTrade, getSessionLedger } = await import("../src/utils/polymarket/orders.js"); + const before = getSessionLedger().totalUsd; + let duringDialog: number | undefined; + const res = await executeTrade({ + action: "buy", token_id: "111", amount_usd: 5, confirm: true, + askUser: async () => { duringDialog = getSessionLedger().totalUsd; return { ok: false, reason: "declined" }; }, + } as never); + assert.equal(res.isError, true); + assert.match(res.text, /Declined at the confirmation prompt/); + assert.equal(duringDialog, before + 5, "the notional is reserved while the user is deciding — a concurrent confirm sees it"); + assert.equal(getSessionLedger().totalUsd, before, "and released on decline"); +}); diff --git a/test/polymarket-preview-bound.test.ts b/test/polymarket-preview-bound.test.ts index 8d1abf4..61e2a33 100644 --- a/test/polymarket-preview-bound.test.ts +++ b/test/polymarket-preview-bound.test.ts @@ -19,6 +19,16 @@ let bids: Array<{ price: string; size: string }> = [{ price: "0.39", size: "100" const fakeClob = { getOrderBook: async () => ({ tick_size: "0.01", neg_risk: false, min_order_size: "5", asks, bids }), + // Round 4b: the tool signs (createOrder / createMarketOrder — the SDK's + // pre-sign network reads live there) and POSTs the signed order separately, + // so only the POST can have an unknown outcome. These three route the + // split calls through the createAndPost* behaviour each test scripts. + createOrder: async (order: Record, options: Record) => ({ signedOf: "limit", order, options }), + createMarketOrder: async (order: Record, options: Record) => ({ signedOf: "market", order, options }), + postOrder: async (signed: { signedOf: string; order: Record; options: Record }, orderType: unknown, postOnly?: boolean) => + signed.signedOf === "limit" + ? (fakeClob as any).createAndPostOrder(signed.order, signed.options, orderType, postOnly) + : (fakeClob as any).createAndPostMarketOrder(signed.order, signed.options, orderType), createAndPostOrder: async (order: Record) => { calls.push({ kind: "limit", order }); return { success: true, orderID: "0xORDER", status: "matched" }; diff --git a/test/polymarket-state-unreadable.test.ts b/test/polymarket-state-unreadable.test.ts new file mode 100644 index 0000000..05dcd28 --- /dev/null +++ b/test/polymarket-state-unreadable.test.ts @@ -0,0 +1,35 @@ +// Run with: npm test (tsx --experimental-test-module-mocks --test) +// +// Round 4b (PM-7): loadState() answered {} for ANY failure — missing file, +// EACCES, a half-written JSON — and fund/withdraw read `.pendingFund` / +// `.pendingWithdraw` off it. A state file that exists but cannot be read for +// the 300s+60s window therefore let a second full authorization be signed, +// and the next saveState merged onto {} and overwrote the evidence. Absent +// is {}; unreadable is a refusal. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const home = fs.mkdtempSync(path.join(os.tmpdir(), "br-pm-state-")); +process.env.HOME = home; +fs.mkdirSync(path.join(home, ".blockrun"), { recursive: true }); + +const { loadState, saveState, StateUnreadableError } = await import("../src/utils/polymarket/creds.js"); + +process.on("exit", () => fs.rmSync(home, { recursive: true, force: true })); + +test("an absent state file is an empty state", () => { + assert.deepEqual(loadState(), {}); +}); + +test("a state file that exists but is not JSON refuses to load — the guards inside it are not 'nothing pending'", () => { + const file = path.join(home, ".blockrun", ".polymarket.json"); + saveState({ pendingFund: { amountUsd: 5, deadline: 4_102_444_800 } }); + assert.equal(loadState().pendingFund?.amountUsd, 5); + fs.writeFileSync(file, '{"pendingFund": {"amountUsd": 5, "dead', { mode: 0o600 }); // interrupted write + assert.throws(() => loadState(), (err: Error) => err instanceof StateUnreadableError && /double-send guards/.test(err.message)); + assert.throws(() => saveState({ pendingWithdraw: undefined }), StateUnreadableError, "a save must not merge onto {} and erase the file"); + assert.match(fs.readFileSync(file, "utf-8"), /"dead$/, "the evidence is still on disk for a human to read"); +}); diff --git a/test/polymarket-submit-unknown.test.ts b/test/polymarket-submit-unknown.test.ts index 2dd93a9..38684da 100644 --- a/test/polymarket-submit-unknown.test.ts +++ b/test/polymarket-submit-unknown.test.ts @@ -38,6 +38,16 @@ const fakeClob = { tick_size: "0.01", neg_risk: false, min_order_size: "5", asks: [{ price: "0.45", size: "100" }], bids: [{ price: "0.44", size: "100" }], }), + // Round 4b: the tool signs (createOrder / createMarketOrder — the SDK's + // pre-sign network reads live there) and POSTs the signed order separately, + // so only the POST can have an unknown outcome. These three route the + // split calls through the createAndPost* behaviour each test scripts. + createOrder: async (order: Record, options: Record) => ({ signedOf: "limit", order, options }), + createMarketOrder: async (order: Record, options: Record) => ({ signedOf: "market", order, options }), + postOrder: async (signed: { signedOf: string; order: Record; options: Record }, orderType: unknown, postOnly?: boolean) => + signed.signedOf === "limit" + ? (fakeClob as any).createAndPostOrder(signed.order, signed.options, orderType, postOnly) + : (fakeClob as any).createAndPostMarketOrder(signed.order, signed.options, orderType), createAndPostOrder: async () => { calls.push({ kind: "limit" }); return limitBehaviour(); }, createAndPostMarketOrder: async () => { calls.push({ kind: "market" }); return marketBehaviour(); }, updateBalanceAllowance: async (args: unknown) => { refreshCalls.push(args); return refreshBehaviour(); }, @@ -182,3 +192,64 @@ test("the unconfirmed booking is visible in the session ledger the success text assert.match(res.text, /unconfirmed/i, "a later success must keep showing the unresolved order"); assert.equal(getSessionLedger().unconfirmed, (ledgerBefore.unconfirmed ?? 0) + 1); }); + +// Round 4b (PM-2): the SDK's createAndPost* helpers read the network BEFORE +// they sign (GET /version, the market's condition id, tick size, builder +// fees). A relay 502 or a dropped socket on any of those used to be wrapped +// as outcome-unknown — a phantom "possibly live" order that never released, +// with a "do NOT re-place" instruction — when nothing had been signed. Only +// the POST of the signed order can be unknown. +test("a failure BEFORE signing (the SDK's pre-sign reads) is a plain error: released, retryable, not unknown", async () => { + reset(); + const before = getSessionLedger(); + const original = (fakeClob as any).createMarketOrder; + (fakeClob as any).createMarketOrder = async () => { throw Object.assign(new Error("request error"), { status: 502 }); }; + try { + const res = await executeTrade({ action: "buy", token_id: "111", amount_usd: 5, confirm: true }); + assert.equal(res.isError, true); + assert.doesNotMatch(res.text, /MAY have been accepted|outcome UNKNOWN/i, res.text); + const after = getSessionLedger(); + assert.equal(after.totalUsd, before.totalUsd, "nothing was signed — the reservation is released"); + assert.equal(after.unconfirmed, before.unconfirmed); + assert.deepEqual(calls, [], "no POST was made"); + } finally { + (fakeClob as any).createMarketOrder = original; + } +}); + +// Round 4b (PM-4): withCredsRetry re-ran the WHOLE trade on any error whose +// text matched the creds-mismatch phrases — and an unknown outcome carries +// the raw transport text, which can say "unauthorized". A second signed +// submit on top of a possibly-live order is the one thing it must not do. +test("an unknown outcome whose raw text mentions 'unauthorized' is never re-submitted by the creds retry", async () => { + reset(); + marketBehaviour = async () => { throw new Error("socket hang up: unauthorized proxy"); }; + const res = await executeTrade({ action: "buy", token_id: "111", amount_usd: 5, confirm: true }); + assert.equal(res.isError, true); + assert.match(res.text, /MAY have been accepted/); + assert.equal(calls.length, 1, "exactly one POST — no retry on an unknown outcome"); +}); + +// Round 4b (PM-6): every definite refusal ends with the repo's uncharged +// wording, so the order card's outcomeIsUnknown() can re-arm on it instead of +// locking as "outcome UNKNOWN" for an order the server never placed. +test("a definite refusal carries the not-placed sentence and outcome:\"rejected\"; an unknown outcome does not", async () => { + const { outcomeIsUnknown } = await import("../apps/order-safety.js"); + reset(); + marketBehaviour = async () => { throw new ApiError("invalid amount", 400, { error: "invalid amount" }); }; + const rejected = await executeTrade({ action: "buy", token_id: "111", amount_usd: 5, confirm: true }); + assert.equal(rejected.isError, true); + assert.match(rejected.text, /nothing was charged/); + assert.equal((rejected.structured as { outcome?: string }).outcome, "rejected"); + assert.equal(outcomeIsUnknown(rejected.text), false, "the card may re-arm"); + + const capped = await executeTrade({ action: "buy", token_id: "111", amount_usd: 5000, confirm: true }); + assert.equal(capped.isError, true); + assert.equal(outcomeIsUnknown(capped.text), false, `a cap refusal signs nothing: ${capped.text}`); + + reset(); + marketBehaviour = async () => { throw new Error("socket hang up"); }; + const unknown = await executeTrade({ action: "buy", token_id: "111", amount_usd: 5, confirm: true }); + assert.equal((unknown.structured as { outcome?: string }).outcome, "unknown"); + assert.equal(outcomeIsUnknown(unknown.text), true, "the card must stay locked"); +}); diff --git a/test/polymarket-trade-gating.test.ts b/test/polymarket-trade-gating.test.ts index f6f79ba..9d453db 100644 --- a/test/polymarket-trade-gating.test.ts +++ b/test/polymarket-trade-gating.test.ts @@ -34,6 +34,16 @@ const fakeClob = { asks: [{ price: "0.45", size: "100" }], bids: [{ price: "0.44", size: "100" }], }), + // Round 4b: the tool signs (createOrder / createMarketOrder — the SDK's + // pre-sign network reads live there) and POSTs the signed order separately, + // so only the POST can have an unknown outcome. These three route the + // split calls through the createAndPost* behaviour each test scripts. + createOrder: async (order: Record, options: Record) => ({ signedOf: "limit", order, options }), + createMarketOrder: async (order: Record, options: Record) => ({ signedOf: "market", order, options }), + postOrder: async (signed: { signedOf: string; order: Record; options: Record }, orderType: unknown, postOnly?: boolean) => + signed.signedOf === "limit" + ? (fakeClob as any).createAndPostOrder(signed.order, signed.options, orderType, postOnly) + : (fakeClob as any).createAndPostMarketOrder(signed.order, signed.options, orderType), createAndPostOrder: async (order: Record, options: Record, orderType: unknown) => { calls.push({ kind: "limit", order, options, orderType }); return { success: true, orderID: "0xORDER", status: "matched", transactionsHashes: ["0xTX"] }; diff --git a/test/polymarket-walk-book.test.ts b/test/polymarket-walk-book.test.ts new file mode 100644 index 0000000..227ccf1 --- /dev/null +++ b/test/polymarket-walk-book.test.ts @@ -0,0 +1,56 @@ +// Run with: npm test (tsx --experimental-test-module-mocks --test) +// +// walkBook is the number the user consents to and the limit the order is +// signed at, so its arithmetic is pinned directly — the handler suites cover +// it only through the preview text. (Round 4b: the "Exported for tests" +// comment named a test that did not exist.) +import { test, mock } from "node:test"; +import assert from "node:assert/strict"; + +// orders.ts pulls in the CLOB client and the relayer at import; neither is +// touched here. +mock.module("../src/utils/polymarket/client.js", { + namedExports: { + getClobClient: async () => { throw new Error("not used"); }, + checkGeoblock: async () => ({ orderPlacement: "permitted", country: null, ip: null, raw: {} }), + getPolymarketAccount: () => ({ address: "0x0000000000000000000000000000000000000001" }), + resetClobClient: () => {}, + getClobProxyAgent: () => null, + installUnderscoreHeaderBridge: () => {}, + }, +}); +mock.module("../src/utils/http.js", { + namedExports: { fetchWithTimeout: async () => { throw new Error("not used"); }, isTimeoutError: () => false }, +}); + +const { walkBook } = await import("../src/utils/polymarket/orders.js"); +const near = (a: number, b: number) => Math.abs(a - b) < 1e-9; + +test("a buy walks the asks from the cheapest, whatever order the book arrived in", () => { + const book = { asks: [{ price: "0.50", size: "10" }, { price: "0.40", size: "10" }], bids: [] } as never; + const r = walkBook(book, "buy", 6); // $6: $4 at 0.40 (10 shares), $2 at 0.50 (4 shares) + assert.ok(near(r.filled, 14), `filled=${r.filled}`); + assert.ok(near(r.unfilled, 0)); + assert.equal(r.worstPrice, 0.5, "the last level consumed is the signed limit"); +}); + +test("a sell walks the bids from the best, and reports the floor it reached", () => { + const book = { asks: [], bids: [{ price: "0.30", size: "5" }, { price: "0.35", size: "5" }] } as never; + const r = walkBook(book, "sell", 8); // 5 shares at 0.35, 3 at 0.30 + assert.ok(near(r.filled, 5 * 0.35 + 3 * 0.3), `proceeds=${r.filled}`); + assert.equal(r.worstPrice, 0.3); + assert.ok(near(r.unfilled, 0)); +}); + +test("a thin book reports the unfilled remainder and the deepest level touched", () => { + const book = { asks: [{ price: "0.45", size: "2" }], bids: [] } as never; + const r = walkBook(book, "buy", 5); // only $0.90 of depth + assert.ok(near(r.filled, 2)); + assert.ok(near(r.unfilled, 5 - 0.9), `unfilled=${r.unfilled}`); + assert.equal(r.worstPrice, 0.45); +}); + +test("garbage levels are ignored and an empty side walks nothing", () => { + const book = { asks: [{ price: "abc", size: "1" }, { price: "0.5", size: "0" }], bids: [] } as never; + assert.deepEqual(walkBook(book, "buy", 1), { filled: 0, unfilled: 1, worstPrice: null }); +}); diff --git a/test/polymarket-withdraw.test.ts b/test/polymarket-withdraw.test.ts index 5de30b4..8a42d91 100644 --- a/test/polymarket-withdraw.test.ts +++ b/test/polymarket-withdraw.test.ts @@ -348,3 +348,19 @@ test("relayer anti-retry guidance survives the error path untouched", async () = bridgeError = BRIDGE_OFFLINE; } }); + +// Round 4b (PM-3): the EOA (sigType 0) withdraw is a plain Polygon +// transaction, and it had no pendingWithdraw guard at all — a receipt timeout +// after the broadcast invited a second full transfer with a fresh nonce. The +// guard now records "eoa" before the send, "eoa:" once known, and is +// cleared only by a receipt; a bare "eoa" blocks until the deadline. +test("an EOA withdrawal whose send never answered blocks a second one without asking the relayer", async () => { + pusdRaw = 7_500_000n; usdceRaw = 0n; + stateFile = { pendingWithdraw: { transactionID: "eoa", deadline: futureDeadline() } }; + relayerStateCalls = 0; + const res = await withdrawFunds({ amount_usd: 2, confirm: true }); + assert.equal(res.isError, true); + assert.match(res.text, /double-send/); + assert.equal(relayerStateCalls, 0, "an EOA transaction is not a relayer batch"); + assert.ok(stateFile.pendingWithdraw, "the guard stays armed"); +}); diff --git a/test/rail-parity.test.ts b/test/rail-parity.test.ts index 9a568af..07ee975 100644 --- a/test/rail-parity.test.ts +++ b/test/rail-parity.test.ts @@ -366,7 +366,12 @@ for (const row of ROWS) { // verdict now comes off the error (a status, a typed job verdict, the // gateway's uncharged marker), never its message. // --------------------------------------------------------------------------- -const answered5xx = () => ({ status: 504, ok: false, headers: headers(), json: async () => ({ error: "The operation was aborted due to timeout" }) }); +const answered5xx = () => ({ status: 500, ok: false, headers: headers(), json: async () => ({ error: "The operation was aborted due to timeout" }) }); +// The EDGE answering for an origin that did not: not a verdict. The origin may +// still be running the request and settling it, so this is booked as a +// precaution — the rule chat and the path tools already apply to the same +// status on the same rails (round 4b, P1: the tracker read it as an answer). +const edge504 = () => ({ status: 504, ok: false, headers: headers(), json: async () => ({ error: "upstream request timeout" }) }); for (const row of ROWS) { // video's Solana submit answering 5xx is the helper's own "poll error" shape // after a 202; a 5xx ON the submit is covered the same way in the helper. @@ -394,6 +399,28 @@ for (const row of ROWS) { }); } +for (const row of ROWS) { + // video and music go through the async helpers, whose own post-submit + // classification (poll loop, BilledJobError) owns the edge case; the + // single-POST tools are the ones whose only evidence is the status. + if (row.name === "video" || row.name === "music") continue; + test(`${row.name} on Solana: the paid request is answered 504 by the EDGE → booked as a precaution`, async () => { + rail = "solana"; + quotedAmount = micro(row.reserve); + script = [resp402, edge504]; + const { call, budget } = harness(row.register); + const res = await call(row.args); + assertBooked(res, budget, row.reserve, /MAY have (gone through|settled)/, `${row.name}/solana/504`); + }); + test(`${row.name} on the account rail: the Bearer request is answered 504 by the EDGE → booked as a precaution`, async () => { + rail = "account"; + script = [edge504]; + const { call, budget } = harness(row.register); + const res = await call(row.args); + assertBooked(res, budget, row.reserve, /MAY have (gone through|settled|been accepted)/, `${row.name}/account/504`); + }); +} + // --------------------------------------------------------------------------- // Cell 3: a Solana quote far above the estimate is refused BEFORE signing, on // every manual-402 tool — the 2026-09-08 shape (sol.blockrun.ai quoting a diff --git a/test/realface-money-path.test.ts b/test/realface-money-path.test.ts index c1bb9af..599fb0a 100644 --- a/test/realface-money-path.test.ts +++ b/test/realface-money-path.test.ts @@ -361,3 +361,20 @@ test("list on Base still asks the Base gateway for the EVM address", async () => assert.match(text(res), /Base wallet/); assert.equal(res.structuredContent.chain, "base"); }); + +// Round 4b (RP-3): both enrolments book before validating the body (a settled +// 2xx with a malformed body must not un-record the charge) and then threw on +// a missing asset id — which the catch rendered as "RealFace enroll failed" +// with nothing about the $0.01 that stands, inviting a second paid enrolment. +test("a settled 2xx with no asset id says the charge stands and points at action:\"list\"", async () => { + chain = "base"; + script = [resp402, () => ({ status: 200, ok: true, headers: headers({ "x-payment-receipt": "0xtx" }), json: async () => ({ name: "Ada" }) })]; + const { call, budget } = makeHarness(); + const res = await call({ action: "portrait", name: "Ada", image_url: "https://ok.example.com/ada.png" }); + const t = text(res); + assert.equal(res.isError, true, t); + assert.ok(budget.spent > 0, `spent=${budget.spent}`); + assert.match(t, /charge stands/, t); + assert.match(t, /action:"list"/, t); + assert.doesNotMatch(t, /RealFace portrait failed|try again/i, t); +}); diff --git a/test/search-sources.test.ts b/test/search-sources.test.ts index bdcdf3f..c5ed781 100644 --- a/test/search-sources.test.ts +++ b/test/search-sources.test.ts @@ -148,3 +148,18 @@ test("a non-array sources value is left for the gateway's own 400 (unpaid) rathe assert.notEqual(res.isError, true); assert.equal(walletCalls.length, 1); }); + +// The predicate itself, pinned directly (round 4b: the comment on it named a +// test contract that did not exist). +test("unsupportedSearchSource: only the names are checked, and X/Twitter gets the retired-source note", async () => { + const { unsupportedSearchSource } = await import("../src/tools/search.js"); + assert.equal(unsupportedSearchSource(undefined), null); + assert.equal(unsupportedSearchSource({ sources: "web" }), null, "a non-array is the gateway's shape error, not ours"); + assert.equal(unsupportedSearchSource({ sources: ["web", "news"] }), null); + assert.match(unsupportedSearchSource({ sources: ["web", "x"] }) ?? "", /removed upstream on 2026-07-05/); + assert.match(unsupportedSearchSource({ sources: ["Twitter"] }) ?? "", /do not retry with "x"/); + const other = unsupportedSearchSource({ sources: ["rss"] }) ?? ""; + assert.match(other, /\["rss"\]/); + assert.doesNotMatch(other, /X\/Twitter/); + assert.match(other, /No payment was made/); +}); diff --git a/test/wallet-first-run-race.test.ts b/test/wallet-first-run-race.test.ts index 2303d0d..496679a 100644 --- a/test/wallet-first-run-race.test.ts +++ b/test/wallet-first-run-race.test.ts @@ -179,3 +179,36 @@ test("the session files are created mode 0600", async () => { assert.equal(fs.statSync(f).mode & 0o777, 0o600, f); } }); + +// Round 4b (P1, a regression of round 4's placeholder claim): the claim is a +// rename by NAME after a read that saw an empty file. A peer that claimed the +// same placeholder and linked ITS key in the gap had that key renamed aside +// and deleted; this process then published its own and the peer signed with +// a key that was on disk nowhere. The aside is now inspected and a key found +// there is linked back and adopted. Simulated by making the "empty" read +// happen on a file a peer fills before this process's rename lands: node:fs +// is mocked so the FIRST readFileSync of the session file returns "" while +// the real file already holds the peer's key. +test("Base: a peer's key published into the placeholder gap is adopted, never renamed aside and deleted", async () => { + const processB = realLlm.createWallet(); + fs.writeFileSync(SESSION, "", { mode: 0o600 }); // the placeholder both processes lose to + const realRead = fs.readFileSync; + let staleReads = 1; + const spy = mock.method(fs, "readFileSync", (p: fs.PathOrFileDescriptor, ...rest: unknown[]) => { + if (String(p) === SESSION && staleReads > 0) { + staleReads--; + // The peer wins the claim and publishes between this read and our rename. + fs.writeFileSync(SESSION, processB.privateKey, { mode: 0o600 }); + return ""; + } + return (realRead as any)(p, ...rest); + }); + try { + const key = wallet.getOrCreateWalletKey(); + assert.equal(key, processB.privateKey, "the peer's key survived the claim and is what this process signs with"); + assert.equal(realRead(SESSION, "utf-8").trim(), processB.privateKey, "and it is still on disk"); + assert.deepEqual(fs.readdirSync(blockrunDir).filter((f) => f.includes("placeholder") || f.endsWith(".tmp")), [], "no aside or temp file left behind"); + } finally { + spy.mock.restore(); + } +}); diff --git a/test/wallet-solana-rpc-headers.test.ts b/test/wallet-solana-rpc-headers.test.ts index 26cdadf..b05b863 100644 --- a/test/wallet-solana-rpc-headers.test.ts +++ b/test/wallet-solana-rpc-headers.test.ts @@ -117,3 +117,26 @@ test("SOLANA_RPC_HEADERS outranks SOLANA_RPC_API_KEY, the SDK's precedence", asy assert.equal(captured.headers["authorization"], "Bearer t"); assert.equal(captured.headers["x-api-key"], undefined, "the JSON form replaces, it does not merge"); }); + +// Round 4b (RP-4): the Base balance read walked the public fallback list and +// ignored BASE_RPC_URL, which the SDK's own getBalance honours — rail parity +// with the Solana read above. +test("BASE_RPC_URL is tried first on the Base balance read", async () => { + const saved = process.env.BASE_RPC_URL; + process.env.BASE_RPC_URL = "https://private.rpc.example/base"; + const realFetch = globalThis.fetch; + const urls: string[] = []; + globalThis.fetch = (async (input: string | URL | Request) => { + urls.push(String(input)); + // 100 USDC (6 decimals) as a 32-byte hex word + return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: "0x" + (100_000_000).toString(16).padStart(64, "0") }), { status: 200, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + try { + const balance = await getChainBalance("base", "0x0000000000000000000000000000000000000001"); + assert.equal(urls[0], "https://private.rpc.example/base"); + assert.equal(balance, 100); + } finally { + globalThis.fetch = realFetch; + if (saved === undefined) delete process.env.BASE_RPC_URL; else process.env.BASE_RPC_URL = saved; + } +}); diff --git a/test/workflows.test.ts b/test/workflows.test.ts index c6c5c76..9cac0b8 100644 --- a/test/workflows.test.ts +++ b/test/workflows.test.ts @@ -51,7 +51,7 @@ function step(wf: Workflow, job: string, name: string): Step { * the way it does for GITHUB_TOKEN here ("not permitted to create or approve * pull requests"). */ -function landingFixture(opts: { ghSucceeds: boolean }) { +function landingFixture(opts: { ghSucceeds: boolean; existingPr?: number }) { const dir = mkdtempSync(path.join(os.tmpdir(), "brand-sync-run-")); const repo = path.join(dir, "repo"); const bin = path.join(dir, "bin"); @@ -76,6 +76,9 @@ exec "${realGit}" "$@" path.join(bin, "gh"), `#!/bin/sh echo "gh $*" >> "${log}" +if [ "$1 $2" = "pr list" ]; then + ${opts.existingPr ? `echo "${opts.existingPr}"; exit 0` : "exit 0"} +fi ${opts.ghSucceeds ? 'echo "https://github.com/BlockRunAI/blockrun-mcp/pull/999"; exit 0' : 'echo "pull request create failed: GraphQL: GitHub Actions is not permitted to create or approve pull requests (createPullRequest)" >&2; exit 1'} `, ); @@ -157,6 +160,22 @@ test("brand-sync: nothing to land is a quiet green, no push at all", () => { } }); +// Round 4b (CI-5): once a human has opened the fallback PR, the next Monday's +// force-push refreshes it and `gh pr create` fails with "already exists" — +// which used to be reported as "PR creation is unavailable, open it by hand". +test("brand-sync: an already-open fallback PR is refreshed and the run is green", () => { + const f = landingFixture({ ghSucceeds: false, existingPr: 151 }); + try { + const r = runLandStep(f); + assert.equal(r.status, 0, r.stdout + r.stderr); + assert.match(r.stdout, /refreshed the open PR #151/); + assert.match(f.calls(), /git push -f origin HEAD:brand-sync/); + assert.doesNotMatch(f.calls(), /gh pr create/); + } finally { + f.cleanup(); + } +}); + // ---- publish.yml: three targets, three INDEPENDENT guards ---- test("publish: a version below npm latest is refused before anything installs or publishes", () => { @@ -180,7 +199,12 @@ test("publish: the tag/release step is gated on the tag being ABSENT, not on npm const release = step(wf, "publish", "Tag + GitHub release"); const cond = (release.if ?? "").replace(/\s+/g, " "); - assert.match(cond, /steps\.v\.outputs\.tag_missing == 'true'/, "the release step's own guard is the tag"); + // Round 4b: NOT gated on tag_missing either — once the tag is pushed, a + // re-run after `gh release create` failed must still reach the in-step + // `gh release view` guard, or a tag with no release is unrepairable. + assert.doesNotMatch(cond, /tag_missing/, "gating on the tag skips the release half on every re-run after the tag was pushed"); + assert.match(release.run ?? "", /gh release view "\$TAG"/, "the release existence check lives in-step"); + assert.match(release.run ?? "", /git ls-remote --exit-code --tags origin "refs\/tags\/\$TAG"/, "so does the tag existence check"); assert.doesNotMatch( cond, /^\s*steps\.v\.outputs\.pkg != steps\.v\.outputs\.npm\s*$/, @@ -195,6 +219,20 @@ test("publish: the tag/release step is gated on the tag being ABSENT, not on npm assert.equal(step(wf, "publish", "Build, typecheck, test").id, "build"); }); +test("publish: the job runs on main only — a workflow_dispatch from a branch must not publish", () => { + const wf = load("publish.yml"); + assert.equal((wf.jobs.publish as { if?: string }).if, "github.ref == 'refs/heads/main'"); +}); + +test("publish: the MCP-registry lookup splits none/unknown too, and an unknown state stops the job", () => { + const wf = load("publish.yml"); + const resolve = step(wf, "publish", "Resolve versions").run ?? ""; + assert.match(resolve, /curl -fsS "https:\/\/registry\.modelcontextprotocol\.io/, "a non-2xx must not read as 'none'"); + assert.match(resolve, /REG=unknown/); + const guard = step(wf, "publish", "Refuse to run against an unknown registry state"); + assert.equal(guard.if, "steps.v.outputs.reg == 'unknown'"); +}); + test("publish: an npm registry failure is 'unknown' and refused, only an E404 is 'none'", () => { const wf = load("publish.yml"); const resolve = step(wf, "publish", "Resolve versions").run ?? "";