Profiles
Values and exact conversion
The value tags: what each one means, how input is parsed and output shown, and why nothing is rounded.
prepare_icrc1_transfer({ amount: "1.5 ICP", … })record { amount = 150_000_000 : nat; … }
The model writes "1.5 ICP". The ledger takes a nat in units of
10-8. Between the two sits one tag on one position:
- at
- icrc1_transfer(0).amount
- tag
amount
- args
- decimals: query:icrc1_decimals
- symbol: query:icrc1_symbol
A value tag says what the number, the bytes or the record at a position is: a token
amount, a point in time, a span of time, an account, a subaccount, a byte string in some
encoding. From that, every consumer parses what a person types and shows what a call carries by
the same rules (spec §5.3). @meantjs/format implements them, and the agent tools
and the consent generator both use it, so they cannot disagree about what a value means:
import { formatterFor } from "@meantjs/format";
// The resolved form's node for icrc1_transfer(0).amount, as the ICRC-1 pack tags it.
const node = {
candid: "nat",
tags: { amount: { args: { decimals: { from: "query:icrc1_decimals" }, symbol: { from: "query:icrc1_symbol" } } } },
};
const amount = formatterFor(node, { refs: { "query:icrc1_decimals": 8, "query:icrc1_symbol": "ICP" } });
amount?.parse("1.5 ICP"); // "150000000"
amount?.display("150000000"); // "1.5 ICP"
amount?.safeParse("1.500000000"); // { ok: false, error: { code: "E_EXCESS_PRECISION", … } }The functions, their options and their error codes are on Parse and display values; every tag's arguments in one table are on Tags.
Why nothing is rounded#
Every rule below comes back to one: a conversion is exact, or it is refused. Input with more
fractional digits than the token has is rejected, even when the extra digits are zeros. A
value outside the range of its Candid type is rejected, never wrapped or clamped. A time that
does not fit the tagged unit exactly is rejected. An integer is a decimal string or a
bigint, never a JavaScript number, which cannot hold a nanosecond timestamp
(about 1.8 × 1018) or an 18-decimal amount exactly.
Rounding would be friendlier, and it would send an amount nobody asked for. "0.000000005 ICP" rounded to 8 decimals is either nothing or one base unit, and neither is what the person wrote. A refusal costs one correction; a silent rounding costs a wrong transfer that nobody sees until the balance is wrong. The format library's message says how to fix the input:
E_EXCESS_PRECISION "1.500000000" has 9 fractional digits but this token has 8; amounts are never rounded, so give at most 8amount#
amount applies to nat, int and the sized integers
nat8 to int64. decimals (0 to 255, or a dynamic value)
is required, unless token names the token (below); symbol is optional. A base-unit integer v with
d decimals is shown as a decimal with trailing fractional zeros removed, a space and
the symbol: 150000000 with d = 8 is 1.5 ICP, and
100000000 is 1 ICP.
Input is an optionally negative decimal number, with digits on both sides of the point if it
has one, optionally followed by whitespace and the symbol. With decimals: 8 and
symbol: "ICP", on a nat:
| Input | Result | Why |
|---|---|---|
1.5 ICP | "150000000" | |
1.5 icp | "150000000" | The symbol matches case-insensitively, for ASCII letters only |
1.5 | "150000000" | The symbol is optional |
1.500000000 | EE_EXCESS_PRECISION | Nine fractional digits, even though the ninth is a zero |
1,000 · 1e3 · +1 · .5 | EE_SYNTAX | No thousands separators, exponents, leading + or bare point |
-1 | EE_NEGATIVE | nat is unsigned |
1.5 ckBTC | EE_SYMBOL_MISMATCH | Not this token's symbol |
300 on a nat8 with decimals: 0 | EE_RANGE | Above 255, the type's maximum |
The symbol in the input is a check, not a conversion: "1.5 ckBTC" on an ICP ledger is refused
rather than read as 1.5 of whatever this ledger holds. Bounds in a range tag on the
same position are always in base units, so they never depend on the decimals.
Unlimited: threshold and message#
Two arguments change only what is shown. A value at or above threshold, a decimal
string of base units, is shown as message (default Unlimited) and the
symbol: an approval of the largest uint256 is Unlimited USDC, not a
78-digit number. message needs threshold. Input is unchanged: a number at
the threshold still parses to its base units. In an ICRC-21 fields message such a value is
Text, never a TokenAmount of the raw number
(consent messages).
An amount in another token: token#
On an EVM contract, token names the token by a field instead of giving its decimals
and symbol. It is a relative path to an address in the same argument, in the same
array element as the amount or outside every array. Permit2's allowance names its token beside the
amount:
- at
- PermitSingle(0).details.amount
- tag
amount
- args
- token: details.token
- threshold: 1461501637330902918203684832716283019655932542975
decimals and symbol are then not given (the schema refuses them): they
are that token's decimals() and symbol(), which the consumer reads and
passes in per token address. Without them there is no text (EE_REF_UNRESOLVED), never
a number read in the wrong decimals, and the token's address is shown beside its symbol, because a
symbol is the token's own claim. A path that breaks these rules is EE_TOKEN_PATH, and
so is token on a Candid service, whose ledger a field cannot name yet (OQ-6).
EVM contracts has the consent text this gives.
timestamp#
timestamp applies to nat, int, nat32,
nat64 and int64. Its unit (ns,
us, ms or s) is required, and epoch is
unix. threshold, message and labels are
optional and display only. The ICRC-1 pack tags created_at_time as nanoseconds since
the Unix epoch.
-
Display is canonical RFC 3339 in UTC, with trailing zeros of the fraction
removed:
1791547200000000000ns is2026-10-09T12:00:00Z. A human renderer localizes it, using ICRC-21'sutc_offset_minuteswhen the request has one. -
Input is an absolute RFC 3339 time with an explicit zone (leap seconds are
rejected), or a relative ISO 8601 duration with a
+:+PT24H,+P7D. A relative time is resolved when the call is prepared, againstnowfloored to the tagged unit, so+PT24Hon a seconds field is exactly 86,400 seconds after the current second. -
Conversion to the unit is exact.
2026-10-09T12:00:00.5Zon a seconds field is EE_INEXACT: half a second is not a whole number of seconds.
Words for values that are not dates#
Some deadlines have extreme values that mean something else. EIP-2612 uses the largest
uint256 for a permit that never expires, which as a time is out of range; Permit2
uses an expiration of 0 for an allowance that lasts only the block it is used in, which as a time
reads 1970. Words say what those values mean:
- at
- PermitSingle(0).details.expiration
- tag
timestamp
- args
- unit: s
- epoch: unix
- threshold: 281474976710655
- message: Never
- labels: { "0": "Only in the block it is used" }
labelsmaps exact values, as decimal strings of the tagged unit, to words: 1 to 16 entries.- A value at or above
thresholdis shown asmessage. The two come together, and there is no default word. - A label is checked first, then the threshold, then the time.
- A value shown as words is never read as a time: it is not localized, and it is
Textin ICRC-21, neverTimestampSeconds. Input is unchanged; a word is never parsed back, so typingNeveris EE_SYNTAX.
Value of that uint48 | Display | Why |
|---|---|---|
0 | Only in the block it is used | A label |
281474976710655 | Never | At the threshold, the type's maximum |
1798761600 | 2027-01-01T00:00:00Z | A time |
Without a threshold, the largest uint256 is no date at all:
EE_RANGE, "the time is outside 0000-01-01T00:00:00Z to 9999-12-31T23:59:59Z".
duration#
duration applies to the same types, with a required unit, and uses ISO
8601 durations for input and display. Only W, D, H,
M (after T) and S are allowed, because each has a fixed
length: P1D is exactly 86,400 seconds. A year or a month has no fixed length, so
P1Y and P1M are EE_CALENDAR_UNIT, and so is
+P1M as a relative timestamp.
| Seconds | Display |
|---|---|
86400 | P1D |
5400 | PT1H30M |
0 | PT0S |
Display never uses W and leaves out zero components. A fraction is allowed on the
seconds only and must convert exactly: PT0.5S is 500 on a
milliseconds field and EE_INEXACT on a seconds field. A leading - is
allowed only when the Candid type is signed.
account#
account applies to the ICRC-1 account record,
record { owner : principal; subaccount : opt blob }, and turns the whole record into
one piece of text: the ICRC-1 textual encoding, which is a principal alone for the default
subaccount and principal-checksum.subaccount otherwise. Input is case-insensitive,
and the checksum is verified. Non-canonical text is refused, as the ICRC-1 standard specifies,
because two spellings of one account are two chances for a person to compare the wrong one.
These are the standard's own examples, shortened in the middle:
| Input | Result |
|---|---|
k2t6j-…-teztq-6ae | The principal's default subaccount |
k2t6j-…-teztq-6ae-6cc627i.1 | Subaccount 1 |
K2T6J-…-TEZTQ-6AE-6CC627I.1 | The same account; displayed in lowercase |
k2t6j-…-teztq-6ae-6cc627i.01 | EE_NOT_CANONICAL: leading zeros in the subaccount |
k2t6j-…-teztq-6ae-q6bn32y. | EE_NOT_CANONICAL: the default subaccount written out |
k2t6j-…-teztq-6ae.1 | EE_CHECKSUM_MISSING |
subaccount#
subaccount applies to a blob of 32 bytes. Input is #n or a
plain decimal n with 0 ≤ n < 264, encoded big-endian into 32 bytes,
or exactly 64 hex characters, with or without 0x. A string of 64 hex digits is
read as hex even when every digit is a decimal digit, and a decimal has no leading zeros, so no
input has two readings.
Display is #n when the first 24 bytes are zero, and 64 lowercase hex characters
otherwise: #7 parses to 31 zero bytes and a 7, and displays as #7.
parse(display(x)) = x must hold for every 32-byte value, and the format library
checks it on 20,000 random values in its tests.
bytes#
bytes applies to a blob, with a required encoding
(utf8, hex or base64) and an optional
max_len, which counts bytes after encoding. The ICRC-1 pack tags
memo as utf8.
-
utf8: input is plain text, encoded as UTF-8; input that starts with0xis read as hex, so any bytes can still be entered. Display shows the text when the bytes are valid UTF-8 with no control, format or line-separator characters and the text does not start with0x; otherwise it shows0xand lowercase hex. A memo of68 0a("h" and a newline) displays as0x680a, so a line break cannot hide the rest of a message. -
hexaccepts an optional0xand either case, and always displays0xand lowercase.base64uses the standard alphabet, with padding optional on input and always written on display. -
max_lenis a byte count:héllois six bytes, so amax_lenof 5 refuses it with EE_MAX_LEN.
default#
default fills a position the person need not think about. Its value is
a Candid literal or a dynamic value, and its mode says who sees it:
auto: the tool layer fills the value, and the position is left out of the agent's tool schema altogether. The ICRC-1 pack setsfeetonullthis way, so the ledger charges its current fee and the model is never asked to guess one.suggest: the position is shown, with the default filled in. The pack suggestsnullformemo.
null is allowed only on an opt position (EE_DEFAULT_NULL).
The v0 validator does not check that other literals type-check against their position, so a
consumer checks a default before it uses one.
describe, text, range and ui#
-
describegives any position, or a method, alabelandhelp. The ICRC-1 pack labelsfee"Fee", with the help "Leave empty to pay the ledger's current default fee. A wrong explicit fee fails with BadFee." Labels become the rows of a consent message and the names of form fields. -
text(experimental) applies totext:format(plain,markdown,email,url),max_lenand apattern. -
range(experimental) bounds a number withminandmax, as decimal strings in base units, or atextorvecwithmin_lenandmax_len. -
ui(experimental) is for user interfaces only:order,group,hiddenandwidget. Agents ignore it. The ICRC-1 pack hidescreated_at_time, which the tool layer fills.
A numeric argument of an update method with none of amount,
timestamp, duration or describe gets the info
II_UNTAGGED_NUMERIC: a bare number is the kind of position where an agent guesses.
named#
named (experimental) marks a position where a name from a names set
can stand in for the value: "Savings" for an account id, "Alice" for a principal. It stacks with
the position's other tags, and a name is tried only when the input is not already a valid value.
The matching rule, and why a name can never redirect a transfer, are on
Names.
On an EVM contract#
Profiles for EVM contracts are experimental (spec §15). The rules above carry over unchanged
for amount, timestamp, duration and bytes,
with ABI types in place of Candid ones: amount applies to uint8 to
uint256 and int8 to int256, each with the bounds of its
bits; timestamp and duration to uint32 to
uint256 and int64 to int256; and bytes to
bytes and bytes1 to bytes32.
- An address has one text form,
0xand 40 hex digits, shown with its EIP-55 checksum. It needs no tag, soaccountandsubaccounton an EVM contract are EE_TAG_NOT_APPLICABLE. - The ERC-20 pack gives
amountathresholdandmessage, so that an approval of the largestuint256shows asUnlimited USDC(above). amount'stokenis EVM only: an amount in the token anaddressfield names, as in an EIP-712 message such as Permit2's (above).timestamp's words are for EVM deadlines first: the EIP-2612 pack shows a deadline ofuint256max as "Never" (above).
EVM contracts has the rest.
Next#
The @meantjs/format API: formatters, safe results and error codes.
All 18 tags in one table: where each applies, its arguments, core or experimental.
Concept Intent, effects and confirmationHow these values become the line a person reads before a call is sent.