diff --git a/frontend/src/components/SendTip.jsx b/frontend/src/components/SendTip.jsx index 6eef6f54..ecb28986 100644 --- a/frontend/src/components/SendTip.jsx +++ b/frontend/src/components/SendTip.jsx @@ -20,6 +20,7 @@ import { useBlockCheck } from '../hooks/useBlockCheck'; import { useStxPrice } from '../hooks/useStxPrice'; import { useSenderAddress } from '../hooks/useSenderAddress'; import { useContractFee } from '../hooks/useContractFee'; +import { useTransactionFeeEstimate } from '../hooks/useTransactionFeeEstimate'; import { analytics } from '../lib/analytics'; import ConfirmDialog from './ui/confirm-dialog'; import TxStatus from './ui/tx-status'; @@ -80,6 +81,16 @@ export default function SendTip({ addToast }) { }, [balance]); const { feeBasisPoints, feePercent } = useContractFee(); + const { + feeEstimateMicroSTX, + feeEstimateSTX, + feeEstimateUsd, + highFeeWarning, + feeLevel, + setFeeLevel, + speedEstimates, + refresh: refreshTransactionFee, + } = useTransactionFeeEstimate(); const isRecipientHighRisk = !canProceedWithRecipient(recipient, blockedWarning); @@ -149,10 +160,10 @@ export default function SendTip({ addToast }) { } else if (parsed > MAX_TIP_STX) { setAmountError(`Maximum tip is ${MAX_TIP_STX.toLocaleString()} STX`); } else if (balanceSTX !== null) { - // Account for the platform fee when checking balance + // Account for the platform fee and estimated network fee when checking balance const microSTX = toMicroSTX(parsed.toString()); - if (!hasSufficientMicroStx(balance, totalDeduction(microSTX, feeBasisPoints))) { - setAmountError('Insufficient balance (tip + fee exceeds balance)'); + if (!hasSufficientMicroStx(balance, totalDeduction(microSTX, feeBasisPoints) + feeEstimateMicroSTX)) { + setAmountError('Insufficient balance (tip + platform fee + gas exceeds balance)'); } else { setAmountError(''); } @@ -179,8 +190,8 @@ export default function SendTip({ addToast }) { if (parsedAmount > MAX_TIP_STX) { addToast(`Maximum tip is ${MAX_TIP_STX.toLocaleString()} STX`, 'warning'); return; } if (balanceSTX !== null) { const microSTX = toMicroSTX(amount); - if (!hasSufficientMicroStx(balance, totalDeduction(microSTX, feeBasisPoints))) { - addToast('Insufficient balance to cover tip plus platform fee', 'warning'); + if (!hasSufficientMicroStx(balance, totalDeduction(microSTX, feeBasisPoints) + feeEstimateMicroSTX)) { + addToast('Insufficient balance to cover tip, platform fee, and gas fee', 'warning'); return; } } @@ -372,7 +383,38 @@ export default function SendTip({ addToast }) { {/* Breakdown with fee preview and post-condition ceiling */} {amount && parseFloat(amount) > 0 && (
+ {highFeeWarning && ( +
+ + + +
+

High Transaction Fee Warning

+

Estimated gas fees are currently high ({feeEstimateSTX.toFixed(4)} STX). You can select a lower fee speed option to minimize costs.

+
+
+ )}

Fee Preview

+
+ {['low', 'medium', 'high'].map((level) => { + const isActive = feeLevel === level; + return ( + + ); + })} +
Tip amount @@ -385,10 +427,17 @@ export default function SendTip({ addToast }) { Platform fee ({feePercent.toFixed(2)}%) {formatSTX(feeForTip(toMicroSTX(amount), feeBasisPoints), 6)} STX
+
+ Estimated gas fee ({feeLevel}) + + {feeEstimateSTX.toFixed(6)} STX + {feeEstimateUsd && (~${feeEstimateUsd})} + +
Total from wallet - {formatSTX(totalDeduction(toMicroSTX(amount), feeBasisPoints), 6)} STX + {formatSTX(Number(totalDeduction(toMicroSTX(amount), feeBasisPoints)) + feeEstimateMicroSTX, 6)} STX
@@ -447,9 +496,13 @@ export default function SendTip({ addToast }) { Platform fee ({feePercent.toFixed(2)}%) {formatSTX(feeForTip(toMicroSTX(amount), feeBasisPoints), 6)} STX
+
+ Estimated gas fee ({feeLevel}) + {feeEstimateSTX.toFixed(6)} STX +
Total from your wallet - {formatSTX(totalDeduction(toMicroSTX(amount), feeBasisPoints), 6)} STX + {formatSTX(Number(totalDeduction(toMicroSTX(amount), feeBasisPoints)) + feeEstimateMicroSTX, 6)} STX
)} diff --git a/frontend/src/hooks/useTransactionFeeEstimate.js b/frontend/src/hooks/useTransactionFeeEstimate.js new file mode 100644 index 00000000..1c558abe --- /dev/null +++ b/frontend/src/hooks/useTransactionFeeEstimate.js @@ -0,0 +1,192 @@ +import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import { STACKS_API_BASE } from '../config/contracts'; +import { useStxPrice } from './useStxPrice'; +import { useDemoMode } from '../context/DemoContext'; + +export const DEFAULT_FEE_MICROSTX = 5_000; +export const DEFAULT_LOW_FEE_MICROSTX = 3_000; +export const DEFAULT_HIGH_FEE_MICROSTX = 15_000; + +export const HIGH_FEE_THRESHOLD_MICROSTX = 50_000; +const REFRESH_INTERVAL_MS = 30_000; + +const DUMMY_PAYLOAD_HEX = '0000000000000000000000000000000000000000'; + +function uint8ArrayToHex(uint8Array) { + return Array.from(uint8Array) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); +} + +async function generateDummyTransactionPayloadHex() { + try { + const { makeContractCall, principalCV, uintCV, stringUtf8CV, PostConditionMode } = await import('@stacks/transactions'); + const { network } = await import('../utils/stacks'); + const { CONTRACT_ADDRESS, CONTRACT_NAME, FN_SEND_CATEGORIZED_TIP } = await import('../config/contracts'); + + const dummyRecipient = 'SP1W6XQZ6XVYGTVW32SJW2ZG48ZJBW9BATRD19N60'; + const dummyAmount = 1000000; + + const functionArgs = [ + principalCV(dummyRecipient), + uintCV(dummyAmount), + stringUtf8CV('Thanks!'), + uintCV(0) + ]; + + const tx = await makeContractCall({ + contractAddress: CONTRACT_ADDRESS, + contractName: CONTRACT_NAME, + functionName: FN_SEND_CATEGORIZED_TIP, + functionArgs, + senderKey: '0000000000000000000000000000000000000000000000000000000000000001', + network, + postConditionMode: PostConditionMode.Deny, + postConditions: [], + }); + + const serialized = tx.serialize(); + return uint8ArrayToHex(serialized); + } catch (e) { + return DUMMY_PAYLOAD_HEX; + } +} + +export function useTransactionFeeEstimate({ pollInterval = REFRESH_INTERVAL_MS } = {}) { + const { demoEnabled } = useDemoMode(); + const { toUsd } = useStxPrice(); + + const [feeLevel, setFeeLevel] = useState('medium'); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [speedEstimates, setSpeedEstimates] = useState({ + low: { microSTX: DEFAULT_LOW_FEE_MICROSTX, STX: DEFAULT_LOW_FEE_MICROSTX / 1_000_000 }, + medium: { microSTX: DEFAULT_FEE_MICROSTX, STX: DEFAULT_FEE_MICROSTX / 1_000_000 }, + high: { microSTX: DEFAULT_HIGH_FEE_MICROSTX, STX: DEFAULT_HIGH_FEE_MICROSTX / 1_000_000 }, + }); + + const isMountedRef = useRef(true); + const intervalRef = useRef(null); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + const speedEstimatesWithUsd = useMemo(() => { + const updated = {}; + Object.keys(speedEstimates).forEach((level) => { + const est = speedEstimates[level]; + const usdVal = toUsd ? toUsd(est.STX) : null; + updated[level] = { + ...est, + Usd: usdVal, + }; + }); + return updated; + }, [speedEstimates, toUsd]); + + const estimate = useCallback(async () => { + if (!isMountedRef.current) return; + setLoading(true); + if (demoEnabled) { + if (isMountedRef.current) { + setLoading(false); + setError(null); + } + return; + } + + let baseEstimates = null; + const payloadHex = await generateDummyTransactionPayloadHex(); + + try { + const response = await fetch(`${STACKS_API_BASE}/v2/fees/transaction`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + transaction_payload: payloadHex, + estimated_len: 200, + }), + }); + + if (response.ok) { + const data = await response.json(); + if (data && Array.isArray(data.estimations) && data.estimations.length >= 3) { + baseEstimates = { + low: { microSTX: data.estimations[0].fee, STX: data.estimations[0].fee / 1_000_000 }, + medium: { microSTX: data.estimations[1].fee, STX: data.estimations[1].fee / 1_000_000 }, + high: { microSTX: data.estimations[2].fee, STX: data.estimations[2].fee / 1_000_000 }, + }; + } + } + } catch (e) { + // Silence Stacks Node API error + } + + if (!baseEstimates) { + try { + const response = await fetch(`${STACKS_API_BASE}/extended/v1/fee_rate`); + if (response.ok) { + const data = await response.json(); + if (data && typeof data.fee_rate === 'number') { + const standardFee = Math.max(DEFAULT_FEE_MICROSTX, data.fee_rate * 250); + baseEstimates = { + low: { microSTX: Math.floor(standardFee * 0.6), STX: (standardFee * 0.6) / 1_000_000 }, + medium: { microSTX: standardFee, STX: standardFee / 1_000_000 }, + high: { microSTX: Math.floor(standardFee * 2), STX: (standardFee * 2) / 1_000_000 }, + }; + } + } + } catch (e) { + // Silence secondary fallback error + } + } + + if (!baseEstimates) { + baseEstimates = { + low: { microSTX: DEFAULT_LOW_FEE_MICROSTX, STX: DEFAULT_LOW_FEE_MICROSTX / 1_000_000 }, + medium: { microSTX: DEFAULT_FEE_MICROSTX, STX: DEFAULT_FEE_MICROSTX / 1_000_000 }, + high: { microSTX: DEFAULT_HIGH_FEE_MICROSTX, STX: DEFAULT_HIGH_FEE_MICROSTX / 1_000_000 }, + }; + } + + if (isMountedRef.current) { + setSpeedEstimates(baseEstimates); + setLoading(false); + setError(null); + } + }, [demoEnabled]); + + useEffect(() => { + estimate(); + + if (pollInterval > 0) { + intervalRef.current = setInterval(estimate, pollInterval); + } + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }; + }, [estimate, pollInterval]); + + const activeEstimate = speedEstimatesWithUsd[feeLevel]; + + return { + feeEstimateMicroSTX: activeEstimate.microSTX, + feeEstimateSTX: activeEstimate.STX, + feeEstimateUsd: activeEstimate.Usd, + loading, + error, + highFeeWarning: activeEstimate.microSTX >= HIGH_FEE_THRESHOLD_MICROSTX, + feeLevel, + setFeeLevel, + speedEstimates, + refresh: estimate, + }; +} diff --git a/frontend/src/hooks/useTransactionFeeEstimate.test.js b/frontend/src/hooks/useTransactionFeeEstimate.test.js new file mode 100644 index 00000000..2e559adf --- /dev/null +++ b/frontend/src/hooks/useTransactionFeeEstimate.test.js @@ -0,0 +1,166 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { renderHook, waitFor, act } from '@testing-library/react'; + +// Mock heavy Stacks dependencies to prevent slow cryptography execution in tests +vi.mock('@stacks/transactions', () => ({ + PostConditionMode: { Deny: 0x02 }, + uintCV: vi.fn((v) => ({ type: 'uint', value: v })), + principalCV: vi.fn((v) => ({ type: 'principal', value: v })), + stringUtf8CV: vi.fn((v) => ({ type: 'string-utf8', value: v })), + makeContractCall: vi.fn().mockResolvedValue({ + serialize: () => new Uint8Array([0, 1, 2, 3]) + }), +})); + +vi.mock('@stacks/network', () => ({ + STACKS_MAINNET: 'mainnet', + STACKS_TESTNET: 'testnet', + STACKS_DEVNET: 'devnet', +})); + +vi.mock('../utils/stacks', () => ({ + network: 'mainnet', + appDetails: { name: 'TipStream', icon: '/logo.svg' }, + userSession: { isUserSignedIn: () => false }, +})); + +vi.mock('../config/contracts', () => ({ + CONTRACT_ADDRESS: 'SP1W6XQZ6XVYGTVW32SJW2ZG48ZJBW9BATRD19N60', + CONTRACT_NAME: 'tipstream', + STACKS_API_BASE: 'https://api.hiro.so', + FN_SEND_CATEGORIZED_TIP: 'send-categorized-tip', +})); + +vi.mock('../context/DemoContext', () => ({ + useDemoMode: () => ({ demoEnabled: false }), +})); + +vi.mock('./useStxPrice', () => ({ + useStxPrice: () => ({ + price: 2.5, + loading: false, + error: null, + toUsd: (stx) => (Number(stx) * 2.5).toFixed(2), + }), +})); + +import { useTransactionFeeEstimate } from './useTransactionFeeEstimate'; + +describe('useTransactionFeeEstimate', () => { + beforeEach(() => { + vi.clearAllMocks(); + global.fetch = vi.fn(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('starts with fallback values when loading', () => { + global.fetch.mockReturnValue(new Promise(() => {})); + const { result } = renderHook(() => useTransactionFeeEstimate({ pollInterval: 0 })); + + expect(result.current.loading).toBe(true); + expect(result.current.feeEstimateMicroSTX).toBe(5000); + expect(result.current.feeEstimateSTX).toBe(0.005); + }); + + it('updates USD conversion based on STX price hook', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ + estimations: [ + { fee: 3000, fee_rate: 1 }, + { fee: 6000, fee_rate: 2 }, + { fee: 12000, fee_rate: 3 } + ] + }) + }); + + const { result } = renderHook(() => useTransactionFeeEstimate({ pollInterval: 0 })); + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.feeEstimateMicroSTX).toBe(6000); + expect(result.current.feeEstimateSTX).toBe(0.006); + expect(result.current.feeEstimateUsd).toBe('0.01'); + }); + + it('falls back to extended/v1/fee_rate when v2 POST fails', async () => { + global.fetch + .mockRejectedValueOnce(new Error('Cost estimation disabled')) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ fee_rate: 40 }), + }); + + const { result } = renderHook(() => useTransactionFeeEstimate({ pollInterval: 0 })); + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.feeEstimateMicroSTX).toBe(10000); + expect(result.current.feeEstimateSTX).toBe(0.01); + }); + + it('falls back to default parameters when both network calls fail', async () => { + global.fetch + .mockRejectedValueOnce(new Error('V2 Failed')) + .mockRejectedValueOnce(new Error('Extended Failed')); + + const { result } = renderHook(() => useTransactionFeeEstimate({ pollInterval: 0 })); + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.feeEstimateMicroSTX).toBe(5000); + expect(result.current.error).toBeNull(); + }); + + it('supports selecting different fee levels (low, medium, high)', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ + estimations: [ + { fee: 3000, fee_rate: 1 }, + { fee: 6000, fee_rate: 2 }, + { fee: 12000, fee_rate: 3 } + ] + }) + }); + + const { result } = renderHook(() => useTransactionFeeEstimate({ pollInterval: 0 })); + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.feeLevel).toBe('medium'); + expect(result.current.feeEstimateMicroSTX).toBe(6000); + + act(() => { + result.current.setFeeLevel('low'); + }); + expect(result.current.feeEstimateMicroSTX).toBe(3000); + + act(() => { + result.current.setFeeLevel('high'); + }); + expect(result.current.feeEstimateMicroSTX).toBe(12000); + }); + + it('raises high fee warning when active level estimate meets or exceeds 50000 microSTX', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ + estimations: [ + { fee: 10000, fee_rate: 1 }, + { fee: 20000, fee_rate: 2 }, + { fee: 55000, fee_rate: 3 } + ] + }) + }); + + const { result } = renderHook(() => useTransactionFeeEstimate({ pollInterval: 0 })); + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.highFeeWarning).toBe(false); + + act(() => { + result.current.setFeeLevel('high'); + }); + expect(result.current.highFeeWarning).toBe(true); + }); +}); diff --git a/frontend/src/test/SendTip.fee-preview.test.jsx b/frontend/src/test/SendTip.fee-preview.test.jsx new file mode 100644 index 00000000..ce7d3096 --- /dev/null +++ b/frontend/src/test/SendTip.fee-preview.test.jsx @@ -0,0 +1,157 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import SendTip from '../components/SendTip'; +import { TipProvider } from '../context/TipContext'; +import * as useBlockCheckModule from '../hooks/useBlockCheck'; +import * as useBalanceModule from '../hooks/useBalance'; +import * as useStxPriceModule from '../hooks/useStxPrice'; +import * as stacksModule from '../utils/stacks'; +import * as analyticsModule from '../lib/analytics'; +import * as useTransactionFeeEstimateModule from '../hooks/useTransactionFeeEstimate'; + +vi.mock('../hooks/useBlockCheck'); +vi.mock('../hooks/useBalance'); +vi.mock('../hooks/useStxPrice'); +vi.mock('../utils/stacks'); +vi.mock('../lib/analytics'); +vi.mock('../hooks/useTransactionFeeEstimate'); +vi.mock('@stacks/connect', () => ({ + openContractCall: vi.fn(), +})); + +vi.mock('../lib/contractEvents', () => ({ + contractEvents: { + fetchAll: vi.fn().mockResolvedValue([]), + subscribe: vi.fn(), + }, + POLL_INTERVAL_MS: 30000, + fetchAllContractEvents: vi.fn().mockResolvedValue([]), +})); + +describe('SendTip - Transaction Fee Preview & Toggles', () => { + const mockSenderAddress = 'SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T'; + const mockRecipient = 'SP2RDS2YKXMFSP4H9Q5D1FXF5K5J91TH1P5KH3HVP'; + const mockSetFeeLevel = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + + vi.spyOn(stacksModule, 'getSenderAddress').mockReturnValue(mockSenderAddress); + vi.spyOn(useStxPriceModule, 'useStxPrice').mockReturnValue({ + toUsd: (stx) => (Number(stx) * 2.5).toFixed(2), + }); + vi.spyOn(analyticsModule, 'analytics', 'get').mockReturnValue({ + trackTipStarted: vi.fn(), + trackTipSubmitted: vi.fn(), + trackTipConfirmed: vi.fn(), + trackTipCancelled: vi.fn(), + trackTipFailed: vi.fn(), + }); + + vi.spyOn(useBlockCheckModule, 'useBlockCheck').mockReturnValue({ + blocked: false, + checking: false, + checkBlocked: vi.fn(), + reset: vi.fn(), + }); + + vi.spyOn(useBalanceModule, 'useBalance').mockReturnValue({ + balance: '100000000', // 100 STX + balanceStx: 100, + loading: false, + refetch: vi.fn(), + }); + }); + + it('renders estimated gas fee and USD amount in breakdown', async () => { + vi.spyOn(useTransactionFeeEstimateModule, 'useTransactionFeeEstimate').mockReturnValue({ + feeEstimateMicroSTX: 5000, + feeEstimateSTX: 0.005, + feeEstimateUsd: '0.01', + highFeeWarning: false, + feeLevel: 'medium', + setFeeLevel: mockSetFeeLevel, + speedEstimates: {}, + refresh: vi.fn(), + }); + + render( + + + + ); + + // Set recipient and tip amount using fast fireEvent to trigger breakdown synchronously + await act(async () => { + fireEvent.change(screen.getByPlaceholderText('SP2...'), { target: { value: mockRecipient } }); + fireEvent.change(screen.getByPlaceholderText('0.5'), { target: { value: '1.0' } }); + }); + + // Confirm gas fee preview is displayed with correct values + const gasFeePreview = screen.getByTestId('gas-fee-preview'); + expect(gasFeePreview).toBeInTheDocument(); + expect(gasFeePreview).toHaveTextContent('0.005000 STX'); + expect(gasFeePreview).toHaveTextContent('~$0.01'); + }); + + it('allows switching fee levels via speed toggle pills', async () => { + vi.spyOn(useTransactionFeeEstimateModule, 'useTransactionFeeEstimate').mockReturnValue({ + feeEstimateMicroSTX: 5000, + feeEstimateSTX: 0.005, + feeEstimateUsd: '0.01', + highFeeWarning: false, + feeLevel: 'medium', + setFeeLevel: mockSetFeeLevel, + speedEstimates: {}, + refresh: vi.fn(), + }); + + render( + + + + ); + + await act(async () => { + fireEvent.change(screen.getByPlaceholderText('SP2...'), { target: { value: mockRecipient } }); + fireEvent.change(screen.getByPlaceholderText('0.5'), { target: { value: '1.0' } }); + }); + + // Click "low" fee speed pill + const lowPill = screen.getByTestId('fee-level-low'); + await act(async () => { + fireEvent.click(lowPill); + }); + + expect(mockSetFeeLevel).toHaveBeenCalledWith('low'); + }); + + it('renders high-fee warning banner when fee matches or exceeds threshold', async () => { + vi.spyOn(useTransactionFeeEstimateModule, 'useTransactionFeeEstimate').mockReturnValue({ + feeEstimateMicroSTX: 60000, + feeEstimateSTX: 0.06, + feeEstimateUsd: '0.15', + highFeeWarning: true, + feeLevel: 'high', + setFeeLevel: mockSetFeeLevel, + speedEstimates: {}, + refresh: vi.fn(), + }); + + render( + + + + ); + + await act(async () => { + fireEvent.change(screen.getByPlaceholderText('SP2...'), { target: { value: mockRecipient } }); + fireEvent.change(screen.getByPlaceholderText('0.5'), { target: { value: '1.0' } }); + }); + + // Verify warning is displayed + const warningAlert = screen.getByTestId('high-fee-warning'); + expect(warningAlert).toBeInTheDocument(); + expect(warningAlert).toHaveTextContent('High Transaction Fee Warning'); + }); +});