@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 ADDED
@@ -0,0 +1,35 @@
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.2.0 — 2026-10-28
6
+
7
+ - A call takes the `intent` and `effect` of the deepest tagged variant case its arguments hold, else the method's
8
+ (spec §5.2, D-053): the title, template and provenance of the message, the model's `effect` and the effect line
9
+ of the SNS rendering. A resolved form without case tags renders as before. The Rust version does the same.
10
+ - EVM contracts, experimental (spec §15, D-054), in two new subpaths; the main entry is unchanged and refuses an EVM
11
+ resolved form (`E_RESOLVED_FORM`).
12
+ - `@meantjs/consent/evm`: `decodeCalldata` (strict) and `evmConsent`, the consent text of a call in the generic or
13
+ fields form, ending with the contract's address and chain. A call to a contract its profiles do not list is
14
+ `E_DEPLOYMENT`; a payable function is refused until its native value has a position.
15
+ - `@meantjs/consent/erc7730`: `erc7730Descriptor`, the ERC-7730 clear-signing file of a contract (schema 2.1.0,
16
+ deterministic) with its ERC-8176 `descriptorHash`; `descriptorHash`; `erc8176Attestation`, the unsigned EAS
17
+ offchain attestation a reviewer signs, with its EIP-712 digest. What a file cannot say is refused (`E_EXPORT`).
18
+ - New codes `E_DEPLOYMENT` and `E_EXPORT`. The Rust version does not cover EVM.
19
+
20
+ ## 0.1.0 — 2026-10-28
21
+
22
+ First published version.
23
+
24
+ Published under the `@meantjs` scope (D-051). Before any release, this package was `@candid-core/profile-consent`.
25
+
26
+ - ICRC-21 consent messages for any method of a resolved form, as `GenericDisplayMessage` (Markdown) and
27
+ `FieldsDisplayMessage`, values formatted by `@meantjs/format` and escaped, nothing rounded
28
+ (`icrc21ConsentMessage`, `handleConsentRequest`, `generateConsent`).
29
+ - The ICRC-21 Candid codec (`encodeConsentRequest`, `decodeConsentResponse`, ...), built on `@candid-core/schema` 0.5.0.
30
+ - `./sns` (Node only): SNS generic-function validator renderings and the reviewer's check of a stored rendering.
31
+ - The main entry imports no Node built-in and runs in browsers.
32
+ - The argument bytes may be a `Uint8Array` or an `ArrayBuffer` from any realm (an iframe, an extension's content script),
33
+ or an array of integers 0 to 255. An array with any other element is refused (`E_REQUEST`; `E_DECODE` from
34
+ `decodeArguments` and the SNS rendering), where it used to be wrapped modulo 256 and described as other bytes.
35
+ - TypeScript declarations for both entries.
package/LICENSE ADDED
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
package/README.md CHANGED
@@ -1,3 +1,247 @@
1
- # Temporary Holding Version
1
+ # @meantjs/consent
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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 and the EVM subpaths (no Node built-in, no `Buffer`;
10
+ checked with esbuild). `@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 call's `intent.template`: that of the deepest variant case with an `intent` that the decoded arguments hold, else the method's (spec §5.2, D-053; the call's effect, shown in the SNS rendering, is chosen the same way). A resolved form whose tagged cases one call holds side by side is not one a resolver writes; the method's tag is used for it. 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
+ ## EVM contracts (experimental: `src/evm.mjs`, `src/erc7730.mjs`)
65
+
66
+ Spec section 15, D-054. An EVM contract's resolved form (`"chain": "evm"`, from `resolveService({ abi })` of
67
+ `@meantjs/profile`) is read by two subpaths; the main entry refuses it with `E_RESOLVED_FORM`, and `evmConsent` refuses
68
+ a Candid one.
69
+
70
+ | Function | Does |
71
+ |---|---|
72
+ | `decodeCalldata(resolved, calldata, limits)` (`/evm`) | The function a selector names and its arguments, keyed by parameter name. Strict: values read from anything but their canonical ABI encoding are `E_DECODE`. |
73
+ | `evmConsent({ resolved, calldata, to, chainId, refs, form, utcOffsetMinutes, rawArguments, limits })` (`/evm`) | The consent text of a call: the same model and the same two forms as ICRC-21, without its envelope (`"generic"` Markdown, or `"fields"` as an intent line and `[label, text]` rows), ending with the contract's address and chain, which a person must see because a pack matches any contract with the right functions. A call to a contract its profiles do not list is `E_DEPLOYMENT`; a payable function is `E_UNSUPPORTED_TYPE` (its native value has no position yet). |
74
+ | `erc7730Descriptor(resolved, { deployments, owner, contractName, info, token })` (`/erc7730`) | The contract's ERC-7730 clear-signing file and its ERC-8176 `descriptorHash`: deterministic, `$schema` pinned to the exact 2.1.0 file, one entry per function a transaction calls. What an ERC-7730 file cannot say (a time in ms, `utf8` bytes, an amount in another token, an unnamed parameter, a placeholder over an array) is `E_EXPORT`, never written as something near it; no deployment is `E_DEPLOYMENT`. The mapping is the table in spec §15.7. |
75
+ | `descriptorHash(descriptor)` (`/erc7730`) | Keccak-256 of the RFC 8785 form: the hash of a file someone edited. |
76
+ | `erc8176Attestation({ descriptorHash, time, expirationTime, salt, refUID })` (`/erc7730`) | The unsigned ERC-8176 attestation (EAS offchain v2 typed data) and its EIP-712 digest, for a reviewer to sign with their own key. Nothing is signed or sent. |
77
+
78
+ ```js
79
+ import { evmConsent } from "@meantjs/consent/evm";
80
+ import { erc7730Descriptor, erc8176Attestation } from "@meantjs/consent/erc7730";
81
+
82
+ evmConsent({ resolved, calldata, to: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", refs: { "query:decimals": 18, "query:symbol": "WETH" } }).message;
83
+ // # Send tokens\n\nSend `1.5 WETH` to `0xfB69…d359`\n\n- **Amount:** `1.5 WETH`\n- **Recipient:** `0xfB69…d359`\n\n**Contract:** `0xC02a…6Cc2`
84
+ const { descriptor, descriptorHash } = erc7730Descriptor(resolved, { deployments: [{ chainId: 1, address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" }] });
85
+ const { typedData, digest } = erc8176Attestation({ descriptorHash, time: 1777630591, expirationTime: 1785406591, salt });
86
+ ```
87
+
88
+ Every exported file in the tests is checked against the ERC-7730 2.1.0 JSON Schema (vendored in
89
+ `test/upstream/erc7730/`, CC0, sha256 in its `SOURCE.json`), and the hashes and digests they pin were computed by an
90
+ independent implementation. **Fact** (ERC-7730): a wallet that finds no ticker for a `tokenAmount` "SHOULD display the
91
+ raw value instead with an 'Unknown token' warning"; this package would produce no message there, and a file cannot make
92
+ a wallet refuse. The Rust version does not cover EVM.
93
+
94
+ ## SNS proposal validators (`src/sns.mjs`)
95
+
96
+ 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`.
97
+
98
+ ```js
99
+ import { snsValidatorRendering, handleSnsValidatorCall, verifySnsRendering } from "@meantjs/consent/sns";
100
+
101
+ snsValidatorRendering({ payload, resolved, method: "pay_grant", refs });
102
+ // { Ok: "### Pay a grant\n\nPay `123.456789 GOV` from the treasury to ..." } or { Err: "E_DECODE: ..." }
103
+ handleSnsValidatorCall(payloadBytes, { resolved, method, refs }); // Candid bytes of variant { Ok : text; Err : text }
104
+ verifySnsRendering({ payloadTextRendering, payload, resolved, targetCanisterId, method, refs }); // { ok, checks, expectedRendering, parsed }
105
+ ```
106
+
107
+ **What SNS governance does** (fact: dfinity/ic `649156c`, `rs/sns/governance/src/proposal.rs`, `canister_control.rs`, `types.rs`):
108
+ - On submission it calls the validator with the payload bytes, unchanged, and decodes the reply as `Result<String, String>`. `Err` rejects the proposal.
109
+ - 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.
110
+ - On adoption it calls the target method with the same bytes.
111
+ - `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.
112
+
113
+ The NNS dapp shows the rendering as Markdown (fact: dfinity/nns-dapp `94b6cde`, `SnsProposalPayloadSection.svelte`).
114
+
115
+ **The text** (rules `candid-profile-sns/v0`):
116
+ 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.
117
+ 2. `**Method:**` and `**Effect:**` (kind and reversibility). When the resolver supplied the section 5.3 default, the text says so.
118
+ 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.
119
+ 4. `**Rendered from:**`: each profile with its `profile_id`, the `interface_id`, and the rules.
120
+
121
+ 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.
122
+
123
+ **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:
124
+ - 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;
125
+ - `sha256(payload)` equals governance's `Payload sha256`;
126
+ - the function's `target_canister_id` is the canister the reviewer names (a resolved form names no canister, so without `targetCanisterId` this check fails);
127
+ - the function's `target_method_name` is the method the profile describes;
128
+ - the validator's text equals what the profile renders for those bytes.
129
+
130
+ It names `get_proposal`'s 64-byte summary when it is passed instead of the payload. From the command line:
131
+
132
+ ```sh
133
+ 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>
134
+ ```
135
+
136
+ Local files only, no network.
137
+
138
+ ## Codec
139
+
140
+ **`@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.
141
+
142
+ 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`.
143
+
144
+ ## Escaping (spec section 6, section 11 item 4)
145
+
146
+ The relying party is not trusted, so every value that comes from the call is shown inert:
147
+
148
+ - **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.
149
+ - **Fields form:** plain text, as ICRC-21 requires. Only the invisible characters are made visible.
150
+ - 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).
151
+
152
+ ## Errors
153
+
154
+ | Cause | ICRC-21 result |
155
+ |---|---|
156
+ | 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`) |
157
+ | 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`) |
158
+ | Missing dynamic ref, invalid tag argument, malformed resolved form (an EVM one among them) | `GenericError` with `error_code` 1100 (ref), 1101 (arg), 1102 (tag type), 1103 (tag conflict), 1000 (resolved form), 1999 (internal) |
159
+
160
+ 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.
161
+
162
+ ## Rust version for canisters (`rust/`)
163
+
164
+ 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:
165
+
166
+ | Function | Does |
167
+ |---|---|
168
+ | `sns_validator_rendering(resolved, method, payload, refs)` | `Ok(text)` or `Err(text)`, as `snsValidatorRendering`. |
169
+ | `handle_sns_validator_call(...)`, `encode_validator_result(...)` | The Candid-encoded reply a validator method returns. |
170
+ | `render_sns_payload(..., limits)` | The same with the error as a value and the diagnostics. |
171
+ | `generic_consent_message(resolved, method, arg, refs, utc_offset_minutes, raw_arguments, limits)` | The ICRC-21 `GenericDisplayMessage` text, as `generateConsent` with `form: "generic"`. |
172
+ | `parse_resolved(json)` | The resolved form from its JSON text (field order kept: it is the order of the Candid text). |
173
+
174
+ 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.
175
+
176
+ ```sh
177
+ cd packages/consent/rust
178
+ cargo test # unit tests, the goldens, the differential corpus
179
+ cargo build --release --target wasm32-unknown-unknown --example sns_validator
180
+ node check-canister.mjs # the dapp's SNS goldens through the example canister's wasm, in Node
181
+ ```
182
+
183
+ - **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.
184
+ - **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.
185
+ - **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).
186
+ - **Not ported:** the `FieldsDisplayMessage` form, the ICRC-21 request and response envelope, the reviewer's check (it runs off-chain, in JavaScript).
187
+
188
+ ### On SNS governance (`pocketic/`)
189
+
190
+ 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.
191
+
192
+ ```sh
193
+ npm ci # at the repository root (the test imports this package and @dfinity/didc)
194
+ cd packages/consent/pocketic
195
+ npm ci # @dfinity/pic 0.25.0, @icp-sdk/core 6.1.0; install scripts off (.npmrc)
196
+ npm test # fetch + sha256-check the artifacts, build both example canisters, run
197
+ ```
198
+
199
+ 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/`.
200
+
201
+ **Observed** (2026-10-12; 10 of 10 tests pass):
202
+ - Governance accepts the example's query methods as validators and calls them across subnets.
203
+ - 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.
204
+ - `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.
205
+ - 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.
206
+ - On adoption the target receives the payload unchanged with governance as the caller, and governance keeps the reply in `execution_reply`.
207
+ - 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.
208
+
209
+ **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.
210
+ - **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).
211
+
212
+ ## Tests and fixtures
213
+
214
+ ```sh
215
+ npm test -w packages/consent # 171 tests, or `npm test` at the repo root
216
+ npm run golden -w packages/consent # regenerate the goldens, both REVIEW.md, the resolved forms and the corpus the Rust tests read
217
+ npm run upstream -w packages/consent -- --out <new empty dir> # re-download the ICRC-21 files and compare sha256 (network)
218
+ ```
219
+
220
+ | File | What it checks |
221
+ |---|---|
222
+ | `test/decode*.test.mjs` | The value tree; refusal of what cannot be typed; agreement with Rust candid. |
223
+ | `test/template.test.mjs`, `paths.test.mjs` | Same scan as the resolver's `parseTemplate` (5000 random templates), placeholder paths. |
224
+ | `test/text.test.mjs`, `render.test.mjs` | Escaping with the CommonMark parser, optional segments, generic intent, limits. |
225
+ | `test/icrc21.test.mjs` | Forms, language, time zones, section 12 mapping, errors; replies decoded against `test/upstream/icrc21/ICRC-21.did`. |
226
+ | `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`). |
227
+ | `test/pipeline.test.mjs` | The real ledger `.did` through the real resolver; the tokens example (`type:` addresses, nested placeholder); a canister with no profile. |
228
+ | `test/oisy-comparison.test.mjs` | Backs `docs/oisy-comparison.md`. |
229
+ | `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. |
230
+ | `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. |
231
+
232
+ 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.
233
+
234
+ ## Security notes
235
+
236
+ - 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.
237
+ - 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.
238
+ - The package has no I/O. Work is bounded: decoder budgets, field and element limits, message size.
239
+
240
+ ## Not done
241
+
242
+ - In Rust: the `FieldsDisplayMessage` form and the ICRC-21 envelope (`rust/`, above).
243
+ - The caller's account (`From`) and a fee line: see `docs/oisy-comparison.md` for why they are absent.
244
+ - Other languages; `device_spec` hints beyond the two forms.
245
+ - Recursive and `func`/`service` positions (need a richer resolved form, or the Contract).
246
+ - EVM: payable functions in the consent text, EIP-712 messages, ERC-7730 import, checking attestations, and the Rust
247
+ version.
package/package.json CHANGED
@@ -1,6 +1,76 @@
1
1
  {
2
2
  "name": "@meantjs/consent",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.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
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/B3Pay/Meant.git",
9
+ "directory": "profile/packages/consent"
10
+ },
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
+ "evm",
24
+ "erc-7730",
25
+ "erc-8176",
26
+ "clear-signing"
27
+ ],
28
+ "type": "module",
29
+ "sideEffects": false,
30
+ "main": "./src/index.mjs",
31
+ "types": "./src/index.d.mts",
32
+ "exports": {
33
+ ".": {
34
+ "types": "./src/index.d.mts",
35
+ "default": "./src/index.mjs"
36
+ },
37
+ "./sns": {
38
+ "types": "./src/sns.d.mts",
39
+ "default": "./src/sns.mjs"
40
+ },
41
+ "./evm": {
42
+ "types": "./src/evm.d.mts",
43
+ "default": "./src/evm.mjs"
44
+ },
45
+ "./erc7730": {
46
+ "types": "./src/erc7730.d.mts",
47
+ "default": "./src/erc7730.mjs"
48
+ },
49
+ "./package.json": "./package.json"
50
+ },
51
+ "files": [
52
+ "src",
53
+ "CHANGELOG.md",
54
+ "LICENSE"
55
+ ],
56
+ "engines": {
57
+ "node": ">=22"
58
+ },
59
+ "scripts": {
60
+ "test": "node --test \"test/*.test.mjs\"",
61
+ "golden": "node scripts/generate-golden.mjs",
62
+ "upstream": "node scripts/fetch-icrc21.mjs",
63
+ "sns": "node scripts/sns-render.mjs"
64
+ },
65
+ "dependencies": {
66
+ "@candid-core/schema": "0.5.0",
67
+ "@meantjs/format": "0.2.0",
68
+ "@meantjs/profile": "0.2.0"
69
+ },
70
+ "devDependencies": {
71
+ "@dfinity/didc": "0.0.4",
72
+ "ajv": "8.18.0",
73
+ "commonmark": "0.31.2",
74
+ "esbuild": "0.28.2"
75
+ }
76
+ }
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
+ }