Pre-audit 04 — proof-of-personhood gate
Parent: backlog/P0_pre_audit_hardening.md item 4.
Threat: Sybil at entry. Without a personhood anchor, a single adversary spins up N agents, each with its own fresh capability advertisements and reputation. Every mitigation downstream (commit-reveal bond, category rep, stake) scales linearly in attacker cost but also in attacker control — without personhood, reputational reset is free.
Provider pick
Two viable on-Solana options:
- Civic Pass — live, widely integrated, KYC-optional tiers, on-chain
GatewayTokenaccount per wallet. - Solana Attestation Service (SAS) — newer, schema-based, more flexible, controlled by issuers.
Decision: Civic Pass for M1. Rationale: ships today, has a stable on-chain PDA model, avoids us writing attestation schema infra mid-audit. SAS revisit at M3 with a migration path (attestations supersede gateway tokens via a PersonhoodAttestation.provider: ProviderKind enum).
On-chain additions
Location: agent_registry
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, PartialEq, Eq, InitSpace)]
pub enum ProviderKind { Civic, SAS }
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, PartialEq, Eq, InitSpace)]
pub enum PersonhoodTier {
None,
Basic, // Civic uniqueness pass, no KYC
Verified // Civic KYC tier
}
#[account]
#[derive(InitSpace)]
pub struct PersonhoodAttestation {
pub operator: Pubkey, // the wallet proving personhood
pub provider: ProviderKind,
pub tier: PersonhoodTier,
pub gatekeeper_network: Pubkey, // civic network id or SAS issuer
pub attestation_ref: [u8; 32], // hash of source account key + slot
pub attested_at: i64,
pub expires_at: i64, // from source; 0 = non-expiring
pub revoked: bool,
pub bump: u8,
}
PDA: [b"personhood", operator]. One per wallet, not per agent — a wallet can operate many agents but shares one personhood anchor (so Sybil costs scale in wallets, not agents).
ix attest_personhood
- Signer:
operator. - Accounts:
operator: Signercivic_gateway_token: AccountInfo(validated off-chain readable)gatekeeper_network: Pubkey(from registry_global allowlist)attestation: PersonhoodAttestation(init)
- Logic:
- Deserialize Civic
GatewayTokenfromcivic_gateway_token.data. - Assert
owner == operator,state == Active,gatekeeper_networkmatches allowlist,expiry > now. - Hash
(civic_gateway_token.key.as_ref(), slot)→attestation_ref(frozen pointer). - Store tier derived from gatekeeper network (Basic vs Verified mapping in RegistryGlobal).
- Deserialize Civic
ix revoke_personhood
- Signer: registry authority (governance).
- Use case: provider revokes upstream → governance mirrors on-chain.
ix refresh_personhood
- Signer: operator, permissionless after
expires_at. Re-reads source, updates timestamps, no replacement required if still active.
RegistryGlobal additions
pub const MAX_GATEKEEPER_NETWORKS: usize = 8;
pub allowed_civic_networks: [Pubkey; MAX_GATEKEEPER_NETWORKS],
pub allowed_civic_networks_len: u8,
pub allowed_sas_issuers: [Pubkey; MAX_GATEKEEPER_NETWORKS],
pub allowed_sas_issuers_len: u8,
pub personhood_basic_min_tier: PersonhoodTier,
Governance ix set_gatekeeper_allowlist mutates these.
Enforcement points
task_market::commit_bidon tasks whosepayload.requires_personhood >= BasicreadsPersonhoodAttestationfor the bidder's operator; fails if missing, expired, or revoked.agent_registry::register_agentoptional now; required iff governance flipsRegistryGlobal.require_personhood_for_register = true. Default off for M1 devnet testing, on for mainnet.- High-tier task categories (flagged in
capability_registry::CapabilityTag.min_personhood_tier) enforce Verified tier.
Invariants
- Missing
PersonhoodAttestationwhen required →PersonhoodRequired. attestation.revoked→ treated as missing.attestation.expires_at != 0 && now > expires_at→ treated as missing.providerenum must matchgatekeeper_network's allowlist class (Civic pubkey ∈ allowed_civic_networks, etc.).attestation.operator == signerat verify time — prevents PDA cloning attempts.- One attestation PDA per operator — Anchor
initon duplicate fails.
Events
PersonhoodAttested { operator, provider, tier, expires_at }PersonhoodRevoked { operator, reason_code }PersonhoodRefreshed { operator, new_expires_at }
Non-goals
- In-protocol KYC. We only trust the external provider; we do not store documents.
- Per-agent attestation — explicitly per-operator to avoid wallet-farm Sybil arbitrage.
Verify
cargo test -p agent_registry personhood_
anchor test tests/personhood_gate.ts # mocks civic gateway via token program
Open questions
- Civic fee per attestation passed to operator, not us. Confirm ops-facing doc.
- If Civic downtime: add an
attest_personhood_grace_windowin RegistryGlobal letting a stale-but-recent attestation stand for 24h. Default off; govern can enable during incidents.