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 }); });