Skip to content
IC Reactor

Utilities

@ic-reactor/core exports plain functions for the values a canister call hands you. They import nothing from React, so @ic-reactor/react re-exports them from both of its entries: a client component, a server component, a route loader and a Node script import them the same way.

import { formatTokenAmount, parseTokenAmount } from "@ic-reactor/react"
// or, without React
import { formatTokenAmount, parseTokenAmount } from "@ic-reactor/core"

A ledger counts in base units — e8s for ICP, satoshis for ckBTC, wei for ckETH — as a nat. A Reactor returns it as a bigint and a DisplayReactor as integer text such as "150000000". Turning that into what a person reads, and what a person types back into base units, is where apps lose money:

// ❌ Number math is binary floating point
Math.floor(Number("0.29") * Math.pow(10, 8)) // 28999999: sends 0.28999999
BigInt(Math.floor(Number("1.1") * 10 ** 18)) // 1100000000000000128n
Number(balance) / 1e8 // wrong past 2^53 base units

formatTokenAmount and parseTokenAmount work on the decimal digits and never go through a Number, so they are exact for every amount a ledger can hold, at any number of decimals.

Shows an amount of base units as decimal text.

function formatTokenAmount(
value: bigint | string | number,
decimals: number | bigint | string,
options?: FormatTokenAmountOptions
): string
  • value — the amount in base units: a bigint (Reactor), integer text (DisplayReactor) or a safe integer. Decimal text such as "1.5" is refused with a TypeError; that is what parseTokenAmount reads.
  • decimals — the token’s decimals, from 0 to 255, in whatever form icrc1_decimals or icrc1_metadata returned them.
formatTokenAmount(150_000_000n, 8) // "1.5"
formatTokenAmount("100000000", 8) // "1"
formatTokenAmount(1n, 8) // "0.00000001"
formatTokenAmount(-150_000_000n, 8) // "-1.5"
formatTokenAmount(10n ** 30n + 1n, 18) // "1000000000000.000000000000000001"
Option Type Default Effect
maxFractionDigits number decimals The most fraction digits to show. Digits past it are dropped as roundingMode says.
minFractionDigits number 0 Pad the fraction with zeros to at least this many digits.
trimTrailingZeros boolean true Drop zeros at the end of the fraction, down to minFractionDigits. false pads to maxFractionDigits.
roundingMode "trunc" | "halfExpand" "trunc" "trunc" cuts the dropped digits; "halfExpand" rounds half away from zero.
locale string | readonly string[] none A BCP 47 locale whose separators, grouping and digits to use, through Intl.NumberFormat.
useGrouping boolean false, or the locale’s Group thousands: , every three digits without a locale, the locale’s own grouping with one.
formatTokenAmount(123_456_789n, 8, { maxFractionDigits: 4 }) // "1.2345"
formatTokenAmount(123_456_789n, 8, {
maxFractionDigits: 4,
roundingMode: "halfExpand",
}) // "1.2346"
formatTokenAmount(100_000_000n, 8, { minFractionDigits: 2 }) // "1.00"
formatTokenAmount(100_000_000n, 8, { trimTrailingZeros: false }) // "1.00000000"
formatTokenAmount(123_456_789_000_000n, 8, { useGrouping: true }) // "1,234,567.89"
formatTokenAmount(123_456_789_000n, 8, { locale: "de-DE" }) // "1.234,56789"

Truncating is the default so a balance never shows more than is held: a balance of 0.99999999 shown to two digits is "0.99", where toFixed(2) and Intl.NumberFormat show "1.00". Pass roundingMode: "halfExpand" for their rounding, which suits totals and statistics better than balances.

A negative amount, which only an int can hold, gets a leading - (with a locale, the locale’s sign), unless every digit shown is zero: -1n shown to two digits is "0", not "-0".

Reads decimal text, as a person types it, into base units.

function parseTokenAmount(
text: string,
decimals: number | bigint | string,
options?: { allowNegative?: boolean }
): bigint
parseTokenAmount("1.5", 8) // 150000000n
parseTokenAmount("0.29", 8) // 29000000n
parseTokenAmount("1.1", 18) // 1100000000000000000n
parseTokenAmount(" 5. ", 8) // 500000000n
parseTokenAmount(".5", 8) // 50000000n
parseTokenAmount("1.100000000", 8) // 110000000n: extra zeros change nothing

It accepts digits with at most one ., with whitespace around them trimmed, and refuses everything else rather than guess at it:

Input Error Why
"", ".", "abc", "1.5 ICP" TypeError Not an amount.
"1,000", "1,5", "1 000" TypeError A comma groups thousands in one locale and separates the fraction in another.
"1e-8", "+1", "0x10" TypeError Only plain decimal notation.
"0.123456789" at 8 decimals RangeError More fraction digits than the token has. Rounding would send something else than typed.
"-1.5" RangeError A ledger’s amounts are nat. Pass { allowNegative: true } for an int.

Every message starts with [ic-reactor] parseTokenAmount: and quotes what it refused (up to 40 characters of it), so a form can show it, or map the error class to its own wording. Checking a long paste takes time in proportion to its length, so it is safe to run on every keystroke.

The result is a bigint. A Reactor takes it as it is. A DisplayReactor takes a nat as text and refuses a bigint, so pass it amount.toString().

import { useState } from "react"
import {
formatTokenAmount,
isPrincipalText,
parseTokenAmount,
} from "@ic-reactor/react"
import { balanceQuery, decimalsQuery, transferMutation } from "./ledger"
function Transfer({ owner }: { owner: string }) {
const { data: decimals } = decimalsQuery.useSuspenseQuery()
const { data: balance } = balanceQuery([{ owner }]).useSuspenseQuery()
const { mutate, isPending } = transferMutation.useMutation()
const [to, setTo] = useState("")
const [amount, setAmount] = useState("")
const [error, setError] = useState<string | null>(null)
const onSubmit = (event: React.FormEvent) => {
event.preventDefault()
const recipient = to.trim()
if (!isPrincipalText(recipient)) return setError("Invalid principal ID")
try {
const units = parseTokenAmount(amount, decimals)
setError(null)
mutate([{ to: { owner: recipient }, amount: units.toString() }])
} catch (err) {
setError((err as Error).message)
}
}
return (
<form onSubmit={onSubmit}>
<p>
Balance:{" "}
{formatTokenAmount(balance, decimals, { maxFractionDigits: 4 })}
</p>
<input value={to} onChange={(e) => setTo(e.target.value)} />
{/* text, not type="number": a number input can hold "1e-8" */}
<input
inputMode="decimal"
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
{error && <p role="alert">{error}</p>}
<button disabled={isPending}>Send</button>
</form>
)
}

examples/tanstack-router/src/components/transfer.tsx is this component in a running app.

Both are plain functions, so a React Server Component can format an amount it fetched itself. examples/nextjs-app-router/src/app/LedgerSnapshot.tsx:

import { formatTokenAmount } from "@ic-reactor/react"
// `ledger` is a Reactor built inside the request
const [decimals, totalSupply] = await Promise.all([
ledger.fetchQuery({ functionName: "icrc1_decimals" }),
ledger.fetchQuery({ functionName: "icrc1_total_supply" }),
])
formatTokenAmount(totalSupply, decimals, {
maxFractionDigits: 0,
locale: "en-US",
}) // whole tokens, grouped, such as "513,084,297"

Whether a value is the text of a principal: a user, a canister, the management canister aaaaa-aa or the anonymous principal 2vxsx-fae.

function isPrincipalText(value: unknown): boolean

It is true exactly when the value is the canonical text of a principal of at most 29 bytes, the most the Internet Computer accepts: lowercase, grouped by dashes, with a matching checksum and no whitespace, so trim what a person typed first. Principal.fromText also reads the JSON form {"__principal__":"aaaaa-aa"}; isPrincipalText refuses it, so text that passes is the principal’s own text, safe to show, compare or put in a URL. It never throws, and anything other than a string is false.

isPrincipalText("ryjl3-tyaaa-aaaaa-aaaba-cai") // true
isPrincipalText("ryjl3-tyaaa") // false: the checksum does not match
isPrincipalText(" aaaaa-aa") // false: trim first
isPrincipalText("RYJL3-TYAAA-AAAAA-AAABA-CAI") // false: not canonical
isPrincipalText('{"__principal__":"aaaaa-aa"}') // false: JSON, not text

Use it where an input is validated, instead of a try around Principal.fromText:

const handleLookup = () => {
const owner = input.trim()
if (!isPrincipalText(owner)) return setError("Invalid principal ID")
setAccount({ owner }) // a DisplayReactor takes the text as it is
}

A Reactor takes a Principal, so pass it Principal.fromText(owner) once the check passes. isPrincipalText returns a boolean rather than being a type guard: a string it refuses is still typed a string, where a guard would narrow it to never.

@ic-reactor/candid’s isPrincipalId is the same check.

Writes a value as JSON indented by two spaces, for a <pre>, a toast or a log line.

function jsonToString(value: unknown): string

JSON.stringify throws on a bigint, writes a Principal as {"__principal__":"aaaaa-aa"} and writes a Uint8Array as an object keyed by index. jsonToString writes the values a Reactor returns the way a DisplayReactor shows them:

Value Written as
bigint its decimal digits, in quotes: "100000000"
Principal its text: "aaaaa-aa"
Uint8Array (a blob) lowercase hex without 0x: "0a0b"
another typed array (vec nat16, …) an array of its numbers
anything else as JSON.stringify(value, null, 2) writes it
import { jsonToString } from "@ic-reactor/core"
import { Principal } from "@icp-sdk/core/principal"
jsonToString({
owner: Principal.fromText("aaaaa-aa"),
subaccount: [new Uint8Array([1, 2])],
amount: 5n,
})
// {
// "owner": "aaaaa-aa",
// "subaccount": [
// "0102"
// ],
// "amount": "5"
// }

A principal is recognised whichever copy of @icp-sdk/core created it. The text is for reading: it does not record which strings were numbers, principals or bytes, so it does not parse back into the value. Use it in place of a hand-written replacer such as JSON.stringify(value, (_, v) => (typeof v === "bigint" ? v.toString() : v)).