@meantjs/profile 0.4.0 → 0.6.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/CHANGELOG.md +31 -0
- package/README.md +11 -4
- package/data/packs/permit2.profile.json +85 -1
- package/data/packs/usdc-base-sepolia.profile.json +301 -0
- package/data/packs/usdc-domain-usdc.profile.json +437 -0
- package/data/packs/usdc.profile.json +421 -0
- package/data/packs/x402-exact-permit2.profile.json +251 -0
- package/data/packs/x402-upto-permit2.profile.json +254 -0
- package/data/profile-v0.schema.json +35 -2
- package/data/profile-v0.validate.mjs +3 -3
- package/package.json +1 -1
- package/src/analyze.mjs +55 -13
- package/src/browser.mjs +2 -1
- package/src/checks.mjs +22 -0
- package/src/resolve.mjs +3 -1
- package/src/served.mjs +6 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meantjs/profile",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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/analyze.mjs
CHANGED
|
@@ -9,7 +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, grantError,
|
|
12
|
+
tokenPathError, grantError, pinError,
|
|
13
13
|
} from "./checks.mjs";
|
|
14
14
|
|
|
15
15
|
/** Spec section 8, highest precedence first. */
|
|
@@ -29,7 +29,9 @@ function buildLayers(entries, keep = () => true) {
|
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
31
|
* @param iface the interface every address resolves against
|
|
32
|
-
* @param items [{ doc, pid, rebinding, apply }]; `apply` marks a pack that matched a service and is applied to it
|
|
32
|
+
* @param items [{ doc, pid, rebinding, bound, apply }]; `apply` marks a pack that matched a service and is applied to it;
|
|
33
|
+
* `bound` is { at, id, given }, what a profile binds to against what was given, when it is not `binds.interface_id`
|
|
34
|
+
* against the interface's id (a contract profile binds by its own ABI, spec section 15.3)
|
|
33
35
|
* @param opts.attribute add `profile` to each diagnostic (multi-profile output)
|
|
34
36
|
* @returns { diagnostics, layered, ordered, stale } `ordered` is items by precedence; `stale` the stale items
|
|
35
37
|
*/
|
|
@@ -44,11 +46,24 @@ export function analyzeProfiles(iface, items, { attribute = true } = {}) {
|
|
|
44
46
|
item.diags.push(d);
|
|
45
47
|
};
|
|
46
48
|
}
|
|
47
|
-
const
|
|
49
|
+
const boundOf = (item) => item.bound ?? { at: "binds.interface_id", id: item.doc.binds.interface_id, given: iface.interfaceId };
|
|
50
|
+
|
|
51
|
+
// A pin (`fixed`, spec 15.4, D-058) holds the value the profile's words were written for. A profile with a pin that
|
|
52
|
+
// does not hold keeps only its effects, without grants, and its pins that hold: never its words without the pin. A
|
|
53
|
+
// pin's problem is always an error, never a pack annotation skipped.
|
|
54
|
+
const unpinned = new Set();
|
|
55
|
+
const pinFailed = (item, code, at, message) => {
|
|
56
|
+
unpinned.add(item);
|
|
57
|
+
item.diag("error", code, at, `${message}; this profile keeps only its effects (without grants) and its pins that hold, since its words were written for the pinned value (spec section 15.4)`);
|
|
58
|
+
};
|
|
59
|
+
const pins = new Map(); // position key -> { value, item, at }: every profile pins a position to one value (spec 15.4)
|
|
60
|
+
const badPins = new Set(); // pin entries that do not hold, left out of the resolved form
|
|
48
61
|
|
|
49
62
|
for (const item of ordered) {
|
|
50
63
|
if (item.rebinding) {
|
|
51
|
-
|
|
64
|
+
const { at, id, given } = boundOf(item);
|
|
65
|
+
const what = at === "binds.abi" ? "ABI" : "interface";
|
|
66
|
+
item.diag("warning", "W_REBOUND", at, `profile is bound to ${id} but the given ${what} is ${given}; checking whether every address still resolves`);
|
|
52
67
|
}
|
|
53
68
|
}
|
|
54
69
|
|
|
@@ -64,16 +79,28 @@ export function analyzeProfiles(iface, items, { attribute = true } = {}) {
|
|
|
64
79
|
let a = original;
|
|
65
80
|
const at = a.at;
|
|
66
81
|
const addr = parseAddress(at);
|
|
67
|
-
|
|
68
|
-
|
|
82
|
+
const pinned = a.tag === "fixed";
|
|
83
|
+
const fail = pinned ? (code, message) => pinFailed(item, code, at, message) : (code, message) => diag("error", code, at, message);
|
|
84
|
+
if (!addr) { fail("E_ADDR_SYNTAX", "address does not parse"); continue; }
|
|
85
|
+
if (doc.kind === "pack" && addr.kind === "type") { fail("E_PACK_TYPE_REF", "packs must use method addresses (declaration names are not standardized)"); continue; }
|
|
69
86
|
const res = iface.resolve(addr);
|
|
70
87
|
if (res.error) {
|
|
71
88
|
// A pack applied to a service that is a narrower subtype (fewer result cases, fewer optional fields) cannot
|
|
72
89
|
// address what is not there. That is expected, not an error in the pack.
|
|
73
|
-
if (item.apply) diag("info", "I_PACK_ANNOTATION_SKIPPED", at, `${a.tag}: ${res.error}`);
|
|
74
|
-
else
|
|
90
|
+
if (item.apply && !pinned) diag("info", "I_PACK_ANNOTATION_SKIPPED", at, `${a.tag}: ${res.error}`);
|
|
91
|
+
else fail(res.code ?? "E_ADDR_UNRESOLVED", res.error);
|
|
75
92
|
continue;
|
|
76
93
|
}
|
|
94
|
+
if (pinned) {
|
|
95
|
+
const prev = pins.get(res.key);
|
|
96
|
+
const value = String(a.args.value).toLowerCase();
|
|
97
|
+
if (prev && prev.value !== value) {
|
|
98
|
+
const where = prev.item === item ? `"${prev.at}"` : `profile "${prev.item.doc.name}" "${prev.at}"`;
|
|
99
|
+
pinFailed(item, "E_CONFLICT", at, `fixed pins this position to ${a.args.value}, where ${where} pins it to another value`);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (!prev) pins.set(res.key, { value, item, at });
|
|
103
|
+
}
|
|
77
104
|
if (addr.kind === "type") {
|
|
78
105
|
const others = (iface.aliases.get(iface.decls.get(addr.decl)) ?? []).filter((n) => n !== addr.decl);
|
|
79
106
|
if (others.length) diag("warning", "W_STRUCTURAL_ALIAS", at, `declaration "${addr.decl}" is structurally identical to ${others.map((n) => `"${n}"`).join(", ")}; the tag reaches every one of them`);
|
|
@@ -252,6 +279,14 @@ export function analyzeProfiles(iface, items, { attribute = true } = {}) {
|
|
|
252
279
|
}
|
|
253
280
|
continue;
|
|
254
281
|
}
|
|
282
|
+
if (a.tag === "fixed") {
|
|
283
|
+
const why = pinError(iface, addr, res, a.args.value);
|
|
284
|
+
if (why) {
|
|
285
|
+
badPins.add(e);
|
|
286
|
+
pinFailed(item, why.code, at, why.message);
|
|
287
|
+
}
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
255
290
|
if (res.method) {
|
|
256
291
|
if (a.tag !== "describe") diag("error", "E_TAG_TARGET", at, `${a.tag} is a value tag; address a value, not a method`);
|
|
257
292
|
continue;
|
|
@@ -326,12 +361,19 @@ export function analyzeProfiles(iface, items, { attribute = true } = {}) {
|
|
|
326
361
|
}
|
|
327
362
|
}
|
|
328
363
|
|
|
329
|
-
// Binding (spec
|
|
364
|
+
// Binding (spec sections 7 and 15.3): a canister or contract profile whose interface changed is stale when anything
|
|
365
|
+
// no longer holds.
|
|
330
366
|
const stale = new Set(ordered.filter((item) => item.rebinding && item.diags.some((d) => STALE_CODES.has(d.code))));
|
|
331
367
|
// A stale profile must not supply value-conversion tags, and a dropped pack annotation is not applied: keep both
|
|
332
|
-
// out of the merged result.
|
|
333
|
-
|
|
334
|
-
const
|
|
368
|
+
// out of the merged result. A profile with a pin that does not hold supplies its effects, without grants, and its
|
|
369
|
+
// pins that hold (spec 15.4, D-058).
|
|
370
|
+
for (const e of entries) {
|
|
371
|
+
if (!unpinned.has(e.item) || e.ann.tag !== "effect" || e.entry.args.grant === undefined) continue;
|
|
372
|
+
const { grant, ...args } = e.entry.args;
|
|
373
|
+
e.entry.args = args;
|
|
374
|
+
}
|
|
375
|
+
const keep = (e) => !dropped.has(e) && !badPins.has(e) && !(stale.has(e.item) && VALUE_CONVERSION_TAGS.has(e.ann.tag)) && !(unpinned.has(e.item) && e.ann.tag !== "effect" && e.ann.tag !== "fixed");
|
|
376
|
+
const final = stale.size || dropped.size || unpinned.size ? buildLayers(entries, keep) : tags;
|
|
335
377
|
|
|
336
378
|
// Overrides: the same tag at the same position in a lower layer (W_OVERRIDDEN, spec section 8).
|
|
337
379
|
if (final.layers.length > 1) {
|
|
@@ -372,7 +414,7 @@ export function analyzeProfiles(iface, items, { attribute = true } = {}) {
|
|
|
372
414
|
}
|
|
373
415
|
|
|
374
416
|
for (const item of stale) {
|
|
375
|
-
item.diag("error", "E_STALE",
|
|
417
|
+
item.diag("error", "E_STALE", boundOf(item).at, "the interface changed and some annotations no longer hold; value-conversion tags must not be used (the resolved form omits this profile's value-conversion tags)");
|
|
376
418
|
}
|
|
377
419
|
return { diagnostics, layered: final, ordered, stale };
|
|
378
420
|
}
|
package/src/browser.mjs
CHANGED
|
@@ -35,7 +35,8 @@ export const PROFILE_SCHEMA = schema;
|
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
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
|
|
38
|
+
* `loadPacks` returns. The standard contract profiles there (Permit2, DAI's permit, USDC under its two domain names,
|
|
39
|
+
* x402's two Permit2 payments) are under `./packs/*` too.
|
|
39
40
|
*/
|
|
40
41
|
export const STANDARD_PACKS = Object.freeze([
|
|
41
42
|
{ file: "erc20.profile.json", doc: erc20 },
|
package/src/checks.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Per-annotation semantic checks (spec sections 3 to 6). Moved verbatim from scripts/validate.mjs.
|
|
3
3
|
*/
|
|
4
|
+
import { AbiError, readAddress } from "./abi.mjs";
|
|
4
5
|
import { parseRelPath } from "./address.mjs";
|
|
5
6
|
|
|
6
7
|
/** EVM integer types (spec section 15): uint8 to uint256 and int8 to int256. int8 to int64 are also Candid's, with the same bounds. */
|
|
@@ -20,6 +21,24 @@ export const CONFIRM_KINDS = new Set(["transfer", "approve", "admin", "destroy"]
|
|
|
20
21
|
export const VALUE_CONVERSION_TAGS = new Set(["amount", "timestamp", "duration", "account", "subaccount", "bytes", "named"]);
|
|
21
22
|
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", "E_GRANT"]);
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Why a pin (`fixed`, spec 15.4, D-058) does not hold, as `{ code, message }`, or null. A pin sits on an `address` in the
|
|
26
|
+
* arguments of an EVM function or message, where a consumer reads it from what is sent or signed before any text, and
|
|
27
|
+
* its value is an address (§15.2: in mixed case, with its EIP-55 checksum).
|
|
28
|
+
*/
|
|
29
|
+
export function pinError(iface, addr, res, value) {
|
|
30
|
+
if (res.method || addr.kind !== "arg") return { code: "E_TAG_TARGET", message: "fixed pins a value in the arguments of a function or a message, where a consumer reads it before it shows text" };
|
|
31
|
+
const wrong = checkApplicability(iface, "fixed", res.ref);
|
|
32
|
+
if (wrong) return { code: "E_TAG_NOT_APPLICABLE", message: wrong };
|
|
33
|
+
try {
|
|
34
|
+
readAddress(String(value));
|
|
35
|
+
} catch (e) {
|
|
36
|
+
if (!(e instanceof AbiError)) throw e;
|
|
37
|
+
return { code: "E_ADDRESS_CHECKSUM", message: `fixed: ${e.message}` };
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
23
42
|
export function checkApplicability(iface, tag, ref) {
|
|
24
43
|
const { ref: r } = iface.unwrapOpt(ref);
|
|
25
44
|
const p = iface.prim(r);
|
|
@@ -33,6 +52,9 @@ export function checkApplicability(iface, tag, ref) {
|
|
|
33
52
|
case "bytes": return iface.isBlob(r) ? null : `${tag} needs ${iface.chain === "evm" ? "bytes or bytesN" : "blob"}, found ${iface.describeType(ref)}`;
|
|
34
53
|
case "text": return TEXT.has(p) ? null : `text needs ${iface.chain === "evm" ? "string" : "text"}, found ${iface.describeType(ref)}`;
|
|
35
54
|
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)}`;
|
|
55
|
+
case "fixed":
|
|
56
|
+
if (iface.chain !== "evm") return "fixed pins an EVM address; a Candid position cannot carry it yet (spec section 15.4)";
|
|
57
|
+
return p === "address" ? null : `fixed needs an address, found ${iface.describeType(ref)}`;
|
|
36
58
|
default: return null;
|
|
37
59
|
}
|
|
38
60
|
}
|
package/src/resolve.mjs
CHANGED
|
@@ -141,7 +141,9 @@ async function resolveWith(iface, opts, diagnostics, statuses, rebuild = null) {
|
|
|
141
141
|
continue;
|
|
142
142
|
}
|
|
143
143
|
checkDeployments(doc, note);
|
|
144
|
-
|
|
144
|
+
// It binds by the identity of its own ABI (spec section 15.1), compared with the given ABI's functions only.
|
|
145
|
+
const bound = doc.binds.abi === undefined ? null : { at: "binds.abi", id: abiInterfaceId(own.functions), given: iface.abiId };
|
|
146
|
+
items.push({ doc, pid, rebinding: bound !== null && bound.id !== bound.given, bound, apply: false, status, eip712 });
|
|
145
147
|
} else if (doc.kind === "pack") {
|
|
146
148
|
const m = await matchPack(iface, doc);
|
|
147
149
|
if (m.error) {
|
package/src/served.mjs
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
* A profile served by its canister (spec section 8.1, D-048): the public custom section `icp:public candid:profile`,
|
|
3
3
|
* read by a consumer through a certified `read_state` at `/canister/<id>/metadata/candid:profile`.
|
|
4
4
|
*
|
|
5
|
-
* This module reads the section's bytes; it does not fetch them, so the library keeps no agent dependency (the
|
|
6
|
-
*
|
|
7
|
-
* JSON of at most 1 MiB, the document is a `canister` profile of origin `author`,
|
|
8
|
-
* given, is the canister that served it. Everything else (the schema, the binding to
|
|
9
|
-
* serves as `candid:service`, every tag) is `resolveService`'s, as for any profile.
|
|
5
|
+
* This module reads the section's bytes; it does not fetch them, so the library keeps no agent dependency (the caller
|
|
6
|
+
* does the network part, for example with `@icp-sdk/core`'s `readState`). What it checks is what only a served profile
|
|
7
|
+
* can get wrong: the bytes are UTF-8 JSON of at most 1 MiB, the document is a `canister` profile of origin `author`,
|
|
8
|
+
* and its `binds.canister_id`, when given, is the canister that served it. Everything else (the schema, the binding to
|
|
9
|
+
* the interface the same canister serves as `candid:service`, every tag) is `resolveService`'s, as for any profile.
|
|
10
10
|
*
|
|
11
11
|
* New diagnostic code (spec section 13): E_SERVED.
|
|
12
12
|
*/
|
|
@@ -23,7 +23,7 @@ const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
|
23
23
|
/**
|
|
24
24
|
* @param bytes the content of the canister's `candid:profile` metadata (a Uint8Array)
|
|
25
25
|
* @param opts.canisterId the textual id of the canister that served it; null only before the module is installed
|
|
26
|
-
* anywhere (
|
|
26
|
+
* anywhere (a section added to a built module), which leaves `binds.canister_id` unchecked
|
|
27
27
|
* @param opts.label how diagnostics name the input (default "candid:profile")
|
|
28
28
|
* @returns { ok, doc, diagnostics } `doc` is the parsed profile, or null when it cannot be used (E_SERVED)
|
|
29
29
|
*/
|