Skip to content
Draft
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
38 changes: 38 additions & 0 deletions packages/bitcoin-wallet-snap/src/entities/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ import type { Inscription } from './meta-protocols';
import type { AccountStateSnapshot } from './snap';
import type { TransactionBuilder } from './transaction';

/** A BIP-32 keychain of an account: receive or change addresses. */
export type Keychain = 'external' | 'internal';

/**
* A Bitcoin account.
*/
Expand Down Expand Up @@ -82,6 +85,32 @@ export type BitcoinAccount = {
*/
peekAddress(index: number): AddressInfo;

/**
* Get the address at a given index of a keychain, without revealing it.
*
* @param keychain - 'external' (receive) or 'internal' (change).
* @param index - derivation index.
* @returns the address
*/
peekKeychainAddress(keychain: Keychain, index: number): AddressInfo;

/**
* Highest derivation index of the keychain that received an output the
* wallet knows of (the keychain's last active index for a gap scan).
*
* @param keychain - 'external' (receive) or 'internal' (change).
* @returns the index, or undefined if the keychain has no known output
*/
lastUsedIndex(keychain: Keychain): number | undefined;

/**
* Highest revealed derivation index of the keychain.
*
* @param keychain - 'external' (receive) or 'internal' (change).
* @returns the index, or undefined if no address is revealed
*/
lastRevealedIndex(keychain: Keychain): number | undefined;

/**
* Get the next unused address. This will reveal a new address if there is no unused address.
* Note that the account needs to be persisted for this operation to be idempotent.
Expand Down Expand Up @@ -126,6 +155,15 @@ export type BitcoinAccount = {
*/
applyUpdate(update: Update): void;

/**
* Whether the account's wallet has applied at least one chain update (sync
* or full scan). A wallet that never synced still sits on the genesis
* checkpoint (height 0), and its zero balance means "unknown", not "empty".
*
* @returns true if the wallet has synchronized with the chain at least once
*/
hasSynchronized(): boolean;

/**
* Extract the change set if it exists.
*
Expand Down
12 changes: 12 additions & 0 deletions packages/bitcoin-wallet-snap/src/entities/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ export type BlockchainClient = {
*/
fullScan(account: BitcoinAccount, mode?: 'discovery' | 'scan'): Promise<void>;

/**
* Complete a discovery-gap full scan that found activity to the standard
* gap. Only the addresses the standard gap adds beyond what the discovery
* scan already fetched are checked; the standard-gap full scan runs only if
* one of them has history or the check fails, so a used account keeps its
* full address coverage without refetching the addresses already scanned.
* Note that this operation may modify the account in place.
*
* @param account - the account the discovery-gap scan was applied to.
*/
extendDiscoveryScan(account: BitcoinAccount): Promise<void>;

/**
* Perform a sync operation on the account.
* Note that this operation modifies the account in place.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
ChangeSet,
DescriptorPair,
KeychainKind,
LocalOutput,
Network,
ScriptBuf,
SpkIndexed,
Expand Down Expand Up @@ -205,6 +206,41 @@ describe('BdkAccountAdapter', () => {
mockNetwork,
);

describe('gap scan indexes (v5)', () => {
const output = (keychain: KeychainKind, index: number): LocalOutput =>
({ keychain, derivation_index: index }) as unknown as LocalOutput;

it('returns the highest index of a keychain that received an output', () => {
mockWallet.list_output.mockReturnValue([
output('external', 2),
output('internal', 7),
output('external', 0),
output('external', 4),
]);

expect(adapter().lastUsedIndex('external')).toBe(4);
expect(adapter().lastUsedIndex('internal')).toBe(7);
});

it('returns undefined for a keychain without outputs', () => {
mockWallet.list_output.mockReturnValue([output('external', 0)]);

expect(adapter().lastUsedIndex('internal')).toBeUndefined();
});

it('returns the last revealed index and peeks keychain addresses without revealing them', () => {
const info = mock<AddressInfo>();
mockWallet.derivation_index.mockReturnValue(3);
mockWallet.peek_address.mockReturnValue(info);

expect(adapter().lastRevealedIndex('internal')).toBe(3);
expect(mockWallet.derivation_index).toHaveBeenCalledWith('internal');
expect(adapter().peekKeychainAddress('internal', 9)).toBe(info);
expect(mockWallet.peek_address).toHaveBeenCalledWith('internal', 9);
expect(mockWallet.reveal_addresses_to).not.toHaveBeenCalled();
});
});

describe('isChange', () => {
it('returns true when the script belongs to the internal keychain', () => {
mockWallet.derivation_of_spk.mockReturnValue(indexed('internal', 3));
Expand Down
29 changes: 28 additions & 1 deletion packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
} from '@metamask/bitcoindevkit';

import { AccountCapability, ValidationError, WalletError } from '../entities';
import type { BitcoinAccount, TransactionBuilder } from '../entities';
import type { BitcoinAccount, Keychain, TransactionBuilder } from '../entities';
import { toFeeRateSatsPerVb } from '../utils/fee-rate';
import { BdkTxBuilderAdapter } from './BdkTxBuilderAdapter';

Expand Down Expand Up @@ -149,6 +149,27 @@ export class BdkAccountAdapter implements BitcoinAccount {
return this.#wallet.peek_address('external', index);
}

peekKeychainAddress(keychain: Keychain, index: number): AddressInfo {
return this.#wallet.peek_address(keychain, index);
}

lastUsedIndex(keychain: Keychain): number | undefined {
let last: number | undefined;
for (const output of this.#wallet.list_output()) {
if (
output.keychain === keychain &&
(last === undefined || output.derivation_index > last)
) {
last = output.derivation_index;
}
}
return last;
}

lastRevealedIndex(keychain: Keychain): number | undefined {
return this.#wallet.derivation_index(keychain);
}

nextUnusedAddress(): AddressInfo {
return this.#wallet.next_unused_address('external');
}
Expand Down Expand Up @@ -183,6 +204,12 @@ export class BdkAccountAdapter implements BitcoinAccount {
return this.#wallet.apply_update(update);
}

hasSynchronized(): boolean {
// A freshly created wallet only holds the genesis checkpoint (height 0);
// every successful sync or full scan applies a chain update to the tip.
return this.#wallet.latest_checkpoint.height > 0;
}

takeStaged(): ChangeSet | undefined {
const staged = this.#collectStaged();
this.#staged = undefined;
Expand Down
192 changes: 190 additions & 2 deletions packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
AddressInfo,
FeeEstimates,
FullScanRequest,
Psbt as BdkPsbt,
Expand All @@ -16,8 +17,8 @@ import { readFile } from 'fs/promises';
import { mock } from 'jest-mock-extended';
import { dirname, join } from 'path';

import type { BitcoinAccount, ChainConfig } from '../entities';
import { EsploraClientAdapter } from './EsploraClientAdapter';
import type { BitcoinAccount, ChainConfig, Keychain } from '../entities';
import { EsploraClientAdapter, toScripthash } from './EsploraClientAdapter';

jest.mock('@metamask/bitcoindevkit', () => ({
EsploraClient: jest.fn(),
Expand Down Expand Up @@ -455,6 +456,193 @@ describe('EsploraClientAdapter', () => {
});
});

describe('extendDiscoveryScan (v5: complete a discovery-gap scan without refetching it)', () => {
const mockFetch = jest.fn();

beforeEach(() => {
global.fetch = mockFetch;
});

afterEach(() => {
mockFetch.mockReset();
});

const okResponse = (
body: unknown,
): { ok: boolean; text: () => Promise<string> } => ({
ok: true,
text: async (): Promise<string> => JSON.stringify(body),
});

// Script bytes encode the keychain and index, so each address has a
// distinct script hash that the tests can map back.
const scriptBytes = (keychain: Keychain, index: number): Uint8Array =>
new Uint8Array([keychain === 'external' ? 0 : 1, index]);

const setupAccount = (
account: BitcoinAccount,
indexes: Record<Keychain, { used?: number; revealed?: number }>,
): void => {
jest
.mocked(account.lastUsedIndex)
.mockImplementation((keychain) => indexes[keychain].used);
jest
.mocked(account.lastRevealedIndex)
.mockImplementation((keychain) => indexes[keychain].revealed);
jest.mocked(account.peekKeychainAddress).mockImplementation(
(keychain, index) =>
({
address: {
// eslint-disable-next-line @typescript-eslint/naming-convention
script_pubkey: { as_bytes: () => scriptBytes(keychain, index) },
},
}) as unknown as AddressInfo,
);
};

const checkedIndexes = (account: BitcoinAccount): [Keychain, number][] =>
jest
.mocked(account.peekKeychainAddress)
.mock.calls.map(([keychain, index]) => [keychain, index]);

const range = (
keychain: Keychain,
first: number,
last: number,
): [Keychain, number][] =>
Array.from({ length: last - first + 1 }, (_unused, offset) => [
keychain,
first + offset,
]);

it('computes the Esplora script hash (reversed SHA-256)', async () => {
expect(await toScripthash(new Uint8Array())).toBe(
'55b852781b9995a44c939b64e441ae2724b96f99c8f4fb9a141cfc9842c4b0e3',
);
});

it('checks only the addresses the standard gap adds and skips the full scan when they are unused (run B-v4 account 0)', async () => {
const { adapter, mockEsploraClient, account } = setupTest();
// Account 0 of run B-v4: activity at index 0 of both keychains (the
// receive keychain has one more revealed address). The discovery scan
// fetched up to index 5 of each keychain (last active + 5).
setupAccount(account, {
external: { used: 0, revealed: 1 },
internal: { used: 0, revealed: 0 },
});
mockFetch.mockResolvedValue(okResponse([]));

await adapter.extendDiscoveryScan(account);

expect(checkedIndexes(account)).toStrictEqual([
...range('external', 6, 21),
...range('internal', 6, 20),
]);
expect(mockFetch).toHaveBeenCalledTimes(31);
// The standard-gap full scan refetched both keychains from index 0
// (50 requests in run B-v4).
expect(mockEsploraClient.full_scan).not.toHaveBeenCalled();
const hash = await toScripthash(scriptBytes('external', 6));
expect(mockFetch).toHaveBeenCalledWith(
`https://bitcoin.example/scripthash/${hash}/txs`,
{ signal: expect.any(AbortSignal) },
);
expect(account.applyUpdate).not.toHaveBeenCalled();
});

it('covers indexes discovery..scan-1 of a keychain without activity', async () => {
const { adapter, account } = setupTest();
setupAccount(account, {
external: { used: 3 },
internal: {},
});
mockFetch.mockResolvedValue(okResponse([]));

await adapter.extendDiscoveryScan(account);

expect(checkedIndexes(account)).toStrictEqual([
...range('external', 9, 23),
...range('internal', 5, 19),
]);
});

it('runs the standard-gap full scan as soon as a checked address has history', async () => {
const { adapter, mockEsploraClient, account, mockRequest } = setupTest();
setupAccount(account, {
external: { used: 0, revealed: 0 },
internal: { used: 0, revealed: 0 },
});
const usedHash = await toScripthash(scriptBytes('external', 8));
mockFetch.mockImplementation(async (url: string) =>
okResponse(url.includes(usedHash) ? [{ txid: 'tx' }] : []),
);

await adapter.extendDiscoveryScan(account);

// Batches of `parallelRequests`: 6..10 holds the used index 8, so the
// following batches are not requested.
expect(mockFetch).toHaveBeenCalledTimes(5);
expect(mockEsploraClient.full_scan).toHaveBeenCalledTimes(1);
expect(mockEsploraClient.full_scan).toHaveBeenCalledWith(
mockRequest,
20,
5,
);
});

it('falls back to the standard-gap full scan when the check fails', async () => {
const { adapter, mockEsploraClient, account } = setupTest();
setupAccount(account, {
external: { used: 0 },
internal: {},
});
mockFetch.mockResolvedValue({ ok: false, status: 404 });

await adapter.extendDiscoveryScan(account);

expect(mockEsploraClient.full_scan).toHaveBeenCalledTimes(1);
});

it('falls back to the standard-gap full scan when the addresses cannot be derived', async () => {
const { adapter, mockEsploraClient, account } = setupTest();
jest.mocked(account.lastUsedIndex).mockImplementation(() => {
throw new Error('unsupported');
});

await adapter.extendDiscoveryScan(account);

expect(mockFetch).not.toHaveBeenCalled();
expect(mockEsploraClient.full_scan).toHaveBeenCalledTimes(1);
});

it('treats an unexpected response body as history', async () => {
const { adapter, mockEsploraClient, account } = setupTest();
setupAccount(account, {
external: { used: 0 },
internal: {},
});
mockFetch.mockResolvedValue(okResponse({ error: 'unexpected' }));

await adapter.extendDiscoveryScan(account);

expect(mockEsploraClient.full_scan).toHaveBeenCalledTimes(1);
});

it('propagates a failure of the fallback full scan', async () => {
const { adapter, mockEsploraClient, account } = setupTest();
setupAccount(account, {
external: { used: 0 },
internal: {},
});
mockFetch.mockResolvedValue(okResponse([{ txid: 'tx' }]));
mockEsploraClient.full_scan.mockRejectedValue(new Error('boom'));

await expect(adapter.extendDiscoveryScan(account)).rejects.toThrow(
'Failed to perform initial full scan',
);
});
});

describe('with the real BDK WASM client', () => {
const SAMPLE_PSBT =
'cHNidP8BAI4CAAAAAAM1gwEAAAAAACJRIORP1Ndiq325lSC/jMG0RlhATHYmuuULfXgEHUM3u5i4AAAAAAAAAAAxai8AAUSx+i9Igg4HWdcpyagCs8mzuRCklgA7nRMkm69rAAAAAAAAAAAAAQACAAAAACp2AAAAAAAAFgAUgu3FEiFNy9ZR/zSpTo9nHREjrSoAAAAAAAAAAAA=';
Expand Down
Loading