@meantjs/consent 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  The five Candid Profile library packages (`@meantjs/profile`, `/format`, `/consent`, `/tools`, `/reconcile`) release together under one version and pin each other exactly; a version appears here even when this package did not change. Release steps: `docs/releasing.md` in the repository.
4
4
 
5
+ ## 0.3.0 — 2026-10-29
6
+
7
+ - `@meantjs/consent/evm`: `eip712Consent`, the consent text of an EIP-712 signing request (spec §15.9, D-055),
8
+ matched to a message of the resolved form by its type hash and refused before anything is shown when its domain,
9
+ contract, type hash or values do not match (new codes `E_DOMAIN` and `E_TYPE_HASH`). The text ends with the
10
+ verifying contract, the EIP-712 digest and the note that a signature is not a transaction. `evmConsent` and
11
+ `eip712Consent` take `tokens`, the decimals and symbol of each token an amount's `token` field names; the token's
12
+ address is shown beside its symbol.
13
+ - `@meantjs/consent/erc7730`: `erc7730Descriptor(resolved, { context: "eip712" })` writes the ERC-7730 file of the
14
+ messages (formats keyed by `encode_type`, field groups for arrays of tokens). It returns `notes`: a timestamp's
15
+ words and a template ERC-7730 cannot interpolate are left out with a note instead of refusing the file, for
16
+ functions as well. Every file 0.2.0 writes is written the same.
17
+ - ICRC-21 fields: an amount at or above its threshold, and a timestamp shown as words, are `Text`, never a
18
+ `TokenAmount` or `TimestampSeconds` of the raw number.
19
+
5
20
  ## 0.2.0 — 2026-10-28
6
21
 
7
22
  - A call takes the `intent` and `effect` of the deepest tagged variant case its arguments hold, else the method's
package/README.md CHANGED
@@ -63,26 +63,31 @@ 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
+ | `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
76
  | `descriptorHash(descriptor)` (`/erc7730`) | Keccak-256 of the RFC 8785 form: the hash of a file someone edited. |
76
77
  | `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
78
 
78
79
  ```js
79
- import { evmConsent } from "@meantjs/consent/evm";
80
+ import { eip712Consent, evmConsent } from "@meantjs/consent/evm";
80
81
  import { erc7730Descriptor, erc8176Attestation } from "@meantjs/consent/erc7730";
81
82
 
82
83
  evmConsent({ resolved, calldata, to: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", refs: { "query:decimals": 18, "query:symbol": "WETH" } }).message;
83
84
  // # 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
85
  const { descriptor, descriptorHash } = erc7730Descriptor(resolved, { deployments: [{ chainId: 1, address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" }] });
85
86
  const { typedData, digest } = erc8176Attestation({ descriptorHash, time: 1777630591, expirationTime: 1785406591, salt });
87
+
88
+ // A Permit2 signing request, with the profile in @meantjs/profile/packs/permit2.profile.json
89
+ eip712Consent({ resolved: permit2, typedData, chainId: 1, tokens: { "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48": { decimals: 6, symbol: "USDC" } } }).message;
90
+ // # Permit2: allow token spending\n\nLet `0x66a9…A8Af` spend up to `Unlimited USDC (0xA0b8…eB48)`. Allowance expires: …
86
91
  ```
87
92
 
88
93
  Every exported file in the tests is checked against the ERC-7730 2.1.0 JSON Schema (vendored in
@@ -243,5 +248,6 @@ Untrusted third-party data lives in `test/upstream/icrc21/` (the standard, commi
243
248
  - The caller's account (`From`) and a fee line: see `docs/oisy-comparison.md` for why they are absent.
244
249
  - Other languages; `device_spec` hints beyond the two forms.
245
250
  - 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
251
+ - EVM: payable functions in the consent text, EIP-712 witness messages and the signer check (spec §15.9 O9, O13),
252
+ ERC-7730 import, checking attestations, and the Rust
247
253
  version.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meantjs/consent",
3
- "version": "0.2.0",
3
+ "version": "0.3.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": {
@@ -64,8 +64,8 @@
64
64
  },
65
65
  "dependencies": {
66
66
  "@candid-core/schema": "0.5.0",
67
- "@meantjs/format": "0.2.0",
68
- "@meantjs/profile": "0.2.0"
67
+ "@meantjs/format": "0.3.0",
68
+ "@meantjs/profile": "0.3.0"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@dfinity/didc": "0.0.4",
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
 
package/src/erc7730.mjs CHANGED
@@ -19,6 +19,17 @@
19
19
  // The descriptor binds to deployments (ERC-7730 `context.contract.deployments`): the profile's, or the caller's when
20
20
  // every profile that lists deployments lists them too. `effect`, `idempotency`, `outcome` and the other agent tags
21
21
  // have no ERC-7730 form and are left out: the file carries what a call says, not what it does.
22
+ //
23
+ // EIP-712 messages (spec section 15.9) are a file of their own (`options.context: "eip712"`): ERC-7730 binds a file to
24
+ // a contract or to messages, not both. Its formats are keyed by each message's `encode_type`, whose Keccak-256 must be
25
+ // the message's `type_hash`; `context.eip712` carries the deployments and the domain name and version the profiles
26
+ // give. An amount whose token a field names is `tokenAmount` with that field's path, inside a field group of the array
27
+ // the two share, as ERC-7730's PermitBatch example writes it.
28
+ //
29
+ // Some words a profile adds have no ERC-7730 form but change no value: a timestamp's `threshold`, `message` and
30
+ // `labels` (written as a plain date), and an intent template ERC-7730 cannot interpolate (an array, a whole tuple, a
31
+ // [ ] segment; `interpolatedIntent` is optional and a wallet falls back to `intent`). They are left out, and each is
32
+ // returned as a note, instead of refusing the file.
22
33
 
23
34
  import { AbiError, jcs, keccak256Hex, parseSignature, readAddress, typedDataDigest } from "@meantjs/profile/evm";
24
35
  import { CODES, fail } from "./errors.mjs";
@@ -35,7 +46,7 @@ export const EAS_ADDRESS = "0xA1207F3BBa224E2c9c3c6D5aF63D0eb1582Ce587";
35
46
 
36
47
  const ZERO32 = `0x${"0".repeat(64)}`;
37
48
  const ZERO_ADDRESS = `0x${"0".repeat(40)}`;
38
- const OPTIONS = new Set(["deployments", "owner", "contractName", "info", "token"]);
49
+ const OPTIONS = new Set(["context", "deployments", "owner", "contractName", "info", "token"]);
39
50
  const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
40
51
  const nonEmpty = (v) => typeof v === "string" && v.trim() !== "";
41
52
 
@@ -91,10 +102,32 @@ function erc20Metadata(resolved) {
91
102
  return isObject(d) && isObject(s) && d.mode === "query" && s.mode === "query" && d.signature === "decimals()" && s.signature === "symbol()" && /^uint\d+$/.test(d.results?.[0]?.abi ?? "") && s.results?.[0]?.abi === "string" && d.results.length === 1 && s.results.length === 1;
92
103
  }
93
104
 
94
- /** One ERC-7730 field for a leaf of a function's parameters. */
95
- function fieldOf(node, path, label, ctx, where) {
105
+ /** The keys of a placeholder or token path through a viewed tree (`["details", "[]", "token"]`), or null. */
106
+ function keysOf(method, relpath) {
107
+ const rp = resolvePath(method, relpath);
108
+ return rp.error ? null : rp.steps.map((st) => (st.kind === "elem" ? "[]" : st.key));
109
+ }
110
+ const startsWith = (keys, prefix) => prefix.length <= keys.length && prefix.every((k, i) => keys[i] === k);
111
+
112
+ /**
113
+ * One ERC-7730 field for a leaf of a function's parameters or a message's members. `at` is where the leaf is:
114
+ * `{ 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
+ */
116
+ function fieldOf(node, path, label, ctx, where, at) {
96
117
  const field = { path, label };
97
118
  const amount = tagArgs(node, "amount");
119
+ if (amount?.token !== undefined) {
120
+ // In the token a field of the same value names (spec section 15.9): its path, relative to the group.
121
+ const keys = keysOf(at.method, String(amount.token));
122
+ if (keys === null) return ctx.problem(where, `amount.token ${JSON.stringify(amount.token)} does not resolve`);
123
+ if (!startsWith(keys, at.base)) return ctx.problem(where, "the amount is in an array group and its token is outside it; this export writes no path that leaves a group");
124
+ const params = { tokenPath: keys.slice(at.base.length).join(".") };
125
+ if (amount.threshold !== undefined) {
126
+ params.threshold = `0x${BigInt(amount.threshold).toString(16)}`;
127
+ params.message = amount.message ?? "Unlimited";
128
+ }
129
+ return { ...field, format: "tokenAmount", params };
130
+ }
98
131
  if (amount !== undefined) {
99
132
  const dynamic = amount.decimals?.from === "query:decimals" && amount.symbol?.from === "query:symbol";
100
133
  const literal = Number.isInteger(amount.decimals) && typeof amount.symbol === "string";
@@ -120,6 +153,8 @@ function fieldOf(node, path, label, ctx, where) {
120
153
  if (time !== undefined) {
121
154
  const kind = tagArgs(node, "timestamp") !== undefined ? "timestamp" : "duration";
122
155
  if (time.unit !== "s") return ctx.problem(where, `${kind} in ${time.unit}: ERC-7730 ${kind === "timestamp" ? "dates" : "durations"} are in seconds`);
156
+ const words = ["threshold", "labels"].filter((k) => time[k] !== undefined);
157
+ if (kind === "timestamp" && words.length) ctx.note(where, `the timestamp's ${words.join(" and ")} ha${words.length > 1 ? "ve" : "s"} no ERC-7730 date form and ${words.length > 1 ? "are" : "is"} left out; the file shows the date`);
123
158
  return kind === "timestamp" ? { ...field, format: "date", params: { encoding: "timestamp" } } : { ...field, format: "duration" };
124
159
  }
125
160
  const bytes = tagArgs(node, "bytes");
@@ -127,12 +162,30 @@ function fieldOf(node, path, label, ctx, where) {
127
162
  return { ...field, format: node.abi === "address" ? "addressName" : "raw" };
128
163
  }
129
164
 
130
- /** Every leaf of a viewed parameter tree, depth first in ABI order: its ERC-7730 path and its label. */
131
- function leaves(node, keys, out) {
132
- if (node.candid === "record") for (const [k, child] of entriesOf(node.fields)) leaves(child, [...keys, k], out);
133
- else if (node.candid === "vec") leaves(node.element, [...keys, "[]"], out);
134
- else out.push({ node, path: keys.join("."), label: labelOf(node) ?? [...keys].reverse().find((k) => k !== "[]") });
135
- return out;
165
+ /** Whether a subtree holds an amount whose token path starts with `prefix` (it is in the same array element). */
166
+ function tokenInside(node, method, prefix) {
167
+ if (node.candid === "record") return entriesOf(node.fields).some(([, child]) => tokenInside(child, method, prefix));
168
+ if (node.candid === "vec") return tokenInside(node.element, method, prefix);
169
+ const token = tagArgs(node, "amount")?.token;
170
+ const keys = token === undefined ? null : keysOf(method, String(token));
171
+ return keys !== null && startsWith(keys, prefix);
172
+ }
173
+
174
+ /**
175
+ * The ERC-7730 fields of a viewed parameter tree, depth first in ABI order. An array whose elements hold an amount
176
+ * whose token is in the same element is a field group (`{ path: "details.[]", fields }`), its paths relative to the
177
+ * element, as ERC-7730's PermitBatch example writes it; any other array is flattened (`legs.[].to`).
178
+ */
179
+ function fieldsOf(node, abs, rel, ctx, method, name) {
180
+ if (node.candid === "record") return entriesOf(node.fields).flatMap(([k, child]) => fieldsOf(child, [...abs, k], [...rel, k], ctx, method, name));
181
+ if (node.candid === "vec") {
182
+ const element = [...abs, "[]"];
183
+ if (tokenInside(node.element, method, element)) return [{ path: [...rel, "[]"].join("."), fields: fieldsOf(node.element, element, [], ctx, method, name) }];
184
+ return fieldsOf(node.element, element, [...rel, "[]"], ctx, method, name);
185
+ }
186
+ const label = labelOf(node) ?? [...abs].reverse().find((k) => k !== "[]");
187
+ const where = `${name}(0).${abs.join(".").replaceAll(".[]", "[]")}`;
188
+ return [fieldOf(node, rel.join("."), label, ctx, where, { method, abs, base: abs.slice(0, abs.length - rel.length) })];
136
189
  }
137
190
 
138
191
  /** A parameter or tuple component without a name: an ERC-7730 key names every one, and paths need the names. */
@@ -150,23 +203,28 @@ function unnamed(params, at = "") {
150
203
  return null;
151
204
  }
152
205
 
153
- /** The template as an ERC-7730 interpolatedIntent: `{(0).to}` reads `{to}`, `[[` and `]]` are plain brackets. */
206
+ /**
207
+ * The template as an ERC-7730 interpolatedIntent: `{(0).to}` reads `{to}`, `[[` and `]]` are plain brackets. A
208
+ * template ERC-7730 cannot interpolate gets none, and a note: `interpolatedIntent` is optional, and `intent` and the
209
+ * fields still say everything.
210
+ */
154
211
  function interpolated(template, method, ctx, where) {
212
+ const leftOut = (why) => ctx.note(where, `${why}; interpolatedIntent is left out`);
155
213
  let parts;
156
214
  try {
157
215
  parts = parseTemplate(template);
158
216
  } catch (e) {
159
- return ctx.problem(where, e.message);
217
+ return leftOut(e.message);
160
218
  }
161
219
  let out = "";
162
220
  for (const p of parts) {
163
221
  if (p.t === "lit") out += p.text.replaceAll("{", "{{").replaceAll("}", "}}");
164
- else if (p.t === "opt") return ctx.problem(where, "an optional [ ] segment has no ERC-7730 form");
222
+ else if (p.t === "opt") return leftOut("an optional [ ] segment has no ERC-7730 form");
165
223
  else {
166
224
  const rp = resolvePath(method, p.path);
167
225
  if (rp.error) return ctx.problem(where, `{${p.path}}: ${rp.error}`);
168
- if (rp.steps.some((s) => s.kind === "elem")) return ctx.problem(where, `{${p.path}} reads every element of an array; an ERC-7730 interpolation shows one value`);
169
- if (rp.node.candid === "record" || rp.node.candid === "vec") return ctx.problem(where, `{${p.path}} is a whole ${rp.node.abi}; an ERC-7730 interpolation shows one formatted field`);
226
+ 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
+ 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`);
170
228
  out += `{${rp.steps.map((s) => s.key).join(".")}}`;
171
229
  }
172
230
  }
@@ -180,29 +238,20 @@ function checkText(v, where) {
180
238
  else if (isObject(v)) for (const [k, x] of Object.entries(v)) checkText(k, where), checkText(x, `${where}.${k}`);
181
239
  }
182
240
 
183
- /**
184
- * The ERC-7730 file of an EVM contract's resolved form, and its ERC-8176 `descriptorHash`.
185
- *
186
- * @param {object} resolved an EVM contract's resolved form (`chain: "evm"`)
187
- * @param {object} [options]
188
- * @param {{ chainId: number, address: string }[]} [options.deployments] default: the deployments every profile lists
189
- * @param {string} [options.owner] ERC-7730 `metadata.owner`
190
- * @param {string} [options.contractName] `metadata.contractName`
191
- * @param {{ url: string, deploymentDate?: string }} [options.info] `metadata.info`
192
- * @param {{ name: string }} [options.token] the contract is the token its literal amounts are in: `metadata.token`
193
- * @returns {{ descriptor: object, descriptorHash: string }}
194
- */
195
- export function erc7730Descriptor(resolved, options = {}) {
196
- if (!isObject(resolved) || resolved.format !== "candid-profile-resolved" || !isObject(resolved.methods) || resolved.chain !== "evm") methodOf(resolved, "", "evm"); // throws the right error
197
- if (!isObject(options)) fail(CODES.REQUEST, "options must be an object");
198
- for (const k of Object.keys(options)) if (!OPTIONS.has(k)) fail(CODES.REQUEST, `unknown option ${JSON.stringify(k)}; expected one of ${[...OPTIONS].join(", ")}`);
199
- if (options.token !== undefined && !(isObject(options.token) && nonEmpty(options.token.name) && Object.keys(options.token).length === 1)) fail(CODES.REQUEST, "options.token must be { name } with a non-empty string");
200
-
201
- const problems = [];
202
- const ctx = { erc20: erc20Metadata(resolved), tokenName: options.token?.name, token: null, dynamic: false, problem: (at, message) => void problems.push(`${at}: ${message}`) };
203
- const deployments = deploymentsOf(resolved, options.deployments);
204
- const metadata = metadataOf(options);
241
+ /** One ERC-7730 format entry: the intent's title (else `describe.label`, else `fallback`), its template, the fields. */
242
+ function entryOf(name, method, viewed, fallback, ctx) {
243
+ const intent = method.tags?.intent?.args;
244
+ const entry = { intent: nonEmpty(intent?.title) ? intent.title : (labelOf(method) ?? fallback) };
245
+ if (typeof intent?.template === "string") {
246
+ const text = interpolated(intent.template, viewed, ctx, `${name}: intent template`);
247
+ if (text !== undefined) entry.interpolatedIntent = text;
248
+ }
249
+ const fields = viewed.args.length ? fieldsOf(viewed.args[0], [], [], ctx, viewed, name) : [];
250
+ return { entry, fields };
251
+ }
205
252
 
253
+ /** The formats of every function a transaction calls, keyed by its named signature. */
254
+ function functionFormats(resolved, ctx) {
206
255
  const formats = {};
207
256
  for (const [name, m] of entriesOf(resolved.methods)) {
208
257
  if (!isObject(m) || m.mode !== "update") continue;
@@ -221,26 +270,92 @@ export function erc7730Descriptor(resolved, options = {}) {
221
270
  continue;
222
271
  }
223
272
  const viewed = { ...method, args: method.args.map((n, i) => abiView(n, `${name}(${i})`)) };
224
- const intent = method.tags?.intent?.args;
225
- const entry = { intent: nonEmpty(intent?.title) ? intent.title : (labelOf(method) ?? `Call ${name}`) };
226
- if (typeof intent?.template === "string") {
227
- const text = interpolated(intent.template, viewed, ctx, `${name}: intent template`);
228
- if (text !== undefined) entry.interpolatedIntent = text;
229
- }
230
- const fields = viewed.args.length ? leaves(viewed.args[0], [], []).map((l) => fieldOf(l.node, l.path, l.label, ctx, `${name}(0).${l.path.replaceAll(".[]", "[]")}`)) : [];
273
+ const { entry, fields } = entryOf(name, method, viewed, `Call ${name}`, ctx);
231
274
  if (method.state_mutability === "payable") fields.push({ path: "@.value", label: "Value sent", format: "amount" });
232
275
  if (fields.length) entry.fields = fields;
233
276
  formats[method.signature] = entry;
234
277
  }
278
+ if (Object.keys(formats).length === 0) ctx.problem("functions", "the contract has no function a transaction calls (every function is view or pure)");
279
+ return formats;
280
+ }
281
+
282
+ /** The formats of every EIP-712 message (spec section 15.9), keyed by its encode_type, which must hash to its type_hash. */
283
+ function messageFormats(resolved, ctx) {
284
+ const formats = {};
285
+ for (const [name, m] of entriesOf(resolved.messages)) {
286
+ if (!isObject(m) || typeof m.encode_type !== "string" || !m.encode_type.startsWith(`${name}(`) || `0x${keccak256Hex(m.encode_type)}` !== m.type_hash) {
287
+ fail(CODES.RESOLVED_FORM, `messages.${name}: its encode_type does not hash to its type_hash, or names another type`);
288
+ }
289
+ if (!Array.isArray(m.args) || m.args.length !== 1) fail(CODES.RESOLVED_FORM, `messages.${name} has no args array of one struct`);
290
+ const viewed = { ...m, args: m.args.map((n, i) => abiView(n, `${name}(${i})`)) };
291
+ const { entry, fields } = entryOf(name, m, viewed, `Sign ${name}`, ctx);
292
+ if (fields.length) entry.fields = fields;
293
+ formats[m.encode_type] = entry;
294
+ }
295
+ if (Object.keys(formats).length === 0) ctx.problem("messages", "the resolved form describes no EIP-712 message");
296
+ return formats;
297
+ }
298
+
299
+ /** ERC-7730 `context.eip712.domain`: the name and version every profile that gives one gives. */
300
+ function domainOf(resolved, ctx) {
301
+ const domain = {};
302
+ for (const p of Array.isArray(resolved.profiles) ? resolved.profiles : []) {
303
+ if (!isObject(p?.eip712_domain)) continue;
304
+ for (const [k, v] of Object.entries(p.eip712_domain)) {
305
+ if ((k !== "name" && k !== "version") || typeof v !== "string") fail(CODES.RESOLVED_FORM, `profile ${p.name}: eip712_domain has ${JSON.stringify(k)}; only a name and a version are bound by a profile`);
306
+ if (Object.hasOwn(domain, k) && domain[k] !== v) ctx.problem("domain", `profiles give the domain two ${k}s, ${JSON.stringify(domain[k])} and ${JSON.stringify(v)}`);
307
+ domain[k] = v;
308
+ }
309
+ }
310
+ return domain;
311
+ }
312
+
313
+ /**
314
+ * The ERC-7730 file of an EVM contract's resolved form, and its ERC-8176 `descriptorHash`.
315
+ *
316
+ * @param {object} resolved an EVM contract's resolved form (`chain: "evm"`)
317
+ * @param {object} [options]
318
+ * @param {"contract" | "eip712"} [options.context] the functions a transaction calls (default), or the EIP-712
319
+ * messages (spec section 15.9): ERC-7730 binds one file to one of the two
320
+ * @param {{ chainId: number, address: string }[]} [options.deployments] default: the deployments every profile lists
321
+ * @param {string} [options.owner] ERC-7730 `metadata.owner`
322
+ * @param {string} [options.contractName] `metadata.contractName`
323
+ * @param {{ url: string, deploymentDate?: string }} [options.info] `metadata.info`
324
+ * @param {{ name: string }} [options.token] the contract is the token its literal amounts are in: `metadata.token`
325
+ * @returns {{ descriptor: object, descriptorHash: string, notes: string[] }} `notes`: what was left out because a
326
+ * file cannot say it, though the file still shows every value (a timestamp's words, an interpolatedIntent)
327
+ */
328
+ export function erc7730Descriptor(resolved, options = {}) {
329
+ if (!isObject(resolved) || resolved.format !== "candid-profile-resolved" || !isObject(resolved.methods) || resolved.chain !== "evm") methodOf(resolved, "", "evm"); // throws the right error
330
+ if (!isObject(options)) fail(CODES.REQUEST, "options must be an object");
331
+ for (const k of Object.keys(options)) if (!OPTIONS.has(k)) fail(CODES.REQUEST, `unknown option ${JSON.stringify(k)}; expected one of ${[...OPTIONS].join(", ")}`);
332
+ if (options.token !== undefined && !(isObject(options.token) && nonEmpty(options.token.name) && Object.keys(options.token).length === 1)) fail(CODES.REQUEST, "options.token must be { name } with a non-empty string");
333
+ const context = options.context ?? "contract";
334
+ if (context !== "contract" && context !== "eip712") fail(CODES.REQUEST, `options.context must be "contract" or "eip712", got ${JSON.stringify(options.context)}`);
335
+
336
+ const problems = [];
337
+ const notes = [];
338
+ const ctx = {
339
+ erc20: erc20Metadata(resolved),
340
+ tokenName: options.token?.name,
341
+ token: null,
342
+ dynamic: false,
343
+ problem: (at, message) => void problems.push(`${at}: ${message}`),
344
+ note: (at, message) => void notes.push(`${at}: ${message}`),
345
+ };
346
+ const deployments = deploymentsOf(resolved, options.deployments);
347
+ const metadata = metadataOf(options);
348
+ const formats = context === "eip712" ? messageFormats(resolved, ctx) : functionFormats(resolved, ctx);
349
+ const domain = context === "eip712" ? domainOf(resolved, ctx) : {};
235
350
  if (ctx.dynamic && ctx.token) problems.push("amounts: some read decimals() and symbol(), others are literal; an ERC-7730 wallet reads one token for @.to");
236
351
  if (problems.length) fail(CODES.EXPORT, `no ERC-7730 file: ${problems.join("; ")}`);
237
- if (Object.keys(formats).length === 0) fail(CODES.EXPORT, "no ERC-7730 file: the contract has no function a transaction calls (every function is view or pure)");
238
352
 
239
353
  if (ctx.token) metadata.token = { name: ctx.tokenName, ticker: ctx.token.ticker, decimals: ctx.token.decimals };
240
- const descriptor = { $schema: ERC7730_SCHEMA_URL, context: { contract: { deployments } } };
354
+ const bound = context === "eip712" ? { eip712: { ...(Object.keys(domain).length ? { domain } : {}), deployments } } : { contract: { deployments } };
355
+ const descriptor = { $schema: ERC7730_SCHEMA_URL, context: bound };
241
356
  if (Object.keys(metadata).length) descriptor.metadata = metadata;
242
357
  descriptor.display = { formats };
243
- return { descriptor, descriptorHash: descriptorHash(descriptor) };
358
+ return { descriptor, descriptorHash: descriptorHash(descriptor), notes };
244
359
  }
245
360
 
246
361
  /** ERC-8176 `descriptorHash`: Keccak-256 of the RFC 8785 (JCS) form of a descriptor without `includes`. */
package/src/errors.mjs CHANGED
@@ -22,6 +22,8 @@ export const CODES = Object.freeze({
22
22
  LANGUAGE: "E_LANGUAGE", // only with languagePolicy "error": the requested language is not supported
23
23
  REQUEST: "E_REQUEST", // the ICRC-21 request itself is malformed
24
24
  DEPLOYMENT: "E_DEPLOYMENT", // an EVM call's target is not a deployment its profile lists (spec section 15)
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
+ TYPE_HASH: "E_TYPE_HASH", // no message of the resolved form has the request's EIP-712 type hash (spec section 15.9)
25
27
  // this generator cannot describe the call (ICRC-21 ConsentMessageUnavailable)
26
28
  UNSUPPORTED_TYPE: "E_UNSUPPORTED_TYPE", // a position the resolved form cannot type (recursive cut, func, service)
27
29
  // the profile or the caller's context is wrong (ICRC-21 GenericError)
package/src/evm.d.mts CHANGED
@@ -23,6 +23,11 @@ export interface EvmConsentInput {
23
23
  chainId?: number | bigint | string;
24
24
  /** Resolved dynamic refs, keyed by `from`: `{ "query:decimals": 6, "query:symbol": "USDC" }`. */
25
25
  refs?: Record<string, unknown>;
26
+ /**
27
+ * For an amount whose token a field names (spec section 15.9): each such token's `decimals()` and `symbol()`, keyed by
28
+ * its address. An amount in a token not listed is E_REF_UNRESOLVED; it is shown beside its token's address.
29
+ */
30
+ tokens?: Record<string, { decimals: number | bigint | string; symbol?: string }>;
26
31
  /** Default "generic" (Markdown). */
27
32
  form?: "generic" | "fields";
28
33
  /** Generic form only: show timestamps in this zone (default UTC). */
@@ -49,3 +54,53 @@ export function evmConsent(input: EvmConsentInput): {
49
54
  provenance: Provenance;
50
55
  model: Model;
51
56
  };
57
+
58
+ /** An EIP-712 signing request, as `eth_signTypedData_v4` takes it. `types.EIP712Domain` may be left out. */
59
+ export interface TypedData {
60
+ types: Record<string, { name: string; type: string }[]>;
61
+ primaryType: string;
62
+ domain: { name?: string; version?: string; chainId?: number | bigint | string; verifyingContract?: string; salt?: string };
63
+ message: Record<string, unknown>;
64
+ }
65
+ export interface Eip712ConsentInput {
66
+ /** An EVM contract's resolved form with `messages` (spec section 15.9). */
67
+ resolved: ResolvedForm;
68
+ typedData: TypedData;
69
+ /** The wallet's active chain: a domain for another chain is refused (E_DOMAIN). */
70
+ chainId: number | bigint | string;
71
+ /** Resolved dynamic refs; `query:` refs are calls to the verifying contract. */
72
+ refs?: Record<string, unknown>;
73
+ /** For an amount whose token a member names: each token's `decimals()` and `symbol()`, keyed by its address. */
74
+ tokens?: Record<string, { decimals: number | bigint | string; symbol?: string }>;
75
+ /** Default "generic" (Markdown). */
76
+ form?: "generic" | "fields";
77
+ /** Generic form only: show timestamps in this zone (default UTC). */
78
+ utcOffsetMinutes?: number | null;
79
+ /** Append the message's values (spec section 11 item 1). */
80
+ rawArguments?: boolean;
81
+ limits?: { maxBytes?: number; maxElements?: number };
82
+ }
83
+ /**
84
+ * The consent text of an EIP-712 signing request (spec section 15.9): the message a profile describes, matched by its
85
+ * type hash, in words, ending with the verifying contract, the digest and what a signature is. Refused before anything
86
+ * is shown: a domain for another chain, without a chainId or verifyingContract, with a salt, or whose name or version
87
+ * is not its profiles' (E_DOMAIN); a contract its profiles do not list (E_DEPLOYMENT); no message with the request's
88
+ * type hash (E_TYPE_HASH); values that are not exactly the members, canonically written (E_DECODE); an amount in a
89
+ * token not in `tokens` (E_REF_UNRESOLVED).
90
+ */
91
+ export function eip712Consent(input: Eip712ConsentInput): {
92
+ form: "generic" | "fields";
93
+ /** Markdown for "generic"; the intent line and `[label, text]` rows for "fields". */
94
+ message: string | { intent: string; fields: [string, string][] };
95
+ primaryType: string;
96
+ /** `0x` and 64 hex digits. */
97
+ typeHash: string;
98
+ /** The EIP-712 digest the signature signs: `0x` and 64 hex digits. */
99
+ digest: string;
100
+ /** The verifying contract, in its EIP-55 form. */
101
+ verifyingContract: string;
102
+ chainId: string;
103
+ diagnostics: ConsentDiagnostic[];
104
+ provenance: Provenance;
105
+ model: Model;
106
+ };
package/src/evm.mjs CHANGED
@@ -13,13 +13,19 @@
13
13
  // person must see which contract asks (spec section 11).
14
14
  //
15
15
  // Refused: a payable function (its native value has no position yet, O2), and a call with no matching selector.
16
+ //
17
+ // A typed message (EIP-712, spec section 15.9) is the other thing an EVM wallet is asked to sign: `eip712Consent`
18
+ // gives it the same model and layouts, after checking that the request is the message a profile describes (its chain,
19
+ // its contract, its domain, its type hash) and reading its values strictly. An amount whose token a field names is
20
+ // shown with that token's decimals and symbol, which the caller supplies per address (`tokens`), and its address.
16
21
 
17
- import { AbiError, decodeParams, parseSignature, readAddress } from "@meantjs/profile/evm";
18
- import { Renderer } from "./display.mjs";
22
+ import { AbiError, DEFAULT_ABI_LIMITS, decodeParams, eip712TypeHash, keccak256Hex, parseEip712Types, parseSignature, readAddress, typedDataDigest } from "@meantjs/profile/evm";
23
+ import { Renderer, TokenAmount } from "./display.mjs";
19
24
  import { CODES, ConsentError, fail } from "./errors.mjs";
20
25
  import { modelOf } from "./model.mjs";
26
+ import { resolvePath } from "./paths.mjs";
21
27
  import { renderGeneric, renderPlain } from "./render.mjs";
22
- import { abiView, methodOf } from "./resolved.mjs";
28
+ import { abiView, entriesOf, methodOf, tagArgs } from "./resolved.mjs";
23
29
  import { codeSpan } from "./text.mjs";
24
30
 
25
31
  const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
@@ -85,6 +91,49 @@ function checkTarget(resolved, to, chain) {
85
91
  }
86
92
  }
87
93
 
94
+ /** The caller's `tokens`: the decimals and symbol of each token, keyed by its address in any valid spelling. */
95
+ function tokenTable(tokens) {
96
+ if (!isObject(tokens)) fail(CODES.REQUEST, "tokens must be an object of token address to { decimals, symbol }");
97
+ const out = new Map();
98
+ for (const [k, v] of Object.entries(tokens)) {
99
+ let address;
100
+ try {
101
+ address = readAddress(k);
102
+ } catch (e) {
103
+ if (e instanceof AbiError) fail(CODES.REQUEST, `tokens: ${e.message}`);
104
+ throw e;
105
+ }
106
+ if (!isObject(v)) fail(CODES.REQUEST, `tokens[${JSON.stringify(k)}] must be { decimals, symbol }`);
107
+ if (out.has(address)) fail(CODES.REQUEST, `tokens lists ${address} twice`);
108
+ out.set(address, { decimals: v.decimals, symbol: v.symbol });
109
+ }
110
+ return out;
111
+ }
112
+
113
+ /**
114
+ * The arguments with each amount whose token a field names (spec section 15.9) made a TokenAmount: its base units, the
115
+ * address that field holds in the same array element, and that token's metadata from `tokens`. A token the caller did
116
+ * not describe stops the message (E_REF_UNRESOLVED): a number without its decimals is never shown (spec section 12
117
+ * rule 10).
118
+ */
119
+ function withTokens(name, viewed, args, tokens) {
120
+ const box = (node, value, keys, indices) => {
121
+ if (node.candid === "record") return Object.fromEntries(entriesOf(node.fields).map(([k, child]) => [k, box(child, value[k], [...keys, k], indices)]));
122
+ if (node.candid === "vec") return value.map((e, i) => box(node.element, e, [...keys, "[]"], [...indices, i]));
123
+ const token = tagArgs(node, "amount")?.token;
124
+ if (token === undefined) return value;
125
+ const where = `${name}(0)${keys.map((k) => (k === "[]" ? "[]" : `.${k}`)).join("")}`;
126
+ const rp = resolvePath(viewed, String(token));
127
+ let address = rp.error ? undefined : args[rp.index];
128
+ let n = 0;
129
+ for (const step of rp.steps ?? []) address = step.kind === "elem" ? address?.[indices[n++]] : address?.[step.key];
130
+ if (typeof address !== "string" || rp.node.abi !== "address" || n > indices.length) fail(CODES.RESOLVED_FORM, `${where}: amount.token ${JSON.stringify(token)} does not lead to an address in the same element`);
131
+ if (!tokens.has(address)) fail(CODES.REF_UNRESOLVED, `${where} is an amount of the token ${address}, whose decimals and symbol were not given (tokens); without them it has no text`);
132
+ return new TokenAmount(value, address, tokens.get(address));
133
+ };
134
+ return args.map((v, i) => box(viewed.args[i], v, [], []));
135
+ }
136
+
88
137
  /**
89
138
  * The consent text of an EVM call.
90
139
  *
@@ -94,13 +143,15 @@ function checkTarget(resolved, to, chain) {
94
143
  * @param {string} input.to the contract called (shown, and checked against the profiles' deployments)
95
144
  * @param {number | bigint | string} [input.chainId] needed when a profile lists deployments
96
145
  * @param {Record<string, unknown>} [input.refs] resolved dynamic refs, for example { "query:decimals": 6, "query:symbol": "USDC" }
146
+ * @param {Record<string, { decimals: number, symbol?: string }>} [input.tokens] for an amount whose token a field
147
+ * names (spec section 15.9): each such token's decimals() and symbol(), keyed by its address
97
148
  * @param {"generic" | "fields"} [input.form] default "generic"
98
149
  * @param {number | null} [input.utcOffsetMinutes] generic form only (default 0, UTC)
99
150
  * @param {boolean} [input.rawArguments] append the decoded arguments (spec section 11 item 1)
100
151
  * @param {object} [input.limits] decoder limits (maxBytes, maxDepth, maxElements)
101
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 }}
102
153
  */
103
- export function evmConsent({ resolved, calldata, to, chainId, refs = {}, form = "generic", utcOffsetMinutes = null, rawArguments = false, limits }) {
154
+ export function evmConsent({ resolved, calldata, to, chainId, refs = {}, tokens = {}, form = "generic", utcOffsetMinutes = null, rawArguments = false, limits }) {
104
155
  if (form !== "generic" && form !== "fields") fail(CODES.REQUEST, `form must be "generic" or "fields", got ${JSON.stringify(form)}`);
105
156
  let target;
106
157
  try {
@@ -115,7 +166,7 @@ export function evmConsent({ resolved, calldata, to, chainId, refs = {}, form =
115
166
  const method = methodOf(resolved, name, "evm");
116
167
  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)`);
117
168
  const viewed = { ...method, args: method.args.map((n, i) => abiView(n, `${name}(${i})`)) };
118
- const model = modelOf(resolved, name, viewed, viewed.args.length ? [values] : []);
169
+ const model = modelOf(resolved, name, viewed, viewed.args.length ? withTokens(name, viewed, [values], tokenTable(tokens)) : []);
119
170
  const where = `${target}${chain === null ? "" : ` on chain ${chain}`}`;
120
171
  const done = (rest) => ({ ...rest, method: name, selector, to: target, chainId: chain, diagnostics: model.diagnostics, provenance: model.provenance, model });
121
172
  if (form === "fields") {
@@ -127,3 +178,181 @@ export function evmConsent({ resolved, calldata, to, chainId, refs = {}, form =
127
178
  const text = `${renderGeneric(model, renderer, { rawArguments })}\n\n**Contract:** ${codeSpan(target)}${chain === null ? "" : ` on chain ${chain}`}`;
128
179
  return done({ form, message: text });
129
180
  }
181
+
182
+ // ---------------------------------------------------------------------------------------------- EIP-712 messages
183
+
184
+ /** The domain's fields in the order EIP-712 lists them, with their types. */
185
+ const DOMAIN_FIELDS = [
186
+ ["name", "string"],
187
+ ["version", "string"],
188
+ ["chainId", "uint256"],
189
+ ["verifyingContract", "address"],
190
+ ["salt", "bytes32"],
191
+ ];
192
+
193
+ /** One value of a typed message, read strictly (spec section 15.9): the shapes decodeCalldata gives, or E_DECODE. */
194
+ function readTyped(types, type, value, where, budget) {
195
+ const bad = (message) => fail(CODES.DECODE, `${where}: ${message}`);
196
+ const arr = /^(.*)\[(\d*)\]$/.exec(type);
197
+ if (arr) {
198
+ if (!Array.isArray(value)) bad(`${type} needs an array`);
199
+ if (arr[2] !== "" && value.length !== Number(arr[2])) bad(`${type} needs ${arr[2]} elements, got ${value.length}`);
200
+ budget.elements += value.length;
201
+ if (budget.elements > budget.maxElements) bad(`more than ${budget.maxElements} array elements in the message`);
202
+ return value.map((v, i) => readTyped(types, arr[1], v, `${where}[${i}]`, budget));
203
+ }
204
+ if (Object.hasOwn(types, type)) {
205
+ if (!isObject(value)) bad(`${type} needs an object of its members`);
206
+ const members = types[type];
207
+ for (const k of Object.keys(value)) if (!members.some((m) => m.name === k)) bad(`${type} has no member ${JSON.stringify(k)}`);
208
+ return Object.fromEntries(members.map((m) => [m.name, Object.hasOwn(value, m.name) ? readTyped(types, m.type, value[m.name], `${where}.${m.name}`, budget) : bad(`${type} needs its member ${m.name}`)]));
209
+ }
210
+ const int = /^(u?)int([0-9]+)$/.exec(type);
211
+ if (int) {
212
+ let x;
213
+ if (typeof value === "number" && Number.isSafeInteger(value)) x = BigInt(value);
214
+ else if (typeof value === "string" && /^-?(?:0|[1-9][0-9]*)$/.test(value) && value !== "-0") x = BigInt(value);
215
+ else bad(`${type} needs a decimal string or a JSON integer below 2^53, got ${JSON.stringify(value)}`);
216
+ const bits = BigInt(int[2]);
217
+ const [min, max] = int[1] === "u" ? [0n, 2n ** bits - 1n] : [-(2n ** (bits - 1n)), 2n ** (bits - 1n) - 1n];
218
+ if (x < min || x > max) bad(`${x} is outside ${type}`);
219
+ return x.toString();
220
+ }
221
+ if (type === "address") {
222
+ try {
223
+ return readAddress(value);
224
+ } catch (e) {
225
+ if (e instanceof AbiError) bad(e.message);
226
+ throw e;
227
+ }
228
+ }
229
+ if (type === "bool") return typeof value === "boolean" ? value : bad("bool needs true or false");
230
+ if (type === "string") return typeof value === "string" && value.isWellFormed() ? value : bad("string needs well-formed text");
231
+ const bytes = /^bytes([0-9]*)$/.exec(type);
232
+ if (bytes) {
233
+ if (typeof value !== "string" || !/^0x(?:[0-9a-fA-F]{2})*$/.test(value)) bad(`${type} needs 0x and hex digits`);
234
+ const b = Uint8Array.from(value.slice(2).match(/../g) ?? [], (h) => parseInt(h, 16));
235
+ if (bytes[1] !== "" && b.length !== Number(bytes[1])) bad(`${type} needs ${bytes[1]} bytes, got ${b.length}`);
236
+ if (b.length > budget.maxBytes) bad(`${b.length} bytes is over the limit of ${budget.maxBytes}`);
237
+ return b;
238
+ }
239
+ bad(`unknown type ${type}`);
240
+ }
241
+
242
+ /** The request's domain: only EIP-712's fields, no salt (O3), and its EIP712Domain type, if given, exactly those fields. */
243
+ function readDomain(domain, declared) {
244
+ if (!isObject(domain)) fail(CODES.REQUEST, "typedData.domain must be an object");
245
+ for (const k of Object.keys(domain)) if (!DOMAIN_FIELDS.some(([f]) => f === k)) fail(CODES.DOMAIN, `the domain has a field ${JSON.stringify(k)} EIP-712 does not define`);
246
+ if (Object.hasOwn(domain, "salt")) fail(CODES.DOMAIN, "a domain with a salt is not supported yet (spec section 15.9, O3)");
247
+ const fields = DOMAIN_FIELDS.filter(([k]) => Object.hasOwn(domain, k)).map(([name, type]) => ({ name, type }));
248
+ // Each member's name and type, in order; the order of a member's JSON keys is not part of the type (as for the other structs).
249
+ const same = Array.isArray(declared) && declared.length === fields.length && declared.every((m, i) => isObject(m) && m.name === fields[i].name && m.type === fields[i].type);
250
+ if (declared !== undefined && !same) {
251
+ fail(CODES.DOMAIN, `types.EIP712Domain must list the domain's fields in EIP-712 order (${fields.map((f) => `${f.type} ${f.name}`).join(", ")})`);
252
+ }
253
+ for (const k of ["name", "version"]) if (Object.hasOwn(domain, k) && typeof domain[k] !== "string") fail(CODES.DOMAIN, `domain.${k} must be a string`);
254
+ if (!Object.hasOwn(domain, "chainId")) fail(CODES.DOMAIN, "the domain has no chainId, so the signature is valid on every chain; it is refused");
255
+ if (!Object.hasOwn(domain, "verifyingContract")) fail(CODES.DOMAIN, "the domain has no verifyingContract: the contract that will act on the signature must be known");
256
+ const chain = readTyped({}, "uint256", domain.chainId, "domain.chainId", { elements: 0, maxElements: 0, maxBytes: 0 });
257
+ let contract;
258
+ try {
259
+ contract = readAddress(domain.verifyingContract);
260
+ } catch (e) {
261
+ if (e instanceof AbiError) fail(CODES.DOMAIN, `domain.verifyingContract: ${e.message}`);
262
+ throw e;
263
+ }
264
+ return { fields, chain, contract };
265
+ }
266
+
267
+ /** The message of a resolved form whose type hash is the request's; the entry's own encode_type must hash to it. */
268
+ function messageOf(resolved, primaryType, typeHash) {
269
+ if (!isObject(resolved) || resolved.format !== "candid-profile-resolved" || !isObject(resolved.methods) || resolved.chain !== "evm") methodOf(resolved, "", "evm"); // throws the right error
270
+ if (!isObject(resolved.messages)) fail(CODES.TYPE_HASH, "the resolved form describes no EIP-712 message");
271
+ const hit = entriesOf(resolved.messages).find(([, m]) => isObject(m) && m.type_hash === typeHash);
272
+ if (!hit) fail(CODES.TYPE_HASH, `no message of the resolved form has the type hash ${typeHash} (${primaryType}); a request is matched by its type hash, never by its name`);
273
+ const [name, m] = hit;
274
+ if (typeof m.encode_type !== "string" || `0x${keccak256Hex(m.encode_type)}` !== typeHash || name !== primaryType || !m.encode_type.startsWith(`${name}(`)) {
275
+ fail(CODES.RESOLVED_FORM, `messages.${name}: its encode_type does not hash to its type_hash, or names another type`);
276
+ }
277
+ if (!Array.isArray(m.args) || m.args.length !== 1) fail(CODES.RESOLVED_FORM, `messages.${name} has no args array of one struct`);
278
+ return { name, message: m };
279
+ }
280
+
281
+ /**
282
+ * Every profile that names the domain (`eip712_domain`) must name the request's: its name and its version each equal,
283
+ * and absent where the profile's is absent (Permit2's domain has no version; one with a version is another domain).
284
+ */
285
+ function checkDomain(resolved, domain) {
286
+ for (const p of Array.isArray(resolved.profiles) ? resolved.profiles : []) {
287
+ if (!isObject(p?.eip712_domain)) continue;
288
+ for (const k of ["name", "version"]) {
289
+ const v = p.eip712_domain[k];
290
+ if (domain[k] === v) continue;
291
+ const theirs = domain[k] === undefined ? "a domain without one" : JSON.stringify(domain[k]);
292
+ fail(CODES.DOMAIN, v === undefined ? `profile ${p.name} describes a domain without a ${k}, not one whose ${k} is ${theirs}` : `profile ${p.name} describes the domain whose ${k} is ${JSON.stringify(v)}, not ${theirs}`);
293
+ }
294
+ }
295
+ }
296
+
297
+ /**
298
+ * The consent text of an EIP-712 signing request (spec section 15.9): the message a profile describes, its values in
299
+ * words, and what a signature is. Refused before anything is shown when the request does not match: a domain for
300
+ * another chain than the wallet's or without a chainId or verifyingContract (E_DOMAIN), a contract its profiles do not
301
+ * list (E_DEPLOYMENT), a domain name or version other than its profiles' (E_DOMAIN), no message with the request's
302
+ * type hash (E_TYPE_HASH), a message whose values are not exactly its members, canonically written (E_DECODE).
303
+ *
304
+ * @param {object} input
305
+ * @param {object} input.resolved an EVM contract's resolved form with `messages`
306
+ * @param {{ types: object, primaryType: string, domain: object, message: object }} input.typedData the request
307
+ * (`eth_signTypedData_v4`'s JSON); `types.EIP712Domain` may be left out
308
+ * @param {number | bigint | string} input.chainId the wallet's active chain
309
+ * @param {Record<string, unknown>} [input.refs] resolved dynamic refs: `query:` refs are calls to the verifying contract
310
+ * @param {Record<string, { decimals: number, symbol?: string }>} [input.tokens] each token an amount's `token` names
311
+ * @param {"generic" | "fields"} [input.form] default "generic"
312
+ * @param {number | null} [input.utcOffsetMinutes] generic form only (default 0, UTC)
313
+ * @param {boolean} [input.rawArguments] append the message's values (spec section 11 item 1)
314
+ * @param {{ maxBytes?: number, maxElements?: number }} [input.limits]
315
+ */
316
+ export function eip712Consent({ resolved, typedData, chainId, refs = {}, tokens = {}, form = "generic", utcOffsetMinutes = null, rawArguments = false, limits = {} }) {
317
+ if (form !== "generic" && form !== "fields") fail(CODES.REQUEST, `form must be "generic" or "fields", got ${JSON.stringify(form)}`);
318
+ if (!isObject(typedData) || !isObject(typedData.types) || typeof typedData.primaryType !== "string" || !isObject(typedData.message)) {
319
+ fail(CODES.REQUEST, "typedData must be { types, primaryType, domain, message }");
320
+ }
321
+ const active = chainOf(chainId);
322
+ if (active === null) fail(CODES.REQUEST, "give the wallet's active chainId: a signature for another chain is refused");
323
+ const { EIP712Domain: declared, ...own } = typedData.types;
324
+ const { fields: domainType, chain, contract } = readDomain(typedData.domain, declared);
325
+ if (chain !== active) fail(CODES.DOMAIN, `the domain is for chain ${chain}, the wallet is on chain ${active}`);
326
+ const parsed = parseEip712Types(own, "typedData.types");
327
+ if (parsed.errors.length) fail(CODES.REQUEST, parsed.errors.map((e) => `${e.at}: ${e.message}`).join("; "));
328
+ if (!Object.hasOwn(parsed.types, typedData.primaryType)) fail(CODES.REQUEST, `typedData.types has no ${JSON.stringify(typedData.primaryType)}`);
329
+ const typeHash = eip712TypeHash(parsed.types, typedData.primaryType);
330
+ const { name, message } = messageOf(resolved, typedData.primaryType, typeHash);
331
+ checkTarget(resolved, contract, chain);
332
+ checkDomain(resolved, typedData.domain);
333
+
334
+ const budget = { elements: 0, maxElements: limits.maxElements ?? DEFAULT_ABI_LIMITS.maxElements, maxBytes: limits.maxBytes ?? DEFAULT_ABI_LIMITS.maxBytes };
335
+ const values = readTyped(parsed.types, name, typedData.message, "message", budget);
336
+ let digest;
337
+ try {
338
+ digest = typedDataDigest({ domain: typedData.domain, types: { ...parsed.types, EIP712Domain: domainType }, primaryType: name, message: typedData.message });
339
+ } catch (e) {
340
+ if (e instanceof AbiError) fail(CODES.DECODE, e.message);
341
+ throw e;
342
+ }
343
+ const viewed = { ...message, args: message.args.map((n, i) => abiView(n, `${name}(${i})`)) };
344
+ const model = modelOf(resolved, name, viewed, withTokens(name, viewed, [values], tokenTable(tokens)));
345
+ 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 });
347
+ if (form === "fields") {
348
+ const plain = renderPlain(model, new Renderer({ refs, diagnostics: model.diagnostics }), { rawArguments });
349
+ plain.fields.push(["Contract", where], ["Signature", SIGNATURE_NOTE], ["EIP-712 digest", digest]);
350
+ return done({ form, message: plain });
351
+ }
352
+ const renderer = new Renderer({ refs, utcOffsetMinutes: utcOffsetMinutes ?? 0, diagnostics: model.diagnostics });
353
+ const text = `${renderGeneric(model, renderer, { rawArguments })}\n\n**Contract:** ${codeSpan(contract)} on chain ${chain}\n\n**EIP-712 digest:** ${codeSpan(digest)}\n\n${SIGNATURE_NOTE}.`;
354
+ return done({ form, message: text });
355
+ }
356
+
357
+ /** What a typed-data signature is, said with every message (spec section 15.9). */
358
+ const SIGNATURE_NOTE = "This is a signature, not a transaction: whoever holds it can submit it";
package/src/index.d.mts CHANGED
@@ -207,6 +207,8 @@ export const CODES: Readonly<{
207
207
  LANGUAGE: "E_LANGUAGE";
208
208
  REQUEST: "E_REQUEST";
209
209
  DEPLOYMENT: "E_DEPLOYMENT";
210
+ DOMAIN: "E_DOMAIN";
211
+ TYPE_HASH: "E_TYPE_HASH";
210
212
  UNSUPPORTED_TYPE: "E_UNSUPPORTED_TYPE";
211
213
  RESOLVED_FORM: "E_RESOLVED_FORM";
212
214
  TEMPLATE: "E_TEMPLATE";