@meantjs/consent 0.2.0 → 0.4.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,38 @@
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.4.0 — 2026-10-30
6
+
7
+ - What a request grants (spec §5.3, D-056). `evmConsent`, `eip712Consent` and `generateConsent` also return `values`,
8
+ the decoded arguments or message, and `grant`: who may take tokens because of the request, which token, how many
9
+ and until when, read from the effect's `grant` (`{ kind, spender | to, token, amount: { units, unlimited }, ends }`),
10
+ or null. Addresses are EIP-55, accounts their ICRC-1 text, units a decimal string and ends exact seconds, never
11
+ rounded. A grant that cannot be read is null with a `W_GRANT` diagnostic, and the text is the same.
12
+ `generateConsent` takes `canisterId`, the ledger called, and reads the grant only then, so an ICRC-21 message is
13
+ unchanged.
14
+ - `@meantjs/consent/terms`, new: terms the user confirms for one task, and the check of a request's grant against them
15
+ (spec §5.3, "Confirmed terms"; §11 item 10). `readTerms` reads an agent's terms in the wallet's values (a token by
16
+ its symbol, an app by its name, an amount never rounded, an end as a date, a length, hours or "never") or throws a
17
+ `TermsError` with a message for the agent; `termsText` writes them out exactly for the wallet's own screen;
18
+ `confirmTerms` freezes them with an id; `checkGrant` says whether a grant fits, and why not; `termsGate` keeps the
19
+ order (confirmed before the request is read, fixed after, one request, named by the id the wallet gives it). It runs
20
+ in browsers.
21
+
22
+ ## 0.3.0 — 2026-10-29
23
+
24
+ - `@meantjs/consent/evm`: `eip712Consent`, the consent text of an EIP-712 signing request (spec §15.9, D-055),
25
+ matched to a message of the resolved form by its type hash and refused before anything is shown when its domain,
26
+ contract, type hash or values do not match (new codes `E_DOMAIN` and `E_TYPE_HASH`). The text ends with the
27
+ verifying contract, the EIP-712 digest and the note that a signature is not a transaction. `evmConsent` and
28
+ `eip712Consent` take `tokens`, the decimals and symbol of each token an amount's `token` field names; the token's
29
+ address is shown beside its symbol.
30
+ - `@meantjs/consent/erc7730`: `erc7730Descriptor(resolved, { context: "eip712" })` writes the ERC-7730 file of the
31
+ messages (formats keyed by `encode_type`, field groups for arrays of tokens). It returns `notes`: a timestamp's
32
+ words and a template ERC-7730 cannot interpolate are left out with a note instead of refusing the file, for
33
+ functions as well. Every file 0.2.0 writes is written the same.
34
+ - ICRC-21 fields: an amount at or above its threshold, and a timestamp shown as words, are `Text`, never a
35
+ `TokenAmount` or `TimestampSeconds` of the raw number.
36
+
5
37
  ## 0.2.0 — 2026-10-28
6
38
 
7
39
  - A call takes the `intent` and `effect` of the deepest tagged variant case its arguments hold, else the method's
package/README.md CHANGED
@@ -6,8 +6,8 @@ ICRC-21 consent messages, and the text an SNS proposal's validator returns, from
6
6
  npm install @meantjs/consent
7
7
  ```
8
8
 
9
- - **Runs on:** Node 22 or later and browsers for the main entry and the EVM subpaths (no Node built-in, no `Buffer`;
10
- checked with esbuild). `@meantjs/consent/sns` hashes with `node:crypto` and is Node only.
9
+ - **Runs on:** Node 22 or later and browsers for the main entry, the EVM subpaths and `./terms` (no Node built-in, no
10
+ `Buffer`; checked with esbuild). `@meantjs/consent/sns` hashes with `node:crypto` and is Node only.
11
11
  - **Module format:** ES modules only, with hand-written TypeScript declarations for every entry.
12
12
  - **Spec:** [Candid Profile v0](https://github.com/B3Pay/Meant/blob/main/profile/docs/profile-v0.md). **Licence:** Apache-2.0.
13
13
  - **Versions:** the five Candid Profile packages (`@meantjs/profile`, `/format`, `/consent`, `/tools`,
@@ -47,7 +47,7 @@ bundles it with esbuild for the browser platform). The three SNS rows below are
47
47
  | `snsValidatorRendering({ payload, resolved, method, refs, limits })` | An SNS generic-function validator as a function: payload bytes in, `{ Ok: text }` or `{ Err: text }` out (section below). Non-enumerable `diagnostics` and `provenance`, as above. |
48
48
  | `handleSnsValidatorCall(payload, context)` / `encodeValidatorResult` / `decodeValidatorResult` | The canister-side wrapper and the codec of `variant { Ok : text; Err : text }`. |
49
49
  | `verifySnsRendering({ payloadTextRendering, payload, resolved, targetCanisterId, method, refs })`, `parseGovernanceRendering(text)` | The reviewer's check of a stored proposal rendering; `SNS_RENDERING_RULES` names the rules. |
50
- | `generateConsent({ resolved, method, arg, refs, form, utcOffsetMinutes, rawArguments })` | One form without the ICRC-21 envelope; returns `{ message, zoneSensitive, diagnostics, provenance, model }`. |
50
+ | `generateConsent({ resolved, method, arg, refs, form, utcOffsetMinutes, rawArguments, canisterId })` | One form without the ICRC-21 envelope; returns `{ message, zoneSensitive, values, grant, diagnostics, provenance, model }`. `values`: the decoded arguments. `grant`: what the call grants (section "Terms the user confirms"), read only when `canisterId` names the canister called, its token; else null. |
51
51
  | `decodeArguments(method, bytes, limits)` | Step 1 alone: argument bytes to the value tree. |
52
52
  | `parseTemplate`, `resolvePath`, `valuesAt`, `Renderer`, `renderFields`, `renderGeneric`, `codeSpan`, `escapeMarkdown`, `visible` | The parts. |
53
53
 
@@ -63,26 +63,32 @@ bundles it with esbuild for the browser platform). The three SNS rows below are
63
63
 
64
64
  ## EVM contracts (experimental: `src/evm.mjs`, `src/erc7730.mjs`)
65
65
 
66
- Spec section 15, D-054. An EVM contract's resolved form (`"chain": "evm"`, from `resolveService({ abi })` of
66
+ Spec section 15, D-054 and D-055 (EIP-712 messages, §15.9). An EVM contract's resolved form (`"chain": "evm"`, from `resolveService({ abi })` of
67
67
  `@meantjs/profile`) is read by two subpaths; the main entry refuses it with `E_RESOLVED_FORM`, and `evmConsent` refuses
68
68
  a Candid one.
69
69
 
70
70
  | Function | Does |
71
71
  |---|---|
72
72
  | `decodeCalldata(resolved, calldata, limits)` (`/evm`) | The function a selector names and its arguments, keyed by parameter name. Strict: values read from anything but their canonical ABI encoding are `E_DECODE`. |
73
- | `evmConsent({ resolved, calldata, to, chainId, refs, form, utcOffsetMinutes, rawArguments, limits })` (`/evm`) | The consent text of a call: the same model and the same two forms as ICRC-21, without its envelope (`"generic"` Markdown, or `"fields"` as an intent line and `[label, text]` rows), ending with the contract's address and chain, which a person must see because a pack matches any contract with the right functions. A call to a contract its profiles do not list is `E_DEPLOYMENT`; a payable function is `E_UNSUPPORTED_TYPE` (its native value has no position yet). |
74
- | `erc7730Descriptor(resolved, { deployments, owner, contractName, info, token })` (`/erc7730`) | The contract's ERC-7730 clear-signing file and its ERC-8176 `descriptorHash`: deterministic, `$schema` pinned to the exact 2.1.0 file, one entry per function a transaction calls. What an ERC-7730 file cannot say (a time in ms, `utf8` bytes, an amount in another token, an unnamed parameter, a placeholder over an array) is `E_EXPORT`, never written as something near it; no deployment is `E_DEPLOYMENT`. The mapping is the table in spec §15.7. |
73
+ | `evmConsent({ resolved, calldata, to, chainId, refs, tokens, form, utcOffsetMinutes, rawArguments, limits })` (`/evm`) | The consent text of a call: the same model and the same two forms as ICRC-21, without its envelope (`"generic"` Markdown, or `"fields"` as an intent line and `[label, text]` rows), ending with the contract's address and chain, which a person must see because a pack matches any contract with the right functions. A call to a contract its profiles do not list is `E_DEPLOYMENT`; a payable function is `E_UNSUPPORTED_TYPE` (its native value has no position yet). `tokens`: the decimals and symbol of each token an amount's `token` field names, by address; the address is shown beside the symbol. |
74
+ | `eip712Consent({ resolved, typedData, chainId, refs, tokens, form, utcOffsetMinutes, rawArguments, limits })` (`/evm`) | The consent text of an EIP-712 signing request (`eth_signTypedData_v4`'s JSON), matched to a message of the resolved form by its type hash, never its name. Refused before anything is shown: a domain for another chain than the wallet's, without `chainId` or `verifyingContract`, with a salt, or whose name or version is not its profiles' (`E_DOMAIN`); a contract its profiles do not list (`E_DEPLOYMENT`); no message with the type hash (`E_TYPE_HASH`); values that are not exactly the members, canonically written (`E_DECODE`); a token not in `tokens` (`E_REF_UNRESOLVED`). Ends with the verifying contract, the EIP-712 digest and "This is a signature, not a transaction: whoever holds it can submit it." Returns `typeHash`, `digest`, `verifyingContract` and `chainId` with the text. |
75
+ | `values`, `grant` (both, `/evm`) | `evmConsent` and `eip712Consent` also return the decoded arguments or message by name (`values`), and what the call or signature grants (`grant`, below), or null. |
76
+ | `erc7730Descriptor(resolved, { context, deployments, owner, contractName, info, token })` (`/erc7730`) | The contract's ERC-7730 clear-signing file and its ERC-8176 `descriptorHash`: deterministic, `$schema` pinned to the exact 2.1.0 file, one entry per function a transaction calls, or with `context: "eip712"` one per message, keyed by its `encodeType`. What an ERC-7730 file cannot say (a time in ms, `utf8` bytes, an amount in another token, an unnamed parameter) is `E_EXPORT`, never written as something near it; no deployment is `E_DEPLOYMENT`. What it cannot say but changes no value (a timestamp's "Never", a template over an array) is left out and listed in `notes`. The mapping is spec §15.7 and §15.9. |
75
77
  | `descriptorHash(descriptor)` (`/erc7730`) | Keccak-256 of the RFC 8785 form: the hash of a file someone edited. |
76
78
  | `erc8176Attestation({ descriptorHash, time, expirationTime, salt, refUID })` (`/erc7730`) | The unsigned ERC-8176 attestation (EAS offchain v2 typed data) and its EIP-712 digest, for a reviewer to sign with their own key. Nothing is signed or sent. |
77
79
 
78
80
  ```js
79
- import { evmConsent } from "@meantjs/consent/evm";
81
+ import { eip712Consent, evmConsent } from "@meantjs/consent/evm";
80
82
  import { erc7730Descriptor, erc8176Attestation } from "@meantjs/consent/erc7730";
81
83
 
82
84
  evmConsent({ resolved, calldata, to: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", refs: { "query:decimals": 18, "query:symbol": "WETH" } }).message;
83
85
  // # Send tokens\n\nSend `1.5 WETH` to `0xfB69…d359`\n\n- **Amount:** `1.5 WETH`\n- **Recipient:** `0xfB69…d359`\n\n**Contract:** `0xC02a…6Cc2`
84
86
  const { descriptor, descriptorHash } = erc7730Descriptor(resolved, { deployments: [{ chainId: 1, address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" }] });
85
87
  const { typedData, digest } = erc8176Attestation({ descriptorHash, time: 1777630591, expirationTime: 1785406591, salt });
88
+
89
+ // A Permit2 signing request, with the profile in @meantjs/profile/packs/permit2.profile.json
90
+ eip712Consent({ resolved: permit2, typedData, chainId: 1, tokens: { "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48": { decimals: 6, symbol: "USDC" } } }).message;
91
+ // # Permit2: allow token spending\n\nLet `0x66a9…A8Af` spend up to `Unlimited USDC (0xA0b8…eB48)`. Allowance expires: …
86
92
  ```
87
93
 
88
94
  Every exported file in the tests is checked against the ERC-7730 2.1.0 JSON Schema (vendored in
@@ -91,6 +97,55 @@ independent implementation. **Fact** (ERC-7730): a wallet that finds no ticker f
91
97
  raw value instead with an 'Unknown token' warning"; this package would produce no message there, and a file cannot make
92
98
  a wallet refuse. The Rust version does not cover EVM.
93
99
 
100
+ ## Terms the user confirms (`src/terms.mjs`, `@meantjs/consent/terms`)
101
+
102
+ Spec §5.3 ("What a call grants", "Confirmed terms") and §11 item 10, D-056. An agent that signs for a user should sign
103
+ only what the user asked for. The consent functions above return each request's `grant`, read from the profile's
104
+ `effect.grant`:
105
+
106
+ ```js
107
+ { kind: "approve", spender: "0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af", token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
108
+ amount: { units: "500000000", unlimited: false }, ends: { at: "1796083199" } } // or { never: true }, or { now: true } for a transfer
109
+ ```
110
+
111
+ Addresses are EIP-55, accounts their ICRC-1 text, a canister's token its canonical id, units a decimal string and ends
112
+ exact seconds, never rounded. A Permit2 allowance whose expiration is 0 ends at `sigDeadline`, and an EIP-2612 or DAI
113
+ permit never ends (their deadlines are the signature's). A batch, a request without a grant, or a grant that cannot be
114
+ read is null; the last with a `W_GRANT` diagnostic, and the text is the same.
115
+
116
+ The subpath compares a grant with terms the user confirmed for one task:
117
+
118
+ | Function | Does |
119
+ |---|---|
120
+ | `readTerms(fields, wallet)` | The agent's terms (`token`, `spender` or `to`, `max_amount`, `expires_by`, as a tool takes them) in the wallet's values: a token by its symbol and an app by its name from the wallet's lists, never guessed; an address or account the user gave, checked; the most never rounded; the end as a date (its whole day in the user's zone), a length ("30 days", "1 month", calendar months), hours from now (an exact time) or "never". "unlimited" and "never" only as those words. Throws `TermsError` with a code and a message for the agent. `wallet`: `{ tokens, apps, now, utcOffsetMinutes }`, `now` in nanoseconds as a bigint. |
121
+ | `termsText(terms)` | The terms in words, exactly, for the wallet's own screen: "Uniswap (0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af) may spend at most 500 USDC, until the end of 30 November 2026 (UTC)." "any amount (unlimited)" and "never ends" are written out. |
122
+ | `confirmTerms(terms)` | Once the user confirmed exactly that text, never through a tool the agent can call: the terms frozen, with `id`, the SHA-256 of their canonical JSON. |
123
+ | `checkGrant(confirmed, grant, wallet)` | `{ fits, reasons }`. A grant fits when its token and its spender or recipient are the terms', its amount is at most the most (unlimited only under unlimited terms; a grant of nothing under any), and it ends no later than the terms (never only under never; a transfer while the terms have not ended). A request that fits needs no second yes; one that does not is refused with the reasons, and the agent asks the user for new terms. A null grant is not covered by terms. |
124
+ | `termsGate()` | The order, kept: `propose(terms)` returns the text to show, `confirm(shown)` takes the text the user confirmed, `read()` before the agent reads the request, `check(grant, wallet, requestId)`. The request cannot be read before the terms are confirmed, the terms cannot change once it is read, and they cover one request: `requestId` names it (`eip712Consent`'s `digest`, or the wallet's own id of a call), and another is refused even when it grants the same. Out of order is `E_TERMS_ORDER`. |
125
+
126
+ ```js
127
+ import { checkGrant, readTerms, termsGate, termsText } from "@meantjs/consent/terms";
128
+ import { eip712Consent } from "@meantjs/consent/evm";
129
+
130
+ const wallet = {
131
+ tokens: { USDC: { address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", decimals: 6 } },
132
+ apps: { Uniswap: "0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af" },
133
+ now: BigInt(Date.now()) * 1_000_000n,
134
+ };
135
+ const gate = termsGate();
136
+ const shown = gate.propose(readTerms({ token: "USDC", spender: "Uniswap", max_amount: "500", expires_by: "2026-11-30" }, wallet));
137
+ // the wallet shows `shown` on its own screen; when the user confirms it:
138
+ gate.confirm(shown);
139
+ gate.read(); // now the agent may read the request
140
+ const { grant, digest } = eip712Consent({ resolved: permit2, typedData, chainId: 1, tokens });
141
+ gate.check(grant, wallet, digest); // { fits: true, reasons: [] }, or { fits: false, reasons: ["the request is unlimited, the terms say at most 500 USDC"] }
142
+ ```
143
+
144
+ The rules come from Meant's signing test, where the comparison in code held every time and the terms agents wrote from
145
+ everyday words were the weak point (spec §11 item 10); hence the user's confirmation. The checks cover the eight
146
+ requests of the proposal (Permit2 `PermitSingle` and `PermitTransferFrom`, EIP-2612, DAI, ERC-20 `approve` and
147
+ `transfer`, ICRC-2 `icrc2_approve`, ICRC-1 `icrc1_transfer`) end to end, both traps, and the test's planted faults.
148
+
94
149
  ## SNS proposal validators (`src/sns.mjs`)
95
150
 
96
151
  The governance counterpart of ICRC-21. An SNS proposal that executes a generic nervous system function calls a dapp method with a payload. Apart from the proposer's own title and summary, the only description of that payload governance shows is the text the dapp's validator method returns. `snsValidatorRendering` is that validator, generated from the profile. Proposal for the spec text: `docs/proposals/w4-sns-validator-rendering.md`.
@@ -243,5 +298,6 @@ Untrusted third-party data lives in `test/upstream/icrc21/` (the standard, commi
243
298
  - The caller's account (`From`) and a fee line: see `docs/oisy-comparison.md` for why they are absent.
244
299
  - Other languages; `device_spec` hints beyond the two forms.
245
300
  - Recursive and `func`/`service` positions (need a richer resolved form, or the Contract).
246
- - EVM: payable functions in the consent text, EIP-712 messages, ERC-7730 import, checking attestations, and the Rust
301
+ - EVM: payable functions in the consent text, EIP-712 witness messages and the signer check (spec §15.9 O9, O13),
302
+ ERC-7730 import, checking attestations, and the Rust
247
303
  version.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meantjs/consent",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "ICRC-21 consent messages and SNS proposal validator renderings generated from a Candid Profile resolved form: any method, both GenericDisplayMessage and FieldsDisplayMessage, values formatted and escaped, nothing rounded.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -46,6 +46,10 @@
46
46
  "types": "./src/erc7730.d.mts",
47
47
  "default": "./src/erc7730.mjs"
48
48
  },
49
+ "./terms": {
50
+ "types": "./src/terms.d.mts",
51
+ "default": "./src/terms.mjs"
52
+ },
49
53
  "./package.json": "./package.json"
50
54
  },
51
55
  "files": [
@@ -64,8 +68,8 @@
64
68
  },
65
69
  "dependencies": {
66
70
  "@candid-core/schema": "0.5.0",
67
- "@meantjs/format": "0.2.0",
68
- "@meantjs/profile": "0.2.0"
71
+ "@meantjs/format": "0.4.0",
72
+ "@meantjs/profile": "0.4.0"
69
73
  },
70
74
  "devDependencies": {
71
75
  "@dfinity/didc": "0.0.4",
package/src/consent.mjs CHANGED
@@ -2,8 +2,10 @@
2
2
  // `icrc21.mjs` wraps this into the ICRC-21 request/response shapes.
3
3
 
4
4
  import { Renderer } from "./display.mjs";
5
+ import { canisterText, grantOf } from "./grant.mjs";
5
6
  import { buildModel } from "./model.mjs";
6
7
  import { renderFields, renderGeneric } from "./render.mjs";
8
+ import { methodOf } from "./resolved.mjs";
7
9
 
8
10
  /**
9
11
  * @param {object} input
@@ -15,14 +17,20 @@ import { renderFields, renderGeneric } from "./render.mjs";
15
17
  * @param {number | null} [input.utcOffsetMinutes] generic form only: show timestamps in this zone (default 0, UTC). The fields form is always UTC seconds
16
18
  * @param {boolean} [input.rawArguments] append the decoded raw arguments (spec section 11 item 1)
17
19
  * @param {object} [input.limits] decoder limits (maxBytes, maxDepth, maxElements)
18
- * @returns {{ form: "fields" | "generic", message: object, zoneSensitive: boolean, diagnostics: object[], provenance: object, model: object }}
20
+ * @param {string} [input.canisterId] the canister called: the token of a grant (spec section 5.3, D-056). Without it
21
+ * nothing is read: `grant` is null and no diagnostic says so, since a caller that does not name the canister is not
22
+ * asking what the call grants (an ICRC-21 message, for one)
23
+ * @returns {{ form: "fields" | "generic", message: object, zoneSensitive: boolean, values: unknown[], grant: object | null, diagnostics: object[], provenance: object, model: object }}
19
24
  * `message` is `{ FieldsDisplayMessage: { intent, fields } }` or `{ GenericDisplayMessage: string }` (ICRC-21 shapes,
20
- * integers as bigint). `zoneSensitive` is true when a timestamp was shown in the requested zone.
25
+ * integers as bigint). `zoneSensitive` is true when a timestamp was shown in the requested zone. `values` are the
26
+ * decoded arguments as `decodeArguments` gives them, and `grant` what the call grants (`grant.mjs`), or null.
21
27
  */
22
- export function generateConsent({ resolved, method, arg, refs = {}, form = "generic", utcOffsetMinutes = null, rawArguments = false, limits }) {
28
+ export function generateConsent({ resolved, method, arg, refs = {}, form = "generic", utcOffsetMinutes = null, rawArguments = false, limits, canisterId }) {
29
+ const called = canisterId === undefined ? undefined : canisterText(canisterId);
23
30
  const model = buildModel({ resolved, method, arg, limits });
24
31
  const diagnostics = model.diagnostics;
25
- const done = (rest) => ({ ...rest, diagnostics: dedupe(diagnostics), provenance: model.provenance, model });
32
+ const grant = called === undefined ? null : grantOf({ name: method, method: methodOf(resolved, method), argValues: model.argValues, called, diagnostics });
33
+ const done = (rest) => ({ ...rest, values: model.argValues, grant, diagnostics: dedupe(diagnostics), provenance: model.provenance, model });
26
34
  if (form === "fields") {
27
35
  const renderer = new Renderer({ refs, utcOffsetMinutes: null, diagnostics });
28
36
  const { intent, fields } = renderFields(model, renderer, { rawArguments });
package/src/display.mjs CHANGED
@@ -20,6 +20,23 @@ const CONFIG_CODES = new Set(["E_REF_UNRESOLVED", "E_ARG", "E_TAG_TYPE", "E_TAG_
20
20
  /** Budget for the text of one message: a consent message is read by a person (ICRC-21: short and concise). */
21
21
  export const MAX_MESSAGE_CHARS = 32 * 1024;
22
22
 
23
+ /**
24
+ * An amount in the token a field of the same value names (spec section 15.9): the base units, the token's EIP-55
25
+ * address, and its decimals and symbol as the caller supplied them. evm.mjs puts one in place of each such amount of a
26
+ * decoded value; its text is the base units, so a view without formatters shows those.
27
+ */
28
+ export class TokenAmount {
29
+ constructor(value, token, metadata) {
30
+ this.value = value;
31
+ this.token = token;
32
+ this.metadata = metadata;
33
+ Object.freeze(this);
34
+ }
35
+ toString() {
36
+ return this.value;
37
+ }
38
+ }
39
+
23
40
  export class Renderer {
24
41
  /** Set while `plainTupleText` runs: no formatter applies. */
25
42
  #plain = false;
@@ -39,17 +56,24 @@ export class Renderer {
39
56
  this.usedZone = false;
40
57
  }
41
58
 
42
- /** @returns {import("@meantjs/format").Formatter | null} */
43
- formatter(node) {
44
- if (this.formatters.has(node)) return this.formatters.get(node);
59
+ /**
60
+ * @param {object} node
61
+ * @param {TokenAmount} [amount] a value whose token a field names: the formatter takes that token's metadata
62
+ * @returns {import("@meantjs/format").Formatter | null}
63
+ */
64
+ formatter(node, amount) {
65
+ const key = amount === undefined ? "" : amount.token;
66
+ if (!this.formatters.has(node)) this.formatters.set(node, new Map());
67
+ const cache = this.formatters.get(node);
68
+ if (cache.has(key)) return cache.get(key);
45
69
  let f;
46
70
  try {
47
- f = formatterFor(node, { refs: this.refs });
71
+ f = formatterFor(node, { refs: this.refs, token: amount?.metadata });
48
72
  } catch (e) {
49
73
  if (e instanceof FormatError && CONFIG_CODES.has(e.code)) throw new ConsentError(e.code, e.message, { cause: e });
50
74
  throw e;
51
75
  }
52
- this.formatters.set(node, f);
76
+ cache.set(key, f);
53
77
  return f;
54
78
  }
55
79
 
@@ -98,10 +122,21 @@ export class Renderer {
98
122
  /** The formatter's text, or null when the position has no value tag or the formatter refuses the value. */
99
123
  #formatted(node, value, where) {
100
124
  if (this.#plain) return null;
125
+ if (value instanceof TokenAmount) {
126
+ // The token's own symbol is its claim, so its address is always shown beside it (spec section 15.9).
127
+ const f = this.formatter(node, value);
128
+ try {
129
+ return `${f.display(value.value)} (${value.token})`;
130
+ } catch (e) {
131
+ if (!(e instanceof FormatError)) throw e;
132
+ this.diagnostics.push({ code: "W_FORMAT_FALLBACK", at: where, message: `the ${f.tag} formatter refused the value (${e.code}: ${e.message}); it is shown in base units` });
133
+ return `${value.value} (${value.token})`;
134
+ }
135
+ }
101
136
  const f = this.formatter(node);
102
137
  if (f === null) return null;
103
138
  try {
104
- return f.tag === "timestamp" && this.utcOffsetMinutes !== null ? this.#zoned(f, value) : f.display(value);
139
+ return f.tag === "timestamp" && this.utcOffsetMinutes !== null && !this.#worded(f, value) ? this.#zoned(f, value) : f.display(value);
105
140
  } catch (e) {
106
141
  if (!(e instanceof FormatError)) throw e;
107
142
  this.diagnostics.push({ code: "W_FORMAT_FALLBACK", at: where, message: `the ${f.tag} formatter refused the value (${e.code}: ${e.message}); it is shown as raw Candid text` });
@@ -109,6 +144,13 @@ export class Renderer {
109
144
  }
110
145
  }
111
146
 
147
+ /** Whether a timestamp shows words instead of a time: a label for its value, or its threshold's message (spec section 15.9). */
148
+ #worded(f, value) {
149
+ if (typeof value !== "bigint" && !(typeof value === "string" && /^-?[0-9]+$/.test(value))) return false; // the formatter refuses it anyway
150
+ const v = BigInt(value);
151
+ return (f.args.labels !== undefined && Object.hasOwn(f.args.labels, v.toString())) || (f.args.threshold !== undefined && v >= BigInt(f.args.threshold));
152
+ }
153
+
112
154
  /** A timestamp in the requested zone: the instant shifted by whole minutes, then shown with the library's calendar code. */
113
155
  #zoned(f, value) {
114
156
  const offset = this.utcOffsetMinutes;
@@ -205,12 +247,14 @@ export class Renderer {
205
247
  try {
206
248
  if (f.tag === "amount") {
207
249
  const amount = BigInt(value);
208
- const { decimals, symbol } = f.args;
250
+ const { decimals, symbol, threshold } = f.args;
209
251
  if (amount < 0n || amount > U64_MAX || typeof symbol !== "string") return null;
252
+ if (threshold !== undefined && amount >= BigInt(threshold)) return null; // shown as its message ("Unlimited ICP"), not a number
210
253
  f.display(value); // the library must accept the value too (type range), or it is shown as raw text
211
254
  return { TokenAmount: { decimals, amount, symbol } };
212
255
  }
213
256
  if (f.tag === "timestamp" || f.tag === "duration") {
257
+ if (f.tag === "timestamp" && this.#worded(f, value)) return null; // shown as its words ("Never"), not a time
214
258
  const seconds = BigInt(convertUnit(value, f.args.unit, "s")); // throws E_INEXACT when not whole seconds
215
259
  if (seconds < 0n || seconds > U64_MAX) return null;
216
260
  f.display(value);
package/src/erc7730.d.mts CHANGED
@@ -19,20 +19,30 @@ export interface Erc7730Field {
19
19
  path: string;
20
20
  label: string;
21
21
  format: "tokenAmount" | "amount" | "date" | "duration" | "addressName" | "raw";
22
+ /** `tokenPath`: `@.to` (the contract, or a message's verifying contract) or a path to the token's address field. */
22
23
  params?: Record<string, unknown>;
23
24
  }
25
+ /** The fields of each element of an array whose elements carry their own token (`path: "details.[]"`); paths relative. */
26
+ export interface Erc7730FieldGroup {
27
+ path: string;
28
+ fields: (Erc7730Field | Erc7730FieldGroup)[];
29
+ }
24
30
  export interface Erc7730Descriptor {
25
31
  $schema: typeof ERC7730_SCHEMA_URL;
26
- context: { contract: { deployments: Erc7730Deployment[] } };
32
+ /** `contract` for a file of functions; `eip712` for a file of messages, with the domain name and version its profiles give. */
33
+ context: { contract: { deployments: Erc7730Deployment[] } } | { eip712: { domain?: { name?: string; version?: string }; deployments: Erc7730Deployment[] } };
27
34
  metadata?: {
28
35
  owner?: string;
29
36
  contractName?: string;
30
37
  info?: { url: string; deploymentDate?: string };
31
38
  token?: { name: string; ticker: string; decimals: number };
32
39
  };
33
- display: { formats: Record<string, { intent: string; interpolatedIntent?: string; fields?: Erc7730Field[] }> };
40
+ /** Keyed by named signature (functions) or by encodeType (messages). */
41
+ display: { formats: Record<string, { intent: string; interpolatedIntent?: string; fields?: (Erc7730Field | Erc7730FieldGroup)[] }> };
34
42
  }
35
43
  export interface Erc7730Options {
44
+ /** "contract" (default): the functions a transaction calls. "eip712": the EIP-712 messages (spec section 15.9). */
45
+ context?: "contract" | "eip712";
36
46
  /** Default: the deployments every profile that lists deployments lists. Given, each must be listed by those profiles. */
37
47
  deployments?: Erc7730Deployment[];
38
48
  owner?: string;
@@ -43,9 +53,10 @@ export interface Erc7730Options {
43
53
  }
44
54
  /**
45
55
  * The ERC-7730 file of an EVM contract's resolved form and its ERC-8176 `descriptorHash`. What the profile says that an
46
- * ERC-7730 file cannot say is refused (E_EXPORT); no deployments is E_DEPLOYMENT.
56
+ * ERC-7730 file cannot say is refused (E_EXPORT); no deployments is E_DEPLOYMENT. What it cannot say but changes no
57
+ * value (a timestamp's words, an interpolation of an array) is left out and listed in `notes`.
47
58
  */
48
- export function erc7730Descriptor(resolved: ResolvedForm, options?: Erc7730Options): { descriptor: Erc7730Descriptor; descriptorHash: string };
59
+ export function erc7730Descriptor(resolved: ResolvedForm, options?: Erc7730Options): { descriptor: Erc7730Descriptor; descriptorHash: string; notes: string[] };
49
60
  /** ERC-8176 `descriptorHash`: `0x` and the Keccak-256 of the RFC 8785 (JCS) form, for a descriptor without `includes`. */
50
61
  export function descriptorHash(descriptor: object): string;
51
62