@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/src/terms.mjs ADDED
@@ -0,0 +1,500 @@
1
+ // Terms the user allows for one task (docs/profile-v0.md section 5.3, "Confirmed terms", D-056): the token, who may
2
+ // take it, the most and the latest end. The agent drafts them from the user's words (readTerms). The wallet shows them
3
+ // exactly (termsText), and the user confirms them on the wallet's own screen, never through a tool the agent can call
4
+ // (section 11 item 10; confirmTerms). A request whose grant (grant.mjs) fits them then needs no second yes, and one
5
+ // that does not is refused with its reasons (checkGrant). termsGate keeps the order: the terms are confirmed before the
6
+ // request is read, and they cannot change after.
7
+ //
8
+ // Pure: no clock and no zone of their own. The wallet passes `now`, nanoseconds since the Unix epoch as a bigint (as
9
+ // @meantjs/format takes it), and its UTC offset. Nothing is rounded: an amount with more decimals than its token has is
10
+ // refused (spec section 5.3, D-006), and times are compared in nanoseconds.
11
+
12
+ import { displayAccount, displayAmount, normalizeName, parseAccount, parseAmount, principalFromText, principalToText } from "@meantjs/format";
13
+ import { jcs, readAddress } from "@meantjs/profile/evm";
14
+ import { zoneName } from "./display.mjs";
15
+ import { CODES, fail } from "./errors.mjs";
16
+ import { secondsText } from "./grant.mjs";
17
+ import { sha256Hex } from "./sha256.mjs";
18
+
19
+ /** A term the agent wrote that cannot be read, or a step out of order. `message` is written for the agent, which asks the user when it cannot fix the term itself. */
20
+ export class TermsError extends Error {
21
+ /**
22
+ * @param {string} code one of TERMS_CODES
23
+ * @param {string} message
24
+ */
25
+ constructor(code, message) {
26
+ super(message);
27
+ this.name = "TermsError";
28
+ this.code = code;
29
+ }
30
+ }
31
+
32
+ export const TERMS_CODES = Object.freeze({
33
+ MISSING: "E_TERMS_MISSING", // a field is missing or empty, or both spender and to are given
34
+ TOKEN: "E_TERMS_TOKEN", // a token the wallet does not know
35
+ PARTY: "E_TERMS_PARTY", // an app the wallet does not know, or an address or account that is not one
36
+ AMOUNT: "E_TERMS_AMOUNT", // an amount that cannot be read, or has more decimals than its token
37
+ END: "E_TERMS_END", // an end that cannot be read, or a date before today
38
+ ORDER: "E_TERMS_ORDER", // termsGate: a step out of order
39
+ });
40
+
41
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
42
+ const NS_PER_S = 1_000_000_000n;
43
+ const NS_PER_MINUTE = 60n * NS_PER_S;
44
+ const NS_PER_DAY = 86_400n * NS_PER_S;
45
+ /** As icrc21.mjs takes a zone: a fixed offset of at most a day. */
46
+ const MAX_OFFSET_MINUTES = 24 * 60;
47
+ const HOURS = /^(\d{1,3})\s*hours?$/i;
48
+ const LENGTH = /^(\d{1,4})\s*(day|week|month|year)s?$/i;
49
+ const DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
50
+ const SECONDS = /^(-?)(0|[1-9][0-9]*)(?:\.([0-9]{1,9}))?$/;
51
+ const NATURAL = /^(0|[1-9][0-9]*)$/;
52
+ const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
53
+
54
+ /** How a token or an app is looked up by name: NFKC, lowercase, letters and digits only, so "Uni-swap" and "1 inch" find Uniswap and 1inch. */
55
+ const nameKey = (s) => normalizeName(s).replace(/[^\p{L}\p{N}]/gu, "");
56
+ /** "A", "A and B", "A, B and C". */
57
+ const listText = (names) => (names.length < 2 ? names.join("") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`);
58
+ const ADDRESS = /^0x/i;
59
+
60
+ // ------------------------------------------------------------------------------------------------ days, exactly
61
+
62
+ const floorDiv = (a, b) => (a >= 0n ? a / b : -((-a + b - 1n) / b));
63
+ /** Days since 1970-01-01 of a date in the proleptic Gregorian calendar (H. Hinnant's days_from_civil), any year. */
64
+ function daysFrom(y, m, d) {
65
+ const yy = BigInt(y) - (m <= 2 ? 1n : 0n);
66
+ const era = floorDiv(yy, 400n);
67
+ const yoe = yy - era * 400n;
68
+ const doy = (153n * BigInt(m > 2 ? m - 3 : m + 9) + 2n) / 5n + BigInt(d) - 1n;
69
+ return era * 146097n + yoe * 365n + yoe / 4n - yoe / 100n + doy - 719468n;
70
+ }
71
+ /** The date of a day count (civil_from_days): { y, m, d } with y a bigint. */
72
+ function dateOf(days) {
73
+ const z = days + 719468n;
74
+ const era = floorDiv(z, 146097n);
75
+ const doe = z - era * 146097n;
76
+ const yoe = (doe - doe / 1460n + doe / 36524n - doe / 146096n) / 365n;
77
+ const doy = doe - (365n * yoe + yoe / 4n - yoe / 100n);
78
+ const mp = (5n * doy + 2n) / 153n;
79
+ const d = Number(doy - (153n * mp + 2n) / 5n + 1n);
80
+ const m = Number(mp < 10n ? mp + 3n : mp - 9n);
81
+ return { y: yoe + era * 400n + (m <= 2 ? 1n : 0n), m, d };
82
+ }
83
+ const daysIn = (y, m) => Number(daysFrom(m === 12 ? BigInt(y) + 1n : y, m === 12 ? 1 : m + 1, 1) - daysFrom(y, m, 1));
84
+ const isoDate = ({ y, m, d }) => `${String(y).padStart(4, "0")}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
85
+ /** A YYYY-MM-DD that names a real day, as { y, m, d }; null otherwise ("2027-02-30"). */
86
+ function readDate(text) {
87
+ const m = DATE.exec(text);
88
+ if (!m) return null;
89
+ const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
90
+ return mo >= 1 && mo <= 12 && d >= 1 && d <= daysIn(y, mo) ? { y: BigInt(y), m: mo, d } : null;
91
+ }
92
+ /** Local time in ns at an offset -> its day count. */
93
+ const localDays = (ns, offset) => floorDiv(ns + BigInt(offset) * NS_PER_MINUTE, NS_PER_DAY);
94
+ /** The last nanosecond of a local date: a date allows its whole day. */
95
+ const endOfDay = (date, offset) => (daysFrom(date.y, date.m, date.d) + 1n) * NS_PER_DAY - BigInt(offset) * NS_PER_MINUTE - 1n;
96
+
97
+ /**
98
+ * A length of time from a date as the date it ends on. Days and weeks count days. Months and years count calendar
99
+ * months, and a day the target month does not have becomes its last day (31 January and 1 month is 28 or 29 February).
100
+ */
101
+ function addLength(n, unit, from) {
102
+ if (unit === "day" || unit === "week") return dateOf(daysFrom(from.y, from.m, from.d) + BigInt(n * (unit === "week" ? 7 : 1)));
103
+ const months = from.y * 12n + BigInt(from.m - 1) + BigInt(n * (unit === "year" ? 12 : 1));
104
+ const y = floorDiv(months, 12n);
105
+ const m = Number(months - y * 12n) + 1;
106
+ return { y, m, d: Math.min(from.d, daysIn(y, m)) };
107
+ }
108
+
109
+ /** Exact seconds (a decimal string, as a grant writes them) -> ns. */
110
+ function nsOf(seconds) {
111
+ const m = SECONDS.exec(seconds);
112
+ const ns = BigInt(m[2]) * NS_PER_S + BigInt((m[3] ?? "").padEnd(9, "0"));
113
+ return m[1] ? -ns : ns;
114
+ }
115
+ const dayText = ({ y, m, d }) => `${d} ${MONTHS[m - 1]} ${y}`;
116
+ /** A moment in the zone, exactly: "2 November 2026 at 14:00", with seconds and their fraction only when there are any. */
117
+ function timeText(ns, offset) {
118
+ const local = ns + BigInt(offset) * NS_PER_MINUTE;
119
+ const days = floorDiv(local, NS_PER_DAY);
120
+ const rest = local - days * NS_PER_DAY;
121
+ const s = rest / NS_PER_S;
122
+ const frac = (rest % NS_PER_S).toString().padStart(9, "0").replace(/0+$/, "");
123
+ const hm = `${String(s / 3600n).padStart(2, "0")}:${String((s / 60n) % 60n).padStart(2, "0")}`;
124
+ const sec = s % 60n !== 0n || frac ? `:${String(s % 60n).padStart(2, "0")}${frac ? `.${frac}` : ""}` : "";
125
+ return `${dayText(dateOf(days))} at ${hm}${sec}`;
126
+ }
127
+
128
+ // ------------------------------------------------------------------------------------------------ the wallet
129
+
130
+ /**
131
+ * @typedef {object} Wallet
132
+ * @property {Record<string, { address?: string, canister?: string, decimals: number }>} tokens the tokens it knows, by
133
+ * symbol: an EVM token's address or an Internet Computer ledger's canister id, and its decimals
134
+ * @property {Record<string, string>} [apps] the apps and people it knows by name: an address, or an account in its
135
+ * ICRC-1 text. The agent names one and code looks it up: the agent never turns a name into an address itself
136
+ * @property {bigint} now nanoseconds since the Unix epoch, from the wallet's clock
137
+ * @property {number} [utcOffsetMinutes] the user's zone, default 0 (UTC): a date allows its whole day there
138
+ */
139
+
140
+ const canister = (text) => principalToText(principalFromText(text));
141
+ /** An address in EIP-55 form, or an account in its ICRC-1 text, or null. */
142
+ function partyId(text) {
143
+ if (typeof text !== "string") return null;
144
+ try {
145
+ return ADDRESS.test(text) ? { chain: "evm", id: readAddress(text) } : { chain: "icp", id: displayAccount(parseAccount(text)) };
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+ function tokenId(text) {
151
+ if (typeof text !== "string") return null;
152
+ try {
153
+ return ADDRESS.test(text) ? { chain: "evm", id: readAddress(text) } : { chain: "icp", id: canister(text) };
154
+ } catch {
155
+ return null;
156
+ }
157
+ }
158
+
159
+ function walletOf(wallet) {
160
+ const bad = (why) => fail(CODES.REQUEST, `wallet ${why}`);
161
+ if (!isObject(wallet)) bad("must be { tokens, apps, now, utcOffsetMinutes }");
162
+ const { tokens, apps = {}, now, utcOffsetMinutes = 0 } = wallet;
163
+ if (typeof now !== "bigint" || now < 0n) bad(".now must be nanoseconds since the Unix epoch, a bigint");
164
+ if (!Number.isInteger(utcOffsetMinutes) || Math.abs(utcOffsetMinutes) > MAX_OFFSET_MINUTES) bad(`.utcOffsetMinutes must be whole minutes within +-${MAX_OFFSET_MINUTES}`);
165
+ if (!isObject(tokens) || Object.keys(tokens).length === 0) bad(".tokens must name its tokens by symbol: { USDC: { address, decimals }, ICP: { canister, decimals } }");
166
+ if (!isObject(apps)) bad(".apps must be { name: address or account }");
167
+ const byKey = new Map();
168
+ const unique = (kind, name) => {
169
+ const key = nameKey(name);
170
+ if (!key) bad(`${kind} ${JSON.stringify(name)} has no letters or digits`);
171
+ if (byKey.has(`${kind}:${key}`)) bad(`${kind}s ${JSON.stringify(byKey.get(`${kind}:${key}`))} and ${JSON.stringify(name)} are one name once case, spaces and punctuation are set aside`);
172
+ byKey.set(`${kind}:${key}`, name);
173
+ return key;
174
+ };
175
+ const ids = new Map();
176
+ const tokenList = Object.entries(tokens).map(([symbol, t]) => {
177
+ const key = unique("token", symbol);
178
+ if (!isObject(t) || (t.address === undefined) === (t.canister === undefined)) bad(`.tokens.${symbol} must give its address or its canister, not both`);
179
+ const id = t.address !== undefined ? tokenId(t.address) : tokenId(t.canister);
180
+ if (id === null || (t.address !== undefined) !== (id.chain === "evm")) bad(`.tokens.${symbol}: ${JSON.stringify(t.address ?? t.canister)} is not ${t.address !== undefined ? "an address (0x and 40 hex digits)" : "a canister id"}`);
181
+ if (ids.has(id.id)) bad(`.tokens ${ids.get(id.id)} and ${symbol} are the same token`);
182
+ ids.set(id.id, symbol);
183
+ try {
184
+ displayAmount("0", { decimals: t.decimals, symbol });
185
+ } catch (e) {
186
+ bad(`.tokens.${symbol}: ${e.message}`);
187
+ }
188
+ return { symbol, key, ...id, decimals: Number(t.decimals) };
189
+ });
190
+ const appList = Object.entries(apps).map(([name, value]) => {
191
+ const key = unique("app", name);
192
+ const id = partyId(value);
193
+ if (id === null) bad(`.apps.${name}: ${JSON.stringify(value)} is neither an address (0x and 40 hex digits) nor an ICRC-1 account`);
194
+ return { name, key, ...id };
195
+ });
196
+ return { tokens: tokenList, apps: appList, now, offset: utcOffsetMinutes };
197
+ }
198
+
199
+ /** The one app with this address or account, by name; null for none. */
200
+ const appNamed = (w, id) => w.apps.find((a) => a.id === id)?.name ?? null;
201
+
202
+ // ------------------------------------------------------------------------------------------------ terms
203
+
204
+ /**
205
+ * The agent's terms, read: what the user allows, in the wallet's own values. Throws TermsError with a message for the
206
+ * agent when a term cannot be read; it never guesses a token, an app, a number of decimals or a date.
207
+ *
208
+ * @param {{ token: string, spender?: string, to?: string, max_amount: string, expires_by: string }} fields as the agent
209
+ * wrote them: the token by symbol; `spender` (who may spend the tokens) or `to` (who is paid directly), by the name
210
+ * the wallet knows or an address or account the user gave; the most, in tokens ("100", "0.5"), or "unlimited"; the
211
+ * latest end, as YYYY-MM-DD (the whole day counts), a length from today ("30 days", "2 weeks", "1 month", "1 year"),
212
+ * a number of hours from now ("2 hours", an exact time), or "never"
213
+ * @param {Wallet} wallet
214
+ */
215
+ export function readTerms(fields, wallet) {
216
+ const w = walletOf(wallet);
217
+ const o = isObject(fields) ? fields : {};
218
+ const given = (k) => typeof o[k] === "string" && o[k].trim() !== "";
219
+ if (given("spender") && given("to")) throw new TermsError(TERMS_CODES.MISSING, "give spender or to, not both: spender for who may spend the tokens, to for who is paid directly");
220
+ const role = given("to") ? "to" : "spender";
221
+ const str = (k, what = k) => {
222
+ if (!given(k)) throw new TermsError(TERMS_CODES.MISSING, `missing parameter: ${what}`);
223
+ return o[k].trim();
224
+ };
225
+ const written = { token: str("token"), [role]: str(role, "spender or to"), max_amount: str("max_amount"), expires_by: str("expires_by") };
226
+
227
+ const token = w.tokens.find((t) => t.key === nameKey(written.token));
228
+ if (!token) {
229
+ throw new TermsError(TERMS_CODES.TOKEN, `the wallet knows no token named ${JSON.stringify(written.token)}; it knows ${listText(w.tokens.map((t) => t.symbol))}. If the user named another token, or did not say which, ask the user`);
230
+ }
231
+ const party = readParty(role, written[role], token, w);
232
+
233
+ let amount;
234
+ if (written.max_amount.toLowerCase() === "unlimited") amount = { units: null, unlimited: true };
235
+ else {
236
+ try {
237
+ amount = { units: parseAmount(written.max_amount, { decimals: token.decimals, symbol: token.symbol, candid: token.chain === "evm" ? "uint256" : "nat" }), unlimited: false };
238
+ } catch (e) {
239
+ if (typeof e?.code !== "string") throw e;
240
+ throw new TermsError(TERMS_CODES.AMOUNT, `max_amount: ${e.message}`); // refuses, never rounds
241
+ }
242
+ }
243
+
244
+ const exp = written.expires_by;
245
+ const today = dateOf(localDays(w.now, w.offset));
246
+ let ends;
247
+ const hours = HOURS.exec(exp);
248
+ const length = LENGTH.exec(exp);
249
+ if (exp.toLowerCase() === "never") ends = { never: true };
250
+ else if (hours) ends = { at: secondsText((w.now / NS_PER_S) * NS_PER_S + BigInt(hours[1]) * 3600n * NS_PER_S) }; // from now in whole seconds: an exact time
251
+ else {
252
+ const date = length ? addLength(Number(length[1]), length[2].toLowerCase(), today) : readDate(exp);
253
+ if (!date) {
254
+ throw new TermsError(TERMS_CODES.END, `expires_by must be a date as YYYY-MM-DD, a length of time from now as a number and a unit (hours, days, weeks, months or years), such as "2 hours", "30 days" or "1 month", or "never"; got ${JSON.stringify(exp)}`);
255
+ }
256
+ if (date.y > 9999n) throw new TermsError(TERMS_CODES.END, `expires_by ${JSON.stringify(exp)} ends after the year 9999; write "never" only if the user plainly accepts no end`);
257
+ if (daysFrom(date.y, date.m, date.d) < daysFrom(today.y, today.m, today.d)) throw new TermsError(TERMS_CODES.END, `expires_by ${isoDate(date)} is before today, ${isoDate(today)}`);
258
+ ends = { at: secondsText(endOfDay(date, w.offset)), day: isoDate(date) };
259
+ }
260
+ return { token: token.id, symbol: token.symbol, decimals: token.decimals, [role]: party.id, name: party.name, amount, ends, utcOffsetMinutes: w.offset, written };
261
+ }
262
+
263
+ function readParty(role, text, token, w) {
264
+ const evm = token.chain === "evm";
265
+ if (evm && ADDRESS.test(text)) {
266
+ try {
267
+ const id = readAddress(text);
268
+ return { id, name: appNamed(w, id) };
269
+ } catch (e) {
270
+ if (e?.code === "E_ADDRESS_CHECKSUM") throw new TermsError(TERMS_CODES.PARTY, `${role} ${text} is in mixed case, but its EIP-55 checksum does not match: a character may be mistyped. Ask the user to check the address`);
271
+ throw new TermsError(TERMS_CODES.PARTY, `${role} ${JSON.stringify(text)} is not a 0x address with 40 hex digits`);
272
+ }
273
+ }
274
+ if (!evm) {
275
+ const id = partyId(text);
276
+ if (id?.chain === "icp") return { id: id.id, name: appNamed(w, id.id) };
277
+ }
278
+ const what = evm ? "a 0x address" : "an account: a principal, or its ICRC-1 text with a subaccount";
279
+ const app = w.apps.find((a) => a.key === nameKey(text));
280
+ const here = w.apps.filter((a) => a.chain === token.chain).map((a) => a.name);
281
+ if (!app && here.length === 0) throw new TermsError(TERMS_CODES.PARTY, `${role} must be ${what}; got ${JSON.stringify(text)}`);
282
+ if (!app) {
283
+ throw new TermsError(TERMS_CODES.PARTY, `the wallet knows no app named ${JSON.stringify(text)}; it knows ${listText(here)}. If the user named another app, or none, ask the user which app they mean, or for its ${evm ? "address" : "account"}`);
284
+ }
285
+ if (app.chain !== token.chain) {
286
+ throw new TermsError(TERMS_CODES.PARTY, `the wallet has ${app.chain === "evm" ? "an EVM address" : "an Internet Computer account"} for ${app.name}, and ${token.symbol} is a token ${evm ? "on an EVM chain" : "on the Internet Computer"}; ask the user for ${what}`);
287
+ }
288
+ return { id: app.id, name: app.name };
289
+ }
290
+
291
+ /** Terms as readTerms wrote them (with `id` when confirmed), checked so that their text cannot say less than they allow. */
292
+ function termsOf(terms, { confirmed = false } = {}) {
293
+ const bad = (why) => fail(CODES.REQUEST, `terms ${why}: they come from readTerms${confirmed ? ", then confirmTerms" : ""}`);
294
+ if (!isObject(terms)) bad("must be an object");
295
+ const role = terms.spender !== undefined ? "spender" : "to";
296
+ const want = ["token", "symbol", "decimals", role, "name", "amount", "ends", "utcOffsetMinutes", "written", ...(confirmed ? ["id"] : [])];
297
+ const keys = Object.keys(terms).filter((k) => !(k === "id" && !confirmed));
298
+ if (keys.length !== want.length || !want.every((k) => Object.hasOwn(terms, k))) bad(`must have exactly ${want.join(", ")}`);
299
+ const token = tokenId(terms.token);
300
+ const party = partyId(terms[role]);
301
+ if (!token || token.id !== terms.token) bad("name their token by its address (EIP-55) or canister id");
302
+ if (!party || party.id !== terms[role] || party.chain !== token.chain) bad(`name their ${role} by an address (EIP-55) or an account (ICRC-1 text) on the token's chain`);
303
+ if (terms.name !== null && (typeof terms.name !== "string" || !terms.name)) bad("name the party's app by a non-empty string, or null");
304
+ try {
305
+ displayAmount("0", { decimals: terms.decimals, symbol: terms.symbol });
306
+ } catch (e) {
307
+ bad(`have a symbol and decimals: ${e.message}`);
308
+ }
309
+ const { amount, ends, utcOffsetMinutes: offset } = terms;
310
+ if (!isObject(amount) || Object.keys(amount).length !== 2 || typeof amount.unlimited !== "boolean" || (amount.unlimited ? amount.units !== null : typeof amount.units !== "string" || !NATURAL.test(amount.units))) {
311
+ bad("give their amount as { units, unlimited: false } or { units: null, unlimited: true }");
312
+ }
313
+ if (!Number.isInteger(offset) || Math.abs(offset) > MAX_OFFSET_MINUTES) bad("give their zone in whole minutes");
314
+ const keysOf = isObject(ends) ? Object.keys(ends).sort().join() : "";
315
+ const at = keysOf === "at" || keysOf === "at,day" ? SECONDS.test(ends.at) && !ends.at.startsWith("-") : keysOf === "never" && ends.never === true;
316
+ if (!at) bad("give their end as { at }, { at, day } or { never: true }");
317
+ if (keysOf === "at,day") {
318
+ const date = typeof ends.day === "string" ? readDate(ends.day) : null;
319
+ if (!date || nsOf(ends.at) !== endOfDay(date, offset)) bad("end with the last moment of their day");
320
+ }
321
+ if (!isObject(terms.written) || !Object.values(terms.written).every((v) => typeof v === "string")) bad("keep what the agent wrote, as strings");
322
+ if (confirmed && terms.id !== termsId(terms)) bad("are not the ones the user confirmed: they changed after confirmTerms");
323
+ return terms;
324
+ }
325
+
326
+ const termsId = ({ id, ...rest }) => sha256Hex(jcs(rest));
327
+ const partyText = (name, id) => (name ? `${name} (${id})` : id);
328
+ const zoneOf = (offset) => `(${zoneName(offset)})`;
329
+ const mostText = (t) => displayAmount(t.amount.units, { decimals: t.decimals, symbol: t.symbol });
330
+ function untilText(t) {
331
+ if (t.ends.day !== undefined) return `until the end of ${dayText(readDate(t.ends.day))} ${zoneOf(t.utcOffsetMinutes)}`;
332
+ return `until ${timeText(nsOf(t.ends.at), t.utcOffsetMinutes)} ${zoneOf(t.utcOffsetMinutes)}`;
333
+ }
334
+
335
+ /**
336
+ * The terms in words, for the user to confirm on the wallet's own screen: the full address, the amount without
337
+ * rounding, and "any amount (unlimited)" or "never ends" written out (spec section 11 item 10). For example: "Uniswap
338
+ * (0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af) may spend at most 500 USDC, until the end of 30 November 2026 (UTC)."
339
+ */
340
+ export function termsText(terms) {
341
+ const t = termsOf(terms, { confirmed: terms?.id !== undefined });
342
+ const role = t.spender !== undefined ? "spender" : "to";
343
+ const verb = role === "spender" ? "may spend" : "may be sent";
344
+ const amount = t.amount.unlimited ? `any amount of ${t.symbol} (unlimited)` : `at most ${mostText(t)}`;
345
+ const end = t.ends.never ? "and this permission never ends" : untilText(t);
346
+ return `${partyText(t.name, t[role])} ${verb} ${amount}, ${end}.`;
347
+ }
348
+
349
+ /**
350
+ * The terms as the user confirmed them, with `id`: the SHA-256 of their canonical JSON (RFC 8785), lowercase hex. Call it
351
+ * only once the user has confirmed exactly `termsText(terms)` on the wallet's own screen. The result is frozen, and
352
+ * checkGrant refuses terms whose id no longer matches.
353
+ */
354
+ export function confirmTerms(terms) {
355
+ const { id, ...rest } = isObject(terms) ? terms : {};
356
+ termsOf(rest);
357
+ return deepFreeze(structuredClone({ ...rest, id: termsId(rest) }));
358
+ }
359
+
360
+ function deepFreeze(value) {
361
+ if (value !== null && typeof value === "object") {
362
+ for (const v of Object.values(value)) deepFreeze(v);
363
+ Object.freeze(value);
364
+ }
365
+ return value;
366
+ }
367
+
368
+ /** A grant from the consent functions, checked; its token and party as the terms write theirs. */
369
+ function grantOf(grant) {
370
+ const bad = (why) => fail(CODES.REQUEST, `grant ${why}: it comes from evmConsent, eip712Consent or generateConsent`);
371
+ if (!isObject(grant)) bad("must be an object or null");
372
+ if (grant.kind !== "transfer" && grant.kind !== "approve") bad("has kind transfer or approve");
373
+ if ((grant.spender === undefined) === (grant.to === undefined)) bad("names spender or to");
374
+ const role = grant.spender !== undefined ? "spender" : "to";
375
+ const token = tokenId(grant.token);
376
+ const party = partyId(grant[role]);
377
+ if (!token || !party || token.chain !== party.chain) bad(`names its token and its ${role} on one chain`);
378
+ const { amount, ends } = grant;
379
+ if (!isObject(amount) || typeof amount.unlimited !== "boolean" || !(amount.units === null ? amount.unlimited : typeof amount.units === "string" && NATURAL.test(amount.units))) bad("gives its amount as { units, unlimited }");
380
+ const end = isObject(ends) ? Object.keys(ends).join() : "";
381
+ if (!(end === "at" ? typeof ends.at === "string" && SECONDS.test(ends.at) : (end === "never" && ends.never === true) || (end === "now" && ends.now === true))) bad("ends { at }, { never: true } or { now: true }");
382
+ return { role, token: token.id, party: party.id, amount, ends };
383
+ }
384
+
385
+ /**
386
+ * Whether a request's grant fits terms the user confirmed (spec section 5.3, "Confirmed terms"): its token and its
387
+ * spender or recipient are the terms', its amount is at most the terms' most (unlimited only under unlimited terms; a
388
+ * grant of nothing fits any), and it ends no later than the terms' end (one that never ends only under terms that never
389
+ * end; a transfer while the terms have not ended). A request that fits may be signed or sent without a second yes. One
390
+ * that does not is refused with the reasons, and the agent asks the user for new terms. A request without a grant
391
+ * (`null`) is not covered by terms and needs its confirmation.
392
+ *
393
+ * @param {object} confirmed confirmTerms' result, unchanged
394
+ * @param {object | null} grant the `grant` of evmConsent, eip712Consent or generateConsent
395
+ * @param {Wallet} wallet its clock, and the names it shows: its tokens and apps
396
+ * @returns {{ fits: boolean, reasons: string[] }}
397
+ */
398
+ export function checkGrant(confirmed, grant, wallet) {
399
+ const t = termsOf(confirmed, { confirmed: true });
400
+ const w = walletOf(wallet);
401
+ if (grant === null || grant === undefined) {
402
+ return { fits: false, reasons: ["the request has no grant a profile describes, so no terms cover it: it needs the user's confirmation"] };
403
+ }
404
+ const g = grantOf(grant);
405
+ const role = t.spender !== undefined ? "spender" : "to";
406
+ const tokenName = (id) => w.tokens.find((k) => k.id === id)?.symbol ?? id;
407
+ const named = (id) => partyText(appNamed(w, id), id);
408
+ const terms = partyText(t.name, t[role]);
409
+ const reasons = [];
410
+ const sameToken = g.token === t.token;
411
+ if (!sameToken) reasons.push(`the request is for ${tokenName(g.token)}, the terms say ${t.symbol}`);
412
+ if (g.role !== role) reasons.push(role === "spender" ? `the request pays ${named(g.party)} directly, the terms let ${terms} spend` : `the request lets ${named(g.party)} spend, the terms pay ${terms} directly`);
413
+ else if (g.party !== t[role]) reasons.push(`the ${role === "spender" ? "spender" : "recipient"} is ${named(g.party)}, the terms say ${terms}`);
414
+ if (sameToken && !t.amount.unlimited) {
415
+ if (g.amount.unlimited) reasons.push(`the request is unlimited, the terms say at most ${mostText(t)}`);
416
+ else if (BigInt(g.amount.units) > BigInt(t.amount.units)) reasons.push(`the request allows ${displayAmount(g.amount.units, { decimals: t.decimals, symbol: t.symbol })}, the terms say at most ${mostText(t)}`);
417
+ }
418
+ if (!t.ends.never) {
419
+ const end = nsOf(t.ends.at);
420
+ if (g.ends.never) reasons.push(`the permission never ends, the terms say ${untilText(t)}`);
421
+ else if (g.ends.now) {
422
+ if (w.now > end) reasons.push(`the terms have ended: they said ${untilText(t)}`);
423
+ } else if (nsOf(g.ends.at) > end) reasons.push(`the permission lasts until ${timeText(nsOf(g.ends.at), t.utcOffsetMinutes)} ${zoneOf(t.utcOffsetMinutes)}, the terms say ${untilText(t)}`);
424
+ }
425
+ return { fits: reasons.length === 0, reasons };
426
+ }
427
+
428
+ /**
429
+ * The order of one task, kept (spec section 5.3, "Confirmed terms"; the order surface D2 of the everyday-wording test
430
+ * kept): the agent proposes terms, the user confirms exactly their text on the wallet's own screen, the agent reads the
431
+ * request, and its grant is checked. Once the request is read the terms cannot change: new terms are a new task, with a
432
+ * new gate. The terms cover one request, the first checked, named by the id the wallet gives it. Steps out of order
433
+ * throw TermsError E_TERMS_ORDER, with a message for the agent.
434
+ */
435
+ export function termsGate() {
436
+ let proposed = null;
437
+ let confirmed = null;
438
+ let read = false;
439
+ let request = null;
440
+ const order = (why) => {
441
+ throw new TermsError(TERMS_CODES.ORDER, why);
442
+ };
443
+ const fixed = () => read && order("the request was read under the confirmed terms, so they cannot be changed now; new terms start a new task");
444
+ return {
445
+ /** "open", "proposed", "confirmed" or "read". */
446
+ get state() {
447
+ return read ? "read" : confirmed ? "confirmed" : proposed ? "proposed" : "open";
448
+ },
449
+ /** The terms waiting for the user, or null. */
450
+ get proposed() {
451
+ return proposed;
452
+ },
453
+ /** The terms the user confirmed, or null. */
454
+ get confirmed() {
455
+ return confirmed;
456
+ },
457
+ /** The agent's draft, readTerms' terms. It replaces an earlier draft and its confirmation. Returns the text to show the user. */
458
+ propose(terms) {
459
+ fixed();
460
+ const { id, ...draft } = isObject(terms) ? terms : {};
461
+ proposed = deepFreeze(structuredClone(termsOf(draft)));
462
+ confirmed = null;
463
+ return termsText(proposed);
464
+ },
465
+ /** The wallet, when the user confirmed `shown` on its own screen: it must be exactly termsText of the draft. Returns the confirmed terms. */
466
+ confirm(shown) {
467
+ fixed();
468
+ if (proposed === null) order("there are no terms to confirm: the agent proposes them first");
469
+ if (shown !== termsText(proposed)) order("the text the user confirmed is not the text of the terms proposed; show termsText(gate.proposed) and ask again");
470
+ confirmed = confirmTerms(proposed);
471
+ return confirmed;
472
+ },
473
+ /** The user said no: the draft is dropped, and the agent may propose other terms or ask the user. */
474
+ reject() {
475
+ fixed();
476
+ proposed = null;
477
+ confirmed = null;
478
+ },
479
+ /** Before the agent reads the request: refused until the user has confirmed terms. */
480
+ read() {
481
+ if (confirmed === null) order(proposed ? "the user has not confirmed the terms yet; the request can be read once they do" : "there are no terms yet: propose the user's terms, and read the request once the user confirms them");
482
+ read = true;
483
+ },
484
+ /**
485
+ * The grant of the request read, against the confirmed terms: checkGrant's { fits, reasons }. `requestId` names the
486
+ * request the wallet will sign or send: eip712Consent's `digest`, or for a call the wallet's own id of it (an EVM
487
+ * transaction's hash, an Internet Computer request's id). The same request may be checked again; another is refused
488
+ * even when it grants the same, since two Permit2 transfers that differ only in their nonce are two transfers.
489
+ */
490
+ check(grant, wallet, requestId) {
491
+ if (!read) order("read the request first: its grant is checked against the terms it was read under");
492
+ if (typeof requestId !== "string" || requestId === "") fail(CODES.REQUEST, "termsGate's check needs the request's id: eip712Consent's digest, or the wallet's own id of a call");
493
+ const result = checkGrant(confirmed, grant, wallet);
494
+ const key = jcs(grant ?? null);
495
+ if (request !== null && (requestId !== request.id || key !== request.grant)) order("these terms cover one request, the one already checked; another request is a new task, with new terms");
496
+ request = { id: requestId, grant: key };
497
+ return result;
498
+ },
499
+ };
500
+ }