web-ui: full history on by default; trim trailing zeros on amounts

Blockbook history is enabled by default (existing configs migrated once
via a v2 marker; a later deliberate opt-out sticks). The in-panel opt-in
prompt is replaced by a one-line note when it is off.

All displayed DGB amounts go through one formatter: thousands separators,
trailing zeros trimmed, never fewer than two decimals (3,811.32468574,
5.00, 0.0003345). Amount-field values keep full precision.
This commit is contained in:
Digibyte.X 2026-10-02 17:55:08 +02:00
parent 989a2696c9
commit a965a09b40
9 changed files with 56 additions and 55 deletions

View file

@ -1,4 +1,5 @@
import { useState } from 'react';
import { fmtSats } from './format.js';
import type { DashboardUtxo } from './useWalletBalance.js';
import type { BlockbookHistoryTx } from '@dgb-wallet/blockbook';
import type { HistoryStatus } from './useWalletHistory.js';
@ -60,17 +61,9 @@ export function ActivityList({
</div>
{historyStatus === 'disabled' && (
<div className="history-optin">
<div className="history-optin-body">
<strong>Want the full history?</strong>
<p>
Send + receive with dates comes from a Blockbook indexer. Enable it and the operator (<code>digibyte.atomicwallet.io</code>) will see the addresses this wallet queries.
Your seed and keys never leave this browser.
</p>
</div>
<button className="history-optin-btn" onClick={onEnableHistory}>
Enable full history
</button>
<div className="field-help" style={{ padding: '0 4px 10px' }}>
Full history is off (Settings → Connectivity → Blockbook).{' '}
<button className="text-btn" style={{ padding: 0 }} onClick={onEnableHistory}>Turn it on</button>
</div>
)}
@ -343,15 +336,7 @@ function shortenAddr(a: string): string {
return a.length <= 20 ? a : `${a.slice(0, 10)}…${a.slice(-6)}`;
}
function formatSats(sats: bigint): string {
// BigInt → 8-decimal DGB. Avoid Number rounding.
const neg = sats < 0n;
const abs = neg ? -sats : sats;
const s = abs.toString().padStart(9, '0');
const int = s.slice(0, -8);
const frac = s.slice(-8);
return `${neg ? '-' : ''}${Number(int).toLocaleString()}.${frac}`;
}
const formatSats = fmtSats;
function formatTime(ms: number): string {
const d = new Date(ms);

View file

@ -1,4 +1,5 @@
import { DgbLogo } from './DgbLogo.js';
import { fmtDgb } from './format.js';
// Assets section. Today the only asset is native DGB. When
// DigiAssets (Layer-2 token) support lands, each token becomes
@ -13,8 +14,7 @@ export function AssetsList({
loading: boolean;
}): JSX.Element {
const dgb = totalSats / 1e8;
const [intPart, fracPart] = dgb.toFixed(8).split('.');
const intFmt = Number(intPart).toLocaleString();
const [intFmt, fracPart = '00'] = fmtDgb(dgb).split('.');
return (
<section className="assets">

View file

@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { fmtDgb } from './format.js';
import { DgbLogo } from './DgbLogo.js';
import { WINDOW_CHOICES, type WindowChoice, type ScanDiagnostic } from './useWalletBalance.js';
@ -164,12 +165,7 @@ export function BalanceCard({
// 8-decimal fixed. Splits into integer + fractional so the CSS can
// dim the low-order fraction (a common wallet UX to make small
// balances feel less overwhelming).
function formatDgb(dgb: number): string {
const rounded = Math.round(dgb * 1e8) / 1e8;
const [intPart, fracPart = '00000000'] = rounded.toFixed(8).split('.');
const intWithSep = Number(intPart).toLocaleString(undefined, { maximumFractionDigits: 0 });
return `${intWithSep}.${fracPart}`;
}
const formatDgb = (dgb: number): string => fmtDgb(dgb);
function formatAge(ms: number): string {
if (ms < 60_000) return `${Math.floor(ms / 1000)}s`;

View file

@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { fmtSats } from './format.js';
import {
seedFromPhrase, rootFromSeed, accountNode, addressNode,
addressesForPubkey, importWifWithAddresses,
@ -411,12 +412,4 @@ function AddressesPanel({
);
}
function formatSats(sats: bigint): string {
if (sats === 0n) return '0';
const neg = sats < 0n;
const abs = neg ? -sats : sats;
const s = abs.toString().padStart(9, '0');
const int = s.slice(0, -8);
const frac = s.slice(-8).replace(/0+$/, '');
return `${neg ? '-' : ''}${Number(int).toLocaleString()}${frac ? '.' + frac : ''}`;
}
const formatSats = fmtSats;

View file

@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { fmtSats } from './format.js';
import { PasswordInput } from './PasswordInput.js';
import {
validateSeedPhrase,
@ -559,7 +560,5 @@ export function ImportScreen(): JSX.Element {
}
function fmt(sat: bigint | null): string {
if (sat === null) return '—';
const s = sat.toString().padStart(9, '0');
return `${Number(s.slice(0, -8)).toLocaleString()}.${s.slice(-8)}`;
return sat === null ? '—' : fmtSats(sat);
}

View file

@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { fmtSats } from './format.js';
import { searchPaths, searchSingle, PATH_CANDIDATES, candidateKey, type PathHit, type PathCandidate } from './path-search.js';
import { useActiveSeed } from './ActiveSeed.js';
import {
@ -269,7 +270,5 @@ export function PathSearchPanel(): JSX.Element | null {
}
function fmt(sat: bigint | null): string {
if (sat === null) return '—';
const s = sat.toString().padStart(9, '0');
return `${Number(s.slice(0, -8)).toLocaleString()}.${s.slice(-8)}`;
return sat === null ? '—' : fmtSats(sat);
}

View file

@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { fmtSats } from './format.js';
import { PinConfirmModal } from './PinConfirmModal.js';
import { useSessionAuth } from './session-auth.js';
import {
@ -258,7 +259,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 ${(inputSats / 1e8).toFixed(8)}, need ${((amtSats + need) / 1e8).toFixed(8)} (incl. ${(need / 1e8).toFixed(8)} fee)`);
throw new Error(`Insufficient funds: have ${fmtSats(inputSats)}, need ${fmtSats(amtSats + need)} (incl. ${fmtSats(need)} fee)`);
}
changeAmt = 0;
feeSats = inputSats - amtSats;
@ -492,7 +493,7 @@ export function SendScreen({
{pinPrompt && (
<PinConfirmModal
title="Confirm broadcast"
message={`Enter your session PIN to send ${(r.amountSats / 1e8).toFixed(8)} DGB.`}
message={`Enter your session PIN to send ${fmtSats(r.amountSats)} DGB.`}
onConfirm={() => { setPinPrompt(false); void doBroadcast(); }}
onCancel={() => setPinPrompt(false)}
/>
@ -506,15 +507,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">{(r.amountSats / 1e8).toFixed(8)} DGB</span>
<span className="tx-v send-review-amount">{fmtSats(r.amountSats)} DGB</span>
<span className="tx-k">Fee</span>
<span className="tx-v">{(r.feeSats / 1e8).toFixed(8)} DGB</span>
<span className="tx-v">{fmtSats(r.feeSats)} DGB</span>
<span className="tx-k">Total out</span>
<span className="tx-v">{((r.amountSats + r.feeSats) / 1e8).toFixed(8)} DGB</span>
<span className="tx-v">{fmtSats(r.amountSats + r.feeSats)} DGB</span>
<span className="tx-k">Change</span>
<span className="tx-v">
{status.changeAddress
? <>{(r.change.reduce((s, c) => s + c.value, 0) / 1e8).toFixed(8)} DGB → <code className="tx-mono">{status.changeAddress}</code></>
? <>{fmtSats(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>
@ -635,12 +636,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 {(feeFor(estimateVbytes(['p2wpkh'], ['dgb1q', 'dgb1q'])) / 1e8).toFixed(5)} DGB for a typical send.
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.
{' '}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: {(availableSats / 1e8).toFixed(8)} DGB
{' '}Spendable now: {fmtSats(availableSats)} DGB
{scanStatus === 'loading' ? ' (scan still running)' : ''}.
</div>

View file

@ -74,7 +74,7 @@ const STORAGE_KEY = 'dgb-wallet:backend-config';
const DEFAULT_CONFIG: BackendConfig = {
p2p: { scan: true, broadcast: true },
blockbook: { scan: false, broadcast: false },
blockbook: { scan: true, broadcast: false },
display: { priceFeed: false, autoWidenScan: true, autoRefreshSec: 60 },
privacy: { freshAddresses: false, changePolicy: 'same' },
security: { requirePinOnSend: false },
@ -84,12 +84,18 @@ function readStored(): BackendConfig {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return DEFAULT_CONFIG;
const parsed = JSON.parse(raw) as BackendConfig;
const parsed = JSON.parse(raw) as BackendConfig & { v?: number };
// Fill missing keys from defaults so old stored configs stay
// valid when we add capabilities.
// v2 (2026-10): full history became the default. Configs saved before
// that carry blockbook.scan=false only because it was the old default,
// not because anyone chose it — flip it once, then record v2 so a
// deliberate later opt-out sticks.
const blockbook = { ...DEFAULT_CONFIG.blockbook, ...(parsed.blockbook ?? {}) };
if ((parsed.v ?? 1) < 2) blockbook.scan = true;
return {
p2p: { ...DEFAULT_CONFIG.p2p, ...(parsed.p2p ?? {}) },
blockbook: { ...DEFAULT_CONFIG.blockbook, ...(parsed.blockbook ?? {}) },
blockbook,
display: { ...DEFAULT_CONFIG.display, ...(parsed.display ?? {}) },
privacy: { ...DEFAULT_CONFIG.privacy, ...(parsed.privacy ?? {}) },
security: { ...DEFAULT_CONFIG.security, ...(parsed.security ?? {}) },
@ -100,7 +106,7 @@ function readStored(): BackendConfig {
}
function writeStored(config: BackendConfig): void {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(config)); } catch { /* private mode */ }
try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...config, v: 2 })); } catch { /* private mode */ }
}
// Context-backed store. Every consumer in the tree shares the same

View file

@ -0,0 +1,22 @@
// One rule for every DGB amount the wallet displays: integer part with
// thousands separators, decimals trimmed of trailing zeros but never
// fewer than MIN_DECIMALS. So 3,811.32468574 stays as is, 5.00000000
// becomes 5.00, and 0.00033450 becomes 0.0003345. Exact: works on
// satoshis as bigint, never through a float.
const MIN_DECIMALS = 2;
export function fmtSats(sats: bigint | number, min = MIN_DECIMALS): string {
const v = typeof sats === 'bigint' ? sats : BigInt(Math.round(sats));
const neg = v < 0n;
const abs = neg ? -v : v;
const s = abs.toString().padStart(9, '0');
const int = Number(s.slice(0, -8)).toLocaleString();
let frac = s.slice(-8).replace(/0+$/, '');
if (frac.length < min) frac = frac.padEnd(min, '0');
return `${neg ? '-' : ''}${int}${frac ? '.' + frac : ''}`;
}
/** 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);
}