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
98 changes: 80 additions & 18 deletions app/src/components/BitcoinWallet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand All @@ -79,6 +77,11 @@ export default function BitcoinWallet({ explorerUrl }: { explorerUrl?: string |
const [restoreSeed, setRestoreSeed] = useState('');
const [setupError, setSetupError] = useState<string | null>(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<string | null>(null);

// Send form
const [sendTo, setSendTo] = useState('');
const [sendAmount, setSendAmount] = useState('');
Expand Down Expand Up @@ -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<Record<string, unknown>>('/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);
Expand Down Expand Up @@ -364,26 +390,61 @@ export default function BitcoinWallet({ explorerUrl }: { explorerUrl?: string |

{/* ── No wallet yet ─────────────────────────────────────────────────────── */}
{overview.status && !walletExists && !newMnemonic && (
<div className="space-y-4">
<div className={card}>
<h2 className="text-base font-semibold text-gray-100 mb-2">Set up your BTC wallet</h2>
<h2 className="text-base font-semibold text-gray-100 mb-2">Use my Mintlayer seed</h2>
<p className="text-sm text-gray-400 mb-4">
Recommended: restore with the <span className="text-gray-200 font-medium">same seed phrase as your Mintlayer wallet</span> —
Recommended: derive the BTC wallet from the <span className="text-gray-200 font-medium">same seed phrase as your Mintlayer wallet</span> —
one seed, both chains (Bitcoin keys derive via BIP84 from the same words).
<span className="text-yellow-400"> The words are then stored by the BTC sidecar on this machine.</span>
Your password and 2FA code are verified on the server; the words themselves never leave it.
</p>
<form
onSubmit={(e) => {
e.preventDefault();
void linkSeed();
}}
className="space-y-3"
>
<div>
<label htmlFor="btc-link-password" className="block text-xs text-gray-400 mb-1">Mintlayer wallet password</label>
<input
id="btc-link-password"
type="password"
value={linkPassword}
onChange={(e) => setLinkPassword(e.target.value)}
autoComplete="current-password"
className={`${input} max-w-md`}
/>
</div>
<div>
<label htmlFor="btc-link-totp" className="block text-xs text-gray-400 mb-1">2FA code</label>
<input
id="btc-link-totp"
value={linkTotp}
onChange={(e) => setLinkTotp(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="123456"
inputMode="numeric"
autoComplete="one-time-code"
className={totpInput}
/>
<p className="mt-1 text-xs text-gray-500">Moving key material requires a fresh authenticator code.</p>
</div>
{linkError && <p className="text-sm text-red-400">{linkError}</p>}
<button type="submit" disabled={busy || linkTotp.length !== 6 || linkPassword === ''} className={primaryBtn}>
{busy ? 'Linking…' : 'Link BTC wallet to my Mintlayer seed'}
</button>
</form>
<p className="mt-3 text-xs text-gray-500">
The BTC wallet scans the full chain so coins received before today are found too.
</p>
</div>

<div className={card}>
<h2 className="text-base font-semibold text-gray-100 mb-2">Or restore from a different seed</h2>
<p className="text-sm text-gray-400 mb-4">
Use a <span className="text-gray-200 font-medium">different 12 or 24-word seed phrase</span> for the BTC wallet.
The words are then stored by the BTC sidecar on this machine.
</p>
<p className="mt-4 text-xs text-gray-500">{totpLabel}</p>
<div className="mt-2 mb-4">
<label htmlFor="btc-setup-totp" className="block text-xs text-gray-400 mb-1">2FA code</label>
<input
id="btc-setup-totp"
value={setupTotp}
onChange={(e) => setSetupTotp(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="123456"
inputMode="numeric"
autoComplete="one-time-code"
className={totpInput}
/>
</div>
<button onClick={() => { setRestoreOpen(true); setSetupError(null); }} disabled={busy} className={`${primaryBtn} mb-4`}>
Restore from seed
</button>
Expand All @@ -399,6 +460,7 @@ export default function BitcoinWallet({ explorerUrl }: { explorerUrl?: string |
</div>
{setupError && <p className="mt-2 text-sm text-red-400">{setupError}</p>}
</div>
</div>
)}

{/* ── One-time seed display (generated wallet only) ────────────────────── */}
Expand Down
4 changes: 2 additions & 2 deletions app/src/lib/bitcoin-wallet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand Down
13 changes: 9 additions & 4 deletions app/src/lib/bitcoin-wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,11 +146,16 @@ async function sidecarRequest<T>(
// ── Wallet lifecycle ──────────────────────────────────────────────────────────

/** Create the BTC wallet (generated seed, or restore from the given mnemonic). */
export function createBitcoinWallet(seed?: string): Promise<BitcoinCreateResult> {
export function createBitcoinWallet(
seed?: string,
scanFrom?: number,
): Promise<BitcoinCreateResult> {
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 }),
});
}

Expand All @@ -164,14 +169,14 @@ export function createBitcoinWallet(seed?: string): Promise<BitcoinCreateResult>
*/
export async function createBitcoinWalletFromSeedWithRetry(
seed: string,
opts: { attempts?: number; delayMs?: number } = {},
opts: { attempts?: number; delayMs?: number; scanFrom?: number } = {},
): Promise<BitcoinCreateResult> {
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 =
Expand Down
120 changes: 120 additions & 0 deletions app/src/pages/api/bitcoin/link-seed.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof POST>[0] {
return {
request: new Request('http://x/api/bitcoin/link-seed', {
method: 'POST',
body: JSON.stringify(body),
}),
clientAddress: '127.0.0.1',
} as Parameters<typeof POST>[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);
});
});
Loading
Loading