JavaScript 如何避免浮點誤差並準確處理加密貨幣金額
Handling Cryptocurrency Amounts in JavaScript Without Floating-Point Errors
文章示範如何在 JavaScript 中以最小單位的 BigInt 處理加密貨幣金額,避免浮點數運算造成精度損失。做法是驗證十進制字串並轉成整數單位,再以整數運算和格式化函數處理;示例亦涵蓋明確捨入、JSON 傳輸和 Node.js 測試。文中的 0–30 位小數限制只適用於該教學示例,並非區塊鏈通用規則。
Why a small rounding error matters
Try this in your browser console or Node.js:
console.log(0.1 + 0.2); // 0.30000000000000004
This is expected behavior, not a JavaScript bug. The Number type uses IEEE 754 binary floating-point values, so many decimal fractions cannot be represented exactly.
That usually isn't a problem when you're positioning an element on a page. It becomes a problem when a value represents a cryptocurrency balance, transfer amount, fee, or transaction limit.
For financial amounts, the goal is not to hide floating-point artifacts with formatting. It's to avoid introducing them into the calculation.
Let's build a small JavaScript utility that parses decimal amounts into integer base units, performs exact arithmetic, and formats the results without converting back to Number.
The code below is a standalone educational example, not a description of any particular exchange's production implementation. It handles non-negative amounts and intentionally enforces a limited input format.
Store amounts in the smallest unit
Most crypto assets have a defined number of decimal places. For example:
| Asset | Decimal places | Smallest unit example |
|---|---|---|
| Bitcoin (BTC) | 8 | 0.00000001 BTC = 1 satoshi |
| Ether (ETH) | 18 | 0.000000000000000001 ETH = 1 wei |
| ERC-20 tokens | Token-specific | Read the token's decimals metadata |
Instead of storing 0.1 BTC as a floating-point value, store 10,000,000 satoshis as an integer. The decimal separator becomes a concern only when parsing input or displaying output.
The decimal precision is part of the asset definition. Never assume that two tokens—or even versions of an asset on different chains—have the same precision.
Why BigInt is useful
JavaScript's Number can represent integers exactly only up to Number.MAX_SAFE_INTEGER (9007199254740991). BigInt supports integer arithmetic without that safe-integer limit.
console.log(Number('9007199254740993'));
// 9007199254740992 — precision has already been lost
console.log(BigInt('9007199254740993'));
// 9007199254740993n — exact
The distinction matters for base-unit quantities, which can grow large even when the human-readable asset amount looks ordinary.
BigInt doesn't store decimal fractions, and that's an advantage here: we convert the decimal string into an integer before doing any arithmetic.
Build two small utilities
Create a file named amounts.mjs:
function validateDecimals(decimals) {
// Application-level guard for this demonstration.
if (!Number.isInteger(decimals) || decimals < 0 || decimals > 30) {
throw new RangeError('decimals must be an integer from 0 to 30');
}
}
export function parseUnits(value, decimals) {
validateDecimals(decimals);
if (typeof value !== 'string' || !/^(0|[1-9]\d*)(?:\.\d+)?$/.test(value)) {
throw new TypeError('amount must be an unsigned decimal string');
}
const [whole, fraction = ''] = value.split('.');
if (fraction.length > decimals) {
throw new RangeError(`amount exceeds ${decimals} decimal places`);
}
const base = 10n ** BigInt(decimals);
const fractionalUnits = BigInt(fraction.padEnd(decimals, '0') || '0');
return BigInt(whole) * base + fractionalUnits;
}
export function formatUnits(units, decimals) {
validateDecimals(decimals);
if (typeof units !== 'bigint' || units < 0n) {
throw new TypeError('units must be a non-negative bigint');
}
const base = 10n ** BigInt(decimals);
const whole = (units / base).toString();
if (decimals === 0) return whole;
const fraction = (units % base)
.toString()
.padStart(decimals, '0')
.replace(/0+$/, '');
return fraction ? `${whole}.${fraction}` : whole;
}
parseUnits() accepts an amount string, never a JavaScript floating-point value. It rejects negative signs, scientific notation, grouping separators, and inputs with more precision than the asset allows. It doesn't silently round or truncate.
formatUnits() does the reverse using integer division and remainder. It removes unnecessary trailing zeros for display but doesn't change the underlying amount.
The 0–30 decimal-place check is a protective limit for this tutorial, not a blockchain-wide rule. A production system must define limits based on the assets and inputs it actually supports.
Add amounts without floating-point math
With eight decimal places:
import { parseUnits, formatUnits } from './amounts.mjs';
const first = parseUnits('0.1', 8); // 10000000n
const second = parseUnits('0.2', 8); // 20000000n
const total = first + second; // 30000000n
console.log(formatUnits(total, 8)); // '0.3'
The values are exact because we're adding integers. No Number conversion or toFixed() call is needed.
The same functions handle one satoshi:
parseUnits('0.00000001', 8); // 1n
formatUnits(1n, 8); // '0.00000001'
They also preserve a base-unit amount larger than JavaScript's maximum safe integer:
const amount = '90071992.54740993';
const units = parseUnits(amount, 8);
console.log(units); // 9007199254740993n
console.log(formatUnits(units, 8)); // '90071992.54740993'
Rounding is a business rule, not a formatting trick
Exact integer arithmetic doesn't remove the need for rounding. It makes rounding explicit.
For example, imagine an illustrative fee of 25 basis points (0.25%). If the policy requires rounding the fee up to the nearest smallest unit, for a non-negative amount we can use:
const amountUnits = parseUnits('0.12345678', 8);
const feeBps = 25n; // 25 / 10,000 = 0.25%
const feeUnits = (amountUnits * feeBps + 9_999n) / 10_000n;
console.log(formatUnits(feeUnits, 8)); // '0.00030865'
Why add 9_999n? Integer division truncates the fractional part, so that expression implements ceiling division for non-negative integers. If a fee policy requires rounding down, you'd use a different calculation.
Don't reuse a rounding rule blindly. Trading quotes, displayed estimates, payout amounts, and fees can have different requirements. When converting between assets, also specify the quote's price representation and which side of the conversion is rounded. BigInt alone does not solve exchange-rate arithmetic.
Keep JSON boundaries explicit
JSON.stringify() does not serialize a BigInt directly:
JSON.stringify({ amount: 10000000n });
// Throws TypeError
Instead, define an API format that uses a decimal string for the base-unit integer, plus the asset and precision metadata:
const payload = {
asset: 'BTC',
amount_units: total.toString(),
decimals: 8,
};
const json = JSON.stringify(payload);
// {"asset":"BTC","amount_units":"30000000","decimals":8}
When reading this data, validate the asset, network, precision, and amount string before reconstructing a BigInt. Treat the JSON fields as an explicit contract, not something to guess from the displayed number.
Test the edge cases
Create amounts.test.mjs next to amounts.mjs:
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseUnits, formatUnits } from './amounts.mjs';
test('adds 0.1 and 0.2 exactly', () => {
const a = parseUnits('0.1', 8);
const b = parseUnits('0.2', 8);
assert.equal(formatUnits(a + b, 8), '0.3');
});
test('preserves values beyond Number.MAX_SAFE_INTEGER', () => {
const amount = '90071992.54740993';
assert.equal(parseUnits(amount, 8), 9007199254740993n);
assert.equal(formatUnits(parseUnits(amount, 8), 8), amount);
});
test('supports one satoshi and zero-decimal assets', () => {
assert.equal(parseUnits('0.00000001', 8), 1n);
assert.equal(formatUnits(1n, 8), '0.00000001');
assert.equal(parseUnits('12', 0), 12n);
assert.equal(formatUnits(12n, 0), '12');
});
test('formats trailing zeros without changing the amount', () => {
assert.equal(formatUnits(parseUnits('1.2300', 6), 6), '1.23');
});
test('rejects excess precision', () => {
assert.throws(() => parseUnits('0.000000001', 8), RangeError);
});
test('rejects invalid amount formats', () => {
assert.throws(() => parseUnits('-1', 8), TypeError);
assert.throws(() => parseUnits('1e-8', 8), TypeError);
assert.throws(() => parseUnits(0.1, 8), TypeError);
});
Run them with a modern version of Node.js:
node --test amounts.test.mjs
These cases check exact addition, maximum-safe-integer boundaries, the smallest supported unit, zero-decimal assets, display formatting, and invalid input.
For production use, you'd also want limits on input length, asset metadata validation, explicit overflow rules for external systems, property-based tests, and round-trip tests over a much larger set of values.
Common mistakes worth avoiding
-
Converting a user-entered amount to
Numberfirst. Once precision is lost, a laterBigIntconversion cannot recover it. -
Using
toFixed()as the calculation strategy. It formats a floating-point value; it does not turn previous arithmetic into exact decimal arithmetic. - Assuming all assets use 18 decimals. Precision must come from trusted metadata for the specific asset and network.
-
Mixing smallest units from different assets.
1000nsatoshis and1000nwei are not comparable amounts. - Ignoring the rounding policy. An exact integer calculation can still implement the wrong fee or conversion rule.
-
Serializing
BigIntas a JSON number. Preserve base-unit counts as strings when exchanging data with systems that may not safely represent large integers.
Key takeaways
A reliable money-handling boundary in JavaScript can be summarized as:
Decimal input string → validated smallest-unit BigInt → integer arithmetic → explicit rounding → decimal output string.
This pattern is straightforward, testable, and avoids accidental floating-point precision loss. It does not replace asset validation, appropriate arithmetic libraries for complex price ratios, or a careful review of financial requirements. But it gives the rest of the application something valuable: exact amounts to work with.
References
- MDN: Number.MAX_SAFE_INTEGER
- MDN: BigInt
- MDN: JSON.stringify
- Ethereum.org: ERC-20 Token Standard
- Node.js: Test Runner
AI disclosure: The initial article draft and code examples were generated with AI assistance.
來源:DEV Community · dev.to
