diff --git a/src/components/HDSendBitcoinDialog.tsx b/src/components/HDSendBitcoinDialog.tsx index 3941e15c..c10efb3a 100644 --- a/src/components/HDSendBitcoinDialog.tsx +++ b/src/components/HDSendBitcoinDialog.tsx @@ -173,6 +173,14 @@ interface SendResult { txid: string; amountSats: number; fee: number; + /** + * Silent-payment UTXOs (`(txid, vout)`) consumed by the broadcast tx. + * Pruned from local SP storage in `onSuccess` — otherwise the wallet + * would keep treating them as spendable and the displayed balance would + * jump *up* after the spend (because the BIP-86 change credits to + * Blockbook's xpub balance while the SP entries remain locally). + */ + consumedSpUtxos: Array<{ txid: string; vout: number }>; } /** @@ -190,6 +198,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin silentPaymentBalance, silentPaymentStorage, refetch: refetchWallet, + pruneSpentSilentPaymentUtxos, } = useHdWallet(); const { config } = useAppContext(); const { blockbookBaseUrl } = config; @@ -388,16 +397,23 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin setProgress('broadcasting'); const txid = await broadcastBlockbookTx(blockbookBaseUrl, txHex); - return { txid, amountSats, fee: built.fee }; + return { txid, amountSats, fee: built.fee, consumedSpUtxos: built.consumedSpUtxos }; }, onSuccess: (result) => { notificationSuccess(); setSuccess(result); queryClient.invalidateQueries({ queryKey: ['hdwallet-scan'] }); - // SP storage is keyed by `(txid, vout)`; the next scan will mark - // spent UTXOs as gone. Invalidate the doc query so the optimistic - // copy is dropped in favour of the relay copy on the next scan. - queryClient.invalidateQueries({ queryKey: ['hdwallet-sp-doc'] }); + // Remove the SP UTXOs we just spent from local storage and + // republish the NIP-78 doc. Blockbook's xpub scan can't see SP + // outputs, so without this the spent UTXOs would linger forever: + // the balance would still count them, the coin selector would try + // to spend them again (resulting in "missing/spent input" broadcast + // errors), and the wallet would appear to *gain* money on each SP + // spend (BIP-86 change is observed by Blockbook, but the consumed + // SP value is not subtracted locally). + if (result.consumedSpUtxos.length > 0) { + pruneSpentSilentPaymentUtxos(result.consumedSpUtxos); + } void refetchWallet(); }, onError: (err) => { diff --git a/src/hooks/useHdWallet.ts b/src/hooks/useHdWallet.ts index 4ef2c654..2610b6e7 100644 --- a/src/hooks/useHdWallet.ts +++ b/src/hooks/useHdWallet.ts @@ -90,6 +90,12 @@ export interface UseHdWalletResult { refetch: () => Promise; /** Advance the receive cursor to the next unused address. Persisted. */ nextReceiveAddress: () => DerivedAddress | undefined; + /** + * Drop the given SP UTXOs from local storage and republish so other + * devices stay in sync. Call after a successful spend that consumed + * silent-payment UTXOs — see `useHdWalletSp.pruneSpentUtxos`. + */ + pruneSpentSilentPaymentUtxos: (spent: ReadonlyArray<{ txid: string; vout: number }>) => void; } // --------------------------------------------------------------------------- @@ -263,5 +269,6 @@ export function useHdWallet(): UseHdWalletResult { error: scanError, refetch, nextReceiveAddress, + pruneSpentSilentPaymentUtxos: sp.pruneSpentUtxos, }; } diff --git a/src/hooks/useHdWalletSp.ts b/src/hooks/useHdWalletSp.ts index 0f77bf36..45b25d58 100644 --- a/src/hooks/useHdWalletSp.ts +++ b/src/hooks/useHdWalletSp.ts @@ -18,6 +18,7 @@ import { matchedUtxoToStored, mergeUtxos, parseSPStorage, + pruneSpUtxos, serializeSPStorage, type SPStorageDocument, type SPStoredUtxo, @@ -82,6 +83,18 @@ export interface UseHdWalletSpResult { scanRecent: () => Promise; /** Abort an in-flight scan. */ cancelScan: () => void; + + /** + * Drop the given SP UTXOs from local storage and republish the NIP-78 + * document so other devices stay in sync. + * + * Called by the send flow after a successful broadcast — Blockbook's + * xpub-scoped scan can't observe silent-payment outputs, so without + * this the wallet has no way to learn that an SP UTXO it just spent is + * gone. Failure to call it (or to publish) results in stale balance and + * subsequent double-spend attempts. + */ + pruneSpentUtxos: (spent: ReadonlyArray<{ txid: string; vout: number }>) => void; } const EMPTY_RESULT: UseHdWalletSpResult = { @@ -92,6 +105,7 @@ const EMPTY_RESULT: UseHdWalletSpResult = { scanRange: async () => {}, scanRecent: async () => {}, cancelScan: () => {}, + pruneSpentUtxos: () => {}, }; export function useHdWalletSp(): UseHdWalletSpResult { @@ -215,8 +229,17 @@ export function useHdWalletSp(): UseHdWalletSpResult { }, [enabled, storageDocQuery.data]); // ── Mutation: persist a new document to relays ─────────────── + // + // Optionally accepts a list of `(txid, vout)` entries that were spent + // locally; these are stripped from the remote-merged document too, so + // the canonical published copy actually loses the spent UTXOs instead + // of having them merged back in by `mergeUtxos` (which is insert-only). const publishStorage = useMutation({ - mutationFn: async (next: SPStorageDocument) => { + mutationFn: async (args: { + next: SPStorageDocument; + spent?: ReadonlyArray<{ txid: string; vout: number }>; + }) => { + const { next, spent } = args; if (!user) throw new Error('not logged in'); if (!user.signer.nip44) throw new Error('signer does not support NIP-44'); // Always read-modify-write off the freshest event so a concurrent device @@ -231,10 +254,15 @@ export function useHdWalletSp(): UseHdWalletSpResult { try { const decrypted = await user.signer.nip44.decrypt(user.pubkey, prev.content); const remote = parseSPStorage(decrypted); + // Prune any spent UTXOs from the remote *before* the merge — + // otherwise insert-only `mergeUtxos` would re-add them. + const remoteUtxos = spent && spent.length > 0 + ? pruneSpUtxos(remote.utxos, spent) + : remote.utxos; merged = { version: SP_STORAGE_VERSION, scanHeight: Math.max(remote.scanHeight, next.scanHeight), - utxos: mergeUtxos(remote.utxos, next.utxos), + utxos: mergeUtxos(remoteUtxos, next.utxos), }; } catch { // Treat undecryptable remote as empty rather than blocking the write. @@ -285,7 +313,7 @@ export function useHdWalletSp(): UseHdWalletSpResult { } const doc = optimisticRef.current; if (!doc) return; - publishStorage.mutate(doc); + publishStorage.mutate({ next: doc }); }, [publishStorage]); const scheduleRepublish = useCallback(() => { @@ -293,7 +321,7 @@ export function useHdWalletSp(): UseHdWalletSpResult { republishTimerRef.current = setTimeout(() => { republishTimerRef.current = null; const doc = optimisticRef.current; - if (doc) publishStorage.mutate(doc); + if (doc) publishStorage.mutate({ next: doc }); }, 5000); }, [publishStorage]); @@ -434,6 +462,57 @@ export function useHdWalletSp(): UseHdWalletSpResult { const balance = useMemo(() => (storage ? spStorageBalance(storage) : 0), [storage]); + // Keep a stable ref to the latest storage so callbacks called from outside + // the React render cycle (e.g. the send dialog's mutation success handler) + // see the freshest UTXO set without forcing the callback to re-create. + const storageRef = useRef(storage); + storageRef.current = storage; + + // ── Prune spent SP UTXOs after a successful broadcast ──────── + // + // The send flow consumes one or more SP UTXOs but Blockbook's xpub scan + // can't observe them — they sit in the NIP-78 doc forever unless we + // remove them explicitly. Without this, `balance` would keep counting + // the spent UTXOs and the coin selector would offer them again on the + // next send (producing a "missing/spent input" broadcast failure), all + // while Blockbook's view of the BIP-86 change credits to total balance, + // so the wallet appears to *gain* money after a spend. + const pruneSpentUtxos = useCallback( + (spent) => { + if (!spent.length) return; + // Cancel any pending debounced republish — its document snapshot + // doesn't know about the prune. + if (republishTimerRef.current) { + clearTimeout(republishTimerRef.current); + republishTimerRef.current = null; + } + const base = storageRef.current ?? optimisticRef.current; + if (!base) return; + const next: SPStorageDocument = { + version: SP_STORAGE_VERSION, + scanHeight: base.scanHeight, + utxos: pruneSpUtxos(base.utxos, spent), + }; + optimisticRef.current = next; + setOptimisticVersion((v) => v + 1); + // Also write the pruned doc directly into the doc-query cache so + // the `storage` memo doesn't briefly fall back to the unpruned + // relay copy while the publish round-trip is in flight (the + // optimistic-preference heuristic uses `utxos.length` to decide + // freshness, and a prune *shrinks* the list). + const eventId = queryClient.getQueryData<{ id?: string } | null>([ + 'hdwallet-sp-event', + pubkey, + dTag, + ])?.id; + if (eventId) { + queryClient.setQueryData(['hdwallet-sp-doc', eventId], next); + } + publishStorage.mutate({ next, spent }); + }, + [publishStorage, queryClient, pubkey, dTag], + ); + // ── Backfill missing block timestamps ──────────────────────── // // Older docs (written before SP UTXOs carried `time`) and any UTXOs that @@ -497,7 +576,7 @@ export function useHdWalletSp(): UseHdWalletSpResult { // republish so other devices pick up the backfilled timestamps. optimisticRef.current = next; setOptimisticVersion((v) => v + 1); - publishStorage.mutate(next); + publishStorage.mutate({ next }); })(); return () => controller.abort(); @@ -524,5 +603,6 @@ export function useHdWalletSp(): UseHdWalletSpResult { scanRange, scanRecent, cancelScan, + pruneSpentUtxos, }; } diff --git a/src/lib/hdwallet/sp/storage.ts b/src/lib/hdwallet/sp/storage.ts index 689d937a..ee4e9c55 100644 --- a/src/lib/hdwallet/sp/storage.ts +++ b/src/lib/hdwallet/sp/storage.ts @@ -206,5 +206,24 @@ export function spStorageBalance(doc: SPStorageDocument): number { return total; } +/** + * Remove the given `(txid, vout)` entries from a UTXO list. Used after a + * successful spend to drop the SP UTXOs the wallet just consumed — without + * this, `spStorageBalance` would still count them, the coin selector would + * still treat them as spendable, and the wallet's overall balance would + * appear to *increase* after the spend (because the BIP-86 change output + * lands in Blockbook's xpub balance while the consumed SP entries remain + * locally tracked). Matching the published-document semantics here keeps + * other devices in sync via the next NIP-78 republish. + */ +export function pruneSpUtxos( + existing: ReadonlyArray, + spent: ReadonlyArray<{ txid: string; vout: number }>, +): SPStoredUtxo[] { + if (spent.length === 0) return existing.slice(); + const spentKeys = new Set(spent.map((s) => `${s.txid}:${s.vout}`)); + return existing.filter((u) => !spentKeys.has(`${u.txid}:${u.vout}`)); +} + // Re-export hex helpers for callers that want to read tweak bytes back. export { hexToBytes, bytesToHex }; diff --git a/src/lib/hdwallet/transaction.ts b/src/lib/hdwallet/transaction.ts index 235a2b10..a457ea74 100644 --- a/src/lib/hdwallet/transaction.ts +++ b/src/lib/hdwallet/transaction.ts @@ -145,6 +145,15 @@ export interface HdUnsignedPsbt { * `P_k`, which differs from the original `sp1…` string the user typed. */ resolvedRecipientAddress: string; + /** + * The silent-payment UTXOs (by `(txid, vout)`) actually consumed by this + * PSBT, in input order. Empty when the build selected no SP inputs. The + * caller uses this to prune the spent UTXOs from local SP storage after + * a successful broadcast — Blockbook's xpub scan can't observe SP + * outputs, so the wallet has to do this bookkeeping itself, otherwise + * the balance would still count them. + */ + consumedSpUtxos: Array<{ txid: string; vout: number }>; } /** Per-input descriptor stored alongside the PSBT for signing dispatch. */ @@ -462,6 +471,7 @@ export function buildHdSpendPsbt(args: BuildHdSpendArgs): HdUnsignedPsbt { const tx = new btc.Transaction(); const inputDescriptors: HdInputDescriptor[] = []; + const consumedSpUtxos: Array<{ txid: string; vout: number }> = []; // For SP recipients we need each input's tweaked private key to derive // the per-recipient output P_k. Compute and stash them up front so we @@ -535,6 +545,7 @@ export function buildHdSpendPsbt(args: BuildHdSpendArgs): HdUnsignedPsbt { // signer in `signHdPsbt` writes `tapKeySig` directly instead. }); inputDescriptors.push({ kind: 'sp', tweakHex: utxo.tweakHex }); + consumedSpUtxos.push({ txid: utxo.txid, vout: utxo.vout }); if (recipient.kind === 'sp') { // d_k is also the BIP-352 input scalar — it's already the actual @@ -593,6 +604,7 @@ export function buildHdSpendPsbt(args: BuildHdSpendArgs): HdUnsignedPsbt { changeAddress, inputDescriptors, resolvedRecipientAddress, + consumedSpUtxos, }; }