Decimals & Amounts
SPL tokens store raw integer amounts on-chain. Decimals on the mint define how to display human-readable values. Off-by-one decimal errors are among the most expensive bugs in token integrations.
Recipe
const UI = 1.5;
const raw = BigInt(Math.round(UI * 10 ** decimals));
const display = Number(raw) / 10 ** decimals;pub fn to_raw(ui: f64, decimals: u8) -> Result<u64> {
let factor = 10u64.pow(decimals as u32);
let raw = (ui * factor as f64).round() as u64;
Ok(raw)
}When to reach for this:
- Formatting wallet balances in UI
- Parsing user input before building transfer instructions
- Validating
transfer_checkeddecimals on-chain - Comparing prices across tokens with different decimals
Working Example
import { createSolanaRpc, address } from "@solana/kit";
const rpc = createSolanaRpc("https://api.devnet.solana.com");
const mint = address("MINT_PUBKEY");
const { value } = await rpc.getAccountInfo(mint, { encoding: "jsonParsed" }).send();
const decimals = value?.data.parsed.info.decimals as number;
function toRawAmount(uiAmount: string, decimals: number): bigint {
const [whole, frac = ""] = uiAmount.split(".");
const padded = (frac + "0".repeat(decimals)).slice(0, decimals);
return BigInt(whole + padded);
}
function toUiAmount(raw: bigint, decimals: number): string {
const s = raw.toString().padStart(decimals + 1, "0");
const whole = s.slice(0, -decimals) || "0";
const frac = s.slice(-decimals).replace(/0+$/, "");
return frac ? `${whole}.${frac}` : whole;
}
const raw = toRawAmount("2.5", decimals);
console.log("raw:", raw.toString());
console.log("ui:", toUiAmount(raw, decimals));use anchor_lang::prelude::*;
use anchor_spl::token::{self, Token, TokenAccount, TransferChecked, Mint};
#[derive(Accounts)]
pub struct SafeTransfer<'info> {
pub mint: Account<'info, Mint>,
#[account(mut)]
pub from: Account<'info, TokenAccount>,
#[account(mut)]
pub to: Account<'info, TokenAccount>,
pub authority: Signer<'info>,
pub token_program: Program<'info, Token>,
}
pub fn safe_transfer(ctx: Context<SafeTransfer>, amount: u64) -> Result<()> {
let decimals = ctx.accounts.mint.decimals;
let cpi = CpiContext::new(
ctx.accounts.token_program.to_account_info(),
TransferChecked {
mint: ctx.accounts.mint.to_account_info(),
from: ctx.accounts.from.to_account_info(),
to: ctx.accounts.to.to_account_info(),
authority: ctx.accounts.authority.to_account_info(),
},
);
token::transfer_checked(cpi, amount, decimals)?;
Ok(())
}What this demonstrates:
- RPC
jsonParsedexposestokenAmount.amount,decimals, anduiAmount - String-based conversion avoids JavaScript float rounding on large balances
transfer_checkedbinds amount to expected decimals in programs
Deep Dive
How It Works
- Mint
decimals: u8is immutable after creation (0-9 typical; up to 255 allowed) - Token account
amount: u64is always raw base units - UI amount = raw / 10^decimals (display only, not stored)
transfer_checkedfails if instruction decimals != mint decimals
Common Decimal Choices
| Asset style | Decimals | 1.0 token raw |
|---|---|---|
| USDC-like | 6 | 1_000_000 |
| SOL-wrapped | 9 | 1_000_000_000 |
| Whole-number NFT | 0 | 1 |
Rust Notes
use anchor_lang::solana_program::program_error::ProgramError;
pub fn mul_scaled(a: u64, b: u64) -> Result<u64, ProgramError> {
a.checked_mul(b).ok_or(ProgramError::InvalidArgument)
}Gotchas
- JavaScript float math -
1.1 * 1e6rounding errors. Fix: string/BigInt conversion liketoRawAmount. - Assuming 9 decimals - USDC uses 6. Fix: always read decimals from mint account.
- Skipping transfer_checked in programs - decimal confusion attacks across mismatched assets. Fix: use
transfer_checkedwith mint decimals. - Displaying raw as UI - off by 10^6 in UI. Fix: format with mint decimals everywhere.
- u64 overflow in off-chain math - huge supplies exceed JS safe integer. Fix: use BigInt end-to-end.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
uiAmount from RPC | Quick display | Building instructions (use amount string) |
| Fixed-decimal constants | Single-asset app | Multi-token portfolio |
Decimal libraries (decimal.js) | Financial precision | Simple wallet with BigInt sufficient |
FAQs
Where are decimals stored?
On the mint account only. Token accounts store raw amount.
Can decimals change after mint creation?
No. Immutable at mint initialization.
What is raw amount?
Integer stored on-chain - the only value instructions use.
Why does USDC show 6 decimals?
Industry convention matching issuer fiat precision.
What is transfer_checked?
Transfer that includes expected decimals - prevents certain cross-asset mistakes in programs.
Can decimals be 0?
Yes. Common for NFTs (supply 1, amount 1).
How do CLI commands handle decimals?
spl-token accepts raw integers. You multiply UI values manually.
Do Token-2022 decimals differ?
Same mint-level decimals field and raw amount semantics.
What is max u64 supply?
~1.8e19 raw units - combine with decimals for realistic UI cap planning.
Should UI round or floor?
Product decision - always document. Payments often floor; displays may round.
How do oracles pass amounts?
Often raw or fixed-point with explicit decimals in account layout - read protocol docs.
BigInt in @solana/kit?
Use BigInt for raw amounts in kit 7.0.0 transaction building when values exceed Number.MAX_SAFE_INTEGER.
Related
- Minting & Burning - raw mint amounts
- Transfers & Delegates - transfer_checked
- Wrapped SOL - 9 decimals
Stack versions: This page was written for Agave 4.1.1, Solana CLI 3.0.10, Anchor 0.32.1, anchor-lang 0.32.1, Rust 1.91.1, @solana/kit 7.0.0, Surfpool 0.12.0, and LiteSVM 0.6.x.