Solana Registry Program
bio-did-registry is the on-chain program that acts as the verifiable data registry for the did:bio method. It is deliberately small: eight instructions, no cross-program invocation into untrusted programs, checked arithmetic, and exact-size reallocation on every mutation.
| Program ID | 7rxZthJmEaPCKK2WEsX77nm4vf7FJHeN1wRx9ajokgmd |
| Framework | Anchor 1.1.2 (Rust) |
| Account | One PDA per DID, seeds ["bio-did", subject] |
| Reference cluster | devnet |
| Source | ekayana-labs/bio-did-registry |
1. The account
Exactly one account backs each DID, at the program-derived address find_program_address(["bio-did", subject], PROGRAM_ID). Because a PDA has no private key and the runtime restricts data writes to the owning program, the only way state changes is through an instruction of this program.
#[account]
pub struct DidAccount {
pub version: u64, // monotonic; surfaces as versionId
pub bump: u8, // PDA bump
pub subject: Pubkey, // the DID's method-specific id
pub deactivated: bool, // permanent tombstone flag
pub updated_at: i64, // unix seconds
pub native_controllers: Vec<Pubkey>,
pub other_controllers: Vec<String>,
pub verification_methods: Vec<VerificationMethod>,
pub services: Vec<Service>,
}Accounts are exactly sized on every mutation via realloc: growth is paid by the transaction's payer, and shrinkage refunds rent to that payer. Nothing is over-allocated "just in case", so a DID costs what it actually uses - the initial account is 124 bytes, roughly 0.00175 SOL of rent.
Caps
Every list is bounded, which bounds both account size and the work any resolver must do (worst case ≈ 52 KB):
| Field | Cap |
|---|---|
| Verification methods | 16 |
| Services | 16 |
| Native controllers | 8 |
| Other-method controllers | 8 |
| Fragment length | 32 |
| Service type / endpoint | 64 / 512 |
| Controller DID string | 128 |
| Key material | 2592 (fits ML-DSA-87) |
2. Authorization - the one rule
Every state-changing instruction passes through a single gate:
A mutation is authorized iff it carries an Ed25519 signature, verified by the Solana runtime, from a key listed in the DID's current verification methods with the capabilityInvocation flag - and the DID is not deactivated.pub fn require_authority(did: &DidAccount, signer: &Pubkey) -> Result<()> {
require!(!did.deactivated, ErrorCode::DidDeactivated);
require!(did.is_authority(signer), ErrorCode::Unauthorized);
Ok(())
}Note what is not in that rule: the fee payer. payer and authority are separate signers on every mutating instruction, so a platform can fund a researcher's transaction while only the researcher's key authorizes it. Sponsored operations fall out of the design rather than needing a trusted relayer.
Two invariants protect authority integrity
Last-authority. Any operation that would leave the DID with zero Ed25519 + capabilityInvocation methods is rejected with LastAuthority. You cannot accidentally strand a DID; relinquishing control entirely is only possible through deactivate.
require!(did.authority_count() > 1, ErrorCode::LastAuthority);Protected methods. A method whose PROTECTED bit is set can only be added, re-flagged, or removed in a transaction signed by that method's own key. The subject's #default method is created protected, so a co-authority can never evict the subject - nor plant an unremovable key of its own, because a method may only be born protected if its key matches the signer.
3. Verification method flags
The stored flags bitmask maps to the five W3C verification relationships, plus one method-internal bit:
| Bit | Flag | Document property |
|---|---|---|
1 << 0 | AUTHENTICATION | authentication |
1 << 1 | ASSERTION | assertionMethod |
1 << 2 | KEY_AGREEMENT | keyAgreement |
1 << 3 | CAPABILITY_INVOCATION | capabilityInvocation |
1 << 4 | CAPABILITY_DELEGATION | capabilityDelegation |
1 << 8 | PROTECTED | - (never expressed) |
Flags are validated against the key type, because some combinations are meaningless rather than merely unusual:
// capabilityInvocation implies signing a Solana transaction -> Ed25519 only.
if flags & VM_FLAG_CAPABILITY_INVOCATION != 0 {
require!(method_type == VerificationMethodType::Ed25519, ErrorCode::InvalidFlags);
}
// X25519 is a key-agreement key: it cannot sign or assert anything.
if method_type == VerificationMethodType::X25519 {
require!(flags & VM_RELATIONSHIP_MASK & !VM_FLAG_KEY_AGREEMENT == 0, ErrorCode::InvalidFlags);
}Key types
| On-chain type | Bytes | Materializes as |
|---|---|---|
Ed25519 | 32 | Multikey z6Mk… |
X25519 | 32 | Multikey z6LS… |
Secp256k1 | 33 | Multikey zQ3s… |
Dilithium5 | 2592 | JsonWebKey, ML-DSA-87 |
The Dilithium5 tag is a legacy name for what is stored: a final FIPS 204 ML-DSA-87 public key, used for post-quantum assertions verified off-chain. It cannot hold capabilityInvocation - on-chain control stays Ed25519 until Solana itself offers post-quantum transaction signatures. See Post-Quantum Security.
4. Instruction set
Eight instructions, and that is the complete write surface.
initialize(subject)
Creates the PDA holding exactly the generative default state: version 1, the protected #default method carrying all five relationships, empty controller and service sets.
It is permissionless - any payer may fund it. That is safe precisely because the created content is byte-for-byte the generative document, so a third-party initializer gains no authority; every later mutation still needs the subject key's signature. The only thing an attacker accomplishes is donating rent.
Updates (authority required)
| Instruction | Effect | Notable constraints |
|---|---|---|
add_verification_method(vm) | Append a method | unique fragment [A-Za-z0-9_-]{1,32}; key length must match type; PROTECTED only self-grantable; ≤ 16 |
remove_verification_method(fragment) | Remove a method | protected ⇒ own key; last-authority |
set_verification_method_flags(fragment, flags) | Replace flags | touching PROTECTED ⇒ own key; last-authority |
add_service(service) | Append a service | type ≤ 64, endpoint ≤ 512, printable ASCII 0x21–0x7E; ≤ 16 |
remove_service(fragment) | Remove a service | - |
set_controllers(native, other) | Replace both sets | ≤ 8 + 8, no duplicates, no self-reference; other must be did:-prefixed and not did:bio |
Fragments are unique across verification methods and services together, so every DID URL fragment is unambiguous:
pub fn require_fragment_free(did: &DidAccount, fragment: &str) -> Result<()> {
require!(did.verification_methods.iter().all(|vm| vm.fragment != fragment),
ErrorCode::FragmentAlreadyInUse);
require!(did.services.iter().all(|s| s.fragment != fragment),
ErrorCode::FragmentAlreadyInUse);
Ok(())
}Each successful update increments version, sets updated_at, and emits DidModified.
deactivate()
Erases all methods, services and controllers, sets deactivated = true, shrinks the account to a 74-byte tombstone, and refunds the excess rent.
Deactivation is deliberately not implemented by closing the account. A closed account would make the DID resolve to its generative document again - silently resurrecting the subject key's authority, which is catastrophic if deactivation was prompted by key compromise. The tombstone guarantees that once deactivated, the registry can never again hold verification material for that DID.
5. Events
Three events let indexers follow a DID without polling:
#[event] pub struct DidInitialized { pub did_account: Pubkey, pub subject: Pubkey, pub version: u64 }
#[event] pub struct DidModified { pub did_account: Pubkey, pub subject: Pubkey, pub version: u64 }
#[event] pub struct DidDeactivated { pub did_account: Pubkey, pub subject: Pubkey, pub version: u64 }6. Errors
| Code | Meaning |
|---|---|
Unauthorized | Signer holds no capabilityInvocation method |
DidDeactivated | The DID is a tombstone; all mutations rejected |
InvalidFragment | Empty, too long, or illegal characters |
FragmentAlreadyInUse | Collides with a method or service |
VerificationMethodNotFound / ServiceNotFound | No such fragment |
TooManyVerificationMethods / TooManyServices / TooManyControllers | Cap reached |
InvalidKeyLength | Key bytes do not match the declared type |
InvalidFlags | Unknown bits, or flags illegal for the key type |
ProtectedVerificationMethod | Protected method touched without its own key |
LastAuthority | Would leave zero update authorities |
InvalidController | Invalid, duplicated, or self-referential controller |
InvalidServiceValue | Service type/endpoint empty, too long, or non-printable |
7. Reading the registry
Resolution never writes, and never requires an account to exist. A resolver derives the PDA, fetches it, and branches:
- No account, empty data, or wrong owner -> return the generative document (
versionId: "0"). A lamport-only account someone creates at the PDA is system-owned and carries no data, so it fails the ownership check and resolves generatively - it cannot be used to spoof state. - Account found -> verify the 8-byte Anchor discriminator, deserialize, and either return the deactivated tombstone document or materialize the full DID document.
The did-bio-core crate implements this algorithm as a pure function over an optional fetched account, so the same logic runs in the CLI resolver, the backend, and tests.
Trust boundary. Consensus authenticates the state; your RPC connection authenticates the response. The significant read-path risk is withholding - a node falsely reporting no account, causing a fallback to the generative document and hiding rotations or deactivation. Resolvers should usefinalizedcommitment, query independent providers for high-value decisions, and treat a generative result for a DID previously seen withversionId > 0as a version regression.
8. Operational notes
- Upgrade authority. A production deployment must either burn it (
solana program set-upgrade-authority --final) or transfer it to governance. Verifiers relying on the registry for high-value decisions should check this before trusting it. - Testing. The program ships LiteSVM integration tests covering the full lifecycle and every authorization invariant - no validator required.
- Recovery. There is no super-authority by design. Controllers should register a second
capabilityInvocationmethod (a hardware or organizational escrow key) or use a multisig-controlled subject key; after a hostile rotation, on-chain recovery is impossible and the remedy is a new DID plus updated off-chain linkages.