dgb-wallet/packages/web-ui/src/useWalletHistory.ts
Digibyte.X 0a5d7ade28 scan gap 200/100 (DigiScope parity); trim decimals only on the main balance; price feed on by default
Receive-chain gap 200 and change-chain gap 100 everywhere a chain is
walked: daemon /balance (new gapInternal, cap 200), Blockbook xpub
queries, history ownership set, and the signing-key derivation in Send so
anything the scan finds is spendable.

Trailing-zero trimming now applies to the hero balance and assets row
only; history, details and the send review keep eight decimals. The hero
number no longer wraps — long balances step down in size.

Price feed defaults on; existing configs flipped once via the v3 marker.
2026-10-02 18:13:07 +02:00

285 lines
13 KiB
TypeScript

// 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<HistoryStatus>('idle');
const [fetched, setFetched] = useState<BlockbookHistoryTx[]>([]);
// 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<BlockbookHistoryTx[]>([]);
const [error, setError] = useState<string | null>(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<string | null>(null);
const fetchProblems: string[] = [];
const [nonce, setNonce] = useState(0);
const pollTimers = useRef<number[]>([]);
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<string, BlockbookHistoryTx>();
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<typeof defaultBlockbookClient>): Promise<{ results: BlockbookHistoryTx[][]; own: Set<string> }> {
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<string>();
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/<addr>?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<string> }> {
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<string, any>;
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<bigint>((a, o) => a + o.value, 0n);
const spent = vin.filter((i) => i.isOwn).reduce<bigint>((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<string>): BlockbookHistoryTx {
const mark = <V extends { isOwn: boolean; addresses?: string[] }>(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<bigint>((s, o) => s + o.value, 0n);
const spent = t.vin.filter((i) => i.isOwn).reduce<bigint>((s, i) => s + i.value, 0n);
return { ...t, netSat: received - spent };
}