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 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 IDH1gnV4GjNT3UV7AgGNUCkSaciuVVtM7hKb8JhPV3Xxy6
ImplementationRust on Pinocchio, with no_std, no allocator and account data edited in place
AccountOne PDA per DID, seeds ["bio-did", subject]
ClusterSolana devnet, live. The build is reproducible with solana-verify
BinaryAbout 92 KB
Cratebio-did-registry 0.2.0, the same code as a host library for clients
Specificationdid:bio method specification v1.3
Sourceekayana-labs/bio-did-registry
WRITE PATHController keypairEd25519 with capabilityInvocationsignsTransactioninstruction + recent blockhashreplay protectedSolana runtimeEd25519 verifybio-did-registry, program H1gn...3Xxy6Authorization gatesigner holds capabilityInvocationon the DID or a native controllerINVARIANTSLast authority, never zero capable keysProtected methods, own key onlyINSTRUCTION SET (14)CREATEinitializeinitialize_ownedkey subject: permissionlessowned: the wallet signsUPDATE, authority requiredadd_vmremove_vmset_flagsset_ctrladd_svcupdate_svcremove_svcDEACTIVATEdeactivateirreversible;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, spec section 6.2Resolverderives the same PDAgetAccountInfoAccount exists?+ owner & discriminatoryesMaterialize documentnoGenerative documentkey subject, version 0an owned subject gets notFound insteadW3C DIDdocumentstateConsensus authenticates state, the program authenticates every write. No registrar, one PDA per subject and cluster.
Anatomy of the registry program. The authorized write path goes through fourteen instructions, and the read path is generative.

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.

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 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.

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, 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.

Key subjecton the curve32 byte Ed25519 public keybase58btcdid:bio:devnet:2T6zLFvMx7NJ...WjUK1yKv4tcExists the moment the keypair does. No transaction, no fee.The key itself signs for the document until it is registered.Owned subjectoff the curvewallet key+nonceprogram derived["bio-did-owned", authority, nonce_le]No keypair can exist for it, so the wallet that signedinitialize_owned becomes the protected #default method.One account rule["bio-did", subject]find_program_address,then getAccountInfono accounttombstoneNo accountkey: generative document, versionId 0owned: notFound, nothing to materializeAccount, activeboth: the stored documentversionId n, updated timestampAccount, deactivatedboth: the minimal documentdeactivated: true, foreverThe curve test tells the forms apart; the identifier never says who owns an owned subject, only the registry does.
The two forms of a did:bio subject, a key subject and a wallet owned program derived subject, both stored under the same account rule and resolving to a generative document, notFound, the stored document or the tombstone

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.

rust
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.

rust
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.

BitFlagDocument property
1 << 0AUTHENTICATIONauthentication
1 << 1ASSERTIONassertionMethod
1 << 2KEY_AGREEMENTkeyAgreement
1 << 3CAPABILITY_INVOCATIONcapabilityInvocation
1 << 4CAPABILITY_DELEGATIONcapabilityDelegation
1 << 8PROTECTEDnever expressed in the document

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 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 typeBytesAccepted keyMaterializes as
Ed2551932A point on the curve, or the signer itselfMultikey z6Mk...
X2551932Any 32 bytesMultikey z6LS...
Secp256k133A compressed point, first byte 0x02 or 0x03Multikey zQ3s...
Dilithium52592Any 2592 bytes, uploaded through a key bufferJsonWebKey, 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.

InstructionEffectNotable constraints
add_verification_method(vm)Append a methodunique 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 methoda protected method needs its own key, and the last authority cannot be removed
set_verification_method_flags(fragment, flags)Replace flagstouching PROTECTED needs the method's own key, and the last authority cannot lose the flag
add_service(service)Append a servicetype max 64, endpoint max 512, printable ASCII 0x21-0x7E. Max 16
update_service(service)Replace a service's type and endpoint in placethe 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 setsmax 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.

rust
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.

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_bufferrechecks, appends, closesopens, rent from payerfills in orderKeyBuffer PDA["bio-did-key", did, 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 rechecks 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, 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 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 rechecked 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. 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.

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. 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

CodeErrorMeaning
6000UnauthorizedSigner is not an authority of the DID or of the controller passed
6001DidDeactivatedThe DID is a tombstone, and all mutations are rejected
6002InvalidFragmentEmpty, too long, reserved, 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 or endpoint empty, too long, or nonprintable
6015InvalidKeyBufferBuffer not bound to this DID and authority
6016InvalidKeyChunkChunk out of order, empty, or past the key length
6017KeyBufferIncompleteFinishing before every byte arrived
6018InvalidKeyKey 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.

InstructionCompute 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_buffer463
remove_verification_method (2.5 KB key)1,852
set_verification_method_flags1,493
add_service4,417
update_service (new CID)2,772
remove_service1,880
set_controllers (2 native + 2 external)4,383
deactivate1,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 documentCompute 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
deactivate2,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.

READ PATH - never writes, never needs an account to exist1 Parse the DIDagainst the ABNF of spec section 4.12 Pick the clusterfrom the network segment; none means mainnet-beta3 Decode the subject32 bytes; on the curve is a key, off it is owned4 Derive the accountfind_program_address(["bio-did", subject])5 Fetch itgetAccountInfo at finalized commitment6 Account present?exists, has data, owned by the program7 Check the discriminatorsha256("account:DidAccount")[..8], then deserialize8 Deactivated?the tombstone flag9 MaterializeversionId = version, updated = updated_atmalformederror: invalidDidalso a subject that is not 32 bytesnokey subject: generative documentversionId 0, deactivated falseowned subject: error notFoundno keypair could stand behind ityesdeactivated documentonly @context and id, deactivated trueA transport failure at step 5 is an error,never a fallback: a node that withholds theaccount must not be able to hide a keyrotation or a deactivation.
The did:bio resolution algorithm as a flow, from parsing the identifier to the generative, notFound, deactivated and materialized outcomes
  • No account, empty data, or wrong owner. A key subject gets its generative document, with versionId of "0". An owned subject gets notFound, 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 to internalError. 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 --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, 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-verify builds 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 capabilityInvocation method, 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

ComponentWhereWhat it gives you
bio-did-registry 0.2.0crates.iothe 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.0crates.iodata 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.0crates.iocommand line resolver and client with --dry-run and errors printed by name, covering every instruction except update_service
did:bio specification v1.3GitHubthe 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.

bash
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.