
In traditional Web2 commerce, many junior engineers use JavaScript or Python native floating-point types (float / number) for monetary amounts. Fiat currencies usually have two decimal places, so even the classic IEEE-754 issue 0.1 + 0.2 = 0.30000000000000004 can often be hidden with toFixed(2) or display-layer rounding.
Move that approach into multichain stablecoin checkout and refund systems (USDC / USDT / DAI), however, and a difference of just 1 wei, the smallest token unit at 10−6 or 10−18, can cause severe production accounting failures:
- The 1-wei underpayment lockup: a product costs 100 USDC. The frontend calculates the transfer using floating point, but JavaScript serialization truncation leaves the contract with
99999999raw units, or 99.999999 USDC. The checkout state machine strictly enforcespaidAmount >= expectedAmount; that missing0.000001USDC leaves the order permanentlyUNDERPAID, prevents automatic activation-code delivery, and prompts a customer complaint. - Cross-chain decimal divergence: “USDT” commonly has 6 decimals (106) on Ethereum mainnet, Arbitrum, and Tron, while many BNB Chain (BSC) implementations historically use 18 (1018). If the backend stores amounts in loosely typed floating-point fields and universally divides by 106, a 100 USDT refund on BSC can be interpreted as an astronomical amount 1014 times larger and drain the merchant’s pool.
- Repeated partial refunds break accounting in reverse: a customer buys a 100 USDC digital service and requests two partial refunds, such as 33.33 USDC each. If the refund service divides and accumulates historical refunds using floating point, precision drift can make
sum(refunds)slightly exceed the original payment, triggering the contract’s over-refund assertion and reverting the operation. The final legitimate refund then becomes impossible.
The first rule in the enterprise multichain gateway StableIn is: no floating-point arithmetic in core accounting. This article examines raw-unit big-integer parsing, the dual-field storage standard, and the remaining-budget invariant for repeated partial refunds.
Why Floating Point Fails in Crypto Payments
IEEE-754 double precision has only 53 bits of significand, representing roughly 15–17 significant decimal digits exactly.
Blockchain values, by contrast, are discrete unsigned integers (uint256) with up to 78 decimal digits:
+-----------------------------------------------------------------------------------+
| Floating-Point Precision vs. Blockchain Raw Units |
+-----------------------------------------------------------------------------------+
| 1. IEEE-754 limit: Number.MAX_SAFE_INTEGER = 9,007,199,254,740,991 (about 16 digits) |
| 2. 18-decimal tokens (DAI / WETH): 1 Token = 1,000,000,000,000,000,000 (18 digits) |
| -> Above 9 tokens, native JavaScript Number loses low-order precision! |
| -------------------------------------------------------------------------------- |
| [Failure scenario: floating-point serialization truncation] |
| User input string: "99.999999" |
| Incorrect: parseFloat("99.999999") * 10^6 = 99999998.99999999 |
| Rounded down: Math.floor(99999998.99999999) = 99999998 |
| Actual on-chain receipt: 99.999998 USDC (1 unit short!) |
| -> State machine: 99999998 < 99999999 -> order enters UNDERPAID branch |
+-----------------------------------------------------------------------------------+
Once precision diverges, a single missing wei can leave the on-chain contract and server database with incompatible logical outcomes.
Comparing Three Monetary Arithmetic Approaches
| Architecture | Native float (JavaScript Number / Python float) |
Arbitrary-precision libraries (decimal.js / bignumber.js) |
StableIn native BigInt atomic-unit contract |
|---|---|---|---|
| Accuracy | Poor (digits lost beyond 16) | Good (high precision implemented in software) | 100% exact (lossless integer arithmetic) |
| Smart contract alignment | Severe mismatch (EVM natively supports unsigned integers only) | Frequent unit conversions required | Exact alignment with EVM uint256 representation |
| Cross-language serialization | Poor (JSON deserialization can alter values) | Depends on emitting plain strings | High (consistent ascii_bin decimal strings) |
| Performance and memory | Fast (unacceptable error rate) | Slow (heavy objects and GC overhead) | Very fast (native modern V8 / Node.js optimization) |
| Remaining partial-refund budget | Rounding can cause over-refund failures | Requires complex rounding adjustments | Strict discrete conservation (remaining = amount - refunded) |
Core Implementation 1: Lossless String-to-Integer Parsing (amounts.ts)
In stablein-platform/packages/protocol/src/amounts.ts, we implement conversion from strings to BigInt without any floating-point arithmetic.
1. String Parsing and Decimal Alignment
The algorithm separates whole and fractional parts as strings, pads the fraction with padEnd, and combines them into an atomic-unit integer. This eliminates error amplification from floating-point multiplication:
// From stablein-platform/packages/protocol/src/amounts.ts
export const UINT256_MAX = (1n << 256n) - 1n;
export function parseUint(value: string, bits = 256): bigint {
// Strict regex: canonical unsigned decimal string, no more than 78 digits
if (typeof value !== 'string' || !/^(0|[1-9][0-9]*)$/.test(value) || value.length > 78) {
throw new Error('Expected canonical unsigned decimal string');
}
if (!Number.isInteger(bits) || bits < 1 || bits > 256) {
throw new Error('Invalid integer width');
}
const result = BigInt(value);
if (result >= (1n << BigInt(bits))) {
throw new Error('Integer exceeds its width');
}
return result;
}
function scale(decimals: number): bigint {
if (!Number.isInteger(decimals) || decimals < 0 || decimals > 77) {
throw new Error('Invalid decimals');
}
return 10n ** BigInt(decimals);
}
export function parseAmount(value: string, decimals: number): bigint {
const unit = scale(decimals);
// Validate amount string with optional decimal point
if (
typeof value !== 'string' ||
value.length > 157 ||
!/^(0|[1-9][0-9]*)(\.[0-9]+)?$/.test(value)
) {
throw new Error('Invalid decimal amount');
}
const [whole, fraction = ''] = value.split('.');
// Reject fractional precision beyond the token's decimals; never silently round!
if (fraction.length > decimals) {
throw new Error('Too many fractional digits');
}
// Core algorithm: scale whole part + right-pad fractional part and convert to integer
const result = BigInt(whole) * unit + BigInt(fraction.padEnd(decimals, '0') || '0');
if (result > UINT256_MAX) {
throw new Error('Amount exceeds uint256');
}
return result;
}
2. Formatting Integers for Human Readers
When producing UI or invoice output, reverse conversion must also use integer division and modulo, never floating-point conversion:
// From stablein-platform/packages/protocol/src/amounts.ts
export function formatAmount(amount: bigint, decimals: number): string {
const unit = scale(decimals);
if (typeof amount !== 'bigint' || amount < 0n || amount > UINT256_MAX) {
throw new Error('Invalid uint256 amount');
}
const whole = (amount / unit).toString();
// Use remainder as fractional part; left-pad to precision and remove trailing zeros
const fraction = (amount % unit).toString().padStart(decimals, '0').replace(/0+$/, '');
return decimals && fraction ? `${whole}.${fraction}` : whole;
}
Core Implementation 2: The Database Dual-Field Storage Standard and DDL
Storing blockchain amounts in a relational database needs equal care. Beginners often use MySQL DOUBLE or FLOAT, which financial audits prohibit. Even DECIMAL(18,2) cannot store Ethereum’s 18-decimal native units (1 ETH = 1018 wei).
In stablein-platform/src/migrations/1792108801000-PaymentOrders.ts, we define a dual-field storage standard:
-- From stablein-platform/src/migrations/1792108801000-PaymentOrders.ts
CREATE TABLE pay_orders (
id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY,
tenant_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
merchant_order_id VARBINARY(512) NOT NULL,
chain_id INT UNSIGNED NOT NULL,
gateway BINARY(20) NOT NULL,
token BINARY(20) NOT NULL,
-- Core accounting fields: VARCHAR(78) with ascii_bin stores raw integer strings up to 2^256-1 losslessly
amount VARCHAR(78) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
fee VARCHAR(78) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
net_amount VARCHAR(78) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
refunded_amount VARCHAR(78) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT '0',
payment_status VARCHAR(20) NOT NULL DEFAULT 'open',
refund_status VARCHAR(8) NOT NULL DEFAULT 'none',
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
UNIQUE KEY merchant_reference (tenant_id, merchant_order_id)
) ENGINE=InnoDB;
Why VARCHAR(78) ascii_bin?
- 2256 − 1 has 78 decimal digits. The fixed-width
asciicharacter set withascii_bincollation makes comparisons case- and space-sensitive and ensures that database drivers do not silently convert the field to floating point when read from Node.js, Python, Go, or Java.
Core Implementation 3: Preserving the Remaining-Budget Invariant Across Partial Refunds
Partial refunds are frequent in digital goods and cross-border commerce, such as 10% compensation for late delivery or a 20% refund for damaged goods.
Merely recording the amount of each refund makes over-refunding likely under concurrent or repeated requests.
In stablein-platform/src/modules/payment/refunds/refunds.service.ts, we implement an invariant-based remaining-budget check in raw units:
// From stablein-platform/src/modules/payment/refunds/refunds.service.ts
// 1. Refunds are allowed only for finalized paid orders
if (r.payment_status !== 'paid') {
throw new PaymentError(409, 'PAYMENT_NOT_FINALIZED');
}
// 2. Collect submitted, non-abandoned, in-flight refund requests for this order
const pending = await m.query(
"SELECT amount FROM pay_refund_requests WHERE order_id=? AND abandoned=FALSE AND status<>'completed'",
[orderId]
);
// 3. Core invariant: subtract using BigInt integer arithmetic only!
// Remaining refundable = original payment - completed on-chain refunds - pending database requests
const remaining =
BigInt(r.amount) -
BigInt(r.refunded_amount) -
pending.reduce((sum, p) => sum + BigInt(p.amount), 0n);
// 4. Assert requested amount does not exceed remaining availability
if (BigInt(input.amount) > remaining) {
throw new PaymentError(409, 'REFUND_EXCEEDS_REMAINING', {
remaining: String(remaining > 0n ? remaining : 0n)
});
}
// 5. Insert refund record ...
In the on-chain event projector (projector.ts), each confirmed PaymentRefunded event must report a cumulativeRefunded value exactly equal to the previously observed cumulative refund plus this refund:
// From stablein-platform/src/modules/payment/chain/projector.ts
if (
BigInt(a.amount) <= 0n ||
BigInt(a.cumulativeRefunded) !== seen + BigInt(a.amount) ||
BigInt(a.cumulativeRefunded) > BigInt(r.amount)
) {
throw new Error('Inconsistent canonical refund sequence');
}
These two checks ensure that, whether requests arrive concurrently off-chain or refund events arrive out of order on-chain, the refund ledger always satisfies this discrete invariant: ∑Refundscompleted + ∑Refundspending ≤ OrderAmount
Four Production Rules for Stablecoin Accounting
- Never use floating point in monetary calculations: parse API inputs directly from strings into lossless
BigIntvalues, preventing 1-wei underpayment lockups caused by IEEE-754 truncation. - Handle decimal differences for identically named tokens across chains: never assume USDT always has 6 decimals. Declare each token’s actual
decimalsin the chain configuration and bind the correct scale before any display conversion. - Store amounts as
VARCHAR(78) ascii_binorDECIMAL(65,0): prohibit floating-point columns for crypto amounts and prevent database drivers from automatically coercing large integers into scientific-notation strings on reads. - Deduct pending requests when processing partial refunds: remaining refundable funds must subtract both completed refunds and requested amounts not yet paid out, preventing treasury over-refunds under concurrent submissions.
Enterprise Stablecoin Payment and Accounting Architecture Consulting

Fone builds zero-error, reorg-resistant, high-concurrency stablecoin checkout and settlement systems for enterprise international merchants, cross-border commerce platforms, and decentralized commerce protocols. Our StableIn infrastructure has been tested against large volumes of production on-chain payments and refund reconciliation:
- Explore the full enterprise multichain stablecoin acceptance architecture: StableIn Solutions
- Understand server-side order idempotency: Build an Idempotent Order API with Python and SQLite
- Explore multichain reorg handling: Block Reorg Defense and Finality Confirmation Tiers for Multichain Stablecoin Payments
- Arrange an accounting audit and custom architecture assessment with a senior distributed-systems architect: Contact Fone Engineering