@meantjs/consent 0.3.0 → 0.5.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,37 @@
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.5.0 — 2026-10-30
6
+
7
+ - Pins (spec §15.4, D-058): `evmConsent` and `eip712Consent` refuse a call or a signing request whose value at a
8
+ position a profile pins (`fixed`) is another address, before any text (new code `E_FIXED`). x402's Permit2 payment
9
+ needs it: its words hold only when the spender is x402's proxy. A pin whose value is not an address is
10
+ `E_RESOLVED_FORM`.
11
+ - `erc7730Descriptor` writes a pinned position as a field with `visible: { mustMatch: [address] }`, which a wallet
12
+ hides and checks; a template that names one gets no `interpolatedIntent`, and `notes` says why.
13
+ - x402 payments read with the new contract profiles of `@meantjs/profile` (`usdc`, `usdc-base-sepolia`,
14
+ `x402-exact-permit2`), and their grants pay the payee directly (`to`), so terms for one payee pass no payment to
15
+ another.
16
+ - The ERC-7730 file of the `permit2` profile's messages lists its two new deployments (Base and Base Sepolia), so its
17
+ `descriptorHash` changed; a caller that passes the earlier `deployments` gets the earlier file.
18
+
19
+ ## 0.4.0 — 2026-10-30
20
+
21
+ - What a request grants (spec §5.3, D-056). `evmConsent`, `eip712Consent` and `generateConsent` also return `values`,
22
+ the decoded arguments or message, and `grant`: who may take tokens because of the request, which token, how many
23
+ and until when, read from the effect's `grant` (`{ kind, spender | to, token, amount: { units, unlimited }, ends }`),
24
+ or null. Addresses are EIP-55, accounts their ICRC-1 text, units a decimal string and ends exact seconds, never
25
+ rounded. A grant that cannot be read is null with a `W_GRANT` diagnostic, and the text is the same.
26
+ `generateConsent` takes `canisterId`, the ledger called, and reads the grant only then, so an ICRC-21 message is
27
+ unchanged.
28
+ - `@meantjs/consent/terms`, new: terms the user confirms for one task, and the check of a request's grant against them
29
+ (spec §5.3, "Confirmed terms"; §11 item 10). `readTerms` reads an agent's terms in the wallet's values (a token by
30
+ its symbol, an app by its name, an amount never rounded, an end as a date, a length, hours or "never") or throws a
31
+ `TermsError` with a message for the agent; `termsText` writes them out exactly for the wallet's own screen;
32
+ `confirmTerms` freezes them with an id; `checkGrant` says whether a grant fits, and why not; `termsGate` keeps the
33
+ order (confirmed before the request is read, fixed after, one request, named by the id the wallet gives it). It runs
34
+ in browsers.
35
+
5
36
  ## 0.3.0 — 2026-10-29
6
37
 
7
38
  - `@meantjs/consent/evm`: `eip712Consent`, the consent text of an EIP-712 signing request (spec §15.9, D-055),
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
 
@@ -70,9 +70,10 @@ a Candid one.
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, 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
- | `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. |
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); an argument a profile pins (`fixed`) that holds another address is `E_FIXED`. `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 member a profile pins (`fixed`) that holds another address, such as x402's Permit2 payment with a spender other than x402's proxy (`E_FIXED`); 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`. A pinned position (`fixed`) is a field with `visible: { mustMatch: [address] }`, which a wallet hides and checks, and a template that names it has no `interpolatedIntent` (a note says why). The mapping is spec §15.7 and §15.9. |
76
77
  | `descriptorHash(descriptor)` (`/erc7730`) | Keccak-256 of the RFC 8785 form: the hash of a file someone edited. |
77
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. |
78
79
 
@@ -96,6 +97,55 @@ independent implementation. **Fact** (ERC-7730): a wallet that finds no ticker f
96
97
  raw value instead with an 'Unknown token' warning"; this package would produce no message there, and a file cannot make
97
98
  a wallet refuse. The Rust version does not cover EVM.
98
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
+
99
149
  ## SNS proposal validators (`src/sns.mjs`)
100
150
 
101
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`.
@@ -241,6 +291,9 @@ Untrusted third-party data lives in `test/upstream/icrc21/` (the standard, commi
241
291
  - A profile is a claim, not a proof (spec section 11 item 1). `provenance` tells the signer which profile wrote the words; `rawArguments: true` appends the decoded raw arguments for transfer, approve, admin and destroy.
242
292
  - Dynamic refs come from a single replica unless the caller certifies them (section 11 item 2). Resolve `decimals` and `symbol` through an update call or cross-check with `icrc1_metadata` before showing a transfer. Generation inside the canister avoids this.
243
293
  - The package has no I/O. Work is bounded: decoder budgets, field and element limits, message size.
294
+ - A pin (`fixed`) protects only a consumer that reads it (section 11 item 11): x402's Permit2 payment pays its payee
295
+ only through x402's proxy, and a consumer that skips the tag shows its words for any spender. This package reads it
296
+ from 0.5.0; give a resolved form that holds pins to no older consumer.
244
297
 
245
298
  ## Not done
246
299
 
@@ -248,6 +301,6 @@ Untrusted third-party data lives in `test/upstream/icrc21/` (the standard, commi
248
301
  - The caller's account (`From`) and a fee line: see `docs/oisy-comparison.md` for why they are absent.
249
302
  - Other languages; `device_spec` hints beyond the two forms.
250
303
  - Recursive and `func`/`service` positions (need a richer resolved form, or the Contract).
251
- - EVM: payable functions in the consent text, EIP-712 witness messages and the signer check (spec §15.9 O9, O13),
304
+ - EVM: payable functions in the consent text, EIP-712 witness messages other than x402's and the signer check (spec §15.9 O9, O13),
252
305
  ERC-7730 import, checking attestations, and the Rust
253
306
  version.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meantjs/consent",
3
- "version": "0.3.0",
3
+ "version": "0.5.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.3.0",
68
- "@meantjs/profile": "0.3.0"
71
+ "@meantjs/format": "0.5.0",
72
+ "@meantjs/profile": "0.5.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/erc7730.d.mts CHANGED
@@ -21,6 +21,8 @@ export interface Erc7730Field {
21
21
  format: "tokenAmount" | "amount" | "date" | "duration" | "addressName" | "raw";
22
22
  /** `tokenPath`: `@.to` (the contract, or a message's verifying contract) or a path to the token's address field. */
23
23
  params?: Record<string, unknown>;
24
+ /** A position the profile pins (`fixed`): hidden, and a wallet refuses any other value (EIP-55 text). */
25
+ visible?: { mustMatch: string[] };
24
26
  }
25
27
  /** The fields of each element of an array whose elements carry their own token (`path: "details.[]"`); paths relative. */
26
28
  export interface Erc7730FieldGroup {
package/src/erc7730.mjs CHANGED
@@ -109,12 +109,27 @@ function keysOf(method, relpath) {
109
109
  }
110
110
  const startsWith = (keys, prefix) => prefix.length <= keys.length && prefix.every((k, i) => keys[i] === k);
111
111
 
112
+ /**
113
+ * ERC-7730 `visible` of a position a profile pins (`fixed`, spec section 15.4, D-058): `mustMatch` hides the field and
114
+ * makes a wallet refuse any other value, as `evmConsent` and `eip712Consent` do (E_FIXED). Empty for any other position.
115
+ */
116
+ function pinOf(node, where) {
117
+ const fixed = tagArgs(node, "fixed");
118
+ if (fixed === undefined) return {};
119
+ try {
120
+ return { visible: { mustMatch: [readAddress(String(fixed.value))] } };
121
+ } catch (e) {
122
+ if (e instanceof AbiError) fail(CODES.RESOLVED_FORM, `${where}: fixed: ${e.message}`);
123
+ throw e;
124
+ }
125
+ }
126
+
112
127
  /**
113
128
  * One ERC-7730 field for a leaf of a function's parameters or a message's members. `at` is where the leaf is:
114
129
  * `{ method, abs, base }`, its keys from the root and the keys of the field group it is in (its path is relative to it).
115
130
  */
116
131
  function fieldOf(node, path, label, ctx, where, at) {
117
- const field = { path, label };
132
+ const field = { path, label, ...pinOf(node, where) };
118
133
  const amount = tagArgs(node, "amount");
119
134
  if (amount?.token !== undefined) {
120
135
  // In the token a field of the same value names (spec section 15.9): its path, relative to the group.
@@ -225,6 +240,7 @@ function interpolated(template, method, ctx, where) {
225
240
  if (rp.error) return ctx.problem(where, `{${p.path}}: ${rp.error}`);
226
241
  if (rp.steps.some((s) => s.kind === "elem")) return leftOut(`{${p.path}} reads every element of an array; an ERC-7730 interpolation shows one value`);
227
242
  if (rp.node.candid === "record" || rp.node.candid === "vec") return leftOut(`{${p.path}} is a whole ${rp.node.abi}; an ERC-7730 interpolation shows one formatted field`);
243
+ if (tagArgs(rp.node, "fixed") !== undefined) return leftOut(`{${p.path}} is pinned (fixed), so its field is hidden (mustMatch); an ERC-7730 interpolation names only fields that are always shown`);
228
244
  out += `{${rp.steps.map((s) => s.key).join(".")}}`;
229
245
  }
230
246
  }
package/src/errors.mjs CHANGED
@@ -24,6 +24,7 @@ export const CODES = Object.freeze({
24
24
  DEPLOYMENT: "E_DEPLOYMENT", // an EVM call's target is not a deployment its profile lists (spec section 15)
25
25
  DOMAIN: "E_DOMAIN", // an EIP-712 request's domain is for another chain, contract or name than its profile's (spec section 15.9)
26
26
  TYPE_HASH: "E_TYPE_HASH", // no message of the resolved form has the request's EIP-712 type hash (spec section 15.9)
27
+ FIXED: "E_FIXED", // a position a profile pins (`fixed`) holds another value: the profile does not describe the request (spec section 15.4)
27
28
  // this generator cannot describe the call (ICRC-21 ConsentMessageUnavailable)
28
29
  UNSUPPORTED_TYPE: "E_UNSUPPORTED_TYPE", // a position the resolved form cannot type (recursive cut, func, service)
29
30
  // the profile or the caller's context is wrong (ICRC-21 GenericError)
package/src/evm.d.mts CHANGED
@@ -1,7 +1,7 @@
1
1
  // Types for @meantjs/consent/evm: the consent text of an EVM call (spec section 15, D-054). Hand-written; the source is
2
2
  // src/evm.mjs. scripts/types.test.mjs keeps the declared names equal to the runtime exports.
3
3
  import type { AbiLimits, AbiValue } from "@meantjs/profile/evm";
4
- import type { ConsentDiagnostic, Model, Provenance, ResolvedForm } from "./index.mjs";
4
+ import type { ConsentDiagnostic, Grant, Model, Provenance, ResolvedForm } from "./index.mjs";
5
5
 
6
6
  /** An EVM contract's resolved form: `chain: "evm"`, and each method with its selector and named signature. */
7
7
  export type EvmResolvedForm = ResolvedForm & { chain: "evm" };
@@ -50,6 +50,10 @@ export function evmConsent(input: EvmConsentInput): {
50
50
  /** The contract, in its EIP-55 form. */
51
51
  to: string;
52
52
  chainId: string | null;
53
+ /** The decoded arguments by parameter name, as `decodeCalldata` gives them. */
54
+ values: Record<string, AbiValue>;
55
+ /** What the call grants (its token is the contract called, unless the amount's tag names a field), or null; a W_GRANT diagnostic says why one cannot be read. */
56
+ grant: Grant | null;
53
57
  diagnostics: ConsentDiagnostic[];
54
58
  provenance: Provenance;
55
59
  model: Model;
@@ -100,6 +104,10 @@ export function eip712Consent(input: Eip712ConsentInput): {
100
104
  /** The verifying contract, in its EIP-55 form. */
101
105
  verifyingContract: string;
102
106
  chainId: string;
107
+ /** The message's values by member name, as read from the typed data: integers as decimal strings, addresses in EIP-55 form. */
108
+ values: Record<string, AbiValue>;
109
+ /** What the signature grants (its token is the verifying contract, unless the amount's tag names a member), or null; a W_GRANT diagnostic says why one cannot be read. */
110
+ grant: Grant | null;
103
111
  diagnostics: ConsentDiagnostic[];
104
112
  provenance: Provenance;
105
113
  model: Model;
package/src/evm.mjs CHANGED
@@ -22,6 +22,7 @@
22
22
  import { AbiError, DEFAULT_ABI_LIMITS, decodeParams, eip712TypeHash, keccak256Hex, parseEip712Types, parseSignature, readAddress, typedDataDigest } from "@meantjs/profile/evm";
23
23
  import { Renderer, TokenAmount } from "./display.mjs";
24
24
  import { CODES, ConsentError, fail } from "./errors.mjs";
25
+ import { grantOf } from "./grant.mjs";
25
26
  import { modelOf } from "./model.mjs";
26
27
  import { resolvePath } from "./paths.mjs";
27
28
  import { renderGeneric, renderPlain } from "./render.mjs";
@@ -134,6 +135,31 @@ function withTokens(name, viewed, args, tokens) {
134
135
  return args.map((v, i) => box(viewed.args[i], v, [], []));
135
136
  }
136
137
 
138
+ /**
139
+ * Every position a profile pins (`fixed`, spec section 15.4, D-058) holds its value: the profile describes the call or
140
+ * the message only with it, as x402's Permit2 message holds x402's proxy as its spender. Anything else is refused
141
+ * before any text (E_FIXED), since the words would be wrong for it.
142
+ */
143
+ function checkFixed(name, viewed, args) {
144
+ const walk = (node, value, where) => {
145
+ if (node.candid === "record") return entriesOf(node.fields).forEach(([k, child]) => walk(child, value[k], `${where}.${k}`));
146
+ if (node.candid === "vec") return value.forEach((e) => walk(node.element, e, `${where}[]`));
147
+ const fixed = tagArgs(node, "fixed");
148
+ if (fixed === undefined) return;
149
+ let want;
150
+ try {
151
+ want = readAddress(String(fixed.value));
152
+ } catch (e) {
153
+ if (e instanceof AbiError) fail(CODES.RESOLVED_FORM, `${where}: fixed: ${e.message}`);
154
+ throw e;
155
+ }
156
+ if (typeof value !== "string" || value.toLowerCase() !== want.toLowerCase()) {
157
+ fail(CODES.FIXED, `${where} is ${String(value)}, but the profile describes ${name} only with ${want} there, so this request gets no text`);
158
+ }
159
+ };
160
+ args.forEach((v, i) => walk(viewed.args[i], v, `${name}(${i})`));
161
+ }
162
+
137
163
  /**
138
164
  * The consent text of an EVM call.
139
165
  *
@@ -149,7 +175,9 @@ function withTokens(name, viewed, args, tokens) {
149
175
  * @param {number | null} [input.utcOffsetMinutes] generic form only (default 0, UTC)
150
176
  * @param {boolean} [input.rawArguments] append the decoded arguments (spec section 11 item 1)
151
177
  * @param {object} [input.limits] decoder limits (maxBytes, maxDepth, maxElements)
152
- * @returns {{ form: string, message: string | { intent: string, fields: Array<[string, string]> }, method: string, selector: string, to: string, chainId: string | null, diagnostics: object[], provenance: object, model: object }}
178
+ * @returns {{ form: string, message: string | { intent: string, fields: Array<[string, string]> }, method: string, selector: string, to: string, chainId: string | null, values: Record<string, unknown>, grant: object | null, diagnostics: object[], provenance: object, model: object }}
179
+ * `values` are the decoded arguments as `decodeCalldata` gives them, and `grant` what the call grants (spec section
180
+ * 5.3, D-056; its token is the contract called unless the amount's tag names a field), or null.
153
181
  */
154
182
  export function evmConsent({ resolved, calldata, to, chainId, refs = {}, tokens = {}, form = "generic", utcOffsetMinutes = null, rawArguments = false, limits }) {
155
183
  if (form !== "generic" && form !== "fields") fail(CODES.REQUEST, `form must be "generic" or "fields", got ${JSON.stringify(form)}`);
@@ -166,9 +194,11 @@ export function evmConsent({ resolved, calldata, to, chainId, refs = {}, tokens
166
194
  const method = methodOf(resolved, name, "evm");
167
195
  if (method.state_mutability === "payable") fail(CODES.UNSUPPORTED_TYPE, `${name} is payable: the native value a call sends has no position in a profile yet (spec section 15, O2)`);
168
196
  const viewed = { ...method, args: method.args.map((n, i) => abiView(n, `${name}(${i})`)) };
197
+ if (viewed.args.length) checkFixed(name, viewed, [values]);
169
198
  const model = modelOf(resolved, name, viewed, viewed.args.length ? withTokens(name, viewed, [values], tokenTable(tokens)) : []);
199
+ const grant = grantOf({ name, method: viewed, argValues: viewed.args.length ? [values] : [], called: target, diagnostics: model.diagnostics });
170
200
  const where = `${target}${chain === null ? "" : ` on chain ${chain}`}`;
171
- const done = (rest) => ({ ...rest, method: name, selector, to: target, chainId: chain, diagnostics: model.diagnostics, provenance: model.provenance, model });
201
+ const done = (rest) => ({ ...rest, method: name, selector, to: target, chainId: chain, values, grant, diagnostics: model.diagnostics, provenance: model.provenance, model });
172
202
  if (form === "fields") {
173
203
  const plain = renderPlain(model, new Renderer({ refs, diagnostics: model.diagnostics }), { rawArguments });
174
204
  plain.fields.push(["Contract", where]);
@@ -312,6 +342,9 @@ function checkDomain(resolved, domain) {
312
342
  * @param {number | null} [input.utcOffsetMinutes] generic form only (default 0, UTC)
313
343
  * @param {boolean} [input.rawArguments] append the message's values (spec section 11 item 1)
314
344
  * @param {{ maxBytes?: number, maxElements?: number }} [input.limits]
345
+ * @returns the text, `primaryType`, `typeHash`, `digest`, `verifyingContract`, `chainId`, `values` (the message as read
346
+ * from the typed data, by member name), `grant` (what the signature grants, spec section 5.3, D-056; its token is
347
+ * the verifying contract unless the amount's tag names a field), or null, `diagnostics`, `provenance` and `model`
315
348
  */
316
349
  export function eip712Consent({ resolved, typedData, chainId, refs = {}, tokens = {}, form = "generic", utcOffsetMinutes = null, rawArguments = false, limits = {} }) {
317
350
  if (form !== "generic" && form !== "fields") fail(CODES.REQUEST, `form must be "generic" or "fields", got ${JSON.stringify(form)}`);
@@ -341,9 +374,11 @@ export function eip712Consent({ resolved, typedData, chainId, refs = {}, tokens
341
374
  throw e;
342
375
  }
343
376
  const viewed = { ...message, args: message.args.map((n, i) => abiView(n, `${name}(${i})`)) };
377
+ checkFixed(name, viewed, [values]);
344
378
  const model = modelOf(resolved, name, viewed, withTokens(name, viewed, [values], tokenTable(tokens)));
379
+ const grant = grantOf({ name, method: viewed, argValues: [values], called: contract, diagnostics: model.diagnostics });
345
380
  const where = `${contract} on chain ${chain}`;
346
- const done = (rest) => ({ ...rest, primaryType: name, typeHash, digest, verifyingContract: contract, chainId: chain, diagnostics: model.diagnostics, provenance: model.provenance, model });
381
+ const done = (rest) => ({ ...rest, primaryType: name, typeHash, digest, verifyingContract: contract, chainId: chain, values, grant, diagnostics: model.diagnostics, provenance: model.provenance, model });
347
382
  if (form === "fields") {
348
383
  const plain = renderPlain(model, new Renderer({ refs, diagnostics: model.diagnostics }), { rawArguments });
349
384
  plain.fields.push(["Contract", where], ["Signature", SIGNATURE_NOTE], ["EIP-712 digest", digest]);
package/src/grant.mjs ADDED
@@ -0,0 +1,140 @@
1
+ // What a call or a signature grants (docs/profile-v0.md section 5.3, D-056): who may take tokens because of it, which
2
+ // token, how many and until when, read from its effect's `grant` and its decoded values. A wallet compares it with the
3
+ // terms the user confirmed (`@meantjs/consent/terms`).
4
+ //
5
+ // A grant that cannot be read is no grant: `null`, with a W_GRANT diagnostic. The request then needs its confirmation,
6
+ // as any request without a grant does, and the consent text is the same either way.
7
+
8
+ import { displayAccount, principalFromText, principalToText } from "@meantjs/format";
9
+ import { CODES, fail } from "./errors.mjs";
10
+ import { callTag, tagArgs } from "./resolved.mjs";
11
+ import { resolvePath, valuesAt } from "./paths.mjs";
12
+
13
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
14
+ const NATURAL = /^(0|[1-9][0-9]*)$/;
15
+ const INTEGER = /^-?(0|[1-9][0-9]*)$/;
16
+ /** Nanoseconds per unit of a `timestamp` tag (spec section 5.3); every grant end is compared in nanoseconds, exactly. */
17
+ const NS = { s: 1_000_000_000n, ms: 1_000_000n, us: 1_000n, ns: 1n };
18
+
19
+ /** Nanoseconds as exact seconds: a decimal string with a fraction only when there is one, never rounded. */
20
+ export function secondsText(ns) {
21
+ const sign = ns < 0n ? "-" : "";
22
+ const a = ns < 0n ? -ns : ns;
23
+ const frac = (a % NS.s).toString().padStart(9, "0").replace(/0+$/, "");
24
+ return `${sign}${a / NS.s}${frac ? `.${frac}` : ""}`;
25
+ }
26
+
27
+ /** The canonical text of a canister id; anything else is E_REQUEST. */
28
+ export function canisterText(id) {
29
+ try {
30
+ return principalToText(principalFromText(id));
31
+ } catch {
32
+ fail(CODES.REQUEST, `canisterId must be a principal's text, got ${JSON.stringify(String(id))}`);
33
+ }
34
+ }
35
+
36
+ class Skip extends Error {}
37
+
38
+ /**
39
+ * @param {object} input
40
+ * @param {string} input.name the method or message, for the diagnostic
41
+ * @param {object} input.method its resolved-form entry (an EVM one as abiView reads it)
42
+ * @param {unknown[]} input.argValues its decoded arguments, before any amount is boxed with its token
43
+ * @param {string | undefined} input.called the contract or canister the call is for: the token, unless the amount's
44
+ * tag names a field that holds it (spec section 15.9)
45
+ * @param {object[]} input.diagnostics where W_GRANT goes
46
+ * @returns {null | { kind: "transfer" | "approve", spender?: string, to?: string, token: string,
47
+ * amount: { units: string | null, unlimited: boolean }, ends: { at: string } | { never: true } | { now: true } }}
48
+ */
49
+ export function grantOf({ name, method, argValues, called, diagnostics }) {
50
+ const effect = callTag(method, argValues, "effect");
51
+ const grant = effect?.args?.grant;
52
+ if (grant === undefined) return null;
53
+ try {
54
+ return read(method, argValues, effect, grant, called);
55
+ } catch (e) {
56
+ if (!(e instanceof Skip)) throw e;
57
+ diagnostics.push({ code: "W_GRANT", at: name, message: `${e.message}; the request has no grant, so terms the user confirmed cannot cover it` });
58
+ return null;
59
+ }
60
+ }
61
+
62
+ function read(method, argValues, effect, grant, called) {
63
+ const skip = (why) => {
64
+ throw new Skip(why);
65
+ };
66
+ if (effect !== method.tags?.effect) skip("a grant on the effect of a case is not read in this version (D-056)");
67
+ const { kind } = effect.args;
68
+ if (kind !== "transfer" && kind !== "approve") skip(`a grant goes with effect kind transfer or approve, not ${String(kind)}`);
69
+ if (!isObject(grant) || (grant.spender === undefined) === (grant.to === undefined)) skip("the grant names neither or both of spender and to");
70
+
71
+ /** The one value at a grant position: no [] and no case of a variant on the way, so a call holds it once at most. */
72
+ const at = (what, path, mayBeAbsent = false) => {
73
+ if (typeof path !== "string") skip(`grant ${what} is not a path`);
74
+ const rp = resolvePath(method, path);
75
+ if (rp.error) skip(`grant ${what} ${JSON.stringify(path)}: ${rp.error}`);
76
+ if (rp.steps.some((s) => s.kind !== "field")) skip(`grant ${what} ${JSON.stringify(path)} steps into [] or a case of a variant`);
77
+ const values = valuesAt(rp, argValues);
78
+ if (values.length === 0 && !mayBeAbsent) skip(`grant ${what} ${JSON.stringify(path)} is absent from this call`);
79
+ return { node: rp.node, value: values[0] };
80
+ };
81
+
82
+ const who = grant.spender !== undefined ? "spender" : "to";
83
+ const party = at(who, grant[who]);
84
+ let address;
85
+ if (party.node.abi === "address" && typeof party.value === "string") address = party.value; // EIP-55, as decoded
86
+ else if (tagArgs(party.node, "account") !== undefined && isObject(party.value)) {
87
+ try {
88
+ address = displayAccount(party.value);
89
+ } catch (e) {
90
+ skip(`grant ${who}: ${e.message}`);
91
+ }
92
+ } else skip(`grant ${who} ${JSON.stringify(grant[who])} is neither an address nor an account`);
93
+
94
+ const amt = at("amount", grant.amount);
95
+ const tag = tagArgs(amt.node, "amount");
96
+ let amount;
97
+ if ((amt.node.abi ?? amt.node.candid) === "bool" && typeof amt.value === "boolean") {
98
+ amount = amt.value ? { units: null, unlimited: true } : { units: "0", unlimited: false }; // DAI's allowed (spec 15.9)
99
+ } else {
100
+ if (tag === undefined || typeof amt.value !== "string" || !NATURAL.test(amt.value)) skip(`grant amount ${JSON.stringify(grant.amount)} is neither an amount nor a bool`);
101
+ if (tag.threshold !== undefined && (typeof tag.threshold !== "string" || !NATURAL.test(tag.threshold))) skip("the amount tag's threshold is not a decimal string");
102
+ amount = { units: amt.value, unlimited: tag.threshold !== undefined && BigInt(amt.value) >= BigInt(tag.threshold) };
103
+ }
104
+
105
+ let token;
106
+ if (tag?.token !== undefined) {
107
+ const t = at("amount token", tag.token);
108
+ if (t.node.abi !== "address" || typeof t.value !== "string") skip(`the amount's token ${JSON.stringify(tag.token)} is not an address`);
109
+ token = t.value;
110
+ } else if (typeof called === "string") token = called;
111
+ else skip("the canister the call is for was not given (canisterId), so its token is not known");
112
+
113
+ let ends;
114
+ if (grant.ends === undefined) ends = kind === "approve" ? { never: true } : { now: true };
115
+ else {
116
+ if (!Array.isArray(grant.ends) || grant.ends.length === 0) skip("grant ends is not a list of paths");
117
+ let latest = null;
118
+ let never = false;
119
+ for (const path of grant.ends) {
120
+ const end = at("ends", path, true);
121
+ if (end.value === undefined) { // an optional end left out: the grant never ends (spec 5.3)
122
+ never = true;
123
+ continue;
124
+ }
125
+ const ts = tagArgs(end.node, "timestamp");
126
+ if (ts === undefined || !Object.hasOwn(NS, ts.unit) || (ts.epoch ?? "unix") !== "unix" || typeof end.value !== "string" || !INTEGER.test(end.value)) {
127
+ skip(`grant ends ${JSON.stringify(path)} is not a unix timestamp`);
128
+ }
129
+ if (ts.threshold !== undefined && (typeof ts.threshold !== "string" || !NATURAL.test(ts.threshold))) skip(`the timestamp tag of ${JSON.stringify(path)} has a threshold that is not a decimal string`);
130
+ if (ts.threshold !== undefined && BigInt(end.value) >= BigInt(ts.threshold)) { // at its threshold: never (spec 5.3)
131
+ never = true;
132
+ continue;
133
+ }
134
+ const ns = BigInt(end.value) * NS[ts.unit]; // a label is how a value is shown; here it is the time it is
135
+ if (latest === null || ns > latest) latest = ns;
136
+ }
137
+ ends = never ? { never: true } : { at: secondsText(latest) };
138
+ }
139
+ return { kind, [who]: address, token, amount, ends };
140
+ }
package/src/index.d.mts CHANGED
@@ -126,6 +126,24 @@ export interface Model {
126
126
  [key: string]: unknown;
127
127
  }
128
128
 
129
+ /**
130
+ * What a call or a signature grants (spec section 5.3, D-056): who may take tokens because of it, which token, how many
131
+ * and until when, read from its effect's `grant`. Compare it with terms the user confirmed (`@meantjs/consent/terms`).
132
+ */
133
+ export interface Grant {
134
+ kind: "transfer" | "approve";
135
+ /** Who may take the tokens: an EIP-55 address, or an account in its ICRC-1 text. Exactly one of `spender` and `to`. */
136
+ spender?: string;
137
+ /** Who is paid directly: an EIP-55 address, or an account in its ICRC-1 text. */
138
+ to?: string;
139
+ /** The token: an EIP-55 address, or a canister id in its canonical text. */
140
+ token: string;
141
+ /** Base units as a decimal string; `null` when unlimited without a count (DAI's `allowed: true`). A grant of nothing is `"0"`. */
142
+ amount: { units: string | null; unlimited: boolean };
143
+ /** `at`: exact seconds since the Unix epoch as a decimal string, never rounded ("1767225600.123456789"). `now`: a transfer, made when it is sent. */
144
+ ends: { at: string } | { never: true } | { now: true };
145
+ }
146
+
129
147
  export interface GenerateConsentInput {
130
148
  resolved: ResolvedForm;
131
149
  method: string;
@@ -138,12 +156,21 @@ export interface GenerateConsentInput {
138
156
  utcOffsetMinutes?: number | null;
139
157
  rawArguments?: boolean;
140
158
  limits?: DecodeLimits;
159
+ /**
160
+ * The canister called, the token of a grant. Without it nothing asks what the call grants: `grant` is null, with no
161
+ * diagnostic. Any spelling of a principal; anything else is E_REQUEST.
162
+ */
163
+ canisterId?: string;
141
164
  }
142
165
  /** One form without the ICRC-21 envelope. `zoneSensitive` is true when a timestamp was shown in the requested zone. */
143
166
  export function generateConsent(input: GenerateConsentInput): {
144
167
  form: "fields" | "generic";
145
168
  message: Icrc21ConsentMessage;
146
169
  zoneSensitive: boolean;
170
+ /** The decoded arguments, one value tree per argument, as `decodeArguments` gives them. */
171
+ values: unknown[];
172
+ /** What the call grants, or null: no `canisterId`, no grant in its effect, or one that cannot be read (a W_GRANT diagnostic says why). */
173
+ grant: Grant | null;
147
174
  diagnostics: ConsentDiagnostic[];
148
175
  provenance: Provenance;
149
176
  model: Model;
@@ -209,6 +236,7 @@ export const CODES: Readonly<{
209
236
  DEPLOYMENT: "E_DEPLOYMENT";
210
237
  DOMAIN: "E_DOMAIN";
211
238
  TYPE_HASH: "E_TYPE_HASH";
239
+ FIXED: "E_FIXED";
212
240
  UNSUPPORTED_TYPE: "E_UNSUPPORTED_TYPE";
213
241
  RESOLVED_FORM: "E_RESOLVED_FORM";
214
242
  TEMPLATE: "E_TEMPLATE";
package/src/render.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // FieldsDisplayMessage (plain text, no formatting; times are UTC seconds, so the message does not depend on the
4
4
  // user's time zone):
5
- // intent = title + ": " + the rendered template (no template: the title alone)
5
+ // intent = title + ": " + the rendered template (the title alone when the template renders empty or fell back)
6
6
  // fields = one (label, Value) row per placeholder; label = describe.label, else the field name
7
7
  //
8
8
  // GenericDisplayMessage (Markdown, the layout of the ICRC-21 examples: a `#` title, one paragraph, then