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#

bash
npm install --save-exact @meantjs/consent@0.5.0
js
import { 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));
json
{
  "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:

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#

js
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#

js
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));
json
{
  "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 canisterId names the canister called, which is its token. Without it, grant is null and nothing warns. The ICRC-21 call, icrc21ConsentMessage and handleConsentRequest, takes no canisterId and its message is unchanged.
  • A method whose effect names no grant, such as icrc2_transfer_from, has grant: null and no diagnostic.
  • A grant that cannot be read is null with WW_GRANT in diagnostics, 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#

  1. Decode

    The argument bytes are decoded against the method's tree in the resolved form, with @candid-core/schema 0.5.0 (decodeArgs), so the bytes never choose the types. Integers become decimal strings, blob a Uint8Array, an absent opt null, a variant { tag, value }. Nothing is rounded or truncated.

  2. Intent

    The call's intent is that of the deepest variant case with an intent that the arguments hold, else the method's (intent and effects). Its template's placeholders are rendered through formatterFor of @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.

  3. 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:

ProfileFieldsDisplayMessage
intent.title and the rendered templateintent
each placeholderone (label, Value) row; the label is describe.label, else the field name
amountTokenAmount { decimals; amount; symbol } when it fits nat64, else Text; Text at or above its threshold
timestampTimestampSeconds { amount } after unit conversion; Text when it is shown as words
durationDurationSeconds { amount } after unit conversion
everything elseText { 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:

  1. The fields-form intent is the call's intent title, ": " and the rendered template; the title alone when the intent has no template.
  2. 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).
  3. 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.
  4. Untagged values render as Candid text, except that a principal on its own renders as its textual form.
  5. TokenAmount only when the amount is non-negative, fits nat64 and the tag has a symbol; TimestampSeconds and DurationSeconds only when the conversion is exact and fits nat64. Otherwise Text, with every digit. An amount at or above its threshold and a timestamp shown as words are Text too, never a TokenAmount or TimestampSeconds of the raw number.
  6. The generic form shows a time as YYYY-MM-DD HH:MM:SS[.f] UTC+HH:MM in the requested offset, and the response carries utc_offset_minutes exactly 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.
  7. The generic layout is # title, the paragraph, then - **label:** value lines. Values from the call are Markdown code spans; an empty value is *(empty)*; text from the profile is backslash-escaped.
  8. A call without an intent is titled by describe.label, else Call <method>, with one row per leaf of the decoded arguments. More than 64 rows is refused, never truncated.
  9. A language the profile does not have is answered in the profile's language, and the metadata says which. An absent device_spec means GenericDisplay.
  10. 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.
  11. The decoded raw arguments are appended only on request (rawArguments: true).
  12. 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 a func, service or opt opt T position. 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:

textprofile/packages/consent/test/golden/REVIEW.md
- **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:

ValueRow
amount 999TokenAmount {"decimals":2,"amount":"999","symbol":"EUR"}
amount 1000Text {"content":"Unlimited EUR"}
timestamp 1TimestampSeconds {"amount":"1"}
timestamp 0Text {"content":"Now"}
timestamp 4102444800Text {"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:

textprofile/packages/consent/test/golden/REVIEW.md
- **Memo:** ``[Approve now](https://evil.example/drain) **URGENT** <b>x</b> `y` &amp;``
  • 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 0x is shown as hex: 0xfffe0001.

Errors#

CauseICRC-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#

js
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:

  1. The generic consent message, with the title at heading level 3 so it nests under ## Payload:. Times in UTC.
  2. **Method:** and **Effect:**, the kind and whether it is reversible.
  3. The arguments as Candid text with no formatter applied: base units, nanoseconds, bytes. This view does not depend on the profile's units.
  4. **Rendered from:**: each profile with its profile_id, the interface_id, and the rules.
textprofile/packages/consent/test/sns-golden/REVIEW.md
**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.

rustprofile/packages/consent/rust/src/lib.rs
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> {
FunctionDoes
sns_validator_rendering(resolved, method, payload, refs)Ok(text) or Err(text), as snsValidatorRendering.
handle_sns_validator_call, encode_validator_resultThe 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.

js
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" },
});
text
# 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, without chainId or verifyingContract, 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, and grant, what the call or signature grants, or null; 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 is null with WW_GRANT.
  • tokens, taken by both functions, gives the decimals and symbol of each token an amount's token names, 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.

  • fact A profile is a claim, not a proof (spec §11 item 1): an author profile can describe a drain as "claim reward". provenance tells the signer which profile wrote the words, and rawArguments: true appends 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 decimals and symbol through an update call, or cross-check them with icrc1_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.