Skip to content

Latest commit

 

History

History
332 lines (246 loc) · 23.5 KB

File metadata and controls

332 lines (246 loc) · 23.5 KB

Lesson 6 — Identity, utility, multisig, proxy: accounts as institutions

Commit: c39096fTurn bare keys into institutions: identity, utility, multisig, proxy

Reading time: ~13 min.

Where we are. After lesson 5 we have a chain that produces blocks, finalizes them, has economic security, can govern itself, and operates a treasury. But the actors on the chain are still raw cryptographic keys — anonymous, single-purpose, indivisible. A multi-person team has no way to act as a single account. A treasury beneficiary has no way to publish "this is me, this is my website". A hot wallet has no way to delegate "vote on referenda but don't move my balance" to a low-trust key. This lesson fixes those gaps. Spec version 104 → 105.

Goal

The chain gains four pallets that collectively turn raw account-ids into institutional actors:

  • pallet-identity — humans, projects, registrars. Attach a name, website, twitter handle, etc. to an account; let trusted registrars vouch for the data.
  • pallet-utility — atomic batching and deterministic sub-accounts. Run multiple calls in one transaction, or run one call as a derived account.
  • pallet-multisig — M-of-N approval flow. A group of N signatories controls one account; M of them must approve any action.
  • pallet-proxy — delegated keys with scoped permissions. Authorize a separate key to act on your behalf for a specific subset of calls.

Why bundle them in one commit? Each is independent, but their combined surface is what enables real organizational structure on-chain: a treasury beneficiary is a multisig, with an identity attached, voting via a Governance-only proxy from a cold key, batching its votes via utility. Splitting them across four commits would technically work but obscures the shared design space.

The runtime grows from 18 to 22 pallets (indices 18–21).

Background

What each pallet does, at a useful level of detail

pallet-identity stores a structured IdentityInfo per account (display name, email, web, twitter, additional fields) plus an optional set of sub-accounts. Setting identity costs a refundable deposit. Registrars — accounts whitelisted by RegistrarOrigin — can issue judgements (KnownGood, Reasonable, OutOfDate, LowQuality, Erroneous) that other parties can use as a heuristic. Recent versions also add a username layer: a UsernameAuthorityOrigin can grant a username authority the right to assign on-chain usernames under a configured suffix.

The deposit pattern reflects the storage cost: BasicDeposit (one-time per account) plus ByteDeposit * stored_bytes plus SubAccountDeposit * num_subs. All deposits unbond on identity removal.

pallet-utility is the smallest of the four — pure dispatch glue. Three primitives matter:

  • batch(calls) — dispatch a sequence of calls. If one fails, the remaining are skipped and a BatchInterrupted event records the failure index.
  • batch_all(calls) — same as batch but atomic. If any fails, the entire batch reverts.
  • as_derivative(index, call) — dispatch a call from a derived account: a deterministic function of (caller, index). This is how you produce sub-accounts without storing them: as_derivative(0, …) is your sub-account 0, as_derivative(1, …) is sub-account 1, no on-chain bookkeeping needed.

Importantly, when batch is invoked through a filtered origin (e.g. via pallet-proxy), the filter applies recursively to each inner call. A Governance-proxy batch containing a Balances::transfer will fail on the transfer — the proxy filter sees each inner call as it's dispatched.

pallet-multisig turns an N-tuple of accounts plus a threshold M into a deterministic multisig account. The address is a hash of (threshold, sorted_signatories) — same group, same threshold ⇒ same account, computable off-chain. The flow:

  1. One signatory calls as_multi(threshold, others, maybe_timepoint, call_hash, ...) proposing the call.
  2. Other signatories call approve_as_multi(...) referencing the same call hash.
  3. When the M-th approval arrives along with the actual call, it dispatches.
  4. The proposer's deposit (DepositBase + DepositFactor * threshold) is refunded.

Multisig accounts can hold balances, set identities, run extrinsics — they look like any other account to the rest of the runtime.

pallet-proxy lets account A authorize account B to dispatch some subset of A's extrinsics. Each proxy registration is (delegator, delegate, ProxyType, delay). The ProxyType is a runtime-defined enum implementing InstanceFilter<RuntimeCall> — for each call, the impl says yes/no. Proxies can be added/removed by the delegator; the deposit (ProxyDepositBase + ProxyDepositFactor * num_proxies) scales with how many proxies you've registered.

Proxies optionally have a delay: an announced call must wait delay blocks before execution, giving the delegator a window to cancel via Proxy::reject_announcement. Delay 0 = immediate dispatch.

The "accounts as institutions" framing

The four pallets together let one logical entity operate the chain with appropriate machinery:

Pallet Capability
Identity "I am the Substrate Tutorial Foundation, here's my website"
Multisig "Our four directors must jointly approve treasury spends"
Proxy "Our governance hot key can vote, but can't move funds"
Utility "Approve and pay out this referendum in one transaction"

None is required by the others — you can wire just identity, or just proxy. But the combination is what real-world organizations need: a multisig main account, a proxy with governance permissions for everyday voting, an identity attached so people know who you are, and batched calls for atomicity.

InstanceFilter and the recursive-filter property

The ProxyType enum implements frame_support::traits::InstanceFilter<RuntimeCall>:

impl InstanceFilter<RuntimeCall> for ProxyType {
    fn filter(&self, c: &RuntimeCall) -> bool { /* yes/no per variant */ }
    fn is_superset(&self, o: &Self) -> bool { /* partial order */ }
}

filter(c) answers "may this proxy type dispatch this call?". is_superset(other) answers "may a proxy of type self add a sub-proxy of type other?" — i.e. defines a partial order on permission scopes. The canonical order is Any > NonTransfer > {everything else}, meaning an Any proxy can add any sub-proxy, a NonTransfer proxy can add any non-transfer-flavour sub-proxy, but specific-purpose proxies (Governance, Staking, IdentityJudgement) can only add sub-proxies of their own exact type.

The recursive-filter property — that filter is re-applied to inner calls when a proxy dispatches a Utility::batch — is what makes narrow ProxyType variants safe. Without it, Governance proxy could trivially escalate to full access by batching a Balances::transfer inside a permitted Referenda::submit.

The path we chose

All four pallets in one commit. Each is small and self-contained; their value is in the combination. Splitting them would force four "what does this enable?" sections without ever showing the joint design.

Identity uses legacy IdentityInfo<MaxAdditionalFields> plus the username layer. Legacy in pallet_identity::legacy::IdentityInfo is what Westend/Polkadot still ship with — it's the established schema (display, web, email, twitter, etc.). The newer username system is additive: a UsernameAuthorityOrigin = EnsureRoot can grant authorities the right to assign usernames under a configured suffix. We accept both.

Identity Slashed → Treasury. When a registrar issues Erroneous/LowQuality judgement and the deposit is slashed, route to treasury via the legacy Currency API (type Slashed = Treasury;) — same pattern as pallet_referenda::Slash. Consistency with lesson 5.

Six ProxyType variants. Any, NonTransfer, Governance, Staking, IdentityJudgement, CancelProxy. This mirrors Westend's relay set (the runtime we model on) minus the variants tied to pallets we don't have (Auction, NominationPools, ParaRegistration, SudoBalances). Adding more later is a match arm and a new enum variant — ~5 lines per type.

Deposits match Polkadot's ratios exactly. A const fn deposit(items, bytes) -> Balance returns items * 20 UNIT + bytes * 1 mUNIT. Polkadot's 2000 CENTS * items + 100 MILLICENTS * bytes collapses to the same per-item / per-byte amounts in UNIT-denominated balances. Heavy deposits discourage spam — 20 UNIT per multisig or proxy registration is intentional friction.

MaxSignatories 100, MaxProxies 32, MaxPending 32. Polkadot defaults. Generous enough that no realistic use case bumps the limit; conservative enough to bound storage.

Code walkthrough

runtime/src/lib.rs — four new pallets

#[runtime::pallet_index(18)] pub type Identity = pallet_identity;
#[runtime::pallet_index(19)] pub type Utility = pallet_utility;
#[runtime::pallet_index(20)] pub type Multisig = pallet_multisig;
#[runtime::pallet_index(21)] pub type Proxy = pallet_proxy;

And spec_version: 104 → 105.

runtime/src/configs.rs — the deposit helper

const fn deposit(items: u32, bytes: u32) -> Balance {
    items as Balance * 20 * UNIT + bytes as Balance * MILLI_UNIT
}

Used by multisig and proxy directly; identity uses a mix of deposit(0, n) for byte-based fields and explicit constants (10 * UNIT, 2 * UNIT) for the base / sub-account fees.

Identity config

parameter_types! {
    pub const BasicDeposit: Balance = 10 * UNIT;
    pub const ByteDeposit: Balance = deposit(0, 1);
    pub const UsernameDeposit: Balance = deposit(0, 32);
    pub const SubAccountDeposit: Balance = 2 * UNIT;
    pub const MaxSubAccounts: u32 = 100;
    pub const MaxAdditionalFields: u32 = 100;
    pub const MaxRegistrars: u32 = 20;
}

impl pallet_identity::Config for Runtime {
    type Currency = Balances;
    type Slashed = Treasury;
    type BasicDeposit = BasicDeposit;
    type ByteDeposit = ByteDeposit;
    type UsernameDeposit = UsernameDeposit;
    type SubAccountDeposit = SubAccountDeposit;
    type MaxSubAccounts = MaxSubAccounts;
    type IdentityInformation = IdentityInfo<MaxAdditionalFields>;
    type MaxRegistrars = MaxRegistrars;
    type ForceOrigin = EitherOf<EnsureRoot<AccountId>, GeneralAdmin>;
    type RegistrarOrigin = EitherOf<EnsureRoot<AccountId>, GeneralAdmin>;
    type OffchainSignature = Signature;
    type SigningPublicKey = <Signature as Verify>::Signer;
    type UsernameAuthorityOrigin = EnsureRoot<AccountId>;
    type PendingUsernameExpiration = ConstU32<{ 7 * DAYS }>;
    type UsernameGracePeriod = ConstU32<{ 30 * DAYS }>;
    type MaxSuffixLength = ConstU32<7>;
    type MaxUsernameLength = ConstU32<32>;
    // …
}

Notable bits:

  • Slashed = Treasury — legacy Currency API, same pattern as referenda's Slash. Forfeited identity deposits accumulate in the treasury.
  • ForceOrigin = RegistrarOrigin = EitherOf<EnsureRoot, GeneralAdmin> — Root or a GeneralAdmin-track referendum can add/remove registrars and force-set identities. No registrar slots are auto-allocated; governance must add_registrar explicitly.
  • UsernameAuthorityOrigin = EnsureRoot — only Root referenda can grant a username authority. This is intentionally stricter than registrar admin because a username authority can effectively create accounts (by assigning usernames).
  • OffchainSignature = Signature and SigningPublicKey = <Signature as Verify>::Signer — used for username off-chain attestations. The authority signs (username, target_account) off-chain; the runtime verifies the signature before applying.

Utility config

impl pallet_utility::Config for Runtime {
    type RuntimeEvent = RuntimeEvent;
    type RuntimeCall = RuntimeCall;
    type PalletsOrigin = OriginCaller;
    type WeightInfo = pallet_utility::weights::SubstrateWeight<Runtime>;
}

Nothing to choose. PalletsOrigin = OriginCaller is required for as_derivative — the derived call needs a runtime-aggregate origin type.

Multisig config

parameter_types! {
    pub const MultisigDepositBase: Balance = deposit(1, 88);
    pub const MultisigDepositFactor: Balance = deposit(0, 32);
    pub const MaxSignatories: u32 = 100;
}

impl pallet_multisig::Config for Runtime {
    type DepositBase = MultisigDepositBase;
    type DepositFactor = MultisigDepositFactor;
    type MaxSignatories = MaxSignatories;
    // …
}

DepositBase = deposit(1, 88) ≈ 20.088 UNIT — one storage item (32-byte key, 56-byte value). DepositFactor = deposit(0, 32) ≈ 32 mUNIT. A 5-of-9 multisig pays DepositBase + 5 * DepositFactor ≈ 20.248 UNIT to propose a call — the factor scales with the threshold M (here 5), not the signatory count N — refunded on completion.

Proxy config and ProxyType

parameter_types! {
    pub const ProxyDepositBase: Balance = deposit(1, 8);
    pub const ProxyDepositFactor: Balance = deposit(0, 33);
    pub const MaxProxies: u32 = 32;
    pub const AnnouncementDepositBase: Balance = deposit(1, 8);
    pub const AnnouncementDepositFactor: Balance = deposit(0, 66);
    pub const MaxPending: u32 = 32;
}

#[derive(/* ... */)]
pub enum ProxyType {
    Any,
    NonTransfer,
    Governance,
    Staking,
    IdentityJudgement,
    CancelProxy,
}

impl InstanceFilter<RuntimeCall> for ProxyType {
    fn filter(&self, c: &RuntimeCall) -> bool {
        match self {
            ProxyType::Any => true,
            ProxyType::NonTransfer => matches!(c,
                RuntimeCall::System(..) | RuntimeCall::Babe(..) | RuntimeCall::Timestamp(..)
                | RuntimeCall::Grandpa(..) | RuntimeCall::Session(..) | RuntimeCall::Staking(..)
                | RuntimeCall::ImOnline(..) | RuntimeCall::Scheduler(..) | RuntimeCall::Preimage(..)
                | RuntimeCall::ConvictionVoting(..) | RuntimeCall::Referenda(..)
                | RuntimeCall::Treasury(..) | RuntimeCall::Identity(..) | RuntimeCall::Utility(..)
                | RuntimeCall::Multisig(..) | RuntimeCall::Proxy(..)
            ),
            ProxyType::Governance => matches!(c,
                RuntimeCall::ConvictionVoting(..) | RuntimeCall::Referenda(..)
                | RuntimeCall::Treasury(..) | RuntimeCall::Utility(..)
            ),
            ProxyType::Staking => matches!(c,
                RuntimeCall::Staking(..) | RuntimeCall::Session(..) | RuntimeCall::Utility(..)
            ),
            ProxyType::IdentityJudgement => matches!(c,
                RuntimeCall::Identity(pallet_identity::Call::provide_judgement { .. })
                | RuntimeCall::Utility(..)
            ),
            ProxyType::CancelProxy => matches!(c,
                RuntimeCall::Proxy(pallet_proxy::Call::reject_announcement { .. })
            ),
        }
    }
    fn is_superset(&self, o: &Self) -> bool {
        match (self, o) {
            (x, y) if x == y => true,
            (ProxyType::Any, _) => true,
            (_, ProxyType::Any) => false,
            (ProxyType::NonTransfer, _) => true,
            _ => false,
        }
    }
}

Three notes on the filter implementation:

  • NonTransfer is a positive whitelist, not a negative blacklist. "Anything but Balances" via !matches!(c, RuntimeCall::Balances(..)) would be subtly broken — every pallet added in a future commit would be silently allowed in NonTransfer. The whitelist makes the permission explicit; new pallets default to deny until the runtime author opts them in.
  • Governance includes Treasury. Now that treasury exists, governance proxies should be able to call Treasury::spend and Treasury::payout. Without that arm, a governance proxy couldn't enact a treasury payment even though the referendum that approved it was passed by the governance proxy.
  • IdentityJudgement allows only one Identity call. provide_judgement is the registrar's only normal duty; everything else (add_registrar, kill_identity) is gated on RegistrarOrigin / ForceOrigin separately and isn't a proxy concern.

is_superset orders the types: Any > NonTransfer > {Governance, Staking, IdentityJudgement, CancelProxy}. The four leaf types have no children — a Governance proxy cannot add a Staking sub-proxy (or vice versa), since neither is a superset of the other.

Other paths we could have taken

More proxy types

Westend's relay set has additional types we skipped: Auction, NominationPools, ParaRegistration, SudoBalances. We don't have those pallets — adding the types would be dead code. If/when we add nomination pools, NominationPools joins the list (~5 lines).

A useful extra for our chain shape would be Treasury as a distinct proxy — limited to treasury-spending calls only, no governance. We folded it into Governance because the two are tightly coupled here, but a chain that wants to separate "vote on referenda" from "execute approved spends" would want them split.

Different identity schema

IdentityInformation = IdentityInfo<MaxAdditionalFields> is the legacy schema. The new pluggable identity (post-username-system) lets a runtime define its own IdentityInformation type with arbitrary fields. Useful when:

  • Your chain has domain-specific identity attributes ("this account is a validator of region X").
  • You want to drop fields you don't need (no email, no twitter).
  • You want strongly-typed structured fields instead of Data (the legacy free-form blob type).

Cost: 50–200 lines defining the type plus migration tooling. Not worth it for a generic chain.

pallet-recovery for social recovery

A pallet we did not add. pallet-recovery lets an account designate "friends" and a threshold; if the account loses its key, the friends can collectively recover access. Used as part of the wallet-recovery story on Polkadot for years. We skipped it because the per-account state machine is non-trivial and the use case is narrow — for a tutorial chain, multisig + proxy cover most "key compromise" scenarios.

pallet-vesting

A pallet we did not add. pallet-vesting adds time-locked balance transfers — token unlocks linearly between block A and block B. Useful for IDOs, vested grants, employee compensation schedules. Adds about 50 lines of config plus a MAX_VESTING_SCHEDULES constant. Skip until you have a vesting use case.

pallet-indices

A pallet we did not add. pallet-indices lets accounts claim short numeric aliases (e.g. account 1234 instead of a 32-byte address). It predates pallet-identity's username feature. With identity-usernames available, indices are largely redundant unless your UX specifically wants numeric aliases. Polkadot has both; we don't need either separately.

Different deposit scale

We mirror Polkadot's ratios. Alternatives:

  • Higher deposits (e.g. 200 UNIT per multisig). Stronger spam deterrent; harder for new chains with limited token distribution.
  • Lower deposits (e.g. 0.1 UNIT per proxy). Cheaper to experiment; less protection against state bloat.
  • Zero deposits. Free. Only sensible for closed/private chains with controlled account sets.

Trade-offs and caveats

  • Polkadot.js Apps hides the "Set on-chain identity" menu for unknown chains. The per-account ⋯ menu has an extra enableIdentity flag (in apps-config) gating identity. For chains not in their hardcoded list, the flag is false and the menu is hidden — even though api.tx.identity.setIdentity exists in metadata. Workaround: Developer → Extrinsics → identity.setIdentity dispatches it directly. The proxy menu has no such gate. Reason apps does this: most modern Polkadot ecosystem chains delegate identity to a "People" parachain, so apps assumes unknown chains do too.
  • Proxy filters compose with utility batches. A proxy of type Governance running Utility::batch([Referenda::vote, Balances::transfer]) will fail on the inner transfer — the proxy filter applies recursively. This is the intended behavior and is what makes narrow proxy types safe. Subtle gotcha: if you write a new proxy type, make sure its filter actually rejects the transfer leg, otherwise you've quietly opened a hole.
  • Multisig deposits aren't escrow-style. The proposer pays the deposit and gets it back when the multisig completes. Other signatories don't pay. If the multisig is never completed (M-1 of M approve, last signer disappears), the deposit is stuck until someone cancels via cancel_as_multi — which only the proposer can do.
  • Proxy delays don't help against compromised delegators. The delay window is for the delegator to cancel via Proxy::reject_announcement. If the delegator's key is the one that was compromised (not the proxy's), the attacker is the one announcing the call, and they can simply not cancel. Useful in narrow scenarios (proxy's key compromised, delegator's still safe); not a general defense.
  • No on-chain registrar configured at genesis. Registrars must be added by governance (Identity::add_registrar via Root or GeneralAdmin referendum). Until at least one registrar exists, no provide_judgement calls succeed, and the IdentityJudgement proxy type is dead code. First-mover task for a freshly-launched chain.
  • MaxProxies = 32 per account. If you exceed this, add_proxy fails with TooMany. Bumpable, but each proxy slot adds to your deposit, so the practical limit is lower than the configured one for most accounts.
  • No pallet-recovery, no pallet-vesting. See "Other paths" above. Not a defect, but worth noting if a follow-up commit needs them.
  • Spec version 104 → 105. New pallets in the metadata; existing dev DBs must be purged.

What's next

Lesson 7 switches the chain to an H160 account model; beyond the tutorial, reasonable follow-ups from this commit are:

  1. Recovery + vesting if your chain has key-loss or grant-unlock use cases.
  2. A Treasury proxy type separate from Governance, if your operational model wants to split voting from spending.
  3. Custom IdentityInformation if your chain has domain-specific identity attributes that don't fit the legacy schema.
  4. The first registrar. Submit a GeneralAdmin-track referendum to Identity::add_registrar — this is the first piece of operational governance the chain will need.
  5. Custom pallets. The chain has the institutional infrastructure now; the next interesting commits are about what the chain actually does.

See 99-where-to-next.md for the broader roadmap.

References


Next up: Lesson 7 — H160 account model.