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));
+}