@meantjs/consent 0.1.0 → 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 CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
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
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
+
5
20
  ## 0.1.0 — 2026-10-28
6
21
 
7
22
  First published version.
package/README.md CHANGED
@@ -6,8 +6,8 @@ ICRC-21 consent messages, and the text an SNS proposal's validator returns, from
6
6
  npm install @meantjs/consent
7
7
  ```
8
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.
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
11
  - **Module format:** ES modules only, with hand-written TypeScript declarations for every entry.
12
12
  - **Spec:** [Candid Profile v0](https://github.com/B3Pay/Meant/blob/main/profile/docs/profile-v0.md). **Licence:** Apache-2.0.
13
13
  - **Versions:** the five Candid Profile packages (`@meantjs/profile`, `/format`, `/consent`, `/tools`,
@@ -56,11 +56,41 @@ bundles it with esbuild for the browser platform). The three SNS rows below are
56
56
  ## What happens, in order
57
57
 
58
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.
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
60
  3. **Forms** (`src/render.mjs`, `src/icrc21.mjs`), section 12:
61
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
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
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
+
64
94
  ## SNS proposal validators (`src/sns.mjs`)
65
95
 
66
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`.
@@ -125,7 +155,7 @@ The relying party is not trusted, so every value that comes from the call is sho
125
155
  |---|---|
126
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`) |
127
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`) |
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) |
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) |
129
159
 
130
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.
131
161
 
@@ -213,3 +243,5 @@ Untrusted third-party data lives in `test/upstream/icrc21/` (the standard, commi
213
243
  - The caller's account (`From`) and a fee line: see `docs/oisy-comparison.md` for why they are absent.
214
244
  - Other languages; `device_spec` hints beyond the two forms.
215
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,6 @@
1
1
  {
2
2
  "name": "@meantjs/consent",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
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": {
@@ -19,7 +19,11 @@
19
19
  "icrc-21",
20
20
  "consent-message",
21
21
  "sns",
22
- "wallet"
22
+ "wallet",
23
+ "evm",
24
+ "erc-7730",
25
+ "erc-8176",
26
+ "clear-signing"
23
27
  ],
24
28
  "type": "module",
25
29
  "sideEffects": false,
@@ -34,6 +38,14 @@
34
38
  "types": "./src/sns.d.mts",
35
39
  "default": "./src/sns.mjs"
36
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
+ },
37
49
  "./package.json": "./package.json"
38
50
  },
39
51
  "files": [
@@ -51,12 +63,13 @@
51
63
  "sns": "node scripts/sns-render.mjs"
52
64
  },
53
65
  "dependencies": {
54
- "@meantjs/profile": "0.1.0",
55
- "@meantjs/format": "0.1.0",
56
- "@candid-core/schema": "0.5.0"
66
+ "@candid-core/schema": "0.5.0",
67
+ "@meantjs/format": "0.2.0",
68
+ "@meantjs/profile": "0.2.0"
57
69
  },
58
70
  "devDependencies": {
59
71
  "@dfinity/didc": "0.0.4",
72
+ "ajv": "8.18.0",
60
73
  "commonmark": "0.31.2",
61
74
  "esbuild": "0.28.2"
62
75
  }
package/src/display.mjs CHANGED
@@ -77,6 +77,7 @@ export class Renderer {
77
77
 
78
78
  /** Candid text of a whole argument tuple (the "raw arguments" of spec section 11 item 1), tagged children formatted. */
79
79
  tupleText(nodes, values) {
80
+ if (isAbiParams(nodes)) return this.charge(this.#child(nodes[0], values[0], "arguments"));
80
81
  return this.charge(`(${nodes.map((n, i) => this.#child(n, values[i], `argument ${i}`)).join(", ")})`);
81
82
  }
82
83
 
@@ -87,6 +88,7 @@ export class Renderer {
87
88
  plainTupleText(nodes, values) {
88
89
  this.#plain = true;
89
90
  try {
91
+ if (isAbiParams(nodes)) return this.charge(this.#child(nodes[0], values[0], "arguments"));
90
92
  return this.charge(`(${nodes.map((n, i) => this.#child(n, values[i], `argument ${i}`)).join(", ")})`);
91
93
  } finally {
92
94
  this.#plain = false;
@@ -126,6 +128,7 @@ export class Renderer {
126
128
  }
127
129
 
128
130
  #raw(node, value, where) {
131
+ if (typeof node.abi === "string") return this.#abi(node, value, where);
129
132
  switch (node.candid) {
130
133
  case "null":
131
134
  case "reserved":
@@ -158,6 +161,30 @@ export class Renderer {
158
161
  }
159
162
  }
160
163
 
164
+ /**
165
+ * ABI text of an EVM value (spec section 15), as evm.mjs decodes it: an address in its EIP-55 form, bytes as 0x hex,
166
+ * a string quoted, an array in [ ], a tuple in ( ) with its component names.
167
+ */
168
+ #abi(node, value, where) {
169
+ switch (node.abi) {
170
+ case "address":
171
+ return value;
172
+ case "bool":
173
+ return value ? "true" : "false";
174
+ case "string":
175
+ return candidString(value);
176
+ case "array":
177
+ return `[${value.map((e, i) => this.#child(node.element, e, `${where}[${i}]`)).join(", ")}]`;
178
+ case "tuple":
179
+ return `(${entriesOf(node.fields)
180
+ .map(([key, child]) => `${key.startsWith("#") ? "" : `${key}: `}${this.#child(child, value[key], `${where}.${key}`)}`)
181
+ .join(", ")})`;
182
+ default:
183
+ if (value instanceof Uint8Array) return `0x${[...value].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
184
+ return String(value); // integers (decimal strings)
185
+ }
186
+ }
187
+
161
188
  /**
162
189
  * The ICRC-21 `Value` of one value (spec section 12). Falls back to `Text` whenever the typed form cannot carry
163
190
  * the value exactly: an amount above nat64 or negative or without a symbol, a timestamp or duration that is not a
@@ -197,6 +224,9 @@ export class Renderer {
197
224
  }
198
225
  }
199
226
 
227
+ /** An EVM function's arguments: its parameters as one tuple (spec section 15), written as the ABI writes them. */
228
+ const isAbiParams = (nodes) => nodes.length === 1 && nodes[0]?.abi === "tuple";
229
+
200
230
  /** `UTC`, `UTC+02:00`, `UTC-05:30`. */
201
231
  export function zoneName(offsetMinutes) {
202
232
  if (offsetMinutes === 0) return "UTC";
@@ -0,0 +1,81 @@
1
+ // Types for @meantjs/consent/erc7730: ERC-7730 clear-signing files from an EVM contract's resolved form, and the
2
+ // ERC-8176 attestation a reviewer signs for one (spec section 15, D-054). Hand-written; the source is src/erc7730.mjs.
3
+ // scripts/types.test.mjs keeps the declared names equal to the runtime exports.
4
+ import type { ResolvedForm } from "./index.mjs";
5
+
6
+ /** The exact-version ERC-7730 schema every exported file names in `$schema`. */
7
+ export const ERC7730_SCHEMA_URL: "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.1.0.schema.json";
8
+ /** ERC-8176's canonical EAS schema UID (`bytes32 descriptorHash`). */
9
+ export const ERC8176_SCHEMA_UID: "0xe023eef113c1670774801c34b377fdf612dd8a4d2fa92fe382e15bd91fafb5c2";
10
+ /** The canonical EAS contract on Ethereum mainnet. */
11
+ export const EAS_ADDRESS: "0xA1207F3BBa224E2c9c3c6D5aF63D0eb1582Ce587";
12
+
13
+ export interface Erc7730Deployment {
14
+ chainId: number;
15
+ /** EIP-55 text. */
16
+ address: string;
17
+ }
18
+ export interface Erc7730Field {
19
+ path: string;
20
+ label: string;
21
+ format: "tokenAmount" | "amount" | "date" | "duration" | "addressName" | "raw";
22
+ params?: Record<string, unknown>;
23
+ }
24
+ export interface Erc7730Descriptor {
25
+ $schema: typeof ERC7730_SCHEMA_URL;
26
+ context: { contract: { deployments: Erc7730Deployment[] } };
27
+ metadata?: {
28
+ owner?: string;
29
+ contractName?: string;
30
+ info?: { url: string; deploymentDate?: string };
31
+ token?: { name: string; ticker: string; decimals: number };
32
+ };
33
+ display: { formats: Record<string, { intent: string; interpolatedIntent?: string; fields?: Erc7730Field[] }> };
34
+ }
35
+ export interface Erc7730Options {
36
+ /** Default: the deployments every profile that lists deployments lists. Given, each must be listed by those profiles. */
37
+ deployments?: Erc7730Deployment[];
38
+ owner?: string;
39
+ contractName?: string;
40
+ info?: { url: string; deploymentDate?: string };
41
+ /** Say that the contract is the token its literal amounts are in: written as `metadata.token`. */
42
+ token?: { name: string };
43
+ }
44
+ /**
45
+ * The ERC-7730 file of an EVM contract's resolved form and its ERC-8176 `descriptorHash`. What the profile says that an
46
+ * ERC-7730 file cannot say is refused (E_EXPORT); no deployments is E_DEPLOYMENT.
47
+ */
48
+ export function erc7730Descriptor(resolved: ResolvedForm, options?: Erc7730Options): { descriptor: Erc7730Descriptor; descriptorHash: string };
49
+ /** ERC-8176 `descriptorHash`: `0x` and the Keccak-256 of the RFC 8785 (JCS) form, for a descriptor without `includes`. */
50
+ export function descriptorHash(descriptor: object): string;
51
+
52
+ export interface Erc8176Input {
53
+ descriptorHash: string;
54
+ /** Unix seconds. */
55
+ time: bigint | number | string;
56
+ /** Unix seconds; 0 never expires (ERC-8176 advises a bound). */
57
+ expirationTime: bigint | number | string;
58
+ /** 32 random bytes as 0x hex, chosen by the caller. */
59
+ salt: string;
60
+ /** Default 32 zero bytes. */
61
+ refUID?: string;
62
+ }
63
+ export interface Erc8176TypedData {
64
+ domain: { name: "EAS Attestation"; version: "0.26"; chainId: 1; verifyingContract: typeof EAS_ADDRESS };
65
+ primaryType: "Attest";
66
+ types: { Attest: { name: string; type: string }[] };
67
+ message: {
68
+ version: 2;
69
+ schema: typeof ERC8176_SCHEMA_UID;
70
+ recipient: string;
71
+ time: string;
72
+ expirationTime: string;
73
+ revocable: true;
74
+ refUID: string;
75
+ /** abi.encode(descriptorHash): the 32 bytes. */
76
+ data: string;
77
+ salt: string;
78
+ };
79
+ }
80
+ /** The unsigned ERC-8176 offchain attestation (EAS offchain v2 typed data) and its EIP-712 digest. Nothing is signed. */
81
+ export function erc8176Attestation(input: Erc8176Input): { typedData: Erc8176TypedData; digest: string };
@@ -0,0 +1,304 @@
1
+ // ERC-7730 clear-signing files from an EVM contract's resolved form, and the ERC-8176 attestation a reviewer signs for
2
+ // one (spec section 15, D-054). `@meantjs/consent/erc7730`.
3
+ //
4
+ // The file is the display half of a profile: per function a transaction calls, its `intent` (the intent title), its
5
+ // `interpolatedIntent` (the template) and one field per leaf of its parameters. The same resolved form always gives
6
+ // the same file, so the same `descriptorHash`. What a profile says that an ERC-7730 file cannot say is refused
7
+ // (E_EXPORT), never written as something near it:
8
+ //
9
+ // amount tokenAmount of the contract's own token (`tokenPath: "@.to"`): decimals and symbol read from the
10
+ // ERC-20 `decimals()` and `symbol()`, or given literally and carried as `metadata.token` when the
11
+ // caller says the contract is that token (`options.token`); `threshold` and `message` one to one.
12
+ // Refused otherwise (another method, a symbol missing, a token named by an argument).
13
+ // timestamp `date` with `encoding: "timestamp"`, seconds only duration `duration`, seconds only
14
+ // bytes `raw` (hex), hex only address `addressName`
15
+ // the rest `raw`; `describe.label` is the label, else the parameter's name
16
+ // payable one more field, `@.value` as `amount` (the native value, which ERC-7730 wallets know and a profile
17
+ // has no position for yet, O2)
18
+ //
19
+ // The descriptor binds to deployments (ERC-7730 `context.contract.deployments`): the profile's, or the caller's when
20
+ // every profile that lists deployments lists them too. `effect`, `idempotency`, `outcome` and the other agent tags
21
+ // have no ERC-7730 form and are left out: the file carries what a call says, not what it does.
22
+
23
+ import { AbiError, jcs, keccak256Hex, parseSignature, readAddress, typedDataDigest } from "@meantjs/profile/evm";
24
+ import { CODES, fail } from "./errors.mjs";
25
+ import { resolvePath } from "./paths.mjs";
26
+ import { abiView, entriesOf, labelOf, methodOf, tagArgs } from "./resolved.mjs";
27
+ import { parseTemplate } from "./template.mjs";
28
+
29
+ /** The exact-version schema every exported file names: an alias would move the hash when the alias moves. */
30
+ export const ERC7730_SCHEMA_URL = "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.1.0.schema.json";
31
+ /** ERC-8176's canonical EAS schema, `bytes32 descriptorHash` (no resolver, revocable). */
32
+ export const ERC8176_SCHEMA_UID = "0xe023eef113c1670774801c34b377fdf612dd8a4d2fa92fe382e15bd91fafb5c2";
33
+ /** The canonical EAS contract on Ethereum mainnet: the verifying contract of an offchain attestation. */
34
+ export const EAS_ADDRESS = "0xA1207F3BBa224E2c9c3c6D5aF63D0eb1582Ce587";
35
+
36
+ const ZERO32 = `0x${"0".repeat(64)}`;
37
+ const ZERO_ADDRESS = `0x${"0".repeat(40)}`;
38
+ const OPTIONS = new Set(["deployments", "owner", "contractName", "info", "token"]);
39
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
40
+ const nonEmpty = (v) => typeof v === "string" && v.trim() !== "";
41
+
42
+ /** `{ chainId, address }` of the caller or `{ chain_id, address }` of a profile, checked; null when not one. */
43
+ function deploymentOf(d) {
44
+ const chainId = d?.chainId ?? d?.chain_id;
45
+ if (!Number.isSafeInteger(chainId) || chainId < 1) return null;
46
+ try {
47
+ return { chainId, address: readAddress(d.address) };
48
+ } catch (e) {
49
+ if (e instanceof AbiError) return null;
50
+ throw e;
51
+ }
52
+ }
53
+
54
+ function deploymentsOf(resolved, given) {
55
+ const key = (d) => `${d.chainId}:${d.address}`;
56
+ const listing = (Array.isArray(resolved.profiles) ? resolved.profiles : []).filter((p) => Array.isArray(p?.deployments));
57
+ const sets = listing.map((p) => ({ name: p.name, keys: new Set(p.deployments.map(deploymentOf).filter(Boolean).map(key)) }));
58
+ let out;
59
+ if (given !== undefined) {
60
+ if (!Array.isArray(given) || given.length === 0) fail(CODES.REQUEST, "options.deployments must be a non-empty array of { chainId, address }");
61
+ out = given.map((d, i) => deploymentOf(d) ?? fail(CODES.REQUEST, `options.deployments[${i}] needs a positive integer chainId and an address (0x and 40 hex digits; mixed case must match EIP-55)`));
62
+ for (const d of out) for (const s of sets) if (!s.keys.has(key(d))) fail(CODES.DEPLOYMENT, `profile ${s.name} does not list ${d.address} on chain ${d.chainId}`);
63
+ } else {
64
+ if (listing.length === 0) fail(CODES.DEPLOYMENT, "an ERC-7730 file binds to deployments: give options.deployments, or bind them in a contract profile (binds.deployments)");
65
+ out = listing[0].deployments.map(deploymentOf).filter((d) => d && sets.every((s) => s.keys.has(key(d))));
66
+ if (out.length === 0) fail(CODES.DEPLOYMENT, `no deployment is listed by every profile that lists deployments (${listing.map((p) => p.name).join(", ")})`);
67
+ }
68
+ const seen = new Set();
69
+ return out.filter((d) => !seen.has(key(d)) && seen.add(key(d)));
70
+ }
71
+
72
+ function metadataOf(options) {
73
+ const metadata = {};
74
+ for (const k of ["owner", "contractName"]) {
75
+ if (options[k] === undefined) continue;
76
+ if (!nonEmpty(options[k])) fail(CODES.REQUEST, `options.${k} must be a non-empty string`);
77
+ metadata[k] = options[k];
78
+ }
79
+ if (options.info !== undefined) {
80
+ const { url, deploymentDate, ...rest } = isObject(options.info) ? options.info : {};
81
+ if (!nonEmpty(url) || (deploymentDate !== undefined && !nonEmpty(deploymentDate)) || Object.keys(rest).length) fail(CODES.REQUEST, "options.info must be { url, deploymentDate? } with strings");
82
+ metadata.info = deploymentDate === undefined ? { url } : { url, deploymentDate };
83
+ }
84
+ return metadata;
85
+ }
86
+
87
+ /** Whether the contract has the ERC-20 `decimals()` and `symbol()` an ERC-7730 wallet reads for `tokenPath: "@.to"`. */
88
+ function erc20Metadata(resolved) {
89
+ const d = resolved.methods.decimals;
90
+ const s = resolved.methods.symbol;
91
+ return isObject(d) && isObject(s) && d.mode === "query" && s.mode === "query" && d.signature === "decimals()" && s.signature === "symbol()" && /^uint\d+$/.test(d.results?.[0]?.abi ?? "") && s.results?.[0]?.abi === "string" && d.results.length === 1 && s.results.length === 1;
92
+ }
93
+
94
+ /** One ERC-7730 field for a leaf of a function's parameters. */
95
+ function fieldOf(node, path, label, ctx, where) {
96
+ const field = { path, label };
97
+ const amount = tagArgs(node, "amount");
98
+ if (amount !== undefined) {
99
+ const dynamic = amount.decimals?.from === "query:decimals" && amount.symbol?.from === "query:symbol";
100
+ const literal = Number.isInteger(amount.decimals) && typeof amount.symbol === "string";
101
+ if (dynamic) {
102
+ if (!ctx.erc20) return ctx.problem(where, "amount reads query:decimals and query:symbol, but the contract has no ERC-20 decimals() and symbol() an ERC-7730 wallet reads");
103
+ ctx.dynamic = true;
104
+ } else if (literal) {
105
+ if (!ctx.tokenName) return ctx.problem(where, "amount with literal decimals and symbol: an ERC-7730 file carries them only as the contract's own token (metadata.token); pass options.token = { name } if this contract is that token");
106
+ const token = { decimals: amount.decimals, ticker: amount.symbol };
107
+ if (ctx.token && (ctx.token.decimals !== token.decimals || ctx.token.ticker !== token.ticker)) return ctx.problem(where, `amount in ${token.ticker} with ${token.decimals} decimals, where another is in ${ctx.token.ticker} with ${ctx.token.decimals}: an ERC-7730 file has one metadata.token`);
108
+ ctx.token = token;
109
+ } else {
110
+ return ctx.problem(where, "amount without the contract's own decimals and symbol (both read from decimals() and symbol(), or both literal) has no ERC-7730 form");
111
+ }
112
+ const params = { tokenPath: "@.to" };
113
+ if (amount.threshold !== undefined) {
114
+ params.threshold = `0x${BigInt(amount.threshold).toString(16)}`;
115
+ params.message = amount.message ?? "Unlimited";
116
+ }
117
+ return { ...field, format: "tokenAmount", params };
118
+ }
119
+ const time = tagArgs(node, "timestamp") ?? tagArgs(node, "duration");
120
+ if (time !== undefined) {
121
+ const kind = tagArgs(node, "timestamp") !== undefined ? "timestamp" : "duration";
122
+ if (time.unit !== "s") return ctx.problem(where, `${kind} in ${time.unit}: ERC-7730 ${kind === "timestamp" ? "dates" : "durations"} are in seconds`);
123
+ return kind === "timestamp" ? { ...field, format: "date", params: { encoding: "timestamp" } } : { ...field, format: "duration" };
124
+ }
125
+ const bytes = tagArgs(node, "bytes");
126
+ if (bytes !== undefined && bytes.encoding !== "hex") return ctx.problem(where, `bytes as ${bytes.encoding}: ERC-7730 shows bytes as hex only`);
127
+ return { ...field, format: node.abi === "address" ? "addressName" : "raw" };
128
+ }
129
+
130
+ /** Every leaf of a viewed parameter tree, depth first in ABI order: its ERC-7730 path and its label. */
131
+ function leaves(node, keys, out) {
132
+ if (node.candid === "record") for (const [k, child] of entriesOf(node.fields)) leaves(child, [...keys, k], out);
133
+ else if (node.candid === "vec") leaves(node.element, [...keys, "[]"], out);
134
+ else out.push({ node, path: keys.join("."), label: labelOf(node) ?? [...keys].reverse().find((k) => k !== "[]") });
135
+ return out;
136
+ }
137
+
138
+ /** A parameter or tuple component without a name: an ERC-7730 key names every one, and paths need the names. */
139
+ function unnamed(params, at = "") {
140
+ for (const [i, p] of params.entries()) {
141
+ const here = `${at}${p.name ?? `#${i}`}`;
142
+ if (p.name === null) return here;
143
+ let t = p.type;
144
+ while (t.kind === "array") t = t.element;
145
+ if (t.kind === "tuple") {
146
+ const hit = unnamed(t.components, `${here}.`);
147
+ if (hit) return hit;
148
+ }
149
+ }
150
+ return null;
151
+ }
152
+
153
+ /** The template as an ERC-7730 interpolatedIntent: `{(0).to}` reads `{to}`, `[[` and `]]` are plain brackets. */
154
+ function interpolated(template, method, ctx, where) {
155
+ let parts;
156
+ try {
157
+ parts = parseTemplate(template);
158
+ } catch (e) {
159
+ return ctx.problem(where, e.message);
160
+ }
161
+ let out = "";
162
+ for (const p of parts) {
163
+ if (p.t === "lit") out += p.text.replaceAll("{", "{{").replaceAll("}", "}}");
164
+ else if (p.t === "opt") return ctx.problem(where, "an optional [ ] segment has no ERC-7730 form");
165
+ else {
166
+ const rp = resolvePath(method, p.path);
167
+ if (rp.error) return ctx.problem(where, `{${p.path}}: ${rp.error}`);
168
+ if (rp.steps.some((s) => s.kind === "elem")) return ctx.problem(where, `{${p.path}} reads every element of an array; an ERC-7730 interpolation shows one value`);
169
+ if (rp.node.candid === "record" || rp.node.candid === "vec") return ctx.problem(where, `{${p.path}} is a whole ${rp.node.abi}; an ERC-7730 interpolation shows one formatted field`);
170
+ out += `{${rp.steps.map((s) => s.key).join(".")}}`;
171
+ }
172
+ }
173
+ return out;
174
+ }
175
+
176
+ /** Every string of the file must be well-formed Unicode: RFC 8785 (JCS) serializes no lone surrogate. */
177
+ function checkText(v, where) {
178
+ if (typeof v === "string" && !v.isWellFormed()) fail(CODES.EXPORT, `${where}: the text has a lone surrogate, which RFC 8785 cannot serialize`);
179
+ if (Array.isArray(v)) v.forEach((x, i) => checkText(x, `${where}[${i}]`));
180
+ else if (isObject(v)) for (const [k, x] of Object.entries(v)) checkText(k, where), checkText(x, `${where}.${k}`);
181
+ }
182
+
183
+ /**
184
+ * The ERC-7730 file of an EVM contract's resolved form, and its ERC-8176 `descriptorHash`.
185
+ *
186
+ * @param {object} resolved an EVM contract's resolved form (`chain: "evm"`)
187
+ * @param {object} [options]
188
+ * @param {{ chainId: number, address: string }[]} [options.deployments] default: the deployments every profile lists
189
+ * @param {string} [options.owner] ERC-7730 `metadata.owner`
190
+ * @param {string} [options.contractName] `metadata.contractName`
191
+ * @param {{ url: string, deploymentDate?: string }} [options.info] `metadata.info`
192
+ * @param {{ name: string }} [options.token] the contract is the token its literal amounts are in: `metadata.token`
193
+ * @returns {{ descriptor: object, descriptorHash: string }}
194
+ */
195
+ export function erc7730Descriptor(resolved, options = {}) {
196
+ if (!isObject(resolved) || resolved.format !== "candid-profile-resolved" || !isObject(resolved.methods) || resolved.chain !== "evm") methodOf(resolved, "", "evm"); // throws the right error
197
+ if (!isObject(options)) fail(CODES.REQUEST, "options must be an object");
198
+ for (const k of Object.keys(options)) if (!OPTIONS.has(k)) fail(CODES.REQUEST, `unknown option ${JSON.stringify(k)}; expected one of ${[...OPTIONS].join(", ")}`);
199
+ if (options.token !== undefined && !(isObject(options.token) && nonEmpty(options.token.name) && Object.keys(options.token).length === 1)) fail(CODES.REQUEST, "options.token must be { name } with a non-empty string");
200
+
201
+ const problems = [];
202
+ const ctx = { erc20: erc20Metadata(resolved), tokenName: options.token?.name, token: null, dynamic: false, problem: (at, message) => void problems.push(`${at}: ${message}`) };
203
+ const deployments = deploymentsOf(resolved, options.deployments);
204
+ const metadata = metadataOf(options);
205
+
206
+ const formats = {};
207
+ for (const [name, m] of entriesOf(resolved.methods)) {
208
+ if (!isObject(m) || m.mode !== "update") continue;
209
+ const method = methodOf(resolved, name, "evm");
210
+ let f;
211
+ try {
212
+ f = parseSignature(String(method.signature), `methods.${name}.signature`);
213
+ } catch (e) {
214
+ if (e instanceof AbiError) fail(CODES.RESOLVED_FORM, e.message);
215
+ throw e;
216
+ }
217
+ if (f.name !== name || f.selector !== method.selector) fail(CODES.RESOLVED_FORM, `methods.${name}: the signature ${method.signature} does not hash to ${method.selector}`);
218
+ const missing = unnamed(f.inputs);
219
+ if (missing !== null) {
220
+ ctx.problem(name, `parameter ${missing} has no name; an ERC-7730 key names every parameter`);
221
+ continue;
222
+ }
223
+ const viewed = { ...method, args: method.args.map((n, i) => abiView(n, `${name}(${i})`)) };
224
+ const intent = method.tags?.intent?.args;
225
+ const entry = { intent: nonEmpty(intent?.title) ? intent.title : (labelOf(method) ?? `Call ${name}`) };
226
+ if (typeof intent?.template === "string") {
227
+ const text = interpolated(intent.template, viewed, ctx, `${name}: intent template`);
228
+ if (text !== undefined) entry.interpolatedIntent = text;
229
+ }
230
+ const fields = viewed.args.length ? leaves(viewed.args[0], [], []).map((l) => fieldOf(l.node, l.path, l.label, ctx, `${name}(0).${l.path.replaceAll(".[]", "[]")}`)) : [];
231
+ if (method.state_mutability === "payable") fields.push({ path: "@.value", label: "Value sent", format: "amount" });
232
+ if (fields.length) entry.fields = fields;
233
+ formats[method.signature] = entry;
234
+ }
235
+ if (ctx.dynamic && ctx.token) problems.push("amounts: some read decimals() and symbol(), others are literal; an ERC-7730 wallet reads one token for @.to");
236
+ if (problems.length) fail(CODES.EXPORT, `no ERC-7730 file: ${problems.join("; ")}`);
237
+ if (Object.keys(formats).length === 0) fail(CODES.EXPORT, "no ERC-7730 file: the contract has no function a transaction calls (every function is view or pure)");
238
+
239
+ if (ctx.token) metadata.token = { name: ctx.tokenName, ticker: ctx.token.ticker, decimals: ctx.token.decimals };
240
+ const descriptor = { $schema: ERC7730_SCHEMA_URL, context: { contract: { deployments } } };
241
+ if (Object.keys(metadata).length) descriptor.metadata = metadata;
242
+ descriptor.display = { formats };
243
+ return { descriptor, descriptorHash: descriptorHash(descriptor) };
244
+ }
245
+
246
+ /** ERC-8176 `descriptorHash`: Keccak-256 of the RFC 8785 (JCS) form of a descriptor without `includes`. */
247
+ export function descriptorHash(descriptor) {
248
+ if (!isObject(descriptor)) fail(CODES.REQUEST, "a descriptor is a JSON object");
249
+ if (Object.hasOwn(descriptor, "includes")) fail(CODES.REQUEST, "resolve the descriptor's includes first (ERC-8176 hashes the merged file)");
250
+ checkText(descriptor, "descriptor");
251
+ return `0x${keccak256Hex(jcs(descriptor))}`;
252
+ }
253
+
254
+ const ATTEST = [
255
+ { name: "version", type: "uint16" },
256
+ { name: "schema", type: "bytes32" },
257
+ { name: "recipient", type: "address" },
258
+ { name: "time", type: "uint64" },
259
+ { name: "expirationTime", type: "uint64" },
260
+ { name: "revocable", type: "bool" },
261
+ { name: "refUID", type: "bytes32" },
262
+ { name: "data", type: "bytes" },
263
+ { name: "salt", type: "bytes32" },
264
+ ];
265
+
266
+ function uint64(v, name) {
267
+ const s = typeof v === "bigint" ? v.toString() : Number.isSafeInteger(v) ? String(v) : v;
268
+ if (typeof s !== "string" || !/^(0|[1-9][0-9]{0,19})$/.test(s) || BigInt(s) >= 2n ** 64n) fail(CODES.REQUEST, `${name} must be a uint64 (seconds since the Unix epoch), got ${String(v)}`);
269
+ return s;
270
+ }
271
+
272
+ function bytes32(v, name) {
273
+ if (typeof v !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(v)) fail(CODES.REQUEST, `${name} must be 0x and 64 hex digits`);
274
+ return v.toLowerCase();
275
+ }
276
+
277
+ /**
278
+ * The unsigned ERC-8176 offchain attestation of a descriptor: EAS offchain attestation v2 typed data for a reviewer to
279
+ * sign with their own key (EIP-712), and its digest. Nothing is signed or sent here.
280
+ *
281
+ * @param {object} input
282
+ * @param {string} input.descriptorHash from erc7730Descriptor (or descriptorHash)
283
+ * @param {bigint | number | string} input.time when the attestation is made, in Unix seconds
284
+ * @param {bigint | number | string} input.expirationTime when it expires, in Unix seconds; 0 never (ERC-8176 advises a bound)
285
+ * @param {string} input.salt 32 random bytes, 0x hex: the caller's, so the same input gives the same output
286
+ * @param {string} [input.refUID] an attestation this one refers to; default none (32 zero bytes)
287
+ * @returns {{ typedData: { domain: object, primaryType: "Attest", types: { Attest: { name: string, type: string }[] }, message: object }, digest: string }}
288
+ */
289
+ export function erc8176Attestation({ descriptorHash: hash, time, expirationTime, salt, refUID = ZERO32 } = {}) {
290
+ const message = {
291
+ version: 2,
292
+ schema: ERC8176_SCHEMA_UID,
293
+ recipient: ZERO_ADDRESS,
294
+ time: uint64(time, "time"),
295
+ expirationTime: uint64(expirationTime, "expirationTime"),
296
+ revocable: true,
297
+ refUID: bytes32(refUID, "refUID"),
298
+ data: bytes32(hash, "descriptorHash"), // abi.encode(bytes32) is the 32 bytes themselves
299
+ salt: bytes32(salt, "salt"),
300
+ };
301
+ if (message.expirationTime !== "0" && BigInt(message.expirationTime) <= BigInt(message.time)) fail(CODES.REQUEST, "expirationTime must be 0 or after time");
302
+ const typedData = { domain: { name: "EAS Attestation", version: "0.26", chainId: 1, verifyingContract: EAS_ADDRESS }, primaryType: "Attest", types: { Attest: ATTEST.map((f) => ({ ...f })) }, message };
303
+ return { typedData, digest: typedDataDigest(typedData) };
304
+ }
package/src/errors.mjs CHANGED
@@ -21,6 +21,7 @@ export const CODES = Object.freeze({
21
21
  TOO_LARGE: "E_TOO_LARGE", // the arguments exceed the bounds of a consent message (never truncated)
22
22
  LANGUAGE: "E_LANGUAGE", // only with languagePolicy "error": the requested language is not supported
23
23
  REQUEST: "E_REQUEST", // the ICRC-21 request itself is malformed
24
+ DEPLOYMENT: "E_DEPLOYMENT", // an EVM call's target is not a deployment its profile lists (spec section 15)
24
25
  // this generator cannot describe the call (ICRC-21 ConsentMessageUnavailable)
25
26
  UNSUPPORTED_TYPE: "E_UNSUPPORTED_TYPE", // a position the resolved form cannot type (recursive cut, func, service)
26
27
  // the profile or the caller's context is wrong (ICRC-21 GenericError)
@@ -32,6 +33,8 @@ export const CODES = Object.freeze({
32
33
  ARG: "E_ARG",
33
34
  TAG_TYPE: "E_TAG_TYPE",
34
35
  TAG_CONFLICT: "E_TAG_CONFLICT",
36
+ // the ERC-7730 export (erc7730.mjs): the resolved form says something an ERC-7730 file cannot say (spec section 15)
37
+ EXPORT: "E_EXPORT",
35
38
  });
36
39
 
37
40
  /** ICRC-21 GenericError.error_code values this package uses (a nat chosen by the canister; see the README). */
package/src/evm.d.mts ADDED
@@ -0,0 +1,51 @@
1
+ // Types for @meantjs/consent/evm: the consent text of an EVM call (spec section 15, D-054). Hand-written; the source is
2
+ // src/evm.mjs. scripts/types.test.mjs keeps the declared names equal to the runtime exports.
3
+ import type { AbiLimits, AbiValue } from "@meantjs/profile/evm";
4
+ import type { ConsentDiagnostic, Model, Provenance, ResolvedForm } from "./index.mjs";
5
+
6
+ /** An EVM contract's resolved form: `chain: "evm"`, and each method with its selector and named signature. */
7
+ export type EvmResolvedForm = ResolvedForm & { chain: "evm" };
8
+
9
+ /**
10
+ * One call decoded against a resolved form: the function its selector names, and its arguments keyed by parameter
11
+ * name (`#i` for an unnamed one). Strict: anything but the canonical encoding of the values read is E_DECODE.
12
+ * Throws E_RESOLVED_FORM (not an EVM resolved form), E_METHOD_UNKNOWN (no such selector) or E_DECODE.
13
+ */
14
+ export function decodeCalldata(resolved: ResolvedForm, calldata: Uint8Array | string, limits?: AbiLimits): { method: string; selector: string; values: Record<string, AbiValue> };
15
+
16
+ export interface EvmConsentInput {
17
+ resolved: ResolvedForm;
18
+ /** The selector and the ABI-encoded arguments, as bytes or 0x hex. */
19
+ calldata: Uint8Array | string;
20
+ /** The contract called: shown in the message, and checked against the deployments its profiles list (E_DEPLOYMENT). */
21
+ to: string;
22
+ /** Needed when a profile lists deployments. */
23
+ chainId?: number | bigint | string;
24
+ /** Resolved dynamic refs, keyed by `from`: `{ "query:decimals": 6, "query:symbol": "USDC" }`. */
25
+ refs?: Record<string, unknown>;
26
+ /** Default "generic" (Markdown). */
27
+ form?: "generic" | "fields";
28
+ /** Generic form only: show timestamps in this zone (default UTC). */
29
+ utcOffsetMinutes?: number | null;
30
+ /** Append the decoded raw arguments (spec section 11 item 1). */
31
+ rawArguments?: boolean;
32
+ limits?: AbiLimits;
33
+ }
34
+ /**
35
+ * The consent text of an EVM call: the same model and the same two layouts as an ICRC-21 message, without its envelope,
36
+ * ending with the contract's address and chain. A payable function is refused (E_UNSUPPORTED_TYPE): its native value
37
+ * has no position in a profile yet.
38
+ */
39
+ export function evmConsent(input: EvmConsentInput): {
40
+ form: "generic" | "fields";
41
+ /** Markdown for "generic"; the intent line and `[label, text]` rows for "fields". */
42
+ message: string | { intent: string; fields: [string, string][] };
43
+ method: string;
44
+ selector: string;
45
+ /** The contract, in its EIP-55 form. */
46
+ to: string;
47
+ chainId: string | null;
48
+ diagnostics: ConsentDiagnostic[];
49
+ provenance: Provenance;
50
+ model: Model;
51
+ };
package/src/evm.mjs ADDED
@@ -0,0 +1,129 @@
1
+ // EVM calldata -> the consent text of a call (spec section 15, D-054). ICRC-21 is the Internet Computer's, so an EVM
2
+ // call gets the same model and the same two layouts without the ICRC-21 envelope: the generic form (Markdown) and the
3
+ // fields form as plain text.
4
+ //
5
+ // Steps:
6
+ // 1. the resolved form must be an EVM contract's (`chain: "evm"`), and the call's target, when a profile lists
7
+ // deployments, one of them (a profile describes the contracts it binds, never a look-alike);
8
+ // 2. the selector picks the function; its `signature` gives the parameter types, and calldata is decoded strictly
9
+ // (`@meantjs/profile/evm` decodeParams: anything but the canonical encoding of the values read is refused);
10
+ // 3. the resolved tree is read as a Candid-shaped view (a tuple is a record, an array a vec), so the template, the
11
+ // paths and the fields are those of a Candid call (model.mjs);
12
+ // 4. the text ends with the contract's address and chain: a pack applies to any contract whose functions match, so a
13
+ // person must see which contract asks (spec section 11).
14
+ //
15
+ // Refused: a payable function (its native value has no position yet, O2), and a call with no matching selector.
16
+
17
+ import { AbiError, decodeParams, parseSignature, readAddress } from "@meantjs/profile/evm";
18
+ import { Renderer } from "./display.mjs";
19
+ import { CODES, ConsentError, fail } from "./errors.mjs";
20
+ import { modelOf } from "./model.mjs";
21
+ import { renderGeneric, renderPlain } from "./render.mjs";
22
+ import { abiView, methodOf } from "./resolved.mjs";
23
+ import { codeSpan } from "./text.mjs";
24
+
25
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
26
+
27
+ function bytesOf(calldata) {
28
+ if (calldata instanceof Uint8Array) return calldata;
29
+ if (typeof calldata === "string" && /^0x(?:[0-9a-fA-F]{2})*$/.test(calldata)) return Uint8Array.from(calldata.slice(2).match(/../g) ?? [], (h) => parseInt(h, 16));
30
+ fail(CODES.REQUEST, "calldata must be a Uint8Array or 0x hex text");
31
+ }
32
+
33
+ function chainOf(chainId) {
34
+ if (chainId === undefined || chainId === null) return null;
35
+ const s = typeof chainId === "number" && Number.isSafeInteger(chainId) ? String(chainId) : typeof chainId === "bigint" ? chainId.toString() : chainId;
36
+ if (typeof s !== "string" || !/^[1-9][0-9]{0,19}$/.test(s)) fail(CODES.REQUEST, `chainId must be a positive integer, got ${String(chainId)}`);
37
+ return s;
38
+ }
39
+
40
+ /** The function of a selector, from the resolved form; its signature is checked against the selector. */
41
+ function functionOf(resolved, selector) {
42
+ const hit = Object.entries(resolved.methods).find(([, m]) => isObject(m) && m.selector === selector);
43
+ if (!hit) fail(CODES.METHOD_UNKNOWN, `the resolved form has no function with selector ${selector}`);
44
+ const [name, method] = hit;
45
+ let f;
46
+ try {
47
+ f = parseSignature(String(method.signature), `methods.${name}.signature`);
48
+ } catch (e) {
49
+ if (e instanceof AbiError) fail(CODES.RESOLVED_FORM, e.message);
50
+ throw e;
51
+ }
52
+ if (f.name !== name || f.selector !== selector) fail(CODES.RESOLVED_FORM, `methods.${name}: the signature ${method.signature} does not hash to ${selector}`);
53
+ return { name, method: methodOf(resolved, name, "evm"), f };
54
+ }
55
+
56
+ /**
57
+ * Decode one EVM call against a resolved form: which function, and its arguments.
58
+ *
59
+ * @param {object} resolved an EVM contract's resolved form (`chain: "evm"`)
60
+ * @param {Uint8Array | string} calldata the selector and the ABI-encoded arguments
61
+ * @param {{ maxBytes?: number, maxDepth?: number, maxElements?: number }} [limits]
62
+ * @returns {{ method: string, selector: string, values: Record<string, unknown> }}
63
+ */
64
+ export function decodeCalldata(resolved, calldata, limits) {
65
+ if (!isObject(resolved) || resolved.format !== "candid-profile-resolved" || !isObject(resolved.methods) || resolved.chain !== "evm") methodOf(resolved, "", "evm"); // throws the right error
66
+ const data = bytesOf(calldata);
67
+ if (data.length < 4) fail(CODES.DECODE, `calldata of ${data.length} bytes has no selector`);
68
+ const selector = `0x${[...data.subarray(0, 4)].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
69
+ const { name, f } = functionOf(resolved, selector);
70
+ try {
71
+ return { method: name, selector, values: decodeParams(f.inputs, data.subarray(4), limits) };
72
+ } catch (e) {
73
+ if (e instanceof AbiError) throw new ConsentError(CODES.DECODE, `${name}: ${e.message}`, { cause: e });
74
+ throw e;
75
+ }
76
+ }
77
+
78
+ /** Every profile that lists deployments must list this one (chain id and address). */
79
+ function checkTarget(resolved, to, chain) {
80
+ for (const p of Array.isArray(resolved.profiles) ? resolved.profiles : []) {
81
+ if (!Array.isArray(p?.deployments)) continue;
82
+ if (chain === null) fail(CODES.REQUEST, `profile ${p.name} binds deployments: give the chainId of the call`);
83
+ const listed = p.deployments.some((d) => String(d.chain_id) === chain && typeof d.address === "string" && d.address.toLowerCase() === to.toLowerCase());
84
+ if (!listed) fail(CODES.DEPLOYMENT, `profile ${p.name} describes ${p.deployments.map((d) => `${d.address} on chain ${d.chain_id}`).join(", ")}, not ${to} on chain ${chain}`);
85
+ }
86
+ }
87
+
88
+ /**
89
+ * The consent text of an EVM call.
90
+ *
91
+ * @param {object} input
92
+ * @param {object} input.resolved an EVM contract's resolved form
93
+ * @param {Uint8Array | string} input.calldata
94
+ * @param {string} input.to the contract called (shown, and checked against the profiles' deployments)
95
+ * @param {number | bigint | string} [input.chainId] needed when a profile lists deployments
96
+ * @param {Record<string, unknown>} [input.refs] resolved dynamic refs, for example { "query:decimals": 6, "query:symbol": "USDC" }
97
+ * @param {"generic" | "fields"} [input.form] default "generic"
98
+ * @param {number | null} [input.utcOffsetMinutes] generic form only (default 0, UTC)
99
+ * @param {boolean} [input.rawArguments] append the decoded arguments (spec section 11 item 1)
100
+ * @param {object} [input.limits] decoder limits (maxBytes, maxDepth, maxElements)
101
+ * @returns {{ form: string, message: string | { intent: string, fields: Array<[string, string]> }, method: string, selector: string, to: string, chainId: string | null, diagnostics: object[], provenance: object, model: object }}
102
+ */
103
+ export function evmConsent({ resolved, calldata, to, chainId, refs = {}, form = "generic", utcOffsetMinutes = null, rawArguments = false, limits }) {
104
+ if (form !== "generic" && form !== "fields") fail(CODES.REQUEST, `form must be "generic" or "fields", got ${JSON.stringify(form)}`);
105
+ let target;
106
+ try {
107
+ target = readAddress(to);
108
+ } catch (e) {
109
+ if (e instanceof AbiError) fail(CODES.REQUEST, `to: ${e.message}`);
110
+ throw e;
111
+ }
112
+ const chain = chainOf(chainId);
113
+ const { method: name, selector, values } = decodeCalldata(resolved, calldata, limits);
114
+ checkTarget(resolved, target, chain);
115
+ const method = methodOf(resolved, name, "evm");
116
+ if (method.state_mutability === "payable") fail(CODES.UNSUPPORTED_TYPE, `${name} is payable: the native value a call sends has no position in a profile yet (spec section 15, O2)`);
117
+ const viewed = { ...method, args: method.args.map((n, i) => abiView(n, `${name}(${i})`)) };
118
+ const model = modelOf(resolved, name, viewed, viewed.args.length ? [values] : []);
119
+ const where = `${target}${chain === null ? "" : ` on chain ${chain}`}`;
120
+ const done = (rest) => ({ ...rest, method: name, selector, to: target, chainId: chain, diagnostics: model.diagnostics, provenance: model.provenance, model });
121
+ if (form === "fields") {
122
+ const plain = renderPlain(model, new Renderer({ refs, diagnostics: model.diagnostics }), { rawArguments });
123
+ plain.fields.push(["Contract", where]);
124
+ return done({ form, message: plain });
125
+ }
126
+ const renderer = new Renderer({ refs, utcOffsetMinutes: utcOffsetMinutes ?? 0, diagnostics: model.diagnostics });
127
+ const text = `${renderGeneric(model, renderer, { rawArguments })}\n\n**Contract:** ${codeSpan(target)}${chain === null ? "" : ` on chain ${chain}`}`;
128
+ return done({ form, message: text });
129
+ }
package/src/index.d.mts CHANGED
@@ -206,6 +206,7 @@ export const CODES: Readonly<{
206
206
  TOO_LARGE: "E_TOO_LARGE";
207
207
  LANGUAGE: "E_LANGUAGE";
208
208
  REQUEST: "E_REQUEST";
209
+ DEPLOYMENT: "E_DEPLOYMENT";
209
210
  UNSUPPORTED_TYPE: "E_UNSUPPORTED_TYPE";
210
211
  RESOLVED_FORM: "E_RESOLVED_FORM";
211
212
  TEMPLATE: "E_TEMPLATE";
@@ -214,6 +215,7 @@ export const CODES: Readonly<{
214
215
  ARG: "E_ARG";
215
216
  TAG_TYPE: "E_TAG_TYPE";
216
217
  TAG_CONFLICT: "E_TAG_CONFLICT";
218
+ EXPORT: "E_EXPORT";
217
219
  }>;
218
220
  export type ConsentErrorCode = (typeof CODES)[keyof typeof CODES];
219
221
  /** ICRC-21 `GenericError.error_code` values this package uses. */
package/src/model.mjs CHANGED
@@ -3,9 +3,10 @@
3
3
  // disagree about which values the call carries.
4
4
  //
5
5
  // Which fields appear:
6
- // - a method with an `intent` tag: the placeholders of its template that are present (spec section 6; section 12:
7
- // "one (label, Value) row per placeholder"), in template order, once per distinct path;
8
- // - a method without one: the generic intent. Title from `describe.label` of the method, else `Call <method>`;
6
+ // - a call with an intent, its case's (spec section 5.2, D-053) or else its method's: the placeholders of its template
7
+ // that are present (spec section 6; section 12: "one (label, Value) row per placeholder"), in template order,
8
+ // once per distinct path;
9
+ // - a call without one: the generic intent. Title from `describe.label` of the method, else `Call <method>`;
9
10
  // one field per leaf of the decoded arguments (records are flattened, tagged positions such as accounts are
10
11
  // leaves, absent optionals are left out). More than MAX_FIELDS fields or MAX_ELEMENTS list elements is
11
12
  // refused (E_TOO_LARGE), never truncated.
@@ -13,7 +14,7 @@
13
14
  import { VALUE_TAGS } from "@meantjs/format";
14
15
  import { decodeArguments } from "./decode.mjs";
15
16
  import { CODES, ConsentError, fail } from "./errors.mjs";
16
- import { effectKind, entriesOf, labelOf, methodOf } from "./resolved.mjs";
17
+ import { callTag, effectKind, entriesOf, labelOf, methodOf } from "./resolved.mjs";
17
18
  import { parseTemplate } from "./template.mjs";
18
19
  import { resolvePath, valuesAt } from "./paths.mjs";
19
20
 
@@ -31,9 +32,9 @@ export const MAX_ELEMENTS = 256;
31
32
  *
32
33
  * @typedef {object} Model
33
34
  * @property {string} method
34
- * @property {string} effect the effect kind of the method (spec section 5.3)
35
+ * @property {string} effect the effect kind of the call: its case's, else its method's (spec section 5.3, D-053)
35
36
  * @property {string} title
36
- * @property {SentencePart[] | null} sentence null for a method without an intent template
37
+ * @property {SentencePart[] | null} sentence null for a call without an intent template
37
38
  * @property {Field[]} fields
38
39
  * @property {object[]} argNodes
39
40
  * @property {unknown[]} argValues
@@ -48,13 +49,18 @@ export const MAX_ELEMENTS = 256;
48
49
  */
49
50
  export function buildModel({ resolved, method: name, arg, limits }) {
50
51
  const method = methodOf(resolved, name);
51
- const argValues = decodeArguments(method, arg, limits);
52
+ return modelOf(resolved, name, method, decodeArguments(method, arg, limits));
53
+ }
54
+
55
+ /** The model of a call whose arguments are decoded already (evm.mjs decodes calldata, then comes here). */
56
+ export function modelOf(resolved, name, method, argValues) {
52
57
  const diagnostics = [];
53
- const intent = method.tags?.intent?.args;
58
+ const intentTag = callTag(method, argValues, "intent"); // the call's intent: its case's, else its method's (D-053)
59
+ const intent = intentTag?.args;
54
60
  const title = typeof intent?.title === "string" && intent.title.trim() !== "" ? intent.title : (labelOf(method) ?? `Call ${name}`);
55
- const tagOrigin = intent ? { origin: method.tags.intent.origin, ...(method.tags.intent.profile === undefined ? {} : { profile: method.tags.intent.profile }) } : null;
61
+ const tagOrigin = intent ? { origin: intentTag.origin, ...(intentTag.profile === undefined ? {} : { profile: intentTag.profile }) } : null;
56
62
  const provenance = { intent: tagOrigin, profiles: Array.isArray(resolved.profiles) ? resolved.profiles : [] };
57
- const base = { method: name, effect: effectKind(method), title, argNodes: method.args, argValues, diagnostics, provenance };
63
+ const base = { method: name, effect: effectKind(method, callTag(method, argValues, "effect")), title, argNodes: method.args, argValues, diagnostics, provenance };
58
64
 
59
65
  if (intent && typeof intent.template === "string") {
60
66
  try {
package/src/render.mjs CHANGED
@@ -50,6 +50,24 @@ export function renderFields(model, renderer, { rawArguments = false } = {}) {
50
50
  return { intent, fields };
51
51
  }
52
52
 
53
+ /**
54
+ * The fields form as plain text, for a message that is not ICRC-21's (an EVM call, spec section 15): the same intent
55
+ * line as renderFields, and each row's values joined by ", ".
56
+ *
57
+ * @returns {{ intent: string, fields: Array<[string, string]> }}
58
+ */
59
+ export function renderPlain(model, renderer, { rawArguments = false } = {}) {
60
+ const title = plainLabel(model.title, model.method);
61
+ let intent = title;
62
+ if (model.sentence !== null) {
63
+ const sentence = model.sentence.map((p) => ("lit" in p ? oneLine(p.lit) : fieldPlain(p.field, renderer))).join("");
64
+ if (sentence.trim() !== "") intent = `${title}: ${sentence.trim()}`;
65
+ }
66
+ const fields = model.fields.map((f) => [plainLabel(f.label, f.path), fieldPlain(f, renderer)]);
67
+ if (rawArguments) fields.push(["Raw arguments", visible(renderer.tupleText(model.argNodes, model.argValues))]);
68
+ return { intent, fields };
69
+ }
70
+
53
71
  /** One row's ICRC-21 Value: typed when the field is a single value the typed form carries exactly, else Text. */
54
72
  function fieldValue(field, renderer) {
55
73
  if (field.values.length === 1) {
package/src/resolved.mjs CHANGED
@@ -6,11 +6,17 @@ import { CODES, fail } from "./errors.mjs";
6
6
 
7
7
  const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
8
8
 
9
- /** The method entry of a resolved form, or a ConsentError. */
10
- export function methodOf(resolved, name) {
9
+ /**
10
+ * The method entry of a resolved form, or a ConsentError. `chain` is "evm" for an EVM contract's form (spec section 15);
11
+ * a Candid entry point never reads one, whose positions are ABI types, not Candid.
12
+ */
13
+ export function methodOf(resolved, name, chain = undefined) {
11
14
  if (!isObject(resolved) || resolved.format !== "candid-profile-resolved" || !isObject(resolved.methods)) {
12
15
  fail(CODES.RESOLVED_FORM, "not a candid-profile-resolved document (format, methods)");
13
16
  }
17
+ if (resolved.chain !== chain) {
18
+ fail(CODES.RESOLVED_FORM, resolved.chain === "evm" ? "this resolved form is an EVM contract's (chain evm): use evmConsent, not a Candid entry point" : `expected the resolved form of an EVM contract (chain evm), found ${resolved.chain === undefined ? "a Candid service's" : `chain ${JSON.stringify(resolved.chain)}`}`);
19
+ }
14
20
  if (typeof name !== "string" || !Object.hasOwn(resolved.methods, name)) {
15
21
  fail(CODES.METHOD_UNKNOWN, `the resolved form has no method ${JSON.stringify(String(name))}`);
16
22
  }
@@ -24,6 +30,20 @@ export function keyId(key) {
24
30
  return /^#(0|[1-9][0-9]{0,9})$/.test(key) ? Number(key.slice(1)) : idlHash(key);
25
31
  }
26
32
 
33
+ /**
34
+ * A position of an EVM contract's resolved form (spec section 15) read as the Candid shapes the model walks: a tuple is
35
+ * a record, an array a vec, any other ABI type a primitive of that name. `abi` is kept, so display writes ABI text.
36
+ */
37
+ export function abiView(node, where, depth = 0) {
38
+ if (!isObject(node) || typeof node.abi !== "string") fail(CODES.RESOLVED_FORM, `${where}: not an EVM resolved-form position`);
39
+ if (depth > 64) fail(CODES.UNSUPPORTED_TYPE, `${where}: nested deeper than 64`);
40
+ if (node.abi === "tuple") {
41
+ return { ...node, candid: "record", fields: Object.fromEntries(entriesOf(node.fields).map(([k, c]) => [k, abiView(c, `${where}.${k}`, depth + 1)])) };
42
+ }
43
+ if (node.abi === "array") return { ...node, candid: "vec", element: abiView(node.element, `${where}[]`, depth + 1) };
44
+ return { ...node, candid: node.abi };
45
+ }
46
+
27
47
  /** Own entries of a `fields` or `cases` object (never walks the prototype chain). */
28
48
  export function entriesOf(bucket) {
29
49
  return isObject(bucket) ? Object.entries(bucket) : [];
@@ -39,7 +59,44 @@ export function tagArgs(node, tag) {
39
59
  return node?.tags && Object.hasOwn(node.tags, tag) ? (node.tags[tag]?.args ?? {}) : undefined;
40
60
  }
41
61
 
42
- /** The effect kind of a method: its tag, or the spec default (section 5.3: queries read, updates write). */
43
- export function effectKind(method) {
44
- return method.tags?.effect?.args?.kind ?? (method.mode === "query" || method.mode === "composite_query" ? "read" : "write");
62
+ /**
63
+ * The effect kind of a call: its effect tag (the call's, `callTag`; the method's by default), or the spec default
64
+ * (section 5.3: queries read, updates write).
65
+ */
66
+ export function effectKind(method, tag = method.tags?.effect) {
67
+ return tag?.args?.kind ?? (method.mode === "query" || method.mode === "composite_query" ? "read" : "write");
68
+ }
69
+
70
+ /**
71
+ * The `intent` or `effect` entry (`{ args, origin, profile }`) a call takes: that of the deepest tagged variant case
72
+ * its decoded arguments hold (spec section 5.2, D-053), else the method's. The resolver lets the tagged cases of one
73
+ * method only exclude each other or nest, so the cases one call holds are a chain; a resolved form with tagged cases
74
+ * that one call holds side by side is not one a resolver writes, and the method's entry is used for it.
75
+ *
76
+ * @param {object} method a method of the resolved form
77
+ * @param {unknown[]} argValues its decoded arguments (decode.mjs: records by key, variants as { tag, value })
78
+ * @param {"intent" | "effect"} tag
79
+ */
80
+ export function callTag(method, argValues, tag) {
81
+ let best = null; // { entry, chain }: the hit with the most hits above and at it
82
+ let hits = 0;
83
+ const visit = (node, value, chain) => {
84
+ if (!isObject(node) || node.recursive === true || !isObject(value)) return; // null: an absent optional, a bare case
85
+ if (node.candid === "record") {
86
+ for (const [key, child] of entriesOf(node.fields)) if (Object.hasOwn(value, key)) visit(child, value[key], chain);
87
+ } else if (node.candid === "variant") {
88
+ const hit = entriesOf(node.cases).find(([key]) => key === value.tag);
89
+ if (hit === undefined) return;
90
+ const child = hit[1];
91
+ const entry = isObject(child) && isObject(child.tags) && Object.hasOwn(child.tags, tag) ? child.tags[tag] : undefined;
92
+ if (isObject(entry)) {
93
+ chain += 1;
94
+ hits += 1;
95
+ if (best === null || chain > best.chain) best = { entry, chain };
96
+ }
97
+ visit(child, value.value, chain);
98
+ }
99
+ };
100
+ method.args.forEach((node, i) => visit(node, argValues[i], 0));
101
+ return best !== null && best.chain === hits ? best.entry : method.tags?.[tag];
45
102
  }
package/src/sns.mjs CHANGED
@@ -26,8 +26,8 @@
26
26
  // 1. The body is the generic consent message of spec section 12 with the title at heading level 3, so it nests
27
27
  // under governance's `## Payload:`. Times are shown in UTC (`2026-10-09 12:00:00 UTC`, rule 6 of section 12):
28
28
  // a proposal has many readers and no user preferences.
29
- // 2. Then the method the rendering describes and its effect (spec section 5.3; the default for an update without
30
- // an `effect` tag is said to be a default).
29
+ // 2. Then the method the rendering describes and the call's effect (spec section 5.3: its case's, else its
30
+ // method's, D-053; the default for an update without an `effect` tag is said to be a default).
31
31
  // 3. Then the arguments as Candid text with no formatter applied (base units, nanoseconds, bytes), always: there is
32
32
  // no signer between the text and the voter to show them (spec section 11 item 1).
33
33
  // 4. Then where the words come from: every profile with its profile_id, the interface_id, and these rules.
@@ -43,7 +43,7 @@ import { bytesOf } from "./bytes.mjs";
43
43
  import { ConsentError, CODES, fail } from "./errors.mjs";
44
44
  import { buildModel } from "./model.mjs";
45
45
  import { renderGeneric } from "./render.mjs";
46
- import { methodOf } from "./resolved.mjs";
46
+ import { callTag, methodOf } from "./resolved.mjs";
47
47
  import { codeBlock, codeSpan, escapeMarkdown, oneLine } from "./text.mjs";
48
48
 
49
49
  /** The name and version of the rules this module renders by. Part of every rendering, so a re-render knows what to compare. */
@@ -114,7 +114,7 @@ function renderSnsPayload({ payload, resolved, method: name, refs, limits }) {
114
114
  const model = buildModel({ resolved, method: name, arg: payload, limits });
115
115
  const renderer = new Renderer({ refs, utcOffsetMinutes: 0, diagnostics: model.diagnostics });
116
116
  const blocks = [renderGeneric(model, renderer, { headingLevel: 3 })];
117
- blocks.push(`**Method:** ${codeSpan(name)}. **Effect:** ${effectText(method)}.`);
117
+ blocks.push(`**Method:** ${codeSpan(name)}. **Effect:** ${effectText(method, callTag(method, model.argValues, "effect"))}.`);
118
118
  blocks.push(`**Arguments (Candid, as sent, no units applied):**\n\n${codeBlock(renderer.plainTupleText(model.argNodes, model.argValues))}`);
119
119
  blocks.push(sourceText(resolved));
120
120
  const text = blocks.join("\n\n");
@@ -125,12 +125,12 @@ function renderSnsPayload({ payload, resolved, method: name, refs, limits }) {
125
125
  const isQuery = (method) => method.mode === "query" || method.mode === "composite_query";
126
126
 
127
127
  /**
128
- * The effect kind and reversibility (spec section 5.3), and whether a profile stated them. The resolver writes the
128
+ * The effect kind and reversibility of the call (spec section 5.3; `tag` is its effect entry, `callTag`), and whether
129
+ * a profile stated them. The resolver writes the
129
130
  * section 5.3 default with origin "default" when no profile has an `effect` tag; this says so instead of passing a
130
131
  * default off as the author's word.
131
132
  */
132
- function effectText(method) {
133
- const tag = method.tags?.effect;
133
+ function effectText(method, tag) {
134
134
  const query = isQuery(method);
135
135
  const kind = typeof tag?.args?.kind === "string" ? tag.args.kind : query ? "read" : "write";
136
136
  const irreversible = typeof tag?.args?.irreversible === "boolean" ? tag.args.irreversible : tag ? null : !query;