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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <address>`
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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 19 additions & 0 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,25 @@ async function runChecks(): Promise<Check[]> {
});
}

// ── 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 <address>',
});
}
} catch { /* a diagnostic must never take down `franklin doctor` */ }

return out;
}

Expand Down
52 changes: 52 additions & 0 deletions src/commands/wallet-adopt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* `franklin wallet-adopt <address>` — 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<void> {
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;
}
}
8 changes: 8 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,14 @@ program
await doctorCommand(opts);
});

program
.command('wallet-adopt <address>')
.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)')
Expand Down
2 changes: 1 addition & 1 deletion src/tools/sensitive-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
89 changes: 89 additions & 0 deletions src/wallet/solana-migration.ts
Original file line number Diff line number Diff line change
@@ -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 <address>` — 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<SolanaWalletDivergence | null> {
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 <address>`,
'The current session file is backed up before anything is replaced.',
];
return lines.join('\n');
}
104 changes: 104 additions & 0 deletions test/solana-migration.local.mjs
Original file line number Diff line number Diff line change
@@ -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 }); });
Loading