From a799e1e709b998c8d0d428cbd45e42d835210fa7 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Wed, 20 May 2026 09:17:11 +0100 Subject: [PATCH 01/20] feat: Create transaction fee estimate hook base skeleton --- .../src/hooks/useTransactionFeeEstimate.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 frontend/src/hooks/useTransactionFeeEstimate.js diff --git a/frontend/src/hooks/useTransactionFeeEstimate.js b/frontend/src/hooks/useTransactionFeeEstimate.js new file mode 100644 index 00000000..686bd1fe --- /dev/null +++ b/frontend/src/hooks/useTransactionFeeEstimate.js @@ -0,0 +1,19 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { STACKS_API_BASE } from '../config/contracts'; +import { useStxPrice } from './useStxPrice'; +import { useDemoMode } from '../context/DemoContext'; + +export function useTransactionFeeEstimate() { + return { + feeEstimateMicroSTX: 5000, + feeEstimateSTX: 0.005, + feeEstimateUsd: '0.01', + loading: false, + error: null, + highFeeWarning: false, + feeLevel: 'medium', + setFeeLevel: () => {}, + speedEstimates: {}, + refresh: async () => {}, + }; +} From c015c6baaafe230e5d00fffabbbdab195b75a04b Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Wed, 20 May 2026 09:17:15 +0100 Subject: [PATCH 02/20] feat: Add default transaction fee parameters and constants --- frontend/src/hooks/useTransactionFeeEstimate.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/frontend/src/hooks/useTransactionFeeEstimate.js b/frontend/src/hooks/useTransactionFeeEstimate.js index 686bd1fe..e5c3b30b 100644 --- a/frontend/src/hooks/useTransactionFeeEstimate.js +++ b/frontend/src/hooks/useTransactionFeeEstimate.js @@ -3,6 +3,15 @@ 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'; + export function useTransactionFeeEstimate() { return { feeEstimateMicroSTX: 5000, From f9298a2f3ca69629039282b71450649961e3d5e5 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Wed, 20 May 2026 09:17:22 +0100 Subject: [PATCH 03/20] feat: Implement dummy transaction payload hex generator --- .../src/hooks/useTransactionFeeEstimate.js | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/frontend/src/hooks/useTransactionFeeEstimate.js b/frontend/src/hooks/useTransactionFeeEstimate.js index e5c3b30b..52ec31dc 100644 --- a/frontend/src/hooks/useTransactionFeeEstimate.js +++ b/frontend/src/hooks/useTransactionFeeEstimate.js @@ -12,6 +12,46 @@ 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() { return { feeEstimateMicroSTX: 5000, From 0df29a612e02f018ca2bc543674b33267adbcb06 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Wed, 20 May 2026 09:17:28 +0100 Subject: [PATCH 04/20] feat: Set up local state for loading, error, and speed estimates --- .../src/hooks/useTransactionFeeEstimate.js | 45 ++++++++++++++----- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/frontend/src/hooks/useTransactionFeeEstimate.js b/frontend/src/hooks/useTransactionFeeEstimate.js index 52ec31dc..5f48b86f 100644 --- a/frontend/src/hooks/useTransactionFeeEstimate.js +++ b/frontend/src/hooks/useTransactionFeeEstimate.js @@ -52,17 +52,42 @@ async function generateDummyTransactionPayloadHex() { } } -export function useTransactionFeeEstimate() { +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, Usd: null }, + medium: { microSTX: DEFAULT_FEE_MICROSTX, STX: DEFAULT_FEE_MICROSTX / 1_000_000, Usd: null }, + high: { microSTX: DEFAULT_HIGH_FEE_MICROSTX, STX: DEFAULT_HIGH_FEE_MICROSTX / 1_000_000, Usd: null }, + }); + + const isMountedRef = useRef(true); + const intervalRef = useRef(null); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + const activeEstimate = speedEstimates[feeLevel]; + return { - feeEstimateMicroSTX: 5000, - feeEstimateSTX: 0.005, - feeEstimateUsd: '0.01', - loading: false, - error: null, - highFeeWarning: false, - feeLevel: 'medium', - setFeeLevel: () => {}, - speedEstimates: {}, + feeEstimateMicroSTX: activeEstimate.microSTX, + feeEstimateSTX: activeEstimate.STX, + feeEstimateUsd: activeEstimate.Usd, + loading, + error, + highFeeWarning: activeEstimate.microSTX >= HIGH_FEE_THRESHOLD_MICROSTX, + feeLevel, + setFeeLevel, + speedEstimates, refresh: async () => {}, }; } From 8bb9a64580c8e0a29bad8f55c7760c140019aa34 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Wed, 20 May 2026 09:17:40 +0100 Subject: [PATCH 05/20] feat: Build dynamic USD conversion logic based on STX price --- .../src/hooks/useTransactionFeeEstimate.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/frontend/src/hooks/useTransactionFeeEstimate.js b/frontend/src/hooks/useTransactionFeeEstimate.js index 5f48b86f..01383eb9 100644 --- a/frontend/src/hooks/useTransactionFeeEstimate.js +++ b/frontend/src/hooks/useTransactionFeeEstimate.js @@ -76,6 +76,25 @@ export function useTransactionFeeEstimate({ pollInterval = REFRESH_INTERVAL_MS } }; }, []); + const updateUsdPrices = useCallback((estimatesObj) => { + const updated = {}; + Object.keys(estimatesObj).forEach((level) => { + const est = estimatesObj[level]; + const usdVal = toUsd ? toUsd(est.STX) : null; + updated[level] = { + ...est, + Usd: usdVal, + }; + }); + return updated; + }, [toUsd]); + + useEffect(() => { + if (isMountedRef.current) { + setSpeedEstimates(prev => updateUsdPrices(prev)); + } + }, [updateUsdPrices]); + const activeEstimate = speedEstimates[feeLevel]; return { From ecc54bac35f1fdcffd5639f9bc7f25c8e56e1338 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Wed, 20 May 2026 09:17:50 +0100 Subject: [PATCH 06/20] feat: Implement direct fetch from Stacks Node API for fee rate estimations --- .../src/hooks/useTransactionFeeEstimate.js | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/frontend/src/hooks/useTransactionFeeEstimate.js b/frontend/src/hooks/useTransactionFeeEstimate.js index 01383eb9..ef0cc4d2 100644 --- a/frontend/src/hooks/useTransactionFeeEstimate.js +++ b/frontend/src/hooks/useTransactionFeeEstimate.js @@ -95,6 +95,59 @@ export function useTransactionFeeEstimate({ pollInterval = REFRESH_INTERVAL_MS } } }, [updateUsdPrices]); + 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) { + 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(updateUsdPrices(baseEstimates)); + setLoading(false); + setError(null); + } + }, [demoEnabled, updateUsdPrices]); + const activeEstimate = speedEstimates[feeLevel]; return { From 2ee854a47fbb1ed3f8706eeb1e37c5a592853b1a Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Wed, 20 May 2026 09:17:57 +0100 Subject: [PATCH 07/20] feat: Add fallback to extended v1 fee rate API when v2 estimation fails --- .../src/hooks/useTransactionFeeEstimate.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/frontend/src/hooks/useTransactionFeeEstimate.js b/frontend/src/hooks/useTransactionFeeEstimate.js index ef0cc4d2..e84a6f9c 100644 --- a/frontend/src/hooks/useTransactionFeeEstimate.js +++ b/frontend/src/hooks/useTransactionFeeEstimate.js @@ -133,6 +133,25 @@ export function useTransactionFeeEstimate({ pollInterval = REFRESH_INTERVAL_MS } // 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 }, From 18d58a967e523e0127ca8923fd5dbf257cfaf341 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Wed, 20 May 2026 09:18:07 +0100 Subject: [PATCH 08/20] feat: Integrate standard mock fallback values and estimation logic --- frontend/src/hooks/useTransactionFeeEstimate.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/hooks/useTransactionFeeEstimate.js b/frontend/src/hooks/useTransactionFeeEstimate.js index e84a6f9c..6145f96b 100644 --- a/frontend/src/hooks/useTransactionFeeEstimate.js +++ b/frontend/src/hooks/useTransactionFeeEstimate.js @@ -179,6 +179,6 @@ export function useTransactionFeeEstimate({ pollInterval = REFRESH_INTERVAL_MS } feeLevel, setFeeLevel, speedEstimates, - refresh: async () => {}, + refresh: estimate, }; } From 4f8255a7bf51df6289eba5403e9e5c7a5e07dbbe Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Wed, 20 May 2026 09:18:21 +0100 Subject: [PATCH 09/20] feat: Implement poll-based auto-refresh of network conditions --- frontend/src/hooks/useTransactionFeeEstimate.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/frontend/src/hooks/useTransactionFeeEstimate.js b/frontend/src/hooks/useTransactionFeeEstimate.js index 6145f96b..c33c796e 100644 --- a/frontend/src/hooks/useTransactionFeeEstimate.js +++ b/frontend/src/hooks/useTransactionFeeEstimate.js @@ -167,6 +167,20 @@ export function useTransactionFeeEstimate({ pollInterval = REFRESH_INTERVAL_MS } } }, [demoEnabled, updateUsdPrices]); + useEffect(() => { + estimate(); + + if (pollInterval > 0) { + intervalRef.current = setInterval(estimate, pollInterval); + } + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }; + }, [estimate, pollInterval]); + const activeEstimate = speedEstimates[feeLevel]; return { From b2674c206b0d09ffbcb7dbcd2b87638dfe4aa407 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Wed, 20 May 2026 09:18:27 +0100 Subject: [PATCH 10/20] test: Add basic unit tests for transaction fee estimation hook --- .../hooks/useTransactionFeeEstimate.test.js | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 frontend/src/hooks/useTransactionFeeEstimate.test.js diff --git a/frontend/src/hooks/useTransactionFeeEstimate.test.js b/frontend/src/hooks/useTransactionFeeEstimate.test.js new file mode 100644 index 00000000..77a1a08b --- /dev/null +++ b/frontend/src/hooks/useTransactionFeeEstimate.test.js @@ -0,0 +1,67 @@ +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); + }); +}); From 27c5afe1f00061423f3e19d297159a7f4e68855c Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Wed, 20 May 2026 09:18:36 +0100 Subject: [PATCH 11/20] test: Mock Stacks API and verify success/error hook states --- .../hooks/useTransactionFeeEstimate.test.js | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/frontend/src/hooks/useTransactionFeeEstimate.test.js b/frontend/src/hooks/useTransactionFeeEstimate.test.js index 77a1a08b..9a8b84cd 100644 --- a/frontend/src/hooks/useTransactionFeeEstimate.test.js +++ b/frontend/src/hooks/useTransactionFeeEstimate.test.js @@ -64,4 +64,51 @@ describe('useTransactionFeeEstimate', () => { 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.02'); + }); + + 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(); + }); }); From 056dd819f4c4d9c2c14239f41f4e4de7692cc7dc Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Wed, 20 May 2026 09:18:46 +0100 Subject: [PATCH 12/20] test: Verify fee level toggle and high fee warning thresholds --- .../hooks/useTransactionFeeEstimate.test.js | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/frontend/src/hooks/useTransactionFeeEstimate.test.js b/frontend/src/hooks/useTransactionFeeEstimate.test.js index 9a8b84cd..57b55286 100644 --- a/frontend/src/hooks/useTransactionFeeEstimate.test.js +++ b/frontend/src/hooks/useTransactionFeeEstimate.test.js @@ -111,4 +111,56 @@ describe('useTransactionFeeEstimate', () => { 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); + }); }); From 8a4f6600babb4bf97f2d9e8cfd95f997e9bddc76 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Wed, 20 May 2026 09:20:03 +0100 Subject: [PATCH 13/20] feat: Import and setup transaction fee estimation in SendTip component --- frontend/src/components/SendTip.jsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/frontend/src/components/SendTip.jsx b/frontend/src/components/SendTip.jsx index 6eef6f54..a599fa15 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); From 731a768e5188bf8a6cf2d3c46be699a01f52aa43 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Wed, 20 May 2026 09:20:15 +0100 Subject: [PATCH 14/20] feat: Account for estimated gas fees during balance validation --- frontend/src/components/SendTip.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/SendTip.jsx b/frontend/src/components/SendTip.jsx index a599fa15..b8b46c60 100644 --- a/frontend/src/components/SendTip.jsx +++ b/frontend/src/components/SendTip.jsx @@ -160,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(''); } From e30627250d6689da28a918ce12cc555463eccb6e Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Wed, 20 May 2026 09:20:24 +0100 Subject: [PATCH 15/20] feat: Update submit click handling to respect fee-inclusive balance checks --- frontend/src/components/SendTip.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/SendTip.jsx b/frontend/src/components/SendTip.jsx index b8b46c60..815cfd2b 100644 --- a/frontend/src/components/SendTip.jsx +++ b/frontend/src/components/SendTip.jsx @@ -190,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; } } From dbf0060e484198f8103c5351f136d7eb19c7c1b8 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Wed, 20 May 2026 09:20:32 +0100 Subject: [PATCH 16/20] feat: Render estimated gas fee in STX and USD in tip form preview --- frontend/src/components/SendTip.jsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/SendTip.jsx b/frontend/src/components/SendTip.jsx index 815cfd2b..3ab08878 100644 --- a/frontend/src/components/SendTip.jsx +++ b/frontend/src/components/SendTip.jsx @@ -396,10 +396,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
From 56beea6a994bddc62b6ed216be39a3dd72386368 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Wed, 20 May 2026 09:20:51 +0100 Subject: [PATCH 17/20] feat: Add network status toggle options (low, medium, high fee options) --- frontend/src/components/SendTip.jsx | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/frontend/src/components/SendTip.jsx b/frontend/src/components/SendTip.jsx index 3ab08878..2ad29a21 100644 --- a/frontend/src/components/SendTip.jsx +++ b/frontend/src/components/SendTip.jsx @@ -384,6 +384,26 @@ export default function SendTip({ addToast }) { {amount && parseFloat(amount) > 0 && (

Fee Preview

+
+ {['low', 'medium', 'high'].map((level) => { + const isActive = feeLevel === level; + return ( + + ); + })} +
Tip amount From 44af6838305bbe02257eba878dd8a9e2a7066c99 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Wed, 20 May 2026 09:21:02 +0100 Subject: [PATCH 18/20] feat: Implement warning alert for high transaction fees --- frontend/src/components/SendTip.jsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/frontend/src/components/SendTip.jsx b/frontend/src/components/SendTip.jsx index 2ad29a21..281bcb3f 100644 --- a/frontend/src/components/SendTip.jsx +++ b/frontend/src/components/SendTip.jsx @@ -383,6 +383,17 @@ 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) => { From 956e42a49e00ce78b21ff470c62843026a41739a Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Wed, 20 May 2026 09:21:11 +0100 Subject: [PATCH 19/20] feat: Update confirm dialog with network fee breakdown --- frontend/src/components/SendTip.jsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/SendTip.jsx b/frontend/src/components/SendTip.jsx index 281bcb3f..ecb28986 100644 --- a/frontend/src/components/SendTip.jsx +++ b/frontend/src/components/SendTip.jsx @@ -496,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
)} From b847e415b4ba2d27e8303f9cc33caa82867012de Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Wed, 20 May 2026 09:21:26 +0100 Subject: [PATCH 20/20] test: Add component tests for SendTip fee preview integration --- .../src/hooks/useTransactionFeeEstimate.js | 28 ++-- .../hooks/useTransactionFeeEstimate.test.js | 2 +- .../src/test/SendTip.fee-preview.test.jsx | 157 ++++++++++++++++++ 3 files changed, 169 insertions(+), 18 deletions(-) create mode 100644 frontend/src/test/SendTip.fee-preview.test.jsx diff --git a/frontend/src/hooks/useTransactionFeeEstimate.js b/frontend/src/hooks/useTransactionFeeEstimate.js index c33c796e..1c558abe 100644 --- a/frontend/src/hooks/useTransactionFeeEstimate.js +++ b/frontend/src/hooks/useTransactionFeeEstimate.js @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { STACKS_API_BASE } from '../config/contracts'; import { useStxPrice } from './useStxPrice'; import { useDemoMode } from '../context/DemoContext'; @@ -61,9 +61,9 @@ export function useTransactionFeeEstimate({ pollInterval = REFRESH_INTERVAL_MS } const [error, setError] = useState(null); const [speedEstimates, setSpeedEstimates] = useState({ - low: { microSTX: DEFAULT_LOW_FEE_MICROSTX, STX: DEFAULT_LOW_FEE_MICROSTX / 1_000_000, Usd: null }, - medium: { microSTX: DEFAULT_FEE_MICROSTX, STX: DEFAULT_FEE_MICROSTX / 1_000_000, Usd: null }, - high: { microSTX: DEFAULT_HIGH_FEE_MICROSTX, STX: DEFAULT_HIGH_FEE_MICROSTX / 1_000_000, Usd: null }, + 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); @@ -76,10 +76,10 @@ export function useTransactionFeeEstimate({ pollInterval = REFRESH_INTERVAL_MS } }; }, []); - const updateUsdPrices = useCallback((estimatesObj) => { + const speedEstimatesWithUsd = useMemo(() => { const updated = {}; - Object.keys(estimatesObj).forEach((level) => { - const est = estimatesObj[level]; + Object.keys(speedEstimates).forEach((level) => { + const est = speedEstimates[level]; const usdVal = toUsd ? toUsd(est.STX) : null; updated[level] = { ...est, @@ -87,13 +87,7 @@ export function useTransactionFeeEstimate({ pollInterval = REFRESH_INTERVAL_MS } }; }); return updated; - }, [toUsd]); - - useEffect(() => { - if (isMountedRef.current) { - setSpeedEstimates(prev => updateUsdPrices(prev)); - } - }, [updateUsdPrices]); + }, [speedEstimates, toUsd]); const estimate = useCallback(async () => { if (!isMountedRef.current) return; @@ -161,11 +155,11 @@ export function useTransactionFeeEstimate({ pollInterval = REFRESH_INTERVAL_MS } } if (isMountedRef.current) { - setSpeedEstimates(updateUsdPrices(baseEstimates)); + setSpeedEstimates(baseEstimates); setLoading(false); setError(null); } - }, [demoEnabled, updateUsdPrices]); + }, [demoEnabled]); useEffect(() => { estimate(); @@ -181,7 +175,7 @@ export function useTransactionFeeEstimate({ pollInterval = REFRESH_INTERVAL_MS } }; }, [estimate, pollInterval]); - const activeEstimate = speedEstimates[feeLevel]; + const activeEstimate = speedEstimatesWithUsd[feeLevel]; return { feeEstimateMicroSTX: activeEstimate.microSTX, diff --git a/frontend/src/hooks/useTransactionFeeEstimate.test.js b/frontend/src/hooks/useTransactionFeeEstimate.test.js index 57b55286..2e559adf 100644 --- a/frontend/src/hooks/useTransactionFeeEstimate.test.js +++ b/frontend/src/hooks/useTransactionFeeEstimate.test.js @@ -82,7 +82,7 @@ describe('useTransactionFeeEstimate', () => { expect(result.current.feeEstimateMicroSTX).toBe(6000); expect(result.current.feeEstimateSTX).toBe(0.006); - expect(result.current.feeEstimateUsd).toBe('0.02'); + expect(result.current.feeEstimateUsd).toBe('0.01'); }); it('falls back to extended/v1/fee_rate when v2 POST fails', async () => { 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'); + }); +});