Skip to content
Closed
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
10 changes: 10 additions & 0 deletions packages/bitcoin-wallet-snap/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Run a one-time full scan of every existing account after the update, so funds on previously unwatched addresses are found ([#201](https://github.com/MetaMask/internal-snaps/pull/201))
- Emit a `Scan Discovered Missed Transactions` tracking event during this rescan when a full scan finds transactions that routine sync did not know about ([#201](https://github.com/MetaMask/internal-snaps/pull/201))

### Changed

- Split the chain `stopGap` configuration into `{ discovery: 5, scan: 20 }` so account discovery keeps the cheap probe while real account scans use the BIP44 gap limit ([#201](https://github.com/MetaMask/internal-snaps/pull/201))

### Fixed

- Ensure certain errors are stringified correctly ([#179](https://github.com/MetaMask/internal-snaps/pull/179))
- Reveal and persist the wallet's own change script when filling a partner-supplied PSBT, so bridged change is always covered by routine sync ([#201](https://github.com/MetaMask/internal-snaps/pull/201))

## [2.0.1]

Expand Down
2 changes: 1 addition & 1 deletion packages/bitcoin-wallet-snap/snap.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"url": "https://github.com/MetaMask/internal-snaps.git"
},
"source": {
"shasum": "sYefpN30aR0fb7v2DtdJ+jNFSnJJX5jtqvdsDof4RHQ=",
"shasum": "XYQC8S5QfNIDySwZcIqvx08yZ+e50hr+UMW7629c8ns=",
"location": {
"npm": {
"filePath": "dist/bundle.js",
Expand Down
2 changes: 1 addition & 1 deletion packages/bitcoin-wallet-snap/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export const Config: SnapConfig = {
encrypt: false,
chain: {
parallelRequests: 5,
stopGap: 5,
stopGap: { discovery: 5, scan: 20 },
maxRetries: 3,
url: {
bitcoin: fromEnv('ESPLORA_BITCOIN', 'https://blockstream.info/api'),
Expand Down
9 changes: 9 additions & 0 deletions packages/bitcoin-wallet-snap/src/entities/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,15 @@ export type BitcoinAccount = {
*/
revealNextAddress(): AddressInfo;

/**
* Reveals addresses up to and including the derivation index of `script` if it belongs to
* this wallet and lies beyond the revealed set.
*
* @param script - the script to reveal up to.
* @returns true if new addresses were revealed.
*/
revealToScript(script: ScriptBuf): boolean;

/**
* Start a full scan.
*
Expand Down
4 changes: 3 additions & 1 deletion packages/bitcoin-wallet-snap/src/entities/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ export type BlockchainClient = {
* Note that this operation modifies the account in place.
*
* @param account - the account to full scan.
* @param mode - 'discovery' uses the short discovery stop gap for probing
* candidate accounts; the default 'scan' uses the full BIP44-sized gap.
*/
fullScan(account: BitcoinAccount): Promise<void>;
fullScan(account: BitcoinAccount, mode?: 'discovery' | 'scan'): Promise<void>;

/**
* Perform a sync operation on the account.
Expand Down
2 changes: 1 addition & 1 deletion packages/bitcoin-wallet-snap/src/entities/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export type SnapConfig = {

export type ChainConfig = {
parallelRequests: number;
stopGap: number;
stopGap: { discovery: number; scan: number };
maxRetries: number;
url: {
[network in Network]: string;
Expand Down
1 change: 1 addition & 0 deletions packages/bitcoin-wallet-snap/src/entities/snap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export enum TrackingSnapEvent {
TransactionReceived = 'Transaction Received',
TransactionReorged = 'Transaction Reorged',
TransactionSubmitted = 'Transaction Submitted',
ScanDiscoveredMissedTransactions = 'Scan Discovered Missed Transactions',
}

/**
Expand Down
133 changes: 133 additions & 0 deletions packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { SnapsProvider, JsonRpcRequest } from '@metamask/snaps-sdk';
import { mock } from 'jest-mock-extended';

import type { BitcoinAccount, SnapClient, SyncResult } from '../entities';
import { TrackingSnapEvent } from '../entities';
import type { SendFlowUseCases, AccountUseCases } from '../use-cases';
import { CronHandler, CronMethod } from './CronHandler';

Expand Down Expand Up @@ -148,6 +149,70 @@ describe('CronHandler', () => {
mockSnapClient.emitAccountBalancesUpdatedEvent,
).toHaveBeenCalledWith([mockAccounts[0]]);
});

it('schedules a one-time full scan for every account when rescanV1 state is not set', async () => {
const mockResult1: SyncResult = {
account: mockAccount1,
transactionsToNotify: [],
};
const mockResult2: SyncResult = {
account: mockAccount2,
transactionsToNotify: [],
};
mockSnapClient.getState.mockResolvedValue(null);
(getSelectedAccounts as jest.Mock).mockResolvedValue([
'account-1',
'account-2',
]);
mockAccountUseCases.list.mockResolvedValue(mockAccounts);
mockAccountUseCases.synchronize
.mockResolvedValueOnce(mockResult1)
.mockResolvedValueOnce(mockResult2);

await handler.route(request);

expect(mockSnapClient.getState).toHaveBeenCalledWith('rescanV1');
expect(mockSnapClient.setState).toHaveBeenCalledWith('rescanV1', true);
expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalledTimes(2);
expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalledWith({
duration: 'PT5S',
method: CronMethod.FullScanAccount,
params: { accountId: 'account-1', trackMissed: true },
});
expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalledWith({
duration: 'PT5S',
method: CronMethod.FullScanAccount,
params: { accountId: 'account-2', trackMissed: true },
});
// Still proceeds with the normal sync
expect(mockAccountUseCases.synchronize).toHaveBeenCalledTimes(2);
});

it('does not schedule background events when rescanV1 state is already set', async () => {
const mockResult1: SyncResult = {
account: mockAccount1,
transactionsToNotify: [],
};
const mockResult2: SyncResult = {
account: mockAccount2,
transactionsToNotify: [],
};
mockSnapClient.getState.mockResolvedValue(true);
(getSelectedAccounts as jest.Mock).mockResolvedValue([
'account-1',
'account-2',
]);
mockAccountUseCases.list.mockResolvedValue(mockAccounts);
mockAccountUseCases.synchronize
.mockResolvedValueOnce(mockResult1)
.mockResolvedValueOnce(mockResult2);

await handler.route(request);

expect(mockSnapClient.getState).toHaveBeenCalledWith('rescanV1');
expect(mockSnapClient.setState).not.toHaveBeenCalled();
expect(mockSnapClient.scheduleBackgroundEvent).not.toHaveBeenCalled();
});
});

describe('refreshRates', () => {
Expand Down Expand Up @@ -349,5 +414,73 @@ describe('CronHandler', () => {

await expect(handler.route(request)).rejects.toThrow(error);
});

it('passes trackMissed through from the request params', async () => {
const trackMissedRequest = {
method: CronMethod.FullScanAccount,
params: { accountId: 'account-1', trackMissed: true },
} as unknown as JsonRpcRequest;
const mockResult: SyncResult = {
account: mockAccount,
transactionsToNotify: [],
};
mockAccountUseCases.get.mockResolvedValue(mockAccount);
mockAccount.listTransactions.mockReturnValue([]);
mockAccountUseCases.fullScan.mockResolvedValue(mockResult);

await handler.route(trackMissedRequest);

expect(mockAccountUseCases.get).toHaveBeenCalledWith('account-1');
expect(mockAccountUseCases.fullScan).toHaveBeenCalledWith(mockAccount);
});

it('emits a tracking event only for transactions not present before the scan when trackMissed is true', async () => {
const trackMissedRequest = {
method: CronMethod.FullScanAccount,
params: { accountId: 'account-1', trackMissed: true },
} as unknown as JsonRpcRequest;
const existingTx = mock<WalletTx>({
txid: { toString: () => 'existing-txid' },
});
const newTx = mock<WalletTx>({
txid: { toString: () => 'new-txid' },
});
const mockResult: SyncResult = {
account: mockAccount,
transactionsToNotify: [existingTx, newTx],
};
mockAccountUseCases.get.mockResolvedValue(mockAccount);
mockAccount.listTransactions
.mockReturnValueOnce([existingTx])
.mockReturnValueOnce([existingTx, newTx]);
mockAccountUseCases.fullScan.mockResolvedValue(mockResult);

await handler.route(trackMissedRequest);

expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledTimes(1);
expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledWith(
TrackingSnapEvent.ScanDiscoveredMissedTransactions,
mockAccount,
newTx,
'cron',
);
});

it('never emits a tracking event when trackMissed is false or undefined', async () => {
const existingTx = mock<WalletTx>({
txid: { toString: () => 'existing-txid' },
});
const mockResult: SyncResult = {
account: mockAccount,
transactionsToNotify: [existingTx],
};
mockAccountUseCases.get.mockResolvedValue(mockAccount);
mockAccountUseCases.fullScan.mockResolvedValue(mockResult);

await handler.route(request);

expect(mockSnapClient.emitTrackingEvent).not.toHaveBeenCalled();
expect(mockAccount.listTransactions).not.toHaveBeenCalled();
});
});
});
109 changes: 77 additions & 32 deletions packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { getSelectedAccounts } from '@metamask/keyring-snap-sdk';
import type { JsonRpcRequest, SnapsProvider } from '@metamask/snaps-sdk';
import { array, assert, object, string } from 'superstruct';

import { InexistentMethodError, SynchronizationError } from '../entities';
import type { SnapClient, SyncResult } from '../entities';
import type { Json, JsonRpcRequest, SnapsProvider } from '@metamask/snaps-sdk';
import { array, assert, boolean, object, optional, string } from 'superstruct';

import {
InexistentMethodError,
SynchronizationError,
TrackingSnapEvent,
} from '../entities';
import type { BitcoinAccount, SnapClient, SyncResult } from '../entities';
import type { SendFlowUseCases, AccountUseCases } from '../use-cases';

export enum CronMethod {
Expand All @@ -23,6 +27,7 @@ export const SyncSelectedAccountsRequest = object({

export const FullScanAccountRequest = object({
accountId: string(),
trackMissed: optional(boolean()),
});

export class CronHandler {
Expand Down Expand Up @@ -68,14 +73,28 @@ export class CronHandler {
}
case CronMethod.FullScanAccount: {
assert(params, FullScanAccountRequest);
return this.fullScanAccount(params.accountId);
return this.fullScanAccount(params.accountId, params.trackMissed);
}
default:
throw new InexistentMethodError(`Method not found: ${method}`);
}
}

async synchronizeAccounts(): Promise<void> {
const rescanned = await this.#snapClient.getState('rescanV1');
if (rescanned !== true) {
await this.#snapClient.setState('rescanV1', true);

const allAccounts = await this.#accountsUseCases.list();
for (const account of allAccounts) {
await this.#snapClient.scheduleBackgroundEvent({
duration: 'PT5S',
method: CronMethod.FullScanAccount,
params: { accountId: account.id, trackMissed: true },
});
}
}

const selectedAccounts: Set<string> = new Set(
await getSelectedAccounts(this.#snap),
);
Expand All @@ -90,31 +109,11 @@ export class CronHandler {
),
);

const successfulResults: SyncResult[] = [];

// TODO: Replace `any` with type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const errors: Record<string, any> = {};

results.forEach((result, index) => {
if (result.status === 'fulfilled') {
successfulResults.push(result.value);
} else {
const id = accounts[index]?.id;
if (id) {
errors[id] = result.reason;
}
}
});

await this.#emitSyncEvents(successfulResults);

if (Object.keys(errors).length > 0) {
throw new SynchronizationError(
'Account synchronization failures',
errors,
);
}
await this.#finishSync(
accounts,
results,
'Account synchronization failures',
);
}

async syncSelectedAccounts(accountIds: string[]): Promise<void> {
Expand Down Expand Up @@ -181,10 +180,56 @@ export class CronHandler {
}
}

async fullScanAccount(accountId: string): Promise<void> {
async fullScanAccount(
accountId: string,
trackMissed?: boolean,
): Promise<void> {
const account = await this.#accountsUseCases.get(accountId);
const txIdsBefore = trackMissed
? new Set(account.listTransactions().map((tx) => tx.txid.toString()))
: undefined;

const result = await this.#accountsUseCases.fullScan(account);

if (txIdsBefore) {
for (const tx of account.listTransactions()) {
if (!txIdsBefore.has(tx.txid.toString())) {
await this.#snapClient.emitTrackingEvent(
TrackingSnapEvent.ScanDiscoveredMissedTransactions,
account,
tx,
'cron',
);
}
}
}

await this.#emitSyncEvents([result]);
}

async #finishSync(
accounts: BitcoinAccount[],
results: PromiseSettledResult<SyncResult>[],
message: string,
): Promise<void> {
const successfulResults: SyncResult[] = [];
const errors: Record<string, Json> = {};

results.forEach((result, index) => {
if (result.status === 'fulfilled') {
successfulResults.push(result.value);
} else {
const id = accounts[index]?.id;
if (id) {
errors[id] = String(result.reason);
}
}
});

await this.#emitSyncEvents(successfulResults);

if (Object.keys(errors).length > 0) {
throw new SynchronizationError(message, errors);
}
}
}
Loading