Keep SP receives in the tx history after the UTXO is spent

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
This commit is contained in:
Alex Gleason
2026-05-22 13:55:28 -05:00
parent 6d15204b47
commit 357e18e063
7 changed files with 338 additions and 65 deletions
@@ -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
</p>
)}
{/*
* "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.
*/}
<div className="flex items-start gap-2">
<Checkbox
id="sp-include-spent"
checked={includeSpent}
onCheckedChange={(v) => setIncludeSpent(v === true)}
disabled={sp.isScanning}
className="mt-0.5"
/>
<div className="space-y-0.5">
<Label htmlFor="sp-include-spent" className="text-xs cursor-pointer">
Include already-spent
</Label>
<p className="text-xs text-muted-foreground">
Also detect silent payments that have since been spent. Use when
rebuilding receive history after a missed scan or a reset.
</p>
</div>
</div>
{sp.isScanning && sp.scanProgress && (
<div className="space-y-2">
<Progress value={progressPercent} />
+24 -3
View File
@@ -172,15 +172,36 @@ export function useHdWallet(): UseHdWalletResult {
const transactions = useMemo<HdTransaction[] | undefined>(() => {
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<string, number>();
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;
+81 -16
View File
@@ -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<void>;
/**
* 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<void>;
/** Scan the most recent `DEFAULT_RECENT_SCAN_BLOCKS` blocks (or fewer if newer). */
scanRecent: () => Promise<void>;
/** 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<string, SPStoredUtxo>();
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<UseHdWalletSpResult['scanRange']>(
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
+83 -35
View File
@@ -73,6 +73,15 @@ export interface AccountScanResult {
pendingBalance: number;
/** Map from derived address → metadata. */
addressMap: Map<string, DerivedAddress>;
/**
* 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<string, number>,
): HdTransaction[] {
const ourAddresses = new Set<string>();
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;
+18 -4
View File
@@ -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<ParsedUtxo[]> {
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<ScanTweakEntry[]> {
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;
+22 -1
View File
@@ -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;
+77 -6
View File
@@ -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<string, unknown>;
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<string, SPStoredUtxo>();
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 };