Profiles

Values and exact conversion

The value tags: what each one means, how input is parsed and output shown, and why nothing is rounded.

The model sees1.5 ICP
prepare_icrc1_transfer({ amount: "1.5 ICP", … })
The call sends150000000
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:

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

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

text
E_EXCESS_PRECISION "1.500000000" has 9 fractional digits but this token has 8; amounts are never rounded, so give at most 8

amount#

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:

InputResultWhy
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.500000000EE_EXCESS_PRECISIONNine fractional digits, even though the ninth is a zero
1,000 · 1e3 · +1 · .5EE_SYNTAXNo thousands separators, exponents, leading + or bare point
-1EE_NEGATIVEnat is unsigned
1.5 ckBTCEE_SYMBOL_MISMATCHNot this token's symbol
300 on a nat8 with decimals: 0EE_RANGEAbove 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:

Profile annotation
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: 1791547200000000000 ns is 2026-10-09T12:00:00Z. A human renderer localizes it, using ICRC-21's utc_offset_minutes when 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, against now floored to the tagged unit, so +PT24H on a seconds field is exactly 86,400 seconds after the current second.
  • Conversion to the unit is exact. 2026-10-09T12:00:00.5Z on 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:

Profile annotation
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" }
  • labels maps exact values, as decimal strings of the tagged unit, to words: 1 to 16 entries.
  • A value at or above threshold is shown as message. 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 Text in ICRC-21, never TimestampSeconds. Input is unchanged; a word is never parsed back, so typing Never is EE_SYNTAX.
Value of that uint48DisplayWhy
0Only in the block it is usedA label
281474976710655NeverAt the threshold, the type's maximum
17987616002027-01-01T00:00:00ZA 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.

SecondsDisplay
86400P1D
5400PT1H30M
0PT0S

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:

InputResult
k2t6j-…-teztq-6aeThe principal's default subaccount
k2t6j-…-teztq-6ae-6cc627i.1Subaccount 1
K2T6J-…-TEZTQ-6AE-6CC627I.1The same account; displayed in lowercase
k2t6j-…-teztq-6ae-6cc627i.01EE_NOT_CANONICAL: leading zeros in the subaccount
k2t6j-…-teztq-6ae-q6bn32y.EE_NOT_CANONICAL: the default subaccount written out
k2t6j-…-teztq-6ae.1EE_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 with 0x is 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 with 0x; otherwise it shows 0x and lowercase hex. A memo of 68 0a ("h" and a newline) displays as 0x680a, so a line break cannot hide the rest of a message.
  • hex accepts an optional 0x and either case, and always displays 0x and lowercase. base64 uses the standard alphabet, with padding optional on input and always written on display.
  • max_len is a byte count: héllo is six bytes, so a max_len of 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 sets fee to null this 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 suggests null for memo.

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#

  • describe gives any position, or a method, a label and help. The ICRC-1 pack labels fee "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 to text: format (plain, markdown, email, url), max_len and a pattern.
  • range (experimental) bounds a number with min and max, as decimal strings in base units, or a text or vec with min_len and max_len.
  • ui (experimental) is for user interfaces only: order, group, hidden and widget. Agents ignore it. The ICRC-1 pack hides created_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, 0x and 40 hex digits, shown with its EIP-55 checksum. It needs no tag, so account and subaccount on an EVM contract are EE_TAG_NOT_APPLICABLE.
  • The ERC-20 pack gives amount a threshold and message, so that an approval of the largest uint256 shows as Unlimited USDC (above).
  • amount's token is EVM only: an amount in the token an address field 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 of uint256 max as "Never" (above).

EVM contracts has the rest.

Next#