Skip to content
Open
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
30 changes: 30 additions & 0 deletions class/wallets/hd-bip352-wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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();
Expand Down Expand Up @@ -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;
Expand Down
55 changes: 55 additions & 0 deletions tests/unit/sp-spent-check.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading