Profiles

Names

How a person's "Savings" or "Alice" stands in for a value, by one rule every consumer shares.

candidwallet.did
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) -> ();
}
jsonwallet.profile.json
{ "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:

  • name is the text a person uses, and must be text (EE_NAMES_FIELD).
  • value is what the name stands for, here the account's id.
  • show, when given, is what to display beside the name, typically an address when value is 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:

InputNormalizedWhy
SavingssavingsLetters lowercased
␣␣My␣␣␣Savings␣my savingsRuns of spaces made one, ends trimmed
Savings U+00A0 Accountsavings accountA no-break space is White_Space
SAVINGSsavingsFullwidth letters fold to ASCII (NFKC)
StraßestraßeLowercase, not case folding: the sharp s stays
STRASSEstrasseSo this does not equal the line above
Sav U+200B ingssav U+200B ingsA zero-width space is not White_Space: it stays, so this is not "savings"
Cafe U+0301café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:

  1. 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 text position, the valid values are the texts its text.pattern matches. A named text position 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:

    text
      warning 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)
  2. 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 its value (or its show, with by: "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".

  3. 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:

js
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.

Not on EVM contracts yet

names and named on an EVM contract are EE_TAG_TARGET until a later phase adds the reads they need (EVM contracts).

Next#