Back to Blog
Technical

Inside the Solana DID Registry: One Identifier Primitive from Student to Consortium

A deep dive into the bio-did-registry program, and how two thousand lines of Rust on a global immutable chain give students, universities and pharmaceutical consortia an identifier they hold themselves, from the first day of the work.

Suraj Kumar
January 12, 2026
22 min read

The gap nobody designed, everybody inherits

Ask who is allowed to hold a persistent, resolvable identifier for research output today, and the answer is better than it used to be. Zenodo, Figshare, OSF and Harvard Dataverse will each mint a DOI for free, for anyone, for whatever they publish there. An undergraduate with a genuinely novel dataset can have a DOI by this afternoon. Getting an identifier is not the problem anymore.

What a free DOI gives you is a receipt for a deposit, and three things follow from that.

  • It arrives with the deposit. The DOI belongs to a record in someone's repository and becomes resolvable when that record is published, so the raw captures, failed runs, intermediate datasets and calibration files that never become a record never get one.
  • It is a name rather than a key. Resolving a DOI tells you where a landing page is. It cannot tell you that a file, a claim or a grant of access came from the person the record is about.
  • It is held by the repository. You change the record through the repository's interface, under its rules and its metadata schema, and the right to mint under a prefix of your own belongs to organizations that join a registration agency such as DataCite.
Repository DOIdid:bioRaw captureAnalysis draftsShared privatelyPublishedRevisedone keypair,from day one,DOI linked laterThe rows without a DOI are where most primary data is produced.
Where a repository DOI and a did:bio each enter the life of a dataset

This is not malice. It is architecture. A DOI is issued by an organization that holds a prefix, so the identifier is that organization's artifact, however generous the organization. The bio-did-registry program gives the researcher an identifier of their own instead, derived from a key they hold, usable from the first raw capture, and able to sign. It does not replace the DOI. When the work is published, the DOI goes into the DID document as a service and the two identifiers point at each other, which is what the platform's Dataverse bridge does.

This post walks through the program in detail, covering the account model, the authorization gate, and the invariants. It then works through what the program means in practice for three very different adopters, a lone student, a university, and a pharmaceutical research consortium. The remarkable property is that all three use the same two thousand lines of Rust, unchanged.

First principles: the identifier is the key

A did:bio identifier is the base58 encoding of a 32 byte subject, usually an Ed25519 public key.

code
did:bio:devnet:2T6zLFvMx7NJac5qQtiKTaPhMwHLkwKETWjUK1yKv4tc
        ^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        network segment          base58btc(32-byte Ed25519
        (absent = mainnet)       public key)

Nothing is assigned. Nothing is looked up. Generate a keypair on a laptop with no network connection, and the DID exists and resolves, because every valid key subject identifier resolves to a deterministic generative document derived from the key itself. Since release 0.1.2 there is a second kind, the owned DID, whose subject is an address the program derives for its creator from ["bio-did-owned", authority, nonce]. It has no key of its own, so it has no generative form and resolves as notFound until it is registered. The registry only enters the picture when you want more than the default, such as key rotation, service endpoints, additional verification methods, or deactivation.

GenerativeKeypair only, free, offlineinitialize~0.0013 SOLRegisteredPDA holds the documentauthorized updates, version++deactivateTombstonePermanent, keeps ~0.001 SOLno path back - a closed account would resurrect the subject keyEvery state on this line resolves. Registering costs rent, and most of it stays with the tombstone.
The DID lifecycle, from generative to registered to tombstone

The lifecycle above is the whole product in one picture. Three states, two transitions, and one deliberately missing edge. There is no path out of the tombstone, for reasons we will get to.

The on-chain anatomy

The program, whose full specification is here, is a zero allocation Rust program of fourteen instructions whose lasting state is one account type. The program edits its bytes in place rather than deserializing a struct. After an 8 byte discriminator the layout reads like this.

rust
pub struct DidAccount {
    pub version: u64,                 // monotonic update counter
    pub bump: u8,                     // PDA bump seed
    pub subject: Pubkey,              // the key that IS the identifier
    pub deactivated: bool,            // tombstone flag
    pub updated_at: i64,
    pub native_controllers: Vec<Pubkey>,
    pub other_controllers: Vec<String>,
    pub verification_methods: Vec<VerificationMethod>,
    pub services: Vec<Service>,
}

Each DID maps to exactly one program derived address:

rust
find_program_address(["bio-did", subject], PROGRAM_ID)

A PDA has no private key, and the Solana runtime only lets the owning program write its data. Together those two facts are the security model. State at that address can only ever have been produced by this program's instructions, and consensus replicates that guarantee across every honest node in the world. That is what "global immutable chain" buys concretely. It is not permanence of data, since the account is mutable by design. It is permanence of the rules by which the data may change.

Why every list has a cap

code
verification methods <= 16       services <= 16
native controllers   <= 8        other controllers <= 8
fragment <= 32 chars             endpoint <= 512 chars
service type <= 64 chars         other controller <= 128 chars
key material: exactly 32 (Ed25519, X25519), 33 (secp256k1) or 2592 (ML-DSA-87)

The caps bound the account below ~52 KB in the worst case, which bounds three things at once, namely rent, resolver work, and grief. There is no instruction an attacker can spam to make someone else's DID expensive to read.

Rent as a deposit rather than a fee

Accounts are exactly sized on every mutation via realloc. Growth is paid by the transaction's payer. Shrinkage refunds the difference to whoever pays for the shrinking transaction. Deactivating shrinks the account to a 74 byte tombstone and returns the excess, but only the excess. At today's rent of 5,080 lamports per byte, a freshly registered DID holds about 0.00128 SOL, and about 0.001 SOL of that stays with the tombstone for good, because the tombstone is never closed (the reason is below). The economics are a deposit on state you actually use, most of which you do not get back. It is still a shape institutional procurement rarely sees.

DOI, from a repositoryZenodo, Figshare, OSF, DataversefreeDOI, under your own prefixOrganizations onlymembership and per DOI feesdid:bio, registered~0.001 SOL stays in the tombstone~0.0013 SOL rentdid:bio, generativeKeypair DIDs resolve without an accountfreeRent is a deposit, not a fee, but deactivation returns only about a fifth of it. Bars are indicative, not toscale across currencies.
Cost per identifier, compared

The authorization gate

Every mutation flows through one rule.

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)
}

A signer is an authority iff it matches an Ed25519 verification method carrying the capabilityInvocation flag, either on the DID itself or, since release 0.2.0, on one of the DID's native controllers. In the second case the transaction passes the controller's registry account along, and authority reaches exactly one level. Two consequences deserve emphasis.

The fee payer is not the authority. Every instruction that changes rent takes payer and authority as distinct signers, and the rest can have their fee paid by anyone. A university can pay for ten thousand student transactions while holding authority over none of them. Sponsorship without custody is not a feature bolted on top. It falls out of the account structure.

Authorization is stateful rather than credential based. The gate checks the DID's current methods. Rotate a key out and its authority ends at that slot - nothing to revoke, no CRL to distribute, no cached token that lingers.

Two invariants stand guard

The program refuses two whole classes of footgun and betrayal:

  • LastAuthority - any operation that would leave zero Ed25519+capabilityInvocation methods is rejected. You cannot strand a DID by accident. The only exit is explicit deactivation.
  • ProtectedVerificationMethod. A method with the PROTECTED bit can only be added, re-flagged or removed by its own key. The subject's #default method is born protected, so a coauthority such as a departing colleague or a compromised admin key can never remove the subject's own key. And a method can only be born protected if its key signs the transaction, so nobody can plant an unremovable key either. Since 0.2.0 a protected method must also keep capabilityInvocation, so its own key can always sign the changes reserved to it. One power is not fenced off. Deactivation needs only an authority, so any capabilityInvocation key, including one held through a native controller, can tombstone the DID. Only add keys and controllers you would trust with that.

These two rules are why the multiparty arrangements later in this post are safe to build at all.

The tombstone, and the missing edge

deactivate erases all methods, services and controllers, sets a permanent flag, and shrinks to the tombstone. It does not close the account, because a closed account would make the DID resolve generatively again, silently resurrecting the original subject key's authority. If the reason for deactivation was key compromise, that would be the worst possible outcome. The tombstone guarantees every honest node forever answers "deactivated". The missing edge in the lifecycle diagram is a security property rather than an omission.

Onboarding, rung by rung

Now the practical half. The same primitive serves four postures, and each rung of the ladder is reached by adding to the previous one, never by migrating.

one primitive, four posturesIndividualStudent, independent- Generate keypair- Cite the DID- Zero costLabPI + members- initialize- Add services- Per dataset DIDsInstitutionUniversity, core facility- Sponsor fees- Escrow authority- Bulk registrationConsortiumPharma, multisite- Cross org controllers- ML-DSA-87 assertions- Audit indexingEach step adds capability. None invalidates the identifier minted at the step before.The student's DID and the consortium's DID resolve through the same algorithm.No migration, no reissuance, no second identifier to reconcile.
The onboarding ladder from individual to consortium

The individual, whether student or independent researcher

The entry cost is a keypair.

bash
# The DID exists the moment this returns. It is the base58 of the raw
# 32-byte public key inside the PEM.
openssl genpkey -algorithm ed25519 -out my-dataset.pem

No transaction, no account, no permission. The student cites did:bio:devnet:... in their thesis. Any resolver in the world returns the generative document, and any signature they make over their data verifies against the identifier itself. When they later join a lab, publish, graduate, and change institutions twice, the identifier does not care. It was never affiliated with anything but a key.

This is the gap closing move, participation before publication. The system meets people where research actually begins. When the dataset is published, the student deposits it with Zenodo or Dataverse, registers the DID and adds the DOI as a service, so the identifier that named the work from its first day now points at the citable version.

The lab

A lab's needs are modest but real. It wants metadata endpoints, shared datasets, and more than one person who can act. That is initialize plus a handful of updates.

code
initialize(subject)                      -> account exists, version 1
add_service("metadata", "BioMetadata", "ipfs://bafy...")
add_verification_method(pi_escrow_key)   -> second capabilityInvocation key

Per dataset DIDs keep correlation surface small (one keypair per dataset), and the LastAuthority invariant quietly protects the lab from its own turnover: nobody can remove the final working key while removing a departed member's.

The institution

A university onboards by doing the two things institutions are actually good at, which are paying and providing continuity.

  • Sponsored registration. The library or core facility funds initialize for every researcher, and the payer gains no authority. A thousand DIDs cost the institution about 1.3 SOL of rent, against the membership and per DOI fees of running its own DOI prefix. Budget it as spent, since about 80% of it stays locked in tombstones if the DIDs are ever deactivated.
  • Escrow, honestly scoped. Researchers may add an institutional escrow key as a second capabilityInvocation method, as recovery insurance against lost laptops. The researcher's #default method is protected, so escrow can never remove the researcher's key. Like any authority, though, it could deactivate the DID, so the escrow key deserves the care of a root key. The trust relationship is written into the program rather than into a policy PDF.
  • No lock in, structurally. When the researcher leaves, the DID goes with them, because it was always theirs. The institution removes its escrow key in one instruction, and its involvement ends. Compare the status quo.
Status quoDOI issued by a repositoryOnly for published recordsDrafts on a lab serverProvenance asserted in proseControl is a repository accountTrust the repositoryRegistry backedIdentifier derived from a keyFrom first raw capture onwardSealed, content addressed storageProvenance signed by its keysDOI linked when publishedVerify the signatures
What changes for an institution

Pharma and multisite research

Regulated research is where the registry's properties compound. A multisite trial has a sponsor, several sites, a CRO, and a regulator. Those are five parties who today reconcile provenance through contracts, portals, and PDFs.

controlsgovernsgovernsgovernsderivesverified independentlyTrial protocoldid:bio, versionedSponsorControllerRegulatorRead only verifierSite ADataset DIDSite BDataset DIDSite CDataset DIDCRO analysisDerived, references inputsSubmission bundleEvery DID resolvable
A multisite trial as a controller graph

The registry makes that graph public and versioned:

  • Cross organisation controllers. The protocol DID lists the sponsor's own did:bio in native_controllers and, via other_controllers, DIDs from other methods, such as a partner's did:web. Since release 0.2.0 the native entries carry authority. A capabilityInvocation key of the sponsor's registered DID can update the protocol DID directly, one level deep, so a key the sponsor rotates in reaches every protocol that names it. The other method entries stay declarative, because the program cannot check a did:web. Governance is public, machine readable and versioned. Every change increments version and lands in a DidModified event that any auditor can index.
  • Verification without access. A regulator resolving the submission bundle checks every referenced dataset's DID and every content hash without asking the sponsor for anything. GxP audit trails are usually reconstructed. This one is simply read.
  • Post quantum assertions where they matter. Trials generate data whose confidentiality horizon is measured in decades, which is exactly the "harvest now, decrypt later" window. The registry's Dilithium5 method type carries a final FIPS 204 ML-DSA-87 public key (2592 bytes, the largest thing the account admits) for assertions verified off chain, while on-chain control remains Ed25519 until Solana itself goes post quantum. A key that size does not fit in one Solana tx, so it is uploaded in chunks through a staging account and folded into the document in a final step, five txs behind one command. The design is honest about the boundary, see Post Quantum Security.
  • Deactivation with a paper trail. When a program ends or a key is compromised, the tombstone is a permanent, public record that the identifier was retired, rather than a 404 that an auditor must interpret.

Building applications across the gap

For builders, the registry is deliberately boring to integrate. It is a read path you can trust and a write path you can sponsor.

Resolution is a pure function. The did-bio-core crate implements the resolution algorithm over an optional fetched account, with no network opinions and no SDK lock in.

rust
let did: BioDid = "did:bio:devnet:2T6z...".parse()?;
let resolution = resolve_from_account(&did, fetched.as_ref());
// None | wrong owner | empty  -> generative document, versionId "0"
//                                (notFound for an owned subject)
// Some(account)               -> materialized document, or deactivated

The same logic runs in the CLI resolver, the backend, and the test suite, with parity tests pinning the program ID, discriminators, layout and error codes against the on-chain program.

One caution for verifiers. Consensus authenticates state. Your RPC connection authenticates the response. The read path risk that matters is withholding - a node claiming the account does not exist so resolution falls back to the generative document, hiding a rotation or a deactivation. Production verifiers use finalized commitment, cross check independent providers, and treat a generative answer for a DID previously seen at versionId > 0 as the alarm it is. The Quorum reader in did-bio-core does the cross check, accepting an account only when a majority of the providers it asks return it identically.

On top of those two paths, the surrounding stack composes per audience. That stack is capability scoped sharing with UCAN tokens, encrypted IPFS storage, and Dataverse bridging. A student notebook plugin needs nothing but a keypair and the resolver. An institutional dashboard adds sponsored initialize and escrow management. A consortium adds controller governance and audit indexing over the three events. None of these applications needs permission from us or from each other, which is the entire point of putting the registrar on a public chain. The registry is infrastructure rather than a product you integrate with, and infrastructure is what closes gaps.

The shape of the argument

Strip the details and the post says one thing. Free repositories fixed access to identifiers for published work. They did not give researchers an identifier of their own, one that exists from the first raw capture and carries the keys to sign for it. Put that identifier under two thousand lines of Rust whose rules are enforced by global consensus, and issuance becomes free, instant and universal, while everything institutions and repositories genuinely provide, funding, continuity, governance, escrow and the citable DOI, attaches on top, scoped by invariants that make betrayal a compile error rather than a breach of contract.

A student's thesis dataset and a consortium's trial protocol resolve through the same nine steps. That symmetry is not a demo simplification. It is the design.


The program specification lives in the Solana Registry Program docs, and the method itself in did:bio Identifiers. Source: ekayana-labs/bio-did-registry.

Ready to Get Started?

Explore our documentation to learn how to integrate Ekayana into your research workflow.