Prove your agent refused an order — without revealing the order, the leash, or where the leash sits.
A privacy-first dApp on Midnight. Built for THE MIDNIGHT BUILDATHON (AKINDO), Wave 1.
⚠️ Status: scaffold. The Compact circuits are specified but their bodies are unwritten, and the UI is route stubs. Nothing here has been throughcompactcyet. This README documents the design and the build plan; it will be rewritten as an evaluation guide before submission. Do not read any claim below as "implemented" unless the judge verification section says so.
Nadia runs a two-person crypto treasury desk. Her rebalancing agent runs unattended overnight against three venues. At 02:14 it parses an inbound message containing an injected instruction — transfer 40,000 USDC to an address whose counterparty class her mandate does not cover, at an amount above her epoch cap.
It refuses. And she cannot prove it refused.
Her auditor sees an absence of a transaction, which is indistinguishable from the agent having been down. She cannot hand over the log, because it contains the desk's entire venue strategy and the injected payload names a real counterparty. Her actual workaround today is a Notion page saying "trust me, it held." That page is worth nothing.
Every agent-authorization system can prove an agent was allowed to act. ALIBI proves the other half: that an agent was asked, and declined — publishable to anyone, revealing nothing.
Refusal evidence is already being standardized. IETF
draft-sabey-refusal-transparency-00
specifies it as signed plaintext with a mandatory refusal_ground, and
draft-kamimura-scitt-refusal-events
maps refusal decisions onto SCITT signed statements. (Both are Internet-Drafts. Neither is WG-adopted.)
Publishing the ground publishes the boundary. Each published refusal is one negative sample of the policy surface, so an attacker who can trigger refusals binary-searches the cap in O(log n) probes. Refusal transparency as currently drafted is an oracle for the thing it protects.
ZK is what lets you have the count without the oracle.
Delete any one leg and it collapses:
| Delete | What breaks |
|---|---|
| the agent | Humans don't publish evidence they declined. Human authority is presumed; machine authority must be presented. The hijack question — was that my delegate's intent, or an injected instruction? — has no human counterpart. |
| ZK | Publishing the refusal ground is self-defeating, per above. |
| Midnight | The count must be public, monotonic and un-rewritable by the agent's own operator, while the mandate and the instruction never leave the machine. Public-tamper-proof-counter over private-unrevealed-content is the dual-ledger model. |
No backend. No database. State lives in exactly two places, and that split is the product:
PUBLIC LEDGER (indexerPublicDataProvider) LOCAL LEVELDB (levelPrivateStateProvider)
mandates agentId → commitment agentSk, submitterSk
agentPks agentId → agentPk mandate { capPerEpoch, allowedClasses[8], salt }
tallyCommit agentId → H(spent‖epoch‖salt) spentThisEpoch, tallySalt
pending instrCommit → anchored? instrNonce
posted the denominator refusalLog [{ instrCommit, reasonCode }]
executed / refused / outstanding
nullifiers anti-replay
epoch
alibi/
contract/ Compact source, generated artifacts, simulator tests
api/ provider wiring, circuit callers, commitment derivation
ui/ Vite + React + Tailwind + shadcn
| # | Circuit | What it asserts |
|---|---|---|
| 1 | registerMandate |
One mandate per agent. Only H(cap ‖ classes ‖ agentPk ‖ salt) ever reaches the chain. |
| 2 | submitInstruction |
A third party anchors an instruction before the agent may act on it — proving H(submitterSk) != agentPks[agentId]. |
| 3 | authorizeAction |
Anchor is pending · nullifier unused · mandate, tally and instruction commitments all re-derived and matched · class ∈ allowed · spent + amount <= cap. |
| 4 | recordRefusal |
(a)–(e) identical to #3, then the disjunction: (class ∉ allowed) || (spent + amount > cap). |
The disjunction is the design. It proves a violation occurred while revealing which rule fired to nobody —
including the verifier. Both branches collapse to a single bit. A reasonCode would have been the obvious thing
to publish, and it is precisely what leaks the boundary; it stays in local state.
Midnight's own guidance: "Do not assume in your contract that the code of any witness function is the code that you wrote." Every witness in this contract is hostile until proven otherwise, so circuits 3 and 4 re-derive the mandate commitment, the tally commitment and the instruction commitment, and assert each against on-chain state before anything else happens.
That third one matters more than it looks. Without tallyCommit, spentThisEpoch is an unbound witness — and
the agent could satisfy the over-cap branch for any instruction by simply reporting a large tally. The
falsifier below would be theatre. Tests N7 and N8 exist to keep it honest.
posted == executed + refused + outstanding (per agent, always)
Deliberately not enforced inside a circuit. It is public-ledger arithmetic that any observer can check
against four public counters — which is exactly what makes it worth having. An earlier design proved this over a
private witness; that was worthless, because a witness cannot be trusted. outstanding is arithmetically
redundant on purpose: tracking it separately turns a definition into a testable invariant, and it is the
anti-suppression number — a rising outstanding is what a suppressing agent looks like.
Precision here is not modesty; an engineering panel punishes an overclaim far harder than it rewards an ambitious one.
We do not claim "nothing is revealed." A successful recordRefusal publicly discloses that a violation
occurred — intended, and the entire point of a public counter. What the disjunction protects is which rule
fired.
Exactly one bit is disclosed, by design. The boundary is not.
Two further honest limits:
- A Midnight transaction names the entry point it invoked. So for a specific anchor, an observer can infer
authorizeActionvsrecordRefusal. Modelled explicitly, not hidden. - Wave 1 attests; it does not enforce. Nothing forces the agent through this contract. The gate is client-side. Wave 2 moves value into contract custody, which is the headline next deliverable. The irony is worth stating first: under full custody enforcement a hijacked agent physically could not make the bad transfer, and proof-of-refusal would degrade toward telemetry.
Threat model. Instructions arriving over the anchored channel are in scope. Unsigned prompt injection from unwitnessed sources is out of scope — if nobody anchored it, there is nothing to prove a refusal against.
No ZKML. We do not prove model inference; that is infeasible on Compact 0.31.0's bounded circuits. We prove policy compliance of actions. The AI is the thing being constrained, never the thing being proven.
Prerequisites: Node per .nvmrc (24.11.1) · Docker · Chrome (Lace is Chrome-only) · the Compact toolchain.
# 1. Compact toolchain (installs the `compact` manager, then the compiler)
curl --proto '=https' --tlsv1.2 -LsSf \
https://github.com/midnightntwrk/compact/releases/latest/download/compact-installer.sh | sh
export PATH="$HOME/.local/bin:$PATH" # docs say ~/.compact/bin; it lands in ~/.local/bin
compact update # installs compiler 0.31.1
compact compile --version # → 0.31.1
# 2. Build
nvm use # 24.11.1 — the docs say "Node 22+"; the repo pins higher
npm install
npm run compact # compile Compact → contract/src/managed/ ← THE GATE
npm run proof-server:up # Docker, localhost:6300
npm test # contract simulator suite + fixture invariants
npm run dev # http://localhost:5173npm run compact is the gate that matters: a Compact contract that does not compile is an automatic
disqualification for the wave. Run it before anything else.
✅ Verified 2026-08-12 on compiler 0.31.1 (toolchain manager 0.5.1, x86_64 linux). A clean
npm run compactproducescontract/src/managed/alibi/{compiler/contract-info.json, contract/index.{js,d.ts,js.map}}with all four circuits present in the generated API.On the version: earlier notes pinned 0.31.0. That was just the newest release when the event was scouted, not a deliberate choice —
compact listnow offers 0.31.1, and a judge following the steps above runscompact updateand gets 0.31.1 too. Developing on the version the judge will actually run removes the drift the pin was meant to prevent. Same minor; the compiler is pinned incontract/compile.sh, which fails loudly on a mismatch.
For testnet: install Lace (Midnight Preview) on Chrome, request from the faucet, then click Generate tDUST — a separate second step that is easy to miss.
| Script | Does |
|---|---|
npm run compact |
Compile the Compact contract. The DQ gate. |
npm test |
Contract simulator suite + UI tests + fixture invariants |
npm run test:negative |
Just the negative suite — the tests that must fail |
npm run verify |
Boundary + domain-separator + fixture checks |
npm run verify:boundary |
Greps that /ledger and the proof path never touch mock data |
npm run verify:fixtures |
Asserts the conservation identity across every fixture |
npm run proof-server:up / :down / :logs |
Proof server on :6300 |
npm run dev / build / preview |
Vite |
npm run lint |
ESLint, including the mock-data boundary rule |
Not one of these depends on believing us.
git clone && npm ci && npm run compact && npm run build && npm teston your own machine. The compiler runs, proving/verifying keys are generated, the suite passes including the negative cases.npm run test:negative— the in-mandate case throws an assertion failure. This product's core claim is a test that must fail.- Kill the proof server mid-demo. The UI shows
ERRORED_UNEXPECTEDLYnaming the proof server — notREFUSED_AS_EXPECTED. A hardcoded refusal branch could not tell those apart. This is why the two states are built to be visually unmistakable: mint shield versus red octagon, in different regions of the screen, separable in greyscale and with audio muted. - Query the public ledger yourself from your own indexer. The counters match what is on screen, and
posted == executed + refused + outstandingholds. - Type a different attack string than the scripted one — the commitment differs, the nullifier differs, the refusal still proves. Type the same one twice: the second is rejected.
npm run verify:boundary— a dependency-free grep, readable in ten seconds, proving the/ledgerroute and the proof path never import mock data.
contract/src/test/ · vitest · an AlibiSimulator modelled on the Midnight example repos' CounterSimulator.
Every case asserts on both sides of the dual ledger — getLedger() and getPrivateState(). 7 positive
cases, 12 negative, plus a 25-call randomized soak.
The negative suite is not a safety net; it is the specification. Every claim ALIBI makes is a claim that something cannot be done, and a claim of that form is worth exactly what its failing test is worth. Two matter most:
- N1 —
recordRefusalon an in-mandate instruction throws. You cannot fake a refusal you were not owed. - N5 / P5 — two refusals for two different reasons must produce byte-identical public state. If that ever fails, the disjunction is leaking and the product is broken.
| Wave | Delivers |
|---|---|
| 1 | Four circuits · public conservation identity · negative-test suite · judge-operated attack console |
| 2 | Contract custody of value (enforcement, not just attestation) · selective disclosure of reasonCode to the principal · fixed-depth Merkle counterparty allowlist · liveness/heartbeat layer |
| 3 | Delegation chains — principal → agent → sub-agent, where each hop can only narrow the mandate, proven in ZK |
Apache License 2.0 — see LICENSE and NOTICE. All Midnight-related code in this
repository is newly written for this Buildathon.
Design reference: DESIGN.md (technique) + docs/DESIGN_BRIEF.md (tone, palette, and the authority on
both).