Research · Deep dive · Decimals

Stablecoin decimals and rounding, without losing a base unit

USDC has 6 decimals, except where it has 18. A guide for backend engineers to token scales by chain, integer amounts end to end, fiat and FX conversion, and rounding rules you can defend.

Decimal bugs look like the most boring item on any review list. They are also the ones that move money by factors of a trillion. A token amount is an integer with a scale attached, and every place your code forgets the scale, converts it to a float, or rounds without saying which way is a place where the ledger and the chain stop agreeing.

This guide goes deeper than No. 05 of the failure modes guide: the actual decimals on the chains you are likely to support, how to keep amounts as integers from the RPC response to the database and back, how to convert between fiat cents and token units, and how to write rounding rules you can defend.

No. 01The same symbol does not mean the same scale

I queried decimals() on each of these contracts (and the mint account on Solana) while writing this. Treat the table as a starting point for your own config, not as the config:

  • USDC: 6 on Ethereum, Base, Arbitrum, Polygon PoS and Solana. 18 on BNB Chain, where the Binance-Peg contract also calls itself “USD Coin” with symbol “USDC”.
  • USDT: 6 on Ethereum, Arbitrum, Polygon PoS, Tron and Solana. 18 on BNB Chain, where the Binance-Peg contract is named “Tether USD” with symbol “USDT”.
  • PYUSD: 6 on Ethereum and Solana. On Solana it is a Token-2022 mint, not a classic SPL Token mint, which matters for the code in No. 07.
  • EURC: 6 on Ethereum.
  • DAI: 18 on Ethereum.

The BNB Chain rows are the trap. A codebase that started on Ethereum and Base learns “USDC has 6 decimals” and encodes it as a constant. Then someone adds BNB Chain, the symbol and name match, and every amount is off by a factor of 1012. Credit a BNB Chain deposit of 1 USDC with a 6-decimal constant and the user’s balance shows one trillion dollars.

The rule that prevents this is the same one that stops fake token deposits: decimals belong to a (chain, contract) pair, never to a symbol.

// One reviewed source of truth. No decimals constant anywhere else in the codebase.
const TOKENS = {
  "eip155:1/USDC":    { address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", decimals: 6 },
  "eip155:8453/USDC": { address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", decimals: 6 },
  "eip155:56/USDC":   { address: "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", decimals: 18 },
  "eip155:56/USDT":   { address: "0x55d398326f99059fF775485246999027B3197955", decimals: 18 },
};

No. 02Keep amounts as integers end to end

The base unit is the only representation that matches the chain: 1 USDC on Ethereum is 1000000, and the smallest possible transfer is 1. Hold amounts in that form everywhere money is computed, and convert to a human decimal only for display and at API boundaries that demand it.

In JavaScript, that means bigint, not number. A number is a 64-bit float. It represents integers exactly only up to Number.MAX_SAFE_INTEGER, which is 9,007,199,254,740,991. At 6 decimals that is about 9 billion USDC, which feels safe until you hit an 18-decimal token, where it is about 0.009 of a token. Above that limit, numbers silently round:

JSON.parse("9007199254740993")   // 9007199254740992: off by one, no error
0.1 + 0.2                          // 0.30000000000000004
JSON.stringify({ amount: 1n })     // TypeError: Do not know how to serialize a BigInt

That last error is useful. It pushes teams towards the right serialisation, which is a string of base units: {"amount": "12500000", "decimals": 6}. Strings survive every JSON parser, every language and every message queue unchanged. Parse them back with BigInt(s) after checking they are digits only, because BigInt("") is 0n and an empty string should never mean zero dollars.

The most common leak in a review is not the core ledger; it is the edges. A logging library, an analytics event, an admin dashboard or a webhook to a partner that takes the amount as a number “just for display”, and later becomes the input to something else.

No. 03Store them without a float in the path

  • Postgres: use NUMERIC, or NUMERIC(78,0) for base units, which fits any uint256. Never real or double precision. A bigint column (signed 64-bit) is fine for 6-decimal tokens at realistic sizes but overflows for 18-decimal amounts above about 9.2 tokens.
  • node-postgres returns NUMERIC as a string by default. Keep it that way. Overriding the type parser with parseFloat to “make the frontend easier” reintroduces the float.
  • Python: use int for base units and decimal.Decimal for human amounts, built from strings. Decimal(0.1) captures the float’s binary error; Decimal("0.1") does not. json.loads turns 0.1 into a float unless you pass parse_float=Decimal.

Put a CHECK (amount >= 0) on columns that should never be negative, and a check that the stored decimals match the token, if you store both. The database is the last place a bad conversion can still be stopped before it becomes a balance.

No. 04Know what each provider means by “amount”

Providers disagree about how to express money, and each integration is a conversion point:

  • Circle’s Mint APIs express amounts as decimal strings in whole units: {"amount": "3.14", "currency": "USD"}. That is 3,140,000 USDC base units, and 314 cents.
  • Stripe expresses amounts as integers in the currency’s smallest unit: 1000 is 10.00 USD. For zero-decimal currencies such as JPY, the amount is the whole yen value, so 500 is 500 JPY, not 5.
  • RPC nodes return raw base units, usually as hex. They know nothing about decimals.

Write one parser per format, and make it strict. A decimal string with more fractional digits than the token supports should be rejected, not rounded, because silently dropping digits is how a partner’s 10.0000004 becomes your 10.000000:

// "3.14" with 6 decimals → 3140000n. Rejects signs, exponents, spaces and excess precision.
function toBaseUnits(s, decimals) {
  const m = /^(\d+)(?:\.(\d+))?$/.exec(s);
  if (!m) throw new Error(`malformed amount: ${JSON.stringify(s)}`);
  const frac = m[2] ?? "";
  if (frac.length > decimals) throw new Error(`more than ${decimals} decimals: ${s}`);
  return BigInt(m[1] + frac.padEnd(decimals, "0"));
}

function fromBaseUnits(n, decimals) {
  const s = n.toString().padStart(decimals + 1, "0");
  const whole = s.slice(0, -decimals) || "0";
  const frac = s.slice(-decimals).replace(/0+$/, "");
  return frac ? `${whole}.${frac}` : whole;
}

Libraries such as ethers’ parseUnits and formatUnits do the same job. What matters is that exactly one function per format exists, it is tested, and nobody writes Number(amount) * 1e6 inline.

No. 05Convert between cents and token units deliberately

Going from fiat cents to a 6-decimal token is exact: multiply by 104. Going back is not: 1,234,567 base units is 123.4567 cents, and something has to decide what happens to the 0.4567. If you don’t decide, bigint division decides for you by truncating towards zero, which is a policy, just not one anyone wrote down.

Make the direction an argument, so every call site has to choose:

// Non-negative integer division with an explicit rounding direction.
function div(n, d, mode) {
  if (n < 0n || d <= 0n) throw new Error("div: non-negative only");
  const q = n / d, r = n % d;
  if (r === 0n) return q;
  if (mode === "down") return q;
  if (mode === "up") return q + 1n;
  if (mode === "half_even") return 2n * r > d || (2n * r === d && q % 2n === 1n) ? q + 1n : q;
  throw new Error(`unknown rounding mode ${mode}`);
}

const usdcToCents = (units, mode) => div(units, 10_000n, mode);
const centsToUsdc = (cents) => cents * 10_000n;

FX conversions need the same care. Represent the rate as an integer ratio from the quote (for example 10842/10000 for a EUR to USD rate of 1.0842), multiply first and divide once at the end. Dividing early, or applying a rate stored as a float, loses precision at every step.

// EUR cents → USDC base units at a quoted rate. Paying out: round down.
function eurCentsToUsdcPayout(eurCents, rateNum, rateDen) {
  return div(eurCents * 10_000n * rateNum, rateDen, "down");
}

The policy I recommend, written in the code and in the terms:

  • Money you collect (amount due, fees): round up to the next base unit.
  • Money you pay out (withdrawals, payouts, conversions to the user’s currency): round down.
  • Refunds: round in the user’s favour, and never refund more than was received.
  • Splits (fee shares, multi-party payouts): allocate so the parts sum exactly to the whole. Round each part down, then give the leftover base units to a named party, rather than rounding each part independently and letting the total drift.

Each of these is a fraction of a cent per transaction. Across millions of transactions, or one attacker who notices that a round-trip conversion gains a base unit and automates it, it is a reconciliation break that nobody can explain.

No. 06Decide what to do with dust and near-misses

Integer amounts make two old questions explicit:

  • Underpayment. An invoice for 10.00 USDC receives 9.999999. Is it paid? Wallets and exchanges that deduct fees, or round on display, produce this routinely. Define a tolerance in base units per token (zero is a valid choice), apply it in one place, and record the shortfall rather than forgiving it silently. Overpayment needs the same rule: credit it, refund it, or hold it, but don’t lose it.
  • Dust. One base unit of USDC is a millionth of a dollar. Tiny transfers arrive constantly, many as part of address poisoning. Set a minimum credit amount per token, book anything below it to a dust account so reconciliation still balances, and hide it from user-facing history.

Minimums apply on the way out too. A payout whose network fee exceeds its value is a loss, and a withdrawal minimum that is checked in display units (“at least 1.00”) but compared against base units is the kind of mismatch that lets 1 base unit through.

No. 07Assert decimals against the chain at startup

Config can be wrong. The chain is the authority. On boot, and before enabling a new token, compare every configured value with what the contract or mint reports, and refuse to start on a mismatch:

// ethers v6: uint8 comes back as a bigint.
const ERC20 = ["function decimals() view returns (uint8)"];

async function assertEvmToken(provider, expectedChainId, cfg) {
  const { chainId } = await provider.getNetwork();
  if (chainId !== BigInt(expectedChainId)) throw new Error(`RPC is on chain ${chainId}`);
  const onChain = await new ethers.Contract(cfg.address, ERC20, provider).decimals();
  if (Number(onChain) !== cfg.decimals) {
    throw new Error(`${cfg.address}: config says ${cfg.decimals}, chain says ${onChain}`);
  }
}

On Solana, read the mint account’s decimals field. With @solana/spl-token, getMint(connection, mint, "finalized", programId) defaults to the classic Token program, so pass TOKEN_2022_PROGRAM_ID for Token-2022 mints such as PYUSD. When you build transfers, prefer transferChecked, which takes the decimals and fails on-chain if they don’t match the mint: a cheap second line of defence.

No. 08Test the arithmetic with properties, not examples

Example tests check the amounts someone thought of. Property-based tests generate thousands of amounts, including the edges nobody thinks of: zero, one base unit, values around 253, and 78-digit uint256 maximums. With fast-check in JavaScript (Hypothesis plays the same role in Python):

import fc from "fast-check";

const units = fc.bigInt({ min: 0n, max: 2n ** 256n - 1n });

// The function under test: equal parts rounded down, leftover base units to the first party.
function split(total, parts) {
  const each = total / BigInt(parts);
  const out = Array(parts).fill(each);
  out[0] += total - each * BigInt(parts);
  return out;
}

// Formatting and parsing round-trip exactly, for any amount and any supported scale.
fc.assert(fc.property(units, fc.constantFrom(6, 18), (n, d) =>
  toBaseUnits(fromBaseUnits(n, d), d) === n));

// A payout split never creates or destroys a base unit.
fc.assert(fc.property(units, fc.integer({ min: 1, max: 50 }), (total, parts) =>
  split(total, parts).reduce((a, b) => a + b, 0n) === total));

// Converting to cents and back never gives the user more than they had.
fc.assert(fc.property(units, (n) => centsToUsdc(usdcToCents(n, "down")) <= n));

Run the same properties against every conversion function in the money path. They are short, they run in seconds, and they find the off-by-one in the rounding helper before an attacker does.

Test planTen tests for your amount handling

  • Every token’s decimals come from one config keyed by chain and contract, and a search of the codebase finds no other decimals constants.
  • The service refuses to start if any configured decimals value differs from the chain.
  • A 1 USDC deposit on BNB Chain is credited as 1 USDC, not 1012.
  • An amount of 253 + 1 base units survives the round trip through your API, queue, database and back unchanged.
  • No column in the money path is real or double precision, and no code path calls parseFloat or Number() on an amount.
  • A provider amount with more decimals than the token supports is rejected, not rounded.
  • Fees round up, payouts round down, and a test asserts each direction explicitly.
  • A split of any total into any number of parts sums exactly to the total.
  • An underpayment by one base unit follows the documented tolerance, and the shortfall is recorded.
  • Deposits below the dust threshold are booked to a dust account and still reconcile.

Want a second pair of eyes?

I run this review on your code, before launch.

Fixed scope, fixed fee, a written report with fixes, and a re-test once you’ve shipped them. Most reviews take five to ten working days.