diff --git a/app/src/components/BitcoinWallet.tsx b/app/src/components/BitcoinWallet.tsx index b4dc905..8c1ec36 100644 --- a/app/src/components/BitcoinWallet.tsx +++ b/app/src/components/BitcoinWallet.tsx @@ -60,8 +60,6 @@ const input = 'w-full rounded-lg bg-gray-800 border border-gray-700 text-gray-10 const primaryBtn = 'rounded-lg bg-mint-700 hover:bg-mint-600 px-4 py-2 text-sm font-semibold text-white transition-colors disabled:opacity-50'; const totpInput = `${input} max-w-[8rem] tracking-[0.3em] text-center`; -const totpLabel = 'Web login is protected by 2FA — moving funds requires a fresh authenticator code.'; - // ── Component ───────────────────────────────────────────────────────────────── export default function BitcoinWallet({ explorerUrl }: { explorerUrl?: string | null }) { @@ -79,6 +77,11 @@ export default function BitcoinWallet({ explorerUrl }: { explorerUrl?: string | const [restoreSeed, setRestoreSeed] = useState(''); const [setupError, setSetupError] = useState(null); + // Link to the Mintlayer seed (server-side reveal — words never transit the browser) + const [linkPassword, setLinkPassword] = useState(''); + const [linkTotp, setLinkTotp] = useState(''); + const [linkError, setLinkError] = useState(null); + // Send form const [sendTo, setSendTo] = useState(''); const [sendAmount, setSendAmount] = useState(''); @@ -150,6 +153,29 @@ export default function BitcoinWallet({ explorerUrl }: { explorerUrl?: string | } } + /** Derive the BTC wallet from the Mintlayer wallet's own seed. The + * mnemonic is read server-side (wallet-rpc → sidecar) and never sent + * to the browser; this call only carries the password + 2FA code. */ + async function linkSeed() { + setBusy(true); + setLinkError(null); + try { + await api>('/api/bitcoin/link-seed', { + method: 'POST', + body: JSON.stringify({ password: linkPassword, totp_code: linkTotp.trim() }), + }); + setLinkPassword(''); + setLinkTotp(''); + showNotice('BTC wallet linked to your Mintlayer seed. It is now scanning for funds.'); + await refresh(); + } catch (err) { + setLinkTotp(''); + setLinkError((err as Error).message); + } finally { + setBusy(false); + } + } + function submitRestore(e: React.FormEvent) { e.preventDefault(); const seed = cleanSeed(restoreSeed); @@ -364,26 +390,61 @@ export default function BitcoinWallet({ explorerUrl }: { explorerUrl?: string | {/* ── No wallet yet ─────────────────────────────────────────────────────── */} {overview.status && !walletExists && !newMnemonic && ( +
-

Set up your BTC wallet

+

Use my Mintlayer seed

- Recommended: restore with the same seed phrase as your Mintlayer wallet — + Recommended: derive the BTC wallet from the same seed phrase as your Mintlayer wallet — one seed, both chains (Bitcoin keys derive via BIP84 from the same words). - The words are then stored by the BTC sidecar on this machine. + Your password and 2FA code are verified on the server; the words themselves never leave it. +

+
{ + e.preventDefault(); + void linkSeed(); + }} + className="space-y-3" + > +
+ + setLinkPassword(e.target.value)} + autoComplete="current-password" + className={`${input} max-w-md`} + /> +
+
+ + setLinkTotp(e.target.value.replace(/\D/g, '').slice(0, 6))} + placeholder="123456" + inputMode="numeric" + autoComplete="one-time-code" + className={totpInput} + /> +

Moving key material requires a fresh authenticator code.

+
+ {linkError &&

{linkError}

} + +
+

+ The BTC wallet scans the full chain so coins received before today are found too. +

+
+ +
+

Or restore from a different seed

+

+ Use a different 12 or 24-word seed phrase for the BTC wallet. + The words are then stored by the BTC sidecar on this machine.

-

{totpLabel}

-
- - setSetupTotp(e.target.value.replace(/\D/g, '').slice(0, 6))} - placeholder="123456" - inputMode="numeric" - autoComplete="one-time-code" - className={totpInput} - /> -
@@ -399,6 +460,7 @@ export default function BitcoinWallet({ explorerUrl }: { explorerUrl?: string |
{setupError &&

{setupError}

}
+ )} {/* ── One-time seed display (generated wallet only) ────────────────────── */} diff --git a/app/src/lib/bitcoin-wallet.test.ts b/app/src/lib/bitcoin-wallet.test.ts index 0e02249..c086221 100644 --- a/app/src/lib/bitcoin-wallet.test.ts +++ b/app/src/lib/bitcoin-wallet.test.ts @@ -73,7 +73,7 @@ describe('bitcoin-wallet client', () => { const res = await createBitcoinWallet('a b c'); expect(res.mnemonic).toBe('a b c'); const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; - expect((init.body as string)).toBe(JSON.stringify({ seed: 'a b c' })); + expect((init.body as string)).toBe(JSON.stringify({ seed: 'a b c', scan_from: null })); }); it('createBitcoinWalletFromSeedWithRetry retries transient 503s and succeeds', async () => { @@ -86,7 +86,7 @@ describe('bitcoin-wallet client', () => { const res = await createBitcoinWalletFromSeedWithRetry('same seed', { delayMs: 1 }); expect(res.created).toBe(true); expect(mockFetch).toHaveBeenCalledTimes(2); - expect(JSON.parse(mockFetch.mock.calls[1][1]!.body as string)).toEqual({ seed: 'same seed' }); + expect(JSON.parse(mockFetch.mock.calls[1][1]!.body as string)).toEqual({ seed: 'same seed', scan_from: null }); }); it('createBitcoinWalletFromSeedWithRetry never retries deterministic 4xx errors', async () => { diff --git a/app/src/lib/bitcoin-wallet.ts b/app/src/lib/bitcoin-wallet.ts index 8bed4df..2b19a6e 100644 --- a/app/src/lib/bitcoin-wallet.ts +++ b/app/src/lib/bitcoin-wallet.ts @@ -146,11 +146,16 @@ async function sidecarRequest( // ── Wallet lifecycle ────────────────────────────────────────────────────────── /** Create the BTC wallet (generated seed, or restore from the given mnemonic). */ -export function createBitcoinWallet(seed?: string): Promise { +export function createBitcoinWallet( + seed?: string, + scanFrom?: number, +): Promise { return sidecarRequest('/wallet', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ seed: seed ?? null }), + // scan_from narrows the sync horizon for brand-new seeds; absent → + // the sidecar defaults restores to a full scan from block 0. + body: JSON.stringify({ seed: seed ?? null, scan_from: scanFrom ?? null }), }); } @@ -164,14 +169,14 @@ export function createBitcoinWallet(seed?: string): Promise */ export async function createBitcoinWalletFromSeedWithRetry( seed: string, - opts: { attempts?: number; delayMs?: number } = {}, + opts: { attempts?: number; delayMs?: number; scanFrom?: number } = {}, ): Promise { const attempts = Math.max(1, opts.attempts ?? 3); const delayMs = opts.delayMs ?? 3_000; let lastErr: unknown; for (let attempt = 1; attempt <= attempts; attempt++) { try { - return await createBitcoinWallet(seed); + return await createBitcoinWallet(seed, opts.scanFrom); } catch (err) { lastErr = err; const retryable = diff --git a/app/src/pages/api/bitcoin/link-seed.test.ts b/app/src/pages/api/bitcoin/link-seed.test.ts new file mode 100644 index 0000000..d657a46 --- /dev/null +++ b/app/src/pages/api/bitcoin/link-seed.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { POST } from './link-seed'; + +/** + * POST /api/bitcoin/link-seed — server-side seed reveal → sidecar. + * Contract under test: gates (enabled, step-up, password), the + * re-lock-in-finally guarantee, honest error mapping and the hard rule + * that the seed phrase never appears in any response. + */ + +vi.mock('@/lib/step-up', () => ({ + requireStepUp: vi.fn(() => ({ ok: true as const })), + totpFromBody: vi.fn(() => '123456'), +})); + +const lockPrivateKeys = vi.fn(); +const unlockPrivateKeys = vi.fn(); +const showSeedPhrase = vi.fn(); +const createFromSeed = vi.fn(); + +vi.mock('@/lib/wallet-rpc', () => ({ + walletLockPrivateKeys: (...a: unknown[]) => lockPrivateKeys(...(a as [])), + walletUnlockPrivateKeys: (...a: unknown[]) => unlockPrivateKeys(...(a as [])), + walletShowSeedPhrase: (...a: unknown[]) => showSeedPhrase(...(a as [])), + isWalletNotOpenError: (err: unknown) => + String((err as Error)?.message ?? '').includes('no wallet'), +})); + +vi.mock('@/lib/bitcoin-wallet', () => ({ + isBitcoinEnabled: vi.fn(() => true), + createBitcoinWalletFromSeedWithRetry: (...a: unknown[]) => createFromSeed(...(a as [])), +})); + +function req(body: unknown): Parameters[0] { + return { + request: new Request('http://x/api/bitcoin/link-seed', { + method: 'POST', + body: JSON.stringify(body), + }), + clientAddress: '127.0.0.1', + } as Parameters[0]; +} + +const SEED = ['abandon', 'abandon', 'abandon', 'abandon', 'abandon', 'abandon', 'abandon', 'abandon', 'abandon', 'abandon', 'abandon', 'about']; + +describe('POST /api/bitcoin/link-seed', () => { + beforeEach(() => { + vi.clearAllMocks(); + unlockPrivateKeys.mockResolvedValue(undefined); + lockPrivateKeys.mockResolvedValue(undefined); + showSeedPhrase.mockResolvedValue({ seed_phrase: SEED, passphrase: null }); + createFromSeed.mockResolvedValue({ ok: true, created: true, network: 'mainnet' }); + }); + + it('links: unlocks, reveals, derives the wallet, re-locks', async () => { + const res = await POST(req({ password: 'hunter2', totp_code: '123456' })); + expect(res.status).toBe(200); + const passed = createFromSeed.mock.calls[0]?.[0] as string; + expect(passed).toBe(SEED.join(' ')); + expect(lockPrivateKeys).toHaveBeenCalledTimes(1); + // Re-lock happens as soon as the seed is extracted — before the + // sidecar call — keeping the unlocked window minimal. + expect(lockPrivateKeys.mock.invocationCallOrder[0]).toBeGreaterThan( + showSeedPhrase.mock.invocationCallOrder[0], + ); + }); + + it('never echoes the seed phrase in the response', async () => { + const res = await POST(req({ password: 'hunter2', totp_code: '123456' })); + const text = JSON.stringify(await res.json()); + for (const word of SEED) { + expect(text).not.toContain(word); + } + }); + + it('re-locks even when the reveal fails', async () => { + showSeedPhrase.mockRejectedValue(new Error('private keys are locked')); + const res = await POST(req({ password: 'hunter2', totp_code: '123456' })); + expect(res.status).toBe(403); + expect(lockPrivateKeys).toHaveBeenCalledTimes(1); + }); + + it('re-locks even when the sidecar call fails', async () => { + createFromSeed.mockRejectedValue( + Object.assign(new Error('BTC wallet service unreachable'), { status: 503 }), + ); + const res = await POST(req({ password: 'hunter2', totp_code: '123456' })); + expect(res.status).toBe(503); + expect(lockPrivateKeys).toHaveBeenCalledTimes(1); + }); + + it('maps a wrong password to 403 and never reaches the reveal', async () => { + unlockPrivateKeys.mockRejectedValue(new Error('Invalid password or passphrase')); + await POST(req({ password: 'nope', totp_code: '123456' })); + expect(showSeedPhrase).not.toHaveBeenCalled(); + }); + + it('maps a locked reveal to 403 and a closed wallet to 409', async () => { + showSeedPhrase.mockRejectedValue(new Error('wallet private keys are locked')); + expect((await POST(req({ password: 'p', totp_code: '123456' }))).status).toBe(403); + + showSeedPhrase.mockRejectedValue(new Error('no wallet is open')); + expect((await POST(req({ password: 'p', totp_code: '123456' }))).status).toBe(409); + }); + + it('maps a missing/empty seed to 409', async () => { + showSeedPhrase.mockResolvedValue({ seed_phrase: [], passphrase: null }); + expect((await POST(req({ password: 'p', totp_code: '123456' }))).status).toBe(409); + }); + + it('requires a password', async () => { + expect((await POST(req({ totp_code: '123456' }))).status).toBe(400); + expect(unlockPrivateKeys).not.toHaveBeenCalled(); + }); + + it('rejects a broken body', async () => { + const res = await POST(req(undefined)); + expect(res.status).toBe(400); + }); +}); diff --git a/app/src/pages/api/bitcoin/link-seed.ts b/app/src/pages/api/bitcoin/link-seed.ts new file mode 100644 index 0000000..dcec710 --- /dev/null +++ b/app/src/pages/api/bitcoin/link-seed.ts @@ -0,0 +1,97 @@ +import type { APIRoute } from 'astro'; +import { json } from '@/lib/api-utils'; +import { isBitcoinEnabled, createBitcoinWalletFromSeedWithRetry } from '@/lib/bitcoin-wallet'; +import { requireStepUp, totpFromBody } from '@/lib/step-up'; +import { + walletShowSeedPhrase, + walletUnlockPrivateKeys, + walletLockPrivateKeys, + isWalletNotOpenError, +} from '@/lib/wallet-rpc'; + +/** + * POST /api/bitcoin/link-seed - derive the BTC wallet from the Mintlayer + * wallet's own seed ("one seed, both chains") for stacks where the bitcoin + * profile was enabled after setup (the setup flow already links at birth). + * + * The seed phrase is read server-side and NEVER leaves this process: it is + * fetched from wallet-rpc, handed straight to the BTC sidecar and dropped. + * It is not echoed in responses, not logged, and never sent to the browser. + * + * Key-material lifecycle endpoint: requires the session, a fresh single-use + * TOTP code (step-up) AND the wallet password (private keys must be unlocked + * to reveal the seed). Private keys are re-locked when this handler exits, + * success or failure. + */ +export const POST: APIRoute = async ({ request, clientAddress }) => { + if (!isBitcoinEnabled()) { + return json({ ok: false, error: 'Bitcoin support is not enabled' }, 404); + } + + let body: Record = {}; + try { + body = (await request.json()) as Record; + } catch { + return json({ ok: false, error: 'Invalid request body' }, 400); + } + + const stepUp = requireStepUp(totpFromBody(body), request, clientAddress as string | undefined); + if (!stepUp.ok) { + return json({ ok: false, error: stepUp.error }, stepUp.status); + } + + const password = typeof body.password === 'string' ? body.password : ''; + if (password === '') { + return json({ ok: false, error: 'Your Mintlayer wallet password is required' }, 400); + } + + let seed = ''; + try { + try { + await walletUnlockPrivateKeys(password); + } catch (err) { + if (/password|passphrase/i.test((err as Error).message)) { + return json({ ok: false, error: 'Wrong wallet password' }, 403); + } + // Already-unlocked and similar states are not fatal here — the + // reveal below is the authority on whether the keys are readable. + } + + try { + const res = await walletShowSeedPhrase(); + if (!res || !Array.isArray(res.seed_phrase) || res.seed_phrase.length === 0) { + return json( + { ok: false, error: 'No seed phrase available — create or recover your Mintlayer wallet first' }, + 409, + ); + } + seed = res.seed_phrase.join(' '); + } catch (err) { + const msg = (err as Error).message; + if (/lock/i.test(msg)) { + return json({ ok: false, error: 'Could not unlock the wallet — check the password' }, 403); + } + if (isWalletNotOpenError(err)) { + return json({ ok: false, error: 'Open your Mintlayer wallet first' }, 409); + } + throw err; + } + } finally { + // Re-lock no matter which path we take — keep the unlocked window + // as short as possible even on the failure paths. + await walletLockPrivateKeys().catch(() => {}); + } + + try { + // No scan_from: this seed may be old — the sidecar scans from block 0 + // so BTC received before the link is not missed. + const result = await createBitcoinWalletFromSeedWithRetry(seed); + return json(result); + } catch (err) { + const status = (err as { status?: number }).status ?? 500; + return json( + { ok: false, error: (err as Error).message }, + status === 503 ? 503 : status === 409 ? 409 : 400, + ); + } +}; diff --git a/app/src/pages/setup.astro b/app/src/pages/setup.astro index 3961f2d..c8483fd 100644 --- a/app/src/pages/setup.astro +++ b/app/src/pages/setup.astro @@ -46,10 +46,16 @@ let result: SetupResult = { action: 'none' }; // (or restored) with. Non-fatal - setup still succeeds when the sidecar is // unreachable; the Bitcoin page offers its own create/restore as a fallback. // Failures are logged server-side so silent misses are diagnosable. -async function initBtcWalletFromSeed(mnemonic: string): Promise { +// scanFrom narrows the BTC sync horizon: a freshly generated seed has no +// BTC history (start at now); an imported mnemonic may hold old UTXOs +// (undefined → the sidecar scans from block 0). +async function initBtcWalletFromSeed( + mnemonic: string, + scanFrom?: number, +): Promise { if (!isBitcoinEnabled() || !mnemonic.trim()) return undefined; try { - await createBitcoinWalletFromSeedWithRetry(mnemonic); + await createBitcoinWalletFromSeedWithRetry(mnemonic, { scanFrom }); console.info('[setup] BTC wallet initialized from the setup seed'); return 'The same seed was configured for the BTC wallet (see the Bitcoin page).'; } catch (err) { @@ -100,7 +106,10 @@ if (Astro.request.method === 'POST') { const res = await createWallet(WALLET_PATH, storeSeed, undefined, noPassword ? undefined : passphrase); const mnemonicStr = res.mnemonic.type === 'NewlyGenerated' ? res.mnemonic.content.mnemonic : ''; - const btcNote = await initBtcWalletFromSeed(mnemonicStr); + const btcNote = await initBtcWalletFromSeed( + mnemonicStr, + Math.floor(Date.now() / 1000), + ); result = { action: 'create_success', mnemonic: mnemonicStr, walletPath: WALLET_PATH, btcNote }; } catch (err) { result = { action: 'error', message: friendlyWalletError((err as Error).message) }; diff --git a/bdk-wallet/src/main.rs b/bdk-wallet/src/main.rs index 5255c23..ab51768 100644 --- a/bdk-wallet/src/main.rs +++ b/bdk-wallet/src/main.rs @@ -134,11 +134,11 @@ fn now_unix() -> u64 { .as_secs() } -fn save_seed(path: &PathBuf, mnemonic: &str, network: Network) -> Result<()> { +fn save_seed(path: &PathBuf, mnemonic: &str, network: Network, created_at: u64) -> Result<()> { let file = SeedFile { mnemonic: mnemonic.to_string(), network: network_name(network).to_string(), - created_at: now_unix(), + created_at, }; // create_new + 0600 from the start: no window where the mnemonic sits on // disk with default (world-readable) permissions before the chmod lands. @@ -407,6 +407,11 @@ async fn open_wallet(state: &Arc, mnemonic: &str, created_at: u64) -> struct CreateWalletReq { /// Optional BIP39 mnemonic to restore. Generated when absent. seed: Option, + /// Sync horizon override (unix secs). Restores default to 0 - a full + /// scan - because addresses derived from an existing seed may hold + /// UTXOs from before the wallet was created. Freshly generated seeds + /// use "now" and callers may narrow a restore with an explicit value. + scan_from: Option, } /// POST /wallet - create the wallet from an optional seed. @@ -441,11 +446,21 @@ async fn create_wallet( }, }; - if let Err(e) = save_seed(&state.seed_path(), &mnemonic, state.config.network) { + // Freshly generated seeds have no history - sync from now. Restores + // (including linking an existing Mintlayer seed) must see every block: + // addresses derived from the same words may hold old UTXOs, so the + // horizon defaults to 0 (full scan) unless the caller narrows it. + let created_at = if req.seed.is_none() { + now_unix() + } else { + req.scan_from.unwrap_or(0) + }; + + if let Err(e) = save_seed(&state.seed_path(), &mnemonic, state.config.network, created_at) { return internal_error(e.context("persisting seed")); } - match open_wallet(&state, &mnemonic, now_unix()).await { + match open_wallet(&state, &mnemonic, created_at).await { Ok(()) => { // Echo the mnemonic ONLY when we generated it. For restores the // user already knows their seed - never serve it back.