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. + */} +
+ setIncludeSpent(v === true)} + disabled={sp.isScanning} + className="mt-0.5" + /> +
+ +

+ 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 };