From cb6b102e1f5087c6af2c15b9ed4650cc69a1d588 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Tue, 19 May 2026 12:23:56 +0100 Subject: [PATCH 01/10] add get-fee-summary read-only function to tipstream contract --- contracts/tipstream.clar | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 })) ) From 866f0a66f6f6f8ef2b024dbb9aa3a84d8d715329 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Tue, 19 May 2026 12:24:21 +0100 Subject: [PATCH 02/10] add FN_GET_FEE_FOR_AMOUNT and FN_GET_FEE_SUMMARY constants to contracts config --- frontend/src/config/contracts.js | 2 ++ 1 file changed, 2 insertions(+) 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'; From 25ff7a4a095763ec7db79f4c0f6d245b202134f6 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Tue, 19 May 2026 12:32:56 +0100 Subject: [PATCH 03/10] add fetchFeeForAmount and fetchFeeSummary helpers to admin-contract --- frontend/src/lib/admin-contract.js | 64 +++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) 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} Fee in micro-STX + */ +export async function fetchFeeForAmount(amountMicroSTX) { + try { + const { uintCV, serializeCV } = await import('@stacks/transactions'); + const arg = serializeCV(uintCV(Math.floor(Number(amountMicroSTX)))); + const data = await callReadOnly(FN_GET_FEE_FOR_AMOUNT, [ + Buffer.from(arg).toString('hex'), + ]); + const result = parseClarityValue(data.result); + return typeof result === 'number' ? result : 0; + } catch (err) { + throw new Error(`Failed to fetch fee for amount: ${err.message}`); + } +} + +/** + * Fetch the full fee summary for a given tip amount. + * Returns fee-basis-points, basis-points-divisor, min-fee-ustx, + * fee-percent, fee-for-amount, amount, and net-amount in one call. + * + * @param {number} amountMicroSTX - Tip amount in micro-STX + * @returns {Promise<{ + * feeBasisPoints: number, + * basisPointsDivisor: number, + * minFeeUstx: number, + * feePercent: number, + * feeForAmount: number, + * amount: number, + * netAmount: number, + * }>} + */ +export async function fetchFeeSummary(amountMicroSTX) { + try { + const { uintCV, serializeCV } = await import('@stacks/transactions'); + const arg = serializeCV(uintCV(Math.floor(Number(amountMicroSTX)))); + const data = await callReadOnly(FN_GET_FEE_SUMMARY, [ + Buffer.from(arg).toString('hex'), + ]); + const result = parseClarityValue(data.result); + if (!result || typeof result !== 'object') { + throw new Error('Unexpected response shape from get-fee-summary'); + } + return { + feeBasisPoints: result['fee-basis-points'] ?? 0, + basisPointsDivisor: result['basis-points-divisor'] ?? 10000, + minFeeUstx: result['min-fee-ustx'] ?? 1, + feePercent: result['fee-percent'] ?? 0, + feeForAmount: result['fee-for-amount'] ?? 0, + amount: result['amount'] ?? 0, + netAmount: result['net-amount'] ?? 0, + }; + } catch (err) { + throw new Error(`Failed to fetch fee summary: ${err.message}`); + } +} + /** * Parse a hex-encoded Clarity value into a JavaScript value. * From 15b3ca72202e95b779d395b5622f6295f07982b3 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Tue, 19 May 2026 12:33:24 +0100 Subject: [PATCH 04/10] add useContractFee hook to fetch live fee rate from contract --- frontend/src/hooks/useContractFee.js | 82 ++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 frontend/src/hooks/useContractFee.js 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, + }; +} From 21841918eeeef8b0feb0e4189de40cc4af703530 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Tue, 19 May 2026 12:38:17 +0100 Subject: [PATCH 05/10] =?UTF-8?q?wire=20useContractFee=20into=20SendTip=20?= =?UTF-8?q?=E2=80=94=20fee=20preview=20and=20post-conditions=20use=20live?= =?UTF-8?q?=20on-chain=20rate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/SendTip.jsx | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/SendTip.jsx b/frontend/src/components/SendTip.jsx index ba64ef22..6eef6f54 100644 --- a/frontend/src/components/SendTip.jsx +++ b/frontend/src/components/SendTip.jsx @@ -11,7 +11,7 @@ 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'; @@ -19,6 +19,7 @@ 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'; @@ -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(() => { @@ -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
)} From 5088ed2e93cfb3c04f44fecc2a28530b9c57700b Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Tue, 19 May 2026 12:38:58 +0100 Subject: [PATCH 06/10] initialise currentFeeBasisPoints in useAdmin feeState default --- frontend/src/hooks/useAdmin.js | 1 + 1 file changed, 1 insertion(+) 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, }); From 212d6a3c36e4e060c9645e48a65987a3fb7b4ab7 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Tue, 19 May 2026 21:21:30 +0100 Subject: [PATCH 07/10] show live fee rate in AdminDashboard status bar --- frontend/src/components/AdminDashboard.jsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 */} -
+
+ Date: Tue, 19 May 2026 23:01:46 +0100 Subject: [PATCH 08/10] =?UTF-8?q?add=20useContractFee=20hook=20tests=20?= =?UTF-8?q?=E2=80=94=2013=20cases=20covering=20live=20fetch,=20fallback,?= =?UTF-8?q?=20refresh,=20and=20error=20recovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/hooks/useContractFee.test.js | 145 ++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 frontend/src/hooks/useContractFee.test.js 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); + }); +}); From 0c3cce5563e8b5756591e29badab9e05964ae061 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Tue, 19 May 2026 23:14:09 +0100 Subject: [PATCH 09/10] add fetchFeeForAmount and fetchFeeSummary tests to admin-contract test suite --- frontend/src/lib/admin-contract.test.js | 88 ++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/admin-contract.test.js b/frontend/src/lib/admin-contract.test.js index 510f0097..a6ab9b59 100644 --- a/frontend/src/lib/admin-contract.test.js +++ b/frontend/src/lib/admin-contract.test.js @@ -6,7 +6,9 @@ import { fetchCurrentBlockHeight, fetchPauseState, fetchContractOwner, - fetchMultisig + fetchMultisig, + fetchFeeForAmount, + fetchFeeSummary, } from './admin-contract'; import { STACKS_API_BASE, CONTRACT_ADDRESS, CONTRACT_NAME } from '../config/contracts'; @@ -171,3 +173,87 @@ describe('Admin Contract Helpers', () => { }); }); }); + +describe('fetchFeeForAmount', () => { + beforeEach(() => { + global.fetch = vi.fn(); + }); + + it('returns the computed fee for a given amount', async () => { + const mockResultHex = '0x070100000000000000000000000000001388'; + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ result: mockResultHex }), + }); + + const fee = await fetchFeeForAmount(1000000); + expect(typeof fee).toBe('number'); + expect(fee).toBeGreaterThanOrEqual(0); + }); + + it('throws when the API call fails', async () => { + global.fetch.mockResolvedValueOnce({ + ok: false, + statusText: 'Service Unavailable', + }); + + await expect(fetchFeeForAmount(1000000)).rejects.toThrow('Failed to fetch fee for amount'); + }); + + it('returns 0 when the contract returns an error response', async () => { + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ result: '0x08' }), + }); + + const fee = await fetchFeeForAmount(1000000); + expect(fee).toBe(0); + }); +}); + +describe('fetchFeeSummary', () => { + beforeEach(() => { + global.fetch = vi.fn(); + }); + + it('returns a structured fee summary object', async () => { + const mockResultHex = '0x070c000000070668616d6f756e74010000000000000000000000000000000032126261736973 2d706f696e74732d646976697365720100000000000000000000000000002710' + + '0e6665652d62617369732d706f696e74730100000000000000000000000000000032' + + '0e6665652d666f722d616d6f756e74010000000000000000000000000000000001' + + '0b6665652d70657263656e74010000000000000000000000000000000000' + + '0c6d696e2d6665652d757374780100000000000000000000000000000001' + + '0a6e65742d616d6f756e74010000000000000000000000000000000031'; + + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ result: mockResultHex }), + }); + + const summary = await fetchFeeSummary(50); + expect(summary).toHaveProperty('feeBasisPoints'); + expect(summary).toHaveProperty('basisPointsDivisor'); + expect(summary).toHaveProperty('minFeeUstx'); + expect(summary).toHaveProperty('feePercent'); + expect(summary).toHaveProperty('feeForAmount'); + expect(summary).toHaveProperty('amount'); + expect(summary).toHaveProperty('netAmount'); + }); + + it('throws when the API call fails', async () => { + global.fetch.mockResolvedValueOnce({ + ok: false, + statusText: 'Bad Gateway', + }); + + await expect(fetchFeeSummary(1000000)).rejects.toThrow('Failed to fetch fee summary'); + }); + + it('throws when the response shape is unexpected', async () => { + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ result: '0x09' }), + }); + + await expect(fetchFeeSummary(1000000)).rejects.toThrow('Failed to fetch fee summary'); + }); +}); From 5445fdd718d7ff3b1975fe2a4b3bbd7ab87c3e17 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Tue, 19 May 2026 23:15:36 +0100 Subject: [PATCH 10/10] add contract tests for get-fee-summary, get-fee-for-amount, and get-current-fee-basis-points --- tests/tipstream.test.ts | 225 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 225 insertions(+) diff --git a/tests/tipstream.test.ts b/tests/tipstream.test.ts index d9923e31..c7f985bb 100644 --- a/tests/tipstream.test.ts +++ b/tests/tipstream.test.ts @@ -2196,3 +2196,228 @@ describe("TipStream Contract Tests", () => { }); }); }); + +describe("Fee Read-Only Functions", () => { + describe("get-current-fee-basis-points", () => { + it("returns the default fee of 50 basis points", () => { + const { result } = simnet.callReadOnlyFn( + "tipstream", + "get-current-fee-basis-points", + [], + wallet1 + ); + expect(result).toBeOk(Cl.uint(50)); + }); + + it("reflects an updated fee after set-fee-basis-points", () => { + simnet.callPublicFn("tipstream", "set-fee-basis-points", [Cl.uint(100)], deployer); + const { result } = simnet.callReadOnlyFn( + "tipstream", + "get-current-fee-basis-points", + [], + wallet1 + ); + expect(result).toBeOk(Cl.uint(100)); + simnet.callPublicFn("tipstream", "set-fee-basis-points", [Cl.uint(50)], deployer); + }); + + it("returns zero when fee is disabled", () => { + simnet.callPublicFn("tipstream", "set-fee-basis-points", [Cl.uint(0)], deployer); + const { result } = simnet.callReadOnlyFn( + "tipstream", + "get-current-fee-basis-points", + [], + wallet1 + ); + expect(result).toBeOk(Cl.uint(0)); + simnet.callPublicFn("tipstream", "set-fee-basis-points", [Cl.uint(50)], deployer); + }); + + it("returns the maximum allowed fee of 1000 basis points", () => { + simnet.callPublicFn("tipstream", "set-fee-basis-points", [Cl.uint(1000)], deployer); + const { result } = simnet.callReadOnlyFn( + "tipstream", + "get-current-fee-basis-points", + [], + wallet1 + ); + expect(result).toBeOk(Cl.uint(1000)); + simnet.callPublicFn("tipstream", "set-fee-basis-points", [Cl.uint(50)], deployer); + }); + }); + + describe("get-fee-for-amount", () => { + it("returns 5000 uSTX fee for a 1 STX tip at 50 bps", () => { + const { result } = simnet.callReadOnlyFn( + "tipstream", + "get-fee-for-amount", + [Cl.uint(1_000_000)], + wallet1 + ); + expect(result).toBeOk(Cl.uint(5_000)); + }); + + it("returns the minimum fee of 1 uSTX when raw calculation rounds to zero", () => { + simnet.callPublicFn("tipstream", "set-fee-basis-points", [Cl.uint(1)], deployer); + const { result } = simnet.callReadOnlyFn( + "tipstream", + "get-fee-for-amount", + [Cl.uint(1_000)], + wallet1 + ); + expect(result).toBeOk(Cl.uint(1)); + simnet.callPublicFn("tipstream", "set-fee-basis-points", [Cl.uint(50)], deployer); + }); + + it("returns zero fee when fee is disabled", () => { + simnet.callPublicFn("tipstream", "set-fee-basis-points", [Cl.uint(0)], deployer); + const { result } = simnet.callReadOnlyFn( + "tipstream", + "get-fee-for-amount", + [Cl.uint(1_000_000)], + wallet1 + ); + expect(result).toBeOk(Cl.uint(0)); + simnet.callPublicFn("tipstream", "set-fee-basis-points", [Cl.uint(50)], deployer); + }); + + it("scales correctly for a large tip amount", () => { + const { result } = simnet.callReadOnlyFn( + "tipstream", + "get-fee-for-amount", + [Cl.uint(10_000_000_000)], + wallet1 + ); + expect(result).toBeOk(Cl.uint(50_000_000)); + }); + + it("matches the fee deducted in an actual send-tip call", () => { + const { events } = simnet.callPublicFn( + "tipstream", + "send-tip", + [Cl.principal(wallet2), Cl.uint(1_000_000), Cl.stringUtf8("fee check")], + wallet1 + ); + const feeTransfer = events + .filter(e => e.event === "stx_transfer_event") + .find(e => e.data.recipient === deployer); + + const { result } = simnet.callReadOnlyFn( + "tipstream", + "get-fee-for-amount", + [Cl.uint(1_000_000)], + wallet1 + ); + expect(result).toBeOk(Cl.uint(Number(feeTransfer!.data.amount))); + }); + }); + + describe("get-fee-summary", () => { + it("returns a complete fee summary tuple for a 1 STX tip", () => { + const { result } = simnet.callReadOnlyFn( + "tipstream", + "get-fee-summary", + [Cl.uint(1_000_000)], + wallet1 + ); + expect(result).toBeOk(Cl.tuple({ + "fee-basis-points": Cl.uint(50), + "basis-points-divisor": Cl.uint(10_000), + "min-fee-ustx": Cl.uint(1), + "fee-percent": Cl.uint(0), + "fee-for-amount": Cl.uint(5_000), + "amount": Cl.uint(1_000_000), + "net-amount": Cl.uint(995_000), + })); + }); + + it("net-amount equals amount minus fee-for-amount", () => { + const { result } = simnet.callReadOnlyFn( + "tipstream", + "get-fee-summary", + [Cl.uint(2_000_000)], + wallet1 + ); + expect(result).toBeOk(Cl.tuple({ + "fee-basis-points": Cl.uint(50), + "basis-points-divisor": Cl.uint(10_000), + "min-fee-ustx": Cl.uint(1), + "fee-percent": Cl.uint(0), + "fee-for-amount": Cl.uint(10_000), + "amount": Cl.uint(2_000_000), + "net-amount": Cl.uint(1_990_000), + })); + }); + + it("reflects a changed fee rate in the summary", () => { + simnet.callPublicFn("tipstream", "set-fee-basis-points", [Cl.uint(100)], deployer); + const { result } = simnet.callReadOnlyFn( + "tipstream", + "get-fee-summary", + [Cl.uint(1_000_000)], + wallet1 + ); + expect(result).toBeOk(Cl.tuple({ + "fee-basis-points": Cl.uint(100), + "basis-points-divisor": Cl.uint(10_000), + "min-fee-ustx": Cl.uint(1), + "fee-percent": Cl.uint(1), + "fee-for-amount": Cl.uint(10_000), + "amount": Cl.uint(1_000_000), + "net-amount": Cl.uint(990_000), + })); + simnet.callPublicFn("tipstream", "set-fee-basis-points", [Cl.uint(50)], deployer); + }); + + it("returns zero fee-for-amount and full net-amount when fee is disabled", () => { + simnet.callPublicFn("tipstream", "set-fee-basis-points", [Cl.uint(0)], deployer); + const { result } = simnet.callReadOnlyFn( + "tipstream", + "get-fee-summary", + [Cl.uint(1_000_000)], + wallet1 + ); + expect(result).toBeOk(Cl.tuple({ + "fee-basis-points": Cl.uint(0), + "basis-points-divisor": Cl.uint(10_000), + "min-fee-ustx": Cl.uint(1), + "fee-percent": Cl.uint(0), + "fee-for-amount": Cl.uint(0), + "amount": Cl.uint(1_000_000), + "net-amount": Cl.uint(1_000_000), + })); + simnet.callPublicFn("tipstream", "set-fee-basis-points", [Cl.uint(50)], deployer); + }); + + it("summary fee-for-amount matches get-fee-for-amount for the same input", () => { + const amount = 5_000_000; + const { result: summaryResult } = simnet.callReadOnlyFn( + "tipstream", + "get-fee-summary", + [Cl.uint(amount)], + wallet1 + ); + const { result: feeResult } = simnet.callReadOnlyFn( + "tipstream", + "get-fee-for-amount", + [Cl.uint(amount)], + wallet1 + ); + expect(summaryResult).toBeOk(expect.objectContaining({ + value: expect.objectContaining({ + "fee-for-amount": feeResult.value, + }) + })); + }); + + it("is callable by any principal, not just the owner", () => { + const { result } = simnet.callReadOnlyFn( + "tipstream", + "get-fee-summary", + [Cl.uint(1_000_000)], + wallet2 + ); + expect(result).toBeOk(expect.anything()); + }); + }); +});