Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions src/components/common/NetworkFeeHint.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className={cn('flex items-center gap-1.5 text-xs text-white/40', className)}>
<div
className={cn(
'flex items-center gap-1.5 text-xs text-white/40',
className
)}
>
<Zap className="size-3 text-amber-500/50" />
<span>Network fee: {fee}</span>
<span>
{label}: {fee}
</span>
</div>
);
}
Expand Down
80 changes: 71 additions & 9 deletions src/components/common/TradeDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -30,8 +34,13 @@ export interface TradeDialogProps {
onOpenChange: (open: boolean) => void;
onConfirm: (amount: number) => Promise<void> | void;
isSubmitting?: boolean;
networkFeeEstimateProvider?: NetworkFeeDataProvider;
}

type NetworkFeeEstimateState =
| { status: 'idle' | 'loading' | 'error'; fee: null }
| { status: 'success'; fee: number };

const TradeDialog: React.FC<TradeDialogProps> = ({
open,
side,
Expand All @@ -41,8 +50,11 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
onOpenChange,
onConfirm,
isSubmitting = false,
networkFeeEstimateProvider,
}) => {
const [amountText, setAmountText] = useState('1');
const [networkFeeEstimate, setNetworkFeeEstimate] =
useState<NetworkFeeEstimateState>({ status: 'idle', fee: null });
const amountInputRef = useRef<HTMLInputElement | null>(null);

useEffect(() => {
Expand All @@ -65,9 +77,60 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
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 (
<Dialog
Expand Down Expand Up @@ -147,13 +210,12 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
/>
)}
</div>
{side === 'buy' && (
<NetworkFeeHint
variant="text"
fee={estimatedNetworkFee}
className="text-white/45"
/>
)}
<NetworkFeeHint
variant="text"
label="Approx. network fee"
fee={networkFeeCopy}
className="text-white/45"
/>
{side === 'sell' && parsedAmount > availableHoldings && (
<div className="text-xs text-red-300">
You can’t sell more than your current holdings.
Expand Down
29 changes: 29 additions & 0 deletions src/components/common/__tests__/TradeDialog.focusOrder.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
2 changes: 2 additions & 0 deletions src/constants/fees.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
3 changes: 3 additions & 0 deletions src/pages/LandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -249,6 +250,7 @@ function LandingPage() {
const [tradeSide, setTradeSide] = useState<TradeSide>('buy');
const [tradeDialogOpen, setTradeDialogOpen] = useState(false);
const [tradeSubmitting, setTradeSubmitting] = useState(false);
const tradeFeeEstimateProvider = useEthersProvider();
const prefersReducedMotion = usePrefersReducedMotion();
const [sortOption, setSortOption] = useState<SortOption>(() => {
if (typeof window === 'undefined') return 'featured';
Expand Down Expand Up @@ -1027,6 +1029,7 @@ function LandingPage() {
availableHoldings={featuredHoldings}
keyPriceStroops={resolveCreatorKeyPriceStroops(featuredCreator)}
isSubmitting={tradeSubmitting}
networkFeeEstimateProvider={tradeFeeEstimateProvider}
onOpenChange={setTradeDialogOpen}
onConfirm={handleConfirmTrade}
/>
Expand Down
41 changes: 41 additions & 0 deletions src/utils/transactionFee.utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { formatEther } from 'ethers';
import { TRADE_FEE_ESTIMATE } from '@/constants/fees';
import { formatNumber } from '@/utils/numberFormat.utils';

export interface FormatTransactionFeeOptions {
Expand All @@ -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.
*
Expand All @@ -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<number | null> {
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));
}
Loading