diff --git a/contracts/tipstream.clar b/contracts/tipstream.clar
index 7a2258c0..3e8e6bc1 100644
--- a/contracts/tipstream.clar
+++ b/contracts/tipstream.clar
@@ -713,6 +713,24 @@
(ok (default-to u0 (map-get? category-tip-count category)))
)
+(define-read-only (get-fee-summary (amount uint))
+ (let
+ (
+ (bps (var-get current-fee-basis-points))
+ (computed-fee (calculate-fee amount))
+ )
+ (ok {
+ fee-basis-points: bps,
+ basis-points-divisor: basis-points-divisor,
+ min-fee-ustx: min-fee,
+ fee-percent: (/ (* bps u100) basis-points-divisor),
+ fee-for-amount: computed-fee,
+ amount: amount,
+ net-amount: (if (>= amount computed-fee) (- amount computed-fee) u0)
+ })
+ )
+)
+
(define-read-only (get-refund-request (tip-id uint))
(ok (map-get? refund-requests { tip-id: tip-id }))
)
diff --git a/frontend/src/components/AdminDashboard.jsx b/frontend/src/components/AdminDashboard.jsx
index 75660217..f5f7a2b1 100644
--- a/frontend/src/components/AdminDashboard.jsx
+++ b/frontend/src/components/AdminDashboard.jsx
@@ -276,7 +276,7 @@ export default function AdminDashboard({ userAddress, addToast }) {
)}
{/* Status Bar */}
-
+
+
{
@@ -148,8 +151,8 @@ export default function SendTip({ addToast }) {
} else if (balanceSTX !== null) {
// Account for the platform fee when checking balance
const microSTX = toMicroSTX(parsed.toString());
- if (!hasSufficientMicroStx(balance, totalDeduction(microSTX))) {
- setAmountError('Insufficient balance (tip + 0.5% fee exceeds balance)');
+ if (!hasSufficientMicroStx(balance, totalDeduction(microSTX, feeBasisPoints))) {
+ setAmountError('Insufficient balance (tip + fee exceeds balance)');
} else {
setAmountError('');
}
@@ -176,7 +179,7 @@ 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))) {
+ if (!hasSufficientMicroStx(balance, totalDeduction(microSTX, feeBasisPoints))) {
addToast('Insufficient balance to cover tip plus platform fee', 'warning');
return;
}
@@ -206,7 +209,7 @@ export default function SendTip({ addToast }) {
const microSTX = toMicroSTX(amount);
const postConditions = [
- tipPostCondition(senderAddress, microSTX)
+ tipPostCondition(senderAddress, microSTX, feeBasisPoints)
];
const functionArgs = [
@@ -379,24 +382,24 @@ export default function SendTip({ addToast }) {
- Platform fee ({FEE_PERCENT}%)
- {formatSTX(feeForTip(toMicroSTX(amount)), 6)} STX
+ Platform fee ({feePercent.toFixed(2)}%)
+ {formatSTX(feeForTip(toMicroSTX(amount), feeBasisPoints), 6)} STX
Total from wallet
- {formatSTX(totalDeduction(toMicroSTX(amount)), 6)} STX
+ {formatSTX(totalDeduction(toMicroSTX(amount), feeBasisPoints), 6)} STX
Recipient receives
- {formatSTX(recipientReceives(toMicroSTX(amount)), 6)} STX
+ {formatSTX(recipientReceives(toMicroSTX(amount), feeBasisPoints), 6)} STX
Post-condition ceiling
- {formatSTX(maxTransferForTip(toMicroSTX(amount)), 6)} STX
+ {formatSTX(maxTransferForTip(toMicroSTX(amount), feeBasisPoints), 6)} STX
@@ -441,12 +444,12 @@ export default function SendTip({ addToast }) {
{amount && parseFloat(amount) > 0 && (
- Platform fee ({FEE_PERCENT}%)
- {formatSTX(feeForTip(toMicroSTX(amount)), 6)} STX
+ Platform fee ({feePercent.toFixed(2)}%)
+ {formatSTX(feeForTip(toMicroSTX(amount), feeBasisPoints), 6)} STX
Total from your wallet
- {formatSTX(totalDeduction(toMicroSTX(amount)), 6)} STX
+ {formatSTX(totalDeduction(toMicroSTX(amount), feeBasisPoints), 6)} STX
)}
diff --git a/frontend/src/config/contracts.js b/frontend/src/config/contracts.js
index 31bf00da..351bf805 100644
--- a/frontend/src/config/contracts.js
+++ b/frontend/src/config/contracts.js
@@ -60,6 +60,8 @@ export const FN_WHITELIST_TOKEN = 'whitelist-token';
export const FN_GET_USER_STATS = 'get-user-stats';
export const FN_GET_PLATFORM_STATS = 'get-platform-stats';
export const FN_GET_CURRENT_FEE_BASIS_POINTS = 'get-current-fee-basis-points';
+export const FN_GET_FEE_FOR_AMOUNT = 'get-fee-for-amount';
+export const FN_GET_FEE_SUMMARY = 'get-fee-summary';
// Refund
export const FN_REQUEST_REFUND = 'request-refund';
diff --git a/frontend/src/hooks/useAdmin.js b/frontend/src/hooks/useAdmin.js
index fca7c585..8a459546 100644
--- a/frontend/src/hooks/useAdmin.js
+++ b/frontend/src/hooks/useAdmin.js
@@ -31,6 +31,7 @@ export function useAdmin(userAddress, options = {}) {
effectiveHeight: 0,
});
const [feeState, setFeeState] = useState({
+ currentFeeBasisPoints: 0,
pendingFee: null,
effectiveHeight: 0,
});
diff --git a/frontend/src/hooks/useContractFee.js b/frontend/src/hooks/useContractFee.js
new file mode 100644
index 00000000..7c714f18
--- /dev/null
+++ b/frontend/src/hooks/useContractFee.js
@@ -0,0 +1,82 @@
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { fetchCurrentFee } from '../lib/admin-contract';
+import { FEE_BASIS_POINTS, BASIS_POINTS_DIVISOR } from '../lib/post-conditions';
+import { useDemoMode } from '../context/DemoContext';
+
+const POLL_INTERVAL_MS = 60_000;
+
+/**
+ * Fetch and cache the live platform fee rate from the contract.
+ *
+ * Falls back to the SDK's compile-time constant when the contract is
+ * unreachable so the UI never shows a broken fee preview.
+ *
+ * @param {object} [options]
+ * @param {number} [options.pollInterval=60000] - Polling interval in ms. Pass 0 to disable.
+ * @returns {{
+ * feeBasisPoints: number,
+ * feePercent: number,
+ * loading: boolean,
+ * error: string|null,
+ * isLive: boolean,
+ * refresh: () => void,
+ * }}
+ */
+export function useContractFee({ pollInterval = POLL_INTERVAL_MS } = {}) {
+ const { demoEnabled } = useDemoMode();
+
+ const [feeBasisPoints, setFeeBasisPoints] = useState(FEE_BASIS_POINTS);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [isLive, setIsLive] = useState(false);
+
+ const intervalRef = useRef(null);
+
+ const load = useCallback(async () => {
+ if (demoEnabled) {
+ setFeeBasisPoints(FEE_BASIS_POINTS);
+ setIsLive(false);
+ setLoading(false);
+ setError(null);
+ return;
+ }
+
+ try {
+ const bps = await fetchCurrentFee();
+ setFeeBasisPoints(typeof bps === 'number' && bps >= 0 ? bps : FEE_BASIS_POINTS);
+ setIsLive(true);
+ setError(null);
+ } catch (err) {
+ setError(err.message || 'Failed to fetch fee rate');
+ setIsLive(false);
+ } finally {
+ setLoading(false);
+ }
+ }, [demoEnabled]);
+
+ useEffect(() => {
+ load();
+
+ if (pollInterval > 0) {
+ intervalRef.current = setInterval(load, pollInterval);
+ }
+
+ return () => {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current);
+ intervalRef.current = null;
+ }
+ };
+ }, [load, pollInterval]);
+
+ const feePercent = (feeBasisPoints / BASIS_POINTS_DIVISOR) * 100;
+
+ return {
+ feeBasisPoints,
+ feePercent,
+ loading,
+ error,
+ isLive,
+ refresh: load,
+ };
+}
diff --git a/frontend/src/hooks/useContractFee.test.js b/frontend/src/hooks/useContractFee.test.js
new file mode 100644
index 00000000..66e450a9
--- /dev/null
+++ b/frontend/src/hooks/useContractFee.test.js
@@ -0,0 +1,145 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { renderHook, waitFor, act } from '@testing-library/react';
+import { useContractFee } from './useContractFee';
+
+vi.mock('../config/contracts', () => ({
+ CONTRACT_ADDRESS: 'SP1W6XQZ6XVYGTVW32SJW2ZG48ZJBW9BATRD19N60',
+ CONTRACT_NAME: 'tipstream',
+ STACKS_API_BASE: 'https://api.hiro.so',
+ FN_GET_CURRENT_FEE_BASIS_POINTS: 'get-current-fee-basis-points',
+ FN_GET_FEE_FOR_AMOUNT: 'get-fee-for-amount',
+ FN_GET_FEE_SUMMARY: 'get-fee-summary',
+}));
+
+vi.mock('../context/DemoContext', () => ({
+ useDemoMode: () => ({ demoEnabled: false }),
+}));
+
+const mockFetchCurrentFee = vi.fn();
+
+vi.mock('../lib/admin-contract', () => ({
+ fetchCurrentFee: (...args) => mockFetchCurrentFee(...args),
+}));
+
+describe('useContractFee', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('starts with the SDK fallback value while loading', () => {
+ mockFetchCurrentFee.mockReturnValue(new Promise(() => {}));
+ const { result } = renderHook(() => useContractFee({ pollInterval: 0 }));
+ expect(result.current.loading).toBe(true);
+ expect(result.current.feeBasisPoints).toBe(50);
+ });
+
+ it('resolves to the live fee from the contract', async () => {
+ mockFetchCurrentFee.mockResolvedValue(75);
+ const { result } = renderHook(() => useContractFee({ pollInterval: 0 }));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.feeBasisPoints).toBe(75);
+ expect(result.current.isLive).toBe(true);
+ expect(result.current.error).toBeNull();
+ });
+
+ it('computes feePercent correctly for the default 50 bps', async () => {
+ mockFetchCurrentFee.mockResolvedValue(50);
+ const { result } = renderHook(() => useContractFee({ pollInterval: 0 }));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.feePercent).toBeCloseTo(0.5);
+ });
+
+ it('computes feePercent correctly for 100 bps', async () => {
+ mockFetchCurrentFee.mockResolvedValue(100);
+ const { result } = renderHook(() => useContractFee({ pollInterval: 0 }));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.feePercent).toBeCloseTo(1.0);
+ });
+
+ it('falls back to SDK constant when the contract call fails', async () => {
+ mockFetchCurrentFee.mockRejectedValue(new Error('Network error'));
+ const { result } = renderHook(() => useContractFee({ pollInterval: 0 }));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.feeBasisPoints).toBe(50);
+ expect(result.current.isLive).toBe(false);
+ expect(result.current.error).toBe('Network error');
+ });
+
+ it('falls back to SDK constant when contract returns a negative value', async () => {
+ mockFetchCurrentFee.mockResolvedValue(-1);
+ const { result } = renderHook(() => useContractFee({ pollInterval: 0 }));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.feeBasisPoints).toBe(50);
+ });
+
+ it('falls back to SDK constant when contract returns null', async () => {
+ mockFetchCurrentFee.mockResolvedValue(null);
+ const { result } = renderHook(() => useContractFee({ pollInterval: 0 }));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.feeBasisPoints).toBe(50);
+ });
+
+ it('accepts zero fee when fee is disabled on-chain', async () => {
+ mockFetchCurrentFee.mockResolvedValue(0);
+ const { result } = renderHook(() => useContractFee({ pollInterval: 0 }));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.feeBasisPoints).toBe(0);
+ expect(result.current.feePercent).toBe(0);
+ expect(result.current.isLive).toBe(true);
+ });
+
+ it('refresh() re-fetches the fee on demand', async () => {
+ mockFetchCurrentFee
+ .mockResolvedValueOnce(50)
+ .mockResolvedValueOnce(80);
+ const { result } = renderHook(() => useContractFee({ pollInterval: 0 }));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.feeBasisPoints).toBe(50);
+
+ await act(async () => {
+ result.current.refresh();
+ });
+ await waitFor(() => expect(result.current.feeBasisPoints).toBe(80));
+ expect(mockFetchCurrentFee).toHaveBeenCalledTimes(2);
+ });
+
+ it('clears error state after a successful refresh', async () => {
+ mockFetchCurrentFee
+ .mockRejectedValueOnce(new Error('Timeout'))
+ .mockResolvedValueOnce(50);
+ const { result } = renderHook(() => useContractFee({ pollInterval: 0 }));
+ await waitFor(() => expect(result.current.error).toBe('Timeout'));
+
+ await act(async () => {
+ result.current.refresh();
+ });
+ await waitFor(() => expect(result.current.error).toBeNull());
+ expect(result.current.feeBasisPoints).toBe(50);
+ expect(result.current.isLive).toBe(true);
+ });
+
+ it('isLive is false before the first successful fetch', () => {
+ mockFetchCurrentFee.mockReturnValue(new Promise(() => {}));
+ const { result } = renderHook(() => useContractFee({ pollInterval: 0 }));
+ expect(result.current.isLive).toBe(false);
+ });
+
+ it('exposes a refresh function', async () => {
+ mockFetchCurrentFee.mockResolvedValue(50);
+ const { result } = renderHook(() => useContractFee({ pollInterval: 0 }));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(typeof result.current.refresh).toBe('function');
+ });
+
+ it('does not poll when pollInterval is 0', async () => {
+ mockFetchCurrentFee.mockResolvedValue(50);
+ renderHook(() => useContractFee({ pollInterval: 0 }));
+ await waitFor(() => expect(mockFetchCurrentFee).toHaveBeenCalledTimes(1));
+ await new Promise(r => setTimeout(r, 50));
+ expect(mockFetchCurrentFee).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/frontend/src/lib/admin-contract.js b/frontend/src/lib/admin-contract.js
index c41e4e4b..e63fae17 100644
--- a/frontend/src/lib/admin-contract.js
+++ b/frontend/src/lib/admin-contract.js
@@ -7,7 +7,7 @@
* enforce the 144-block timelock on all admin actions.
*/
-import { CONTRACT_ADDRESS, CONTRACT_NAME, STACKS_API_BASE, FN_GET_CURRENT_FEE_BASIS_POINTS } from '../config/contracts';
+import { CONTRACT_ADDRESS, CONTRACT_NAME, STACKS_API_BASE, FN_GET_CURRENT_FEE_BASIS_POINTS, FN_GET_FEE_FOR_AMOUNT, FN_GET_FEE_SUMMARY } from '../config/contracts';
/**
* Contract function names for read-only calls
@@ -171,6 +171,68 @@ export async function fetchCurrentFee() {
}
}
+/**
+ * Fetch the computed fee for a specific tip amount from the contract.
+ * Uses the on-chain calculate-fee logic so the result is always exact.
+ *
+ * @param {number} amountMicroSTX - Tip amount in micro-STX
+ * @returns {Promise