Profiles
Addresses
How a profile names one position in an interface: a method argument or result, then a path.
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 });
}- 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#
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.
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#
-
optis transparent. A path passes through anyoptwrappers without a step of its own. A tag onfee : opt natapplies to thenat, and the resolved form marks the position"optional": true, so a consumer handles absence before it formats anything. -
[]steps into a vector's element.blobisvec nat8, but it is a leaf: a tag addresses the blob itself (icrc1_transfer(0).memo), nevermemo[]. -
Variant cases are selected like fields.
icrc1_transfer->(0).Err.BadFee.expected_feewalks result 0, itsErrcase, theBadFeecase inside that, and theexpected_feefield 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:
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:
warning W_STRUCTURAL_ALIAS [tokens-example] type:Tokens.e8s declaration "Tokens" is structurally identical to "Fee"; the tag reaches every one of themA 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:
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 addressRelative 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.stampandlist.cursorresolve against the method's argument tuple. The ICRC-1 pack's stamp iscreated_at_time, and its template saysSend {amount} to {to}. -
outcomekeys,list.nextandlist.itemsresolve against the result tuple:Err.Duplicateis theDuplicatecase of theErrcase of result 0. -
When the tuple has one element, paths are relative to element 0, so
Ok,.Okand(0).Okare one path. When it has more than one, the path must start with(i).
Examples#
| Address | What it names |
|---|---|
icrc1_transfer | The method, for method tags |
icrc1_transfer(0).amount | The amount field of the first argument |
icrc1_transfer(0).to | The whole Account record, for the account tag |
icrc1_balance_of->(0) | The nat result |
icrc1_transfer->(0).Err.InsufficientFunds.balance | A field inside an error case |
propose(0).Transfer | The Transfer case of propose's argument, for a case intent or effect (Intent, effects and confirmation) |
get_blocks->(0).blocks[].#0 | Field id 0 of each vector element |
type:Tokens.e8s | Declaration-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:
- 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#
What the value tags at these positions mean, and why nothing is rounded.
Guide Write a profileDescribe a canister's methods once, with addresses like these, bound to its interface.
Reference Profile diagnosticsEvery code the resolver returns, including each address and reference error above.