The People Chain is a system parachain in Polkadot that provides privacy-preserving proof-of-personhood. It uses Bandersnatch ring VRF cryptography to let individuals prove they are unique persons without revealing which person they are.
The core pallet lives at substrate/frame/people/ and the parachain runtime at cumulus/parachains/runtimes/people/.
The pallet itself does not decide who is a person. That happens externally. The pallet doc says it plainly:
The pallet accepts new persons after they prove their uniqueness elsewhere... While other systems (e.g., wallets) generate the proofs, this pallet handles the storage.
Entry is via force_recognize_personhood (root-only), which takes a Vec<BandersnatchPublicKey>. Some external personhood-proving system (not defined in this pallet) decides you're real and calls this. So the flow is:
- You prove your uniqueness somewhere off-chain (the mechanism is pluggable)
- A privileged origin calls
force_recognize_personhoodwith your Bandersnatch public key - You get assigned a
PersonalId(sequential u64)
You generate your Bandersnatch keypair yourself (e.g. in a wallet). Only the public key goes on-chain.
Important: These are Bandersnatch public keys (32-byte points on the Bandersnatch elliptic curve), NOT Sr25519 keys. Bandersnatch is an Edwards curve over the BLS12-381 scalar field, chosen because it supports ring VRF proofs. Sr25519 doesn't have the algebraic structure needed for efficient ring proofs.
Once recognized, your key sits in an onboarding queue. The pallet's on_idle hook picks keys off the queue in batches and places them into the current ring. Then build_ring bakes them into the ring's cryptographic commitment.
You don't do anything here, it happens automatically. Batching is intentional: if keys were added one-by-one, an observer could correlate "key X was added at block N" with the person who was just recognized.
- Rings are indexed groups of keys (
RingIndex = u32) with a configurable max size CurrentRingIndextracks which ring accepts new members- When a ring fills up, the index increments
- Each ring has a
RingRoot(cryptographic commitment built incrementally viastart_members()->push_members()->finish_members()) - Ring roots include a revision number that increments on rebuilds
Your PersonRecord stores it directly:
pub struct PersonRecord<Member, AccountId> {
pub key: Member,
pub position: RingPosition,
pub account: Option<AccountId>,
}Where RingPosition::Included { ring_index, ring_position, .. } tells you exactly which ring and your position within it. You query People::<T>::get(your_personal_id) and read position.ring_index(). Then fetch the ring's keys via RingKeys::<T>::get(ring_index) to build the ring context client-side for proof generation. All of this is public on-chain state. The privacy comes from the ring VRF proof itself, not from hiding ring membership.
AppendOnly ──► Mutating(u8) ──► KeyMigration ──► AppendOnly
(normal) (suspensions) (key rotations)
Once your key is baked into a ring, you need to link a regular Substrate account so you can transact normally (with nonces, pay fees, etc.). There are two bootstrapping paths:
For set_alias_account:
- Client-side: Using your Bandersnatch secret key and the ring context (the set of all public keys in your ring), you produce a
RingVrfProoffor a givenContext. The proof demonstrates "I am someone in ring N" without revealing who. - Submit: You send an unsigned extrinsic with the
AsPersontransaction extension set toAsPersonalAliasWithProof(proof, ring_index, context). - Validation: The chain calls
T::Crypto::validate(proof, &ring.root, &context, &msg)which verifies the ring VRF proof against the stored ring root. - Result: The origin is transmuted to
Origin::PersonalAlias(rev_ca). The callset_alias_accountlinks your regular AccountId to this contextual alias.
After this, you can use AsPersonalAliasWithAccount(nonce) on future transactions, which is just a signed extrinsic lookup (no proof needed each time).
For set_personal_id_account:
- Client-side: Sign the transaction with your Bandersnatch secret key (a regular signature, not a ring proof).
- Submit: Unsigned extrinsic with
AsPersonalIdentityWithProof(signature, personal_id). - Validation: The chain looks up your key from
People::<T>::get(personal_id)and callsT::Crypto::verify_signature(signature, &msg, &key). - Result: Origin becomes
Origin::PersonalIdentity(id). Your AccountId is linked to your PersonalId.
This path reveals which person you are. It's for cases where you don't need anonymity.
A Context is just [u8; 32], an application identifier (not random bytes):
/// Identifier for a specific application in which we may wish to track individual people.
pub type Context = [u8; 32];The runtime defines which contexts are valid for account linking via the AccountContexts config type. set_alias_account rejects unknown contexts with InvalidContext.
The key property: the same person produces a different alias for each context (because the VRF output depends on the context input). This prevents cross-context tracking. You can prove "I'm a unique person in the voting app" and "I'm a unique person in the airdrop app", but nobody can link those two aliases together.
Who defines them is up to governance / runtime configuration. Think of them like application IDs.
pub struct PersonRecord<Member, AccountId> {
pub key: Member,
pub position: RingPosition,
pub account: Option<AccountId>,
}The account field is the AccountId linked via set_personal_id_account (Path B). Once set, AccountToPersonalId maps AccountId -> PersonalId and you can use AsPersonalIdentityWithAccount(nonce) for regular signed transactions as your identified PersonalId.
It's Option because it's not required. You might only use the anonymous alias path and never link an account to your identity. Or you might not have set it up yet.
This is separate from the alias account (set via set_alias_account), which links to AccountToAlias instead. A person could have both: one account for anonymous actions (alias), another for identified actions (personal id), or neither.
Once your account is linked, you have two modes for ongoing transactions:
| Mode | Extension variant | What it proves | Privacy |
|---|---|---|---|
AsPersonalAliasWithAccount(nonce) |
Signed tx, account lookup | "I'm a person" (alias) | Anonymous within ring |
AsPersonalIdentityWithAccount(nonce) |
Signed tx, account lookup | "I'm person #N" | Identified |
Both use regular signed extrinsics with nonces after the initial setup. No ring VRF proof needed on every transaction, only during the initial account linking.
You (off-chain) Chain
───────────── ─────
Generate Bandersnatch
keypair locally
│
│ "this person is real"
▼
External system ───► force_recognize_personhood(your_pubkey)
│
▼
OnboardingQueue (batched)
│
on_idle hook
│
▼
RingKeys[ring_n] ◄── key added
│
build_ring()
│
▼
RingRoot[ring_n] ◄── commitment updated
You (client-side)
─────────────
Generate ring VRF proof
using secret key +
ring context
│
▼
set_alias_account ──► Validates proof against RingRoot
(unsigned tx) │
▼
AccountToAlias[your_account] = alias
AliasToAccount[alias] = your_account
Now use signed txs
with nonce normally
Identity:
Keys<T>: Bandersnatch public key -> PersonalIdPeople<T>: PersonalId -> PersonRecord (key, ring position, optional account)
Rings:
RingKeys<T>: RingIndex -> BoundedVec of keysRoot<T>: RingIndex -> RingRoot (commitment, revision, intermediate state)RingKeysStatus<T>: RingIndex -> (total, included) countsChunks<T>: Paginated static VRF verifier parameters
Account linking:
AccountToPersonalId<T>: AccountId -> PersonalIdAccountToAlias<T>/AliasToAccount<T>: bidirectional alias-account mapping
Queues:
OnboardingQueue<T>: paginated queue of keys waiting to enter a ringPendingSuspensions<T>: per-ring list of indices to removeKeyMigrationQueue<T>: PersonalId -> new key during rotation
The ring VRF proof is only needed once to bootstrap the account link. After that, the account serves as a proxy and you transact normally. The anonymity guarantee holds because the initial linking proof doesn't reveal which ring member you are.
- Curve: Bandersnatch, an Edwards curve over BLS12-381 scalar field
- Suite:
BandersnatchSha512Ell2(SHA-512 hasher, Elligator2 encoding) - Public key size: 32 bytes
- Ring VRF signature: 784 bytes (32-byte VRF pre-output + 752-byte ring proof)
- Library:
ark_vrf(Arkworks-based) - Host functions:
ed_on_bls12_381_bandersnatch_msmanded_on_bls12_381_bandersnatch_mulfor efficient curve operations in runtime