Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
1e25fe8
fix(balance): add bigint-safe micro-STX normalizer
Mosas2000 Mar 17, 2026
0029074
fix(balance): add precision-safe sufficiency check
Mosas2000 Mar 17, 2026
240e8e0
fix(balance): add exact decimal formatter for micro-STX
Mosas2000 Mar 17, 2026
89b51d0
fix(balance): harden display formatting for oversized balances
Mosas2000 Mar 17, 2026
2d5437e
refactor(send-tip): import precision-safe balance comparator
Mosas2000 Mar 17, 2026
0534bfe
fix(send-tip): use integer-safe balance check during amount input
Mosas2000 Mar 17, 2026
167fc1e
fix(send-tip): use integer-safe balance check before submit
Mosas2000 Mar 17, 2026
caa6ba4
chore(send-tip): remove obsolete balance conversion import
Mosas2000 Mar 17, 2026
bfe0789
refactor(batch-tip): import precision-safe balance comparator
Mosas2000 Mar 17, 2026
97f41ed
refactor(batch-tip): derive micro-STX total for batch comparisons
Mosas2000 Mar 17, 2026
3c2e96b
fix(batch-tip): validate balance with micro-STX integer math
Mosas2000 Mar 17, 2026
8c7cd17
refactor(batch-tip): derive insufficient-total flag from micro-STX
Mosas2000 Mar 17, 2026
7ac6c49
fix(batch-tip): color insufficient totals using safe comparator
Mosas2000 Mar 17, 2026
7193561
fix(use-balance): enforce canonical integer micro-STX balance strings
Mosas2000 Mar 17, 2026
d0df76d
docs(use-balance): document normalized micro-STX balance contract
Mosas2000 Mar 17, 2026
6950fea
test(balance-utils): import new precision-safe helpers
Mosas2000 Mar 17, 2026
50fc9f7
test(balance-utils): cover bigint normalization edge cases
Mosas2000 Mar 17, 2026
3545b7d
test(balance-utils): add sufficiency comparator coverage
Mosas2000 Mar 17, 2026
a6f6b72
test(balance-utils): verify exact micro-to-STX decimal conversion
Mosas2000 Mar 17, 2026
fa9e11d
test(balance-utils): cover very large balance formatting
Mosas2000 Mar 17, 2026
bb7a7b3
test(use-balance): reject decimal balance strings from API
Mosas2000 Mar 17, 2026
58380d7
test(use-balance): reject scientific-notation balances
Mosas2000 Mar 17, 2026
5092421
test(use-balance): reject negative numeric balances
Mosas2000 Mar 17, 2026
fb8dea5
test(send-tip): align balance-insufficient checks with integer micro-…
Mosas2000 Mar 17, 2026
42e163e
docs(changelog): record balance precision hardening for issue #227
Mosas2000 Mar 17, 2026
279a1e5
test(balance-utils): make huge-balance expectation locale-agnostic
Mosas2000 Mar 17, 2026
447adee
test(use-balance): make malformed-payload cases deterministic with fa…
Mosas2000 Mar 17, 2026
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 17 additions & 3 deletions frontend/src/components/BatchTip.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
} from '@stacks/transactions';
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';

Check failure on line 14 in frontend/src/components/BatchTip.jsx

View workflow job for this annotation

GitHub Actions / Frontend Lint

'formatAddress' is defined but never used. Allowed unused vars must match /^[A-Z_]/u

Check failure on line 14 in frontend/src/components/BatchTip.jsx

View workflow job for this annotation

GitHub Actions / Frontend Lint

'formatSTX' is defined but never used. Allowed unused vars must match /^[A-Z_]/u
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';
Expand Down Expand Up @@ -48,6 +48,20 @@
}, 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();
Expand Down Expand Up @@ -142,7 +156,7 @@
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;
}
Expand Down Expand Up @@ -260,7 +274,7 @@
{totalAmount > 0 && (
<div className="text-right">
<p className="text-xs text-gray-500 dark:text-gray-400">Batch Total</p>
<p className={`text-lg font-bold ${balanceSTX !== null && totalAmount > balanceSTX ? 'text-red-500' : 'text-amber-600 dark:text-amber-400'}`}>
<p className={`text-lg font-bold ${isBatchTotalInsufficient ? 'text-red-500' : 'text-amber-600 dark:text-amber-400'}`}>
{totalAmount.toFixed(6)} STX
</p>
</div>
Expand Down
7 changes: 3 additions & 4 deletions frontend/src/components/SendTip.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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('');
Expand All @@ -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;
}
Expand Down
27 changes: 23 additions & 4 deletions frontend/src/hooks/useBalance.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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) {
Expand Down
84 changes: 82 additions & 2 deletions frontend/src/lib/balance-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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,
Expand Down
84 changes: 84 additions & 0 deletions frontend/src/test/balance-utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import {
stxToMicro,
formatBalance,
isValidBalance,
toMicroStxBigInt,
hasSufficientMicroStx,
microToStxDecimalString,
} from '../lib/balance-utils';

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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');
});
});

// ---------------------------------------------------------------------------
Expand Down
23 changes: 12 additions & 11 deletions frontend/src/test/send-tip-validation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down
Loading
Loading