Profiles

Addresses

How a profile names one position in an interface: a method argument or result, then a path.

candidprofile/packs/icrc1.profile.json
type Account = record { owner : principal; subaccount : opt blob };
type TransferArgs = record {
  from_subaccount : opt blob;
  to : Account;
  amount : nat;
  fee : opt nat;
  memo : opt blob;
  created_at_time : opt nat64;
};
// type TransferError = variant { … };
service : {
  // …
  icrc1_transfer : (TransferArgs) -> (variant { Ok : nat; Err : TransferError });
}
Profile annotation
at
icrc1_transfer(0).to
tag
account
args
  • encoding: icrc1

The ICRC-1 pack's annotation above says that the to field of the first argument of icrc1_transfer is an ICRC-1 account. It never mentions TransferArgs or Account: an address starts at a method, picks an argument or a result by its index, and walks a path of field and case names from there. Why a profile names that walk, the edge to a value, rather than the type node it ends on, is on Profiles, layers and binding.

The grammar#

textprofile/docs/profile-v0.md
address      = value-address / method-address
method-address = METHOD                                   ; the method itself
value-address  = METHOD "(" INDEX ")" path                ; argument INDEX
               / METHOD "->(" INDEX ")" path              ; result INDEX
               / "type:" DECL path                         ; canister profiles only
path         = *( "." label / "[]" )
label        = IDENT / "#" U32                             ; field/case name, or raw Candid id
relpath      = [ "(" INDEX ")" ] [ [ "." ] label / "[]" ] *( "." label / "[]" )   ; non-empty
METHOD, DECL, IDENT = [A-Za-z_][A-Za-z0-9_]*
U32          = decimal integer, 0 … 4294967295

There are three shapes. A bare method name, icrc1_transfer, is the address of the method itself, where the method tags (intent, effect, idempotency, outcome, list, names) sit. A method with (i) or ->(i) and a path is a position in its argument or result tuple. A type: address starts from a declaration instead of a method, and only a canister profile may use one. relpath is the short form used inside tag arguments; it has its own section below.

Method names must be identifier-shaped in v0. Candid allows quoted method names such as "foo-bar"; how to address them is open question OQ-3.

Spellings are compared by what they resolve to#

icrc1_transfer(0).amount and icrc1_transfer(0).#3573748184 are the same position, because 3573748184 is the Candid id of the label amount. The spec requires every implementation to compare addresses by their resolved form (method, side, index and id path, or the type edge for a type: address) and never as strings, so the two spellings in two profiles are the same tag at the same position, and can override or conflict with each other.

js
import { parseAddress, methodKey } from "@meantjs/profile/address";

const byName = parseAddress("icrc1_transfer(0).amount");
const byId = parseAddress("icrc1_transfer(0).#3573748184");

if (byName?.kind === "arg" && byId?.kind === "arg") {
  // Both print "m:icrc1_transfer|arg|0|3573748184".
  console.log(methodKey(byName.method, "arg", byName.index, byName.segs));
  console.log(methodKey(byId.method, "arg", byId.index, byId.segs));
}

@meantjs/profile/address has no dependency, so a browser bundle can parse and compare addresses without the resolver.

Labels hash to Candid ids#

A label resolves to idl_hash(label), the same 32-bit id the Contract stores. The Contract graph stores a field's id, not its name (The Contract graph), so a name finds its field by being hashed. #n writes the id directly. It is the only way to name a field of a tuple, whose fields are numbered rather than named, and a field whose Candid label is a number: get_blocks->(0).blocks[].#0 is field 0 of each element of blocks.

opt, vectors and variant cases#

  • opt is transparent. A path passes through any opt wrappers without a step of its own. A tag on fee : opt nat applies to the nat, and the resolved form marks the position "optional": true, so a consumer handles absence before it formats anything.
  • [] steps into a vector's element. blob is vec nat8, but it is a leaf: a tag addresses the blob itself (icrc1_transfer(0).memo), never memo[].
  • Variant cases are selected like fields. icrc1_transfer->(0).Err.BadFee.expected_fee walks result 0, its Err case, the BadFee case inside that, and the expected_fee field of its record.

Every address resolves to one position#

Each address must resolve to exactly one position of the interface, or the profile has an error. A string that does not parse is EE_ADDR_SYNTAX; one that parses and names nothing is EE_ADDR_UNRESOLVED, with the reason:

text
  error   E_ADDR_UNRESOLVED          [alias-demo] icrc1_fee->(1)  result index 1 out of range (1)

A matched pack is the one exception, and only because subtyping makes it one: a pack's address that names a position the canister lacks (an optional field it leaves out, a result case it does not have) is skipped with II_PACK_ANNOTATION_SKIPPED, not reported against the pack. A canister profile that stops resolving after its interface changes is stale.

type: addresses#

A type: address starts from a declaration of the .did file: type:Tokens.e8s is the e8s field of the record declared as Tokens. It saves writing one annotation per method, and it is what doc comments on declarations and their fields become (Annotate a .did file). Three rules keep it from tagging more than its author meant.

Packs never use them#

Declaration names are not standardized: one ledger calls its argument TransferArgs, another TransferArg, and a pack must match both. A type: address in a pack is EE_PACK_TYPE_REF.

They reach every place the edge is reached#

A type: address tags an edge of the type graph, and the tag appears at every position that edge leads to. In examples/tokens.profile.json, type:Tokens.e8s carries amount with decimals: 8, and the resolved form has it at send(0).amount.e8s, balance->(0).e8s and send->(0).Err.Insufficient.balance.e8s.

Because the interface layer interns structurally equal types into one node, the edge is also reached through any other declaration of the same structure. Add type Fee = record { e8s : nat64 } beside Tokens and every fee becomes an amount of TOK too. The validator warns, and a method address is the way to narrow the tag:

text
  warning W_STRUCTURAL_ALIAS         [tokens-example] type:Tokens.e8s  declaration "Tokens" is structurally identical to "Fee"; the tag reaches every one of them

A pathless one is for records and variants only#

type:Account, with no path, is the declaration's whole type, so it reaches every position of that type. That is allowed only when the type is a record or a variant. The ICRC-1 .did declares type Tokens = nat and type Subaccount = blob; a pathless address on either would tag every nat or every blob of the interface, block indexes and memos included:

text
  error   E_TYPE_NODE                [alias-demo] type:Tokens  pathless type: address on "Tokens" (nat) would tag every nat in the interface; use a field path or a method address

Relative paths inside tag arguments#

Some tag arguments name positions of their own: the placeholders of an intent template, the stamp of idempotency, the keys of outcome, the paths of list and names. They use relpath, which is relative to the method the tag sits on.

  • Intent placeholders, idempotency.stamp and list.cursor resolve against the method's argument tuple. The ICRC-1 pack's stamp is created_at_time, and its template says Send {amount} to {to}.
  • outcome keys, list.next and list.items resolve against the result tuple: Err.Duplicate is the Duplicate case of the Err case of result 0.
  • When the tuple has one element, paths are relative to element 0, so Ok, .Ok and (0).Ok are one path. When it has more than one, the path must start with (i).

Examples#

AddressWhat it names
icrc1_transferThe method, for method tags
icrc1_transfer(0).amountThe amount field of the first argument
icrc1_transfer(0).toThe whole Account record, for the account tag
icrc1_balance_of->(0)The nat result
icrc1_transfer->(0).Err.InsufficientFunds.balanceA field inside an error case
propose(0).TransferThe Transfer case of propose's argument, for a case intent or effect (Intent, effects and confirmation)
get_blocks->(0).blocks[].#0Field id 0 of each vector element
type:Tokens.e8sDeclaration-based; canister profiles only

Values inside tag arguments#

An address says where a tag sits. Its arguments then hold values, and those have rules of their own (spec §4).

Literals, and numbers as strings#

Arguments are JSON strings, booleans and integers. An integer that can exceed 253 (an amount, a bound) must be a decimal string, such as "100000000". A JSON number above 253 is read by JavaScript as the nearest double, which is not always the number written; a string is read exactly, and the format library never passes an integer through a JavaScript number.

Dynamic values#

Some arguments are not known when the profile is written. The ICRC-1 pack cannot say how many decimals a ledger has, so it asks the ledger:

Profile annotation
at
icrc1_transfer(0).amount
tag
amount
args
  • decimals: query:icrc1_decimals
  • symbol: query:icrc1_symbol

In the document each is an object, { "from": "query:icrc1_decimals" }. A query:<method> must name a query or composite query of the same canister that takes no arguments and returns one value of the kind the argument needs. The resolver checks each part: EE_REF_UNRESOLVED (no such method), EE_REF_NOT_QUERY, EE_REF_SHAPE (arguments, or not one result) and EE_REF_TYPE (a text where a number is needed). The two other dynamic values, now and caller, are valid only where a tag allows them, which in v0 is default.value; anywhere else they are EE_DYNAMIC_KIND.

The resolved form leaves dynamic values unresolved, because resolving them needs the network. Consumers resolve them once per canister per session, by querying or from a trusted configuration of the same canister, and cache them. A plain query is answered by one replica, which is why the security model asks for a certified path for values that render a transfer.

now is the canister's clock#

now means the canister's time, never the host's. A consumer takes it from the canister's environment, such as the time in a recent certificate or its transport's now(). The reason is ICRC-1's deduplication: a stamp from a host clock that runs ahead of the replica can be refused as CreatedInFuture, and one from a clock that runs behind as TooOld (Outcomes and retries).

Candid literals#

default.value holds Candid textual syntax, such as "null" or "opt 0". The ICRC-1 pack defaults fee and memo to "null". A null default on a position that is not optional is EE_DEFAULT_NULL; whether any other literal type-checks against its position is not checked by the v0 validator, so a consumer must check it before using the default.

On an EVM contract#

The grammar is the same on every chain. What differs is the interface an address walks, and so how its first steps read.

A Candid method takes a tuple of arguments, often one record. The ICRC-1 transfer has one argument, so its amount is icrc1_transfer(0).amount, and a balance is the first result, icrc1_balance_of->(0). A canister profile may also start from a declaration with type:.

Experimental (spec §15). The resolver reads an ABI as the graph a .did compiles to: a function is a method, and its parameters are one record at argument 0, so function transfer(address to, uint256 value) has the positions transfer(0).to and transfer(0).value. A function without parameters has no argument. Outputs are the result tuple: balanceOf->(0). A tuple is a record whose named components hash like Candid labels and whose unnamed ones are #i; an array is a vector, []. Overloaded function names are refused (EE_ABI), because an address names a function by its name alone. See EVM contracts.

Next#