@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 +16 -0
- package/README.md +8 -0
- package/package.json +1 -1
- package/src/amount.mjs +41 -6
- package/src/formatter.mjs +56 -18
- package/src/index.d.mts +27 -7
- package/src/time.mjs +34 -5
- package/src/util.mjs +32 -5
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
70
|
-
|
|
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
|
|
127
|
-
|
|
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 {
|
|
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
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
57
|
-
if (type.
|
|
58
|
-
if (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
|