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#

bash
npm install --save-exact @meantjs/format@0.5.0 @meantjs/profile@0.5.0
npx candid-profile resolve ledger.did --packs > ledger.resolved.json
js
import { 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"
The model sees1.5 TKA
amount.parse("1.5 TKA")
The call sends150000000
"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.

Profile annotation
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:

  • null when 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 methods parse, display, safeParse, safeDisplay and describe.
  • 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#

js
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:

json
{
  "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#

TagA person writesparse returnsdisplay shows
amount1.5 TKA"150000000", base units as a decimal string1.5 TKA
timestamp2026-10-10T14:00:00+02:00, or +PT24H from now"1791633600000000000", a count of unit since the Unix epoch2026-10-10T12:00:00Z
durationPT1H30M"5400" with unit: sPT1H30M
accountk2t6j-…-teztq-6ae-6cc627i.1{ owner, subaccount }, subaccount a Uint8Array(32) or null for the defaultthe ICRC-1 textual encoding
subaccount#7Uint8Array(32)#7
byteslunch (encoding: utf8)Uint8Arraylunch; 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#

js
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#

js
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.

js
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.

js
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.

CodeTagsMeaning
EE_TYPEallThe wrong JavaScript type, such as a number where a decimal string is required.
EE_SYNTAXallThe text does not match the grammar.
EE_RANGEallOutside the range of the type or of the representable calendar.
EE_TOO_LONGallInput longer than the bound on work.
EE_ARGallA tag argument is missing or invalid.
EE_TAG_TYPEallThe tag does not apply to this type.
EE_TAG_CONFLICTallTwo value tags at one position.
EE_REF_UNRESOLVEDallThe caller did not supply a dynamic value.
EE_EXCESS_PRECISIONamountMore fractional digits than the token's decimals. Never rounded.
EE_NEGATIVEamountNegative input for an unsigned type.
EE_SYMBOL_MISMATCHamountThe symbol in the input is not the token's symbol.
EE_INEXACTtimesThe value cannot be represented exactly in the requested unit.
EE_CALENDAR_UNITtimesYears and months have no fixed length.
EE_NEEDS_NOWtimestampA relative time without a now.
EE_CHECKSUMaccountThe checksum does not match.
EE_CHECKSUM_MISSINGaccountA subaccount written without a checksum.
EE_NOT_CANONICALaccountA valid value written in a non-canonical way.
EE_PRINCIPALaccountInvalid principal text.
EE_HEXaccount, bytesMalformed hex.
EE_BASE64bytesMalformed base64.
EE_UTF8bytesText that cannot be encoded as UTF-8 (a lone surrogate).
EE_LENGTHaccount, subaccount, bytesThe wrong length in bytes.
EE_MAX_LENbytesLonger 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:

RuleCodeAlso tested in
amount: more than d fractional digits is refused, even zerosEE_EXCESS_PRECISIONa property in amount.test.mjs
amount: negative only when the type is signedEE_NEGATIVE
amount: the symbol matches, ignoring ASCII case onlyEE_SYMBOL_MISMATCH
timestamp: conversion to the unit is exactEE_INEXACTproperties 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 togetherEE_SYNTAX, EE_ARGevm.test.mjs
amount: with token, the decimals and symbol come from the caller, never a guessEE_REF_UNRESOLVEDevm.test.mjs
duration: W, D, H, M, S only; P1Y and P1M refusedEE_SYNTAX, EE_CALENDAR_UNIT
account: the checksum is verified on inputEE_CHECKSUM, EE_CHECKSUM_MISSINGthe ICRC-1 vectors in account.test.mjs
subaccount: 0 <= n < 2^64; hex is exactly 64 charactersEE_RANGE, EE_LENGTH
subaccount: parse(display(x)) = x for every 32-byte value20,000 random values in subaccount.test.mjs
bytes: max_len counts bytes after encodingEE_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:

tomlCargo.toml
[dependencies]
meant-format = "=0.2.0"
rustcrates/meant-format/README.md
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:

bash
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.