@meantjs/consent 0.1.0 → 0.3.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 +30 -0
- package/README.md +42 -4
- package/package.json +18 -5
- package/src/display.mjs +81 -7
- package/src/erc7730.d.mts +92 -0
- package/src/erc7730.mjs +419 -0
- package/src/errors.mjs +5 -0
- package/src/evm.d.mts +106 -0
- package/src/evm.mjs +358 -0
- package/src/index.d.mts +4 -0
- package/src/model.mjs +16 -10
- package/src/render.mjs +18 -0
- package/src/resolved.mjs +62 -5
- package/src/sns.mjs +7 -7
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,36 @@
|
|
|
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.3.0 — 2026-10-29
|
|
6
|
+
|
|
7
|
+
- `@meantjs/consent/evm`: `eip712Consent`, the consent text of an EIP-712 signing request (spec §15.9, D-055),
|
|
8
|
+
matched to a message of the resolved form by its type hash and refused before anything is shown when its domain,
|
|
9
|
+
contract, type hash or values do not match (new codes `E_DOMAIN` and `E_TYPE_HASH`). The text ends with the
|
|
10
|
+
verifying contract, the EIP-712 digest and the note that a signature is not a transaction. `evmConsent` and
|
|
11
|
+
`eip712Consent` take `tokens`, the decimals and symbol of each token an amount's `token` field names; the token's
|
|
12
|
+
address is shown beside its symbol.
|
|
13
|
+
- `@meantjs/consent/erc7730`: `erc7730Descriptor(resolved, { context: "eip712" })` writes the ERC-7730 file of the
|
|
14
|
+
messages (formats keyed by `encode_type`, field groups for arrays of tokens). It returns `notes`: a timestamp's
|
|
15
|
+
words and a template ERC-7730 cannot interpolate are left out with a note instead of refusing the file, for
|
|
16
|
+
functions as well. Every file 0.2.0 writes is written the same.
|
|
17
|
+
- ICRC-21 fields: an amount at or above its threshold, and a timestamp shown as words, are `Text`, never a
|
|
18
|
+
`TokenAmount` or `TimestampSeconds` of the raw number.
|
|
19
|
+
|
|
20
|
+
## 0.2.0 — 2026-10-28
|
|
21
|
+
|
|
22
|
+
- A call takes the `intent` and `effect` of the deepest tagged variant case its arguments hold, else the method's
|
|
23
|
+
(spec §5.2, D-053): the title, template and provenance of the message, the model's `effect` and the effect line
|
|
24
|
+
of the SNS rendering. A resolved form without case tags renders as before. The Rust version does the same.
|
|
25
|
+
- EVM contracts, experimental (spec §15, D-054), in two new subpaths; the main entry is unchanged and refuses an EVM
|
|
26
|
+
resolved form (`E_RESOLVED_FORM`).
|
|
27
|
+
- `@meantjs/consent/evm`: `decodeCalldata` (strict) and `evmConsent`, the consent text of a call in the generic or
|
|
28
|
+
fields form, ending with the contract's address and chain. A call to a contract its profiles do not list is
|
|
29
|
+
`E_DEPLOYMENT`; a payable function is refused until its native value has a position.
|
|
30
|
+
- `@meantjs/consent/erc7730`: `erc7730Descriptor`, the ERC-7730 clear-signing file of a contract (schema 2.1.0,
|
|
31
|
+
deterministic) with its ERC-8176 `descriptorHash`; `descriptorHash`; `erc8176Attestation`, the unsigned EAS
|
|
32
|
+
offchain attestation a reviewer signs, with its EIP-712 digest. What a file cannot say is refused (`E_EXPORT`).
|
|
33
|
+
- New codes `E_DEPLOYMENT` and `E_EXPORT`. The Rust version does not cover EVM.
|
|
34
|
+
|
|
5
35
|
## 0.1.0 — 2026-10-28
|
|
6
36
|
|
|
7
37
|
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`;
|
|
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,46 @@ 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
|
|
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 and D-055 (EIP-712 messages, §15.9). 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, tokens, 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). `tokens`: the decimals and symbol of each token an amount's `token` field names, by address; the address is shown beside the symbol. |
|
|
74
|
+
| `eip712Consent({ resolved, typedData, chainId, refs, tokens, form, utcOffsetMinutes, rawArguments, limits })` (`/evm`) | The consent text of an EIP-712 signing request (`eth_signTypedData_v4`'s JSON), matched to a message of the resolved form by its type hash, never its name. Refused before anything is shown: a domain for another chain than the wallet's, without `chainId` or `verifyingContract`, with a salt, or whose name or version is not its profiles' (`E_DOMAIN`); a contract its profiles do not list (`E_DEPLOYMENT`); no message with the type hash (`E_TYPE_HASH`); values that are not exactly the members, canonically written (`E_DECODE`); a token not in `tokens` (`E_REF_UNRESOLVED`). Ends with the verifying contract, the EIP-712 digest and "This is a signature, not a transaction: whoever holds it can submit it." Returns `typeHash`, `digest`, `verifyingContract` and `chainId` with the text. |
|
|
75
|
+
| `erc7730Descriptor(resolved, { context, 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, or with `context: "eip712"` one per message, keyed by its `encodeType`. What an ERC-7730 file cannot say (a time in ms, `utf8` bytes, an amount in another token, an unnamed parameter) is `E_EXPORT`, never written as something near it; no deployment is `E_DEPLOYMENT`. What it cannot say but changes no value (a timestamp's "Never", a template over an array) is left out and listed in `notes`. The mapping is spec §15.7 and §15.9. |
|
|
76
|
+
| `descriptorHash(descriptor)` (`/erc7730`) | Keccak-256 of the RFC 8785 form: the hash of a file someone edited. |
|
|
77
|
+
| `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. |
|
|
78
|
+
|
|
79
|
+
```js
|
|
80
|
+
import { eip712Consent, evmConsent } from "@meantjs/consent/evm";
|
|
81
|
+
import { erc7730Descriptor, erc8176Attestation } from "@meantjs/consent/erc7730";
|
|
82
|
+
|
|
83
|
+
evmConsent({ resolved, calldata, to: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", refs: { "query:decimals": 18, "query:symbol": "WETH" } }).message;
|
|
84
|
+
// # 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`
|
|
85
|
+
const { descriptor, descriptorHash } = erc7730Descriptor(resolved, { deployments: [{ chainId: 1, address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" }] });
|
|
86
|
+
const { typedData, digest } = erc8176Attestation({ descriptorHash, time: 1777630591, expirationTime: 1785406591, salt });
|
|
87
|
+
|
|
88
|
+
// A Permit2 signing request, with the profile in @meantjs/profile/packs/permit2.profile.json
|
|
89
|
+
eip712Consent({ resolved: permit2, typedData, chainId: 1, tokens: { "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48": { decimals: 6, symbol: "USDC" } } }).message;
|
|
90
|
+
// # Permit2: allow token spending\n\nLet `0x66a9…A8Af` spend up to `Unlimited USDC (0xA0b8…eB48)`. Allowance expires: …
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Every exported file in the tests is checked against the ERC-7730 2.1.0 JSON Schema (vendored in
|
|
94
|
+
`test/upstream/erc7730/`, CC0, sha256 in its `SOURCE.json`), and the hashes and digests they pin were computed by an
|
|
95
|
+
independent implementation. **Fact** (ERC-7730): a wallet that finds no ticker for a `tokenAmount` "SHOULD display the
|
|
96
|
+
raw value instead with an 'Unknown token' warning"; this package would produce no message there, and a file cannot make
|
|
97
|
+
a wallet refuse. The Rust version does not cover EVM.
|
|
98
|
+
|
|
64
99
|
## SNS proposal validators (`src/sns.mjs`)
|
|
65
100
|
|
|
66
101
|
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 +160,7 @@ The relying party is not trusted, so every value that comes from the call is sho
|
|
|
125
160
|
|---|---|
|
|
126
161
|
| 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
162
|
| 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) |
|
|
163
|
+
| 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
164
|
|
|
130
165
|
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
166
|
|
|
@@ -213,3 +248,6 @@ Untrusted third-party data lives in `test/upstream/icrc21/` (the standard, commi
|
|
|
213
248
|
- The caller's account (`From`) and a fee line: see `docs/oisy-comparison.md` for why they are absent.
|
|
214
249
|
- Other languages; `device_spec` hints beyond the two forms.
|
|
215
250
|
- Recursive and `func`/`service` positions (need a richer resolved form, or the Contract).
|
|
251
|
+
- EVM: payable functions in the consent text, EIP-712 witness messages and the signer check (spec §15.9 O9, O13),
|
|
252
|
+
ERC-7730 import, checking attestations, and the Rust
|
|
253
|
+
version.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meantjs/consent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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
|
-
"@
|
|
55
|
-
"@meantjs/format": "0.
|
|
56
|
-
"@
|
|
66
|
+
"@candid-core/schema": "0.5.0",
|
|
67
|
+
"@meantjs/format": "0.3.0",
|
|
68
|
+
"@meantjs/profile": "0.3.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
|
@@ -20,6 +20,23 @@ const CONFIG_CODES = new Set(["E_REF_UNRESOLVED", "E_ARG", "E_TAG_TYPE", "E_TAG_
|
|
|
20
20
|
/** Budget for the text of one message: a consent message is read by a person (ICRC-21: short and concise). */
|
|
21
21
|
export const MAX_MESSAGE_CHARS = 32 * 1024;
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* An amount in the token a field of the same value names (spec section 15.9): the base units, the token's EIP-55
|
|
25
|
+
* address, and its decimals and symbol as the caller supplied them. evm.mjs puts one in place of each such amount of a
|
|
26
|
+
* decoded value; its text is the base units, so a view without formatters shows those.
|
|
27
|
+
*/
|
|
28
|
+
export class TokenAmount {
|
|
29
|
+
constructor(value, token, metadata) {
|
|
30
|
+
this.value = value;
|
|
31
|
+
this.token = token;
|
|
32
|
+
this.metadata = metadata;
|
|
33
|
+
Object.freeze(this);
|
|
34
|
+
}
|
|
35
|
+
toString() {
|
|
36
|
+
return this.value;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
23
40
|
export class Renderer {
|
|
24
41
|
/** Set while `plainTupleText` runs: no formatter applies. */
|
|
25
42
|
#plain = false;
|
|
@@ -39,17 +56,24 @@ export class Renderer {
|
|
|
39
56
|
this.usedZone = false;
|
|
40
57
|
}
|
|
41
58
|
|
|
42
|
-
/**
|
|
43
|
-
|
|
44
|
-
|
|
59
|
+
/**
|
|
60
|
+
* @param {object} node
|
|
61
|
+
* @param {TokenAmount} [amount] a value whose token a field names: the formatter takes that token's metadata
|
|
62
|
+
* @returns {import("@meantjs/format").Formatter | null}
|
|
63
|
+
*/
|
|
64
|
+
formatter(node, amount) {
|
|
65
|
+
const key = amount === undefined ? "" : amount.token;
|
|
66
|
+
if (!this.formatters.has(node)) this.formatters.set(node, new Map());
|
|
67
|
+
const cache = this.formatters.get(node);
|
|
68
|
+
if (cache.has(key)) return cache.get(key);
|
|
45
69
|
let f;
|
|
46
70
|
try {
|
|
47
|
-
f = formatterFor(node, { refs: this.refs });
|
|
71
|
+
f = formatterFor(node, { refs: this.refs, token: amount?.metadata });
|
|
48
72
|
} catch (e) {
|
|
49
73
|
if (e instanceof FormatError && CONFIG_CODES.has(e.code)) throw new ConsentError(e.code, e.message, { cause: e });
|
|
50
74
|
throw e;
|
|
51
75
|
}
|
|
52
|
-
|
|
76
|
+
cache.set(key, f);
|
|
53
77
|
return f;
|
|
54
78
|
}
|
|
55
79
|
|
|
@@ -77,6 +101,7 @@ export class Renderer {
|
|
|
77
101
|
|
|
78
102
|
/** Candid text of a whole argument tuple (the "raw arguments" of spec section 11 item 1), tagged children formatted. */
|
|
79
103
|
tupleText(nodes, values) {
|
|
104
|
+
if (isAbiParams(nodes)) return this.charge(this.#child(nodes[0], values[0], "arguments"));
|
|
80
105
|
return this.charge(`(${nodes.map((n, i) => this.#child(n, values[i], `argument ${i}`)).join(", ")})`);
|
|
81
106
|
}
|
|
82
107
|
|
|
@@ -87,6 +112,7 @@ export class Renderer {
|
|
|
87
112
|
plainTupleText(nodes, values) {
|
|
88
113
|
this.#plain = true;
|
|
89
114
|
try {
|
|
115
|
+
if (isAbiParams(nodes)) return this.charge(this.#child(nodes[0], values[0], "arguments"));
|
|
90
116
|
return this.charge(`(${nodes.map((n, i) => this.#child(n, values[i], `argument ${i}`)).join(", ")})`);
|
|
91
117
|
} finally {
|
|
92
118
|
this.#plain = false;
|
|
@@ -96,10 +122,21 @@ export class Renderer {
|
|
|
96
122
|
/** The formatter's text, or null when the position has no value tag or the formatter refuses the value. */
|
|
97
123
|
#formatted(node, value, where) {
|
|
98
124
|
if (this.#plain) return null;
|
|
125
|
+
if (value instanceof TokenAmount) {
|
|
126
|
+
// The token's own symbol is its claim, so its address is always shown beside it (spec section 15.9).
|
|
127
|
+
const f = this.formatter(node, value);
|
|
128
|
+
try {
|
|
129
|
+
return `${f.display(value.value)} (${value.token})`;
|
|
130
|
+
} catch (e) {
|
|
131
|
+
if (!(e instanceof FormatError)) throw e;
|
|
132
|
+
this.diagnostics.push({ code: "W_FORMAT_FALLBACK", at: where, message: `the ${f.tag} formatter refused the value (${e.code}: ${e.message}); it is shown in base units` });
|
|
133
|
+
return `${value.value} (${value.token})`;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
99
136
|
const f = this.formatter(node);
|
|
100
137
|
if (f === null) return null;
|
|
101
138
|
try {
|
|
102
|
-
return f.tag === "timestamp" && this.utcOffsetMinutes !== null ? this.#zoned(f, value) : f.display(value);
|
|
139
|
+
return f.tag === "timestamp" && this.utcOffsetMinutes !== null && !this.#worded(f, value) ? this.#zoned(f, value) : f.display(value);
|
|
103
140
|
} catch (e) {
|
|
104
141
|
if (!(e instanceof FormatError)) throw e;
|
|
105
142
|
this.diagnostics.push({ code: "W_FORMAT_FALLBACK", at: where, message: `the ${f.tag} formatter refused the value (${e.code}: ${e.message}); it is shown as raw Candid text` });
|
|
@@ -107,6 +144,13 @@ export class Renderer {
|
|
|
107
144
|
}
|
|
108
145
|
}
|
|
109
146
|
|
|
147
|
+
/** Whether a timestamp shows words instead of a time: a label for its value, or its threshold's message (spec section 15.9). */
|
|
148
|
+
#worded(f, value) {
|
|
149
|
+
if (typeof value !== "bigint" && !(typeof value === "string" && /^-?[0-9]+$/.test(value))) return false; // the formatter refuses it anyway
|
|
150
|
+
const v = BigInt(value);
|
|
151
|
+
return (f.args.labels !== undefined && Object.hasOwn(f.args.labels, v.toString())) || (f.args.threshold !== undefined && v >= BigInt(f.args.threshold));
|
|
152
|
+
}
|
|
153
|
+
|
|
110
154
|
/** A timestamp in the requested zone: the instant shifted by whole minutes, then shown with the library's calendar code. */
|
|
111
155
|
#zoned(f, value) {
|
|
112
156
|
const offset = this.utcOffsetMinutes;
|
|
@@ -126,6 +170,7 @@ export class Renderer {
|
|
|
126
170
|
}
|
|
127
171
|
|
|
128
172
|
#raw(node, value, where) {
|
|
173
|
+
if (typeof node.abi === "string") return this.#abi(node, value, where);
|
|
129
174
|
switch (node.candid) {
|
|
130
175
|
case "null":
|
|
131
176
|
case "reserved":
|
|
@@ -158,6 +203,30 @@ export class Renderer {
|
|
|
158
203
|
}
|
|
159
204
|
}
|
|
160
205
|
|
|
206
|
+
/**
|
|
207
|
+
* 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,
|
|
208
|
+
* a string quoted, an array in [ ], a tuple in ( ) with its component names.
|
|
209
|
+
*/
|
|
210
|
+
#abi(node, value, where) {
|
|
211
|
+
switch (node.abi) {
|
|
212
|
+
case "address":
|
|
213
|
+
return value;
|
|
214
|
+
case "bool":
|
|
215
|
+
return value ? "true" : "false";
|
|
216
|
+
case "string":
|
|
217
|
+
return candidString(value);
|
|
218
|
+
case "array":
|
|
219
|
+
return `[${value.map((e, i) => this.#child(node.element, e, `${where}[${i}]`)).join(", ")}]`;
|
|
220
|
+
case "tuple":
|
|
221
|
+
return `(${entriesOf(node.fields)
|
|
222
|
+
.map(([key, child]) => `${key.startsWith("#") ? "" : `${key}: `}${this.#child(child, value[key], `${where}.${key}`)}`)
|
|
223
|
+
.join(", ")})`;
|
|
224
|
+
default:
|
|
225
|
+
if (value instanceof Uint8Array) return `0x${[...value].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
|
|
226
|
+
return String(value); // integers (decimal strings)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
161
230
|
/**
|
|
162
231
|
* The ICRC-21 `Value` of one value (spec section 12). Falls back to `Text` whenever the typed form cannot carry
|
|
163
232
|
* the value exactly: an amount above nat64 or negative or without a symbol, a timestamp or duration that is not a
|
|
@@ -178,12 +247,14 @@ export class Renderer {
|
|
|
178
247
|
try {
|
|
179
248
|
if (f.tag === "amount") {
|
|
180
249
|
const amount = BigInt(value);
|
|
181
|
-
const { decimals, symbol } = f.args;
|
|
250
|
+
const { decimals, symbol, threshold } = f.args;
|
|
182
251
|
if (amount < 0n || amount > U64_MAX || typeof symbol !== "string") return null;
|
|
252
|
+
if (threshold !== undefined && amount >= BigInt(threshold)) return null; // shown as its message ("Unlimited ICP"), not a number
|
|
183
253
|
f.display(value); // the library must accept the value too (type range), or it is shown as raw text
|
|
184
254
|
return { TokenAmount: { decimals, amount, symbol } };
|
|
185
255
|
}
|
|
186
256
|
if (f.tag === "timestamp" || f.tag === "duration") {
|
|
257
|
+
if (f.tag === "timestamp" && this.#worded(f, value)) return null; // shown as its words ("Never"), not a time
|
|
187
258
|
const seconds = BigInt(convertUnit(value, f.args.unit, "s")); // throws E_INEXACT when not whole seconds
|
|
188
259
|
if (seconds < 0n || seconds > U64_MAX) return null;
|
|
189
260
|
f.display(value);
|
|
@@ -197,6 +268,9 @@ export class Renderer {
|
|
|
197
268
|
}
|
|
198
269
|
}
|
|
199
270
|
|
|
271
|
+
/** An EVM function's arguments: its parameters as one tuple (spec section 15), written as the ABI writes them. */
|
|
272
|
+
const isAbiParams = (nodes) => nodes.length === 1 && nodes[0]?.abi === "tuple";
|
|
273
|
+
|
|
200
274
|
/** `UTC`, `UTC+02:00`, `UTC-05:30`. */
|
|
201
275
|
export function zoneName(offsetMinutes) {
|
|
202
276
|
if (offsetMinutes === 0) return "UTC";
|
|
@@ -0,0 +1,92 @@
|
|
|
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
|
+
/** `tokenPath`: `@.to` (the contract, or a message's verifying contract) or a path to the token's address field. */
|
|
23
|
+
params?: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
/** The fields of each element of an array whose elements carry their own token (`path: "details.[]"`); paths relative. */
|
|
26
|
+
export interface Erc7730FieldGroup {
|
|
27
|
+
path: string;
|
|
28
|
+
fields: (Erc7730Field | Erc7730FieldGroup)[];
|
|
29
|
+
}
|
|
30
|
+
export interface Erc7730Descriptor {
|
|
31
|
+
$schema: typeof ERC7730_SCHEMA_URL;
|
|
32
|
+
/** `contract` for a file of functions; `eip712` for a file of messages, with the domain name and version its profiles give. */
|
|
33
|
+
context: { contract: { deployments: Erc7730Deployment[] } } | { eip712: { domain?: { name?: string; version?: string }; deployments: Erc7730Deployment[] } };
|
|
34
|
+
metadata?: {
|
|
35
|
+
owner?: string;
|
|
36
|
+
contractName?: string;
|
|
37
|
+
info?: { url: string; deploymentDate?: string };
|
|
38
|
+
token?: { name: string; ticker: string; decimals: number };
|
|
39
|
+
};
|
|
40
|
+
/** Keyed by named signature (functions) or by encodeType (messages). */
|
|
41
|
+
display: { formats: Record<string, { intent: string; interpolatedIntent?: string; fields?: (Erc7730Field | Erc7730FieldGroup)[] }> };
|
|
42
|
+
}
|
|
43
|
+
export interface Erc7730Options {
|
|
44
|
+
/** "contract" (default): the functions a transaction calls. "eip712": the EIP-712 messages (spec section 15.9). */
|
|
45
|
+
context?: "contract" | "eip712";
|
|
46
|
+
/** Default: the deployments every profile that lists deployments lists. Given, each must be listed by those profiles. */
|
|
47
|
+
deployments?: Erc7730Deployment[];
|
|
48
|
+
owner?: string;
|
|
49
|
+
contractName?: string;
|
|
50
|
+
info?: { url: string; deploymentDate?: string };
|
|
51
|
+
/** Say that the contract is the token its literal amounts are in: written as `metadata.token`. */
|
|
52
|
+
token?: { name: string };
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The ERC-7730 file of an EVM contract's resolved form and its ERC-8176 `descriptorHash`. What the profile says that an
|
|
56
|
+
* ERC-7730 file cannot say is refused (E_EXPORT); no deployments is E_DEPLOYMENT. What it cannot say but changes no
|
|
57
|
+
* value (a timestamp's words, an interpolation of an array) is left out and listed in `notes`.
|
|
58
|
+
*/
|
|
59
|
+
export function erc7730Descriptor(resolved: ResolvedForm, options?: Erc7730Options): { descriptor: Erc7730Descriptor; descriptorHash: string; notes: string[] };
|
|
60
|
+
/** ERC-8176 `descriptorHash`: `0x` and the Keccak-256 of the RFC 8785 (JCS) form, for a descriptor without `includes`. */
|
|
61
|
+
export function descriptorHash(descriptor: object): string;
|
|
62
|
+
|
|
63
|
+
export interface Erc8176Input {
|
|
64
|
+
descriptorHash: string;
|
|
65
|
+
/** Unix seconds. */
|
|
66
|
+
time: bigint | number | string;
|
|
67
|
+
/** Unix seconds; 0 never expires (ERC-8176 advises a bound). */
|
|
68
|
+
expirationTime: bigint | number | string;
|
|
69
|
+
/** 32 random bytes as 0x hex, chosen by the caller. */
|
|
70
|
+
salt: string;
|
|
71
|
+
/** Default 32 zero bytes. */
|
|
72
|
+
refUID?: string;
|
|
73
|
+
}
|
|
74
|
+
export interface Erc8176TypedData {
|
|
75
|
+
domain: { name: "EAS Attestation"; version: "0.26"; chainId: 1; verifyingContract: typeof EAS_ADDRESS };
|
|
76
|
+
primaryType: "Attest";
|
|
77
|
+
types: { Attest: { name: string; type: string }[] };
|
|
78
|
+
message: {
|
|
79
|
+
version: 2;
|
|
80
|
+
schema: typeof ERC8176_SCHEMA_UID;
|
|
81
|
+
recipient: string;
|
|
82
|
+
time: string;
|
|
83
|
+
expirationTime: string;
|
|
84
|
+
revocable: true;
|
|
85
|
+
refUID: string;
|
|
86
|
+
/** abi.encode(descriptorHash): the 32 bytes. */
|
|
87
|
+
data: string;
|
|
88
|
+
salt: string;
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/** The unsigned ERC-8176 offchain attestation (EAS offchain v2 typed data) and its EIP-712 digest. Nothing is signed. */
|
|
92
|
+
export function erc8176Attestation(input: Erc8176Input): { typedData: Erc8176TypedData; digest: string };
|