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 ID | H1gnV4GjNT3UV7AgGNUCkSaciuVVtM7hKb8JhPV3Xxy6 |
| Implementation | Rust on Pinocchio - no_std, zero allocation |
| Account | One PDA per DID, seeds ["bio-did", subject] |
| Cluster | Solana devnet - live, verified build hash 35727c18...a524f1 |
| Binary | ~92 KB, reproducible with solana-verify |
| Crate | bio-did-registry 0.1.1 - the same code as a host library for clients |
| Specification | did:bio method specification v1.1 |
| 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.
The account is a borsh encoded record behind an 8-byte discriminator:
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):
| 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) |
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.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.
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:
| 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:
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 type | Bytes | Materializes as |
|---|---|---|
Ed25519 | 32 | Multikey z6Mk... |
X25519 | 32 | Multikey z6LS... |
Secp256k1 | 33 | Multikey zQ3s... |
Dilithium5 | 2592 | JsonWebKey, 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)
| 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; max 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 max 64, endpoint max 512, printable ASCII 0x21-0x7E; max 16 |
remove_service(fragment) | Remove a service | - |
set_controllers(native, other) | Replace both sets | max 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(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.
| Instruction | Effect | Notable constraints |
|---|---|---|
create_key_buffer(fragment, type, flags, key_len) | Open a buffer for one pending method | every 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 bytes | offset 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 buffer | the 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 rent | only 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:
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
| Code | Error | Meaning |
|---|---|---|
| 6000 | Unauthorized | Signer holds no capabilityInvocation method |
| 6001 | DidDeactivated | The DID is a tombstone; all mutations rejected |
| 6002 | InvalidFragment | Empty, too long, or illegal characters |
| 6003 | FragmentAlreadyInUse | Collides with a method or service |
| 6004 / 6005 | VerificationMethodNotFound / ServiceNotFound | No such fragment |
| 6006 / 6007 / 6008 | TooManyVerificationMethods / TooManyServices / TooManyControllers | Cap reached |
| 6009 | InvalidKeyLength | Key bytes do not match the declared type |
| 6010 | InvalidFlags | Unknown bits, or flags illegal for the key type |
| 6011 | ProtectedVerificationMethod | Protected method touched without its own key |
| 6012 | LastAuthority | Would leave zero update authorities |
| 6013 | InvalidController | Invalid, duplicated, or self-referential controller |
| 6014 | InvalidServiceValue | Service type/endpoint empty, too long, or non-printable |
| 6015 | InvalidKeyBuffer | Buffer not bound to this DID and authority |
| 6016 | InvalidKeyChunk | Chunk out of order, empty, or past the key length |
| 6017 | KeyBufferIncomplete | Finishing 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.
| Instruction | Compute 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 usefinalizedcommitment, query independent providers for high-value decisions, and treat a generative result for a DID previously seen withversionId > 0as 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-verifybuilds 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
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.
10. Tooling
| Component | Where | What it gives you |
|---|---|---|
bio-did-registry 0.1.1 | crates.io | the program's discriminators, layouts and error codes as a no-entrypoint library for clients and CPI callers |
did-bio-core 0.1.1 | crates.io | data model, account and key buffer decoders, resolution, PDA derivation, ML-DSA-87 verification |
bio-did-resolver 0.1.0 | crates.io | command line resolver and client for every instruction |
| did:bio specification v1.1 | GitHub | the 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:
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>