Skip to content

Instantly share code, notes, and snippets.

@pgherveou
Last active April 15, 2026 19:15
Show Gist options
  • Select an option

  • Save pgherveou/0f67bb795cbb3a170ac49dd7f1e59d73 to your computer and use it in GitHub Desktop.

Select an option

Save pgherveou/0f67bb795cbb3a170ac49dd7f1e59d73 to your computer and use it in GitHub Desktop.

People Chain: How a Person Gets In and Uses the System

Overview

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


Step 1: Getting In (off-chain / external)

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:

  1. You prove your uniqueness somewhere off-chain (the mechanism is pluggable)
  2. A privileged origin calls force_recognize_personhood with your Bandersnatch public key
  3. 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.


Step 2: Getting Into a Ring (automatic)

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.

Ring Architecture

  • Rings are indexed groups of keys (RingIndex = u32) with a configurable max size
  • CurrentRingIndex tracks which ring accepts new members
  • When a ring fills up, the index increments
  • Each ring has a RingRoot (cryptographic commitment built incrementally via start_members() -> push_members() -> finish_members())
  • Ring roots include a revision number that increments on rebuilds

How You Know Which Ring You're In

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.

Ring State Machine

AppendOnly ──► Mutating(u8) ──► KeyMigration ──► AppendOnly
   (normal)      (suspensions)    (key rotations)

Step 3: Linking an Account (two paths)

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:

Path A: Anonymous alias via ring VRF proof

For set_alias_account:

  1. Client-side: Using your Bandersnatch secret key and the ring context (the set of all public keys in your ring), you produce a RingVrfProof for a given Context. The proof demonstrates "I am someone in ring N" without revealing who.
  2. Submit: You send an unsigned extrinsic with the AsPerson transaction extension set to AsPersonalAliasWithProof(proof, ring_index, context).
  3. Validation: The chain calls T::Crypto::validate(proof, &ring.root, &context, &msg) which verifies the ring VRF proof against the stored ring root.
  4. Result: The origin is transmuted to Origin::PersonalAlias(rev_ca). The call set_alias_account links 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).

Path B: Identified via signature

For set_personal_id_account:

  1. Client-side: Sign the transaction with your Bandersnatch secret key (a regular signature, not a ring proof).
  2. Submit: Unsigned extrinsic with AsPersonalIdentityWithProof(signature, personal_id).
  3. Validation: The chain looks up your key from People::<T>::get(personal_id) and calls T::Crypto::verify_signature(signature, &msg, &key).
  4. 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.


What is a Context?

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.


PersonRecord.account Field

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.


Step 4: Using the System Day-to-Day

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.


Full Flow Diagram

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      

Key Data Stored On-Chain

Identity:

  • Keys<T>: Bandersnatch public key -> PersonalId
  • People<T>: PersonalId -> PersonRecord (key, ring position, optional account)

Rings:

  • RingKeys<T>: RingIndex -> BoundedVec of keys
  • Root<T>: RingIndex -> RingRoot (commitment, revision, intermediate state)
  • RingKeysStatus<T>: RingIndex -> (total, included) counts
  • Chunks<T>: Paginated static VRF verifier parameters

Account linking:

  • AccountToPersonalId<T>: AccountId -> PersonalId
  • AccountToAlias<T> / AliasToAccount<T>: bidirectional alias-account mapping

Queues:

  • OnboardingQueue<T>: paginated queue of keys waiting to enter a ring
  • PendingSuspensions<T>: per-ring list of indices to remove
  • KeyMigrationQueue<T>: PersonalId -> new key during rotation

Key Insight

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.

Bandersnatch Ring VRF

  • 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_msm and ed_on_bls12_381_bandersnatch_mul for efficient curve operations in runtime
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment