// Full send + receive history via Blockbook's /xpub?details=txs. // Only fetches when the user has opted into Blockbook for `scan` in the // backend matrix — Blockbook reveals every queried xpub to the operator, // so the default (P2P only) has NO history. That's an honest privacy // trade-off surfaced in the UI. // // Runs one request per BIP purpose (44/49/84/86) in parallel, merges, // dedupes by txid, and sorts by height descending. // // Locally-broadcast transactions: Send reports each broadcast via // `notePending`. The row is shown immediately as unconfirmed (Blockbook // may not have seen the tx yet — with Dandelion++ relay it can take // several seconds to reach the indexer's node), and a short burst of // re-fetches runs until the indexer returns the txid itself, at which // point the local placeholder is dropped in favour of the real row. import { useEffect, useRef, useState } from 'react'; import { GAP_EXTERNAL, GAP_INTERNAL } from './gap.js'; import { seedFromPhrase, rootFromSeed, accountNode, accountXpub as toXpub, HMAC_DIGIBYTE_SEED, addressNode, addressesForPubkey, importWifWithAddresses, } from '@dgb-wallet/core'; import { type BlockbookHistoryTx, } from '@dgb-wallet/blockbook'; import bs58check from 'bs58check'; import { useActiveSeed } from './ActiveSeed.js'; import { useBackendConfig } from './backend-config.js'; import { defaultBlockbookClient, DEFAULT_BROWSER_BLOCKBOOK_BASE } from './blockbook-default.js'; // Same SLIP-0132 recoding logic as useWalletBalance — Blockbook picks // derivation type from the extended-key prefix. See that file for the // full explanation. const YPUB_VERSION = 0x049d7cb2; const ZPUB_VERSION = 0x04b24746; function recodeXpub(xpub: string, newVersion: number): string { const raw = Buffer.from(bs58check.decode(xpub)); raw.writeUInt32BE(newVersion, 0); return bs58check.encode(raw); } function keyForPurpose(xpub: string, purpose: 44 | 49 | 84 | 86): { key: string; supported: boolean } { if (purpose === 44) return { key: xpub, supported: true }; if (purpose === 49) return { key: recodeXpub(xpub, YPUB_VERSION), supported: true }; if (purpose === 84) return { key: recodeXpub(xpub, ZPUB_VERSION), supported: true }; return { key: xpub, supported: false }; } export type HistoryStatus = | 'idle' // no active seed | 'disabled' // active seed but user hasn't enabled Blockbook for scan | 'loading' | 'ok' | 'error'; export function useWalletHistory(): { status: HistoryStatus; txs: BlockbookHistoryTx[]; server: string; error: string | null; refresh: () => void; /** Register a tx this wallet just broadcast so it shows up immediately. */ notePending: (tx: BlockbookHistoryTx) => void; /** Non-fatal per-path fetch failures from the last run, if any. */ warning: string | null; } { const { active } = useActiveSeed(); const { config } = useBackendConfig(); // History requires the same address enumeration as scan does, and // we treat it as "part of the scan" for opt-in purposes. Wallet // operators who want history without balance scans is an edge case. const enabled = config.blockbook.scan; const [status, setStatus] = useState('idle'); const [fetched, setFetched] = useState([]); // Placeholders for txs we broadcast ourselves that the indexer has // not returned yet. Cleared per-txid once the indexer catches up. const [pending, setPending] = useState([]); const [error, setError] = useState(null); // Non-fatal: some path queries failed (rate limit, timeout) while others // succeeded. Shown as a warning so an empty list is never mistaken for // "this seed has no history" when the indexer simply did not answer. const [warning, setWarning] = useState(null); const fetchProblems: string[] = []; const [nonce, setNonce] = useState(0); const pollTimers = useRef([]); function clearPolls(): void { for (const t of pollTimers.current) window.clearTimeout(t); pollTimers.current = []; } useEffect(() => { // Wallet switch / sign-out: pending rows belong to the old wallet. setPending([]); clearPolls(); }, [active?.setAt]); useEffect(() => clearPolls, []); useEffect(() => { if (!active) { setStatus('idle'); setFetched([]); return; } if (!enabled) { setStatus('disabled'); setFetched([]); return; } let cancelled = false; (async () => { setStatus('loading'); setError(null); fetchProblems.length = 0; try { const bb = defaultBlockbookClient(); const { results, own } = active.kind === 'wif' ? await historyForWif(active.phrase, bb.baseUrl) : await historyForSeed(active.phrase, bb); if (cancelled) return; // One tx can touch several of our derivation paths (BIP44 coins // in, BIP84 change out). Each xpub query only flags ITS OWN // addresses as `isOwn`, so taking the last copy made a send look // like a receipt of its own change. Merge every copy (OR the // flags per input/output), then apply our locally derived // address set on top — that also covers what Blockbook cannot // see (BIP86 Taproot, change beyond its gap) — and recompute the // net amount from the merged flags. const byId = new Map(); for (const arr of results) { for (const t of arr) { const prev = byId.get(t.txid); byId.set(t.txid, prev ? mergeOwnFlags(prev, t) : t); } } for (const [id, t] of byId) byId.set(id, applyOwnSet(t, own)); const sorted = [...byId.values()].sort( (a, b) => (b.blockHeight ?? Number.POSITIVE_INFINITY) - (a.blockHeight ?? Number.POSITIVE_INFINITY), ); setFetched(sorted); // Indexer now knows these — drop our placeholders for them. setPending((p) => p.filter((t) => !byId.has(t.txid))); setWarning(fetchProblems.length ? `Some paths could not be fetched — ${fetchProblems.join(' · ')}` : null); setStatus('ok'); } catch (e) { if (cancelled) return; setError((e as Error).message); setStatus('error'); } })(); return () => { cancelled = true; }; }, [active?.setAt, enabled, nonce]); // Pending placeholders first (unconfirmed → top), then indexer rows. const txs = pending.length === 0 ? fetched : [...pending, ...fetched.filter((t) => !pending.some((p) => p.txid === t.txid))]; async function historyForSeed(phrase: string, bb: ReturnType): Promise<{ results: BlockbookHistoryTx[][]; own: Set }> { const seed = await seedFromPhrase(phrase); const root = rootFromSeed(seed); // Every address form at the first OWN_SCAN indexes of every branch — // the local ground truth for "is this ours" (see merge above). const own = new Set(); const accounts = [ ...([44, 49, 84, 86] as const).map((purpose) => accountNode(root, purpose, 0)), root.deriveHardened(0), // legacy m/0' (standard key) rootFromSeed(seed, undefined, HMAC_DIGIBYTE_SEED).deriveHardened(0), // official 2018-19 DGB mobile ("DigiByte seed" key) root.derivePath("m/44'/0'/0'"), // BIP44 with Bitcoin's coin type (early multi-coin apps) ]; for (const acct of accounts) { for (const change of [0, 1] as const) { for (let i = 0; i < OWN_SCAN; i++) { const a = addressesForPubkey(Buffer.from(addressNode(acct, change, i).publicKey)); for (const f of [a.p2pkh, a.p2shP2wpkhModern, a.p2shP2wpkhLegacy, a.p2wpkh, a.p2tr]) own.add(f); } } } const xpubs: Array<{ xpub: string; purpose: 44 | 49 | 84 | 86 }> = [ ...([44, 49, 84, 86] as const).map((purpose) => ({ xpub: toXpub(accountNode(root, purpose, 0)), purpose })), { xpub: toXpub(root.deriveHardened(0)), purpose: 44 }, // legacy m/0' (standard key) { xpub: toXpub(rootFromSeed(seed, undefined, HMAC_DIGIBYTE_SEED).deriveHardened(0)), purpose: 44 }, // 2018-19 DGB mobile { xpub: toXpub(root.derivePath("m/44'/0'/0'")), purpose: 44 }, // BIP44 coin type 0 ]; const results = await Promise.all(xpubs.map(async ({ xpub, purpose }) => { const { key, supported } = keyForPurpose(xpub, purpose); if (!supported) return []; try { const page = await bb.xpubTxs(key, { gap: GAP_EXTERNAL, pageSize: 100 }); return page.transactions; } catch (e) { fetchProblems.push(`${purpose === 44 ? 'legacy/BIP44' : `BIP${purpose}`}: ${(e as Error).message.replace(/ at \.\.\/blockbook\S*/, '').slice(0, 90)}`); return []; } })); return { results, own }; } // WIF has no xpub — hit /address/?details=txs per script form // and merge. Bech32 forms will 400 with "unknown format" on the // current DGB Blockbook fork; those are silently skipped so the // legacy forms (D…, S…, 3…) still populate history. async function historyForWif(wif: string, baseUrl: string): Promise<{ results: BlockbookHistoryTx[][]; own: Set }> { const w = importWifWithAddresses(wif); const addrs = [w.p2pkh, w.p2shP2wpkhModern, w.p2shP2wpkhLegacy, w.p2wpkh, w.p2tr]; const own = new Set(addrs); const results = await Promise.all(addrs.map(async (addr) => { try { const res = await fetch(`${baseUrl}/address/${encodeURIComponent(addr)}?details=txs&pageSize=100`); if (!res.ok) return []; const j = await res.json() as Record; return ((j.transactions ?? []) as any[]).map((t) => normalizeAddressTx(t, addr)); } catch { return []; } })); return { results, own }; } function normalizeAddressTx(t: any, addr: string): BlockbookHistoryTx { // Blockbook's /address tx entries don't set `isOwn` the way xpub // does — "own" means the vin/vout matches the queried address. const own = (v: any): boolean => Array.isArray(v.addresses) && v.addresses.includes(addr); const vin = ((t.vin ?? []) as any[]).map((i) => ({ isOwn: own(i), value: BigInt(i.value ?? '0'), addresses: Array.isArray(i.addresses) ? i.addresses : undefined, })); const vout = ((t.vout ?? []) as any[]).map((o) => ({ isOwn: own(o), value: BigInt(o.value ?? '0'), addresses: Array.isArray(o.addresses) ? o.addresses : undefined, })); const received = vout.filter((o) => o.isOwn).reduce((a, o) => a + o.value, 0n); const spent = vin.filter((i) => i.isOwn).reduce((a, i) => a + i.value, 0n); return { txid: String(t.txid), blockHash: t.blockHash, blockHeight: typeof t.blockHeight === 'number' ? t.blockHeight : undefined, blockTime: typeof t.blockTime === 'number' ? t.blockTime : undefined, confirmations: Number(t.confirmations ?? 0), netSat: received - spent, feeSat: BigInt(t.fees ?? '0'), vin, vout, }; } return { status, txs, server: DEFAULT_BROWSER_BLOCKBOOK_BASE, error, refresh: () => setNonce((n) => n + 1), warning, notePending: (tx) => { setPending((p) => (p.some((t) => t.txid === tx.txid) ? p : [tx, ...p])); // Re-fetch in a short burst so the real row replaces the // placeholder as soon as the indexer sees the tx. Only when // history is actually enabled — otherwise there is nothing to // fetch and the placeholder simply stays until wallet switch. if (!enabled) return; clearPolls(); for (const delay of [3_000, 10_000, 30_000, 90_000]) { pollTimers.current.push(window.setTimeout(() => setNonce((n) => n + 1), delay)); } }, }; } // How many indexes per branch we derive locally to judge ownership. // Matches the gap limit the scanners use, plus headroom for change. const OWN_SCAN = GAP_EXTERNAL; // OR the isOwn flags of two Blockbook copies of the same tx. function mergeOwnFlags(a: BlockbookHistoryTx, b: BlockbookHistoryTx): BlockbookHistoryTx { const vin = a.vin.map((v, i) => ({ ...v, isOwn: v.isOwn || !!b.vin[i]?.isOwn })); const vout = a.vout.map((v, i) => ({ ...v, isOwn: v.isOwn || !!b.vout[i]?.isOwn })); return recomputeNet({ ...a, vin, vout }); } // Mark anything paying to / spending from one of OUR addresses as own. function applyOwnSet(t: BlockbookHistoryTx, own: Set): BlockbookHistoryTx { const mark = (v: V): V => v.isOwn || (v.addresses ?? []).some((a) => own.has(a)) ? { ...v, isOwn: true } : v; return recomputeNet({ ...t, vin: t.vin.map(mark), vout: t.vout.map(mark) }); } function recomputeNet(t: BlockbookHistoryTx): BlockbookHistoryTx { const received = t.vout.filter((o) => o.isOwn).reduce((s, o) => s + o.value, 0n); const spent = t.vin.filter((i) => i.isOwn).reduce((s, i) => s + i.value, 0n); return { ...t, netSat: received - spent }; }