Skip to content

0.51.0 — round four went at round three - #150

Merged
VickyXAI merged 39 commits into
mainfrom
fix/audit-r3
Sep 15, 2026
Merged

VickyXAI merged 39 commits into
mainfrom
fix/audit-r3

Conversation

@VickyXAI

Copy link
Copy Markdown
Contributor

Summary

Supersedes #145 (this branch contains every fix/audit-r2 commit plus rounds three and four).

  • Round three (nine commits): every chat path streams and classifies its failure by what the wire said (settlementOnThrow none/unknown/settled, account rail books exact usage); one per-call in-flight tracker replaces five hand-rolled paidRequestInFlight booleans; the seven path tools book a payment the origin never answered; BLOCKRUN_BUDGET_LIMIT is a ceiling the session cannot raise; four ways a funded key could be lost or shadowed are closed; Polymarket unknown-outcome handling, pendingFund guard, preview-bound confirm, and the spend dialog on trades; release gates that fail when they cannot verify.
  • Round four audited round three with the same adversarial loop (10 finders → 2 verifiers per finding → critic): 14 confirmed (1 P0, 3 P1), all fixed with pinning tests — an answered error is never a maybe (isAnswer), every settled-at-submit escape is billed, secret-tool exit 1 read by stderr (the P0: Linux strict mode minted over a funded keychain wallet), the legacy wallet.key ranks below the keychain, revoked agents keep their ledger, the fund fee actually reaches the ledger, RawCallSettledError, and the publish gate's E404/unknown split.
  • verify:prices fixes: anthropic/claude-opus-4 price row ($15/$75 on the account sheet, was reserving the $5/$30 default); the seedance-2.5@1080p probe marked expectRefused now that blockrun #353 has deployed.
  • Context cost re-measured: 13.0K tokens, --profile trading 58% less.

Full notes: CHANGELOG.md § 0.51.0.

Test plan

  • npm run typecheck
  • npm test — 1273/1273
  • npm run build
  • npm run verify:prices — exit 0 (0 under-reserving, 0 unreachable, catalogue sweep clean)
  • stdio smoke: initializetools/list = 19 tools
  • after merge: gh run watch the publish run; verify npm, MCP registry, tag v0.51.0, GitHub release; one cold npx -y @blockrun/mcp@latest

🤖 Generated with Claude Code

VickyXAI and others added 30 commits September 9, 2026 09:05
…count-rail errors that classify

Three items verified by hand after the 0.49.0 audit, plus the test file its
tail called out.

- blockrun_wallet action:"delegate" wrote spent: 0 unconditionally, so an
  agent that had exhausted its allocation could re-delegate its own agent_id
  and start again — and delegate is a tool the model can call. The ledger now
  carries across a re-delegation and the response says what carried over; a
  limit is the operator's to raise, spend is not theirs to erase. The global
  cap was never bypassable this way, so the sub-cap simply meant nothing.
- The status-code boundary excluded a following dot outright, which kept
  $402.50 from reading as a status but also meant every account-rail message
  ('BlockRun account API error: 502.', the SDK's own shape) fell through
  unclassified: the wallet rail got guidance, the account rail got none. A dot
  NOT followed by a digit is punctuation; a dot followed by one is a decimal
  point and still disqualifies. One shared STATUS_END constant now, so the
  three matchers cannot drift.
- raw-call.ts — the rail switch for eight path-based tools — had no test on
  either rail. Eight now, covering which rail runs, that the other is never
  touched, and that paidUsd stays null (never 0) on a wallet call, since
  recordActualSpend books a real charge as free for 0.
- docs/mcp-schema-overhead.md re-measured at 0.49.0 (19 tools, 12,657), with a
  line saying the README card is the source of truth and this page is dated
  prose.

654 tests, typecheck clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
…o the route's own limit, and correct four stale claims

Six more from the audit tail, each read end to end first.

- The model cache was a single shared list with no key, so blockrun_wallet
  action:"chain" left the previous gateway's catalogue in place for the rest of
  the 5-minute TTL — and the two gateways do not serve the same one (78 chat
  models on Base, 83 on Solana, measured 2026-09-09). Keyed by rail+chain now,
  with the in-flight fetch shared between the tool and the resource instead of
  both issuing it.
- blockrun_models was annotated openWorldHint:false while fetching the live
  catalogue over the network. It is readOnlyOpenWorld: the hint describes
  whether the tool reaches outside the process, and this one does.
- VIDEO_POLL_TIMEOUT_MS was 90s against a gateway poll route that declares
  maxDuration = 60, so 30s of the signed authorization's window was spent on a
  request the server had already abandoned. The invariant test now states the
  real unclamped worst case (budget + interval + timeout), which is what its
  own comment always said.
- README FAQ said 'a few media/paid tools settle on Base only (noted above)':
  not media, and not noted. It names them — blockrun_defi, blockrun_modal and
  native Anthropic chat.
- budget_action accepts 'check' and DEFAULTS to it; neither the examples nor
  the field description said so.
- skills/blockrun claimed 'No API keys, no accounts' — the account rail has
  existed since 0.46.0.

654 tests, typecheck clean. README card and docs/mcp-schema-overhead.md
re-measured (12,686 full / 5,189 trading).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
…not only what it is for

The setup prompt explained the purpose ('settle YOUR signed orders from the
deposit wallet') and never the size: the default grants an UNLIMITED pUSD
allowance to four collateral spenders plus all-or-nothing ERC-1155 operator
rights to five contracts. That is standard for Polymarket and it is exactly the
kind of thing to state before the signature rather than after.

POLYMARKET_BOUNDED_APPROVALS has existed all along and nothing surfaced it at
the moment of consent; the prompt now names the amount on both branches and
points at the bound, with a test pinning both wordings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
Found by the round-2 audit, which exists because each round must regression-hunt
the last one's fixes. Both are about a funded wallet.

- The continuity pin was written by asking getChain again after provisioning and
  comparing. Under BLOCKRUN_KEYCHAIN=strict that question cannot be answered at
  that point: minting stores the key and DELETES .solana-session, so the file
  check misses and the keychain probe returns the value memoised before the
  mint. It answered "base" both times, no pin was written, and the next start
  found the stored key and moved a funded Base user onto an empty Solana wallet
  — the 0.32.3 failure CHAIN_AUTO_FILE exists to prevent. The pin is now written
  off the provisioning FACT (did THIS call mint the other chain's wallet), which
  is local and cache-free, and the probes are dropped after a mint so a
  same-process reader is honest too.

- ensureSolanaWallet assigned its cache only after awaiting createSolanaWallet,
  so two overlapping callers both minted; 0.49.0 made that reachable from two
  entry points at once (the blockrun://wallet resource and action:"setup"), and
  last-writer-wins means one caller gets a funding QR for an address whose key
  was discarded. Single-flighted — but the rejection is deliberately NOT cached,
  or unlocking a keychain and retrying would stay broken until restart, which is
  the poisoning 0.49.0 removed when it stopped memoising a miss.

Both were untested: chain-precedence runs with the keychain off and
keychain-precedence mocks persistKey to a no-op, so neither could see the strict
delete. Six new tests cover the strict-mode pin, the fresh-install direction,
the no-mint case, concurrent callers, and a rejection that must not stick.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
…default chain too

0.49.0 taught the media tools two things and taught them per-rail, so Solana —
the default chain since 0.46.0 — ended up with neither. Round-2 audit.

- blockrun_music (both wallet rails) and blockrun_speech (Base) signed whatever
  the 402 quoted: no sanity check, no re-reservation against the cap. Music's
  Solana call passed no onQuote at all, so the helper's guard hook fired against
  nobody and the SPL transfer was signed for whatever the quote said. Both now
  run assertQuoteNearEstimate and re-reserve the real amount before signing,
  the way video and image already did.

- Giving up while a paid request may still be in flight booked the charge on
  Base (paidPollInFlight) and on the account rail (BilledJobError), and nothing
  on Solana — where the shared helper's own message says a poll in flight at the
  deadline can settle server-side. A settled Solana render therefore moved no
  budget at all and the caller was invited to pay for it again. Both tools now
  book the quote conservatively there and say the charge MAY have gone through,
  instead of the Base-only "No payment was taken".

- formatError told users to fund their wallet on messages that say in the same
  breath that nothing was charged: the uncharged markers gated only the
  "payment" keyword clause, so a bare 402, "balance" or "insufficient" still
  earned the footer. Two of this repo's own messages did it — the video tool's
  unreadable-quote refusal (a wallet holding $1,000 told to top up) and
  RealFace's "No payment taken", which was not even in the marker list. The
  markers now cover every phrasing the four authors use and gate the whole
  branch. A genuinely empty wallet still gets the advice.

Ten new tests: five for Solana rail parity, five for the funding footer,
including negative controls on both sides.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
…r booked at zero

Round-2 audit, the last of the P2s. All three are the same shape: 0.49.0 taught
one surface that "we do not know whether it landed" and left its siblings
saying "nothing happened".

- The order card's stale-amount guard wrote place.disabled on every input
  event, including while a submit was in flight, so nudging the amount up and
  back down during the CLOB round-trip re-enabled an ARMED button reading
  "Submitting…" — one more click placed a second identical real-money order
  with no confirmation. The card now tracks submitting and outcome-unknown
  explicitly, disables the amount field during a submit, and re-arms only when
  the failure says nothing was signed. A throw is transport-level, which is
  exactly when the order may already be live, so it now warns instead of
  inviting a retry; a declined consent prompt still restores the card, because
  that one really did sign nothing.

- withSettledCost short-circuited the account rail with no try/catch, so
  onSettledThrow never fired there: a chat call the gateway accepted, billed and
  then dropped mid-stream booked $0 and read as a free failure, whose obvious
  next step is to pay for it again. The account rail now reports a billed
  failure with the amount unknown, the ledger books the estimate, and the note
  points at the dashboard for the exact figure.

- blockrun_realface had no in-flight tracking on its paid POST on any of the
  three rails, so an abort after the gateway settled left a real charge unbooked
  and the reservation released. It gets the same flag and the same wording video
  and music already had.

The order card's two post-failure predicates moved out of the DOM into
apps/order-safety.ts and are unit-tested, replacing a test that grepped two
string literals out of the minified bundle.

673 tests, typecheck and build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
tx-fee.ts has stated the rule since 0.40.1 — reserve high, book accurate, and
do not collapse the two numbers — and exactly one file honoured it. Every
path-based tool passed its RESERVE (base + TRANSACTION_FEE_USD, $0.002, rounded
against us on purpose) as recordActualSpend's fallback, and on the wallet rail
there is never a settled figure to override it, so the ledger booked the
reserve on every call.

Measured with unauthenticated 402 probes, no payment header:

  route                     reserved   Base      Solana
  rpc/ethereum (single)     $0.0040    $0.0030   $0.0020
  pm/*                      $0.0095    $0.0085   $0.0075
  phone/lookup              $0.0120    $0.0110   $0.0100
  search (max_results=10)   $0.2645    $0.2635   $0.2625

On Solana, the default chain since 0.46.0, the gateway charges no transaction
fee at all — so that is $0.002 of invented spend per call. An agent delegated a
$1.00 cap making only rpc calls was cut off after 250 of them having actually
spent $0.50, and action:"report" showed $1.00. The same inflated figure is what
the spend-confirmation dialog showed the human.

ledgerFallback() in raw-call.ts, next to the rail switch it depends on, converts
a reserve into the observed charge for the seven tools that route through it.
The gate is untouched and still reserves the higher figure; the result is
clamped so it can never exceed the reserve. Found by the round-2 audit's
completeness critic, which noted that both prior rounds walked past it.

678 tests, typecheck clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
…s first run

Round 2's regressions had one fingerprint: six agents fixed 0.49.0 in parallel
and each hardened the rail it was looking at. The quote guard landed on video
and image but not music and speech; the in-flight booking on Base and the
account rail but not Solana, the default chain; music's Solana call passed no
onQuote at all, so the helper's guard hook fired against nobody. Every one was a
money path and every one passed CI.

So this is the table the completeness critic asked for instead of a round 3:
every paid tool, every rail it serves, every treatment a paid call needs — quote
guard, re-reserve at the real price, in-flight booking, honest give-up wording,
ledger figure. A cell is a claim about the source, and adding a rail-specific
guard without filling in its siblings turns the file red. It also asserts the
DIVISION is deliberate: the seven tools whose 402 the SDK owns must NOT grow a
quote guard, because they cannot see the quote, and it fails if a paid tool is
missing from the table altogether.

It found four gaps on its first run, all now fixed:

- realface had no quote check on any rail and never re-reserved at the quoted
  price — it read the 402 amount and signed it five lines later. payAndPostJson
  takes an onQuote hook, wired on both the Base and Solana rails and to both
  enrollment calls; the account rail has no 402 to check and says so.
- speech booked nothing when it gave up, though its own message already said a
  charge MAY have settled. It books conservatively now, and only when the
  timeout happened AFTER the signature went out — a timeout on the unpaid 402
  probe charges nothing and must not invent spend.
- image had no in-flight tracking, so an abort after the gateway settled left a
  real charge unbooked and released the reservation.

686 tests, typecheck and build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
… stale claims

Round-2 P3s.

- getSolanaUsdcBalance took the balance query off the SDK in 0.49.0 and dropped
  SOLANA_RPC_HEADERS with it, so a private RPC that authenticates by header
  answered 401 and every balance read as "unavailable". Same parse the SDK does,
  same failure mode on malformed JSON.

- resolveSolanaKey collapses "absent" and "the keychain would not open" into
  undefined, so buildSolanaClient told a user with a funded, locked wallet to run
  setup — advice that invites a second wallet. ensureSolanaWallet already refuses
  to mint on that distinction; solanaKeyUnavailableReason() exposes it so the
  sync callers can say the same thing.

- blockrun_video and blockrun_realface told account-rail users their "wallet is
  out of funds" and offered a card top-up for a wallet that is not paying.
  Music and speech got the isApiKeyMode branch in 0.49.0; these two did not.

- The Stanford demo preflight asserted a nine-tool trading profile including
  surf, so it now always fails; skills/rpc still routed agents to a removed
  tool; index.ts's own comment cited the pre-removal counts; and
  blockrun_markets described polymarket/wallets/profiles as a POST batch route,
  which 404s — GET returns the 402 (probed).

Not changed: the completeness critic filed phone.ts's unknown-reserve comment as
citing a route that no longer exists. /v1/phone/numbers/search answers 402 today,
so the comment is accurate and the finding is refuted.

686 tests. Context figures resynced after the description edits (12,698 full).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
Round-2 P3s.

- publish.yml curled `releases/latest` of a third-party binary straight into
  tar and then into /usr/local/bin, in the job that holds the npm publish
  token. Its contents could change between two runs of the same commit with
  nothing here recording it. Pinned to v1.8.1 and checksum-verified against the
  same release: pinning is the part that matters, the checksum only proves the
  download is intact.

- solanaPaidPost parsed the settled 200 body unguarded, so a truncated or
  aborted payload threw AFTER the money moved and the caller reported a failure
  with nothing booked — the one direction that must never happen. The 200 is
  the settlement; the charge is handed back whether or not the body parsed.

- The wallet card's primary CTA read "Buy USDC with card" on Solana, where card
  top-up is Base-only: the user clicked, watched "Minting link…", and landed on
  a plain-text refusal. It now says what the active chain can actually do.

- The order card labelled the worst fill "signed max/min", which reads as a
  guarantee about the order about to be placed. Place re-walks a fresh book on
  the server, so the bound is from THIS quote and the label now says so.

- blockrun_image's quote guard was untested: deleting it left every test green.
  Two tests now pin the refusal, the tolerance, and that image calls the guard
  on the one rail that surfaces a 402 amount.

686 tests, typecheck and build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
…is unknown

The last two round-2 P3s.

- sendWalletBatch had `await getRelayClient()` inside its try. That call derives
  CLOB credentials and creates a builder key — real network calls that happen
  before the RelayClient exists, so they cannot have signed or posted anything —
  and their failures armed the double-send guard, wedging the user behind a
  five-minute deadline for a transfer nothing had signed. It is hoisted out.
  The 4xx detector also read only the JSON shape, while the CLOB SDK's ApiError
  puts its code on a `.status` property and leaves the message bare, so every
  definite refusal it raises looked ambiguous; it now reads the property, the
  JSON shape, and a bare "HTTP 403" in the text.

- The catalogue sweep claimed to check every FREE_CHAT_MODELS member against the
  live price, and could only check the ones the catalogue returns: six are
  absent from both gateways today, and two more were skipped by the
  `available === false` guard. A free member is now checked even when marked
  unavailable — "retired today" does not promise "still free when it returns" —
  and one the catalogue does not list is reported as UNVERIFIED rather than
  counted as checked. Delisting is not death here (gpt-oss-120b is the
  gateway's own free fallback and answers while absent from the catalogue), so
  settling one costs a real POST and stays a human's call. The comment in
  constants.ts that overclaimed the coverage now says exactly this.

690 tests, typecheck clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
…, not just inside one call

"Signed at the worst fill you saw" held within ONE call: the book walk that
produced the preview also set the signed limit. But the preview and the confirm
are two calls, and the confirm re-walks a fresh book — so a book that moved in
between was signed at a price the card never displayed, which is the direction
it moves exactly when it matters.

blockrun_polymarket takes an optional max_fill_price for market orders: the
worst fill the caller was shown. A walk that comes out worse is refused before
anything is signed, with the two prices named and nothing charged. Buy is a
ceiling, sell a floor. Absent, behaviour is unchanged and the walk stands on its
own — this is a bound the caller opts into, not a new failure mode for callers
who do not pass it. The order card carries its own displayed figure
automatically, so the guarantee now holds for the surface that makes it.

Four tests: a book that moved against the quote, one that moved in the user's
favour (a better price is not a reason to refuse), the sell side's inverted
comparison, and the unchanged no-bound path.

694 tests. Context figures resynced (12,767 full).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
0.49.0's thirty-seven fixes were written by six agents in parallel, and this
round went looking for what that costs. It found the shape immediately: each
agent had hardened the rail it was looking at. Thirty findings survived
adversarial verification and NOT ONE was a P0 or a P1 — 0.49.0's own list had
one of each — so the general search is spent. What is not spent is that class,
so the last change here is a table rather than a fix: test/rail-parity.test.ts
states which treatments every paid tool needs on every rail, and turns red when
a rail-specific guard lands without its siblings. It found four more gaps on
its first run.

Minor, not patch: max_fill_price is a new input, the ledger now books the
observed charge rather than the reserve, and the order card refuses submits it
used to allow.

694 tests, typecheck, build and verify:prices all green (0 under-reserving on
either chain; the catalogue sweep now names the free-list members it cannot
verify instead of counting them as checked).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
… keychain

The gate deciding whether to consult the OS keychain asked `existsSync`. The
loaders on the far side of it -- the SDK's resolveFromFiles() and
loadSolanaWallet() -- both `.trim()` the file and treat whitespace as NO KEY.

So a zero-byte session file read as PRESENT to the gate and ABSENT to the
loader, and the two disagreed in the one direction that costs money: the
keychain was skipped, a brand new wallet was minted, and the persistKey() call
immediately after overwrote the keychain entry that still held the funded key.
No delete call is involved and nothing is printed. saveWallet() is a plain
non-atomic writeFileSync, so an interrupted write, a full disk, a restore
tool's placeholder or a stray shell redirect all leave exactly this file.

Both rails now ask whether the file HOLDS a key. getChain() already asked it
that way in two places, each with a comment explaining why existsSync is the
wrong question; these were the two callers that had not. An unreadable file
counts as present: we cannot tell whether it holds a key, and guessing toward
the keychain is how a stale entry shadows a live wallet -- the loader then
hits the same unreadable file and fails loudly, which is the outcome we want.

Also fixes keychainDelete's platform asymmetry. It documents "true when the
entry is gone, including was never there" and only macOS honoured that;
LINUX_ITEM_NOT_FOUND was defined in the file and unused, so `secret-tool clear`
on a miss reported the key as still in the keychain when it was not. It had
zero callers, which is why nothing caught it.

Tests: both empty-file cases are red before this change and green after
(verified by reverting each gate in turn); the file-outranks-keychain
precedence test stays green in both directions, so rotation by replacing
~/.blockrun/.session is unaffected. keychain-delete.test.ts covers both
backends without spawning a real helper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
…to delete the live credential path

The file header described the ERC-7739 wrapped L1 signature as the workaround
in force and signed off with "if fixed upstream, delete this module and use
client.createOrDeriveApiKey()".

Both halves are now wrong, and following the second one breaks trading. The
wrap was the wrong diagnosis of clob-client-v2#65: the CLOB rejects the
wrapped envelope with "Invalid L1 Request headers", L2 creds are bound to the
owner EOA even in POLY_1271 mode (matching rs-clob-client-v2 src/auth.rs), and
both call sites pass sigType 0. The long note at the buildClobClient() call
site in client.ts has said so since; the module header never caught up. And
the module has meanwhile become the home of deriveApiCreds(), which every
Polymarket action needs -- so deleting it on a version bump removes credential
derivation entirely.

Header rewritten to say what is true: the wrapped path is a tested reference
implementation of the envelope, correct about the bytes and wrong about what
the server wants, reachable by no caller. deriveApiCreds's own doc no longer
offers sigType 3 as if it were a working alternative.

Also adds test/axios-scope.test.ts. applyClobProxyOnce() sets
axios.defaults.httpsAgent process-wide, which is unavoidable -- clob-client-v2
reaches for the hoisted axios itself, so there is no instance to scope. The
whole safety argument is that only Polymarket shares that axios, and it lived
in a comment. Now a non-Polymarket axios import turns the suite red instead of
silently routing that module's traffic through an operator's
POLYMARKET_CLOB_PROXY. Verified by adding an import to utils/http.ts and
watching it fail. The second case pins that the Finland default is a HOST, not
a proxy, so axios.defaults stays untouched unless an operator opts in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
…omised to hide

Three of the four Polymarket e2e scripts state in their own doc comment that
wallet addresses and transaction ids are never printed. Each implemented that
with a different regex:

  e2e-readonly    0x[hex]{40}   -> <wallet>     addresses only
  e2e-approve     0x[hex]{40,}  -> <redacted>   both, unlabelled
  e2e-live        0x[hex]{64}   -> <tx>         hashes only
  e2e-withdraw    0x[hex]{64}   -> <tx>         hashes only

The last two are the ones that move real money, and {64} does not match a
40-hex address. withdraw.ts interpolates the bridge response into its error
text and that response carries `address.evm`, so the address printed in full.

Only the isError branch was guarded at all. A thrown exception -- a network
failure inside fetchPositions(), a viem revert -- bypassed redaction entirely
and Node printed the raw message and stack to stderr.

One redaction now, in scripts/redact.ts, longest-match-first in a single pass
so a hash cannot be half-eaten by the address rule, applied on every exit path
including uncaught throws. A 32-byte private key comes out as <tx>:
mislabelled, but not printed, which is the direction that matters.

test/scripts-redaction.test.ts covers the helper and fails if any e2e script
grows its own 0x[a-fA-F0-9]{...} regex again.

scripts/ is now in tsconfig include. These files import from src/ and were
typechecked by nothing, so a changed signature in withdrawFunds or
redeemPosition would first show up while running against a funded wallet.
Adding them surfaced no errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
`npx tsx scripts/smoke-speech.ts` registered the real blockrun_speech handler
and immediately spent from the machine-global wallet at ~/.blockrun/.session.
No flag, no prompt, and `limit: null` so nothing capped it. Its header said
"real $0.001 speak"; the run ends with a $0.0525 sound effect, fifty times
that, and about $0.054 total.

This repo has already lost $0.42 to a paid handler that was run because it
looked like a read. Nothing sitting in scripts/ should spend money by being
run.

It now refuses without --confirm (or BLOCKRUN_SMOKE_CONFIRM=1), names the real
total in the refusal, and sets a $0.15 budget cap as a second backstop for a
moved price or a doubled retry. Verified: a bare run exits 1 having charged
nothing.

test/scripts-spend-gate.test.ts pins this for the next script like it —
anything in scripts/ that calls register…Tool() must carry a confirm gate
ahead of its first paid call and a numeric budget limit. The assertions are
static on purpose: a test that proved the gate by running the script would
charge the wallet the day the gate regressed, which is the failure it exists
to catch. Verified by setting limit back to null and watching it fail.

The polymarket e2e scripts are deliberately out of scope and the test says so:
they reach paid paths through utils/ rather than a handler, run only as
explicitly named npm targets, and carry their own bounds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
…out failing

Four scripts that had never been audited, each able to produce confident
output from a failure.

measure-tool-schema.mjs
- rpc() resolved on any reply with a matching id and never looked at
  msg.error. Callers destructure `{ result }`, so an error reply handed them
  undefined -> `result?.tools ?? []` -> a measurement of ZERO tokens across
  ZERO tools, printed as a real figure at exit 0. With --svg that reaches
  assets/context-cost*.svg as "0.0K tokens" and, since `cut` divides by the
  total, a literal "NaN% less". A server answering initialize with an error --
  the foreign-server case this tool advertises, when the package wants auth --
  is all it takes. Now rejects, naming the method and the error.
- stdout was decoded per chunk (`buf += chunk` on a Buffer), so a multi-byte
  character split across a boundary became U+FFFD. JSON.parse still succeeds,
  so only the number comes out wrong. The payload is ~50KB against a 16KB pipe
  buffer and these descriptions are full of em dashes. The in-process test
  that pins these figures uses InMemoryTransport and never exercises this
  reader, so it would have surfaced as an unexplainable README diff.
  setEncoding("utf8").
- a profile that lists no tools is now a failure, not a measurement of zero.

stamp-server-json.mjs stamped nothing when no package entry matched pkg.name,
left the template's "0.0.0-template" (valid semver, so mcp-publisher validate
passes it), and printed "Stamped server.json → …" regardless. publish.yml
would then point PulseMCP, Glama and the rest at an npm version that does not
exist. The input was guarded against a placeholder; the output was not.
Verified by renaming the identifier and watching it exit 1.

changelog-section.mjs and measure-tool-schema.mjs both compared a realpath'd
import.meta.url against a non-realpath'd process.argv[1]. Node realpaths the
ESM main entry, so from any checkout reached through a symlink (macOS
/tmp -> /private/tmp, npm link) the CLI half silently did nothing at exit 0.
publish.yml guards on `if ! node scripts/changelog-section.mjs "$VERSION"`, so
exit 0 with empty stdout skips the generic fallback and publishes a release
with an EMPTY body -- the one thing that file's header promises cannot happen.
Verified: through a symlinked path it printed nothing and exited 0 before,
prints the section now.

sync-brand-numbers.mjs rendered remote values with String(value) and
interpolated them into src="…" and alt="…" with no escaping. Those files are
README.md, CONTRIBUTING.md and skills/*/SKILL.md, and brand-sync.yml commits
and pushes them to the default branch weekly, unattended, with contents:write.
A value carrying a quote or an angle bracket closed the attribute and injected
markup into every consuming repo; write access to the awesome-blockrun mirror
was enough to reach them. Rendered values are now checked at the point of use
(a finite number or a short plain label) and escaped on top. Checked at use
rather than over the whole artifact because the payload legitimately carries
prose fields we never render. Verified with a quote-carrying value: exit 1,
nothing written. Its --check also no longer lists stale fenced markers and
then declares everything up to date, and "keys in use" no longer counts
mcp.tools and mcp.tools@badge as two.

Documentation claims nothing was watching: README said "same 20 tools either
way" two lines under a marker rendering 19 (now the marker, so the sync keeps
it right), and docs/mcp-schema-overhead.md kept a second copy of the
profile-cost table that no test pinned. Both covered now, plus the profile
list itself, hardcoded in the script and the test, which would have left a
newly added profile measured and pinned by neither.

Found by a read-only audit subagent; every finding re-verified here before
fixing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
…and call the SDK directly

Two defects, one of which produces a broken tool.

The dead references. `src/tools/surf.ts` went away with the tool in 0.49.0,
and CONTRIBUTING still named it four times: as the template to copy in step 1
of "Adding a new MCP tool", as the reference example of the path-based
pattern, and as the example for the sync payment call. Step 1 was literally
uncopyable. `skills/surf/SKILL.md` was offered as the structural template for
a new skill; it is now a retirement map for a removed tool, which is the one
thing a new skill should not be modelled on.

The one that costs money. The x402 section documented
`client.getWithPaymentRaw(endpoint, params)` and
`client.requestWithPaymentRaw(endpoint, body)` as how a tool makes a paid
call. No tool in src/ has called those directly for some time, and for a
reason raw-call.ts states in its own header: there are THREE payment rails and
the SDK knows two. On the account rail requestWithPaymentRaw degrades to a
plain Bearer fetch and throws away the x-blockrun-cost-usd response header, so
a tool built that way silently does not support API-key users and cannot say
what the call cost. utils/raw-call.ts is the single entry point that exists so
no tool chooses a rail for itself -- and per-tool rail divergence is the exact
fingerprint of every money bug rounds 1 through 3 found.

Now documented as rawGet/rawPost with the SDK-direct path called out as wrong
and the ledgerFallback() note, since the gate and the ledger are deliberately
different numbers and Solana has no gateway tx fee.

Also repoints the examples at src/tools/markets.ts and
skills/prediction-markets/SKILL.md, both of which exist and both of which
demonstrate what the surrounding sentence claims, and drops the retired surf
from raw-call.ts's own list of its callers.

test/doc-file-refs.test.ts is the guard: every `src|test|skills|scripts|apps|
assets`-rooted path named in README, CONTRIBUTING or docs/ must exist; the
raw-call guidance must stay in CONTRIBUTING; and every path-based tool must
actually import raw-call and must not call the SDK raw methods directly.
Verified against the pre-fix CONTRIBUTING: tests 1 and 2 both fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
…found a P0

The 0.50.0 entry opened as "a second audit round" and stated that the general
search was spent. Two more rounds have since landed in the same unreleased
version, and round 3 found a path that destroys a funded wallet key -- so the
release now contradicts its own headline.

Reframed honestly: the general SWEEP was spent, and another pass over the same
files would have returned docs and cosmetics. What was not spent was the
surfaces no sweep had opened, and the class where a comment states a contract
the code does not honour on some branch or platform. Comments cannot fail a
test, which is why nothing had ever checked them and why four of round 3's
five findings were that shape.

Version stays 0.50.0: it is unreleased (npm latest is 0.49.0), so these are
additional contents of the same release, not a new one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
…what the wire said, and books what the rail bills

The three OpenAI-compat chat paths and the native claude-* path now share one
question on a throw — did money move? — answered from evidence, not from the
words in the message: settlementOnThrow returns none/unknown/settled from the
error's type, status, prefix and transport cause. Before, the account rail
booked the full reserve for every refusal (a typo'd model id exhausted a
delegated cap at $0 real spend — C5/C18/C23/C31), the wallet rails read a
payment sent-and-never-answered as $0 and let the routing loop pay a second
model (C19), and the native path booked nothing at all after a settled 524
(C20).

- Solana chat streams via SolanaLLMClient.stream() — the 60s non-streaming
  abort on the default chain, after the SPL payment was sent, is gone.
- The native path streams too (maxRetries 0): the SDK refused non-streaming
  thinking budgets above ~21k tokens (D51) and retried a settled 5xx with a
  fresh x402 payment each time (C20).
- The account rail books exact usage at the model's rate, no fee, no floor
  (accountLedgerUsd / anthropicAccountLedgerUsd) instead of the gate reserve
  that ran 3-50x high (C33/D53/D58); a settled x-blockrun-cost-usd, zero
  included, wins when present.
- served_model, finish_reason and truncated_output ride on every reply;
  partial text survives a mid-stream failure (D55/D57).
- claude-* without `thinking` is allowed on Solana through the compat path
  (D5); the refusal now names the one thing the compat path cannot carry.
- formatError: an account-rail 402 says "out of credit", never "run setup"
  (C28); a 5xx or transport failure after the payment left says the charge
  MAY stand instead of "try again in a few minutes" (C38); the Base-only
  replay-nonce reading of "Payment was rejected" is a shared hedge.
- The free tier is re-swept: six of eleven routed ids answered as another
  model on both chains, so the tier keeps only the five that echo their own
  name; the rest stay $0 in FREE_CHAT_MODELS.
- anthropic/claude-opus-4 gets a price row: the account sheet bills the
  hidden-but-served id at $15/$75 and it was reserving the $5/$30 default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ssion cannot raise, and a revoked agent keeps its ledger

BLOCKRUN_BUDGET_LIMIT seeded the same mutable `budget.limit` that
blockrun_wallet action:"budget" writes, so the agent it constrained could
clear or raise it in one free tool call — and the denial text at the cap
pointed it at exactly that tool. The env value is now sealed at wallet-tool
registration: `set` clamps to it, `clear` restores it, `delegate` clamps the
child cap to it, and every response says when and why it clamped.

delegate replaced the Map entry and revoke deleted it while reserveBudget's
closure held the old object, so a re-delegation mid-render stranded the
estimate on the ledger, and revoke+delegate reset `spent` to zero — the refill
0.50.0 said it had closed. Entries are now mutated in place and revoke
tombstones them for the next delegate of the same id.

recordActualSpend treats an explicit 0 as a settled figure, not as "unknown":
a free-priced account call was booked at the reserve and a $0.05 delegate was
cut off after seven of them having spent nothing (D33/D40).

action:"status" reports this session's spend and cap next to the balance —
the question an agent on an allotment is actually asking.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…booleans, and every give-up is classified by fact, not by prose

0.50.0's `paidRequestInFlight` flag was copied into each media tool and every
copy was wrong in its own way: speech, realface and image cleared it in a
`.finally()` that runs BEFORE the rejection reaches the catch, so the booking
branch was dead code the rail-parity matrix greenlit; speech set it on Base
only and image's Base rail never set it; realface's was module-global, so one
call's outstanding payment booked a phantom charge against another call's
failure; image's Solana branch armed it before the unpaid 402 probe, so a
probe timeout booked a whole render. utils/in-flight.ts is the replacement:
armed only when a signed (or Bearer) request is about to leave, settled by any
response, read by the catch through settleGiveUp.

The async tools' catches now classify in one fixed order on all three rails —
settlement observed and booked; billed at submit (BilledJobError); the wallet
refused (PaymentError, by type); a paid request outstanding (the tracker); the
gateway said the job failed (JobFailedError); a labelled 5xx; a timeout with
nothing outstanding. Each step is something the code observed. Before, MiniMax's
"The operation was aborted due to timeout" in a not-charged failure booked a
render (C13/C37), a moderation "Your request was rejected" opened a Coinbase
top-up page (C36), and the Solana rail booked the estimate on ANY timeout
including the unpaid quote probe (C32).

- solana-402: the helper reads payment_status "settled_optimistic" off the
  submit answer and turns every later failure on that route into a certain
  charge — music's Solana route settles at POST, and "No payment was taken"
  was a lie there. onPaidRequest/onPaidResponse hooks give the tracker exact
  edges; a poll answered "in_progress" no longer books at the deadline.
  Settle-failure reasons are split by remedy: only "insufficient" is the
  wallet, "expired"/"invalid signature" are the authorization (D2). A missing
  Solana key says whether the keychain is locked or the wallet is absent
  (D3/D30). Messages name their caller — music no longer says "re-running
  blockrun_video" (D1/D22).
- music: the Solana submit timeout is the 95s the audio route needs (shared
  MUSIC_SUBMIT_TIMEOUT_MS), not the 30s video default that aborted every track
  slower than 30s after the money had moved (C15).
- image: no `quality` parameter — the gateway 400s every value for every
  listed model, before the 402, so the zod default of "standard" was 400ing
  every Base generate; the account rail polls the 202 + poll_url the
  30s-inline window hands back instead of reporting "No image URL"
  (a regression of #140); Base books the observed charge, not the reserve.
- realface: action:"list" asks the ACTIVE chain's gateway with the active
  chain's address (sol.blockrun.ai wants base58), reading the Solana key
  rather than minting an EVM one; the account rail books the settled cost
  header instead of a 20%-high estimate.
- speech: the quote guard (assertQuoteNearEstimate) reaches the Solana rail.
- video/music: a completed job whose payload had no URL says the charge
  stands and names the claimable job, instead of "failed" with model advice
  that invites paying again (D13).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… answered, and three pre-payment guards that were letting paid mistakes through

The seven path-based tools (search, exa, markets, rpc, defi, phone, modal)
each did `formatError(extractErrorMessage(err))` and nothing else, so a call
whose payment left and whose origin never answered booked $0 and read "try
again in a few minutes" (C38). utils/path-tool-catch.ts books the reserve when
settlementOnThrow says "unknown" AND the status is an edge 502/504/52x or a
transport failure AND the gateway attached no uncharged marker — narrower than
chat's rule on purpose, because the search route's bare 500 is pre-settle and
booking it would invent $0.26 of phantom spend per Grok blip.

- path-safety: decode per segment, never the whole string. One malformed `%`
  after `?`/`#` threw decodeURIComponent, the fallback kept the literal
  `%2e%2e`, and the guard said "not `..`" while the URL parser resolved it
  natively: `blockrun_modal({path:"%2e%2e/phone/numbers/buy#%"})` priced as a
  $0.003 modal op and POSTed the $5.001 number purchase; the mirror image
  bought a $192 H100 sandbox through blockrun_phone's namespace pin. The
  query/fragment cut also failed across a line terminator (`buy?\n`).
- modal: the gateway trims `gpu` before pricing and the estimator did not, so
  `" H100 "` quoted $8.001 against a $0.102 reserve (and $192 against $2.40
  at 24h) — past a cap and the confirm dialog. The body is trimmed the same
  way before estimating and sending, and a gpu outside the five tiers is
  refused with the list instead of an opaque 400.
- search: `sources` is checked against the gateway's live enum. X/Twitter was
  removed upstream on 2026-07-05 and the description advertised
  ["web","x","news"] for two months; following it reserved $0.26, sat through
  the confirm dialog and came back "Invalid request body".
- markets: a `?` or `#` in `path` is refused before payment — a query string
  in the path bypassed every params-based pre-payment rule and, on the account
  rail, concatenated into `…?limit=5?status=open`. The retired markets/listings
  text says 404-before-payment, which is what it does now; the bare
  `kalshi/markets/KXBTC…` example that 404'd on segment count is gone.
- exa/defi: the Base-only replay-nonce reading of "Payment was rejected" is
  appended, so a 30-second upstream blip stops telling a $50 wallet to top up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e way a free link could have been signed for money

- An env key (BLOCKRUN_WALLET_KEY, and the SDK's own BASE_CHAIN_WALLET_KEY,
  which the gate never read) is a signer override, not a wallet this machine
  owns: it is no longer mirrored into the keychain with -U, and under strict
  mode the read-back no longer deletes a .session still holding the FUNDED
  key. persistKey refuses to remove a file whose key is not the one it just
  verified.
- Two MCP servers on a machine that has never held a key (Claude Code,
  Cursor and Desktop are commonly all installed `-s user`) could both find
  every store empty, both mint, and both write; the last writer won and USDC
  sent to the loser's address was unrecoverable once that process exited.
  The mint is published exclusively (hard link, `wx` fallback) and the loser
  adopts the winner's key — on both chains.
- The chain selector's keychain probes collapsed a read ERROR (locked
  keychain, unlock-dialog timeout) into "absent" and memoised it for the
  process, so a funded Base-only user under strict mode was routed to Solana
  and told to run setup. "unknown" is never memoised and never a Solana
  signal; the Base path then fails loudly with its unlock message.
- A missing `secret-tool` binary (fresh Ubuntu/Debian/WSL/Docker) reached the
  Linux branch as `{status: null, error: ENOENT}` and was reported as a
  keychain ERROR, so no wallet could be created on either chain with a message
  blaming BLOCKRUN_KEYCHAIN=strict on a machine that never set it. A keychain
  that does not exist is "unavailable", never a fault.
- The onramp's $0 x402 quote was a comment and a test fixture; the code
  signed whatever the 402 said. It now refuses anything but a literal zero.
- The strict-mode refusal describes the state it found (an empty file, a
  missing file) instead of a mode the user may never have set, and says a
  backup restore is a fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e confirm is held to the preview, and the spend dialog reaches the trades

- executeTrade: a submit that threw with no 4xx behind it (dropped socket,
  client timeout, relay 502/504) released the reservation and rendered as a
  plain error, steering the agent into a second real order while the session
  cap saw one. The notional now stays booked as unconfirmed, the message says
  what to check before retrying, and the balance-cache retry never re-submits
  on top of a possibly-live order. The order card keeps its lock across
  Re-quote and tells the model.
- fund: the signed EIP-3009 authorization stays executable for 300s, and a
  lost response, 5xx or success:false read as "Funding failed" — the retry
  signed a second full transfer. A definite 4xx clears; anything else arms a
  pendingFund guard that refuses to re-sign until the window has passed.
  The $0.01 gateway fee is reserved and booked against the x402 budget like
  any paid call.
- Market orders: the preview records its worst fill per (token, side) and a
  bare confirm:true is held to it — the documented preview → quote → confirm
  flow never carried max_fill_price forward, so "signed at the worst fill you
  saw" only held inside one call.
- BLOCKRUN_CONFIRM_SPEND=on now asks the human before buy/sell/fund/withdraw
  sign, with the real notional; a $0.004 rpc call got the dialog while a $25
  bet did not. confirm:true stays the model-supplied floor.
- setup: a blocked egress printed "❌ Region: BLOCKED" and, two lines later,
  "🎯 Ready to trade" with ready:true — the field an agent keys on before
  funding. ready now requires the region check to pass.
- relayer: the SDK's console.log progress lines went to stdout, the JSON-RPC
  channel, mid-money-operation; they are routed to stderr for the duration
  of every SDK call. Its private axios instance now gets the underscore-header
  bridge and POLYMARKET_CLOB_PROXY agent, so a relayed setup actually relays.
- One isDefiniteRejection for the CLOB submit and the relayer batch, so the
  two money paths cannot drift on the question again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nothing in scripts/ moves money by being run

- publish.yml refuses a package.json version BELOW npm latest (npm publish
  does not compare semver — a typo'd 0.5.1 would have become `latest` and
  downgraded every npx user), and the tag/release step is gated on the tag
  being ABSENT rather than on `pkg != npm`: a job that went red after the npm
  publish could never be repaired by a re-run, because the re-run found npm
  == pkg and skipped the step green.
- brand-sync.yml: the direct push always fails on a protected main and
  GITHUB_TOKEN cannot open PRs here, and `|| echo` swallowed that too — the
  run went green having landed nothing, and a stale count sat in the README
  for 26 days behind a green badge. A drift the job cannot land is now a red
  run, from one fixed force-pushed branch.
- verify:prices exits 2 when it could not look (too many `?` rows, an
  unreadable catalogue) instead of 0 — the day the gateway renames a header
  field, every row prints `?` and the release gate must not go green. The
  account rail's public pricing sheet is swept as a third catalogue (its
  other routes cannot be quoted without a key that would be charged, and the
  header says so). A probe row the CLIENT refuses (seedance-2.5@1080p) is
  marked as expecting an unpaid 4xx, now that blockrun PR #353 has deployed
  and the gateway refuses it too.
- The live Polymarket e2e scripts require --confirm (or
  POLYMARKET_E2E_CONFIRM=1) before signing anything; every script installs
  the redacted exit handler so a thrown RPC error cannot print the wallet
  address the header promises to hide; the read-only preflight reports an
  outage as an error instead of `positions: []`.
- smoke-speech prints the charge it computes from the estimator instead of a
  typed figure that understated it.
- sync-brand-numbers.mjs documents why this copy is ahead of the source, so
  the next "copy the source over the consumer" remediation does not erase
  the render guard again (#84, #128).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
VickyXAI and others added 9 commits September 15, 2026 06:45
…e suite, and stop tracking a node_modules symlink

- test/_setup.ts is preloaded into every test child (`--import`): ten
  mocked-handler suites never mocked utils/auth.js, and on a machine with
  ~/.blockrun/.api-key they ran their Base-rail assertions on the account
  rail. test/harness-pin.test.ts asserts the pin took effect.
- A `node_modules` symlink pointing at itself was tracked in git and shipped
  in every clone; `git rm --cached` and a .gitignore that matches the symlink
  as well as the directory. test/repo-hygiene.test.ts keeps it out.
- VERSION (0.41.1) was nine minors behind package.json; the two must agree,
  and test/version-file.test.ts pins them.
- doc-file-refs scans every tracked markdown file and every relative link,
  not a hand-kept list of three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… less

Description edits in this round moved every profile; the schema-tokens test
pins the README rows, the overhead doc and the SVG cards to the live
measurement (full 13,006 · media 5,790 · trading 5,411 · research 2,752 ·
chat 2,079).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…it escape is billed, and speech says when the charge stands

Audit round 4, on round 3's in-flight tracker. A helper wrapped in sendPaid
can inspect the response and THROW on it, so settle() was never reached and
the catch classified the error by its prose — isTimeoutError's substring
match — and image's account rail booked a whole render for a not_charged job
whose upstream text read "aborted due to timeout", telling the user the
charge MAY have gone through (the C13 shape, on the rail whose sibling tools
had just documented why it must not happen).

- in-flight: the verdict comes off the error, never its message. A numeric
  status (the SDK's APIError, AccountApiError, the Anthropic SDK), a typed
  job verdict (JobFailedError / BilledJobError) or the gateway's own
  uncharged marker is an ANSWER. isExplicitlyUncharged moves to a leaf
  module (utils/uncharged.ts) so in-flight stays linkable under the suites
  that mock utils/auth.js.
- api-key-call: a not_charged terminal failure is a JobFailedError, and a
  202 with no poll_url is the BilledJobError the callers' comments claimed
  it already was.
- image: the account rail is not wrapped — video and music never were.
- image, speech, realface: the Solana tracker is armed at the helper's
  onPaidRequest and settled at onPaidResponse, not at onQuote (a signing
  failure no longer reads as "may have settled"), and solanaPaidPost puts
  the status on the error it throws for a non-2xx answer — realface reads
  it instead of a regex that turned any "402" in a quote fault into "out of
  funds" plus a top-up page.
- solana-402: on a settled-at-submit route EVERY post-submit escape is a
  BilledJobError — the reactive re-sign path could still throw "No charge
  was made" or a bare RPC error for a track the gateway had settled at POST.
- speech: the charge-stands step video and music got in round 3 — a
  settled 200 whose body is unusable says so, on all three rails, instead
  of "failed" plus retry advice that pays again.
- rail-parity: a new cell — the paid request ANSWERED 5xx with "timeout" in
  the body — on Solana and the account rail, for every manual-402 tool.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ds on that agent's ledger, and the fund fee reaches the ledger at all

revoke → (the in-flight call settles) → delegate was still a per-agent
refill: recordSpending looked the id up in the live map, found nothing,
credited the global ledger only, and the next delegate carried a ledger that
had forgotten the call. The tombstone is now credited too, and
action:"report" lists revoked agents with their kept spend — the description
promised "its spend is kept", and a tombstone nobody could read was not kept.

mcp-handler never passed the ledger to registerPolymarketTool, so fund's
$0.01 gateway fee was reserved and booked only in the unit test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t.key ranks below the keychain, and a keychain replacement is announced

- Linux: `secret-tool lookup` exits 1 for a MISS and for a FAULT alike
  (libsecret returns 1 whether value == NULL or error != NULL); only the
  fault prints why. Round 3's tri-state read was macOS-only — a locked
  collection, a missing D-Bus session, a dismissed unlock prompt all read
  as "absent", and under strict mode both provisioners minted over the
  funded keychain wallet. A silent exit 1 is a miss; one that said
  something is an error, with the tool's own reason in the detail.
- The SDK loader reads .session THEN the legacy wallet.key, and the EVM gate
  asked the loader — so once strict mode retired .session, a stale legacy
  file outranked the keychain and its key was stored over the funded one
  with -U. .session stays the rotation seam; wallet.key is consulted only
  after the keychain says "absent".
- getChain()'s Base-wallet guard treated an unreadable .session as "no Base
  wallet" while ensureEvmWallet treated the same file as present; it now
  counts as present, and the Base path fails loudly on it.
- publishMintedKey: an empty placeholder is CLAIMED (renamed aside, then the
  exclusive link retried) rather than replaced by a plain rename two losers
  could both make; the published file is read back and is what the process
  signs with.
- persistKey announces on stderr, with the address, when it replaces a
  DIFFERENT key in the keychain — the mirror follows the file by design, but
  a rotation nobody meant is now visible the run it happens.
- getSolanaUsdcBalance honours SOLANA_RPC_API_KEY as x-api-key, the SDK's
  other spelling for a keyed private RPC.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he counted amount

Both wallet clients record the settlement on the paid retry's 2xx and only
then read the body, so a non-JSON 200 surfaces as a bare SyntaxError — no
status, no transport words, "none" to settlementOnThrow — for a call the
SDK's own counter says was paid. rawGet/rawPost read the counter around the
call and hand the delta to the catch as RawCallSettledError; pathToolFailure
books it as a certain charge and says the charge stands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…and the version gate refuses it

`npm view ... || echo none` spelled every failure as "none", which both the
gate and the npm step's `pkg != npm` accept — so a registry blip on a run
whose package.json was BELOW latest, the exact case the gate was written
for, would have published and downgraded `latest`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…w and the project headers said there was

The gateway removed it on 2026-07-05 and blockrun_search refuses it before
payment since round 3; the search skill still taught `sources: ["x"]` with a
worked example and "defaults to all three".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@VickyXAI
VickyXAI merged commit f9480ad into main Sep 15, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant