@meantjs/consent 0.4.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,20 @@
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
+
5
19
  ## 0.4.0 — 2026-10-30
6
20
 
7
21
  - What a request grants (spec §5.3, D-056). `evmConsent`, `eip712Consent` and `generateConsent` also return `values`,
package/README.md CHANGED
@@ -70,10 +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. |
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
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. |
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. |
77
77
  | `descriptorHash(descriptor)` (`/erc7730`) | Keccak-256 of the RFC 8785 form: the hash of a file someone edited. |
78
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. |
79
79
 
@@ -291,6 +291,9 @@ Untrusted third-party data lives in `test/upstream/icrc21/` (the standard, commi
291
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.
292
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.
293
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.
294
297
 
295
298
  ## Not done
296
299
 
@@ -298,6 +301,6 @@ Untrusted third-party data lives in `test/upstream/icrc21/` (the standard, commi
298
301
  - The caller's account (`From`) and a fee line: see `docs/oisy-comparison.md` for why they are absent.
299
302
  - Other languages; `device_spec` hints beyond the two forms.
300
303
  - Recursive and `func`/`service` positions (need a richer resolved form, or the Contract).
301
- - 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),
302
305
  ERC-7730 import, checking attestations, and the Rust
303
306
  version.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meantjs/consent",
3
- "version": "0.4.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": {
@@ -68,8 +68,8 @@
68
68
  },
69
69
  "dependencies": {
70
70
  "@candid-core/schema": "0.5.0",
71
- "@meantjs/format": "0.4.0",
72
- "@meantjs/profile": "0.4.0"
71
+ "@meantjs/format": "0.5.0",
72
+ "@meantjs/profile": "0.5.0"
73
73
  },
74
74
  "devDependencies": {
75
75
  "@dfinity/didc": "0.0.4",
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.mjs CHANGED
@@ -135,6 +135,31 @@ function withTokens(name, viewed, args, tokens) {
135
135
  return args.map((v, i) => box(viewed.args[i], v, [], []));
136
136
  }
137
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
+
138
163
  /**
139
164
  * The consent text of an EVM call.
140
165
  *
@@ -169,6 +194,7 @@ export function evmConsent({ resolved, calldata, to, chainId, refs = {}, tokens
169
194
  const method = methodOf(resolved, name, "evm");
170
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)`);
171
196
  const viewed = { ...method, args: method.args.map((n, i) => abiView(n, `${name}(${i})`)) };
197
+ if (viewed.args.length) checkFixed(name, viewed, [values]);
172
198
  const model = modelOf(resolved, name, viewed, viewed.args.length ? withTokens(name, viewed, [values], tokenTable(tokens)) : []);
173
199
  const grant = grantOf({ name, method: viewed, argValues: viewed.args.length ? [values] : [], called: target, diagnostics: model.diagnostics });
174
200
  const where = `${target}${chain === null ? "" : ` on chain ${chain}`}`;
@@ -348,6 +374,7 @@ export function eip712Consent({ resolved, typedData, chainId, refs = {}, tokens
348
374
  throw e;
349
375
  }
350
376
  const viewed = { ...message, args: message.args.map((n, i) => abiView(n, `${name}(${i})`)) };
377
+ checkFixed(name, viewed, [values]);
351
378
  const model = modelOf(resolved, name, viewed, withTokens(name, viewed, [values], tokenTable(tokens)));
352
379
  const grant = grantOf({ name, method: viewed, argValues: [values], called: contract, diagnostics: model.diagnostics });
353
380
  const where = `${contract} on chain ${chain}`;
package/src/index.d.mts CHANGED
@@ -236,6 +236,7 @@ export const CODES: Readonly<{
236
236
  DEPLOYMENT: "E_DEPLOYMENT";
237
237
  DOMAIN: "E_DOMAIN";
238
238
  TYPE_HASH: "E_TYPE_HASH";
239
+ FIXED: "E_FIXED";
239
240
  UNSUPPORTED_TYPE: "E_UNSUPPORTED_TYPE";
240
241
  RESOLVED_FORM: "E_RESOLVED_FORM";
241
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