Prune spent silent-payment UTXOs from HD wallet storage after a send

Blockbook's xpub scan can't observe silent-payment outputs, so when the
send flow consumes one, nothing on the chain-scan side removes it from
the wallet's local NIP-78 UTXO doc. The previous `onSuccess` only
invalidated the doc query, but the relay copy still contained the spent
UTXO and `mergeUtxos` is insert-only — so the entry never went away.

The visible symptom: spending SP UTXOs made the wallet balance go *up*.
The send tx routed change to a fresh BIP-86 address, which credited to
Blockbook's xpub balance, while `silentPaymentBalance` kept counting
the consumed SP UTXOs as still spendable.

Surface the actually-consumed SP `(txid, vout)` set from
`buildHdSpendPsbt`, thread it through the send mutation, and have
`useHdWalletSp` apply a prune+republish that also strips the same
entries from the remote doc before merging (otherwise insert-only
`mergeUtxos` would re-add them on the next read-modify-write).

Regression-of: 3adfe5d8
This commit is contained in:
Alex Gleason
2026-05-22 11:36:26 -05:00
parent 553edf761e
commit c983d406c9
5 changed files with 144 additions and 10 deletions
+21 -5
View File
@@ -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) => {
+7
View File
@@ -90,6 +90,12 @@ export interface UseHdWalletResult {
refetch: () => Promise<unknown>;
/** 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,
};
}
+85 -5
View File
@@ -18,6 +18,7 @@ import {
matchedUtxoToStored,
mergeUtxos,
parseSPStorage,
pruneSpUtxos,
serializeSPStorage,
type SPStorageDocument,
type SPStoredUtxo,
@@ -82,6 +83,18 @@ export interface UseHdWalletSpResult {
scanRecent: () => Promise<void>;
/** 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<SPStorageDocument | undefined>(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<UseHdWalletSpResult['pruneSpentUtxos']>(
(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,
};
}
+19
View File
@@ -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<SPStoredUtxo>,
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 };
+12
View File
@@ -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,
};
}