@meantjs/consent 0.0.0-stage → 0.1.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/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
+ }
@@ -0,0 +1,218 @@
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
+ return this.charge(`(${nodes.map((n, i) => this.#child(n, values[i], `argument ${i}`)).join(", ")})`);
81
+ }
82
+
83
+ /**
84
+ * Candid text of a whole argument tuple with no formatter applied: base units, nanoseconds, blobs as bytes. A view
85
+ * that does not depend on the profile's units, for a reader who checks the profile itself (spec section 11 item 1).
86
+ */
87
+ plainTupleText(nodes, values) {
88
+ this.#plain = true;
89
+ try {
90
+ return this.charge(`(${nodes.map((n, i) => this.#child(n, values[i], `argument ${i}`)).join(", ")})`);
91
+ } finally {
92
+ this.#plain = false;
93
+ }
94
+ }
95
+
96
+ /** The formatter's text, or null when the position has no value tag or the formatter refuses the value. */
97
+ #formatted(node, value, where) {
98
+ if (this.#plain) return null;
99
+ const f = this.formatter(node);
100
+ if (f === null) return null;
101
+ try {
102
+ return f.tag === "timestamp" && this.utcOffsetMinutes !== null ? this.#zoned(f, value) : f.display(value);
103
+ } catch (e) {
104
+ if (!(e instanceof FormatError)) throw e;
105
+ 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` });
106
+ return null;
107
+ }
108
+ }
109
+
110
+ /** A timestamp in the requested zone: the instant shifted by whole minutes, then shown with the library's calendar code. */
111
+ #zoned(f, value) {
112
+ const offset = this.utcOffsetMinutes;
113
+ const local = BigInt(convertUnit(value, f.args.unit, "ns")) + BigInt(offset) * 60_000_000_000n;
114
+ const utc = displayTimestamp(local, { unit: "ns", candid: "int" });
115
+ this.usedZone = true;
116
+ return `${utc.slice(0, -1).replace("T", " ")} ${zoneName(offset)}`;
117
+ }
118
+
119
+ /** A child of a composite: absent is `null`, a tagged child uses its formatter, any other is raw (`opt` marks a present optional). */
120
+ #child(node, value, where) {
121
+ if (node.optional === true && value === null) return "null";
122
+ const shown = this.#formatted(node, value, where);
123
+ if (shown !== null) return shown;
124
+ const raw = this.#raw(node, value, where);
125
+ return node.optional === true ? `opt ${raw}` : raw;
126
+ }
127
+
128
+ #raw(node, value, where) {
129
+ switch (node.candid) {
130
+ case "null":
131
+ case "reserved":
132
+ return "null";
133
+ case "bool":
134
+ return value ? "true" : "false";
135
+ case "text":
136
+ return candidString(value);
137
+ case "principal":
138
+ return `principal "${value}"`;
139
+ case "blob":
140
+ return `blob "${[...value].map((b) => `\\${b.toString(16).padStart(2, "0")}`).join("")}"`;
141
+ case "float32":
142
+ case "float64":
143
+ return Number.isNaN(value) ? "nan" : value === Infinity ? "inf" : value === -Infinity ? "-inf" : String(value);
144
+ case "vec":
145
+ return value.length === 0 ? "vec {}" : `vec { ${value.map((e, i) => this.#child(node.element, e, `${where}[${i}]`)).join("; ")} }`;
146
+ case "record": {
147
+ const parts = entriesOf(node.fields).map(([key, child]) => `${candidLabel(key)} = ${this.#child(child, value[key], `${where}.${key}`)}`);
148
+ return parts.length === 0 ? "record {}" : `record { ${parts.join("; ")} }`;
149
+ }
150
+ case "variant": {
151
+ const arm = entriesOf(node.cases).find(([key]) => key === value.tag);
152
+ const key = arm === undefined ? String(value.tag) : arm[0];
153
+ if (arm === undefined || (arm[1].candid === "null" && arm[1].optional !== true)) return `variant { ${candidLabel(key)} }`;
154
+ return `variant { ${candidLabel(key)} = ${this.#child(arm[1], value.value, `${where}.${key}`)} }`;
155
+ }
156
+ default:
157
+ return String(value); // integers (decimal strings)
158
+ }
159
+ }
160
+
161
+ /**
162
+ * The ICRC-21 `Value` of one value (spec section 12). Falls back to `Text` whenever the typed form cannot carry
163
+ * the value exactly: an amount above nat64 or negative or without a symbol, a timestamp or duration that is not a
164
+ * whole number of seconds, below zero or above nat64.
165
+ *
166
+ * @returns {{ TokenAmount: object } | { TimestampSeconds: object } | { DurationSeconds: object } | { Text: { content: string } }}
167
+ */
168
+ icrc21Value(node, value, where) {
169
+ const f = this.formatter(node);
170
+ if (f !== null && typeof value === "string") {
171
+ const typed = this.#typed(f, value);
172
+ if (typed !== null) return typed;
173
+ }
174
+ return { Text: { content: this.text(node, value, where) } };
175
+ }
176
+
177
+ #typed(f, value) {
178
+ try {
179
+ if (f.tag === "amount") {
180
+ const amount = BigInt(value);
181
+ const { decimals, symbol } = f.args;
182
+ if (amount < 0n || amount > U64_MAX || typeof symbol !== "string") return null;
183
+ f.display(value); // the library must accept the value too (type range), or it is shown as raw text
184
+ return { TokenAmount: { decimals, amount, symbol } };
185
+ }
186
+ if (f.tag === "timestamp" || f.tag === "duration") {
187
+ const seconds = BigInt(convertUnit(value, f.args.unit, "s")); // throws E_INEXACT when not whole seconds
188
+ if (seconds < 0n || seconds > U64_MAX) return null;
189
+ f.display(value);
190
+ return f.tag === "timestamp" ? { TimestampSeconds: { amount: seconds } } : { DurationSeconds: { amount: seconds } };
191
+ }
192
+ } catch (e) {
193
+ if (e instanceof FormatError) return null;
194
+ throw e;
195
+ }
196
+ return null;
197
+ }
198
+ }
199
+
200
+ /** `UTC`, `UTC+02:00`, `UTC-05:30`. */
201
+ export function zoneName(offsetMinutes) {
202
+ if (offsetMinutes === 0) return "UTC";
203
+ const abs = Math.abs(offsetMinutes);
204
+ return `UTC${offsetMinutes < 0 ? "-" : "+"}${String(Math.floor(abs / 60)).padStart(2, "0")}:${String(abs % 60).padStart(2, "0")}`;
205
+ }
206
+
207
+ /** A Candid text literal: quotes, backslashes and invisible characters escaped. */
208
+ function candidString(s) {
209
+ const escaped = s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
210
+ return `"${visible(escaped)}"`;
211
+ }
212
+
213
+ /** A record field or variant case label as Candid writes it: an identifier, a number for `#id`, else quoted. */
214
+ function candidLabel(key) {
215
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return key;
216
+ if (/^#(0|[1-9][0-9]*)$/.test(key)) return key.slice(1);
217
+ return candidString(key);
218
+ }
package/src/errors.mjs ADDED
@@ -0,0 +1,51 @@
1
+ // Every failure of this package is a ConsentError with a stable `code`. The ICRC-21 entry point maps
2
+ // the codes to the ICRC-21 error variants (icrc21.mjs); the lower-level functions throw them.
3
+
4
+ export class ConsentError extends Error {
5
+ /**
6
+ * @param {string} code one of CODES
7
+ * @param {string} message written for a developer, not for the end user (ICRC-21: icrc21_error_info.description)
8
+ * @param {{ cause?: unknown }} [options]
9
+ */
10
+ constructor(code, message, options = {}) {
11
+ super(message, options.cause === undefined ? undefined : { cause: options.cause });
12
+ this.name = "ConsentError";
13
+ this.code = code;
14
+ }
15
+ }
16
+
17
+ export const CODES = Object.freeze({
18
+ // the call is malformed (ICRC-21 UnsupportedCanisterCall)
19
+ METHOD_UNKNOWN: "E_METHOD_UNKNOWN", // the resolved form has no such method
20
+ DECODE: "E_DECODE", // the argument bytes do not decode against the method's signature
21
+ TOO_LARGE: "E_TOO_LARGE", // the arguments exceed the bounds of a consent message (never truncated)
22
+ LANGUAGE: "E_LANGUAGE", // only with languagePolicy "error": the requested language is not supported
23
+ REQUEST: "E_REQUEST", // the ICRC-21 request itself is malformed
24
+ // this generator cannot describe the call (ICRC-21 ConsentMessageUnavailable)
25
+ UNSUPPORTED_TYPE: "E_UNSUPPORTED_TYPE", // a position the resolved form cannot type (recursive cut, func, service)
26
+ // the profile or the caller's context is wrong (ICRC-21 GenericError)
27
+ RESOLVED_FORM: "E_RESOLVED_FORM", // the resolved form is malformed
28
+ TEMPLATE: "E_TEMPLATE", // the intent template does not parse
29
+ TEMPLATE_PATH: "E_TEMPLATE_PATH", // a placeholder names a position the method does not have
30
+ // pass-through of the format library's configuration errors (a dynamic ref the caller did not supply, a bad tag argument)
31
+ REF_UNRESOLVED: "E_REF_UNRESOLVED",
32
+ ARG: "E_ARG",
33
+ TAG_TYPE: "E_TAG_TYPE",
34
+ TAG_CONFLICT: "E_TAG_CONFLICT",
35
+ });
36
+
37
+ /** ICRC-21 GenericError.error_code values this package uses (a nat chosen by the canister; see the README). */
38
+ export const GENERIC_ERROR_CODES = Object.freeze({
39
+ [CODES.RESOLVED_FORM]: 1000n,
40
+ [CODES.TEMPLATE]: 1001n,
41
+ [CODES.TEMPLATE_PATH]: 1002n,
42
+ [CODES.REF_UNRESOLVED]: 1100n,
43
+ [CODES.ARG]: 1101n,
44
+ [CODES.TAG_TYPE]: 1102n,
45
+ [CODES.TAG_CONFLICT]: 1103n,
46
+ INTERNAL: 1999n,
47
+ });
48
+
49
+ export function fail(code, message, options) {
50
+ throw new ConsentError(code, message, options);
51
+ }
@@ -0,0 +1,160 @@
1
+ // The ICRC-21 types (topics/ICRC-21/ICRC-21.did of dfinity/wg-identity-authentication, commit pinned in
2
+ // test/upstream/icrc21/SOURCE.json) as @candid-core/schema schemas, and the conversion between the JS shapes
3
+ // this package uses and the shapes of the codec.
4
+ //
5
+ // JS shape (what `icrc21ConsentMessage` takes and returns):
6
+ // variant { Tag: payload } a bare case is { Tag: null }
7
+ // opt T null when absent, else the value
8
+ // nat, nat64 bigint nat8, int16 number blob Uint8Array
9
+ // vec record { text; Value } array of [label, value] pairs
10
+ // Codec shape: variants are { tag, value } (a bare case is { tag }).
11
+
12
+ import { c } from "@candid-core/schema";
13
+ import { decode, encode } from "@candid-core/schema/codec";
14
+ import { asUint8Array } from "@meantjs/format";
15
+ import { CODES, fail } from "./errors.mjs";
16
+
17
+ const Metadata = c.record({ language: c.text, utc_offset_minutes: c.opt(c.int16) });
18
+ const DeviceSpec = c.variant({ GenericDisplay: c.null, FieldsDisplay: c.null });
19
+ const MessageSpec = c.record({ metadata: Metadata, device_spec: c.opt(DeviceSpec) });
20
+ export const RequestSchema = c.record({ method: c.text, arg: c.blob(), user_preferences: MessageSpec });
21
+
22
+ const TokenAmount = c.record({ decimals: c.nat8, amount: c.nat64, symbol: c.text });
23
+ const TimestampSeconds = c.record({ amount: c.nat64 });
24
+ const DurationSeconds = c.record({ amount: c.nat64 });
25
+ const TextValue = c.record({ content: c.text });
26
+ const Value = c.variant({ TokenAmount, TimestampSeconds, DurationSeconds, Text: TextValue });
27
+ const ConsentMessage = c.variant({
28
+ GenericDisplayMessage: c.text,
29
+ FieldsDisplayMessage: c.record({ intent: c.text, fields: c.vec(c.tuple([c.text, Value])) }),
30
+ });
31
+ const ConsentInfo = c.record({ consent_message: ConsentMessage, metadata: Metadata });
32
+ const ErrorInfo = c.record({ description: c.text });
33
+ const Icrc21Error = c.variant({
34
+ UnsupportedCanisterCall: ErrorInfo,
35
+ ConsentMessageUnavailable: ErrorInfo,
36
+ InsufficientPayment: ErrorInfo,
37
+ GenericError: c.record({ error_code: c.nat, description: c.text }),
38
+ });
39
+ export const ResponseSchema = c.variant({ Ok: ConsentInfo, Err: Icrc21Error });
40
+
41
+ // ------------------------------------------------------------------------------------------ shape conversion
42
+
43
+ function toCodec(schema, v, where) {
44
+ switch (schema.kind) {
45
+ case "variant": {
46
+ const keys = v !== null && typeof v === "object" ? Object.keys(v) : [];
47
+ if (keys.length !== 1 || !Object.hasOwn(schema.arms, keys[0])) fail(CODES.REQUEST, `${where}: expected a variant with exactly one of ${Object.keys(schema.arms).join(", ")}`);
48
+ const arm = schema.arms[keys[0]];
49
+ return arm.kind === "primitive" && arm.primitive === "null" ? { tag: keys[0] } : { tag: keys[0], value: toCodec(arm, v[keys[0]], `${where}.${keys[0]}`) };
50
+ }
51
+ case "record":
52
+ return Object.fromEntries(Object.entries(schema.fields).map(([k, s]) => [k, toCodec(s, v?.[k], `${where}.${k}`)]));
53
+ case "tuple":
54
+ return schema.elements.map((s, i) => toCodec(s, v?.[i], `${where}[${i}]`));
55
+ case "vec":
56
+ return v.map((e, i) => toCodec(schema.inner, e, `${where}[${i}]`));
57
+ case "opt":
58
+ return v === null || v === undefined ? null : toCodec(schema.inner, v, where);
59
+ default:
60
+ return v;
61
+ }
62
+ }
63
+
64
+ function fromCodec(schema, v) {
65
+ switch (schema.kind) {
66
+ case "variant": {
67
+ const arm = schema.arms[v.tag];
68
+ return { [v.tag]: arm.kind === "primitive" && arm.primitive === "null" ? null : fromCodec(arm, v.value) };
69
+ }
70
+ case "record":
71
+ return Object.fromEntries(Object.entries(schema.fields).map(([k, s]) => [k, fromCodec(s, v[k])]));
72
+ case "tuple":
73
+ return schema.elements.map((s, i) => fromCodec(s, v[i]));
74
+ case "vec":
75
+ return v.map((e) => fromCodec(schema.inner, e));
76
+ case "opt":
77
+ return v === null ? null : fromCodec(schema.inner, v);
78
+ default:
79
+ return v;
80
+ }
81
+ }
82
+
83
+ /** The widths the JS shape accepts for the numbers the codec wants as bigint. */
84
+ function widen(schema, v) {
85
+ switch (schema.kind) {
86
+ case "primitive":
87
+ return schema.primitive === "nat" || schema.primitive === "nat64" ? (typeof v === "number" && Number.isSafeInteger(v) ? BigInt(v) : v) : v;
88
+ case "variant":
89
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, schema.arms[k] ? widen(schema.arms[k], x) : x]));
90
+ case "record":
91
+ return Object.fromEntries(Object.entries(schema.fields).map(([k, s]) => [k, widen(s, v?.[k])]));
92
+ case "tuple":
93
+ return schema.elements.map((s, i) => widen(s, v?.[i]));
94
+ case "vec":
95
+ return Array.isArray(v) ? v.map((e) => widen(schema.inner, e)) : v;
96
+ case "opt":
97
+ return v === null || v === undefined ? null : widen(schema.inner, v);
98
+ default:
99
+ return v;
100
+ }
101
+ }
102
+
103
+ function run(schema, value, what) {
104
+ const r = encode(schema, toCodec(schema, widen(schema, value), what));
105
+ if (!r.ok) fail(CODES.REQUEST, `${what} does not match the ICRC-21 type: ${r.issues[0].code} at ${r.issues[0].path}: ${r.issues[0].message}`);
106
+ return r.bytes;
107
+ }
108
+
109
+ /**
110
+ * Candid-encode an `icrc21_consent_message_response` (the reply of `icrc21_canister_call_consent_message`).
111
+ * @param {{ Ok: object } | { Err: object }} response the JS shape of `icrc21ConsentMessage`'s result
112
+ * @returns {Uint8Array}
113
+ */
114
+ export function encodeConsentResponse(response) {
115
+ return run(ResponseSchema, response, "response");
116
+ }
117
+
118
+ /** Decode a Candid-encoded `icrc21_consent_message_response` into the JS shape. */
119
+ export function decodeConsentResponse(bytes) {
120
+ const r = decode(ResponseSchema, bytes);
121
+ if (!r.ok) fail(CODES.DECODE, `not an icrc21_consent_message_response: ${r.issues[0].code} at ${r.issues[0].path}`);
122
+ return fromCodec(ResponseSchema, r.value);
123
+ }
124
+
125
+ /** Candid-encode an `icrc21_consent_message_request` (the argument of the ICRC-21 call). */
126
+ export function encodeConsentRequest(request) {
127
+ return run(RequestSchema, request, "request");
128
+ }
129
+
130
+ /** Decode the argument of `icrc21_canister_call_consent_message` into the JS shape. Throws E_REQUEST on bytes that are not one. */
131
+ export function decodeConsentRequest(bytes) {
132
+ const r = decode(RequestSchema, bytes, { maxBytes: 2 * 1024 * 1024 + 4096 });
133
+ if (!r.ok) fail(CODES.REQUEST, `not an icrc21_consent_message_request: ${r.issues[0].code} at ${r.issues[0].path}: ${r.issues[0].message}`);
134
+ return fromCodec(RequestSchema, r.value);
135
+ }
136
+
137
+ const HEX_DIGITS = new TextEncoder().encode("0123456789abcdef");
138
+ const ascii = new TextDecoder();
139
+ /**
140
+ * Lowercase hex of the bytes (no Buffer: this module runs in a browser too). The digits go into one byte array and
141
+ * are decoded once, so a blob near the 2 MiB request limit costs two buffers, not a string per byte.
142
+ */
143
+ function hex(bytes) {
144
+ const out = new Uint8Array(bytes.length * 2);
145
+ for (let i = 0; i < bytes.length; i++) {
146
+ out[2 * i] = HEX_DIGITS[bytes[i] >> 4];
147
+ out[2 * i + 1] = HEX_DIGITS[bytes[i] & 15];
148
+ }
149
+ return ascii.decode(out);
150
+ }
151
+
152
+ /** JSON-safe copy of a JS-shape value: bigint as a decimal string (D-006), Uint8Array as { hex }. For fixtures and logs. */
153
+ export function toJson(value) {
154
+ if (typeof value === "bigint") return value.toString();
155
+ const bytes = asUint8Array(value); // from any realm
156
+ if (bytes !== null) return { hex: hex(bytes) };
157
+ if (Array.isArray(value)) return value.map(toJson);
158
+ if (value !== null && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, toJson(v)]));
159
+ return value;
160
+ }