@meantjs/profile 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meantjs/profile",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Candid Profile resolver: validates profiles, matches packs by Candid subtyping, merges layers (spec section 8) and emits the resolved form (spec section 10).",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
package/src/abi-iface.mjs CHANGED
@@ -9,17 +9,33 @@
9
9
  * an unnamed one's is its position (`#1`)
10
10
  * T[] and T[k] a vector (`[]`); T[k] keeps its length
11
11
  * anything else a primitive named by its canonical ABI type (`uint256`, `address`, `bytes32`, `string`)
12
+ * an EIP-712 a method of mode "message" (spec section 15.9) whose one argument is the struct, a record of its
13
+ * message members in order (`PermitSingle(0).details.amount`); it has no results
12
14
  */
13
15
  import { Iface } from "./iface.mjs";
14
16
  import { idlHash } from "./idl.mjs";
15
- import { AbiError, abiInterfaceId, componentKey, parseAbi, readAddress } from "./abi.mjs";
17
+ import { AbiError, abiInterfaceId, componentKey, eip712AbiType, eip712TypeHash, encodeType, parseAbi, parseEip712Types, readAddress } from "./abi.mjs";
16
18
 
17
19
  export class AbiIface extends Iface {
18
- /** @param functions from parseAbi */
19
- constructor(envelope, functions, label) {
20
+ /**
21
+ * @param functions from parseAbi
22
+ * @param eip712 { types, messages } (spec section 15.9): the struct types, and which of them are messages
23
+ */
24
+ constructor(envelope, functions, label, eip712 = { types: {}, messages: [] }) {
20
25
  super(envelope, label);
21
26
  this.chain = "evm";
22
27
  this.functions = new Map(functions.map((f) => [f.name, f]));
28
+ /** The identity of the functions alone: what a contract profile's `binds.abi` binds to (spec section 15.1). */
29
+ this.abiId = abiInterfaceId(functions);
30
+ this.structs = eip712.types;
31
+ this.messages = new Map(eip712.messages.map((name) => [name, { encodeType: encodeType(eip712.types, name), typeHash: eip712TypeHash(eip712.types, name) }]));
32
+ }
33
+ resolve(addr) {
34
+ const r = super.resolve(addr);
35
+ if (r.error && addr.kind !== "type" && !this.methods.has(addr.method) && Object.hasOwn(this.structs, addr.method)) {
36
+ return { error: `${addr.method} is an EIP-712 type but not a message: tag its name with intent to make it one, or address it through a message that contains it` };
37
+ }
38
+ return r;
23
39
  }
24
40
  isBlob(ref) {
25
41
  const p = this.prim(ref);
@@ -43,12 +59,17 @@ export class AbiIface extends Iface {
43
59
  /**
44
60
  * @param entries an ABI: human-readable fragments or JSON ABI entries (parseAbi)
45
61
  * @param label how diagnostics name it (`binds.abi`, `abi`)
62
+ * @param eip712 { types, messages, label } (spec section 15.9): struct types already read by parseEip712Types, the
63
+ * names of those that are messages, and how diagnostics name the types; null for none
64
+ * @param opts.allowEmpty an interface with no function and no message is not refused (resolveService adds the
65
+ * messages of its profiles later)
46
66
  * @returns {{ iface: AbiIface } | { failure: { at: string, message: string }[] }}
47
67
  */
48
- export function abiInterface(entries, label = "abi", ifaceLabel = "contract") {
68
+ export function abiInterface(entries, label = "abi", ifaceLabel = "contract", eip712 = null, { allowEmpty = false } = {}) {
49
69
  const { functions, errors } = parseAbi(entries, label);
50
70
  if (errors.length) return { failure: errors };
51
- if (functions.length === 0) return { failure: [{ at: label, message: "the ABI has no functions" }] };
71
+ const messages = eip712?.messages ?? [];
72
+ if (functions.length === 0 && messages.length === 0 && !allowEmpty) return { failure: [eip712 ? { at: eip712.label, message: "the ABI has no functions and no type is a message (tag a type's name with intent)" } : { at: label, message: "the ABI has no functions" }] };
52
73
  const types = [];
53
74
  const names = [];
54
75
  const failure = [];
@@ -81,13 +102,34 @@ export function abiInterface(entries, label = "abi", ifaceLabel = "contract") {
81
102
  const mode = f.stateMutability === "view" || f.stateMutability === "pure" ? "query" : "update";
82
103
  return { name: f.name, function: add({ kind: "func", mode, args, results }) };
83
104
  });
105
+ for (const name of messages) {
106
+ const at = `${eip712.label}.${name}`;
107
+ if (functions.some((f) => f.name === name)) {
108
+ failure.push({ at, message: `a function and a message are both named ${name}; an address names one of them` });
109
+ continue;
110
+ }
111
+ methods.push({ name, function: add({ kind: "func", mode: "message", args: [build(eip712AbiType(eip712.types, name), `${name}(0)`)], results: [] }) });
112
+ }
84
113
  if (failure.length) return { failure };
85
114
  const service = add({ kind: "service", methods });
115
+ const structs = eip712?.types ?? {};
86
116
  const envelope = {
87
- contract: { types, declarations: [], actor: { kind: "service", service }, identities: { interface: abiInterfaceId(functions) } },
117
+ contract: { types, declarations: [], actor: { kind: "service", service }, identities: { interface: abiInterfaceId(functions, structs) } },
88
118
  extensions: { "org.candid-core.field-names/v1": names },
89
119
  };
90
- return { iface: new AbiIface(envelope, functions, ifaceLabel) };
120
+ return { iface: new AbiIface(envelope, functions, ifaceLabel, { types: structs, messages }) };
121
+ }
122
+
123
+ /**
124
+ * The messages a profile or pack declares (spec section 15.9): the types of `binds.eip712` whose name the same
125
+ * document tags with `intent`. Returns { types, messages, errors }.
126
+ */
127
+ export function eip712Of(doc, label = "binds.eip712.types") {
128
+ const spec = doc.binds?.eip712;
129
+ if (spec === undefined) return { types: {}, messages: [], errors: [] };
130
+ const { types, errors } = parseEip712Types(spec.types, label);
131
+ const messages = [...new Set(doc.annotations.filter((a) => a.tag === "intent" && Object.hasOwn(types, a.at)).map((a) => a.at))].sort();
132
+ return { types, messages, errors };
91
133
  }
92
134
 
93
135
  /**
package/src/abi-pack.mjs CHANGED
@@ -5,7 +5,8 @@
5
5
  *
6
6
  * Parameter names are not part of a selector, and contracts name the parameters of one standard differently (EIP-20
7
7
  * says `_to`, OpenZeppelin `to`, WETH `dst`). A pack's addresses and template placeholders use the pack's own names;
8
- * when the pack applies, they are rewritten to the contract's names, component by component, by position.
8
+ * when the pack applies, they are rewritten to the contract's names, component by component, by position. So is an
9
+ * amount's `token` path (spec section 15.9).
9
10
  */
10
11
  import { parseAddress, parseRelPath } from "./address.mjs";
11
12
  import { componentKey, parseAbi, typeString } from "./abi.mjs";
@@ -107,7 +108,9 @@ export function translatePack(pack, functions, iface) {
107
108
  };
108
109
  const annotations = pack.annotations.map((a) => {
109
110
  const method = parseAddress(a.at)?.method;
110
- const args = a.tag === "intent" && typeof a.args?.template === "string" && method ? { ...a.args, template: mapTemplate(a.args.template, placeholder(method)) } : a.args;
111
+ let args = a.args;
112
+ if (a.tag === "intent" && typeof a.args?.template === "string" && method) args = { ...a.args, template: mapTemplate(a.args.template, placeholder(method)) };
113
+ if (a.tag === "amount" && typeof a.args?.token === "string" && method) args = { ...a.args, token: placeholder(method)(a.args.token) ?? a.args.token };
111
114
  return { ...a, at: at(a.at), args };
112
115
  });
113
116
  return { ...pack, annotations };
package/src/abi.mjs CHANGED
@@ -320,9 +320,13 @@ export function parseAbi(entries, label = "abi") {
320
320
  return { functions: kept.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)), errors };
321
321
  }
322
322
 
323
- /** The identity of an EVM interface: SHA-256 over the JCS array of its canonical fragments, sorted (spec section 15). */
324
- export function abiInterfaceId(functions) {
325
- return `candid-profile:evm-abi:v0:sha256:${sha256Hex(jcs(functions.map((f) => f.canonical).sort()))}`;
323
+ /**
324
+ * The identity of an EVM interface: SHA-256 over the JCS array of its canonical fragments and, when it has EIP-712
325
+ * types (spec section 15.9), one `struct Name(type member,…)` line per type, sorted (spec section 15).
326
+ */
327
+ export function abiInterfaceId(functions, types = {}) {
328
+ const structs = Object.keys(types).map((n) => `struct ${structText(types, n)}`);
329
+ return `candid-profile:evm-abi:v0:sha256:${sha256Hex(jcs([...functions.map((f) => f.canonical), ...structs].sort()))}`;
326
330
  }
327
331
 
328
332
  /** Read one function from its named signature (`transfer(address to,uint256 value)`), as a resolved form carries it. */
@@ -610,6 +614,8 @@ const EIP712_DOMAIN = [
610
614
  ["salt", "bytes32"],
611
615
  ];
612
616
 
617
+ const structText = (types, name) => `${name}(${types[name].map((f) => `${f.type} ${f.name}`).join(",")})`;
618
+
613
619
  function typeEncoding(types, primary) {
614
620
  const deps = new Set();
615
621
  const visit = (name) => {
@@ -619,7 +625,108 @@ function typeEncoding(types, primary) {
619
625
  };
620
626
  visit(primary);
621
627
  deps.delete(primary);
622
- return [primary, ...[...deps].sort()].map((n) => `${n}(${types[n].map((f) => `${f.type} ${f.name}`).join(",")})`).join("");
628
+ return [primary, ...[...deps].sort()].map((n) => structText(types, n)).join("");
629
+ }
630
+
631
+ /**
632
+ * EIP-712 `encodeType` of a struct type: the type with its members, then every struct type it refers to, sorted by
633
+ * name (`Mail(Person from,Person to,string contents)Person(string name,address wallet)`).
634
+ *
635
+ * @param {Record<string, { name: string, type: string }[]>} types
636
+ * @param {string} primary
637
+ */
638
+ export function encodeType(types, primary) {
639
+ if (!Object.hasOwn(types, primary)) fail("E_EIP712", `no type ${primary}`);
640
+ return typeEncoding(types, primary);
641
+ }
642
+
643
+ /** EIP-712 `typeHash`: the Keccak-256 of `encodeType`, as `0x` and 64 hex digits. */
644
+ export const eip712TypeHash = (types, primary) => `0x${keccak256Hex(encodeType(types, primary))}`;
645
+
646
+ const EIP712_MEMBER = /^([A-Za-z_][A-Za-z0-9_]*)((?:\[\d*\])*)$/;
647
+ /** Names a struct may not take: they read as ABI types, or are the domain's own type. */
648
+ const RESERVED_STRUCT = /^(?:u?int\d*|u?fixed.*|address|bool|string|bytes\d*|byte|function|tuple|EIP712Domain)$/;
649
+
650
+ /**
651
+ * Read the EIP-712 types of a profile (`binds.eip712.types`, spec section 15.9): struct names that are identifiers and
652
+ * not ABI type names, members with unique names, member types that are canonical ABI types (`uint256`, never `uint`:
653
+ * the type hash hashes the text as written) or struct types of the same map, with array suffixes, and no struct that
654
+ * contains itself. Every problem is reported, none thrown.
655
+ *
656
+ * @param {unknown} types
657
+ * @param {string} [label] how diagnostics name the map (`binds.eip712.types`)
658
+ * @returns {{ types: Record<string, { name: string, type: string }[]>, errors: { at: string, message: string }[] }}
659
+ */
660
+ export function parseEip712Types(types, label = "types") {
661
+ const errors = [];
662
+ const err = (at, message) => void errors.push({ at, message });
663
+ if (types === null || typeof types !== "object" || Array.isArray(types)) return { types: {}, errors: [{ at: label, message: "EIP-712 types are an object of struct name to members" }] };
664
+ const out = {};
665
+ for (const [name, members] of Object.entries(types)) {
666
+ const at = `${label}.${name}`;
667
+ if (!IDENT.test(name)) { err(at, `the struct name ${JSON.stringify(name)} is not an identifier (letters, digits and _)`); continue; }
668
+ if (RESERVED_STRUCT.test(name)) { err(at, `a struct cannot be named ${name}: the name reads as an ABI type or the domain's type`); continue; }
669
+ if (!Array.isArray(members) || members.length === 0) { err(at, "a struct has at least one member, each { name, type }"); continue; }
670
+ const seen = new Set();
671
+ let ok = true;
672
+ members.forEach((m, i) => {
673
+ const where = `${at}[${i}]`;
674
+ if (m === null || typeof m !== "object" || typeof m.name !== "string" || typeof m.type !== "string") { ok = false; return err(where, "a member is { name, type } with two strings"); }
675
+ if (!IDENT.test(m.name)) { ok = false; return err(where, `the member name ${JSON.stringify(m.name)} is not an identifier`); }
676
+ if (seen.has(m.name)) { ok = false; return err(where, `two members are named ${m.name}`); }
677
+ seen.add(m.name);
678
+ const t = EIP712_MEMBER.exec(m.type);
679
+ if (!t) { ok = false; return err(where, `${JSON.stringify(m.type)} is not a type`); }
680
+ for (const d of t[2].matchAll(/\[(\d*)\]/g)) if (d[1] !== "" && !/^[1-9]\d{0,5}$/.test(d[1])) { ok = false; return err(where, `array length [${d[1]}] must be a positive integer`); }
681
+ if (Object.hasOwn(types, t[1])) return;
682
+ try {
683
+ const e = elementary(t[1], where);
684
+ if (typeString(e) !== t[1]) { ok = false; err(where, `write ${typeString(e)}, not ${t[1]}: the type hash hashes the type as written`); }
685
+ } catch (e) {
686
+ if (!(e instanceof AbiError)) throw e;
687
+ ok = false;
688
+ err(where, `${t[1]} is neither an ABI type nor a struct of these types`);
689
+ }
690
+ });
691
+ if (ok) out[name] = members.map((m) => ({ name: m.name, type: m.type }));
692
+ }
693
+ // A struct that contains itself, directly or not, has no finite value to sign.
694
+ const state = new Map(); // name -> 1 on the walk, 2 done
695
+ const cyclic = new Set();
696
+ const visit = (name, trail) => {
697
+ if (state.get(name) === 2 || !Object.hasOwn(out, name)) return;
698
+ if (state.get(name) === 1) {
699
+ const loop = trail.slice(trail.indexOf(name));
700
+ for (const n of loop) cyclic.add(n);
701
+ err(`${label}.${name}`, `${[...loop, name].join(" -> ")}: a struct cannot contain itself`);
702
+ return;
703
+ }
704
+ state.set(name, 1);
705
+ for (const m of out[name]) visit(m.type.replace(/(\[\d*\])+$/, ""), [...trail, name]);
706
+ state.set(name, 2);
707
+ };
708
+ for (const name of Object.keys(out).sort()) visit(name, []);
709
+ for (const n of cyclic) delete out[n];
710
+ // A struct that refers to one refused above cannot be encoded either.
711
+ for (let dropped = true; dropped; ) {
712
+ dropped = false;
713
+ for (const name of Object.keys(out).sort()) {
714
+ const bad = out[name].map((m) => m.type.replace(/(\[\d*\])+$/, "")).find((t) => Object.hasOwn(types, t) && !Object.hasOwn(out, t));
715
+ if (bad === undefined) continue;
716
+ err(`${label}.${name}`, `refers to ${bad}, which is refused above`);
717
+ delete out[name];
718
+ dropped = true;
719
+ }
720
+ }
721
+ return { types: out, errors };
722
+ }
723
+
724
+ /** The ABI type tree of an EIP-712 member type: a struct is a tuple of its members, in order. */
725
+ export function eip712AbiType(types, text) {
726
+ const m = EIP712_MEMBER.exec(text);
727
+ if (!m) fail("E_EIP712", `${JSON.stringify(text)} is not a type`);
728
+ const base = Object.hasOwn(types, m[1]) ? { kind: "tuple", components: types[m[1]].map((f) => ({ name: f.name, type: eip712AbiType(types, f.type) })) } : elementary(m[1], text);
729
+ return withDims(base, [...m[2].matchAll(/\[(\d*)\]/g)].map((d) => d[1]), text);
623
730
  }
624
731
 
625
732
  function encodeField(types, type, value, where) {
package/src/analyze.mjs CHANGED
@@ -9,6 +9,7 @@ import { TagIndex, LayeredTags, positions } from "./tags.mjs";
9
9
  import {
10
10
  NUMERIC, METHOD_TAGS, CASE_TAGS, CONFIRM_KINDS, STALE_CODES, VALUE_CONVERSION_TAGS, EVM_LATER_TAGS,
11
11
  checkApplicability, checkDynamic, parseTemplate, outcomeCoverage, caseTargetError, otherCase, overlap, defaultEffect,
12
+ tokenPathError,
12
13
  } from "./checks.mjs";
13
14
 
14
15
  /** Spec section 8, highest precedence first. */
@@ -111,6 +112,11 @@ export function analyzeProfiles(iface, items, { attribute = true } = {}) {
111
112
 
112
113
  /** An effect against the method's mode (spec 5.3); for a case effect, the mode of the method it sits in. */
113
114
  const checkEffectMode = (m, { kind, irreversible, replicated }, diag, at) => {
115
+ // A signed EIP-712 message (spec section 15.9) runs nothing when it is signed: any kind describes what it allows.
116
+ if (m.mode === "message") {
117
+ if (replicated === true) diag("error", "E_EFFECT_MODE", at, "replicated: true applies to update methods, not to a message");
118
+ return;
119
+ }
114
120
  if (m.mode !== "update" && m.mode !== "oneway" && kind !== "read") diag("error", "E_EFFECT_MODE", at, `${m.mode} method cannot have effect kind "${kind}"`);
115
121
  // A read-only update (D-038): a read sent as an update so that its answer goes through consensus.
116
122
  if (replicated === true) {
@@ -234,7 +240,10 @@ export function analyzeProfiles(iface, items, { attribute = true } = {}) {
234
240
  const err = checkApplicability(iface, a.tag, res.ref);
235
241
  if (err) diag("error", "E_TAG_NOT_APPLICABLE", at, err);
236
242
  if (a.tag === "named") named.push({ a, res, diag, at });
237
- if (a.tag === "amount") {
243
+ if (a.tag === "amount" && a.args.token !== undefined) {
244
+ const why = tokenPathError(iface, addr, a.args.token);
245
+ if (why) diag("error", "E_TOKEN_PATH", at, why);
246
+ } else if (a.tag === "amount") {
238
247
  checkDynamic(iface, a.args.decimals, "numeric", diag, at);
239
248
  checkDynamic(iface, a.args.symbol, "text", diag, at);
240
249
  }
@@ -326,10 +335,11 @@ export function analyzeProfiles(iface, items, { attribute = true } = {}) {
326
335
  }
327
336
  }
328
337
 
329
- // Coverage hint: numeric positions in update-method arguments with no meaning attached (merged view).
338
+ // Coverage hint: numeric positions in the arguments of update methods and EIP-712 messages with no meaning attached
339
+ // (merged view).
330
340
  const MEANING = ["amount", "timestamp", "duration", "describe", "range"];
331
341
  for (const [name, m] of iface.methods) {
332
- if (m.mode !== "update") continue;
342
+ if (m.mode !== "update" && m.mode !== "message") continue;
333
343
  for (const pos of positions(iface, name, "args")) {
334
344
  const p = iface.prim(iface.unwrapOpt(pos.ref).ref);
335
345
  const t = final.at(pos, iface);
package/src/browser.mjs CHANGED
@@ -11,6 +11,7 @@ import schema from "../data/profile-v0.schema.json" with { type: "json" };
11
11
  import icrc1 from "../data/packs/icrc1.profile.json" with { type: "json" };
12
12
  import icrc2 from "../data/packs/icrc2.profile.json" with { type: "json" };
13
13
  import erc20 from "../data/packs/erc20.profile.json" with { type: "json" };
14
+ import erc2612 from "../data/packs/erc2612.profile.json" with { type: "json" };
14
15
  import { compileSchema } from "./schema-core.mjs";
15
16
 
16
17
  export { init as initCompiler } from "@candid-core/cli";
@@ -32,9 +33,13 @@ export { readServedProfile, SERVED_SECTION, SERVED_METADATA, SERVED_MAX_BYTES }
32
33
  /** The profile schema (a copy of spec/profile-v0.schema.json). */
33
34
  export const PROFILE_SCHEMA = schema;
34
35
 
35
- /** The standard packs (copies of packs/*.profile.json), sorted by file name, in the shape `loadPacks` returns. */
36
+ /**
37
+ * The standard packs (copies of packs/*.profile.json whose kind is "pack"), sorted by file name, in the shape
38
+ * `loadPacks` returns. The standard contract profiles there (Permit2, DAI's permit) are under `./packs/*` too.
39
+ */
36
40
  export const STANDARD_PACKS = Object.freeze([
37
41
  { file: "erc20.profile.json", doc: erc20 },
42
+ { file: "erc2612.profile.json", doc: erc2612 },
38
43
  { file: "icrc1.profile.json", doc: icrc1 },
39
44
  { file: "icrc2.profile.json", doc: icrc2 },
40
45
  ]);
package/src/checks.mjs CHANGED
@@ -18,7 +18,7 @@ export const CASE_TAGS = new Set(["intent", "effect"]);
18
18
  export const CONFIRM_KINDS = new Set(["transfer", "approve", "admin", "destroy"]);
19
19
  /** Tags that convert a value between its wire form and a human form; a stale profile must not supply them (spec section 7). */
20
20
  export const VALUE_CONVERSION_TAGS = new Set(["amount", "timestamp", "duration", "account", "subaccount", "bytes", "named"]);
21
- export const STALE_CODES = new Set(["E_ADDR_UNRESOLVED", "E_TYPE_NODE", "E_TAG_NOT_APPLICABLE", "E_REF_UNRESOLVED", "E_REF_NOT_QUERY", "E_REF_SHAPE", "E_REF_TYPE", "E_STAMP_UNTAGGED", "E_OUTCOME_NOT_CASE", "E_TEMPLATE_PATH", "E_TAG_TARGET", "E_NAMES_MODE", "E_NAMES_SHAPE", "E_NAMES_FIELD", "E_NAMED_SET", "E_NAMED_TYPE", "E_CASE_OVERLAP", "E_CASE_EFFECT"]);
21
+ export const STALE_CODES = new Set(["E_ADDR_UNRESOLVED", "E_TYPE_NODE", "E_TAG_NOT_APPLICABLE", "E_REF_UNRESOLVED", "E_REF_NOT_QUERY", "E_REF_SHAPE", "E_REF_TYPE", "E_STAMP_UNTAGGED", "E_OUTCOME_NOT_CASE", "E_TEMPLATE_PATH", "E_TAG_TARGET", "E_NAMES_MODE", "E_NAMES_SHAPE", "E_NAMES_FIELD", "E_NAMED_SET", "E_NAMED_TYPE", "E_CASE_OVERLAP", "E_CASE_EFFECT", "E_TOKEN_PATH"]);
22
22
 
23
23
  export function checkApplicability(iface, tag, ref) {
24
24
  const { ref: r } = iface.unwrapOpt(ref);
@@ -37,6 +37,26 @@ export function checkApplicability(iface, tag, ref) {
37
37
  }
38
38
  }
39
39
 
40
+ /**
41
+ * An amount's `token` (spec section 15.9): a path from the arguments of the same function or message to the `address`
42
+ * of the token the amount is in, through the same array elements as the amount. The reason it is wrong, or null.
43
+ */
44
+ export function tokenPathError(iface, addr, token) {
45
+ if (iface.chain !== "evm") return "token names the address of an EVM token; a Candid amount's ledger cannot be named by a field yet (OQ-6)";
46
+ if (addr.kind !== "arg") return "an amount with a token sits in the arguments of a function or message, where the token's address is";
47
+ const p = parseRelPath(token);
48
+ const r = iface.resolveRel(addr.method, "args", token);
49
+ if (!p || r.error) return `token ${JSON.stringify(token)}: ${r.error ?? "not a relative path"}`;
50
+ if ((p.index ?? 0) !== addr.index) return `token ${JSON.stringify(token)} is in another argument than the amount`;
51
+ if (iface.prim(r.ref) !== "address") return `token ${JSON.stringify(token)} is ${iface.describeType(r.ref)}, not an address`;
52
+ const last = p.segs.findLastIndex((s) => s.kind === "elem");
53
+ const same = (a, b) => a.kind === b.kind && (a.kind === "elem" || a.id === b.id);
54
+ if (last >= 0 && (addr.segs.length <= last || !p.segs.slice(0, last + 1).every((s, i) => same(s, addr.segs[i])))) {
55
+ return `token ${JSON.stringify(token)} must be in the same array element as the amount: its path up to its last [] must be the amount's`;
56
+ }
57
+ return null;
58
+ }
59
+
40
60
  export function checkDynamic(iface, value, want, diag, at) {
41
61
  if (!value || typeof value !== "object") return;
42
62
  const from = value.from;
package/src/evm.d.mts CHANGED
@@ -38,7 +38,7 @@ export interface AbiLimits {
38
38
  maxDepth?: number;
39
39
  maxElements?: number;
40
40
  }
41
- /** Every failure of the ABI helpers: E_ABI (a fragment), E_ABI_ENCODE, E_ABI_DECODE, E_ADDRESS or E_ADDRESS_CHECKSUM. */
41
+ /** Every failure of the ABI helpers: E_ABI (a fragment), E_ABI_ENCODE, E_ABI_DECODE, E_EIP712, E_ADDRESS or E_ADDRESS_CHECKSUM. */
42
42
  export declare class AbiError extends Error {
43
43
  constructor(code: string, message: string);
44
44
  name: "AbiError";
@@ -62,8 +62,11 @@ export const DEFAULT_ABI_LIMITS: Readonly<Required<AbiLimits>>;
62
62
  export function parseAbi(entries: unknown, label?: string): { functions: AbiFunction[]; errors: { at: string; message: string }[] };
63
63
  /** One function from its named signature, as a resolved form carries it. Throws AbiError. */
64
64
  export function parseSignature(signature: string, where?: string): AbiFunction;
65
- /** `candid-profile:evm-abi:v0:sha256:…`: SHA-256 over the JCS array of the canonical fragments, sorted. */
66
- export function abiInterfaceId(functions: readonly AbiFunction[]): string;
65
+ /**
66
+ * `candid-profile:evm-abi:v0:sha256:…`: SHA-256 over the JCS array of the canonical fragments and, when given EIP-712
67
+ * types, one `struct Name(type member,…)` line per type, sorted.
68
+ */
69
+ export function abiInterfaceId(functions: readonly AbiFunction[], types?: Eip712Types): string;
67
70
  export function canonicalFragment(f: Pick<AbiFunction, "name" | "inputs" | "outputs" | "stateMutability">): string;
68
71
  export function namedSignature(f: Pick<AbiFunction, "name" | "inputs">): string;
69
72
  export function typeSignature(f: Pick<AbiFunction, "name" | "inputs">): string;
@@ -84,3 +87,15 @@ export function toChecksumAddress(address: string): string;
84
87
  export function readAddress(text: string): string;
85
88
  /** The EIP-712 digest a signer signs, as `0x` and 64 hex digits. */
86
89
  export function typedDataDigest(typed: TypedData): string;
90
+ /** EIP-712 struct types: struct name to its members, in order. */
91
+ export type Eip712Types = Record<string, { name: string; type: string }[]>;
92
+ /**
93
+ * Read EIP-712 struct types (spec section 15.9): identifier names that are not ABI types, unique member names,
94
+ * canonical member types (`uint256`, never `uint`) or structs of the same map, and no struct that contains itself.
95
+ * Problems are listed, not thrown; `types` keeps the structs that read.
96
+ */
97
+ export function parseEip712Types(types: unknown, label?: string): { types: Eip712Types; errors: { at: string; message: string }[] };
98
+ /** EIP-712 `encodeType`: the struct with its members, then the structs it refers to, sorted by name. Throws AbiError (E_EIP712). */
99
+ export function encodeType(types: Eip712Types, primary: string): string;
100
+ /** EIP-712 `typeHash`: the Keccak-256 of `encodeType`, as `0x` and 64 hex digits. */
101
+ export function eip712TypeHash(types: Eip712Types, primary: string): string;
package/src/evm.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * `@meantjs/profile/evm`: the EVM helpers of spec section 15 (D-054), browser-safe and without dependencies.
3
3
  * Keccak-256, ABI fragments (reading, canonical text, selectors, the interface identity), a strict ABI codec, EIP-55
4
- * addresses and EIP-712 digests. The consent package builds calldata display and the ERC-7730 export on them.
4
+ * addresses, EIP-712 types (section 15.9: reading them, encodeType, type hashes) and digests. The consent package
5
+ * builds calldata display, typed-message display and the ERC-7730 export on them.
5
6
  *
6
7
  * test/evm.test.mjs checks the vectors; test/address-entry.test.mjs keeps this entry free of node:* imports.
7
8
  */
@@ -13,10 +14,13 @@ export {
13
14
  canonicalFragment,
14
15
  componentKey,
15
16
  decodeParams,
17
+ eip712TypeHash,
16
18
  encodeCall,
17
19
  encodeParams,
20
+ encodeType,
18
21
  namedSignature,
19
22
  parseAbi,
23
+ parseEip712Types,
20
24
  parseSignature,
21
25
  readAddress,
22
26
  selectorOf,
package/src/resolve.mjs CHANGED
@@ -15,9 +15,10 @@ import { profileId } from "./util.mjs";
15
15
  import { compileInterface, interfaceFromEnvelope } from "./compile.mjs";
16
16
  import { analyzeProfiles } from "./analyze.mjs";
17
17
  import { matchPack } from "./pack-match.mjs";
18
- import { abiInterface, checkDeployments } from "./abi-iface.mjs";
18
+ import { abiInterface, checkDeployments, eip712Of } from "./abi-iface.mjs";
19
19
  import { matchAbiPack, translatePack } from "./abi-pack.mjs";
20
20
  import { abiInterfaceId, parseAbi } from "./abi.mjs";
21
+ import { jcs } from "./util.mjs";
21
22
  import { resolvedForm } from "./resolved.mjs";
22
23
  import { schemaDiagnostics } from "./schema-core.mjs";
23
24
 
@@ -59,12 +60,15 @@ async function resolveCompiled(opts, fromEnvelope) {
59
60
  const statuses = [];
60
61
  if (opts.abi !== undefined) {
61
62
  const label = opts.didLabel ?? "abi";
62
- const compiled = abiInterface(opts.abi, label);
63
+ // The messages of an EVM contract come from its profiles and packs (spec section 15.9), so the interface is built
64
+ // again once they are known; an ABI without functions is refused only if none brings a message.
65
+ const build = (eip712, allowEmpty = false) => abiInterface(opts.abi, label, "contract", eip712, { allowEmpty });
66
+ const compiled = build(null, true);
63
67
  if (compiled.failure) {
64
68
  for (const d of compiled.failure) diagnostics.push({ severity: "error", code: "E_ABI", at: d.at, message: d.message });
65
69
  return { ok: false, resolved: null, diagnostics, profiles: statuses };
66
70
  }
67
- return resolveWith(compiled.iface, opts, diagnostics, statuses);
71
+ return resolveWith(compiled.iface, opts, diagnostics, statuses, build);
68
72
  }
69
73
  const compiled = fromEnvelope ? interfaceFromEnvelope(opts.envelope, "canister") : await compileInterface(opts.did, "canister");
70
74
  if (compiled.failure) {
@@ -78,7 +82,7 @@ async function resolveCompiled(opts, fromEnvelope) {
78
82
  /** A profile or pack for an EVM contract (spec section 15) rather than a Candid service. */
79
83
  const isEvmDoc = (doc) => doc.kind === "contract" || (doc.kind === "pack" && doc.binds.abi !== undefined);
80
84
 
81
- async function resolveWith(iface, opts, diagnostics, statuses) {
85
+ async function resolveWith(iface, opts, diagnostics, statuses, rebuild = null) {
82
86
  const evm = iface.chain === "evm";
83
87
 
84
88
  const candidates = [
@@ -106,6 +110,12 @@ async function resolveWith(iface, opts, diagnostics, statuses) {
106
110
  statuses.push({ ...status, status: "invalid" });
107
111
  continue;
108
112
  }
113
+ const eip712 = evm ? eip712Of(doc) : null;
114
+ if (eip712?.errors.length) {
115
+ for (const d of eip712.errors) note("error", "E_EIP712", d.at, d.message);
116
+ statuses.push({ ...status, status: "invalid" });
117
+ continue;
118
+ }
109
119
  if (evm && doc.kind === "pack") {
110
120
  const m = matchAbiPack(iface, doc);
111
121
  if (m.error) {
@@ -121,16 +131,17 @@ async function resolveWith(iface, opts, diagnostics, statuses) {
121
131
  continue;
122
132
  }
123
133
  note("info", "I_PACK_MATCHED", doc.name, "the contract has every function of the pack's binds.abi (same selectors, outputs and kind of call)");
124
- items.push({ doc: translatePack(doc, m.functions, iface), pid, rebinding: false, apply: true, status });
134
+ items.push({ doc: translatePack(doc, m.functions, iface), pid, rebinding: false, apply: true, status, eip712 });
125
135
  } else if (evm) {
126
- const own = parseAbi(doc.binds.abi, "binds.abi");
136
+ // A contract profile binds the functions of its `binds.abi`; one that only describes messages binds none.
137
+ const own = parseAbi(doc.binds.abi ?? [], "binds.abi");
127
138
  if (own.errors.length) {
128
139
  for (const d of own.errors) note("error", "E_ABI", d.at, d.message);
129
140
  statuses.push({ ...status, status: "invalid" });
130
141
  continue;
131
142
  }
132
143
  checkDeployments(doc, note);
133
- items.push({ doc, pid, rebinding: abiInterfaceId(own.functions) !== iface.interfaceId, apply: false, status });
144
+ items.push({ doc, pid, rebinding: doc.binds.abi !== undefined && abiInterfaceId(own.functions) !== iface.abiId, apply: false, status, eip712 });
134
145
  } else if (doc.kind === "pack") {
135
146
  const m = await matchPack(iface, doc);
136
147
  if (m.error) {
@@ -154,6 +165,33 @@ async function resolveWith(iface, opts, diagnostics, statuses) {
154
165
  taken.add(pid);
155
166
  }
156
167
 
168
+ if (evm) {
169
+ // The EIP-712 types of every profile and pack applied, merged: one name, one struct (spec section 15.9).
170
+ const types = {};
171
+ const owner = {};
172
+ const messages = new Set();
173
+ for (const item of [...items]) {
174
+ const clash = Object.entries(item.eip712.types).find(([n, members]) => Object.hasOwn(types, n) && jcs(types[n]) !== jcs(members));
175
+ if (clash) {
176
+ diagnostics.push({ severity: "error", code: "E_EIP712", at: `binds.eip712.types.${clash[0]}`, message: `the type ${clash[0]} differs from the one profile "${owner[clash[0]]}" declares; one name is one struct`, profile: item.doc.name });
177
+ statuses.push({ ...item.status, status: "invalid" });
178
+ items.splice(items.indexOf(item), 1);
179
+ continue;
180
+ }
181
+ for (const [n, members] of Object.entries(item.eip712.types)) {
182
+ types[n] = members;
183
+ owner[n] ??= item.doc.name;
184
+ }
185
+ for (const n of item.eip712.messages) messages.add(n);
186
+ }
187
+ const built = Object.keys(types).length ? rebuild({ types, messages: [...messages].sort(), label: "binds.eip712.types" }) : iface.methods.size ? { iface } : rebuild(null);
188
+ if (built.failure) {
189
+ for (const d of built.failure) diagnostics.push({ severity: "error", code: d.at.startsWith("binds.eip712") ? "E_EIP712" : "E_ABI", at: d.at, message: d.message });
190
+ return { ok: false, resolved: null, diagnostics, profiles: [...items.map((i) => ({ ...i.status, status: "invalid" })), ...statuses] };
191
+ }
192
+ iface = built.iface;
193
+ }
194
+
157
195
  const analysis = analyzeProfiles(iface, items, { attribute: true });
158
196
  diagnostics.push(...analysis.diagnostics);
159
197
  for (const item of analysis.ordered) {
package/src/resolved.mjs CHANGED
@@ -41,24 +41,36 @@ export function buildTree(iface, methodName, side, tags) {
41
41
  */
42
42
  export function resolvedForm(iface, profiles, tags, diagnostics) {
43
43
  const methods = {};
44
+ const messages = {};
44
45
  for (const [name, m] of [...iface.methods.entries()].sort(([a], [b]) => (a < b ? -1 : 1))) {
45
46
  const mt = { ...tags.methodTags(name) };
46
47
  if (!mt.effect) {
47
- mt.effect = m.mode === "update" || m.mode === "oneway"
48
+ // A signed message authorizes what someone else submits later (spec section 15.9): as conservative as an update.
49
+ mt.effect = m.mode === "update" || m.mode === "oneway" || m.mode === "message"
48
50
  ? { args: { kind: "write", irreversible: true }, origin: "default" }
49
51
  : { args: { kind: "read", irreversible: false }, origin: "default" };
50
52
  }
53
+ if (m.mode === "message") {
54
+ const { encodeType, typeHash } = iface.messages.get(name); // an EIP-712 message: what a consumer matches a request by
55
+ messages[name] = { encode_type: encodeType, type_hash: typeHash, tags: mt, args: buildTree(iface, name, "args", tags) };
56
+ continue;
57
+ }
51
58
  const f = iface.functions?.get(name); // an EVM function: what a consumer needs to match and decode calldata
52
59
  const abi = f ? { selector: f.selector, signature: f.signature, state_mutability: f.stateMutability } : {};
53
60
  methods[name] = { mode: m.mode, ...abi, tags: mt, args: buildTree(iface, name, "args", tags), results: buildTree(iface, name, "results", tags) };
54
61
  }
62
+ const bound = (doc) => ({
63
+ ...(doc.binds?.deployments ? { deployments: doc.binds.deployments } : {}),
64
+ ...(doc.binds?.eip712?.domain ? { eip712_domain: doc.binds.eip712.domain } : {}),
65
+ });
55
66
  return {
56
67
  format: "candid-profile-resolved",
57
68
  format_version: 0,
58
69
  ...(iface.chain === "evm" ? { chain: "evm" } : {}),
59
70
  interface_id: iface.contract.identities.interface ?? null,
60
- profiles: profiles.map(({ doc, pid }) => ({ name: doc.name, origin: doc.origin, profile_id: pid, ...(doc.binds?.deployments ? { deployments: doc.binds.deployments } : {}) })),
71
+ profiles: profiles.map(({ doc, pid }) => ({ name: doc.name, origin: doc.origin, profile_id: pid, ...bound(doc) })),
61
72
  methods,
73
+ ...(Object.keys(messages).length ? { messages } : {}),
62
74
  diagnostics,
63
75
  };
64
76
  }
package/src/validate.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { profileId } from "./util.mjs";
6
6
  import { compileInterface } from "./compile.mjs";
7
- import { abiInterface, checkDeployments } from "./abi-iface.mjs";
7
+ import { abiInterface, checkDeployments, eip712Of } from "./abi-iface.mjs";
8
8
  import { analyzeProfiles } from "./analyze.mjs";
9
9
  import { resolvedForm } from "./resolved.mjs";
10
10
  import { schemaDiagnostics } from "./schema-core.mjs";
@@ -27,11 +27,15 @@ export async function validateProfile(doc, opts) {
27
27
  }
28
28
  const pid = profileId(doc);
29
29
 
30
- // An EVM contract profile or pack carries its own ABI (spec section 15): it is checked against that.
30
+ // An EVM contract profile or pack carries its own ABI and EIP-712 types (spec sections 15 and 15.9): it is
31
+ // checked against them.
31
32
  if (doc.kind === "contract" || (doc.kind === "pack" && doc.binds.abi !== undefined)) {
32
- const compiled = abiInterface(doc.binds.abi, "binds.abi");
33
+ const eip712 = eip712Of(doc);
34
+ for (const d of eip712.errors) diag("error", "E_EIP712", d.at, d.message);
35
+ if (eip712.errors.length) return { ok: false, profile_id: pid, diagnostics };
36
+ const compiled = abiInterface(doc.binds.abi ?? [], "binds.abi", "contract", doc.binds.eip712 ? { ...eip712, label: "binds.eip712.types" } : null);
33
37
  if (compiled.failure) {
34
- for (const d of compiled.failure) diag("error", "E_ABI", d.at, d.message);
38
+ for (const d of compiled.failure) diag("error", d.at.startsWith("binds.eip712") ? "E_EIP712" : "E_ABI", d.at, d.message);
35
39
  return { ok: false, profile_id: pid, diagnostics };
36
40
  }
37
41
  if (doc.kind === "contract") checkDeployments(doc, diag);