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:
@@ -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} />
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user