@meantjs/consent 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/LICENSE +202 -0
- package/README.md +246 -2
- package/package.json +74 -4
- package/src/bytes.mjs +32 -0
- package/src/consent.mjs +42 -0
- package/src/decode.mjs +140 -0
- package/src/display.mjs +248 -0
- package/src/erc7730.d.mts +81 -0
- package/src/erc7730.mjs +304 -0
- package/src/errors.mjs +54 -0
- package/src/evm.d.mts +51 -0
- package/src/evm.mjs +129 -0
- package/src/icrc21-codec.mjs +160 -0
- package/src/icrc21.mjs +139 -0
- package/src/index.d.mts +236 -0
- package/src/index.mjs +13 -0
- package/src/model.mjs +165 -0
- package/src/paths.mjs +74 -0
- package/src/render.mjs +104 -0
- package/src/resolved.mjs +102 -0
- package/src/sns.d.mts +54 -0
- package/src/sns.mjs +293 -0
- package/src/template.mjs +69 -0
- package/src/text.mjs +74 -0
package/src/consent.mjs
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// resolved form + method + Candid argument bytes -> a consent message, without the ICRC-21 envelope.
|
|
2
|
+
// `icrc21.mjs` wraps this into the ICRC-21 request/response shapes.
|
|
3
|
+
|
|
4
|
+
import { Renderer } from "./display.mjs";
|
|
5
|
+
import { buildModel } from "./model.mjs";
|
|
6
|
+
import { renderFields, renderGeneric } from "./render.mjs";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {object} input
|
|
10
|
+
* @param {object} input.resolved a resolved form (docs/profile-v0.md section 10)
|
|
11
|
+
* @param {string} input.method the method being called
|
|
12
|
+
* @param {Uint8Array | ArrayBuffer | number[]} input.arg the Candid-encoded argument tuple
|
|
13
|
+
* @param {Record<string, unknown>} [input.refs] resolved dynamic refs, for example { "query:icrc1_decimals": 8, "query:icrc1_symbol": "ICP" }
|
|
14
|
+
* @param {"fields" | "generic"} [input.form] default "generic"
|
|
15
|
+
* @param {number | null} [input.utcOffsetMinutes] generic form only: show timestamps in this zone (default 0, UTC). The fields form is always UTC seconds
|
|
16
|
+
* @param {boolean} [input.rawArguments] append the decoded raw arguments (spec section 11 item 1)
|
|
17
|
+
* @param {object} [input.limits] decoder limits (maxBytes, maxDepth, maxElements)
|
|
18
|
+
* @returns {{ form: "fields" | "generic", message: object, zoneSensitive: boolean, diagnostics: object[], provenance: object, model: object }}
|
|
19
|
+
* `message` is `{ FieldsDisplayMessage: { intent, fields } }` or `{ GenericDisplayMessage: string }` (ICRC-21 shapes,
|
|
20
|
+
* integers as bigint). `zoneSensitive` is true when a timestamp was shown in the requested zone.
|
|
21
|
+
*/
|
|
22
|
+
export function generateConsent({ resolved, method, arg, refs = {}, form = "generic", utcOffsetMinutes = null, rawArguments = false, limits }) {
|
|
23
|
+
const model = buildModel({ resolved, method, arg, limits });
|
|
24
|
+
const diagnostics = model.diagnostics;
|
|
25
|
+
const done = (rest) => ({ ...rest, diagnostics: dedupe(diagnostics), provenance: model.provenance, model });
|
|
26
|
+
if (form === "fields") {
|
|
27
|
+
const renderer = new Renderer({ refs, utcOffsetMinutes: null, diagnostics });
|
|
28
|
+
const { intent, fields } = renderFields(model, renderer, { rawArguments });
|
|
29
|
+
return done({ form, message: { FieldsDisplayMessage: { intent, fields } }, zoneSensitive: false });
|
|
30
|
+
}
|
|
31
|
+
const renderer = new Renderer({ refs, utcOffsetMinutes: utcOffsetMinutes ?? 0, diagnostics });
|
|
32
|
+
const text = renderGeneric(model, renderer, { rawArguments });
|
|
33
|
+
return done({ form: "generic", message: { GenericDisplayMessage: text }, zoneSensitive: renderer.usedZone });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function dedupe(diagnostics) {
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
return diagnostics.filter((d) => {
|
|
39
|
+
const key = `${d.code}\u0000${d.at}\u0000${d.message}`;
|
|
40
|
+
return seen.has(key) ? false : (seen.add(key), true);
|
|
41
|
+
});
|
|
42
|
+
}
|
package/src/decode.mjs
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Step 1: Candid argument bytes -> a value tree aligned with the resolved form's positions.
|
|
2
|
+
//
|
|
3
|
+
// Codec: @candid-core/schema 0.5.0 `decodeArgs` (schema-directed, with the Candid coercion relation and explicit
|
|
4
|
+
// resource budgets). The expected schema is built from the method's resolved-form tree, so the decoder never
|
|
5
|
+
// trusts the types the bytes claim: an argument is read as the signature says or the call is refused.
|
|
6
|
+
//
|
|
7
|
+
// Value tree (lossless; the same shape at every position of the resolved form):
|
|
8
|
+
// nat, int, nat8..nat64, int8..int64 decimal string (D-006: never a JavaScript number)
|
|
9
|
+
// float32, float64 number
|
|
10
|
+
// bool, text boolean, string
|
|
11
|
+
// null, reserved null
|
|
12
|
+
// principal canonical text
|
|
13
|
+
// blob (vec nat8) Uint8Array
|
|
14
|
+
// opt T null when absent, else the value of T
|
|
15
|
+
// record plain object keyed exactly like the resolved form's `fields`
|
|
16
|
+
// variant { tag: <key of the resolved form's `cases`>, value } (value null for a bare tag)
|
|
17
|
+
// vec T array
|
|
18
|
+
//
|
|
19
|
+
// Limits of the resolved form, refused (E_UNSUPPORTED_TYPE) rather than guessed:
|
|
20
|
+
// - a value that reaches a position with `recursive: true` (the resolver cut a cycle or a depth of 8 there; the type
|
|
21
|
+
// below is unknown). The position is decoded as `reserved`, which every Candid type is a subtype of, so the bytes
|
|
22
|
+
// there are skipped without trusting their type; a call whose value lies there (the case is chosen, the `opt` is
|
|
23
|
+
// present, the field exists, the `vec` has an element) is refused when the value tree is built. A call whose value
|
|
24
|
+
// never goes there gets its message (spec section 12, rule 12; D-042);
|
|
25
|
+
// - `func` and `service` positions anywhere in the method (the resolved form does not carry their signatures);
|
|
26
|
+
// - nested `opt` (`opt opt T` collapses to one `optional` flag in the resolved form).
|
|
27
|
+
|
|
28
|
+
import { c } from "@candid-core/schema";
|
|
29
|
+
import { decodeArgs } from "@candid-core/schema/codec";
|
|
30
|
+
import { bytesOf } from "./bytes.mjs";
|
|
31
|
+
import { CODES, fail } from "./errors.mjs";
|
|
32
|
+
import { entriesOf } from "./resolved.mjs";
|
|
33
|
+
|
|
34
|
+
/** A consent message is for one ingress message at most (IC limit: 2 MiB). */
|
|
35
|
+
export const DEFAULT_LIMITS = Object.freeze({ maxBytes: 2 * 1024 * 1024, maxDepth: 64, maxElements: 100_000 });
|
|
36
|
+
|
|
37
|
+
const INTEGERS = new Set(["nat", "int", "nat8", "nat16", "nat32", "nat64", "int8", "int16", "int32", "int64"]);
|
|
38
|
+
const FLOATS = new Set(["float32", "float64"]);
|
|
39
|
+
const PRIMITIVES = new Set(["null", "bool", "text", "principal", "reserved", "empty", ...INTEGERS, ...FLOATS]);
|
|
40
|
+
const MAX_NODE_DEPTH = 64;
|
|
41
|
+
|
|
42
|
+
/** The codec's name for a resolved-form key: `#123` has no name, so the codec's `_123_` convention carries the id. */
|
|
43
|
+
const codecName = (key) => (/^#(0|[1-9][0-9]{0,9})$/.test(key) ? `_${key.slice(1)}_` : key);
|
|
44
|
+
|
|
45
|
+
const NO_OPT_BOX = new Set(["null", "reserved"]); // `opt null` and `opt reserved` are boxed by the codec
|
|
46
|
+
|
|
47
|
+
/** Build the codec schema for one position of the resolved form. */
|
|
48
|
+
export function schemaFor(node, where = "argument", depth = 0) {
|
|
49
|
+
if (node === null || typeof node !== "object" || typeof node.candid !== "string") {
|
|
50
|
+
fail(CODES.RESOLVED_FORM, `${where}: not a resolved-form position`);
|
|
51
|
+
}
|
|
52
|
+
if (depth > MAX_NODE_DEPTH) fail(CODES.UNSUPPORTED_TYPE, `${where}: nested deeper than ${MAX_NODE_DEPTH}`);
|
|
53
|
+
const inner = node.recursive === true ? c.reserved : innerSchema(node, where, depth);
|
|
54
|
+
return node.optional === true ? c.opt(inner) : inner;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function innerSchema(node, where, depth) {
|
|
58
|
+
const kind = node.candid;
|
|
59
|
+
if (PRIMITIVES.has(kind)) return c[kind];
|
|
60
|
+
switch (kind) {
|
|
61
|
+
case "blob":
|
|
62
|
+
return c.blob();
|
|
63
|
+
case "vec": {
|
|
64
|
+
if (node.element === undefined) fail(CODES.UNSUPPORTED_TYPE, `${where}: vec without an element type`);
|
|
65
|
+
return c.vec(schemaFor(node.element, `${where}[]`, depth + 1));
|
|
66
|
+
}
|
|
67
|
+
case "record": {
|
|
68
|
+
const fields = Object.create(null);
|
|
69
|
+
for (const [key, child] of entriesOf(node.fields)) fields[codecName(key)] = schemaFor(child, `${where}.${key}`, depth + 1);
|
|
70
|
+
return c.record(fields);
|
|
71
|
+
}
|
|
72
|
+
case "variant": {
|
|
73
|
+
const arms = Object.create(null);
|
|
74
|
+
for (const [key, child] of entriesOf(node.cases)) arms[codecName(key)] = schemaFor(child, `${where}.${key}`, depth + 1);
|
|
75
|
+
return c.variant(arms);
|
|
76
|
+
}
|
|
77
|
+
default:
|
|
78
|
+
fail(CODES.UNSUPPORTED_TYPE, `${where}: ${kind} positions cannot be decoded (the resolved form carries no signature for them)`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const hasOwn = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
|
|
83
|
+
|
|
84
|
+
/** Own enumerable property, also for a key like "__proto__" (assignment would set the prototype instead). */
|
|
85
|
+
export function setOwn(obj, key, value) {
|
|
86
|
+
Object.defineProperty(obj, key, { value, enumerable: true, writable: true, configurable: true });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Codec value -> value tree. `node` is the resolved-form position the schema was built from. */
|
|
90
|
+
export function toTree(node, v, where = "argument") {
|
|
91
|
+
if (node.recursive === true) {
|
|
92
|
+
if (node.optional === true && v === null) return null;
|
|
93
|
+
fail(CODES.UNSUPPORTED_TYPE, `${where}: the resolved form cuts this ${node.candid} at a cycle (recursive: true), so its type below is unknown`);
|
|
94
|
+
}
|
|
95
|
+
if (node.optional === true) {
|
|
96
|
+
if (v === null) return null;
|
|
97
|
+
if (NO_OPT_BOX.has(node.candid)) v = v.some; // boxed opt null / opt reserved
|
|
98
|
+
}
|
|
99
|
+
const kind = node.candid;
|
|
100
|
+
if (INTEGERS.has(kind)) return String(v);
|
|
101
|
+
if (FLOATS.has(kind) || kind === "bool" || kind === "text" || kind === "principal" || kind === "blob") return v;
|
|
102
|
+
if (kind === "null" || kind === "reserved") return null;
|
|
103
|
+
if (kind === "vec") return v.map((e, i) => toTree(node.element, e, `${where}[${i}]`));
|
|
104
|
+
if (kind === "record") {
|
|
105
|
+
const out = {};
|
|
106
|
+
for (const [key, child] of entriesOf(node.fields)) setOwn(out, key, toTree(child, v[codecName(key)], `${where}.${key}`));
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
if (kind === "variant") {
|
|
110
|
+
const cases = entriesOf(node.cases);
|
|
111
|
+
const hit = cases.find(([key]) => codecName(key) === v.tag);
|
|
112
|
+
if (hit === undefined) fail(CODES.DECODE, `${where}: the decoder returned the case ${JSON.stringify(v.tag)}, which the resolved form lacks`);
|
|
113
|
+
return { tag: hit[0], value: hasOwn(v, "value") ? toTree(hit[1], v.value, `${where}.${hit[0]}`) : null };
|
|
114
|
+
}
|
|
115
|
+
fail(CODES.UNSUPPORTED_TYPE, `${where}: cannot convert ${kind}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Decode the Candid-encoded argument tuple of a call against a resolved-form method.
|
|
120
|
+
*
|
|
121
|
+
* @param {{ args: object[] }} method an entry of `resolved.methods`
|
|
122
|
+
* @param {Uint8Array | ArrayBuffer | number[]} arg the bytes of the call's argument (the `arg` of the ICRC-21 request)
|
|
123
|
+
* @param {{ maxBytes?: number, maxDepth?: number, maxElements?: number }} [limits]
|
|
124
|
+
* @returns {unknown[]} one value tree per argument, in order
|
|
125
|
+
* @throws {ConsentError} E_DECODE, E_TOO_LARGE, E_UNSUPPORTED_TYPE, E_RESOLVED_FORM
|
|
126
|
+
*/
|
|
127
|
+
export function decodeArguments(method, arg, limits = {}) {
|
|
128
|
+
const bytes = bytesOf(arg);
|
|
129
|
+
if (bytes === null) fail(CODES.DECODE, "the argument is not bytes: a Uint8Array, an ArrayBuffer or an array of integers 0 to 255");
|
|
130
|
+
const opts = { ...DEFAULT_LIMITS, ...limits };
|
|
131
|
+
if (bytes.length > opts.maxBytes) fail(CODES.TOO_LARGE, `the argument is ${bytes.length} bytes; the limit for a consent message is ${opts.maxBytes}`);
|
|
132
|
+
const schemas = method.args.map((node, i) => schemaFor(node, `argument ${i}`));
|
|
133
|
+
const r = decodeArgs(schemas, bytes, opts);
|
|
134
|
+
if (!r.ok) {
|
|
135
|
+
const first = r.issues[0];
|
|
136
|
+
const code = first.code === "resource_limit_exceeded" ? CODES.TOO_LARGE : CODES.DECODE;
|
|
137
|
+
fail(code, `the argument bytes do not decode as the method's signature: ${first.code} at ${first.path}: ${first.message}`);
|
|
138
|
+
}
|
|
139
|
+
return r.values.map((v, i) => toTree(method.args[i], v, `argument ${i}`));
|
|
140
|
+
}
|
package/src/display.mjs
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// Turning one value of the tree into text, and into an ICRC-21 `Value`.
|
|
2
|
+
//
|
|
3
|
+
// Formatting goes through `formatterFor` of @meantjs/format for every tagged position
|
|
4
|
+
// (spec section 6: "rendered with the formatter of the tag at that position"); nothing here converts a unit,
|
|
5
|
+
// a decimal or an account by itself. An untagged position renders as Candid text.
|
|
6
|
+
//
|
|
7
|
+
// A formatter can fail on a value (for example a 5-byte subaccount, which no ledger accepts). The value is then
|
|
8
|
+
// shown as raw Candid text instead of hiding it, and the failure is recorded in `diagnostics`. A formatter that
|
|
9
|
+
// cannot be built (a dynamic ref the caller did not supply, a bad tag argument) is a configuration error and
|
|
10
|
+
// stops the message: showing a number without its decimals would be worse than no message.
|
|
11
|
+
|
|
12
|
+
import { FormatError, convertUnit, displayTimestamp, formatterFor } from "@meantjs/format";
|
|
13
|
+
import { CODES, ConsentError, fail } from "./errors.mjs";
|
|
14
|
+
import { entriesOf } from "./resolved.mjs";
|
|
15
|
+
import { visible } from "./text.mjs";
|
|
16
|
+
|
|
17
|
+
const U64_MAX = 2n ** 64n - 1n;
|
|
18
|
+
const CONFIG_CODES = new Set(["E_REF_UNRESOLVED", "E_ARG", "E_TAG_TYPE", "E_TAG_CONFLICT", "E_TYPE"]);
|
|
19
|
+
|
|
20
|
+
/** Budget for the text of one message: a consent message is read by a person (ICRC-21: short and concise). */
|
|
21
|
+
export const MAX_MESSAGE_CHARS = 32 * 1024;
|
|
22
|
+
|
|
23
|
+
export class Renderer {
|
|
24
|
+
/** Set while `plainTupleText` runs: no formatter applies. */
|
|
25
|
+
#plain = false;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {{ refs?: Record<string, unknown>, utcOffsetMinutes?: number | null, diagnostics?: object[] }} options
|
|
29
|
+
* `refs`: the resolved dynamic values, keyed by their `from` string (for example `query:icrc1_decimals`).
|
|
30
|
+
* `utcOffsetMinutes`: when a number, timestamps are shown in that zone (the generic form); otherwise in UTC.
|
|
31
|
+
*/
|
|
32
|
+
constructor({ refs = {}, utcOffsetMinutes = null, diagnostics = [] } = {}) {
|
|
33
|
+
this.refs = refs;
|
|
34
|
+
this.utcOffsetMinutes = utcOffsetMinutes;
|
|
35
|
+
this.diagnostics = diagnostics;
|
|
36
|
+
this.chars = 0;
|
|
37
|
+
this.formatters = new WeakMap();
|
|
38
|
+
/** Set when a timestamp was shown in the requested zone, so the response can say the text is zone-sensitive. */
|
|
39
|
+
this.usedZone = false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** @returns {import("@meantjs/format").Formatter | null} */
|
|
43
|
+
formatter(node) {
|
|
44
|
+
if (this.formatters.has(node)) return this.formatters.get(node);
|
|
45
|
+
let f;
|
|
46
|
+
try {
|
|
47
|
+
f = formatterFor(node, { refs: this.refs });
|
|
48
|
+
} catch (e) {
|
|
49
|
+
if (e instanceof FormatError && CONFIG_CODES.has(e.code)) throw new ConsentError(e.code, e.message, { cause: e });
|
|
50
|
+
throw e;
|
|
51
|
+
}
|
|
52
|
+
this.formatters.set(node, f);
|
|
53
|
+
return f;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
charge(text) {
|
|
57
|
+
this.chars += text.length;
|
|
58
|
+
if (this.chars > MAX_MESSAGE_CHARS) fail(CODES.TOO_LARGE, `the message would exceed ${MAX_MESSAGE_CHARS} characters; it is never truncated`);
|
|
59
|
+
return text;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Plain display text of one value, not yet escaped for any format: the formatter of its tag, or Candid text
|
|
64
|
+
* (spec section 6; a bare principal is its canonical text).
|
|
65
|
+
* @param {object} node the resolved-form position
|
|
66
|
+
* @param {unknown} value its value (a tree node, never absent)
|
|
67
|
+
* @param {string} where for diagnostics
|
|
68
|
+
*/
|
|
69
|
+
text(node, value, where) {
|
|
70
|
+
const shown = this.#formatted(node, value, where);
|
|
71
|
+
if (shown !== null) return this.charge(shown);
|
|
72
|
+
// An untagged principal is its canonical text, as everywhere else on the Internet Computer. Inside a composite it
|
|
73
|
+
// stays `principal "..."` so that the Candid text of the composite is still Candid.
|
|
74
|
+
if (node.candid === "principal") return this.charge(value);
|
|
75
|
+
return this.charge(this.#raw(node, value, where));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Candid text of a whole argument tuple (the "raw arguments" of spec section 11 item 1), tagged children formatted. */
|
|
79
|
+
tupleText(nodes, values) {
|
|
80
|
+
if (isAbiParams(nodes)) return this.charge(this.#child(nodes[0], values[0], "arguments"));
|
|
81
|
+
return this.charge(`(${nodes.map((n, i) => this.#child(n, values[i], `argument ${i}`)).join(", ")})`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Candid text of a whole argument tuple with no formatter applied: base units, nanoseconds, blobs as bytes. A view
|
|
86
|
+
* that does not depend on the profile's units, for a reader who checks the profile itself (spec section 11 item 1).
|
|
87
|
+
*/
|
|
88
|
+
plainTupleText(nodes, values) {
|
|
89
|
+
this.#plain = true;
|
|
90
|
+
try {
|
|
91
|
+
if (isAbiParams(nodes)) return this.charge(this.#child(nodes[0], values[0], "arguments"));
|
|
92
|
+
return this.charge(`(${nodes.map((n, i) => this.#child(n, values[i], `argument ${i}`)).join(", ")})`);
|
|
93
|
+
} finally {
|
|
94
|
+
this.#plain = false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The formatter's text, or null when the position has no value tag or the formatter refuses the value. */
|
|
99
|
+
#formatted(node, value, where) {
|
|
100
|
+
if (this.#plain) return null;
|
|
101
|
+
const f = this.formatter(node);
|
|
102
|
+
if (f === null) return null;
|
|
103
|
+
try {
|
|
104
|
+
return f.tag === "timestamp" && this.utcOffsetMinutes !== null ? this.#zoned(f, value) : f.display(value);
|
|
105
|
+
} catch (e) {
|
|
106
|
+
if (!(e instanceof FormatError)) throw e;
|
|
107
|
+
this.diagnostics.push({ code: "W_FORMAT_FALLBACK", at: where, message: `the ${f.tag} formatter refused the value (${e.code}: ${e.message}); it is shown as raw Candid text` });
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A timestamp in the requested zone: the instant shifted by whole minutes, then shown with the library's calendar code. */
|
|
113
|
+
#zoned(f, value) {
|
|
114
|
+
const offset = this.utcOffsetMinutes;
|
|
115
|
+
const local = BigInt(convertUnit(value, f.args.unit, "ns")) + BigInt(offset) * 60_000_000_000n;
|
|
116
|
+
const utc = displayTimestamp(local, { unit: "ns", candid: "int" });
|
|
117
|
+
this.usedZone = true;
|
|
118
|
+
return `${utc.slice(0, -1).replace("T", " ")} ${zoneName(offset)}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A child of a composite: absent is `null`, a tagged child uses its formatter, any other is raw (`opt` marks a present optional). */
|
|
122
|
+
#child(node, value, where) {
|
|
123
|
+
if (node.optional === true && value === null) return "null";
|
|
124
|
+
const shown = this.#formatted(node, value, where);
|
|
125
|
+
if (shown !== null) return shown;
|
|
126
|
+
const raw = this.#raw(node, value, where);
|
|
127
|
+
return node.optional === true ? `opt ${raw}` : raw;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
#raw(node, value, where) {
|
|
131
|
+
if (typeof node.abi === "string") return this.#abi(node, value, where);
|
|
132
|
+
switch (node.candid) {
|
|
133
|
+
case "null":
|
|
134
|
+
case "reserved":
|
|
135
|
+
return "null";
|
|
136
|
+
case "bool":
|
|
137
|
+
return value ? "true" : "false";
|
|
138
|
+
case "text":
|
|
139
|
+
return candidString(value);
|
|
140
|
+
case "principal":
|
|
141
|
+
return `principal "${value}"`;
|
|
142
|
+
case "blob":
|
|
143
|
+
return `blob "${[...value].map((b) => `\\${b.toString(16).padStart(2, "0")}`).join("")}"`;
|
|
144
|
+
case "float32":
|
|
145
|
+
case "float64":
|
|
146
|
+
return Number.isNaN(value) ? "nan" : value === Infinity ? "inf" : value === -Infinity ? "-inf" : String(value);
|
|
147
|
+
case "vec":
|
|
148
|
+
return value.length === 0 ? "vec {}" : `vec { ${value.map((e, i) => this.#child(node.element, e, `${where}[${i}]`)).join("; ")} }`;
|
|
149
|
+
case "record": {
|
|
150
|
+
const parts = entriesOf(node.fields).map(([key, child]) => `${candidLabel(key)} = ${this.#child(child, value[key], `${where}.${key}`)}`);
|
|
151
|
+
return parts.length === 0 ? "record {}" : `record { ${parts.join("; ")} }`;
|
|
152
|
+
}
|
|
153
|
+
case "variant": {
|
|
154
|
+
const arm = entriesOf(node.cases).find(([key]) => key === value.tag);
|
|
155
|
+
const key = arm === undefined ? String(value.tag) : arm[0];
|
|
156
|
+
if (arm === undefined || (arm[1].candid === "null" && arm[1].optional !== true)) return `variant { ${candidLabel(key)} }`;
|
|
157
|
+
return `variant { ${candidLabel(key)} = ${this.#child(arm[1], value.value, `${where}.${key}`)} }`;
|
|
158
|
+
}
|
|
159
|
+
default:
|
|
160
|
+
return String(value); // integers (decimal strings)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* ABI text of an EVM value (spec section 15), as evm.mjs decodes it: an address in its EIP-55 form, bytes as 0x hex,
|
|
166
|
+
* a string quoted, an array in [ ], a tuple in ( ) with its component names.
|
|
167
|
+
*/
|
|
168
|
+
#abi(node, value, where) {
|
|
169
|
+
switch (node.abi) {
|
|
170
|
+
case "address":
|
|
171
|
+
return value;
|
|
172
|
+
case "bool":
|
|
173
|
+
return value ? "true" : "false";
|
|
174
|
+
case "string":
|
|
175
|
+
return candidString(value);
|
|
176
|
+
case "array":
|
|
177
|
+
return `[${value.map((e, i) => this.#child(node.element, e, `${where}[${i}]`)).join(", ")}]`;
|
|
178
|
+
case "tuple":
|
|
179
|
+
return `(${entriesOf(node.fields)
|
|
180
|
+
.map(([key, child]) => `${key.startsWith("#") ? "" : `${key}: `}${this.#child(child, value[key], `${where}.${key}`)}`)
|
|
181
|
+
.join(", ")})`;
|
|
182
|
+
default:
|
|
183
|
+
if (value instanceof Uint8Array) return `0x${[...value].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
|
|
184
|
+
return String(value); // integers (decimal strings)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The ICRC-21 `Value` of one value (spec section 12). Falls back to `Text` whenever the typed form cannot carry
|
|
190
|
+
* the value exactly: an amount above nat64 or negative or without a symbol, a timestamp or duration that is not a
|
|
191
|
+
* whole number of seconds, below zero or above nat64.
|
|
192
|
+
*
|
|
193
|
+
* @returns {{ TokenAmount: object } | { TimestampSeconds: object } | { DurationSeconds: object } | { Text: { content: string } }}
|
|
194
|
+
*/
|
|
195
|
+
icrc21Value(node, value, where) {
|
|
196
|
+
const f = this.formatter(node);
|
|
197
|
+
if (f !== null && typeof value === "string") {
|
|
198
|
+
const typed = this.#typed(f, value);
|
|
199
|
+
if (typed !== null) return typed;
|
|
200
|
+
}
|
|
201
|
+
return { Text: { content: this.text(node, value, where) } };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
#typed(f, value) {
|
|
205
|
+
try {
|
|
206
|
+
if (f.tag === "amount") {
|
|
207
|
+
const amount = BigInt(value);
|
|
208
|
+
const { decimals, symbol } = f.args;
|
|
209
|
+
if (amount < 0n || amount > U64_MAX || typeof symbol !== "string") return null;
|
|
210
|
+
f.display(value); // the library must accept the value too (type range), or it is shown as raw text
|
|
211
|
+
return { TokenAmount: { decimals, amount, symbol } };
|
|
212
|
+
}
|
|
213
|
+
if (f.tag === "timestamp" || f.tag === "duration") {
|
|
214
|
+
const seconds = BigInt(convertUnit(value, f.args.unit, "s")); // throws E_INEXACT when not whole seconds
|
|
215
|
+
if (seconds < 0n || seconds > U64_MAX) return null;
|
|
216
|
+
f.display(value);
|
|
217
|
+
return f.tag === "timestamp" ? { TimestampSeconds: { amount: seconds } } : { DurationSeconds: { amount: seconds } };
|
|
218
|
+
}
|
|
219
|
+
} catch (e) {
|
|
220
|
+
if (e instanceof FormatError) return null;
|
|
221
|
+
throw e;
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** An EVM function's arguments: its parameters as one tuple (spec section 15), written as the ABI writes them. */
|
|
228
|
+
const isAbiParams = (nodes) => nodes.length === 1 && nodes[0]?.abi === "tuple";
|
|
229
|
+
|
|
230
|
+
/** `UTC`, `UTC+02:00`, `UTC-05:30`. */
|
|
231
|
+
export function zoneName(offsetMinutes) {
|
|
232
|
+
if (offsetMinutes === 0) return "UTC";
|
|
233
|
+
const abs = Math.abs(offsetMinutes);
|
|
234
|
+
return `UTC${offsetMinutes < 0 ? "-" : "+"}${String(Math.floor(abs / 60)).padStart(2, "0")}:${String(abs % 60).padStart(2, "0")}`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** A Candid text literal: quotes, backslashes and invisible characters escaped. */
|
|
238
|
+
function candidString(s) {
|
|
239
|
+
const escaped = s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
|
|
240
|
+
return `"${visible(escaped)}"`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** A record field or variant case label as Candid writes it: an identifier, a number for `#id`, else quoted. */
|
|
244
|
+
function candidLabel(key) {
|
|
245
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return key;
|
|
246
|
+
if (/^#(0|[1-9][0-9]*)$/.test(key)) return key.slice(1);
|
|
247
|
+
return candidString(key);
|
|
248
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Types for @meantjs/consent/erc7730: ERC-7730 clear-signing files from an EVM contract's resolved form, and the
|
|
2
|
+
// ERC-8176 attestation a reviewer signs for one (spec section 15, D-054). Hand-written; the source is src/erc7730.mjs.
|
|
3
|
+
// scripts/types.test.mjs keeps the declared names equal to the runtime exports.
|
|
4
|
+
import type { ResolvedForm } from "./index.mjs";
|
|
5
|
+
|
|
6
|
+
/** The exact-version ERC-7730 schema every exported file names in `$schema`. */
|
|
7
|
+
export const ERC7730_SCHEMA_URL: "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.1.0.schema.json";
|
|
8
|
+
/** ERC-8176's canonical EAS schema UID (`bytes32 descriptorHash`). */
|
|
9
|
+
export const ERC8176_SCHEMA_UID: "0xe023eef113c1670774801c34b377fdf612dd8a4d2fa92fe382e15bd91fafb5c2";
|
|
10
|
+
/** The canonical EAS contract on Ethereum mainnet. */
|
|
11
|
+
export const EAS_ADDRESS: "0xA1207F3BBa224E2c9c3c6D5aF63D0eb1582Ce587";
|
|
12
|
+
|
|
13
|
+
export interface Erc7730Deployment {
|
|
14
|
+
chainId: number;
|
|
15
|
+
/** EIP-55 text. */
|
|
16
|
+
address: string;
|
|
17
|
+
}
|
|
18
|
+
export interface Erc7730Field {
|
|
19
|
+
path: string;
|
|
20
|
+
label: string;
|
|
21
|
+
format: "tokenAmount" | "amount" | "date" | "duration" | "addressName" | "raw";
|
|
22
|
+
params?: Record<string, unknown>;
|
|
23
|
+
}
|
|
24
|
+
export interface Erc7730Descriptor {
|
|
25
|
+
$schema: typeof ERC7730_SCHEMA_URL;
|
|
26
|
+
context: { contract: { deployments: Erc7730Deployment[] } };
|
|
27
|
+
metadata?: {
|
|
28
|
+
owner?: string;
|
|
29
|
+
contractName?: string;
|
|
30
|
+
info?: { url: string; deploymentDate?: string };
|
|
31
|
+
token?: { name: string; ticker: string; decimals: number };
|
|
32
|
+
};
|
|
33
|
+
display: { formats: Record<string, { intent: string; interpolatedIntent?: string; fields?: Erc7730Field[] }> };
|
|
34
|
+
}
|
|
35
|
+
export interface Erc7730Options {
|
|
36
|
+
/** Default: the deployments every profile that lists deployments lists. Given, each must be listed by those profiles. */
|
|
37
|
+
deployments?: Erc7730Deployment[];
|
|
38
|
+
owner?: string;
|
|
39
|
+
contractName?: string;
|
|
40
|
+
info?: { url: string; deploymentDate?: string };
|
|
41
|
+
/** Say that the contract is the token its literal amounts are in: written as `metadata.token`. */
|
|
42
|
+
token?: { name: string };
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The ERC-7730 file of an EVM contract's resolved form and its ERC-8176 `descriptorHash`. What the profile says that an
|
|
46
|
+
* ERC-7730 file cannot say is refused (E_EXPORT); no deployments is E_DEPLOYMENT.
|
|
47
|
+
*/
|
|
48
|
+
export function erc7730Descriptor(resolved: ResolvedForm, options?: Erc7730Options): { descriptor: Erc7730Descriptor; descriptorHash: string };
|
|
49
|
+
/** ERC-8176 `descriptorHash`: `0x` and the Keccak-256 of the RFC 8785 (JCS) form, for a descriptor without `includes`. */
|
|
50
|
+
export function descriptorHash(descriptor: object): string;
|
|
51
|
+
|
|
52
|
+
export interface Erc8176Input {
|
|
53
|
+
descriptorHash: string;
|
|
54
|
+
/** Unix seconds. */
|
|
55
|
+
time: bigint | number | string;
|
|
56
|
+
/** Unix seconds; 0 never expires (ERC-8176 advises a bound). */
|
|
57
|
+
expirationTime: bigint | number | string;
|
|
58
|
+
/** 32 random bytes as 0x hex, chosen by the caller. */
|
|
59
|
+
salt: string;
|
|
60
|
+
/** Default 32 zero bytes. */
|
|
61
|
+
refUID?: string;
|
|
62
|
+
}
|
|
63
|
+
export interface Erc8176TypedData {
|
|
64
|
+
domain: { name: "EAS Attestation"; version: "0.26"; chainId: 1; verifyingContract: typeof EAS_ADDRESS };
|
|
65
|
+
primaryType: "Attest";
|
|
66
|
+
types: { Attest: { name: string; type: string }[] };
|
|
67
|
+
message: {
|
|
68
|
+
version: 2;
|
|
69
|
+
schema: typeof ERC8176_SCHEMA_UID;
|
|
70
|
+
recipient: string;
|
|
71
|
+
time: string;
|
|
72
|
+
expirationTime: string;
|
|
73
|
+
revocable: true;
|
|
74
|
+
refUID: string;
|
|
75
|
+
/** abi.encode(descriptorHash): the 32 bytes. */
|
|
76
|
+
data: string;
|
|
77
|
+
salt: string;
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** The unsigned ERC-8176 offchain attestation (EAS offchain v2 typed data) and its EIP-712 digest. Nothing is signed. */
|
|
81
|
+
export function erc8176Attestation(input: Erc8176Input): { typedData: Erc8176TypedData; digest: string };
|