@meantjs/format 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  The five Candid Profile library packages (`@meantjs/profile`, `/format`, `/consent`, `/tools`, `/reconcile`) release together under one version and pin each other exactly; a version appears here even when this package did not change. Release steps: `docs/releasing.md` in the repository.
4
4
 
5
+ ## 0.3.0 — 2026-10-29
6
+
7
+ - `timestamp` takes `threshold` with `message`, and `labels` (spec §5.3, §15.9, D-055): `displayTimestamp` shows a
8
+ labelled value as its words, and a value at or above the threshold as the message ("Never"), never as a date.
9
+ Parsing is unchanged. The two come together, with no default word (`E_ARG`).
10
+ - `formatterFor(node, { token })`: an `amount` whose `token` names its token by a field takes that token's decimals
11
+ and symbol from the caller; without them it is `E_REF_UNRESOLVED`.
12
+
13
+ ## 0.2.0 — 2026-10-28
14
+
15
+ - EVM integer types (spec §15, D-054): `amount` on `uint8`…`uint256` and `int8`…`int256`, `timestamp` and `duration`
16
+ on `uint32`…`uint256` and `int64`…`int256`, with the bounds of their bits; `bytes` on `bytes` and `bytes1`…`bytes32`
17
+ (a `bytesN` takes exactly N bytes). A resolved-form node with `abi` is read like one with `candid`.
18
+ - `amount` takes `threshold` and `message`: `displayAmount` shows a value at or above the threshold as the message
19
+ (default "Unlimited") and the symbol. Parsing is unchanged.
20
+
5
21
  ## 0.1.0 — 2026-10-28
6
22
 
7
23
  First published version.
package/README.md CHANGED
@@ -17,6 +17,14 @@ npm install @meantjs/format
17
17
  - **No dependencies** at run time or for tests. Node >= 22.
18
18
  - **No I/O.** Dynamic tag arguments (`{ "from": "query:icrc1_decimals" }`) are resolved by the caller and passed in.
19
19
  - The choices it made where §5.3 was open were accepted as D-024 and are now in spec §5.3.
20
+ - **EVM types** (spec §15, experimental): a position of an EVM contract's resolved form (`{ abi: "uint256", tags }`)
21
+ works like a Candid one. `amount` takes `uint8`…`uint256` and `int8`…`int256`, `timestamp` and `duration`
22
+ `uint32`…`uint256` and `int64`…`int256`, each with the bounds of its bits; `bytes` takes `bytes` and `bytesN`
23
+ (exactly N bytes). `amount`'s `threshold` and `message` show a value at or above the threshold as `Unlimited USDC`.
24
+ - **Words for times** (spec §5.3, §15.9): `timestamp`'s `labels` show exact values as words, and `threshold` with
25
+ `message` a value at or above it ("Never" for a deadline of `uint256` max, which is no date). Display only.
26
+ - **An amount in a named token** (spec §15.9): an `amount` whose `token` names its token by a field takes that token's
27
+ decimals and symbol from the caller, `formatterFor(node, { token: { decimals: 6, symbol: "USDC" } })`.
20
28
 
21
29
  ```sh
22
30
  npm test -w packages/format # or: npm test at the repo root
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meantjs/format",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Exact value conversion for Candid Profile value tags (amount, timestamp, duration, account, subaccount, bytes): parse human input, display canonical text, never round.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
package/src/amount.mjs CHANGED
@@ -5,9 +5,12 @@
5
5
  // extra digits are zeros (the literal reading of "more than d fractional digits MUST be rejected").
6
6
 
7
7
  import { CODES, fail } from "./errors.mjs";
8
- import { UNSAFE_DISPLAY_CHAR, checkRange, intType, requireString, toBigInt } from "./util.mjs";
8
+ import { ABI_INT_TYPES, UNSAFE_DISPLAY_CHAR, checkRange, intType, requireString, toBigInt, typeWord } from "./util.mjs";
9
9
 
10
- export const AMOUNT_TYPES = ["nat", "int", "nat8", "nat16", "nat32", "nat64", "int8", "int16", "int32", "int64"];
10
+ export const AMOUNT_TYPES = ["nat", "int", "nat8", "nat16", "nat32", "nat64", "int8", "int16", "int32", "int64", ...ABI_INT_TYPES.filter((t) => !/^int(?:8|16|32|64)$/.test(t))];
11
+
12
+ /** The default `message` of an amount at or above its `threshold` (spec section 5.3; ERC-7730's tokenAmount default). */
13
+ export const DEFAULT_THRESHOLD_MESSAGE = "Unlimited";
11
14
 
12
15
  // number, then (after whitespace) an optional symbol. "1.", ".5", "1,5", "1e3" and "+1" do not match.
13
16
  const AMOUNT_RE = /^(-?)([0-9]+)(?:\.([0-9]+))?(?:\s+(\S(?:.*\S)?))?$/s;
@@ -27,6 +30,33 @@ export function checkSymbol(symbol) {
27
30
  return symbol;
28
31
  }
29
32
 
33
+ /**
34
+ * @param {string} [what] how errors name the argument: "amount" (base units), or "timestamp" (the tagged unit)
35
+ * @returns {bigint | undefined} a validated threshold: a canonical decimal integer string or a bigint, not negative
36
+ */
37
+ export function checkThreshold(threshold, what = "amount") {
38
+ if (threshold === undefined || threshold === null) return undefined;
39
+ if (typeof threshold === "bigint" ? threshold < 0n : typeof threshold !== "string" || !/^(?:0|[1-9][0-9]{0,77})$/.test(threshold)) {
40
+ fail(CODES.ARG, `${what} threshold must be a decimal string of ${what === "amount" ? "base units" : "the tagged unit"} (0 or more, at most 78 digits) or a bigint, got ${typeof threshold === "string" ? JSON.stringify(threshold) : String(threshold)}`);
41
+ }
42
+ return BigInt(threshold);
43
+ }
44
+
45
+ /**
46
+ * @param {string} [what] how errors name the text: "amount message", "timestamp message", a timestamp label
47
+ * @returns {string | undefined} a validated threshold message: 1 to 32 characters, nothing invisible
48
+ */
49
+ export function checkMessage(message, what = "amount message") {
50
+ if (message === undefined || message === null) return undefined;
51
+ if (typeof message !== "string") fail(CODES.ARG, `${what} must be a string, got ${typeof message}`);
52
+ const n = [...message].length;
53
+ if (n < 1 || n > 32) fail(CODES.ARG, `${what} must be 1 to 32 characters, got ${n}`);
54
+ if (message !== message.trim() || UNSAFE_DISPLAY_CHAR.test(message) || !message.isWellFormed()) {
55
+ fail(CODES.ARG, `${what} ${JSON.stringify(message)} has surrounding whitespace, control or invisible characters`);
56
+ }
57
+ return message;
58
+ }
59
+
30
60
  export function checkDecimals(decimals) {
31
61
  let d = decimals;
32
62
  if (typeof d === "string" && /^(?:0|[1-9][0-9]{0,2})$/.test(d)) d = Number(d);
@@ -55,7 +85,7 @@ export function parseAmount(input, { decimals, symbol, candid = "nat" }) {
55
85
  );
56
86
  }
57
87
  const [, sign, whole, frac = "", given] = m;
58
- if (sign && !type.signed) fail(CODES.NEGATIVE, `negative amounts are not allowed here (the Candid type ${candid} is unsigned)`);
88
+ if (sign && !type.signed) fail(CODES.NEGATIVE, `negative amounts are not allowed here (the ${typeWord(candid)} ${candid} is unsigned)`);
59
89
  if (given !== undefined) {
60
90
  if (expected === undefined) fail(CODES.SYMBOL_MISMATCH, `this amount has no symbol; remove "${given}" and give the number only`);
61
91
  if (asciiLower(given) !== asciiLower(expected)) fail(CODES.SYMBOL_MISMATCH, `the symbol "${given}" is not this token's symbol "${expected}"`);
@@ -73,18 +103,23 @@ export function parseAmount(input, { decimals, symbol, candid = "nat" }) {
73
103
  }
74
104
 
75
105
  /**
76
- * Display a base-unit integer as "1.5 ICP": trailing fractional zeros removed, symbol appended.
106
+ * Display a base-unit integer as "1.5 ICP": trailing fractional zeros removed, symbol appended. A value at or above
107
+ * `threshold` displays as `message` (default "Unlimited") and the symbol: "Unlimited USDC" (spec section 5.3).
77
108
  *
78
109
  * @param {string | bigint} value
79
- * @param {{ decimals: number | bigint | string, symbol?: string, candid?: string }} opts
110
+ * @param {{ decimals: number | bigint | string, symbol?: string, candid?: string, threshold?: string | bigint, message?: string }} opts
80
111
  * @returns {string}
81
112
  */
82
- export function displayAmount(value, { decimals, symbol, candid = "nat" }) {
113
+ export function displayAmount(value, { decimals, symbol, candid = "nat", threshold, message }) {
83
114
  const d = checkDecimals(decimals);
84
115
  const sym = checkSymbol(symbol);
116
+ const limit = checkThreshold(threshold);
117
+ const text = checkMessage(message);
118
+ if (text !== undefined && limit === undefined) fail(CODES.ARG, "amount message needs a threshold");
85
119
  const type = intType(candid, AMOUNT_TYPES, "amount");
86
120
  const v = toBigInt(value, "amount");
87
121
  checkRange(v, type, "amount");
122
+ if (limit !== undefined && v >= limit) return `${text ?? DEFAULT_THRESHOLD_MESSAGE}${sym ? ` ${sym}` : ""}`;
88
123
  const digits = (v < 0n ? -v : v).toString().padStart(d + 1, "0");
89
124
  const whole = digits.slice(0, digits.length - d);
90
125
  const frac = digits.slice(digits.length - d).replace(/0+$/, "");
package/src/formatter.mjs CHANGED
@@ -6,11 +6,15 @@
6
6
  // resolved by the caller (the library does no I/O) and passed in `refs`, keyed by the `from` string.
7
7
 
8
8
  import { CODES, FormatError, fail } from "./errors.mjs";
9
- import { AMOUNT_TYPES, checkDecimals, checkSymbol, displayAmount, parseAmount } from "./amount.mjs";
10
- import { TIME_TYPES, displayDuration, displayTimestamp, parseDuration, parseTimestamp, unitFactor } from "./time.mjs";
9
+ import { AMOUNT_TYPES, DEFAULT_THRESHOLD_MESSAGE, checkDecimals, checkMessage, checkSymbol, checkThreshold, displayAmount, parseAmount } from "./amount.mjs";
10
+ import { TIME_TYPES, checkTimeLabels, displayDuration, displayTimestamp, parseDuration, parseTimestamp, unitFactor } from "./time.mjs";
11
11
  import { displayAccount, parseAccount } from "./account.mjs";
12
12
  import { displaySubaccount, parseSubaccount } from "./subaccount.mjs";
13
13
  import { BYTES_ENCODINGS, checkMaxLen, displayBytes, parseBytes } from "./bytes.mjs";
14
+ import { INT_TYPES, listTypes } from "./util.mjs";
15
+
16
+ /** The byte types: Candid's blob, the EVM ABI's bytes and bytes1 to bytes32 (spec section 15). */
17
+ const BYTE_TYPES = ["blob", "bytes", ...Array.from({ length: 32 }, (_, i) => `bytes${i + 1}`)];
14
18
 
15
19
  /** The tags that give a position a text form, in spec §5.1. */
16
20
  export const VALUE_TAGS = Object.freeze(["amount", "timestamp", "duration", "account", "subaccount", "bytes"]);
@@ -28,7 +32,7 @@ function resolveArg(arg, refs, where) {
28
32
  }
29
33
 
30
34
  function requireCandid(node, allowed, tag) {
31
- if (!allowed.includes(node.candid)) fail(CODES.TAG_TYPE, `the ${tag} tag applies to ${allowed.join(", ")}, not ${node.candid}`);
35
+ if (!allowed.includes(node.candid)) fail(CODES.TAG_TYPE, `the ${tag} tag applies to ${listTypes(allowed)}, not ${node.candid}`);
32
36
  }
33
37
 
34
38
  function requireUnit(unit, tag) {
@@ -61,14 +65,17 @@ function requireUnit(unit, tag) {
61
65
 
62
66
  /**
63
67
  * @param {{ candid: string, tags?: Record<string, { args?: Record<string, unknown> }> }} node
64
- * @param {{ refs?: Record<string, unknown>, now?: bigint }} [options]
68
+ * @param {{ refs?: Record<string, unknown>, now?: bigint, token?: { decimals: number | bigint | string, symbol?: string } }} [options]
65
69
  * `refs`: resolved dynamic values keyed by their `from` string, for example
66
70
  * `{ "query:icrc1_decimals": 8, "query:icrc1_symbol": "ICP" }`.
67
71
  * `now`: default clock (nanoseconds, bigint) for relative timestamps; `parse(input, { now })` overrides it.
72
+ * `token`: for an amount whose `token` argument names the token by a field (spec section 15.9), the decimals and
73
+ * symbol of the token that field holds in this value; the caller reads them, as it resolves `refs`.
68
74
  * @returns {Formatter | null} null when the position has no value tag (render it as Candid text, spec §6)
69
75
  */
70
- export function formatterFor(node, { refs = {}, now: defaultNow } = {}) {
71
- if (node === null || typeof node !== "object" || typeof node.candid !== "string") fail(CODES.TYPE, "node must be a resolved-form position with a `candid` type");
76
+ export function formatterFor(node, { refs = {}, now: defaultNow, token } = {}) {
77
+ if (node !== null && typeof node === "object" && node.candid === undefined && typeof node.abi === "string") node = { ...node, candid: node.abi }; // an EVM position (spec section 15)
78
+ if (node === null || typeof node !== "object" || typeof node.candid !== "string") fail(CODES.TYPE, "node must be a resolved-form position with a `candid` type (or an EVM position's `abi` type)");
72
79
  const tags = node.tags ?? {};
73
80
  const present = VALUE_TAGS.filter((t) => Object.hasOwn(tags, t));
74
81
  if (present.length === 0) return null;
@@ -83,11 +90,27 @@ export function formatterFor(node, { refs = {}, now: defaultNow } = {}) {
83
90
  switch (tag) {
84
91
  case "amount": {
85
92
  requireCandid(node, AMOUNT_TYPES, tag);
86
- if (raw.decimals === undefined) fail(CODES.ARG, "the amount tag needs decimals");
87
- const decimals = checkDecimals(arg("decimals"));
88
- const symbol = checkSymbol(arg("symbol"));
89
- const o = { decimals, symbol, candid: node.candid };
90
- args = { decimals, symbol };
93
+ let decimals;
94
+ let symbol;
95
+ if (raw.token !== undefined) {
96
+ // The token is the one a field of the value names (spec section 15.9): its metadata comes with the value.
97
+ if (typeof raw.token !== "string" || raw.token === "") fail(CODES.ARG, "amount token must be a relative path to an address");
98
+ if (raw.decimals !== undefined || raw.symbol !== undefined) fail(CODES.ARG, "an amount with a token takes that token's decimals and symbol, not its own");
99
+ if (token === undefined || token === null || typeof token !== "object") {
100
+ fail(CODES.REF_UNRESOLVED, `amount.token: the amount is in the token at ${JSON.stringify(raw.token)}; pass that token's decimals and symbol as \`token\``);
101
+ }
102
+ decimals = checkDecimals(token.decimals);
103
+ symbol = checkSymbol(token.symbol);
104
+ } else {
105
+ if (raw.decimals === undefined) fail(CODES.ARG, "the amount tag needs decimals");
106
+ decimals = checkDecimals(arg("decimals"));
107
+ symbol = checkSymbol(arg("symbol"));
108
+ }
109
+ const threshold = checkThreshold(raw.threshold);
110
+ const message = checkMessage(raw.message);
111
+ if (message !== undefined && threshold === undefined) fail(CODES.ARG, "amount message needs a threshold");
112
+ const o = { decimals, symbol, candid: node.candid, threshold, message };
113
+ args = { decimals, symbol, ...(raw.token === undefined ? {} : { token: raw.token }), ...(threshold === undefined ? {} : { threshold: threshold.toString(), message: message ?? DEFAULT_THRESHOLD_MESSAGE }) };
91
114
  parse = (input) => parseAmount(input, o);
92
115
  display = (value) => displayAmount(value, o);
93
116
  break;
@@ -96,10 +119,20 @@ export function formatterFor(node, { refs = {}, now: defaultNow } = {}) {
96
119
  requireCandid(node, TIME_TYPES, tag);
97
120
  if (raw.epoch !== undefined && raw.epoch !== "unix") fail(CODES.ARG, `timestamp epoch must be "unix", got ${JSON.stringify(raw.epoch)}`);
98
121
  const unit = requireUnit(arg("unit"), tag);
122
+ const threshold = checkThreshold(raw.threshold, "timestamp");
123
+ const message = checkMessage(raw.message, "timestamp message");
124
+ if ((threshold === undefined) !== (message === undefined)) fail(CODES.ARG, "a timestamp threshold and its message go together: give both or neither");
125
+ const labels = checkTimeLabels(raw.labels);
99
126
  const o = { unit, candid: node.candid };
100
- args = { unit, epoch: "unix" };
127
+ const shown = { ...o, threshold, message, labels: raw.labels };
128
+ args = {
129
+ unit,
130
+ epoch: "unix",
131
+ ...(threshold === undefined ? {} : { threshold: threshold.toString(), message }),
132
+ ...(labels === undefined ? {} : { labels: Object.fromEntries([...labels].map(([k, v]) => [k.toString(), v])) }),
133
+ };
101
134
  parse = (input, ctx = {}) => parseTimestamp(input, { ...o, now: ctx.now ?? defaultNow });
102
- display = (value) => displayTimestamp(value, o);
135
+ display = (value) => displayTimestamp(value, shown);
103
136
  break;
104
137
  }
105
138
  case "duration": {
@@ -127,12 +160,17 @@ export function formatterFor(node, { refs = {}, now: defaultNow } = {}) {
127
160
  break;
128
161
  }
129
162
  case "bytes": {
130
- requireCandid(node, ["blob"], tag);
163
+ requireCandid(node, BYTE_TYPES, tag);
131
164
  const encoding = arg("encoding");
132
165
  if (!BYTES_ENCODINGS.includes(encoding)) fail(CODES.ARG, `the bytes tag needs encoding utf8, hex or base64, got ${JSON.stringify(encoding)}`);
133
166
  const maxLen = raw.max_len;
134
167
  args = { encoding, ...(maxLen === undefined ? {} : { max_len: maxLen }) };
135
- parse = (input) => parseBytes(input, { encoding, maxLen });
168
+ const size = /^bytes(\d+)$/.exec(node.candid)?.[1]; // bytesN holds exactly N bytes
169
+ parse = (input) => {
170
+ const b = parseBytes(input, { encoding, maxLen });
171
+ if (size !== undefined && b.length !== Number(size)) fail(CODES.LENGTH, `${node.candid} holds exactly ${size} bytes, got ${b.length}`);
172
+ return b;
173
+ };
136
174
  display = (value) => displayBytes(value, { encoding });
137
175
  break;
138
176
  }
@@ -165,10 +203,10 @@ export function formatterFor(node, { refs = {}, now: defaultNow } = {}) {
165
203
  // ---------------------------------------------------------------- describe
166
204
 
167
205
  /** The Candid types each value tag applies to (the `requireCandid` calls in `formatterFor`). */
168
- const CANDID_OF = { amount: AMOUNT_TYPES, timestamp: TIME_TYPES, duration: TIME_TYPES, account: ["record"], subaccount: ["blob"], bytes: ["blob"] };
206
+ const CANDID_OF = { amount: AMOUNT_TYPES, timestamp: TIME_TYPES, duration: TIME_TYPES, account: ["record"], subaccount: ["blob"], bytes: BYTE_TYPES };
169
207
  /** 2026-01-01T00:00:00Z in ns: the reference instant relative examples are checked against. */
170
208
  const DESCRIBE_NOW = 1_767_225_600_000_000_000n;
171
- const SIGNED = new Set(["int", "int8", "int16", "int32", "int64"]);
209
+
172
210
  // ECMAScript syntax characters only: escaping anything else (such as "-") is an error under the `u` flag, which JSON
173
211
  // Schema validators such as Ajv use.
174
212
  const reEscape = (s) => s.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&");
@@ -178,7 +216,7 @@ const quoted = (xs) => xs.map((x) => JSON.stringify(x)).join(", ");
178
216
 
179
217
  /** The grammar in words (no examples), candidate examples, and a pattern where the grammar is regular. */
180
218
  function grammar(tag, args, candid) {
181
- const signed = SIGNED.has(candid);
219
+ const signed = INT_TYPES[candid]?.signed === true;
182
220
  const finer = (what) => (args.unit === "ns" ? "" : ` Stored in ${args.unit}: a ${what} finer than that is an error, not rounded.`);
183
221
  switch (tag) {
184
222
  case "amount": {
package/src/index.d.mts CHANGED
@@ -52,8 +52,12 @@ export declare class FormatError extends Error {
52
52
  export interface AmountOptions {
53
53
  decimals: number | bigint | string;
54
54
  symbol?: string;
55
- /** The Candid type, for the sign and range (default `nat`). */
55
+ /** The Candid type, or an EVM ABI integer type (`uint256`), for the sign and range (default `nat`). */
56
56
  candid?: string;
57
+ /** Display only: a value at or above it (base units) displays as `message` and the symbol, "Unlimited USDC". */
58
+ threshold?: string | bigint;
59
+ /** What a value at or above `threshold` displays as (default "Unlimited"); needs `threshold`. */
60
+ message?: string;
57
61
  }
58
62
  /** "1.5" or "1.5 ICP" to base units as a decimal string. Excess precision throws; nothing is rounded. */
59
63
  export function parseAmount(input: string, options: AmountOptions): string;
@@ -66,8 +70,21 @@ export function convertUnit(value: string | bigint, from: TimeUnit, to: TimeUnit
66
70
  export function alignNow(nowNs: bigint, unit: TimeUnit): bigint;
67
71
  /** An RFC 3339 time, or "+" and an ISO 8601 duration from `now` (ns, required then). Returns the count of `unit`. */
68
72
  export function parseTimestamp(input: string, options: { unit: TimeUnit; candid?: string; now?: bigint }): string;
69
- /** Canonical RFC 3339 UTC, for example "2026-10-09T12:00:00.5Z". */
70
- export function displayTimestamp(value: string | bigint, options: { unit: TimeUnit; candid?: string }): string;
73
+ export interface TimestampOptions {
74
+ unit: TimeUnit;
75
+ candid?: string;
76
+ /** Display only (spec section 15.9): a value at or above it (in `unit`) displays as `message`; needs `message`. */
77
+ threshold?: string | bigint;
78
+ /** What a value at or above `threshold` displays as ("Never"); needs `threshold`. */
79
+ message?: string;
80
+ /** Display only: exact values (decimal strings of `unit`) shown as words, `{ "0": "Only in the block it is used" }`. */
81
+ labels?: Record<string, string>;
82
+ }
83
+ /**
84
+ * Canonical RFC 3339 UTC, for example "2026-10-09T12:00:00.5Z"; a value `labels` names, or at or above `threshold`,
85
+ * displays as its words instead, so that it is never read as a date.
86
+ */
87
+ export function displayTimestamp(value: string | bigint, options: TimestampOptions): string;
71
88
  /** An ISO 8601 duration of W, D, H, M and S ("PT24H"); returns the count of `unit`. */
72
89
  export function parseDuration(input: string, options: { unit: TimeUnit; candid?: string }): string;
73
90
  /** Canonical ISO 8601: "P1D", "PT1H30M", "PT0S". */
@@ -111,6 +128,7 @@ export type ParsedValue = string | Account | Uint8Array;
111
128
  /** One position's formatter: `formatterFor` returns it with the tag's arguments resolved. */
112
129
  export interface Formatter {
113
130
  readonly tag: ValueTag;
131
+ /** The position's Candid type, or its EVM ABI type (`uint256`, `bytes32`) for an EVM position. */
114
132
  readonly candid: string;
115
133
  readonly args: Readonly<Record<string, unknown>>;
116
134
  /** Throws FormatError. */
@@ -122,9 +140,9 @@ export interface Formatter {
122
140
  describe(): Description;
123
141
  }
124
142
 
125
- /** A position of a resolved form, as far as formatting reads it. */
126
- export interface FormatNode {
127
- candid: string;
143
+ /** A position of a resolved form, as far as formatting reads it: `candid`, or `abi` for an EVM contract's (spec section 15). */
144
+ export type FormatNode = FormatNodeFields & ({ candid: string } | { abi: string; candid?: undefined });
145
+ interface FormatNodeFields {
128
146
  tags?: Record<string, { args?: Record<string, unknown> } | undefined>;
129
147
  [key: string]: unknown;
130
148
  }
@@ -137,8 +155,10 @@ export const VALUE_TAGS: readonly ValueTag[];
137
155
  *
138
156
  * @param options.refs dynamic tag values keyed by their `from`: `{ "query:icrc1_decimals": 8 }`
139
157
  * @param options.now default clock for relative timestamps, ns since the Unix epoch
158
+ * @param options.token for an amount whose `token` names its token by a field (spec section 15.9): the decimals and
159
+ * symbol of the token that field holds in this value, which the caller reads as it resolves `refs`
140
160
  */
141
- export function formatterFor(node: FormatNode, options?: { refs?: Record<string, unknown>; now?: bigint }): Formatter | null;
161
+ export function formatterFor(node: FormatNode, options?: { refs?: Record<string, unknown>; now?: bigint; token?: { decimals: number | bigint | string; symbol?: string } }): Formatter | null;
142
162
  /** `Formatter.describe` without a formatter; `accepts` keeps only the examples it returns true for. */
143
163
  export function describeInput(tag: ValueTag, args: Readonly<Record<string, unknown>>, candid: string, options?: { accepts?: (input: string) => boolean }): Description;
144
164
 
package/src/time.mjs CHANGED
@@ -10,9 +10,10 @@
10
10
  // millisecond precision.
11
11
 
12
12
  import { CODES, fail } from "./errors.mjs";
13
- import { checkRange, intType, requireString, toBigInt } from "./util.mjs";
13
+ import { checkMessage, checkThreshold } from "./amount.mjs";
14
+ import { ABI_INT_TYPES, checkRange, intType, requireString, toBigInt, typeWord } from "./util.mjs";
14
15
 
15
- export const TIME_TYPES = ["nat", "int", "nat32", "nat64", "int64"];
16
+ export const TIME_TYPES = ["nat", "int", "nat32", "nat64", "int64", ...ABI_INT_TYPES.filter((t) => (t.startsWith("u") ? Number(t.slice(4)) >= 32 : Number(t.slice(3)) > 64))];
16
17
 
17
18
  const NS_PER = Object.freeze({ ns: 1n, us: 1_000n, ms: 1_000_000n, s: 1_000_000_000n });
18
19
  const NS_PER_S = 1_000_000_000n;
@@ -203,15 +204,43 @@ export function parseTimestamp(input, { unit, candid = "nat64", now } = {}) {
203
204
  }
204
205
 
205
206
  /**
206
- * Display a timestamp as canonical RFC 3339 UTC, for example "2026-10-09T12:00:00.5Z".
207
+ * A timestamp's `labels` (spec section 15.9): words for exact values, keyed by the value in the tagged unit as a
208
+ * canonical decimal string. At most 16, each 1 to 32 characters with nothing invisible.
209
+ *
210
+ * @returns {Map<bigint, string> | undefined}
211
+ */
212
+ export function checkTimeLabels(labels) {
213
+ if (labels === undefined || labels === null) return undefined;
214
+ if (typeof labels !== "object" || Array.isArray(labels)) fail(CODES.ARG, "timestamp labels must be an object of value to words");
215
+ const entries = Object.entries(labels);
216
+ if (entries.length < 1 || entries.length > 16) fail(CODES.ARG, `timestamp labels must have 1 to 16 entries, got ${entries.length}`);
217
+ const out = new Map();
218
+ for (const [k, v] of entries) {
219
+ if (!/^(?:0|[1-9][0-9]{0,77})$/.test(k)) fail(CODES.ARG, `a timestamp label's value must be a decimal string of the tagged unit (0 or more, at most 78 digits), got ${JSON.stringify(k)}`);
220
+ out.set(BigInt(k), checkMessage(v, `timestamp label of ${k}`));
221
+ }
222
+ return out;
223
+ }
224
+
225
+ /**
226
+ * Display a timestamp as canonical RFC 3339 UTC, for example "2026-10-09T12:00:00.5Z". Display only (spec section
227
+ * 15.9): a value that `labels` names shows its words, and a value at or above `threshold` shows `message` ("Never"
228
+ * for a deadline of uint256 max), so that such a value is never read as a date.
207
229
  *
208
230
  * @param {string | bigint} value integer count of `unit` since the Unix epoch
231
+ * @param {{ unit: string, candid?: string, threshold?: string | bigint, message?: string, labels?: Record<string, string> }} opts
209
232
  */
210
- export function displayTimestamp(value, { unit, candid = "nat64" } = {}) {
233
+ export function displayTimestamp(value, { unit, candid = "nat64", threshold, message, labels } = {}) {
211
234
  const f = unitFactor(unit);
212
235
  const type = intType(candid, TIME_TYPES, "timestamp");
236
+ const limit = checkThreshold(threshold, "timestamp");
237
+ const text = checkMessage(message, "timestamp message");
238
+ if ((limit === undefined) !== (text === undefined)) fail(CODES.ARG, "a timestamp threshold and its message go together: give both or neither");
239
+ const words = checkTimeLabels(labels);
213
240
  const v = toBigInt(value, "timestamp");
214
241
  checkRange(v, type, "timestamp");
242
+ if (words?.has(v)) return words.get(v);
243
+ if (limit !== undefined && v >= limit) return text;
215
244
  return rfc3339FromNs(v * f);
216
245
  }
217
246
 
@@ -227,7 +256,7 @@ export function parseDuration(input, { unit, candid = "nat64" } = {}) {
227
256
  const type = intType(candid, TIME_TYPES, "duration");
228
257
  const text = requireString(input, "duration").trim();
229
258
  const { negative, ns } = parseDurationNs(text, "a duration");
230
- if (negative && ns !== 0n && !type.signed) fail(CODES.NEGATIVE, `negative durations are not allowed here (the Candid type ${candid} is unsigned)`);
259
+ if (negative && ns !== 0n && !type.signed) fail(CODES.NEGATIVE, `negative durations are not allowed here (the ${typeWord(candid)} ${candid} is unsigned)`);
231
260
  const v = nsToUnit(negative ? -ns : ns, unit);
232
261
  checkRange(v, type, "duration");
233
262
  return v.toString();
package/src/util.mjs CHANGED
@@ -5,7 +5,11 @@ export const MAX_INPUT_CHARS = 1024;
5
5
 
6
6
  const DEC_INT = /^-?(?:0|[1-9][0-9]*)$/;
7
7
 
8
- /** The Candid integer types the value tags apply to, with their inclusive bounds (null = unbounded). */
8
+ /**
9
+ * The integer types the value tags apply to, with their inclusive bounds (null = unbounded): Candid's, then the EVM ABI's
10
+ * uint8 to uint256 and int8 to int256 (spec section 15). int8 to int64 are both Candid and ABI types, with the same
11
+ * bounds; the others carry `abi: true`, which only changes the wording of messages.
12
+ */
9
13
  export const INT_TYPES = Object.freeze({
10
14
  nat: { signed: false, min: 0n, max: null },
11
15
  nat8: { signed: false, min: 0n, max: 2n ** 8n - 1n },
@@ -17,12 +21,34 @@ export const INT_TYPES = Object.freeze({
17
21
  int16: { signed: true, min: -(2n ** 15n), max: 2n ** 15n - 1n },
18
22
  int32: { signed: true, min: -(2n ** 31n), max: 2n ** 31n - 1n },
19
23
  int64: { signed: true, min: -(2n ** 63n), max: 2n ** 63n - 1n },
24
+ ...Object.fromEntries(
25
+ Array.from({ length: 32 }, (_, i) => BigInt(8 * (i + 1))).flatMap((b) => [
26
+ [`uint${b}`, { signed: false, min: 0n, max: 2n ** b - 1n, abi: true }],
27
+ ...(b <= 64n && (b & (b - 1n)) === 0n ? [] : [[`int${b}`, { signed: true, min: -(2n ** (b - 1n)), max: 2n ** (b - 1n) - 1n, abi: true }]]),
28
+ ]),
29
+ ),
20
30
  });
21
31
 
32
+ /** The EVM ABI integer types (uint8 to uint256, int8 to int256), in that order. */
33
+ export const ABI_INT_TYPES = Object.freeze(Array.from({ length: 32 }, (_, i) => 8 * (i + 1)).flatMap((b) => [`uint${b}`, `int${b}`]));
34
+
35
+ /** How a message names the type: "ABI type" for the EVM-only integer types, "Candid type" otherwise. */
36
+ export const typeWord = (name) => (INT_TYPES[name]?.abi ? "ABI type" : "Candid type");
37
+
38
+ /** A list of types for a message: the Candid types by name, the ABI's integer and byte families by range. */
39
+ export function listTypes(types) {
40
+ const candid = types.filter((t) => !INT_TYPES[t]?.abi && !/^bytes\d*$/.test(t));
41
+ const abi = [];
42
+ if (types.includes("uint8")) abi.push("uint8 to uint256", "int8 to int256");
43
+ else if (types.includes("uint32")) abi.push("uint32 to uint256", "int72 to int256");
44
+ if (types.includes("bytes")) abi.push("bytes", "bytes1 to bytes32");
45
+ return [...candid, ...abi].join(", ");
46
+ }
47
+
22
48
  /** @param {string} candid @param {string[]} allowed */
23
49
  export function intType(candid, allowed, tag) {
24
50
  if (!allowed.includes(candid)) {
25
- fail(CODES.TAG_TYPE, `the ${tag} tag applies to ${allowed.join(", ")}, not ${candid}`);
51
+ fail(CODES.TAG_TYPE, `the ${tag} tag applies to ${listTypes(allowed)}, not ${candid}`);
26
52
  }
27
53
  return INT_TYPES[candid];
28
54
  }
@@ -53,9 +79,10 @@ export function toBigInt(value, what = "value") {
53
79
  }
54
80
 
55
81
  export function checkRange(v, type, what) {
56
- if (!type.signed && v < 0n) fail(CODES.RANGE, `${what} ${v} is negative but the Candid type is unsigned`);
57
- if (type.min !== null && v < type.min) fail(CODES.RANGE, `${what} ${v} is below the minimum ${type.min} of the Candid type`);
58
- if (type.max !== null && v > type.max) fail(CODES.RANGE, `${what} ${v} is above the maximum ${type.max} of the Candid type`);
82
+ const word = type.abi ? "ABI type" : "Candid type";
83
+ if (!type.signed && v < 0n) fail(CODES.RANGE, `${what} ${v} is negative but the ${word} is unsigned`);
84
+ if (type.min !== null && v < type.min) fail(CODES.RANGE, `${what} ${v} is below the minimum ${type.min} of the ${word}`);
85
+ if (type.max !== null && v > type.max) fail(CODES.RANGE, `${what} ${v} is above the maximum ${type.max} of the ${word}`);
59
86
  }
60
87
 
61
88
  // ---------------------------------------------------------------- bytes helpers