Skip to content

fix(tools): stop telling the user USDC moved when it was account credits - #163

Merged
VickyXAI merged 1 commit into
mainfrom
fix/key-mode-billing-copy
Sep 5, 2026
Merged

fix(tools): stop telling the user USDC moved when it was account credits#163
VickyXAI merged 1 commit into
mainfrom
fix/key-mode-billing-copy

Conversation

@VickyXAI

@VickyXAI VickyXAI commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #162 (merged). Verified against main, not against the closed #156.

The problem

Six paid tool files have zero key-mode awareness and state the wrong payment instrument in 38 places.

file sites isKeyMode refs before
phone.ts 15 0
prediction.ts 9 0
videogen.ts 5 0
imagegen.ts 4 0
voice.ts 4 0
musicgen.ts 3 0
modal.ts, blockrun.ts 2
PredictionMarket  →  _$0.005 paid via x402._                  (signed nothing)
BuyPhoneNumber    →  ## Number provisioned ($5 USDC charged)  (wallet never moved)

The worst of the set are the approval prompts in ImageGen / VideoGen / phone. The human is asked to approve a spend in a currency the session does not use, and the model reads the same wrong sentence when it reasons about what it can still afford.

Before / after

[wallet mode]                                    [key mode]
## Number provisioned ($5.00 USDC charged)       ## Number provisioned (billed to account
                                                    credits — see Activity at user.blockrun.ai)
Buy a new US number for $5.00?                   Buy a new US number for $5.00?
  No USDC is spent if you cancel.                  No account credits are spent if you cancel.
_$0.0050 paid via x402._                         _Billed to account credits._

The split that matters

Runtime output (receipts, prompts, cancellations) calls the new src/payments/billing-copy.ts helpers, which read the mode at call time. invalidateKey() demotes a session mid-run after a 401 — a helper that captured the mode at import would keep claiming account credits while Franklin signed from the wallet again. There is a test for exactly that.

Static spec.description is built once at module load, before any mode is settled, and cannot re-render. Branching there would freeze whichever mode resolved first, so those strings are reworded to be true in both: Costs $0.001 per call, not Costs $0.001 USDC from the wallet. Likewise wallet-owned phone numberBlockRun-provisioned.

Key mode deliberately states no dollar amount on a receipt. The local figure is a catalog estimate; the account ledger is authoritative, so it points at Activity rather than inventing precision.

Verification

A regression fence scans every paid tool for an unconditional payment-rail string and names the offending file:line, so the next tool cannot reintroduce the class.

Mutation-tested — each fails a named test:

mutation result
revert one phone.ts call site fails the regression fence
make chargedNote ignore the mode 2 fail
memoise the mode at import 2 fail

730 local tests pass.

Credit

phone.ts had 5 of these fixed in #156 before it was closed as superseded; that approach is carried forward here and extended to the other six files.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Rm7cRGtC7wCofhYuHRo21h

Six paid tool files had zero key-mode awareness and printed the wrong payment
instrument in 38 places. PredictionMarket closed every result with
`_$0.005 paid via x402._` on a call that signed nothing. BuyPhoneNumber
reported `$5 USDC charged` against a wallet that never moved. Worst of the set,
the ImageGen / VideoGen / phone approval prompts asked the user to approve a
spend in a currency the session does not use — the human says yes to the wrong
sentence, and the model reads the same wrong sentence when it reasons about
what it can still afford.

New src/payments/billing-copy.ts owns the wording. The split matters:

  Runtime output — receipts, approval prompts, cancellations — calls the
  helpers, which read the mode at call time. invalidateKey() demotes a session
  mid-run after a 401, so a helper that captured the mode at import would keep
  claiming account credits while Franklin signed from the wallet again. There
  is a test for exactly that.

  Static spec.description text is built once at module load, before any mode
  is settled, and cannot re-render. Branching there would freeze whichever
  mode happened to be resolved first, so those strings are reworded to be true
  in both: "Costs $0.001 per call", not "Costs $0.001 USDC from the wallet".
  Same for "wallet-owned phone number" -> "BlockRun-provisioned".

Key mode deliberately states no dollar amount on a receipt. The local figure is
a catalog estimate; the account ledger is authoritative, so the receipt points
at Activity rather than inventing precision it does not have.

A regression fence scans every paid tool for an unconditional payment-rail
string and names the offending file:line, so the next tool cannot reintroduce
the class.

Mutation-tested: reverting one call site, ignoring the mode in the helper, and
memoising the mode at import each fail a named test. 730 local tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rm7cRGtC7wCofhYuHRo21h
@VickyXAI
VickyXAI merged commit 67dfaf9 into main Sep 5, 2026
6 checks passed
@VickyXAI
VickyXAI deleted the fix/key-mode-billing-copy branch September 5, 2026 22:12
VickyXAI added a commit that referenced this pull request Sep 5, 2026
main went red on `the mode is read at call time, not captured at import`.
The code is right; the test was wrong.

#163 landed before #158, when invalidateKey() demoted a session to wallet
mode after a 401, so the test used it to prove the billing copy re-reads the
mode. #158 changed that on purpose: a rejected key now only refreshes the
credential, because an account failure is never permission to spend from a
wallet.

The property still holds — useWalletMode() (`--wallet`) is the lever that
actually flips the mode. The test now uses it, and additionally asserts that
invalidateKey() does NOT move billing to the wallet, so it cannot drift back.

Both directions are mutation-tested: freezing the mode at import fails it, and
restoring invalidateKey()'s old demotion fails it too.


Claude-Session: https://claude.ai/code/session_01Rm7cRGtC7wCofhYuHRo21h

Co-authored-by: 1bcMax <195689928+1bcMax@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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