Refine wallet nav and fee helpers
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user