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.

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

  1. Download an ICRC-1 ledger's interface#

    bash
    curl -LO https://github.com/dfinity/ic/releases/download/ledger-suite-icrc-2026-03-09/ledger.did
    sha256sum ledger.did
    text
    02a42599e3d2036a19dcfa53db04ad5a5271b0bd78ac1a5986b3dd361f33a99c  ledger.did

    fact This is the ledger.did of the DFINITY ledger suite release ledger-suite-icrc-2026-03-09, 16,004 bytes. The repository keeps the same file at profile/packages/resolver/test/fixtures/ledger.did, its hash recorded beside it in SOURCES.json, and its tests resolve it. On macOS, shasum -a 256 ledger.did prints the same line.

  2. Resolve it with the standard packs#

    bash
    npx candid-profile resolve ledger.did --packs > ledger.resolved.json
    text
    candid-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 info

    The summary goes to stderr; the resolved form goes to stdout, into ledger.resolved.json. The first line is the interface's interface_id and its 26 methods. The next two say that the ICRC-1 and ICRC-2 packs both apply to this ledger, each with its profile_id. No errors, no warnings, and the command exits with status 0.

  3. Read one method of the resolved form#

    bash
    node -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 amount field of its argument:

    bash
    node -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"
        }
      }
    }
  4. Parse "1.5 ICP" into the value the call carries#

    Save this as parse-amount.mjs:

    js
    import { 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"));
    bash
    node parse-amount.mjs
    text
    150000000
    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'
      }
    }
  5. See what an agent would see#

    The model sees1.5 ICP
    prepare_icrc1_transfer({
      amount: "1.5 ICP", …
    })
    The call sends150000000
    icrc1_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/tools builds that tool from the same resolved form: one prepare_<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#

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.