@meantjs/consent 0.2.0 → 0.4.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 +32 -0
- package/README.md +64 -8
- package/package.json +7 -3
- package/src/consent.mjs +12 -4
- 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 +64 -1
- package/src/evm.mjs +244 -7
- package/src/grant.mjs +140 -0
- package/src/index.d.mts +29 -0
- package/src/sha256.mjs +60 -0
- package/src/terms.d.mts +103 -0
- package/src/terms.mjs +500 -0
package/src/evm.mjs
CHANGED
|
@@ -13,13 +13,20 @@
|
|
|
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";
|
|
25
|
+
import { grantOf } from "./grant.mjs";
|
|
20
26
|
import { modelOf } from "./model.mjs";
|
|
27
|
+
import { resolvePath } from "./paths.mjs";
|
|
21
28
|
import { renderGeneric, renderPlain } from "./render.mjs";
|
|
22
|
-
import { abiView, methodOf } from "./resolved.mjs";
|
|
29
|
+
import { abiView, entriesOf, methodOf, tagArgs } from "./resolved.mjs";
|
|
23
30
|
import { codeSpan } from "./text.mjs";
|
|
24
31
|
|
|
25
32
|
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
@@ -85,6 +92,49 @@ function checkTarget(resolved, to, chain) {
|
|
|
85
92
|
}
|
|
86
93
|
}
|
|
87
94
|
|
|
95
|
+
/** The caller's `tokens`: the decimals and symbol of each token, keyed by its address in any valid spelling. */
|
|
96
|
+
function tokenTable(tokens) {
|
|
97
|
+
if (!isObject(tokens)) fail(CODES.REQUEST, "tokens must be an object of token address to { decimals, symbol }");
|
|
98
|
+
const out = new Map();
|
|
99
|
+
for (const [k, v] of Object.entries(tokens)) {
|
|
100
|
+
let address;
|
|
101
|
+
try {
|
|
102
|
+
address = readAddress(k);
|
|
103
|
+
} catch (e) {
|
|
104
|
+
if (e instanceof AbiError) fail(CODES.REQUEST, `tokens: ${e.message}`);
|
|
105
|
+
throw e;
|
|
106
|
+
}
|
|
107
|
+
if (!isObject(v)) fail(CODES.REQUEST, `tokens[${JSON.stringify(k)}] must be { decimals, symbol }`);
|
|
108
|
+
if (out.has(address)) fail(CODES.REQUEST, `tokens lists ${address} twice`);
|
|
109
|
+
out.set(address, { decimals: v.decimals, symbol: v.symbol });
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The arguments with each amount whose token a field names (spec section 15.9) made a TokenAmount: its base units, the
|
|
116
|
+
* address that field holds in the same array element, and that token's metadata from `tokens`. A token the caller did
|
|
117
|
+
* not describe stops the message (E_REF_UNRESOLVED): a number without its decimals is never shown (spec section 12
|
|
118
|
+
* rule 10).
|
|
119
|
+
*/
|
|
120
|
+
function withTokens(name, viewed, args, tokens) {
|
|
121
|
+
const box = (node, value, keys, indices) => {
|
|
122
|
+
if (node.candid === "record") return Object.fromEntries(entriesOf(node.fields).map(([k, child]) => [k, box(child, value[k], [...keys, k], indices)]));
|
|
123
|
+
if (node.candid === "vec") return value.map((e, i) => box(node.element, e, [...keys, "[]"], [...indices, i]));
|
|
124
|
+
const token = tagArgs(node, "amount")?.token;
|
|
125
|
+
if (token === undefined) return value;
|
|
126
|
+
const where = `${name}(0)${keys.map((k) => (k === "[]" ? "[]" : `.${k}`)).join("")}`;
|
|
127
|
+
const rp = resolvePath(viewed, String(token));
|
|
128
|
+
let address = rp.error ? undefined : args[rp.index];
|
|
129
|
+
let n = 0;
|
|
130
|
+
for (const step of rp.steps ?? []) address = step.kind === "elem" ? address?.[indices[n++]] : address?.[step.key];
|
|
131
|
+
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`);
|
|
132
|
+
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`);
|
|
133
|
+
return new TokenAmount(value, address, tokens.get(address));
|
|
134
|
+
};
|
|
135
|
+
return args.map((v, i) => box(viewed.args[i], v, [], []));
|
|
136
|
+
}
|
|
137
|
+
|
|
88
138
|
/**
|
|
89
139
|
* The consent text of an EVM call.
|
|
90
140
|
*
|
|
@@ -94,13 +144,17 @@ function checkTarget(resolved, to, chain) {
|
|
|
94
144
|
* @param {string} input.to the contract called (shown, and checked against the profiles' deployments)
|
|
95
145
|
* @param {number | bigint | string} [input.chainId] needed when a profile lists deployments
|
|
96
146
|
* @param {Record<string, unknown>} [input.refs] resolved dynamic refs, for example { "query:decimals": 6, "query:symbol": "USDC" }
|
|
147
|
+
* @param {Record<string, { decimals: number, symbol?: string }>} [input.tokens] for an amount whose token a field
|
|
148
|
+
* names (spec section 15.9): each such token's decimals() and symbol(), keyed by its address
|
|
97
149
|
* @param {"generic" | "fields"} [input.form] default "generic"
|
|
98
150
|
* @param {number | null} [input.utcOffsetMinutes] generic form only (default 0, UTC)
|
|
99
151
|
* @param {boolean} [input.rawArguments] append the decoded arguments (spec section 11 item 1)
|
|
100
152
|
* @param {object} [input.limits] decoder limits (maxBytes, maxDepth, maxElements)
|
|
101
|
-
* @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
|
+
* @returns {{ form: string, message: string | { intent: string, fields: Array<[string, string]> }, method: string, selector: string, to: string, chainId: string | null, values: Record<string, unknown>, grant: object | null, diagnostics: object[], provenance: object, model: object }}
|
|
154
|
+
* `values` are the decoded arguments as `decodeCalldata` gives them, and `grant` what the call grants (spec section
|
|
155
|
+
* 5.3, D-056; its token is the contract called unless the amount's tag names a field), or null.
|
|
102
156
|
*/
|
|
103
|
-
export function evmConsent({ resolved, calldata, to, chainId, refs = {}, form = "generic", utcOffsetMinutes = null, rawArguments = false, limits }) {
|
|
157
|
+
export function evmConsent({ resolved, calldata, to, chainId, refs = {}, tokens = {}, form = "generic", utcOffsetMinutes = null, rawArguments = false, limits }) {
|
|
104
158
|
if (form !== "generic" && form !== "fields") fail(CODES.REQUEST, `form must be "generic" or "fields", got ${JSON.stringify(form)}`);
|
|
105
159
|
let target;
|
|
106
160
|
try {
|
|
@@ -115,9 +169,10 @@ export function evmConsent({ resolved, calldata, to, chainId, refs = {}, form =
|
|
|
115
169
|
const method = methodOf(resolved, name, "evm");
|
|
116
170
|
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
171
|
const viewed = { ...method, args: method.args.map((n, i) => abiView(n, `${name}(${i})`)) };
|
|
118
|
-
const model = modelOf(resolved, name, viewed, viewed.args.length ? [values] : []);
|
|
172
|
+
const model = modelOf(resolved, name, viewed, viewed.args.length ? withTokens(name, viewed, [values], tokenTable(tokens)) : []);
|
|
173
|
+
const grant = grantOf({ name, method: viewed, argValues: viewed.args.length ? [values] : [], called: target, diagnostics: model.diagnostics });
|
|
119
174
|
const where = `${target}${chain === null ? "" : ` on chain ${chain}`}`;
|
|
120
|
-
const done = (rest) => ({ ...rest, method: name, selector, to: target, chainId: chain, diagnostics: model.diagnostics, provenance: model.provenance, model });
|
|
175
|
+
const done = (rest) => ({ ...rest, method: name, selector, to: target, chainId: chain, values, grant, diagnostics: model.diagnostics, provenance: model.provenance, model });
|
|
121
176
|
if (form === "fields") {
|
|
122
177
|
const plain = renderPlain(model, new Renderer({ refs, diagnostics: model.diagnostics }), { rawArguments });
|
|
123
178
|
plain.fields.push(["Contract", where]);
|
|
@@ -127,3 +182,185 @@ export function evmConsent({ resolved, calldata, to, chainId, refs = {}, form =
|
|
|
127
182
|
const text = `${renderGeneric(model, renderer, { rawArguments })}\n\n**Contract:** ${codeSpan(target)}${chain === null ? "" : ` on chain ${chain}`}`;
|
|
128
183
|
return done({ form, message: text });
|
|
129
184
|
}
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------------------------- EIP-712 messages
|
|
187
|
+
|
|
188
|
+
/** The domain's fields in the order EIP-712 lists them, with their types. */
|
|
189
|
+
const DOMAIN_FIELDS = [
|
|
190
|
+
["name", "string"],
|
|
191
|
+
["version", "string"],
|
|
192
|
+
["chainId", "uint256"],
|
|
193
|
+
["verifyingContract", "address"],
|
|
194
|
+
["salt", "bytes32"],
|
|
195
|
+
];
|
|
196
|
+
|
|
197
|
+
/** One value of a typed message, read strictly (spec section 15.9): the shapes decodeCalldata gives, or E_DECODE. */
|
|
198
|
+
function readTyped(types, type, value, where, budget) {
|
|
199
|
+
const bad = (message) => fail(CODES.DECODE, `${where}: ${message}`);
|
|
200
|
+
const arr = /^(.*)\[(\d*)\]$/.exec(type);
|
|
201
|
+
if (arr) {
|
|
202
|
+
if (!Array.isArray(value)) bad(`${type} needs an array`);
|
|
203
|
+
if (arr[2] !== "" && value.length !== Number(arr[2])) bad(`${type} needs ${arr[2]} elements, got ${value.length}`);
|
|
204
|
+
budget.elements += value.length;
|
|
205
|
+
if (budget.elements > budget.maxElements) bad(`more than ${budget.maxElements} array elements in the message`);
|
|
206
|
+
return value.map((v, i) => readTyped(types, arr[1], v, `${where}[${i}]`, budget));
|
|
207
|
+
}
|
|
208
|
+
if (Object.hasOwn(types, type)) {
|
|
209
|
+
if (!isObject(value)) bad(`${type} needs an object of its members`);
|
|
210
|
+
const members = types[type];
|
|
211
|
+
for (const k of Object.keys(value)) if (!members.some((m) => m.name === k)) bad(`${type} has no member ${JSON.stringify(k)}`);
|
|
212
|
+
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}`)]));
|
|
213
|
+
}
|
|
214
|
+
const int = /^(u?)int([0-9]+)$/.exec(type);
|
|
215
|
+
if (int) {
|
|
216
|
+
let x;
|
|
217
|
+
if (typeof value === "number" && Number.isSafeInteger(value)) x = BigInt(value);
|
|
218
|
+
else if (typeof value === "string" && /^-?(?:0|[1-9][0-9]*)$/.test(value) && value !== "-0") x = BigInt(value);
|
|
219
|
+
else bad(`${type} needs a decimal string or a JSON integer below 2^53, got ${JSON.stringify(value)}`);
|
|
220
|
+
const bits = BigInt(int[2]);
|
|
221
|
+
const [min, max] = int[1] === "u" ? [0n, 2n ** bits - 1n] : [-(2n ** (bits - 1n)), 2n ** (bits - 1n) - 1n];
|
|
222
|
+
if (x < min || x > max) bad(`${x} is outside ${type}`);
|
|
223
|
+
return x.toString();
|
|
224
|
+
}
|
|
225
|
+
if (type === "address") {
|
|
226
|
+
try {
|
|
227
|
+
return readAddress(value);
|
|
228
|
+
} catch (e) {
|
|
229
|
+
if (e instanceof AbiError) bad(e.message);
|
|
230
|
+
throw e;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (type === "bool") return typeof value === "boolean" ? value : bad("bool needs true or false");
|
|
234
|
+
if (type === "string") return typeof value === "string" && value.isWellFormed() ? value : bad("string needs well-formed text");
|
|
235
|
+
const bytes = /^bytes([0-9]*)$/.exec(type);
|
|
236
|
+
if (bytes) {
|
|
237
|
+
if (typeof value !== "string" || !/^0x(?:[0-9a-fA-F]{2})*$/.test(value)) bad(`${type} needs 0x and hex digits`);
|
|
238
|
+
const b = Uint8Array.from(value.slice(2).match(/../g) ?? [], (h) => parseInt(h, 16));
|
|
239
|
+
if (bytes[1] !== "" && b.length !== Number(bytes[1])) bad(`${type} needs ${bytes[1]} bytes, got ${b.length}`);
|
|
240
|
+
if (b.length > budget.maxBytes) bad(`${b.length} bytes is over the limit of ${budget.maxBytes}`);
|
|
241
|
+
return b;
|
|
242
|
+
}
|
|
243
|
+
bad(`unknown type ${type}`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** The request's domain: only EIP-712's fields, no salt (O3), and its EIP712Domain type, if given, exactly those fields. */
|
|
247
|
+
function readDomain(domain, declared) {
|
|
248
|
+
if (!isObject(domain)) fail(CODES.REQUEST, "typedData.domain must be an object");
|
|
249
|
+
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`);
|
|
250
|
+
if (Object.hasOwn(domain, "salt")) fail(CODES.DOMAIN, "a domain with a salt is not supported yet (spec section 15.9, O3)");
|
|
251
|
+
const fields = DOMAIN_FIELDS.filter(([k]) => Object.hasOwn(domain, k)).map(([name, type]) => ({ name, type }));
|
|
252
|
+
// 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).
|
|
253
|
+
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);
|
|
254
|
+
if (declared !== undefined && !same) {
|
|
255
|
+
fail(CODES.DOMAIN, `types.EIP712Domain must list the domain's fields in EIP-712 order (${fields.map((f) => `${f.type} ${f.name}`).join(", ")})`);
|
|
256
|
+
}
|
|
257
|
+
for (const k of ["name", "version"]) if (Object.hasOwn(domain, k) && typeof domain[k] !== "string") fail(CODES.DOMAIN, `domain.${k} must be a string`);
|
|
258
|
+
if (!Object.hasOwn(domain, "chainId")) fail(CODES.DOMAIN, "the domain has no chainId, so the signature is valid on every chain; it is refused");
|
|
259
|
+
if (!Object.hasOwn(domain, "verifyingContract")) fail(CODES.DOMAIN, "the domain has no verifyingContract: the contract that will act on the signature must be known");
|
|
260
|
+
const chain = readTyped({}, "uint256", domain.chainId, "domain.chainId", { elements: 0, maxElements: 0, maxBytes: 0 });
|
|
261
|
+
let contract;
|
|
262
|
+
try {
|
|
263
|
+
contract = readAddress(domain.verifyingContract);
|
|
264
|
+
} catch (e) {
|
|
265
|
+
if (e instanceof AbiError) fail(CODES.DOMAIN, `domain.verifyingContract: ${e.message}`);
|
|
266
|
+
throw e;
|
|
267
|
+
}
|
|
268
|
+
return { fields, chain, contract };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** The message of a resolved form whose type hash is the request's; the entry's own encode_type must hash to it. */
|
|
272
|
+
function messageOf(resolved, primaryType, typeHash) {
|
|
273
|
+
if (!isObject(resolved) || resolved.format !== "candid-profile-resolved" || !isObject(resolved.methods) || resolved.chain !== "evm") methodOf(resolved, "", "evm"); // throws the right error
|
|
274
|
+
if (!isObject(resolved.messages)) fail(CODES.TYPE_HASH, "the resolved form describes no EIP-712 message");
|
|
275
|
+
const hit = entriesOf(resolved.messages).find(([, m]) => isObject(m) && m.type_hash === typeHash);
|
|
276
|
+
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`);
|
|
277
|
+
const [name, m] = hit;
|
|
278
|
+
if (typeof m.encode_type !== "string" || `0x${keccak256Hex(m.encode_type)}` !== typeHash || name !== primaryType || !m.encode_type.startsWith(`${name}(`)) {
|
|
279
|
+
fail(CODES.RESOLVED_FORM, `messages.${name}: its encode_type does not hash to its type_hash, or names another type`);
|
|
280
|
+
}
|
|
281
|
+
if (!Array.isArray(m.args) || m.args.length !== 1) fail(CODES.RESOLVED_FORM, `messages.${name} has no args array of one struct`);
|
|
282
|
+
return { name, message: m };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Every profile that names the domain (`eip712_domain`) must name the request's: its name and its version each equal,
|
|
287
|
+
* and absent where the profile's is absent (Permit2's domain has no version; one with a version is another domain).
|
|
288
|
+
*/
|
|
289
|
+
function checkDomain(resolved, domain) {
|
|
290
|
+
for (const p of Array.isArray(resolved.profiles) ? resolved.profiles : []) {
|
|
291
|
+
if (!isObject(p?.eip712_domain)) continue;
|
|
292
|
+
for (const k of ["name", "version"]) {
|
|
293
|
+
const v = p.eip712_domain[k];
|
|
294
|
+
if (domain[k] === v) continue;
|
|
295
|
+
const theirs = domain[k] === undefined ? "a domain without one" : JSON.stringify(domain[k]);
|
|
296
|
+
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}`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* The consent text of an EIP-712 signing request (spec section 15.9): the message a profile describes, its values in
|
|
303
|
+
* words, and what a signature is. Refused before anything is shown when the request does not match: a domain for
|
|
304
|
+
* another chain than the wallet's or without a chainId or verifyingContract (E_DOMAIN), a contract its profiles do not
|
|
305
|
+
* list (E_DEPLOYMENT), a domain name or version other than its profiles' (E_DOMAIN), no message with the request's
|
|
306
|
+
* type hash (E_TYPE_HASH), a message whose values are not exactly its members, canonically written (E_DECODE).
|
|
307
|
+
*
|
|
308
|
+
* @param {object} input
|
|
309
|
+
* @param {object} input.resolved an EVM contract's resolved form with `messages`
|
|
310
|
+
* @param {{ types: object, primaryType: string, domain: object, message: object }} input.typedData the request
|
|
311
|
+
* (`eth_signTypedData_v4`'s JSON); `types.EIP712Domain` may be left out
|
|
312
|
+
* @param {number | bigint | string} input.chainId the wallet's active chain
|
|
313
|
+
* @param {Record<string, unknown>} [input.refs] resolved dynamic refs: `query:` refs are calls to the verifying contract
|
|
314
|
+
* @param {Record<string, { decimals: number, symbol?: string }>} [input.tokens] each token an amount's `token` names
|
|
315
|
+
* @param {"generic" | "fields"} [input.form] default "generic"
|
|
316
|
+
* @param {number | null} [input.utcOffsetMinutes] generic form only (default 0, UTC)
|
|
317
|
+
* @param {boolean} [input.rawArguments] append the message's values (spec section 11 item 1)
|
|
318
|
+
* @param {{ maxBytes?: number, maxElements?: number }} [input.limits]
|
|
319
|
+
* @returns the text, `primaryType`, `typeHash`, `digest`, `verifyingContract`, `chainId`, `values` (the message as read
|
|
320
|
+
* from the typed data, by member name), `grant` (what the signature grants, spec section 5.3, D-056; its token is
|
|
321
|
+
* the verifying contract unless the amount's tag names a field), or null, `diagnostics`, `provenance` and `model`
|
|
322
|
+
*/
|
|
323
|
+
export function eip712Consent({ resolved, typedData, chainId, refs = {}, tokens = {}, form = "generic", utcOffsetMinutes = null, rawArguments = false, limits = {} }) {
|
|
324
|
+
if (form !== "generic" && form !== "fields") fail(CODES.REQUEST, `form must be "generic" or "fields", got ${JSON.stringify(form)}`);
|
|
325
|
+
if (!isObject(typedData) || !isObject(typedData.types) || typeof typedData.primaryType !== "string" || !isObject(typedData.message)) {
|
|
326
|
+
fail(CODES.REQUEST, "typedData must be { types, primaryType, domain, message }");
|
|
327
|
+
}
|
|
328
|
+
const active = chainOf(chainId);
|
|
329
|
+
if (active === null) fail(CODES.REQUEST, "give the wallet's active chainId: a signature for another chain is refused");
|
|
330
|
+
const { EIP712Domain: declared, ...own } = typedData.types;
|
|
331
|
+
const { fields: domainType, chain, contract } = readDomain(typedData.domain, declared);
|
|
332
|
+
if (chain !== active) fail(CODES.DOMAIN, `the domain is for chain ${chain}, the wallet is on chain ${active}`);
|
|
333
|
+
const parsed = parseEip712Types(own, "typedData.types");
|
|
334
|
+
if (parsed.errors.length) fail(CODES.REQUEST, parsed.errors.map((e) => `${e.at}: ${e.message}`).join("; "));
|
|
335
|
+
if (!Object.hasOwn(parsed.types, typedData.primaryType)) fail(CODES.REQUEST, `typedData.types has no ${JSON.stringify(typedData.primaryType)}`);
|
|
336
|
+
const typeHash = eip712TypeHash(parsed.types, typedData.primaryType);
|
|
337
|
+
const { name, message } = messageOf(resolved, typedData.primaryType, typeHash);
|
|
338
|
+
checkTarget(resolved, contract, chain);
|
|
339
|
+
checkDomain(resolved, typedData.domain);
|
|
340
|
+
|
|
341
|
+
const budget = { elements: 0, maxElements: limits.maxElements ?? DEFAULT_ABI_LIMITS.maxElements, maxBytes: limits.maxBytes ?? DEFAULT_ABI_LIMITS.maxBytes };
|
|
342
|
+
const values = readTyped(parsed.types, name, typedData.message, "message", budget);
|
|
343
|
+
let digest;
|
|
344
|
+
try {
|
|
345
|
+
digest = typedDataDigest({ domain: typedData.domain, types: { ...parsed.types, EIP712Domain: domainType }, primaryType: name, message: typedData.message });
|
|
346
|
+
} catch (e) {
|
|
347
|
+
if (e instanceof AbiError) fail(CODES.DECODE, e.message);
|
|
348
|
+
throw e;
|
|
349
|
+
}
|
|
350
|
+
const viewed = { ...message, args: message.args.map((n, i) => abiView(n, `${name}(${i})`)) };
|
|
351
|
+
const model = modelOf(resolved, name, viewed, withTokens(name, viewed, [values], tokenTable(tokens)));
|
|
352
|
+
const grant = grantOf({ name, method: viewed, argValues: [values], called: contract, diagnostics: model.diagnostics });
|
|
353
|
+
const where = `${contract} on chain ${chain}`;
|
|
354
|
+
const done = (rest) => ({ ...rest, primaryType: name, typeHash, digest, verifyingContract: contract, chainId: chain, values, grant, diagnostics: model.diagnostics, provenance: model.provenance, model });
|
|
355
|
+
if (form === "fields") {
|
|
356
|
+
const plain = renderPlain(model, new Renderer({ refs, diagnostics: model.diagnostics }), { rawArguments });
|
|
357
|
+
plain.fields.push(["Contract", where], ["Signature", SIGNATURE_NOTE], ["EIP-712 digest", digest]);
|
|
358
|
+
return done({ form, message: plain });
|
|
359
|
+
}
|
|
360
|
+
const renderer = new Renderer({ refs, utcOffsetMinutes: utcOffsetMinutes ?? 0, diagnostics: model.diagnostics });
|
|
361
|
+
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}.`;
|
|
362
|
+
return done({ form, message: text });
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** What a typed-data signature is, said with every message (spec section 15.9). */
|
|
366
|
+
const SIGNATURE_NOTE = "This is a signature, not a transaction: whoever holds it can submit it";
|
package/src/grant.mjs
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// What a call or a signature grants (docs/profile-v0.md section 5.3, D-056): who may take tokens because of it, which
|
|
2
|
+
// token, how many and until when, read from its effect's `grant` and its decoded values. A wallet compares it with the
|
|
3
|
+
// terms the user confirmed (`@meantjs/consent/terms`).
|
|
4
|
+
//
|
|
5
|
+
// A grant that cannot be read is no grant: `null`, with a W_GRANT diagnostic. The request then needs its confirmation,
|
|
6
|
+
// as any request without a grant does, and the consent text is the same either way.
|
|
7
|
+
|
|
8
|
+
import { displayAccount, principalFromText, principalToText } from "@meantjs/format";
|
|
9
|
+
import { CODES, fail } from "./errors.mjs";
|
|
10
|
+
import { callTag, tagArgs } from "./resolved.mjs";
|
|
11
|
+
import { resolvePath, valuesAt } from "./paths.mjs";
|
|
12
|
+
|
|
13
|
+
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
14
|
+
const NATURAL = /^(0|[1-9][0-9]*)$/;
|
|
15
|
+
const INTEGER = /^-?(0|[1-9][0-9]*)$/;
|
|
16
|
+
/** Nanoseconds per unit of a `timestamp` tag (spec section 5.3); every grant end is compared in nanoseconds, exactly. */
|
|
17
|
+
const NS = { s: 1_000_000_000n, ms: 1_000_000n, us: 1_000n, ns: 1n };
|
|
18
|
+
|
|
19
|
+
/** Nanoseconds as exact seconds: a decimal string with a fraction only when there is one, never rounded. */
|
|
20
|
+
export function secondsText(ns) {
|
|
21
|
+
const sign = ns < 0n ? "-" : "";
|
|
22
|
+
const a = ns < 0n ? -ns : ns;
|
|
23
|
+
const frac = (a % NS.s).toString().padStart(9, "0").replace(/0+$/, "");
|
|
24
|
+
return `${sign}${a / NS.s}${frac ? `.${frac}` : ""}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The canonical text of a canister id; anything else is E_REQUEST. */
|
|
28
|
+
export function canisterText(id) {
|
|
29
|
+
try {
|
|
30
|
+
return principalToText(principalFromText(id));
|
|
31
|
+
} catch {
|
|
32
|
+
fail(CODES.REQUEST, `canisterId must be a principal's text, got ${JSON.stringify(String(id))}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
class Skip extends Error {}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {object} input
|
|
40
|
+
* @param {string} input.name the method or message, for the diagnostic
|
|
41
|
+
* @param {object} input.method its resolved-form entry (an EVM one as abiView reads it)
|
|
42
|
+
* @param {unknown[]} input.argValues its decoded arguments, before any amount is boxed with its token
|
|
43
|
+
* @param {string | undefined} input.called the contract or canister the call is for: the token, unless the amount's
|
|
44
|
+
* tag names a field that holds it (spec section 15.9)
|
|
45
|
+
* @param {object[]} input.diagnostics where W_GRANT goes
|
|
46
|
+
* @returns {null | { kind: "transfer" | "approve", spender?: string, to?: string, token: string,
|
|
47
|
+
* amount: { units: string | null, unlimited: boolean }, ends: { at: string } | { never: true } | { now: true } }}
|
|
48
|
+
*/
|
|
49
|
+
export function grantOf({ name, method, argValues, called, diagnostics }) {
|
|
50
|
+
const effect = callTag(method, argValues, "effect");
|
|
51
|
+
const grant = effect?.args?.grant;
|
|
52
|
+
if (grant === undefined) return null;
|
|
53
|
+
try {
|
|
54
|
+
return read(method, argValues, effect, grant, called);
|
|
55
|
+
} catch (e) {
|
|
56
|
+
if (!(e instanceof Skip)) throw e;
|
|
57
|
+
diagnostics.push({ code: "W_GRANT", at: name, message: `${e.message}; the request has no grant, so terms the user confirmed cannot cover it` });
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function read(method, argValues, effect, grant, called) {
|
|
63
|
+
const skip = (why) => {
|
|
64
|
+
throw new Skip(why);
|
|
65
|
+
};
|
|
66
|
+
if (effect !== method.tags?.effect) skip("a grant on the effect of a case is not read in this version (D-056)");
|
|
67
|
+
const { kind } = effect.args;
|
|
68
|
+
if (kind !== "transfer" && kind !== "approve") skip(`a grant goes with effect kind transfer or approve, not ${String(kind)}`);
|
|
69
|
+
if (!isObject(grant) || (grant.spender === undefined) === (grant.to === undefined)) skip("the grant names neither or both of spender and to");
|
|
70
|
+
|
|
71
|
+
/** The one value at a grant position: no [] and no case of a variant on the way, so a call holds it once at most. */
|
|
72
|
+
const at = (what, path, mayBeAbsent = false) => {
|
|
73
|
+
if (typeof path !== "string") skip(`grant ${what} is not a path`);
|
|
74
|
+
const rp = resolvePath(method, path);
|
|
75
|
+
if (rp.error) skip(`grant ${what} ${JSON.stringify(path)}: ${rp.error}`);
|
|
76
|
+
if (rp.steps.some((s) => s.kind !== "field")) skip(`grant ${what} ${JSON.stringify(path)} steps into [] or a case of a variant`);
|
|
77
|
+
const values = valuesAt(rp, argValues);
|
|
78
|
+
if (values.length === 0 && !mayBeAbsent) skip(`grant ${what} ${JSON.stringify(path)} is absent from this call`);
|
|
79
|
+
return { node: rp.node, value: values[0] };
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const who = grant.spender !== undefined ? "spender" : "to";
|
|
83
|
+
const party = at(who, grant[who]);
|
|
84
|
+
let address;
|
|
85
|
+
if (party.node.abi === "address" && typeof party.value === "string") address = party.value; // EIP-55, as decoded
|
|
86
|
+
else if (tagArgs(party.node, "account") !== undefined && isObject(party.value)) {
|
|
87
|
+
try {
|
|
88
|
+
address = displayAccount(party.value);
|
|
89
|
+
} catch (e) {
|
|
90
|
+
skip(`grant ${who}: ${e.message}`);
|
|
91
|
+
}
|
|
92
|
+
} else skip(`grant ${who} ${JSON.stringify(grant[who])} is neither an address nor an account`);
|
|
93
|
+
|
|
94
|
+
const amt = at("amount", grant.amount);
|
|
95
|
+
const tag = tagArgs(amt.node, "amount");
|
|
96
|
+
let amount;
|
|
97
|
+
if ((amt.node.abi ?? amt.node.candid) === "bool" && typeof amt.value === "boolean") {
|
|
98
|
+
amount = amt.value ? { units: null, unlimited: true } : { units: "0", unlimited: false }; // DAI's allowed (spec 15.9)
|
|
99
|
+
} else {
|
|
100
|
+
if (tag === undefined || typeof amt.value !== "string" || !NATURAL.test(amt.value)) skip(`grant amount ${JSON.stringify(grant.amount)} is neither an amount nor a bool`);
|
|
101
|
+
if (tag.threshold !== undefined && (typeof tag.threshold !== "string" || !NATURAL.test(tag.threshold))) skip("the amount tag's threshold is not a decimal string");
|
|
102
|
+
amount = { units: amt.value, unlimited: tag.threshold !== undefined && BigInt(amt.value) >= BigInt(tag.threshold) };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let token;
|
|
106
|
+
if (tag?.token !== undefined) {
|
|
107
|
+
const t = at("amount token", tag.token);
|
|
108
|
+
if (t.node.abi !== "address" || typeof t.value !== "string") skip(`the amount's token ${JSON.stringify(tag.token)} is not an address`);
|
|
109
|
+
token = t.value;
|
|
110
|
+
} else if (typeof called === "string") token = called;
|
|
111
|
+
else skip("the canister the call is for was not given (canisterId), so its token is not known");
|
|
112
|
+
|
|
113
|
+
let ends;
|
|
114
|
+
if (grant.ends === undefined) ends = kind === "approve" ? { never: true } : { now: true };
|
|
115
|
+
else {
|
|
116
|
+
if (!Array.isArray(grant.ends) || grant.ends.length === 0) skip("grant ends is not a list of paths");
|
|
117
|
+
let latest = null;
|
|
118
|
+
let never = false;
|
|
119
|
+
for (const path of grant.ends) {
|
|
120
|
+
const end = at("ends", path, true);
|
|
121
|
+
if (end.value === undefined) { // an optional end left out: the grant never ends (spec 5.3)
|
|
122
|
+
never = true;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const ts = tagArgs(end.node, "timestamp");
|
|
126
|
+
if (ts === undefined || !Object.hasOwn(NS, ts.unit) || (ts.epoch ?? "unix") !== "unix" || typeof end.value !== "string" || !INTEGER.test(end.value)) {
|
|
127
|
+
skip(`grant ends ${JSON.stringify(path)} is not a unix timestamp`);
|
|
128
|
+
}
|
|
129
|
+
if (ts.threshold !== undefined && (typeof ts.threshold !== "string" || !NATURAL.test(ts.threshold))) skip(`the timestamp tag of ${JSON.stringify(path)} has a threshold that is not a decimal string`);
|
|
130
|
+
if (ts.threshold !== undefined && BigInt(end.value) >= BigInt(ts.threshold)) { // at its threshold: never (spec 5.3)
|
|
131
|
+
never = true;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const ns = BigInt(end.value) * NS[ts.unit]; // a label is how a value is shown; here it is the time it is
|
|
135
|
+
if (latest === null || ns > latest) latest = ns;
|
|
136
|
+
}
|
|
137
|
+
ends = never ? { never: true } : { at: secondsText(latest) };
|
|
138
|
+
}
|
|
139
|
+
return { kind, [who]: address, token, amount, ends };
|
|
140
|
+
}
|
package/src/index.d.mts
CHANGED
|
@@ -126,6 +126,24 @@ export interface Model {
|
|
|
126
126
|
[key: string]: unknown;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
/**
|
|
130
|
+
* What a call or a signature grants (spec section 5.3, D-056): who may take tokens because of it, which token, how many
|
|
131
|
+
* and until when, read from its effect's `grant`. Compare it with terms the user confirmed (`@meantjs/consent/terms`).
|
|
132
|
+
*/
|
|
133
|
+
export interface Grant {
|
|
134
|
+
kind: "transfer" | "approve";
|
|
135
|
+
/** Who may take the tokens: an EIP-55 address, or an account in its ICRC-1 text. Exactly one of `spender` and `to`. */
|
|
136
|
+
spender?: string;
|
|
137
|
+
/** Who is paid directly: an EIP-55 address, or an account in its ICRC-1 text. */
|
|
138
|
+
to?: string;
|
|
139
|
+
/** The token: an EIP-55 address, or a canister id in its canonical text. */
|
|
140
|
+
token: string;
|
|
141
|
+
/** Base units as a decimal string; `null` when unlimited without a count (DAI's `allowed: true`). A grant of nothing is `"0"`. */
|
|
142
|
+
amount: { units: string | null; unlimited: boolean };
|
|
143
|
+
/** `at`: exact seconds since the Unix epoch as a decimal string, never rounded ("1767225600.123456789"). `now`: a transfer, made when it is sent. */
|
|
144
|
+
ends: { at: string } | { never: true } | { now: true };
|
|
145
|
+
}
|
|
146
|
+
|
|
129
147
|
export interface GenerateConsentInput {
|
|
130
148
|
resolved: ResolvedForm;
|
|
131
149
|
method: string;
|
|
@@ -138,12 +156,21 @@ export interface GenerateConsentInput {
|
|
|
138
156
|
utcOffsetMinutes?: number | null;
|
|
139
157
|
rawArguments?: boolean;
|
|
140
158
|
limits?: DecodeLimits;
|
|
159
|
+
/**
|
|
160
|
+
* The canister called, the token of a grant. Without it nothing asks what the call grants: `grant` is null, with no
|
|
161
|
+
* diagnostic. Any spelling of a principal; anything else is E_REQUEST.
|
|
162
|
+
*/
|
|
163
|
+
canisterId?: string;
|
|
141
164
|
}
|
|
142
165
|
/** One form without the ICRC-21 envelope. `zoneSensitive` is true when a timestamp was shown in the requested zone. */
|
|
143
166
|
export function generateConsent(input: GenerateConsentInput): {
|
|
144
167
|
form: "fields" | "generic";
|
|
145
168
|
message: Icrc21ConsentMessage;
|
|
146
169
|
zoneSensitive: boolean;
|
|
170
|
+
/** The decoded arguments, one value tree per argument, as `decodeArguments` gives them. */
|
|
171
|
+
values: unknown[];
|
|
172
|
+
/** What the call grants, or null: no `canisterId`, no grant in its effect, or one that cannot be read (a W_GRANT diagnostic says why). */
|
|
173
|
+
grant: Grant | null;
|
|
147
174
|
diagnostics: ConsentDiagnostic[];
|
|
148
175
|
provenance: Provenance;
|
|
149
176
|
model: Model;
|
|
@@ -207,6 +234,8 @@ export const CODES: Readonly<{
|
|
|
207
234
|
LANGUAGE: "E_LANGUAGE";
|
|
208
235
|
REQUEST: "E_REQUEST";
|
|
209
236
|
DEPLOYMENT: "E_DEPLOYMENT";
|
|
237
|
+
DOMAIN: "E_DOMAIN";
|
|
238
|
+
TYPE_HASH: "E_TYPE_HASH";
|
|
210
239
|
UNSUPPORTED_TYPE: "E_UNSUPPORTED_TYPE";
|
|
211
240
|
RESOLVED_FORM: "E_RESOLVED_FORM";
|
|
212
241
|
TEMPLATE: "E_TEMPLATE";
|
package/src/sha256.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// SHA-256 in plain JavaScript, synchronous, so that the id of confirmed terms (terms.mjs) needs no node:crypto and the
|
|
2
|
+
// ./terms subpath bundles for a browser (WebCrypto is asynchronous). The same code as the resolver's src/sha256.mjs;
|
|
3
|
+
// test/terms.test.mjs compares it with node:crypto.
|
|
4
|
+
|
|
5
|
+
const K = new Uint32Array([
|
|
6
|
+
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
|
7
|
+
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
|
8
|
+
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
|
9
|
+
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
const rotr = (x, n) => (x >>> n) | (x << (32 - n));
|
|
13
|
+
|
|
14
|
+
/** SHA-256 of a byte array, as 32 bytes. */
|
|
15
|
+
export function sha256(bytes) {
|
|
16
|
+
const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]);
|
|
17
|
+
const bitLength = bytes.length * 8;
|
|
18
|
+
const padded = new Uint8Array(((bytes.length + 9 + 63) >> 6) << 6);
|
|
19
|
+
padded.set(bytes);
|
|
20
|
+
padded[bytes.length] = 0x80;
|
|
21
|
+
const view = new DataView(padded.buffer);
|
|
22
|
+
view.setUint32(padded.length - 8, Math.floor(bitLength / 2 ** 32), false);
|
|
23
|
+
view.setUint32(padded.length - 4, bitLength >>> 0, false);
|
|
24
|
+
const w = new Uint32Array(64);
|
|
25
|
+
for (let off = 0; off < padded.length; off += 64) {
|
|
26
|
+
for (let i = 0; i < 16; i++) w[i] = view.getUint32(off + 4 * i, false);
|
|
27
|
+
for (let i = 16; i < 64; i++) {
|
|
28
|
+
const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
|
|
29
|
+
const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
|
|
30
|
+
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0;
|
|
31
|
+
}
|
|
32
|
+
let [a, b, c, d, e, f, g, hh] = h;
|
|
33
|
+
for (let i = 0; i < 64; i++) {
|
|
34
|
+
const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
|
|
35
|
+
const ch = (e & f) ^ (~e & g);
|
|
36
|
+
const t1 = (hh + S1 + ch + K[i] + w[i]) | 0;
|
|
37
|
+
const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
|
|
38
|
+
const maj = (a & b) ^ (a & c) ^ (b & c);
|
|
39
|
+
const t2 = (S0 + maj) | 0;
|
|
40
|
+
hh = g;
|
|
41
|
+
g = f;
|
|
42
|
+
f = e;
|
|
43
|
+
e = (d + t1) | 0;
|
|
44
|
+
d = c;
|
|
45
|
+
c = b;
|
|
46
|
+
b = a;
|
|
47
|
+
a = (t1 + t2) | 0;
|
|
48
|
+
}
|
|
49
|
+
h[0] += a; h[1] += b; h[2] += c; h[3] += d; h[4] += e; h[5] += f; h[6] += g; h[7] += hh;
|
|
50
|
+
}
|
|
51
|
+
const out = new Uint8Array(32);
|
|
52
|
+
const ov = new DataView(out.buffer);
|
|
53
|
+
h.forEach((x, i) => ov.setUint32(4 * i, x, false));
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Lowercase hex SHA-256 of a string's UTF-8 bytes. */
|
|
58
|
+
export function sha256Hex(text) {
|
|
59
|
+
return Array.from(sha256(new TextEncoder().encode(text)), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
60
|
+
}
|
package/src/terms.d.mts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Types for @meantjs/consent/terms: terms the user confirms for one task, and the check of a request's grant against
|
|
2
|
+
// them (spec section 5.3, "Confirmed terms", D-056). Hand-written; the source is src/terms.mjs. scripts/types.test.mjs
|
|
3
|
+
// keeps the declared names equal to the runtime exports.
|
|
4
|
+
import type { Grant } from "./index.mjs";
|
|
5
|
+
|
|
6
|
+
/** A term the agent wrote that cannot be read, or a step out of order: `message` is written for the agent. */
|
|
7
|
+
export declare class TermsError extends Error {
|
|
8
|
+
constructor(code: string, message: string);
|
|
9
|
+
readonly code: TermsCode;
|
|
10
|
+
}
|
|
11
|
+
export declare const TERMS_CODES: {
|
|
12
|
+
/** A field is missing or empty, or both spender and to are given. */
|
|
13
|
+
readonly MISSING: "E_TERMS_MISSING";
|
|
14
|
+
/** A token the wallet does not know. */
|
|
15
|
+
readonly TOKEN: "E_TERMS_TOKEN";
|
|
16
|
+
/** An app the wallet does not know, or an address or account that is not one. */
|
|
17
|
+
readonly PARTY: "E_TERMS_PARTY";
|
|
18
|
+
/** An amount that cannot be read, or has more decimals than its token: never rounded. */
|
|
19
|
+
readonly AMOUNT: "E_TERMS_AMOUNT";
|
|
20
|
+
/** An end that cannot be read, or a date before today. */
|
|
21
|
+
readonly END: "E_TERMS_END";
|
|
22
|
+
/** termsGate: a step out of order. */
|
|
23
|
+
readonly ORDER: "E_TERMS_ORDER";
|
|
24
|
+
};
|
|
25
|
+
export type TermsCode = (typeof TERMS_CODES)[keyof typeof TERMS_CODES];
|
|
26
|
+
|
|
27
|
+
/** What the terms functions read from the wallet. Pure: the wallet's clock and zone come in here. */
|
|
28
|
+
export interface Wallet {
|
|
29
|
+
/** The tokens it knows, by symbol: an EVM token's address or a ledger's canister id, and its decimals. */
|
|
30
|
+
tokens: Record<string, { address: string; decimals: number } | { canister: string; decimals: number }>;
|
|
31
|
+
/** The apps and people it knows by name: an address, or an account in its ICRC-1 text. */
|
|
32
|
+
apps?: Record<string, string>;
|
|
33
|
+
/** Nanoseconds since the Unix epoch. */
|
|
34
|
+
now: bigint;
|
|
35
|
+
/** The user's zone (default 0, UTC): a date allows its whole day there. */
|
|
36
|
+
utcOffsetMinutes?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The terms as the agent writes them, a tool's arguments: `spender` or `to`, exactly one. */
|
|
40
|
+
export interface TermsFields {
|
|
41
|
+
/** The token, by the symbol the wallet knows. */
|
|
42
|
+
token: string;
|
|
43
|
+
/** Who may spend the tokens: an app's name as the wallet knows it, or an address or account the user gave. */
|
|
44
|
+
spender?: string;
|
|
45
|
+
/** Who is paid directly: a name the wallet knows, or an address or account the user gave. */
|
|
46
|
+
to?: string;
|
|
47
|
+
/** The most, in tokens ("100", "0.5", "100 USDC"), or "unlimited". */
|
|
48
|
+
max_amount: string;
|
|
49
|
+
/** YYYY-MM-DD (the whole day counts), a length from today ("30 days", "2 weeks", "1 month", "1 year"), hours from now ("2 hours", an exact time), or "never". */
|
|
50
|
+
expires_by: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** What the user allows for one task, in the wallet's own values: `spender` or `to`, exactly one. */
|
|
54
|
+
export interface Terms {
|
|
55
|
+
/** The token: an EIP-55 address, or a canister id in its canonical text. */
|
|
56
|
+
token: string;
|
|
57
|
+
symbol: string;
|
|
58
|
+
decimals: number;
|
|
59
|
+
spender?: string;
|
|
60
|
+
to?: string;
|
|
61
|
+
/** The app's name in the wallet's list, or null. */
|
|
62
|
+
name: string | null;
|
|
63
|
+
/** The most, in base units as a decimal string; `null` when unlimited. */
|
|
64
|
+
amount: { units: string; unlimited: false } | { units: null; unlimited: true };
|
|
65
|
+
/** `at`: the latest end, exact seconds since the Unix epoch as a decimal string; `day`: the local date whose whole day counts. */
|
|
66
|
+
ends: { at: string; day?: string } | { never: true };
|
|
67
|
+
utcOffsetMinutes: number;
|
|
68
|
+
/** The fields as the agent wrote them, trimmed. */
|
|
69
|
+
written: Record<string, string>;
|
|
70
|
+
}
|
|
71
|
+
/** Terms the user confirmed: `id` is the SHA-256 of their canonical JSON (RFC 8785), lowercase hex. */
|
|
72
|
+
export type ConfirmedTerms = Readonly<Terms & { id: string }>;
|
|
73
|
+
|
|
74
|
+
/** Reads the agent's terms. Throws TermsError when a term cannot be read; it never guesses a token, an app, decimals or a date. */
|
|
75
|
+
export function readTerms(fields: TermsFields | Record<string, unknown>, wallet: Wallet): Terms;
|
|
76
|
+
/** The terms in words, exactly, for the user to confirm on the wallet's own screen. */
|
|
77
|
+
export function termsText(terms: Terms | ConfirmedTerms): string;
|
|
78
|
+
/** The terms once the user has confirmed exactly `termsText(terms)`: frozen, with their `id`. */
|
|
79
|
+
export function confirmTerms(terms: Terms): ConfirmedTerms;
|
|
80
|
+
/** Whether a request's grant fits confirmed terms, and if not, why, in words. A null grant is not covered by terms. */
|
|
81
|
+
export function checkGrant(confirmed: ConfirmedTerms, grant: Grant | null, wallet: Wallet): { fits: boolean; reasons: string[] };
|
|
82
|
+
|
|
83
|
+
/** The order of one task: propose, confirm on the wallet's screen, read, check. Steps out of order throw TermsError E_TERMS_ORDER. */
|
|
84
|
+
export interface TermsGate {
|
|
85
|
+
readonly state: "open" | "proposed" | "confirmed" | "read";
|
|
86
|
+
readonly proposed: Readonly<Terms> | null;
|
|
87
|
+
readonly confirmed: ConfirmedTerms | null;
|
|
88
|
+
/** The agent's draft; replaces an earlier one and its confirmation. Returns the text to show the user. Refused once the request was read. */
|
|
89
|
+
propose(terms: Terms): string;
|
|
90
|
+
/** The user confirmed `shown`, which must be exactly the draft's text. */
|
|
91
|
+
confirm(shown: string): ConfirmedTerms;
|
|
92
|
+
/** The user said no: the draft is dropped. */
|
|
93
|
+
reject(): void;
|
|
94
|
+
/** Before the agent reads the request: refused until the user has confirmed terms. */
|
|
95
|
+
read(): void;
|
|
96
|
+
/**
|
|
97
|
+
* The grant of the request read. `requestId` names the request the wallet will sign or send: eip712Consent's `digest`,
|
|
98
|
+
* or for a call the wallet's own id of it. The terms cover one request, the first checked: another is refused, even
|
|
99
|
+
* one that grants the same.
|
|
100
|
+
*/
|
|
101
|
+
check(grant: Grant | null, wallet: Wallet, requestId: string): { fits: boolean; reasons: string[] };
|
|
102
|
+
}
|
|
103
|
+
export function termsGate(): TermsGate;
|