@meantjs/consent 0.0.0-stage → 0.2.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.
@@ -0,0 +1,304 @@
1
+ // ERC-7730 clear-signing files from an EVM contract's resolved form, and the ERC-8176 attestation a reviewer signs for
2
+ // one (spec section 15, D-054). `@meantjs/consent/erc7730`.
3
+ //
4
+ // The file is the display half of a profile: per function a transaction calls, its `intent` (the intent title), its
5
+ // `interpolatedIntent` (the template) and one field per leaf of its parameters. The same resolved form always gives
6
+ // the same file, so the same `descriptorHash`. What a profile says that an ERC-7730 file cannot say is refused
7
+ // (E_EXPORT), never written as something near it:
8
+ //
9
+ // amount tokenAmount of the contract's own token (`tokenPath: "@.to"`): decimals and symbol read from the
10
+ // ERC-20 `decimals()` and `symbol()`, or given literally and carried as `metadata.token` when the
11
+ // caller says the contract is that token (`options.token`); `threshold` and `message` one to one.
12
+ // Refused otherwise (another method, a symbol missing, a token named by an argument).
13
+ // timestamp `date` with `encoding: "timestamp"`, seconds only duration `duration`, seconds only
14
+ // bytes `raw` (hex), hex only address `addressName`
15
+ // the rest `raw`; `describe.label` is the label, else the parameter's name
16
+ // payable one more field, `@.value` as `amount` (the native value, which ERC-7730 wallets know and a profile
17
+ // has no position for yet, O2)
18
+ //
19
+ // The descriptor binds to deployments (ERC-7730 `context.contract.deployments`): the profile's, or the caller's when
20
+ // every profile that lists deployments lists them too. `effect`, `idempotency`, `outcome` and the other agent tags
21
+ // have no ERC-7730 form and are left out: the file carries what a call says, not what it does.
22
+
23
+ import { AbiError, jcs, keccak256Hex, parseSignature, readAddress, typedDataDigest } from "@meantjs/profile/evm";
24
+ import { CODES, fail } from "./errors.mjs";
25
+ import { resolvePath } from "./paths.mjs";
26
+ import { abiView, entriesOf, labelOf, methodOf, tagArgs } from "./resolved.mjs";
27
+ import { parseTemplate } from "./template.mjs";
28
+
29
+ /** The exact-version schema every exported file names: an alias would move the hash when the alias moves. */
30
+ export const ERC7730_SCHEMA_URL = "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.1.0.schema.json";
31
+ /** ERC-8176's canonical EAS schema, `bytes32 descriptorHash` (no resolver, revocable). */
32
+ export const ERC8176_SCHEMA_UID = "0xe023eef113c1670774801c34b377fdf612dd8a4d2fa92fe382e15bd91fafb5c2";
33
+ /** The canonical EAS contract on Ethereum mainnet: the verifying contract of an offchain attestation. */
34
+ export const EAS_ADDRESS = "0xA1207F3BBa224E2c9c3c6D5aF63D0eb1582Ce587";
35
+
36
+ const ZERO32 = `0x${"0".repeat(64)}`;
37
+ const ZERO_ADDRESS = `0x${"0".repeat(40)}`;
38
+ const OPTIONS = new Set(["deployments", "owner", "contractName", "info", "token"]);
39
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
40
+ const nonEmpty = (v) => typeof v === "string" && v.trim() !== "";
41
+
42
+ /** `{ chainId, address }` of the caller or `{ chain_id, address }` of a profile, checked; null when not one. */
43
+ function deploymentOf(d) {
44
+ const chainId = d?.chainId ?? d?.chain_id;
45
+ if (!Number.isSafeInteger(chainId) || chainId < 1) return null;
46
+ try {
47
+ return { chainId, address: readAddress(d.address) };
48
+ } catch (e) {
49
+ if (e instanceof AbiError) return null;
50
+ throw e;
51
+ }
52
+ }
53
+
54
+ function deploymentsOf(resolved, given) {
55
+ const key = (d) => `${d.chainId}:${d.address}`;
56
+ const listing = (Array.isArray(resolved.profiles) ? resolved.profiles : []).filter((p) => Array.isArray(p?.deployments));
57
+ const sets = listing.map((p) => ({ name: p.name, keys: new Set(p.deployments.map(deploymentOf).filter(Boolean).map(key)) }));
58
+ let out;
59
+ if (given !== undefined) {
60
+ if (!Array.isArray(given) || given.length === 0) fail(CODES.REQUEST, "options.deployments must be a non-empty array of { chainId, address }");
61
+ out = given.map((d, i) => deploymentOf(d) ?? fail(CODES.REQUEST, `options.deployments[${i}] needs a positive integer chainId and an address (0x and 40 hex digits; mixed case must match EIP-55)`));
62
+ for (const d of out) for (const s of sets) if (!s.keys.has(key(d))) fail(CODES.DEPLOYMENT, `profile ${s.name} does not list ${d.address} on chain ${d.chainId}`);
63
+ } else {
64
+ if (listing.length === 0) fail(CODES.DEPLOYMENT, "an ERC-7730 file binds to deployments: give options.deployments, or bind them in a contract profile (binds.deployments)");
65
+ out = listing[0].deployments.map(deploymentOf).filter((d) => d && sets.every((s) => s.keys.has(key(d))));
66
+ if (out.length === 0) fail(CODES.DEPLOYMENT, `no deployment is listed by every profile that lists deployments (${listing.map((p) => p.name).join(", ")})`);
67
+ }
68
+ const seen = new Set();
69
+ return out.filter((d) => !seen.has(key(d)) && seen.add(key(d)));
70
+ }
71
+
72
+ function metadataOf(options) {
73
+ const metadata = {};
74
+ for (const k of ["owner", "contractName"]) {
75
+ if (options[k] === undefined) continue;
76
+ if (!nonEmpty(options[k])) fail(CODES.REQUEST, `options.${k} must be a non-empty string`);
77
+ metadata[k] = options[k];
78
+ }
79
+ if (options.info !== undefined) {
80
+ const { url, deploymentDate, ...rest } = isObject(options.info) ? options.info : {};
81
+ if (!nonEmpty(url) || (deploymentDate !== undefined && !nonEmpty(deploymentDate)) || Object.keys(rest).length) fail(CODES.REQUEST, "options.info must be { url, deploymentDate? } with strings");
82
+ metadata.info = deploymentDate === undefined ? { url } : { url, deploymentDate };
83
+ }
84
+ return metadata;
85
+ }
86
+
87
+ /** Whether the contract has the ERC-20 `decimals()` and `symbol()` an ERC-7730 wallet reads for `tokenPath: "@.to"`. */
88
+ function erc20Metadata(resolved) {
89
+ const d = resolved.methods.decimals;
90
+ const s = resolved.methods.symbol;
91
+ 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
+ }
93
+
94
+ /** One ERC-7730 field for a leaf of a function's parameters. */
95
+ function fieldOf(node, path, label, ctx, where) {
96
+ const field = { path, label };
97
+ const amount = tagArgs(node, "amount");
98
+ if (amount !== undefined) {
99
+ const dynamic = amount.decimals?.from === "query:decimals" && amount.symbol?.from === "query:symbol";
100
+ const literal = Number.isInteger(amount.decimals) && typeof amount.symbol === "string";
101
+ if (dynamic) {
102
+ if (!ctx.erc20) return ctx.problem(where, "amount reads query:decimals and query:symbol, but the contract has no ERC-20 decimals() and symbol() an ERC-7730 wallet reads");
103
+ ctx.dynamic = true;
104
+ } else if (literal) {
105
+ if (!ctx.tokenName) return ctx.problem(where, "amount with literal decimals and symbol: an ERC-7730 file carries them only as the contract's own token (metadata.token); pass options.token = { name } if this contract is that token");
106
+ const token = { decimals: amount.decimals, ticker: amount.symbol };
107
+ if (ctx.token && (ctx.token.decimals !== token.decimals || ctx.token.ticker !== token.ticker)) return ctx.problem(where, `amount in ${token.ticker} with ${token.decimals} decimals, where another is in ${ctx.token.ticker} with ${ctx.token.decimals}: an ERC-7730 file has one metadata.token`);
108
+ ctx.token = token;
109
+ } else {
110
+ return ctx.problem(where, "amount without the contract's own decimals and symbol (both read from decimals() and symbol(), or both literal) has no ERC-7730 form");
111
+ }
112
+ const params = { tokenPath: "@.to" };
113
+ if (amount.threshold !== undefined) {
114
+ params.threshold = `0x${BigInt(amount.threshold).toString(16)}`;
115
+ params.message = amount.message ?? "Unlimited";
116
+ }
117
+ return { ...field, format: "tokenAmount", params };
118
+ }
119
+ const time = tagArgs(node, "timestamp") ?? tagArgs(node, "duration");
120
+ if (time !== undefined) {
121
+ const kind = tagArgs(node, "timestamp") !== undefined ? "timestamp" : "duration";
122
+ if (time.unit !== "s") return ctx.problem(where, `${kind} in ${time.unit}: ERC-7730 ${kind === "timestamp" ? "dates" : "durations"} are in seconds`);
123
+ return kind === "timestamp" ? { ...field, format: "date", params: { encoding: "timestamp" } } : { ...field, format: "duration" };
124
+ }
125
+ const bytes = tagArgs(node, "bytes");
126
+ if (bytes !== undefined && bytes.encoding !== "hex") return ctx.problem(where, `bytes as ${bytes.encoding}: ERC-7730 shows bytes as hex only`);
127
+ return { ...field, format: node.abi === "address" ? "addressName" : "raw" };
128
+ }
129
+
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;
136
+ }
137
+
138
+ /** A parameter or tuple component without a name: an ERC-7730 key names every one, and paths need the names. */
139
+ function unnamed(params, at = "") {
140
+ for (const [i, p] of params.entries()) {
141
+ const here = `${at}${p.name ?? `#${i}`}`;
142
+ if (p.name === null) return here;
143
+ let t = p.type;
144
+ while (t.kind === "array") t = t.element;
145
+ if (t.kind === "tuple") {
146
+ const hit = unnamed(t.components, `${here}.`);
147
+ if (hit) return hit;
148
+ }
149
+ }
150
+ return null;
151
+ }
152
+
153
+ /** The template as an ERC-7730 interpolatedIntent: `{(0).to}` reads `{to}`, `[[` and `]]` are plain brackets. */
154
+ function interpolated(template, method, ctx, where) {
155
+ let parts;
156
+ try {
157
+ parts = parseTemplate(template);
158
+ } catch (e) {
159
+ return ctx.problem(where, e.message);
160
+ }
161
+ let out = "";
162
+ for (const p of parts) {
163
+ 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");
165
+ else {
166
+ const rp = resolvePath(method, p.path);
167
+ 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`);
170
+ out += `{${rp.steps.map((s) => s.key).join(".")}}`;
171
+ }
172
+ }
173
+ return out;
174
+ }
175
+
176
+ /** Every string of the file must be well-formed Unicode: RFC 8785 (JCS) serializes no lone surrogate. */
177
+ function checkText(v, where) {
178
+ if (typeof v === "string" && !v.isWellFormed()) fail(CODES.EXPORT, `${where}: the text has a lone surrogate, which RFC 8785 cannot serialize`);
179
+ if (Array.isArray(v)) v.forEach((x, i) => checkText(x, `${where}[${i}]`));
180
+ else if (isObject(v)) for (const [k, x] of Object.entries(v)) checkText(k, where), checkText(x, `${where}.${k}`);
181
+ }
182
+
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);
205
+
206
+ const formats = {};
207
+ for (const [name, m] of entriesOf(resolved.methods)) {
208
+ if (!isObject(m) || m.mode !== "update") continue;
209
+ const method = methodOf(resolved, name, "evm");
210
+ let f;
211
+ try {
212
+ f = parseSignature(String(method.signature), `methods.${name}.signature`);
213
+ } catch (e) {
214
+ if (e instanceof AbiError) fail(CODES.RESOLVED_FORM, e.message);
215
+ throw e;
216
+ }
217
+ if (f.name !== name || f.selector !== method.selector) fail(CODES.RESOLVED_FORM, `methods.${name}: the signature ${method.signature} does not hash to ${method.selector}`);
218
+ const missing = unnamed(f.inputs);
219
+ if (missing !== null) {
220
+ ctx.problem(name, `parameter ${missing} has no name; an ERC-7730 key names every parameter`);
221
+ continue;
222
+ }
223
+ 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(".[]", "[]")}`)) : [];
231
+ if (method.state_mutability === "payable") fields.push({ path: "@.value", label: "Value sent", format: "amount" });
232
+ if (fields.length) entry.fields = fields;
233
+ formats[method.signature] = entry;
234
+ }
235
+ 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
+ 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
+
239
+ 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 } } };
241
+ if (Object.keys(metadata).length) descriptor.metadata = metadata;
242
+ descriptor.display = { formats };
243
+ return { descriptor, descriptorHash: descriptorHash(descriptor) };
244
+ }
245
+
246
+ /** ERC-8176 `descriptorHash`: Keccak-256 of the RFC 8785 (JCS) form of a descriptor without `includes`. */
247
+ export function descriptorHash(descriptor) {
248
+ if (!isObject(descriptor)) fail(CODES.REQUEST, "a descriptor is a JSON object");
249
+ if (Object.hasOwn(descriptor, "includes")) fail(CODES.REQUEST, "resolve the descriptor's includes first (ERC-8176 hashes the merged file)");
250
+ checkText(descriptor, "descriptor");
251
+ return `0x${keccak256Hex(jcs(descriptor))}`;
252
+ }
253
+
254
+ const ATTEST = [
255
+ { name: "version", type: "uint16" },
256
+ { name: "schema", type: "bytes32" },
257
+ { name: "recipient", type: "address" },
258
+ { name: "time", type: "uint64" },
259
+ { name: "expirationTime", type: "uint64" },
260
+ { name: "revocable", type: "bool" },
261
+ { name: "refUID", type: "bytes32" },
262
+ { name: "data", type: "bytes" },
263
+ { name: "salt", type: "bytes32" },
264
+ ];
265
+
266
+ function uint64(v, name) {
267
+ const s = typeof v === "bigint" ? v.toString() : Number.isSafeInteger(v) ? String(v) : v;
268
+ if (typeof s !== "string" || !/^(0|[1-9][0-9]{0,19})$/.test(s) || BigInt(s) >= 2n ** 64n) fail(CODES.REQUEST, `${name} must be a uint64 (seconds since the Unix epoch), got ${String(v)}`);
269
+ return s;
270
+ }
271
+
272
+ function bytes32(v, name) {
273
+ if (typeof v !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(v)) fail(CODES.REQUEST, `${name} must be 0x and 64 hex digits`);
274
+ return v.toLowerCase();
275
+ }
276
+
277
+ /**
278
+ * The unsigned ERC-8176 offchain attestation of a descriptor: EAS offchain attestation v2 typed data for a reviewer to
279
+ * sign with their own key (EIP-712), and its digest. Nothing is signed or sent here.
280
+ *
281
+ * @param {object} input
282
+ * @param {string} input.descriptorHash from erc7730Descriptor (or descriptorHash)
283
+ * @param {bigint | number | string} input.time when the attestation is made, in Unix seconds
284
+ * @param {bigint | number | string} input.expirationTime when it expires, in Unix seconds; 0 never (ERC-8176 advises a bound)
285
+ * @param {string} input.salt 32 random bytes, 0x hex: the caller's, so the same input gives the same output
286
+ * @param {string} [input.refUID] an attestation this one refers to; default none (32 zero bytes)
287
+ * @returns {{ typedData: { domain: object, primaryType: "Attest", types: { Attest: { name: string, type: string }[] }, message: object }, digest: string }}
288
+ */
289
+ export function erc8176Attestation({ descriptorHash: hash, time, expirationTime, salt, refUID = ZERO32 } = {}) {
290
+ const message = {
291
+ version: 2,
292
+ schema: ERC8176_SCHEMA_UID,
293
+ recipient: ZERO_ADDRESS,
294
+ time: uint64(time, "time"),
295
+ expirationTime: uint64(expirationTime, "expirationTime"),
296
+ revocable: true,
297
+ refUID: bytes32(refUID, "refUID"),
298
+ data: bytes32(hash, "descriptorHash"), // abi.encode(bytes32) is the 32 bytes themselves
299
+ salt: bytes32(salt, "salt"),
300
+ };
301
+ if (message.expirationTime !== "0" && BigInt(message.expirationTime) <= BigInt(message.time)) fail(CODES.REQUEST, "expirationTime must be 0 or after time");
302
+ const typedData = { domain: { name: "EAS Attestation", version: "0.26", chainId: 1, verifyingContract: EAS_ADDRESS }, primaryType: "Attest", types: { Attest: ATTEST.map((f) => ({ ...f })) }, message };
303
+ return { typedData, digest: typedDataDigest(typedData) };
304
+ }
package/src/errors.mjs ADDED
@@ -0,0 +1,54 @@
1
+ // Every failure of this package is a ConsentError with a stable `code`. The ICRC-21 entry point maps
2
+ // the codes to the ICRC-21 error variants (icrc21.mjs); the lower-level functions throw them.
3
+
4
+ export class ConsentError extends Error {
5
+ /**
6
+ * @param {string} code one of CODES
7
+ * @param {string} message written for a developer, not for the end user (ICRC-21: icrc21_error_info.description)
8
+ * @param {{ cause?: unknown }} [options]
9
+ */
10
+ constructor(code, message, options = {}) {
11
+ super(message, options.cause === undefined ? undefined : { cause: options.cause });
12
+ this.name = "ConsentError";
13
+ this.code = code;
14
+ }
15
+ }
16
+
17
+ export const CODES = Object.freeze({
18
+ // the call is malformed (ICRC-21 UnsupportedCanisterCall)
19
+ METHOD_UNKNOWN: "E_METHOD_UNKNOWN", // the resolved form has no such method
20
+ DECODE: "E_DECODE", // the argument bytes do not decode against the method's signature
21
+ TOO_LARGE: "E_TOO_LARGE", // the arguments exceed the bounds of a consent message (never truncated)
22
+ LANGUAGE: "E_LANGUAGE", // only with languagePolicy "error": the requested language is not supported
23
+ REQUEST: "E_REQUEST", // the ICRC-21 request itself is malformed
24
+ DEPLOYMENT: "E_DEPLOYMENT", // an EVM call's target is not a deployment its profile lists (spec section 15)
25
+ // this generator cannot describe the call (ICRC-21 ConsentMessageUnavailable)
26
+ UNSUPPORTED_TYPE: "E_UNSUPPORTED_TYPE", // a position the resolved form cannot type (recursive cut, func, service)
27
+ // the profile or the caller's context is wrong (ICRC-21 GenericError)
28
+ RESOLVED_FORM: "E_RESOLVED_FORM", // the resolved form is malformed
29
+ TEMPLATE: "E_TEMPLATE", // the intent template does not parse
30
+ TEMPLATE_PATH: "E_TEMPLATE_PATH", // a placeholder names a position the method does not have
31
+ // pass-through of the format library's configuration errors (a dynamic ref the caller did not supply, a bad tag argument)
32
+ REF_UNRESOLVED: "E_REF_UNRESOLVED",
33
+ ARG: "E_ARG",
34
+ TAG_TYPE: "E_TAG_TYPE",
35
+ TAG_CONFLICT: "E_TAG_CONFLICT",
36
+ // the ERC-7730 export (erc7730.mjs): the resolved form says something an ERC-7730 file cannot say (spec section 15)
37
+ EXPORT: "E_EXPORT",
38
+ });
39
+
40
+ /** ICRC-21 GenericError.error_code values this package uses (a nat chosen by the canister; see the README). */
41
+ export const GENERIC_ERROR_CODES = Object.freeze({
42
+ [CODES.RESOLVED_FORM]: 1000n,
43
+ [CODES.TEMPLATE]: 1001n,
44
+ [CODES.TEMPLATE_PATH]: 1002n,
45
+ [CODES.REF_UNRESOLVED]: 1100n,
46
+ [CODES.ARG]: 1101n,
47
+ [CODES.TAG_TYPE]: 1102n,
48
+ [CODES.TAG_CONFLICT]: 1103n,
49
+ INTERNAL: 1999n,
50
+ });
51
+
52
+ export function fail(code, message, options) {
53
+ throw new ConsentError(code, message, options);
54
+ }
package/src/evm.d.mts ADDED
@@ -0,0 +1,51 @@
1
+ // Types for @meantjs/consent/evm: the consent text of an EVM call (spec section 15, D-054). Hand-written; the source is
2
+ // src/evm.mjs. scripts/types.test.mjs keeps the declared names equal to the runtime exports.
3
+ import type { AbiLimits, AbiValue } from "@meantjs/profile/evm";
4
+ import type { ConsentDiagnostic, Model, Provenance, ResolvedForm } from "./index.mjs";
5
+
6
+ /** An EVM contract's resolved form: `chain: "evm"`, and each method with its selector and named signature. */
7
+ export type EvmResolvedForm = ResolvedForm & { chain: "evm" };
8
+
9
+ /**
10
+ * One call decoded against a resolved form: the function its selector names, and its arguments keyed by parameter
11
+ * name (`#i` for an unnamed one). Strict: anything but the canonical encoding of the values read is E_DECODE.
12
+ * Throws E_RESOLVED_FORM (not an EVM resolved form), E_METHOD_UNKNOWN (no such selector) or E_DECODE.
13
+ */
14
+ export function decodeCalldata(resolved: ResolvedForm, calldata: Uint8Array | string, limits?: AbiLimits): { method: string; selector: string; values: Record<string, AbiValue> };
15
+
16
+ export interface EvmConsentInput {
17
+ resolved: ResolvedForm;
18
+ /** The selector and the ABI-encoded arguments, as bytes or 0x hex. */
19
+ calldata: Uint8Array | string;
20
+ /** The contract called: shown in the message, and checked against the deployments its profiles list (E_DEPLOYMENT). */
21
+ to: string;
22
+ /** Needed when a profile lists deployments. */
23
+ chainId?: number | bigint | string;
24
+ /** Resolved dynamic refs, keyed by `from`: `{ "query:decimals": 6, "query:symbol": "USDC" }`. */
25
+ refs?: Record<string, unknown>;
26
+ /** Default "generic" (Markdown). */
27
+ form?: "generic" | "fields";
28
+ /** Generic form only: show timestamps in this zone (default UTC). */
29
+ utcOffsetMinutes?: number | null;
30
+ /** Append the decoded raw arguments (spec section 11 item 1). */
31
+ rawArguments?: boolean;
32
+ limits?: AbiLimits;
33
+ }
34
+ /**
35
+ * The consent text of an EVM call: the same model and the same two layouts as an ICRC-21 message, without its envelope,
36
+ * ending with the contract's address and chain. A payable function is refused (E_UNSUPPORTED_TYPE): its native value
37
+ * has no position in a profile yet.
38
+ */
39
+ export function evmConsent(input: EvmConsentInput): {
40
+ form: "generic" | "fields";
41
+ /** Markdown for "generic"; the intent line and `[label, text]` rows for "fields". */
42
+ message: string | { intent: string; fields: [string, string][] };
43
+ method: string;
44
+ selector: string;
45
+ /** The contract, in its EIP-55 form. */
46
+ to: string;
47
+ chainId: string | null;
48
+ diagnostics: ConsentDiagnostic[];
49
+ provenance: Provenance;
50
+ model: Model;
51
+ };
package/src/evm.mjs ADDED
@@ -0,0 +1,129 @@
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
+ import { AbiError, decodeParams, parseSignature, readAddress } from "@meantjs/profile/evm";
18
+ import { Renderer } from "./display.mjs";
19
+ import { CODES, ConsentError, fail } from "./errors.mjs";
20
+ import { modelOf } from "./model.mjs";
21
+ import { renderGeneric, renderPlain } from "./render.mjs";
22
+ import { abiView, methodOf } from "./resolved.mjs";
23
+ import { codeSpan } from "./text.mjs";
24
+
25
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
26
+
27
+ function bytesOf(calldata) {
28
+ if (calldata instanceof Uint8Array) return calldata;
29
+ if (typeof calldata === "string" && /^0x(?:[0-9a-fA-F]{2})*$/.test(calldata)) return Uint8Array.from(calldata.slice(2).match(/../g) ?? [], (h) => parseInt(h, 16));
30
+ fail(CODES.REQUEST, "calldata must be a Uint8Array or 0x hex text");
31
+ }
32
+
33
+ function chainOf(chainId) {
34
+ if (chainId === undefined || chainId === null) return null;
35
+ const s = typeof chainId === "number" && Number.isSafeInteger(chainId) ? String(chainId) : typeof chainId === "bigint" ? chainId.toString() : chainId;
36
+ if (typeof s !== "string" || !/^[1-9][0-9]{0,19}$/.test(s)) fail(CODES.REQUEST, `chainId must be a positive integer, got ${String(chainId)}`);
37
+ return s;
38
+ }
39
+
40
+ /** The function of a selector, from the resolved form; its signature is checked against the selector. */
41
+ function functionOf(resolved, selector) {
42
+ const hit = Object.entries(resolved.methods).find(([, m]) => isObject(m) && m.selector === selector);
43
+ if (!hit) fail(CODES.METHOD_UNKNOWN, `the resolved form has no function with selector ${selector}`);
44
+ const [name, method] = hit;
45
+ let f;
46
+ try {
47
+ f = parseSignature(String(method.signature), `methods.${name}.signature`);
48
+ } catch (e) {
49
+ if (e instanceof AbiError) fail(CODES.RESOLVED_FORM, e.message);
50
+ throw e;
51
+ }
52
+ if (f.name !== name || f.selector !== selector) fail(CODES.RESOLVED_FORM, `methods.${name}: the signature ${method.signature} does not hash to ${selector}`);
53
+ return { name, method: methodOf(resolved, name, "evm"), f };
54
+ }
55
+
56
+ /**
57
+ * Decode one EVM call against a resolved form: which function, and its arguments.
58
+ *
59
+ * @param {object} resolved an EVM contract's resolved form (`chain: "evm"`)
60
+ * @param {Uint8Array | string} calldata the selector and the ABI-encoded arguments
61
+ * @param {{ maxBytes?: number, maxDepth?: number, maxElements?: number }} [limits]
62
+ * @returns {{ method: string, selector: string, values: Record<string, unknown> }}
63
+ */
64
+ export function decodeCalldata(resolved, calldata, limits) {
65
+ if (!isObject(resolved) || resolved.format !== "candid-profile-resolved" || !isObject(resolved.methods) || resolved.chain !== "evm") methodOf(resolved, "", "evm"); // throws the right error
66
+ const data = bytesOf(calldata);
67
+ if (data.length < 4) fail(CODES.DECODE, `calldata of ${data.length} bytes has no selector`);
68
+ const selector = `0x${[...data.subarray(0, 4)].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
69
+ const { name, f } = functionOf(resolved, selector);
70
+ try {
71
+ return { method: name, selector, values: decodeParams(f.inputs, data.subarray(4), limits) };
72
+ } catch (e) {
73
+ if (e instanceof AbiError) throw new ConsentError(CODES.DECODE, `${name}: ${e.message}`, { cause: e });
74
+ throw e;
75
+ }
76
+ }
77
+
78
+ /** Every profile that lists deployments must list this one (chain id and address). */
79
+ function checkTarget(resolved, to, chain) {
80
+ for (const p of Array.isArray(resolved.profiles) ? resolved.profiles : []) {
81
+ if (!Array.isArray(p?.deployments)) continue;
82
+ if (chain === null) fail(CODES.REQUEST, `profile ${p.name} binds deployments: give the chainId of the call`);
83
+ const listed = p.deployments.some((d) => String(d.chain_id) === chain && typeof d.address === "string" && d.address.toLowerCase() === to.toLowerCase());
84
+ 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}`);
85
+ }
86
+ }
87
+
88
+ /**
89
+ * The consent text of an EVM call.
90
+ *
91
+ * @param {object} input
92
+ * @param {object} input.resolved an EVM contract's resolved form
93
+ * @param {Uint8Array | string} input.calldata
94
+ * @param {string} input.to the contract called (shown, and checked against the profiles' deployments)
95
+ * @param {number | bigint | string} [input.chainId] needed when a profile lists deployments
96
+ * @param {Record<string, unknown>} [input.refs] resolved dynamic refs, for example { "query:decimals": 6, "query:symbol": "USDC" }
97
+ * @param {"generic" | "fields"} [input.form] default "generic"
98
+ * @param {number | null} [input.utcOffsetMinutes] generic form only (default 0, UTC)
99
+ * @param {boolean} [input.rawArguments] append the decoded arguments (spec section 11 item 1)
100
+ * @param {object} [input.limits] decoder limits (maxBytes, maxDepth, maxElements)
101
+ * @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
+ */
103
+ export function evmConsent({ resolved, calldata, to, chainId, refs = {}, form = "generic", utcOffsetMinutes = null, rawArguments = false, limits }) {
104
+ if (form !== "generic" && form !== "fields") fail(CODES.REQUEST, `form must be "generic" or "fields", got ${JSON.stringify(form)}`);
105
+ let target;
106
+ try {
107
+ target = readAddress(to);
108
+ } catch (e) {
109
+ if (e instanceof AbiError) fail(CODES.REQUEST, `to: ${e.message}`);
110
+ throw e;
111
+ }
112
+ const chain = chainOf(chainId);
113
+ const { method: name, selector, values } = decodeCalldata(resolved, calldata, limits);
114
+ checkTarget(resolved, target, chain);
115
+ const method = methodOf(resolved, name, "evm");
116
+ 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
+ const viewed = { ...method, args: method.args.map((n, i) => abiView(n, `${name}(${i})`)) };
118
+ const model = modelOf(resolved, name, viewed, viewed.args.length ? [values] : []);
119
+ const where = `${target}${chain === null ? "" : ` on chain ${chain}`}`;
120
+ const done = (rest) => ({ ...rest, method: name, selector, to: target, chainId: chain, diagnostics: model.diagnostics, provenance: model.provenance, model });
121
+ if (form === "fields") {
122
+ const plain = renderPlain(model, new Renderer({ refs, diagnostics: model.diagnostics }), { rawArguments });
123
+ plain.fields.push(["Contract", where]);
124
+ return done({ form, message: plain });
125
+ }
126
+ const renderer = new Renderer({ refs, utcOffsetMinutes: utcOffsetMinutes ?? 0, diagnostics: model.diagnostics });
127
+ const text = `${renderGeneric(model, renderer, { rawArguments })}\n\n**Contract:** ${codeSpan(target)}${chain === null ? "" : ` on chain ${chain}`}`;
128
+ return done({ form, message: text });
129
+ }