diff --git a/CHANGELOG.md b/CHANGELOG.md index 93feb398..be596df6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- Balance handling is now fully integer-safe end-to-end for issue #227: + `useBalance` normalizes API balances to canonical non-negative integer + micro-STX strings, `SendTip` and `BatchTip` compare required amounts + with precision-safe micro-STX checks (instead of floating-point STX + comparisons), and balance utilities now include bigint-safe helpers for + normalization, sufficiency checks, and exact decimal conversion. + - `useBalance` tests now use fake timers to correctly handle the hook's retry logic (MAX_RETRIES=2, RETRY_DELAY_MS=1500), fixing 4 previously failing error-path tests. Added retry count verification and recovery diff --git a/frontend/src/components/BatchTip.jsx b/frontend/src/components/BatchTip.jsx index 3b0005ce..c9f5eebf 100644 --- a/frontend/src/components/BatchTip.jsx +++ b/frontend/src/components/BatchTip.jsx @@ -12,7 +12,7 @@ import { import { network, appDetails, getSenderAddress } from '../utils/stacks'; import { CONTRACT_ADDRESS, CONTRACT_NAME, FN_SEND_BATCH_TIPS, FN_SEND_BATCH_TIPS_STRICT } from '../config/contracts'; import { toMicroSTX, formatSTX, formatAddress } from '../lib/utils'; -import { formatBalance } from '../lib/balance-utils'; +import { formatBalance, hasSufficientMicroStx } from '../lib/balance-utils'; import { analytics } from '../lib/analytics'; import { summarizeBatchTipResult, buildBatchTipOutcomeMessage } from '../lib/batchTipResults'; import { useBalance } from '../hooks/useBalance'; @@ -48,6 +48,20 @@ export default function BatchTip({ addToast }) { }, 0); }, [recipients]); + const totalAmountMicro = useMemo(() => { + return recipients.reduce((sum, r) => { + if (!r.amount) return sum; + const parsed = parseFloat(r.amount); + if (isNaN(parsed) || parsed <= 0) return sum; + return sum + toMicroSTX(r.amount); + }, 0); + }, [recipients]); + + const isBatchTotalInsufficient = useMemo(() => { + if (balanceSTX === null) return false; + return !hasSufficientMicroStx(balance, totalAmountMicro); + }, [balance, balanceSTX, totalAmountMicro]); + const isValidStacksAddress = (address) => { if (!address) return false; const trimmed = address.trim(); @@ -142,7 +156,7 @@ export default function BatchTip({ addToast }) { if (addr) seen.add(addr); }); - if (valid && balanceSTX !== null && totalAmount > balanceSTX) { + if (valid && balanceSTX !== null && !hasSufficientMicroStx(balance, totalAmountMicro)) { addToast?.('Insufficient balance for this batch', 'warning'); return false; } @@ -260,7 +274,7 @@ export default function BatchTip({ addToast }) { {totalAmount > 0 && (

Batch Total

-

balanceSTX ? 'text-red-500' : 'text-amber-600 dark:text-amber-400'}`}> +

{totalAmount.toFixed(6)} STX

diff --git a/frontend/src/components/SendTip.jsx b/frontend/src/components/SendTip.jsx index bc20b260..ac6d7a8e 100644 --- a/frontend/src/components/SendTip.jsx +++ b/frontend/src/components/SendTip.jsx @@ -8,7 +8,7 @@ import { import { network, appDetails, getSenderAddress } from '../utils/stacks'; import { CONTRACT_ADDRESS, CONTRACT_NAME, FN_SEND_CATEGORIZED_TIP } from '../config/contracts'; import { toMicroSTX, formatSTX } from '../lib/utils'; -import { microToStx, formatBalance } from '../lib/balance-utils'; +import { formatBalance, hasSufficientMicroStx } from '../lib/balance-utils'; import { isContractPrincipal, isValidStacksPrincipal } from '../lib/stacks-principal'; import { tipPostCondition, maxTransferForTip, feeForTip, totalDeduction, recipientReceives, SAFE_POST_CONDITION_MODE, FEE_PERCENT } from '../lib/post-conditions'; import { useTipContext } from '../context/TipContext'; @@ -117,8 +117,7 @@ export default function SendTip({ addToast }) { } else if (balanceSTX !== null) { // Account for the platform fee when checking balance const microSTX = toMicroSTX(parsed.toString()); - const totalSTX = microToStx(totalDeduction(microSTX)); - if (totalSTX > balanceSTX) { + if (!hasSufficientMicroStx(balance, totalDeduction(microSTX))) { setAmountError('Insufficient balance (tip + 0.5% fee exceeds balance)'); } else { setAmountError(''); @@ -139,7 +138,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 (microToStx(totalDeduction(microSTX)) > balanceSTX) { + if (!hasSufficientMicroStx(balance, totalDeduction(microSTX))) { addToast('Insufficient balance to cover tip plus platform fee', 'warning'); return; } diff --git a/frontend/src/hooks/useBalance.js b/frontend/src/hooks/useBalance.js index d2d8a1e5..2fd0f2dd 100644 --- a/frontend/src/hooks/useBalance.js +++ b/frontend/src/hooks/useBalance.js @@ -5,12 +5,30 @@ import { microToStx } from '../lib/balance-utils'; const MAX_RETRIES = 2; const RETRY_DELAY_MS = 1500; +function normalizeMicroStxBalance(rawBalance) { + if (typeof rawBalance === 'number') { + if (!Number.isFinite(rawBalance) || !Number.isInteger(rawBalance) || rawBalance < 0) { + return null; + } + return String(rawBalance); + } + + if (typeof rawBalance === 'string') { + const trimmed = rawBalance.trim(); + if (!/^\d+$/.test(trimmed)) return null; + return trimmed; + } + + return null; +} + /** * Fetch and track the STX balance for a Stacks address. * Includes automatic retry on transient failures. * - * The balance is stored as the raw string returned by the Stacks API - * (`/extended/v1/address/:addr/stx`), representing micro-STX. Consumers + * The balance is stored as a normalized non-negative integer string + * representing micro-STX, derived from `/extended/v1/address/:addr/stx`. + * Consumers * should use the balance-utils helpers (`microToStx`, `formatBalance`) to * convert for display rather than dividing by a magic number. * @@ -50,11 +68,12 @@ export function useBalance(address) { const data = await res.json(); - if (typeof data?.balance !== 'string' && typeof data?.balance !== 'number') { + const normalized = normalizeMicroStxBalance(data?.balance); + if (normalized === null) { throw new Error('Unexpected balance format in API response'); } - setBalance(String(data.balance)); + setBalance(normalized); setLastFetched(Date.now()); setLoading(false); } catch (err) { diff --git a/frontend/src/lib/balance-utils.js b/frontend/src/lib/balance-utils.js index 3d2cdade..2a8ca25e 100644 --- a/frontend/src/lib/balance-utils.js +++ b/frontend/src/lib/balance-utils.js @@ -13,6 +13,77 @@ /** Number of micro-STX in one STX. */ export const MICRO_STX = 1_000_000; +/** BigInt variant of MICRO_STX used for precision-safe integer operations. */ +const MICRO_STX_BIGINT = 1_000_000n; + +/** + * Convert a micro-STX value into a normalized non-negative bigint. + * + * Accepts decimal digit strings, finite integer numbers, and bigint values. + * Returns null for invalid, fractional, or negative values. + * + * @param {string|number|bigint|null|undefined} value + * @returns {bigint|null} + */ +export function toMicroStxBigInt(value) { + if (value === null || value === undefined || value === '') return null; + + if (typeof value === 'bigint') { + return value >= 0n ? value : null; + } + + if (typeof value === 'number') { + if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) return null; + return BigInt(value); + } + + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!/^\d+$/.test(trimmed)) return null; + return BigInt(trimmed); + } + + return null; +} + +/** + * Check if a balance (micro-STX) can cover a required amount (micro-STX). + * + * Both values are normalized via bigint conversion to avoid Number precision + * issues and BigInt/Number mixing in consumers. + * + * @param {string|number|bigint|null|undefined} balanceMicroStx + * @param {string|number|bigint|null|undefined} requiredMicroStx + * @returns {boolean} + */ +export function hasSufficientMicroStx(balanceMicroStx, requiredMicroStx) { + const balance = toMicroStxBigInt(balanceMicroStx); + const required = toMicroStxBigInt(requiredMicroStx); + + if (balance === null || required === null) return false; + return balance >= required; +} + +/** + * Convert micro-STX to a decimal STX string with fixed precision. + * + * @param {string|number|bigint|null|undefined} microStx + * @param {number} [precision=6] + * @returns {string|null} + */ +export function microToStxDecimalString(microStx, precision = 6) { + const normalized = toMicroStxBigInt(microStx); + if (normalized === null) return null; + + const whole = normalized / MICRO_STX_BIGINT; + const fractionalRaw = normalized % MICRO_STX_BIGINT; + const fullFraction = fractionalRaw.toString().padStart(6, '0'); + const clippedFraction = fullFraction.slice(0, Math.max(0, Math.min(6, precision))); + + if (precision <= 0) return whole.toString(); + return `${whole.toString()}.${clippedFraction.padEnd(precision, '0')}`; +} + /** * Parse a balance value (string, number, or BigInt) into a finite number. * @@ -79,8 +150,17 @@ export function formatBalance(microStx, options = {}) { fallback = '--', } = options; - const stx = microToStx(microStx); - if (stx === null) return fallback; + const stxDecimal = microToStxDecimalString(microStx, maxDecimals); + if (stxDecimal === null) return fallback; + + const stx = Number(stxDecimal); + if (!Number.isFinite(stx)) { + // Fallback for very large balances that exceed Number range. + const plain = maxDecimals > 0 + ? stxDecimal.replace(/\.0+$/, '') + : stxDecimal; + return suffix ? `${plain} STX` : plain; + } const formatted = stx.toLocaleString(undefined, { minimumFractionDigits: minDecimals, diff --git a/frontend/src/test/balance-utils.test.js b/frontend/src/test/balance-utils.test.js index 72cb96a2..a8571b7e 100644 --- a/frontend/src/test/balance-utils.test.js +++ b/frontend/src/test/balance-utils.test.js @@ -6,6 +6,9 @@ import { stxToMicro, formatBalance, isValidBalance, + toMicroStxBigInt, + hasSufficientMicroStx, + microToStxDecimalString, } from '../lib/balance-utils'; // --------------------------------------------------------------------------- @@ -74,6 +77,78 @@ describe('parseBalance', () => { }); }); +// --------------------------------------------------------------------------- +// toMicroStxBigInt +// --------------------------------------------------------------------------- +describe('toMicroStxBigInt', () => { + it('normalizes a digit string to bigint', () => { + expect(toMicroStxBigInt('1500000')).toBe(1500000n); + }); + + it('normalizes a non-negative integer number to bigint', () => { + expect(toMicroStxBigInt(42)).toBe(42n); + }); + + it('returns null for decimal strings', () => { + expect(toMicroStxBigInt('1.5')).toBeNull(); + }); + + it('returns null for negative values', () => { + expect(toMicroStxBigInt('-5')).toBeNull(); + expect(toMicroStxBigInt(-5)).toBeNull(); + expect(toMicroStxBigInt(-5n)).toBeNull(); + }); + + it('returns null for scientific notation strings', () => { + expect(toMicroStxBigInt('1e6')).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// hasSufficientMicroStx +// --------------------------------------------------------------------------- +describe('hasSufficientMicroStx', () => { + it('returns true when balance equals required amount', () => { + expect(hasSufficientMicroStx('1000', 1000)).toBe(true); + }); + + it('returns true when balance exceeds required amount', () => { + expect(hasSufficientMicroStx('1001', 1000)).toBe(true); + }); + + it('returns false when balance is lower than required amount', () => { + expect(hasSufficientMicroStx('999', 1000)).toBe(false); + }); + + it('returns false for invalid values', () => { + expect(hasSufficientMicroStx('abc', 1000)).toBe(false); + expect(hasSufficientMicroStx('1000', '1.5')).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// microToStxDecimalString +// --------------------------------------------------------------------------- +describe('microToStxDecimalString', () => { + it('converts a whole STX value exactly', () => { + expect(microToStxDecimalString('1000000')).toBe('1.000000'); + }); + + it('converts a fractional STX value exactly', () => { + expect(microToStxDecimalString('1500000')).toBe('1.500000'); + }); + + it('supports custom precision', () => { + expect(microToStxDecimalString('1500000', 2)).toBe('1.50'); + expect(microToStxDecimalString('1500000', 0)).toBe('1'); + }); + + it('returns null for invalid inputs', () => { + expect(microToStxDecimalString('1.5')).toBeNull(); + expect(microToStxDecimalString(null)).toBeNull(); + }); +}); + // --------------------------------------------------------------------------- // microToStx // --------------------------------------------------------------------------- @@ -218,6 +293,15 @@ describe('formatBalance', () => { }); expect(result).toMatch(/1[.,]5/); }); + + it('formats very large balances as plain decimal text', () => { + const result = formatBalance('9000000000000000', { + minDecimals: 2, + maxDecimals: 2, + suffix: false, + }); + expect(result.replace(/,/g, '')).toBe('9000000000.00'); + }); }); // --------------------------------------------------------------------------- diff --git a/frontend/src/test/send-tip-validation.test.js b/frontend/src/test/send-tip-validation.test.js index a69cbc66..f9d80bd9 100644 --- a/frontend/src/test/send-tip-validation.test.js +++ b/frontend/src/test/send-tip-validation.test.js @@ -88,31 +88,32 @@ describe('SendTip self-tip check', () => { }); describe('SendTip balance-insufficient check', () => { - function isBalanceInsufficient(amount, balanceSTX) { - if (balanceSTX === null) return false; - const parsed = parseFloat(amount); - if (isNaN(parsed)) return false; - return parsed > balanceSTX; + function isBalanceInsufficient(balanceMicroStx, requiredMicroStx) { + if (balanceMicroStx === null) return false; + if (!/^\d+$/.test(String(balanceMicroStx))) return false; + if (!/^\d+$/.test(String(requiredMicroStx))) return false; + return BigInt(requiredMicroStx) > BigInt(balanceMicroStx); } it('returns false when balance is null (unknown)', () => { - expect(isBalanceInsufficient('5', null)).toBe(false); + expect(isBalanceInsufficient(null, '5000000')).toBe(false); }); it('returns false when amount is within balance', () => { - expect(isBalanceInsufficient('5', 10)).toBe(false); + expect(isBalanceInsufficient('10000000', '5000000')).toBe(false); }); it('returns true when amount exceeds balance', () => { - expect(isBalanceInsufficient('15', 10)).toBe(true); + expect(isBalanceInsufficient('10000000', '15000000')).toBe(true); }); it('returns false when amount equals balance', () => { - expect(isBalanceInsufficient('10', 10)).toBe(false); + expect(isBalanceInsufficient('10000000', '10000000')).toBe(false); }); - it('returns false for non-numeric amount', () => { - expect(isBalanceInsufficient('abc', 10)).toBe(false); + it('returns false for malformed values', () => { + expect(isBalanceInsufficient('abc', '10')).toBe(false); + expect(isBalanceInsufficient('10', '1.5')).toBe(false); }); }); diff --git a/frontend/src/test/useBalance.test.js b/frontend/src/test/useBalance.test.js index 8b2d4734..2e48a551 100644 --- a/frontend/src/test/useBalance.test.js +++ b/frontend/src/test/useBalance.test.js @@ -62,6 +62,72 @@ describe('useBalance', () => { expect(typeof result.current.balance).toBe('string'); }); + it('rejects decimal balance strings from API payload', async () => { + vi.useFakeTimers(); + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ balance: '12.34' }), + }); + + const { result } = renderHook(() => + useBalance('SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T'), + ); + + for (let i = 0; i < 3; i++) { + await act(async () => { + await vi.advanceTimersByTimeAsync(1600); + }); + } + + expect(result.current.balance).toBeNull(); + expect(result.current.error).toBe('Unexpected balance format in API response'); + vi.useRealTimers(); + }); + + it('rejects scientific notation balance strings from API payload', async () => { + vi.useFakeTimers(); + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ balance: '1e6' }), + }); + + const { result } = renderHook(() => + useBalance('SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T'), + ); + + for (let i = 0; i < 3; i++) { + await act(async () => { + await vi.advanceTimersByTimeAsync(1600); + }); + } + + expect(result.current.balance).toBeNull(); + expect(result.current.error).toBe('Unexpected balance format in API response'); + vi.useRealTimers(); + }); + + it('rejects negative numeric balances from API payload', async () => { + vi.useFakeTimers(); + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ balance: -10 }), + }); + + const { result } = renderHook(() => + useBalance('SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T'), + ); + + for (let i = 0; i < 3; i++) { + await act(async () => { + await vi.advanceTimersByTimeAsync(1600); + }); + } + + expect(result.current.balance).toBeNull(); + expect(result.current.error).toBe('Unexpected balance format in API response'); + vi.useRealTimers(); + }); + it('computes balanceStx correctly from a micro-STX string', async () => { global.fetch = vi.fn().mockResolvedValue({ ok: true,