@meantjs/consent 0.2.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 +15 -0
- package/README.md +11 -5
- package/package.json +3 -3
- package/src/display.mjs +51 -7
- package/src/erc7730.d.mts +15 -4
- package/src/erc7730.mjs +161 -46
- package/src/errors.mjs +2 -0
- package/src/evm.d.mts +55 -0
- package/src/evm.mjs +234 -5
- package/src/index.d.mts +2 -0
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.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
|
+
|
|
5
20
|
## 0.2.0 — 2026-10-28
|
|
6
21
|
|
|
7
22
|
- A call takes the `intent` and `effect` of the deepest tagged variant case its arguments hold, else the method's
|
package/README.md
CHANGED
|
@@ -63,26 +63,31 @@ bundles it with esbuild for the browser platform). The three SNS rows below are
|
|
|
63
63
|
|
|
64
64
|
## EVM contracts (experimental: `src/evm.mjs`, `src/erc7730.mjs`)
|
|
65
65
|
|
|
66
|
-
Spec section 15, D-054. An EVM contract's resolved form (`"chain": "evm"`, from `resolveService({ abi })` of
|
|
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
67
|
`@meantjs/profile`) is read by two subpaths; the main entry refuses it with `E_RESOLVED_FORM`, and `evmConsent` refuses
|
|
68
68
|
a Candid one.
|
|
69
69
|
|
|
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, 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
|
-
| `
|
|
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. |
|
|
75
76
|
| `descriptorHash(descriptor)` (`/erc7730`) | Keccak-256 of the RFC 8785 form: the hash of a file someone edited. |
|
|
76
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. |
|
|
77
78
|
|
|
78
79
|
```js
|
|
79
|
-
import { evmConsent } from "@meantjs/consent/evm";
|
|
80
|
+
import { eip712Consent, evmConsent } from "@meantjs/consent/evm";
|
|
80
81
|
import { erc7730Descriptor, erc8176Attestation } from "@meantjs/consent/erc7730";
|
|
81
82
|
|
|
82
83
|
evmConsent({ resolved, calldata, to: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", refs: { "query:decimals": 18, "query:symbol": "WETH" } }).message;
|
|
83
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`
|
|
84
85
|
const { descriptor, descriptorHash } = erc7730Descriptor(resolved, { deployments: [{ chainId: 1, address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" }] });
|
|
85
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: …
|
|
86
91
|
```
|
|
87
92
|
|
|
88
93
|
Every exported file in the tests is checked against the ERC-7730 2.1.0 JSON Schema (vendored in
|
|
@@ -243,5 +248,6 @@ Untrusted third-party data lives in `test/upstream/icrc21/` (the standard, commi
|
|
|
243
248
|
- The caller's account (`From`) and a fee line: see `docs/oisy-comparison.md` for why they are absent.
|
|
244
249
|
- Other languages; `device_spec` hints beyond the two forms.
|
|
245
250
|
- Recursive and `func`/`service` positions (need a richer resolved form, or the Contract).
|
|
246
|
-
- EVM: payable functions in the consent text, EIP-712 messages
|
|
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
|
|
247
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": {
|
|
@@ -64,8 +64,8 @@
|
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
66
|
"@candid-core/schema": "0.5.0",
|
|
67
|
-
"@meantjs/format": "0.
|
|
68
|
-
"@meantjs/profile": "0.
|
|
67
|
+
"@meantjs/format": "0.3.0",
|
|
68
|
+
"@meantjs/profile": "0.3.0"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"@dfinity/didc": "0.0.4",
|
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
|
|
|
@@ -98,10 +122,21 @@ export class Renderer {
|
|
|
98
122
|
/** The formatter's text, or null when the position has no value tag or the formatter refuses the value. */
|
|
99
123
|
#formatted(node, value, where) {
|
|
100
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
|
+
}
|
|
101
136
|
const f = this.formatter(node);
|
|
102
137
|
if (f === null) return null;
|
|
103
138
|
try {
|
|
104
|
-
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);
|
|
105
140
|
} catch (e) {
|
|
106
141
|
if (!(e instanceof FormatError)) throw e;
|
|
107
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` });
|
|
@@ -109,6 +144,13 @@ export class Renderer {
|
|
|
109
144
|
}
|
|
110
145
|
}
|
|
111
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
|
+
|
|
112
154
|
/** A timestamp in the requested zone: the instant shifted by whole minutes, then shown with the library's calendar code. */
|
|
113
155
|
#zoned(f, value) {
|
|
114
156
|
const offset = this.utcOffsetMinutes;
|
|
@@ -205,12 +247,14 @@ export class Renderer {
|
|
|
205
247
|
try {
|
|
206
248
|
if (f.tag === "amount") {
|
|
207
249
|
const amount = BigInt(value);
|
|
208
|
-
const { decimals, symbol } = f.args;
|
|
250
|
+
const { decimals, symbol, threshold } = f.args;
|
|
209
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
|
|
210
253
|
f.display(value); // the library must accept the value too (type range), or it is shown as raw text
|
|
211
254
|
return { TokenAmount: { decimals, amount, symbol } };
|
|
212
255
|
}
|
|
213
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
|
|
214
258
|
const seconds = BigInt(convertUnit(value, f.args.unit, "s")); // throws E_INEXACT when not whole seconds
|
|
215
259
|
if (seconds < 0n || seconds > U64_MAX) return null;
|
|
216
260
|
f.display(value);
|
package/src/erc7730.d.mts
CHANGED
|
@@ -19,20 +19,30 @@ export interface Erc7730Field {
|
|
|
19
19
|
path: string;
|
|
20
20
|
label: string;
|
|
21
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. */
|
|
22
23
|
params?: Record<string, unknown>;
|
|
23
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
|
+
}
|
|
24
30
|
export interface Erc7730Descriptor {
|
|
25
31
|
$schema: typeof ERC7730_SCHEMA_URL;
|
|
26
|
-
|
|
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[] } };
|
|
27
34
|
metadata?: {
|
|
28
35
|
owner?: string;
|
|
29
36
|
contractName?: string;
|
|
30
37
|
info?: { url: string; deploymentDate?: string };
|
|
31
38
|
token?: { name: string; ticker: string; decimals: number };
|
|
32
39
|
};
|
|
33
|
-
|
|
40
|
+
/** Keyed by named signature (functions) or by encodeType (messages). */
|
|
41
|
+
display: { formats: Record<string, { intent: string; interpolatedIntent?: string; fields?: (Erc7730Field | Erc7730FieldGroup)[] }> };
|
|
34
42
|
}
|
|
35
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";
|
|
36
46
|
/** Default: the deployments every profile that lists deployments lists. Given, each must be listed by those profiles. */
|
|
37
47
|
deployments?: Erc7730Deployment[];
|
|
38
48
|
owner?: string;
|
|
@@ -43,9 +53,10 @@ export interface Erc7730Options {
|
|
|
43
53
|
}
|
|
44
54
|
/**
|
|
45
55
|
* 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.
|
|
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`.
|
|
47
58
|
*/
|
|
48
|
-
export function erc7730Descriptor(resolved: ResolvedForm, options?: Erc7730Options): { descriptor: Erc7730Descriptor; descriptorHash: string };
|
|
59
|
+
export function erc7730Descriptor(resolved: ResolvedForm, options?: Erc7730Options): { descriptor: Erc7730Descriptor; descriptorHash: string; notes: string[] };
|
|
49
60
|
/** ERC-8176 `descriptorHash`: `0x` and the Keccak-256 of the RFC 8785 (JCS) form, for a descriptor without `includes`. */
|
|
50
61
|
export function descriptorHash(descriptor: object): string;
|
|
51
62
|
|
package/src/erc7730.mjs
CHANGED
|
@@ -19,6 +19,17 @@
|
|
|
19
19
|
// The descriptor binds to deployments (ERC-7730 `context.contract.deployments`): the profile's, or the caller's when
|
|
20
20
|
// every profile that lists deployments lists them too. `effect`, `idempotency`, `outcome` and the other agent tags
|
|
21
21
|
// have no ERC-7730 form and are left out: the file carries what a call says, not what it does.
|
|
22
|
+
//
|
|
23
|
+
// EIP-712 messages (spec section 15.9) are a file of their own (`options.context: "eip712"`): ERC-7730 binds a file to
|
|
24
|
+
// a contract or to messages, not both. Its formats are keyed by each message's `encode_type`, whose Keccak-256 must be
|
|
25
|
+
// the message's `type_hash`; `context.eip712` carries the deployments and the domain name and version the profiles
|
|
26
|
+
// give. An amount whose token a field names is `tokenAmount` with that field's path, inside a field group of the array
|
|
27
|
+
// the two share, as ERC-7730's PermitBatch example writes it.
|
|
28
|
+
//
|
|
29
|
+
// Some words a profile adds have no ERC-7730 form but change no value: a timestamp's `threshold`, `message` and
|
|
30
|
+
// `labels` (written as a plain date), and an intent template ERC-7730 cannot interpolate (an array, a whole tuple, a
|
|
31
|
+
// [ ] segment; `interpolatedIntent` is optional and a wallet falls back to `intent`). They are left out, and each is
|
|
32
|
+
// returned as a note, instead of refusing the file.
|
|
22
33
|
|
|
23
34
|
import { AbiError, jcs, keccak256Hex, parseSignature, readAddress, typedDataDigest } from "@meantjs/profile/evm";
|
|
24
35
|
import { CODES, fail } from "./errors.mjs";
|
|
@@ -35,7 +46,7 @@ export const EAS_ADDRESS = "0xA1207F3BBa224E2c9c3c6D5aF63D0eb1582Ce587";
|
|
|
35
46
|
|
|
36
47
|
const ZERO32 = `0x${"0".repeat(64)}`;
|
|
37
48
|
const ZERO_ADDRESS = `0x${"0".repeat(40)}`;
|
|
38
|
-
const OPTIONS = new Set(["deployments", "owner", "contractName", "info", "token"]);
|
|
49
|
+
const OPTIONS = new Set(["context", "deployments", "owner", "contractName", "info", "token"]);
|
|
39
50
|
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
40
51
|
const nonEmpty = (v) => typeof v === "string" && v.trim() !== "";
|
|
41
52
|
|
|
@@ -91,10 +102,32 @@ function erc20Metadata(resolved) {
|
|
|
91
102
|
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
103
|
}
|
|
93
104
|
|
|
94
|
-
/**
|
|
95
|
-
function
|
|
105
|
+
/** The keys of a placeholder or token path through a viewed tree (`["details", "[]", "token"]`), or null. */
|
|
106
|
+
function keysOf(method, relpath) {
|
|
107
|
+
const rp = resolvePath(method, relpath);
|
|
108
|
+
return rp.error ? null : rp.steps.map((st) => (st.kind === "elem" ? "[]" : st.key));
|
|
109
|
+
}
|
|
110
|
+
const startsWith = (keys, prefix) => prefix.length <= keys.length && prefix.every((k, i) => keys[i] === k);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* One ERC-7730 field for a leaf of a function's parameters or a message's members. `at` is where the leaf is:
|
|
114
|
+
* `{ 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
|
+
*/
|
|
116
|
+
function fieldOf(node, path, label, ctx, where, at) {
|
|
96
117
|
const field = { path, label };
|
|
97
118
|
const amount = tagArgs(node, "amount");
|
|
119
|
+
if (amount?.token !== undefined) {
|
|
120
|
+
// In the token a field of the same value names (spec section 15.9): its path, relative to the group.
|
|
121
|
+
const keys = keysOf(at.method, String(amount.token));
|
|
122
|
+
if (keys === null) return ctx.problem(where, `amount.token ${JSON.stringify(amount.token)} does not resolve`);
|
|
123
|
+
if (!startsWith(keys, at.base)) return ctx.problem(where, "the amount is in an array group and its token is outside it; this export writes no path that leaves a group");
|
|
124
|
+
const params = { tokenPath: keys.slice(at.base.length).join(".") };
|
|
125
|
+
if (amount.threshold !== undefined) {
|
|
126
|
+
params.threshold = `0x${BigInt(amount.threshold).toString(16)}`;
|
|
127
|
+
params.message = amount.message ?? "Unlimited";
|
|
128
|
+
}
|
|
129
|
+
return { ...field, format: "tokenAmount", params };
|
|
130
|
+
}
|
|
98
131
|
if (amount !== undefined) {
|
|
99
132
|
const dynamic = amount.decimals?.from === "query:decimals" && amount.symbol?.from === "query:symbol";
|
|
100
133
|
const literal = Number.isInteger(amount.decimals) && typeof amount.symbol === "string";
|
|
@@ -120,6 +153,8 @@ function fieldOf(node, path, label, ctx, where) {
|
|
|
120
153
|
if (time !== undefined) {
|
|
121
154
|
const kind = tagArgs(node, "timestamp") !== undefined ? "timestamp" : "duration";
|
|
122
155
|
if (time.unit !== "s") return ctx.problem(where, `${kind} in ${time.unit}: ERC-7730 ${kind === "timestamp" ? "dates" : "durations"} are in seconds`);
|
|
156
|
+
const words = ["threshold", "labels"].filter((k) => time[k] !== undefined);
|
|
157
|
+
if (kind === "timestamp" && words.length) ctx.note(where, `the timestamp's ${words.join(" and ")} ha${words.length > 1 ? "ve" : "s"} no ERC-7730 date form and ${words.length > 1 ? "are" : "is"} left out; the file shows the date`);
|
|
123
158
|
return kind === "timestamp" ? { ...field, format: "date", params: { encoding: "timestamp" } } : { ...field, format: "duration" };
|
|
124
159
|
}
|
|
125
160
|
const bytes = tagArgs(node, "bytes");
|
|
@@ -127,12 +162,30 @@ function fieldOf(node, path, label, ctx, where) {
|
|
|
127
162
|
return { ...field, format: node.abi === "address" ? "addressName" : "raw" };
|
|
128
163
|
}
|
|
129
164
|
|
|
130
|
-
/**
|
|
131
|
-
function
|
|
132
|
-
if (node.candid === "record")
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
165
|
+
/** Whether a subtree holds an amount whose token path starts with `prefix` (it is in the same array element). */
|
|
166
|
+
function tokenInside(node, method, prefix) {
|
|
167
|
+
if (node.candid === "record") return entriesOf(node.fields).some(([, child]) => tokenInside(child, method, prefix));
|
|
168
|
+
if (node.candid === "vec") return tokenInside(node.element, method, prefix);
|
|
169
|
+
const token = tagArgs(node, "amount")?.token;
|
|
170
|
+
const keys = token === undefined ? null : keysOf(method, String(token));
|
|
171
|
+
return keys !== null && startsWith(keys, prefix);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The ERC-7730 fields of a viewed parameter tree, depth first in ABI order. An array whose elements hold an amount
|
|
176
|
+
* whose token is in the same element is a field group (`{ path: "details.[]", fields }`), its paths relative to the
|
|
177
|
+
* element, as ERC-7730's PermitBatch example writes it; any other array is flattened (`legs.[].to`).
|
|
178
|
+
*/
|
|
179
|
+
function fieldsOf(node, abs, rel, ctx, method, name) {
|
|
180
|
+
if (node.candid === "record") return entriesOf(node.fields).flatMap(([k, child]) => fieldsOf(child, [...abs, k], [...rel, k], ctx, method, name));
|
|
181
|
+
if (node.candid === "vec") {
|
|
182
|
+
const element = [...abs, "[]"];
|
|
183
|
+
if (tokenInside(node.element, method, element)) return [{ path: [...rel, "[]"].join("."), fields: fieldsOf(node.element, element, [], ctx, method, name) }];
|
|
184
|
+
return fieldsOf(node.element, element, [...rel, "[]"], ctx, method, name);
|
|
185
|
+
}
|
|
186
|
+
const label = labelOf(node) ?? [...abs].reverse().find((k) => k !== "[]");
|
|
187
|
+
const where = `${name}(0).${abs.join(".").replaceAll(".[]", "[]")}`;
|
|
188
|
+
return [fieldOf(node, rel.join("."), label, ctx, where, { method, abs, base: abs.slice(0, abs.length - rel.length) })];
|
|
136
189
|
}
|
|
137
190
|
|
|
138
191
|
/** A parameter or tuple component without a name: an ERC-7730 key names every one, and paths need the names. */
|
|
@@ -150,23 +203,28 @@ function unnamed(params, at = "") {
|
|
|
150
203
|
return null;
|
|
151
204
|
}
|
|
152
205
|
|
|
153
|
-
/**
|
|
206
|
+
/**
|
|
207
|
+
* The template as an ERC-7730 interpolatedIntent: `{(0).to}` reads `{to}`, `[[` and `]]` are plain brackets. A
|
|
208
|
+
* template ERC-7730 cannot interpolate gets none, and a note: `interpolatedIntent` is optional, and `intent` and the
|
|
209
|
+
* fields still say everything.
|
|
210
|
+
*/
|
|
154
211
|
function interpolated(template, method, ctx, where) {
|
|
212
|
+
const leftOut = (why) => ctx.note(where, `${why}; interpolatedIntent is left out`);
|
|
155
213
|
let parts;
|
|
156
214
|
try {
|
|
157
215
|
parts = parseTemplate(template);
|
|
158
216
|
} catch (e) {
|
|
159
|
-
return
|
|
217
|
+
return leftOut(e.message);
|
|
160
218
|
}
|
|
161
219
|
let out = "";
|
|
162
220
|
for (const p of parts) {
|
|
163
221
|
if (p.t === "lit") out += p.text.replaceAll("{", "{{").replaceAll("}", "}}");
|
|
164
|
-
else if (p.t === "opt") return
|
|
222
|
+
else if (p.t === "opt") return leftOut("an optional [ ] segment has no ERC-7730 form");
|
|
165
223
|
else {
|
|
166
224
|
const rp = resolvePath(method, p.path);
|
|
167
225
|
if (rp.error) return ctx.problem(where, `{${p.path}}: ${rp.error}`);
|
|
168
|
-
if (rp.steps.some((s) => s.kind === "elem")) return
|
|
169
|
-
if (rp.node.candid === "record" || rp.node.candid === "vec") return
|
|
226
|
+
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
|
+
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`);
|
|
170
228
|
out += `{${rp.steps.map((s) => s.key).join(".")}}`;
|
|
171
229
|
}
|
|
172
230
|
}
|
|
@@ -180,29 +238,20 @@ function checkText(v, where) {
|
|
|
180
238
|
else if (isObject(v)) for (const [k, x] of Object.entries(v)) checkText(k, where), checkText(x, `${where}.${k}`);
|
|
181
239
|
}
|
|
182
240
|
|
|
183
|
-
/**
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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);
|
|
241
|
+
/** One ERC-7730 format entry: the intent's title (else `describe.label`, else `fallback`), its template, the fields. */
|
|
242
|
+
function entryOf(name, method, viewed, fallback, ctx) {
|
|
243
|
+
const intent = method.tags?.intent?.args;
|
|
244
|
+
const entry = { intent: nonEmpty(intent?.title) ? intent.title : (labelOf(method) ?? fallback) };
|
|
245
|
+
if (typeof intent?.template === "string") {
|
|
246
|
+
const text = interpolated(intent.template, viewed, ctx, `${name}: intent template`);
|
|
247
|
+
if (text !== undefined) entry.interpolatedIntent = text;
|
|
248
|
+
}
|
|
249
|
+
const fields = viewed.args.length ? fieldsOf(viewed.args[0], [], [], ctx, viewed, name) : [];
|
|
250
|
+
return { entry, fields };
|
|
251
|
+
}
|
|
205
252
|
|
|
253
|
+
/** The formats of every function a transaction calls, keyed by its named signature. */
|
|
254
|
+
function functionFormats(resolved, ctx) {
|
|
206
255
|
const formats = {};
|
|
207
256
|
for (const [name, m] of entriesOf(resolved.methods)) {
|
|
208
257
|
if (!isObject(m) || m.mode !== "update") continue;
|
|
@@ -221,26 +270,92 @@ export function erc7730Descriptor(resolved, options = {}) {
|
|
|
221
270
|
continue;
|
|
222
271
|
}
|
|
223
272
|
const viewed = { ...method, args: method.args.map((n, i) => abiView(n, `${name}(${i})`)) };
|
|
224
|
-
const
|
|
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(".[]", "[]")}`)) : [];
|
|
273
|
+
const { entry, fields } = entryOf(name, method, viewed, `Call ${name}`, ctx);
|
|
231
274
|
if (method.state_mutability === "payable") fields.push({ path: "@.value", label: "Value sent", format: "amount" });
|
|
232
275
|
if (fields.length) entry.fields = fields;
|
|
233
276
|
formats[method.signature] = entry;
|
|
234
277
|
}
|
|
278
|
+
if (Object.keys(formats).length === 0) ctx.problem("functions", "the contract has no function a transaction calls (every function is view or pure)");
|
|
279
|
+
return formats;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** The formats of every EIP-712 message (spec section 15.9), keyed by its encode_type, which must hash to its type_hash. */
|
|
283
|
+
function messageFormats(resolved, ctx) {
|
|
284
|
+
const formats = {};
|
|
285
|
+
for (const [name, m] of entriesOf(resolved.messages)) {
|
|
286
|
+
if (!isObject(m) || typeof m.encode_type !== "string" || !m.encode_type.startsWith(`${name}(`) || `0x${keccak256Hex(m.encode_type)}` !== m.type_hash) {
|
|
287
|
+
fail(CODES.RESOLVED_FORM, `messages.${name}: its encode_type does not hash to its type_hash, or names another type`);
|
|
288
|
+
}
|
|
289
|
+
if (!Array.isArray(m.args) || m.args.length !== 1) fail(CODES.RESOLVED_FORM, `messages.${name} has no args array of one struct`);
|
|
290
|
+
const viewed = { ...m, args: m.args.map((n, i) => abiView(n, `${name}(${i})`)) };
|
|
291
|
+
const { entry, fields } = entryOf(name, m, viewed, `Sign ${name}`, ctx);
|
|
292
|
+
if (fields.length) entry.fields = fields;
|
|
293
|
+
formats[m.encode_type] = entry;
|
|
294
|
+
}
|
|
295
|
+
if (Object.keys(formats).length === 0) ctx.problem("messages", "the resolved form describes no EIP-712 message");
|
|
296
|
+
return formats;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** ERC-7730 `context.eip712.domain`: the name and version every profile that gives one gives. */
|
|
300
|
+
function domainOf(resolved, ctx) {
|
|
301
|
+
const domain = {};
|
|
302
|
+
for (const p of Array.isArray(resolved.profiles) ? resolved.profiles : []) {
|
|
303
|
+
if (!isObject(p?.eip712_domain)) continue;
|
|
304
|
+
for (const [k, v] of Object.entries(p.eip712_domain)) {
|
|
305
|
+
if ((k !== "name" && k !== "version") || typeof v !== "string") fail(CODES.RESOLVED_FORM, `profile ${p.name}: eip712_domain has ${JSON.stringify(k)}; only a name and a version are bound by a profile`);
|
|
306
|
+
if (Object.hasOwn(domain, k) && domain[k] !== v) ctx.problem("domain", `profiles give the domain two ${k}s, ${JSON.stringify(domain[k])} and ${JSON.stringify(v)}`);
|
|
307
|
+
domain[k] = v;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return domain;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* The ERC-7730 file of an EVM contract's resolved form, and its ERC-8176 `descriptorHash`.
|
|
315
|
+
*
|
|
316
|
+
* @param {object} resolved an EVM contract's resolved form (`chain: "evm"`)
|
|
317
|
+
* @param {object} [options]
|
|
318
|
+
* @param {"contract" | "eip712"} [options.context] the functions a transaction calls (default), or the EIP-712
|
|
319
|
+
* messages (spec section 15.9): ERC-7730 binds one file to one of the two
|
|
320
|
+
* @param {{ chainId: number, address: string }[]} [options.deployments] default: the deployments every profile lists
|
|
321
|
+
* @param {string} [options.owner] ERC-7730 `metadata.owner`
|
|
322
|
+
* @param {string} [options.contractName] `metadata.contractName`
|
|
323
|
+
* @param {{ url: string, deploymentDate?: string }} [options.info] `metadata.info`
|
|
324
|
+
* @param {{ name: string }} [options.token] the contract is the token its literal amounts are in: `metadata.token`
|
|
325
|
+
* @returns {{ descriptor: object, descriptorHash: string, notes: string[] }} `notes`: what was left out because a
|
|
326
|
+
* file cannot say it, though the file still shows every value (a timestamp's words, an interpolatedIntent)
|
|
327
|
+
*/
|
|
328
|
+
export function erc7730Descriptor(resolved, options = {}) {
|
|
329
|
+
if (!isObject(resolved) || resolved.format !== "candid-profile-resolved" || !isObject(resolved.methods) || resolved.chain !== "evm") methodOf(resolved, "", "evm"); // throws the right error
|
|
330
|
+
if (!isObject(options)) fail(CODES.REQUEST, "options must be an object");
|
|
331
|
+
for (const k of Object.keys(options)) if (!OPTIONS.has(k)) fail(CODES.REQUEST, `unknown option ${JSON.stringify(k)}; expected one of ${[...OPTIONS].join(", ")}`);
|
|
332
|
+
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");
|
|
333
|
+
const context = options.context ?? "contract";
|
|
334
|
+
if (context !== "contract" && context !== "eip712") fail(CODES.REQUEST, `options.context must be "contract" or "eip712", got ${JSON.stringify(options.context)}`);
|
|
335
|
+
|
|
336
|
+
const problems = [];
|
|
337
|
+
const notes = [];
|
|
338
|
+
const ctx = {
|
|
339
|
+
erc20: erc20Metadata(resolved),
|
|
340
|
+
tokenName: options.token?.name,
|
|
341
|
+
token: null,
|
|
342
|
+
dynamic: false,
|
|
343
|
+
problem: (at, message) => void problems.push(`${at}: ${message}`),
|
|
344
|
+
note: (at, message) => void notes.push(`${at}: ${message}`),
|
|
345
|
+
};
|
|
346
|
+
const deployments = deploymentsOf(resolved, options.deployments);
|
|
347
|
+
const metadata = metadataOf(options);
|
|
348
|
+
const formats = context === "eip712" ? messageFormats(resolved, ctx) : functionFormats(resolved, ctx);
|
|
349
|
+
const domain = context === "eip712" ? domainOf(resolved, ctx) : {};
|
|
235
350
|
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
351
|
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
352
|
|
|
239
353
|
if (ctx.token) metadata.token = { name: ctx.tokenName, ticker: ctx.token.ticker, decimals: ctx.token.decimals };
|
|
240
|
-
const
|
|
354
|
+
const bound = context === "eip712" ? { eip712: { ...(Object.keys(domain).length ? { domain } : {}), deployments } } : { contract: { deployments } };
|
|
355
|
+
const descriptor = { $schema: ERC7730_SCHEMA_URL, context: bound };
|
|
241
356
|
if (Object.keys(metadata).length) descriptor.metadata = metadata;
|
|
242
357
|
descriptor.display = { formats };
|
|
243
|
-
return { descriptor, descriptorHash: descriptorHash(descriptor) };
|
|
358
|
+
return { descriptor, descriptorHash: descriptorHash(descriptor), notes };
|
|
244
359
|
}
|
|
245
360
|
|
|
246
361
|
/** ERC-8176 `descriptorHash`: Keccak-256 of the RFC 8785 (JCS) form of a descriptor without `includes`. */
|
package/src/errors.mjs
CHANGED
|
@@ -22,6 +22,8 @@ export const CODES = Object.freeze({
|
|
|
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
24
|
DEPLOYMENT: "E_DEPLOYMENT", // an EVM call's target is not a deployment its profile lists (spec section 15)
|
|
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
|
+
TYPE_HASH: "E_TYPE_HASH", // no message of the resolved form has the request's EIP-712 type hash (spec section 15.9)
|
|
25
27
|
// this generator cannot describe the call (ICRC-21 ConsentMessageUnavailable)
|
|
26
28
|
UNSUPPORTED_TYPE: "E_UNSUPPORTED_TYPE", // a position the resolved form cannot type (recursive cut, func, service)
|
|
27
29
|
// the profile or the caller's context is wrong (ICRC-21 GenericError)
|
package/src/evm.d.mts
CHANGED
|
@@ -23,6 +23,11 @@ export interface EvmConsentInput {
|
|
|
23
23
|
chainId?: number | bigint | string;
|
|
24
24
|
/** Resolved dynamic refs, keyed by `from`: `{ "query:decimals": 6, "query:symbol": "USDC" }`. */
|
|
25
25
|
refs?: Record<string, unknown>;
|
|
26
|
+
/**
|
|
27
|
+
* For an amount whose token a field names (spec section 15.9): each such token's `decimals()` and `symbol()`, keyed by
|
|
28
|
+
* its address. An amount in a token not listed is E_REF_UNRESOLVED; it is shown beside its token's address.
|
|
29
|
+
*/
|
|
30
|
+
tokens?: Record<string, { decimals: number | bigint | string; symbol?: string }>;
|
|
26
31
|
/** Default "generic" (Markdown). */
|
|
27
32
|
form?: "generic" | "fields";
|
|
28
33
|
/** Generic form only: show timestamps in this zone (default UTC). */
|
|
@@ -49,3 +54,53 @@ export function evmConsent(input: EvmConsentInput): {
|
|
|
49
54
|
provenance: Provenance;
|
|
50
55
|
model: Model;
|
|
51
56
|
};
|
|
57
|
+
|
|
58
|
+
/** An EIP-712 signing request, as `eth_signTypedData_v4` takes it. `types.EIP712Domain` may be left out. */
|
|
59
|
+
export interface TypedData {
|
|
60
|
+
types: Record<string, { name: string; type: string }[]>;
|
|
61
|
+
primaryType: string;
|
|
62
|
+
domain: { name?: string; version?: string; chainId?: number | bigint | string; verifyingContract?: string; salt?: string };
|
|
63
|
+
message: Record<string, unknown>;
|
|
64
|
+
}
|
|
65
|
+
export interface Eip712ConsentInput {
|
|
66
|
+
/** An EVM contract's resolved form with `messages` (spec section 15.9). */
|
|
67
|
+
resolved: ResolvedForm;
|
|
68
|
+
typedData: TypedData;
|
|
69
|
+
/** The wallet's active chain: a domain for another chain is refused (E_DOMAIN). */
|
|
70
|
+
chainId: number | bigint | string;
|
|
71
|
+
/** Resolved dynamic refs; `query:` refs are calls to the verifying contract. */
|
|
72
|
+
refs?: Record<string, unknown>;
|
|
73
|
+
/** For an amount whose token a member names: each token's `decimals()` and `symbol()`, keyed by its address. */
|
|
74
|
+
tokens?: Record<string, { decimals: number | bigint | string; symbol?: string }>;
|
|
75
|
+
/** Default "generic" (Markdown). */
|
|
76
|
+
form?: "generic" | "fields";
|
|
77
|
+
/** Generic form only: show timestamps in this zone (default UTC). */
|
|
78
|
+
utcOffsetMinutes?: number | null;
|
|
79
|
+
/** Append the message's values (spec section 11 item 1). */
|
|
80
|
+
rawArguments?: boolean;
|
|
81
|
+
limits?: { maxBytes?: number; maxElements?: number };
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The consent text of an EIP-712 signing request (spec section 15.9): the message a profile describes, matched by its
|
|
85
|
+
* type hash, in words, ending with the verifying contract, the digest and what a signature is. Refused before anything
|
|
86
|
+
* is shown: a domain for another chain, without a chainId or verifyingContract, with a salt, or whose name or version
|
|
87
|
+
* is not its profiles' (E_DOMAIN); a contract its profiles do not list (E_DEPLOYMENT); no message with the request's
|
|
88
|
+
* type hash (E_TYPE_HASH); values that are not exactly the members, canonically written (E_DECODE); an amount in a
|
|
89
|
+
* token not in `tokens` (E_REF_UNRESOLVED).
|
|
90
|
+
*/
|
|
91
|
+
export function eip712Consent(input: Eip712ConsentInput): {
|
|
92
|
+
form: "generic" | "fields";
|
|
93
|
+
/** Markdown for "generic"; the intent line and `[label, text]` rows for "fields". */
|
|
94
|
+
message: string | { intent: string; fields: [string, string][] };
|
|
95
|
+
primaryType: string;
|
|
96
|
+
/** `0x` and 64 hex digits. */
|
|
97
|
+
typeHash: string;
|
|
98
|
+
/** The EIP-712 digest the signature signs: `0x` and 64 hex digits. */
|
|
99
|
+
digest: string;
|
|
100
|
+
/** The verifying contract, in its EIP-55 form. */
|
|
101
|
+
verifyingContract: string;
|
|
102
|
+
chainId: string;
|
|
103
|
+
diagnostics: ConsentDiagnostic[];
|
|
104
|
+
provenance: Provenance;
|
|
105
|
+
model: Model;
|
|
106
|
+
};
|
package/src/evm.mjs
CHANGED
|
@@ -13,13 +13,19 @@
|
|
|
13
13
|
// person must see which contract asks (spec section 11).
|
|
14
14
|
//
|
|
15
15
|
// Refused: a payable function (its native value has no position yet, O2), and a call with no matching selector.
|
|
16
|
+
//
|
|
17
|
+
// A typed message (EIP-712, spec section 15.9) is the other thing an EVM wallet is asked to sign: `eip712Consent`
|
|
18
|
+
// gives it the same model and layouts, after checking that the request is the message a profile describes (its chain,
|
|
19
|
+
// its contract, its domain, its type hash) and reading its values strictly. An amount whose token a field names is
|
|
20
|
+
// shown with that token's decimals and symbol, which the caller supplies per address (`tokens`), and its address.
|
|
16
21
|
|
|
17
|
-
import { AbiError, decodeParams, parseSignature, readAddress } from "@meantjs/profile/evm";
|
|
18
|
-
import { Renderer } from "./display.mjs";
|
|
22
|
+
import { AbiError, DEFAULT_ABI_LIMITS, decodeParams, eip712TypeHash, keccak256Hex, parseEip712Types, parseSignature, readAddress, typedDataDigest } from "@meantjs/profile/evm";
|
|
23
|
+
import { Renderer, TokenAmount } from "./display.mjs";
|
|
19
24
|
import { CODES, ConsentError, fail } from "./errors.mjs";
|
|
20
25
|
import { modelOf } from "./model.mjs";
|
|
26
|
+
import { resolvePath } from "./paths.mjs";
|
|
21
27
|
import { renderGeneric, renderPlain } from "./render.mjs";
|
|
22
|
-
import { abiView, methodOf } from "./resolved.mjs";
|
|
28
|
+
import { abiView, entriesOf, methodOf, tagArgs } from "./resolved.mjs";
|
|
23
29
|
import { codeSpan } from "./text.mjs";
|
|
24
30
|
|
|
25
31
|
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
@@ -85,6 +91,49 @@ function checkTarget(resolved, to, chain) {
|
|
|
85
91
|
}
|
|
86
92
|
}
|
|
87
93
|
|
|
94
|
+
/** The caller's `tokens`: the decimals and symbol of each token, keyed by its address in any valid spelling. */
|
|
95
|
+
function tokenTable(tokens) {
|
|
96
|
+
if (!isObject(tokens)) fail(CODES.REQUEST, "tokens must be an object of token address to { decimals, symbol }");
|
|
97
|
+
const out = new Map();
|
|
98
|
+
for (const [k, v] of Object.entries(tokens)) {
|
|
99
|
+
let address;
|
|
100
|
+
try {
|
|
101
|
+
address = readAddress(k);
|
|
102
|
+
} catch (e) {
|
|
103
|
+
if (e instanceof AbiError) fail(CODES.REQUEST, `tokens: ${e.message}`);
|
|
104
|
+
throw e;
|
|
105
|
+
}
|
|
106
|
+
if (!isObject(v)) fail(CODES.REQUEST, `tokens[${JSON.stringify(k)}] must be { decimals, symbol }`);
|
|
107
|
+
if (out.has(address)) fail(CODES.REQUEST, `tokens lists ${address} twice`);
|
|
108
|
+
out.set(address, { decimals: v.decimals, symbol: v.symbol });
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The arguments with each amount whose token a field names (spec section 15.9) made a TokenAmount: its base units, the
|
|
115
|
+
* address that field holds in the same array element, and that token's metadata from `tokens`. A token the caller did
|
|
116
|
+
* not describe stops the message (E_REF_UNRESOLVED): a number without its decimals is never shown (spec section 12
|
|
117
|
+
* rule 10).
|
|
118
|
+
*/
|
|
119
|
+
function withTokens(name, viewed, args, tokens) {
|
|
120
|
+
const box = (node, value, keys, indices) => {
|
|
121
|
+
if (node.candid === "record") return Object.fromEntries(entriesOf(node.fields).map(([k, child]) => [k, box(child, value[k], [...keys, k], indices)]));
|
|
122
|
+
if (node.candid === "vec") return value.map((e, i) => box(node.element, e, [...keys, "[]"], [...indices, i]));
|
|
123
|
+
const token = tagArgs(node, "amount")?.token;
|
|
124
|
+
if (token === undefined) return value;
|
|
125
|
+
const where = `${name}(0)${keys.map((k) => (k === "[]" ? "[]" : `.${k}`)).join("")}`;
|
|
126
|
+
const rp = resolvePath(viewed, String(token));
|
|
127
|
+
let address = rp.error ? undefined : args[rp.index];
|
|
128
|
+
let n = 0;
|
|
129
|
+
for (const step of rp.steps ?? []) address = step.kind === "elem" ? address?.[indices[n++]] : address?.[step.key];
|
|
130
|
+
if (typeof address !== "string" || rp.node.abi !== "address" || n > indices.length) fail(CODES.RESOLVED_FORM, `${where}: amount.token ${JSON.stringify(token)} does not lead to an address in the same element`);
|
|
131
|
+
if (!tokens.has(address)) fail(CODES.REF_UNRESOLVED, `${where} is an amount of the token ${address}, whose decimals and symbol were not given (tokens); without them it has no text`);
|
|
132
|
+
return new TokenAmount(value, address, tokens.get(address));
|
|
133
|
+
};
|
|
134
|
+
return args.map((v, i) => box(viewed.args[i], v, [], []));
|
|
135
|
+
}
|
|
136
|
+
|
|
88
137
|
/**
|
|
89
138
|
* The consent text of an EVM call.
|
|
90
139
|
*
|
|
@@ -94,13 +143,15 @@ function checkTarget(resolved, to, chain) {
|
|
|
94
143
|
* @param {string} input.to the contract called (shown, and checked against the profiles' deployments)
|
|
95
144
|
* @param {number | bigint | string} [input.chainId] needed when a profile lists deployments
|
|
96
145
|
* @param {Record<string, unknown>} [input.refs] resolved dynamic refs, for example { "query:decimals": 6, "query:symbol": "USDC" }
|
|
146
|
+
* @param {Record<string, { decimals: number, symbol?: string }>} [input.tokens] for an amount whose token a field
|
|
147
|
+
* names (spec section 15.9): each such token's decimals() and symbol(), keyed by its address
|
|
97
148
|
* @param {"generic" | "fields"} [input.form] default "generic"
|
|
98
149
|
* @param {number | null} [input.utcOffsetMinutes] generic form only (default 0, UTC)
|
|
99
150
|
* @param {boolean} [input.rawArguments] append the decoded arguments (spec section 11 item 1)
|
|
100
151
|
* @param {object} [input.limits] decoder limits (maxBytes, maxDepth, maxElements)
|
|
101
152
|
* @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
153
|
*/
|
|
103
|
-
export function evmConsent({ resolved, calldata, to, chainId, refs = {}, form = "generic", utcOffsetMinutes = null, rawArguments = false, limits }) {
|
|
154
|
+
export function evmConsent({ resolved, calldata, to, chainId, refs = {}, tokens = {}, form = "generic", utcOffsetMinutes = null, rawArguments = false, limits }) {
|
|
104
155
|
if (form !== "generic" && form !== "fields") fail(CODES.REQUEST, `form must be "generic" or "fields", got ${JSON.stringify(form)}`);
|
|
105
156
|
let target;
|
|
106
157
|
try {
|
|
@@ -115,7 +166,7 @@ export function evmConsent({ resolved, calldata, to, chainId, refs = {}, form =
|
|
|
115
166
|
const method = methodOf(resolved, name, "evm");
|
|
116
167
|
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
168
|
const viewed = { ...method, args: method.args.map((n, i) => abiView(n, `${name}(${i})`)) };
|
|
118
|
-
const model = modelOf(resolved, name, viewed, viewed.args.length ? [values] : []);
|
|
169
|
+
const model = modelOf(resolved, name, viewed, viewed.args.length ? withTokens(name, viewed, [values], tokenTable(tokens)) : []);
|
|
119
170
|
const where = `${target}${chain === null ? "" : ` on chain ${chain}`}`;
|
|
120
171
|
const done = (rest) => ({ ...rest, method: name, selector, to: target, chainId: chain, diagnostics: model.diagnostics, provenance: model.provenance, model });
|
|
121
172
|
if (form === "fields") {
|
|
@@ -127,3 +178,181 @@ export function evmConsent({ resolved, calldata, to, chainId, refs = {}, form =
|
|
|
127
178
|
const text = `${renderGeneric(model, renderer, { rawArguments })}\n\n**Contract:** ${codeSpan(target)}${chain === null ? "" : ` on chain ${chain}`}`;
|
|
128
179
|
return done({ form, message: text });
|
|
129
180
|
}
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------------------------------------- EIP-712 messages
|
|
183
|
+
|
|
184
|
+
/** The domain's fields in the order EIP-712 lists them, with their types. */
|
|
185
|
+
const DOMAIN_FIELDS = [
|
|
186
|
+
["name", "string"],
|
|
187
|
+
["version", "string"],
|
|
188
|
+
["chainId", "uint256"],
|
|
189
|
+
["verifyingContract", "address"],
|
|
190
|
+
["salt", "bytes32"],
|
|
191
|
+
];
|
|
192
|
+
|
|
193
|
+
/** One value of a typed message, read strictly (spec section 15.9): the shapes decodeCalldata gives, or E_DECODE. */
|
|
194
|
+
function readTyped(types, type, value, where, budget) {
|
|
195
|
+
const bad = (message) => fail(CODES.DECODE, `${where}: ${message}`);
|
|
196
|
+
const arr = /^(.*)\[(\d*)\]$/.exec(type);
|
|
197
|
+
if (arr) {
|
|
198
|
+
if (!Array.isArray(value)) bad(`${type} needs an array`);
|
|
199
|
+
if (arr[2] !== "" && value.length !== Number(arr[2])) bad(`${type} needs ${arr[2]} elements, got ${value.length}`);
|
|
200
|
+
budget.elements += value.length;
|
|
201
|
+
if (budget.elements > budget.maxElements) bad(`more than ${budget.maxElements} array elements in the message`);
|
|
202
|
+
return value.map((v, i) => readTyped(types, arr[1], v, `${where}[${i}]`, budget));
|
|
203
|
+
}
|
|
204
|
+
if (Object.hasOwn(types, type)) {
|
|
205
|
+
if (!isObject(value)) bad(`${type} needs an object of its members`);
|
|
206
|
+
const members = types[type];
|
|
207
|
+
for (const k of Object.keys(value)) if (!members.some((m) => m.name === k)) bad(`${type} has no member ${JSON.stringify(k)}`);
|
|
208
|
+
return Object.fromEntries(members.map((m) => [m.name, Object.hasOwn(value, m.name) ? readTyped(types, m.type, value[m.name], `${where}.${m.name}`, budget) : bad(`${type} needs its member ${m.name}`)]));
|
|
209
|
+
}
|
|
210
|
+
const int = /^(u?)int([0-9]+)$/.exec(type);
|
|
211
|
+
if (int) {
|
|
212
|
+
let x;
|
|
213
|
+
if (typeof value === "number" && Number.isSafeInteger(value)) x = BigInt(value);
|
|
214
|
+
else if (typeof value === "string" && /^-?(?:0|[1-9][0-9]*)$/.test(value) && value !== "-0") x = BigInt(value);
|
|
215
|
+
else bad(`${type} needs a decimal string or a JSON integer below 2^53, got ${JSON.stringify(value)}`);
|
|
216
|
+
const bits = BigInt(int[2]);
|
|
217
|
+
const [min, max] = int[1] === "u" ? [0n, 2n ** bits - 1n] : [-(2n ** (bits - 1n)), 2n ** (bits - 1n) - 1n];
|
|
218
|
+
if (x < min || x > max) bad(`${x} is outside ${type}`);
|
|
219
|
+
return x.toString();
|
|
220
|
+
}
|
|
221
|
+
if (type === "address") {
|
|
222
|
+
try {
|
|
223
|
+
return readAddress(value);
|
|
224
|
+
} catch (e) {
|
|
225
|
+
if (e instanceof AbiError) bad(e.message);
|
|
226
|
+
throw e;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (type === "bool") return typeof value === "boolean" ? value : bad("bool needs true or false");
|
|
230
|
+
if (type === "string") return typeof value === "string" && value.isWellFormed() ? value : bad("string needs well-formed text");
|
|
231
|
+
const bytes = /^bytes([0-9]*)$/.exec(type);
|
|
232
|
+
if (bytes) {
|
|
233
|
+
if (typeof value !== "string" || !/^0x(?:[0-9a-fA-F]{2})*$/.test(value)) bad(`${type} needs 0x and hex digits`);
|
|
234
|
+
const b = Uint8Array.from(value.slice(2).match(/../g) ?? [], (h) => parseInt(h, 16));
|
|
235
|
+
if (bytes[1] !== "" && b.length !== Number(bytes[1])) bad(`${type} needs ${bytes[1]} bytes, got ${b.length}`);
|
|
236
|
+
if (b.length > budget.maxBytes) bad(`${b.length} bytes is over the limit of ${budget.maxBytes}`);
|
|
237
|
+
return b;
|
|
238
|
+
}
|
|
239
|
+
bad(`unknown type ${type}`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The request's domain: only EIP-712's fields, no salt (O3), and its EIP712Domain type, if given, exactly those fields. */
|
|
243
|
+
function readDomain(domain, declared) {
|
|
244
|
+
if (!isObject(domain)) fail(CODES.REQUEST, "typedData.domain must be an object");
|
|
245
|
+
for (const k of Object.keys(domain)) if (!DOMAIN_FIELDS.some(([f]) => f === k)) fail(CODES.DOMAIN, `the domain has a field ${JSON.stringify(k)} EIP-712 does not define`);
|
|
246
|
+
if (Object.hasOwn(domain, "salt")) fail(CODES.DOMAIN, "a domain with a salt is not supported yet (spec section 15.9, O3)");
|
|
247
|
+
const fields = DOMAIN_FIELDS.filter(([k]) => Object.hasOwn(domain, k)).map(([name, type]) => ({ name, type }));
|
|
248
|
+
// Each member's name and type, in order; the order of a member's JSON keys is not part of the type (as for the other structs).
|
|
249
|
+
const same = Array.isArray(declared) && declared.length === fields.length && declared.every((m, i) => isObject(m) && m.name === fields[i].name && m.type === fields[i].type);
|
|
250
|
+
if (declared !== undefined && !same) {
|
|
251
|
+
fail(CODES.DOMAIN, `types.EIP712Domain must list the domain's fields in EIP-712 order (${fields.map((f) => `${f.type} ${f.name}`).join(", ")})`);
|
|
252
|
+
}
|
|
253
|
+
for (const k of ["name", "version"]) if (Object.hasOwn(domain, k) && typeof domain[k] !== "string") fail(CODES.DOMAIN, `domain.${k} must be a string`);
|
|
254
|
+
if (!Object.hasOwn(domain, "chainId")) fail(CODES.DOMAIN, "the domain has no chainId, so the signature is valid on every chain; it is refused");
|
|
255
|
+
if (!Object.hasOwn(domain, "verifyingContract")) fail(CODES.DOMAIN, "the domain has no verifyingContract: the contract that will act on the signature must be known");
|
|
256
|
+
const chain = readTyped({}, "uint256", domain.chainId, "domain.chainId", { elements: 0, maxElements: 0, maxBytes: 0 });
|
|
257
|
+
let contract;
|
|
258
|
+
try {
|
|
259
|
+
contract = readAddress(domain.verifyingContract);
|
|
260
|
+
} catch (e) {
|
|
261
|
+
if (e instanceof AbiError) fail(CODES.DOMAIN, `domain.verifyingContract: ${e.message}`);
|
|
262
|
+
throw e;
|
|
263
|
+
}
|
|
264
|
+
return { fields, chain, contract };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The message of a resolved form whose type hash is the request's; the entry's own encode_type must hash to it. */
|
|
268
|
+
function messageOf(resolved, primaryType, typeHash) {
|
|
269
|
+
if (!isObject(resolved) || resolved.format !== "candid-profile-resolved" || !isObject(resolved.methods) || resolved.chain !== "evm") methodOf(resolved, "", "evm"); // throws the right error
|
|
270
|
+
if (!isObject(resolved.messages)) fail(CODES.TYPE_HASH, "the resolved form describes no EIP-712 message");
|
|
271
|
+
const hit = entriesOf(resolved.messages).find(([, m]) => isObject(m) && m.type_hash === typeHash);
|
|
272
|
+
if (!hit) fail(CODES.TYPE_HASH, `no message of the resolved form has the type hash ${typeHash} (${primaryType}); a request is matched by its type hash, never by its name`);
|
|
273
|
+
const [name, m] = hit;
|
|
274
|
+
if (typeof m.encode_type !== "string" || `0x${keccak256Hex(m.encode_type)}` !== typeHash || name !== primaryType || !m.encode_type.startsWith(`${name}(`)) {
|
|
275
|
+
fail(CODES.RESOLVED_FORM, `messages.${name}: its encode_type does not hash to its type_hash, or names another type`);
|
|
276
|
+
}
|
|
277
|
+
if (!Array.isArray(m.args) || m.args.length !== 1) fail(CODES.RESOLVED_FORM, `messages.${name} has no args array of one struct`);
|
|
278
|
+
return { name, message: m };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Every profile that names the domain (`eip712_domain`) must name the request's: its name and its version each equal,
|
|
283
|
+
* and absent where the profile's is absent (Permit2's domain has no version; one with a version is another domain).
|
|
284
|
+
*/
|
|
285
|
+
function checkDomain(resolved, domain) {
|
|
286
|
+
for (const p of Array.isArray(resolved.profiles) ? resolved.profiles : []) {
|
|
287
|
+
if (!isObject(p?.eip712_domain)) continue;
|
|
288
|
+
for (const k of ["name", "version"]) {
|
|
289
|
+
const v = p.eip712_domain[k];
|
|
290
|
+
if (domain[k] === v) continue;
|
|
291
|
+
const theirs = domain[k] === undefined ? "a domain without one" : JSON.stringify(domain[k]);
|
|
292
|
+
fail(CODES.DOMAIN, v === undefined ? `profile ${p.name} describes a domain without a ${k}, not one whose ${k} is ${theirs}` : `profile ${p.name} describes the domain whose ${k} is ${JSON.stringify(v)}, not ${theirs}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* The consent text of an EIP-712 signing request (spec section 15.9): the message a profile describes, its values in
|
|
299
|
+
* words, and what a signature is. Refused before anything is shown when the request does not match: a domain for
|
|
300
|
+
* another chain than the wallet's or without a chainId or verifyingContract (E_DOMAIN), a contract its profiles do not
|
|
301
|
+
* list (E_DEPLOYMENT), a domain name or version other than its profiles' (E_DOMAIN), no message with the request's
|
|
302
|
+
* type hash (E_TYPE_HASH), a message whose values are not exactly its members, canonically written (E_DECODE).
|
|
303
|
+
*
|
|
304
|
+
* @param {object} input
|
|
305
|
+
* @param {object} input.resolved an EVM contract's resolved form with `messages`
|
|
306
|
+
* @param {{ types: object, primaryType: string, domain: object, message: object }} input.typedData the request
|
|
307
|
+
* (`eth_signTypedData_v4`'s JSON); `types.EIP712Domain` may be left out
|
|
308
|
+
* @param {number | bigint | string} input.chainId the wallet's active chain
|
|
309
|
+
* @param {Record<string, unknown>} [input.refs] resolved dynamic refs: `query:` refs are calls to the verifying contract
|
|
310
|
+
* @param {Record<string, { decimals: number, symbol?: string }>} [input.tokens] each token an amount's `token` names
|
|
311
|
+
* @param {"generic" | "fields"} [input.form] default "generic"
|
|
312
|
+
* @param {number | null} [input.utcOffsetMinutes] generic form only (default 0, UTC)
|
|
313
|
+
* @param {boolean} [input.rawArguments] append the message's values (spec section 11 item 1)
|
|
314
|
+
* @param {{ maxBytes?: number, maxElements?: number }} [input.limits]
|
|
315
|
+
*/
|
|
316
|
+
export function eip712Consent({ resolved, typedData, chainId, refs = {}, tokens = {}, form = "generic", utcOffsetMinutes = null, rawArguments = false, limits = {} }) {
|
|
317
|
+
if (form !== "generic" && form !== "fields") fail(CODES.REQUEST, `form must be "generic" or "fields", got ${JSON.stringify(form)}`);
|
|
318
|
+
if (!isObject(typedData) || !isObject(typedData.types) || typeof typedData.primaryType !== "string" || !isObject(typedData.message)) {
|
|
319
|
+
fail(CODES.REQUEST, "typedData must be { types, primaryType, domain, message }");
|
|
320
|
+
}
|
|
321
|
+
const active = chainOf(chainId);
|
|
322
|
+
if (active === null) fail(CODES.REQUEST, "give the wallet's active chainId: a signature for another chain is refused");
|
|
323
|
+
const { EIP712Domain: declared, ...own } = typedData.types;
|
|
324
|
+
const { fields: domainType, chain, contract } = readDomain(typedData.domain, declared);
|
|
325
|
+
if (chain !== active) fail(CODES.DOMAIN, `the domain is for chain ${chain}, the wallet is on chain ${active}`);
|
|
326
|
+
const parsed = parseEip712Types(own, "typedData.types");
|
|
327
|
+
if (parsed.errors.length) fail(CODES.REQUEST, parsed.errors.map((e) => `${e.at}: ${e.message}`).join("; "));
|
|
328
|
+
if (!Object.hasOwn(parsed.types, typedData.primaryType)) fail(CODES.REQUEST, `typedData.types has no ${JSON.stringify(typedData.primaryType)}`);
|
|
329
|
+
const typeHash = eip712TypeHash(parsed.types, typedData.primaryType);
|
|
330
|
+
const { name, message } = messageOf(resolved, typedData.primaryType, typeHash);
|
|
331
|
+
checkTarget(resolved, contract, chain);
|
|
332
|
+
checkDomain(resolved, typedData.domain);
|
|
333
|
+
|
|
334
|
+
const budget = { elements: 0, maxElements: limits.maxElements ?? DEFAULT_ABI_LIMITS.maxElements, maxBytes: limits.maxBytes ?? DEFAULT_ABI_LIMITS.maxBytes };
|
|
335
|
+
const values = readTyped(parsed.types, name, typedData.message, "message", budget);
|
|
336
|
+
let digest;
|
|
337
|
+
try {
|
|
338
|
+
digest = typedDataDigest({ domain: typedData.domain, types: { ...parsed.types, EIP712Domain: domainType }, primaryType: name, message: typedData.message });
|
|
339
|
+
} catch (e) {
|
|
340
|
+
if (e instanceof AbiError) fail(CODES.DECODE, e.message);
|
|
341
|
+
throw e;
|
|
342
|
+
}
|
|
343
|
+
const viewed = { ...message, args: message.args.map((n, i) => abiView(n, `${name}(${i})`)) };
|
|
344
|
+
const model = modelOf(resolved, name, viewed, withTokens(name, viewed, [values], tokenTable(tokens)));
|
|
345
|
+
const where = `${contract} on chain ${chain}`;
|
|
346
|
+
const done = (rest) => ({ ...rest, primaryType: name, typeHash, digest, verifyingContract: contract, chainId: chain, diagnostics: model.diagnostics, provenance: model.provenance, model });
|
|
347
|
+
if (form === "fields") {
|
|
348
|
+
const plain = renderPlain(model, new Renderer({ refs, diagnostics: model.diagnostics }), { rawArguments });
|
|
349
|
+
plain.fields.push(["Contract", where], ["Signature", SIGNATURE_NOTE], ["EIP-712 digest", digest]);
|
|
350
|
+
return done({ form, message: plain });
|
|
351
|
+
}
|
|
352
|
+
const renderer = new Renderer({ refs, utcOffsetMinutes: utcOffsetMinutes ?? 0, diagnostics: model.diagnostics });
|
|
353
|
+
const text = `${renderGeneric(model, renderer, { rawArguments })}\n\n**Contract:** ${codeSpan(contract)} on chain ${chain}\n\n**EIP-712 digest:** ${codeSpan(digest)}\n\n${SIGNATURE_NOTE}.`;
|
|
354
|
+
return done({ form, message: text });
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** What a typed-data signature is, said with every message (spec section 15.9). */
|
|
358
|
+
const SIGNATURE_NOTE = "This is a signature, not a transaction: whoever holds it can submit it";
|
package/src/index.d.mts
CHANGED
|
@@ -207,6 +207,8 @@ export const CODES: Readonly<{
|
|
|
207
207
|
LANGUAGE: "E_LANGUAGE";
|
|
208
208
|
REQUEST: "E_REQUEST";
|
|
209
209
|
DEPLOYMENT: "E_DEPLOYMENT";
|
|
210
|
+
DOMAIN: "E_DOMAIN";
|
|
211
|
+
TYPE_HASH: "E_TYPE_HASH";
|
|
210
212
|
UNSUPPORTED_TYPE: "E_UNSUPPORTED_TYPE";
|
|
211
213
|
RESOLVED_FORM: "E_RESOLVED_FORM";
|
|
212
214
|
TEMPLATE: "E_TEMPLATE";
|