From a7495fb7c6f6395c6d535083b5c599b099b3222a Mon Sep 17 00:00:00 2001 From: Vaibhav Date: Fri, 9 Oct 2026 17:25:59 +0530 Subject: [PATCH] fix(sp): mark silent payment coins spent elsewhere as spent A restored wallet, or one whose coins were spent from another device, kept counting those silent payment coins as unspent: the wallet only learned about spends it broadcast itself. After each scan, check each unspent SP coin's address on Electrum and mark it spent when Electrum knows the funding transaction but no longer lists the coin as unspent. Co-Authored-By: Claude Opus 5.5 (1M context) --- class/wallets/hd-bip352-wallet.ts | 30 +++++++++++++++++ tests/unit/sp-spent-check.test.ts | 55 +++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 tests/unit/sp-spent-check.test.ts 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); + }); +});