Cairn mascot — a stack of colorful pixel-art stones
CAIRNdocs
· the long version

How Cairn works,
explained properly.

The homepage is the elevator pitch. This page is the whiteboard. Same project, more details, fewer slogans. If you read all of it, you'll understand exactly what trust Cairn removes from your supply chain — and what it can't.

// why this exists

What's broken about installing code today

Every time you run npm install or pip install, you're trusting a long chain of strangers. The registry company. Their employees. Every maintainer of every package in your tree. The CDN their files happen to be on today. The DNS resolver pointing you there. Any one of them can lie, and you have basically no way to notice.

That trust gets abused, regularly, in three ways most developers now know by name:

  • ·Typosquatting. Someone registers colorss next to colors, ships malicious code, and waits for the typo. Names look identical to humans; installs don't care.
  • ·Versions that mutate. The bytes behind foo@1.0.4 the day you audited it are not necessarily the bytes behind foo@1.0.4 next Tuesday. Lockfiles help, but only if you trust the registry to honor them.
  • ·Stolen or sold accounts. A maintainer's session gets phished, or someone buys a burnt-out solo dev's GitHub for a few hundred dollars, and the next release of a library you've trusted for five years is suddenly hostile.

None of this is exotic. It happens in production every quarter, gets a CVE, a blog post, a couple of postmortems, and then the next one shows up. The reason it keeps happening isn't bad engineering — it's that the whole system runs on names you trust people to keep honest. And then agents showed up.

Agents install fast. An autonomous coding agent might pull in fifty dependencies in a workflow, look at none of them, and never raise an eyebrow when one of them is wrong. The threat model that humans tolerate falls apart when there's no human in the loop.

Cairn is a different shape of registry, designed so the three attacks above just don't work. Not “mitigated” — actually don't work. The rest of this page is how.

// the four pieces

Cairn is four small things, stacked

The whole protocol is four parts. None of them is novel on its own — that's on purpose. Boring crypto, boring contracts, boring data structures. The interesting bit is how they compose.

· content

A CID per release

Hash the bytes. The hash is the address. Same bytes, anywhere, any time, give the same CID. That's it.

· who you are

A did:cairn identity

An ed25519 keypair. The public key, base32-encoded, is your identity. No accounts to create, nothing to sign up for.

· what gets signed

A short manifest

Six fields of JSON tying name, version, CID, your DID, and the time together. You sign it once. Anyone can verify.

· the registry

A tiny Base L2 contract

~200 lines of Solidity. Maps name@version → cid. Writes are permanent. Anyone can publish.

The mesh layer that actually delivers the bytes between people is a fifth thing, but it's deliberately not in the trust circle. You can swap it out for IPFS, Filecoin, raw HTTPS, a USB stick — and Cairn still works. We'll come back to that.

// hashes as addresses

The address is the proof

A Content Identifier is a short string that you derive deterministically from a blob of bytes. Hash the bytes; encode the hash; that's the CID. Hand someone a CID, and they can verify whether any blob claiming to be it actually is, just by re-hashing locally.

Cairn uses CIDv1 with SHA-256 and the raw codec. It's a deliberately minimal pick. SHA-256 is everywhere, fast, and not collision-prone in practice. The raw codec means we treat your release as opaque bytes — no IPFS chunking, no IPLD wrapping, nothing to argue about. The hash is the file's identity.

typescriptweb-app/lib/cid.ts
import { CID }    from "multiformats/cid";
import { sha256 } from "multiformats/hashes/sha2";
import * as raw   from "multiformats/codecs/raw";

export async function bytesToCID(bytes: Uint8Array): Promise<string> {
  const hash = await sha256.digest(bytes);
  const cid  = CID.createV1(raw.code, hash);
  return cid.toString();
  // → "bafkreihv3vjpx2c5qbb6...rfqp4"
}

Forty-something characters of base32. Easy to log, easy to paste, URL-safe, case-insensitive in the bits you can mess up. The Cairn client never trusts the network to deliver the right bytes — it trusts arithmetic.

Once you know the CID, the registry can be slow, the mirror can lie, the CDN can hand you a poisoned file. You'll notice on the next line of code. That's the whole game.
// who you are

No accounts, just keys

An author is an ed25519 keypair. The public key, encoded in base32 and prefixed did:cairn:, is your identity. That's the whole sign-up flow.

text
did:cairn:z6MkuWqTNwHWxgY6JKaPxQYwL3rT8b9vKsRBL2nE7wQ4

Anyone who knows the DID also knows the public key (it's literally encoded in the string), so anyone can verify a signature against it. There's no DID resolver to hit, no registry to consult, no account to be deactivated by some upstream company. The DID exists the moment your keypair exists, and stops existing the moment you forget about it.

Right now the dApp keeps your key in browser localStorage. That's fine for a demo, terrible for anything you care about. The proper CLI (on the roadmap) will move keys into your OS keychain and support hardware-backed signers — YubiKey, Secure Enclave, Ledger.

!
Keys are your problem. Lose the private key and you lose the ability to publish under that DID. The Base contract pins ownership of the name to the wallet that did the first publish, so a wallet rotation is recoverable; the DID layer attests who actually pressed the button, and that one is on you.
// what gets signed

The manifest is six fields and a signature

Every release ships a tiny JSON file alongside the bytes. This is the thing your DID actually signs.

jsoncairn manifest v1
{
  "spec":        "cairn/v1",
  "name":        "pixel-cairn",
  "version":     "0.2.1",
  "cid":         "bafybeicairn0003pixelmascotreleaseccccccccccccccccccc",
  "did":         "did:cairn:z6Mki…7wQ4",
  "publishedAt": "2026-05-22T14:31:07Z"
}

The tricky bit is the canonicalization. A signature is over bytes, and JSON can be serialized 100 ways for the same logical object — different key order, different whitespace, different number formatting. So we define one canonical form and stick to it: explicit key order, no whitespace, no surprises. Two compliant implementations will produce the exact same bytes from the same manifest.

typescriptweb-app/lib/manifest.ts
export function canonicalize(m: CairnManifest): Uint8Array {
  // explicit key order — never trust Object.keys() insertion semantics
  const ordered = {
    spec:        m.spec,
    name:        m.name,
    version:     m.version,
    cid:         m.cid,
    did:         m.did,
    publishedAt: m.publishedAt,
  };
  return new TextEncoder().encode(JSON.stringify(ordered));
}

The signature travels next to the manifest, not inside it. Keeps the manifest readable, and makes the signed bytes exactly the manifest bytes — no envelope to strip before verifying.

// the on-chain bit

A contract that does one thing

CairnRegistry.sol lives on Base. It maintains an append-only map from name@version to cid, plus the author address and a timestamp. That's the whole contract. Anyone can call publish(), anyone can call resolve(), nobody can edit a release after the fact.

soliditycontracts/CairnRegistry.sol
function publish(
    string calldata name,
    string calldata version,
    string calldata cid
) external {
    if (bytes(name).length    == 0) revert EmptyName();
    if (bytes(version).length == 0) revert EmptyVersion();
    if (bytes(cid).length     == 0) revert EmptyCid();

    address currentOwner = _owners[name];
    if (currentOwner == address(0)) {
        // first publish — claim the name
        _owners[name] = msg.sender;
        _packages.push(name);
    } else if (currentOwner != msg.sender) {
        revert NameTaken(currentOwner);
    }

    Release storage existing = _releases[name][version];
    if (existing.exists) revert VersionAlreadyPublished();

    uint64 ts = uint64(block.timestamp);
    _releases[name][version] = Release({
        cid: cid, author: msg.sender, timestamp: ts, exists: true
    });
    _versions[name].push(version);

    emit PackagePublished(name, version, cid, msg.sender, ts);
}

If you remove all the comments, there are three real rules:

  1. First person to publish a name owns it from then on.
  2. Only the owner can publish further versions under that name.
  3. A given name@version can be written exactly once. Republishing the same pair reverts.

Read side, for the dApp and anyone who wants to index:

solidity
function resolve(string calldata name, string calldata version)
    external view returns (string memory cid, address author, uint64 timestamp);

function ownerOf(string calldata name)         external view returns (address);
function versionsOf(string calldata name)      external view returns (string[] memory);
function packageCount()                        external view returns (uint256);
function packageAt(uint256 index)              external view returns (string memory);

And one event so off-chain indexers can keep up:

solidity
event PackagePublished(
    string  indexed name,
    string          version,
    string          cid,
    address indexed author,
    uint64          timestamp
);
Why Base, and not L1? Names are low-frequency, high-value writes — maybe a handful per maintainer per month. L1 is ~30× more expensive for the same guarantees; off-chain registries (DNS, ENS) push trust back to a registrar. Base is the sweet spot in 2026.
// where the bytes live

The mesh delivers; it doesn't decide

The registry tells you which bytes to fetch (by CID). It doesn't store the bytes themselves — that'd be expensive, slow, and pointless. The bytes live on a mesh.

In the alpha, the mesh is a local simulation. We label it as such everywhere it shows up in the UI (the “alpha · simulated telemetry” chip on the live mesh panel, the // STUB: markers in lib/cid.ts). In production the same interface plugs into any of:

  • IPFS or Filecoin via a pinning service (Pinata, web3.storage)
  • A libp2p DHT with Bitswap retrieval
  • Plain HTTPS gateways — basically a CDN
  • A self-hosted mirror for air-gapped or audit-sensitive setups
!
The mesh can lie, drop your request, or replay yesterday's bytes. None of that matters. The client re-hashes whatever shows up and throws away anything that doesn't match the on-chain CID. A malicious mirror can refuse you service. It can't corrupt you.
// what happens on publish

Five steps, in order

When you publish a release, here's what the client actually does:

  1. step 1 · hashWalk your build output, serialize it deterministically (tarball with sorted entries and zeroed mtimes), SHA-256 the result, encode as CIDv1.
  2. step 2 · manifestAssemble the v1 manifest — name, version, CID, your DID, UTC timestamp — and canonicalize it.
  3. step 3 · signed25519-sign the canonical bytes with your private key. The signature is detached and travels next to the manifest.
  4. step 4 · pinPush the bytes to the mesh. Wait for some number of mirrors to acknowledge they have the CID. (Alpha: this is simulated. Real: Pinata or libp2p.)
  5. step 5 · registerSend one transaction: publish(name, version, cid). On confirmation, the name → CID mapping is permanent on Base.

Steps 1 through 4 are local or peer-to-peer. Step 5 is the only one that touches a shared system, and it's permissionless — anyone with gas can publish.

// what happens on install

Five steps, in reverse

When you (or an agent) run cairn install pixel-cairn@0.2.1:

  1. step 1 · lookupCall registry.resolve("pixel-cairn", "0.2.1") on Base. Get back (cid, author, timestamp). One read, often cached.
  2. step 2 · fetchAsk the mesh for the CID. Any node will do. Where the bytes come from doesn't matter — yet.
  3. step 3 · re-hashHash the bytes you received. If the CID doesn't match the one from the registry, drop them and try a different mirror.
  4. step 4 · verifyFetch the manifest (also content-addressed). Re-canonicalize. Check the ed25519 signature against the DID it claims to be signed by.
  5. step 5 · installOnly after all of the above does anything land on disk.

Every check above is local. The agent doesn't need to trust any server, any maintainer, or any mirror. It needs to trust the registry contract (open source, verifiable on Basescan), SHA-256, and ed25519. That's the entire trust base.

// what this protects you from

The honest split

Cairn is not a panacea. It shrinks the trust surface; it doesn't eliminate it. Here's the split, written plainly:

things that just stop working

  • ✓ Typosquatting — a name maps to one CID, ever
  • ✓ Silent version mutation — versions are write-once on-chain
  • ✓ Compromised mirror serving bad bytes — caught on re-hash
  • ✓ MITM in transit — same reason
  • ✓ Registry server takedown — there is no registry server
  • ✓ Platform-account hijack — there is no platform account

things you still have to worry about

  • ✗ Someone steals your private key — they are now you
  • ✗ Your wallet gets drained — the new owner now owns your names
  • ✗ Maintainer ships intentionally bad code — Cairn proves who shipped it, not that it's safe
  • ✗ Bugs in the contract — audit pending
  • ✗ Base going down — reads keep working from cache; writes pause

The point is to make the right column small and recoverable, and the left column impossible. Today's registries have the same right column (or worse), plus a left column that includes “whoever happens to work at the registry company this week.” Removing that one entry is most of what Cairn is for.

// why agents care

A note about machines doing this at machine speed

Humans, mostly, don't actually audit their dependencies. We pin versions, we glance at the lockfile, we trust the maintainer's reputation, we move on. It works because the tempo is slow enough that catastrophic mistakes get caught eventually.

An agent does not have that tempo. Give an autonomous coding agent a task and it will happily pull fifty packages, require the wrong one, and never notice. There's no human to glance at anything.

Cairn gives an agent two things it couldn't otherwise have cheaply:

  • 1.Reproducibility for free. Pin a CID and you get the same bytes forever, anywhere. Lockfile drift just stops happening.
  • 2.Verification it can actually afford. Hashing fifty megabytes is microseconds. Verifying an ed25519 signature is microseconds. The agent can do both on every single install without you noticing in the bill.

On the roadmap there's an MCP server — cairn.resolve, cairn.verify, a couple more — so any MCP-capable agent (Claude, Cursor, your in-house orchestrator) gets all of this with no glue code.

// what's real, what isn't

The honesty note

We're at v0.2.1-alpha. The split below is the truth, so you know what you're looking at when you click around.

real, today

  • · The Solidity registry, deployable to Base + Base Sepolia
  • · Hardhat tests covering publish, resolve, immutability, ownership
  • · CID hashing in the dApp via multiformats
  • · ed25519 signing + verifying via @noble/ed25519
  • · Persistent did:cairn identity in localStorage
  • · Real on-chain publish using wagmi + viem + RainbowKit

simulated, alpha

  • · The “mirrored to N nodes” mesh layer
  • · Live node telemetry on the marketing page
  • · Pinata / Filecoin / libp2p integration (stubbed)
  • · The CLI binary and MCP server (not built yet)

Every stubbed call carries a // STUB: comment in the source with a description of what real infrastructure plugs in. The UI labels itself as alpha wherever the mesh is mocked.

// questions people ask

FAQ

Why Base and not Ethereum mainnet?

Names are low-frequency, high-value writes — a handful per maintainer per month. L1 would be ~30× more expensive for identical guarantees. Off-chain alternatives like DNS or ENS push trust back to a registrar. Base hits the right tradeoff in 2026.

What stops me from publishing under someone else's name?

The contract does. ownerOf(name) is set on first publish; every subsequent publish must come from the same address, or it reverts with NameTaken. There's no admin override.

Can I delete a release?

No, and that's the whole point. Once a name@version is on-chain, its CID is set forever. If a release is broken or compromised, the move is to publish a fixed version — and let downstream consumers explicitly upgrade. Deletion would be a hole in the immutability guarantee, so we don't have one.

What if the bytes are lost from the mesh?

The on-chain CID is still there. Anyone who has the bytes can re-pin them and the package becomes installable again. Cairn separates naming (permanent) from delivery (replaceable) on purpose — losing bytes is annoying, but recoverable.

Isn't this just IPFS with extra steps?

IPFS solves byte delivery. It doesn't solve naming, ownership, or version commitment — and those are the parts that actually matter for a supply chain. Cairn layers an on-chain name registry with first-publisher-wins semantics and a signed-manifest convention on top of whatever delivery layer you like. You can run Cairn on IPFS. You can run it on Filecoin, libp2p, or HTTPS gateways. The trust story doesn't change.

What about npm compatibility?

A compatibility shim is on the Q3 2026 roadmap. The idea is a local proxy: npm install reads from Cairn, npm publish writes to Cairn, and the two namespaces map cleanly. You'd be able to migrate package-by-package without breaking anyone downstream.

Is this audited?

Not yet. The contract is small (~200 lines, no proxies, no upgradeability, no admin) which makes auditing tractable, but until a third party signs off, don't trust it with anything you can't afford to lose. We'll publish the audit report when there is one.

Cairn mascot — a stack of colorful pixel-art stones

That's the whole protocol.

Four pieces, three rules in the contract, one promise. Everything else is just engineering.