@meantjs/profile 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/analyze.mjs CHANGED
@@ -7,8 +7,9 @@ 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
+ tokenPathError,
12
13
  } from "./checks.mjs";
13
14
 
14
15
  /** Spec section 8, highest precedence first. */
@@ -107,6 +108,45 @@ export function analyzeProfiles(iface, items, { attribute = true } = {}) {
107
108
  // A pack applied to a service is allowed to differ from its fragment in the ways Candid subtyping allows, so an
108
109
  // annotation that no longer holds on this service is dropped with an info, not reported as an error in the pack.
109
110
  const dropped = new Set();
111
+ const caseEntries = []; // case intents and effects, checked against each other and their method after the loop
112
+
113
+ /** An effect against the method's mode (spec 5.3); for a case effect, the mode of the method it sits in. */
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
+ }
120
+ if (m.mode !== "update" && m.mode !== "oneway" && kind !== "read") diag("error", "E_EFFECT_MODE", at, `${m.mode} method cannot have effect kind "${kind}"`);
121
+ // A read-only update (D-038): a read sent as an update so that its answer goes through consensus.
122
+ if (replicated === true) {
123
+ if (m.mode !== "update") diag("error", "E_EFFECT_MODE", at, `replicated: true applies to update methods, not to a ${m.mode} method`);
124
+ 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}`);
125
+ }
126
+ const replicatedRead = replicated === true && m.mode === "update" && kind === "read" && irreversible === false;
127
+ if ((m.mode === "update" || m.mode === "oneway") && kind === "read" && !replicatedRead) {
128
+ 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" : ""}`);
129
+ }
130
+ };
131
+
132
+ /** An intent template's placeholders (spec section 6); `caseAddr` is the case a case intent sits on. */
133
+ const checkTemplate = (item, methodName, template, diag, at, caseAddr) => {
134
+ const t = parseTemplate(template);
135
+ if (t.error) return diag("error", "E_TEMPLATE", at, t.error);
136
+ 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)");
137
+ for (const p of t.placeholders) {
138
+ const r = iface.resolveRel(methodName, "args", p.path);
139
+ if (r.error && item.apply && p.inOptional) { // absent in this service: the optional segment renders empty (spec section 6)
140
+ item.diag("info", "I_PACK_ANNOTATION_SKIPPED", at, `intent: {${p.path}} is absent in this service (${r.error}); its optional segment renders empty`);
141
+ continue;
142
+ }
143
+ if (r.error) { diag("error", "E_TEMPLATE_PATH", at, `{${p.path}}: ${r.error}`); continue; }
144
+ const other = caseAddr && otherCase(iface, caseAddr, p.path);
145
+ if (other) { diag("error", "E_TEMPLATE_PATH", at, `{${p.path}}: ${other}`); continue; }
146
+ if (!p.inOptional && iface.node(r.ref).kind === "opt") diag("warning", "W_TEMPLATE_OPTIONAL", at, `{${p.path}} is optional but sits outside a [ ] segment`);
147
+ }
148
+ };
149
+
110
150
  const sets = new Map(); // names set -> { at, method, fields: { name, value, show?, kind? } } (spec 5.3, D-045)
111
151
  const named = []; // named positions, checked once every set is known
112
152
  for (const e of entries) {
@@ -118,22 +158,24 @@ export function analyzeProfiles(iface, items, { attribute = true } = {}) {
118
158
  item.diag("info", "I_PACK_ANNOTATION_SKIPPED", at, `${a.tag} dropped: ${code}: ${message}`);
119
159
  };
120
160
  const at = a.at;
161
+ if (iface.chain === "evm" && EVM_LATER_TAGS.has(a.tag)) {
162
+ 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)`);
163
+ continue;
164
+ }
165
+ // intent and effect on a case of a variant in the arguments (spec 5.2, D-053)
166
+ if (CASE_TAGS.has(a.tag) && !res.method) {
167
+ const why = caseTargetError(iface, addr, res, a.tag);
168
+ if (why) { diag("error", "E_TAG_TARGET", at, why); continue; }
169
+ const m = iface.methods.get(addr.method);
170
+ if (a.tag === "effect") checkEffectMode(m, a.args, diag, at);
171
+ else checkTemplate(item, addr.method, a.args.template, diag, at, addr);
172
+ caseEntries.push({ e, diag });
173
+ continue;
174
+ }
121
175
  if (METHOD_TAGS.has(a.tag)) {
122
176
  const m = res.method;
123
177
  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
- }
178
+ if (a.tag === "effect") checkEffectMode(m, a.args, diag, at);
137
179
  if (a.tag === "idempotency") {
138
180
  if (m.mode !== "update") diag("error", "E_IDEMPOTENCY_MODE", at, "idempotency applies to update methods");
139
181
  const r = iface.resolveRel(methodName, "args", a.args.stamp);
@@ -153,19 +195,7 @@ export function analyzeProfiles(iface, items, { attribute = true } = {}) {
153
195
  outcomeCoverage(iface, methodName, classes, diag, at, Boolean(a.args.default));
154
196
  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
197
  }
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
- }
198
+ if (a.tag === "intent") checkTemplate(item, methodName, a.args.template, diag, at);
169
199
  if (a.tag === "names") {
170
200
  const effect = tags.methodTags(methodName).effect?.args;
171
201
  const replicatedRead = m.mode === "update" && effect?.kind === "read" && effect.irreversible === false && effect.replicated === true;
@@ -210,7 +240,10 @@ export function analyzeProfiles(iface, items, { attribute = true } = {}) {
210
240
  const err = checkApplicability(iface, a.tag, res.ref);
211
241
  if (err) diag("error", "E_TAG_NOT_APPLICABLE", at, err);
212
242
  if (a.tag === "named") named.push({ a, res, diag, at });
213
- 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") {
214
247
  checkDynamic(iface, a.args.decimals, "numeric", diag, at);
215
248
  checkDynamic(iface, a.args.symbol, "text", diag, at);
216
249
  }
@@ -234,6 +267,28 @@ export function analyzeProfiles(iface, items, { attribute = true } = {}) {
234
267
  }
235
268
  }
236
269
 
270
+ // Tagged cases of one method must not overlap, and a case effect needs a method effect at least as strong
271
+ // (spec 5.2 and 5.3, D-053).
272
+ for (let j = 0; j < caseEntries.length; j++) {
273
+ const { e, diag } = caseEntries[j];
274
+ const { addr, ann: a } = e;
275
+ for (const { e: prev } of caseEntries.slice(0, j)) {
276
+ if (prev.ann.tag !== a.tag || prev.addr.method !== addr.method || prev.res.key === e.res.key) continue;
277
+ const why = overlap(iface, prev.addr, addr);
278
+ 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})`);
279
+ }
280
+ if (a.tag !== "effect") continue;
281
+ const m = iface.methods.get(addr.method);
282
+ const declared = tags.methodTags(addr.method).effect?.args;
283
+ const method = declared ?? defaultEffect(m.mode);
284
+ const whose = `the method's effect is ${method.kind}${method.irreversible ? ", irreversible" : ""}${declared ? "" : " (the default)"}`;
285
+ if (CONFIRM_KINDS.has(a.args.kind) && !CONFIRM_KINDS.has(method.kind)) {
286
+ 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`);
287
+ } else if (a.args.irreversible === true && method.irreversible !== true) {
288
+ diag("error", "E_CASE_EFFECT", a.at, `case effect is irreversible, but ${whose}; a method with an irreversible case is irreversible`);
289
+ }
290
+ }
291
+
237
292
  // Dropping a value tag can leave an idempotency stamp without its timestamp tag: drop that idempotency too.
238
293
  if (dropped.size) {
239
294
  const without = buildLayers(entries, (e) => !dropped.has(e));
@@ -280,10 +335,11 @@ export function analyzeProfiles(iface, items, { attribute = true } = {}) {
280
335
  }
281
336
  }
282
337
 
283
- // 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).
284
340
  const MEANING = ["amount", "timestamp", "duration", "describe", "range"];
285
341
  for (const [name, m] of iface.methods) {
286
- if (m.mode !== "update") continue;
342
+ if (m.mode !== "update" && m.mode !== "message") continue;
287
343
  for (const pos of positions(iface, name, "args")) {
288
344
  const p = iface.prim(iface.unwrapOpt(pos.ref).ref);
289
345
  const t = final.at(pos, iface);
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,8 @@
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" };
14
+ import erc2612 from "../data/packs/erc2612.profile.json" with { type: "json" };
13
15
  import { compileSchema } from "./schema-core.mjs";
14
16
 
15
17
  export { init as initCompiler } from "@candid-core/cli";
@@ -31,8 +33,13 @@ export { readServedProfile, SERVED_SECTION, SERVED_METADATA, SERVED_MAX_BYTES }
31
33
  /** The profile schema (a copy of spec/profile-v0.schema.json). */
32
34
  export const PROFILE_SCHEMA = schema;
33
35
 
34
- /** 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
+ */
35
40
  export const STANDARD_PACKS = Object.freeze([
41
+ { file: "erc20.profile.json", doc: erc20 },
42
+ { file: "erc2612.profile.json", doc: erc2612 },
36
43
  { file: "icrc1.profile.json", doc: icrc1 },
37
44
  { file: "icrc2.profile.json", doc: icrc2 },
38
45
  ]);
package/src/checks.mjs CHANGED
@@ -1,29 +1,62 @@
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", "E_TOKEN_PATH"]);
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
  }
26
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
+
27
60
  export function checkDynamic(iface, value, want, diag, at) {
28
61
  if (!value || typeof value !== "object") return;
29
62
  const from = value.from;
@@ -39,17 +72,18 @@ export function checkDynamic(iface, value, want, diag, at) {
39
72
  else {
40
73
  const p = iface.prim(iface.unwrapOpt(m.results[0]).ref);
41
74
  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`);
75
+ if (want === "text" && !TEXT.has(p)) diag("error", "E_REF_TYPE", at, `"${from}" returns ${p}, expected text`);
43
76
  }
44
77
  }
45
78
 
46
79
  export function parseTemplate(t) {
47
80
  const placeholders = [];
48
81
  let depth = 0;
82
+ let segments = 0;
49
83
  for (let i = 0; i < t.length; i++) {
50
84
  const c = t[i];
51
85
  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; }
86
+ if (c === "[") { if (depth) return { error: "nested [ ] segments are not allowed in v0" }; depth = 1; segments++; }
53
87
  else if (c === "]") { if (!depth) return { error: "unbalanced ]" }; depth = 0; }
54
88
  else if (c === "{") {
55
89
  const end = t.indexOf("}", i);
@@ -59,7 +93,7 @@ export function parseTemplate(t) {
59
93
  } else if (c === "}") return { error: "unbalanced }" };
60
94
  }
61
95
  if (depth) return { error: "unclosed [" };
62
- return { placeholders };
96
+ return { placeholders, segments };
63
97
  }
64
98
 
65
99
  export function outcomeCoverage(iface, methodName, classes, diag, at, hasDefault) {
@@ -79,3 +113,62 @@ export function outcomeCoverage(iface, methodName, classes, diag, at, hasDefault
79
113
  };
80
114
  visit(m.results[0], "");
81
115
  }
116
+
117
+ /** The effect a method has without an effect tag (spec 5.3): queries read, updates write irreversibly. */
118
+ export function defaultEffect(mode) {
119
+ return mode === "query" || mode === "composite_query" ? { kind: "read", irreversible: false } : { kind: "write", irreversible: true };
120
+ }
121
+
122
+ /** Why an intent or effect at a value address is not on a case of an argument (spec 5.2, D-053), or null. */
123
+ export function caseTargetError(iface, addr, res, tag) {
124
+ 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)`;
125
+ if (addr.kind === "result") return `${tag} sits on a method or on a case of a variant in its arguments, not in its results`;
126
+ if (addr.segs.some((s) => s.kind === "elem")) return `${tag} on a case cannot step into a vector element ([]): one call holds many elements`;
127
+ 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"}`;
128
+ return null;
129
+ }
130
+
131
+ /**
132
+ * Walk two segment lists of one method argument side by side. Returns where they part: { kind } of the container at
133
+ * the first differing step, with the two labels, or null when one is a prefix of the other.
134
+ */
135
+ function part(iface, start, a, b) {
136
+ let cur = start;
137
+ for (let k = 0; k < Math.min(a.length, b.length); k++) {
138
+ cur = iface.unwrapOpt(cur).ref;
139
+ const n = iface.node(cur);
140
+ const x = a[k];
141
+ const y = b[k];
142
+ if (x.kind === "elem" || y.kind === "elem") {
143
+ if (x.kind === y.kind) { cur = n.inner; continue; }
144
+ return { kind: n.kind };
145
+ }
146
+ if (x.id !== y.id) return { kind: n.kind, a: x.label, b: y.label };
147
+ cur = n.fields.find((f) => f.id === x.id).type;
148
+ }
149
+ return null;
150
+ }
151
+
152
+ /**
153
+ * A case template's placeholder that selects another case of a variant the case address passes through, so it can
154
+ * never be present (spec section 6, D-053): the reason, or null. Both paths resolve; `rel` is a method-tuple relpath.
155
+ */
156
+ export function otherCase(iface, caseAddr, rel) {
157
+ const p = parseRelPath(rel);
158
+ const index = p.index ?? 0;
159
+ if (index !== caseAddr.index) return null;
160
+ const m = iface.methods.get(caseAddr.method);
161
+ const at = part(iface, m.args[index], caseAddr.segs, p.segs);
162
+ 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;
163
+ }
164
+
165
+ /**
166
+ * Whether one call can take two tagged cases of one method (spec 5.2, D-053): the reason, or null when they exclude
167
+ * each other (they part at a variant) or nest.
168
+ */
169
+ export function overlap(iface, a, b) {
170
+ if (a.index !== b.index) return "they sit in different arguments";
171
+ const m = iface.methods.get(a.method);
172
+ const at = part(iface, m.args[a.index], a.segs, b.segs);
173
+ return at && at.kind !== "variant" ? "their paths part at a record field" : null;
174
+ }
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,101 @@
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_EIP712, 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
+ /**
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;
70
+ export function canonicalFragment(f: Pick<AbiFunction, "name" | "inputs" | "outputs" | "stateMutability">): string;
71
+ export function namedSignature(f: Pick<AbiFunction, "name" | "inputs">): string;
72
+ export function typeSignature(f: Pick<AbiFunction, "name" | "inputs">): string;
73
+ export function selectorOf(f: Pick<AbiFunction, "name" | "inputs">): string;
74
+ /** Canonical type text; with `names`, tuple components keep their names. */
75
+ export function typeString(type: AbiType, names?: boolean): string;
76
+ /** A tuple component's key in values and resolved forms: its name, else `#` and its position. */
77
+ export function componentKey(param: AbiParam, index: number): string;
78
+ /** ABI-encode a parameter list's values, keyed like a tuple. Throws AbiError (E_ABI_ENCODE). */
79
+ export function encodeParams(params: readonly AbiParam[], values: Record<string, AbiValue>): Uint8Array;
80
+ /** The selector followed by the encoded parameters. */
81
+ export function encodeCall(f: AbiFunction, values: Record<string, AbiValue>): Uint8Array;
82
+ /** Decode strictly: the bytes must be the canonical encoding of the values read. Throws AbiError (E_ABI_DECODE). */
83
+ export function decodeParams(params: readonly AbiParam[], data: Uint8Array, limits?: AbiLimits): Record<string, AbiValue>;
84
+ /** The EIP-55 checksummed form of `0x` and 40 hex digits. */
85
+ export function toChecksumAddress(address: string): string;
86
+ /** Read an address: mixed case must match its EIP-55 checksum. Returns the checksummed form. */
87
+ export function readAddress(text: string): string;
88
+ /** The EIP-712 digest a signer signs, as `0x` and 64 hex digits. */
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 ADDED
@@ -0,0 +1,32 @@
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, 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.
6
+ *
7
+ * test/evm.test.mjs checks the vectors; test/address-entry.test.mjs keeps this entry free of node:* imports.
8
+ */
9
+ export { keccak256, keccak256Hex } from "./keccak.mjs";
10
+ export {
11
+ AbiError,
12
+ DEFAULT_ABI_LIMITS,
13
+ abiInterfaceId,
14
+ canonicalFragment,
15
+ componentKey,
16
+ decodeParams,
17
+ eip712TypeHash,
18
+ encodeCall,
19
+ encodeParams,
20
+ encodeType,
21
+ namedSignature,
22
+ parseAbi,
23
+ parseEip712Types,
24
+ parseSignature,
25
+ readAddress,
26
+ selectorOf,
27
+ toChecksumAddress,
28
+ typeSignature,
29
+ typeString,
30
+ typedDataDigest,
31
+ } from "./abi.mjs";
32
+ export { jcs } from "./util.mjs";