@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/src/erc7730.mjs
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
// ERC-7730 clear-signing files from an EVM contract's resolved form, and the ERC-8176 attestation a reviewer signs for
|
|
2
|
+
// one (spec section 15, D-054). `@meantjs/consent/erc7730`.
|
|
3
|
+
//
|
|
4
|
+
// The file is the display half of a profile: per function a transaction calls, its `intent` (the intent title), its
|
|
5
|
+
// `interpolatedIntent` (the template) and one field per leaf of its parameters. The same resolved form always gives
|
|
6
|
+
// the same file, so the same `descriptorHash`. What a profile says that an ERC-7730 file cannot say is refused
|
|
7
|
+
// (E_EXPORT), never written as something near it:
|
|
8
|
+
//
|
|
9
|
+
// amount tokenAmount of the contract's own token (`tokenPath: "@.to"`): decimals and symbol read from the
|
|
10
|
+
// ERC-20 `decimals()` and `symbol()`, or given literally and carried as `metadata.token` when the
|
|
11
|
+
// caller says the contract is that token (`options.token`); `threshold` and `message` one to one.
|
|
12
|
+
// Refused otherwise (another method, a symbol missing, a token named by an argument).
|
|
13
|
+
// timestamp `date` with `encoding: "timestamp"`, seconds only duration `duration`, seconds only
|
|
14
|
+
// bytes `raw` (hex), hex only address `addressName`
|
|
15
|
+
// the rest `raw`; `describe.label` is the label, else the parameter's name
|
|
16
|
+
// payable one more field, `@.value` as `amount` (the native value, which ERC-7730 wallets know and a profile
|
|
17
|
+
// has no position for yet, O2)
|
|
18
|
+
//
|
|
19
|
+
// The descriptor binds to deployments (ERC-7730 `context.contract.deployments`): the profile's, or the caller's when
|
|
20
|
+
// every profile that lists deployments lists them too. `effect`, `idempotency`, `outcome` and the other agent tags
|
|
21
|
+
// have no ERC-7730 form and are left out: the file carries what a call says, not what it does.
|
|
22
|
+
//
|
|
23
|
+
// 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.
|
|
33
|
+
|
|
34
|
+
import { AbiError, jcs, keccak256Hex, parseSignature, readAddress, typedDataDigest } from "@meantjs/profile/evm";
|
|
35
|
+
import { CODES, fail } from "./errors.mjs";
|
|
36
|
+
import { resolvePath } from "./paths.mjs";
|
|
37
|
+
import { abiView, entriesOf, labelOf, methodOf, tagArgs } from "./resolved.mjs";
|
|
38
|
+
import { parseTemplate } from "./template.mjs";
|
|
39
|
+
|
|
40
|
+
/** The exact-version schema every exported file names: an alias would move the hash when the alias moves. */
|
|
41
|
+
export const ERC7730_SCHEMA_URL = "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.1.0.schema.json";
|
|
42
|
+
/** ERC-8176's canonical EAS schema, `bytes32 descriptorHash` (no resolver, revocable). */
|
|
43
|
+
export const ERC8176_SCHEMA_UID = "0xe023eef113c1670774801c34b377fdf612dd8a4d2fa92fe382e15bd91fafb5c2";
|
|
44
|
+
/** The canonical EAS contract on Ethereum mainnet: the verifying contract of an offchain attestation. */
|
|
45
|
+
export const EAS_ADDRESS = "0xA1207F3BBa224E2c9c3c6D5aF63D0eb1582Ce587";
|
|
46
|
+
|
|
47
|
+
const ZERO32 = `0x${"0".repeat(64)}`;
|
|
48
|
+
const ZERO_ADDRESS = `0x${"0".repeat(40)}`;
|
|
49
|
+
const OPTIONS = new Set(["context", "deployments", "owner", "contractName", "info", "token"]);
|
|
50
|
+
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
51
|
+
const nonEmpty = (v) => typeof v === "string" && v.trim() !== "";
|
|
52
|
+
|
|
53
|
+
/** `{ chainId, address }` of the caller or `{ chain_id, address }` of a profile, checked; null when not one. */
|
|
54
|
+
function deploymentOf(d) {
|
|
55
|
+
const chainId = d?.chainId ?? d?.chain_id;
|
|
56
|
+
if (!Number.isSafeInteger(chainId) || chainId < 1) return null;
|
|
57
|
+
try {
|
|
58
|
+
return { chainId, address: readAddress(d.address) };
|
|
59
|
+
} catch (e) {
|
|
60
|
+
if (e instanceof AbiError) return null;
|
|
61
|
+
throw e;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function deploymentsOf(resolved, given) {
|
|
66
|
+
const key = (d) => `${d.chainId}:${d.address}`;
|
|
67
|
+
const listing = (Array.isArray(resolved.profiles) ? resolved.profiles : []).filter((p) => Array.isArray(p?.deployments));
|
|
68
|
+
const sets = listing.map((p) => ({ name: p.name, keys: new Set(p.deployments.map(deploymentOf).filter(Boolean).map(key)) }));
|
|
69
|
+
let out;
|
|
70
|
+
if (given !== undefined) {
|
|
71
|
+
if (!Array.isArray(given) || given.length === 0) fail(CODES.REQUEST, "options.deployments must be a non-empty array of { chainId, address }");
|
|
72
|
+
out = given.map((d, i) => deploymentOf(d) ?? fail(CODES.REQUEST, `options.deployments[${i}] needs a positive integer chainId and an address (0x and 40 hex digits; mixed case must match EIP-55)`));
|
|
73
|
+
for (const d of out) for (const s of sets) if (!s.keys.has(key(d))) fail(CODES.DEPLOYMENT, `profile ${s.name} does not list ${d.address} on chain ${d.chainId}`);
|
|
74
|
+
} else {
|
|
75
|
+
if (listing.length === 0) fail(CODES.DEPLOYMENT, "an ERC-7730 file binds to deployments: give options.deployments, or bind them in a contract profile (binds.deployments)");
|
|
76
|
+
out = listing[0].deployments.map(deploymentOf).filter((d) => d && sets.every((s) => s.keys.has(key(d))));
|
|
77
|
+
if (out.length === 0) fail(CODES.DEPLOYMENT, `no deployment is listed by every profile that lists deployments (${listing.map((p) => p.name).join(", ")})`);
|
|
78
|
+
}
|
|
79
|
+
const seen = new Set();
|
|
80
|
+
return out.filter((d) => !seen.has(key(d)) && seen.add(key(d)));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function metadataOf(options) {
|
|
84
|
+
const metadata = {};
|
|
85
|
+
for (const k of ["owner", "contractName"]) {
|
|
86
|
+
if (options[k] === undefined) continue;
|
|
87
|
+
if (!nonEmpty(options[k])) fail(CODES.REQUEST, `options.${k} must be a non-empty string`);
|
|
88
|
+
metadata[k] = options[k];
|
|
89
|
+
}
|
|
90
|
+
if (options.info !== undefined) {
|
|
91
|
+
const { url, deploymentDate, ...rest } = isObject(options.info) ? options.info : {};
|
|
92
|
+
if (!nonEmpty(url) || (deploymentDate !== undefined && !nonEmpty(deploymentDate)) || Object.keys(rest).length) fail(CODES.REQUEST, "options.info must be { url, deploymentDate? } with strings");
|
|
93
|
+
metadata.info = deploymentDate === undefined ? { url } : { url, deploymentDate };
|
|
94
|
+
}
|
|
95
|
+
return metadata;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Whether the contract has the ERC-20 `decimals()` and `symbol()` an ERC-7730 wallet reads for `tokenPath: "@.to"`. */
|
|
99
|
+
function erc20Metadata(resolved) {
|
|
100
|
+
const d = resolved.methods.decimals;
|
|
101
|
+
const s = resolved.methods.symbol;
|
|
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;
|
|
103
|
+
}
|
|
104
|
+
|
|
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) {
|
|
117
|
+
const field = { path, label };
|
|
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
|
+
}
|
|
131
|
+
if (amount !== undefined) {
|
|
132
|
+
const dynamic = amount.decimals?.from === "query:decimals" && amount.symbol?.from === "query:symbol";
|
|
133
|
+
const literal = Number.isInteger(amount.decimals) && typeof amount.symbol === "string";
|
|
134
|
+
if (dynamic) {
|
|
135
|
+
if (!ctx.erc20) return ctx.problem(where, "amount reads query:decimals and query:symbol, but the contract has no ERC-20 decimals() and symbol() an ERC-7730 wallet reads");
|
|
136
|
+
ctx.dynamic = true;
|
|
137
|
+
} else if (literal) {
|
|
138
|
+
if (!ctx.tokenName) return ctx.problem(where, "amount with literal decimals and symbol: an ERC-7730 file carries them only as the contract's own token (metadata.token); pass options.token = { name } if this contract is that token");
|
|
139
|
+
const token = { decimals: amount.decimals, ticker: amount.symbol };
|
|
140
|
+
if (ctx.token && (ctx.token.decimals !== token.decimals || ctx.token.ticker !== token.ticker)) return ctx.problem(where, `amount in ${token.ticker} with ${token.decimals} decimals, where another is in ${ctx.token.ticker} with ${ctx.token.decimals}: an ERC-7730 file has one metadata.token`);
|
|
141
|
+
ctx.token = token;
|
|
142
|
+
} else {
|
|
143
|
+
return ctx.problem(where, "amount without the contract's own decimals and symbol (both read from decimals() and symbol(), or both literal) has no ERC-7730 form");
|
|
144
|
+
}
|
|
145
|
+
const params = { tokenPath: "@.to" };
|
|
146
|
+
if (amount.threshold !== undefined) {
|
|
147
|
+
params.threshold = `0x${BigInt(amount.threshold).toString(16)}`;
|
|
148
|
+
params.message = amount.message ?? "Unlimited";
|
|
149
|
+
}
|
|
150
|
+
return { ...field, format: "tokenAmount", params };
|
|
151
|
+
}
|
|
152
|
+
const time = tagArgs(node, "timestamp") ?? tagArgs(node, "duration");
|
|
153
|
+
if (time !== undefined) {
|
|
154
|
+
const kind = tagArgs(node, "timestamp") !== undefined ? "timestamp" : "duration";
|
|
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`);
|
|
158
|
+
return kind === "timestamp" ? { ...field, format: "date", params: { encoding: "timestamp" } } : { ...field, format: "duration" };
|
|
159
|
+
}
|
|
160
|
+
const bytes = tagArgs(node, "bytes");
|
|
161
|
+
if (bytes !== undefined && bytes.encoding !== "hex") return ctx.problem(where, `bytes as ${bytes.encoding}: ERC-7730 shows bytes as hex only`);
|
|
162
|
+
return { ...field, format: node.abi === "address" ? "addressName" : "raw" };
|
|
163
|
+
}
|
|
164
|
+
|
|
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) })];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** A parameter or tuple component without a name: an ERC-7730 key names every one, and paths need the names. */
|
|
192
|
+
function unnamed(params, at = "") {
|
|
193
|
+
for (const [i, p] of params.entries()) {
|
|
194
|
+
const here = `${at}${p.name ?? `#${i}`}`;
|
|
195
|
+
if (p.name === null) return here;
|
|
196
|
+
let t = p.type;
|
|
197
|
+
while (t.kind === "array") t = t.element;
|
|
198
|
+
if (t.kind === "tuple") {
|
|
199
|
+
const hit = unnamed(t.components, `${here}.`);
|
|
200
|
+
if (hit) return hit;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
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
|
+
*/
|
|
211
|
+
function interpolated(template, method, ctx, where) {
|
|
212
|
+
const leftOut = (why) => ctx.note(where, `${why}; interpolatedIntent is left out`);
|
|
213
|
+
let parts;
|
|
214
|
+
try {
|
|
215
|
+
parts = parseTemplate(template);
|
|
216
|
+
} catch (e) {
|
|
217
|
+
return leftOut(e.message);
|
|
218
|
+
}
|
|
219
|
+
let out = "";
|
|
220
|
+
for (const p of parts) {
|
|
221
|
+
if (p.t === "lit") out += p.text.replaceAll("{", "{{").replaceAll("}", "}}");
|
|
222
|
+
else if (p.t === "opt") return leftOut("an optional [ ] segment has no ERC-7730 form");
|
|
223
|
+
else {
|
|
224
|
+
const rp = resolvePath(method, p.path);
|
|
225
|
+
if (rp.error) return ctx.problem(where, `{${p.path}}: ${rp.error}`);
|
|
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`);
|
|
228
|
+
out += `{${rp.steps.map((s) => s.key).join(".")}}`;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Every string of the file must be well-formed Unicode: RFC 8785 (JCS) serializes no lone surrogate. */
|
|
235
|
+
function checkText(v, where) {
|
|
236
|
+
if (typeof v === "string" && !v.isWellFormed()) fail(CODES.EXPORT, `${where}: the text has a lone surrogate, which RFC 8785 cannot serialize`);
|
|
237
|
+
if (Array.isArray(v)) v.forEach((x, i) => checkText(x, `${where}[${i}]`));
|
|
238
|
+
else if (isObject(v)) for (const [k, x] of Object.entries(v)) checkText(k, where), checkText(x, `${where}.${k}`);
|
|
239
|
+
}
|
|
240
|
+
|
|
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
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The formats of every function a transaction calls, keyed by its named signature. */
|
|
254
|
+
function functionFormats(resolved, ctx) {
|
|
255
|
+
const formats = {};
|
|
256
|
+
for (const [name, m] of entriesOf(resolved.methods)) {
|
|
257
|
+
if (!isObject(m) || m.mode !== "update") continue;
|
|
258
|
+
const method = methodOf(resolved, name, "evm");
|
|
259
|
+
let f;
|
|
260
|
+
try {
|
|
261
|
+
f = parseSignature(String(method.signature), `methods.${name}.signature`);
|
|
262
|
+
} catch (e) {
|
|
263
|
+
if (e instanceof AbiError) fail(CODES.RESOLVED_FORM, e.message);
|
|
264
|
+
throw e;
|
|
265
|
+
}
|
|
266
|
+
if (f.name !== name || f.selector !== method.selector) fail(CODES.RESOLVED_FORM, `methods.${name}: the signature ${method.signature} does not hash to ${method.selector}`);
|
|
267
|
+
const missing = unnamed(f.inputs);
|
|
268
|
+
if (missing !== null) {
|
|
269
|
+
ctx.problem(name, `parameter ${missing} has no name; an ERC-7730 key names every parameter`);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const viewed = { ...method, args: method.args.map((n, i) => abiView(n, `${name}(${i})`)) };
|
|
273
|
+
const { entry, fields } = entryOf(name, method, viewed, `Call ${name}`, ctx);
|
|
274
|
+
if (method.state_mutability === "payable") fields.push({ path: "@.value", label: "Value sent", format: "amount" });
|
|
275
|
+
if (fields.length) entry.fields = fields;
|
|
276
|
+
formats[method.signature] = entry;
|
|
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) : {};
|
|
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");
|
|
351
|
+
if (problems.length) fail(CODES.EXPORT, `no ERC-7730 file: ${problems.join("; ")}`);
|
|
352
|
+
|
|
353
|
+
if (ctx.token) metadata.token = { name: ctx.tokenName, ticker: ctx.token.ticker, decimals: ctx.token.decimals };
|
|
354
|
+
const bound = context === "eip712" ? { eip712: { ...(Object.keys(domain).length ? { domain } : {}), deployments } } : { contract: { deployments } };
|
|
355
|
+
const descriptor = { $schema: ERC7730_SCHEMA_URL, context: bound };
|
|
356
|
+
if (Object.keys(metadata).length) descriptor.metadata = metadata;
|
|
357
|
+
descriptor.display = { formats };
|
|
358
|
+
return { descriptor, descriptorHash: descriptorHash(descriptor), notes };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** ERC-8176 `descriptorHash`: Keccak-256 of the RFC 8785 (JCS) form of a descriptor without `includes`. */
|
|
362
|
+
export function descriptorHash(descriptor) {
|
|
363
|
+
if (!isObject(descriptor)) fail(CODES.REQUEST, "a descriptor is a JSON object");
|
|
364
|
+
if (Object.hasOwn(descriptor, "includes")) fail(CODES.REQUEST, "resolve the descriptor's includes first (ERC-8176 hashes the merged file)");
|
|
365
|
+
checkText(descriptor, "descriptor");
|
|
366
|
+
return `0x${keccak256Hex(jcs(descriptor))}`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const ATTEST = [
|
|
370
|
+
{ name: "version", type: "uint16" },
|
|
371
|
+
{ name: "schema", type: "bytes32" },
|
|
372
|
+
{ name: "recipient", type: "address" },
|
|
373
|
+
{ name: "time", type: "uint64" },
|
|
374
|
+
{ name: "expirationTime", type: "uint64" },
|
|
375
|
+
{ name: "revocable", type: "bool" },
|
|
376
|
+
{ name: "refUID", type: "bytes32" },
|
|
377
|
+
{ name: "data", type: "bytes" },
|
|
378
|
+
{ name: "salt", type: "bytes32" },
|
|
379
|
+
];
|
|
380
|
+
|
|
381
|
+
function uint64(v, name) {
|
|
382
|
+
const s = typeof v === "bigint" ? v.toString() : Number.isSafeInteger(v) ? String(v) : v;
|
|
383
|
+
if (typeof s !== "string" || !/^(0|[1-9][0-9]{0,19})$/.test(s) || BigInt(s) >= 2n ** 64n) fail(CODES.REQUEST, `${name} must be a uint64 (seconds since the Unix epoch), got ${String(v)}`);
|
|
384
|
+
return s;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function bytes32(v, name) {
|
|
388
|
+
if (typeof v !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(v)) fail(CODES.REQUEST, `${name} must be 0x and 64 hex digits`);
|
|
389
|
+
return v.toLowerCase();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* The unsigned ERC-8176 offchain attestation of a descriptor: EAS offchain attestation v2 typed data for a reviewer to
|
|
394
|
+
* sign with their own key (EIP-712), and its digest. Nothing is signed or sent here.
|
|
395
|
+
*
|
|
396
|
+
* @param {object} input
|
|
397
|
+
* @param {string} input.descriptorHash from erc7730Descriptor (or descriptorHash)
|
|
398
|
+
* @param {bigint | number | string} input.time when the attestation is made, in Unix seconds
|
|
399
|
+
* @param {bigint | number | string} input.expirationTime when it expires, in Unix seconds; 0 never (ERC-8176 advises a bound)
|
|
400
|
+
* @param {string} input.salt 32 random bytes, 0x hex: the caller's, so the same input gives the same output
|
|
401
|
+
* @param {string} [input.refUID] an attestation this one refers to; default none (32 zero bytes)
|
|
402
|
+
* @returns {{ typedData: { domain: object, primaryType: "Attest", types: { Attest: { name: string, type: string }[] }, message: object }, digest: string }}
|
|
403
|
+
*/
|
|
404
|
+
export function erc8176Attestation({ descriptorHash: hash, time, expirationTime, salt, refUID = ZERO32 } = {}) {
|
|
405
|
+
const message = {
|
|
406
|
+
version: 2,
|
|
407
|
+
schema: ERC8176_SCHEMA_UID,
|
|
408
|
+
recipient: ZERO_ADDRESS,
|
|
409
|
+
time: uint64(time, "time"),
|
|
410
|
+
expirationTime: uint64(expirationTime, "expirationTime"),
|
|
411
|
+
revocable: true,
|
|
412
|
+
refUID: bytes32(refUID, "refUID"),
|
|
413
|
+
data: bytes32(hash, "descriptorHash"), // abi.encode(bytes32) is the 32 bytes themselves
|
|
414
|
+
salt: bytes32(salt, "salt"),
|
|
415
|
+
};
|
|
416
|
+
if (message.expirationTime !== "0" && BigInt(message.expirationTime) <= BigInt(message.time)) fail(CODES.REQUEST, "expirationTime must be 0 or after time");
|
|
417
|
+
const typedData = { domain: { name: "EAS Attestation", version: "0.26", chainId: 1, verifyingContract: EAS_ADDRESS }, primaryType: "Attest", types: { Attest: ATTEST.map((f) => ({ ...f })) }, message };
|
|
418
|
+
return { typedData, digest: typedDataDigest(typedData) };
|
|
419
|
+
}
|
package/src/errors.mjs
CHANGED
|
@@ -21,6 +21,9 @@ export const CODES = Object.freeze({
|
|
|
21
21
|
TOO_LARGE: "E_TOO_LARGE", // the arguments exceed the bounds of a consent message (never truncated)
|
|
22
22
|
LANGUAGE: "E_LANGUAGE", // only with languagePolicy "error": the requested language is not supported
|
|
23
23
|
REQUEST: "E_REQUEST", // the ICRC-21 request itself is malformed
|
|
24
|
+
DEPLOYMENT: "E_DEPLOYMENT", // an EVM call's target is not a deployment its profile lists (spec section 15)
|
|
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)
|
|
24
27
|
// this generator cannot describe the call (ICRC-21 ConsentMessageUnavailable)
|
|
25
28
|
UNSUPPORTED_TYPE: "E_UNSUPPORTED_TYPE", // a position the resolved form cannot type (recursive cut, func, service)
|
|
26
29
|
// the profile or the caller's context is wrong (ICRC-21 GenericError)
|
|
@@ -32,6 +35,8 @@ export const CODES = Object.freeze({
|
|
|
32
35
|
ARG: "E_ARG",
|
|
33
36
|
TAG_TYPE: "E_TAG_TYPE",
|
|
34
37
|
TAG_CONFLICT: "E_TAG_CONFLICT",
|
|
38
|
+
// the ERC-7730 export (erc7730.mjs): the resolved form says something an ERC-7730 file cannot say (spec section 15)
|
|
39
|
+
EXPORT: "E_EXPORT",
|
|
35
40
|
});
|
|
36
41
|
|
|
37
42
|
/** ICRC-21 GenericError.error_code values this package uses (a nat chosen by the canister; see the README). */
|
package/src/evm.d.mts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Types for @meantjs/consent/evm: the consent text of an EVM call (spec section 15, D-054). Hand-written; the source is
|
|
2
|
+
// src/evm.mjs. scripts/types.test.mjs keeps the declared names equal to the runtime exports.
|
|
3
|
+
import type { AbiLimits, AbiValue } from "@meantjs/profile/evm";
|
|
4
|
+
import type { ConsentDiagnostic, Model, Provenance, ResolvedForm } from "./index.mjs";
|
|
5
|
+
|
|
6
|
+
/** An EVM contract's resolved form: `chain: "evm"`, and each method with its selector and named signature. */
|
|
7
|
+
export type EvmResolvedForm = ResolvedForm & { chain: "evm" };
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One call decoded against a resolved form: the function its selector names, and its arguments keyed by parameter
|
|
11
|
+
* name (`#i` for an unnamed one). Strict: anything but the canonical encoding of the values read is E_DECODE.
|
|
12
|
+
* Throws E_RESOLVED_FORM (not an EVM resolved form), E_METHOD_UNKNOWN (no such selector) or E_DECODE.
|
|
13
|
+
*/
|
|
14
|
+
export function decodeCalldata(resolved: ResolvedForm, calldata: Uint8Array | string, limits?: AbiLimits): { method: string; selector: string; values: Record<string, AbiValue> };
|
|
15
|
+
|
|
16
|
+
export interface EvmConsentInput {
|
|
17
|
+
resolved: ResolvedForm;
|
|
18
|
+
/** The selector and the ABI-encoded arguments, as bytes or 0x hex. */
|
|
19
|
+
calldata: Uint8Array | string;
|
|
20
|
+
/** The contract called: shown in the message, and checked against the deployments its profiles list (E_DEPLOYMENT). */
|
|
21
|
+
to: string;
|
|
22
|
+
/** Needed when a profile lists deployments. */
|
|
23
|
+
chainId?: number | bigint | string;
|
|
24
|
+
/** Resolved dynamic refs, keyed by `from`: `{ "query:decimals": 6, "query:symbol": "USDC" }`. */
|
|
25
|
+
refs?: Record<string, unknown>;
|
|
26
|
+
/**
|
|
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 }>;
|
|
31
|
+
/** Default "generic" (Markdown). */
|
|
32
|
+
form?: "generic" | "fields";
|
|
33
|
+
/** Generic form only: show timestamps in this zone (default UTC). */
|
|
34
|
+
utcOffsetMinutes?: number | null;
|
|
35
|
+
/** Append the decoded raw arguments (spec section 11 item 1). */
|
|
36
|
+
rawArguments?: boolean;
|
|
37
|
+
limits?: AbiLimits;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The consent text of an EVM call: the same model and the same two layouts as an ICRC-21 message, without its envelope,
|
|
41
|
+
* ending with the contract's address and chain. A payable function is refused (E_UNSUPPORTED_TYPE): its native value
|
|
42
|
+
* has no position in a profile yet.
|
|
43
|
+
*/
|
|
44
|
+
export function evmConsent(input: EvmConsentInput): {
|
|
45
|
+
form: "generic" | "fields";
|
|
46
|
+
/** Markdown for "generic"; the intent line and `[label, text]` rows for "fields". */
|
|
47
|
+
message: string | { intent: string; fields: [string, string][] };
|
|
48
|
+
method: string;
|
|
49
|
+
selector: string;
|
|
50
|
+
/** The contract, in its EIP-55 form. */
|
|
51
|
+
to: string;
|
|
52
|
+
chainId: string | null;
|
|
53
|
+
diagnostics: ConsentDiagnostic[];
|
|
54
|
+
provenance: Provenance;
|
|
55
|
+
model: Model;
|
|
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
|
+
};
|