Utilities
Overview
Section titled “Overview”@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 Reactimport { formatTokenAmount, parseTokenAmount } from "@ic-reactor/core"Token amounts
Section titled “Token amounts”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 pointMath.floor(Number("0.29") * Math.pow(10, 8)) // 28999999: sends 0.28999999BigInt(Math.floor(Number("1.1") * 10 ** 18)) // 1100000000000000128nNumber(balance) / 1e8 // wrong past 2^53 base unitsformatTokenAmount 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.
formatTokenAmount
Section titled “formatTokenAmount”Shows an amount of base units as decimal text.
function formatTokenAmount( value: bigint | string | number, decimals: number | bigint | string, options?: FormatTokenAmountOptions): stringvalue— the amount in base units: abigint(Reactor), integer text (DisplayReactor) or a safe integer. Decimal text such as"1.5"is refused with aTypeError; that is whatparseTokenAmountreads.decimals— the token’s decimals, from 0 to 255, in whatever formicrc1_decimalsoricrc1_metadatareturned 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"Options
Section titled “Options”| 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".
parseTokenAmount
Section titled “parseTokenAmount”Reads decimal text, as a person types it, into base units.
function parseTokenAmount( text: string, decimals: number | bigint | string, options?: { allowNegative?: boolean }): bigintparseTokenAmount("1.5", 8) // 150000000nparseTokenAmount("0.29", 8) // 29000000nparseTokenAmount("1.1", 18) // 1100000000000000000nparseTokenAmount(" 5. ", 8) // 500000000nparseTokenAmount(".5", 8) // 50000000nparseTokenAmount("1.100000000", 8) // 110000000n: extra zeros change nothingIt 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().
In a form
Section titled “In a form”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.
On the server
Section titled “On the server”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 requestconst [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"Principal text
Section titled “Principal text”isPrincipalText
Section titled “isPrincipalText”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): booleanIt 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") // trueisPrincipalText("ryjl3-tyaaa") // false: the checksum does not matchisPrincipalText(" aaaaa-aa") // false: trim firstisPrincipalText("RYJL3-TYAAA-AAAAA-AAABA-CAI") // false: not canonicalisPrincipalText('{"__principal__":"aaaaa-aa"}') // false: JSON, not textUse 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.
JSON for display
Section titled “JSON for display”jsonToString
Section titled “jsonToString”Writes a value as JSON indented by two spaces, for a <pre>, a toast or a log
line.
function jsonToString(value: unknown): stringJSON.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)).