Internet Computer
Candid interfaces, ICRC ledgers, ICRC-21 consent and PocketIC: the chain Meant was built on.
The Internet Computer is the chain the profile spec was written for: everything in Candid Profile v0 outside §15
describes ICP canisters. A canister publishes a Candid interface, the meant compiler turns it into a
Contract graph with an identity, and a profile binds to that identity. The rest of this page is what each step looks
like on ICP, with links to the pages that explain it.
npm install @meantjs/profile@0.5.0
npx candid-profile resolve ledger.did --packs > ledger.resolved.jsonThe interface: a .did file#
- Candid source
The canister's
.didfile, or thecandid:servicemetadata it serves. - The meant compiler
Parses and type-checks it through the official Candid parser and projects it into a validated Contract graph (The interface layer, The Contract graph).
- interface_id
A SHA-256 identity over the callable interface,
candid-core:interface:v1:sha256:…. Renaming types or reordering fields does not move it; changing a method does (Content-addressed identities). - Profile
A canister profile binds to that
interface_id; a pack binds to any service whose type is a subtype of its fragment (Profiles, layers and binding).
@meantjs/profile compiles a .did with @candid-core/cli 0.4.0, the same compiler
built as WebAssembly, or reads a candid-core ContractEnvelope that a generator already wrote
(--envelope); the resolved form is the same either way. On the ICRC ledger's own interface (release
ledger-suite-icrc-2026-03-09, the copy in profile/packages/resolver/test/fixtures/ledger.did),
run from profile/:
candid-profile: packages/resolver/test/fixtures/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
Profiles address positions by the edge that leads to them (a method argument or result, then a path), not by type
node: the compiler interns structurally equal types, so in the ICRC-1 interface one nat node serves
amount, fee and the block index alike (Addresses).
Values on ICP#
| Tag | Candid type | A person writes | The call carries |
|---|---|---|---|
amount | nat and the sized integers | 1.5 ICP | 150000000 with decimals: 8, from query:icrc1_decimals on a ledger |
account | record { owner : principal; subaccount : opt blob } | the ICRC-1 textual encoding, principal-checksum.subaccounthex, or a bare principal for the default subaccount | the record; the checksum is verified on input |
subaccount | blob | #7, 7, or 64 hex characters | 32 bytes, big-endian |
timestamp | nat64 (ledgers use unit: "ns") | an RFC 3339 time, or +PT24H from the canister's clock | nanoseconds since the Unix epoch |
now is always the canister's clock, never the host's: a host clock ahead of the replica produces
CreatedInFuture, one behind it TooOld. The rules are on
Values and exact conversion.
ICRC-1 and ICRC-2 packs#
The ICRC-1 pack applies to any canister whose service is a subtype of its fragment (icrc1_symbol,
icrc1_decimals, icrc1_fee, icrc1_balance_of, icrc1_transfer); the
ICRC-2 pack to icrc2_approve, icrc2_transfer_from and icrc2_allowance. A ledger
that implements both gets both, as above. Result variants may have more cases than the pack's (D-019): the extra
cases carry no annotations and fall back to the pack's default outcome. Every annotation is listed on
Standard packs.
- at
- icrc1_transfer
- tag
idempotency
- args
- mode: replay_same_payload
- stamp: created_at_time
Retries: created_at_time and the dedup window#
fact An ICRC-1 ledger deduplicates a transfer only when created_at_time is set, and only
within TX_WINDOW plus PERMITTED_DRIFT, which the ledger defines; different memos are never
treated as duplicates; a duplicate returns Duplicate { duplicate_of } (spec §5.3, verified against the
ICRC-1 standard on 2026-10-08). That is what the pack's idempotency tag uses: the tool layer stamps
created_at_time once per intent from the ledger's clock, encodes the arguments once, and sends exactly
those bytes on every retry of that intent. For the DFINITY ledger the window is 24 h 2 min:
DEDUP_WINDOW_NS in @meantjs/reconcile is 86,520,000,000,000 ns, for a caller that has
checked its ledger uses it.
icrc1_transfer result | Class | What the caller does |
|---|---|---|
Ok | success | Done; report the block index. |
Err.Duplicate | already_done | An identical call already executed: treat it as success and report duplicate_of. |
Err.TemporarilyUnavailable | retry_same | Retry with the same bytes, a few times with backoff, before the window can close. |
Err.TooOld, Err.CreatedInFuture | retry_new | Re-stamp, show the person the new intent, and retry only after they confirm. After CreatedInFuture the new stamp must not exceed the ledger_time the error reports. |
Err.BadFee, Err.BadBurn | fix_input | Correct the input; the error's fields carry the hint (expected_fee). |
Err.InsufficientFunds, Err.GenericError, any other case | fail | Do not retry. |
A rejection by the replica (a trap, a refused call) means the call did not execute, and the same bytes may be sent
again. An unknown outcome (a timeout, a lost connection) may be retried with the same bytes inside the window. Without
an idempotency tag, an update with an unknown outcome is never retried automatically. The full rules:
Outcomes and retries.
Reconciliation by ICRC-3#
When an earlier attempt had an unknown outcome and a later one returns TooOld, the window has passed and
the first attempt may have executed: the caller must reconcile before anything is sent or
re-stamped. That means reading the ledger's ICRC-3 blocks since the stamp and matching the original on its operation,
stamp, amount, accounts, memo and caller; balances and allowances cannot settle it. If the history cannot settle it,
the caller asks the person.
import { expectedTransaction, matchBlocks } from "@meantjs/reconcile";
const expected = expectedTransaction("icrc1_transfer", arg, callerText, { minting }); // minting: icrc1_minting_account
// Read the blocks yourself (certified, archives followed): every index from `floor` to `logLength - 1`.
const r = matchBlocks(blocks, expected, { logLength, floor, inFlight: true, ledgerTime, windowNs }); // windowNs: the ledger's TX_WINDOW + PERMITTED_DRIFT
r.status; // "found" (r.block) | "absent" (r.closedBy) | "not_yet" (r.openUntil) | "unreadable" (r.reason, r.lookalike?)
matchBlocks never answers absent while a send may still land or a block in the range could
not be read. See Settle an unknown outcome.
ICRC-21 consent#
ICRC-21 is the Internet Computer's consent message standard: a signer asks the canister to describe a call before the
person confirms it. @meantjs/consent answers it for any method from the resolved form and the argument
bytes. For the ICRC-1 pack, a transfer of 0.29 TKA on a local ledger with 8 decimals gives this generic message:
# Send tokens
Send `0.29 TKA` to `k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae`
- **Amount:** `0.29 TKA`
- **Recipient:** `k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae`| Profile | FieldsDisplayMessage |
|---|---|
intent.title and the rendered template | intent |
| each placeholder | one (label, Value) row; the label from describe.label, else the field name |
amount | TokenAmount { decimals; amount; symbol } when it fits nat64, else Text |
timestamp | TimestampSeconds { amount } after unit conversion |
duration | DurationSeconds { amount } after unit conversion |
| everything else | Text { content } |
const response = icrc21ConsentMessage(
{
method: "icrc1_transfer",
arg: argBytes, // Uint8Array: the Candid-encoded argument tuple of the call
user_preferences: { metadata: { language: "en", utc_offset_minutes: 120 }, device_spec: { GenericDisplay: null } },
},
{
resolved, // fixtures/*.resolved.json shape, or the output of `candid-profile resolve`
refs: { "query:icrc1_decimals": 8, "query:icrc1_symbol": "TKA" }, // dynamic refs, resolved by the caller
},
);
A plain query is answered by a single replica, so a malicious one could report the wrong decimals and
every amount shown would be wrong by a power of ten. Resolve the refs used to render a transfer or
approve through an update call or a certified path, or cross-check with icrc1_metadata.
A consent message generated inside the canister avoids this: the canister knows its own decimals.
TokenAmount.amount is a nat64, so an 18-decimal token above about 18.4 whole units falls back
to a Text row. open OQ-12 (owner W4/W6): propose a nat variant upstream, or
always fall back to Text? See Generate consent messages.
Serving a profile in a custom section#
A canister can serve its own profile as the public Wasm custom section icp:public candid:profile, beside
the icp:public candid:service that holds its interface. A consumer reads both paths in one
read_state request and verifies the certificate, so the pair comes from one version of the module.
- fact The content of a custom section
icp:public <name>is at/canister/<canister_id>/metadata/<name>in the state tree (IC interface specification). - fact Anyone may request that path for a public section, also when the canister has no such section, and the reply is a certificate of the subnet's state, unlike a query's reply, which one replica signs (HTTPS interface).
- fact A canister has at most 16 exported custom sections named
icp:…and 1 MiB of exported custom sections, so a served profile is at most 1 MiB (resource limits). - What may be served: a profile of
kind: "canister"andorigin: "author". Anything else is EE_SERVED, and the profile is not applied. - It is certified, not vouched for: the certificate proves the canister serves these bytes, but the words are still the author's claim.
ic-wasm canister.wasm -o canister.wasm metadata candid:profile -f my.profile.json -v public
readServedProfile in @meantjs/profile checks the bytes a consumer read. The walk-through is
Serve a profile from a canister.
Local tests on PocketIC#
Everything runs on a local network: no mainnet, no cycles bought, no keys. The SNS validator test in
profile/packages/consent/pocketic/
registers the example validator canister with the real SNS governance canister on PocketIC. It downloads its
artifacts into .cache/ and refuses any whose sha256 does not match artifacts.json.
| Pinned | Version |
|---|---|
| PocketIC server | 16.0.0 (linux-x64 and darwin-arm64 binaries, each with its sha256) |
@dfinity/pic | 0.25.0 |
@icp-sdk/core | 6.1.0 |
SNS governance wasm and .did | built by DFINITY's CI at dfinity/ic 649156c2bca1315da5699e2125b34be6397c9987 |
npm ci # at the repository root (the test imports this package and @dfinity/didc)
cd packages/consent/pocketic
npm ci # @dfinity/pic 0.25.0, @icp-sdk/core 6.1.0; install scripts off (.npmrc)
npm test # fetch + sha256-check the artifacts, build both example canisters, runfact Observed on 2026-10-12: 10 of 10 tests pass (the package README, "On SNS governance"). open Deployed SNSes run the governance version blessed through SNS-W, which may differ from that commit; the test is re-run on a version bump. The reconciler's tests use ICRC-3 blocks in the shapes recorded from the pinned DFINITY ledger wasm on PocketIC.