Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `~/.<app>/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)
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<private-key>`.

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
15 changes: 15 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

169 changes: 41 additions & 128 deletions src/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}

/**
Expand All @@ -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();
}

/**
Expand All @@ -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;
}

/**
Expand All @@ -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;
}

/**
Expand All @@ -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");
Expand Down Expand Up @@ -270,23 +206,13 @@ BLOCKRUN_WALLET_KEY=<private-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
Expand All @@ -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;
}

/**
Expand Down
16 changes: 12 additions & 4 deletions test/wallet-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import("node:os")>("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 () => {
Expand All @@ -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 });
}
Expand Down
Loading