diff --git a/src/components/common/NetworkFeeHint.tsx b/src/components/common/NetworkFeeHint.tsx index 2693016e..f4cefdbf 100644 --- a/src/components/common/NetworkFeeHint.tsx +++ b/src/components/common/NetworkFeeHint.tsx @@ -3,20 +3,29 @@ import { Zap } from 'lucide-react'; interface NetworkFeeHintProps { fee?: string; + label?: string; className?: string; variant?: 'chip' | 'text'; } const NetworkFeeHint = ({ fee = '~0.0001 ETH', + label = 'Network fee', className, variant = 'chip', }: NetworkFeeHintProps) => { if (variant === 'text') { return ( -
+
- Network fee: {fee} + + {label}: {fee} +
); } diff --git a/src/components/common/TradeDialog.tsx b/src/components/common/TradeDialog.tsx index 440d533c..fdfdd1ad 100644 --- a/src/components/common/TradeDialog.tsx +++ b/src/components/common/TradeDialog.tsx @@ -15,7 +15,11 @@ import { formatDisplayKeyPrice } from '@/utils/keyPriceDisplay.utils'; import PercentageBadge from '@/components/common/PercentageBadge'; import NetworkFeeHint from '@/components/common/NetworkFeeHint'; import { TRADE_FEE_ESTIMATE } from '@/constants/fees'; -import { formatTransactionFeeDisplay } from '@/utils/transactionFee.utils'; +import { + fetchTradeNetworkFeeEstimate, + formatTransactionFeeDisplay, + type NetworkFeeDataProvider, +} from '@/utils/transactionFee.utils'; import { normalizeCreatorDisplayName } from '@/utils/creatorDisplayName.utils'; export type TradeSide = 'buy' | 'sell'; @@ -30,8 +34,13 @@ export interface TradeDialogProps { onOpenChange: (open: boolean) => void; onConfirm: (amount: number) => Promise | void; isSubmitting?: boolean; + networkFeeEstimateProvider?: NetworkFeeDataProvider; } +type NetworkFeeEstimateState = + | { status: 'idle' | 'loading' | 'error'; fee: null } + | { status: 'success'; fee: number }; + const TradeDialog: React.FC = ({ open, side, @@ -41,8 +50,11 @@ const TradeDialog: React.FC = ({ onOpenChange, onConfirm, isSubmitting = false, + networkFeeEstimateProvider, }) => { const [amountText, setAmountText] = useState('1'); + const [networkFeeEstimate, setNetworkFeeEstimate] = + useState({ status: 'idle', fee: null }); const amountInputRef = useRef(null); useEffect(() => { @@ -65,9 +77,60 @@ const TradeDialog: React.FC = ({ const title = side === 'buy' ? 'Buy keys' : 'Sell keys'; const confirmLabel = side === 'buy' ? 'Confirm buy' : 'Confirm sell'; const estimatedNetworkFee = formatTransactionFeeDisplay( - TRADE_FEE_ESTIMATE.DEFAULT_NETWORK_FEE, + networkFeeEstimate.status === 'success' + ? networkFeeEstimate.fee + : TRADE_FEE_ESTIMATE.DEFAULT_NETWORK_FEE, { unit: TRADE_FEE_ESTIMATE.UNIT } ); + const networkFeeCopy = + networkFeeEstimate.status === 'loading' + ? 'Estimating...' + : networkFeeEstimate.status === 'error' + ? 'Cannot estimate network fee' + : estimatedNetworkFee; + + useEffect(() => { + if (!open) { + setNetworkFeeEstimate({ status: 'idle', fee: null }); + return; + } + + if (!amountValid || !networkFeeEstimateProvider) { + setNetworkFeeEstimate({ status: 'error', fee: null }); + return; + } + + let cancelled = false; + setNetworkFeeEstimate({ status: 'loading', fee: null }); + + fetchTradeNetworkFeeEstimate(networkFeeEstimateProvider, { + side, + amount: parsedAmount, + }) + .then(fee => { + if (cancelled) return; + setNetworkFeeEstimate( + fee == null + ? { status: 'error', fee: null } + : { status: 'success', fee } + ); + }) + .catch(() => { + if (!cancelled) { + setNetworkFeeEstimate({ status: 'error', fee: null }); + } + }); + + return () => { + cancelled = true; + }; + }, [ + amountValid, + networkFeeEstimateProvider, + open, + parsedAmount, + side, + ]); return ( = ({ /> )}
- {side === 'buy' && ( - - )} + {side === 'sell' && parsedAmount > availableHoldings && (
You can’t sell more than your current holdings. diff --git a/src/components/common/__tests__/TradeDialog.focusOrder.test.tsx b/src/components/common/__tests__/TradeDialog.focusOrder.test.tsx index e5eeea26..1ac84489 100644 --- a/src/components/common/__tests__/TradeDialog.focusOrder.test.tsx +++ b/src/components/common/__tests__/TradeDialog.focusOrder.test.tsx @@ -101,4 +101,33 @@ describe('TradeDialog focus order', () => { expect(ordered).toEqual(['1', '2', '3']); }); + + it('shows an approximate network fee estimate before confirmation', async () => { + renderDialog({ + networkFeeEstimateProvider: { + getFeeData: vi.fn().mockResolvedValue({ + gasPrice: 1_000_000_000n, + }), + }, + }); + + expect(screen.getByTestId('trade-dialog-confirm')).toBeInTheDocument(); + expect( + await screen.findByText('Approx. network fee: ~0.00018 ETH') + ).toBeInTheDocument(); + }); + + it('shows a cannot estimate message when the fee estimate fails', async () => { + renderDialog({ + networkFeeEstimateProvider: { + getFeeData: vi.fn().mockRejectedValue(new Error('RPC unavailable')), + }, + }); + + expect( + await screen.findByText( + 'Approx. network fee: Cannot estimate network fee' + ) + ).toBeInTheDocument(); + }); }); diff --git a/src/constants/fees.ts b/src/constants/fees.ts index 4c014821..bf87e95d 100644 --- a/src/constants/fees.ts +++ b/src/constants/fees.ts @@ -15,4 +15,6 @@ export const KEY_PRICE_BOUNDS = { export const TRADE_FEE_ESTIMATE = { DEFAULT_NETWORK_FEE: 0.0001, UNIT: 'ETH', + BUY_GAS_LIMIT: 180_000n, + SELL_GAS_LIMIT: 150_000n, } as const; diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index f33ec486..5c83b954 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -29,6 +29,7 @@ import EmptyTransactionTimelineState from '@/components/common/EmptyTransactionT import TradeDialog, { type TradeSide } from '@/components/common/TradeDialog'; import NetworkMismatchBanner from '@/components/common/NetworkMismatchBanner'; import StellarConnectionQualityBadge from '@/components/common/StellarConnectionQualityBadge'; +import { useEthersProvider } from '@/hooks/useEthersProvider'; import { useNetworkMismatch } from '@/hooks/useNetworkMismatch'; import showToast from '@/utils/toast.util'; import { getSignatureErrorMessage } from '@/utils/errorHandling.utils'; @@ -249,6 +250,7 @@ function LandingPage() { const [tradeSide, setTradeSide] = useState('buy'); const [tradeDialogOpen, setTradeDialogOpen] = useState(false); const [tradeSubmitting, setTradeSubmitting] = useState(false); + const tradeFeeEstimateProvider = useEthersProvider(); const prefersReducedMotion = usePrefersReducedMotion(); const [sortOption, setSortOption] = useState(() => { if (typeof window === 'undefined') return 'featured'; @@ -1027,6 +1029,7 @@ function LandingPage() { availableHoldings={featuredHoldings} keyPriceStroops={resolveCreatorKeyPriceStroops(featuredCreator)} isSubmitting={tradeSubmitting} + networkFeeEstimateProvider={tradeFeeEstimateProvider} onOpenChange={setTradeDialogOpen} onConfirm={handleConfirmTrade} /> diff --git a/src/utils/transactionFee.utils.ts b/src/utils/transactionFee.utils.ts index 22100cea..2b82c2b4 100644 --- a/src/utils/transactionFee.utils.ts +++ b/src/utils/transactionFee.utils.ts @@ -1,3 +1,5 @@ +import { formatEther } from 'ethers'; +import { TRADE_FEE_ESTIMATE } from '@/constants/fees'; import { formatNumber } from '@/utils/numberFormat.utils'; export interface FormatTransactionFeeOptions { @@ -6,6 +8,20 @@ export interface FormatTransactionFeeOptions { prefix?: string; } +export interface NetworkFeeDataProvider { + getFeeData: () => Promise<{ + gasPrice?: bigint | null; + maxFeePerGas?: bigint | null; + }>; +} + +export type TradeFeeEstimateSide = 'buy' | 'sell'; + +export interface TradeNetworkFeeEstimateRequest { + side: TradeFeeEstimateSide; + amount: number; +} + /** * Formats a transaction fee for confirmation UIs. * @@ -29,3 +45,28 @@ export function formatTransactionFeeDisplay( minimumFractionDigits: 0, })} ${unit}`; } + +export function getTradeFeeGasLimit(side: TradeFeeEstimateSide): bigint { + return side === 'buy' + ? TRADE_FEE_ESTIMATE.BUY_GAS_LIMIT + : TRADE_FEE_ESTIMATE.SELL_GAS_LIMIT; +} + +export async function fetchTradeNetworkFeeEstimate( + provider: NetworkFeeDataProvider, + request: TradeNetworkFeeEstimateRequest +): Promise { + if (!Number.isFinite(request.amount) || request.amount <= 0) { + return null; + } + + const feeData = await provider.getFeeData(); + const gasPrice = feeData.maxFeePerGas ?? feeData.gasPrice; + + if (gasPrice == null) { + return null; + } + + const estimatedFeeWei = gasPrice * getTradeFeeGasLimit(request.side); + return Number(formatEther(estimatedFeeWei)); +}