@meantjs/format 0.1.0 → 0.2.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,14 @@
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.2.0 — 2026-10-28
6
+
7
+ - EVM integer types (spec §15, D-054): `amount` on `uint8`…`uint256` and `int8`…`int256`, `timestamp` and `duration`
8
+ on `uint32`…`uint256` and `int64`…`int256`, with the bounds of their bits; `bytes` on `bytes` and `bytes1`…`bytes32`
9
+ (a `bytesN` takes exactly N bytes). A resolved-form node with `abi` is read like one with `candid`.
10
+ - `amount` takes `threshold` and `message`: `displayAmount` shows a value at or above the threshold as the message
11
+ (default "Unlimited") and the symbol. Parsing is unchanged.
12
+
5
13
  ## 0.1.0 — 2026-10-28
6
14
 
7
15
  First published version.
package/README.md CHANGED
@@ -17,6 +17,10 @@ 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`.
20
24
 
21
25
  ```sh
22
26
  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.2.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,27 @@ export function checkSymbol(symbol) {
27
30
  return symbol;
28
31
  }
29
32
 
33
+ /** @returns {bigint | undefined} a validated threshold: a canonical decimal integer string or a bigint, not negative */
34
+ export function checkThreshold(threshold) {
35
+ if (threshold === undefined || threshold === null) return undefined;
36
+ if (typeof threshold === "bigint" ? threshold < 0n : typeof threshold !== "string" || !/^(?:0|[1-9][0-9]{0,77})$/.test(threshold)) {
37
+ fail(CODES.ARG, `amount threshold must be a decimal string of base units (0 or more, at most 78 digits) or a bigint, got ${typeof threshold === "string" ? JSON.stringify(threshold) : String(threshold)}`);
38
+ }
39
+ return BigInt(threshold);
40
+ }
41
+
42
+ /** @returns {string | undefined} a validated threshold message: 1 to 32 characters, nothing invisible */
43
+ export function checkMessage(message) {
44
+ if (message === undefined || message === null) return undefined;
45
+ if (typeof message !== "string") fail(CODES.ARG, `amount message must be a string, got ${typeof message}`);
46
+ const n = [...message].length;
47
+ if (n < 1 || n > 32) fail(CODES.ARG, `amount message must be 1 to 32 characters, got ${n}`);
48
+ if (message !== message.trim() || UNSAFE_DISPLAY_CHAR.test(message) || !message.isWellFormed()) {
49
+ fail(CODES.ARG, `amount message ${JSON.stringify(message)} has surrounding whitespace, control or invisible characters`);
50
+ }
51
+ return message;
52
+ }
53
+
30
54
  export function checkDecimals(decimals) {
31
55
  let d = decimals;
32
56
  if (typeof d === "string" && /^(?:0|[1-9][0-9]{0,2})$/.test(d)) d = Number(d);
@@ -55,7 +79,7 @@ export function parseAmount(input, { decimals, symbol, candid = "nat" }) {
55
79
  );
56
80
  }
57
81
  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)`);
82
+ if (sign && !type.signed) fail(CODES.NEGATIVE, `negative amounts are not allowed here (the ${typeWord(candid)} ${candid} is unsigned)`);
59
83
  if (given !== undefined) {
60
84
  if (expected === undefined) fail(CODES.SYMBOL_MISMATCH, `this amount has no symbol; remove "${given}" and give the number only`);
61
85
  if (asciiLower(given) !== asciiLower(expected)) fail(CODES.SYMBOL_MISMATCH, `the symbol "${given}" is not this token's symbol "${expected}"`);
@@ -73,18 +97,23 @@ export function parseAmount(input, { decimals, symbol, candid = "nat" }) {
73
97
  }
74
98
 
75
99
  /**
76
- * Display a base-unit integer as "1.5 ICP": trailing fractional zeros removed, symbol appended.
100
+ * Display a base-unit integer as "1.5 ICP": trailing fractional zeros removed, symbol appended. A value at or above
101
+ * `threshold` displays as `message` (default "Unlimited") and the symbol: "Unlimited USDC" (spec section 5.3).
77
102
  *
78
103
  * @param {string | bigint} value
79
- * @param {{ decimals: number | bigint | string, symbol?: string, candid?: string }} opts
104
+ * @param {{ decimals: number | bigint | string, symbol?: string, candid?: string, threshold?: string | bigint, message?: string }} opts
80
105
  * @returns {string}
81
106
  */
82
- export function displayAmount(value, { decimals, symbol, candid = "nat" }) {
107
+ export function displayAmount(value, { decimals, symbol, candid = "nat", threshold, message }) {
83
108
  const d = checkDecimals(decimals);
84
109
  const sym = checkSymbol(symbol);
110
+ const limit = checkThreshold(threshold);
111
+ const text = checkMessage(message);
112
+ if (text !== undefined && limit === undefined) fail(CODES.ARG, "amount message needs a threshold");
85
113
  const type = intType(candid, AMOUNT_TYPES, "amount");
86
114
  const v = toBigInt(value, "amount");
87
115
  checkRange(v, type, "amount");
116
+ if (limit !== undefined && v >= limit) return `${text ?? DEFAULT_THRESHOLD_MESSAGE}${sym ? ` ${sym}` : ""}`;
88
117
  const digits = (v < 0n ? -v : v).toString().padStart(d + 1, "0");
89
118
  const whole = digits.slice(0, digits.length - d);
90
119
  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";
9
+ import { AMOUNT_TYPES, DEFAULT_THRESHOLD_MESSAGE, checkDecimals, checkMessage, checkSymbol, checkThreshold, displayAmount, parseAmount } from "./amount.mjs";
10
10
  import { TIME_TYPES, 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) {
@@ -68,7 +72,8 @@ function requireUnit(unit, tag) {
68
72
  * @returns {Formatter | null} null when the position has no value tag (render it as Candid text, spec §6)
69
73
  */
70
74
  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");
75
+ if (node !== null && typeof node === "object" && node.candid === undefined && typeof node.abi === "string") node = { ...node, candid: node.abi }; // an EVM position (spec section 15)
76
+ 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
77
  const tags = node.tags ?? {};
73
78
  const present = VALUE_TAGS.filter((t) => Object.hasOwn(tags, t));
74
79
  if (present.length === 0) return null;
@@ -86,8 +91,11 @@ export function formatterFor(node, { refs = {}, now: defaultNow } = {}) {
86
91
  if (raw.decimals === undefined) fail(CODES.ARG, "the amount tag needs decimals");
87
92
  const decimals = checkDecimals(arg("decimals"));
88
93
  const symbol = checkSymbol(arg("symbol"));
89
- const o = { decimals, symbol, candid: node.candid };
90
- args = { decimals, symbol };
94
+ const threshold = checkThreshold(raw.threshold);
95
+ const message = checkMessage(raw.message);
96
+ if (message !== undefined && threshold === undefined) fail(CODES.ARG, "amount message needs a threshold");
97
+ const o = { decimals, symbol, candid: node.candid, threshold, message };
98
+ args = { decimals, symbol, ...(threshold === undefined ? {} : { threshold: threshold.toString(), message: message ?? DEFAULT_THRESHOLD_MESSAGE }) };
91
99
  parse = (input) => parseAmount(input, o);
92
100
  display = (value) => displayAmount(value, o);
93
101
  break;
@@ -127,12 +135,17 @@ export function formatterFor(node, { refs = {}, now: defaultNow } = {}) {
127
135
  break;
128
136
  }
129
137
  case "bytes": {
130
- requireCandid(node, ["blob"], tag);
138
+ requireCandid(node, BYTE_TYPES, tag);
131
139
  const encoding = arg("encoding");
132
140
  if (!BYTES_ENCODINGS.includes(encoding)) fail(CODES.ARG, `the bytes tag needs encoding utf8, hex or base64, got ${JSON.stringify(encoding)}`);
133
141
  const maxLen = raw.max_len;
134
142
  args = { encoding, ...(maxLen === undefined ? {} : { max_len: maxLen }) };
135
- parse = (input) => parseBytes(input, { encoding, maxLen });
143
+ const size = /^bytes(\d+)$/.exec(node.candid)?.[1]; // bytesN holds exactly N bytes
144
+ parse = (input) => {
145
+ const b = parseBytes(input, { encoding, maxLen });
146
+ if (size !== undefined && b.length !== Number(size)) fail(CODES.LENGTH, `${node.candid} holds exactly ${size} bytes, got ${b.length}`);
147
+ return b;
148
+ };
136
149
  display = (value) => displayBytes(value, { encoding });
137
150
  break;
138
151
  }
@@ -165,10 +178,10 @@ export function formatterFor(node, { refs = {}, now: defaultNow } = {}) {
165
178
  // ---------------------------------------------------------------- describe
166
179
 
167
180
  /** 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"] };
181
+ const CANDID_OF = { amount: AMOUNT_TYPES, timestamp: TIME_TYPES, duration: TIME_TYPES, account: ["record"], subaccount: ["blob"], bytes: BYTE_TYPES };
169
182
  /** 2026-01-01T00:00:00Z in ns: the reference instant relative examples are checked against. */
170
183
  const DESCRIBE_NOW = 1_767_225_600_000_000_000n;
171
- const SIGNED = new Set(["int", "int8", "int16", "int32", "int64"]);
184
+
172
185
  // ECMAScript syntax characters only: escaping anything else (such as "-") is an error under the `u` flag, which JSON
173
186
  // Schema validators such as Ajv use.
174
187
  const reEscape = (s) => s.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&");
@@ -178,7 +191,7 @@ const quoted = (xs) => xs.map((x) => JSON.stringify(x)).join(", ");
178
191
 
179
192
  /** The grammar in words (no examples), candidate examples, and a pattern where the grammar is regular. */
180
193
  function grammar(tag, args, candid) {
181
- const signed = SIGNED.has(candid);
194
+ const signed = INT_TYPES[candid]?.signed === true;
182
195
  const finer = (what) => (args.unit === "ns" ? "" : ` Stored in ${args.unit}: a ${what} finer than that is an error, not rounded.`);
183
196
  switch (tag) {
184
197
  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;
@@ -111,6 +115,7 @@ export type ParsedValue = string | Account | Uint8Array;
111
115
  /** One position's formatter: `formatterFor` returns it with the tag's arguments resolved. */
112
116
  export interface Formatter {
113
117
  readonly tag: ValueTag;
118
+ /** The position's Candid type, or its EVM ABI type (`uint256`, `bytes32`) for an EVM position. */
114
119
  readonly candid: string;
115
120
  readonly args: Readonly<Record<string, unknown>>;
116
121
  /** Throws FormatError. */
@@ -122,9 +127,9 @@ export interface Formatter {
122
127
  describe(): Description;
123
128
  }
124
129
 
125
- /** A position of a resolved form, as far as formatting reads it. */
126
- export interface FormatNode {
127
- candid: string;
130
+ /** A position of a resolved form, as far as formatting reads it: `candid`, or `abi` for an EVM contract's (spec section 15). */
131
+ export type FormatNode = FormatNodeFields & ({ candid: string } | { abi: string; candid?: undefined });
132
+ interface FormatNodeFields {
128
133
  tags?: Record<string, { args?: Record<string, unknown> } | undefined>;
129
134
  [key: string]: unknown;
130
135
  }
package/src/time.mjs CHANGED
@@ -10,9 +10,9 @@
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 { ABI_INT_TYPES, checkRange, intType, requireString, toBigInt, typeWord } from "./util.mjs";
14
14
 
15
- export const TIME_TYPES = ["nat", "int", "nat32", "nat64", "int64"];
15
+ 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
16
 
17
17
  const NS_PER = Object.freeze({ ns: 1n, us: 1_000n, ms: 1_000_000n, s: 1_000_000_000n });
18
18
  const NS_PER_S = 1_000_000_000n;
@@ -227,7 +227,7 @@ export function parseDuration(input, { unit, candid = "nat64" } = {}) {
227
227
  const type = intType(candid, TIME_TYPES, "duration");
228
228
  const text = requireString(input, "duration").trim();
229
229
  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)`);
230
+ if (negative && ns !== 0n && !type.signed) fail(CODES.NEGATIVE, `negative durations are not allowed here (the ${typeWord(candid)} ${candid} is unsigned)`);
231
231
  const v = nsToUnit(negative ? -ns : ns, unit);
232
232
  checkRange(v, type, "duration");
233
233
  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