@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/evm.mjs
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
// EVM calldata -> the consent text of a call (spec section 15, D-054). ICRC-21 is the Internet Computer's, so an EVM
|
|
2
|
+
// call gets the same model and the same two layouts without the ICRC-21 envelope: the generic form (Markdown) and the
|
|
3
|
+
// fields form as plain text.
|
|
4
|
+
//
|
|
5
|
+
// Steps:
|
|
6
|
+
// 1. the resolved form must be an EVM contract's (`chain: "evm"`), and the call's target, when a profile lists
|
|
7
|
+
// deployments, one of them (a profile describes the contracts it binds, never a look-alike);
|
|
8
|
+
// 2. the selector picks the function; its `signature` gives the parameter types, and calldata is decoded strictly
|
|
9
|
+
// (`@meantjs/profile/evm` decodeParams: anything but the canonical encoding of the values read is refused);
|
|
10
|
+
// 3. the resolved tree is read as a Candid-shaped view (a tuple is a record, an array a vec), so the template, the
|
|
11
|
+
// paths and the fields are those of a Candid call (model.mjs);
|
|
12
|
+
// 4. the text ends with the contract's address and chain: a pack applies to any contract whose functions match, so a
|
|
13
|
+
// person must see which contract asks (spec section 11).
|
|
14
|
+
//
|
|
15
|
+
// Refused: a payable function (its native value has no position yet, O2), and a call with no matching selector.
|
|
16
|
+
//
|
|
17
|
+
// 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.
|
|
21
|
+
|
|
22
|
+
import { AbiError, DEFAULT_ABI_LIMITS, decodeParams, eip712TypeHash, keccak256Hex, parseEip712Types, parseSignature, readAddress, typedDataDigest } from "@meantjs/profile/evm";
|
|
23
|
+
import { Renderer, TokenAmount } from "./display.mjs";
|
|
24
|
+
import { CODES, ConsentError, fail } from "./errors.mjs";
|
|
25
|
+
import { modelOf } from "./model.mjs";
|
|
26
|
+
import { resolvePath } from "./paths.mjs";
|
|
27
|
+
import { renderGeneric, renderPlain } from "./render.mjs";
|
|
28
|
+
import { abiView, entriesOf, methodOf, tagArgs } from "./resolved.mjs";
|
|
29
|
+
import { codeSpan } from "./text.mjs";
|
|
30
|
+
|
|
31
|
+
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
32
|
+
|
|
33
|
+
function bytesOf(calldata) {
|
|
34
|
+
if (calldata instanceof Uint8Array) return calldata;
|
|
35
|
+
if (typeof calldata === "string" && /^0x(?:[0-9a-fA-F]{2})*$/.test(calldata)) return Uint8Array.from(calldata.slice(2).match(/../g) ?? [], (h) => parseInt(h, 16));
|
|
36
|
+
fail(CODES.REQUEST, "calldata must be a Uint8Array or 0x hex text");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function chainOf(chainId) {
|
|
40
|
+
if (chainId === undefined || chainId === null) return null;
|
|
41
|
+
const s = typeof chainId === "number" && Number.isSafeInteger(chainId) ? String(chainId) : typeof chainId === "bigint" ? chainId.toString() : chainId;
|
|
42
|
+
if (typeof s !== "string" || !/^[1-9][0-9]{0,19}$/.test(s)) fail(CODES.REQUEST, `chainId must be a positive integer, got ${String(chainId)}`);
|
|
43
|
+
return s;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The function of a selector, from the resolved form; its signature is checked against the selector. */
|
|
47
|
+
function functionOf(resolved, selector) {
|
|
48
|
+
const hit = Object.entries(resolved.methods).find(([, m]) => isObject(m) && m.selector === selector);
|
|
49
|
+
if (!hit) fail(CODES.METHOD_UNKNOWN, `the resolved form has no function with selector ${selector}`);
|
|
50
|
+
const [name, method] = hit;
|
|
51
|
+
let f;
|
|
52
|
+
try {
|
|
53
|
+
f = parseSignature(String(method.signature), `methods.${name}.signature`);
|
|
54
|
+
} catch (e) {
|
|
55
|
+
if (e instanceof AbiError) fail(CODES.RESOLVED_FORM, e.message);
|
|
56
|
+
throw e;
|
|
57
|
+
}
|
|
58
|
+
if (f.name !== name || f.selector !== selector) fail(CODES.RESOLVED_FORM, `methods.${name}: the signature ${method.signature} does not hash to ${selector}`);
|
|
59
|
+
return { name, method: methodOf(resolved, name, "evm"), f };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Decode one EVM call against a resolved form: which function, and its arguments.
|
|
64
|
+
*
|
|
65
|
+
* @param {object} resolved an EVM contract's resolved form (`chain: "evm"`)
|
|
66
|
+
* @param {Uint8Array | string} calldata the selector and the ABI-encoded arguments
|
|
67
|
+
* @param {{ maxBytes?: number, maxDepth?: number, maxElements?: number }} [limits]
|
|
68
|
+
* @returns {{ method: string, selector: string, values: Record<string, unknown> }}
|
|
69
|
+
*/
|
|
70
|
+
export function decodeCalldata(resolved, calldata, limits) {
|
|
71
|
+
if (!isObject(resolved) || resolved.format !== "candid-profile-resolved" || !isObject(resolved.methods) || resolved.chain !== "evm") methodOf(resolved, "", "evm"); // throws the right error
|
|
72
|
+
const data = bytesOf(calldata);
|
|
73
|
+
if (data.length < 4) fail(CODES.DECODE, `calldata of ${data.length} bytes has no selector`);
|
|
74
|
+
const selector = `0x${[...data.subarray(0, 4)].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
|
|
75
|
+
const { name, f } = functionOf(resolved, selector);
|
|
76
|
+
try {
|
|
77
|
+
return { method: name, selector, values: decodeParams(f.inputs, data.subarray(4), limits) };
|
|
78
|
+
} catch (e) {
|
|
79
|
+
if (e instanceof AbiError) throw new ConsentError(CODES.DECODE, `${name}: ${e.message}`, { cause: e });
|
|
80
|
+
throw e;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Every profile that lists deployments must list this one (chain id and address). */
|
|
85
|
+
function checkTarget(resolved, to, chain) {
|
|
86
|
+
for (const p of Array.isArray(resolved.profiles) ? resolved.profiles : []) {
|
|
87
|
+
if (!Array.isArray(p?.deployments)) continue;
|
|
88
|
+
if (chain === null) fail(CODES.REQUEST, `profile ${p.name} binds deployments: give the chainId of the call`);
|
|
89
|
+
const listed = p.deployments.some((d) => String(d.chain_id) === chain && typeof d.address === "string" && d.address.toLowerCase() === to.toLowerCase());
|
|
90
|
+
if (!listed) fail(CODES.DEPLOYMENT, `profile ${p.name} describes ${p.deployments.map((d) => `${d.address} on chain ${d.chain_id}`).join(", ")}, not ${to} on chain ${chain}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
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
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The consent text of an EVM call.
|
|
139
|
+
*
|
|
140
|
+
* @param {object} input
|
|
141
|
+
* @param {object} input.resolved an EVM contract's resolved form
|
|
142
|
+
* @param {Uint8Array | string} input.calldata
|
|
143
|
+
* @param {string} input.to the contract called (shown, and checked against the profiles' deployments)
|
|
144
|
+
* @param {number | bigint | string} [input.chainId] needed when a profile lists deployments
|
|
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
|
|
148
|
+
* @param {"generic" | "fields"} [input.form] default "generic"
|
|
149
|
+
* @param {number | null} [input.utcOffsetMinutes] generic form only (default 0, UTC)
|
|
150
|
+
* @param {boolean} [input.rawArguments] append the decoded arguments (spec section 11 item 1)
|
|
151
|
+
* @param {object} [input.limits] decoder limits (maxBytes, maxDepth, maxElements)
|
|
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 }}
|
|
153
|
+
*/
|
|
154
|
+
export function evmConsent({ resolved, calldata, to, chainId, refs = {}, tokens = {}, form = "generic", utcOffsetMinutes = null, rawArguments = false, limits }) {
|
|
155
|
+
if (form !== "generic" && form !== "fields") fail(CODES.REQUEST, `form must be "generic" or "fields", got ${JSON.stringify(form)}`);
|
|
156
|
+
let target;
|
|
157
|
+
try {
|
|
158
|
+
target = readAddress(to);
|
|
159
|
+
} catch (e) {
|
|
160
|
+
if (e instanceof AbiError) fail(CODES.REQUEST, `to: ${e.message}`);
|
|
161
|
+
throw e;
|
|
162
|
+
}
|
|
163
|
+
const chain = chainOf(chainId);
|
|
164
|
+
const { method: name, selector, values } = decodeCalldata(resolved, calldata, limits);
|
|
165
|
+
checkTarget(resolved, target, chain);
|
|
166
|
+
const method = methodOf(resolved, name, "evm");
|
|
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)`);
|
|
168
|
+
const viewed = { ...method, args: method.args.map((n, i) => abiView(n, `${name}(${i})`)) };
|
|
169
|
+
const model = modelOf(resolved, name, viewed, viewed.args.length ? withTokens(name, viewed, [values], tokenTable(tokens)) : []);
|
|
170
|
+
const where = `${target}${chain === null ? "" : ` on chain ${chain}`}`;
|
|
171
|
+
const done = (rest) => ({ ...rest, method: name, selector, to: target, chainId: chain, diagnostics: model.diagnostics, provenance: model.provenance, model });
|
|
172
|
+
if (form === "fields") {
|
|
173
|
+
const plain = renderPlain(model, new Renderer({ refs, diagnostics: model.diagnostics }), { rawArguments });
|
|
174
|
+
plain.fields.push(["Contract", where]);
|
|
175
|
+
return done({ form, message: plain });
|
|
176
|
+
}
|
|
177
|
+
const renderer = new Renderer({ refs, utcOffsetMinutes: utcOffsetMinutes ?? 0, diagnostics: model.diagnostics });
|
|
178
|
+
const text = `${renderGeneric(model, renderer, { rawArguments })}\n\n**Contract:** ${codeSpan(target)}${chain === null ? "" : ` on chain ${chain}`}`;
|
|
179
|
+
return done({ form, message: text });
|
|
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
|
@@ -206,6 +206,9 @@ export const CODES: Readonly<{
|
|
|
206
206
|
TOO_LARGE: "E_TOO_LARGE";
|
|
207
207
|
LANGUAGE: "E_LANGUAGE";
|
|
208
208
|
REQUEST: "E_REQUEST";
|
|
209
|
+
DEPLOYMENT: "E_DEPLOYMENT";
|
|
210
|
+
DOMAIN: "E_DOMAIN";
|
|
211
|
+
TYPE_HASH: "E_TYPE_HASH";
|
|
209
212
|
UNSUPPORTED_TYPE: "E_UNSUPPORTED_TYPE";
|
|
210
213
|
RESOLVED_FORM: "E_RESOLVED_FORM";
|
|
211
214
|
TEMPLATE: "E_TEMPLATE";
|
|
@@ -214,6 +217,7 @@ export const CODES: Readonly<{
|
|
|
214
217
|
ARG: "E_ARG";
|
|
215
218
|
TAG_TYPE: "E_TAG_TYPE";
|
|
216
219
|
TAG_CONFLICT: "E_TAG_CONFLICT";
|
|
220
|
+
EXPORT: "E_EXPORT";
|
|
217
221
|
}>;
|
|
218
222
|
export type ConsentErrorCode = (typeof CODES)[keyof typeof CODES];
|
|
219
223
|
/** ICRC-21 `GenericError.error_code` values this package uses. */
|
package/src/model.mjs
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
// disagree about which values the call carries.
|
|
4
4
|
//
|
|
5
5
|
// Which fields appear:
|
|
6
|
-
// - a
|
|
7
|
-
// "one (label, Value) row per placeholder"), in template order,
|
|
8
|
-
//
|
|
6
|
+
// - a call with an intent, its case's (spec section 5.2, D-053) or else its method's: the placeholders of its template
|
|
7
|
+
// that are present (spec section 6; section 12: "one (label, Value) row per placeholder"), in template order,
|
|
8
|
+
// once per distinct path;
|
|
9
|
+
// - a call without one: the generic intent. Title from `describe.label` of the method, else `Call <method>`;
|
|
9
10
|
// one field per leaf of the decoded arguments (records are flattened, tagged positions such as accounts are
|
|
10
11
|
// leaves, absent optionals are left out). More than MAX_FIELDS fields or MAX_ELEMENTS list elements is
|
|
11
12
|
// refused (E_TOO_LARGE), never truncated.
|
|
@@ -13,7 +14,7 @@
|
|
|
13
14
|
import { VALUE_TAGS } from "@meantjs/format";
|
|
14
15
|
import { decodeArguments } from "./decode.mjs";
|
|
15
16
|
import { CODES, ConsentError, fail } from "./errors.mjs";
|
|
16
|
-
import { effectKind, entriesOf, labelOf, methodOf } from "./resolved.mjs";
|
|
17
|
+
import { callTag, effectKind, entriesOf, labelOf, methodOf } from "./resolved.mjs";
|
|
17
18
|
import { parseTemplate } from "./template.mjs";
|
|
18
19
|
import { resolvePath, valuesAt } from "./paths.mjs";
|
|
19
20
|
|
|
@@ -31,9 +32,9 @@ export const MAX_ELEMENTS = 256;
|
|
|
31
32
|
*
|
|
32
33
|
* @typedef {object} Model
|
|
33
34
|
* @property {string} method
|
|
34
|
-
* @property {string} effect the effect kind of the method (spec section 5.3)
|
|
35
|
+
* @property {string} effect the effect kind of the call: its case's, else its method's (spec section 5.3, D-053)
|
|
35
36
|
* @property {string} title
|
|
36
|
-
* @property {SentencePart[] | null} sentence null for a
|
|
37
|
+
* @property {SentencePart[] | null} sentence null for a call without an intent template
|
|
37
38
|
* @property {Field[]} fields
|
|
38
39
|
* @property {object[]} argNodes
|
|
39
40
|
* @property {unknown[]} argValues
|
|
@@ -48,13 +49,18 @@ export const MAX_ELEMENTS = 256;
|
|
|
48
49
|
*/
|
|
49
50
|
export function buildModel({ resolved, method: name, arg, limits }) {
|
|
50
51
|
const method = methodOf(resolved, name);
|
|
51
|
-
|
|
52
|
+
return modelOf(resolved, name, method, decodeArguments(method, arg, limits));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The model of a call whose arguments are decoded already (evm.mjs decodes calldata, then comes here). */
|
|
56
|
+
export function modelOf(resolved, name, method, argValues) {
|
|
52
57
|
const diagnostics = [];
|
|
53
|
-
const
|
|
58
|
+
const intentTag = callTag(method, argValues, "intent"); // the call's intent: its case's, else its method's (D-053)
|
|
59
|
+
const intent = intentTag?.args;
|
|
54
60
|
const title = typeof intent?.title === "string" && intent.title.trim() !== "" ? intent.title : (labelOf(method) ?? `Call ${name}`);
|
|
55
|
-
const tagOrigin = intent ? { origin:
|
|
61
|
+
const tagOrigin = intent ? { origin: intentTag.origin, ...(intentTag.profile === undefined ? {} : { profile: intentTag.profile }) } : null;
|
|
56
62
|
const provenance = { intent: tagOrigin, profiles: Array.isArray(resolved.profiles) ? resolved.profiles : [] };
|
|
57
|
-
const base = { method: name, effect: effectKind(method), title, argNodes: method.args, argValues, diagnostics, provenance };
|
|
63
|
+
const base = { method: name, effect: effectKind(method, callTag(method, argValues, "effect")), title, argNodes: method.args, argValues, diagnostics, provenance };
|
|
58
64
|
|
|
59
65
|
if (intent && typeof intent.template === "string") {
|
|
60
66
|
try {
|
package/src/render.mjs
CHANGED
|
@@ -50,6 +50,24 @@ export function renderFields(model, renderer, { rawArguments = false } = {}) {
|
|
|
50
50
|
return { intent, fields };
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* The fields form as plain text, for a message that is not ICRC-21's (an EVM call, spec section 15): the same intent
|
|
55
|
+
* line as renderFields, and each row's values joined by ", ".
|
|
56
|
+
*
|
|
57
|
+
* @returns {{ intent: string, fields: Array<[string, string]> }}
|
|
58
|
+
*/
|
|
59
|
+
export function renderPlain(model, renderer, { rawArguments = false } = {}) {
|
|
60
|
+
const title = plainLabel(model.title, model.method);
|
|
61
|
+
let intent = title;
|
|
62
|
+
if (model.sentence !== null) {
|
|
63
|
+
const sentence = model.sentence.map((p) => ("lit" in p ? oneLine(p.lit) : fieldPlain(p.field, renderer))).join("");
|
|
64
|
+
if (sentence.trim() !== "") intent = `${title}: ${sentence.trim()}`;
|
|
65
|
+
}
|
|
66
|
+
const fields = model.fields.map((f) => [plainLabel(f.label, f.path), fieldPlain(f, renderer)]);
|
|
67
|
+
if (rawArguments) fields.push(["Raw arguments", visible(renderer.tupleText(model.argNodes, model.argValues))]);
|
|
68
|
+
return { intent, fields };
|
|
69
|
+
}
|
|
70
|
+
|
|
53
71
|
/** One row's ICRC-21 Value: typed when the field is a single value the typed form carries exactly, else Text. */
|
|
54
72
|
function fieldValue(field, renderer) {
|
|
55
73
|
if (field.values.length === 1) {
|
package/src/resolved.mjs
CHANGED
|
@@ -6,11 +6,17 @@ import { CODES, fail } from "./errors.mjs";
|
|
|
6
6
|
|
|
7
7
|
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
8
8
|
|
|
9
|
-
/**
|
|
10
|
-
|
|
9
|
+
/**
|
|
10
|
+
* The method entry of a resolved form, or a ConsentError. `chain` is "evm" for an EVM contract's form (spec section 15);
|
|
11
|
+
* a Candid entry point never reads one, whose positions are ABI types, not Candid.
|
|
12
|
+
*/
|
|
13
|
+
export function methodOf(resolved, name, chain = undefined) {
|
|
11
14
|
if (!isObject(resolved) || resolved.format !== "candid-profile-resolved" || !isObject(resolved.methods)) {
|
|
12
15
|
fail(CODES.RESOLVED_FORM, "not a candid-profile-resolved document (format, methods)");
|
|
13
16
|
}
|
|
17
|
+
if (resolved.chain !== chain) {
|
|
18
|
+
fail(CODES.RESOLVED_FORM, resolved.chain === "evm" ? "this resolved form is an EVM contract's (chain evm): use evmConsent, not a Candid entry point" : `expected the resolved form of an EVM contract (chain evm), found ${resolved.chain === undefined ? "a Candid service's" : `chain ${JSON.stringify(resolved.chain)}`}`);
|
|
19
|
+
}
|
|
14
20
|
if (typeof name !== "string" || !Object.hasOwn(resolved.methods, name)) {
|
|
15
21
|
fail(CODES.METHOD_UNKNOWN, `the resolved form has no method ${JSON.stringify(String(name))}`);
|
|
16
22
|
}
|
|
@@ -24,6 +30,20 @@ export function keyId(key) {
|
|
|
24
30
|
return /^#(0|[1-9][0-9]{0,9})$/.test(key) ? Number(key.slice(1)) : idlHash(key);
|
|
25
31
|
}
|
|
26
32
|
|
|
33
|
+
/**
|
|
34
|
+
* A position of an EVM contract's resolved form (spec section 15) read as the Candid shapes the model walks: a tuple is
|
|
35
|
+
* a record, an array a vec, any other ABI type a primitive of that name. `abi` is kept, so display writes ABI text.
|
|
36
|
+
*/
|
|
37
|
+
export function abiView(node, where, depth = 0) {
|
|
38
|
+
if (!isObject(node) || typeof node.abi !== "string") fail(CODES.RESOLVED_FORM, `${where}: not an EVM resolved-form position`);
|
|
39
|
+
if (depth > 64) fail(CODES.UNSUPPORTED_TYPE, `${where}: nested deeper than 64`);
|
|
40
|
+
if (node.abi === "tuple") {
|
|
41
|
+
return { ...node, candid: "record", fields: Object.fromEntries(entriesOf(node.fields).map(([k, c]) => [k, abiView(c, `${where}.${k}`, depth + 1)])) };
|
|
42
|
+
}
|
|
43
|
+
if (node.abi === "array") return { ...node, candid: "vec", element: abiView(node.element, `${where}[]`, depth + 1) };
|
|
44
|
+
return { ...node, candid: node.abi };
|
|
45
|
+
}
|
|
46
|
+
|
|
27
47
|
/** Own entries of a `fields` or `cases` object (never walks the prototype chain). */
|
|
28
48
|
export function entriesOf(bucket) {
|
|
29
49
|
return isObject(bucket) ? Object.entries(bucket) : [];
|
|
@@ -39,7 +59,44 @@ export function tagArgs(node, tag) {
|
|
|
39
59
|
return node?.tags && Object.hasOwn(node.tags, tag) ? (node.tags[tag]?.args ?? {}) : undefined;
|
|
40
60
|
}
|
|
41
61
|
|
|
42
|
-
/**
|
|
43
|
-
|
|
44
|
-
|
|
62
|
+
/**
|
|
63
|
+
* The effect kind of a call: its effect tag (the call's, `callTag`; the method's by default), or the spec default
|
|
64
|
+
* (section 5.3: queries read, updates write).
|
|
65
|
+
*/
|
|
66
|
+
export function effectKind(method, tag = method.tags?.effect) {
|
|
67
|
+
return tag?.args?.kind ?? (method.mode === "query" || method.mode === "composite_query" ? "read" : "write");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The `intent` or `effect` entry (`{ args, origin, profile }`) a call takes: that of the deepest tagged variant case
|
|
72
|
+
* its decoded arguments hold (spec section 5.2, D-053), else the method's. The resolver lets the tagged cases of one
|
|
73
|
+
* method only exclude each other or nest, so the cases one call holds are a chain; a resolved form with tagged cases
|
|
74
|
+
* that one call holds side by side is not one a resolver writes, and the method's entry is used for it.
|
|
75
|
+
*
|
|
76
|
+
* @param {object} method a method of the resolved form
|
|
77
|
+
* @param {unknown[]} argValues its decoded arguments (decode.mjs: records by key, variants as { tag, value })
|
|
78
|
+
* @param {"intent" | "effect"} tag
|
|
79
|
+
*/
|
|
80
|
+
export function callTag(method, argValues, tag) {
|
|
81
|
+
let best = null; // { entry, chain }: the hit with the most hits above and at it
|
|
82
|
+
let hits = 0;
|
|
83
|
+
const visit = (node, value, chain) => {
|
|
84
|
+
if (!isObject(node) || node.recursive === true || !isObject(value)) return; // null: an absent optional, a bare case
|
|
85
|
+
if (node.candid === "record") {
|
|
86
|
+
for (const [key, child] of entriesOf(node.fields)) if (Object.hasOwn(value, key)) visit(child, value[key], chain);
|
|
87
|
+
} else if (node.candid === "variant") {
|
|
88
|
+
const hit = entriesOf(node.cases).find(([key]) => key === value.tag);
|
|
89
|
+
if (hit === undefined) return;
|
|
90
|
+
const child = hit[1];
|
|
91
|
+
const entry = isObject(child) && isObject(child.tags) && Object.hasOwn(child.tags, tag) ? child.tags[tag] : undefined;
|
|
92
|
+
if (isObject(entry)) {
|
|
93
|
+
chain += 1;
|
|
94
|
+
hits += 1;
|
|
95
|
+
if (best === null || chain > best.chain) best = { entry, chain };
|
|
96
|
+
}
|
|
97
|
+
visit(child, value.value, chain);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
method.args.forEach((node, i) => visit(node, argValues[i], 0));
|
|
101
|
+
return best !== null && best.chain === hits ? best.entry : method.tags?.[tag];
|
|
45
102
|
}
|
package/src/sns.mjs
CHANGED
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
// 1. The body is the generic consent message of spec section 12 with the title at heading level 3, so it nests
|
|
27
27
|
// under governance's `## Payload:`. Times are shown in UTC (`2026-10-09 12:00:00 UTC`, rule 6 of section 12):
|
|
28
28
|
// a proposal has many readers and no user preferences.
|
|
29
|
-
// 2. Then the method the rendering describes and
|
|
30
|
-
// an `effect` tag is said to be a default).
|
|
29
|
+
// 2. Then the method the rendering describes and the call's effect (spec section 5.3: its case's, else its
|
|
30
|
+
// method's, D-053; the default for an update without an `effect` tag is said to be a default).
|
|
31
31
|
// 3. Then the arguments as Candid text with no formatter applied (base units, nanoseconds, bytes), always: there is
|
|
32
32
|
// no signer between the text and the voter to show them (spec section 11 item 1).
|
|
33
33
|
// 4. Then where the words come from: every profile with its profile_id, the interface_id, and these rules.
|
|
@@ -43,7 +43,7 @@ import { bytesOf } from "./bytes.mjs";
|
|
|
43
43
|
import { ConsentError, CODES, fail } from "./errors.mjs";
|
|
44
44
|
import { buildModel } from "./model.mjs";
|
|
45
45
|
import { renderGeneric } from "./render.mjs";
|
|
46
|
-
import { methodOf } from "./resolved.mjs";
|
|
46
|
+
import { callTag, methodOf } from "./resolved.mjs";
|
|
47
47
|
import { codeBlock, codeSpan, escapeMarkdown, oneLine } from "./text.mjs";
|
|
48
48
|
|
|
49
49
|
/** The name and version of the rules this module renders by. Part of every rendering, so a re-render knows what to compare. */
|
|
@@ -114,7 +114,7 @@ function renderSnsPayload({ payload, resolved, method: name, refs, limits }) {
|
|
|
114
114
|
const model = buildModel({ resolved, method: name, arg: payload, limits });
|
|
115
115
|
const renderer = new Renderer({ refs, utcOffsetMinutes: 0, diagnostics: model.diagnostics });
|
|
116
116
|
const blocks = [renderGeneric(model, renderer, { headingLevel: 3 })];
|
|
117
|
-
blocks.push(`**Method:** ${codeSpan(name)}. **Effect:** ${effectText(method)}.`);
|
|
117
|
+
blocks.push(`**Method:** ${codeSpan(name)}. **Effect:** ${effectText(method, callTag(method, model.argValues, "effect"))}.`);
|
|
118
118
|
blocks.push(`**Arguments (Candid, as sent, no units applied):**\n\n${codeBlock(renderer.plainTupleText(model.argNodes, model.argValues))}`);
|
|
119
119
|
blocks.push(sourceText(resolved));
|
|
120
120
|
const text = blocks.join("\n\n");
|
|
@@ -125,12 +125,12 @@ function renderSnsPayload({ payload, resolved, method: name, refs, limits }) {
|
|
|
125
125
|
const isQuery = (method) => method.mode === "query" || method.mode === "composite_query";
|
|
126
126
|
|
|
127
127
|
/**
|
|
128
|
-
* The effect kind and reversibility (spec section 5.3
|
|
128
|
+
* The effect kind and reversibility of the call (spec section 5.3; `tag` is its effect entry, `callTag`), and whether
|
|
129
|
+
* a profile stated them. The resolver writes the
|
|
129
130
|
* section 5.3 default with origin "default" when no profile has an `effect` tag; this says so instead of passing a
|
|
130
131
|
* default off as the author's word.
|
|
131
132
|
*/
|
|
132
|
-
function effectText(method) {
|
|
133
|
-
const tag = method.tags?.effect;
|
|
133
|
+
function effectText(method, tag) {
|
|
134
134
|
const query = isQuery(method);
|
|
135
135
|
const kind = typeof tag?.args?.kind === "string" ? tag.args.kind : query ? "read" : "write";
|
|
136
136
|
const irreversible = typeof tag?.args?.irreversible === "boolean" ? tag.args.irreversible : tag ? null : !query;
|