Profiles
Profile and resolved form
The profile document, its identity, and the resolved form consumers read, field by field.
Two JSON documents carry the profile layer. The profile is what an author, a reviewer or a pack
writes: annotations bound to an interface. The resolved form is what the resolver writes for
consumers: one tree per method, with the effective tags at every position. Both are format version 0. The structure
of the profile is profile/spec/profile-v0.schema.json;
the resolved form is spec §10
and, for EVM contracts, §15.5 and §15.9. Excerpts below are the generated fixtures in
profile/fixtures/, quoted unchanged
apart from indentation.
The profile document#
{
"format": "candid-profile",
"format_version": 0,
"kind": "canister",
"name": "tokens-example",
"origin": "author",
"language": "en",
"description": "Example author profile for examples/tokens.did. Shows type: addresses (one tag on Tokens.e8s reaches every Tokens position), a method-address override, and the idempotency stamp.",
"binds": {
"interface_id": "candid-core:interface:v1:sha256:f8358625dd47a9b6a237a3a268ba83291fcd0dc27093e80ac27cc48a49dc1d6c"
},
"annotations": [
{ "at": "type:Tokens.e8s", "tag": "amount", "args": { "decimals": 8, "symbol": "TOK" } },
{ "at": "type:Tokens.e8s", "tag": "describe", "args": { "label": "Amount" } },
{ "at": "balance->(0).e8s", "tag": "describe", "args": { "label": "Balance" } },
{ "at": "send", "tag": "intent", "args": { "title": "Send TOK", "template": "Send {amount.e8s} to {to}[ with memo {memo}]" } },
{ "at": "send", "tag": "effect", "args": { "kind": "transfer", "irreversible": true } },
{ "at": "send", "tag": "idempotency", "args": { "mode": "replay_same_payload", "stamp": "created_at" } },
{ "at": "send", "tag": "outcome", "args": { "classes": { "Ok": "success", "Err.Duplicate": "already_done", "Err.Insufficient": "fail", "Err.Other": "fail" } } },
{ "at": "send(0).created_at", "tag": "timestamp", "args": { "unit": "ns", "epoch": "unix" } },
{ "at": "send(0).memo", "tag": "bytes", "args": { "encoding": "utf8" } },
{ "at": "send(0).memo", "tag": "default", "args": { "value": "null", "mode": "suggest" } }
]
}Top-level fields. No other field is allowed (additionalProperties: false; anything else is EE_SCHEMA).
| Field | Required | Value |
|---|---|---|
format | yes | "candid-profile" |
format_version | yes | 0 |
kind | yes | "canister", "pack" or "contract" (an EVM contract, experimental) |
name | yes | matches ^[a-z0-9][a-z0-9_.-]{0,63}$ |
origin | yes | "author", "third_party", "pack" or "inferred"; which ones a kind allows is below |
language | no | a language tag matching ^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$; default "en" |
description | no | text |
binds | yes | an object whose shape depends on kind (below) |
annotations | yes | an array of at least one annotation |
Kinds and origins#
kind | Allowed origin | binds | Binds by |
|---|---|---|---|
canister | author, third_party, inferred | canister binds | the interface's interface_id |
pack | pack only | pack binds | structure: Candid subtyping of binds.requires, or an EVM contract's selectors for binds.abi |
contract | author, third_party, inferred | contract binds | the identity of its own binds.abi (spec §15.3). Whether it is rebound or stale is decided by its functions alone (§15.9), so one with only binds.eip712 binds no function and is never rebound. |
origin is also the profile's layer when profiles are merged, highest first: author,
third_party, pack, inferred. An inferred profile must be approved by
a person before it is active. See Profiles, layers and binding.
binds#
Canister binds#
| Field | Required | Value |
|---|---|---|
interface_id | yes | candid-core:interface:v1:sha256: and 64 lowercase hex digits (Content-addressed identities) |
contract_id | no | candid-core:contract:v1:sha256: and 64 lowercase hex digits |
canister_id | no | principal text of the canister (^[a-z0-9]{5}(-[a-z0-9]{5})*(-[a-z0-9]{1,5})?$); for a served profile it must be the serving canister (EE_SERVED) |
Against an interface with another interface_id, every address is resolved again: a profile that still
holds is rebound (WW_REBOUND), one that does not is stale
(EE_STALE), and a stale profile's value-conversion tags (amount, timestamp,
duration, account, subaccount, bytes) are left out of the resolved form.
Pack binds#
| Field | Required | Value |
|---|---|---|
standard | yes | matches ^[A-Z]+-[0-9]+$: "ICRC-1", "ERC-20" |
spec_url | no | an https:// URL |
requires | exactly one of the two | a Candid service fragment (at least 10 characters). The pack applies to a canister whose service type is a subtype of it. |
abi | exactly one of the two | an EVM ABI (as for a contract, below). The pack applies to a contract that has each function with the same selector, outputs and kind of call. |
eip712 | no; only beside abi | the EIP-712 messages of an EVM pack (below), experimental. The pack still matches by its binds.abi, and has no domain (spec §15.9: each token's is its own). |
A pack may not use type: addresses (EE_PACK_TYPE_REF). The standard packs are on Standard packs.
Contract binds#
| Field | Required | Value |
|---|---|---|
abi | at least one of abi and eip712 | an array of at least one entry: a human-readable fragment starting with function, event, error, constructor, fallback or receive, or a JSON ABI entry with one of those as type. Only functions are read. |
eip712 | at least one of abi and eip712 | the EIP-712 messages the contract verifies, with its domain's name and version (below), experimental. |
deployments | no | an array of at least one { "chain_id": integer ≥ 1, "address": "0x" and 40 hex digits }. A mixed-case address that fails its EIP-55 checksum is EE_ADDRESS_CHECKSUM; a deployment listed twice is WW_DUPLICATE. For messages, it gives the domain's chainId and verifyingContract. |
binds.eip712#
Spec §15.9, D-055: EIP-712 typed messages, the first part of the EVM binding's phase 2. See EVM contracts.
"binds": {
"eip712": {
"domain": {
"name": "Dai Stablecoin",
"version": "1"
},
"types": {
"Permit": [
{
"name": "holder",
"type": "address"
},
{
"name": "spender",
"type": "address"
},
{
"name": "nonce",
"type": "uint256"
},
{
"name": "expiry",
"type": "uint256"
},
{
"name": "allowed",
"type": "bool"
}
]
}
},
"deployments": [
{
"chain_id": 1,
"address": "0x6B175474E89094C44Da98b954EedeAC495271d0F"
}
]
},| Field | Required | Value |
|---|---|---|
types | yes | an object of at least one struct, in the JSON shape of an eth_signTypedData request without EIP712Domain: a struct name (^[A-Za-z_][A-Za-z0-9_]*$) to an array of at least one { "name", "type" }. A member type matches ^[A-Za-z_][A-Za-z0-9_]*(\[([1-9][0-9]*)?\])*$: a canonical ABI type as written (uint256, never uint: the type hash hashes the text) or a struct of the same map, either with array suffixes T[] or T[k]. |
domain | no | at least one of name and version, non-empty strings. Each must equal the request's domain value of the same name; a request with a value where the profile has none is refused too (Permit2's domain has no version). chainId and verifyingContract come from binds.deployments. |
A struct name is not an ABI type (uint256, bytes32, tuple, …) and not
EIP712Domain, members have unique names, and no struct contains itself. Anything else is
EE_EIP712 at its place, and a struct that refers to a refused one is refused too. A type is a
message when the same document tags its bare name with intent; its struct is argument 0,
so its members are addressed as Permit(0).expiry (Tags). A type without
an intent is a type only. A message may not share its name with a function, two profiles may not give one type name
different members, and types with no message and no function describe nothing: each is EE_EIP712.
annotations#
| Field | Required | Value |
|---|---|---|
at | yes | an address (spec §3, Addresses); which kind depends on the tag (below) |
tag | yes | one of the 18 tags (Tags) |
args | yes | an object, checked against the tag's args_* definition; subaccount takes {} |
note | no | text |
| Tags | at must be |
|---|---|
idempotency, outcome, list, names | a method address: ^[A-Za-z_][A-Za-z0-9_]*$ |
intent, effect | a method address, or a value address (a case of a variant in the arguments, D-053). On an EVM contract the method address may be an EIP-712 type's name, which intent makes a message (§15.9). |
describe | a value address or a method address |
amount, timestamp, duration, account, subaccount, bytes, text, range, default, ui, named | a value address: METHOD(i)path, METHOD->(i)path or type:DECL path |
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 … 4294967295grant, inside effect#
Since 0.4.0 (spec §5.3, D-056), an effect of kind transfer or approve may carry
grant in its args: who may take tokens because of the call, how many and until when, as
relpaths into the method's arguments. The ICRC-2 pack's:
{
"at": "icrc2_approve",
"tag": "effect",
"args": {
"kind": "approve",
"irreversible": false,
"grant": {
"spender": "spender",
"amount": "amount",
"ends": [
"expires_at"
]
}
}
},Field of grant | Required | Value |
|---|---|---|
spender | exactly one of spender and to | a relpath: who may take the tokens through an allowance, or with a signature someone else submits |
to | exactly one of spender and to | a relpath: who a transfer pays directly |
amount | yes | a relpath to an amount-tagged position, or a bool |
ends | no | an array of 1–8 relpaths to timestamp-tagged positions |
No other field is allowed (additionalProperties: false, the schema's grant definition), and
the schema's oneOf takes exactly one of spender and to; anything else is
EE_SCHEMA. What the positions must be, and EE_GRANT when they are not, is on
Tags. A grant is part of its effect annotation, not an annotation of
its own, so the validator's annotation counts do not include it; the profile's profile_id changes with
it, as with any edit.
profile_id#
A profile's identity is candid-profile:v0:sha256: and the SHA-256 of the RFC 8785 (JCS) canonical JSON of
the whole document, in lowercase hex. Any edit changes it, a change to description included; reordering
keys does not, because JCS sorts them. The validator prints it:
cd profile
node scripts/validate.mjs packs/icrc1.profile.jsonPASS packs/icrc1.profile.json (0 error, 0 warning, 0 info)
profile_id candid-profile:v0:sha256:4eaa9312d60661248ff5627dba9906aa91240abe84cb1b1da8ca20a6fba2e3e6
interface_id candid-core:interface:v1:sha256:7fae0dd330b20f53ae1e4e2cb1947c1c57791da0ae20299ceb499f63228d0689 [5 methods, 29 annotations]@meantjs/profile exports the same computation, and the canonical JSON it hashes:
import { profileId, jcs } from "@meantjs/profile";
import { readFileSync } from "node:fs";
const doc = JSON.parse(readFileSync("packs/icrc1.profile.json", "utf8"));
profileId(doc); // "candid-profile:v0:sha256:4eaa9312…"
jcs({ b: 1, a: [true, "x"] }); // '{"a":[true,"x"],"b":1}'
A profile never changes interface_id or contract_id: it is a separate document bound to
them. The resolved form's profiles list carries each applied profile's profile_id, so a
consumer can say which document wrote the words it shows.
The resolved form#
Consumers do not resolve addresses. They read this form, which candid-profile resolve,
resolveService and validateProfile(…, { resolved: true }) write
(Validate and resolve profiles). Its top level:
{
"format": "candid-profile-resolved",
"format_version": 0,
"interface_id": "candid-core:interface:v1:sha256:7fae0dd330b20f53ae1e4e2cb1947c1c57791da0ae20299ceb499f63228d0689",
"profiles": [
{
"name": "icrc1",
"origin": "pack",
"profile_id": "candid-profile:v0:sha256:4eaa9312d60661248ff5627dba9906aa91240abe84cb1b1da8ca20a6fba2e3e6"
}
],
"methods": {| Field | Value |
|---|---|
format | "candid-profile-resolved" |
format_version | 0 |
chain | "evm" for an EVM contract's form; absent for a Candid service's. A consumer MUST refuse a form whose chain it does not know rather than read it as Candid. |
interface_id | the service's candid-core:interface:v1:sha256:… (from the compiled .did, or the envelope's contract.identities.interface), or an ABI's candid-profile:evm-abi:v0:sha256:… |
profiles | every applied profile, highest layer first: { name, origin, profile_id }, plus deployments when the profile binds them and eip712_domain (the domain's name and version) when it binds a domain |
methods | one entry per method, keyed by name, in sorted order. An EIP-712 message is never here. |
messages | EVM only, experimental: one entry per EIP-712 message, keyed by name, in sorted order (below). Absent when there is none, so a form without messages is unchanged. |
diagnostics | the diagnostics of the resolve (Profile diagnostics); a diagnostic from a multi-profile resolve may carry profile |
Method entries#
| Field | Value |
|---|---|
mode | query, composite_query, update or oneway. An EVM view or pure function is a query, nonpayable and payable an update. |
tags | the method tags. effect is always present: when no profile sets it, the resolver writes the §5.3 default with "origin": "default". Its args carry grant as the profile wrote it, when one is given and holds (below). |
args | one node per argument |
results | one node per result |
selector, signature, state_mutability | EVM only: "0xa9059cbb", "transfer(address to,uint256 value)", "nonpayable" |
"icrc1_decimals": {
"mode": "query",
"tags": {
"effect": {
"args": {
"kind": "read",
"irreversible": false
},
"origin": "default"
}
},
"args": [],
"results": [
{
"candid": "nat8"
}
]
},A grant in the resolved form#
A grant stays inside effect.args, unchanged; the consumer reads it there and the decoded values it names
(@meantjs/consent returns it as grant, see
JavaScript API). From the ICRC-2 pack's fixture:
"effect": {
"args": {
"kind": "approve",
"irreversible": false,
"grant": {
"spender": "spender",
"amount": "amount",
"ends": [
"expires_at"
]
}
},
"origin": "pack",
"profile": "icrc2"
},A pack whose grant does not hold in the service it is applied to keeps its effect in the resolved form and loses the grant (II_PACK_ANNOTATION_SKIPPED).
Node fields#
| Field | Present when | Value |
|---|---|---|
candid | a Candid service's form | a primitive name (nat, nat64, text, principal, …), or record, variant, vec, blob, func, service |
abi | an EVM contract's form | the canonical type of a primitive (uint256, address, bytes32), "tuple" or "array" |
optional | the position passes through an opt | true |
fields | a record, or an ABI tuple | { name → node } |
cases | a variant | { name → node } |
element | a vec, or an ABI array | the element's node |
length | an ABI fixed-size array T[k] | k |
recursive | the walk cut a cycle (or reached the depth bound) here | true |
tags | the position has tags | { tag → { args, origin, profile } } |
Tags and their provenance#
Every tag carries where it came from: origin is the layer of the profile that supplied it, and
profile that profile's name. The one tag with "origin": "default" and no
profile is an effect the resolver supplied. This is the idempotency stamp of
icrc1_transfer, which the tool layer fills and ui.hidden keeps out of forms:
"created_at_time": {
"candid": "nat64",
"optional": true,
"tags": {
"timestamp": {
"args": {
"unit": "ns",
"epoch": "unix"
},
"origin": "pack",
"profile": "icrc1"
},
"describe": {
"args": {
"label": "Created at",
"help": "Set once per intent by the tool layer; enables the ledger's duplicate protection."
},
"origin": "pack",
"profile": "icrc1"
},
"ui": {
"args": {
"hidden": true
},
"origin": "pack",
"profile": "icrc1"
}
}
},Where a node's tags come from:
- Tags from
type:addresses appear at every position reached through that edge. - A method-address tag at the same position wins (§8). In the tokens example,
type:Tokens.e8sgives everye8sthe labelAmount, andbalance->(0).e8soverrides it withBalanceinbalance's result. - A case node's
tagsmay holdintentandeffect(D-053). A consumer that does not read them uses the method's. - A stale profile supplies no value-conversion tag; its other tags stay.
"e8s": {
"candid": "nat64",
"tags": {
"amount": {
"args": {
"decimals": 8,
"symbol": "TOK"
},
"origin": "author",
"profile": "tokens-example"
},
"describe": {
"args": {
"label": "Balance"
},
"origin": "author",
"profile": "tokens-example"
}
}
}Ordering#
- Methods are sorted by name.
- Record fields and variant cases follow canonical id order (the Candid field hash), not source order.
icrc1_transfer's argument liststo,fee,memo,from_subaccount,created_at_time,amount. Useui.orderfor presentation. profilesis highest layer first.
Unresolved dynamic values#
A dynamic value stays as written, { "from": "query:icrc1_decimals" }: resolving it needs the network, so
it is the consumer's job. The packages take the resolved values as refs, keyed by their
from ({ "query:icrc1_decimals": 8 }). An EVM amount whose token names
its token by a field stays as that path too; the consumer reads the token's decimals and symbol and passes them per
token address (tokens in @meantjs/consent/evm, token in
formatterFor). open OQ-10 (owner W1/W3): should the
resolved form carry resolved dynamic values with their provenance, certified or not?
EVM contracts: chain evm#
Spec §15, phase 1 and the first part of phase 2 (EIP-712 messages). See EVM contracts.
The resolved form of an EVM contract has "chain": "evm" at the top, an ABI identity as its
interface_id, and nodes with abi where a Candid form's have candid. Each method
adds selector, signature (the ERC-7730 key form, with names) and
state_mutability. Its parameters are one tuple at argument index 0. From the ERC-20 pack's fixture:
{
"format": "candid-profile-resolved",
"format_version": 0,
"chain": "evm",
"interface_id": "candid-profile:evm-abi:v0:sha256:81471b5efd3e34c464b5dda12ccdd454d8ca6e0d0c069871c8152bc6b154be4b",
"profiles": [
{
"name": "erc20",
"origin": "pack",
"profile_id": "candid-profile:v0:sha256:afc7d231076699406b2fc9f74da372f5da88465027ab1d46eca526e646368a81"
}
],"transfer": {
"mode": "update",
"selector": "0xa9059cbb",
"signature": "transfer(address to,uint256 value)",
"state_mutability": "nonpayable",
"tags": {
"intent": {
"args": {
"title": "Send tokens",
"template": "Send {value} to {to}"
},
"origin": "pack",
"profile": "erc20"
},
"effect": {
"args": {
"kind": "transfer",
"irreversible": true,
"grant": {
"to": "to",
"amount": "value"
}
},
"origin": "pack",
"profile": "erc20"
}
},
"args": [
{
"abi": "tuple",
"fields": {
"to": {
"abi": "address",
"tags": {
"describe": {
"args": {
"label": "Recipient"
},
"origin": "pack",
"profile": "erc20"
}
}
},
"value": {
"abi": "uint256",
"tags": {
"amount": {
"args": {
"decimals": {
"from": "query:decimals"
},
"symbol": {
"from": "query:symbol"
}
},
"origin": "pack",
"profile": "erc20"
},
"describe": {
"args": {
"label": "Amount"
},
"origin": "pack",
"profile": "erc20"
}
}
}
}
}
],
"results": [
{
"abi": "bool"
}
]
},
@meantjs/consent refuses this form at its main entry, and @meantjs/tools refuses it
outright: EVM tools are a later phase.
EIP-712 messages#
A form whose profiles bind EIP-712 messages lists them under a top-level messages map, never under
methods (spec §15.5, §15.9). Each entry has encode_type (EIP-712's
encodeType), type_hash (its Keccak-256), tags (intent and
effect) and args: one tuple node, the struct, with the same node fields as a function's
argument. It has no results, mode or selector. A consumer matches a signing
request to a message by type_hash, never by its name. Without an effect tag, a message's
default is write, irreversible, as for an update. From the EIP-2612 pack's fixture, with three of its
five members left out:
"messages": {
"Permit": {
"encode_type": "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)",
"type_hash": "0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9",
"tags": {
"intent": {
"args": {
"title": "Permit token spending",
"template": "Allow {spender} to spend up to {value} from {owner}. Signature expires: {deadline}."
},
"origin": "pack",
"profile": "erc2612"
},
"effect": {
"args": {
"kind": "approve",
"irreversible": false,
"grant": {
"spender": "spender",
"amount": "value"
}
},
"origin": "pack",
"profile": "erc2612"
}
},
"args": [
{
"abi": "tuple",
"fields": {
…
"value": {
"abi": "uint256",
"tags": {
"amount": {
"args": {
"decimals": {
"from": "query:decimals"
},
"symbol": {
"from": "query:symbol"
},
"threshold": "115792089237316195423570985008687907853269984665640564039457584007913129639935"
},
"origin": "pack",
"profile": "erc2612"
},
…
}
},
…
"deadline": {
"abi": "uint256",
"tags": {
"timestamp": {
"args": {
"unit": "s",
"epoch": "unix",
"threshold": "115792089237316195423570985008687907853269984665640564039457584007913129639935",
"message": "Never"
},
"origin": "pack",
"profile": "erc2612"
},
…
Each entry of profiles that binds a domain lists its name and version as eip712_domain,
beside its deployments. From the Permit2 profile's fixture, whose domain has a name and no version:
"profiles": [
{
"name": "permit2",
"origin": "third_party",
"profile_id": "candid-profile:v0:sha256:d85261a93989a3f9cdaf7b8f3a93a3a508a388c9d8e808696bf867ace920b0c6",
"deployments": [
{
"chain_id": 1,
"address": "0x000000000022D473030F116dDEE9F6B43aC78BA3"
},
{
"chain_id": 42161,
"address": "0x000000000022D473030F116dDEE9F6B43aC78BA3"
}
],
"eip712_domain": {
"name": "Permit2"
}
}
],
"methods": {},
"messages": {
"PermitBatch": {
"encode_type": "PermitBatch(PermitDetails[] details,address spender,uint256 sigDeadline)PermitDetails(address token,uint160 amount,uint48 expiration,uint48 nonce)",
"type_hash": "0xaf1b0d30d2cab0380e68f0689007e3254993c596f2fdd0aaa7f4d04f79440863",
Permit2's profile binds no function, so its methods is empty. A struct a message refers to
(PermitDetails) is part of its encode_type, after the primary type, sorted by name; it is no
message of its own, because nothing tags its name with intent.
The interface identity#
The ABI identity is candid-profile:evm-abi:v0:sha256: and the SHA-256 of the JCS array of the
functions' canonical fragments and, when the form has EIP-712 types, one struct <Name>(<type>
<name>,…) line per type, sorted (spec §15.1, §15.9). With no types it is §15.1's, so the ERC-20 pack's
form keeps its identity. The array hashed for the EIP-2612 pack, five functions and one struct:
[
"function DOMAIN_SEPARATOR() view returns (bytes32)",
"function decimals() view returns (uint8)",
"function nonces(address owner) view returns (uint256)",
"function permit(address owner,address spender,uint256 value,uint256 deadline,uint8 v,bytes32 r,bytes32 s)",
"function symbol() view returns (string)",
"struct Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
]
Its SHA-256 gives the fixture's
interface_idcandid-profile:evm-abi:v0:sha256:73c9721a3b8abd52830805c1ac5f848f1ef8918e9cf681b5d4e134333d0955ee;
the five functions alone give
interface_idcandid-profile:evm-abi:v0:sha256:caf7f6c5bf79a00460009c26ebb9b4169ae67841375e1658fbb3ff456e65f4e5
(both computed with abiInterfaceId of @meantjs/profile/evm, with and without the types). A
pack's messages join the contract it matches, so they are part of the merged form's identity; whether a contract
profile is rebound or stale is decided by its functions alone, so the messages a pack adds never make it stale.