diff --git a/class/wallets/hd-bip352-wallet.ts b/class/wallets/hd-bip352-wallet.ts index 2728d38a5..7f6c235c1 100644 --- a/class/wallets/hd-bip352-wallet.ts +++ b/class/wallets/hd-bip352-wallet.ts @@ -349,6 +349,35 @@ export class HDSilentPaymentsWallet extends HDTaprootWallet implements IScannabl return true; } + /** + * The indexer doesn't report spends, and markUTXOAsSpent only sees this device's own broadcasts, + * so a restored wallet would count SP coins spent long ago. Every SP output has its own taproot + * address, so ask Electrum: a coin it knows (funding tx in the address history) but no longer + * lists as unspent is spent. A coin Electrum hasn't seen yet is left alone. + */ + private async markSpentSilentPaymentUTXOs(): Promise { + const utxos = this.getUTXOs(); + if (utxos.length === 0) return; + + try { + const addresses = [...new Set(utxos.map(u => u.address))]; + const [unspent, history] = await Promise.all([ + Electrum.multiGetUtxoByAddress(addresses), + Electrum.multiGetHistoryByAddress(addresses), + ]); + + for (const utxo of utxos) { + const known = history[utxo.address]?.some(h => h.tx_hash === utxo.txid); + const listed = unspent[utxo.address]; + if (known && listed && !listed.some(u => u.txid === utxo.txid && u.vout === utxo.vout)) { + this.markUTXOAsSpent(utxo.txid, utxo.vout); + } + } + } catch (error) { + console.warn('[SP] Could not check SP coins for spends:', error); + } + } + private ensurePendingInputsInitialized(): void { if (!this._sp_pending_inputs || !(this._sp_pending_inputs instanceof Set)) { this._sp_pending_inputs = new Set(); @@ -620,6 +649,7 @@ export class HDSilentPaymentsWallet extends HDTaprootWallet implements IScannabl try { const result = await this.activeScanPromise; + await this.markSpentSilentPaymentUTXOs(); return result; } finally { this.activeScanPromise = null; diff --git a/tests/unit/sp-spent-check.test.ts b/tests/unit/sp-spent-check.test.ts new file mode 100644 index 000000000..ac497e5b4 --- /dev/null +++ b/tests/unit/sp-spent-check.test.ts @@ -0,0 +1,55 @@ +import * as Electrum from '../../modules/Electrum'; +import { HDSilentPaymentsWallet } from '../../class/wallets/hd-bip352-wallet.ts'; +import { type SilentPaymentUTXO } from '../../helpers/silent-payments/types.ts'; + +jest.mock('../../modules/Electrum', () => ({ + multiGetUtxoByAddress: jest.fn(), + multiGetHistoryByAddress: jest.fn(), +})); + +const coin = (txid: string, address: string): SilentPaymentUTXO => ({ + txid, + vout: 0, + value: 1000, + height: 1, + address, + silentPaymentAddress: '', + pubKey: '', + tweak: new Uint8Array(32), + blockHash: '', + blockTime: 0, + isSpent: false, +}); + +describe('markSpentSilentPaymentUTXOs', () => { + it('marks only coins Electrum knows but no longer lists as unspent', async () => { + const wallet = new HDSilentPaymentsWallet(); + // a: spent elsewhere · b: still unspent · c: Electrum hasn't seen its funding tx yet + (wallet as any)._utxo = [coin('aa', 'addr-a'), coin('bb', 'addr-b'), coin('cc', 'addr-c')]; + + (Electrum.multiGetUtxoByAddress as jest.Mock).mockResolvedValue({ + 'addr-a': [], + 'addr-b': [{ txid: 'bb', vout: 0 }], + 'addr-c': [], + }); + (Electrum.multiGetHistoryByAddress as jest.Mock).mockResolvedValue({ + 'addr-a': [{ tx_hash: 'aa' }, { tx_hash: 'spender' }], + 'addr-b': [{ tx_hash: 'bb' }], + 'addr-c': [], + }); + + await (wallet as any).markSpentSilentPaymentUTXOs(); + + expect(wallet.getUTXOs().map(u => u.txid)).toEqual(['bb', 'cc']); + }); + + it('changes nothing when Electrum fails', async () => { + const wallet = new HDSilentPaymentsWallet(); + (wallet as any)._utxo = [coin('aa', 'addr-a')]; + (Electrum.multiGetUtxoByAddress as jest.Mock).mockRejectedValue(new Error('offline')); + + await (wallet as any).markSpentSilentPaymentUTXOs(); + + expect(wallet.getUTXOs()).toHaveLength(1); + }); +});