From 357e18e06342cd71b95ddec80918c7b30241da63 Mon Sep 17 00:00:00 2001
From: Alex Gleason
Date: Fri, 22 May 2026 13:55:28 -0500
Subject: [PATCH] Keep SP receives in the tx history after the UTXO is spent
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The transaction list derived SP receive rows from the active UTXO set,
so a UTXO that got pruned (either by a send or by the manual reconcile
pass) silently vanished from history. The spending transaction itself
also mis-classified: Blockbook's xpub scan sees only the BIP-86 change
output, so a self-send appeared as a small unsolicited receive.
Archive SP UTXOs instead of deleting them:
- `SPStorageDocument` gains an optional `spent: SPStoredUtxo[]`
list; the parser, serialiser, and the publish-time merge handle it
alongside `utxos`.
- `pruneSpentUtxos` moves entries from `utxos` to `spent` via the
new `archiveSpentUtxos` helper rather than dropping them.
- The optimistic-vs-loaded heuristic compares combined (`utxos` +
`spent`) counts so a prune that shrinks `utxos` while growing
`spent` doesn't accidentally fall back to the stale relay copy.
Use the archive to fix the tx-history UI:
- The receive-history builder in `useHdWallet` merges active +
archived SP UTXOs, so historical receives stay visible.
- `buildHdTransactions` is reworked to do per-Blockbook-tx accounting
using raw `vin`/`vout` data (now plumbed through
`AccountScanResult.rawTransactions`). It accepts a map of SP
outpoints we own (active + archived) and subtracts `outflowsSp`
from the net delta — a tx whose vin matches one of our SP UTXOs
flips from 'receive of change' to 'send' with the correct amount.
Add a deep-rescan recovery path for state that was pruned before the
archive logic shipped. The BIP-352 indexer fetchers gain an
`includeSpent` flag; spent-flagged matches surface in
`SPMatchedUtxo.spent` and the orchestrator routes them straight into
the archive. Exposed as an 'Include already-spent' checkbox in the
existing scan dialog.
Regression-of: 3adfe5d8
---
src/components/HDSilentPaymentScanDialog.tsx | 33 ++++++
src/hooks/useHdWallet.ts | 27 ++++-
src/hooks/useHdWalletSp.ts | 97 ++++++++++++---
src/lib/hdwallet/scan.ts | 118 +++++++++++++------
src/lib/hdwallet/sp/indexer.ts | 22 +++-
src/lib/hdwallet/sp/scanner.ts | 23 +++-
src/lib/hdwallet/sp/storage.ts | 83 ++++++++++++-
7 files changed, 338 insertions(+), 65 deletions(-)
diff --git a/src/components/HDSilentPaymentScanDialog.tsx b/src/components/HDSilentPaymentScanDialog.tsx
index 0cab7794..348eaf7c 100644
--- a/src/components/HDSilentPaymentScanDialog.tsx
+++ b/src/components/HDSilentPaymentScanDialog.tsx
@@ -9,6 +9,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
+import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Progress } from '@/components/ui/progress';
@@ -34,11 +35,13 @@ export function HDSilentPaymentScanDialog({ open, onOpenChange }: HDSilentPaymen
const [from, setFrom] = useState('');
const [to, setTo] = useState('');
const [touched, setTouched] = useState(false);
+ const [includeSpent, setIncludeSpent] = useState(false);
// Seed defaults whenever the dialog opens or upstream data changes.
useEffect(() => {
if (!open) {
setTouched(false);
+ setIncludeSpent(false);
return;
}
if (touched) return;
@@ -60,6 +63,7 @@ export function HDSilentPaymentScanDialog({ open, onOpenChange }: HDSilentPaymen
await sp.scanRange({
fromHeight: fromNum,
toHeight: to === '' ? undefined : toNum,
+ includeSpent,
});
};
@@ -140,6 +144,35 @@ export function HDSilentPaymentScanDialog({ open, onOpenChange }: HDSilentPaymen
)}
+ {/*
+ * "Include already-spent" deep-rescan toggle. Off by default
+ * because the normal scan path doesn't want already-spent
+ * outputs cluttering the active UTXO set. Turn on to recover
+ * historical receive rows whose UTXOs were later spent and
+ * subsequently pruned from local storage — matches against
+ * spent outputs are routed straight into the `spent` archive,
+ * which powers both the receive-history rows and the
+ * send-vs-receive classifier in the tx list.
+ */}
+
+ Also detect silent payments that have since been spent. Use when
+ rebuilding receive history after a missed scan or a reset.
+
+
+
+
{sp.isScanning && sp.scanProgress && (
diff --git a/src/hooks/useHdWallet.ts b/src/hooks/useHdWallet.ts
index 2610b6e7..16955918 100644
--- a/src/hooks/useHdWallet.ts
+++ b/src/hooks/useHdWallet.ts
@@ -172,15 +172,36 @@ export function useHdWallet(): UseHdWalletResult {
const transactions = useMemo(() => {
if (!scan && !sp.storage) return undefined;
- const bip86 = scan ? buildHdTransactions(scan) : [];
+ // Build the SP outpoint → value map (active + archived) so the BIP-86
+ // tx classifier can detect transactions that spent our SP UTXOs and
+ // mark them as sends instead of mis-attributing the BIP-86 change
+ // output as an unsolicited receive.
+ const spOutpoints = new Map();
+ const archivedSpUtxos = sp.storage?.spent ?? [];
+ for (const u of sp.storage?.utxos ?? []) {
+ spOutpoints.set(`${u.txid}:${u.vout}`, u.value);
+ }
+ for (const u of archivedSpUtxos) {
+ // Don't clobber a value already recorded from the active set (in the
+ // unlikely event of overlap).
+ if (!spOutpoints.has(`${u.txid}:${u.vout}`)) {
+ spOutpoints.set(`${u.txid}:${u.vout}`, u.value);
+ }
+ }
+
+ const bip86 = scan ? buildHdTransactions(scan, spOutpoints) : [];
// Group SP UTXOs by txid and sum to keep the row shape consistent with
- // the rest of the wallet (one row per tx, not per output).
+ // the rest of the wallet (one row per tx, not per output). Include
+ // archived (spent) UTXOs so the receive history doesn't disappear when
+ // a UTXO is later spent — the original receive is still real wallet
+ // activity worth showing.
const spByTxid = new Map<
string,
{ amount: number; height: number; time?: number }
>();
- for (const u of sp.storage?.utxos ?? []) {
+ const allSpUtxos = [...(sp.storage?.utxos ?? []), ...archivedSpUtxos];
+ for (const u of allSpUtxos) {
const existing = spByTxid.get(u.txid);
if (existing) {
existing.amount += u.value;
diff --git a/src/hooks/useHdWalletSp.ts b/src/hooks/useHdWalletSp.ts
index 4a1403f5..b6e0f63e 100644
--- a/src/hooks/useHdWalletSp.ts
+++ b/src/hooks/useHdWalletSp.ts
@@ -15,6 +15,7 @@ import { fetchBlockEntries, fetchTipHeight } from '@/lib/hdwallet/sp/indexer';
import { scanBatch, type SPMatchedUtxo } from '@/lib/hdwallet/sp/scanner';
import {
EMPTY_SP_STORAGE,
+ archiveSpentUtxos,
matchedUtxoToStored,
mergeUtxos,
parseSPStorage,
@@ -84,8 +85,20 @@ export interface UseHdWalletSpResult {
/** Tip height as reported by the indexer (cached, lightly refreshed). */
tipHeight?: number;
- /** Scan a contiguous block range. `toHeight` defaults to current tip. */
- scanRange: (args: { fromHeight: number; toHeight?: number }) => Promise;
+ /**
+ * Scan a contiguous block range. `toHeight` defaults to current tip.
+ *
+ * `includeSpent` opts into a deeper rescan that also considers UTXOs
+ * already spent on-chain. Matches against spent outputs land in the
+ * `spent` archive rather than the active set — useful for recovering
+ * historical receive rows when the wallet's local doc was pruned
+ * without archiving (e.g. by a build that predates the archive logic).
+ */
+ scanRange: (args: {
+ fromHeight: number;
+ toHeight?: number;
+ includeSpent?: boolean;
+ }) => Promise;
/** Scan the most recent `DEFAULT_RECENT_SCAN_BLOCKS` blocks (or fewer if newer). */
scanRecent: () => Promise;
/** Abort an in-flight scan. */
@@ -259,7 +272,14 @@ export function useHdWalletSp(): UseHdWalletSpResult {
const loaded = storageDocQuery.data;
const opt = optimisticRef.current;
if (!opt) return loaded;
- if (opt.scanHeight >= loaded.scanHeight && opt.utxos.length >= loaded.utxos.length) {
+ // Heuristic: optimistic wins when it's caught up scan-wise AND it
+ // accounts for at least as many entries (active + archived) as the
+ // loaded copy. The combined-count check matters because prunes shrink
+ // `utxos` while growing `spent`, and deep rescans grow `spent` without
+ // touching `utxos`.
+ const optTotal = opt.utxos.length + (opt.spent?.length ?? 0);
+ const loadedTotal = loaded.utxos.length + (loaded.spent?.length ?? 0);
+ if (opt.scanHeight >= loaded.scanHeight && optTotal >= loadedTotal) {
return opt;
}
return loaded;
@@ -296,10 +316,36 @@ export function useHdWalletSp(): UseHdWalletSpResult {
const remoteUtxos = spent && spent.length > 0
? pruneSpUtxos(remote.utxos, spent)
: remote.utxos;
+ // Merge the spent archive: union both sides' archives, plus the
+ // entries we just pruned out of `remote.utxos`. Without this a
+ // racing relay copy could resurrect a row in `utxos` that the
+ // local prune already classified as spent, or drop archive
+ // entries the local copy intentionally retained for history.
+ const localArchive = next.spent ?? [];
+ const remoteArchive = remote.spent ?? [];
+ const archiveByKey = new Map();
+ for (const u of remoteArchive) archiveByKey.set(`${u.txid}:${u.vout}`, u);
+ for (const u of localArchive) {
+ if (!archiveByKey.has(`${u.txid}:${u.vout}`)) {
+ archiveByKey.set(`${u.txid}:${u.vout}`, u);
+ }
+ }
+ // Pull pruned-from-remote rows into the archive too — they're
+ // outpoints we know are spent but the remote didn't realise.
+ if (spent && spent.length > 0) {
+ const spentKeys = new Set(spent.map((s) => `${s.txid}:${s.vout}`));
+ for (const u of remote.utxos) {
+ const k = `${u.txid}:${u.vout}`;
+ if (spentKeys.has(k) && !archiveByKey.has(k)) {
+ archiveByKey.set(k, u);
+ }
+ }
+ }
merged = {
version: SP_STORAGE_VERSION,
scanHeight: Math.max(remote.scanHeight, next.scanHeight),
utxos: mergeUtxos(remoteUtxos, next.utxos),
+ spent: Array.from(archiveByKey.values()),
};
} catch {
// Treat undecryptable remote as empty rather than blocking the write.
@@ -364,7 +410,7 @@ export function useHdWalletSp(): UseHdWalletSpResult {
// ── The core scan loop ───────────────────────────────────────
const scanRange = useCallback(
- async ({ fromHeight, toHeight }) => {
+ async ({ fromHeight, toHeight, includeSpent = false }) => {
if (!enabled || !keys) return;
if (!storage) return; // Wait for the first load — caller can retry.
if (!Number.isInteger(fromHeight) || fromHeight < 0) {
@@ -392,11 +438,12 @@ export function useHdWalletSp(): UseHdWalletSpResult {
});
// Seed the optimistic doc from the current snapshot so we don't lose
- // existing UTXOs while scanning a sparse range.
+ // existing UTXOs (or archive entries) while scanning a sparse range.
optimisticRef.current = {
version: SP_STORAGE_VERSION,
scanHeight: storage.scanHeight,
utxos: storage.utxos.slice(),
+ spent: (storage.spent ?? []).slice(),
};
let matchesFound = 0;
@@ -406,7 +453,12 @@ export function useHdWalletSp(): UseHdWalletSpResult {
for (let h = fromHeight; h <= resolvedTo; h++) {
if (controller.signal.aborted) break;
- const entries = await fetchBlockEntries(indexerUrl, h, controller.signal);
+ const entries = await fetchBlockEntries(
+ indexerUrl,
+ h,
+ controller.signal,
+ includeSpent,
+ );
let blockMatches: SPMatchedUtxo[] = [];
if (entries.length > 0) {
blockMatches = await scanBatch(entries, keys.bscan, keys.Bspend, {
@@ -435,15 +487,27 @@ export function useHdWalletSp(): UseHdWalletSpResult {
}
}
- const opt = optimisticRef.current!;
- const fresh: SPStoredUtxo[] = blockMatches.map((m) => {
+ // Partition matches into "still unspent" (active set) and
+ // "already spent at scan time" (archive). The archive entries
+ // are essential for the tx-history classifier to attribute the
+ // spending tx as a wallet send — without them a deep rescan is
+ // useless for history recovery.
+ const freshActive: SPStoredUtxo[] = [];
+ const freshArchive: SPStoredUtxo[] = [];
+ for (const m of blockMatches) {
const stored = matchedUtxoToStored(m);
- return blockTime !== undefined ? { ...stored, time: blockTime } : stored;
- });
+ const stamped =
+ blockTime !== undefined ? { ...stored, time: blockTime } : stored;
+ if (m.spent) freshArchive.push(stamped);
+ else freshActive.push(stamped);
+ }
+
+ const opt = optimisticRef.current!;
optimisticRef.current = {
version: SP_STORAGE_VERSION,
scanHeight: opt.scanHeight,
- utxos: mergeUtxos(opt.utxos, fresh),
+ utxos: mergeUtxos(opt.utxos, freshActive),
+ spent: mergeUtxos(opt.spent ?? [], freshArchive),
};
matchesFound += blockMatches.length;
}
@@ -525,11 +589,12 @@ export function useHdWalletSp(): UseHdWalletSpResult {
}
const base = storageRef.current ?? optimisticRef.current;
if (!base) return;
- const next: SPStorageDocument = {
- version: SP_STORAGE_VERSION,
- scanHeight: base.scanHeight,
- utxos: pruneSpUtxos(base.utxos, spent),
- };
+ // Archive (don't delete) the pruned entries so the transaction-history
+ // UI can still show their original receive row, and the send-vs-
+ // receive classifier in `buildHdTransactions` can attribute any
+ // future Blockbook tx that referenced one of these outpoints as a
+ // wallet send.
+ const next: SPStorageDocument = archiveSpentUtxos(base, spent);
optimisticRef.current = next;
setOptimisticVersion((v) => v + 1);
// Also write the pruned doc directly into the doc-query cache so
diff --git a/src/lib/hdwallet/scan.ts b/src/lib/hdwallet/scan.ts
index 41614a57..ce652ce7 100644
--- a/src/lib/hdwallet/scan.ts
+++ b/src/lib/hdwallet/scan.ts
@@ -73,6 +73,15 @@ export interface AccountScanResult {
pendingBalance: number;
/** Map from derived address → metadata. */
addressMap: Map;
+ /**
+ * Raw Blockbook tx rows from the xpub response, retained verbatim so
+ * `buildHdTransactions` can do per-tx accounting with `vin`/`vout`
+ * visibility — required to detect SP-input spends (Blockbook doesn't
+ * mark SP outpoints as ours, so the per-address tx-row summary in
+ * `ScannedAddress.txs` would mis-classify those spends as receives of
+ * change).
+ */
+ rawTransactions: BlockbookTx[];
}
/** Aggregated wallet-level transaction row. */
@@ -370,6 +379,7 @@ export async function scanAccount(
totalBalance,
pendingBalance,
addressMap,
+ rawTransactions: xpubResponse.transactions ?? [],
};
}
@@ -378,48 +388,86 @@ export async function scanAccount(
// ---------------------------------------------------------------------------
/**
- * Build the wallet-level merged transaction list from per-address tx rows
- * already collected by `scanAccount`. A tx touching multiple owned addresses
- * (e.g. send-with-change) is summed once.
+ * Build the wallet-level merged transaction list from the raw Blockbook tx
+ * rows. Produces one row per txid, summing wallet inflows and outflows to
+ * compute net direction (`receive` if net ≥ 0, `send` otherwise) and
+ * absolute amount.
+ *
+ * `spOutpoints` lets the caller surface silent-payment UTXOs the wallet
+ * owns (`txid:vout` keys). Without this argument, a tx whose input is one
+ * of our SP UTXOs would be mis-classified as a receive — Blockbook can't
+ * mark SP outpoints as belonging to the wallet (they're not under the
+ * xpub), so any BIP-86 change credit looks like an unsolicited receive.
+ * Pass the union of active + archived SP outpoints so historical spends
+ * stay correctly classified after the SP UTXO is gone from the active set.
*/
-export function buildHdTransactions(result: AccountScanResult): HdTransaction[] {
- const allUsed = [...result.receive.used, ...result.change.used];
- if (allUsed.length === 0) return [];
+export function buildHdTransactions(
+ result: AccountScanResult,
+ spOutpoints?: ReadonlyMap,
+): HdTransaction[] {
+ const ourAddresses = new Set();
+ for (const sa of result.receive.used) ourAddresses.add(sa.derived.address);
+ for (const sa of result.change.used) ourAddresses.add(sa.derived.address);
- const merged = new Map<
- string,
- { txid: string; netSats: number; confirmed: boolean; timestamp?: number }
- >();
+ const out: HdTransaction[] = [];
+ for (const tx of result.rawTransactions) {
+ let inflowsBip86 = 0;
+ let outflowsBip86 = 0;
+ let outflowsSp = 0;
- for (const sa of allUsed) {
- for (const tx of sa.txs) {
- const signed = tx.type === 'receive' ? tx.amount : -tx.amount;
- const existing = merged.get(tx.txid);
- if (existing) {
- existing.netSats += signed;
- existing.confirmed = existing.confirmed || tx.confirmed;
- if (tx.timestamp && (!existing.timestamp || tx.timestamp < existing.timestamp)) {
- existing.timestamp = tx.timestamp;
+ // Sum vouts paying to our BIP-86 addresses.
+ for (const v of tx.vout) {
+ const value = Number(v.value) || 0;
+ if (!value) continue;
+ for (const a of v.addresses ?? []) {
+ if (ourAddresses.has(a)) {
+ inflowsBip86 += value;
+ break;
}
- } else {
- merged.set(tx.txid, {
- txid: tx.txid,
- netSats: signed,
- confirmed: tx.confirmed,
- timestamp: tx.timestamp,
- });
}
}
- }
- const out: HdTransaction[] = Array.from(merged.values()).map((m) => ({
- txid: m.txid,
- amount: Math.abs(m.netSats),
- type: m.netSats >= 0 ? 'receive' : 'send',
- confirmed: m.confirmed,
- timestamp: m.timestamp,
- source: 'bip86',
- }));
+ // Sum vins consuming our BIP-86 UTXOs (by address membership), and SP
+ // UTXOs (by outpoint membership).
+ for (const v of tx.vin) {
+ const value = Number(v.value) || 0;
+ let attributed = false;
+ for (const a of v.addresses ?? []) {
+ if (ourAddresses.has(a)) {
+ outflowsBip86 += value;
+ attributed = true;
+ break;
+ }
+ }
+ if (attributed) continue;
+ if (
+ spOutpoints &&
+ typeof v.txid === 'string' &&
+ typeof v.vout === 'number'
+ ) {
+ const spValue = spOutpoints.get(`${v.txid}:${v.vout}`);
+ if (spValue !== undefined) {
+ // Trust the wallet's own stored value for SP inputs — Blockbook
+ // doesn't always populate `vin.value` for taproot inputs.
+ outflowsSp += spValue || value;
+ }
+ }
+ }
+
+ // A tx with no wallet involvement at all shouldn't be in the xpub
+ // response, but defensively skip if so.
+ if (inflowsBip86 === 0 && outflowsBip86 === 0 && outflowsSp === 0) continue;
+
+ const net = inflowsBip86 - outflowsBip86 - outflowsSp;
+ out.push({
+ txid: tx.txid,
+ amount: Math.abs(net),
+ type: net >= 0 ? 'receive' : 'send',
+ confirmed: tx.confirmations > 0,
+ timestamp: tx.blockTime,
+ source: 'bip86',
+ });
+ }
out.sort((a, b) => {
if (!a.timestamp && !b.timestamp) return 0;
diff --git a/src/lib/hdwallet/sp/indexer.ts b/src/lib/hdwallet/sp/indexer.ts
index 4efb0ded..608ea092 100644
--- a/src/lib/hdwallet/sp/indexer.ts
+++ b/src/lib/hdwallet/sp/indexer.ts
@@ -75,6 +75,14 @@ interface ParsedUtxo {
vout: number;
xonlyPk: Uint8Array;
value: number;
+ /**
+ * True if BlindBit marked this row as already spent at the time of fetch.
+ * The default scan path filters these out; the "include spent" path keeps
+ * them so historical receives whose UTXOs were later spent can be
+ * recovered into the archive (and used by the tx classifier to attribute
+ * the spending tx as a send).
+ */
+ spent: boolean;
}
function parseUtxoRow(raw: BlindBitUtxoRow): ParsedUtxo | null {
@@ -89,6 +97,7 @@ function parseUtxoRow(raw: BlindBitUtxoRow): ParsedUtxo | null {
vout: raw.vout,
xonlyPk: xonly,
value: raw.value,
+ spent: raw.spent === true,
};
}
@@ -133,6 +142,7 @@ async function fetchUtxosForBlock(
root: string,
height: number,
signal?: AbortSignal,
+ includeSpent = false,
): Promise {
const r = await fetch(`${root}/utxos/${height}`, { signal });
if (!r.ok) throw new Error(`BIP-352 /utxos/${height} returned ${r.status}`);
@@ -142,9 +152,12 @@ async function fetchUtxosForBlock(
}
const out: ParsedUtxo[] = [];
for (const raw of data as BlindBitUtxoRow[]) {
- // Filter out already-spent rows so we don't add them to the wallet's
- // active UTXO set just to have a future reconcile pass prune them.
- if (raw && raw.spent === true) continue;
+ // Default: filter out already-spent rows so we don't add them to the
+ // wallet's active UTXO set just to have a future reconcile pass prune
+ // them. The "recover history" flow flips this filter off so the
+ // scanner can identify outputs we received and then later spent, and
+ // route them into the `spent` archive.
+ if (!includeSpent && raw && raw.spent === true) continue;
const parsed = parseUtxoRow(raw);
if (parsed) out.push(parsed);
}
@@ -189,12 +202,13 @@ export async function fetchBlockEntries(
baseUrl: string,
height: number,
signal?: AbortSignal,
+ includeSpent = false,
): Promise {
const root = requireBase(baseUrl);
const tweaks = await fetchTweaksForBlock(root, height, signal);
if (tweaks.length === 0) return [];
if (signal?.aborted) return [];
- const utxos = await fetchUtxosForBlock(root, height, signal);
+ const utxos = await fetchUtxosForBlock(root, height, signal, includeSpent);
if (utxos.length === 0) return [];
const sharedOutputs: ScanTweakEntry['outputs'] = utxos;
diff --git a/src/lib/hdwallet/sp/scanner.ts b/src/lib/hdwallet/sp/scanner.ts
index 9945847b..e6dc278b 100644
--- a/src/lib/hdwallet/sp/scanner.ts
+++ b/src/lib/hdwallet/sp/scanner.ts
@@ -42,8 +42,21 @@ export interface ScanTweakEntry {
* Candidate Taproot outputs. Each output carries its txid so the matched
* UTXO can be attributed correctly even when the indexer pools outputs
* from multiple txs against the same tweak.
+ *
+ * `spent` is the indexer's view of whether the output has been consumed
+ * by a later transaction. Default scans ignore spent outputs entirely
+ * (the orchestrator filters them out before they reach here). When the
+ * caller opts into recovering history, spent outputs are included and
+ * the orchestrator routes their matches into the spent archive instead
+ * of the active set.
*/
- outputs: ReadonlyArray<{ txid: string; vout: number; xonlyPk: Uint8Array; value: number }>;
+ outputs: ReadonlyArray<{
+ txid: string;
+ vout: number;
+ xonlyPk: Uint8Array;
+ value: number;
+ spent?: boolean;
+ }>;
}
/** A UTXO the scanner determined belongs to us. */
@@ -58,6 +71,13 @@ export interface SPMatchedUtxo {
tweak: Uint8Array;
/** Output index within the transaction's SP output set (k = 0, 1, …). */
k: number;
+ /**
+ * True if the matching candidate output was marked spent by the indexer
+ * at scan time. The orchestrator uses this to route the match into the
+ * archive instead of the active set — preserves history (for the tx
+ * list) without offering a spent UTXO to the coin selector.
+ */
+ spent?: boolean;
}
/**
@@ -115,6 +135,7 @@ export function scanTransaction(
height: entry.height,
tweak: tk,
k,
+ ...(o.spent ? { spent: true } : {}),
});
remaining.delete(matchedIdx);
k += 1;
diff --git a/src/lib/hdwallet/sp/storage.ts b/src/lib/hdwallet/sp/storage.ts
index ee4e9c55..fc1f00e4 100644
--- a/src/lib/hdwallet/sp/storage.ts
+++ b/src/lib/hdwallet/sp/storage.ts
@@ -87,8 +87,20 @@ export interface SPStorageDocument {
* `scanHeight + 1`. `0` means "never scanned".
*/
scanHeight: number;
- /** All discovered SP UTXOs the wallet has not pruned as spent. */
+ /** All discovered SP UTXOs the wallet still considers spendable. */
utxos: SPStoredUtxo[];
+ /**
+ * SP UTXOs that have been confirmed spent (either by the local send flow
+ * or by the manual reconcile pass). Retained here — rather than deleted —
+ * so the transaction-history UI can still show the original receive, and
+ * the Blockbook-tx classifier can attribute later spends correctly
+ * (Blockbook can't tell us an input came from our wallet for SP inputs,
+ * because their scriptpubkey isn't under the xpub).
+ *
+ * Optional for backward compatibility with pre-archive docs; readers
+ * should default to `[]`.
+ */
+ spent?: SPStoredUtxo[];
}
/** Empty document used as the starting state. */
@@ -96,6 +108,7 @@ export const EMPTY_SP_STORAGE: SPStorageDocument = {
version: SP_STORAGE_VERSION,
scanHeight: 0,
utxos: [],
+ spent: [],
};
// ---------------------------------------------------------------------------
@@ -119,9 +132,16 @@ export function parseSPStorage(plaintext: string): SPStorageDocument {
const scanHeight = typeof obj.scanHeight === 'number' && Number.isInteger(obj.scanHeight) && obj.scanHeight >= 0
? obj.scanHeight
: 0;
- const utxosRaw = Array.isArray(obj.utxos) ? obj.utxos : [];
- const utxos: SPStoredUtxo[] = [];
- for (const u of utxosRaw) {
+ const utxos = parseUtxoArray(obj.utxos);
+ const spent = parseUtxoArray(obj.spent);
+ return { version: SP_STORAGE_VERSION, scanHeight, utxos, spent };
+}
+
+/** Shared validator for both the active and archived UTXO lists. */
+function parseUtxoArray(raw: unknown): SPStoredUtxo[] {
+ const rows = Array.isArray(raw) ? raw : [];
+ const out: SPStoredUtxo[] = [];
+ for (const u of rows) {
if (!u || typeof u !== 'object') continue;
const row = u as Record;
if (typeof row.txid !== 'string' || !/^[0-9a-f]{64}$/.test(row.txid)) continue;
@@ -134,7 +154,7 @@ export function parseSPStorage(plaintext: string): SPStorageDocument {
typeof row.time === 'number' && Number.isInteger(row.time) && row.time > 0
? row.time
: undefined;
- utxos.push({
+ out.push({
txid: row.txid,
vout: row.vout,
value: row.value,
@@ -144,7 +164,7 @@ export function parseSPStorage(plaintext: string): SPStorageDocument {
...(time !== undefined ? { time } : {}),
});
}
- return { version: SP_STORAGE_VERSION, scanHeight, utxos };
+ return out;
}
/** Serialise a document for encryption — pretty-printed for slightly better diff-ability. */
@@ -153,6 +173,9 @@ export function serializeSPStorage(doc: SPStorageDocument): string {
version: SP_STORAGE_VERSION,
scanHeight: doc.scanHeight,
utxos: doc.utxos,
+ // Always emit `spent` (as `[]` when empty) so downstream consumers can
+ // rely on it being present after a round-trip.
+ spent: doc.spent ?? [],
});
}
@@ -225,5 +248,53 @@ export function pruneSpUtxos(
return existing.filter((u) => !spentKeys.has(`${u.txid}:${u.vout}`));
}
+/**
+ * Move the given `(txid, vout)` entries from a document's active `utxos`
+ * list to its `spent` archive, deduplicated against any existing archive
+ * entries.
+ *
+ * The archive is what powers the receive-history row for outputs we no
+ * longer hold AND the send-vs-receive classifier in
+ * `buildHdTransactions` (a Blockbook tx whose input is one of our
+ * archived SP UTXOs is a send, not a receive of change).
+ *
+ * Entries listed in `spent` that aren't in `existing.utxos` are silently
+ * skipped — the active set wins as the source of truth for what to move.
+ */
+export function archiveSpentUtxos(
+ doc: SPStorageDocument,
+ spent: ReadonlyArray<{ txid: string; vout: number }>,
+): SPStorageDocument {
+ if (spent.length === 0) return doc;
+ const spentKeys = new Set(spent.map((s) => `${s.txid}:${s.vout}`));
+ const remaining: SPStoredUtxo[] = [];
+ const toArchive: SPStoredUtxo[] = [];
+ for (const u of doc.utxos) {
+ if (spentKeys.has(`${u.txid}:${u.vout}`)) {
+ toArchive.push(u);
+ } else {
+ remaining.push(u);
+ }
+ }
+ if (toArchive.length === 0) return doc;
+
+ // Deduplicate the archive by `(txid, vout)`, keeping the existing entry
+ // when both exist (preserves any timestamps backfilled previously).
+ const existingArchive = doc.spent ?? [];
+ const archiveByKey = new Map();
+ for (const u of existingArchive) archiveByKey.set(`${u.txid}:${u.vout}`, u);
+ for (const u of toArchive) {
+ const k = `${u.txid}:${u.vout}`;
+ if (!archiveByKey.has(k)) archiveByKey.set(k, u);
+ }
+
+ return {
+ version: SP_STORAGE_VERSION,
+ scanHeight: doc.scanHeight,
+ utxos: remaining,
+ spent: Array.from(archiveByKey.values()),
+ };
+}
+
// Re-export hex helpers for callers that want to read tweak bytes back.
export { hexToBytes, bytesToHex };