Refine wallet nav and fee helpers

This commit is contained in:
lemon
2026-05-26 12:44:54 -07:00
parent 13432b4865
commit cb9231d135
4 changed files with 65 additions and 74 deletions
+12 -30
View File
@@ -34,6 +34,11 @@ import { useAppContext } from '@/hooks/useAppContext';
import { useHdWalletAccess } from '@/hooks/useHdWalletAccess';
import { useHdWallet } from '@/hooks/useHdWallet';
import { notificationSuccess } from '@/lib/haptics';
import {
getBitcoinFeeRate,
getUniqueBitcoinFeeSpeeds,
type BitcoinFeeSpeed,
} from '@/lib/bitcoinFeeSpeed';
import {
isLargeAmount,
nostrPubkeyToBitcoinAddress,
@@ -41,7 +46,6 @@ import {
} from '@/lib/bitcoin';
import {
broadcastBlockbookTx,
type BlockbookFeeRates,
fetchFeeRates,
} from '@/lib/hdwallet/blockbook';
import {
@@ -62,29 +66,7 @@ import { useQuery } from '@tanstack/react-query';
const USD_PRESETS = [1, 5, 10, 25, 100];
type FeeSpeed = 'fastest' | 'halfHour' | 'hour' | 'economy';
const FEE_SPEED_ORDER: FeeSpeed[] = ['fastest', 'halfHour', 'hour', 'economy'];
function getRateForSpeed(rates: BlockbookFeeRates, speed: FeeSpeed): number {
switch (speed) {
case 'fastest': return rates.fastestFee;
case 'halfHour': return rates.halfHourFee;
case 'hour': return rates.hourFee;
case 'economy': return rates.economyFee;
}
}
function getUniqueFeeSpeeds(rates: BlockbookFeeRates | undefined): FeeSpeed[] {
if (!rates) return FEE_SPEED_ORDER;
const seen = new Set<number>();
const result: FeeSpeed[] = [];
for (const speed of FEE_SPEED_ORDER) {
const rate = getRateForSpeed(rates, speed);
if (!seen.has(rate)) { seen.add(rate); result.push(speed); }
}
return result;
}
type FeeSpeed = BitcoinFeeSpeed;
// ---------------------------------------------------------------------------
// Recipient resolution
@@ -236,7 +218,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin
const currentFeeRate = useMemo(() => {
if (!feeRates) return undefined;
return getRateForSpeed(feeRates, feeSpeed);
return getBitcoinFeeRate(feeRates, feeSpeed);
}, [feeRates, feeSpeed]);
// ── Owned UTXO set ───────────────────────────────────────────
@@ -303,12 +285,12 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin
if (feeSpeedUserChanged.current) return;
if (!ownedInputs.length || !feeRates || amountSats <= 0) return;
const uniqueSpeeds = getUniqueFeeSpeeds(feeRates);
const uniqueSpeeds = getUniqueBitcoinFeeSpeeds(feeRates);
const threshold = amountSats * 0.4;
let target: FeeSpeed = uniqueSpeeds[uniqueSpeeds.length - 1];
for (const speed of uniqueSpeeds) {
const rate = getRateForSpeed(feeRates, speed);
const rate = getBitcoinFeeRate(feeRates, speed);
const fee = previewHdFee(ownedInputs, amountSats, rate);
if (fee > 0 && fee <= threshold) { target = speed; break; }
}
@@ -360,7 +342,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin
if (amountSats <= 0) throw new Error(t('walletSend.errors.enterAmount'));
if (insufficient) throw new Error(t('walletSend.errors.insufficient'));
const rate = getRateForSpeed(feeRates, feeSpeed);
const rate = getBitcoinFeeRate(feeRates, feeSpeed);
const nextChangeIndex = scan?.change.firstUnusedIndex ?? 0;
setProgress('building');
@@ -578,7 +560,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin
</PopoverTrigger>
<PopoverContent className="w-44 p-1" align="end">
<div className="grid gap-0.5">
{getUniqueFeeSpeeds(feeRates).map((speed) => (
{getUniqueBitcoinFeeSpeeds(feeRates).map((speed) => (
<button
key={speed}
type="button"
@@ -591,7 +573,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin
<span>{feeSpeedLabels[speed]}</span>
{feeRates && (
<span className="text-muted-foreground tabular-nums">
{t('walletSend.satPerVB', { rate: getRateForSpeed(feeRates, speed) })}
{t('walletSend.satPerVB', { rate: getBitcoinFeeRate(feeRates, speed) })}
</span>
)}
</button>
+6 -39
View File
@@ -13,6 +13,7 @@ import {
import { QRCodeCanvas } from '@/components/ui/qrcode';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { BitcoinAmountPicker } from '@/components/BitcoinAmountPicker';
import { getBitcoinFeeRate, getUniqueBitcoinFeeSpeeds } from '@/lib/bitcoinFeeSpeed';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useBitcoinSigner } from '@/hooks/useBitcoinSigner';
@@ -41,40 +42,6 @@ const FEE_SPEED_LABELS: Record<OnchainFeeSpeed, string> = {
economy: '~1 day',
};
const FEE_SPEED_ORDER: OnchainFeeSpeed[] = ['fastest', 'halfHour', 'hour', 'economy'];
/**
* Given the raw mempool fee rates (sat/vB), return a deduplicated list of
* speed tiers. When multiple tiers share the same rate (common when the
* mempool is empty and everything collapses to 1 sat/vB), we keep only the
* fastest-labeled tier for that rate. This prevents rows like "~10 min 2
* sat/vB / ~30 min 2 sat/vB / ~1 hour 2 sat/vB" in the UI.
*/
function getRateForSpeed(rates: { fastestFee: number; halfHourFee: number; hourFee: number; economyFee: number }, speed: OnchainFeeSpeed): number {
switch (speed) {
case 'fastest': return rates.fastestFee;
case 'halfHour': return rates.halfHourFee;
case 'hour': return rates.hourFee;
case 'economy': return rates.economyFee;
}
}
function getUniqueFeeSpeeds(
rates: { fastestFee: number; halfHourFee: number; hourFee: number; economyFee: number } | undefined,
): OnchainFeeSpeed[] {
if (!rates) return FEE_SPEED_ORDER;
const seen = new Set<number>();
const result: OnchainFeeSpeed[] = [];
for (const speed of FEE_SPEED_ORDER) {
const rate = getRateForSpeed(rates, speed);
if (!seen.has(rate)) {
seen.add(rate);
result.push(speed);
}
}
return result;
}
interface OnchainZapContentProps {
target: NostrEvent;
/** Called with the tx result when a zap successfully broadcasts. */
@@ -139,7 +106,7 @@ export function OnchainZapContent({ target, onSuccess, onClose }: OnchainZapCont
const currentFeeRate = useMemo(() => {
if (!feeRates) return 0;
return getRateForSpeed(feeRates, feeSpeed);
return getBitcoinFeeRate(feeRates, feeSpeed);
}, [feeRates, feeSpeed]);
// Convert the USD amount to sats
@@ -172,12 +139,12 @@ export function OnchainZapContent({ target, onSuccess, onClose }: OnchainZapCont
if (feeSpeedUserChanged.current) return;
if (!utxos?.length || !feeRates || amountSats <= 0) return;
const uniqueSpeeds = getUniqueFeeSpeeds(feeRates);
const uniqueSpeeds = getUniqueBitcoinFeeSpeeds(feeRates);
const threshold = amountSats * 0.4;
let target: OnchainFeeSpeed = uniqueSpeeds[uniqueSpeeds.length - 1];
for (const speed of uniqueSpeeds) {
const rate = getRateForSpeed(feeRates, speed);
const rate = getBitcoinFeeRate(feeRates, speed);
const fee2 = estimateFee(utxos.length, 2, rate);
const change = totalBalance - amountSats - fee2;
const outputs = change > 546 ? 2 : 1;
@@ -253,7 +220,7 @@ export function OnchainZapContent({ target, onSuccess, onClose }: OnchainZapCont
const currentUsd = typeof usdAmount === 'string' ? parseFloat(usdAmount) : usdAmount;
const hasValidAmount = Number.isFinite(currentUsd) && currentUsd > 0;
const totalUsdString = btcPrice ? satsToUSD(totalSats, btcPrice) : '';
const uniqueFeeSpeeds = useMemo(() => getUniqueFeeSpeeds(feeRates), [feeRates]);
const uniqueFeeSpeeds = useMemo(() => getUniqueBitcoinFeeSpeeds(feeRates), [feeRates]);
if (user && capability === 'unsupported') {
return (
@@ -326,7 +293,7 @@ export function OnchainZapContent({ target, onSuccess, onClose }: OnchainZapCont
<PopoverContent align="center" sideOffset={6} className="w-56 p-1">
<div className="flex flex-col">
{uniqueFeeSpeeds.map((speed) => {
const rate = feeRates ? getRateForSpeed(feeRates, speed) : 0;
const rate = feeRates ? getBitcoinFeeRate(feeRates, speed) : 0;
const selected = speed === feeSpeed;
return (
<button
+12 -5
View File
@@ -113,11 +113,18 @@ export function TopNav() {
{/* Right cluster */}
<div className="flex items-center gap-2 sm:gap-3">
{/* Wallet balance in USD — links to /wallet. Replaces the search
entry point in the chrome; mobile users still reach search via
the hamburger menu. Hidden until the HD wallet is available and
the BTC price has loaded. */}
<DeferredWalletBalancePill />
{user ? (
<DeferredWalletBalancePill />
) : (
<Link
to="/search"
className="shrink-0 size-9 rounded-full flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-secondary motion-safe:transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label={t('nav.search')}
title={t('nav.search')}
>
<Search className="size-5" />
</Link>
)}
{/* LoginArea handles both logged-in (account avatar dropdown) and
logged-out (Log in / Sign up) states. We render it inline-flex
+35
View File
@@ -0,0 +1,35 @@
export const BITCOIN_FEE_SPEED_ORDER = ['fastest', 'halfHour', 'hour', 'economy'] as const;
export type BitcoinFeeSpeed = typeof BITCOIN_FEE_SPEED_ORDER[number];
export interface BitcoinFeeRates {
fastestFee: number;
halfHourFee: number;
hourFee: number;
economyFee: number;
}
export function getBitcoinFeeRate(rates: BitcoinFeeRates, speed: BitcoinFeeSpeed): number {
switch (speed) {
case 'fastest': return rates.fastestFee;
case 'halfHour': return rates.halfHourFee;
case 'hour': return rates.hourFee;
case 'economy': return rates.economyFee;
}
}
export function getUniqueBitcoinFeeSpeeds(
rates: BitcoinFeeRates | undefined,
): BitcoinFeeSpeed[] {
if (!rates) return [...BITCOIN_FEE_SPEED_ORDER];
const seen = new Set<number>();
const result: BitcoinFeeSpeed[] = [];
for (const speed of BITCOIN_FEE_SPEED_ORDER) {
const rate = getBitcoinFeeRate(rates, speed);
if (!seen.has(rate)) {
seen.add(rate);
result.push(speed);
}
}
return result;
}