diff --git a/CHANGELOG.md b/CHANGELOG.md index 0091c65..0e792d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,25 @@ wallet" while account credits sat there. They read the credit balance now, and an ungated account or an unreachable gateway means no local ceiling rather than an invented one. +**A Solana zero is the SDK's error value, not a balance.** `getBalance()` +catches every transport error and returns 0, and an RPC that answers 200 with a +JSON-RPC error body reaches the same 0. The reservation layer could not tell an +empty wallet from a dead endpoint and chose the harsher reading, so an RPC blip +refused every sandbox call against a wallet that was fine. It now treats a +Solana zero as unknown and fails open — and does so by throwing, which also +stops ambiguous settlement holds from being pruned against a number that +reflects nothing (#140). + +**Franklin tells you when it stopped using your wallet.** Tightened wallet +selection in 3.35.6 meant an install with both `~/.blockrun/.solana-session` and +a legacy `solana-wallet.json` could quietly run on a different address than the +one holding the USDC. The SDK prints a migration notice only when it CREATES a +wallet, and that install creates nothing. `franklin doctor` now reports the +divergence with both public addresses, and `franklin wallet-adopt
` +switches deliberately — addresses are derived from the keys rather than trusted +from a file, no secret is printed, and the current session is backed up before +it is replaced (#119). + **The system prompt no longer promises a wallet that isn't there.** Key-mode sessions were briefed on a chain, an address and a USDC balance, and pointed at a Wallet tool that returns a portal link. The instruction cache is keyed on the diff --git a/package.json b/package.json index f8a0bfd..9b87849 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "desktop:package:win": "npm run dist:win --workspace @blockrun/franklin-desktop", "dev": "tsc --watch", "start": "node dist/index.js", - "test": "npm run build && node --test --test-concurrency=4 --test-reporter=spec test/local.mjs test/api-key.local.mjs test/serve-security.local.mjs test/skills.local.mjs test/repair.mjs test/model-resolver.mjs test/exa.local.mjs test/audit-batch.local.mjs test/polymarket.local.mjs test/market.local.mjs test/hooks.local.mjs test/scheduler.local.mjs test/trade-plan.local.mjs test/goal.local.mjs test/memory.local.mjs test/agent-host.local.mjs test/reservation.local.mjs", + "test": "npm run build && node --test --test-concurrency=4 --test-reporter=spec test/local.mjs test/api-key.local.mjs test/solana-migration.local.mjs test/serve-security.local.mjs test/skills.local.mjs test/repair.mjs test/model-resolver.mjs test/exa.local.mjs test/audit-batch.local.mjs test/polymarket.local.mjs test/market.local.mjs test/hooks.local.mjs test/scheduler.local.mjs test/trade-plan.local.mjs test/goal.local.mjs test/memory.local.mjs test/agent-host.local.mjs test/reservation.local.mjs", "test:e2e": "npm run build && node --test --test-reporter=spec test/e2e.mjs", "e2e:polymarket:readonly": "npm run build && node scripts/polymarket-e2e-readonly.mjs", "test:free-models": "npm run build && node --test --test-reporter=spec test/free-model-matrix.mjs", diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index a9cede4..dbf8082 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -302,6 +302,25 @@ async function runChecks(): Promise { }); } + // ── Solana wallet divergence (blockrun#119) ─────────────────────── + // A tightened SDK selection can leave a user spending from a different + // address than the one holding their USDC, with nothing printed. Report it + // where they already look when money seems missing. + try { + const { detectSolanaWalletDivergence } = await import('../wallet/solana-migration.js'); + const divergence = await detectSolanaWalletDivergence(); + if (divergence) { + out.push({ + name: 'Solana wallet', + status: 'warn', + detail: + `active ${divergence.active}; ${divergence.alternatives.length} other wallet(s) found: ` + + divergence.alternatives.map((w) => w.address).join(', '), + remedy: 'If your USDC is on one of those: franklin wallet-adopt
', + }); + } + } catch { /* a diagnostic must never take down `franklin doctor` */ } + return out; } diff --git a/src/commands/wallet-adopt.ts b/src/commands/wallet-adopt.ts new file mode 100644 index 0000000..7ae716b --- /dev/null +++ b/src/commands/wallet-adopt.ts @@ -0,0 +1,52 @@ +/** + * `franklin wallet-adopt
` — switch to a Solana wallet Franklin can + * see but is not using (blockrun#119). + * + * Explicit by design. The complaint behind #119 is that an upgrade changed the + * active address without asking, so the remedy must not do the same thing in + * the other direction: the user names the address, and only then does anything + * move. `importSolanaWallet` matches against the address DERIVED from each + * discovered key (never the file's own `address` field) and backs up the + * current `~/.blockrun/.solana-session` before replacing it. + */ + +import chalk from 'chalk'; +import { detectSolanaWalletDivergence, activeSolanaAddress } from '../wallet/solana-migration.js'; + +export async function walletAdoptCommand(address: string): Promise { + const wanted = address.trim(); + const current = activeSolanaAddress(); + + if (current === wanted) { + console.log(`Already active: ${wanted}`); + return; + } + + const divergence = await detectSolanaWalletDivergence(); + const candidates = divergence?.alternatives ?? []; + const match = candidates.find((w) => w.address === wanted); + + if (!match) { + console.log(chalk.red(`No discovered wallet derives to ${wanted}.`)); + if (candidates.length > 0) { + console.log('\nFound on this machine:'); + for (const w of candidates) console.log(` ${w.address} (${w.source})`); + } else { + console.log('Franklin found no other Solana wallets on this machine.'); + } + process.exitCode = 1; + return; + } + + try { + const { importSolanaWallet } = await import('@blockrun/llm'); + const adopted = await importSolanaWallet(wanted); + console.log(chalk.green(`Active Solana wallet is now ${adopted}`)); + console.log(chalk.dim(` was: ${current ?? 'none'}`)); + console.log(chalk.dim(` source: ${match.source}`)); + console.log(chalk.dim(' the previous session file was backed up before it was replaced')); + } catch (err) { + console.log(chalk.red(`Could not adopt ${wanted}: ${(err as Error).message}`)); + process.exitCode = 1; + } +} diff --git a/src/index.ts b/src/index.ts index 41163b0..da0f429 100644 --- a/src/index.ts +++ b/src/index.ts @@ -301,6 +301,14 @@ program await doctorCommand(opts); }); +program + .command('wallet-adopt
') + .description('Make a discovered Solana wallet the active one (backs up the current session)') + .action(async (address: string) => { + const { walletAdoptCommand } = await import('./commands/wallet-adopt.js'); + await walletAdoptCommand(address); + }); + program .command('telemetry [action]') .description('Manage opt-in local telemetry (status|enable|disable|view|summary)') diff --git a/src/tools/sensitive-paths.ts b/src/tools/sensitive-paths.ts index 3fc3551..0b5d184 100644 --- a/src/tools/sensitive-paths.ts +++ b/src/tools/sensitive-paths.ts @@ -23,7 +23,7 @@ const WALLET_KEY_FILES = [ path.join(BLOCKRUN_DIR, '.session'), // EVM private key (0x hex) path.join(BLOCKRUN_DIR, '.solana-session'), // Solana secret key (base58) path.join(BLOCKRUN_DIR, '.solana-session-key2'), - path.join(BLOCKRUN_DIR, 'solana-wallet.json'), // legacy { address, private_key } + path.join(BLOCKRUN_DIR, 'solana-wallet.json'), // legacy { address, privateKey } // Account bearer key (brk_...). Not a private key, but it spends: it draws // on a prepaid balance with no per-call signature and no chain-level // ceiling, so `cat ~/.blockrun/api-key` is exfiltration in one Read. It diff --git a/src/wallet/solana-migration.ts b/src/wallet/solana-migration.ts new file mode 100644 index 0000000..256ef1c --- /dev/null +++ b/src/wallet/solana-migration.ts @@ -0,0 +1,89 @@ +/** + * Detect a Solana wallet the upgrade silently stopped using (blockrun#119). + * + * Franklin 3.35.6 tightened SDK wallet selection: startup now takes + * `SOLANA_WALLET_KEY`, then `~/.blockrun/.solana-session`, then creates a new + * wallet. The legacy `~/.blockrun/solana-wallet.json` is no longer selected. + * That was a deliberate security fix and it stays. + * + * What it left behind is a silent address change. The SDK prints a migration + * notice, but only when it CREATES a wallet — and the install that hurts has a + * `.solana-session` already, so nothing is created, nothing is printed, and the + * user is simply on a different address than the one holding their USDC. + * + * This module only looks. It derives public addresses (never trusting the + * `address` field in a file, and never reading a key it does not derive from), + * and reports a divergence for `franklin doctor` to show. Adopting one is an + * explicit, separate act — `franklin wallet-adopt
` — because the + * whole complaint is that the active wallet changed without anyone asking. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import bs58 from 'bs58'; +import { Keypair } from '@solana/web3.js'; +import { BLOCKRUN_DIR } from '../config.js'; + +export interface SolanaWalletDivergence { + /** Address Franklin will actually spend from, or null if none is active. */ + active: string | null; + /** Discovered wallets whose address differs from the active one. */ + alternatives: Array<{ address: string; source: string }>; +} + +/** + * The address the SDK will select, derived from the canonical session file. + * + * Deliberately does not call `getOrCreateSolanaWallet()`: that CREATES a wallet + * as a side effect, which is exactly the wrong thing to do inside a diagnostic + * that is trying to tell the user which wallet they already have. Same + * primitives the signing path uses. + */ +export function activeSolanaAddress(): string | null { + try { + const key = fs.readFileSync(path.join(BLOCKRUN_DIR, '.solana-session'), 'utf-8').trim(); + if (!key) return null; + return Keypair.fromSecretKey(bs58.decode(key)).publicKey.toBase58(); + } catch { + return null; // absent, unreadable, or not a key we can derive from + } +} + +/** + * Wallets on this machine that Franklin can see but is not using. + * + * Returns null when there is nothing to say — no active wallet, or every + * discovered wallet is the active one. `listDiscoveredSolanaWallets()` derives + * each address from the secret key rather than trusting the file, and returns + * no secret material. + */ +export async function detectSolanaWalletDivergence(): Promise { + const active = activeSolanaAddress(); + if (!active) return null; + + let discovered: Array<{ address: string; source: string }> = []; + try { + const { listDiscoveredSolanaWallets } = await import('@blockrun/llm'); + discovered = await listDiscoveredSolanaWallets(); + } catch { + return null; // SDK unavailable or scan failed — a diagnostic must not throw + } + + const alternatives = discovered.filter((w) => w.address !== active); + if (alternatives.length === 0) return null; + return { active, alternatives }; +} + +/** One-line-per-wallet report. Public addresses only. */ +export function formatDivergence(d: SolanaWalletDivergence): string { + const lines = [ + `Active Solana wallet: ${d.active}`, + `Franklin can also see ${d.alternatives.length} other wallet(s) on this machine:`, + ...d.alternatives.map((w) => ` ${w.address} (${w.source})`), + '', + 'If your USDC is on one of those, Franklin is not spending from it. Adopt it with:', + ` franklin wallet-adopt
`, + 'The current session file is backed up before anything is replaced.', + ]; + return lines.join('\n'); +} diff --git a/test/solana-migration.local.mjs b/test/solana-migration.local.mjs new file mode 100644 index 0000000..9f838ed --- /dev/null +++ b/test/solana-migration.local.mjs @@ -0,0 +1,104 @@ +/** + * blockrun#119 — an upgrade must not silently change the active Solana wallet. + * + * HOME is redirected before any import: config.ts resolves BLOCKRUN_DIR from + * os.homedir() at module load, and this suite writes wallet-shaped files. + * It must never see the developer's real ~/.blockrun. + */ + +import { mkdtempSync, writeFileSync, rmSync, mkdirSync, existsSync } from 'node:fs'; +import { tmpdir, homedir } from 'node:os'; +import { join } from 'node:path'; + +const REAL_HOME = homedir(); +const TEST_HOME = mkdtempSync(join(tmpdir(), 'franklin-solmig-')); +process.env.HOME = TEST_HOME; + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import bs58 from 'bs58'; +import { Keypair } from '@solana/web3.js'; + +const { BLOCKRUN_DIR } = await import('../dist/config.js'); +const mig = await import('../dist/wallet/solana-migration.js'); + +assert.ok(BLOCKRUN_DIR.startsWith(TEST_HOME), `refusing to run against ${REAL_HOME}`); +mkdirSync(BLOCKRUN_DIR, { recursive: true }); + +const SESSION = join(BLOCKRUN_DIR, '.solana-session'); +const LEGACY = join(BLOCKRUN_DIR, 'solana-wallet.json'); + +function newWallet() { + const kp = Keypair.generate(); + return { address: kp.publicKey.toBase58(), secret: bs58.encode(kp.secretKey) }; +} +function clean() { rmSync(SESSION, { force: true }); rmSync(LEGACY, { force: true }); } + +test('the active address is derived from the session key, not read from a file', () => { + clean(); + const w = newWallet(); + writeFileSync(SESSION, w.secret + '\n'); + assert.equal(mig.activeSolanaAddress(), w.address); +}); + +test('no session file means no active wallet, and no wallet is created', () => { + clean(); + assert.equal(mig.activeSolanaAddress(), null); + // The whole point: a diagnostic must not have the side effect of creating + // the wallet it is reporting on. + assert.equal(existsSync(SESSION), false); +}); + +test('a corrupt session file reports no active wallet instead of throwing', () => { + clean(); + writeFileSync(SESSION, 'not-a-base58-key'); + assert.equal(mig.activeSolanaAddress(), null); +}); + +test('a legacy wallet at a different address is reported as a divergence', async () => { + clean(); + const active = newWallet(); + const legacy = newWallet(); + writeFileSync(SESSION, active.secret + '\n'); + writeFileSync(LEGACY, JSON.stringify({ address: legacy.address, privateKey: legacy.secret })); + + const d = await mig.detectSolanaWalletDivergence(); + assert.ok(d, 'a different legacy address must be surfaced — this is the #119 bug'); + assert.equal(d.active, active.address); + assert.ok(d.alternatives.some((w) => w.address === legacy.address)); + + const report = mig.formatDivergence(d); + assert.match(report, new RegExp(active.address)); + assert.match(report, new RegExp(legacy.address)); + assert.doesNotMatch(report, new RegExp(legacy.secret), 'never print a secret key'); + assert.doesNotMatch(report, new RegExp(active.secret), 'never print a secret key'); +}); + +test('a legacy wallet holding the SAME address is not a divergence', async () => { + clean(); + const w = newWallet(); + writeFileSync(SESSION, w.secret + '\n'); + writeFileSync(LEGACY, JSON.stringify({ address: w.address, privateKey: w.secret })); + assert.equal(await mig.detectSolanaWalletDivergence(), null, 'same wallet in both files is fine'); +}); + +test('a lying address field cannot fabricate a divergence', async () => { + clean(); + const w = newWallet(); + const impostor = newWallet(); + writeFileSync(SESSION, w.secret + '\n'); + // File claims someone else's address while holding w's key. The address must + // be derived from the key, so this is the active wallet and not a divergence. + writeFileSync(LEGACY, JSON.stringify({ address: impostor.address, privateKey: w.secret })); + const d = await mig.detectSolanaWalletDivergence(); + assert.equal(d, null, "the file's address field must not be trusted over the key"); +}); + +test('no session file means nothing to diverge from', async () => { + clean(); + const legacy = newWallet(); + writeFileSync(LEGACY, JSON.stringify({ address: legacy.address, privateKey: legacy.secret })); + assert.equal(await mig.detectSolanaWalletDivergence(), null); +}); + +test('cleanup', () => { clean(); rmSync(TEST_HOME, { recursive: true, force: true }); });