Profiles
Generate consent messages
ICRC-21 consent messages for any method, from a resolved form and the encoded arguments.
@meantjs/consent answers icrc21_consent_message for any method a resolved
form describes. You give it the resolved form, the method name and the call's Candid-encoded
argument bytes; it decodes the bytes against the form, renders the method's intent template with
the value formatters, and returns the ICRC-21 response. It has no network code.
Generate a message#
npm install --save-exact @meantjs/consent@0.5.0import { readFileSync } from "node:fs";
import { decodeConsentResponse, encodeConsentRequest, handleConsentRequest, icrc21ConsentMessage, toJson } from "@meantjs/consent";
// candid-profile resolve ledger.did --packs > ledger.resolved.json
const resolved = JSON.parse(readFileSync("ledger.resolved.json", "utf8"));
const refs = { "query:icrc1_decimals": 8, "query:icrc1_symbol": "TKA" };
// The call the person is asked about, as the signer received it: icrc1_transfer of 1.5 TKA to Alice, memo "lunch".
const argHex =
"4449444c066c06fbca0101c6fcb60204ba89e5c20402a2de94eb060282f3f3910c05d8a38ca80d7d6c02b3b0dac30368ad86ca8305026e036d7b6e7d6e780100011db56bf994b37ae8e79f5ce000be1727a6060ae4eef24736b7cc999c3c02000001056c756e63680001004af3f1afdadc1880a3c347";
const arg = Uint8Array.from(argHex.match(/../g) ?? [], (byte) => parseInt(byte, 16));
const response = icrc21ConsentMessage(
{
method: "icrc1_transfer",
arg,
user_preferences: { metadata: { language: "en", utc_offset_minutes: null }, device_spec: { FieldsDisplay: null } },
},
{ resolved, refs },
);
console.log(JSON.stringify(toJson(response), null, 2));{
"Ok": {
"consent_message": {
"FieldsDisplayMessage": {
"intent": "Send tokens: Send 1.5 TKA to k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae with memo lunch",
"fields": [
["Amount", { "TokenAmount": { "decimals": 8, "amount": "150000000", "symbol": "TKA" } }],
["Recipient", { "Text": { "content": "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae" } }],
["Memo", { "Text": { "content": "lunch" } }]
]
}
},
"metadata": { "language": "en", "utc_offset_minutes": null }
}
}That is the output of the code above, as a signer could show it:
Ask for { GenericDisplay: null }, or leave device_spec out, and the same
call is one Markdown text:
# Send tokens
Send `1.5 TKA` to `k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae` with memo `lunch`
- **Amount:** `1.5 TKA`
- **Recipient:** `k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae`
- **Memo:** `lunch`
The words "Send tokens" and the template come from the ICRC-1 pack, not from the ledger. The result
says so: besides Ok or Err it has two non-enumerable properties,
diagnostics (what was adjusted) and provenance, here
{ intent: { origin: "pack", profile: "icrc1" }, profiles: [ … ] }, so a signer can warn
when a profile, not the canister, wrote the words. Nothing in the message says
From or names a fee the call does not set: the argument bytes do not carry the caller,
and the default fee is ledger state, which a template must not claim (spec §6).
Answer the request as bytes#
const requestBytes = encodeConsentRequest({
method: "icrc1_transfer",
arg,
user_preferences: { metadata: { language: "en", utc_offset_minutes: null }, device_spec: null },
});
const replyBytes = handleConsentRequest(requestBytes, { resolved, refs }); // what an icrc21_consent_message endpoint returns
const reply = decodeConsentResponse(replyBytes);
handleConsentRequest takes the Candid bytes of an
icrc21_consent_message_request and returns the Candid bytes of the response. The codec
for the ICRC-21 types is exported too, and generateConsent({ resolved, method, arg, refs, form, canisterId })
gives one form without the ICRC-21 envelope, with the call's values and what it grants
(below). The context takes languagePolicy
("fallback", the default, or "error"), rawArguments and
decoder limits. The main entry runs in a browser: it imports no Node built-in and uses
no Buffer. Every export is on the JavaScript API
reference.
Values and what a call grants#
import { generateConsent } from "@meantjs/consent";
const { values, grant } = generateConsent({
resolved,
method: "icrc1_transfer",
arg,
refs,
canisterId: "bkyz2-fmaaa-aaaaa-qaaaq-cai", // the ledger called: the token the call grants
});
console.log(JSON.stringify(toJson({ values, grant }), null, 2));{
"values": [
{
"to": {
"owner": "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae",
"subaccount": null
},
"fee": null,
"memo": {
"hex": "6c756e6368"
},
"from_subaccount": null,
"created_at_time": "1791547201000000000",
"amount": "150000000"
}
],
"grant": {
"kind": "transfer",
"to": "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae",
"token": "bkyz2-fmaaa-aaaaa-qaaaq-cai",
"amount": {
"units": "150000000",
"unlimited": false
},
"ends": {
"now": true
}
}
}
Beside message, generateConsent returns values, the decoded
arguments, one value tree per argument as decodeArguments gives them, and
grant: who may take tokens because of the call, which token, how many and until when, read
from the grant of the method's effect (spec §5.3,
D-056). Addresses are EIP-55, accounts their ICRC-1 text, units a decimal string and an end exact
seconds, never rounded; a transfer ends { now: true }, when it is made.
-
The grant is read only when
canisterIdnames the canister called, which is its token. Without it,grantisnulland nothing warns. The ICRC-21 call,icrc21ConsentMessageandhandleConsentRequest, takes nocanisterIdand its message is unchanged. -
A method whose effect names no grant, such as
icrc2_transfer_from, hasgrant: nulland no diagnostic. -
A grant that cannot be read is
nullwith WW_GRANT indiagnostics, and the text is the same. A transfer to an account whose subaccount is 2 bytes long:grant to: a subaccount is 32 bytes, got 2; the request has no grant, so terms the user confirmed cannot cover it.
A wallet compares the grant with terms the person confirmed for one task, with
@meantjs/consent/terms: Confirm terms for a task.
What happens, in order#
-
Decode
The argument bytes are decoded against the method's tree in the resolved form, with
@candid-core/schema0.5.0 (decodeArgs), so the bytes never choose the types. Integers become decimal strings,blobaUint8Array, an absentoptnull, a variant{ tag, value }. Nothing is rounded or truncated. -
Intent
The call's intent is that of the deepest variant case with an
intentthat the arguments hold, else the method's (intent and effects). Its template's placeholders are rendered throughformatterForof@meantjs/format, so values mean exactly what they mean in a tool input (parse and display values). A[ … ]segment is dropped when a placeholder inside is absent. A method without an intent gets a generic one. -
Forms
FieldsDisplayMessage: the intent line and one typed row per placeholder.GenericDisplayMessage: a Markdown heading, the rendered template, and the same fields as a list. Both by the mapping below.
fact The decoder is checked against an independent one in the package's tests:
@dfinity/didc (the Rust candid crates as wasm) accepts and rejects 16 wire variants and
3000 mutated encodings exactly as this decoder does (test/decode-differential.test.mjs),
and every reply is decoded against the standard's own ICRC-21.did, vendored with its
commit and sha256 in test/upstream/icrc21/ beside the standard's example messages.
The renderer reads no ledger state. Dynamic refs come in through refs; a ref you did not
supply stops the message with EE_REF_UNRESOLVED, so a number is never shown without its
decimals.
How a profile maps to ICRC-21#
For FieldsDisplay, from
spec §12:
| Profile | FieldsDisplayMessage |
|---|---|
intent.title and the rendered template | intent |
| each placeholder | one (label, Value) row; the label is describe.label, else the field name |
amount | TokenAmount { decimals; amount; symbol } when it fits nat64, else Text; Text at or above its threshold |
timestamp | TimestampSeconds { amount } after unit conversion; Text when it is shown as words |
duration | DurationSeconds { amount } after unit conversion |
| everything else | Text { content } |
For GenericDisplay the output is the rendered template as Markdown, then the same
fields as a list. The rendering rules (D-028), in short:
- The fields-form
intentis the call's intent title,": "and the rendered template; the title alone when the intent has no template. - Rows follow the order of first appearance in the template, one per distinct path. A placeholder in a dropped
[ ]segment adds no row; an absent placeholder outside a segment renders as(none). - A placeholder whose path crosses a variant case is absent unless the value holds that case. A placeholder over
[]joins its elements with,; more than 256 elements is refused. - Untagged values render as Candid text, except that a principal on its own renders as its textual form.
TokenAmountonly when the amount is non-negative, fitsnat64and the tag has a symbol;TimestampSecondsandDurationSecondsonly when the conversion is exact and fitsnat64. OtherwiseText, with every digit. An amount at or above itsthresholdand a timestamp shown as words areTexttoo, never aTokenAmountorTimestampSecondsof the raw number.- The generic form shows a time as
YYYY-MM-DD HH:MM:SS[.f] UTC+HH:MMin the requested offset, and the response carriesutc_offset_minutesexactly when it showed one. The fields form is UTC seconds and never depends on the zone. An offset outside ±24 h is ignored with a diagnostic. A timestamp shown as words is never zoned. - The generic layout is
# title, the paragraph, then- **label:** valuelines. Values from the call are Markdown code spans; an empty value is*(empty)*; text from the profile is backslash-escaped. - A call without an intent is titled by
describe.label, elseCall <method>, with one row per leaf of the decoded arguments. More than 64 rows is refused, never truncated. - A language the profile does not have is answered in the profile's language, and the metadata says which. An absent
device_specmeansGenericDisplay. - A value a formatter refuses is shown as raw Candid text with a diagnostic. A formatter that cannot be built, from an unresolved ref or a bad tag argument, produces no message at all.
- The decoded raw arguments are appended only on request (
rawArguments: true). - Nothing the resolved form cannot carry is guessed at. A call whose value reaches a recursive position the form cuts is
ConsentMessageUnavailable, as is every call to a method with afunc,serviceoropt opt Tposition. A template that fails at consent time falls back to the generic intent with WW_TEMPLATE_FALLBACK.
Rule 5 matters for 18-decimal tokens. fact TokenAmount.amount is a
nat64 in the ICRC-21 interface, so 25 TKC on an 18-decimal ledger, 25 × 1018
base units, is the row Text {"content":"25 TKC"} in the package's goldens, never a
rounded number. Rule 6, for an approval that expires, with the person at UTC+02:00:
- **Expires at:** `2026-10-09 14:00:00 UTC+02:00`
…
- Expires at: TimestampSeconds {"amount":"1791547200"}
Rule 5 also keeps words as words. A profile's threshold, message and
labels (value tags) say what a value means when it is not a number or
a date, and a typed row would hand the signer the raw number to show instead. The package's test
(test/icrc21.test.mjs) has an amount with 2 decimals, symbol EUR and threshold
1000, and a nat64 timestamp in seconds with threshold 4102444800,
message: "Never" and the label 0 for Now:
| Value | Row |
|---|---|
amount 999 | TokenAmount {"decimals":2,"amount":"999","symbol":"EUR"} |
amount 1000 | Text {"content":"Unlimited EUR"} |
timestamp 1 | TimestampSeconds {"amount":"1"} |
timestamp 0 | Text {"content":"Now"} |
timestamp 4102444800 | Text {"content":"Never"} |
In the generic form at UTC+02:00, the timestamp 0 is still shown as Now, not as a zoned time.
Escaping#
The relying party is not trusted, so every value that comes from the call is shown inert. A memo from the package's goldens, on a 6-decimal ledger:
- **Memo:** ``[Approve now](https://evil.example/drain) **URGENT** <b>x</b> `y` &``-
Generic form. A value is a code span, verbatim, with a fence one backtick longer
than any run inside it, so links, images, HTML, emphasis, entities and headings do not exist inside
it. Control, format and line-separator characters, such as a right-to-left override, are shown as
\u{202e}, never passed through. Text from the profile (the title, labels, template literals) is backslash-escaped, and its line breaks become spaces. The package checks this with the CommonMark reference parser on 38 hostile values. - Fields form. Plain text, as ICRC-21 requires. Only invisible characters are made visible.
- A memo that is not valid UTF-8, holds control characters or starts with
0xis shown as hex:0xfffe0001.
Errors#
| Cause | ICRC-21 result |
|---|---|
An unknown method, argument bytes that do not decode, arguments too large, a malformed request, an unsupported language under languagePolicy: "error" |
UnsupportedCanisterCall; description starts with EE_METHOD_UNKNOWN, EE_DECODE, EE_TOO_LARGE, EE_REQUEST or EE_LANGUAGE |
A value that reaches a position the resolved form cuts, or a method with a func or service position |
ConsentMessageUnavailable (EE_UNSUPPORTED_TYPE) |
| A missing ref, an invalid tag argument, a tag on the wrong type, two value tags, a malformed resolved form (an EVM one among them) | GenericError with error_code 1100 (EE_REF_UNRESOLVED), 1101 (EE_ARG), 1102 (EE_TAG_TYPE), 1103 (EE_TAG_CONFLICT), 1000 (EE_RESOLVED_FORM); 1999 for an internal error. GENERIC_ERROR_CODES also names 1001 (EE_TEMPLATE) and 1002 (EE_TEMPLATE_PATH). |
Called with no refs, the transfer above answers
{"Err":{"GenericError":{"error_code":"1100","description":"E_REF_UNRESOLVED: amount.decimals needs the value of \"query:icrc1_decimals\", …"}}}.
A language other than English is not an error: ICRC-21 prefers a message to an error, so the answer
is English with metadata.language: "en" and II_LANGUAGE_FALLBACK in
diagnostics. The limits are explicit and never truncate: 2 MiB of argument bytes, 64
fields, 256 list elements, 32 KiB of message text.
SNS proposal validators#
import { readFileSync } from "node:fs";
import { snsValidatorRendering, verifySnsRendering } from "@meantjs/consent/sns";
const dapp = JSON.parse(readFileSync("dapp.resolved.json", "utf8")); // the dapp's own author profile, resolved
const payloadHex =
"4449444c046c03fbca0101ba89e5c20402d8a38ca80d7d6c02b3b0dac30368ad86ca8305026e036d7b0100011db56bf994b37ae8e79f5ce000be1727a6060ae4eef24736b7cc999c3c020001096772616e7420233432b4b8f0fe2d";
const payload = Uint8Array.from(payloadHex.match(/../g) ?? [], (byte) => parseInt(byte, 16));
const reply = snsValidatorRendering({ payload, resolved: dapp, method: "pay_grant" });
// { Ok: "### Pay a grant\n\nPay `123.456789 GOV` from the treasury to `k2t6j-…` with memo `grant #42`\n\n…" }
const review = verifySnsRendering({
payloadTextRendering: readFileSync("proposal-rendering.txt", "utf8"), // what governance stored
payload,
resolved: dapp,
targetCanisterId: "emmff-iaaaa-aaaaa-accja-cai",
method: "pay_grant",
});
review.ok; // true; each of review.checks names one check
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 that governance
shows is the text the dapp's validator method returns. @meantjs/consent/sns is that
validator, generated from the profile. It hashes with node:crypto, so it runs on Node
only.
fact From dfinity/ic at 649156c (rs/sns/governance/src/proposal.rs):
on submission governance calls the validator with the payload bytes and decodes the reply as
Result<String, String>, and Err rejects the proposal. It stores
Ok(text) as payload_text_rendering under its own headings, the last of which
is ## Payload:. On adoption it calls the target method with the same bytes.
The text follows the rules candid-profile-sns/v0:
- The generic consent message, with the title at heading level 3 so it nests under
## Payload:. Times in UTC. **Method:**and**Effect:**, the kind and whether it is reversible.- The arguments as Candid text with no formatter applied: base units, nanoseconds, bytes. This view does not depend on the profile's units.
**Rendered from:**: each profile with itsprofile_id, theinterface_id, and the rules.
**Method:** `pay_grant`. **Effect:** transfer, irreversible.
**Arguments (Candid, as sent, no units applied):**
```
(record { to = record { owner = principal "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae"; subaccount = null }; memo = opt blob "\67\72\61\6e\74\20\23\34\32"; amount = 12345678900 })
```
**Rendered from:**
- Profile `sns-dapp-example` (author): `candid-profile:v0:sha256:41d00b240eb065919300ff6a9743d033280953feea2833a2027efcdd503510e1`
- Interface: `candid-core:interface:v1:sha256:b8b02efa62a133bbf24d1c211875df5ff59c36c47cfe6bfa924e419bdb23daf1`
- Rules: `candid-profile-sns/v0`
Anything the generator cannot describe is Err, so governance rejects the proposal: an
unknown method, bytes that do not decode ({ Err: "E_DECODE: …" }), a missing dynamic ref,
or a message over the limits. Values are escaped as in the generic message, so a memo that imitates
## Payload: cannot add a heading. handleSnsValidatorCall(payload, context)
returns the reply Candid-encoded, as variant { Ok : text; Err : text }.
verifySnsRendering is the reviewer's check of a stored rendering, given the payload bytes
the proposer published. It runs five checks: governance's layout, the payload's SHA-256, the target
canister the reviewer names, the target method, and that the validator's text equals what the profile
renders for those bytes. Naming another canister fails target_canister.
get_proposal returns a payload only when it is at most 64 bytes, and a text summary with
its SHA-256 otherwise; given that summary in place of the bytes, the check says so.
The Rust version for canisters#
A validator is a canister method, so the renderer also exists in Rust:
candid-profile-consent, in
profile/packages/consent/rust/.
fact It is not published: its manifest says version = "0.0.0" and
publish = false, and it is not in the npm package either. Build it from the repository.
pub fn generic_consent_message(
resolved: &Value,
method: &str,
arg: &[u8],
refs: &Map<String, Value>,
utc_offset_minutes: Option<i32>,
raw_arguments: bool,
limits: Limits,
) -> Result<GenericMessage> {| Function | Does |
|---|---|
sns_validator_rendering(resolved, method, payload, refs) | Ok(text) or Err(text), as snsValidatorRendering. |
handle_sns_validator_call, encode_validator_result | The Candid-encoded reply a validator method returns. |
render_sns_payload(…, limits) | The same, with the error as a value and the diagnostics. |
generic_consent_message(…) | The ICRC-21 GenericDisplayMessage text, as generateConsent with form: "generic". |
parse_resolved(json) | The resolved form from its JSON text, field order kept. |
It reads the same resolved form and writes the same text, byte for byte, except for the display-only
words. It decodes with the candid crate against types built from the resolved form and ports
the display side of the format library; where JavaScript's strings show through (UTF-16 lengths for the
32 KiB budget, trim) it counts the way JavaScript does. fact
rust/src/format.rs reads an amount's decimals and symbol
and a timestamp's unit and epoch only, and ignores
threshold, message and labels, so for such a value it shows the number
or the date where the JavaScript package shows Unlimited or Never. No golden and no
vector of the differential corpus carries those arguments, and 0.3.0, which added the timestamp words,
changed no file of the Rust version. tests/golden.rs runs the package's
13 SNS goldens and the generic message of its 20 ICRC-21 goldens, and tests/differential.rs
the 233 vectors of test/differential/corpus.json, each answered alike. rust-version is 1.85.
Not ported: the FieldsDisplayMessage form, the ICRC-21 request and response envelope, and
the reviewer's check, which runs off-chain in JavaScript. A Rust canister that serves
icrc21_consent_message builds the envelope around generic_consent_message
itself. fact The package's PocketIC test (observed 2026-10-12, 10 of 10 tests passing)
registers the example validator canister with the real SNS governance canister, and the
payload_text_rendering governance stored equals the golden text byte for byte. That
test is not part of the package's npm test: it downloads pinned binaries first.
Consent text for an EVM call#
import { readFileSync } from "node:fs";
import { evmConsent } from "@meantjs/consent/evm";
const erc20 = JSON.parse(readFileSync("erc20.resolved.json", "utf8")); // "chain": "evm"
const { message } = evmConsent({
resolved: erc20,
calldata:
"0xa9059cbb00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8000000000000000000000000000000000000000000000000000000000016e360",
to: "0x5FbDB2315678afecb367f032d93F642f64180aa3", // a token deployed on a local Anvil node
chainId: 31337,
refs: { "query:decimals": 6, "query:symbol": "USDC" },
});# Send tokens
Send `1.5 USDC` to `0x70997970C51812dc3A010C7d01b50e0d17dc79C8`
- **Amount:** `1.5 USDC`
- **Recipient:** `0x70997970C51812dc3A010C7d01b50e0d17dc79C8`
**Contract:** `0x5FbDB2315678afecb367f032d93F642f64180aa3` on chain 31337
fact EVM support is experimental (spec §15, phase 1 and the first part of phase 2).
ICRC-21 is the Internet Computer's, so an EVM call gets the same model and the same two layouts without
the ICRC-21 envelope: evmConsent in @meantjs/consent/evm decodes the calldata
strictly and ends the text with the contract's address and chain, because a pack applies to any contract
with matching functions. @meantjs/consent/erc7730 writes the contract's ERC-7730
clear-signing file and its ERC-8176 descriptorHash. The main entry refuses an EVM resolved
form with EE_RESOLVED_FORM, and the Rust version does not cover EVM.
-
eip712Consent({ resolved, typedData, chainId, refs, tokens, form, utcOffsetMinutes, rawArguments, limits })is the consent text of an EIP-712 signing request, matched to a message of the profile by its type hash, never its name. A domain for another chain than the wallet's, withoutchainIdorverifyingContract, with a salt, or whose name or version is not its profiles' is refused before anything is shown (EE_DOMAIN), and so is a request no message matches (EE_TYPE_HASH). The text ends with the verifying contract, the EIP-712 digest and "This is a signature, not a transaction: whoever holds it can submit it." -
Both functions also return
values, the decoded arguments or message by name, andgrant, what the call or signature grants, ornull; its token is the contract called, or the verifying contract, unless the amount's tag names a field that holds it. The transfer above grants{ kind: "transfer", to: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", token: "0x5FbDB2315678afecb367f032d93F642f64180aa3", amount: { units: "1500000", unlimited: false }, ends: { now: true } }. A grant that cannot be read isnullwith WW_GRANT. -
tokens, taken by both functions, gives the decimals and symbol of each token an amount'stokennames, keyed by its address. The address is shown beside the symbol:1.5 TKA (0x5FbDB2315678afecb367f032d93F642f64180aa3). A token not listed is EE_REF_UNRESOLVED.
The mapping, the deployment check, typed messages, the standard Permit2, EIP-2612 and DAI files and the export are on EVM contracts.
What a consent message does not prove#
-
fact A profile is a claim, not a proof (spec §11 item 1): an author profile can
describe a drain as "claim reward".
provenancetells the signer which profile wrote the words, andrawArguments: trueappends the decoded arguments, which the spec asks a signer to show next to the intent for transfer, approve, admin and destroy. -
Dynamic refs come from a single replica unless you certify them (spec §11 item 2). Read
decimalsandsymbolthrough an update call, or cross-check them withicrc1_metadata, before you show a transfer. A message generated inside the canister avoids this, because the canister knows its own decimals. - The message describes the call, never the state it lands in: no balance after it, no "From" line. Only the owner confirms; the model never does. The full argument is on the security model page.