From 47964faaebfd51fd503b1fc0c8834478cbc484a0 Mon Sep 17 00:00:00 2001 From: chaitika Date: Fri, 24 Jul 2026 23:46:45 +0530 Subject: [PATCH] ADD: detect regular payments in scanByTxid --- class/wallets/hd-bip352-wallet.ts | 63 ++++++++++- tests/unit/scan-by-txid.test.ts | 171 ++++++++++++++++++++++++++++++ 2 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 tests/unit/scan-by-txid.test.ts diff --git a/class/wallets/hd-bip352-wallet.ts b/class/wallets/hd-bip352-wallet.ts index 0292df4be..034bc6f6a 100644 --- a/class/wallets/hd-bip352-wallet.ts +++ b/class/wallets/hd-bip352-wallet.ts @@ -2,6 +2,7 @@ import * as bip39 from 'bip39'; import { Buffer } from 'buffer'; import { ECPairFactory } from 'ecpair'; import { AbstractHDElectrumWallet } from './abstract-hd-electrum-wallet.ts'; +import * as Electrum from '../../modules/Electrum'; import { getDefaultIndexer } from '../../modules/SilentPaymentIndexer'; import ecc from '../../modules/noble_ecc'; import { @@ -31,6 +32,8 @@ const SCAN_PROGRESS_THROTTLE_MS = 500; // Number of recent progress samples kept for the windowed ETA throughput estimate. const SCAN_ETA_ROLLING_WINDOW = 10; +type ScanByTxidResult = { found: boolean; utxosFound: number; blockHeight: number; tipHeight: number }; + export class HDSilentPaymentsWallet extends HDTaprootWallet implements IScannableWallet { static readonly type = 'HDSilentPaymentsWallet'; static readonly typeReadable = 'HD Silent Payments'; @@ -538,7 +541,39 @@ export class HDSilentPaymentsWallet extends HDTaprootWallet implements IScannabl } } - async scanByTxid(txid: string): Promise<{ found: boolean; utxosFound: number; blockHeight: number; tipHeight: number }> { + async scanByTxid(txid: string): Promise { + // Sequential on purpose: fetchUtxo()'s SP snapshot-restore would drop UTXOs added by a concurrent SP branch. + let sp: ScanByTxidResult | null = null; + try { + sp = await this.scanTxidForSilentPayment(txid); + } catch (error) { + console.warn('[SP] scanByTxid: silent payment branch failed:', error); + } + + let regular: ScanByTxidResult | null = null; + try { + regular = await this.scanTxidForRegularPayment(txid); + } catch (error) { + console.warn('[SP] scanByTxid: regular payment branch failed:', error); + } + + const spHit = sp?.found ? sp : null; + const regularHit = regular?.found ? regular : null; + const winner = spHit ?? regularHit; + + if (!winner) { + return { found: false, utxosFound: 0, blockHeight: 0, tipHeight: 0 }; + } + + return { + found: true, + utxosFound: (spHit?.utxosFound ?? 0) + (regularHit?.utxosFound ?? 0), + blockHeight: winner.blockHeight, + tipHeight: winner.tipHeight, + }; + } + + private async scanTxidForSilentPayment(txid: string): Promise { const indexer = getDefaultIndexer(); const [response, tipResponse] = await Promise.all([indexer.getTransactionByTxid(txid), indexer.getLatestBlockHeight()]); const tx = response.transaction; @@ -565,6 +600,32 @@ export class HDSilentPaymentsWallet extends HDTaprootWallet implements IScannabl }; } + private async scanTxidForRegularPayment(txid: string): Promise { + // fetchUtxo() only queries addresses with a known balance, so fetchBalance() must run first. + await super.fetchBalance(); + await super.fetchTransactions(); + await this.fetchUtxo(); + + const tx = super.getTransactions().find(t => t.txid === txid); + if (!tx || !tx.value || tx.value <= 0) { + return { found: false, utxosFound: 0, blockHeight: 0, tipHeight: 0 }; + } + + const utxosFound = tx.outputs.filter(o => o.scriptPubKey?.addresses?.[0] && this.weOwnAddress(o.scriptPubKey.addresses[0])).length; + const tipHeight = Electrum.estimateCurrentBlockheight(); + const confirmations = tx.confirmations || 0; + + this.onBalanceChangeCallback?.(); + this.onPersistCallback?.(); + + return { + found: true, + utxosFound, + blockHeight: confirmations > 0 ? tipHeight - confirmations + 1 : 0, + tipHeight, + }; + } + async fetchUtxo(): Promise { const spUtxos = this.getSilentPaymentUTXOs(); diff --git a/tests/unit/scan-by-txid.test.ts b/tests/unit/scan-by-txid.test.ts new file mode 100644 index 000000000..df2a2bb08 --- /dev/null +++ b/tests/unit/scan-by-txid.test.ts @@ -0,0 +1,171 @@ +import { HDSilentPaymentsWallet } from '../../class/wallets/hd-bip352-wallet.ts'; +import { AbstractHDElectrumWallet } from '../../class/wallets/abstract-hd-electrum-wallet.ts'; + +jest.mock('../../modules/Electrum', () => ({ + ...jest.requireActual('../../modules/Electrum'), + estimateCurrentBlockheight: jest.fn(() => 850000), +})); + +const SEED = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; +const TXID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const OWNED_ADDRESS = 'bc1p-owned-address'; + +const SP_HIT = { found: true, utxosFound: 1, blockHeight: 800100, tipHeight: 800200 }; +const SP_MISS = { found: false, utxosFound: 0, blockHeight: 800100, tipHeight: 800200 }; +const REGULAR_HIT = { found: true, utxosFound: 2, blockHeight: 849998, tipHeight: 850000 }; +const REGULAR_MISS = { found: false, utxosFound: 0, blockHeight: 0, tipHeight: 0 }; +const NOT_FOUND = { found: false, utxosFound: 0, blockHeight: 0, tipHeight: 0 }; + +function makeWallet(): HDSilentPaymentsWallet { + const wallet = new HDSilentPaymentsWallet(); + wallet.setSecret(SEED); + return wallet; +} + +describe('scanByTxid merge of SP and regular branches', () => { + afterEach(() => jest.restoreAllMocks()); + + function stubBranches(wallet: HDSilentPaymentsWallet, sp: unknown, regular: unknown) { + const stub = (name: string, outcome: unknown) => { + const spy = jest.spyOn(wallet as any, name); + if (outcome instanceof Error) spy.mockRejectedValue(outcome); + else spy.mockResolvedValue(outcome); + return spy; + }; + return { spSpy: stub('scanTxidForSilentPayment', sp), regularSpy: stub('scanTxidForRegularPayment', regular) }; + } + + it('returns not-found when both branches miss', async () => { + const wallet = makeWallet(); + stubBranches(wallet, SP_MISS, REGULAR_MISS); + expect(await wallet.scanByTxid(TXID)).toEqual(NOT_FOUND); + }); + + it('returns SP result when only SP branch hits', async () => { + const wallet = makeWallet(); + stubBranches(wallet, SP_HIT, REGULAR_MISS); + expect(await wallet.scanByTxid(TXID)).toEqual(SP_HIT); + }); + + it('returns regular result when only regular branch hits', async () => { + const wallet = makeWallet(); + stubBranches(wallet, SP_MISS, REGULAR_HIT); + expect(await wallet.scanByTxid(TXID)).toEqual(REGULAR_HIT); + }); + + it('sums utxosFound and keeps SP heights when both branches hit', async () => { + const wallet = makeWallet(); + stubBranches(wallet, SP_HIT, REGULAR_HIT); + expect(await wallet.scanByTxid(TXID)).toEqual({ + found: true, + utxosFound: SP_HIT.utxosFound + REGULAR_HIT.utxosFound, + blockHeight: SP_HIT.blockHeight, + tipHeight: SP_HIT.tipHeight, + }); + }); + + it('still finds a regular payment when the SP branch throws', async () => { + const wallet = makeWallet(); + stubBranches(wallet, new Error('indexer down'), REGULAR_HIT); + expect(await wallet.scanByTxid(TXID)).toEqual(REGULAR_HIT); + }); + + it('still finds an SP payment when the regular branch throws', async () => { + const wallet = makeWallet(); + stubBranches(wallet, SP_HIT, new Error('electrum down')); + expect(await wallet.scanByTxid(TXID)).toEqual(SP_HIT); + }); + + it('returns not-found without throwing when both branches throw', async () => { + const wallet = makeWallet(); + stubBranches(wallet, new Error('indexer down'), new Error('electrum down')); + expect(await wallet.scanByTxid(TXID)).toEqual(NOT_FOUND); + }); + + it('awaits the SP branch before starting the regular branch', async () => { + const wallet = makeWallet(); + let spCompleted = false; + let spCompletedWhenRegularStarted = false; + const { spSpy, regularSpy } = stubBranches(wallet, SP_MISS, REGULAR_MISS); + spSpy.mockImplementation(async () => { + await new Promise(resolve => setTimeout(resolve, 20)); + spCompleted = true; + return SP_MISS; + }); + regularSpy.mockImplementation(async () => { + spCompletedWhenRegularStarted = spCompleted; + return REGULAR_MISS; + }); + await wallet.scanByTxid(TXID); + expect(regularSpy).toHaveBeenCalled(); + expect(spCompletedWhenRegularStarted).toBe(true); + }); +}); + +describe('scanByTxid regular branch', () => { + afterEach(() => jest.restoreAllMocks()); + + function makeRegularTx(overrides: Record = {}) { + return { + txid: TXID, + hash: TXID, + confirmations: 3, + value: 150000, + inputs: [], + outputs: [ + { n: 0, value: 0.0015, scriptPubKey: { addresses: [OWNED_ADDRESS] } }, + { n: 1, value: 0.5, scriptPubKey: { addresses: ['bc1p-someone-elses-change'] } }, + ], + ...overrides, + } as any; + } + + function setupRegularBranch(txs: any[]) { + const fetchBalance = jest.spyOn(AbstractHDElectrumWallet.prototype, 'fetchBalance').mockResolvedValue(); + const fetchTransactions = jest.spyOn(AbstractHDElectrumWallet.prototype, 'fetchTransactions').mockResolvedValue(); + const fetchUtxo = jest.spyOn(AbstractHDElectrumWallet.prototype, 'fetchUtxo').mockResolvedValue(); + jest.spyOn(AbstractHDElectrumWallet.prototype, 'getTransactions').mockReturnValue(txs); + jest.spyOn(AbstractHDElectrumWallet.prototype, 'weOwnAddress').mockImplementation((address: string) => address === OWNED_ADDRESS); + + const wallet = makeWallet(); + jest.spyOn(wallet as any, 'scanTxidForSilentPayment').mockResolvedValue(SP_MISS); + return { wallet, fetchBalance, fetchTransactions, fetchUtxo }; + } + + it('finds an incoming payment to an owned address and counts only our outputs', async () => { + const { wallet, fetchBalance, fetchTransactions, fetchUtxo } = setupRegularBranch([makeRegularTx()]); + + const result = await wallet.scanByTxid(TXID); + + expect(result).toEqual({ + found: true, + utxosFound: 1, + blockHeight: 850000 - 3 + 1, + tipHeight: 850000, + }); + expect(fetchBalance).toHaveBeenCalled(); + expect(fetchTransactions).toHaveBeenCalled(); + expect(fetchUtxo).toHaveBeenCalled(); + }); + + it('reports blockHeight 0 for an unconfirmed payment', async () => { + const { wallet } = setupRegularBranch([makeRegularTx({ confirmations: 0 })]); + + const result = await wallet.scanByTxid(TXID); + + expect(result.found).toBe(true); + expect(result.blockHeight).toBe(0); + expect(result.tipHeight).toBe(850000); + }); + + it('does not report an outgoing transaction as a payment', async () => { + const { wallet } = setupRegularBranch([makeRegularTx({ value: -50000 })]); + expect(await wallet.scanByTxid(TXID)).toEqual(NOT_FOUND); + }); + + it('does not report a transaction the wallet does not know after a sync', async () => { + const { wallet, fetchTransactions } = setupRegularBranch([]); + expect(await wallet.scanByTxid(TXID)).toEqual(NOT_FOUND); + expect(fetchTransactions).toHaveBeenCalled(); + }); +});