Skip to content

Commit 32db74e

Browse files
authored
Merge pull request #390 from T-kesh/issue-382-fee-estimate-helper
Add pre-confirmation trade fee estimate
2 parents d4b349c + 7c548d1 commit 32db74e

6 files changed

Lines changed: 157 additions & 11 deletions

File tree

src/components/common/NetworkFeeHint.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,29 @@ import { Zap } from 'lucide-react';
33

44
interface NetworkFeeHintProps {
55
fee?: string;
6+
label?: string;
67
className?: string;
78
variant?: 'chip' | 'text';
89
}
910

1011
const NetworkFeeHint = ({
1112
fee = '~0.0001 ETH',
13+
label = 'Network fee',
1214
className,
1315
variant = 'chip',
1416
}: NetworkFeeHintProps) => {
1517
if (variant === 'text') {
1618
return (
17-
<div className={cn('flex items-center gap-1.5 text-xs text-white/40', className)}>
19+
<div
20+
className={cn(
21+
'flex items-center gap-1.5 text-xs text-white/40',
22+
className
23+
)}
24+
>
1825
<Zap className="size-3 text-amber-500/50" />
19-
<span>Network fee: {fee}</span>
26+
<span>
27+
{label}: {fee}
28+
</span>
2029
</div>
2130
);
2231
}

src/components/common/TradeDialog.tsx

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ import { formatDisplayKeyPrice } from '@/utils/keyPriceDisplay.utils';
1515
import PercentageBadge from '@/components/common/PercentageBadge';
1616
import NetworkFeeHint from '@/components/common/NetworkFeeHint';
1717
import { TRADE_FEE_ESTIMATE } from '@/constants/fees';
18-
import { formatTransactionFeeDisplay } from '@/utils/transactionFee.utils';
18+
import {
19+
fetchTradeNetworkFeeEstimate,
20+
formatTransactionFeeDisplay,
21+
type NetworkFeeDataProvider,
22+
} from '@/utils/transactionFee.utils';
1923
import { normalizeCreatorDisplayName } from '@/utils/creatorDisplayName.utils';
2024

2125
export type TradeSide = 'buy' | 'sell';
@@ -30,8 +34,13 @@ export interface TradeDialogProps {
3034
onOpenChange: (open: boolean) => void;
3135
onConfirm: (amount: number) => Promise<void> | void;
3236
isSubmitting?: boolean;
37+
networkFeeEstimateProvider?: NetworkFeeDataProvider;
3338
}
3439

40+
type NetworkFeeEstimateState =
41+
| { status: 'idle' | 'loading' | 'error'; fee: null }
42+
| { status: 'success'; fee: number };
43+
3544
const TradeDialog: React.FC<TradeDialogProps> = ({
3645
open,
3746
side,
@@ -41,8 +50,11 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
4150
onOpenChange,
4251
onConfirm,
4352
isSubmitting = false,
53+
networkFeeEstimateProvider,
4454
}) => {
4555
const [amountText, setAmountText] = useState('1');
56+
const [networkFeeEstimate, setNetworkFeeEstimate] =
57+
useState<NetworkFeeEstimateState>({ status: 'idle', fee: null });
4658
const amountInputRef = useRef<HTMLInputElement | null>(null);
4759

4860
useEffect(() => {
@@ -65,9 +77,60 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
6577
const title = side === 'buy' ? 'Buy keys' : 'Sell keys';
6678
const confirmLabel = side === 'buy' ? 'Confirm buy' : 'Confirm sell';
6779
const estimatedNetworkFee = formatTransactionFeeDisplay(
68-
TRADE_FEE_ESTIMATE.DEFAULT_NETWORK_FEE,
80+
networkFeeEstimate.status === 'success'
81+
? networkFeeEstimate.fee
82+
: TRADE_FEE_ESTIMATE.DEFAULT_NETWORK_FEE,
6983
{ unit: TRADE_FEE_ESTIMATE.UNIT }
7084
);
85+
const networkFeeCopy =
86+
networkFeeEstimate.status === 'loading'
87+
? 'Estimating...'
88+
: networkFeeEstimate.status === 'error'
89+
? 'Cannot estimate network fee'
90+
: estimatedNetworkFee;
91+
92+
useEffect(() => {
93+
if (!open) {
94+
setNetworkFeeEstimate({ status: 'idle', fee: null });
95+
return;
96+
}
97+
98+
if (!amountValid || !networkFeeEstimateProvider) {
99+
setNetworkFeeEstimate({ status: 'error', fee: null });
100+
return;
101+
}
102+
103+
let cancelled = false;
104+
setNetworkFeeEstimate({ status: 'loading', fee: null });
105+
106+
fetchTradeNetworkFeeEstimate(networkFeeEstimateProvider, {
107+
side,
108+
amount: parsedAmount,
109+
})
110+
.then(fee => {
111+
if (cancelled) return;
112+
setNetworkFeeEstimate(
113+
fee == null
114+
? { status: 'error', fee: null }
115+
: { status: 'success', fee }
116+
);
117+
})
118+
.catch(() => {
119+
if (!cancelled) {
120+
setNetworkFeeEstimate({ status: 'error', fee: null });
121+
}
122+
});
123+
124+
return () => {
125+
cancelled = true;
126+
};
127+
}, [
128+
amountValid,
129+
networkFeeEstimateProvider,
130+
open,
131+
parsedAmount,
132+
side,
133+
]);
71134

72135
return (
73136
<Dialog
@@ -147,13 +210,12 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
147210
/>
148211
)}
149212
</div>
150-
{side === 'buy' && (
151-
<NetworkFeeHint
152-
variant="text"
153-
fee={estimatedNetworkFee}
154-
className="text-white/45"
155-
/>
156-
)}
213+
<NetworkFeeHint
214+
variant="text"
215+
label="Approx. network fee"
216+
fee={networkFeeCopy}
217+
className="text-white/45"
218+
/>
157219
{side === 'sell' && parsedAmount > availableHoldings && (
158220
<div className="text-xs text-red-300">
159221
You can’t sell more than your current holdings.

src/components/common/__tests__/TradeDialog.focusOrder.test.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,4 +101,33 @@ describe('TradeDialog focus order', () => {
101101

102102
expect(ordered).toEqual(['1', '2', '3']);
103103
});
104+
105+
it('shows an approximate network fee estimate before confirmation', async () => {
106+
renderDialog({
107+
networkFeeEstimateProvider: {
108+
getFeeData: vi.fn().mockResolvedValue({
109+
gasPrice: 1_000_000_000n,
110+
}),
111+
},
112+
});
113+
114+
expect(screen.getByTestId('trade-dialog-confirm')).toBeInTheDocument();
115+
expect(
116+
await screen.findByText('Approx. network fee: ~0.00018 ETH')
117+
).toBeInTheDocument();
118+
});
119+
120+
it('shows a cannot estimate message when the fee estimate fails', async () => {
121+
renderDialog({
122+
networkFeeEstimateProvider: {
123+
getFeeData: vi.fn().mockRejectedValue(new Error('RPC unavailable')),
124+
},
125+
});
126+
127+
expect(
128+
await screen.findByText(
129+
'Approx. network fee: Cannot estimate network fee'
130+
)
131+
).toBeInTheDocument();
132+
});
104133
});

src/constants/fees.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,6 @@ export const KEY_PRICE_BOUNDS = {
1515
export const TRADE_FEE_ESTIMATE = {
1616
DEFAULT_NETWORK_FEE: 0.0001,
1717
UNIT: 'ETH',
18+
BUY_GAS_LIMIT: 180_000n,
19+
SELL_GAS_LIMIT: 150_000n,
1820
} as const;

src/pages/LandingPage.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import EmptyTransactionTimelineState from '@/components/common/EmptyTransactionT
2929
import TradeDialog, { type TradeSide } from '@/components/common/TradeDialog';
3030
import NetworkMismatchBanner from '@/components/common/NetworkMismatchBanner';
3131
import StellarConnectionQualityBadge from '@/components/common/StellarConnectionQualityBadge';
32+
import { useEthersProvider } from '@/hooks/useEthersProvider';
3233
import { useNetworkMismatch } from '@/hooks/useNetworkMismatch';
3334
import showToast from '@/utils/toast.util';
3435
import { getSignatureErrorMessage } from '@/utils/errorHandling.utils';
@@ -249,6 +250,7 @@ function LandingPage() {
249250
const [tradeSide, setTradeSide] = useState<TradeSide>('buy');
250251
const [tradeDialogOpen, setTradeDialogOpen] = useState(false);
251252
const [tradeSubmitting, setTradeSubmitting] = useState(false);
253+
const tradeFeeEstimateProvider = useEthersProvider();
252254
const prefersReducedMotion = usePrefersReducedMotion();
253255
const [sortOption, setSortOption] = useState<SortOption>(() => {
254256
if (typeof window === 'undefined') return 'featured';
@@ -1027,6 +1029,7 @@ function LandingPage() {
10271029
availableHoldings={featuredHoldings}
10281030
keyPriceStroops={resolveCreatorKeyPriceStroops(featuredCreator)}
10291031
isSubmitting={tradeSubmitting}
1032+
networkFeeEstimateProvider={tradeFeeEstimateProvider}
10301033
onOpenChange={setTradeDialogOpen}
10311034
onConfirm={handleConfirmTrade}
10321035
/>

src/utils/transactionFee.utils.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { formatEther } from 'ethers';
2+
import { TRADE_FEE_ESTIMATE } from '@/constants/fees';
13
import { formatNumber } from '@/utils/numberFormat.utils';
24

35
export interface FormatTransactionFeeOptions {
@@ -6,6 +8,20 @@ export interface FormatTransactionFeeOptions {
68
prefix?: string;
79
}
810

11+
export interface NetworkFeeDataProvider {
12+
getFeeData: () => Promise<{
13+
gasPrice?: bigint | null;
14+
maxFeePerGas?: bigint | null;
15+
}>;
16+
}
17+
18+
export type TradeFeeEstimateSide = 'buy' | 'sell';
19+
20+
export interface TradeNetworkFeeEstimateRequest {
21+
side: TradeFeeEstimateSide;
22+
amount: number;
23+
}
24+
925
/**
1026
* Formats a transaction fee for confirmation UIs.
1127
*
@@ -29,3 +45,28 @@ export function formatTransactionFeeDisplay(
2945
minimumFractionDigits: 0,
3046
})} ${unit}`;
3147
}
48+
49+
export function getTradeFeeGasLimit(side: TradeFeeEstimateSide): bigint {
50+
return side === 'buy'
51+
? TRADE_FEE_ESTIMATE.BUY_GAS_LIMIT
52+
: TRADE_FEE_ESTIMATE.SELL_GAS_LIMIT;
53+
}
54+
55+
export async function fetchTradeNetworkFeeEstimate(
56+
provider: NetworkFeeDataProvider,
57+
request: TradeNetworkFeeEstimateRequest
58+
): Promise<number | null> {
59+
if (!Number.isFinite(request.amount) || request.amount <= 0) {
60+
return null;
61+
}
62+
63+
const feeData = await provider.getFeeData();
64+
const gasPrice = feeData.maxFeePerGas ?? feeData.gasPrice;
65+
66+
if (gasPrice == null) {
67+
return null;
68+
}
69+
70+
const estimatedFeeWei = gasPrice * getTradeFeeGasLimit(request.side);
71+
return Number(formatEther(estimatedFeeWei));
72+
}

0 commit comments

Comments
 (0)