Profiles
meant-format (Rust)
The Rust crate for the amount tag: AmountFormat, IntType and the error codes, matched case by case against @meantjs/format.
use meant_format::{AmountFormat, IntType};
// in a function that returns Result<_, meant_format::FormatError>
let usdc = AmountFormat::new(6).with_symbol("USDC")?.with_candid(IntType::Nat64);
assert_eq!(usdc.parse("1.5 usdc")?, "1500000");
assert_eq!(usdc.display_u128(1_500_000)?, "1.5 USDC");
meant-format converts between the amounts people write and the base units a contract
call carries, for the amount tag of
spec §5.3
(and §15 for EVM positions). Base units are canonical decimal strings, so nothing passes through a
float and nothing is rounded, wrapped or clamped. For the same input it gives the same answer as
@meantjs/format: parse and display values shows both
side by side, values and exact conversion explains the rules, and the
JavaScript package's exports are on the JavaScript API
reference.
This page describes 0.2.0, published on crates.io on 2026-10-29 and the version in
the repository's manifest. Depend on it exactly, as on every pre-1.0 crate here:
[dependencies]
meant-format = "=0.2.0"
0.1.0 (2026-10-27) has the ten Candid integer types, an IntType that is
not #[non_exhaustive] with ALL as an array, and no threshold.
0.2.0 is breaking, as a 0.x release may be: it adds the EVM ABI integer types and
with_threshold (CHANGELOG).
The crate#
| Item | Kind | What it is |
|---|---|---|
AmountFormat | struct | The amount tag at one position: decimals, symbol, integer type, threshold. |
IntType | enum, #[non_exhaustive] | The 70 integer types an amount can have: 10 Candid, 60 EVM ABI. |
FormatError | struct | A refused value: a Code and a message. |
Code | enum, #[non_exhaustive] | The 8 stable error codes. |
MAX_INPUT_UTF16_LEN | const | 1024: the longest text the crate reads, in UTF-16 code units. |
- No dependencies. The decimal arithmetic is on strings, so no big-integer library is needed.
serde_jsonis a dev-dependency, for the parity vectors, and never reaches a consumer. no_std. The crate is#![no_std]and needs onlyalloc. It is#![forbid(unsafe_code)].-
The
stdfeature, on by default, only addsimpl std::error::Error for FormatError.core::error::Errorwould make the feature unnecessary, but it needs Rust 1.81. Turn default features off for a target with no standard library; CI checks the library onthumbv7em-none-eabihfthat way. - MSRV.
rust-version = "1.78"; the MSRV workflow runs the crate's tests on Rust 1.78.0. - Stability. Error codes are the stable surface. Message text is written for a person or a model and may change between releases, and in 0.x so may the text
displayproduces.
AmountFormat#
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AmountFormat {
decimals: u8,
symbol: Option<String>,
candid: IntType,
threshold: Option<String>,
threshold_message: Option<String>,
}
The fields are private: build one with new and the with_* methods, which take
and return it by value. A format with a symbol or a threshold is checked when it is built, so a format
you hold is always valid.
pub const DEFAULT_THRESHOLD_MESSAGE: &'static str = "Unlimited";What display shows for a value at or above the threshold when with_threshold gives no message: ERC-7730's default for a token amount.
pub fn new(decimals: u8) -> SelfA token with decimals decimals (0 to 255), no symbol, at a nat position, with no threshold. It cannot fail.
pub fn with_symbol(mut self, symbol: &str) -> Result<Self, FormatError>
Sets the symbol that display appends and parse accepts. It must be 1 to 32
characters, with no surrounding whitespace and no control or invisible characters (Unicode categories
Cc, Cf, Zl and Zp, from a Unicode 16.0 table), since it is shown to the person approving a call.
Otherwise Code::Arg. A canister's icrc1_symbol answer is untrusted text: this is
where a right-to-left override in it is refused.
pub fn with_candid(mut self, candid: IntType) -> SelfSets the integer type of the position, Candid's or the EVM ABI's. It bounds the value and decides whether it may be negative.
pub fn with_candid_name(self, name: &str) -> Result<Self, FormatError>
with_candid by name, as a resolved profile spells it: "nat64", or
"uint256" at an EVM position. Names are case-sensitive. A name that is not an integer type
is Code::TagType, with the message
the amount tag applies to nat, int, nat8, nat16, nat32, nat64, int8, int16, int32, int64, uint8 to uint256, int8 to int256, not <name>.
pub fn with_threshold(
mut self,
threshold: &str,
message: Option<&str>,
) -> Result<Self, FormatError>
A threshold in base units at or above which display shows message and the
symbol instead of the number: an ERC-20 approval of 2256 − 1 shows as
"Unlimited USDC". None uses DEFAULT_THRESHOLD_MESSAGE. Parsing is
unchanged. threshold must be a canonical decimal integer, not negative, of at most 78
digits (2256 − 1 has 78); message follows the rules of
with_symbol. Otherwise Code::Arg.
let max = "115792089237316195423570985008687907853269984665640564039457584007913129639935";
let usdc = AmountFormat::new(6)
.with_symbol("USDC")?
.with_candid(IntType::Uint256)
.with_threshold(max, None)?;
assert_eq!(usdc.display(max)?, "Unlimited USDC");
assert_eq!(usdc.display("1500000")?, "1.5 USDC");pub fn decimals(&self) -> u8
pub fn symbol(&self) -> Option<&str>
pub fn candid(&self) -> IntType
pub fn threshold(&self) -> Option<&str>
pub fn threshold_message(&self) -> Option<&str>
What the format was built with. threshold_message is the message given, or
DEFAULT_THRESHOLD_MESSAGE, and None when there is no threshold.
pub fn parse(&self, input: &str) -> Result<String, FormatError>
Reads an amount a person wrote, such as "1.5" or "1.5 ICP", and returns the
exact base-unit value as a canonical decimal string. Accepted: an optional -, digits,
optionally a . with digits after it, and optionally whitespace and the token's symbol, in
any ASCII case. Surrounding whitespace is ignored; it is ECMAScript's whitespace set, which includes
U+FEFF and excludes U+0085, as in the reference. The checks, in order:
| Input | Code |
|---|---|
Longer than MAX_INPUT_UTF16_LEN | EE_TOO_LONG |
Thousands separators, exponents, a leading +, "1.", ".5" | EE_SYNTAX |
A minus sign at an unsigned position, "-0" included | EE_NEGATIVE |
| A symbol that is not the token's, or any symbol when the token has none | EE_SYMBOL_MISMATCH |
More fractional digits than decimals, even zeros | EE_EXCESS_PRECISION |
| A value outside the integer type | EE_RANGE |
let eth = AmountFormat::new(18).with_symbol("ckETH")?;
assert_eq!(eth.parse("1.000000000000000001")?, "1000000000000000001");
let byte = AmountFormat::new(0).with_candid(IntType::Nat8);
assert_eq!(byte.parse("256").unwrap_err().code(), Code::Range);pub fn display(&self, base_units: &str) -> Result<String, FormatError>
Shows a base-unit value as a person reads it: the decimal point placed by decimals,
trailing fractional zeros removed, then a space and the symbol if there is one.
"150000000" with 8 decimals is "1.5 ICP"; "1" is
"0.00000001 ICP". A value at or above the threshold shows as the threshold message and the
symbol. base_units must be a canonical decimal integer (no leading zeros, no
+, no "-0", no separators) or the error is EE_SYNTAX; that rules
out candid::Nat's Display, which writes 1_500_000. A value outside
the integer type is EE_RANGE, and text over the length bound EE_TOO_LONG.
pub fn display_u128(&self, base_units: u128) -> Result<String, FormatError>
pub fn display_i128(&self, base_units: i128) -> Result<String, FormatError>display for a value held as a native integer. The range check still applies: display_i128(-1) at a nat position is EE_RANGE.
IntType#
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum IntType {
Nat, Int, Nat8, Nat16, Nat32, Nat64, Int8, Int16, Int32, Int64,
Uint8, Uint16, Uint24, Int24, Uint32, Uint40, Int40, // … to
Uint248, Int248, Uint256, Int256,
}
The integer types the amount tag applies to. The enum is generated by the
int_types! macro in src/int_type.rs, one row per type with its name, sign and
inclusive bounds; it is shown here abridged. More types may come, so it is
#[non_exhaustive]: a match on it needs a _ arm.
| Variants | Name in a profile | Range |
|---|---|---|
Nat | nat | 0 and up, unbounded |
Int | int | unbounded, signed |
Nat8, Nat16, Nat32, Nat64 | nat8 … nat64 | 0 to 2N − 1 |
Int8, Int16, Int32, Int64 | int8 … int64 | −2N−1 to 2N−1 − 1. Both Candid and ABI types, with the same bounds, so each appears once. |
Uint8, Uint16, Uint24 … Uint256 (32 variants) | uint8 … uint256, every multiple of 8 | 0 to 2N − 1 (EVM ABI) |
Int24, Int40, Int48 … Int256 (28 variants) | int24 … int256, every multiple of 8 from 24 to 256 except 32 and 64 | −2N−1 to 2N−1 − 1 (EVM ABI) |
pub const ALL: &'static [IntType]Every type, in the reference's order: the ten Candid types, then the ABI's by width, uint before int. A slice since 0.2.0, so that a later type does not change its type.
pub const fn candid_name(self) -> &'static str
pub const fn is_signed(self) -> bool
pub fn from_candid_name(name: &str) -> Option<Self>
candid_name is the name as a resolved profile spells it ("nat64",
"uint256"). is_signed says whether the type admits negative values.
from_candid_name finds the type with exactly this name, case-sensitive as in Candid and
Solidity, or None.
FormatError#
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FormatError {
code: Code,
message: String,
}
impl FormatError {
pub fn code(&self) -> Code
pub fn message(&self) -> &str
}
A refused value. Switch on code(), which is stable; message() says how to fix
the input and is not stable. Only the crate constructs one. Display writes the code, a
colon and the message:
E_EXCESS_PRECISION: "0.123456789" has 9 fractional digits but this token has 8; amounts are never rounded, so give at most 8With the std feature, FormatError implements std::error::Error, with no source.
Code#
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Code {
Syntax,
Range,
TooLong,
Arg,
TagType,
ExcessPrecision,
Negative,
SymbolMismatch,
}
impl Code {
pub const fn as_str(self) -> &'static str
}
as_str spells the code as the JavaScript reference does, and Display writes the
same. More codes may come with more value tags, so the enum is #[non_exhaustive].
| Variant | Code | When |
|---|---|---|
Syntax | EE_SYNTAX | The text does not match the grammar ("1,5", "1e3", "+1"), or a base-unit value is not a canonical decimal integer. |
Range | EE_RANGE | The value is outside the range of its integer type. |
TooLong | EE_TOO_LONG | The text is longer than MAX_INPUT_UTF16_LEN. |
Arg | EE_ARG | A tag argument is invalid: an empty symbol, a negative threshold. |
TagType | EE_TAG_TYPE | The tag does not apply to this type. |
ExcessPrecision | EE_EXCESS_PRECISION | More fractional digits than the token's decimals. Amounts are never rounded. |
Negative | EE_NEGATIVE | A negative amount for an unsigned type. |
SymbolMismatch | EE_SYMBOL_MISMATCH | The symbol in the input is not the token's symbol, or the token has no symbol. |
MAX_INPUT_UTF16_LEN#
pub const MAX_INPUT_UTF16_LEN: usize = 1024;
The longest text parse and display read, counted in UTF-16 code units as
JavaScript's length counts them, so that work is bounded and a value the reference refuses
for its length is refused here too. Whitespace counts before it is trimmed. An emoji is 4 UTF-8 bytes
but 2 UTF-16 units: 512 of them are exactly at the bound, so the text is read and refused only as
EE_SYNTAX; one space more is EE_TOO_LONG (tests/amount.rs,
the_length_bound_counts_utf16_units).
Parity with @meantjs/format#
tests/parity.rs runs every case of tests/vectors/amount.json, generated from
@meantjs/format 0.2.0, and requires the same value, or the same code and the same message:
3,412 cases, the count the README states and the test pins. The vectors are regenerated with
node crates/meant-format/tests/vectors/generate-amount.mjs <candid-profile checkout>,
never edited by hand. Where the two APIs differ in shape:
decimalsis au8, so a value outside 0 to 255 cannot be written at all.- A threshold message is an argument of
with_threshold, so a message without a threshold, EE_ARG in JavaScript, cannot be written either. - The crate has
display_u128anddisplay_i128; JavaScript'sdisplayAmounttakes abigint. - Only
amount: the other five value tags are in JavaScript only.