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.
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.
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.
A shared crate and an immutable Verifier component. Build a new feature against it without re-implementing any atproto crypto.
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.
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.
| Capability | What it proves | Status |
|---|---|---|
| verify_chain | A did:plc operation log is valid and yields the current repo signing key. | trustless |
| prove_record | The account's repo contains a specific record at collection/rkey. | trustless |
| prove_record_field | Proves a record and reads one field atomically — a post's text, a like's subject, any lexicon field = value. | trustless |
| asserts_handle | The DID→handle claim (alsoKnownAs), anchored by the signed chain. | trustless |
| verify_service_auth | A PDS service-auth JWT signed by the repo key; returns iss / aud / exp / lxm. | trustless |
| handle → DID | That a handle resolves back to a DID. Lives in DNS / HTTPS; WASM cannot fetch it. | attestation |
| did:web | A did:web document. Trust rests on TLS/DNS control of a domain, not a hash. | attestation |
| account status | Active / deactivated / takedown — PDS hosting state, not in the signed repo. | attestation |
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.
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) → BucketPay 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) → BucketReturn 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) → BucketGovernance 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.
Escrow::new(resource, min_deposit, admin). This is permanent; every future feature reuses it.
MyPolicy::new(escrow, escrow.policy_badge_resource()) — the policy makes an empty vault for the badge it's about to receive.
The admin calls escrow.approve_policy(my_policy) and routes the returned badge into my_policy.receive_badge(badge) in one transaction.
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.
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 }
}
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.
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"])?;
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
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) → BucketRelease 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 });
}
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.
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
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
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
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
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
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
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
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
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.
Funds are escrowed under the recipient's permanent identifier, governed by ClaimPolicy, with a challenge window the sender chooses.
The recipient posts tari-claim:…, then submits the identity chain, signed commit and MST path. The contract verifies and records the claim.
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.
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.