
Token decimals: USDT is 6 on one chain, 18 on another
Call symbol() on the USDT contract on Ethereum and you get USDT. Call it on the USDT contract on BNB Smart Chain and you also get USDT. Call decimals() and the answers split: 6 on Ethereum, 18 on BNB Smart Chain. The integer 1000000 is one dollar on the first chain and a trillionth of a dollar on the second. A ledger that keys money by ticker will add those two integers together, put the right label on the total, and be wrong by a factor of 10^12.
The fix is three decisions, made before the first deposit lands. Identify an asset by chain plus contract address, with its token decimals pinned as an attribute of that identity. Store amounts as integer base units tagged with that identity, never as a ticker and a number. Convert to one canonical precision at the ledger boundary, and post the remainder (the dust) to a named account instead of letting it vanish. Below is the data model, the conversion code, the journal entries, and the checks that keep it honest.
Token decimals are metadata, not money
On an EVM chain a token balance is a uint256 with no decimal point. The decimals() function in EIP-20 only tells a wallet where to draw one: a value of 8 "means to divide the token amount by 100000000" to get the user representation. The standard marks name, symbol and decimals as OPTIONAL and says other contracts "MUST NOT expect these values to be present." Nothing in the protocol ties a symbol to a precision, or a symbol to an issuer.
We read the live contracts over public RPC on 12 October 2026 to see how far apart the "same" dollar sits:
| Chain | Contract | symbol() | decimals() |
|---|---|---|---|
| Ethereum | 0xdAC17F95...831ec7 | USDT | 6 |
| Tron | TR7NHqje...gjLj6t | USDT | 6 |
| Arbitrum One | 0xFd086bC7...9FCbb9 | USD₮0 | 6 |
| Polygon PoS | 0xc2132D05...B58e8F | USDT0 | 6 |
| BNB Smart Chain | 0x55d39832...197955 | USDT | 18 |
| Ethereum (USDC) | 0xA0b86991...06eB48 | USDC | 6 |
| BNB Smart Chain (USDC) | 0x8AC76a51...2Cd580d | USDC | 18 |
Two things fall out of that table. The 6 versus 18 split is real and hits both major stablecoins, which is why the Across bridge API now returns a 4XX for mismatched-decimal pairs unless the caller passes allowUnmatchedDecimals=true. And the symbol string is not stable even on one deployment. The existing Arbitrum USDT implementation was upgraded behind its proxy to USDT0 in January 2025, and that address now reports USD₮0. Code that matched on the string "USDT" broke without a single decimal changing.
A recent post on why a ticker is not an asset argues for identifying money by the pair of symbol and decimals, so USDT/6 and USDT/18 can never be added. That is the right instinct, and it catches the 10^12 error. It does not go far enough for a ledger that custodies funds. Ethereum USDT and Tron USDT share both symbol and decimals and are still not interchangeable: you cannot pay a Tron withdrawal out of an Ethereum hot wallet.
Step 1: identify assets by chain and contract
The identity is the chain plus the contract address. Decimals hang off that identity as an attribute you verify once. CAIP-19 gives you a ready string format, chain_id/asset_namespace:asset_reference, so BNB Smart Chain USDT becomes eip155:56/erc20:0x55d398326f99059ff775485246999027b3197955. Lowercase the address when you mint the id. The spec does not canonicalise addresses, so two casings of one contract would otherwise become two assets.
CREATE TABLE asset (
id text PRIMARY KEY,
chain_id text NOT NULL,
contract text NOT NULL,
decimals smallint NOT NULL CHECK (decimals BETWEEN 0 AND 36),
unit text NOT NULL,
issuer text NOT NULL,
UNIQUE (chain_id, contract)
);
CREATE TABLE posting (
entry_id uuid NOT NULL,
account text NOT NULL,
asset_id text NOT NULL REFERENCES asset (id),
direction text NOT NULL CHECK (direction IN ('debit', 'credit')),
amount numeric(78, 0) NOT NULL CHECK (amount >= 0)
);
unit is the economic unit a deployment represents (USDT), which is what you report on. issuer records who stands behind it, and it matters here: the BNB Smart Chain contract is the one BscScan labels Binance-Peg BSC-USD, even though its name() returns Tether USD. Same unit, different redemption path. numeric(78, 0) is there because the largest uint256 has 78 digits. The canonical ledger unit gets its own row too (say ledger:usdt, 6 decimals), so every posting carries an asset id and the zero-sum rule from double-entry ledger foundations is checked per asset, never across them.
Step 2: pick a canonical precision and normalise at the boundary
Hold custody balances (hot wallets, omnibus addresses) in each deployment's native base units, because that is what balanceOf will report when you reconcile. Hold customer balances in one canonical unit per economic asset, because a customer who deposits on BNB Smart Chain and withdraws on Tron should never see a conversion.
OWASP's smart contract weakness catalogue lists the bridge version of this bug as SCWE-132: bridging tokens with differing decimals without normalisation "can over- or under-mint wrapped assets." Its remediation reads like a ledger spec: normalise to a canonical precision, store a per-token decimal configuration, and test round trips.
Which precision? The lowest one any supported deployment uses. Cross-chain token standards make the same call. LayerZero's OFT carries amounts in sharedDecimals, 6 by default (OFT reference), and Wormhole's NTT encodes at "the minimum of (TRIMMED_DECIMALS, source token decimals, destination token decimals)" (NTT README). With canonical precision at the minimum, outbound conversion is always an exact multiplication, and dust can only appear on inbound from a higher-precision chain, where you can see it and book it. Pick 18 instead and every withdrawal to Ethereum has to round a customer balance on the way out, which is the worst place to discover a remainder.
export type Asset = { id: string; decimals: number; unit: string };
const CANONICAL_DECIMALS: Record<string, number> = { USDT: 6, USDC: 6 };
function shiftFor(asset: Asset): bigint {
const canonical = CANONICAL_DECIMALS[asset.unit];
if (canonical === undefined) throw new Error(`no canonical precision for ${asset.unit}`);
const shift = asset.decimals - canonical;
if (shift < 0) throw new Error(`${asset.id} is below canonical precision for ${asset.unit}`);
return 10n ** BigInt(shift);
}
// Inbound: native base units to canonical units, returning the dust in native units.
export function toCanonical(native: bigint, asset: Asset): { canonical: bigint; dust: bigint } {
if (native < 0n) throw new Error('toCanonical: amounts are unsigned, the posting carries direction');
const scale = shiftFor(asset);
return { canonical: native / scale, dust: native % scale };
}
// Outbound: always exact, because canonical precision is the minimum.
export function toNative(canonical: bigint, asset: Asset): bigint {
return canonical * shiftFor(asset);
}
Every amount is a bigint. Number.MAX_SAFE_INTEGER is 9,007,199,254,740,991, which at 18 decimals is about 0.009 tokens, so a JavaScript number silently loses integer precision on any 18-decimal stablecoin amount above roughly one cent.
Step 3: book the dust
A customer deposits 12.345678901234567890 USDT on BNB Smart Chain, which arrives as 12345678901234567890 base units. toCanonical returns 12345678 canonical units (12.345678 USDT) and 901234567890 native units of dust. The journal entry posts both legs, and each asset balances on its own:
| Account | Asset | Debit | Credit |
|---|---|---|---|
wallet:bsc:hot | eip155:56/erc20:0x55d3... | 12345678901234567890 | |
conversion:bsc-usdt | eip155:56/erc20:0x55d3... | 12345678000000000000 | |
rounding:bsc-usdt | eip155:56/erc20:0x55d3... | 901234567890 | |
conversion:bsc-usdt | ledger:usdt | 12345678 | |
customer:42 | ledger:usdt | 12345678 |
The conversion account holds native units on one side and canonical units on the other, and the two must always differ by exactly 10^12. That gives you a free invariant to check nightly. The rounding account makes the dust a visible, reconcilable balance instead of an unexplained surplus in the hot wallet.
Who owns the dust is a policy, and you should write it down. A deposit has already arrived, so you cannot refuse part of it the way LayerZero's OFT refunds dust to the sender before debiting. You can keep it in the rounding account (and say so in your terms), or accrue it per customer and credit a whole canonical unit when the pile reaches one. Both are defensible. The indefensible option is neither, which is what you get by default. The leftover-unit reasoning is the same one behind allocating money across parties without losing a cent.
The bridges each made this call explicitly, and their choices are a useful spread:
| Precision on the wire | sharedDecimals, 6 by default | min(TRIMMED_DECIMALS, source, destination) | Rounded to the destination's configured decimals | Rejects mismatched pairs by default |
| What happens to the remainder | Floored off and refunded to the sender before the debit | Trimmed, and the contracts never destroy it | Burned (burn/mint pools) or locked in the source pool (lock/release) | Only arises if you opt in with allowUnmatchedDecimals=true |
| Lesson for a ledger | Fix the shared precision up front | Use the lowest precision any leg supports | Silent loss is a policy, so choose it on purpose | Refusing a mismatch is a valid default |
Chainlink's CCIP token pool docs are blunt about the third column: lost precision "results in permanently burned tokens on the source chain" for burn/mint pools, and their advice is to deploy with the same decimals on every chain whenever possible. You do not control the issuer's deployments, so your ledger has to absorb the difference instead.
Onboarding a new deployment
Adding a chain is where most of these bugs enter, usually as one config line. Make it a checklist:
Adding a stablecoin deployment
Step 1 of 51. Take the address from the issuer
Use the issuer's published contract list, not a token-list search by symbol. Mint the CAIP-19 id from chain id plus lowercased address.
Pitfalls that survive code review
Reading decimals() at request time. An RPC timeout plus a "sensible default" of 18 turns a 6-decimal deposit into dust, or an 18-decimal one into a 10^12 credit. Pin the value at onboarding and treat a live read as a check, never as the source.
Copying base-unit values between chains. Chainlink's guidance on token units and decimals says "Do not copy a base-unit value from one chain to another unless decimals match," and the same applies to your own deposit minimums, velocity limits and fee floors. A 10 USDT minimum is 10000000 on Ethereum and 10000000000000000000 on BNB Smart Chain.
Aggregating by symbol. Reporting wants "total USDT," and that is fine in canonical units. Summing raw posting.amount grouped by unit recreates the original bug in SQL, where no type checker can see it.
Computing output as input minus fee. When the two legs have different decimals, that subtraction mixes scales. Across added a calculated outputAmount to /suggested-fees in the same migration that introduced its decimals check. Use the quoted output, or convert both sides to one precision first.
Verify it
Property tests over random amounts and every registered asset catch conversion bugs before they reach a wallet:
for (const asset of registry.all()) {
for (const native of randomAmounts(10_000)) {
const { canonical, dust } = toCanonical(native, asset);
expect(toNative(canonical, asset) + dust).toBe(native);
expect(toCanonical(toNative(canonical, asset), asset)).toEqual({ canonical, dust: 0n });
}
}
Then reconcile per asset id, not per symbol. For each deployment, on-chain balanceOf for your wallets must equal the ledger's native balance for that asset id, rounding account included. Each conversion account's native side must equal its canonical side times the scale. Run both daily, and page on any difference: the payment reconciliation posture of catching a mismatch the day it happens applies doubly when the mismatch can be twelve orders of magnitude.
The short version
A ticker is a label. The asset is the chain plus the contract, with decimals pinned and verified. Store integer base units tagged with that identity, keep customer balances in one canonical precision set to the lowest any deployment uses, and post the conversion dust to an account you can name. Then reconcile per deployment.
If you are adding chains to a stablecoin deposit or payout stack, this is the layer our stablecoin payment infrastructure work starts with, and we are glad to walk through your asset model.
New posts, in your inbox
Get an email when we publish a new deep-dive. No spam, unsubscribe anytime.