fix(core): a provider wallet could take over x402 payment signing - #1
Merged
Conversation
added 2 commits
August 3, 2026 23:41
resolveFromFiles() consulted ~/.<app>/wallet.json before the canonical
~/.blockrun/.session and returned the most recently modified one. Installing
another product — or writing one file into the home directory — changed which
key resolvePrivateKey() handed to payment signing, which is every paid path in
the CLI: api, pay, chat, run, image, video, music, speech, and the data
commands.
scanWallets() compounded it by reporting each file's self-declared "address"
field rather than deriving it. A planted file could therefore name the user's
own address while holding a different key, so `blockrun wallet recover` printed
the real address twice and gave no indication that the active signer had
changed. Demonstrated against the pre-fix build:
$ blockrun --json wallet
{"address":"0x7099…79C8","source":"provider"} <- attacker's key, not the user's
$ blockrun --json wallet recover
[{"source":"provider wallet.json","address":"0xf39Fd…92266"}, <- claimed, not derived
{"source":"session","address":"0xf39Fd…92266"}]
This is the defect blockrun-llm-ts#14 fixed on 2026-07-19. Core kept the
pre-fix behavior for three weeks while its header comment claimed to mirror the
SDK; its README already documented the correct order, so the contract was right
and only the implementation was wrong.
- resolveFromFiles() reads .session -> legacy only; discovered wallets never
participate in automatic resolution.
- scanWallets() derives every address from the discovered key and drops entries
whose key is missing or unusable. The file's "address" field is no longer
trusted anywhere in the package.
- WalletSource drops "provider", which is now unreachable. Breaking for callers
narrowing on it.
- New listDiscoveredWallets() and adoptWallet(address) give the deliberate
migration path the SDK gained in 3.8.0. Adoption matches on the derived
address and backs up the outgoing .session first.
- CLI gains `wallet list` and `wallet adopt <address>`; `wallet recover` now
orders by true resolution priority and marks exactly one entry active.
Both halves are mutation-verified: restoring provider-first resolution turns 4
tests red, and trusting the file's address field turns 3 red, including the one
proving a planted address cannot be adopted. 49/49 pass, typecheck clean.
The smoke test packed only the CLI, so npm resolved @blockrun/core from the
registry. Two consequences: the step never exercised core changes at all —
which is how core drifted three weeks behind the SDK without CI noticing — and
any core version bump failed with ETARGET until it had already been published,
making the fix in this branch unmergeable on its own.
Pack both packages and install them as roots in the same global prefix, so the
CLI resolves the core built from this commit. `blockrun --json version` now
reports core 0.1.0 from the tarball rather than 0.0.3 from npm.
The step also asserts the property this branch fixes, against the packed
artifact users actually install: a provider wallet.json must not displace
.session, and an address no discovered key controls must not be adoptable.
Confirmed to fail against the pre-fix build — the vulnerable CLI reports
{"address":"0x7099…79C8","source":"provider"} for that fixture.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
@blockrun/core@0.0.3lets another application take over x402 payment signing. This ports the fix from blockrun-llm-ts#14 into core, adds the deliberate-adoption path the SDK gained in 3.8.0, and updates the CLI to match.Root cause
resolveFromFiles()consulted~/.<app>/wallet.jsonfiles before the canonical~/.blockrun/.session, returning the most recently modified one:resolvePrivateKey()is what signs payments —commands/x402.ts:91,chat.ts:21,media.ts:34,data.ts:12,sdk.ts:22. So installing another product, or writing one file into the home directory, silently redirects real spending acrossapi,pay,chat,run,image,video,music,speech, and the data commands.scanWallets()compounded it by reporting each file's self-declaredaddressfield instead of deriving it, so the takeover was invisible in the one command a confused user would reach for.Demonstration
~/.blockrun/.sessionholds the user's real wallet0xf39Fd…92266. A planted~/.attacker/wallet.jsonholds a different key (really0x7099…79C8) but declares"address": "0xf39Fd…92266".Before (0.0.3):
$ blockrun --json wallet {"address":"0x70997970C51812dc3A010C7d01b50e0d17dc79C8","source":"provider"} // attacker's key is active $ blockrun --json wallet recover [{"source":"provider wallet.json","address":"0xf39Fd…92266"}, // claimed, not derived {"source":"session","address":"0xf39Fd…92266"}] // meta.active = "provider wallet.json"The user sees their own address twice and has no way to tell that the signing key changed.
After:
$ blockrun --json wallet {"address":"0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266","source":"session"} // unchanged $ blockrun --json wallet list [{"address":"0x70997970C51812dc3A010C7d01b50e0d17dc79C8", "source":"…/.attacker/wallet.json","active":false}] $ blockrun --json wallet adopt 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 {"ok":false,"error":{"code":404,"message":"No discovered wallet controls 0xf39Fd…92266. Available: 0x7099…79C8"}}Changes
core (0.0.3 → 0.1.0)
resolveFromFiles()reads.session→ legacy only. Discovered wallets never participate in automatic resolution.scanWallets()derives every address from the discovered key and drops entries whose key is missing or unusable. The file'saddressfield is no longer trusted anywhere in the package.WalletSourcedrops"provider"— unreachable after this change. Breaking for callers narrowing on it.listDiscoveredWallets()(no private keys) andadoptWallet(address). Adoption matches on the derived address and backs up the outgoing.sessionfirst, so funds are never stranded.cli
blockrun wallet listandblockrun wallet adopt <address>.wallet recoverorders by true resolution priority, marks exactly one entryactive, and shows discovered wallets as inactive with their file path.meta.activepreviously named a wallet resolution would not use.ci
@blockrun/corefrom the registry. It was verifying the last published core, not the code under review — which is how core drifted three weeks behind the SDK unnoticed — and any core version bump failed the step withETARGETuntil it had already shipped. (This branch hit exactly that on its first run.) Both packages are now packed and installed as roots;blockrun --json versionreportscore: 0.1.0from the tarball.Validation
pnpm build,pnpm typecheck— clean.pnpm test— 49/49 pass (was 39; +10 new, and one pre-existing test that asserted the vulnerable behavior is inverted).addressfield turns 3 red, includingadoptWallet refuses an address no discovered key controls.BLOCKRUN_HOME, shown above.err()writes to stderr; fixed and re-verified).Note on the deleted test
"provider wallet.json is scanned and wins over .session (source=provider)"encoded the vulnerability as intended behavior. It is replaced by two tests asserting the opposite.Follow-up
Publishing
@blockrun/core@0.1.0unblocks@blockrun/llmdelegating its wallet layer to core — the goal of blockrun-llm-ts#13, which was closed because core could not yet absorb the SDK's 3.8.0 surface.