diff --git a/packages/bitcoin-wallet-snap/CHANGELOG.md b/packages/bitcoin-wallet-snap/CHANGELOG.md index 5908ee56b..34027be09 100644 --- a/packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/packages/bitcoin-wallet-snap/CHANGELOG.md @@ -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] diff --git a/packages/bitcoin-wallet-snap/snap.manifest.json b/packages/bitcoin-wallet-snap/snap.manifest.json index d59b88aac..98aca3bb7 100644 --- a/packages/bitcoin-wallet-snap/snap.manifest.json +++ b/packages/bitcoin-wallet-snap/snap.manifest.json @@ -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", diff --git a/packages/bitcoin-wallet-snap/src/config.ts b/packages/bitcoin-wallet-snap/src/config.ts index 21d35aa56..614013b24 100644 --- a/packages/bitcoin-wallet-snap/src/config.ts +++ b/packages/bitcoin-wallet-snap/src/config.ts @@ -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'), diff --git a/packages/bitcoin-wallet-snap/src/entities/account.ts b/packages/bitcoin-wallet-snap/src/entities/account.ts index f8d27eee2..2f590aa38 100644 --- a/packages/bitcoin-wallet-snap/src/entities/account.ts +++ b/packages/bitcoin-wallet-snap/src/entities/account.ts @@ -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. * diff --git a/packages/bitcoin-wallet-snap/src/entities/chain.ts b/packages/bitcoin-wallet-snap/src/entities/chain.ts index 112f916eb..77c29eb93 100644 --- a/packages/bitcoin-wallet-snap/src/entities/chain.ts +++ b/packages/bitcoin-wallet-snap/src/entities/chain.ts @@ -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; + fullScan(account: BitcoinAccount, mode?: 'discovery' | 'scan'): Promise; /** * Perform a sync operation on the account. diff --git a/packages/bitcoin-wallet-snap/src/entities/config.ts b/packages/bitcoin-wallet-snap/src/entities/config.ts index 4675c6e20..ad2c50b68 100644 --- a/packages/bitcoin-wallet-snap/src/entities/config.ts +++ b/packages/bitcoin-wallet-snap/src/entities/config.ts @@ -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; diff --git a/packages/bitcoin-wallet-snap/src/entities/snap.ts b/packages/bitcoin-wallet-snap/src/entities/snap.ts index e3a545ad2..35bd5f60b 100644 --- a/packages/bitcoin-wallet-snap/src/entities/snap.ts +++ b/packages/bitcoin-wallet-snap/src/entities/snap.ts @@ -51,6 +51,7 @@ export enum TrackingSnapEvent { TransactionReceived = 'Transaction Received', TransactionReorged = 'Transaction Reorged', TransactionSubmitted = 'Transaction Submitted', + ScanDiscoveredMissedTransactions = 'Scan Discovered Missed Transactions', } /** diff --git a/packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts b/packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts index 224e9bf12..d7f0a66f6 100644 --- a/packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts +++ b/packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts @@ -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'; @@ -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', () => { @@ -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({ + txid: { toString: () => 'existing-txid' }, + }); + const newTx = mock({ + 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({ + 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(); + }); }); }); diff --git a/packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts b/packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts index f034158ff..27e1458ee 100644 --- a/packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts +++ b/packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts @@ -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 { @@ -23,6 +27,7 @@ export const SyncSelectedAccountsRequest = object({ export const FullScanAccountRequest = object({ accountId: string(), + trackMissed: optional(boolean()), }); export class CronHandler { @@ -68,7 +73,7 @@ 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}`); @@ -76,6 +81,20 @@ export class CronHandler { } async synchronizeAccounts(): Promise { + 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 = new Set( await getSelectedAccounts(this.#snap), ); @@ -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 = {}; - - 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 { @@ -181,10 +180,56 @@ export class CronHandler { } } - async fullScanAccount(accountId: string): Promise { + async fullScanAccount( + accountId: string, + trackMissed?: boolean, + ): Promise { 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[], + message: string, + ): Promise { + const successfulResults: SyncResult[] = []; + const errors: Record = {}; + + 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); + } + } } diff --git a/packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts b/packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts index 89af0a75e..7f83c3454 100644 --- a/packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts +++ b/packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts @@ -156,6 +156,20 @@ export class BdkAccountAdapter implements BitcoinAccount { return this.#wallet.reveal_next_address('external'); } + revealToScript(script: ScriptBuf): boolean { + const indexed = this.#wallet.derivation_of_spk(script); + if (!indexed) { + return false; + } + const keychain = indexed[0]; + const index = indexed[1]; + const lastRevealed = this.#wallet.derivation_index(keychain); + if (lastRevealed !== undefined && lastRevealed >= index) { + return false; + } + return this.#wallet.reveal_addresses_to(keychain, index).length > 0; + } + startFullScan(): FullScanRequest { return this.#wallet.start_full_scan(); } diff --git a/packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.test.ts b/packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.test.ts new file mode 100644 index 000000000..8c73769ea --- /dev/null +++ b/packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.test.ts @@ -0,0 +1,68 @@ +import type { FullScanRequest } from '@metamask/bitcoindevkit'; +import { EsploraClient } from '@metamask/bitcoindevkit'; +import { mock } from 'jest-mock-extended'; + +import type { BitcoinAccount, ChainConfig } from '../entities'; +import { EsploraClientAdapter } from './EsploraClientAdapter'; + +jest.mock('@metamask/bitcoindevkit', () => ({ + EsploraClient: jest.fn(), +})); + +const setupTest = (): { + adapter: EsploraClientAdapter; + mockEsploraClient: ReturnType>; + account: BitcoinAccount; + mockRequest: FullScanRequest; +} => { + const mockEsploraClient = mock(); + jest.mocked(EsploraClient).mockReturnValue(mockEsploraClient); + + const config = mock({ + parallelRequests: 5, + maxRetries: 3, + stopGap: { discovery: 5, scan: 20 }, + url: { + bitcoin: 'https://bitcoin.example', + testnet: 'https://testnet.example', + testnet4: 'https://testnet4.example', + signet: 'https://signet.example', + regtest: 'https://regtest.example', + }, + }); + + const adapter = new EsploraClientAdapter(config); + const mockRequest = mock(); + const account = mock({ network: 'bitcoin' }); + account.startFullScan.mockReturnValue(mockRequest); + + return { adapter, mockEsploraClient, account, mockRequest }; +}; + +describe('EsploraClientAdapter', () => { + describe('fullScan', () => { + it('uses the scan stop gap by default', async () => { + const { adapter, mockEsploraClient, account, mockRequest } = setupTest(); + + await adapter.fullScan(account); + + expect(mockEsploraClient.full_scan).toHaveBeenCalledWith( + mockRequest, + 20, + 5, + ); + }); + + it("uses the discovery stop gap in 'discovery' mode", async () => { + const { adapter, mockEsploraClient, account, mockRequest } = setupTest(); + + await adapter.fullScan(account, 'discovery'); + + expect(mockEsploraClient.full_scan).toHaveBeenCalledWith( + mockRequest, + 5, + 5, + ); + }); + }); +}); diff --git a/packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts b/packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts index 65734c189..8710ecdc7 100644 --- a/packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts +++ b/packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts @@ -30,12 +30,19 @@ export class EsploraClientAdapter implements BlockchainClient { this.#config = config; } - async fullScan(account: BitcoinAccount): Promise { + async fullScan( + account: BitcoinAccount, + mode: 'discovery' | 'scan' = 'scan', + ): Promise { try { + const stopGap = + mode === 'discovery' + ? this.#config.stopGap.discovery + : this.#config.stopGap.scan; const request = account.startFullScan(); const update = await this.#clients[account.network].full_scan( request, - this.#config.stopGap, + stopGap, this.#config.parallelRequests, ); account.applyUpdate(update); diff --git a/packages/bitcoin-wallet-snap/src/infra/StoredAccountAdapter.ts b/packages/bitcoin-wallet-snap/src/infra/StoredAccountAdapter.ts index 908e97ca6..29a451611 100644 --- a/packages/bitcoin-wallet-snap/src/infra/StoredAccountAdapter.ts +++ b/packages/bitcoin-wallet-snap/src/infra/StoredAccountAdapter.ts @@ -167,6 +167,10 @@ export class StoredAccountAdapter implements BitcoinAccount { return this.#unsupported(); } + revealToScript(_script: ScriptBuf): boolean { + return this.#unsupported(); + } + sentAndReceived(_tx: Transaction): [Amount, Amount] { return this.#unsupported(); } diff --git a/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index 694d55691..92e289013 100644 --- a/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -475,7 +475,10 @@ describe('AccountUseCases', () => { discoverParams.network, tAddressType, ); - expect(mockChain.fullScan).toHaveBeenCalledWith(mockAccount); + expect(mockChain.fullScan).toHaveBeenCalledWith( + mockAccount, + 'discovery', + ); }, ); @@ -508,7 +511,10 @@ describe('AccountUseCases', () => { tNetwork, discoverParams.addressType, ); - expect(mockChain.fullScan).toHaveBeenCalledWith(mockAccount); + expect(mockChain.fullScan).toHaveBeenCalledWith( + mockAccount, + 'discovery', + ); }, ); @@ -1007,6 +1013,7 @@ describe('AccountUseCases', () => { network: 'bitcoin', addressType: 'p2wpkh', sign: jest.fn(), + revealToScript: jest.fn(), capabilities: [AccountCapability.SignPsbt], }); const mockWalletTx = mock(); @@ -1291,6 +1298,7 @@ describe('AccountUseCases', () => { network: 'bitcoin', sign: jest.fn(), isMine: () => false, + revealToScript: jest.fn(), capabilities: [AccountCapability.FillPsbt], }); const mockFeeRate = 3; @@ -1369,6 +1377,45 @@ describe('AccountUseCases', () => { expect(psbt).toBe(mockFilledPsbt); }); + it('reveals the derivation index and persists the account when the mine output needs revealing', async () => { + const mineAccount = { + ...mockAccount, + isMine: (): boolean => true, + revealToScript: jest.fn().mockReturnValue(true), + }; + mockRepository.get.mockResolvedValueOnce(mineAccount); + + await useCases.fillPsbt('account-id', mockTemplatePsbt); + + expect(mineAccount.revealToScript).toHaveBeenCalledWith( + mockOutput.script_pubkey, + ); + expect(mockRepository.update).toHaveBeenCalledWith(mineAccount); + }); + + it('does not persist the account when revealToScript finds nothing new to reveal', async () => { + const mineAccount = { + ...mockAccount, + isMine: (): boolean => true, + revealToScript: jest.fn().mockReturnValue(false), + }; + mockRepository.get.mockResolvedValueOnce(mineAccount); + + await useCases.fillPsbt('account-id', mockTemplatePsbt); + + expect(mineAccount.revealToScript).toHaveBeenCalledWith( + mockOutput.script_pubkey, + ); + expect(mockRepository.update).not.toHaveBeenCalled(); + }); + + it('does not call revealToScript for outputs that are not mine', async () => { + await useCases.fillPsbt('account-id', mockTemplatePsbt); + + expect(mockAccount.revealToScript).not.toHaveBeenCalled(); + expect(mockRepository.update).not.toHaveBeenCalled(); + }); + it('propagates an error if get fails', async () => { const error = new Error('get failed'); mockRepository.get.mockRejectedValueOnce(error); @@ -1613,6 +1660,7 @@ describe('AccountUseCases', () => { id: 'account-id', network: 'bitcoin', isMine: () => false, + revealToScript: jest.fn(), capabilities: [AccountCapability.ComputeFee], }); const mockFeeRate = 3; diff --git a/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index 85c8b34d9..9e758b495 100644 --- a/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -230,7 +230,7 @@ export class AccountUseCases { // We need to do a full scan here to know if the account // has any previous activity since later on we filter out // accounts with no tx history - await this.#chain.fullScan(newAccount); + await this.#chain.fullScan(newAccount, 'discovery'); this.#logger.info( 'Bitcoin account discovered successfully. Request: %o', @@ -779,6 +779,7 @@ export class AccountUseCases { const frozenUTXOs = await this.#repository.getFrozenUTXOs(account.id); const feeRateToUse = feeRate ?? (await this.getFallbackFeeRate(account)); + let revealed = false; try { let builder = account .buildTx() @@ -789,6 +790,7 @@ export class AccountUseCases { for (const txout of templatePsbt.unsigned_tx.output) { // if the PSBT contains an output that is sending to ourselves, we change its value. If the PSBT contains no change outputs, one will automatically be added. if (account.isMine(txout.script_pubkey)) { + revealed = account.revealToScript(txout.script_pubkey) || revealed; builder = builder.drainToByScript(txout.script_pubkey); } else { builder = builder.addRecipientByScript( @@ -819,6 +821,10 @@ export class AccountUseCases { builtPsbt = builder.finish(); } + if (revealed) { + await this.#repository.update(account); + } + return builtPsbt; } catch (error) { const causeMessage = (error as Error)?.message ?? 'unknown cause';