Profiles
Names
How a person's "Savings" or "Alice" stands in for a value, by one rule every consumer shares.
type Kind = variant { Icp; Bitcoin };
type Account = record { id : nat32; label : text; address : text; kind : Kind };
type Transfer = record { from : nat32; to : text; amount : nat };
service : {
accounts : () -> (variant { Ok : vec Account; Err : text }) query;
propose : (Transfer) -> ();
}{ "at": "accounts", "tag": "names", "args": { "set": "account", "items": "Ok", "name": "label", "value": "id", "show": "address", "kind": "kind" } },
{ "at": "propose(0).from", "tag": "named", "args": { "set": "account" } },
{ "at": "propose(0).to", "tag": "named", "args": { "set": "account", "by": "show" } }
A wallet canister keeps accounts that its owner has labelled: "Main", "Savings", "BTC Savings".
When the owner says "move 1 BTC from Savings", the call needs from = 4, the
account's id. The first annotation says where the canister lists its named things; the other
two say which positions a name can stand in for. With those written down once (D-045, spec §5.3), every
consumer, whether an agent tool, a consent message or a guard, turns "Savings" into a value by
the same rule, instead of each by its own. Both tags are experimental.
A names set#
names sits on a method that lists named things. items is a
results path to the list, and name, value, show and
kind are paths inside one item:
nameis the text a person uses, and must betext(EE_NAMES_FIELD).valueis what the name stands for, here the account'sid.show, when given, is what to display beside the name, typically an address whenvalueis an id.kind, when given, splits the set, so a consumer can ask for "the account named Main that holds Bitcoin".
The method must be a query, a composite query or a read-only
update (EE_NAMES_MODE), must take no arguments, and its items must
be a vec of records (EE_NAMES_SHAPE). Those rules make the canister
vouch for its own names in the same way it vouches for its own decimals: one call, no input, one
answer. A set's name is declared once among all the merged profiles
(EE_NAMES_SET_DUPLICATE).
A named position#
named marks a position whose value a name from a set can stand in for. Its
set must be declared (EE_NAMED_SET), and by says which
field of the entry fills the position: value by default, or show. The
position must have the same Candid type as that field (EE_NAMED_TYPE): above,
from is a nat32 like an account's id, and to
is a text like its address.
named stacks with the position's other tags. A transfer's to stays an
account or an address; a name is tried only when the input is not a value.
The match#
A name matches a text when both normalize to the same string. normalize(t) is, in
order: Unicode NFKC, then the Unicode default lowercase mapping (full, without locale rules), then
every run of White_Space characters made one space, then leading and trailing
spaces removed. Nothing else is removed. Words such as "my" or "the" are the reader's to drop,
not the resolver's: "my Savings" is not "Savings" until the agent or the person drops "my".
These are cases from spec/names-vectors.json,
the shared test vectors:
| Input | Normalized | Why |
|---|---|---|
Savings | savings | Letters lowercased |
␣␣My␣␣␣Savings␣ | my savings | Runs of spaces made one, ends trimmed |
Savings U+00A0 Account | savings account | A no-break space is White_Space |
SAVINGS | savings | Fullwidth letters fold to ASCII (NFKC) |
Straße | straße | Lowercase, not case folding: the sharp s stays |
STRASSE | strasse | So this does not equal the line above |
Sav U+200B ings | sav U+200B ings | A zero-width space is not White_Space: it stays, so this is not "savings" |
Cafe U+0301 | café | A combining accent composes (NFKC) |
The choice of lowercase over case folding is about agreement, not linguistics. The default lowercase mapping is in the standard library of JavaScript, Rust and Python, and they give the same answer, so an agent tool in one language and a guard in another cannot disagree about whether "STRASSE" is "Straße". Under full Unicode case folding the two would be equal; the spec chose the mapping that all three standard libraries apply the same way.
Resolution#
Given the text t a person or an agent typed at a named position, and the set's
entries (and optionally a kind), three rules decide:
-
A value is a value
Input that is a valid value of the position by its own tags and type (an address, a principal, a number) is that value, and is not looked up. For a
textposition, the valid values are the texts itstext.patternmatches. A namedtextposition without a pattern gets a warning, because then every input is looked up first, and only input that matches no name is passed on as text:textwarning W_NAMED_UNPATTERNED [local-wallet] propose(0).to named text without a text.pattern: every input is looked up as a name first, so a name could stand in for a value (spec 5.3) -
Otherwise, exactly one entry, or no answer
The entries whose normalized name equals
normalize(t), of the given kind if one is given, decide: exactly one gives itsvalue(or itsshow, withby: "show"); none is not found; more than one is ambiguous. A consumer never picks one of several: an agent tool asks the person, and a guard reports "not checked". -
Some names never match
An entry whose name is itself a valid value of the position never matches, so a label written as an address cannot redirect a transfer. An entry whose normalized name is empty never matches either.
@meantjs/format implements the rule once, for every consumer, as
normalizeName and resolveName. The entries here are from the shared
vectors:
import { normalizeName, resolveName, principalFromText } from "@meantjs/format";
const entries = [
{ name: "Main", value: 1, kind: "icp" },
{ name: "Main", value: 2, kind: "bitcoin" },
{ name: "Savings", value: 4, kind: "icp" },
{ name: "BTC Savings", value: 5, kind: "bitcoin" },
{ name: "rrkah-fqaaa-aaaaa-aaaaq-cai", value: 6, kind: "icp" },
];
/** A principal position's own values. @param {string} text */
function isPrincipal(text) {
try {
principalFromText(text);
return true;
} catch {
return false;
}
}
normalizeName(" My Savings "); // "my savings"
resolveName(entries, " sAVINGS ", { isValue: isPrincipal }); // { value: 4, entry: { name: "Savings", … } }
resolveName(entries, "Main", { isValue: isPrincipal }); // { error: "ambiguous", entries: [ …two entries… ] }
resolveName(entries, "Main", { isValue: isPrincipal, kind: (k) => k === "bitcoin" }); // { value: 2, entry: … }
resolveName(entries, "rrkah-fqaaa-aaaaa-aaaaq-cai", { isValue: isPrincipal }); // { value: "rrkah-…", direct: true }The last line is rule 1 and rule 3 together: the input is a principal, so it is that principal, and the entry that is named like a principal is never consulted.
Display#
A consumer should show a named position's value with the name of the one entry it belongs to,
as Savings (efadq-…-kjy554y.1); with no such entry, or several, the value alone.
For a call whose effect is transfer, approve, admin or
destroy, the value must be shown whenever a name is: the person confirms what the
call carries, not what someone called it. Names are untrusted text, so they are escaped like
any rendered value (Intent, effects and confirmation), and
a name never stands in for the value on screen.
Once per intent#
A name is resolved when the call is prepared, and the prepared call carries the value. If the owner renames "Savings" after the person confirmed, the next display beside the value changes; the value sent does not. This is the same reason the idempotency stamp is fixed per intent: what the person confirmed is the bytes, not a lookup that runs again later.
Names are untrusted display text#
Whoever may name an account, or join a group with a label, chooses the text. It can look like an address, mimic another entry, or press the reader ("approved by the CEO"). The rules above are written so that a name can never replace a value: a value always wins over a name, an ambiguous name fails rather than picks, and the value is shown beside the name. Names read through a plain query come from one replica, as the decimals of a dynamic reference do. The security model lists this beside the other things a profile cannot vouch for.
names and named on an EVM contract are EE_TAG_TARGET
until a later phase adds the reads they need (EVM contracts).