How Warrant works
You already have a bot. This is the key it carries when it leaves the chat and acts.
You already have a bot
Grok, Hermes, OpenClaw — anything that can make an HTTP request. Meeting another bot is not acting. Acting is calling a shop, hiring a helper, spending. That is where a human has to stay in authority without staying in the message.
What you do
Open Warrant. Authorize with MetaMask. Install /skill.md once. Paste the warrant into the bot you already have. The bot calls a shop we operate, or a shop someone wrapped with the kit. When you are done, Fire. The next call dies. The shop still does not know it was you.
What each party saw
You signed. You kept the EVM key. You can Fire from a new browser with the same MetaMask. The bot never got that key.
Warrant sees the witness. The hosted helper builds the Groth16 proof for a cloud bot so that bot never downloads a zkey. That is the honesty of this host. A local CLI prove does not show us the witness.
The chat can see the bearer. Treat that warrant like a key. Anyone who has it can act until you Fire.
The shop sees a nullifier, a live root, a scope, a budget ceiling, an expiry, a tier, and a hash of this exact request. Eight public signals. Not your name. Not your address. Not the hops.
Words
These are the objects. Land stays quiet. This book names them.
- Warrant
- A Groth16 proof that this request is authorized by a live leaf and its immediate parent, through hops that only got narrower, bound to this shop challenge. It is not a session cookie and not your MetaMask key.
- Root
- MandateRegistry
currentRoot: one LeanIMT over the identity leaf and every enabled mandate hash. Your MetaMask binds one Baby Jubjub public key. Authorize again recovers the same hop tree under that identity leaf. It fails if those hops are dead. - Leaf
- Poseidon5(
warrant/leaf, pkX, pkY, tier, epoch). That identity field sits in the forest next to each enabled mandate hash (warrant/mandate).Fire everybumps epoch so the old identity leaf is gone.Fire this/Fire helpertombstone a mandate hash (value 0) and leave the identity leaf in place. - Hop
- One signed handoff of a mandate: A signs permission over to B. B can only receive a subset of scope, and a budget and expiry that do not grow. The live circuit is two always-on hops. The leaf sees the immediate parent, not the chain.
- Mandate
- The signed message for one hop. Poseidon10 over domain
warrant/mandate, child key, scope, budget, expiry, tier, epoch, parentHash, and a tag commitment. Hop 0 uses parentHash = 0 and is signed by the root key. - Shop
- An HTTP resource that verifies the warrant, then does the job (leave a memo, translate, or a route you wrap yourself). We do not host a reverse proxy and we do not protect a URL you paste.
- Memo
- A public note written to a Hedera testnet HCS topic. Anyone with the HashScan link can read the text. They still do not learn who authorized the bot. Scope bit
FETCH = 2. - Helper
- A third hop your bot can hire. It only gets memo (
FETCH). It cannot translate. It cannot hire.Fire helperdeletes that hop.Fire thisdeletes the parent bot and the helper. Translate-only warrants cannot hire. - Fire
- Only the MetaMask that bound the root.
Fire helperisrevokeMandateon hop 3 — helper 403, parent live.Fire thisisrevokeMandateon hop 2 — that warrant and its helper 403; other warrants under the same wallet stay live.Fire everybumps the identity epoch and replaces the identity leaf — every hop dies. Any of those movescurrentRoot, so a copied bearer dies asroot_revoked(the shop pinscurrentRootbefore Groth16).invalid_proofis a Groth16 fail against a still-accepted root — a later prove that names today's root but still claims a deleted hop cannot be built honestly.
Cryptography
Warrant is a Groth16 membership-and-delegation proof. The shop verifies eight public signals. The hops stay in the witness.
Curves and proof system
| Piece | Choice | Why it is here |
|---|---|---|
| Agent keys | Baby Jubjub | Semaphore Identity. Same curve as EdDSA-Poseidon inside the circuit. |
| Signatures | EdDSA-Poseidon | Each always-on hop, plus the request, is verified in-circuit. Three EdDSAPoseidon verifiers on the product circuit (two mandate slots + one request). |
| Hashes in-circuit | Poseidon (t=2,3,5,10) | Domain-tagged. Must match circomlib Poseidon and poseidon-solidity on the registry leaf. |
| Request binding | keccak256, then mod r | Outside the SNARK. Binds the proof to one HTTP challenge. r is the BN254 scalar field. |
| SNARK | Groth16 over BN254 | Product circuit WarrantHop(20) in circuits/warrant.circom. 39,424 non-linear / 61,111 snarkjs constraints (pot16). zkey never committed. Solo ceremony tag artifacts-groth16-v3. |
| Merkle tree | LeanIMT / Semaphore Group | BinaryMerkleRoot with MAX_MERKLE_DEPTH = 20. One forest: identity leaf plus each enabled mandate hash. Depth is tree height, not agents per warrant. size counts every insert, so it is larger than the number of bound wallets. |
BN254 scalar field r = 21888242871839275222246405745257275088548364400416034343698204186575808495617. Every public signal and every Poseidon output is in this field. keccak256 digests are reduced mod r before they become requestHash.
Domain-separated Poseidon
Each domain string is UTF-8 interpreted as a big-endian field element (BIP-340-style tagging, Poseidon not SHA). Circom (circuits/lib/domains.circom) and TypeScript (@ronnakamoto/warrant-core) must stay in lockstep with the registry’s DOMAIN_LEAF.
| ASCII | Arity | Formula |
|---|---|---|
warrant/tag | 2 | tagC = Poseidon(DST_tag, humanTag) |
warrant/leaf | 5 | leaf = Poseidon(DST_leaf, pkX, pkY, tier, epoch) |
warrant/mandate | 10 | Poseidon(DST_mandate, childPkX, childPkY, scope, budget, expiry, tier, epoch, parentHash, tagC) |
warrant/nullifier | 3 | nullifier = Poseidon(DST_nullifier, humanTag, contextHash) |
humanTag is a private field sampled at bind. It never appears in the public signals. Binding tagC into every mandate closes quota-rotation: you cannot keep the same chain and swap the tag to mint a fresh nullifier. A leaked humanTag lets someone link your nullifiers inside one contextHash. It does not let them forge a mandate.
contextHash is also sampled at bind. Nullifiers are scoped to that context. Two shops that do not share a context cannot link the same human by nullifier. On this host each mint gets its own pair.
Ceremony
Groth16 needs a circuit-specific proving key. The live circuit WarrantHop(20) is 39,424 non-linear / 61,111 snarkjs constraints, so testnet artifacts come from a solo phase-2 on a pot16 Powers-of-Tau (2^16 = 65536). That is not a multi-party ceremony. It is said plainly: fine on testnet, not production-grade MPC. If operator entropy from the contribution leaks, proofs for this circuit can be forged. Beacon finalize does not heal a leaked prior contribution.
| File | SHA-256 |
|---|---|
warrant_final.zkey | b97ca5dec3b187b59b513b8aaf70b7447c0ec35e584a7065c175ec2f3b50abd2 |
warrant_vkey.json | 6bbd75496678755487820a83f7184da784ccfb1bad1db1ad577535a25cdb2652 |
warrant.wasm | 8709811b852c70ca56c094953d60d6ad54e0538b9c3aa685ac2dabb6a493b30a |
The Solidity verifier is generated (contracts/src/WarrantVerifier.sol) and committed. Hand-editing it is a defect. The zkey is not in git. Shops verify with the vkey only. Provers (this host’s prove worker, or a local CLI) need wasm + zkey.
This stack (Groth16 + Baby Jubjub EdDSA) is not post-quantum. A later IVerifier swap to a transparent setup (for example Honk) is the intended path when pairing-based trust is unacceptable.
The circuit
Live Groth16 is WarrantHop(20) in circuits/warrant.circom (MAX_MERKLE_DEPTH = 20). The leaf sees the immediate parent, not the chain. This is not pairing recursion. warrant_lean.circom is the membership-and-attenuation subset with no EdDSA; it is not what shops verify. Public inputs stay the same eight-tuple on both, so the verifier ABI does not move.
Eight public signals
Slot indices are frozen in @ronnakamoto/warrant-core. Adding a ninth is a type error and a new ceremony.
| i | Name | What the shop learns |
|---|---|---|
| 0 | merkleRoot | Must equal MandateRegistry currentRoot. After any Fire the copied bearer's root is stale → root_revoked. Groth16 fail against a still-accepted root → invalid_proof. |
| 1 | contextHash | Scopes the nullifier. Not your name. |
| 2 | nullifier | Per-human-per-context id for quota and the replay seal. Not a wallet. |
| 3 | effectiveScope | Leaf hop’s uint64 capability bits. |
| 4 | effectiveBudgetCap | Leaf hop’s budget ceiling. Not a conserved coin. |
| 5 | minExpiry | Shop’s now. Circuit checks minExpiry ≤ leaf expiry. |
| 6 | tier | This host binds tier=0. |
| 7 | requestHash | This exact challenge. A copied proof on a different request fails. |
What the circuit checks
tagC = Poseidon(DST_tag, humanTag).leaf = Poseidon(DST_leaf, rootPk, tier, epoch).BinaryMerkleRootof that leaf againstmerkleRoot(single index, siblings padded to 20).- Live forest: each always-on hop’s mandate hash is also a LeanIMT leaf in the same
currentRoot. Fire a hop by_remove(tombstone 0). That movescurrentRoot, so a copied bearer isroot_revoked. A new witness that still names a tombstoned hop cannot satisfy the R1CS. - Two always-on hops. The leaf sees the immediate parent, not the chain.
parentParentHash = 0when the parent is the root; otherwise it is the parent’s parent hash. This is not pairing recursion. - Attenuation: child scope bits ⊆ parent (64-bit
ScopeSubset). The leaf’s budget and expiry are ≤ the immediate parent. - Each hop: mandate hash as above, EdDSA-Poseidon by the previous public key (parent slot by the root when
parentParentHash = 0). - Leaf child key EdDSA-signs
requestHash. minExpiry ≤leaf expiry.effectiveScopeandeffectiveBudgetCapare the leaf hop.nullifier = Poseidon(DST_nullifier, humanTag, contextHash).
Scope bits
| Name | Bit | On this host |
|---|---|---|
TRANSLATE | 1 | Translate shop. Memo-only mint does not set it. |
FETCH | 2 | Memo shop, echo, integrator fetch routes. Required to hire a helper. |
TRADE | 4 | Exists on the bitmask. This host does not mint it. |
A shop’s policy is requireScope ⊆ effectiveScope and tier ≥ minTier. Widening a hop fails the witness. Siblings may each inherit the full parent budget ceiling — that is not UTXO conservation.
WarrantHop(20)
The live circuit is two always-on hops at MAX_MERKLE_DEPTH = 20. The leaf sees the immediate parent, not the chain. A longer handoff is a later two-slot proof, not a padded gadget. This is not pairing recursion.
Request binding
The proof is not a bearer that works on any URL. The leaf key signs requestHash. The shop rebuilds that hash from the live x402 challenge and aborts on mismatch (request_hash_mismatch).
requestHash = keccak256(method|path|nonce|merkleRoot|amount|payTo|bodyHash) mod rThe HTTP header is warrant. Value is JSON { proof, publicSignals } with exactly eight signals. Malformed JSON or the wrong length is malformed_warrant.
path and nonce are required. Empty defaults were a review finding: they would let a proof replay across requests. Default method is POST. Missing merkleRoot, amount, payTo, or bodyHash become empty strings in the preimage, then still hash.
After a successful verify, the shop consumes the pair (nullifier, requestHash) — a single-use seal around this challenge. A copied proof cannot replay against the same 402 nonce. Free-tier quota, when a shop offers it, still counts by nullifier alone (three calls need three distinct challenges). Do not consume nullifier by itself or the free tier dies after one call. This host sets freeCallsPerHuman = 0, so the first shop call is a 402.
On-chain
A warrant is an identity leaf plus its mandate hashes under one forest currentRoot. Your MetaMask binds the identity leaf on Base Sepolia (chain id 84532). Authorize and hire insert hop hashes. Off-chain hops can only get narrower. The proof shows identity membership, the leaf hop and its immediate parent in the same root, and that this request was the one the shop challenged.
| Contract | Address |
|---|---|
| MandateRegistry | 0x8704606Bde5E257dC009cCe55214Df70975f89c5 |
| WarrantVerifier | 0x040b660Ac81cDd775660EDA2f535AF437782cA20 |
| WarrantGate | 0x27B47a65F0E4BF3b45Bb38e020351FE6C18F2dE6 |
MandateRegistry
LeanIMT of Poseidon5 identity leaves and Poseidon mandate hashes. No Groth16 in this contract. Personhood is never checked on-chain. Bind inserts the identity leaf at epoch 0. The prove operator then insertMandates for hops 1 and 2 (hire inserts hop 3). revokeMandate tombstones one hop (_remove → 0). Identity revoke bumps epoch and _updates that wallet’s leaf. Resource servers on this host require merkleRoot == currentRoot. They do not accept historical roots (isKnownRoot exists on the design, not in the v1 x402 hook).
- If
operator != address(0): only the operator maybindRoot(this deployment). Closes permissionless public-key squatting on a public mempool. The hosted prove worker holds the bind key; you still hold Fire on the wallet. - If
operator == address(0): permissionless self-bind,tiermust be 0. Test/demo only. - One wallet, one binding. The same leaf cannot be claimed under a second wallet (
LeafClaimed). ROOT_HISTORY_WINDOWis 1 hour on the contract. Shops still pincurrentRootonly.
WarrantVerifier is the snarkjs-generated Groth16 verifier. WarrantGate composes registry + verifier for optional on-chain onlyWarrant checks. The product path is the x402 hook, not a gate transaction on every shop call. A Studio subgraph indexes Bound / Revoked / MandateInserted / MandateRevoked from block 46661760. That graph is a data plane, not a second mandate model.
Hops on this host
Hosted mint is a 2-hop delegate. Names in the prove worker are implementation labels, not identities the shop sees.
| Hop | From → to | Role |
|---|---|---|
| 1 | alice → orchestrator | Root signs the first mandate. Scope is the bits you picked (memo, translate, or both). Budget ceiling 2_000_000. |
| 2 | orchestrator → translator | Your bot’s leaf key. Budget ceiling 200_000. Last hop of a mint. Expiry is now + 30 minutes. The leaf sees this immediate parent, not the chain. |
| 3 | translator → helper | Only if the bot hires, and only if hop 2 includes FETCH. Budget ceiling 20_000. Same expiry. Re-hire deletes the previous helper session. A new two-slot proof: the helper leaf sees hop 2 as the immediate parent. |
A helper cannot hire (parentId → 403 scope). A helper cannot translate. Fire helper deletes hop 3. Fire this deletes hop 2 (the bot and its helper die; other warrants under the same MetaMask stay live). Fire every bumps the identity epoch and every hop dies. Only the MetaMask that bound the root can Fire. This host already proves WarrantHop(20) (pot16, artifacts-groth16-v3).
The desk session may last up to seven days (GUEST_TTL_MS) so you can Fire from another browser. The mandate expiry in the circuit is thirty minutes. After that the next prove fails minExpiry even if you have not Fired.
Shops and payment
The first shop call is a 402. Hosted chat cannot invent payment. A machine agent with a funded Hedera testnet purse can pay ExactHedera. We do not sponsor the 402. We do not put a Hedera key on this site.
Authorize order in the shop
Fixed pipeline in @ronnakamoto/warrant-x402. Missing header continues to 402. Abort reasons are 403. Grant is the free path when a shop still has quota.
- No
warrantheader → continue (402). - Malformed header or public-signal count ≠ 8 →
malformed_warrant. merkleRootnotcurrentRoot→root_revoked(checked before requestHash).- Rebuilt challenge ≠
requestHash→request_hash_mismatch. - Groth16 verify fails →
invalid_proof. - Policy:
requireScopenot ⊆effectiveScope, or tier below floor →policy. - Free quota by nullifier, if any remain → take
(nullifier, requestHash)seal; already seen →replay; else grant. - Quota exhausted → pay (402). Pay fallthrough must not seal: the client retries the same warrant after attaching ExactHedera.
What we operate
| Shop | Job | Notes |
|---|---|---|
| Translate | POST /v1/translate | MyMemory + HCS audit of {nullifier, scope, tier, txId} — not your prose. Needs TRANSLATE. |
| Memo | POST /v1/memo | Public HCS note. Body text is public on HashScan testnet. Needs FETCH. Second topic (HEDERA_MEMO_TOPIC_ID), not the translate audit topic. |
| Echo | POST /v1/echo | In-repo factory proof, not a hosted proxy. Needs FETCH. |
Hedera testnet treasury used in the published deployment notes: account 0.0.10311260. Translate HCS topic 0.0.10336558. Memo uses a separate topic. Payment is Blocky402 ExactHedera, not an ETH transfer on Base.
This host
Testnet is the product. Console: https://warrant-beta.vercel.app — Authorize, Copy the warrant, Fire. Install /skill.md once. Clone is optional local prove.
| Process | Role | Must not |
|---|---|---|
| Dashboard (Next.js) | UI. MetaMask bind/Fire. Guest BFF to prove + shops. | Import @ronnakamoto/warrant-core. Hold a zkey. Hold a Hedera key. Sponsor 402. |
| Prove worker | Mint 2-hop tree, bind leaf as operator, Groth16 for the Copy bearer, hire helper. | Import x402 or Hedera. That split is load-bearing: a bot must not call prove; this site’s agent API proves for the Copy bearer. |
| Translate / memo shops | Verify + resource. Persist nullifiers on disk. | See hops, names, or wallets. Demo flags (ALLOW_DEMO_*, FIXED_MERKLE_ROOT) are boot-fatal in production. |
The guest BFF may see one warrant header in flight. It must not log it, persist it, or forward it anywhere except the translate shop or the memo shop we operate.
Packages: @ronnakamoto/warrant-core (domain, hashes, prove/verify — no HTTP), @ronnakamoto/warrant-x402 (shop factory + warrantHono), @ronnakamoto/warrant (ready / act via a JS runner already on PATH). Do not call prove from a bot.
Diagrams
The picture is the protocol. Hops stay on the left. Eight public signals cross Groth16 (WarrantHop). The shop only ever sees the right. A proof opens the leaf and its immediate parent — not the chain above. Fire helper or Fire this deletes a hop. Fire every kills the identity leaf. The four sketches below are the same loop, smaller.

Threats and limits
Warrant is a working construction for five predicates — rooted, chained, attenuated, fresh, unrevoked — not a complete ACTA stack, not a policy language, and not personhood. Capability claims (audit score, jurisdiction) stay outside this circuit.
- Compromised bot bearer: can act within its mandate until expiry or Fire. Cannot widen scope. Cannot forge a longer chain (needs parent signatures). Cannot Fire — that needs the MetaMask that bound the leaf.
- Live-mandate forest: one LeanIMT. Identity leaf plus each enabled mandate hash. Delete a hop with
revokeMandate. Insert and delete movecurrentRoot— in-flight proofs already die on any bind.WarrantHop(20)is 39,424 non-linear / 61,111 snarkjs constraints. Solo ceremony is pot16. Said plainly. - The leaf sees the immediate parent, not the chain. The Groth16 witness includes the parent mandate, not the hops above it. The verifier sees neither. This is not pairing recursion.
- Leaked
humanTag: linking, never forging. Rotate by re-binding (this registry is one bind per wallet; Fire and a new wallet, or a later registry that allows re-bind after full exit). - Anonymity set equals the number of bound roots. On day one that is test users. State this plainly.
- Trusted setup: solo ceremony, disclosed. Groth16 + Baby Jubjub is not post-quantum.
- Verifier collusion: two shops cannot link a human unless they share a context.
- Memo text is public. Privacy is who authorized, not what was written.
- Budgets are ceilings in the proof, not conserved coins. Hard money, when it exists, is a Hedera purse the agent already holds.
What this is not
The host binds tier=0. Groth16 is a solo ceremony — fine on testnet, said plainly. Memo text you post is public on HashScan testnet. We do not host a reverse proxy and we do not protect a URL you paste. Wrap your own shop.
If you run a shop
An integrator wraps their own Hono POST with createWarrantShop and warrantHono from @ronnakamoto/warrant-x402. The request body stays in their process. Do not call prove from a bot; this site’s agent API proves for the Copy bearer.
import { Hono } from "hono";
import { FETCH, SnarkjsVerifier } from "@ronnakamoto/warrant-core";
import {
createWarrantShop,
initializeWarrantShop,
CurrentRootChecker,
FileNullifierStore,
FileChallengeStore,
warrantHono,
} from "@ronnakamoto/warrant-x402";
const roots = new CurrentRootChecker({
rpcUrl: process.env.BASE_SEPOLIA_RPC,
registry: process.env.REGISTRY_ADDRESS,
});
const shop = createWarrantShop({
route: "POST /v1/orders",
description: "orders",
policy: { requireScope: FETCH, minTier: 0, freeCallsPerHuman: 0 },
amount: process.env.X402_AMOUNT ?? "100000",
payTo: process.env.HEDERA_PAY_TO,
verifier: SnarkjsVerifier.fromPath(process.env.WARRANT_VKEY_PATH),
roots,
getMerkleRoot: async () => (await roots.currentRoot()).toString(),
nullifiers: new FileNullifierStore(process.env.WARRANT_NULLIFIER_PATH),
challenges: new FileChallengeStore(process.env.WARRANT_CHALLENGE_PATH),
defaultPath: "/v1/orders",
});
await initializeWarrantShop(shop);
const app = new Hono();
app.use("/v1/*", warrantHono(shop));
app.post("/v1/orders", handler);Node 20+. Put the Groth16 vkey on disk from GitHub release artifacts-groth16-v3 (scripts/download-zkey.sh). This package does not include a zkey. initializeWarrantShop after construct. Registering ExactHedera happens inside the factory before initialize.
Registry — The operator graph of bound roots lives on Registry. It is not the product.