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:
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user