Perch
A policy layer for Soroban smart accounts.
What each key may do — written down, hashed, enforced.
Willem Wyndham · github.com/stellar-registry/perch
→ next ← back · or click a screen edge · 25 slides
02 · The problem everyone has
Every operational key does too much.
A wallet session key. A treasury payout key. An AI agent's tool key. A CI key that ships wasm to the registry. Each one should do exactly one narrow thing — yet on Stellar today, scoping a key that tightly means writing and auditing a smart contract. We'll follow one concrete example the whole way — a robot key in GitHub Actions that publishes releases — where all we want the account to guarantee, on-chain, is:
This key may call publish on the registry contract, as the account — and nothing else.
Today that's a contract to write, audit, and deploy. With perch it's a JSON file you can read in one sitting.
03 · The cast
Three characters.
A smart account
A contract that is itself an account: it decides in code what it approves. Ours owns the unverified/perch name on the registry and is the author of every release.
The registry contract
Where wasm releases get published. It has the method we want the robot to call — publish — and methods we never want it near: set_manager, and friends.
The robot key
An ordinary Stellar keypair — an address starting with G. Its secret half sits in GitHub Actions secrets. Assume it can leak; plan for the day it does.
04 · The hard part
Authorization is a function the account writes.
On Stellar, a contract account has no built-in notion of "allowed". Whenever something claims the account's authority, the host — the Stellar runtime that executes contracts — calls the account's own function, __check_auth, and asks:
"Here is what's being approved — which contract, which function, which arguments — and here are the signatures. Yes or no?"
Whatever that code doesn't reject, happens. So "only publish" has to live in code somewhere — the question is whose code, and how much of it.
05 · Without perch · attempt 1
Write it yourself.
pub fn __check_auth(e: Env, payload: Hash<32>, sigs: MySigs, contexts: Vec<Context>) -> Result<(), Error> { verify_ed25519(&e, &payload, &sigs)?; // signature math, by hand — get this wrong, lose everything for ctx in contexts.iter() { match ctx { Context::Contract(c) => { if c.contract != registry() { return Err(Error::NotAllowed); } if c.fn_name != symbol("publish") { return Err(Error::NotAllowed); } // …did you remember to check the arguments? the expiry? every other key? } _ => return Err(Error::NotAllowed), // deployments, sub-calls, edge cases… } } Ok(()) }
- Every team writes its own consensus-critical authorization code — the code standing between the chain and the account's money.
- One bug — an unchecked argument, a signature subtlety — and the account is drained.
- Auditors must re-read every account from scratch. Nothing transfers.
06 · Without perch · attempt 2
OpenZeppelin smart accounts: rules as data.
OpenZeppelin's stellar-accounts library replaces hand-written checks with context rules — "who may do what", stored as data inside the account. This is the actual call that installs the robot's rule:
add_context_rule( &env, &ContextRuleType::CallContract(registry), // scope &String::from_str(&env, "ci-publish"), // name Some(55_000_000), // expiry &vec![&env, Signer::Delegated(robot)], // signers &Map::new(&env), // policies )
- scopeWhat the rule covers: calls to one specific contract. Other options: deploying a specific binary, or anything at all.
- expiryAn optional ledger number — a point in chain time (one ledger ≈ a few seconds) — after which the rule stops working.
- signersWho must approve, up to 15. Here: just the robot's ordinary
Gaddress. - policiesOptional extra checks, packaged as separate contracts the account calls out to (up to 5). Empty — for now.
- self-administeringThis call, and
add_signer,add_policy, … are functions on the account itself, guarded by the same rules.
the library's real API — our integration test makes this call in crates/integration-tests/tests/delegated.rs
07 · Without perch · attempt 2
Two kinds of signer.
pub enum Signer { /// A delegated signer that uses built-in signature verification. Delegated(Address), /// An external signer with custom verification logic. /// Contains the verifier contract address and the public key data. External(Address, Bytes), }
verbatim from the library — stellar-accounts, smart_account/storage.rs
Delegated(address)
Another account approves. For our robot, that's just its ordinary G address. Since protocol 27 (a change called CAP-0071), the host checks that key's signature as part of the same approval the smart account is already making — no extra contract, no extra ceremony.
External(verifier, key)
Raw key bytes plus a verifier contract — a small contract whose only job is the signature math: verify(payload, key, signature) → yes/no. This is how any signature scheme plugs in: passkeys today, anything tomorrow.
Remember this one — the finale hangs on it.
08 · Without perch · attempt 2
The complete account, on OZ.
#[contract] pub struct PerchAccount; #[contractimpl] impl PerchAccount { /// Deploy-time rule 0: the admin signers may manage this account — and nothing else. pub fn __constructor(e: &Env, admin_signers: Vec<Signer>) { smart_account::add_context_rule(e, &ContextRuleType::CallContract(e.current_contract_address()), &String::from_str(e, "admin"), None, &admin_signers, &Map::new(e)); } } #[contractimpl] impl CustomAccountInterface for PerchAccount { type Error = SmartAccountError; type Signature = AuthPayload; fn __check_auth(e: Env, signature_payload: Hash<32>, signatures: AuthPayload, auth_contexts: Vec<Context>) -> Result<(), Self::Error> { smart_account::do_check_auth(&e, &signature_payload, &signatures, &auth_contexts) } } impl SmartAccount for PerchAccount {} // implemented, NOT exported — these entry points don't exist on-chain (slide 16)
crates/perch-account/src/lib.rs — the OZ core of our account, abridged (its one extension, apply_doc, is on slide 16)
A complete, deployable smart account. Everything after deploy — rules, signers, policies — is data. What's still missing: a guarantee about where that data comes from.
09 · Without perch · attempt 2
How one check actually runs.
Inside do_check_auth — the library function the account hands off to — four steps:
-
Select
The signed payload names which rule id to check for each approved action — and those ids are baked into what gets signed, so rules can't be swapped afterward.
-
Match
Look the rule up: not expired? Does its scope cover this exact contract call?
-
Authenticate
Every signer provided: Delegated → the host verifies the signature. External → the verifier contract verifies the bytes.
-
Enforce
Call
enforce(…)on every policy contract attached to the rule. All must say yes — any failure anywhere rejects the whole transaction.
10 · Without perch · sharp edges
Powerful primitives, loaded footguns.
All four of these are real behaviors of the library — some documented as your problem to manage.
One enum variant from "anything"
A rule's scope can be Default, which the library defines as able to "authorize any context". Pick it by mistake — or copy-paste it — and the robot's rule covers everything the account can do.
Attaching a policy weakens signer checks
No policies → all rule signers must have signed. Attach one policy and the library defers signer validation to the policy. Add a spending limit, silently lose the signature requirement — unless your policy re-checks it.
the compiler injects a minimum-signer check into every program, and the interpreter denies outright when no signer authenticated.Rules accumulate; nobody reconciles them
Multiple rules can cover the same contract, and the caller picks which rule id to authorize under. The library's own docs: "It is the caller's responsibility to avoid creating redundant rules." Stale grants linger, live.
one document names every rule. Verification reads the account back and compares — a leftover is a detected mismatch, not a mystery.A policy is whatever code sits at that address
The account calls the attached contract and trusts its answer. Nothing requires that contract to fail closed — a buggy or expired policy can approve by accident.
the interpreter is the one policy contract, and it denies by default: no program installed for a rule → deny, never allow.11 · The gap
OZ gets us to the contract. Not to the method.
Scope the robot's rule to the registry contract, and here's what that rule authorizes:
What we wanted
- ▸
publish
What "scoped to the registry" allows
- ▸
publish - ▸
set_manager— hand the registry name to an attacker - ▸
upgrade, and every other method it has
To narrow to one method, you must attach a policy — which means writing a policy contract. Custom Rust again: write, audit, deploy. For each shape of constraint. For each team.
And what got deployed is code; what got reviewed was intent. That gap is where perch lives.
12 · Perch · the idea
Write the constraint as a document, not a contract.
{ "version": 1, "network": "Test SDF Network ; September 2015", "signers": [ { "id": "admin", "verifier": "CD4IF75D…LXYN", "key": "045e2a75…b17e" }, { "id": "ci", "address": "GA327GGW…57YGA" } ], "rules": [ { "name": "admin", "scope": { "type": "self-admin" }, "principals": { "type": "all", "signers": ["admin"] } }, { "name": "ci-publish", "scope": { "type": "contract", "address": "CCA7QAA6…N6N2AL" }, "principals": { "type": "all", "signers": ["ci"] }, "functions": ["publish", "publish_hash"], "args": [{ "index": 1, "pred": { "type": "is-self" } }], "not-after-ledger": 55000000 } ] }
- "signers"Who may act. admin: raw key bytes checked by a verifier contract. ci: the robot — just its ordinary account address.
- "scope"admin covers managing the account itself; ci-publish covers calls to one contract: the registry.
- "functions"Only these two methods authorize.
set_managerisn't on the list, so it can't happen. - "args" · is-selfArgument 1 — the author — must be the account itself. The robot publishes as us, never as anyone else.
- "not-after-ledger"The rule dies at ledger 55,000,000 — chain time; we set it to roughly end of quarter.
testdata/ci-publish-delegated.json — abridged (long keys and addresses shortened)
13 · Perch · authoring
Just send the JSON.
No registration step, no schema ceremony: the JSON string is the entire input. Hand-write the document from the previous slide, commit it, and pass the string to any entry point — the parser and compiler handle everything else.
// Rust — string in, compiled plan out. Two calls, both fail closed: let doc = perch_ir::from_json(&json)?; let plan = compile(&env, &doc, &cfg)?; // TypeScript — the same string, the same hash: const doc = parsePolicyDoc(json); # CLI — the file is the input (interim; the goal is stock stellar CLI end to end): perch-deploy compose --doc policy.json …
- fail closedAn unknown field, a malformed address, a rule with no checks — a typed error, never a silent skip. If it parses, it's a valid policy.
- builder optionalPrefer code to JSON?
packages/perch-jsships a builder —policy().signer(…).rule(…).build()— that emits exactly this document. Sugar, not a requirement. - one string everywhereRepo, PR review, CLI, and — with
apply_doc, three slides ahead — the account itself all consume the same JSON bytes. One artifact, end to end.
from_json — crates/perch-ir (used verbatim in the integration test) · parsePolicyDoc — packages/perch-js
14 · Perch · the hash
The document has one identity: its hash.
Before hashing, the document is put into one exactly-specified byte form — keys sorted, no whitespace, one escaping rule. That spec (CANONICAL.md) is written down independently of any JSON library, so Rust and TypeScript produce the identical bytes. Shared test files pin the result; if either side ever drifts by one byte, CI fails.
testdata/ci-publish.doc-hash — the pinned hash of the flagship policy (it's also the progress bar below)
You approve a hash. The chain stores it. Anyone can check that what's installed is what was reviewed.
15 · Perch · compile & install
From JSON string to installed rule.
let doc = perch_ir::from_json(&fixture())?; // parse the reviewed document perch_ir::validate(&doc)?; // structural checks, fail closed let plan = compile(&env, &doc, &cfg)?; // → OZ rules + constraint programs // ci-publish carries a program — data, not code — for the interpreter: let install = plan.rules[1].install.clone().expect("attaches interpreter"); let policies = map![&env, (interpreter, install.into_val(&env))]; add_context_rule(&env, &ContextRuleType::CallContract(registry), &String::from_str(&env, "ci-publish"), None, &vec![&env, Signer::Delegated(robot)], &policies);
- from_json + validateParsing is fail-closed: an unknown field, a malformed address, a rule with no checks — all rejected, never skipped.
- compileRules OZ can express alone become plain context rules. Constrained rules get a tiny program the shared interpreter evaluates, filed under (account, rule id) with the
doc_hashstored beside it. - verify_plan_matches_docBefore attaching on a real network, this gate recomputes the plan from the reviewed document and refuses anything that doesn't match. Hash-verified, or it doesn't happen.
abridged from our integration test — crates/integration-tests/tests/delegated.rs, fn setup()
16 · Perch · one call
Publish the document. Switch in one call.
A perch account has no piecemeal mutation surface — add_signer, remove_signer, add_policy… those entry points don't exist on the contract. Computation and state are split: parsing + compiling live in a shared stateless compiler contract; the account holds only the stateful half — its rules and the applied hash — and changes through exactly one call:
/// The doc-only account trait, extending OZ's evaluation machinery. pub trait PerchSmartAccount: CustomAccountInterface + SmartAccount { /// Apply a policy document — the ONLY way authorization changes. fn apply_doc(e: &Env, doc_json: Bytes, compiler: Address, interpreter: Address) -> Result<BytesN<32>, PerchAccountError> { e.current_contract_address().require_auth(); // the admin rule approves // stateless: parse + validate + network binding + compile, in ONE shared contract let compiled = DocCompilerClient::new(e, &compiler).try_compile_doc(&doc_json)??; ensure_admin_survives(&compiled)?; // anti-brick refusal // …remove every rule; install the document's — atomically… // …store + return compiled.doc_hash (the canonical identity)… } fn applied_doc_hash(e: &Env) -> Option<BytesN<32>> { … } // read-only }
- one transactionSend the same JSON you reviewed; the entire rule set switches in one call. No half-migrated states, ever — and formatting doesn't matter: the stored hash is canonical.
- anti-brickA document that would leave the account without a working admin rule is rejected before anything changes. You cannot lock yourself out with an edit. (Tested.)
- shippedReal code, in this PR — the account is a 28 KB wasm with exactly six exports (constructor,
__check_auth,apply_doc, three reads); the stateless compiler (perch-doc-compiler) exports one. Six end-to-end tests prove replace, re-apply, anti-brick, wrong-network, unknown-field, and that the mutator entry points don't exist.
# today — one command; the shim signs the admin's approval (the one gap in stock CLI): perch-deploy apply --account C…ACCT --doc policy.json --compiler C…COMP --interpreter C…INTR # and anyone can check installed == reviewed with the stock CLI, no keys at all: stellar contract invoke --id C…ACCT -- applied_doc_hash
The account's entire authorization = one hash. State you can read, not replay.
17 · Perch · release day
The robot publishes.
-
The robot signs
stellar contract invoke --id C…REG --source robot -- publish --wasm … --author C…ACCT
One command in GitHub Actions; the only secret is the robot's ordinary
Gkey. CAP-0071 lets the host verify its signature directly. (Today an interim shim,perch-deploy publish, signs the account's approval selecting rule 1 — the one piece the stock CLI can't sign yet. Everything else in the repo already runs on plainstellar; the shim's job is to disappear.) -
OZ matches the rule
The account's
__check_authruns, finds ruleci-publish, confirms the robot is its signer and the signature checks out. -
OZ calls the interpreter's
enforceThe rule's attached policy is the shared perch interpreter. It loads the stored program for (this account, this rule).
-
The program checks the call
Function is
publishorpublish_hash? Argument 1 is the account itself? Current ledger below 55,000,000? Any "no" rejects the whole transaction.
// the same key, the same rule — calling a method the document doesn't allow: w.env.set_auths(&[entry(&w, "set_manager", &w.account, 2, true)]); assert!(MockRegistryClient::new(&w.env, &w.registry) .try_set_manager(&7, &w.account) .is_err()); // the delegation authenticates — the program still says no
real test, passing today — crates/integration-tests/tests/delegated.rs (no mocks; fully enforced authorization)
If the key leaks: worst case, someone publishes a wasm as us. Visible, revocable, bounded.
18 · Perch · the audit
What you actually audited.
Without perch
A bespoke policy contract per constraint, per team. Each one consensus-critical, each one a fresh audit, forever.
n teams × m constraintsWith perch
Two shared, stateless-or-immutable contracts, audited once for everyone: the interpreter (evaluation — machine-checked core, immutable, one per network) and the doc compiler (parsing + compiling — stateless, one export). The account itself is a 28 KB wasm with six exports and zero policy logic of its own.
It fails closed by construction: no program installed → deny. No signer authenticated → deny. A malformed program, or an attempt to overwrite an existing one → refused at install.
Audit once. Every policy after that is a document.
19 · The payoff · pluggable verifiers
Today's verifier, in full.
Remember External(verifier, key)? A verifier is a tiny, stateless contract — deployed once per network, shared by every account. Implement verify → yes/no and any signature scheme joins the system. This is ours for ed25519, the scheme behind every ordinary Stellar key:
#[contract] pub struct PerchEd25519Verifier; #[contractimpl] impl Verifier for PerchEd25519Verifier { type KeyData = BytesN<32>; // an ed25519 public key type SigData = BytesN<64>; // an ed25519 signature fn verify(e: &Env, signature_payload: Bytes, key_data: BytesN<32>, sig_data: BytesN<64>) -> bool { ed25519::verify(e, &signature_payload, &key_data, &sig_data) } }
crates/perch-ed25519-verifier/src/lib.rs — the entire deployed contract, minus two key-normalization helpers
20 · The payoff · going post-quantum
The ML-DSA verifier, sketched.
ed25519 is not post-quantum. ML-DSA — FIPS 204, NIST's post-quantum lattice signature standard — is. Same shape, one twist: the key doesn't fit in the key field.
#[contractimpl] impl Verifier for PerchMlDsaVerifier { type KeyData = BytesN<32>; // NOT the key — sha256(public key) type SigData = Bytes; // full public key + signature fn verify(e: &Env, signature_payload: Bytes, key_data: BytesN<32>, sig_data: Bytes) -> bool { let (public_key, signature) = split(e, &sig_data); e.crypto().sha256(&public_key).to_bytes() == key_data && ml_dsa::verify(e, &signature_payload, &public_key, &signature) } }
- the twist: a commitmentML-DSA public keys are ~1.3 KB — bigger than the 256-byte key field a signer may hold. So the stored key is a 32-byte hash of the public key. perch's document model anticipated this: its key-size comment says the cap exists for "commitment-style keys".
- the key rides alongThe full public key travels with each signature; the verifier re-hashes it against the commitment, then checks the signature under it. Both must pass.
- same trait, ~50 linesThe exact
Verifiertrait ed25519 implements. A sketch today — it's the last item on the roadmap.
21 · The payoff · the migration, raw
Rotating the robot's key on raw OZ.
// remember to do this for EVERY rule the robot appears in: let old = get_signer_id(&env, Signer::Delegated(robot)); // find the old signer add_signer(&env, rule_id, Signer::External(mldsa, commitment)); // both keys now live… remove_signer(&env, rule_id, old); // …until this lands
the library's real API — each call its own admin-signed transaction
- Did you find every rule? Nothing enumerates "all grants held by this key" — you grep your own memory.
- Between transactions the account is half-migrated — old and new key both live, and that intermediate state is real and authorized.
- Nothing records the intended end state. Reviewers approve individual transactions; no artifact says what the account should look like when you're done.
22 · The payoff · the migration, perch
The same migration as a document edit.
-
Deploy
perch-ml-dsa-verifierThe only new code — the ~50-line contract from two slides ago.
-
Edit the document — one signer line
- { "id": "ci", "address": "GA327GGW…57YGA" } + { "id": "ci", "verifier": "CMLDSA00…VRFR", "key": "b3a1c04d…9c4e" }
Every rule stays identical — same scope, same functions, same expiry. Every rule that names
"ci"picks up the new key; none can be missed. -
Review
The diff is one line. The new
doc_hashis the thing approved — the intended end state, in writing. -
Apply — one call
The admin applies the new document whole:
apply_doc, one signed transaction, the entire rule set switches, no half-migrated in-between.applied_doc_hashthen confirms installed == reviewed — a read-only stock-CLI call. -
Rotate the GitHub secret
Swap
PERCH_CI_KEYfor the ML-DSA private key. The robot signs the same rule-bound bytes as before — only the key changed.
23 · The payoff
What did not change.
- The account contract — untouched. Not redeployed, not re-audited.
- The interpreter — same immutable contract, same program shape.
- The registry — never knew anything happened.
- The review process — read a one-line diff, approve a hash.
Authorization policy is data. Proof: a post-quantum key migration that's a one-line diff.
24 · Roadmap
Where this goes.
-
Testnet live test now
The registry publish pipeline end-to-end, robot key and all.
-
Stock-CLI signing
apply_docalready shipped (slide 16); what remains is teaching the stellar CLI to sign smart-account approvals, so theperch-deployshim disappears and the stellar CLI is the only tool. -
Mainnet
Same policy, real stakes.
-
Passkey admin
The maintainer's admin signer becomes a passkey (WebAuthn) via a verifier contract.
-
CAP-0071 support upstream to OZ
Our delegated-signer patch lands in
stellar-accountsproper. -
ML-DSA verifier
The finale, for real.
25 · Where you come in
Let's make scoped keys the easy path on Stellar.
github.com/stellar-registry/perchstatus: working pipeline, live on testnet
- Adopt the document format — a wallet session key, a treasury limit, an agent's tool key. Any operational key you'd rather not think about is a policy document waiting to be written.
CANONICAL.mdis normative and short; the flagship documents live intestdata/. - Build on it —
packages/perch-jsemits the exact same canonical bytes as Rust, pinned in CI. Wire it into a wallet, an agent framework, or a deploy tool with no fear of drift. - Upstream the primitives with us — CAP-0071 delegated signers into
stellar-accounts, smart-account signing into the stock stellar CLI. The roadmap's last miles are really invitations to collaborate.