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 small. It has fourteen instructions, no cross program invocation into untrusted programs, checked arithmetic, and an account that is reallocated to its exact size on every write.
| Program ID | H1gnV4GjNT3UV7AgGNUCkSaciuVVtM7hKb8JhPV3Xxy6 |
| Implementation | Rust on Pinocchio, with no_std, no allocator and account data edited in place |
| Account | One PDA per DID, seeds ["bio-did", subject] |
| Cluster | Solana devnet, live. The build is reproducible with solana-verify |
| Binary | About 92 KB |
| Crate | bio-did-registry 0.2.0, the same code as a host library for clients |
| Specification | did:bio method specification v1.3 |
| 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). A PDA has no private key and the runtime lets only the owning program write its data, so 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 into owned structures. Each instruction reads the byte layout through a borrowed view, validates against borrowed slices, edits the buffer in place and resizes it. The program therefore needs no allocator and has no heap to exhaust. The layout offsets come from #[repr(C)] header structs that compile time asserts pin to the format, so a layout mistake fails the build rather than a transaction.
Accounts are exactly sized on every mutation. Growth is paid by the transaction's payer, shrinkage refunds rent to that payer, and the balance settles at the rent exempt minimum each time. Nothing is overallocated in case it is needed later, so a DID costs what it holds. The initial account is 124 bytes. At the current rate of 5,080 lamports per byte, counting the 128 bytes of overhead every account carries, its rent is 1,280,160 lamports, about 0.00128 SOL.
Caps
Every list is bounded, which bounds both account size and the work a resolver does. The worst case is about 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, which is 1232 bytes. ML-DSA-87 keys therefore arrive through the staged upload in section 5 rather than inline.
2. Two ways an account is born
A DID's subject is 32 bytes, and those bytes come from one of two places.
initialize(subject) creates the account for a key subject, an Ed25519 public key. The account holds exactly the generative default state, which is version 1, the protected #default method carrying all five relationships, and empty controller and service sets. The instruction is permissionless. Any payer may fund it, and that is safe because the created content is byte for byte the generative document, so a sponsor gains no authority. Every later mutation still needs an authority's signature, and the only authority of a fresh account is the subject key. The program refuses a subject that is not on the Ed25519 curve, since nothing could ever sign for the document it would name.
initialize_owned(nonce) creates the account for an owned subject. The program derives the subject itself, as find_program_address(["bio-did-owned", authority, nonce_le]), from the signing wallet and a nonce the wallet chose, and writes the same 124 byte document with the wallet's key as the protected #default method. The subject is off the curve, so no keypair exists for it and there is no generative document. It resolves only through the registry. The authority must sign, so nobody can register an owned DID in another wallet's name, and the same nonce under two wallets yields two unrelated DIDs. The payer may still be someone else.
Owned subjects let a researcher name a dataset, a paper or a claim without keeping a key per asset. From version 1 on, the two forms are indistinguishable to every other instruction.
3. Authorization
Every state changing instruction passes through a single gate. The DID must not be deactivated, and the signer must be one of its update authorities, an Ed25519 key listed in its current verification methods with the capabilityInvocation flag. Failing that, the signer may be an update authority of one of the DID's native controllers.
pub fn authorize(doc: &DidView, signer: &[u8; 32], controller: Option<&AccountView>) -> Result<(), ProgramError> {
require(!doc.is_deactivated(), DidError::DidDeactivated)?;
if doc.is_authority(signer) {
return Ok(());
}
let controller = controller.ok_or(DidError::Unauthorized)?;
let subject = load_did_account(controller)?;
require(doc.is_native_controller(&subject), DidError::Unauthorized)?;
let data = controller.try_borrow()?;
let parent = DidView::parse(&data)?;
require(!parent.is_deactivated() && parent.is_authority(signer), DidError::Unauthorized)
}The fee payer is not part of that rule. 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 follow from the design instead of needing a relayer.
Controllers
A native controller is another did:bio DID listed in the document's native_controllers, and its update authorities may update the DID. The transaction passes the controller's registry account after the instruction's own accounts. The program checks that the account sits at that controller's PDA, that the controller is listed and not deactivated, and that the signer is one of the controller's own authorities. Authority reaches one level, so the controllers of a controller gain nothing.
This is how a lab manages its datasets. Every dataset DID names the lab's DID as a controller, and a key the lab adds to its own document can then update all of them, with no change to the datasets themselves. The invariants below still hold. The last authority rule counts only the DID's own methods, so a DID always keeps an authority of its own. Entries in other_controllers are DIDs of other methods. They appear in the document and grant nothing on-chain.
Programs as authorities
A program can hold authority too. A program derived address has no private key, and the program it is derived from signs for it through a cross program invocation, the way a multisig or a DAO vault does. Such an address is off the Ed25519 curve, and the registry accepts an off curve key only when that key signs the instruction that adds it. A program therefore creates an owned DID with initialize_owned, which makes its address the #default method. Through CPI it then acts on that DID and on every DID that lists that DID as a native controller. Control then rests on the program's code and on whoever can upgrade it, which verifiers should check.
Three invariants protect authority integrity
The first is last authority. Any operation that would leave the DID with zero Ed25519 methods carrying capabilityInvocation is rejected with LastAuthority. You cannot strand a DID by accident. Giving up control entirely is only possible through deactivate.
if removes_authority {
require(doc.authority_count() > 1, DidError::LastAuthority)?;
}The second is 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 neither a coauthority nor a controller can evict the founder. It cannot plant an unremovable key of its own either, because a method may only be born protected if its key matches the signer. A protected method must also keep capabilityInvocation. Its key then stays an authority and can always sign the changes reserved to it, so a protected method can never be frozen in place.
The third is that #default is reserved. It names the founding key and nothing else. No instruction can add a method or a service under that fragment, even after the founding method has been rotated out.
4. 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 in the document |
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 means signing a Solana transaction, so Ed25519 only.
if flags & (VM_FLAG_CAPABILITY_INVOCATION | VM_FLAG_PROTECTED) != 0 {
require(method_type == VM_TYPE_ED25519, DidError::InvalidFlags)?;
}
// Only a protected method's own key may change it, so that key stays an authority.
if flags & VM_FLAG_PROTECTED != 0 {
require(flags & VM_FLAG_CAPABILITY_INVOCATION != 0, 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)?;
}
// ML-DSA-87 is a signature algorithm and agrees no keys.
if method_type == VM_TYPE_DILITHIUM5 {
require(flags & VM_FLAG_KEY_AGREEMENT == 0, DidError::InvalidFlags)?;
}Key types
| On-chain type | Bytes | Accepted key | Materializes as |
|---|---|---|---|
Ed25519 | 32 | A point on the curve, or the signer itself | Multikey z6Mk... |
X25519 | 32 | Any 32 bytes | Multikey z6LS... |
Secp256k1 | 33 | A compressed point, first byte 0x02 or 0x03 | Multikey zQ3s... |
Dilithium5 | 2592 | Any 2592 bytes, uploaded through a key buffer | JsonWebKey, ML-DSA-87 |
Key bytes that cannot be a public key of their type fail with InvalidKey. The signer exception for Ed25519 is what lets a program address act as an authority, as section 3 describes. Methods stored before release 0.2.0 that break the newer flag and key rules stay in their documents and still materialize as stored.
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 can hold neither capabilityInvocation nor keyAgreement. On-chain control stays Ed25519 until Solana itself offers post quantum transaction signatures. See Post Quantum Security.
5. Instruction set
Fourteen instructions form the complete write surface. Two create an account, seven edit the document, one deactivates it, and four stage a key that does not fit in one transaction.
Create
initialize(subject) and initialize_owned(nonce), described in section 2.
Updates, authority required
Each update also accepts a controller's registry account after its own accounts, for a signature from a controller's authority.
| Instruction | Effect | Notable constraints |
|---|---|---|
add_verification_method(vm) | Append a method | unique fragment [A-Za-z0-9_-]{1,32}, never default. Key length and form must match the type. PROTECTED is self grantable and needs capabilityInvocation. Max 16 |
remove_verification_method(fragment) | Remove a method | a protected method needs its own key, and the last authority cannot be removed |
set_verification_method_flags(fragment, flags) | Replace flags | touching PROTECTED needs the method's own key, and the last authority cannot lose the flag |
add_service(service) | Append a service | type max 64, endpoint max 512, printable ASCII 0x21-0x7E. Max 16 |
update_service(service) | Replace a service's type and endpoint in place | the fragment must exist. The rules of add_service apply, and the service keeps its position |
remove_service(fragment) | Remove a service | |
set_controllers(native, other) | Replace both sets | max 8 + 8, no duplicates, no self reference. other entries are did:<method>:<id> of another method, never did:bio |
Fragments are unique across verification methods and services together, so every DID URL fragment is unambiguous.
pub fn require_fragment_free(&self, fragment: &[u8]) -> Result<(), ProgramError> {
let taken = self.find_vm(fragment).is_some() || self.find_service(fragment).is_some();
require(!taken, DidError::FragmentAlreadyInUse)?;
require(fragment != DEFAULT_FRAGMENT, DidError::InvalidFragment)
}Each successful update increments version, sets updated_at and emits DidModified. Instruction arguments are exact. Bytes past the last field are rejected, the way borsh's try_from_slice rejects them. A new metadata CID is one update_service, one version and about 2,800 CU, where it used to take a remove and an add.
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 not implemented by closing the account. A closed account would make a key subject resolve to its generative document again, silently resurrecting the founding key's authority, which is the worst outcome if deactivation was prompted by key compromise. The tombstone guarantees that once deactivated, the registry can never again hold verification material for that DID, and an owned subject can never be recreated.
Large keys and 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, so a doomed upload fails before a chunk is sent. 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 rechecked 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. A buffer whose preconditions stopped holding fails to finish and can be closed. The bio-did-resolver command line hides the whole sequence behind a single add-key invocation.
6. 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. Both create instructions emit DidInitialized. Opening, writing or closing a key buffer emits nothing. Only the finishing step, which changes the document, emits DidModified.
7. Errors
| Code | Error | Meaning |
|---|---|---|
| 6000 | Unauthorized | Signer is not an authority of the DID or of the controller passed |
| 6001 | DidDeactivated | The DID is a tombstone, and all mutations are rejected |
| 6002 | InvalidFragment | Empty, too long, reserved, 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 or endpoint empty, too long, or nonprintable |
| 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 |
| 6018 | InvalidKey | Key bytes are not a valid public key for the type |
Codes are only ever appended, so a code never changes meaning between releases. The crate's DidError::from_code and DidError::message turn a code back into its name and text. On the wire they surface as custom program errors, 0x1770 for 6000 upward. An off curve subject passed to initialize is refused with the runtime's own InvalidArgument.
8. What it costs
Measured by the program's compute unit test with fixed keys, so the figures are reproducible. An instruction that derives a PDA on-chain pays about 350 CU for every bump candidate the search rejects, because the program hashes the seeds itself instead of calling the 1,500 CU address syscall. The table says how many the test keys hit.
| Instruction | Compute units |
|---|---|
initialize (1 rejected bump) | 2,918 |
initialize_owned (two searches, 1 rejected bump) | 3,136 |
add_verification_method (Ed25519) | 3,660 |
create_key_buffer (ML-DSA-87, 2 rejected bumps) | 3,715 |
write_key_buffer (900 byte chunk) | 497 |
add_verification_method_from_buffer (2.5 KB key) | 3,750 |
close_key_buffer | 463 |
remove_verification_method (2.5 KB key) | 1,852 |
set_verification_method_flags | 1,493 |
add_service | 4,417 |
update_service (new CID) | 2,772 |
remove_service | 1,880 |
set_controllers (2 native + 2 external) | 4,383 |
deactivate | 1,498 |
The lifecycle in the test, sixteen of these instructions, costs 38,397 CU.
An edit walks the entries before the one it touches and moves the bytes after it, so its cost grows with the document. The same test fills a document to every limit, 50,040 bytes with sixteen methods, sixteen of the longest services and eight controllers of each kind.
| Instruction on a full document | Compute units |
|---|---|
set_verification_method_flags (last method) | 2,854 |
update_service (last service) | 8,911 |
remove_service (first service) | 2,709 |
add_service (sixteenth) | 10,945 |
remove_verification_method (first ML-DSA-87) | 2,865 |
add_verification_method (sixteenth, Ed25519) | 5,453 |
set_controllers (8 + 8) | 17,362 |
deactivate | 2,388 |
A complete ML-DSA-87 upload costs about 9,000 CU spread over five transactions. Solana's default budget is 200,000 CU per transaction, and the most expensive operation, replacing eight controllers of each kind on a full document, uses under 9% of it. The real cost is rent rather than compute. A key buffer holds about 0.014 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 74 byte tombstone, which keeps about 0.001 SOL.
9. 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. A key subject gets its generative document, with
versionIdof"0". An owned subject getsnotFound, because no keypair could stand behind it and there is nothing to materialize. A lamport only account someone creates at the PDA is system owned and carries no data, so it fails the ownership check and 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. Data the program never writes, such as bytes past the state, a count above a cap or a flag the key type cannot carry, is undecodable and resolves tointernalError. It never falls back to the generative 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 as a pure function over an optional fetched account, so the same logic runs in the command line, the API and the tests. A fetcher must report a transport failure as an error and never as a missing account. The crate also dereferences DID URL fragments to the method or service they name.
Consensus authenticates the state, and your RPC connection authenticates the response. The significant read path risk is withholding, where a node falsely reports no account, so that a key subject falls back to its generative document and hides 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. The Quorum reader in did-bio-core asks several providers and accepts an account only when more than half return it identically, and it counts an absent account as an answer, so withholding needs the same majority. Owned subjects are immune. Withholding yields notFound, which is an outage rather than a stale authority.
10. Operational notes
- Upgrade authority. A production deployment must either burn it with
solana program set-upgrade-authority --finalor 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, both create paths, controller authority, the key buffer, a document at every limit, byte exact golden vectors for the account layout, and a packet size check that keeps every chunk under the transaction limit. Property tests cover the layout parser, a model based test runs random update sequences against a reference model of the rules, and a small caller program drives program authorities through CPI. No validator is needed. The resolver repository adds a cluster test script that runs the same lifecycle against a local validator or devnet.
- Verified build. The devnet deployment is reproducible.
solana-verifybuilds the pinned 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. Devnet runs the 0.2.0 build. - Audit status. The program has not yet received an external audit. Treat devnet state as experimental.
- Recovery. There is no super authority by design. Owners should register a second
capabilityInvocationmethod, such as a hardware or organizational escrow key, name a native controller whose keys can act on the DID, or make a multisig program an authority. After a hostile rotation, on-chain recovery is impossible and the remedy is a new DID plus updated off chain linkages.
11. Tooling
| Component | Where | What it gives you |
|---|---|---|
bio-did-registry 0.2.0 | crates.io | the program's discriminators, layouts, error codes with their messages and owned subject derivation as a no-entrypoint library for clients and CPI callers, plus off chain builders for every instruction in client |
did-bio-core 0.2.0 | crates.io | data model, strict account and key buffer decoders, resolution, DID URL dereferencing, PDA and owned subject derivation, Ed25519, ES256K and ML-DSA-87 verification, and the Quorum reader |
bio-did-resolver 0.2.0 | crates.io | command line resolver and client with --dry-run and errors printed by name, covering every instruction except update_service |
| did:bio specification v1.3 | GitHub | the method definition these crates implement |
The command line covers the whole lifecycle. Registering a post quantum key is one command that sends five transactions and resumes if interrupted. update_service and updates authorized by a controller are not in the command line yet. The client builders in the registry crate produce them.
cargo install bio-did-resolver
bio-did-resolver init-owned 42
bio-did-resolver add-service metadata BioMetadata ipfs://<cid> did:bio:devnet:<OWNED>
bio-did-resolver add-key pq --type ml-dsa-87 --key-file pq.pub --flags assertion
bio-did-resolver resolve did:bio:devnet:<subject>Most requests the program would refuse fail before they leave the machine, with a reason. When the program does refuse one, its error is printed by name and meaning rather than as a bare code, except the new InvalidKey, which the command line still shows as code 6018.