Back to blogToken decimals: USDT is 6 on one chain, 18 on another

Token decimals: USDT is 6 on one chain, 18 on another

Fintech·October 13, 2026·11 min read·By CodeDecoders Engineering

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:

ChainContractsymbol()decimals()
Ethereum0xdAC17F95...831ec7USDT6
TronTR7NHqje...gjLj6tUSDT6
Arbitrum One0xFd086bC7...9FCbb9USD₮06
Polygon PoS0xc2132D05...B58e8FUSDT06
BNB Smart Chain0x55d39832...197955USDT18
Ethereum (USDC)0xA0b86991...06eB48USDC6
BNB Smart Chain (USDC)0x8AC76a51...2Cd580dUSDC18

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:

AccountAssetDebitCredit
wallet:bsc:hoteip155:56/erc20:0x55d3...12345678901234567890
conversion:bsc-usdteip155:56/erc20:0x55d3...12345678000000000000
rounding:bsc-usdteip155:56/erc20:0x55d3...901234567890
conversion:bsc-usdtledger:usdt12345678
customer:42ledger:usdt12345678

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 wiresharedDecimals, 6 by defaultmin(TRIMMED_DECIMALS, source, destination)Rounded to the destination's configured decimalsRejects mismatched pairs by default
What happens to the remainderFloored off and refunded to the sender before the debitTrimmed, and the contracts never destroy itBurned (burn/mint pools) or locked in the source pool (lock/release)Only arises if you opt in with allowUnmatchedDecimals=true
Lesson for a ledgerFix the shared precision up frontUse the lowest precision any leg supportsSilent loss is a policy, so choose it on purposeRefusing 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 5

1. 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.

Newsletter

New posts, in your inbox

Get an email when we publish a new deep-dive. No spam, unsubscribe anytime.

Start a Project

Let's build something extraordinary together.

Free consultation·Response within 24h·No commitment

info@codedecoders.io