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.
This commit is contained in:
Digibyte.X 2026-10-02 18:13:07 +02:00
parent a965a09b40
commit 0a5d7ade28
13 changed files with 63 additions and 34 deletions

View file

@ -473,6 +473,7 @@ const server = http.createServer(async (req, res) => {
// are no xpub-derived children to enumerate.
scripts?: string[];
gapLimit?: number;
gapInternal?: number;
startHeight?: number;
endHeight?: number;
};
@ -488,7 +489,11 @@ const server = http.createServer(async (req, res) => {
res.end(JSON.stringify({ error: 'expected {xpub}, {descriptors:[...]}, or {scripts:[hex,…]}' }));
return;
}
const gapLimit = Math.min(body.gapLimit ?? 20, 100);
// Receive-chain gap (default 20, cap 200) and change-chain gap
// (defaults to the receive gap). 200/100 matches what other DGB
// recovery wallets probe, so a sparse old wallet is not missed.
const gapLimit = Math.min(body.gapLimit ?? 20, 200);
const gapInternal = Math.min(body.gapInternal ?? gapLimit, 200);
const DEFAULT_WINDOW = 1000;
const rawStart = body.startHeight ?? Math.max(1, chain.tipHeight - DEFAULT_WINDOW);
@ -520,7 +525,7 @@ const server = http.createServer(async (req, res) => {
const account = bip32.fromBase58(d.xpub, digibyte);
for (const change of [0, 1] as const) {
const chainNode = account.derive(change);
for (let i = 0; i < gapLimit; i++) {
for (let i = 0; i < (change === 0 ? gapLimit : gapInternal); i++) {
const node = chainNode.derive(i);
const pubkey = Buffer.from(node.publicKey);
const spk = spkForPurpose(d.purpose, pubkey);

View file

@ -1,5 +1,5 @@
import { useState } from 'react';
import { fmtSats } from './format.js';
import { fmtSats8 } from './format.js';
import type { DashboardUtxo } from './useWalletBalance.js';
import type { BlockbookHistoryTx } from '@dgb-wallet/blockbook';
import type { HistoryStatus } from './useWalletHistory.js';
@ -336,7 +336,7 @@ function shortenAddr(a: string): string {
return a.length <= 20 ? a : `${a.slice(0, 10)}…${a.slice(-6)}`;
}
const formatSats = fmtSats;
const formatSats = fmtSats8;
function formatTime(ms: number): string {
const d = new Date(ms);

View file

@ -134,7 +134,7 @@ export function BalanceCard({
</div>
<div className="hero-amount">
<span className="hero-value">{formatted}</span>
<span className={`hero-value${formatted.length > 11 ? ' long' : ''}${formatted.length > 15 ? ' longer' : ''}`}>{formatted}</span>
<span className="hero-unit">DGB</span>
</div>

View file

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { fmtSats } from './format.js';
import { fmtSats8 } from './format.js';
import {
seedFromPhrase, rootFromSeed, accountNode, addressNode,
addressesForPubkey, importWifWithAddresses,
@ -412,4 +412,4 @@ function AddressesPanel({
);
}
const formatSats = fmtSats;
const formatSats = fmtSats8;

View file

@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { fmtSats } from './format.js';
import { fmtSats8 } from './format.js';
import { PasswordInput } from './PasswordInput.js';
import {
validateSeedPhrase,
@ -560,5 +560,5 @@ export function ImportScreen(): JSX.Element {
}
function fmt(sat: bigint | null): string {
return sat === null ? '—' : fmtSats(sat);
return sat === null ? '—' : fmtSats8(sat);
}

View file

@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { fmtSats } from './format.js';
import { fmtSats8 } from './format.js';
import { searchPaths, searchSingle, PATH_CANDIDATES, candidateKey, type PathHit, type PathCandidate } from './path-search.js';
import { useActiveSeed } from './ActiveSeed.js';
import {
@ -270,5 +270,5 @@ export function PathSearchPanel(): JSX.Element | null {
}
function fmt(sat: bigint | null): string {
return sat === null ? '—' : fmtSats(sat);
return sat === null ? '—' : fmtSats8(sat);
}

View file

@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { fmtSats } from './format.js';
import { GAP_EXTERNAL, GAP_INTERNAL } from './gap.js';
import { fmtSats8 } from './format.js';
import { PinConfirmModal } from './PinConfirmModal.js';
import { useSessionAuth } from './session-auth.js';
import {
@ -34,7 +35,8 @@ const ECPair = ECPairFactory(ecc);
// ~220 vB → ~0.00033 DGB.
const FEE_RATE_SAT_PER_VB = 150;
const MIN_RELAY_SAT_PER_VB = 100;
const GAP = 20;
// Key derivation depth per chain — must cover what the scans look at (gap.ts).
const GAP_FOR = (change: 0 | 1): number => (change === 0 ? GAP_EXTERNAL : GAP_INTERNAL);
const ALL_PURPOSES: Array<44 | 49 | 84 | 86> = [44, 49, 84, 86];
// The dashboard scan covers account 0 only, so every UTXO we can be
// handed lives in account 0 — deriving further accounts is wasted work.
@ -259,7 +261,7 @@ export function SendScreen({
// is extra fee — bounded by DUST_SATS + one output's fee share.
const need = feeNoChange();
if (inputSats < amtSats + need) {
throw new Error(`Insufficient funds: have ${fmtSats(inputSats)}, need ${fmtSats(amtSats + need)} (incl. ${fmtSats(need)} fee)`);
throw new Error(`Insufficient funds: have ${fmtSats8(inputSats)}, need ${fmtSats8(amtSats + need)} (incl. ${fmtSats8(need)} fee)`);
}
changeAmt = 0;
feeSats = inputSats - amtSats;
@ -493,7 +495,7 @@ export function SendScreen({
{pinPrompt && (
<PinConfirmModal
title="Confirm broadcast"
message={`Enter your session PIN to send ${fmtSats(r.amountSats)} DGB.`}
message={`Enter your session PIN to send ${fmtSats8(r.amountSats)} DGB.`}
onConfirm={() => { setPinPrompt(false); void doBroadcast(); }}
onCancel={() => setPinPrompt(false)}
/>
@ -507,15 +509,15 @@ export function SendScreen({
<span className="tx-k">To</span>
<span className="tx-v"><code className="tx-mono">{r.recipient}</code></span>
<span className="tx-k">Amount</span>
<span className="tx-v send-review-amount">{fmtSats(r.amountSats)} DGB</span>
<span className="tx-v send-review-amount">{fmtSats8(r.amountSats)} DGB</span>
<span className="tx-k">Fee</span>
<span className="tx-v">{fmtSats(r.feeSats)} DGB</span>
<span className="tx-v">{fmtSats8(r.feeSats)} DGB</span>
<span className="tx-k">Total out</span>
<span className="tx-v">{fmtSats(r.amountSats + r.feeSats)} DGB</span>
<span className="tx-v">{fmtSats8(r.amountSats + r.feeSats)} DGB</span>
<span className="tx-k">Change</span>
<span className="tx-v">
{status.changeAddress
? <>{fmtSats(r.change.reduce((s, c) => s + c.value, 0))} DGB → <code className="tx-mono">{status.changeAddress}</code></>
? <>{fmtSats8(r.change.reduce((s, c) => s + c.value, 0))} DGB → <code className="tx-mono">{status.changeAddress}</code></>
: 'none'}
</span>
<span className="tx-k">Inputs</span>
@ -636,12 +638,12 @@ export function SendScreen({
</div>
</div>
<div className="field-help">
Network fee: {FEE_RATE_SAT_PER_VB} sat/vB (DigiByte relays nothing under {MIN_RELAY_SAT_PER_VB}), about {fmtSats(feeFor(estimateVbytes(['p2wpkh'], ['dgb1q', 'dgb1q'])))} DGB for a typical send.
Network fee: {FEE_RATE_SAT_PER_VB} sat/vB (DigiByte relays nothing under {MIN_RELAY_SAT_PER_VB}), about {fmtSats8(feeFor(estimateVbytes(['p2wpkh'], ['dgb1q', 'dgb1q'])))} DGB for a typical send.
{' '}Change goes to {
backendConfig.privacy.changePolicy === 'same' ? 'the address the coins were spent from'
: backendConfig.privacy.changePolicy === 'fresh' ? 'a fresh address on the same derivation path as the coins spent'
: 'your BIP84 bech32 change branch'} (Settings → Security).
{' '}Spendable now: {fmtSats(availableSats)} DGB
{' '}Spendable now: {fmtSats8(availableSats)} DGB
{scanStatus === 'loading' ? ' (scan still running)' : ''}.
</div>
@ -684,7 +686,7 @@ async function buildFromSeed(
const purpose: Purpose = isSegwit ? 84 : 44;
const entries = new Map<string, ScriptEntry>();
for (const change of [0, 1] as const) {
for (let i = 0; i < GAP; i++) {
for (let i = 0; i < GAP_FOR(change); i++) {
const n = addressNode(node, change, i);
if (!n.privateKey) continue;
registerKey(entries, purpose, Buffer.from(n.publicKey), Buffer.from(n.privateKey));
@ -721,7 +723,7 @@ async function buildFromSeed(
for (let a = 0; a < ACCOUNTS; a++) {
const acct = accountNode(root, purpose, a);
for (const change of [0, 1] as const) {
for (let i = 0; i < GAP; i++) {
for (let i = 0; i < GAP_FOR(change); i++) {
const node = addressNode(acct, change, i);
if (!node.privateKey) continue;
const pubkey = Buffer.from(node.publicKey);
@ -739,7 +741,7 @@ async function buildFromSeed(
const rootDgb = rootFromSeed(seed, undefined, HMAC_DIGIBYTE_SEED);
for (const legacy of [root.deriveHardened(0), root.derivePath("m/44'/0'/0'"), rootDgb.deriveHardened(0)]) {
for (const change of [0, 1] as const) {
for (let i = 0; i < GAP; i++) {
for (let i = 0; i < GAP_FOR(change); i++) {
const node = addressNode(legacy, change, i);
if (!node.privateKey) continue;
registerKey(entries, 44, Buffer.from(node.publicKey), Buffer.from(node.privateKey));
@ -760,7 +762,7 @@ async function buildFromSeed(
const acct = extra.path === 'm' ? base : base.derivePath(extra.path);
const purpose = scriptToPurpose[extra.script];
for (const change of [0, 1] as const) {
for (let i = 0; i < GAP; i++) {
for (let i = 0; i < GAP_FOR(change); i++) {
const node = addressNode(acct, change, i);
if (!node.privateKey) continue;
registerKey(entries, purpose, Buffer.from(node.publicKey), Buffer.from(node.privateKey));

View file

@ -75,7 +75,7 @@ const STORAGE_KEY = 'dgb-wallet:backend-config';
const DEFAULT_CONFIG: BackendConfig = {
p2p: { scan: true, broadcast: true },
blockbook: { scan: true, broadcast: false },
display: { priceFeed: false, autoWidenScan: true, autoRefreshSec: 60 },
display: { priceFeed: true, autoWidenScan: true, autoRefreshSec: 60 },
privacy: { freshAddresses: false, changePolicy: 'same' },
security: { requirePinOnSend: false },
};
@ -93,10 +93,13 @@ function readStored(): BackendConfig {
// deliberate later opt-out sticks.
const blockbook = { ...DEFAULT_CONFIG.blockbook, ...(parsed.blockbook ?? {}) };
if ((parsed.v ?? 1) < 2) blockbook.scan = true;
// v3 (2026-10): the price feed became the default too — same one-time flip.
const display = { ...DEFAULT_CONFIG.display, ...(parsed.display ?? {}) };
if ((parsed.v ?? 1) < 3) display.priceFeed = true;
return {
p2p: { ...DEFAULT_CONFIG.p2p, ...(parsed.p2p ?? {}) },
blockbook,
display: { ...DEFAULT_CONFIG.display, ...(parsed.display ?? {}) },
display,
privacy: { ...DEFAULT_CONFIG.privacy, ...(parsed.privacy ?? {}) },
security: { ...DEFAULT_CONFIG.security, ...(parsed.security ?? {}) },
};
@ -106,7 +109,7 @@ function readStored(): BackendConfig {
}
function writeStored(config: BackendConfig): void {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...config, v: 2 })); } catch { /* private mode */ }
try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...config, v: 3 })); } catch { /* private mode */ }
}
// Context-backed store. Every consumer in the tree shares the same

View file

@ -16,6 +16,11 @@ export function fmtSats(sats: bigint | number, min = MIN_DECIMALS): string {
return `${neg ? '-' : ''}${int}${frac ? '.' + frac : ''}`;
}
/** Full 8-decimal form (history, details, send review): never trimmed. */
export function fmtSats8(sats: bigint | number): string {
return fmtSats(sats, 8);
}
/** Same rule for a DGB-denominated float (converted to sats first). */
export function fmtDgb(dgb: number, min = MIN_DECIMALS): string {
return fmtSats(BigInt(Math.round(dgb * 1e8)), min);

View file

@ -0,0 +1,7 @@
// Address-chain gap limits used by every scan and by key derivation for
// signing. 200 receive / 100 change — the same depth the DigiScope
// Android wallet probes during its "universal restore" — so a wallet
// that handed out many addresses over its life is still found. Signing
// derives the same depth so anything the scan finds can be spent.
export const GAP_EXTERNAL = 200;
export const GAP_INTERNAL = 100;

View file

@ -1582,8 +1582,13 @@ button.primary:disabled { opacity: 0.4; cursor: not-allowed; }
color: var(--text);
letter-spacing: -0.02em;
line-height: 1;
overflow-wrap: anywhere;
/* Never wrap the number: a 14-character balance used to break into
two lines. Long values step down in size instead. */
white-space: nowrap;
overflow-wrap: normal;
}
.hero-value.long { font-size: clamp(26px, 3.3vw, 50px); }
.hero-value.longer { font-size: clamp(22px, 2.6vw, 40px); }
.hero-unit {
font-family: var(--mono);
font-size: clamp(14px, 2vw, 18px);

View file

@ -9,6 +9,7 @@
// already opted into a working indexer.
import { useEffect, useRef, useState } from 'react';
import { GAP_EXTERNAL, GAP_INTERNAL } from './gap.js';
import { walletIdHash, cacheKey, readCache, writeCache, type CachedScan } from './scan-cache.js';
import {
seedFromPhrase, seedFromElectrumPhrase, rootFromSeed, accountNode, accountXpub as toXpub,
@ -497,7 +498,7 @@ async function scanSeedViaP2p(
? 1
: Math.max(1, health.chainHeight - win.days * BLOCKS_PER_DAY);
const scan = await p2pScan<ScanResult>({
descriptors, gapLimit: 20, startHeight, endHeight: health.chainHeight,
descriptors, gapLimit: GAP_EXTERNAL, gapInternal: GAP_INTERNAL, startHeight, endHeight: health.chainHeight,
}, { onProgress });
// Per-purpose UTXO count from the flat list
const byPurpose = new Map<number, DashboardUtxo[]>();
@ -541,8 +542,8 @@ async function scanSeedViaBlockbook(
// we can distinguish "Blockbook says this key genuinely has 0" from
// "Blockbook broke on this request".
const [summaryRes, utxosRes] = await Promise.allSettled([
bb.xpub(key, 20),
bb.xpubUtxos(key, 20),
bb.xpub(key, GAP_EXTERNAL),
bb.xpubUtxos(key, GAP_EXTERNAL),
]);
const summary = summaryRes.status === 'fulfilled' ? summaryRes.value : null;
const utxos = utxosRes.status === 'fulfilled' ? utxosRes.value : [];

View file

@ -15,6 +15,7 @@
// 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,
@ -178,7 +179,7 @@ export function useWalletHistory(): {
const { key, supported } = keyForPurpose(xpub, purpose);
if (!supported) return [];
try {
const page = await bb.xpubTxs(key, { pageSize: 100 });
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)}`);
@ -261,7 +262,7 @@ export function useWalletHistory(): {
// 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 = 40;
const OWN_SCAN = GAP_EXTERNAL;
// OR the isOwn flags of two Blockbook copies of the same tx.
function mergeOwnFlags(a: BlockbookHistoryTx, b: BlockbookHistoryTx): BlockbookHistoryTx {