Profiles
Parse and display values
Turn what a person types into the exact value a call carries, and back, without rounding.
@meantjs/format reads the six value tags of a profile: amount,
timestamp, duration, account, subaccount and
bytes. Give it one position of a resolved form and it returns a formatter for that
position, with the tag's arguments filled in. The agent tools and the consent generator use the
same formatters, so a tool input and a consent message cannot disagree about what a value means.
Build a formatter for a position#
npm install --save-exact @meantjs/format@0.5.0 @meantjs/profile@0.5.0
npx candid-profile resolve ledger.did --packs > ledger.resolved.jsonimport { readFileSync } from "node:fs";
import { convertUnit, formatterFor } from "@meantjs/format";
const resolved = JSON.parse(readFileSync("ledger.resolved.json", "utf8"));
const fields = resolved.methods.icrc1_transfer.args[0].fields;
// The ledger's own answers to icrc1_decimals and icrc1_symbol, read once per ledger.
const refs = { "query:icrc1_decimals": 8, "query:icrc1_symbol": "TKA" };
const amount = formatterFor(fields.amount, { refs });
if (amount === null) throw new Error("this position has no value tag");
amount.parse("1.5 TKA"); // "150000000"
amount.parse("1.5"); // "150000000"
amount.display("150000000"); // "1.5 TKA"amount.parse("1.5 TKA")"150000000" // base units, a decimal string
The position icrc1_transfer(0).amount carries this tag from the ICRC-1 pack. Its
arguments are not numbers in the profile: they name a query the ledger answers.
- at
- icrc1_transfer(0).amount
- tag
amount
- args
- decimals: query:icrc1_decimals
- symbol: query:icrc1_symbol
The library does no I/O. You call icrc1_decimals and icrc1_symbol once per
ledger and pass the answers in refs, keyed by the from string. A plain query
is answered by one replica, so for a transfer or an approve read
them through an update call or a certified path (security model).
formatterFor(node, { refs, now, token }) takes the position's node, the resolved refs, a default clock
for relative times (nanoseconds since the Unix epoch), and, for an EVM amount whose
token names its token by a field, that token's { decimals, symbol }
(below). It returns:
nullwhen the position has no value tag. Show it as Candid text.-
A formatter with
tag,candid(the position's type),args(the arguments with refs resolved, here{ decimals: 8, symbol: "TKA" }), and the methodsparse,display,safeParse,safeDisplayanddescribe. -
Or it throws a
FormatError, and never returns a formatter that half works: EE_REF_UNRESOLVED when a ref is missing, EE_ARG for an invalid tag argument, EE_TAG_TYPE when the tag does not fit the Candid type, EE_TAG_CONFLICT when the position carries two value tags.
An opt position is a node with optional: true. The formatter reads the inner
value: handle absence before you call it. What each tag means is on
Values and exact conversion; every export and its signature is on the
JavaScript API reference.
Read input and show results#
const typed = amount.safeParse("0.123456789");
if (!typed.ok) {
typed.error.code; // "E_EXCESS_PRECISION"
typed.error.message; // "\"0.123456789\" has 9 fractional digits but this token has 8; amounts are never rounded, so give at most 8"
}
const hint = amount.describe(); // the words for a form field or a tool input
parse and display throw a FormatError;
safeParse and safeDisplay return { ok: true, value } or
{ ok: false, error: { code, message } }. The message says how to fix the input, for a
person or a model; switch on the code, which is stable. describe() returns the grammar
in words, from the parser itself, with the position's own decimals and symbol:
{
"summary": "Amount in TKA: a decimal number with at most 8 digits after the point, optionally followed by the symbol TKA. Digits on both sides of a point, no thousands separators or exponents. Never rounded: more digits after the point is an error, even zeros. For example \"1\", \"1.25\", \"1.25 TKA\".",
"examples": ["1", "1.25", "1.25 TKA"],
"pattern": "^\\s*[0-9]+(?:\\.[0-9]{1,8})?(?:\\s+[Tt][Kk][Aa])?\\s*$"
}
Every example it keeps parses at this position. pattern is given for
amount and subaccount only, whose grammar is regular. It is valid under
the u flag, for a JSON Schema pattern, and it is a necessary condition only:
everything parse accepts matches it, and a match can still be refused for its range.
describeInput(tag, args, candid) gives the same words without a formatter.
The six value tags#
| Tag | A person writes | parse returns | display shows |
|---|---|---|---|
amount | 1.5 TKA | "150000000", base units as a decimal string | 1.5 TKA |
timestamp | 2026-10-10T14:00:00+02:00, or +PT24H from now | "1791633600000000000", a count of unit since the Unix epoch | 2026-10-10T12:00:00Z |
duration | PT1H30M | "5400" with unit: s | PT1H30M |
account | k2t6j-…-teztq-6ae-6cc627i.1 | { owner, subaccount }, subaccount a Uint8Array(32) or null for the default | the ICRC-1 textual encoding |
subaccount | #7 | Uint8Array(32) | #7 |
bytes | lunch (encoding: utf8) | Uint8Array | lunch; 0xfffe0001 for bytes that are not printable UTF-8 |
Integers are decimal strings, or bigint where display takes them. A
JavaScript number is refused on input and on display (EE_TYPE): nanosecond timestamps
and 18-decimal amounts pass 253. Text is bounded at 1024 characters, and
bytes input at 4 MiB.
Times and durations#
const approve = resolved.methods.icrc2_approve.args[0].fields;
const expires = formatterFor(approve.expires_at, { refs, now: 1_791_547_200_000_000_000n }); // the ledger's clock, ns
if (expires === null) throw new Error("no value tag");
expires.parse("+PT24H"); // "1791633600000000000"
expires.display("1791633600000000000"); // "2026-10-10T12:00:00Z"
expires.safeParse("+P1M").ok; // false: E_CALENDAR_UNIT, a month has no fixed length
convertUnit("1791633600000000000", "ns", "s"); // "1791633600"
A relative time is + and an ISO 8601 duration, resolved against now floored
to the tag's unit; without a now it is EE_NEEDS_NOW. Pass the canister's
clock, not the machine's. Durations take W, D, H,
M and S only: P1D is exactly 86,400 s. A conversion that would
lose precision is EE_INEXACT, never rounded: convertUnit refuses
1791547200500000000 ns as seconds. Display is canonical RFC 3339 in UTC; localizing is
the renderer's job. Calendar arithmetic is in bigint nanoseconds with the proleptic
Gregorian calendar, not Date, which has millisecond precision.
Words for times#
import { displayTimestamp, formatterFor } from "@meantjs/format";
// Permit2's allowance expiration: seconds in a uint48, where 0 and the largest value are not dates.
const expiration = formatterFor({
abi: "uint48",
tags: {
timestamp: {
args: { unit: "s", epoch: "unix", threshold: "281474976710655", message: "Never", labels: { 0: "Only in the block it is used" } },
},
},
});
if (expiration === null) throw new Error("no value tag");
expiration.display("0"); // "Only in the block it is used"
expiration.display("281474976710655"); // "Never"
expiration.display("1798761600"); // "2027-01-01T00:00:00Z"
expiration.safeParse("Never").ok; // false: E_SYNTAX, a word is never read back
const MAX = (2n ** 256n - 1n).toString();
displayTimestamp(MAX, { unit: "s", candid: "uint256", threshold: MAX, message: "Never" }); // "Never"
A timestamp tag may carry words for values that are not dates, display only. labels maps
exact values (decimal strings of the tag's unit, 1 to 16 entries) to words; a value at or above
threshold shows as message. A label is checked first, then the threshold, then the time.
threshold and message come together, with no default word: one without the other is
EE_ARG, "a timestamp threshold and its message go together: give both or neither". Each word is 1 to 32
characters. Parsing is unchanged, so the formatter reads a date or a + duration and never a word.
displayTimestamp(value, { unit, candid, threshold, message, labels }) does the same without a
formatter. Without the threshold, the largest uint256 is no date: EE_RANGE, "the time is
outside 0000-01-01T00:00:00Z to 9999-12-31T23:59:59Z". The words work on any timestamp position; EVM deadlines are
why they exist (value tags).
Bytes and memos#
bytes takes encoding utf8, hex or
base64, and an optional max_len that counts bytes after encoding
(EE_MAX_LEN). With utf8, input that starts with 0x is read as hex,
and display shows 0x and lowercase hex for bytes that are not valid UTF-8 or that hold
control, format or line-separator characters. display returns plain text and escapes
nothing: a memo can hold Markdown, and the renderer escapes it for its target
(consent messages do).
Accounts, addresses and integer types#
This is the part that differs by chain.
const to = formatterFor(fields.to, { refs }); // the transfer's `to`, tagged `account`
if (to === null) throw new Error("no value tag");
const alice = to.parse("k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae-6cc627i.1");
// { owner: "k2t6j-…-teztq-6ae", subaccount: Uint8Array(32) [0, …, 0, 1] }
to.display(alice); // "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae-6cc627i.1"
to.safeParse("k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae.1").ok; // false: E_CHECKSUM_MISSING
account is the ICRC-1 textual encoding: a principal for the default subaccount, or
principal-checksum.subaccount. The checksum is verified on input
(EE_CHECKSUM, EE_CHECKSUM_MISSING), and a non-canonical form, such as an
explicit default subaccount or leading zeros in the subaccount, is EE_NOT_CANONICAL.
subaccount reads #n or n below 264, or exactly 64 hex
characters, and displays #n exactly when the first 24 bytes are zero.
amount applies to nat, int, nat8 to
nat64 and int8 to int64. A negative amount at an unsigned
position is EE_NEGATIVE, and a value outside the type's range is EE_RANGE,
never wrapped or clamped.
The library parses principals itself (base32 and CRC-32, in src/principal.mjs), with no
dependency. principalFromText and
principalToText are exported.
import { formatterFor } from "@meantjs/format";
import { readAddress } from "@meantjs/profile/evm";
const MAX = (2n ** 256n - 1n).toString();
const usdc = formatterFor({ abi: "uint256", tags: { amount: { args: { decimals: 6, symbol: "USDC", threshold: MAX } } } });
if (usdc === null) throw new Error("no value tag");
usdc.parse("1.5 USDC"); // "1500000"
usdc.display(MAX); // "Unlimited USDC"
formatterFor({ abi: "address" }); // null: an address takes no value tag
readAddress("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"); // "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
// Permit2's details.amount: its token is the one at details.token, whose metadata the caller reads and passes.
const allowance = formatterFor(
{ abi: "uint160", tags: { amount: { args: { token: "details.token" } } } },
{ token: { decimals: 6, symbol: "USDC" } },
);
allowance?.display("1500000"); // "1.5 USDC"
fact EVM support is experimental (spec §15,
phase 1 and the first part of phase 2). An EVM position is a node with abi where a Candid one has candid,
and it formats the same way. amount takes uint8 to uint256 and
int8 to int256, each with the bounds of its bits; timestamp and
duration take uint32 to uint256 and int64 to
int256; bytes takes bytes and bytesN, which holds
exactly N bytes (EE_LENGTH). amount's threshold and
message show a value at or above the threshold as the message and the symbol,
Unlimited USDC; parsing is unchanged.
An amount with token takes no decimals or symbol of its own:
they are those of the token another field names, which you read with eth_call and pass as
formatterFor(node, { token: { decimals, symbol } }), one formatter per token. Called without that
option it is EE_REF_UNRESOLVED; a tag that gives decimals beside
token is EE_ARG. display shows 1.5 USDC; the consent text adds
the token's address beside it, because a symbol is the token's own claim.
account and subaccount do not apply to an ABI type. An address has one text
form, 0x and 40 hex digits, and needs no tag: readAddress from
@meantjs/profile/evm reads it and returns its EIP-55 checksummed form. Text in mixed case
must match its checksum (EE_ADDRESS_CHECKSUM); text all in one case carries no checksum
and is accepted. Show it shortened only beside the full form: 0xf39F…2266. More on
EVM contracts.
Errors#
Every rejection is a FormatError with a code from CODES and a
message that says how to fix the input. The meanings are the comments in
src/errors.mjs.
| Code | Tags | Meaning |
|---|---|---|
| EE_TYPE | all | The wrong JavaScript type, such as a number where a decimal string is required. |
| EE_SYNTAX | all | The text does not match the grammar. |
| EE_RANGE | all | Outside the range of the type or of the representable calendar. |
| EE_TOO_LONG | all | Input longer than the bound on work. |
| EE_ARG | all | A tag argument is missing or invalid. |
| EE_TAG_TYPE | all | The tag does not apply to this type. |
| EE_TAG_CONFLICT | all | Two value tags at one position. |
| EE_REF_UNRESOLVED | all | The caller did not supply a dynamic value. |
| EE_EXCESS_PRECISION | amount | More fractional digits than the token's decimals. Never rounded. |
| EE_NEGATIVE | amount | Negative input for an unsigned type. |
| EE_SYMBOL_MISMATCH | amount | The symbol in the input is not the token's symbol. |
| EE_INEXACT | times | The value cannot be represented exactly in the requested unit. |
| EE_CALENDAR_UNIT | times | Years and months have no fixed length. |
| EE_NEEDS_NOW | timestamp | A relative time without a now. |
| EE_CHECKSUM | account | The checksum does not match. |
| EE_CHECKSUM_MISSING | account | A subaccount written without a checksum. |
| EE_NOT_CANONICAL | account | A valid value written in a non-canonical way. |
| EE_PRINCIPAL | account | Invalid principal text. |
| EE_HEX | account, bytes | Malformed hex. |
| EE_BASE64 | bytes | Malformed base64. |
| EE_UTF8 | bytes | Text that cannot be encoded as UTF-8 (a lone surrogate). |
| EE_LENGTH | account, subaccount, bytes | The wrong length in bytes. |
| EE_MAX_LEN | bytes | Longer than the tag's max_len. |
The rules, and where they are tested#
The rules are the normative behavior of
spec §5.3.
Each has a test in the package, most in
test/rejections.test.mjs:
| Rule | Code | Also tested in |
|---|---|---|
amount: more than d fractional digits is refused, even zeros | EE_EXCESS_PRECISION | a property in amount.test.mjs |
amount: negative only when the type is signed | EE_NEGATIVE | |
amount: the symbol matches, ignoring ASCII case only | EE_SYMBOL_MISMATCH | |
timestamp: conversion to the unit is exact | EE_INEXACT | properties in time.test.mjs |
timestamp: relative input resolves against now floored to the unit | ||
timestamp: words are display only, never parsed back; a threshold and its message come together | EE_SYNTAX, EE_ARG | evm.test.mjs |
amount: with token, the decimals and symbol come from the caller, never a guess | EE_REF_UNRESOLVED | evm.test.mjs |
duration: W, D, H, M, S only; P1Y and P1M refused | EE_SYNTAX, EE_CALENDAR_UNIT | |
account: the checksum is verified on input | EE_CHECKSUM, EE_CHECKSUM_MISSING | the ICRC-1 vectors in account.test.mjs |
subaccount: 0 <= n < 2^64; hex is exactly 64 characters | EE_RANGE, EE_LENGTH | |
subaccount: parse(display(x)) = x for every 32-byte value | 20,000 random values in subaccount.test.mjs | |
bytes: max_len counts bytes after encoding | EE_MAX_LEN | |
| Integers are never JavaScript numbers (D-006) | EE_TYPE |
fact The ICRC-1 textual-encoding examples come from
standards/ICRC-1/TextualEncoding.md of dfinity/ICRC-1 at commit
5d670e54d9a58fbf472bf0a25f33743d60cfd0e6; the tests read the committed JSON and check its
recorded sha256, never the network. Principal parsing is also checked against independent oracles in
the tests: CRC-32 from node:zlib and a base32 encoder built on big-integer division. The
property tests use a seeded generator (default seed 20261009, set
FORMAT_TEST_SEED to change it), and every failure message carries the seed.
The same answers in Rust#
meant-format is the amount tag in Rust, for a canister, a signer or any host
without JavaScript. It has no dependencies and runs without the standard library:
[dependencies]
meant-format = "=0.2.0"use meant_format::{AmountFormat, Code, IntType};
let icp = AmountFormat::new(8).with_symbol("ICP").unwrap();
assert_eq!(icp.parse("1.5 ICP").unwrap(), "150000000");
assert_eq!(icp.display("150000000").unwrap(), "1.5 ICP");
// More digits than the token has: refused, never rounded.
assert_eq!(icp.parse("0.123456789").unwrap_err().code(), Code::ExcessPrecision);
// The wrong token's symbol: refused.
assert_eq!(icp.parse("1.5 ckBTC").unwrap_err().code(), Code::SymbolMismatch);
// Outside the integer type: refused, never wrapped or clamped.
let byte = AmountFormat::new(0).with_candid(IntType::Nat8);
assert_eq!(byte.parse("256").unwrap_err().code(), Code::Range);
// An ERC-20 approval of 2^256 - 1, shown as what it means.
let max = "115792089237316195423570985008687907853269984665640564039457584007913129639935";
let usdc = AmountFormat::new(6)
.with_symbol("USDC").unwrap()
.with_candid(IntType::Uint256)
.with_threshold(max, None).unwrap();
assert_eq!(usdc.display(max).unwrap(), "Unlimited USDC");
@meantjs/format is the reference. For the same input the crate gives the same answer:
the same value, or the same error code and the same message.
tests/parity.rs
checks that against the vectors in crates/meant-format/tests/vectors/amount.json, which
generate-amount.mjs writes from the JavaScript package and never by hand. The file records
where it came from: @meantjs/format 0.2.0 at commit
b6059abce12dc1b241a48deddc801db47d860b4d, Node v22.22.0, Unicode 16.0. It holds 3,412
cases, the number the crate's README states and the test pins (DOCUMENTED_CASES): 1,994
parse cases, 1,331 display cases, 24 symbol cases, 27 type-name cases and 36
threshold cases. The test also fails when a regenerated file loses a whole class of answer, such as
every EE_NEGATIVE case, or has too few EVM or threshold cases.
Two details follow JavaScript exactly, because they decide what is accepted: whitespace is
ECMAScript's set (it includes U+FEFF and excludes U+0085), and the 1024 bound counts UTF-16 code
units, as JavaScript's length does.
Versions#
fact crates.io holds two versions: 0.1.0, published on 2026-10-27, with the ten Candid integer types
and no threshold, and 0.2.0, published on 2026-10-29, which adds the EVM ABI's integer types, up to
uint256 and int256, and with_threshold (observed on the crates.io API on
2026-10-29). The manifest in the repository holds 0.2.0. Before 1.0 any 0.x release may change the
API or the text display produces, so pin it exactly:
cargo add meant-format@=0.2.0
The minimum supported Rust version is 1.78. The crate is the amount tag only: for an amount whose
token a field names, build the format from that token's decimals and symbol, and there are no
timestamps or timestamp words. Every item is on the meant-format reference.