diff --git a/CHANGELOG.md b/CHANGELOG.md index fff2666..57d5fe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to @blockrun/llm will be documented in this file. +## [Unreleased] + +### Changed + +- **Wallet resolution now comes from `@blockrun/core`, so every BlockRun product reads the same wallet by construction.** `scanWallets`, `listDiscoveredWallets`, `importWallet`, `loadWallet`, `getOrCreateWallet`, and `getWalletAddress` delegate to the shared kernel instead of re-implementing the canonical order (`env → ~/.blockrun/.session → legacy wallet.key`) in this package. That duplication is what let the two drift: this SDK fixed provider-wallet takeover in 3.7.1 (#14) while core kept resolving provider `wallet.json` files first until `@blockrun/core@0.1.0`, so the `blockrun` CLI signed x402 payments with whatever key a planted `~/./wallet.json` supplied. Public signatures are unchanged and all ten canonical-selection regression tests pass against the delegated implementation — including the ones proving a discovered wallet is never adopted automatically and that a file cannot claim an address it holds no key for. `src/wallet.ts` drops 128 lines of duplicated logic. +- **`BLOCKRUN_HOME` now overrides the wallet directory.** Path resolution comes from core, which supports this for test isolation and power users. Previously this module always used the OS home directory. Unset, behaviour is identical. + +### Notes + +- Requires `@blockrun/core@^0.1.0`. Earlier versions carry the provider-takeover defect described above and must not be used. +- Solana wallet resolution is still SDK-local — core has no Solana key store yet. + ## [3.12.0] - 2026-08-10 ### Added — Router Core V3 is bundled into both chain clients (PR #25, reviewed and hardened) diff --git a/CLAUDE.md b/CLAUDE.md index 7904536..d7bf459 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,6 +48,8 @@ src/ ## Key dependencies - `@blockrun/router-core` — bundled, product-neutral smart model routing +- `@blockrun/core` — Shared kernel; owns Base wallet resolution, discovery, and adoption + so this SDK, the `blockrun` CLI, and clawrouter-codex read the same wallet - `viem` — Ethereum interaction - `bs58` — Base58 encoding (Solana) - Optional: `@anthropic-ai/sdk`, `@solana/web3.js`, `@solana/spl-token` diff --git a/README.md b/README.md index 6e617c3..a661672 100644 --- a/README.md +++ b/README.md @@ -1536,6 +1536,18 @@ matches on that derived address — so a wallet file cannot claim an address it cannot sign for, nor be adopted by one. `listDiscoveredWallets()` never returns private keys. +### One wallet across every BlockRun product + +Base wallet resolution, discovery, and adoption are implemented in +[`@blockrun/core`](https://www.npmjs.com/package/@blockrun/core), the shared kernel +this SDK, the `blockrun` CLI, and clawrouter-codex all read. Defining the canonical +order in one place is what keeps them in agreement — when each product carried its +own copy, they drifted, and a fix made here did not reach the CLI. + +Requires `@blockrun/core@^0.1.0`. Set `BLOCKRUN_HOME` to override the base directory +(`~` by default) for test isolation; unset, behaviour is unchanged. Solana resolution +is still SDK-local. + For a single run without changing anything, use `export BLOCKRUN_WALLET_KEY=`. diff --git a/package.json b/package.json index 0a7021c..f9df620 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "url": "https://github.com/BlockRunAI/blockrun-llm-ts/issues" }, "dependencies": { + "@blockrun/core": "^0.1.0", "bs58": "^6.0.0", "viem": "^2.49.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f392de..faf9aaf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,6 +13,9 @@ importers: .: dependencies: + '@blockrun/core': + specifier: ^0.1.0 + version: 0.1.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@3.25.76) '@solana/spl-token': specifier: ^0.4.14 version: 0.4.14(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@6.0.6) @@ -67,6 +70,9 @@ packages: resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} + '@blockrun/core@0.1.1': + resolution: {integrity: sha512-mXeF/XCFc9z+2Mxq4zGrhrFkEjpYprnuHOp20naE6YhouBu2SwHuaWaYec4gb7yJ+uU8S7aqUf6UIXkD5xDTvA==} + '@blockrun/router-core@https://codeload.github.com/BlockRunAI/router-core/tar.gz/d4308049348e11e17ed08a254676a34949be80f9': resolution: {tarball: https://codeload.github.com/BlockRunAI/router-core/tar.gz/d4308049348e11e17ed08a254676a34949be80f9} version: 0.1.0 @@ -1960,6 +1966,15 @@ snapshots: '@babel/runtime@7.29.2': {} + '@blockrun/core@0.1.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@3.25.76)': + dependencies: + viem: 2.49.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@3.25.76) + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + - zod + '@blockrun/router-core@https://codeload.github.com/BlockRunAI/router-core/tar.gz/d4308049348e11e17ed08a254676a34949be80f9': {} '@esbuild/aix-ppc64@0.21.5': diff --git a/src/wallet.ts b/src/wallet.ts index 36b26d9..99696ca 100644 --- a/src/wallet.ts +++ b/src/wallet.ts @@ -5,20 +5,36 @@ * - Auto-creates wallet if none exists * - Stores key securely at ~/.blockrun/.session * - Generates EIP-681 URIs for easy MetaMask funding + * + * Key resolution, discovery, and adoption are delegated to `@blockrun/core`, the + * shared kernel every BlockRun product reads. That is what guarantees this SDK, + * the `blockrun` CLI, and clawrouter-codex all resolve the SAME wallet — the + * behaviour is defined in one place instead of being re-implemented per product + * and drifting. The funding/messaging surface below stays here because it is + * specific to this SDK. + * + * Because path resolution now comes from core, `BLOCKRUN_HOME` overrides the base + * directory (previously this module always used the OS home directory). */ import { privateKeyToAccount, generatePrivateKey } from "viem/accounts"; +import { + paths as corePaths, + resolveFromFiles, + resolvePrivateKey, + listDiscoveredWallets as coreListDiscoveredWallets, + scanWallets as coreScanWallets, + adoptWallet as coreAdoptWallet, +} from "@blockrun/core"; import * as fs from "fs"; -import * as path from "path"; -import * as os from "os"; // USDC on Base contract address export const USDC_BASE_CONTRACT = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; export const BASE_CHAIN_ID = "8453"; -// Wallet storage location -const WALLET_DIR = path.join(os.homedir(), ".blockrun"); -const WALLET_FILE = path.join(WALLET_DIR, ".session"); +// Wallet storage location — resolved by core so every product agrees on it. +const WALLET_DIR = corePaths().dir; +const WALLET_FILE = corePaths().session; export interface WalletInfo { privateKey: string; @@ -72,29 +88,7 @@ export function saveWallet(privateKey: string): string { * @returns Array of wallet objects with privateKey and address */ export function scanWallets(): Array<{ privateKey: string; address: string; source: string }> { - const home = os.homedir(); - const results: Array<{ mtime: number; privateKey: string; address: string; source: string }> = []; - - try { - const entries = fs.readdirSync(home, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.name.startsWith(".") || !entry.isDirectory()) continue; - const walletFile = path.join(home, entry.name, "wallet.json"); - if (!fs.existsSync(walletFile)) continue; - try { - const data = JSON.parse(fs.readFileSync(walletFile, "utf-8")); - const pk = data.privateKey || ""; - const addr = data.address || ""; - if (pk && addr) { - const mtime = fs.statSync(walletFile).mtimeMs; - results.push({ mtime, privateKey: pk, address: addr, source: walletFile }); - } - } catch { continue; } - } - } catch { /* ignore */ } - - results.sort((a, b) => b.mtime - a.mtime); - return results.map(({ privateKey, address, source }) => ({ privateKey, address, source })); + return coreScanWallets(); } /** @@ -109,18 +103,7 @@ export function scanWallets(): Array<{ privateKey: string; address: string; sour * @returns Discovered wallets as `{ address, source }`, most recent first */ export function listDiscoveredWallets(): Array<{ address: string; source: string }> { - const listed: Array<{ address: string; source: string }> = []; - for (const entry of scanWallets()) { - try { - listed.push({ - address: privateKeyToAccount(entry.privateKey as `0x${string}`).address, - source: entry.source, - }); - } catch { - continue; - } - } - return listed; + return coreListDiscoveredWallets(); } /** @@ -139,36 +122,7 @@ export function listDiscoveredWallets(): Array<{ address: string; source: string * @throws If no discovered wallet derives to that address */ export function importWallet(address: string): string { - const wanted = address.trim().toLowerCase(); - - for (const entry of scanWallets()) { - let derived: string; - try { - derived = privateKeyToAccount(entry.privateKey as `0x${string}`).address; - } catch { - continue; - } - - if (derived.toLowerCase() !== wanted) continue; - - // Preserve the outgoing wallet — it may hold funds. - if (fs.existsSync(WALLET_FILE)) { - const current = fs.readFileSync(WALLET_FILE, "utf-8").trim(); - if (current && current !== entry.privateKey) { - const backup = path.join(WALLET_DIR, `.session.backup-${Math.floor(Date.now() / 1000)}`); - fs.writeFileSync(backup, current, { mode: 0o600 }); - } - } - - saveWallet(entry.privateKey); - return derived; - } - - const available = listDiscoveredWallets().map((w) => w.address); - throw new Error( - `No discovered wallet controls ${address}. ` + - `Available: ${available.length ? available.join(", ") : "none"}` - ); + return coreAdoptWallet(address).address; } /** @@ -181,21 +135,10 @@ export function importWallet(address: string): string { * @returns Private key string or null if not found */ export function loadWallet(): string | null { - // The canonical BlockRun wallet always wins. Do not implicitly adopt a - // wallet discovered in another application's private storage. - if (fs.existsSync(WALLET_FILE)) { - const key = fs.readFileSync(WALLET_FILE, "utf-8").trim(); - if (key) return key; - } - - // Check legacy wallet.key - const legacyFile = path.join(WALLET_DIR, "wallet.key"); - if (fs.existsSync(legacyFile)) { - const key = fs.readFileSync(legacyFile, "utf-8").trim(); - if (key) return key; - } - - return null; + // The canonical BlockRun wallet always wins. core's resolveFromFiles() reads + // .session then legacy and never adopts a wallet discovered in another + // application's private storage. + return resolveFromFiles()?.privateKey ?? null; } /** @@ -215,22 +158,15 @@ export function loadWallet(): string | null { * @returns Formatted notice, or null if nothing was discovered */ export function formatWalletMigrationNotice(newAddress: string): string | null { - let discovered: Array<{ privateKey: string; address: string }>; + let addresses: string[]; try { - discovered = scanWallets(); + // core derives each address from the discovered key, so a planted file cannot + // name an address it has no key for and trick the user into importing it. + addresses = coreListDiscoveredWallets().map((w) => w.address); } catch { return null; } - const addresses: string[] = []; - for (const entry of discovered) { - try { - addresses.push(privateKeyToAccount(entry.privateKey as `0x${string}`).address); - } catch { - continue; - } - } - if (addresses.length === 0) return null; const found = addresses.map((addr) => ` ${addr}`).join("\n"); @@ -270,23 +206,13 @@ BLOCKRUN_WALLET_KEY= for a single run without changing anything. * @returns WalletInfo with address, privateKey, and isNew flag */ export function getOrCreateWallet(): WalletInfo { - // Check environment variable first - const envKey = - typeof process !== "undefined" && process.env - ? process.env.BLOCKRUN_WALLET_KEY || process.env.BASE_CHAIN_WALLET_KEY - : undefined; - - if (envKey) { - const account = privateKeyToAccount(envKey as `0x${string}`); - return { address: account.address, privateKey: envKey, isNew: false }; - } - - // Check the canonical BlockRun wallet. scanWallets() is exposed for an - // explicit migration flow only and must not affect automatic selection. - const fileKey = loadWallet(); - if (fileKey) { - const account = privateKeyToAccount(fileKey as `0x${string}`); - return { address: account.address, privateKey: fileKey, isNew: false }; + // core's canonical order: env (BLOCKRUN_WALLET_KEY|BASE_CHAIN_WALLET_KEY) → + // .session → legacy. Discovered provider wallets are deliberately excluded; + // scanWallets() is exposed for the explicit migration flow only. + const resolved = resolvePrivateKey(); + if (resolved) { + const account = privateKeyToAccount(resolved.privateKey); + return { address: account.address, privateKey: resolved.privateKey, isNew: false }; } // Create new wallet @@ -301,21 +227,8 @@ export function getOrCreateWallet(): WalletInfo { * @returns Wallet address or null if no wallet configured */ export function getWalletAddress(): string | null { - const envKey = - typeof process !== "undefined" && process.env - ? process.env.BLOCKRUN_WALLET_KEY || process.env.BASE_CHAIN_WALLET_KEY - : undefined; - - if (envKey) { - return privateKeyToAccount(envKey as `0x${string}`).address; - } - - const fileKey = loadWallet(); - if (fileKey) { - return privateKeyToAccount(fileKey as `0x${string}`).address; - } - - return null; + const resolved = resolvePrivateKey(); + return resolved ? privateKeyToAccount(resolved.privateKey).address : null; } /** diff --git a/test/wallet-selection.test.ts b/test/wallet-selection.test.ts index 041ace4..d2267c0 100644 --- a/test/wallet-selection.test.ts +++ b/test/wallet-selection.test.ts @@ -11,15 +11,19 @@ function temporaryHome(): string { return home; } +/** + * Base wallet resolution lives in @blockrun/core, which resolves its own paths and + * honours BLOCKRUN_HOME. Mocking `os.homedir()` only redirects this package's own + * module graph, so it would leave core reading the real home directory — point core + * at the fixture instead. + */ async function importWalletModule(home: string) { vi.resetModules(); - vi.doMock("os", async () => { - const actual = await vi.importActual("node:os"); - return { ...actual, homedir: () => home }; - }); + process.env.BLOCKRUN_HOME = home; return import("../src/wallet.js"); } +/** Solana resolution is still SDK-local, so it reads os.homedir() directly. */ async function importSolanaWalletModule(home: string) { vi.resetModules(); vi.doMock("os", async () => { @@ -29,8 +33,12 @@ async function importSolanaWalletModule(home: string) { return import("../src/solana-wallet.js"); } +const savedBlockrunHome = process.env.BLOCKRUN_HOME; + afterEach(() => { vi.doUnmock("os"); + if (savedBlockrunHome === undefined) delete process.env.BLOCKRUN_HOME; + else process.env.BLOCKRUN_HOME = savedBlockrunHome; while (temporaryHomes.length > 0) { fs.rmSync(temporaryHomes.pop()!, { recursive: true, force: true }); }