Documentation

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: twelve instructions - eight that edit a document and four that stage a key too large for one transaction - no cross-program invocation into untrusted programs, checked arithmetic, and exact-size reallocation on every mutation.

Program IDH1gnV4GjNT3UV7AgGNUCkSaciuVVtM7hKb8JhPV3Xxy6
ImplementationRust on Pinocchio - no_std, zero allocation
AccountOne PDA per DID, seeds ["bio-did", subject]
ClusterSolana devnet - live, verified build hash 35727c18...a524f1
Binary~92 KB, reproducible with solana-verify
Cratebio-did-registry 0.1.1 - the same code as a host library for clients
Specificationdid:bio method specification v1.1
Sourceekayana-labs/bio-did-registry
WRITE PATHController keypairEd25519 - capabilityInvocationsignsTransactioninstruction + recent blockhashreplay-protectedSolana runtimeEd25519 verifyBIO-DID-REGISTRY · PROGRAM H1GN…3XXY6Authorization gatesigner ∈ Ed25519 methods withcapabilityInvocation, not deactivatedINVARIANTSLast-authority - never zero capable keysProtected methods - own-key onlyINSTRUCTION SET (12)CREATEinitializepermissionless;writes generativedefault stateUPDATE · authority requiredadd_vmremove_vmset_flagsadd_svcremove_svcset_ctrlDEACTIVATEdeactivateirreversible;shrinks to apermanent tombstoneLARGE KEYS · staged upload, authority requiredcreate_bufferwrite_bufferadd_vm_from_bufferclose_buffer2.5 KBwritesON-CHAIN STATEPDA SEEDS"bio-did"+subjfind_program_addressDidAccountversion: u64bump: u8subject: Pubkeydeactivatedupdated_atcontrollersverif. methodsserviceslarge keys stage ina KeyBuffer PDA,then land hereowned by programREAD PATH · resolution sec 6.2Resolverderives the same PDAgetAccountInfoAccount exists?+ owner & discriminatoryesMaterialize documentnoGenerative documentsubject key, version 0W3C DIDdocumentstateConsensus authenticates state; the program authenticates every write. No human registrar, one PDA per (program, subject, cluster).
Anatomy of the registry program: the authorized write path and the generative read path

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.

The account is a borsh encoded record behind an 8-byte discriminator:

rust
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>,
}

The program never deserializes this record into owned structures. Each instruction walks the byte layout once to locate the section it needs, validates against borrowed slices, then edits the buffer in place and resizes it. That is why the program is no_std with no allocator at all, and why a document holding sixteen 2.5 KB post-quantum keys costs the same per edit as a minimal one - there is no heap to exhaust.

Accounts are exactly sized on every mutation: growth is paid by the transaction's payer, and shrinkage refunds rent to that payer, leaving the account holding exactly the rent-exempt minimum. 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):

FieldCap
Verification methods16
Services16
Native controllers8
Other-method controllers8
Fragment length32
Service type / endpoint64 / 512
Controller DID string128
Key material2592 (fits ML-DSA-87)

The key cap is larger than a whole Solana transaction (1232 bytes), which is why ML-DSA-87 keys arrive through the staged upload described under Large keys in section 4 rather than inline.

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.
rust
pub fn require_authority(data: &[u8], s: &Sections, signer: &[u8; 32]) -> Result<(), ProgramError> {
    require(data[OFF_DEACTIVATED] == 0, DidError::DidDeactivated)?;
    let mut authorized = false;
    for_each_vm(data, s, |vm| {
        if vm.method_type == VM_TYPE_ED25519
            && vm.flags & VM_FLAG_CAPABILITY_INVOCATION != 0
            && vm.key == signer
        {
            authorized = true;
            return Ok(false);
        }
        Ok(true)
    })?;
    require(authorized, DidError::Unauthorized)
}

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.

rust
if removes_authority {
    require(authority_count(&data, &s)? > 1, DidError::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:

BitFlagDocument property
1 << 0AUTHENTICATIONauthentication
1 << 1ASSERTIONassertionMethod
1 << 2KEY_AGREEMENTkeyAgreement
1 << 3CAPABILITY_INVOCATIONcapabilityInvocation
1 << 4CAPABILITY_DELEGATIONcapabilityDelegation
1 << 8PROTECTED- (never expressed)

Flags are validated against the key type, because some combinations are meaningless rather than merely unusual:

rust
require(flags & !VM_VALID_MASK == 0, DidError::InvalidFlags)?;
// capabilityInvocation implies signing a Solana transaction -> Ed25519 only.
if flags & VM_FLAG_CAPABILITY_INVOCATION != 0 {
    require(method_type == VM_TYPE_ED25519, DidError::InvalidFlags)?;
}
// X25519 is a key-agreement key: it cannot sign or assert anything.
if method_type == VM_TYPE_X25519 {
    require(flags & VM_RELATIONSHIP_MASK & !VM_FLAG_KEY_AGREEMENT == 0, DidError::InvalidFlags)?;
}

Key types

On-chain typeBytesMaterializes as
Ed2551932Multikey z6Mk...
X2551932Multikey z6LS...
Secp256k133Multikey zQ3s...
Dilithium52592JsonWebKey, ML-DSA-87 - uploaded through a key buffer

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

Twelve instructions, and that is the complete write surface: eight edit the document directly, four stage a key that does not fit in one transaction.

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)

InstructionEffectNotable constraints
add_verification_method(vm)Append a methodunique fragment [A-Za-z0-9_-]{1,32}; key length must match type; PROTECTED only self-grantable; max 16
remove_verification_method(fragment)Remove a methodprotected => own key; last-authority
set_verification_method_flags(fragment, flags)Replace flagstouching PROTECTED => own key; last-authority
add_service(service)Append a servicetype max 64, endpoint max 512, printable ASCII 0x21-0x7E; max 16
remove_service(fragment)Remove a service-
set_controllers(native, other)Replace both setsmax 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:

rust
pub fn require_fragment_free(data: &[u8], s: &Sections, fragment: &[u8]) -> Result<(), ProgramError> {
    let mut taken = false;
    for_each_vm(data, s, |vm| {
        if vm.fragment == fragment { taken = true; return Ok(false); }
        Ok(true)
    })?;
    if !taken {
        for_each_service(data, s, |svc| {
            if svc.fragment == fragment { taken = true; return Ok(false); }
            Ok(true)
        })?;
    }
    require(!taken, DidError::FragmentAlreadyInUse)
}

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.

Large keys - the key buffer (authority required)

A Solana transaction holds at most 1232 bytes. An ML-DSA-87 key is 2592, so it can never ride inside add_verification_method. Keys larger than one transaction are staged in a key buffer: a program-owned account at ["bio-did-key", did_account, authority] that the authority fills in chunks and then folds into the document.

UPLOAD PATH · keys larger than one transactionAuthority keypairsigns all five steps1create_key_bufferfragment, type, flags, 25922-4write_key_buffer x3900 + 900 + 792 bytes5add_vm_from_bufferre-checks, appends, closesopens, rent from payerfills in orderKeyBuffer PDA["bio-did-key", did_account, authority]headerchunk 1chunk 2chunk 3written: a prefix, never a hole2712 BcopyDidAccount+ #pq JsonWebKeyversion + 1grows by exactly one entrythen the buffer is closed and its rent returns to the payerRULESBound to one authority and one DIDOne buffer per authority and DIDChunks arrive in order, no holesFinish re-checks every rule against the current documentclose_key_buffer reclaims the rent at any timeFive transactions, each under the 1232-byte packet limit. Every existing instruction, layout and error code is unchanged.
Upload path for keys larger than one transaction: five signed transactions fill a KeyBuffer account in order, then the method is appended to the DID account and the buffer is closed
InstructionEffectNotable constraints
create_key_buffer(fragment, type, flags, key_len)Open a buffer for one pending methodevery add_verification_method rule that does not need the key bytes is checked now; PROTECTED needs a 32-byte key; one buffer per authority and DID
write_key_buffer(offset, chunk)Append key bytesoffset must equal the bytes written so far and stay within key_len; only the bound authority may write
add_verification_method_from_buffer()Append the buffered method, close the bufferthe buffer must be complete and bound to this DID and authority; every rule is re-checked against the DID's current state; the buffer's rent goes back to the payer
close_key_buffer()Discard a buffer, refund its rentonly the bound authority; works after deactivation or key rotation, since the DID account is not involved

Chunks of 900 bytes keep every write_key_buffer transaction under the limit even when the fee payer is not the authority, so an ML-DSA-87 key takes five transactions in total, and an interrupted upload resumes from the bytes already written. A buffer holds no document state: resolution never reads one, and finishing is the only path from a buffer into a document, so a buffer whose preconditions stopped holding simply fails to finish and can be closed. The bio-did-resolver command line hides the whole sequence behind a single add-key invocation.

5. Events

Three events let indexers follow a DID without polling:

rust
pub struct DidInitialized { pub did_account: Pubkey, pub subject: Pubkey, pub version: u64 }
pub struct DidModified    { pub did_account: Pubkey, pub subject: Pubkey, pub version: u64 }
pub struct DidDeactivated { pub did_account: Pubkey, pub subject: Pubkey, pub version: u64 }

Each is written to the transaction log as an 8-byte event discriminator (sha256("event:<Name>")[..8]) followed by the borsh encoded fields, so an indexer can subscribe to Program data: log lines and decode them without an RPC round-trip per DID. Opening, writing, or closing a key buffer emits nothing; only the finishing step, which changes the document, emits DidModified.

6. Errors

CodeErrorMeaning
6000UnauthorizedSigner holds no capabilityInvocation method
6001DidDeactivatedThe DID is a tombstone; all mutations rejected
6002InvalidFragmentEmpty, too long, or illegal characters
6003FragmentAlreadyInUseCollides with a method or service
6004 / 6005VerificationMethodNotFound / ServiceNotFoundNo such fragment
6006 / 6007 / 6008TooManyVerificationMethods / TooManyServices / TooManyControllersCap reached
6009InvalidKeyLengthKey bytes do not match the declared type
6010InvalidFlagsUnknown bits, or flags illegal for the key type
6011ProtectedVerificationMethodProtected method touched without its own key
6012LastAuthorityWould leave zero update authorities
6013InvalidControllerInvalid, duplicated, or self-referential controller
6014InvalidServiceValueService type/endpoint empty, too long, or non-printable
6015InvalidKeyBufferBuffer not bound to this DID and authority
6016InvalidKeyChunkChunk out of order, empty, or past the key length
6017KeyBufferIncompleteFinishing before every byte arrived

Codes are only ever appended, so a client built against 0.1.0 still decodes every 0.1.1 error. On the wire they surface as custom program errors, 0x1770 for 6000 upward.

7. What it costs

Measured with the program's compute unit report, per instruction. The in-place design means these are flat in document size: what an edit costs does not depend on how much the document already holds.

InstructionCompute units
initialize~3,600 and up (PDA bump search)
add_verification_method (Ed25519)~4,900
create_key_buffer (ML-DSA-87)~5,600 and up
write_key_buffer (900-byte chunk)~1,900
add_verification_method_from_buffer (2.5 KB)~6,600
close_key_buffer~1,800
remove_verification_method~3,500
set_verification_method_flags~3,100
add_service~5,900
remove_service~3,400
set_controllers (2 native + 2 external)~5,700
deactivate~2,900

A complete ML-DSA-87 upload therefore costs about 18,000 CU spread over five transactions. For comparison, Solana's default per-transaction budget is 200,000 CU, so no single registry operation uses more than 4% of it. The dominant real cost is rent on the account itself, not compute: a key buffer holds about 0.02 SOL while an upload is in flight and returns it when the buffer closes, the document then grows by a similar deposit for the key it keeps, and deactivation refunds everything above the tombstone.

8. 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 account discriminator (sha256("account:DidAccount")[..8]), deserialize, and either return the deactivated tombstone document or materialize the full DID document.

Key buffers live at other addresses under a different discriminator, so a resolver never reads one; a pending upload is invisible until it finishes.

The did-bio-core crate implements this algorithm as a pure function over an optional fetched account, so the same logic runs in the bio-did-resolver command line, 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 use finalized commitment, query independent providers for high-value decisions, and treat a generative result for a DID previously seen with versionId > 0 as a version regression.

9. 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, every authorization invariant, the key buffer path, and a packet-size check that keeps every chunk under the transaction limit - no validator required. The resolver repository adds a cluster test matrix of 133 checks that runs the same lifecycle against a local validator or devnet.
  • Verified build. The devnet deployment is reproducible: solana-verify builds the tagged source in a fixed container, and the on-chain hash must match it, so anyone can confirm that the program running is the program published.
  • Audit status. The program has not yet received an external audit. Treat devnet state as experimental.
  • Recovery. There is no super authority by design. Controllers should register a second capabilityInvocation method (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.

10. Tooling

ComponentWhereWhat it gives you
bio-did-registry 0.1.1crates.iothe program's discriminators, layouts and error codes as a no-entrypoint library for clients and CPI callers
did-bio-core 0.1.1crates.iodata model, account and key buffer decoders, resolution, PDA derivation, ML-DSA-87 verification
bio-did-resolver 0.1.0crates.iocommand line resolver and client for every instruction
did:bio specification v1.1GitHubthe method definition these crates implement

Registering a post-quantum key from the command line is one command; it sends the five transactions and resumes if interrupted:

bash
cargo install bio-did-resolver
bio-did-resolver add-key pq --type ml-dsa-87 --key-file pq.pub --flags assertion
bio-did-resolver resolve did:bio:devnet:<subject>