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
18 changes: 18 additions & 0 deletions contracts/tipstream.clar
Original file line number Diff line number Diff line change
Expand Up @@ -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 }))
)
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/components/AdminDashboard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ export default function AdminDashboard({ userAddress, addToast }) {
)}

{/* Status Bar */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<StatusCard
label="Block Height"
value={formatBlockHeight(blockHeight)}
Expand All @@ -287,6 +287,11 @@ export default function AdminDashboard({ userAddress, addToast }) {
value={contractOwner ? `${contractOwner.slice(0, 8)}...${contractOwner.slice(-4)}` : '--'}
icon={Shield}
/>
<StatusCard
label="Current Fee Rate"
value={feeState.currentFeeBasisPoints != null ? formatBasisPoints(feeState.currentFeeBasisPoints) : '--'}
icon={DollarSign}
/>
<StatusCard
label="Timelock Delay"
value={`${TIMELOCK_BLOCKS} blocks`}
Expand Down
29 changes: 16 additions & 13 deletions frontend/src/components/SendTip.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@ import { toMicroSTX, formatSTX } from '../lib/utils';
import { formatBalance, hasSufficientMicroStx } from '../lib/balance-utils';
import { isValidStacksPrincipal } from '../lib/stacks-principal';
import { canProceedWithRecipient, getRecipientValidationMessage } from '../lib/recipient-validation';
import { tipPostCondition, maxTransferForTip, feeForTip, totalDeduction, recipientReceives, SAFE_POST_CONDITION_MODE, FEE_PERCENT } from '../lib/post-conditions';
import { tipPostCondition, maxTransferForTip, feeForTip, totalDeduction, recipientReceives, SAFE_POST_CONDITION_MODE } from '../lib/post-conditions';
import { useTipContext } from '../context/TipContext';
import { useDemoMode } from '../context/DemoContext';
import { useSendTipWithDemo } from '../hooks/useSendTipWithDemo';
import { useBalance } from '../hooks/useBalance';
import { useBlockCheck } from '../hooks/useBlockCheck';
import { useStxPrice } from '../hooks/useStxPrice';
import { useSenderAddress } from '../hooks/useSenderAddress';
import { useContractFee } from '../hooks/useContractFee';
import { analytics } from '../lib/analytics';
import ConfirmDialog from './ui/confirm-dialog';
import TxStatus from './ui/tx-status';
Expand Down Expand Up @@ -78,6 +79,8 @@ export default function SendTip({ addToast }) {
return typeof balance === 'string' ? Number(balance) / 1000000 : balance / 1000000;
}, [balance]);

const { feeBasisPoints, feePercent } = useContractFee();

const isRecipientHighRisk = !canProceedWithRecipient(recipient, blockedWarning);

useEffect(() => {
Expand Down Expand Up @@ -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('');
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -206,7 +209,7 @@ export default function SendTip({ addToast }) {

const microSTX = toMicroSTX(amount);
const postConditions = [
tipPostCondition(senderAddress, microSTX)
tipPostCondition(senderAddress, microSTX, feeBasisPoints)
];

const functionArgs = [
Expand Down Expand Up @@ -379,24 +382,24 @@ export default function SendTip({ addToast }) {
</span>
</div>
<div className="flex justify-between">
<span>Platform fee ({FEE_PERCENT}%)</span>
<span>{formatSTX(feeForTip(toMicroSTX(amount)), 6)} STX</span>
<span>Platform fee ({feePercent.toFixed(2)}%)</span>
<span>{formatSTX(feeForTip(toMicroSTX(amount), feeBasisPoints), 6)} STX</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)), 6)} STX
{formatSTX(totalDeduction(toMicroSTX(amount), feeBasisPoints), 6)} STX
</span>
</div>
<div className="flex justify-between text-gray-500 dark:text-gray-500">
<span>Recipient receives</span>
<span>
{formatSTX(recipientReceives(toMicroSTX(amount)), 6)} STX
{formatSTX(recipientReceives(toMicroSTX(amount), feeBasisPoints), 6)} STX
</span>
</div>
<div className="flex justify-between text-xs text-gray-400 dark:text-gray-600 pt-1">
<span>Post-condition ceiling</span>
<span>{formatSTX(maxTransferForTip(toMicroSTX(amount)), 6)} STX</span>
<span>{formatSTX(maxTransferForTip(toMicroSTX(amount), feeBasisPoints), 6)} STX</span>
</div>
</div>
</div>
Expand Down Expand Up @@ -441,12 +444,12 @@ export default function SendTip({ addToast }) {
{amount && parseFloat(amount) > 0 && (
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400 space-y-1">
<div className="flex justify-between">
<span>Platform fee ({FEE_PERCENT}%)</span>
<span>{formatSTX(feeForTip(toMicroSTX(amount)), 6)} STX</span>
<span>Platform fee ({feePercent.toFixed(2)}%)</span>
<span>{formatSTX(feeForTip(toMicroSTX(amount), feeBasisPoints), 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)), 6)} STX</span>
<span>{formatSTX(totalDeduction(toMicroSTX(amount), feeBasisPoints), 6)} STX</span>
</div>
</div>
)}
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/config/contracts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions frontend/src/hooks/useAdmin.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export function useAdmin(userAddress, options = {}) {
effectiveHeight: 0,
});
const [feeState, setFeeState] = useState({
currentFeeBasisPoints: 0,
pendingFee: null,
effectiveHeight: 0,
});
Expand Down
82 changes: 82 additions & 0 deletions frontend/src/hooks/useContractFee.js
Original file line number Diff line number Diff line change
@@ -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,
};
}
145 changes: 145 additions & 0 deletions frontend/src/hooks/useContractFee.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading