Skip to content

fix(core): a provider wallet could take over x402 payment signing - #1

Merged
VickyXAI merged 2 commits into
masterfrom
fix/canonical-wallet-selection-core
Aug 4, 2026
Merged

fix(core): a provider wallet could take over x402 payment signing#1
VickyXAI merged 2 commits into
masterfrom
fix/canonical-wallet-selection-core

Conversation

@VickyXAI

@VickyXAI VickyXAI commented Aug 4, 2026

Copy link
Copy Markdown

Summary

@blockrun/core@0.0.3 lets 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.json files before the canonical ~/.blockrun/.session, returning the most recently modified one:

const scanned = scanWallets();
if (scanned.length > 0) return { privateKey: normalize(scanned[0].privateKey), source: "provider" };
// ...only then .session, then legacy

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 across api, pay, chat, run, image, video, music, speech, and the data commands.

scanWallets() compounded it by reporting each file's self-declared address field instead of deriving it, so the takeover was invisible in the one command a confused user would reach for.

Demonstration

~/.blockrun/.session holds the user's real wallet 0xf39Fd…92266. A planted ~/.attacker/wallet.json holds a different key (really 0x7099…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's address field is no longer trusted anywhere in the package.
  • WalletSource drops "provider" — unreachable after this change. Breaking for callers narrowing on it.
  • New listDiscoveredWallets() (no private keys) and adoptWallet(address). Adoption matches on the derived address and backs up the outgoing .session first, so funds are never stranded.

cli

  • New blockrun wallet list and blockrun wallet adopt <address>.
  • wallet recover orders by true resolution priority, marks exactly one entry active, and shows discovered wallets as inactive with their file path. meta.active previously named a wallet resolution would not use.

ci

  • The smoke test packed only the CLI, so npm resolved @blockrun/core from 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 with ETARGET until it had already shipped. (This branch hit exactly that on its first run.) Both packages are now packed and installed as roots; blockrun --json version reports core: 0.1.0 from the tarball.
  • The step now asserts the security property against the packed artifact users actually install. Confirmed to fail against the pre-fix build.

Validation

  • pnpm build, pnpm typecheck — clean.
  • pnpm test49/49 pass (was 39; +10 new, and one pre-existing test that asserted the vulnerable behavior is inverted).
  • Mutation-verified, both halves. Restoring provider-first resolution turns 4 tests red; trusting the file's address field turns 3 red, including adoptWallet refuses an address no discovered key controls.
  • End-to-end against a built CLI in an isolated BLOCKRUN_HOME, shown above.
  • CI green on Node 20/22/24, with the new smoke assertions confirmed executing in the job logs (not passing vacuously — an earlier draft of the assertion silently captured only stdout, while 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.0 unblocks @blockrun/llm delegating 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.

1bcMax 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.
@VickyXAI
VickyXAI merged commit 0deb61f into master Aug 4, 2026
3 checks passed
@VickyXAI
VickyXAI deleted the fix/canonical-wallet-selection-core branch August 4, 2026 04:53
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