@meantjs/consent 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/evm.mjs ADDED
@@ -0,0 +1,358 @@
1
+ // EVM calldata -> the consent text of a call (spec section 15, D-054). ICRC-21 is the Internet Computer's, so an EVM
2
+ // call gets the same model and the same two layouts without the ICRC-21 envelope: the generic form (Markdown) and the
3
+ // fields form as plain text.
4
+ //
5
+ // Steps:
6
+ // 1. the resolved form must be an EVM contract's (`chain: "evm"`), and the call's target, when a profile lists
7
+ // deployments, one of them (a profile describes the contracts it binds, never a look-alike);
8
+ // 2. the selector picks the function; its `signature` gives the parameter types, and calldata is decoded strictly
9
+ // (`@meantjs/profile/evm` decodeParams: anything but the canonical encoding of the values read is refused);
10
+ // 3. the resolved tree is read as a Candid-shaped view (a tuple is a record, an array a vec), so the template, the
11
+ // paths and the fields are those of a Candid call (model.mjs);
12
+ // 4. the text ends with the contract's address and chain: a pack applies to any contract whose functions match, so a
13
+ // person must see which contract asks (spec section 11).
14
+ //
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.
21
+
22
+ import { AbiError, DEFAULT_ABI_LIMITS, decodeParams, eip712TypeHash, keccak256Hex, parseEip712Types, parseSignature, readAddress, typedDataDigest } from "@meantjs/profile/evm";
23
+ import { Renderer, TokenAmount } from "./display.mjs";
24
+ import { CODES, ConsentError, fail } from "./errors.mjs";
25
+ import { modelOf } from "./model.mjs";
26
+ import { resolvePath } from "./paths.mjs";
27
+ import { renderGeneric, renderPlain } from "./render.mjs";
28
+ import { abiView, entriesOf, methodOf, tagArgs } from "./resolved.mjs";
29
+ import { codeSpan } from "./text.mjs";
30
+
31
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
32
+
33
+ function bytesOf(calldata) {
34
+ if (calldata instanceof Uint8Array) return calldata;
35
+ if (typeof calldata === "string" && /^0x(?:[0-9a-fA-F]{2})*$/.test(calldata)) return Uint8Array.from(calldata.slice(2).match(/../g) ?? [], (h) => parseInt(h, 16));
36
+ fail(CODES.REQUEST, "calldata must be a Uint8Array or 0x hex text");
37
+ }
38
+
39
+ function chainOf(chainId) {
40
+ if (chainId === undefined || chainId === null) return null;
41
+ const s = typeof chainId === "number" && Number.isSafeInteger(chainId) ? String(chainId) : typeof chainId === "bigint" ? chainId.toString() : chainId;
42
+ if (typeof s !== "string" || !/^[1-9][0-9]{0,19}$/.test(s)) fail(CODES.REQUEST, `chainId must be a positive integer, got ${String(chainId)}`);
43
+ return s;
44
+ }
45
+
46
+ /** The function of a selector, from the resolved form; its signature is checked against the selector. */
47
+ function functionOf(resolved, selector) {
48
+ const hit = Object.entries(resolved.methods).find(([, m]) => isObject(m) && m.selector === selector);
49
+ if (!hit) fail(CODES.METHOD_UNKNOWN, `the resolved form has no function with selector ${selector}`);
50
+ const [name, method] = hit;
51
+ let f;
52
+ try {
53
+ f = parseSignature(String(method.signature), `methods.${name}.signature`);
54
+ } catch (e) {
55
+ if (e instanceof AbiError) fail(CODES.RESOLVED_FORM, e.message);
56
+ throw e;
57
+ }
58
+ if (f.name !== name || f.selector !== selector) fail(CODES.RESOLVED_FORM, `methods.${name}: the signature ${method.signature} does not hash to ${selector}`);
59
+ return { name, method: methodOf(resolved, name, "evm"), f };
60
+ }
61
+
62
+ /**
63
+ * Decode one EVM call against a resolved form: which function, and its arguments.
64
+ *
65
+ * @param {object} resolved an EVM contract's resolved form (`chain: "evm"`)
66
+ * @param {Uint8Array | string} calldata the selector and the ABI-encoded arguments
67
+ * @param {{ maxBytes?: number, maxDepth?: number, maxElements?: number }} [limits]
68
+ * @returns {{ method: string, selector: string, values: Record<string, unknown> }}
69
+ */
70
+ export function decodeCalldata(resolved, calldata, limits) {
71
+ if (!isObject(resolved) || resolved.format !== "candid-profile-resolved" || !isObject(resolved.methods) || resolved.chain !== "evm") methodOf(resolved, "", "evm"); // throws the right error
72
+ const data = bytesOf(calldata);
73
+ if (data.length < 4) fail(CODES.DECODE, `calldata of ${data.length} bytes has no selector`);
74
+ const selector = `0x${[...data.subarray(0, 4)].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
75
+ const { name, f } = functionOf(resolved, selector);
76
+ try {
77
+ return { method: name, selector, values: decodeParams(f.inputs, data.subarray(4), limits) };
78
+ } catch (e) {
79
+ if (e instanceof AbiError) throw new ConsentError(CODES.DECODE, `${name}: ${e.message}`, { cause: e });
80
+ throw e;
81
+ }
82
+ }
83
+
84
+ /** Every profile that lists deployments must list this one (chain id and address). */
85
+ function checkTarget(resolved, to, chain) {
86
+ for (const p of Array.isArray(resolved.profiles) ? resolved.profiles : []) {
87
+ if (!Array.isArray(p?.deployments)) continue;
88
+ if (chain === null) fail(CODES.REQUEST, `profile ${p.name} binds deployments: give the chainId of the call`);
89
+ const listed = p.deployments.some((d) => String(d.chain_id) === chain && typeof d.address === "string" && d.address.toLowerCase() === to.toLowerCase());
90
+ if (!listed) fail(CODES.DEPLOYMENT, `profile ${p.name} describes ${p.deployments.map((d) => `${d.address} on chain ${d.chain_id}`).join(", ")}, not ${to} on chain ${chain}`);
91
+ }
92
+ }
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
+
137
+ /**
138
+ * The consent text of an EVM call.
139
+ *
140
+ * @param {object} input
141
+ * @param {object} input.resolved an EVM contract's resolved form
142
+ * @param {Uint8Array | string} input.calldata
143
+ * @param {string} input.to the contract called (shown, and checked against the profiles' deployments)
144
+ * @param {number | bigint | string} [input.chainId] needed when a profile lists deployments
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
148
+ * @param {"generic" | "fields"} [input.form] default "generic"
149
+ * @param {number | null} [input.utcOffsetMinutes] generic form only (default 0, UTC)
150
+ * @param {boolean} [input.rawArguments] append the decoded arguments (spec section 11 item 1)
151
+ * @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 }}
153
+ */
154
+ export function evmConsent({ resolved, calldata, to, chainId, refs = {}, tokens = {}, form = "generic", utcOffsetMinutes = null, rawArguments = false, limits }) {
155
+ if (form !== "generic" && form !== "fields") fail(CODES.REQUEST, `form must be "generic" or "fields", got ${JSON.stringify(form)}`);
156
+ let target;
157
+ try {
158
+ target = readAddress(to);
159
+ } catch (e) {
160
+ if (e instanceof AbiError) fail(CODES.REQUEST, `to: ${e.message}`);
161
+ throw e;
162
+ }
163
+ const chain = chainOf(chainId);
164
+ const { method: name, selector, values } = decodeCalldata(resolved, calldata, limits);
165
+ checkTarget(resolved, target, chain);
166
+ const method = methodOf(resolved, name, "evm");
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)`);
168
+ const viewed = { ...method, args: method.args.map((n, i) => abiView(n, `${name}(${i})`)) };
169
+ const model = modelOf(resolved, name, viewed, viewed.args.length ? withTokens(name, viewed, [values], tokenTable(tokens)) : []);
170
+ 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 });
172
+ if (form === "fields") {
173
+ const plain = renderPlain(model, new Renderer({ refs, diagnostics: model.diagnostics }), { rawArguments });
174
+ plain.fields.push(["Contract", where]);
175
+ return done({ form, message: plain });
176
+ }
177
+ const renderer = new Renderer({ refs, utcOffsetMinutes: utcOffsetMinutes ?? 0, diagnostics: model.diagnostics });
178
+ const text = `${renderGeneric(model, renderer, { rawArguments })}\n\n**Contract:** ${codeSpan(target)}${chain === null ? "" : ` on chain ${chain}`}`;
179
+ return done({ form, message: text });
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
@@ -206,6 +206,9 @@ export const CODES: Readonly<{
206
206
  TOO_LARGE: "E_TOO_LARGE";
207
207
  LANGUAGE: "E_LANGUAGE";
208
208
  REQUEST: "E_REQUEST";
209
+ DEPLOYMENT: "E_DEPLOYMENT";
210
+ DOMAIN: "E_DOMAIN";
211
+ TYPE_HASH: "E_TYPE_HASH";
209
212
  UNSUPPORTED_TYPE: "E_UNSUPPORTED_TYPE";
210
213
  RESOLVED_FORM: "E_RESOLVED_FORM";
211
214
  TEMPLATE: "E_TEMPLATE";
@@ -214,6 +217,7 @@ export const CODES: Readonly<{
214
217
  ARG: "E_ARG";
215
218
  TAG_TYPE: "E_TAG_TYPE";
216
219
  TAG_CONFLICT: "E_TAG_CONFLICT";
220
+ EXPORT: "E_EXPORT";
217
221
  }>;
218
222
  export type ConsentErrorCode = (typeof CODES)[keyof typeof CODES];
219
223
  /** ICRC-21 `GenericError.error_code` values this package uses. */
package/src/model.mjs CHANGED
@@ -3,9 +3,10 @@
3
3
  // disagree about which values the call carries.
4
4
  //
5
5
  // Which fields appear:
6
- // - a method with an `intent` tag: the placeholders of its template that are present (spec section 6; section 12:
7
- // "one (label, Value) row per placeholder"), in template order, once per distinct path;
8
- // - a method without one: the generic intent. Title from `describe.label` of the method, else `Call <method>`;
6
+ // - a call with an intent, its case's (spec section 5.2, D-053) or else its method's: the placeholders of its template
7
+ // that are present (spec section 6; section 12: "one (label, Value) row per placeholder"), in template order,
8
+ // once per distinct path;
9
+ // - a call without one: the generic intent. Title from `describe.label` of the method, else `Call <method>`;
9
10
  // one field per leaf of the decoded arguments (records are flattened, tagged positions such as accounts are
10
11
  // leaves, absent optionals are left out). More than MAX_FIELDS fields or MAX_ELEMENTS list elements is
11
12
  // refused (E_TOO_LARGE), never truncated.
@@ -13,7 +14,7 @@
13
14
  import { VALUE_TAGS } from "@meantjs/format";
14
15
  import { decodeArguments } from "./decode.mjs";
15
16
  import { CODES, ConsentError, fail } from "./errors.mjs";
16
- import { effectKind, entriesOf, labelOf, methodOf } from "./resolved.mjs";
17
+ import { callTag, effectKind, entriesOf, labelOf, methodOf } from "./resolved.mjs";
17
18
  import { parseTemplate } from "./template.mjs";
18
19
  import { resolvePath, valuesAt } from "./paths.mjs";
19
20
 
@@ -31,9 +32,9 @@ export const MAX_ELEMENTS = 256;
31
32
  *
32
33
  * @typedef {object} Model
33
34
  * @property {string} method
34
- * @property {string} effect the effect kind of the method (spec section 5.3)
35
+ * @property {string} effect the effect kind of the call: its case's, else its method's (spec section 5.3, D-053)
35
36
  * @property {string} title
36
- * @property {SentencePart[] | null} sentence null for a method without an intent template
37
+ * @property {SentencePart[] | null} sentence null for a call without an intent template
37
38
  * @property {Field[]} fields
38
39
  * @property {object[]} argNodes
39
40
  * @property {unknown[]} argValues
@@ -48,13 +49,18 @@ export const MAX_ELEMENTS = 256;
48
49
  */
49
50
  export function buildModel({ resolved, method: name, arg, limits }) {
50
51
  const method = methodOf(resolved, name);
51
- const argValues = decodeArguments(method, arg, limits);
52
+ return modelOf(resolved, name, method, decodeArguments(method, arg, limits));
53
+ }
54
+
55
+ /** The model of a call whose arguments are decoded already (evm.mjs decodes calldata, then comes here). */
56
+ export function modelOf(resolved, name, method, argValues) {
52
57
  const diagnostics = [];
53
- const intent = method.tags?.intent?.args;
58
+ const intentTag = callTag(method, argValues, "intent"); // the call's intent: its case's, else its method's (D-053)
59
+ const intent = intentTag?.args;
54
60
  const title = typeof intent?.title === "string" && intent.title.trim() !== "" ? intent.title : (labelOf(method) ?? `Call ${name}`);
55
- const tagOrigin = intent ? { origin: method.tags.intent.origin, ...(method.tags.intent.profile === undefined ? {} : { profile: method.tags.intent.profile }) } : null;
61
+ const tagOrigin = intent ? { origin: intentTag.origin, ...(intentTag.profile === undefined ? {} : { profile: intentTag.profile }) } : null;
56
62
  const provenance = { intent: tagOrigin, profiles: Array.isArray(resolved.profiles) ? resolved.profiles : [] };
57
- const base = { method: name, effect: effectKind(method), title, argNodes: method.args, argValues, diagnostics, provenance };
63
+ const base = { method: name, effect: effectKind(method, callTag(method, argValues, "effect")), title, argNodes: method.args, argValues, diagnostics, provenance };
58
64
 
59
65
  if (intent && typeof intent.template === "string") {
60
66
  try {
package/src/render.mjs CHANGED
@@ -50,6 +50,24 @@ export function renderFields(model, renderer, { rawArguments = false } = {}) {
50
50
  return { intent, fields };
51
51
  }
52
52
 
53
+ /**
54
+ * The fields form as plain text, for a message that is not ICRC-21's (an EVM call, spec section 15): the same intent
55
+ * line as renderFields, and each row's values joined by ", ".
56
+ *
57
+ * @returns {{ intent: string, fields: Array<[string, string]> }}
58
+ */
59
+ export function renderPlain(model, renderer, { rawArguments = false } = {}) {
60
+ const title = plainLabel(model.title, model.method);
61
+ let intent = title;
62
+ if (model.sentence !== null) {
63
+ const sentence = model.sentence.map((p) => ("lit" in p ? oneLine(p.lit) : fieldPlain(p.field, renderer))).join("");
64
+ if (sentence.trim() !== "") intent = `${title}: ${sentence.trim()}`;
65
+ }
66
+ const fields = model.fields.map((f) => [plainLabel(f.label, f.path), fieldPlain(f, renderer)]);
67
+ if (rawArguments) fields.push(["Raw arguments", visible(renderer.tupleText(model.argNodes, model.argValues))]);
68
+ return { intent, fields };
69
+ }
70
+
53
71
  /** One row's ICRC-21 Value: typed when the field is a single value the typed form carries exactly, else Text. */
54
72
  function fieldValue(field, renderer) {
55
73
  if (field.values.length === 1) {
package/src/resolved.mjs CHANGED
@@ -6,11 +6,17 @@ import { CODES, fail } from "./errors.mjs";
6
6
 
7
7
  const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
8
8
 
9
- /** The method entry of a resolved form, or a ConsentError. */
10
- export function methodOf(resolved, name) {
9
+ /**
10
+ * The method entry of a resolved form, or a ConsentError. `chain` is "evm" for an EVM contract's form (spec section 15);
11
+ * a Candid entry point never reads one, whose positions are ABI types, not Candid.
12
+ */
13
+ export function methodOf(resolved, name, chain = undefined) {
11
14
  if (!isObject(resolved) || resolved.format !== "candid-profile-resolved" || !isObject(resolved.methods)) {
12
15
  fail(CODES.RESOLVED_FORM, "not a candid-profile-resolved document (format, methods)");
13
16
  }
17
+ if (resolved.chain !== chain) {
18
+ fail(CODES.RESOLVED_FORM, resolved.chain === "evm" ? "this resolved form is an EVM contract's (chain evm): use evmConsent, not a Candid entry point" : `expected the resolved form of an EVM contract (chain evm), found ${resolved.chain === undefined ? "a Candid service's" : `chain ${JSON.stringify(resolved.chain)}`}`);
19
+ }
14
20
  if (typeof name !== "string" || !Object.hasOwn(resolved.methods, name)) {
15
21
  fail(CODES.METHOD_UNKNOWN, `the resolved form has no method ${JSON.stringify(String(name))}`);
16
22
  }
@@ -24,6 +30,20 @@ export function keyId(key) {
24
30
  return /^#(0|[1-9][0-9]{0,9})$/.test(key) ? Number(key.slice(1)) : idlHash(key);
25
31
  }
26
32
 
33
+ /**
34
+ * A position of an EVM contract's resolved form (spec section 15) read as the Candid shapes the model walks: a tuple is
35
+ * a record, an array a vec, any other ABI type a primitive of that name. `abi` is kept, so display writes ABI text.
36
+ */
37
+ export function abiView(node, where, depth = 0) {
38
+ if (!isObject(node) || typeof node.abi !== "string") fail(CODES.RESOLVED_FORM, `${where}: not an EVM resolved-form position`);
39
+ if (depth > 64) fail(CODES.UNSUPPORTED_TYPE, `${where}: nested deeper than 64`);
40
+ if (node.abi === "tuple") {
41
+ return { ...node, candid: "record", fields: Object.fromEntries(entriesOf(node.fields).map(([k, c]) => [k, abiView(c, `${where}.${k}`, depth + 1)])) };
42
+ }
43
+ if (node.abi === "array") return { ...node, candid: "vec", element: abiView(node.element, `${where}[]`, depth + 1) };
44
+ return { ...node, candid: node.abi };
45
+ }
46
+
27
47
  /** Own entries of a `fields` or `cases` object (never walks the prototype chain). */
28
48
  export function entriesOf(bucket) {
29
49
  return isObject(bucket) ? Object.entries(bucket) : [];
@@ -39,7 +59,44 @@ export function tagArgs(node, tag) {
39
59
  return node?.tags && Object.hasOwn(node.tags, tag) ? (node.tags[tag]?.args ?? {}) : undefined;
40
60
  }
41
61
 
42
- /** The effect kind of a method: its tag, or the spec default (section 5.3: queries read, updates write). */
43
- export function effectKind(method) {
44
- return method.tags?.effect?.args?.kind ?? (method.mode === "query" || method.mode === "composite_query" ? "read" : "write");
62
+ /**
63
+ * The effect kind of a call: its effect tag (the call's, `callTag`; the method's by default), or the spec default
64
+ * (section 5.3: queries read, updates write).
65
+ */
66
+ export function effectKind(method, tag = method.tags?.effect) {
67
+ return tag?.args?.kind ?? (method.mode === "query" || method.mode === "composite_query" ? "read" : "write");
68
+ }
69
+
70
+ /**
71
+ * The `intent` or `effect` entry (`{ args, origin, profile }`) a call takes: that of the deepest tagged variant case
72
+ * its decoded arguments hold (spec section 5.2, D-053), else the method's. The resolver lets the tagged cases of one
73
+ * method only exclude each other or nest, so the cases one call holds are a chain; a resolved form with tagged cases
74
+ * that one call holds side by side is not one a resolver writes, and the method's entry is used for it.
75
+ *
76
+ * @param {object} method a method of the resolved form
77
+ * @param {unknown[]} argValues its decoded arguments (decode.mjs: records by key, variants as { tag, value })
78
+ * @param {"intent" | "effect"} tag
79
+ */
80
+ export function callTag(method, argValues, tag) {
81
+ let best = null; // { entry, chain }: the hit with the most hits above and at it
82
+ let hits = 0;
83
+ const visit = (node, value, chain) => {
84
+ if (!isObject(node) || node.recursive === true || !isObject(value)) return; // null: an absent optional, a bare case
85
+ if (node.candid === "record") {
86
+ for (const [key, child] of entriesOf(node.fields)) if (Object.hasOwn(value, key)) visit(child, value[key], chain);
87
+ } else if (node.candid === "variant") {
88
+ const hit = entriesOf(node.cases).find(([key]) => key === value.tag);
89
+ if (hit === undefined) return;
90
+ const child = hit[1];
91
+ const entry = isObject(child) && isObject(child.tags) && Object.hasOwn(child.tags, tag) ? child.tags[tag] : undefined;
92
+ if (isObject(entry)) {
93
+ chain += 1;
94
+ hits += 1;
95
+ if (best === null || chain > best.chain) best = { entry, chain };
96
+ }
97
+ visit(child, value.value, chain);
98
+ }
99
+ };
100
+ method.args.forEach((node, i) => visit(node, argValues[i], 0));
101
+ return best !== null && best.chain === hits ? best.entry : method.tags?.[tag];
45
102
  }
package/src/sns.mjs CHANGED
@@ -26,8 +26,8 @@
26
26
  // 1. The body is the generic consent message of spec section 12 with the title at heading level 3, so it nests
27
27
  // under governance's `## Payload:`. Times are shown in UTC (`2026-10-09 12:00:00 UTC`, rule 6 of section 12):
28
28
  // a proposal has many readers and no user preferences.
29
- // 2. Then the method the rendering describes and its effect (spec section 5.3; the default for an update without
30
- // an `effect` tag is said to be a default).
29
+ // 2. Then the method the rendering describes and the call's effect (spec section 5.3: its case's, else its
30
+ // method's, D-053; the default for an update without an `effect` tag is said to be a default).
31
31
  // 3. Then the arguments as Candid text with no formatter applied (base units, nanoseconds, bytes), always: there is
32
32
  // no signer between the text and the voter to show them (spec section 11 item 1).
33
33
  // 4. Then where the words come from: every profile with its profile_id, the interface_id, and these rules.
@@ -43,7 +43,7 @@ import { bytesOf } from "./bytes.mjs";
43
43
  import { ConsentError, CODES, fail } from "./errors.mjs";
44
44
  import { buildModel } from "./model.mjs";
45
45
  import { renderGeneric } from "./render.mjs";
46
- import { methodOf } from "./resolved.mjs";
46
+ import { callTag, methodOf } from "./resolved.mjs";
47
47
  import { codeBlock, codeSpan, escapeMarkdown, oneLine } from "./text.mjs";
48
48
 
49
49
  /** The name and version of the rules this module renders by. Part of every rendering, so a re-render knows what to compare. */
@@ -114,7 +114,7 @@ function renderSnsPayload({ payload, resolved, method: name, refs, limits }) {
114
114
  const model = buildModel({ resolved, method: name, arg: payload, limits });
115
115
  const renderer = new Renderer({ refs, utcOffsetMinutes: 0, diagnostics: model.diagnostics });
116
116
  const blocks = [renderGeneric(model, renderer, { headingLevel: 3 })];
117
- blocks.push(`**Method:** ${codeSpan(name)}. **Effect:** ${effectText(method)}.`);
117
+ blocks.push(`**Method:** ${codeSpan(name)}. **Effect:** ${effectText(method, callTag(method, model.argValues, "effect"))}.`);
118
118
  blocks.push(`**Arguments (Candid, as sent, no units applied):**\n\n${codeBlock(renderer.plainTupleText(model.argNodes, model.argValues))}`);
119
119
  blocks.push(sourceText(resolved));
120
120
  const text = blocks.join("\n\n");
@@ -125,12 +125,12 @@ function renderSnsPayload({ payload, resolved, method: name, refs, limits }) {
125
125
  const isQuery = (method) => method.mode === "query" || method.mode === "composite_query";
126
126
 
127
127
  /**
128
- * The effect kind and reversibility (spec section 5.3), and whether a profile stated them. The resolver writes the
128
+ * The effect kind and reversibility of the call (spec section 5.3; `tag` is its effect entry, `callTag`), and whether
129
+ * a profile stated them. The resolver writes the
129
130
  * section 5.3 default with origin "default" when no profile has an `effect` tag; this says so instead of passing a
130
131
  * default off as the author's word.
131
132
  */
132
- function effectText(method) {
133
- const tag = method.tags?.effect;
133
+ function effectText(method, tag) {
134
134
  const query = isQuery(method);
135
135
  const kind = typeof tag?.args?.kind === "string" ? tag.args.kind : query ? "read" : "write";
136
136
  const irreversible = typeof tag?.args?.irreversible === "boolean" ? tag.args.irreversible : tag ? null : !query;