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.
285 lines
13 KiB
TypeScript
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 };
|
|
}
|