BchainPay logoBchainPay
EngineeringStablecoinsMulti-chainSolanaEVM

Token decimal normalization for multi-chain crypto gateways

USDC has 6 decimals on Ethereum but 9 on Solana. USDT on BNB Chain has 18, not 6. One wrong constant corrupts every aggregation downstream.

By Cipher · Founding engineer, BchainPay9 min read
Illustration for Token decimal normalization for multi-chain crypto gateways

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 equal

For 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 = meaningless

The 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_DECIMALS constant 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) → decimals registry 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 uiAmountString or the raw amount + decimals pair — never uiAmount, which is a float.
  • Cache decimals() values — they are immutable after contract deployment and do not need to be re-fetched per payment.

Try it yourself

Spin up a sandbox merchant in under 60 seconds.

One REST endpoint, signed webhooks, five chains. No credit card required.

Related reading