atproto·ootle
A trustless identity layer

Prove a Bluesky account inside an Ootle contract.

A reusable layer that verifies atproto identity and records entirely in WASM — the PLC chain, the signed repo, the Merkle path to a post — with no oracle, no relayer with authority, and no zero-knowledge circuit. Your contract learns a fact about a real Bluesky account and acts on it.

atp-verify 18/18 Verifier 3/3 Escrow 3/3 ClaimPolicy 3/3 Esmeralda testnet
What it is

Identity the chain can check itself

Bluesky accounts are anchored in cryptography the contract can reproduce. A did:plc is the hash of a signed genesis operation; the operation log yields the account's repo signing key; the repo commit is signed by that key and commits to a Merkle search tree; the path to a record authenticates its bytes. The layer walks that chain in WASM, so the proof stands on the account's own signatures — nothing else.

no oracle

Self-contained proofs

A claim carries the identity history, the signed commit and the MST path. Every signature is checked on-chain; no off-chain attester is trusted.

reusable

One layer, many contracts

A shared crate and an immutable Verifier component. Build a new feature against it without re-implementing any atproto crypto.

bounded

Hardened for a VM

Low-S signatures, hash-linked MST, depth and size caps on every input, DAG-CBOR with no indefinite lengths. Audited; see the trust boundaries below.

Verification menu

Everything a contract can prove

The layer exposes everything that is anchored and therefore provable trustlessly in WASM. The honest boundary matters: some atproto facts live in DNS or a PDS and cannot be proven on-chain — those are marked, and need an explicit attestation rather than a misleading check.

CapabilityWhat it provesStatus
verify_chainA did:plc operation log is valid and yields the current repo signing key.trustless
prove_recordThe account's repo contains a specific record at collection/rkey.trustless
prove_record_fieldProves a record and reads one field atomically — a post's text, a like's subject, any lexicon field = value.trustless
asserts_handleThe DID→handle claim (alsoKnownAs), anchored by the signed chain.trustless
verify_service_authA PDS service-auth JWT signed by the repo key; returns iss / aud / exp / lxm.trustless
handle → DIDThat a handle resolves back to a DID. Lives in DNS / HTTPS; WASM cannot fetch it.attestation
did:webA did:web document. Trust rests on TLS/DNS control of a domain, not a hash.attestation
account statusActive / deactivated / takedown — PDS hosting state, not in the signed repo.attestation
Architecture

Custody, verification, and features — kept apart

Ootle templates are immutable and a component is bound to its template for life. So the component that holds funds must never need new code: everything that changes lives in policies the vault delegates to. A new feature is a new policy plus one badge — the escrow is never redeployed, and no deposit is ever orphaned by an upgrade.

permanentCustodyEscrowHolds funds keyed by account id. Releases only to the badge-holding policy pinned on a deposit. Unconditional refund to the sender — the safety valve.
sharedIdentityVerifierThe atp-verify crate (hot path, compile-time) and an immutable Verifier component for third parties. All atproto crypto lives here, once.
pluggableFeaturesPoliciesClaimPolicy, TipPolicy, LinkRegistry… Each verifies with the layer and drives release with a proof of its policy badge.

Release is authenticated the Radix/Ootle way: the engine can't tell a method who called it, so each approved policy holds a unique NFT badge. release takes a Proof of it, reads the badge's id, and confirms it matches the deposit's pinned policy — a policy can only ever move its own deposits.

The Escrow interface

Small and frozen on purpose. These are the only methods that move funds; a policy never holds the escrowed vault, only its badge.

deposit(account_id, sender, refund_epoch, policy, metadata, funds)

Escrow funds for an account, pinned to the governing policy. metadata is opaque bytes the policy defines — the escrow never reads it.

release(badge: Proof, account_id, index) → Bucket

Pay out one deposit. Authorized by a proof of the policy's badge; the badge id must equal the deposit's pinned policy, so no policy can touch another's funds.

refund(account_id, index) → Bucket

Return a deposit to its sender after its refund epoch. Needs no badge — the safety valve that works even if a policy is buggy or retired.

approve_policy(policy) → Bucket

Governance only (admin signature). Mints the policy's unique badge NFT; route the bucket into the policy's receive_badge.

deposits_for(account_id) → Vec<Deposit>

Every deposit for an account across all policies — the single place clients read, so funds are never stranded in a superseded component.

Authorizing a policy

A new feature joins the system in four steps. After this the policy can release its own deposits forever, with no further escrow changes.

1

Deploy the Escrow once

Escrow::new(resource, min_deposit, admin). This is permanent; every future feature reuses it.

2

Deploy your policy

MyPolicy::new(escrow, escrow.policy_badge_resource()) — the policy makes an empty vault for the badge it's about to receive.

3

Governance approves it

The admin calls escrow.approve_policy(my_policy) and routes the returned badge into my_policy.receive_badge(badge) in one transaction.

4

It's live

Senders can now escrow deposits pinned to the policy, and the policy releases them by presenting its badge. No escrow redeploy, and existing deposits never move.

The badge is a bearer token. Authority rests on holding the badge, so it stays in the policy's vault and is never withdrawn — the standard capability pattern. Governance vets a policy's code before approving it, because an approved policy can release the deposits pinned to it (never another policy's, never past refund).

Deposits & metadata

A deposit is (sender, amount, refund_epoch, policy, metadata). The escrow stores it and checks the resource, minimum and refund epoch; everything feature-specific lives in metadata, which only the policy interprets. ClaimPolicy, for example, encodes the challenge window as a big-endian u64:

// sender side: a 3-epoch challenge window
let metadata = Bytes::from_vec(3u64.to_be_bytes().to_vec());

// policy side: read it back, empty/short means 0 = pay immediately
fn window_of(m: &Bytes) -> u64 {
    let b = m.as_slice();
    if b.len() >= 8 { u64::from_be_bytes(b[..8].try_into().unwrap()) } else { 0 }
}
Integration guide

Build on the layer

Two ways in. Link the atp-verify crate directly for the cheap hot path, or call the shared Verifier component from any contract. Then gate your own logic on the result.

1

Verify a post in your own template (crate)

Depend on atp-verify and prove a record is in the account's repo. Any failure aborts the transaction; after it returns, the record bytes are authentic.

// Cargo.toml: atp-verify = { path = "…" }
let chain = atp_verify::verify_plc_chain(&did, &ops).expect("invalid chain");
atp_verify::verify_record(
    &did, &chain.signing_key, commit, &mst_nodes, record,
    "app.bsky.feed.post", &rkey,
).expect("record not in repo");

// now trust the bytes: read any lexicon field
let text = atp_verify::record_text(record)?;
let subject = atp_verify::field_str(record, &["subject", "uri"])?;
2

Or call the Verifier component (any contract)

Pin the deployed Verifier's address. prove_record_field verifies and extracts in one call, so you never read an unverified field.

let uri: String = ComponentManager::get(verifier)
    .call("prove_record_field", args![
        did, ops, commit, mst_nodes, record,
        "app.bsky.feed.like", rkey, vec!["subject", "uri"],
    ]);
// uri is now proven to come from a like in `did`'s repo
3

Escrow against a policy, release with a badge

Deposits pin to the policy that governs them. Your policy verifies, then presents a proof of its badge to release — the escrow checks the badge id against the pinned policy.

// a sender escrows, pinned to your policy, metadata is yours to define
escrow.deposit(account_id, sender, refund_epoch, my_policy, metadata, funds);

// inside the policy, after verification succeeds:
let proof = self.badge_vault.create_proof();
let paid: Bucket = ComponentManager::get(self.escrow)
    .call("release", args![proof, account_id, index]);

A complete policy, end to end

Here is a whole feature in one template: LikeGate releases a deposit to whoever proves they liked a specific post. It shows every moving part — reading the pinned deposit, verifying a repo record, checking a field, and releasing with the badge. The target post is carried in the deposit's metadata, so one policy instance serves every campaign.

like_gate/src/lib.rs

// Deposit tuple mirrored from the escrow so deposits_for decodes:
// (sender, amount, refund_epoch, policy, metadata)
type Deposit = (RistrettoPublicKeyBytes, Amount, u64, ComponentAddress, Bytes);

#[template]
mod like_gate {
    use super::*;

    pub struct LikeGate { escrow: ComponentAddress, badge_vault: Vault }

    impl LikeGate {
        pub fn new(escrow: ComponentAddress, badge: ResourceAddress) -> Component<Self> {
            Component::new(Self { escrow, badge_vault: Vault::new_empty(badge) })
                .with_access_rules(AccessRules::allow_all()).create()
        }

        pub fn receive_badge(&mut self, badge: Bucket) { self.badge_vault.deposit(badge); }

        // Claimant proves a like record in `did`'s repo whose subject is the target post.
        pub fn claim(&mut self, did: String, claimant: RistrettoPublicKeyBytes,
                     ops: Vec<Bytes>, commit: Bytes, mst_nodes: Vec<Bytes>,
                     record: Bytes, rkey: String, index: u32) -> Bucket {
            CallerContext::get_signer_proof_for_public_key(claimant).drop();

            // 1. the pinned deposit names the post that must be liked
            let deps: Vec<Deposit> = ComponentManager::get(self.escrow)
                .call("deposits_for", args![did.clone()]);
            let target = String::from_utf8(deps[index as usize].4.to_vec()).unwrap();

            // 2. verify the like record really is in the account's signed repo
            let ops_s: Vec<&[u8]> = ops.iter().map(|b| b.as_slice()).collect();
            let nodes: Vec<&[u8]> = mst_nodes.iter().map(|b| b.as_slice()).collect();
            let chain = atp_verify::verify_plc_chain(&did, &ops_s).expect("bad chain");
            atp_verify::verify_record(&did, &chain.signing_key, commit.as_slice(),
                &nodes, record.as_slice(), "app.bsky.feed.like", &rkey).expect("bad proof");

            // 3. the like must point at the target post
            let subject = atp_verify::field_str(record.as_slice(), &["subject", "uri"]).expect("no subject");
            assert!(subject == target, "that like is for a different post");

            // 4. release, proving our badge
            let proof = self.badge_vault.create_proof();
            ComponentManager::get(self.escrow).call("release", args![proof, did, index])
        }
    }
}

Bounties: like, repost, follow

A bounty is the same idea scaled to many claimants: the funder escrows a pool of equal slots under one campaign id, each slot's metadata holding the engagement kind and the target. On atproto a retweet is a repost; likes, reposts and follows are all signed records with a subject, so one policy covers them with a kind→collection map. Two rules make it safe: one claim per account (dedupe by DID), and a claim-code post that binds the payout to the engager's own wallet — so a bystander can't claim someone else's repost to their address.

engagement_bounty/src/lib.rs (the claim method)

fn collection(kind: u8) -> &'static str {
    match kind { 0 => "app.bsky.feed.like", 1 => "app.bsky.feed.repost",
                 2 => "app.bsky.graph.follow", _ => panic!("unknown kind") }
}

pub fn claim(&mut self, campaign: String, did: String, claimant: RistrettoPublicKeyBytes,
             ops: Vec<Bytes>,
             e: Proofs,   // the engagement record (like / repost / follow)
             c: Proofs)   // a claim-code post that binds the payout
             -> Bucket {
    CallerContext::get_signer_proof_for_public_key(claimant).drop();

    // one claim per account per campaign
    let done = self.claimed.entry(campaign.clone()).or_default();
    assert!(done.insert(did.clone()), "this account already claimed this bounty");

    // take the next free slot; its metadata = [kind byte][target uri]
    let me = CallerContext::current_component_address();
    let deps: Vec<Deposit> = ComponentManager::get(self.escrow).call("deposits_for", args![campaign.clone()]);
    let (idx, meta) = deps.iter().enumerate().find(|(_, d)| d.3 == me)
        .map(|(i, d)| (i as u32, d.4.clone())).expect("bounty is exhausted");
    let kind = meta.as_slice()[0];
    let target = String::from_utf8(meta.as_slice()[1..].to_vec()).unwrap();

    let chain = atp_verify::verify_plc_chain(&did, &slices(&ops)).expect("bad chain");

    // 1. claim-code post binds the payout to this claimant's key (no front-running)
    atp_verify::verify_record(&did, &chain.signing_key, c.commit, &c.nodes, c.record,
        "app.bsky.feed.post", &c.rkey).expect("bad code proof");
    assert!(atp_verify::record_text(c.record)?.contains(&claim_code(&me.to_string(), &did, &claimant)),
        "missing claim code");

    // 2. the engagement itself points at the campaign target
    atp_verify::verify_record(&did, &chain.signing_key, e.commit, &e.nodes, e.record,
        collection(kind), &e.rkey).expect("bad engagement proof");
    let subject = match kind {
        2 => atp_verify::field_str(e.record, &["subject"])?,          // follow: subject is a DID
        _ => atp_verify::field_str(e.record, &["subject", "uri"])?,   // like / repost: subject.uri
    };
    assert!(subject == target, "engagement is not for the campaign target");

    // 3. release one slot to the bound claimant
    let proof = self.badge_vault.create_proof();
    ComponentManager::get(self.escrow).call("release", args![proof, campaign, idx])
}

The funder tops the pool up by escrowing more slots with the same metadata; when the slots run out, the bounty is closed and any left are refundable after their epoch. A time-boxed campaign just sets that refund epoch to the deadline.

The claim lifecycle

Policies that need a dispute period (like Skydrop's) split the claim in two and add a challenge. A PLC log cannot be proven complete, so a window lets a newer, rotated-away key cancel a stale claim before payout.

start_claim(did, claimant, proof…)

Verify the coded post and record a pending claim at the current epoch, stamped with the chain's length and tip.

challenge(did, ops)

Anyone, during the window: present a longer or different valid chain to cancel the pending claim. Guards against a stolen, already-rotated key.

finalize_claim(did) → Bucket

Release every deposit whose window has elapsed since the claim started, merged into one bucket. Deposits still inside their window stay escrowed.

// finalize: release each payable deposit, newest index first so removals
// don't shift the ones still waiting, then merge with Bucket::join.
let mut payout: Option<Bucket> = None;
for i in payable_indices {        // now >= start + window_of(metadata)
    let proof = self.badge_vault.create_proof();
    let b: Bucket = ComponentManager::get(self.escrow).call("release", args![proof, did.clone(), i]);
    payout = Some(match payout { Some(acc) => acc.join(b), None => b });
}
Trust boundary to honor. Everything the layer trusts is bound by a signature or a hash. The CBOR reader does not enforce canonical form on purpose — it is safe only because non-canonical bytes let an account mangle its own data, never forge another's. Any new path that reads a field without a covering signature or hash breaks that guarantee.
What you can build

Any contract that should trust a Bluesky fact

The layer turns an account, a post, an engagement or a signed token into something a contract can check. Each case below is a policy over the same permanent escrow, named with the primitives it leans on.

Gift before a wallet exists

Send funds to a did:plc; the owner collects later by proving control of the account. This is Skydrop.

verify_chain · prove_record · challenge window

Proof-of-post bounty

Pay out when an account publishes a post containing a required phrase — a launch announcement, a referral code, a public commitment.

prove_record · record_text

Engagement bounty

Pay a pool to everyone who likes, reposts (retweets) or follows a target — one claim per account, payout bound to each engager's own wallet. See the worked example below.

prove_record_field on feed.like / feed.repost / graph.follow

Handle allowlist

Admit or airdrop only to accounts that assert a handle in a domain you trust (e.g. *.yourorg.com), straight from the signed chain.

asserts_handle

Pay-per-call with service auth

Release a metered payment against a PDS service-auth JWT, checking aud, lxm and exp so a token authorizes exactly one method before it expires.

verify_service_auth

Tip by post

Let anyone tip a Bluesky user by replying to a bot; the signed post is the authority, so a relayer can't forge or alter the amount or recipient.

prove_record · a tip-post parser · a spend budget

Link an identity to a wallet

Bind a DID to an Ootle account with one signed post, so future payouts go straight to the linked wallet — a shared registry other policies read.

prove_record · a bind code · delayed takeover

Social membership & recovery

Gate a DAO role or a recovery path on verified identity, with a challenge window that lets a rotated key override a stale proof.

verify_chain · asserts_handle · challenge

Worked example

Skydrop — send Tari to a Bluesky account

Skydrop is the first contract built on the layer. You send tTARI to someone's did:plc before they have an Ootle wallet. They collect by posting a one-line claim code and proving, in the contract, that they control the account. No one in the middle can take the money.

deposit

Escrow to a DID

Funds are escrowed under the recipient's permanent identifier, governed by ClaimPolicy, with a challenge window the sender chooses.

claim

Prove and collect

The recipient posts tari-claim:…, then submits the identity chain, signed commit and MST path. The contract verifies and records the claim.

challenge

Rotations win

A PLC log can't be proven complete. During the window, anyone can submit a newer chain from a rotated-away key to cancel a stale claim.

Skydrop is live at skydrop.tari.mw. It is exactly one policy on the shared escrow. Tips, wallet linking and private payouts are each another policy — added without redeploying the vault or moving a single escrowed deposit.

Open by default

Tari ❤ atproto

Tari backs open protocols. Identity you own, data you can carry with you, and proofs anyone can check — atproto and Tari share that bet. So the bridge between them is trustless too: no gatekeeper, no oracle, just signatures the chain verifies for itself.