@meantjs/consent 0.0.0-bootstrap → 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/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ # Changelog: @meantjs/consent
2
+
3
+ The five Candid Profile library packages (`@meantjs/profile`, `/format`, `/consent`, `/tools`, `/reconcile`) release together under one version and pin each other exactly; a version appears here even when this package did not change. Release steps: `docs/releasing.md` in the repository.
4
+
5
+ ## 0.1.0 — 2026-10-28
6
+
7
+ First published version.
8
+
9
+ Published under the `@meantjs` scope (D-051). Before any release, this package was `@candid-core/profile-consent`.
10
+
11
+ - ICRC-21 consent messages for any method of a resolved form, as `GenericDisplayMessage` (Markdown) and
12
+ `FieldsDisplayMessage`, values formatted by `@meantjs/format` and escaped, nothing rounded
13
+ (`icrc21ConsentMessage`, `handleConsentRequest`, `generateConsent`).
14
+ - The ICRC-21 Candid codec (`encodeConsentRequest`, `decodeConsentResponse`, ...), built on `@candid-core/schema` 0.5.0.
15
+ - `./sns` (Node only): SNS generic-function validator renderings and the reviewer's check of a stored rendering.
16
+ - The main entry imports no Node built-in and runs in browsers.
17
+ - The argument bytes may be a `Uint8Array` or an `ArrayBuffer` from any realm (an iframe, an extension's content script),
18
+ or an array of integers 0 to 255. An array with any other element is refused (`E_REQUEST`; `E_DECODE` from
19
+ `decodeArguments` and the SNS rendering), where it used to be wrapped modulo 256 and described as other bytes.
20
+ - TypeScript declarations for both entries.
package/README.md CHANGED
@@ -1,5 +1,215 @@
1
- # @meantjs/consent 0.0.0-bootstrap
1
+ # @meantjs/consent
2
2
 
3
- This version is a placeholder with no code. It exists because npm can attach a trusted publisher only to a
4
- package that is already on the registry. Every real version is published by the repository's release workflow.
5
- Install `@meantjs/consent` without a tag to get the latest released version.
3
+ ICRC-21 consent messages, and the text an SNS proposal's validator returns, from a Candid Profile resolved form (`docs/profile-v0.md` sections 6, 11 and 12). Give it the resolved form of a canister, a method name and the Candid-encoded argument bytes; it returns the `icrc21_consent_message_response` for **any** method, as `GenericDisplayMessage` (Markdown) or `FieldsDisplayMessage` (typed rows). No network code; tested against the standard's `.did`. A Rust version for canisters lives in the repository (`packages/consent/rust/`, not in the npm package; section below).
4
+
5
+ ```sh
6
+ npm install @meantjs/consent
7
+ ```
8
+
9
+ - **Runs on:** Node 22 or later and browsers for the main entry (no Node built-in, no `Buffer`; checked with esbuild).
10
+ `@meantjs/consent/sns` hashes with `node:crypto` and is Node only.
11
+ - **Module format:** ES modules only, with hand-written TypeScript declarations for every entry.
12
+ - **Spec:** [Candid Profile v0](https://github.com/B3Pay/Meant/blob/main/profile/docs/profile-v0.md). **Licence:** Apache-2.0.
13
+ - **Versions:** the five Candid Profile packages (`@meantjs/profile`, `/format`, `/consent`, `/tools`,
14
+ `/reconcile`) release together under one version and pin each other exactly; in 0.x a minor is breaking,
15
+ and so is any change to generated text ([releasing](https://github.com/B3Pay/Meant/blob/main/profile/docs/releasing.md)).
16
+
17
+ ```js
18
+ import { icrc21ConsentMessage, handleConsentRequest } from "@meantjs/consent";
19
+
20
+ const response = icrc21ConsentMessage(
21
+ {
22
+ method: "icrc1_transfer",
23
+ arg: argBytes, // Uint8Array: the Candid-encoded argument tuple of the call
24
+ user_preferences: { metadata: { language: "en", utc_offset_minutes: 120 }, device_spec: { GenericDisplay: null } },
25
+ },
26
+ {
27
+ resolved, // fixtures/*.resolved.json shape, or the output of `candid-profile resolve`
28
+ refs: { "query:icrc1_decimals": 8, "query:icrc1_symbol": "TKA" }, // dynamic refs, resolved by the caller
29
+ },
30
+ );
31
+ // { Ok: { consent_message: { GenericDisplayMessage: "# Send tokens\n\nSend `0.29 TKA` to ..." }, metadata: { language: "en", utc_offset_minutes: null } } }
32
+ // or { Err: { UnsupportedCanisterCall | ConsentMessageUnavailable | GenericError: ... } }
33
+ ```
34
+
35
+ ## API (`src/index.mjs`)
36
+
37
+ The main entry runs in a browser: it imports no Node built-in and uses no `Buffer` (`test/browser-bundle.test.mjs`
38
+ bundles it with esbuild for the browser platform). The three SNS rows below are the `./sns` subpath
39
+ (`@meantjs/consent/sns`, `src/sns.mjs`), which hashes with `node:crypto` and is Node-only.
40
+
41
+ | Function | Does |
42
+ |---|---|
43
+ | `icrc21ConsentMessage(request, context)` | The ICRC-21 call as a function. Request and result are JS objects (below). `context`: `resolved`, `refs`, `languagePolicy` (`"fallback"` default, or `"error"`), `rawArguments`, `limits`. The result also has two non-enumerable properties: `diagnostics` (what was adjusted) and `provenance` (`{ intent: { origin, profile } \| null, profiles }`, so a signer can warn when a profile, not the canister, wrote the words). |
44
+ | `handleConsentRequest(requestBytes, context)` | Candid bytes of `icrc21_consent_message_request` in, Candid bytes of `icrc21_consent_message_response` out: what a canister endpoint returns. |
45
+ | `encodeConsentResponse` / `decodeConsentResponse` / `encodeConsentRequest` / `decodeConsentRequest` | The Candid codec for the ICRC-21 types (schemas written from `ICRC-21.did`). |
46
+ | `toJson(value)` | JSON-safe copy: bigint as decimal string (D-006), `Uint8Array` as `{hex}`. |
47
+ | `snsValidatorRendering({ payload, resolved, method, refs, limits })` | An SNS generic-function validator as a function: payload bytes in, `{ Ok: text }` or `{ Err: text }` out (section below). Non-enumerable `diagnostics` and `provenance`, as above. |
48
+ | `handleSnsValidatorCall(payload, context)` / `encodeValidatorResult` / `decodeValidatorResult` | The canister-side wrapper and the codec of `variant { Ok : text; Err : text }`. |
49
+ | `verifySnsRendering({ payloadTextRendering, payload, resolved, targetCanisterId, method, refs })`, `parseGovernanceRendering(text)` | The reviewer's check of a stored proposal rendering; `SNS_RENDERING_RULES` names the rules. |
50
+ | `generateConsent({ resolved, method, arg, refs, form, utcOffsetMinutes, rawArguments })` | One form without the ICRC-21 envelope; returns `{ message, zoneSensitive, diagnostics, provenance, model }`. |
51
+ | `decodeArguments(method, bytes, limits)` | Step 1 alone: argument bytes to the value tree. |
52
+ | `parseTemplate`, `resolvePath`, `valuesAt`, `Renderer`, `renderFields`, `renderGeneric`, `codeSpan`, `escapeMarkdown`, `visible` | The parts. |
53
+
54
+ **JS shapes** (`src/icrc21-codec.mjs`): a variant is `{ Tag: payload }` (`{ GenericDisplay: null }` for a bare case); `opt T` is `null` or the value; `nat`, `nat64` are `bigint`; `nat8`, `int16` are numbers; `blob` is `Uint8Array`; `vec record { text; Value }` is an array of `[label, value]` pairs. This is the shape of the standard's example JSON (`{"Text": {"content": ...}}`).
55
+
56
+ ## What happens, in order
57
+
58
+ 1. **Decode** (`src/decode.mjs`). The argument bytes are decoded against the method's resolved-form tree into a value tree aligned with its positions: integers as decimal strings, floats as numbers, `blob` as `Uint8Array`, absent `opt` as `null`, records as objects keyed like `fields`, variants as `{ tag, value }`, vectors as arrays. Nothing is rounded or truncated.
59
+ 2. **Intent** (`src/template.mjs`, `src/model.mjs`). The method's `intent.template`: placeholders rendered through `formatterFor` of `@meantjs/format` (so `amount`, `timestamp`, `duration`, `account`, `subaccount`, `bytes` mean exactly what arm C means), `[ ... ]` dropped when a placeholder inside is absent, escapes `{{ }} [[ ]]`. Untagged positions render as Candid text. A method without `intent` gets a generic intent (title from `describe.label` of the method or `Call <method>`, one row per argument leaf). The renderer has no network access and never states anything about ledger state; dynamic refs come in through `refs`. A ref you did not supply stops the message (`E_REF_UNRESOLVED`), a number is never shown without its decimals.
60
+ 3. **Forms** (`src/render.mjs`, `src/icrc21.mjs`), section 12:
61
+ - `FieldsDisplayMessage`: `intent` = title, `": "`, rendered template; one `(label, Value)` row per placeholder, label from `describe.label` else the field name; `amount` is a `TokenAmount` when it fits `nat64` and has a symbol, else `Text`; `timestamp` is `TimestampSeconds` after an exact ns-to-s conversion (the library throws `E_INEXACT` otherwise and the row is `Text` in RFC 3339 UTC); `duration` is `DurationSeconds`; everything else is `Text`. UTC only, never zone-sensitive.
62
+ - `GenericDisplayMessage`: `# title`, the rendered template as a paragraph, then `- **label:** value` for the same fields. Times are shown in `utc_offset_minutes` (`2026-10-09 14:00:00 UTC+02:00`), UTC when absent, and the response metadata says which offset was applied.
63
+
64
+ ## SNS proposal validators (`src/sns.mjs`)
65
+
66
+ The governance counterpart of ICRC-21. An SNS proposal that executes a generic nervous system function calls a dapp method with a payload. Apart from the proposer's own title and summary, the only description of that payload governance shows is the text the dapp's validator method returns. `snsValidatorRendering` is that validator, generated from the profile. Proposal for the spec text: `docs/proposals/w4-sns-validator-rendering.md`.
67
+
68
+ ```js
69
+ import { snsValidatorRendering, handleSnsValidatorCall, verifySnsRendering } from "@meantjs/consent/sns";
70
+
71
+ snsValidatorRendering({ payload, resolved, method: "pay_grant", refs });
72
+ // { Ok: "### Pay a grant\n\nPay `123.456789 GOV` from the treasury to ..." } or { Err: "E_DECODE: ..." }
73
+ handleSnsValidatorCall(payloadBytes, { resolved, method, refs }); // Candid bytes of variant { Ok : text; Err : text }
74
+ verifySnsRendering({ payloadTextRendering, payload, resolved, targetCanisterId, method, refs }); // { ok, checks, expectedRendering, parsed }
75
+ ```
76
+
77
+ **What SNS governance does** (fact: dfinity/ic `649156c`, `rs/sns/governance/src/proposal.rs`, `canister_control.rs`, `types.rs`):
78
+ - On submission it calls the validator with the payload bytes, unchanged, and decodes the reply as `Result<String, String>`. `Err` rejects the proposal.
79
+ - It stores `Ok(text)` as `payload_text_rendering` under its own headings: `# Proposal to execute nervous system function:`, `## Nervous system function:` (the function record as Rust debug text), `## Payload sha256:`, `## Payload:` and then the text.
80
+ - On adoption it calls the target method with the same bytes.
81
+ - `get_proposal` returns the rendering, but the payload only when it is at most 64 bytes; above that it returns a text summary with the SHA-256. `list_proposals` returns neither.
82
+
83
+ The NNS dapp shows the rendering as Markdown (fact: dfinity/nns-dapp `94b6cde`, `SnsProposalPayloadSection.svelte`).
84
+
85
+ **The text** (rules `candid-profile-sns/v0`):
86
+ 1. The generic consent message (section 12), with the title at heading level 3 so it nests under governance's `## Payload:`. Times are in UTC.
87
+ 2. `**Method:**` and `**Effect:**` (kind and reversibility). When the resolver supplied the section 5.3 default, the text says so.
88
+ 3. The arguments as Candid text with no formatter applied, always: base units, nanoseconds, bytes. This view does not depend on the profile's units.
89
+ 4. `**Rendered from:**`: each profile with its `profile_id`, the `interface_id`, and the rules.
90
+
91
+ Anything the generator cannot describe is `Err`, so governance rejects the proposal: an unknown method, bytes that do not decode, an untypeable position, a missing dynamic ref, or a message over the limits. Errors use the codes of the table above. Values are escaped exactly as in the generic consent message: a memo that imitates `## Payload:` cannot add a heading.
92
+
93
+ **The reviewer's check.** The proposer publishes the payload bytes. Given the proposal's `payload_text_rendering`, the same profile and the canister it describes, `verifySnsRendering` runs five checks:
94
+ - governance's layout, with the function record exactly as `{:#?}` prints a generic function (one field per line), so the target is read from its own line and never from text in the function's name or description;
95
+ - `sha256(payload)` equals governance's `Payload sha256`;
96
+ - the function's `target_canister_id` is the canister the reviewer names (a resolved form names no canister, so without `targetCanisterId` this check fails);
97
+ - the function's `target_method_name` is the method the profile describes;
98
+ - the validator's text equals what the profile renders for those bytes.
99
+
100
+ It names `get_proposal`'s 64-byte summary when it is passed instead of the payload. From the command line:
101
+
102
+ ```sh
103
+ npm run sns -w packages/consent -- --did dapp.did --profile dapp.profile.json --method pay_grant --payload-file payload.bin --verify rendering.txt --target <canister id>
104
+ ```
105
+
106
+ Local files only, no network.
107
+
108
+ ## Codec
109
+
110
+ **`@candid-core/schema` 0.5.0** (`decodeArgs`, the same package that backs `@candid-core/cli` 0.4.0, pinned exactly; 0.4.0 until D-047, 0.3.0 and 0.2.0 until D-037). It decodes against an expected schema with the Candid coercion relation and explicit resource budgets, returns `bigint` and `Uint8Array` (lossless), and has no dependencies. The schema is built from the resolved-form tree, so the bytes never choose the types.
111
+
112
+ Considered and not chosen: `@dfinity/didc` 0.0.4 (the Rust crates as wasm) prints textual Candid, which would need a second parser, and wants a `.did` the consent generator does not have; a hand-written decoder would be new code to trust. `@dfinity/didc` is used **in the tests**, as an independent oracle: 16 wire variants and 3000 mutated encodings are accepted and rejected exactly as Rust candid does (`test/decode-differential.test.mjs`; a 20,000-input scratch run found 0 disagreements), and every reply is decoded against the standard's `.did`.
113
+
114
+ ## Escaping (spec section 6, section 11 item 4)
115
+
116
+ The relying party is not trusted, so every value that comes from the call is shown inert:
117
+
118
+ - **Generic form (Markdown):** a value is a code span, verbatim, with a fence one backtick longer than any run inside. Links, images, HTML, autolinks, emphasis, entities, headings and tables do not exist inside it. Control, format (bidi overrides, zero-width) and line-separator characters are shown as `\u{202e}`, never passed through. Text from the profile (template literals, labels, the title) is plain text: Markdown punctuation is backslash-escaped, line breaks become spaces. Checked with the CommonMark reference parser (`commonmark`, dev dependency) on 38 hostile values.
119
+ - **Fields form:** plain text, as ICRC-21 requires. Only the invisible characters are made visible.
120
+ - A memo that is not valid UTF-8, has control characters, or starts with `0x` is hex (spec section 5.3, enforced by the format library).
121
+
122
+ ## Errors
123
+
124
+ | Cause | ICRC-21 result |
125
+ |---|---|
126
+ | Unknown method, argument bytes that do not decode, arguments too large, malformed request, unsupported language with `languagePolicy: "error"` | `UnsupportedCanisterCall` (`description` starts with the code: `E_METHOD_UNKNOWN`, `E_DECODE`, `E_TOO_LARGE`, `E_REQUEST`, `E_LANGUAGE`) |
127
+ | A value that reaches a position the resolved form cuts (`recursive: true`; a call whose value avoids every cut gets its message, D-042), or a method with a `func` or `service` position | `ConsentMessageUnavailable` (`E_UNSUPPORTED_TYPE`) |
128
+ | Missing dynamic ref, invalid tag argument, malformed resolved form | `GenericError` with `error_code` 1100 (ref), 1101 (arg), 1102 (tag type), 1103 (tag conflict), 1000 (resolved form), 1999 (internal) |
129
+
130
+ A language other than English is **not** an error: ICRC-21 says a message "should be preferred over sending an icrc21_error" and "the metadata must match the consent_message provided", so the answer is English with `metadata.language: "en"` (`I_LANGUAGE_FALLBACK` in `diagnostics`). Limits are explicit and never truncate: 2 MiB of argument bytes, 64 fields, 256 list elements, 32 KiB of message text.
131
+
132
+ ## Rust version for canisters (`rust/`)
133
+
134
+ An SNS validator is a canister method, so the renderer also exists as a Rust crate, `candid-profile-consent` (not published). It reads the same resolved form (JSON) and writes the same text, byte for byte:
135
+
136
+ | Function | Does |
137
+ |---|---|
138
+ | `sns_validator_rendering(resolved, method, payload, refs)` | `Ok(text)` or `Err(text)`, as `snsValidatorRendering`. |
139
+ | `handle_sns_validator_call(...)`, `encode_validator_result(...)` | The Candid-encoded reply a validator method returns. |
140
+ | `render_sns_payload(..., limits)` | The same with the error as a value and the diagnostics. |
141
+ | `generic_consent_message(resolved, method, arg, refs, utc_offset_minutes, raw_arguments, limits)` | The ICRC-21 `GenericDisplayMessage` text, as `generateConsent` with `form: "generic"`. |
142
+ | `parse_resolved(json)` | The resolved form from its JSON text (field order kept: it is the order of the Candid text). |
143
+
144
+ It decodes with the `candid` crate against types built from the resolved form, as `src/decode.mjs` does with `@candid-core/schema`, and ports the format library's display side (`packages/format`) for the six value tags. Where JavaScript's strings show through (UTF-16 lengths for the 32 KiB budget, `trim`, the neighbours of an underscore), the crate counts and looks the way JavaScript does.
145
+
146
+ ```sh
147
+ cd packages/consent/rust
148
+ cargo test # unit tests, the goldens, the differential corpus
149
+ cargo build --release --target wasm32-unknown-unknown --example sns_validator
150
+ node check-canister.mjs # the dapp's SNS goldens through the example canister's wasm, in Node
151
+ ```
152
+
153
+ - **Tests.** `tests/golden.rs` runs all 13 SNS goldens and the generic message of all 20 ICRC-21 goldens: `Ok` byte for byte (with the reply's Candid bytes), `Err` by code (the two decoders word their errors differently). `tests/differential.rs` runs the corpus of `test/differential/` (below): 203 payloads, every one answered alike, `Ok` text and `Err` code, in both forms. Deliberate mutations of the port (float formatting, `trim`, the underscore rule, UTF-16 counting, `ui.order`, the subaccount, base64 and duration rules, the limits) each make it fail.
154
+ - **The example canister** (`examples/sns_validator.rs`): `validate_<method>` query methods for the hypothetical dapp of `test/fixtures/sns-dapp.did`, written against the six system API functions it needs, so the crate's own dependencies are all it has. Each call writes one `debug_print` line with the method, the payload size and the instructions spent. `check-canister.mjs` instantiates the wasm in Node with those functions stubbed and checks the dapp's 7 SNS golden cases; that is not a replica. Release wasm: 731,172 bytes, not stripped or optimised further. `examples/sns_target.rs` is a stand-in for the dapp itself, for the PocketIC test only: it records each call.
155
+ - **Versions** (observed 2026-10-12): rustc and cargo 1.97.0, target `wasm32-unknown-unknown`; `rust-version` 1.85 (the highest of the dependencies). Dependencies pinned exactly in `Cargo.toml`, the full tree in `Cargo.lock`: `candid` 0.10.38 (no default features; `value`), `serde_json` 1.0.154 (`preserve_order`), `num-bigint` 0.4.6, `unicode-general-category` 1.1.0 (Unicode 16.0, as Node 22's ICU 77.1), `ryu-js` 1.0.3 (JavaScript's number formatting).
156
+ - **Not ported:** the `FieldsDisplayMessage` form, the ICRC-21 request and response envelope, the reviewer's check (it runs off-chain, in JavaScript).
157
+
158
+ ### On SNS governance (`pocketic/`)
159
+
160
+ The example validator, registered with the real SNS governance canister on PocketIC. Not part of `npm test` at the root: it downloads pinned binaries and builds the canisters first.
161
+
162
+ ```sh
163
+ npm ci # at the repository root (the test imports this package and @dfinity/didc)
164
+ cd packages/consent/pocketic
165
+ npm ci # @dfinity/pic 0.25.0, @icp-sdk/core 6.1.0; install scripts off (.npmrc)
166
+ npm test # fetch + sha256-check the artifacts, build both example canisters, run
167
+ ```
168
+
169
+ The test starts one PocketIC instance with an SNS subnet (governance, plus empty canisters for root, ledger and swap) and an application subnet (the example validator and the stand-in dapp). One neuron holds all the voting power, so each proposal is adopted when made. It adds one generic function per dapp method by proposal, then proposes every dapp case of `test/sns-golden/`.
170
+
171
+ **Observed** (2026-10-12; 10 of 10 tests pass):
172
+ - Governance accepts the example's query methods as validators and calls them across subnets.
173
+ - For the five cases that render, `payload_text_rendering` equals `governanceRendering` of `test/sns-helpers.mjs` (the layout the unit tests derive from the governance source) around the golden text, byte for byte. `verifySnsRendering` passes on it, and fails when the reviewer names another canister. A mutant of the test that expects another topic fails, so the comparison is live.
174
+ - `get_proposal` returns the 91-byte payload as exactly `proposalPayloadSummary` simulates. The reviewer's check names that summary when it is passed in place of the bytes.
175
+ - The two `Err` cases: `manage_neuron` answers `error_type` 15, `1 defects in Proposal:` then `Invalid proposal: ` and the validator's text. No proposal, no call to the target.
176
+ - On adoption the target receives the payload unchanged with governance as the caller, and governance keeps the reply in `execution_reply`.
177
+ - Instructions per validator call, from the canister log, parsing the resolved form included (a query keeps no state): 1.6 to 2.5 million (`pause` 1,807,012; `pay_grant`, 91 bytes, 2,476,305). The direct preview queries left no log lines; the seven calls governance made did.
178
+
179
+ **Versions** (`pocketic/artifacts.json`): PocketIC server 16.0.0 (linux-x64 unpacked here; darwin-arm64 as pinned by W2), SNS governance wasm and `.did` from DFINITY's CI at dfinity/ic `649156c2bca1315da5699e2125b34be6397c9987` (both sha256 equal the published `SHA256SUMS`), `@dfinity/pic` 0.25.0, `@icp-sdk/core` 6.1.0. **Open:** deployed SNSes run the governance version blessed through SNS-W, which may differ from this commit; re-run on a version bump.
180
+ - **Known difference: the decoders' work bounds.** The JavaScript codec refuses bytes with more than 100,000 values or nesting deeper than 64, skipped values included (`E_TOO_LARGE`). The `candid` crate bounds work differently (here: its skipping quota, 16 units per byte of the 2 MiB limit). So a payload with, say, an ignored extra field holding a million `null`s is rendered in Rust and refused in JavaScript; the reviewer's check then fails visibly on `payload_rendering`. Everything the method's type reads is the same in both. Matching the bounds exactly would need a Candid value counter of our own. **Fact:** SNS governance does not cap the payload below the message size at `649156c` (`PROPOSAL_EXECUTE_SNS_FUNCTION_PAYLOAD_BYTES_MAX = 70000` in `types.rs` is marked dead code with a TODO).
181
+
182
+ ## Tests and fixtures
183
+
184
+ ```sh
185
+ npm test -w packages/consent # 171 tests, or `npm test` at the repo root
186
+ npm run golden -w packages/consent # regenerate the goldens, both REVIEW.md, the resolved forms and the corpus the Rust tests read
187
+ npm run upstream -w packages/consent -- --out <new empty dir> # re-download the ICRC-21 files and compare sha256 (network)
188
+ ```
189
+
190
+ | File | What it checks |
191
+ |---|---|
192
+ | `test/decode*.test.mjs` | The value tree; refusal of what cannot be typed; agreement with Rust candid. |
193
+ | `test/template.test.mjs`, `paths.test.mjs` | Same scan as the resolver's `parseTemplate` (5000 random templates), placeholder paths. |
194
+ | `test/text.test.mjs`, `render.test.mjs` | Escaping with the CommonMark parser, optional segments, generic intent, limits. |
195
+ | `test/icrc21.test.mjs` | Forms, language, time zones, section 12 mapping, errors; replies decoded against `test/upstream/icrc21/ICRC-21.did`. |
196
+ | `test/golden.test.mjs`, `test/golden/` | 20 cases: the three update methods on 8, 6 and 18 decimals. `REVIEW.md` is the by-eye copy. Each case also has expectations worked out by hand (`cases.mjs`). |
197
+ | `test/pipeline.test.mjs` | The real ledger `.did` through the real resolver; the tokens example (`type:` addresses, nested placeholder); a canister with no profile. |
198
+ | `test/oisy-comparison.test.mjs` | Backs `docs/oisy-comparison.md`. |
199
+ | `test/sns.test.mjs`, `test/sns-golden/` | 13 SNS validator cases (`cases.mjs`, hand-worked expectations; `REVIEW.md` is the by-eye copy, with one case inside governance's wrapper). Reply bytes are read and re-encoded identically by Rust candid as `variant { Ok : text; Err : text }`. The CommonMark parser checks that the whole stored rendering has only governance's headings and one of ours. The governance layout is checked against the upstream test vector in `test/upstream/sns/`. The reviewer's check covers a misstated amount, another target canister or method, a function name or description that imitates the target fields, no expected canister given, other bytes, the 64-byte summary, other units at the reviewer, and a fake second hash header. The CLI is covered too. `test/sns-golden/resolved/` holds the dapp's resolved forms for the Rust tests; a test checks that they are the resolver's output. |
200
+ | `test/differential.test.mjs`, `test/differential/` | The corpus the Rust port is compared on (`corpus.json`, from the seeded generator in `build.mjs`): `test/fixtures/wide.did` with its author profile, with no profile, and with faults a resolver would not write; payloads encoded by `@dfinity/didc`, then truncated, changed, extended, swapped between methods, or encoded untyped to exercise subtyping; the message limits; refs given in odd forms. The test checks that the stored corpus is what the package answers and that it reaches every outcome it compares. |
201
+
202
+ Untrusted third-party data lives in `test/upstream/icrc21/` (the standard, commit and sha256 in `SOURCE.json`), `test/upstream/sns/` (the governance test vector, same) and `test/fixtures/ledger.did`; tests only read it as text. `test/fixtures/sns-dapp.did`, `test/fixtures/wide.did` and their profiles describe hypothetical canisters written for these tests.
203
+
204
+ ## Security notes
205
+
206
+ - A profile is a claim, not a proof (spec section 11 item 1). `provenance` tells the signer which profile wrote the words; `rawArguments: true` appends the decoded raw arguments for transfer, approve, admin and destroy.
207
+ - Dynamic refs come from a single replica unless the caller certifies them (section 11 item 2). Resolve `decimals` and `symbol` through an update call or cross-check with `icrc1_metadata` before showing a transfer. Generation inside the canister avoids this.
208
+ - The package has no I/O. Work is bounded: decoder budgets, field and element limits, message size.
209
+
210
+ ## Not done
211
+
212
+ - In Rust: the `FieldsDisplayMessage` form and the ICRC-21 envelope (`rust/`, above).
213
+ - The caller's account (`From`) and a fee line: see `docs/oisy-comparison.md` for why they are absent.
214
+ - Other languages; `device_spec` hints beyond the two forms.
215
+ - Recursive and `func`/`service` positions (need a richer resolved form, or the Contract).
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@meantjs/consent",
3
- "version": "0.0.0-bootstrap",
4
- "description": "Placeholder that registers the name for trusted publishing. Install a released version of @meantjs/consent instead.",
3
+ "version": "0.1.0",
4
+ "description": "ICRC-21 consent messages and SNS proposal validator renderings generated from a Candid Profile resolved form: any method, both GenericDisplayMessage and FieldsDisplayMessage, values formatted and escaped, nothing rounded.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -9,8 +9,55 @@
9
9
  "directory": "profile/packages/consent"
10
10
  },
11
11
  "homepage": "https://github.com/B3Pay/Meant/tree/main/profile/packages/consent#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/B3Pay/Meant/issues"
14
+ },
15
+ "keywords": [
16
+ "candid",
17
+ "internet-computer",
18
+ "icp",
19
+ "icrc-21",
20
+ "consent-message",
21
+ "sns",
22
+ "wallet"
23
+ ],
24
+ "type": "module",
25
+ "sideEffects": false,
26
+ "main": "./src/index.mjs",
27
+ "types": "./src/index.d.mts",
28
+ "exports": {
29
+ ".": {
30
+ "types": "./src/index.d.mts",
31
+ "default": "./src/index.mjs"
32
+ },
33
+ "./sns": {
34
+ "types": "./src/sns.d.mts",
35
+ "default": "./src/sns.mjs"
36
+ },
37
+ "./package.json": "./package.json"
38
+ },
12
39
  "files": [
13
- "README.md",
40
+ "src",
41
+ "CHANGELOG.md",
14
42
  "LICENSE"
15
- ]
43
+ ],
44
+ "engines": {
45
+ "node": ">=22"
46
+ },
47
+ "scripts": {
48
+ "test": "node --test \"test/*.test.mjs\"",
49
+ "golden": "node scripts/generate-golden.mjs",
50
+ "upstream": "node scripts/fetch-icrc21.mjs",
51
+ "sns": "node scripts/sns-render.mjs"
52
+ },
53
+ "dependencies": {
54
+ "@meantjs/profile": "0.1.0",
55
+ "@meantjs/format": "0.1.0",
56
+ "@candid-core/schema": "0.5.0"
57
+ },
58
+ "devDependencies": {
59
+ "@dfinity/didc": "0.0.4",
60
+ "commonmark": "0.31.2",
61
+ "esbuild": "0.28.2"
62
+ }
16
63
  }
package/src/bytes.mjs ADDED
@@ -0,0 +1,32 @@
1
+ // Bytes as callers hand them over: a Uint8Array or an ArrayBuffer from any realm (an iframe, a node:vm context, an
2
+ // extension's content script, a test environment), or an array of integers 0..255 (the JSON form of a blob). Anything
3
+ // else is not bytes: an array holding 256 or -1 is refused, never wrapped modulo 256, so a message never describes
4
+ // other bytes than the ones it was given.
5
+ import { asUint8Array } from "@meantjs/format";
6
+
7
+ const arrayBufferByteLength = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, "byteLength").get;
8
+
9
+ /** The value as a Uint8Array of this realm (a view, not a copy, where it can be), or null when it is not bytes. */
10
+ export function bytesOf(value) {
11
+ const view = asUint8Array(value);
12
+ if (view !== null) return view;
13
+ if (isArrayBuffer(value)) {
14
+ try {
15
+ return new Uint8Array(value);
16
+ } catch {
17
+ return null; // detached
18
+ }
19
+ }
20
+ if (Array.isArray(value) && value.every((b) => Number.isInteger(b) && b >= 0 && b <= 255)) return Uint8Array.from(value);
21
+ return null;
22
+ }
23
+
24
+ /** An ArrayBuffer of any realm: its intrinsic getter reads the internal slot, which nothing else has. */
25
+ function isArrayBuffer(value) {
26
+ try {
27
+ arrayBufferByteLength.call(value);
28
+ return true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
@@ -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
+ }