Profiles
Quickstart: profiles
Check the ICRC-1 pack against a ledger's interface, read the resolved form, and turn "1.5 ICP" into the exact value a call carries.
You need Node 22 or later, npm and curl. Nothing here calls a canister or sends a
transaction: you work on a ledger's interface file and the JSON the tools write from it.
mkdir profile-quickstart && cd profile-quickstart
npm init -y
npm install --save-exact @meantjs/profile@0.5.0 @meantjs/format@0.5.0
@meantjs/profile checks profiles, matches the standard packs and writes the resolved
form; it also installs the candid-profile command. @meantjs/format
converts values. The five @meantjs packages release together under one version and
pin each other exactly, so install them at the same version.
Five steps from an interface to an exact value#
-
Download an ICRC-1 ledger's interface#
bashcurl -LO https://github.com/dfinity/ic/releases/download/ledger-suite-icrc-2026-03-09/ledger.did sha256sum ledger.didtext02a42599e3d2036a19dcfa53db04ad5a5271b0bd78ac1a5986b3dd361f33a99c ledger.didfact This is the
ledger.didof the DFINITY ledger suite releaseledger-suite-icrc-2026-03-09, 16,004 bytes. The repository keeps the same file atprofile/packages/resolver/test/fixtures/ledger.did, its hash recorded beside it inSOURCES.json, and its tests resolve it. On macOS,shasum -a 256 ledger.didprints the same line. -
Resolve it with the standard packs#
bashnpx candid-profile resolve ledger.did --packs > ledger.resolved.jsontextcandid-profile: ledger.did candid-core:interface:v1:sha256:22eb4787e1a755acb9aabd0ed2121d754098bcd1cedb5d0ed8b5c4effb921846 [26 methods] applied icrc1 pack candid-profile:v0:sha256:4eaa9312d60661248ff5627dba9906aa91240abe84cb1b1da8ca20a6fba2e3e6 applied icrc2 pack candid-profile:v0:sha256:f9d75e8bf5ff2edb0b167988feee494eeb54c386e6f62252c4bd96ac56346567 0 error, 0 warning, 3 infoThe summary goes to stderr; the resolved form goes to stdout, into
ledger.resolved.json. The first line is the interface'sinterface_idand its 26 methods. The next two say that the ICRC-1 and ICRC-2 packs both apply to this ledger, each with itsprofile_id. No errors, no warnings, and the command exits with status 0. -
Read one method of the resolved form#
bashnode -p 'JSON.stringify(require("./ledger.resolved.json").methods.icrc1_transfer.tags, null, 2)'jsonledger.resolved.json{ "intent": { "args": { "title": "Send tokens", "template": "Send {amount} to {to}[ from subaccount {from_subaccount}][ with memo {memo}][ paying fee {fee}]" }, "origin": "pack", "profile": "icrc1" }, "effect": { "args": { "kind": "transfer", "irreversible": true, "grant": { "to": "to", "amount": "amount" } }, "origin": "pack", "profile": "icrc1" }, "idempotency": { "args": { "mode": "replay_same_payload", "stamp": "created_at_time" }, "origin": "pack", "profile": "icrc1" }, "outcome": { "args": { "classes": { "Ok": "success", "Err.Duplicate": "already_done", "Err.TemporarilyUnavailable": "retry_same", "Err.TooOld": "retry_new", … }, "default": "fail" }, … } }Then the
amountfield of its argument:bashnode -p 'JSON.stringify(require("./ledger.resolved.json").methods.icrc1_transfer.args[0].fields.amount, null, 2)'jsonledger.resolved.json{ "candid": "nat", "tags": { "amount": { "args": { "decimals": { "from": "query:icrc1_decimals" }, "symbol": { "from": "query:icrc1_symbol" } }, "origin": "pack", "profile": "icrc1" }, "describe": { "args": { "label": "Amount" }, "origin": "pack", "profile": "icrc1" } } } -
Parse "1.5 ICP" into the value the call carries#
Save this as
parse-amount.mjs:jsimport { readFileSync } from "node:fs"; import { formatterFor } from "@meantjs/format"; const resolved = JSON.parse(readFileSync("ledger.resolved.json", "utf8")); const amount = resolved.methods.icrc1_transfer.args[0].fields.amount; // The pack leaves decimals and symbol to the ledger's own queries; a local ledger answers 8 and "ICP". const refs = { "query:icrc1_decimals": 8, "query:icrc1_symbol": "ICP" }; const formatter = formatterFor(amount, { refs }); if (formatter === null) throw new Error("amount has no value tag"); console.log(formatter.parse("1.5 ICP")); // 150000000 console.log(formatter.display(150000000n)); // 1.5 ICP console.log(formatter.safeParse("1.000000001 ICP"));bashnode parse-amount.mjstext150000000 1.5 ICP { ok: false, error: { code: 'E_EXCESS_PRECISION', message: '"1.000000001 ICP" has 9 fractional digits but this token has 8; amounts are never rounded, so give at most 8' } } -
See what an agent would see#
The model sees1.5 ICPprepare_icrc1_transfer({ amount: "1.5 ICP", … })The call sends150000000icrc1_transfer(record { amount = 150_000_000 : nat; … })The model writes the amount the way a person says it. The tool layer reads it with the same formatter you used in step 4 and sends the base-unit integer.
@meantjs/toolsbuilds that tool from the same resolved form: oneprepare_<method>tool per update method, and a preview the person confirms before anything is sent.
What happened#
The packs matched by subtyping. A pack carries a Candid service fragment in
binds.requires, and it applies to any canister whose service is a subtype of that
fragment. This ledger has 26 methods; the ICRC-1 pack names five of them, and the ledger has all
five with compatible types, so the pack's tags land on the ledger's own types. Run the command
again with --verbose to see the three info diagnostics: two
II_PACK_MATCHED, and one II_UNTAGGED_NUMERIC for the int16
utc_offset_minutes of ICRC-21, which no pack tags.
The resolved form is what consumers read. Every method has a tree of positions,
and each tag says which profile it came from ("origin": "pack",
"profile": "icrc1"). icrc1_transfer is a transfer that is
irreversible, so a consumer asks the person to confirm it, and its
grant says what the call lets someone take: the amount, paid
to the recipient (checked against terms the person
confirmed, when there are any). Its
created_at_time is the idempotency stamp that makes a retry safe, and each result case
has an outcome class: Err.Duplicate is already_done,
Err.TooOld is retry_new, and any case the pack does not name falls
back to fail. Methods no pack describes, such as icrc3_get_blocks,
still appear, with the default effect of their mode.
Dynamic values stay unresolved. The pack does not know this ledger's decimals.
It says where to ask: { "from": "query:icrc1_decimals" }. Resolving that needs the
network, so it is the consumer's job: query the ledger once per session, cache the answer and
pass it as refs. fact A query's reply comes from a single replica and
is signed only by that node (the IC interface specification, cited in spec §5.3), so the spec
says consumers "SHOULD resolve refs used to render transfer/approve
intents through an update call or a certified path"
(§11,
item 2).
Nothing is rounded. 1.000000001 ICP has one digit more than the token
has, and the formatter refuses it with E_EXCESS_PRECISION instead of sending
100000000. The same rule holds when the extra digits are zeros.
Next#
Describe your own canister's methods, bind the profile to its interface and validate it.
Annotate a .did fileWrite the same tags as doc comments beside the Candid they describe.
Validate and resolve profilesThe @meantjs/profile API: layers, packs, envelopes and diagnostics.
Amounts, timestamps, durations, accounts, subaccounts and bytes, exactly.
Generate consent messagesAn ICRC-21 consent message for any method, from the same resolved form.
Give an agent toolsTool definitions in human units and a preview the person confirms.
The concepts behind each step: profiles, layers and binding, addresses, value tags, intent and effects and outcomes and retries. Every flag of the command is on The candid-profile command.