Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
a799e1e
feat: Create transaction fee estimate hook base skeleton
Mosas2000 May 20, 2026
c015c6b
feat: Add default transaction fee parameters and constants
Mosas2000 May 20, 2026
f9298a2
feat: Implement dummy transaction payload hex generator
Mosas2000 May 20, 2026
0df29a6
feat: Set up local state for loading, error, and speed estimates
Mosas2000 May 20, 2026
8bb9a64
feat: Build dynamic USD conversion logic based on STX price
Mosas2000 May 20, 2026
ecc54ba
feat: Implement direct fetch from Stacks Node API for fee rate estima…
Mosas2000 May 20, 2026
2ee854a
feat: Add fallback to extended v1 fee rate API when v2 estimation fails
Mosas2000 May 20, 2026
18d58a9
feat: Integrate standard mock fallback values and estimation logic
Mosas2000 May 20, 2026
4f8255a
feat: Implement poll-based auto-refresh of network conditions
Mosas2000 May 20, 2026
b2674c2
test: Add basic unit tests for transaction fee estimation hook
Mosas2000 May 20, 2026
27c5afe
test: Mock Stacks API and verify success/error hook states
Mosas2000 May 20, 2026
056dd81
test: Verify fee level toggle and high fee warning thresholds
Mosas2000 May 20, 2026
8a4f660
feat: Import and setup transaction fee estimation in SendTip component
Mosas2000 May 20, 2026
731a768
feat: Account for estimated gas fees during balance validation
Mosas2000 May 20, 2026
e306272
feat: Update submit click handling to respect fee-inclusive balance c…
Mosas2000 May 20, 2026
dbf0060
feat: Render estimated gas fee in STX and USD in tip form preview
Mosas2000 May 20, 2026
56beea6
feat: Add network status toggle options (low, medium, high fee options)
Mosas2000 May 20, 2026
44af683
feat: Implement warning alert for high transaction fees
Mosas2000 May 20, 2026
956e42a
feat: Update confirm dialog with network fee breakdown
Mosas2000 May 20, 2026
b847e41
test: Add component tests for SendTip fee preview integration
Mosas2000 May 20, 2026
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
67 changes: 60 additions & 7 deletions frontend/src/components/SendTip.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
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';
Expand Down Expand Up @@ -80,6 +81,16 @@
}, [balance]);

const { feeBasisPoints, feePercent } = useContractFee();
const {
feeEstimateMicroSTX,
feeEstimateSTX,
feeEstimateUsd,
highFeeWarning,
feeLevel,
setFeeLevel,
speedEstimates,

Check failure on line 91 in frontend/src/components/SendTip.jsx

View workflow job for this annotation

GitHub Actions / Frontend Lint

'speedEstimates' is assigned a value but never used. Allowed unused vars must match /^[A-Z_]/u
refresh: refreshTransactionFee,

Check failure on line 92 in frontend/src/components/SendTip.jsx

View workflow job for this annotation

GitHub Actions / Frontend Lint

'refreshTransactionFee' is assigned a value but never used. Allowed unused vars must match /^[A-Z_]/u
} = useTransactionFeeEstimate();

const isRecipientHighRisk = !canProceedWithRecipient(recipient, blockedWarning);

Expand Down Expand Up @@ -149,10 +160,10 @@
} 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('');
}
Expand All @@ -179,8 +190,8 @@
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;
}
}
Expand Down Expand Up @@ -372,7 +383,38 @@
{/* Breakdown with fee preview and post-condition ceiling */}
{amount && parseFloat(amount) > 0 && (
<div data-testid="fee-preview" className="bg-gray-50 dark:bg-gray-800 rounded-xl p-4 border border-gray-100 dark:border-gray-700 text-sm">
{highFeeWarning && (
<div data-testid="high-fee-warning" className="bg-amber-50 dark:bg-amber-950/20 rounded-xl p-3.5 border border-amber-200 dark:border-amber-900 text-xs text-amber-800 dark:text-amber-300 mb-3.5 flex items-start gap-2">
<svg className="w-4 h-4 shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<p className="font-semibold">High Transaction Fee Warning</p>
<p className="mt-0.5 opacity-90">Estimated gas fees are currently high ({feeEstimateSTX.toFixed(4)} STX). You can select a lower fee speed option to minimize costs.</p>
</div>
</div>
)}
<p className="font-semibold text-gray-700 dark:text-gray-200 mb-2">Fee Preview</p>
<div className="flex items-center gap-1.5 mb-3.5 bg-gray-100 dark:bg-gray-700/50 p-1 rounded-lg">
{['low', 'medium', 'high'].map((level) => {
const isActive = feeLevel === level;
return (
<button
key={level}
type="button"
onClick={() => setFeeLevel(level)}
data-testid={`fee-level-${level}`}
className={`flex-1 text-center py-1 rounded-md text-xs font-semibold capitalize transition-all ${
isActive
? 'bg-amber-500 text-black shadow-sm'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
}`}
>
{level}
</button>
);
})}
</div>
<div className="space-y-1 text-gray-600 dark:text-gray-400">
<div className="flex justify-between">
<span>Tip amount</span>
Expand All @@ -385,10 +427,17 @@
<span>Platform fee ({feePercent.toFixed(2)}%)</span>
<span>{formatSTX(feeForTip(toMicroSTX(amount), feeBasisPoints), 6)} STX</span>
</div>
<div className="flex justify-between" data-testid="gas-fee-preview">
<span>Estimated gas fee ({feeLevel})</span>
<span>
{feeEstimateSTX.toFixed(6)} STX
{feeEstimateUsd && <span className="text-gray-400 ml-1">(~${feeEstimateUsd})</span>}
</span>
</div>
<div className="border-t border-gray-200 dark:border-gray-600 pt-1 mt-1 flex justify-between font-semibold text-gray-900 dark:text-white">
<span>Total from wallet</span>
<span>
{formatSTX(totalDeduction(toMicroSTX(amount), feeBasisPoints), 6)} STX
{formatSTX(Number(totalDeduction(toMicroSTX(amount), feeBasisPoints)) + feeEstimateMicroSTX, 6)} STX
</span>
</div>
<div className="flex justify-between text-gray-500 dark:text-gray-500">
Expand Down Expand Up @@ -447,9 +496,13 @@
<span>Platform fee ({feePercent.toFixed(2)}%)</span>
<span>{formatSTX(feeForTip(toMicroSTX(amount), feeBasisPoints), 6)} STX</span>
</div>
<div className="flex justify-between">
<span>Estimated gas fee ({feeLevel})</span>
<span>{feeEstimateSTX.toFixed(6)} STX</span>
</div>
<div className="flex justify-between font-semibold text-gray-900 dark:text-white">
<span>Total from your wallet</span>
<span>{formatSTX(totalDeduction(toMicroSTX(amount), feeBasisPoints), 6)} STX</span>
<span>{formatSTX(Number(totalDeduction(toMicroSTX(amount), feeBasisPoints)) + feeEstimateMicroSTX, 6)} STX</span>
</div>
</div>
)}
Expand Down
192 changes: 192 additions & 0 deletions frontend/src/hooks/useTransactionFeeEstimate.js
Original file line number Diff line number Diff line change
@@ -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) {

Check failure on line 50 in frontend/src/hooks/useTransactionFeeEstimate.js

View workflow job for this annotation

GitHub Actions / Frontend Lint

'e' is defined but never used
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) {

Check failure on line 126 in frontend/src/hooks/useTransactionFeeEstimate.js

View workflow job for this annotation

GitHub Actions / Frontend Lint

'e' is defined but never used
// 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,
};
}
Loading
Loading