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,