@meantjs/profile 0.1.0 → 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.
package/src/analyze.mjs CHANGED
@@ -7,8 +7,8 @@ import { parseAddress } from "./address.mjs";
7
7
  import { jcs } from "./util.mjs";
8
8
  import { TagIndex, LayeredTags, positions } from "./tags.mjs";
9
9
  import {
10
- NUMERIC, METHOD_TAGS, STALE_CODES, VALUE_CONVERSION_TAGS,
11
- checkApplicability, checkDynamic, parseTemplate, outcomeCoverage,
10
+ NUMERIC, METHOD_TAGS, CASE_TAGS, CONFIRM_KINDS, STALE_CODES, VALUE_CONVERSION_TAGS, EVM_LATER_TAGS,
11
+ checkApplicability, checkDynamic, parseTemplate, outcomeCoverage, caseTargetError, otherCase, overlap, defaultEffect,
12
12
  } from "./checks.mjs";
13
13
 
14
14
  /** Spec section 8, highest precedence first. */
@@ -107,6 +107,40 @@ export function analyzeProfiles(iface, items, { attribute = true } = {}) {
107
107
  // A pack applied to a service is allowed to differ from its fragment in the ways Candid subtyping allows, so an
108
108
  // annotation that no longer holds on this service is dropped with an info, not reported as an error in the pack.
109
109
  const dropped = new Set();
110
+ const caseEntries = []; // case intents and effects, checked against each other and their method after the loop
111
+
112
+ /** An effect against the method's mode (spec 5.3); for a case effect, the mode of the method it sits in. */
113
+ const checkEffectMode = (m, { kind, irreversible, replicated }, diag, at) => {
114
+ if (m.mode !== "update" && m.mode !== "oneway" && kind !== "read") diag("error", "E_EFFECT_MODE", at, `${m.mode} method cannot have effect kind "${kind}"`);
115
+ // A read-only update (D-038): a read sent as an update so that its answer goes through consensus.
116
+ if (replicated === true) {
117
+ if (m.mode !== "update") diag("error", "E_EFFECT_MODE", at, `replicated: true applies to update methods, not to a ${m.mode} method`);
118
+ if (kind !== "read" || irreversible !== false) diag("error", "E_EFFECT_MODE", at, `replicated: true needs kind "read" and irreversible: false, found kind "${kind}" and irreversible: ${irreversible}`);
119
+ }
120
+ const replicatedRead = replicated === true && m.mode === "update" && kind === "read" && irreversible === false;
121
+ if ((m.mode === "update" || m.mode === "oneway") && kind === "read" && !replicatedRead) {
122
+ diag("warning", "W_EFFECT_MODE", at, `${m.mode} method declared as read${m.mode === "update" && replicated !== true ? "; a read sent as an update says irreversible: false, replicated: true" : ""}`);
123
+ }
124
+ };
125
+
126
+ /** An intent template's placeholders (spec section 6); `caseAddr` is the case a case intent sits on. */
127
+ const checkTemplate = (item, methodName, template, diag, at, caseAddr) => {
128
+ const t = parseTemplate(template);
129
+ if (t.error) return diag("error", "E_TEMPLATE", at, t.error);
130
+ if (iface.chain === "evm" && t.segments) return diag("error", "E_TEMPLATE", at, "an EVM contract's template has no [ ] segments: an ABI value is never absent (spec section 15)");
131
+ for (const p of t.placeholders) {
132
+ const r = iface.resolveRel(methodName, "args", p.path);
133
+ if (r.error && item.apply && p.inOptional) { // absent in this service: the optional segment renders empty (spec section 6)
134
+ item.diag("info", "I_PACK_ANNOTATION_SKIPPED", at, `intent: {${p.path}} is absent in this service (${r.error}); its optional segment renders empty`);
135
+ continue;
136
+ }
137
+ if (r.error) { diag("error", "E_TEMPLATE_PATH", at, `{${p.path}}: ${r.error}`); continue; }
138
+ const other = caseAddr && otherCase(iface, caseAddr, p.path);
139
+ if (other) { diag("error", "E_TEMPLATE_PATH", at, `{${p.path}}: ${other}`); continue; }
140
+ if (!p.inOptional && iface.node(r.ref).kind === "opt") diag("warning", "W_TEMPLATE_OPTIONAL", at, `{${p.path}} is optional but sits outside a [ ] segment`);
141
+ }
142
+ };
143
+
110
144
  const sets = new Map(); // names set -> { at, method, fields: { name, value, show?, kind? } } (spec 5.3, D-045)
111
145
  const named = []; // named positions, checked once every set is known
112
146
  for (const e of entries) {
@@ -118,22 +152,24 @@ export function analyzeProfiles(iface, items, { attribute = true } = {}) {
118
152
  item.diag("info", "I_PACK_ANNOTATION_SKIPPED", at, `${a.tag} dropped: ${code}: ${message}`);
119
153
  };
120
154
  const at = a.at;
155
+ if (iface.chain === "evm" && EVM_LATER_TAGS.has(a.tag)) {
156
+ diag("error", "E_TAG_TARGET", at, `${a.tag} is not defined for EVM contracts yet: it needs receipts, nonces or reads that a later phase adds (spec section 15)`);
157
+ continue;
158
+ }
159
+ // intent and effect on a case of a variant in the arguments (spec 5.2, D-053)
160
+ if (CASE_TAGS.has(a.tag) && !res.method) {
161
+ const why = caseTargetError(iface, addr, res, a.tag);
162
+ if (why) { diag("error", "E_TAG_TARGET", at, why); continue; }
163
+ const m = iface.methods.get(addr.method);
164
+ if (a.tag === "effect") checkEffectMode(m, a.args, diag, at);
165
+ else checkTemplate(item, addr.method, a.args.template, diag, at, addr);
166
+ caseEntries.push({ e, diag });
167
+ continue;
168
+ }
121
169
  if (METHOD_TAGS.has(a.tag)) {
122
170
  const m = res.method;
123
171
  const methodName = addr.method;
124
- if (a.tag === "effect") {
125
- const { kind, irreversible, replicated } = a.args;
126
- if (m.mode !== "update" && m.mode !== "oneway" && kind !== "read") diag("error", "E_EFFECT_MODE", at, `${m.mode} method cannot have effect kind "${kind}"`);
127
- // A read-only update (D-038): a read sent as an update so that its answer goes through consensus.
128
- if (replicated === true) {
129
- if (m.mode !== "update") diag("error", "E_EFFECT_MODE", at, `replicated: true applies to update methods, not to a ${m.mode} method`);
130
- if (kind !== "read" || irreversible !== false) diag("error", "E_EFFECT_MODE", at, `replicated: true needs kind "read" and irreversible: false, found kind "${kind}" and irreversible: ${irreversible}`);
131
- }
132
- const replicatedRead = replicated === true && m.mode === "update" && kind === "read" && irreversible === false;
133
- if ((m.mode === "update" || m.mode === "oneway") && kind === "read" && !replicatedRead) {
134
- diag("warning", "W_EFFECT_MODE", at, `${m.mode} method declared as read${m.mode === "update" && replicated !== true ? "; a read sent as an update says irreversible: false, replicated: true" : ""}`);
135
- }
136
- }
172
+ if (a.tag === "effect") checkEffectMode(m, a.args, diag, at);
137
173
  if (a.tag === "idempotency") {
138
174
  if (m.mode !== "update") diag("error", "E_IDEMPOTENCY_MODE", at, "idempotency applies to update methods");
139
175
  const r = iface.resolveRel(methodName, "args", a.args.stamp);
@@ -153,19 +189,7 @@ export function analyzeProfiles(iface, items, { attribute = true } = {}) {
153
189
  outcomeCoverage(iface, methodName, classes, diag, at, Boolean(a.args.default));
154
190
  if (doc.kind === "pack" && !a.args.default) diag("warning", "W_PACK_NO_DEFAULT", at, "pack outcome without default: result cases a matching canister adds (spec §7) would be unclassified");
155
191
  }
156
- if (a.tag === "intent") {
157
- const t = parseTemplate(a.args.template);
158
- if (t.error) diag("error", "E_TEMPLATE", at, t.error);
159
- else for (const p of t.placeholders) {
160
- const r = iface.resolveRel(methodName, "args", p.path);
161
- if (r.error && item.apply && p.inOptional) { // absent in this service: the optional segment renders empty (spec section 6)
162
- item.diag("info", "I_PACK_ANNOTATION_SKIPPED", at, `intent: {${p.path}} is absent in this service (${r.error}); its optional segment renders empty`);
163
- continue;
164
- }
165
- if (r.error) { diag("error", "E_TEMPLATE_PATH", at, `{${p.path}}: ${r.error}`); continue; }
166
- if (!p.inOptional && iface.node(r.ref).kind === "opt") diag("warning", "W_TEMPLATE_OPTIONAL", at, `{${p.path}} is optional but sits outside a [ ] segment`);
167
- }
168
- }
192
+ if (a.tag === "intent") checkTemplate(item, methodName, a.args.template, diag, at);
169
193
  if (a.tag === "names") {
170
194
  const effect = tags.methodTags(methodName).effect?.args;
171
195
  const replicatedRead = m.mode === "update" && effect?.kind === "read" && effect.irreversible === false && effect.replicated === true;
@@ -234,6 +258,28 @@ export function analyzeProfiles(iface, items, { attribute = true } = {}) {
234
258
  }
235
259
  }
236
260
 
261
+ // Tagged cases of one method must not overlap, and a case effect needs a method effect at least as strong
262
+ // (spec 5.2 and 5.3, D-053).
263
+ for (let j = 0; j < caseEntries.length; j++) {
264
+ const { e, diag } = caseEntries[j];
265
+ const { addr, ann: a } = e;
266
+ for (const { e: prev } of caseEntries.slice(0, j)) {
267
+ if (prev.ann.tag !== a.tag || prev.addr.method !== addr.method || prev.res.key === e.res.key) continue;
268
+ const why = overlap(iface, prev.addr, addr);
269
+ if (why) diag("error", "E_CASE_OVERLAP", a.at, `${a.tag} on "${prev.ann.at}" and on "${a.at}": one call can take both cases (${why})`);
270
+ }
271
+ if (a.tag !== "effect") continue;
272
+ const m = iface.methods.get(addr.method);
273
+ const declared = tags.methodTags(addr.method).effect?.args;
274
+ const method = declared ?? defaultEffect(m.mode);
275
+ const whose = `the method's effect is ${method.kind}${method.irreversible ? ", irreversible" : ""}${declared ? "" : " (the default)"}`;
276
+ if (CONFIRM_KINDS.has(a.args.kind) && !CONFIRM_KINDS.has(method.kind)) {
277
+ diag("error", "E_CASE_EFFECT", a.at, `case effect ${a.args.kind} needs a person's confirmation, but ${whose}; declare the method's effect as transfer, approve, admin or destroy, so that a consumer that reads only method tags confirms it too`);
278
+ } else if (a.args.irreversible === true && method.irreversible !== true) {
279
+ diag("error", "E_CASE_EFFECT", a.at, `case effect is irreversible, but ${whose}; a method with an irreversible case is irreversible`);
280
+ }
281
+ }
282
+
237
283
  // Dropping a value tag can leave an idempotency stamp without its timestamp tag: drop that idempotency too.
238
284
  if (dropped.size) {
239
285
  const without = buildLayers(entries, (e) => !dropped.has(e));
package/src/browser.d.mts CHANGED
@@ -1,11 +1,11 @@
1
1
  // Types for @meantjs/profile/browser. Hand-written; the source is plain ES modules (src/*.mjs). The internal
2
2
  // graph classes (Iface, TagIndex, LayeredTags, SubtypeChecker) are declared opaquely.
3
3
 
4
- /** A Candid Profile document (docs/profile-v0.md): a canister profile or a pack. */
4
+ /** A Candid Profile document (docs/profile-v0.md): a canister profile, a pack, or an EVM contract profile (section 15). */
5
5
  export interface ProfileDocument {
6
6
  format: "candid-profile";
7
7
  format_version: 0;
8
- kind: "canister" | "pack";
8
+ kind: "canister" | "pack" | "contract";
9
9
  name: string;
10
10
  origin: "author" | "third_party" | "pack" | "inferred";
11
11
  annotations: { at: string; tag: string; args?: Record<string, unknown> }[];
@@ -17,6 +17,8 @@ export interface ResolvedForm {
17
17
  format: "candid-profile-resolved";
18
18
  format_version: 0;
19
19
  interface_id: string;
20
+ /** "evm" for an EVM contract's form (section 15.5); absent for a Candid service's. */
21
+ chain?: "evm";
20
22
  profiles: unknown[];
21
23
  methods: Record<string, unknown>;
22
24
  diagnostics: Diagnostic[];
@@ -54,14 +56,16 @@ export interface SchemaValidator {
54
56
  }
55
57
 
56
58
  export interface ResolveOptions {
57
- /** Candid source of the service (exactly one of `did` and `envelope`). */
59
+ /** Candid source of the service (exactly one of `did`, `envelope` and `abi`). */
58
60
  did?: string;
59
61
  /** The service's candid-core ContractEnvelope. */
60
62
  envelope?: unknown;
63
+ /** An EVM contract's ABI: human-readable fragments or JSON ABI entries (section 15). */
64
+ abi?: readonly (string | Record<string, unknown>)[];
61
65
  didLabel?: string;
62
66
  /** Applied as given; a pack here must match. */
63
67
  profiles?: { doc: unknown; label?: string }[];
64
- /** Tried by Candid subtyping; applied only if they match. */
68
+ /** Tried by Candid subtyping (an EVM pack by selector); applied only if they match. */
65
69
  packs?: { doc: unknown; label?: string }[];
66
70
  validateSchema: SchemaValidator;
67
71
  }
package/src/browser.mjs CHANGED
@@ -10,6 +10,7 @@
10
10
  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
+ import erc20 from "../data/packs/erc20.profile.json" with { type: "json" };
13
14
  import { compileSchema } from "./schema-core.mjs";
14
15
 
15
16
  export { init as initCompiler } from "@candid-core/cli";
@@ -33,6 +34,7 @@ export const PROFILE_SCHEMA = schema;
33
34
 
34
35
  /** The standard packs (copies of packs/*.profile.json), sorted by file name, in the shape `loadPacks` returns. */
35
36
  export const STANDARD_PACKS = Object.freeze([
37
+ { file: "erc20.profile.json", doc: erc20 },
36
38
  { file: "icrc1.profile.json", doc: icrc1 },
37
39
  { file: "icrc2.profile.json", doc: icrc2 },
38
40
  ]);
package/src/checks.mjs CHANGED
@@ -1,25 +1,38 @@
1
1
  /**
2
2
  * Per-annotation semantic checks (spec sections 3 to 6). Moved verbatim from scripts/validate.mjs.
3
3
  */
4
- export const NUMERIC = new Set(["nat", "int", "nat8", "nat16", "nat32", "nat64", "int8", "int16", "int32", "int64"]);
5
- export const TIMEISH = new Set(["nat", "int", "nat32", "nat64", "int64"]);
4
+ import { parseRelPath } from "./address.mjs";
5
+
6
+ /** EVM integer types (spec section 15): uint8 to uint256 and int8 to int256. int8 to int64 are also Candid's, with the same bounds. */
7
+ const ABI_BITS = Array.from({ length: 32 }, (_, i) => 8 * (i + 1));
8
+ export const NUMERIC = new Set(["nat", "int", "nat8", "nat16", "nat32", "nat64", "int8", "int16", "int32", "int64", ...ABI_BITS.flatMap((b) => [`uint${b}`, `int${b}`])]);
9
+ export const TIMEISH = new Set(["nat", "int", "nat32", "nat64", "int64", ...ABI_BITS.filter((b) => b >= 32).map((b) => `uint${b}`), ...ABI_BITS.filter((b) => b >= 64).map((b) => `int${b}`)]);
10
+ /** Text: Candid's `text`, the ABI's `string`. */
11
+ export const TEXT = new Set(["text", "string"]);
12
+ /** Tags an EVM contract profile cannot use yet: they need receipts, nonces or reads that phase 2 adds (spec section 15). */
13
+ export const EVM_LATER_TAGS = new Set(["idempotency", "outcome", "list", "names", "named"]);
6
14
  export const METHOD_TAGS = new Set(["intent", "effect", "idempotency", "outcome", "list", "names"]);
15
+ /** Method tags that may also sit on a case of a variant in the arguments (spec 5.2, D-053). */
16
+ export const CASE_TAGS = new Set(["intent", "effect"]);
17
+ /** Effect kinds that need a person's confirmation (spec 5.3). */
18
+ export const CONFIRM_KINDS = new Set(["transfer", "approve", "admin", "destroy"]);
7
19
  /** Tags that convert a value between its wire form and a human form; a stale profile must not supply them (spec section 7). */
8
20
  export const VALUE_CONVERSION_TAGS = new Set(["amount", "timestamp", "duration", "account", "subaccount", "bytes", "named"]);
9
- 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"]);
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"]);
10
22
 
11
23
  export function checkApplicability(iface, tag, ref) {
12
24
  const { ref: r } = iface.unwrapOpt(ref);
13
25
  const p = iface.prim(r);
26
+ if (iface.chain === "evm" && (tag === "account" || tag === "subaccount")) return `${tag} is an ICRC type; an EVM address needs no tag (it is shown with its EIP-55 checksum, spec section 15)`;
14
27
  switch (tag) {
15
28
  case "amount": return NUMERIC.has(p) ? null : `amount needs a numeric type, found ${iface.describeType(ref)}`;
16
29
  case "timestamp":
17
- case "duration": return TIMEISH.has(p) ? null : `${tag} needs nat/int/nat32/nat64/int64, found ${iface.describeType(ref)}`;
30
+ case "duration": return TIMEISH.has(p) ? null : `${tag} needs ${iface.chain === "evm" ? "uint32 to uint256 or int64 to int256" : "nat/int/nat32/nat64/int64"}, found ${iface.describeType(ref)}`;
18
31
  case "account": return iface.isAccount(r) ? null : `account needs record { owner : principal; subaccount : opt blob }, found ${iface.describeType(ref)}`;
19
32
  case "subaccount":
20
- case "bytes": return iface.isBlob(r) ? null : `${tag} needs blob, found ${iface.describeType(ref)}`;
21
- case "text": return p === "text" ? null : `text needs text, found ${iface.describeType(ref)}`;
22
- case "range": return NUMERIC.has(p) || p === "text" || iface.node(r).kind === "vec" ? null : `range needs numeric, text or vec, found ${iface.describeType(ref)}`;
33
+ case "bytes": return iface.isBlob(r) ? null : `${tag} needs ${iface.chain === "evm" ? "bytes or bytesN" : "blob"}, found ${iface.describeType(ref)}`;
34
+ case "text": return TEXT.has(p) ? null : `text needs ${iface.chain === "evm" ? "string" : "text"}, found ${iface.describeType(ref)}`;
35
+ case "range": return NUMERIC.has(p) || TEXT.has(p) || iface.node(r).kind === "vec" ? null : `range needs numeric, text or vec, found ${iface.describeType(ref)}`;
23
36
  default: return null;
24
37
  }
25
38
  }
@@ -39,17 +52,18 @@ export function checkDynamic(iface, value, want, diag, at) {
39
52
  else {
40
53
  const p = iface.prim(iface.unwrapOpt(m.results[0]).ref);
41
54
  if (want === "numeric" && !NUMERIC.has(p)) diag("error", "E_REF_TYPE", at, `"${from}" returns ${p}, expected a number`);
42
- if (want === "text" && p !== "text") diag("error", "E_REF_TYPE", at, `"${from}" returns ${p}, expected text`);
55
+ if (want === "text" && !TEXT.has(p)) diag("error", "E_REF_TYPE", at, `"${from}" returns ${p}, expected text`);
43
56
  }
44
57
  }
45
58
 
46
59
  export function parseTemplate(t) {
47
60
  const placeholders = [];
48
61
  let depth = 0;
62
+ let segments = 0;
49
63
  for (let i = 0; i < t.length; i++) {
50
64
  const c = t[i];
51
65
  if ((c === "{" || c === "}" || c === "[" || c === "]") && t[i + 1] === c) { i++; continue; } // escaped
52
- if (c === "[") { if (depth) return { error: "nested [ ] segments are not allowed in v0" }; depth = 1; }
66
+ if (c === "[") { if (depth) return { error: "nested [ ] segments are not allowed in v0" }; depth = 1; segments++; }
53
67
  else if (c === "]") { if (!depth) return { error: "unbalanced ]" }; depth = 0; }
54
68
  else if (c === "{") {
55
69
  const end = t.indexOf("}", i);
@@ -59,7 +73,7 @@ export function parseTemplate(t) {
59
73
  } else if (c === "}") return { error: "unbalanced }" };
60
74
  }
61
75
  if (depth) return { error: "unclosed [" };
62
- return { placeholders };
76
+ return { placeholders, segments };
63
77
  }
64
78
 
65
79
  export function outcomeCoverage(iface, methodName, classes, diag, at, hasDefault) {
@@ -79,3 +93,62 @@ export function outcomeCoverage(iface, methodName, classes, diag, at, hasDefault
79
93
  };
80
94
  visit(m.results[0], "");
81
95
  }
96
+
97
+ /** The effect a method has without an effect tag (spec 5.3): queries read, updates write irreversibly. */
98
+ export function defaultEffect(mode) {
99
+ return mode === "query" || mode === "composite_query" ? { kind: "read", irreversible: false } : { kind: "write", irreversible: true };
100
+ }
101
+
102
+ /** Why an intent or effect at a value address is not on a case of an argument (spec 5.2, D-053), or null. */
103
+ export function caseTargetError(iface, addr, res, tag) {
104
+ if (addr.kind === "type") return `${tag} sits on a method or on a case of a variant in its arguments, never at a type: address (it can reach several methods)`;
105
+ if (addr.kind === "result") return `${tag} sits on a method or on a case of a variant in its arguments, not in its results`;
106
+ if (addr.segs.some((s) => s.kind === "elem")) return `${tag} on a case cannot step into a vector element ([]): one call holds many elements`;
107
+ if (res.parent?.kind !== "variant") return `${tag} sits on a method or on a case of a variant in its arguments; this address ends on ${res.parent ? "a record field" : "a whole argument"}`;
108
+ return null;
109
+ }
110
+
111
+ /**
112
+ * Walk two segment lists of one method argument side by side. Returns where they part: { kind } of the container at
113
+ * the first differing step, with the two labels, or null when one is a prefix of the other.
114
+ */
115
+ function part(iface, start, a, b) {
116
+ let cur = start;
117
+ for (let k = 0; k < Math.min(a.length, b.length); k++) {
118
+ cur = iface.unwrapOpt(cur).ref;
119
+ const n = iface.node(cur);
120
+ const x = a[k];
121
+ const y = b[k];
122
+ if (x.kind === "elem" || y.kind === "elem") {
123
+ if (x.kind === y.kind) { cur = n.inner; continue; }
124
+ return { kind: n.kind };
125
+ }
126
+ if (x.id !== y.id) return { kind: n.kind, a: x.label, b: y.label };
127
+ cur = n.fields.find((f) => f.id === x.id).type;
128
+ }
129
+ return null;
130
+ }
131
+
132
+ /**
133
+ * A case template's placeholder that selects another case of a variant the case address passes through, so it can
134
+ * never be present (spec section 6, D-053): the reason, or null. Both paths resolve; `rel` is a method-tuple relpath.
135
+ */
136
+ export function otherCase(iface, caseAddr, rel) {
137
+ const p = parseRelPath(rel);
138
+ const index = p.index ?? 0;
139
+ if (index !== caseAddr.index) return null;
140
+ const m = iface.methods.get(caseAddr.method);
141
+ const at = part(iface, m.args[index], caseAddr.segs, p.segs);
142
+ return at?.kind === "variant" ? `selects case "${at.b}" of a variant where this intent's case takes "${at.a}", so it is never present` : null;
143
+ }
144
+
145
+ /**
146
+ * Whether one call can take two tagged cases of one method (spec 5.2, D-053): the reason, or null when they exclude
147
+ * each other (they part at a variant) or nest.
148
+ */
149
+ export function overlap(iface, a, b) {
150
+ if (a.index !== b.index) return "they sit in different arguments";
151
+ const m = iface.methods.get(a.method);
152
+ const at = part(iface, m.args[a.index], a.segs, b.segs);
153
+ return at && at.kind !== "variant" ? "their paths part at a record field" : null;
154
+ }
package/src/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * candid-profile resolve (<service.did> | --envelope <file>) [--profile p.json ...] [--packs] [--packs-dir dir]
2
+ * candid-profile resolve (<service.did> | --envelope <file> | --abi <file>) [--profile p.json ...] [--packs] [--packs-dir dir]
3
3
  *
4
4
  * Prints the resolved form (docs/profile-v0.md section 10) on stdout and a summary on stderr.
5
5
  * Exit code: 0 when no diagnostic is an error, 1 when one is (the resolved form is still printed when the service
@@ -13,9 +13,12 @@ import { resolveService } from "./resolve.mjs";
13
13
 
14
14
  export const USAGE = `usage: candid-profile resolve <service.did> [options] > resolved.json
15
15
  candid-profile resolve --envelope <file> [options] > resolved.json
16
+ candid-profile resolve --abi <file> [options] > resolved.json
16
17
 
17
18
  --envelope <file> the service's candid-core ContractEnvelope (as \`candid-core-cli gen\` writes it), instead of
18
19
  compiling a .did here
20
+ --abi <file> an EVM contract's ABI, a JSON array of human-readable fragments or JSON ABI entries
21
+ (experimental, spec section 15)
19
22
  --profile <file> a profile to apply (repeatable). A pack given this way must match the service.
20
23
  --packs try every pack in the packs directory; apply those that match
21
24
  --packs-dir <dir> packs directory (default: the standard packs this package ships)
@@ -27,7 +30,7 @@ export const USAGE = `usage: candid-profile resolve <service.did> [options] > re
27
30
  class UsageError extends Error {}
28
31
 
29
32
  function parseArgs(argv) {
30
- const opts = { did: null, envelope: null, profiles: [], packs: false, packsDir: DEFAULT_PACKS_DIR, verbose: false, quiet: false, help: false };
33
+ const opts = { did: null, envelope: null, abi: null, profiles: [], packs: false, packsDir: DEFAULT_PACKS_DIR, verbose: false, quiet: false, help: false };
31
34
  const args = [...argv];
32
35
  const value = (name, inline) => {
33
36
  if (inline !== undefined) return inline;
@@ -40,6 +43,7 @@ function parseArgs(argv) {
40
43
  const [flag, inline] = a.startsWith("--") && a.includes("=") ? [a.slice(0, a.indexOf("=")), a.slice(a.indexOf("=") + 1)] : [a, undefined];
41
44
  if (flag === "--profile") opts.profiles.push(value(flag, inline));
42
45
  else if (flag === "--envelope") opts.envelope = value(flag, inline);
46
+ else if (flag === "--abi") opts.abi = value(flag, inline);
43
47
  else if (flag === "--packs") opts.packs = true;
44
48
  else if (flag === "--packs-dir") opts.packsDir = value(flag, inline);
45
49
  else if (flag === "--verbose") opts.verbose = true;
@@ -73,10 +77,12 @@ export async function main(argv, io) {
73
77
  if (command !== "resolve") throw new UsageError(`unknown command ${command}`);
74
78
  const opts = parseArgs(rest);
75
79
  if (opts.help) { err(USAGE.trimEnd()); return 0; }
76
- if (opts.did && opts.envelope) throw new UsageError("give <service.did> or --envelope, not both");
77
- if (!opts.did && !opts.envelope) throw new UsageError("missing <service.did> (or --envelope <file>)");
80
+ const given = [opts.did, opts.envelope, opts.abi].filter((x) => x !== null).length;
81
+ if (given > 1) throw new UsageError("give only one of <service.did>, --envelope and --abi");
82
+ if (given === 0) throw new UsageError("missing <service.did> (or --envelope <file>, or --abi <file>)");
78
83
  const input = {};
79
84
  if (opts.envelope) input.envelope = await readJsonFile(opts.envelope, "envelope");
85
+ else if (opts.abi) input.abi = await readJsonFile(opts.abi, "ABI");
80
86
  else {
81
87
  try { input.did = await readFile(opts.did, "utf8"); } catch (e) { throw new UsageError(`cannot read ${opts.did}: ${e.message}`); }
82
88
  }
@@ -92,7 +98,7 @@ export async function main(argv, io) {
92
98
  for (const p of found) packs.push({ doc: p.doc, label: path.relative(process.cwd(), p.file) });
93
99
  }
94
100
 
95
- const r = await resolveService({ ...input, didLabel: opts.did ?? opts.envelope, validateSchema, profiles, packs });
101
+ const r = await resolveService({ ...input, didLabel: opts.did ?? opts.envelope ?? opts.abi, validateSchema, profiles, packs });
96
102
  if (r.resolved) io.stdout.write(`${JSON.stringify(r.resolved, null, 2)}\n`);
97
103
  if (!opts.quiet) summary(r, opts, err);
98
104
  return r.ok ? 0 : 1;
@@ -104,7 +110,7 @@ export async function main(argv, io) {
104
110
 
105
111
  function summary(r, opts, err) {
106
112
  const count = (s) => r.diagnostics.filter((d) => d.severity === s).length;
107
- if (r.resolved) err(`candid-profile: ${opts.did ?? opts.envelope} ${r.resolved.interface_id} [${Object.keys(r.resolved.methods).length} methods]`);
113
+ if (r.resolved) err(`candid-profile: ${opts.did ?? opts.envelope ?? opts.abi} ${r.resolved.interface_id} [${Object.keys(r.resolved.methods).length} methods]`);
108
114
  for (const p of r.profiles) err(` ${p.status.padEnd(11)} ${String(p.name).padEnd(16)} ${String(p.origin ?? "?").padEnd(12)} ${p.profile_id ?? ""}`);
109
115
  err(` ${count("error")} error, ${count("warning")} warning, ${count("info")} info`);
110
116
  for (const d of r.diagnostics) {
package/src/evm.d.mts ADDED
@@ -0,0 +1,86 @@
1
+ // Types for @meantjs/profile/evm: the EVM helpers of spec section 15 (experimental), with no dependency, for a
2
+ // browser bundle. Hand-written; the source is src/evm.mjs, src/abi.mjs and src/keccak.mjs.
3
+ // test/types.test.mjs keeps the declared names equal to the runtime exports.
4
+
5
+ /** An ABI type: `kind` is uint, int, address, bool, string, bytes, fixedbytes, array or tuple. */
6
+ export type AbiType =
7
+ | { kind: "uint" | "int"; bits: number }
8
+ | { kind: "address" | "bool" | "string" | "bytes" }
9
+ | { kind: "fixedbytes"; size: number }
10
+ | { kind: "array"; element: AbiType; length: number | null }
11
+ | { kind: "tuple"; components: AbiParam[] };
12
+ /** A parameter, output or tuple component; `name` is null when it has none. */
13
+ export interface AbiParam {
14
+ name: string | null;
15
+ type: AbiType;
16
+ }
17
+ /** A function read from an ABI. */
18
+ export interface AbiFunction {
19
+ name: string;
20
+ inputs: AbiParam[];
21
+ outputs: AbiParam[];
22
+ stateMutability: "pure" | "view" | "nonpayable" | "payable";
23
+ /** `transfer(address to,uint256 value)`: the ERC-7730 key, and a resolved form's `signature`. */
24
+ signature: string;
25
+ /** `transfer(address,uint256)`: what the selector hashes. */
26
+ typeSignature: string;
27
+ /** `0xa9059cbb` */
28
+ selector: string;
29
+ /** `function transfer(address to,uint256 value) returns (bool)` */
30
+ canonical: string;
31
+ /** Where it was read (`abi[3]`); parseSignature leaves it out. */
32
+ at?: string;
33
+ }
34
+ /** A decoded or encodable value: decimal strings (or bigints) for integers, EIP-55 text for addresses, Uint8Array for bytes. */
35
+ export type AbiValue = string | bigint | boolean | Uint8Array | AbiValue[] | { [key: string]: AbiValue };
36
+ export interface AbiLimits {
37
+ maxBytes?: number;
38
+ maxDepth?: number;
39
+ maxElements?: number;
40
+ }
41
+ /** Every failure of the ABI helpers: E_ABI (a fragment), E_ABI_ENCODE, E_ABI_DECODE, E_ADDRESS or E_ADDRESS_CHECKSUM. */
42
+ export declare class AbiError extends Error {
43
+ constructor(code: string, message: string);
44
+ name: "AbiError";
45
+ code: string;
46
+ }
47
+ export interface TypedData {
48
+ domain: Record<string, unknown>;
49
+ types: Record<string, { name: string; type: string }[]>;
50
+ primaryType: string;
51
+ message: Record<string, unknown>;
52
+ }
53
+
54
+ /** Keccak-256 (Ethereum's, not NIST SHA3-256) of bytes or of a string's UTF-8 bytes. */
55
+ export function keccak256(input: Uint8Array | string): Uint8Array;
56
+ /** Keccak-256 as 64 lowercase hex digits, without `0x`. */
57
+ export function keccak256Hex(input: Uint8Array | string): string;
58
+ /** RFC 8785 canonical JSON. */
59
+ export function jcs(value: unknown): string;
60
+ export const DEFAULT_ABI_LIMITS: Readonly<Required<AbiLimits>>;
61
+ /** Read an ABI (human-readable fragments or JSON ABI entries); only functions are read. Problems are listed, not thrown. */
62
+ export function parseAbi(entries: unknown, label?: string): { functions: AbiFunction[]; errors: { at: string; message: string }[] };
63
+ /** One function from its named signature, as a resolved form carries it. Throws AbiError. */
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;
67
+ export function canonicalFragment(f: Pick<AbiFunction, "name" | "inputs" | "outputs" | "stateMutability">): string;
68
+ export function namedSignature(f: Pick<AbiFunction, "name" | "inputs">): string;
69
+ export function typeSignature(f: Pick<AbiFunction, "name" | "inputs">): string;
70
+ export function selectorOf(f: Pick<AbiFunction, "name" | "inputs">): string;
71
+ /** Canonical type text; with `names`, tuple components keep their names. */
72
+ export function typeString(type: AbiType, names?: boolean): string;
73
+ /** A tuple component's key in values and resolved forms: its name, else `#` and its position. */
74
+ export function componentKey(param: AbiParam, index: number): string;
75
+ /** ABI-encode a parameter list's values, keyed like a tuple. Throws AbiError (E_ABI_ENCODE). */
76
+ export function encodeParams(params: readonly AbiParam[], values: Record<string, AbiValue>): Uint8Array;
77
+ /** The selector followed by the encoded parameters. */
78
+ export function encodeCall(f: AbiFunction, values: Record<string, AbiValue>): Uint8Array;
79
+ /** Decode strictly: the bytes must be the canonical encoding of the values read. Throws AbiError (E_ABI_DECODE). */
80
+ export function decodeParams(params: readonly AbiParam[], data: Uint8Array, limits?: AbiLimits): Record<string, AbiValue>;
81
+ /** The EIP-55 checksummed form of `0x` and 40 hex digits. */
82
+ export function toChecksumAddress(address: string): string;
83
+ /** Read an address: mixed case must match its EIP-55 checksum. Returns the checksummed form. */
84
+ export function readAddress(text: string): string;
85
+ /** The EIP-712 digest a signer signs, as `0x` and 64 hex digits. */
86
+ export function typedDataDigest(typed: TypedData): string;
package/src/evm.mjs ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `@meantjs/profile/evm`: the EVM helpers of spec section 15 (D-054), browser-safe and without dependencies.
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.
5
+ *
6
+ * test/evm.test.mjs checks the vectors; test/address-entry.test.mjs keeps this entry free of node:* imports.
7
+ */
8
+ export { keccak256, keccak256Hex } from "./keccak.mjs";
9
+ export {
10
+ AbiError,
11
+ DEFAULT_ABI_LIMITS,
12
+ abiInterfaceId,
13
+ canonicalFragment,
14
+ componentKey,
15
+ decodeParams,
16
+ encodeCall,
17
+ encodeParams,
18
+ namedSignature,
19
+ parseAbi,
20
+ parseSignature,
21
+ readAddress,
22
+ selectorOf,
23
+ toChecksumAddress,
24
+ typeSignature,
25
+ typeString,
26
+ typedDataDigest,
27
+ } from "./abi.mjs";
28
+ export { jcs } from "./util.mjs";
package/src/keccak.mjs ADDED
@@ -0,0 +1,108 @@
1
+ // Keccak-256 in plain JavaScript, synchronous, as Ethereum uses it: the original Keccak padding (0x01), not the NIST
2
+ // SHA3-256 one (0x06). For function selectors, EIP-55 checksums, ERC-8176 descriptor hashes and EIP-712 digests
3
+ // (spec section 15, D-054). test/evm.test.mjs checks it against the known hashes of the empty string and of the
4
+ // ERC-20 selectors, and against the ERC-8176 schema UID.
5
+ //
6
+ // The 1600-bit state is 25 lanes of 64 bits, each kept as two 32-bit halves: s[2i] the low half, s[2i + 1] the high.
7
+
8
+ const RC = new Uint32Array([
9
+ // round constants, [low, high] per round
10
+ 0x00000001, 0x00000000, 0x00008082, 0x00000000, 0x0000808a, 0x80000000, 0x80008000, 0x80000000,
11
+ 0x0000808b, 0x00000000, 0x80000001, 0x00000000, 0x80008081, 0x80000000, 0x00008009, 0x80000000,
12
+ 0x0000008a, 0x00000000, 0x00000088, 0x00000000, 0x80008009, 0x00000000, 0x8000000a, 0x00000000,
13
+ 0x8000808b, 0x00000000, 0x0000008b, 0x80000000, 0x00008089, 0x80000000, 0x00008003, 0x80000000,
14
+ 0x00008002, 0x80000000, 0x00000080, 0x80000000, 0x0000800a, 0x00000000, 0x8000000a, 0x80000000,
15
+ 0x80008081, 0x80000000, 0x00008080, 0x80000000, 0x80000001, 0x00000000, 0x80008008, 0x80000000,
16
+ ]);
17
+ // rotation offsets of the rho step, by lane index x + 5y
18
+ const ROT = [0, 1, 62, 28, 27, 36, 44, 6, 55, 20, 3, 10, 43, 25, 39, 41, 45, 15, 21, 8, 18, 2, 61, 56, 14];
19
+ // the lane the pi step moves lane x + 5y to: (y, 2x + 3y)
20
+ const PI = ROT.map((_, i) => {
21
+ const x = i % 5;
22
+ const y = (i - x) / 5;
23
+ return y + 5 * ((2 * x + 3 * y) % 5);
24
+ });
25
+ const RATE = 136; // bytes: 1600 - 2 * 256 bits
26
+
27
+ function permute(s) {
28
+ const c = new Uint32Array(10);
29
+ const b = new Uint32Array(50);
30
+ for (let round = 0; round < 24; round++) {
31
+ // theta
32
+ for (let x = 0; x < 5; x++) {
33
+ c[2 * x] = s[2 * x] ^ s[2 * x + 10] ^ s[2 * x + 20] ^ s[2 * x + 30] ^ s[2 * x + 40];
34
+ c[2 * x + 1] = s[2 * x + 1] ^ s[2 * x + 11] ^ s[2 * x + 21] ^ s[2 * x + 31] ^ s[2 * x + 41];
35
+ }
36
+ for (let x = 0; x < 5; x++) {
37
+ const next = (x + 1) % 5;
38
+ const prev = (x + 4) % 5;
39
+ const lo = c[2 * prev] ^ ((c[2 * next] << 1) | (c[2 * next + 1] >>> 31));
40
+ const hi = c[2 * prev + 1] ^ ((c[2 * next + 1] << 1) | (c[2 * next] >>> 31));
41
+ for (let y = 0; y < 25; y += 5) {
42
+ s[2 * (x + y)] ^= lo;
43
+ s[2 * (x + y) + 1] ^= hi;
44
+ }
45
+ }
46
+ // rho and pi
47
+ for (let i = 0; i < 25; i++) {
48
+ const lo = s[2 * i];
49
+ const hi = s[2 * i + 1];
50
+ const n = ROT[i];
51
+ const j = PI[i];
52
+ if (n === 0) {
53
+ b[2 * j] = lo;
54
+ b[2 * j + 1] = hi;
55
+ } else if (n < 32) {
56
+ b[2 * j] = (lo << n) | (hi >>> (32 - n));
57
+ b[2 * j + 1] = (hi << n) | (lo >>> (32 - n));
58
+ } else if (n === 32) {
59
+ b[2 * j] = hi;
60
+ b[2 * j + 1] = lo;
61
+ } else {
62
+ const m = n - 32;
63
+ b[2 * j] = (hi << m) | (lo >>> (32 - m));
64
+ b[2 * j + 1] = (lo << m) | (hi >>> (32 - m));
65
+ }
66
+ }
67
+ // chi
68
+ for (let y = 0; y < 25; y += 5) {
69
+ for (let x = 0; x < 5; x++) {
70
+ const i = x + y;
71
+ const i1 = ((x + 1) % 5) + y;
72
+ const i2 = ((x + 2) % 5) + y;
73
+ s[2 * i] = b[2 * i] ^ (~b[2 * i1] & b[2 * i2]);
74
+ s[2 * i + 1] = b[2 * i + 1] ^ (~b[2 * i1 + 1] & b[2 * i2 + 1]);
75
+ }
76
+ }
77
+ // iota
78
+ s[0] ^= RC[2 * round];
79
+ s[1] ^= RC[2 * round + 1];
80
+ }
81
+ }
82
+
83
+ /** Keccak-256 of a byte array (or a string, as its UTF-8 bytes), as 32 bytes. */
84
+ export function keccak256(input) {
85
+ const bytes = typeof input === "string" ? new TextEncoder().encode(input) : input;
86
+ const s = new Uint32Array(50);
87
+ const blocks = Math.floor(bytes.length / RATE) + 1;
88
+ const padded = new Uint8Array(blocks * RATE);
89
+ padded.set(bytes);
90
+ padded[bytes.length] ^= 0x01;
91
+ padded[padded.length - 1] ^= 0x80;
92
+ for (let off = 0; off < padded.length; off += RATE) {
93
+ for (let k = 0; k < RATE; k += 4) {
94
+ s[k >> 2] ^= padded[off + k] | (padded[off + k + 1] << 8) | (padded[off + k + 2] << 16) | (padded[off + k + 3] << 24);
95
+ }
96
+ permute(s);
97
+ }
98
+ const out = new Uint8Array(32);
99
+ for (let k = 0; k < 32; k++) out[k] = (s[k >> 2] >>> (8 * (k & 3))) & 0xff;
100
+ return out;
101
+ }
102
+
103
+ /** Keccak-256 as 64 lowercase hex digits, without `0x`. */
104
+ export function keccak256Hex(input) {
105
+ let hex = "";
106
+ for (const b of keccak256(input)) hex += b.toString(16).padStart(2, "0");
107
+ return hex;
108
+ }