@meantjs/consent 0.4.0 → 0.5.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 +14 -0
- package/README.md +7 -4
- package/package.json +3 -3
- package/src/erc7730.d.mts +2 -0
- package/src/erc7730.mjs +17 -1
- package/src/errors.mjs +1 -0
- package/src/evm.mjs +27 -0
- package/src/index.d.mts +1 -0
- package/src/render.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
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.5.0 — 2026-10-30
|
|
6
|
+
|
|
7
|
+
- Pins (spec §15.4, D-058): `evmConsent` and `eip712Consent` refuse a call or a signing request whose value at a
|
|
8
|
+
position a profile pins (`fixed`) is another address, before any text (new code `E_FIXED`). x402's Permit2 payment
|
|
9
|
+
needs it: its words hold only when the spender is x402's proxy. A pin whose value is not an address is
|
|
10
|
+
`E_RESOLVED_FORM`.
|
|
11
|
+
- `erc7730Descriptor` writes a pinned position as a field with `visible: { mustMatch: [address] }`, which a wallet
|
|
12
|
+
hides and checks; a template that names one gets no `interpolatedIntent`, and `notes` says why.
|
|
13
|
+
- x402 payments read with the new contract profiles of `@meantjs/profile` (`usdc`, `usdc-base-sepolia`,
|
|
14
|
+
`x402-exact-permit2`), and their grants pay the payee directly (`to`), so terms for one payee pass no payment to
|
|
15
|
+
another.
|
|
16
|
+
- The ERC-7730 file of the `permit2` profile's messages lists its two new deployments (Base and Base Sepolia), so its
|
|
17
|
+
`descriptorHash` changed; a caller that passes the earlier `deployments` gets the earlier file.
|
|
18
|
+
|
|
5
19
|
## 0.4.0 — 2026-10-30
|
|
6
20
|
|
|
7
21
|
- What a request grants (spec §5.3, D-056). `evmConsent`, `eip712Consent` and `generateConsent` also return `values`,
|
package/README.md
CHANGED
|
@@ -70,10 +70,10 @@ a Candid one.
|
|
|
70
70
|
| Function | Does |
|
|
71
71
|
|---|---|
|
|
72
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)
|
|
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. |
|
|
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); an argument a profile pins (`fixed`) that holds another address is `E_FIXED`. `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 member a profile pins (`fixed`) that holds another address, such as x402's Permit2 payment with a spender other than x402's proxy (`E_FIXED`); 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
75
|
| `values`, `grant` (both, `/evm`) | `evmConsent` and `eip712Consent` also return the decoded arguments or message by name (`values`), and what the call or signature grants (`grant`, below), or null. |
|
|
76
|
-
| `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
|
+
| `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`. A pinned position (`fixed`) is a field with `visible: { mustMatch: [address] }`, which a wallet hides and checks, and a template that names it has no `interpolatedIntent` (a note says why). The mapping is spec §15.7 and §15.9. |
|
|
77
77
|
| `descriptorHash(descriptor)` (`/erc7730`) | Keccak-256 of the RFC 8785 form: the hash of a file someone edited. |
|
|
78
78
|
| `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. |
|
|
79
79
|
|
|
@@ -291,6 +291,9 @@ Untrusted third-party data lives in `test/upstream/icrc21/` (the standard, commi
|
|
|
291
291
|
- A profile is a claim, not a proof (spec section 11 item 1). `provenance` tells the signer which profile wrote the words; `rawArguments: true` appends the decoded raw arguments for transfer, approve, admin and destroy.
|
|
292
292
|
- Dynamic refs come from a single replica unless the caller certifies them (section 11 item 2). Resolve `decimals` and `symbol` through an update call or cross-check with `icrc1_metadata` before showing a transfer. Generation inside the canister avoids this.
|
|
293
293
|
- The package has no I/O. Work is bounded: decoder budgets, field and element limits, message size.
|
|
294
|
+
- A pin (`fixed`) protects only a consumer that reads it (section 11 item 11): x402's Permit2 payment pays its payee
|
|
295
|
+
only through x402's proxy, and a consumer that skips the tag shows its words for any spender. This package reads it
|
|
296
|
+
from 0.5.0; give a resolved form that holds pins to no older consumer.
|
|
294
297
|
|
|
295
298
|
## Not done
|
|
296
299
|
|
|
@@ -298,6 +301,6 @@ Untrusted third-party data lives in `test/upstream/icrc21/` (the standard, commi
|
|
|
298
301
|
- The caller's account (`From`) and a fee line: see `docs/oisy-comparison.md` for why they are absent.
|
|
299
302
|
- Other languages; `device_spec` hints beyond the two forms.
|
|
300
303
|
- Recursive and `func`/`service` positions (need a richer resolved form, or the Contract).
|
|
301
|
-
- EVM: payable functions in the consent text, EIP-712 witness messages and the signer check (spec §15.9 O9, O13),
|
|
304
|
+
- EVM: payable functions in the consent text, EIP-712 witness messages other than x402's and the signer check (spec §15.9 O9, O13),
|
|
302
305
|
ERC-7730 import, checking attestations, and the Rust
|
|
303
306
|
version.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meantjs/consent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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": {
|
|
@@ -68,8 +68,8 @@
|
|
|
68
68
|
},
|
|
69
69
|
"dependencies": {
|
|
70
70
|
"@candid-core/schema": "0.5.0",
|
|
71
|
-
"@meantjs/format": "0.
|
|
72
|
-
"@meantjs/profile": "0.
|
|
71
|
+
"@meantjs/format": "0.5.0",
|
|
72
|
+
"@meantjs/profile": "0.5.0"
|
|
73
73
|
},
|
|
74
74
|
"devDependencies": {
|
|
75
75
|
"@dfinity/didc": "0.0.4",
|
package/src/erc7730.d.mts
CHANGED
|
@@ -21,6 +21,8 @@ export interface Erc7730Field {
|
|
|
21
21
|
format: "tokenAmount" | "amount" | "date" | "duration" | "addressName" | "raw";
|
|
22
22
|
/** `tokenPath`: `@.to` (the contract, or a message's verifying contract) or a path to the token's address field. */
|
|
23
23
|
params?: Record<string, unknown>;
|
|
24
|
+
/** A position the profile pins (`fixed`): hidden, and a wallet refuses any other value (EIP-55 text). */
|
|
25
|
+
visible?: { mustMatch: string[] };
|
|
24
26
|
}
|
|
25
27
|
/** The fields of each element of an array whose elements carry their own token (`path: "details.[]"`); paths relative. */
|
|
26
28
|
export interface Erc7730FieldGroup {
|
package/src/erc7730.mjs
CHANGED
|
@@ -109,12 +109,27 @@ function keysOf(method, relpath) {
|
|
|
109
109
|
}
|
|
110
110
|
const startsWith = (keys, prefix) => prefix.length <= keys.length && prefix.every((k, i) => keys[i] === k);
|
|
111
111
|
|
|
112
|
+
/**
|
|
113
|
+
* ERC-7730 `visible` of a position a profile pins (`fixed`, spec section 15.4, D-058): `mustMatch` hides the field and
|
|
114
|
+
* makes a wallet refuse any other value, as `evmConsent` and `eip712Consent` do (E_FIXED). Empty for any other position.
|
|
115
|
+
*/
|
|
116
|
+
function pinOf(node, where) {
|
|
117
|
+
const fixed = tagArgs(node, "fixed");
|
|
118
|
+
if (fixed === undefined) return {};
|
|
119
|
+
try {
|
|
120
|
+
return { visible: { mustMatch: [readAddress(String(fixed.value))] } };
|
|
121
|
+
} catch (e) {
|
|
122
|
+
if (e instanceof AbiError) fail(CODES.RESOLVED_FORM, `${where}: fixed: ${e.message}`);
|
|
123
|
+
throw e;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
112
127
|
/**
|
|
113
128
|
* One ERC-7730 field for a leaf of a function's parameters or a message's members. `at` is where the leaf is:
|
|
114
129
|
* `{ method, abs, base }`, its keys from the root and the keys of the field group it is in (its path is relative to it).
|
|
115
130
|
*/
|
|
116
131
|
function fieldOf(node, path, label, ctx, where, at) {
|
|
117
|
-
const field = { path, label };
|
|
132
|
+
const field = { path, label, ...pinOf(node, where) };
|
|
118
133
|
const amount = tagArgs(node, "amount");
|
|
119
134
|
if (amount?.token !== undefined) {
|
|
120
135
|
// In the token a field of the same value names (spec section 15.9): its path, relative to the group.
|
|
@@ -225,6 +240,7 @@ function interpolated(template, method, ctx, where) {
|
|
|
225
240
|
if (rp.error) return ctx.problem(where, `{${p.path}}: ${rp.error}`);
|
|
226
241
|
if (rp.steps.some((s) => s.kind === "elem")) return leftOut(`{${p.path}} reads every element of an array; an ERC-7730 interpolation shows one value`);
|
|
227
242
|
if (rp.node.candid === "record" || rp.node.candid === "vec") return leftOut(`{${p.path}} is a whole ${rp.node.abi}; an ERC-7730 interpolation shows one formatted field`);
|
|
243
|
+
if (tagArgs(rp.node, "fixed") !== undefined) return leftOut(`{${p.path}} is pinned (fixed), so its field is hidden (mustMatch); an ERC-7730 interpolation names only fields that are always shown`);
|
|
228
244
|
out += `{${rp.steps.map((s) => s.key).join(".")}}`;
|
|
229
245
|
}
|
|
230
246
|
}
|
package/src/errors.mjs
CHANGED
|
@@ -24,6 +24,7 @@ export const CODES = Object.freeze({
|
|
|
24
24
|
DEPLOYMENT: "E_DEPLOYMENT", // an EVM call's target is not a deployment its profile lists (spec section 15)
|
|
25
25
|
DOMAIN: "E_DOMAIN", // an EIP-712 request's domain is for another chain, contract or name than its profile's (spec section 15.9)
|
|
26
26
|
TYPE_HASH: "E_TYPE_HASH", // no message of the resolved form has the request's EIP-712 type hash (spec section 15.9)
|
|
27
|
+
FIXED: "E_FIXED", // a position a profile pins (`fixed`) holds another value: the profile does not describe the request (spec section 15.4)
|
|
27
28
|
// this generator cannot describe the call (ICRC-21 ConsentMessageUnavailable)
|
|
28
29
|
UNSUPPORTED_TYPE: "E_UNSUPPORTED_TYPE", // a position the resolved form cannot type (recursive cut, func, service)
|
|
29
30
|
// the profile or the caller's context is wrong (ICRC-21 GenericError)
|
package/src/evm.mjs
CHANGED
|
@@ -135,6 +135,31 @@ function withTokens(name, viewed, args, tokens) {
|
|
|
135
135
|
return args.map((v, i) => box(viewed.args[i], v, [], []));
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Every position a profile pins (`fixed`, spec section 15.4, D-058) holds its value: the profile describes the call or
|
|
140
|
+
* the message only with it, as x402's Permit2 message holds x402's proxy as its spender. Anything else is refused
|
|
141
|
+
* before any text (E_FIXED), since the words would be wrong for it.
|
|
142
|
+
*/
|
|
143
|
+
function checkFixed(name, viewed, args) {
|
|
144
|
+
const walk = (node, value, where) => {
|
|
145
|
+
if (node.candid === "record") return entriesOf(node.fields).forEach(([k, child]) => walk(child, value[k], `${where}.${k}`));
|
|
146
|
+
if (node.candid === "vec") return value.forEach((e) => walk(node.element, e, `${where}[]`));
|
|
147
|
+
const fixed = tagArgs(node, "fixed");
|
|
148
|
+
if (fixed === undefined) return;
|
|
149
|
+
let want;
|
|
150
|
+
try {
|
|
151
|
+
want = readAddress(String(fixed.value));
|
|
152
|
+
} catch (e) {
|
|
153
|
+
if (e instanceof AbiError) fail(CODES.RESOLVED_FORM, `${where}: fixed: ${e.message}`);
|
|
154
|
+
throw e;
|
|
155
|
+
}
|
|
156
|
+
if (typeof value !== "string" || value.toLowerCase() !== want.toLowerCase()) {
|
|
157
|
+
fail(CODES.FIXED, `${where} is ${String(value)}, but the profile describes ${name} only with ${want} there, so this request gets no text`);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
args.forEach((v, i) => walk(viewed.args[i], v, `${name}(${i})`));
|
|
161
|
+
}
|
|
162
|
+
|
|
138
163
|
/**
|
|
139
164
|
* The consent text of an EVM call.
|
|
140
165
|
*
|
|
@@ -169,6 +194,7 @@ export function evmConsent({ resolved, calldata, to, chainId, refs = {}, tokens
|
|
|
169
194
|
const method = methodOf(resolved, name, "evm");
|
|
170
195
|
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)`);
|
|
171
196
|
const viewed = { ...method, args: method.args.map((n, i) => abiView(n, `${name}(${i})`)) };
|
|
197
|
+
if (viewed.args.length) checkFixed(name, viewed, [values]);
|
|
172
198
|
const model = modelOf(resolved, name, viewed, viewed.args.length ? withTokens(name, viewed, [values], tokenTable(tokens)) : []);
|
|
173
199
|
const grant = grantOf({ name, method: viewed, argValues: viewed.args.length ? [values] : [], called: target, diagnostics: model.diagnostics });
|
|
174
200
|
const where = `${target}${chain === null ? "" : ` on chain ${chain}`}`;
|
|
@@ -348,6 +374,7 @@ export function eip712Consent({ resolved, typedData, chainId, refs = {}, tokens
|
|
|
348
374
|
throw e;
|
|
349
375
|
}
|
|
350
376
|
const viewed = { ...message, args: message.args.map((n, i) => abiView(n, `${name}(${i})`)) };
|
|
377
|
+
checkFixed(name, viewed, [values]);
|
|
351
378
|
const model = modelOf(resolved, name, viewed, withTokens(name, viewed, [values], tokenTable(tokens)));
|
|
352
379
|
const grant = grantOf({ name, method: viewed, argValues: [values], called: contract, diagnostics: model.diagnostics });
|
|
353
380
|
const where = `${contract} on chain ${chain}`;
|
package/src/index.d.mts
CHANGED
package/src/render.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// FieldsDisplayMessage (plain text, no formatting; times are UTC seconds, so the message does not depend on the
|
|
4
4
|
// user's time zone):
|
|
5
|
-
// intent = title + ": " + the rendered template (
|
|
5
|
+
// intent = title + ": " + the rendered template (the title alone when the template renders empty or fell back)
|
|
6
6
|
// fields = one (label, Value) row per placeholder; label = describe.label, else the field name
|
|
7
7
|
//
|
|
8
8
|
// GenericDisplayMessage (Markdown, the layout of the ICRC-21 examples: a `#` title, one paragraph, then
|