@meantjs/consent 0.3.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 +17 -0
- package/README.md +53 -3
- package/package.json +7 -3
- package/src/consent.mjs +12 -4
- package/src/evm.d.mts +9 -1
- package/src/evm.mjs +11 -3
- package/src/grant.mjs +140 -0
- package/src/index.d.mts +27 -0
- package/src/sha256.mjs +60 -0
- package/src/terms.d.mts +103 -0
- package/src/terms.mjs +500 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
The five Candid Profile library packages (`@meantjs/profile`, `/format`, `/consent`, `/tools`, `/reconcile`) release together under one version and pin each other exactly; a version appears here even when this package did not change. Release steps: `docs/releasing.md` in the repository.
|
|
4
4
|
|
|
5
|
+
## 0.4.0 — 2026-10-30
|
|
6
|
+
|
|
7
|
+
- What a request grants (spec §5.3, D-056). `evmConsent`, `eip712Consent` and `generateConsent` also return `values`,
|
|
8
|
+
the decoded arguments or message, and `grant`: who may take tokens because of the request, which token, how many
|
|
9
|
+
and until when, read from the effect's `grant` (`{ kind, spender | to, token, amount: { units, unlimited }, ends }`),
|
|
10
|
+
or null. Addresses are EIP-55, accounts their ICRC-1 text, units a decimal string and ends exact seconds, never
|
|
11
|
+
rounded. A grant that cannot be read is null with a `W_GRANT` diagnostic, and the text is the same.
|
|
12
|
+
`generateConsent` takes `canisterId`, the ledger called, and reads the grant only then, so an ICRC-21 message is
|
|
13
|
+
unchanged.
|
|
14
|
+
- `@meantjs/consent/terms`, new: terms the user confirms for one task, and the check of a request's grant against them
|
|
15
|
+
(spec §5.3, "Confirmed terms"; §11 item 10). `readTerms` reads an agent's terms in the wallet's values (a token by
|
|
16
|
+
its symbol, an app by its name, an amount never rounded, an end as a date, a length, hours or "never") or throws a
|
|
17
|
+
`TermsError` with a message for the agent; `termsText` writes them out exactly for the wallet's own screen;
|
|
18
|
+
`confirmTerms` freezes them with an id; `checkGrant` says whether a grant fits, and why not; `termsGate` keeps the
|
|
19
|
+
order (confirmed before the request is read, fixed after, one request, named by the id the wallet gives it). It runs
|
|
20
|
+
in browsers.
|
|
21
|
+
|
|
5
22
|
## 0.3.0 — 2026-10-29
|
|
6
23
|
|
|
7
24
|
- `@meantjs/consent/evm`: `eip712Consent`, the consent text of an EIP-712 signing request (spec §15.9, D-055),
|
package/README.md
CHANGED
|
@@ -6,8 +6,8 @@ ICRC-21 consent messages, and the text an SNS proposal's validator returns, from
|
|
|
6
6
|
npm install @meantjs/consent
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
- **Runs on:** Node 22 or later and browsers for the main entry
|
|
10
|
-
checked with esbuild). `@meantjs/consent/sns` hashes with `node:crypto` and is Node only.
|
|
9
|
+
- **Runs on:** Node 22 or later and browsers for the main entry, the EVM subpaths and `./terms` (no Node built-in, no
|
|
10
|
+
`Buffer`; checked with esbuild). `@meantjs/consent/sns` hashes with `node:crypto` and is Node only.
|
|
11
11
|
- **Module format:** ES modules only, with hand-written TypeScript declarations for every entry.
|
|
12
12
|
- **Spec:** [Candid Profile v0](https://github.com/B3Pay/Meant/blob/main/profile/docs/profile-v0.md). **Licence:** Apache-2.0.
|
|
13
13
|
- **Versions:** the five Candid Profile packages (`@meantjs/profile`, `/format`, `/consent`, `/tools`,
|
|
@@ -47,7 +47,7 @@ bundles it with esbuild for the browser platform). The three SNS rows below are
|
|
|
47
47
|
| `snsValidatorRendering({ payload, resolved, method, refs, limits })` | An SNS generic-function validator as a function: payload bytes in, `{ Ok: text }` or `{ Err: text }` out (section below). Non-enumerable `diagnostics` and `provenance`, as above. |
|
|
48
48
|
| `handleSnsValidatorCall(payload, context)` / `encodeValidatorResult` / `decodeValidatorResult` | The canister-side wrapper and the codec of `variant { Ok : text; Err : text }`. |
|
|
49
49
|
| `verifySnsRendering({ payloadTextRendering, payload, resolved, targetCanisterId, method, refs })`, `parseGovernanceRendering(text)` | The reviewer's check of a stored proposal rendering; `SNS_RENDERING_RULES` names the rules. |
|
|
50
|
-
| `generateConsent({ resolved, method, arg, refs, form, utcOffsetMinutes, rawArguments })` | One form without the ICRC-21 envelope; returns `{ message, zoneSensitive, diagnostics, provenance, model }`. |
|
|
50
|
+
| `generateConsent({ resolved, method, arg, refs, form, utcOffsetMinutes, rawArguments, canisterId })` | One form without the ICRC-21 envelope; returns `{ message, zoneSensitive, values, grant, diagnostics, provenance, model }`. `values`: the decoded arguments. `grant`: what the call grants (section "Terms the user confirms"), read only when `canisterId` names the canister called, its token; else null. |
|
|
51
51
|
| `decodeArguments(method, bytes, limits)` | Step 1 alone: argument bytes to the value tree. |
|
|
52
52
|
| `parseTemplate`, `resolvePath`, `valuesAt`, `Renderer`, `renderFields`, `renderGeneric`, `codeSpan`, `escapeMarkdown`, `visible` | The parts. |
|
|
53
53
|
|
|
@@ -72,6 +72,7 @@ a Candid one.
|
|
|
72
72
|
| `decodeCalldata(resolved, calldata, limits)` (`/evm`) | The function a selector names and its arguments, keyed by parameter name. Strict: values read from anything but their canonical ABI encoding are `E_DECODE`. |
|
|
73
73
|
| `evmConsent({ resolved, calldata, to, chainId, refs, tokens, form, utcOffsetMinutes, rawArguments, limits })` (`/evm`) | The consent text of a call: the same model and the same two forms as ICRC-21, without its envelope (`"generic"` Markdown, or `"fields"` as an intent line and `[label, text]` rows), ending with the contract's address and chain, which a person must see because a pack matches any contract with the right functions. A call to a contract its profiles do not list is `E_DEPLOYMENT`; a payable function is `E_UNSUPPORTED_TYPE` (its native value has no position yet). `tokens`: the decimals and symbol of each token an amount's `token` field names, by address; the address is shown beside the symbol. |
|
|
74
74
|
| `eip712Consent({ resolved, typedData, chainId, refs, tokens, form, utcOffsetMinutes, rawArguments, limits })` (`/evm`) | The consent text of an EIP-712 signing request (`eth_signTypedData_v4`'s JSON), matched to a message of the resolved form by its type hash, never its name. Refused before anything is shown: a domain for another chain than the wallet's, without `chainId` or `verifyingContract`, with a salt, or whose name or version is not its profiles' (`E_DOMAIN`); a contract its profiles do not list (`E_DEPLOYMENT`); no message with the type hash (`E_TYPE_HASH`); values that are not exactly the members, canonically written (`E_DECODE`); a token not in `tokens` (`E_REF_UNRESOLVED`). Ends with the verifying contract, the EIP-712 digest and "This is a signature, not a transaction: whoever holds it can submit it." Returns `typeHash`, `digest`, `verifyingContract` and `chainId` with the text. |
|
|
75
|
+
| `values`, `grant` (both, `/evm`) | `evmConsent` and `eip712Consent` also return the decoded arguments or message by name (`values`), and what the call or signature grants (`grant`, below), or null. |
|
|
75
76
|
| `erc7730Descriptor(resolved, { context, deployments, owner, contractName, info, token })` (`/erc7730`) | The contract's ERC-7730 clear-signing file and its ERC-8176 `descriptorHash`: deterministic, `$schema` pinned to the exact 2.1.0 file, one entry per function a transaction calls, or with `context: "eip712"` one per message, keyed by its `encodeType`. What an ERC-7730 file cannot say (a time in ms, `utf8` bytes, an amount in another token, an unnamed parameter) is `E_EXPORT`, never written as something near it; no deployment is `E_DEPLOYMENT`. What it cannot say but changes no value (a timestamp's "Never", a template over an array) is left out and listed in `notes`. The mapping is spec §15.7 and §15.9. |
|
|
76
77
|
| `descriptorHash(descriptor)` (`/erc7730`) | Keccak-256 of the RFC 8785 form: the hash of a file someone edited. |
|
|
77
78
|
| `erc8176Attestation({ descriptorHash, time, expirationTime, salt, refUID })` (`/erc7730`) | The unsigned ERC-8176 attestation (EAS offchain v2 typed data) and its EIP-712 digest, for a reviewer to sign with their own key. Nothing is signed or sent. |
|
|
@@ -96,6 +97,55 @@ independent implementation. **Fact** (ERC-7730): a wallet that finds no ticker f
|
|
|
96
97
|
raw value instead with an 'Unknown token' warning"; this package would produce no message there, and a file cannot make
|
|
97
98
|
a wallet refuse. The Rust version does not cover EVM.
|
|
98
99
|
|
|
100
|
+
## Terms the user confirms (`src/terms.mjs`, `@meantjs/consent/terms`)
|
|
101
|
+
|
|
102
|
+
Spec §5.3 ("What a call grants", "Confirmed terms") and §11 item 10, D-056. An agent that signs for a user should sign
|
|
103
|
+
only what the user asked for. The consent functions above return each request's `grant`, read from the profile's
|
|
104
|
+
`effect.grant`:
|
|
105
|
+
|
|
106
|
+
```js
|
|
107
|
+
{ kind: "approve", spender: "0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af", token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
|
|
108
|
+
amount: { units: "500000000", unlimited: false }, ends: { at: "1796083199" } } // or { never: true }, or { now: true } for a transfer
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Addresses are EIP-55, accounts their ICRC-1 text, a canister's token its canonical id, units a decimal string and ends
|
|
112
|
+
exact seconds, never rounded. A Permit2 allowance whose expiration is 0 ends at `sigDeadline`, and an EIP-2612 or DAI
|
|
113
|
+
permit never ends (their deadlines are the signature's). A batch, a request without a grant, or a grant that cannot be
|
|
114
|
+
read is null; the last with a `W_GRANT` diagnostic, and the text is the same.
|
|
115
|
+
|
|
116
|
+
The subpath compares a grant with terms the user confirmed for one task:
|
|
117
|
+
|
|
118
|
+
| Function | Does |
|
|
119
|
+
|---|---|
|
|
120
|
+
| `readTerms(fields, wallet)` | The agent's terms (`token`, `spender` or `to`, `max_amount`, `expires_by`, as a tool takes them) in the wallet's values: a token by its symbol and an app by its name from the wallet's lists, never guessed; an address or account the user gave, checked; the most never rounded; the end as a date (its whole day in the user's zone), a length ("30 days", "1 month", calendar months), hours from now (an exact time) or "never". "unlimited" and "never" only as those words. Throws `TermsError` with a code and a message for the agent. `wallet`: `{ tokens, apps, now, utcOffsetMinutes }`, `now` in nanoseconds as a bigint. |
|
|
121
|
+
| `termsText(terms)` | The terms in words, exactly, for the wallet's own screen: "Uniswap (0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af) may spend at most 500 USDC, until the end of 30 November 2026 (UTC)." "any amount (unlimited)" and "never ends" are written out. |
|
|
122
|
+
| `confirmTerms(terms)` | Once the user confirmed exactly that text, never through a tool the agent can call: the terms frozen, with `id`, the SHA-256 of their canonical JSON. |
|
|
123
|
+
| `checkGrant(confirmed, grant, wallet)` | `{ fits, reasons }`. A grant fits when its token and its spender or recipient are the terms', its amount is at most the most (unlimited only under unlimited terms; a grant of nothing under any), and it ends no later than the terms (never only under never; a transfer while the terms have not ended). A request that fits needs no second yes; one that does not is refused with the reasons, and the agent asks the user for new terms. A null grant is not covered by terms. |
|
|
124
|
+
| `termsGate()` | The order, kept: `propose(terms)` returns the text to show, `confirm(shown)` takes the text the user confirmed, `read()` before the agent reads the request, `check(grant, wallet, requestId)`. The request cannot be read before the terms are confirmed, the terms cannot change once it is read, and they cover one request: `requestId` names it (`eip712Consent`'s `digest`, or the wallet's own id of a call), and another is refused even when it grants the same. Out of order is `E_TERMS_ORDER`. |
|
|
125
|
+
|
|
126
|
+
```js
|
|
127
|
+
import { checkGrant, readTerms, termsGate, termsText } from "@meantjs/consent/terms";
|
|
128
|
+
import { eip712Consent } from "@meantjs/consent/evm";
|
|
129
|
+
|
|
130
|
+
const wallet = {
|
|
131
|
+
tokens: { USDC: { address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", decimals: 6 } },
|
|
132
|
+
apps: { Uniswap: "0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af" },
|
|
133
|
+
now: BigInt(Date.now()) * 1_000_000n,
|
|
134
|
+
};
|
|
135
|
+
const gate = termsGate();
|
|
136
|
+
const shown = gate.propose(readTerms({ token: "USDC", spender: "Uniswap", max_amount: "500", expires_by: "2026-11-30" }, wallet));
|
|
137
|
+
// the wallet shows `shown` on its own screen; when the user confirms it:
|
|
138
|
+
gate.confirm(shown);
|
|
139
|
+
gate.read(); // now the agent may read the request
|
|
140
|
+
const { grant, digest } = eip712Consent({ resolved: permit2, typedData, chainId: 1, tokens });
|
|
141
|
+
gate.check(grant, wallet, digest); // { fits: true, reasons: [] }, or { fits: false, reasons: ["the request is unlimited, the terms say at most 500 USDC"] }
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The rules come from Meant's signing test, where the comparison in code held every time and the terms agents wrote from
|
|
145
|
+
everyday words were the weak point (spec §11 item 10); hence the user's confirmation. The checks cover the eight
|
|
146
|
+
requests of the proposal (Permit2 `PermitSingle` and `PermitTransferFrom`, EIP-2612, DAI, ERC-20 `approve` and
|
|
147
|
+
`transfer`, ICRC-2 `icrc2_approve`, ICRC-1 `icrc1_transfer`) end to end, both traps, and the test's planted faults.
|
|
148
|
+
|
|
99
149
|
## SNS proposal validators (`src/sns.mjs`)
|
|
100
150
|
|
|
101
151
|
The governance counterpart of ICRC-21. An SNS proposal that executes a generic nervous system function calls a dapp method with a payload. Apart from the proposer's own title and summary, the only description of that payload governance shows is the text the dapp's validator method returns. `snsValidatorRendering` is that validator, generated from the profile. Proposal for the spec text: `docs/proposals/w4-sns-validator-rendering.md`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meantjs/consent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "ICRC-21 consent messages and SNS proposal validator renderings generated from a Candid Profile resolved form: any method, both GenericDisplayMessage and FieldsDisplayMessage, values formatted and escaped, nothing rounded.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -46,6 +46,10 @@
|
|
|
46
46
|
"types": "./src/erc7730.d.mts",
|
|
47
47
|
"default": "./src/erc7730.mjs"
|
|
48
48
|
},
|
|
49
|
+
"./terms": {
|
|
50
|
+
"types": "./src/terms.d.mts",
|
|
51
|
+
"default": "./src/terms.mjs"
|
|
52
|
+
},
|
|
49
53
|
"./package.json": "./package.json"
|
|
50
54
|
},
|
|
51
55
|
"files": [
|
|
@@ -64,8 +68,8 @@
|
|
|
64
68
|
},
|
|
65
69
|
"dependencies": {
|
|
66
70
|
"@candid-core/schema": "0.5.0",
|
|
67
|
-
"@meantjs/format": "0.
|
|
68
|
-
"@meantjs/profile": "0.
|
|
71
|
+
"@meantjs/format": "0.4.0",
|
|
72
|
+
"@meantjs/profile": "0.4.0"
|
|
69
73
|
},
|
|
70
74
|
"devDependencies": {
|
|
71
75
|
"@dfinity/didc": "0.0.4",
|
package/src/consent.mjs
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
// `icrc21.mjs` wraps this into the ICRC-21 request/response shapes.
|
|
3
3
|
|
|
4
4
|
import { Renderer } from "./display.mjs";
|
|
5
|
+
import { canisterText, grantOf } from "./grant.mjs";
|
|
5
6
|
import { buildModel } from "./model.mjs";
|
|
6
7
|
import { renderFields, renderGeneric } from "./render.mjs";
|
|
8
|
+
import { methodOf } from "./resolved.mjs";
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* @param {object} input
|
|
@@ -15,14 +17,20 @@ import { renderFields, renderGeneric } from "./render.mjs";
|
|
|
15
17
|
* @param {number | null} [input.utcOffsetMinutes] generic form only: show timestamps in this zone (default 0, UTC). The fields form is always UTC seconds
|
|
16
18
|
* @param {boolean} [input.rawArguments] append the decoded raw arguments (spec section 11 item 1)
|
|
17
19
|
* @param {object} [input.limits] decoder limits (maxBytes, maxDepth, maxElements)
|
|
18
|
-
* @
|
|
20
|
+
* @param {string} [input.canisterId] the canister called: the token of a grant (spec section 5.3, D-056). Without it
|
|
21
|
+
* nothing is read: `grant` is null and no diagnostic says so, since a caller that does not name the canister is not
|
|
22
|
+
* asking what the call grants (an ICRC-21 message, for one)
|
|
23
|
+
* @returns {{ form: "fields" | "generic", message: object, zoneSensitive: boolean, values: unknown[], grant: object | null, diagnostics: object[], provenance: object, model: object }}
|
|
19
24
|
* `message` is `{ FieldsDisplayMessage: { intent, fields } }` or `{ GenericDisplayMessage: string }` (ICRC-21 shapes,
|
|
20
|
-
* integers as bigint). `zoneSensitive` is true when a timestamp was shown in the requested zone.
|
|
25
|
+
* integers as bigint). `zoneSensitive` is true when a timestamp was shown in the requested zone. `values` are the
|
|
26
|
+
* decoded arguments as `decodeArguments` gives them, and `grant` what the call grants (`grant.mjs`), or null.
|
|
21
27
|
*/
|
|
22
|
-
export function generateConsent({ resolved, method, arg, refs = {}, form = "generic", utcOffsetMinutes = null, rawArguments = false, limits }) {
|
|
28
|
+
export function generateConsent({ resolved, method, arg, refs = {}, form = "generic", utcOffsetMinutes = null, rawArguments = false, limits, canisterId }) {
|
|
29
|
+
const called = canisterId === undefined ? undefined : canisterText(canisterId);
|
|
23
30
|
const model = buildModel({ resolved, method, arg, limits });
|
|
24
31
|
const diagnostics = model.diagnostics;
|
|
25
|
-
const
|
|
32
|
+
const grant = called === undefined ? null : grantOf({ name: method, method: methodOf(resolved, method), argValues: model.argValues, called, diagnostics });
|
|
33
|
+
const done = (rest) => ({ ...rest, values: model.argValues, grant, diagnostics: dedupe(diagnostics), provenance: model.provenance, model });
|
|
26
34
|
if (form === "fields") {
|
|
27
35
|
const renderer = new Renderer({ refs, utcOffsetMinutes: null, diagnostics });
|
|
28
36
|
const { intent, fields } = renderFields(model, renderer, { rawArguments });
|
package/src/evm.d.mts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Types for @meantjs/consent/evm: the consent text of an EVM call (spec section 15, D-054). Hand-written; the source is
|
|
2
2
|
// src/evm.mjs. scripts/types.test.mjs keeps the declared names equal to the runtime exports.
|
|
3
3
|
import type { AbiLimits, AbiValue } from "@meantjs/profile/evm";
|
|
4
|
-
import type { ConsentDiagnostic, Model, Provenance, ResolvedForm } from "./index.mjs";
|
|
4
|
+
import type { ConsentDiagnostic, Grant, Model, Provenance, ResolvedForm } from "./index.mjs";
|
|
5
5
|
|
|
6
6
|
/** An EVM contract's resolved form: `chain: "evm"`, and each method with its selector and named signature. */
|
|
7
7
|
export type EvmResolvedForm = ResolvedForm & { chain: "evm" };
|
|
@@ -50,6 +50,10 @@ export function evmConsent(input: EvmConsentInput): {
|
|
|
50
50
|
/** The contract, in its EIP-55 form. */
|
|
51
51
|
to: string;
|
|
52
52
|
chainId: string | null;
|
|
53
|
+
/** The decoded arguments by parameter name, as `decodeCalldata` gives them. */
|
|
54
|
+
values: Record<string, AbiValue>;
|
|
55
|
+
/** What the call grants (its token is the contract called, unless the amount's tag names a field), or null; a W_GRANT diagnostic says why one cannot be read. */
|
|
56
|
+
grant: Grant | null;
|
|
53
57
|
diagnostics: ConsentDiagnostic[];
|
|
54
58
|
provenance: Provenance;
|
|
55
59
|
model: Model;
|
|
@@ -100,6 +104,10 @@ export function eip712Consent(input: Eip712ConsentInput): {
|
|
|
100
104
|
/** The verifying contract, in its EIP-55 form. */
|
|
101
105
|
verifyingContract: string;
|
|
102
106
|
chainId: string;
|
|
107
|
+
/** The message's values by member name, as read from the typed data: integers as decimal strings, addresses in EIP-55 form. */
|
|
108
|
+
values: Record<string, AbiValue>;
|
|
109
|
+
/** What the signature grants (its token is the verifying contract, unless the amount's tag names a member), or null; a W_GRANT diagnostic says why one cannot be read. */
|
|
110
|
+
grant: Grant | null;
|
|
103
111
|
diagnostics: ConsentDiagnostic[];
|
|
104
112
|
provenance: Provenance;
|
|
105
113
|
model: Model;
|
package/src/evm.mjs
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import { AbiError, DEFAULT_ABI_LIMITS, decodeParams, eip712TypeHash, keccak256Hex, parseEip712Types, parseSignature, readAddress, typedDataDigest } from "@meantjs/profile/evm";
|
|
23
23
|
import { Renderer, TokenAmount } from "./display.mjs";
|
|
24
24
|
import { CODES, ConsentError, fail } from "./errors.mjs";
|
|
25
|
+
import { grantOf } from "./grant.mjs";
|
|
25
26
|
import { modelOf } from "./model.mjs";
|
|
26
27
|
import { resolvePath } from "./paths.mjs";
|
|
27
28
|
import { renderGeneric, renderPlain } from "./render.mjs";
|
|
@@ -149,7 +150,9 @@ function withTokens(name, viewed, args, tokens) {
|
|
|
149
150
|
* @param {number | null} [input.utcOffsetMinutes] generic form only (default 0, UTC)
|
|
150
151
|
* @param {boolean} [input.rawArguments] append the decoded arguments (spec section 11 item 1)
|
|
151
152
|
* @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
|
+
* @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.
|
|
153
156
|
*/
|
|
154
157
|
export function evmConsent({ resolved, calldata, to, chainId, refs = {}, tokens = {}, form = "generic", utcOffsetMinutes = null, rawArguments = false, limits }) {
|
|
155
158
|
if (form !== "generic" && form !== "fields") fail(CODES.REQUEST, `form must be "generic" or "fields", got ${JSON.stringify(form)}`);
|
|
@@ -167,8 +170,9 @@ export function evmConsent({ resolved, calldata, to, chainId, refs = {}, tokens
|
|
|
167
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)`);
|
|
168
171
|
const viewed = { ...method, args: method.args.map((n, i) => abiView(n, `${name}(${i})`)) };
|
|
169
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 });
|
|
170
174
|
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 });
|
|
175
|
+
const done = (rest) => ({ ...rest, method: name, selector, to: target, chainId: chain, values, grant, diagnostics: model.diagnostics, provenance: model.provenance, model });
|
|
172
176
|
if (form === "fields") {
|
|
173
177
|
const plain = renderPlain(model, new Renderer({ refs, diagnostics: model.diagnostics }), { rawArguments });
|
|
174
178
|
plain.fields.push(["Contract", where]);
|
|
@@ -312,6 +316,9 @@ function checkDomain(resolved, domain) {
|
|
|
312
316
|
* @param {number | null} [input.utcOffsetMinutes] generic form only (default 0, UTC)
|
|
313
317
|
* @param {boolean} [input.rawArguments] append the message's values (spec section 11 item 1)
|
|
314
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`
|
|
315
322
|
*/
|
|
316
323
|
export function eip712Consent({ resolved, typedData, chainId, refs = {}, tokens = {}, form = "generic", utcOffsetMinutes = null, rawArguments = false, limits = {} }) {
|
|
317
324
|
if (form !== "generic" && form !== "fields") fail(CODES.REQUEST, `form must be "generic" or "fields", got ${JSON.stringify(form)}`);
|
|
@@ -342,8 +349,9 @@ export function eip712Consent({ resolved, typedData, chainId, refs = {}, tokens
|
|
|
342
349
|
}
|
|
343
350
|
const viewed = { ...message, args: message.args.map((n, i) => abiView(n, `${name}(${i})`)) };
|
|
344
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 });
|
|
345
353
|
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 });
|
|
354
|
+
const done = (rest) => ({ ...rest, primaryType: name, typeHash, digest, verifyingContract: contract, chainId: chain, values, grant, diagnostics: model.diagnostics, provenance: model.provenance, model });
|
|
347
355
|
if (form === "fields") {
|
|
348
356
|
const plain = renderPlain(model, new Renderer({ refs, diagnostics: model.diagnostics }), { rawArguments });
|
|
349
357
|
plain.fields.push(["Contract", where], ["Signature", SIGNATURE_NOTE], ["EIP-712 digest", digest]);
|
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;
|
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;
|
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
|
+
}
|