@meantjs/consent 0.3.0 → 0.5.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 +31 -0
- package/README.md +60 -7
- package/package.json +7 -3
- package/src/consent.mjs +12 -4
- package/src/erc7730.d.mts +2 -0
- package/src/erc7730.mjs +17 -1
- package/src/errors.mjs +1 -0
- package/src/evm.d.mts +9 -1
- package/src/evm.mjs +38 -3
- package/src/grant.mjs +140 -0
- package/src/index.d.mts +28 -0
- package/src/render.mjs +1 -1
- 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,37 @@
|
|
|
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.5.0 — 2026-10-30
|
|
6
|
+
|
|
7
|
+
- Pins (spec §15.4, D-058): `evmConsent` and `eip712Consent` refuse a call or a signing request whose value at a
|
|
8
|
+
position a profile pins (`fixed`) is another address, before any text (new code `E_FIXED`). x402's Permit2 payment
|
|
9
|
+
needs it: its words hold only when the spender is x402's proxy. A pin whose value is not an address is
|
|
10
|
+
`E_RESOLVED_FORM`.
|
|
11
|
+
- `erc7730Descriptor` writes a pinned position as a field with `visible: { mustMatch: [address] }`, which a wallet
|
|
12
|
+
hides and checks; a template that names one gets no `interpolatedIntent`, and `notes` says why.
|
|
13
|
+
- x402 payments read with the new contract profiles of `@meantjs/profile` (`usdc`, `usdc-base-sepolia`,
|
|
14
|
+
`x402-exact-permit2`), and their grants pay the payee directly (`to`), so terms for one payee pass no payment to
|
|
15
|
+
another.
|
|
16
|
+
- The ERC-7730 file of the `permit2` profile's messages lists its two new deployments (Base and Base Sepolia), so its
|
|
17
|
+
`descriptorHash` changed; a caller that passes the earlier `deployments` gets the earlier file.
|
|
18
|
+
|
|
19
|
+
## 0.4.0 — 2026-10-30
|
|
20
|
+
|
|
21
|
+
- What a request grants (spec §5.3, D-056). `evmConsent`, `eip712Consent` and `generateConsent` also return `values`,
|
|
22
|
+
the decoded arguments or message, and `grant`: who may take tokens because of the request, which token, how many
|
|
23
|
+
and until when, read from the effect's `grant` (`{ kind, spender | to, token, amount: { units, unlimited }, ends }`),
|
|
24
|
+
or null. Addresses are EIP-55, accounts their ICRC-1 text, units a decimal string and ends exact seconds, never
|
|
25
|
+
rounded. A grant that cannot be read is null with a `W_GRANT` diagnostic, and the text is the same.
|
|
26
|
+
`generateConsent` takes `canisterId`, the ledger called, and reads the grant only then, so an ICRC-21 message is
|
|
27
|
+
unchanged.
|
|
28
|
+
- `@meantjs/consent/terms`, new: terms the user confirms for one task, and the check of a request's grant against them
|
|
29
|
+
(spec §5.3, "Confirmed terms"; §11 item 10). `readTerms` reads an agent's terms in the wallet's values (a token by
|
|
30
|
+
its symbol, an app by its name, an amount never rounded, an end as a date, a length, hours or "never") or throws a
|
|
31
|
+
`TermsError` with a message for the agent; `termsText` writes them out exactly for the wallet's own screen;
|
|
32
|
+
`confirmTerms` freezes them with an id; `checkGrant` says whether a grant fits, and why not; `termsGate` keeps the
|
|
33
|
+
order (confirmed before the request is read, fixed after, one request, named by the id the wallet gives it). It runs
|
|
34
|
+
in browsers.
|
|
35
|
+
|
|
5
36
|
## 0.3.0 — 2026-10-29
|
|
6
37
|
|
|
7
38
|
- `@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
|
|
|
@@ -70,9 +70,10 @@ a Candid one.
|
|
|
70
70
|
| Function | Does |
|
|
71
71
|
|---|---|
|
|
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
|
-
| `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)
|
|
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
|
-
| `
|
|
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); an argument a profile pins (`fixed`) that holds another address is `E_FIXED`. `tokens`: the decimals and symbol of each token an amount's `token` field names, by address; the address is shown beside the symbol. |
|
|
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 member a profile pins (`fixed`) that holds another address, such as x402's Permit2 payment with a spender other than x402's proxy (`E_FIXED`); 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. |
|
|
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`. A pinned position (`fixed`) is a field with `visible: { mustMatch: [address] }`, which a wallet hides and checks, and a template that names it has no `interpolatedIntent` (a note says why). 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. |
|
|
78
79
|
|
|
@@ -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`.
|
|
@@ -241,6 +291,9 @@ Untrusted third-party data lives in `test/upstream/icrc21/` (the standard, commi
|
|
|
241
291
|
- A profile is a claim, not a proof (spec section 11 item 1). `provenance` tells the signer which profile wrote the words; `rawArguments: true` appends the decoded raw arguments for transfer, approve, admin and destroy.
|
|
242
292
|
- Dynamic refs come from a single replica unless the caller certifies them (section 11 item 2). Resolve `decimals` and `symbol` through an update call or cross-check with `icrc1_metadata` before showing a transfer. Generation inside the canister avoids this.
|
|
243
293
|
- The package has no I/O. Work is bounded: decoder budgets, field and element limits, message size.
|
|
294
|
+
- A pin (`fixed`) protects only a consumer that reads it (section 11 item 11): x402's Permit2 payment pays its payee
|
|
295
|
+
only through x402's proxy, and a consumer that skips the tag shows its words for any spender. This package reads it
|
|
296
|
+
from 0.5.0; give a resolved form that holds pins to no older consumer.
|
|
244
297
|
|
|
245
298
|
## Not done
|
|
246
299
|
|
|
@@ -248,6 +301,6 @@ Untrusted third-party data lives in `test/upstream/icrc21/` (the standard, commi
|
|
|
248
301
|
- The caller's account (`From`) and a fee line: see `docs/oisy-comparison.md` for why they are absent.
|
|
249
302
|
- Other languages; `device_spec` hints beyond the two forms.
|
|
250
303
|
- Recursive and `func`/`service` positions (need a richer resolved form, or the Contract).
|
|
251
|
-
- EVM: payable functions in the consent text, EIP-712 witness messages and the signer check (spec §15.9 O9, O13),
|
|
304
|
+
- EVM: payable functions in the consent text, EIP-712 witness messages other than x402's and the signer check (spec §15.9 O9, O13),
|
|
252
305
|
ERC-7730 import, checking attestations, and the Rust
|
|
253
306
|
version.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meantjs/consent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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.5.0",
|
|
72
|
+
"@meantjs/profile": "0.5.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/erc7730.d.mts
CHANGED
|
@@ -21,6 +21,8 @@ export interface Erc7730Field {
|
|
|
21
21
|
format: "tokenAmount" | "amount" | "date" | "duration" | "addressName" | "raw";
|
|
22
22
|
/** `tokenPath`: `@.to` (the contract, or a message's verifying contract) or a path to the token's address field. */
|
|
23
23
|
params?: Record<string, unknown>;
|
|
24
|
+
/** A position the profile pins (`fixed`): hidden, and a wallet refuses any other value (EIP-55 text). */
|
|
25
|
+
visible?: { mustMatch: string[] };
|
|
24
26
|
}
|
|
25
27
|
/** The fields of each element of an array whose elements carry their own token (`path: "details.[]"`); paths relative. */
|
|
26
28
|
export interface Erc7730FieldGroup {
|
package/src/erc7730.mjs
CHANGED
|
@@ -109,12 +109,27 @@ function keysOf(method, relpath) {
|
|
|
109
109
|
}
|
|
110
110
|
const startsWith = (keys, prefix) => prefix.length <= keys.length && prefix.every((k, i) => keys[i] === k);
|
|
111
111
|
|
|
112
|
+
/**
|
|
113
|
+
* ERC-7730 `visible` of a position a profile pins (`fixed`, spec section 15.4, D-058): `mustMatch` hides the field and
|
|
114
|
+
* makes a wallet refuse any other value, as `evmConsent` and `eip712Consent` do (E_FIXED). Empty for any other position.
|
|
115
|
+
*/
|
|
116
|
+
function pinOf(node, where) {
|
|
117
|
+
const fixed = tagArgs(node, "fixed");
|
|
118
|
+
if (fixed === undefined) return {};
|
|
119
|
+
try {
|
|
120
|
+
return { visible: { mustMatch: [readAddress(String(fixed.value))] } };
|
|
121
|
+
} catch (e) {
|
|
122
|
+
if (e instanceof AbiError) fail(CODES.RESOLVED_FORM, `${where}: fixed: ${e.message}`);
|
|
123
|
+
throw e;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
112
127
|
/**
|
|
113
128
|
* One ERC-7730 field for a leaf of a function's parameters or a message's members. `at` is where the leaf is:
|
|
114
129
|
* `{ method, abs, base }`, its keys from the root and the keys of the field group it is in (its path is relative to it).
|
|
115
130
|
*/
|
|
116
131
|
function fieldOf(node, path, label, ctx, where, at) {
|
|
117
|
-
const field = { path, label };
|
|
132
|
+
const field = { path, label, ...pinOf(node, where) };
|
|
118
133
|
const amount = tagArgs(node, "amount");
|
|
119
134
|
if (amount?.token !== undefined) {
|
|
120
135
|
// In the token a field of the same value names (spec section 15.9): its path, relative to the group.
|
|
@@ -225,6 +240,7 @@ function interpolated(template, method, ctx, where) {
|
|
|
225
240
|
if (rp.error) return ctx.problem(where, `{${p.path}}: ${rp.error}`);
|
|
226
241
|
if (rp.steps.some((s) => s.kind === "elem")) return leftOut(`{${p.path}} reads every element of an array; an ERC-7730 interpolation shows one value`);
|
|
227
242
|
if (rp.node.candid === "record" || rp.node.candid === "vec") return leftOut(`{${p.path}} is a whole ${rp.node.abi}; an ERC-7730 interpolation shows one formatted field`);
|
|
243
|
+
if (tagArgs(rp.node, "fixed") !== undefined) return leftOut(`{${p.path}} is pinned (fixed), so its field is hidden (mustMatch); an ERC-7730 interpolation names only fields that are always shown`);
|
|
228
244
|
out += `{${rp.steps.map((s) => s.key).join(".")}}`;
|
|
229
245
|
}
|
|
230
246
|
}
|
package/src/errors.mjs
CHANGED
|
@@ -24,6 +24,7 @@ export const CODES = Object.freeze({
|
|
|
24
24
|
DEPLOYMENT: "E_DEPLOYMENT", // an EVM call's target is not a deployment its profile lists (spec section 15)
|
|
25
25
|
DOMAIN: "E_DOMAIN", // an EIP-712 request's domain is for another chain, contract or name than its profile's (spec section 15.9)
|
|
26
26
|
TYPE_HASH: "E_TYPE_HASH", // no message of the resolved form has the request's EIP-712 type hash (spec section 15.9)
|
|
27
|
+
FIXED: "E_FIXED", // a position a profile pins (`fixed`) holds another value: the profile does not describe the request (spec section 15.4)
|
|
27
28
|
// this generator cannot describe the call (ICRC-21 ConsentMessageUnavailable)
|
|
28
29
|
UNSUPPORTED_TYPE: "E_UNSUPPORTED_TYPE", // a position the resolved form cannot type (recursive cut, func, service)
|
|
29
30
|
// the profile or the caller's context is wrong (ICRC-21 GenericError)
|
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";
|
|
@@ -134,6 +135,31 @@ function withTokens(name, viewed, args, tokens) {
|
|
|
134
135
|
return args.map((v, i) => box(viewed.args[i], v, [], []));
|
|
135
136
|
}
|
|
136
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Every position a profile pins (`fixed`, spec section 15.4, D-058) holds its value: the profile describes the call or
|
|
140
|
+
* the message only with it, as x402's Permit2 message holds x402's proxy as its spender. Anything else is refused
|
|
141
|
+
* before any text (E_FIXED), since the words would be wrong for it.
|
|
142
|
+
*/
|
|
143
|
+
function checkFixed(name, viewed, args) {
|
|
144
|
+
const walk = (node, value, where) => {
|
|
145
|
+
if (node.candid === "record") return entriesOf(node.fields).forEach(([k, child]) => walk(child, value[k], `${where}.${k}`));
|
|
146
|
+
if (node.candid === "vec") return value.forEach((e) => walk(node.element, e, `${where}[]`));
|
|
147
|
+
const fixed = tagArgs(node, "fixed");
|
|
148
|
+
if (fixed === undefined) return;
|
|
149
|
+
let want;
|
|
150
|
+
try {
|
|
151
|
+
want = readAddress(String(fixed.value));
|
|
152
|
+
} catch (e) {
|
|
153
|
+
if (e instanceof AbiError) fail(CODES.RESOLVED_FORM, `${where}: fixed: ${e.message}`);
|
|
154
|
+
throw e;
|
|
155
|
+
}
|
|
156
|
+
if (typeof value !== "string" || value.toLowerCase() !== want.toLowerCase()) {
|
|
157
|
+
fail(CODES.FIXED, `${where} is ${String(value)}, but the profile describes ${name} only with ${want} there, so this request gets no text`);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
args.forEach((v, i) => walk(viewed.args[i], v, `${name}(${i})`));
|
|
161
|
+
}
|
|
162
|
+
|
|
137
163
|
/**
|
|
138
164
|
* The consent text of an EVM call.
|
|
139
165
|
*
|
|
@@ -149,7 +175,9 @@ function withTokens(name, viewed, args, tokens) {
|
|
|
149
175
|
* @param {number | null} [input.utcOffsetMinutes] generic form only (default 0, UTC)
|
|
150
176
|
* @param {boolean} [input.rawArguments] append the decoded arguments (spec section 11 item 1)
|
|
151
177
|
* @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 }}
|
|
178
|
+
* @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 }}
|
|
179
|
+
* `values` are the decoded arguments as `decodeCalldata` gives them, and `grant` what the call grants (spec section
|
|
180
|
+
* 5.3, D-056; its token is the contract called unless the amount's tag names a field), or null.
|
|
153
181
|
*/
|
|
154
182
|
export function evmConsent({ resolved, calldata, to, chainId, refs = {}, tokens = {}, form = "generic", utcOffsetMinutes = null, rawArguments = false, limits }) {
|
|
155
183
|
if (form !== "generic" && form !== "fields") fail(CODES.REQUEST, `form must be "generic" or "fields", got ${JSON.stringify(form)}`);
|
|
@@ -166,9 +194,11 @@ export function evmConsent({ resolved, calldata, to, chainId, refs = {}, tokens
|
|
|
166
194
|
const method = methodOf(resolved, name, "evm");
|
|
167
195
|
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
196
|
const viewed = { ...method, args: method.args.map((n, i) => abiView(n, `${name}(${i})`)) };
|
|
197
|
+
if (viewed.args.length) checkFixed(name, viewed, [values]);
|
|
169
198
|
const model = modelOf(resolved, name, viewed, viewed.args.length ? withTokens(name, viewed, [values], tokenTable(tokens)) : []);
|
|
199
|
+
const grant = grantOf({ name, method: viewed, argValues: viewed.args.length ? [values] : [], called: target, diagnostics: model.diagnostics });
|
|
170
200
|
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 });
|
|
201
|
+
const done = (rest) => ({ ...rest, method: name, selector, to: target, chainId: chain, values, grant, diagnostics: model.diagnostics, provenance: model.provenance, model });
|
|
172
202
|
if (form === "fields") {
|
|
173
203
|
const plain = renderPlain(model, new Renderer({ refs, diagnostics: model.diagnostics }), { rawArguments });
|
|
174
204
|
plain.fields.push(["Contract", where]);
|
|
@@ -312,6 +342,9 @@ function checkDomain(resolved, domain) {
|
|
|
312
342
|
* @param {number | null} [input.utcOffsetMinutes] generic form only (default 0, UTC)
|
|
313
343
|
* @param {boolean} [input.rawArguments] append the message's values (spec section 11 item 1)
|
|
314
344
|
* @param {{ maxBytes?: number, maxElements?: number }} [input.limits]
|
|
345
|
+
* @returns the text, `primaryType`, `typeHash`, `digest`, `verifyingContract`, `chainId`, `values` (the message as read
|
|
346
|
+
* from the typed data, by member name), `grant` (what the signature grants, spec section 5.3, D-056; its token is
|
|
347
|
+
* the verifying contract unless the amount's tag names a field), or null, `diagnostics`, `provenance` and `model`
|
|
315
348
|
*/
|
|
316
349
|
export function eip712Consent({ resolved, typedData, chainId, refs = {}, tokens = {}, form = "generic", utcOffsetMinutes = null, rawArguments = false, limits = {} }) {
|
|
317
350
|
if (form !== "generic" && form !== "fields") fail(CODES.REQUEST, `form must be "generic" or "fields", got ${JSON.stringify(form)}`);
|
|
@@ -341,9 +374,11 @@ export function eip712Consent({ resolved, typedData, chainId, refs = {}, tokens
|
|
|
341
374
|
throw e;
|
|
342
375
|
}
|
|
343
376
|
const viewed = { ...message, args: message.args.map((n, i) => abiView(n, `${name}(${i})`)) };
|
|
377
|
+
checkFixed(name, viewed, [values]);
|
|
344
378
|
const model = modelOf(resolved, name, viewed, withTokens(name, viewed, [values], tokenTable(tokens)));
|
|
379
|
+
const grant = grantOf({ name, method: viewed, argValues: [values], called: contract, diagnostics: model.diagnostics });
|
|
345
380
|
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 });
|
|
381
|
+
const done = (rest) => ({ ...rest, primaryType: name, typeHash, digest, verifyingContract: contract, chainId: chain, values, grant, diagnostics: model.diagnostics, provenance: model.provenance, model });
|
|
347
382
|
if (form === "fields") {
|
|
348
383
|
const plain = renderPlain(model, new Renderer({ refs, diagnostics: model.diagnostics }), { rawArguments });
|
|
349
384
|
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;
|
|
@@ -209,6 +236,7 @@ export const CODES: Readonly<{
|
|
|
209
236
|
DEPLOYMENT: "E_DEPLOYMENT";
|
|
210
237
|
DOMAIN: "E_DOMAIN";
|
|
211
238
|
TYPE_HASH: "E_TYPE_HASH";
|
|
239
|
+
FIXED: "E_FIXED";
|
|
212
240
|
UNSUPPORTED_TYPE: "E_UNSUPPORTED_TYPE";
|
|
213
241
|
RESOLVED_FORM: "E_RESOLVED_FORM";
|
|
214
242
|
TEMPLATE: "E_TEMPLATE";
|
package/src/render.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// FieldsDisplayMessage (plain text, no formatting; times are UTC seconds, so the message does not depend on the
|
|
4
4
|
// user's time zone):
|
|
5
|
-
// intent = title + ": " + the rendered template (
|
|
5
|
+
// intent = title + ": " + the rendered template (the title alone when the template renders empty or fell back)
|
|
6
6
|
// fields = one (label, Value) row per placeholder; label = describe.label, else the field name
|
|
7
7
|
//
|
|
8
8
|
// GenericDisplayMessage (Markdown, the layout of the ICRC-21 examples: a `#` title, one paragraph, then
|