When you first add stablecoin acceptance to a project, the decimal problem looks
trivial. You look up "USDC decimals", get 6, add a / 1e6 somewhere, and ship.
The bug doesn't appear in your single-chain tests. It appears six months later,
at 2 am, when a merchant's Solana balance reports 1,000 times more revenue than
reality and the support queue lights up.
USDC on Solana has 9 decimal places, not 6. USDT on BNB Chain has 18. These are not edge cases — they are the contracts in production right now, and they have burned every team that assumed decimal counts are consistent across chains.
This post is the complete guide to handling token decimals correctly in a multi-chain payment gateway: the data you need, the BigInt patterns that keep arithmetic exact, the registry approach that prevents silent defaults, and how BchainPay exposes all of this through the API so you don't have to manage any of it yourself.
The decimal zoo#
Every ERC-20-compatible contract stores a decimals() function. Nothing in
any standard mandates a specific value. 18 is conventional for Ethereum tokens
that mimic ETH's precision, but stablecoins chose smaller values to reduce the
chance of overflow in older contracts, and cross-chain bridges sometimes diverged
from their source chain's implementation.
| Token | Chain | Decimals | Raw units for 1.00 token |
|---|---|---|---|
| USDC | Ethereum, Base, Polygon, Arbitrum, Optimism | 6 | 1_000_000 |
| USDC | Solana | 9 | 1_000_000_000 |
| USDT | Ethereum | 6 | 1_000_000 |
| USDT | Tron (TRC-20) | 6 | 1_000_000 |
| USDT | BNB Chain (BEP-20) | 18 | 1_000_000_000_000_000_000 |
| DAI | Ethereum, Polygon | 18 | 1_000_000_000_000_000_000 |
| WBTC | Ethereum | 8 | 100_000_000 |
| ETH (native) | Ethereum | 18 | 1_000_000_000_000_000_000 |
| SOL (native) | Solana | 9 | 1_000_000_000 |
| TRX (native) | Tron | 6 | 1_000_000 |
Two rows deserve emphasis. Solana USDC is bridged via Wormhole and issued
by Circle's native mint; the Circle team chose 9 decimals to align with Solana's
native SOL precision. BNB Chain USDT was launched before Tether standardized
at 6; the BEP-20 contract has 18 decimals and cannot be changed. If you hard-code
USDT_DECIMALS = 6 in a shared constant used across chains, every BNB Chain USDT
amount is off by a factor of 10^12.
Why floats are off the table#
JavaScript's Number is a 64-bit IEEE 754 double. It represents integers exactly
up to 2^53 ≈ 9 × 10^15. A single ETH in wei is 10^18 — three orders of magnitude
past that ceiling. The failure mode is silent:
const raw = 1_500_000_000_000_000_001n; // 1.500000000000000001 ETH in wei
const asNumber = Number(raw);
console.log(asNumber); // 1500000000000000000 — loses last digit
console.log(asNumber === Number(raw - 1n)); // true — two distinct values compare equalFor USDC with 6 decimals, amounts up to ~9,007,199,254.740992 USDC are safe
as Number — so in purely USDC-on-Ethereum code the bug may never surface in
practice. But once your platform handles 18-decimal tokens, or you run an
aggregation that accumulates thousands of small payments, the precision loss is
real and undetectable from the output.
The only safe rule: use BigInt for all raw on-chain amounts until the moment
you format for display or send over the API.
Core normalization utilities#
Two functions cover the entire problem space. Put them in a shared module and import them everywhere:
// lib/decimals.ts
/**
* Convert a raw on-chain integer to a human-readable decimal string.
* E.g. toDecimalString(1_000_000n, 6) → "1.000000"
*/
export function toDecimalString(raw: bigint, decimals: number): string {
if (decimals === 0) return raw.toString();
const factor = 10n ** BigInt(decimals);
const whole = raw / factor;
const frac = (raw % factor).toString().padStart(decimals, '0');
return `${whole}.${frac}`;
}
/**
* Parse a human-readable decimal string into a raw on-chain integer.
* Truncates excess fractional digits rather than throwing.
* E.g. fromDecimalString("1.50", 6) → 1_500_000n
* fromDecimalString("1.50", 9) → 1_500_000_000n
*/
export function fromDecimalString(amount: string, decimals: number): bigint {
const [whole, frac = ''] = amount.split('.');
const fracTrimmed = frac.slice(0, decimals).padEnd(decimals, '0');
return BigInt(whole) * 10n ** BigInt(decimals) + BigInt(fracTrimmed);
}Use them symmetrically on the way in and out:
import { fromDecimalString, toDecimalString } from '@/lib/decimals';
// Incoming webhook amount → raw units for DB storage
const raw = fromDecimalString('49.99', getDecimals('solana', USDC_SOLANA)); // 49_990_000_000n
// Raw from DB → display string for merchant dashboard
const display = toDecimalString(raw, 9); // "49.990000000"Building the decimals registry#
Do not scatter decimal constants across the codebase. One registry, keyed by
(chainId, tokenAddress), is the single source of truth:
// lib/decimals-registry.ts
type ChainId = string; // "1", "137", "8453", "solana", "tron", etc.
type Address = string;
const REGISTRY: Record<ChainId, Record<Address, number>> = {
'1': { // Ethereum mainnet
'0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': 6, // USDC
'0xdAC17F958D2ee523a2206206994597C13D831ec7': 6, // USDT
'0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599': 8, // WBTC
'0x6B175474E89094C44Da98b954EedeAC495271d0F': 18, // DAI
},
'56': { // BNB Chain
'0x55d398326f99059fF775485246999027B3197955': 18, // USDT ← 18, not 6
'0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d': 18, // USDC
},
'137': { // Polygon
'0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359': 6, // USDC native
'0xc2132D05D31c914a87C6611C10748AEb04B58e8F': 6, // USDT
},
'8453': { // Base
'0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913': 6, // USDC
},
'solana': {
'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v': 9, // USDC ← 9, not 6
'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB': 6, // USDT
},
'tron': {
'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t': 6, // USDT TRC-20
},
};
/**
* Always throws on missing entries — never returns a default.
* A silent default of 18 would make Solana USDC amounts 1,000x too large.
*/
export function getDecimals(chainId: ChainId, tokenAddress: Address): number {
const entry = REGISTRY[chainId]?.[tokenAddress];
if (entry === undefined) {
throw new Error(
`No decimals registered for token ${tokenAddress} on chain ${chainId}. ` +
`Add it to lib/decimals-registry.ts.`
);
}
return entry;
}Throw on unknown, never default. Every team that has silently defaulted to 18 has had to audit their transaction history afterwards. The exception forces the missing entry to be added before the code can run.
Fetching decimals dynamically#
For tokens you don't know ahead of time, fall back to an on-chain read:
import { createPublicClient, http, erc20Abi } from 'viem'
import { mainnet } from 'viem/chains'
const client = createPublicClient({ chain: mainnet, transport: http(RPC_URL) })
async function resolveDecimals(token: `0x${string}`): Promise<number> {
return client.readContract({
address: token,
abi: erc20Abi,
functionName: 'decimals',
})
}Cache the result in your registry rather than re-fetching per payment. The
decimals() return value of a deployed contract never changes.
Cross-chain aggregation#
The hardest normalization problem is summing balances across chains. A treasury report showing "total USDC received today" must combine 6-decimal Ethereum amounts with 9-decimal Solana amounts. Adding raw values directly produces garbage:
// WRONG: raw values are in different units
const total = ethRawUsdc + solanaRawUsdc; // 6-dec + 9-dec = meaninglessThe correct approach is a shared canonical precision. We use 18 decimal places
internally — it is the LCM of every precision we handle and fits comfortably in
a NUMERIC(38, 0) Postgres column:
// lib/canonical.ts
const CANONICAL = 18;
export function toCanonical(raw: bigint, tokenDecimals: number): bigint {
const diff = CANONICAL - tokenDecimals;
return diff >= 0
? raw * 10n ** BigInt(diff)
: raw / 10n ** BigInt(-diff);
}
export function fromCanonical(canonical: bigint, tokenDecimals: number): bigint {
const diff = CANONICAL - tokenDecimals;
return diff >= 0
? canonical / 10n ** BigInt(diff)
: canonical * 10n ** BigInt(-diff);
}
// Example aggregation
const ethUsdc = 1_000_000n; // 1.00 USDC (6 dec)
const solanaUsdc = 1_000_000_000n; // 1.00 USDC (9 dec)
const total =
toCanonical(ethUsdc, 6) +
toCanonical(solanaUsdc, 9); // 2_000_000_000_000_000_000n
toDecimalString(total, CANONICAL); // "2.000000000000000000"Store canonical amounts in the database. Expose the token-native amount via
fromCanonical(canonical, getDecimals(chainId, token)) when responding to
per-chain balance queries.
Solana RPC precision trap#
Solana's getTokenAccountBalance RPC method returns four amount representations:
{
"value": {
"amount": "49990000000",
"decimals": 9,
"uiAmount": 49.99,
"uiAmountString": "49.99"
}
}uiAmount is a float. At payment amounts above roughly 9,000,000 USDC it
starts losing precision. Use uiAmountString (a string) paired with the
returned decimals field, or use amount (raw) directly:
const { amount, decimals } = result.value;
const raw = BigInt(amount); // "49990000000" → 49_990_000_000n
const display = toDecimalString(raw, decimals); // "49.990000000"Never read uiAmount. It is a convenience field for quick UI display — not for
financial arithmetic.
The BchainPay API contract#
BchainPay's REST API never exposes raw on-chain integers in authoritative fields.
Every amount is a decimal string. The raw_amount and token_decimals fields
exist for verification only:
POST /v1/payment-intents
Authorization: Bearer sk_live_…
Content-Type: application/json
{
"chain_id": "solana",
"token": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
"amount": "49.99",
"currency": "USDC"
}{
"id": "pi_01HXV…",
"amount": "49.99",
"currency": "USDC",
"chain_id": "solana",
"raw_amount": "49990000000",
"token_decimals": 9,
"deposit_address": "7f1d2cEa…",
"expires_at": "2026-04-27T13:00:00Z"
}When payment lands, the webhook carries the same dual representation:
{
"event": "payment_intent.succeeded",
"data": {
"id": "pi_01HXV…",
"amount": "49.99",
"amount_received": "49.99",
"raw_amount_received": "49990000000",
"token_decimals": 9,
"chain_id": "solana",
"overpaid": false,
"underpaid": false
}
}amount_received may differ from amount if the payer sent a slightly different
sum (a common UX issue with wallets that round display amounts). Over- and
under-payment thresholds are configurable per integration. Both amounts are
always decimal strings. If you need to do arithmetic on them, parse with
fromDecimalString(amount, token_decimals) rather than parseFloat.
Key takeaways#
- USDC on Solana is 9 decimals, not 6. This is the single most common multi-chain accounting error we see in integration reviews.
- USDT on BNB Chain is 18 decimals, not 6. Any shared
USDT_DECIMALSconstant that defaults to 6 is wrong on BNB Chain. - Use BigInt throughout your pipeline for raw amounts. Convert to a decimal string only at API boundaries and display layers.
- Build a
(chainId, tokenAddress) → decimalsregistry and throw on missing entries — a silent default of 18 corrupts Solana USDC amounts by 1,000x. - For cross-chain aggregation, normalize to a canonical precision (18 is conventional) before adding or comparing amounts from different chains.
- When reading Solana balances via RPC, use
uiAmountStringor the rawamount + decimalspair — neveruiAmount, which is a float. - Cache
decimals()values — they are immutable after contract deployment and do not need to be re-fetched per payment.