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:
parent
a965a09b40
commit
0a5d7ade28
13 changed files with 63 additions and 34 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
7
packages/web-ui/src/gap.ts
Normal file
7
packages/web-ui/src/gap.ts
Normal 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;
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 : [];
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue