Policy-as-code for private on-chain finance — prove you're allowed, execute the exact action, never expose the person.
Most privacy-finance projects prove one statement — a solvency proof, one compliance check, an age credential. Nullis answers the harder question: can any supported private policy safely authorize an exact on-chain action? An app publishes a financial policy. A user proves — in zero knowledge, on-chain — that they satisfy it. Soroban executes the exact permitted action atomically, and every decision (success and rejection) emits an inspectable Privacy Receipt. The app learns the answer, never the person.
Apps publish a financial policy. Users prove they satisfy it privately. Soroban executes the exact permitted action — without identifying or tracking the user.
Private if allowed. Blocked if not. Verified on-chain.
Live contract ↗ · The real-ZK payment tx ↗ · Architecture ↗ · Run it locally ↗
- The problem
- What I built
- Architecture
verify_and_execute, step by step- The claim-safety split — what proves what
- Cryptographic design
- The circuit
- Cross-app unlinkability — two apps, one engine
- Revocation
- The Privacy Receipt
- Live on Stellar testnet — the evidence
- SDK & CLI
- Engineering decisions & the hard problems
- What's real vs mocked — the honesty table
- Benchmarks
- Threat model
- Tech stack
- Project layout
- Run it locally
- Tests
- Roadmap
Stellar is built for real-world money: stablecoins, remittances, cross-border payments, tokenized assets. Real-world finance has a hard conflict at its core: apps must know a user is allowed to act; users should not have to surrender permanent identity data for temporary financial actions.
The human version: Amina wants to send money home. The transfer takes seconds — but the payment app asks her to upload a passport that may sit in its database for years. A password can be changed after a breach. A passport cannot.
Today teams pick one of two bad extremes:
- Public compliance — KYC everything, store identity documents, reveal far more than the transaction requires. One breach away from permanent harm.
- Unregulated privacy — mixers and anonymity pools that institutions can't touch, because there's no way to prove anyone was allowed to do anything.
Nullis is the missing middle: a policy-as-code execution layer. The app publishes what it requires. The user proves they satisfy it — cryptographically, without revealing who they are. The chain verifies the proof and executes exactly the action that was authorized: this recipient, this amount, this asset, this network, once. Not "proof-only" — proof-to-action, atomically.
And because the same credential must not become a tracking identifier, nullifiers are domain-separated: the same credential produces different, unlinkable nullifiers in different apps. Reusable credentials without a reusable tracking identifier.
One engine, five layers, all real and testnet-proven:
- A Noir circuit (
circuits/nullis) that proves, in zero knowledge: I possess a credential secret · its commitment is in this policy's approved set · I derived my nullifier correctly, bound to this exact action. The secret never leaves the prover's machine. - One modular Soroban contract (
contracts/nullis) that stores policies, verifies the UltraHonk proof on-chain, recomputes the action binding from the submitted action, enforces every public policy parameter, blocks replays, executes the transfer, and emits a Privacy Receipt — in one atomic call. - A reference issuer (
@nullis/issuer) that admits credentials into a Poseidon2 Merkle approved-set, publishes the root on-chain, produces membership witnesses, and revokes by root rotation. - An SDK (
@nullis/sdk) that makes the whole thing a one-liner — it builds the action-bound public inputs and submitsverify_and_executewith all encoding derived from the on-chain contract spec. - A CLI + evidence package —
nullis policy validate/nullis receipt, runnable two-app examples, EVIDENCE.md, SECURITY.md, BENCHMARKS.md, CI.
The one property everything else hangs on: the five canonical hashes are computed byte-identically in three independent implementations — the Rust contract (on-chain host Poseidon2), the TypeScript SDK, and the Noir circuit — locked by a shared test-vector gate. If they ever diverged, a proof built off-chain would silently stop verifying on-chain. They can't, and CI proves it on every push.
flowchart LR
subgraph OffChain["Off-chain"]
I[Reference issuer] -->|admits commitment| T[("Poseidon2 Merkle<br/>approved set")]
U(["User / prover"]) -->|"credential_secret<br/>never leaves device"| P["Noir circuit<br/>+ bb UltraHonk prover"]
T -->|membership witness| P
S["@nullis/sdk<br/>buildRequest"] -->|"action-bound<br/>public inputs"| P
end
subgraph OnChain["Soroban contract — one atomic call"]
V{verify_and_execute}
V --> C1["policy active?<br/>version + root current?"]
C1 --> C2["UltraHonk proof<br/>verifies on-chain?"]
C2 --> C3["expiry · amount ≤ max<br/>asset · action type"]
C3 --> C4["context_hash + action_id<br/>recomputed = submitted?"]
C4 --> C5["nullifier + action_id<br/>unused?"]
C5 --> E["escrow transfer<br/>contract → recipient"]
end
P -->|"proof (14.6 KB)"| V
T -->|approved_root| V
E --> R[["Privacy Receipt<br/>success AND rejection"]]
The consensus-critical core is deliberately factored into exactly one place per language:
| Component | Role |
|---|---|
contracts/nullis |
The one deploy unit. Internal modules: Policy (registry, versioning, rotation) · Verifier (UltraHonk, VK immutable at deploy) · Nullifier (spent-sets) · Executor (the atomic primitive) · hash (canonical Poseidon2). |
circuits/nullis |
The ZK statement. 73 ACIR opcodes / 1,540 gates. |
packages/core |
Canonical field encoding + the five hashes in TypeScript + test-vectors.json — the cross-implementation gate. |
packages/issuer |
Commitments, the approved-set Merkle tree, witnesses, revocation-by-rotation. Reference only — no real KYC. |
packages/sdk |
buildRequest (action binding) + Nullis client (one-line on-chain call). |
packages/cli |
nullis policy validate (canonical policy_hash) · nullis receipt (render receipts). |
One call is the entire product. Here is exactly what the contract does, in order — every step is a tested rejection path with its own receipt reason:
sequenceDiagram
participant App as Consuming app
participant SDK as @nullis/sdk
participant N as Nullis contract
participant Tok as Token (SAC)
App->>SDK: verifyAndExecute({policyId, publicInputs, proof, action})
SDK->>N: verify_and_execute(...)
N->>N: 1. load policy — active? (DISABLED)
N->>N: 2. version + root + policy_hash current? (STALEROOT)
N->>N: 3. UltraHonk proof verifies against stored VK? (PROOFBAD)
N->>N: 4. not expired? (EXPIRED)
N->>N: 5. action type / asset / 0 < amount ≤ max? (ACTION · ASSET · AMOUNT)
N->>N: 6. recompute context_hash + action_id from THIS action — match? (CTXBAD)
N->>N: 7. nullifier AND action_id unused? (REPLAY)
N->>N: 8. mark both spent
N->>Tok: transfer(contract → recipient, amount)
N-->>App: Privacy Receipt (VERIFIED · executed: true)
Note over N: on ANY failure: a rejection Receipt with the reason —<br/>failures leave artifacts too
Two details that matter:
- The network id and the Nullis contract address are read from the environment, never from input — and both are folded into
context_hash. A proof bound to one deployment on one network is cryptographically useless on any other (tested:cross_network_or_contract_is_blocked). - Security decisions never trap. Rejections return a receipt (and emit
ActionRejected(reason)) instead of panicking, so a blocked action leaves the same quality of inspectable artifact as a successful one. Only genuine usage errors (unknown policy, malformed VK) trap.
The single easiest thing for a technical reviewer to reject is a project that attributes a check to the circuit that the contract performs, or vice versa. So the split is a design principle here, enforced in code comments and docs:
flowchart TB
subgraph Circuit["CIRCUIT proves — zero-knowledge, privacy-critical"]
Z1[possession of credential_secret]
Z2["commitment ∈ approved_root<br/>(Merkle membership)"]
Z3["nullifier correctly derived from<br/>secret · policy · app_domain · action_id"]
end
subgraph Contract["CONTRACT enforces — public, on-chain, auditable"]
K1[policy active · version + root current]
K2["not expired · amount ≤ max<br/>asset + action type match"]
K3["context_hash + action_id recomputed<br/>from the submitted action"]
K4[nullifier + action_id uniqueness]
K5[proof verifies · action executes atomically]
end
Circuit -->|"proof + 5 public inputs"| Contract
Privacy-critical statements (who you are, that you're approved) live in the circuit. Policy parameters that are already public (limits, expiry, asset) are enforced by the contract, where they're cheaper and auditable. Full statement: SECURITY.md · docs/public-private-inputs.md.
Hash = Poseidon2 over BN254 (t=4, rate=3), running as a host function on Stellar (Protocol 25 "X-Ray" / 26 "Yardstick" shipped Poseidon + BN254 natively, which is what makes on-chain Noir verification affordable). Never raw JSON — every hash is over ordered field elements:
commitment = Poseidon2(credential_secret)
policy_hash = Poseidon2(policy_id, version, action_type, asset, max_amount,
approved_root, app_domain_hash, expiry)
context_hash = Poseidon2(network_id, nullis_contract, consuming_contract, policy_id,
policy_version, action_type, recipient, amount, asset,
intent_nonce, expiry)
action_id = Poseidon2(consuming_contract, policy_id, policy_version, action_type,
recipient, amount, asset, intent_nonce)
nullifier = Poseidon2(credential_secret, policy_id, app_domain_hash, action_id)Replay semantics, precisely: intent_nonce identifies one application-created authorization intent. It is bound into action_id, and the contract consumes both the action_id and the nullifier. A genuinely new payment — even same recipient, same amount — requires a new intent nonce and a new proof. The action_id set also blocks a subtler attack: a different credential re-authorizing the same intent (tested: action_id_replay_with_different_credential_is_blocked).
One hash, three implementations, one gate:
flowchart LR
RS["Rust contract<br/>soroban-poseidon<br/>(the on-chain host fn)"] --- G{{"test-vectors.json<br/>golden vectors<br/>+ Barretenberg anchor"}}
TS["TypeScript<br/>@zkpassport/poseidon2"] --- G
NR["Noir circuit<br/>noir-lang/poseidon"] --- G
The vectors were generated from the on-chain implementation, anchored externally against Barretenberg's own published test case, and are asserted by the Rust suite, the TypeScript suite, and (via nargo execute on issuer-generated inputs) the circuit. Addresses enter the field canonically: the raw 32-byte contract/account id, big-endian, reduced mod r — identical in hash::addr_to_field (Rust) and addrToField (TS), locked by its own vector.
Noir 1.0.0-beta.9 · UltraHonk (Barretenberg 0.87.0) · BN254. Chosen once, before building — no mid-build proof-stack thrash.
| Public inputs (5 × 32-byte field elements) | Private inputs |
|---|---|
policy_id · app_domain_hash · action_id · approved_root · nullifier |
credential_secret · path_siblings[8] · path_bits[8] |
The circuit proves three statements and nothing else:
commitment = Poseidon2([credential_secret])— possession.- Folding the commitment up the depth-8 Merkle path yields
approved_root— membership (corridor/jurisdiction eligibility in v0 is this root; the issuer only admits approved users). nullifier = Poseidon2([secret, policy_id, app_domain_hash, action_id])— correct derivation, binding the spend to this app and this exact intent.
Soundness is tested, not assumed (nargo test): a valid member is accepted; a non-member secret, a tampered Merkle path, a wrong nullifier, and a non-binary path bit are each rejected — you cannot produce a proof without a genuine witness. On-chain, a tampered proof or tampered public input is rejected by the verifier (tested in contracts/nullis/tests/zk_verifier.rs).
The verifier is ultrahonk_soroban_verifier (pinned rev), with the VK validated and stored immutably at deploy time — auditable by anyone via the contract's vk() method.
Claiming reuse is cheap; demonstrating it is the differentiator. Both example apps use the same contract, same SDK, same credential — different policy, action, and app_domain:
flowchart TB
C["one credential<br/>commitment = Poseidon2(secret)"]
C -->|"app_domain: remittance"| A["Remittance app<br/>policy 777"]
C -->|"app_domain: rwa-access"| B["RWA access app<br/>policy 888"]
A --> NA["nullifier A<br/>0x09e01e5e…6ba08c"]
B --> NB["nullifier B<br/>0x0532e4a7…7f2911"]
NA -. cryptographically unlinkable .- NB
Run it yourself — node examples/unlinkability.mjs prints both nullifiers and writes artifacts/demo-results.json ("unlinkable": true). From Nullis's artifacts alone, the two apps cannot correlate the same user.
The claim is scoped precisely so it survives scrutiny: unlinkability holds at the proof and nullifier layer. Wallet addresses, funding sources, IPs, and timing can still correlate users at other layers — see Threat model.
Learns: Never receives:
✓ Approved credential ✗ Legal name
✓ Credential current ✗ Passport number
✓ Permitted corridor ✗ Date of birth
✓ Amount within limit ✗ Home address
✓ Proof belongs to this action ✗ Cross-app tracking identifierShipped mechanism: approved-root rotation with versioning. Revoking a user rebuilds the approved set and publishes a new root; rotate_root bumps the policy version, and any proof bound to a stale version/root is rejected on-chain (STALEROOT — tested, and exercised live). Fully real, fully demoable: a revoked user visibly fails.
The trade-off, disclosed rather than hidden: every rotation invalidates existing Merkle witnesses, so active users must refresh their paths against the new root. Root rotation gives simple, reliable revocation for this stage; in-circuit sparse-Merkle non-membership is the production path precisely because it avoids the witness-refresh cost — it's on the roadmap and claimed nowhere as shipped.
Emitted after every decision — success and rejection alike. That duality is the story: a blocked payment leaves the same quality of artifact as an executed one.
NULLIS PRIVACY RECEIPT
Policy 1001 v1
Result VERIFIED AND EXECUTED
Action 100 (asset CDRR42LTFS…D3P4RV) transferred
Privacy Identity disclosed: NO · Passport stored: NO · Cross-app id: NO
Stellar Ledger 3406851 · Nullifier 1419930645…077563The privacy flags are structurally false — the contract never receives identity, so it cannot disclose it. Rejection receipts carry the precise reason: DISABLED · STALEROOT · PROOFBAD · EXPIRED · ACTION · ASSET · AMOUNT · CTXBAD · REPLAY. Render any receipt with nullis receipt <receipt.json>.
Canonical events for indexers: PolicyPublished, RootRotated, ProofVerified, ActionExecuted, ActionRejected(reason), ReplayBlocked, ReceiptEmitted — all typed #[contractevent]s in the contract spec.
The load-bearing deployment runs the real UltraHonk verifier with the circuit VK validated on-chain at construction. Protocol 27.
| What | Where |
|---|---|
| Nullis contract | CBVZ3XJQ…F5Y7 |
| Test-USDC asset (SAC) | CDRR42LT…P4RV |
| Claim | Evidence (testnet tx) |
|---|---|
| Contract deploys with a validated VK | e16278f0… |
| Policy published on-chain | 2a3ba6e6… · PolicyPublished |
| A real ZK proof gates a real payment — 100 USDC moved | 214d788b… · ProofVerified → transfer → ActionExecuted → ReceiptEmitted (VERIFIED) |
| Replaying that exact proof | ReplayBlocked → ActionRejected(REPLAY) · executed: false · recipient balance unchanged |
The whole flow was then repeated through the SDK's one-line client with a fresh intent nonce — fresh proof, VERIFIED, executed — proving the developer path end-to-end, not just the CLI path. Machine-readable manifest: submission-evidence.json · artifacts/testnet-transactions.json.
Ten lines, end to end — build the action-bound request, prove, submit:
import { Nullis, buildRequest } from "@nullis/sdk";
// 1. Build the action-bound public inputs (context_hash, action_id, nullifier).
const req = buildRequest({
policyId, policyHash, policyVersion, approvedRoot, appDomainHash, policyExpiry,
networkId, nullisContract, credentialSecret,
action: { actionType: 1, recipient, amount: 100n, asset, consumingContract, intentNonce: 1n },
});
// 2. Prove with nargo + bb (see scripts/live-real-zk.mjs), then:
const nullis = new Nullis({ contractId, secretKey });
const receipt = await nullis.verifyAndExecute({ ...req, proof });
// receipt.result === "VERIFIED", receipt.executed === true → the asset movedThe client fetches the deployed contract's spec and encodes every U256 / struct / Bytes argument automatically — no hand-rolled XDR.
nullis policy validate policy.json # validate a manifest + compute its canonical policy_hash
nullis receipt receipt.json # render a Privacy Receipt (success or rejection)The things that shaped the build, and the bugs that taught me something:
- Skeleton first, real ZK last — deliberately. The riskiest component (on-chain UltraHonk verification, experimental tooling) was isolated: I built and fully tested the deterministic backbone — policy registry, action binding, replay sets, escrow, receipts — behind a clearly-labeled placeholder verifier, proved the whole slice on testnet, then swapped in the real verifier behind the same interface. The placeholder survives only as a
mock-verifiertest feature for the fast logic suite; the deployed wasm always carries the real verifier. This is why the swap took hours, not days: every other moving part was already proven. - The consensus-critical hash was the first thing built and the most-tested thing in the repo. Rust, TypeScript, and Noir must produce byte-identical Poseidon2 — one divergent field ordering and off-chain proofs silently stop verifying. The discovery that made it tractable: Stellar's official
soroban-poseidonis built to matchnoir-lang/poseidon(same t=4/rate=3 sponge, same capacity encoding). I locked the property three ways: golden vectors generated from the on-chain implementation, an external anchor against Barretenberg's own published test vector, andnargo executeruns on issuer-generated inputs that exercise all three sponge arities (1, 2, and 4 inputs). - The wasm build failed with
no global memory allocatorthe moment the real verifier landed — the UltraHonk math needs a heap, andno_stdSoroban contracts don't have one by default. Fix: soroban-sdk'sallocfeature, which the verifier's own reference contracts quietly rely on. Deploy artifact: 44,957 bytes, comfortably deployable. - stellar-sdk v13 couldn't even read our contract. The spec-driven TS client choked with
unknown ScSpecEntryKind member for value 5— the contract uses Protocol 27's typed#[contractevent]spec entries, which v13's XDR reader predates. Upgrading to v16 fixed parsing and auto-generated typed methods for the whole surface. - The SDK classified
verify_and_executeas a read call. Simulation found no caller auth entry — correct, because the contract self-authorizes its own escrow transfer — sosignAndSend()refused to submit a "query." The call is obviously state-changing (it consumes a nullifier and moves money), so the client force-sends. A neat example of auth-shape heuristics misreading escrow patterns. - I mutation-tested the tests. After an adversarial audit of my own suite found four untested paths (wrong action type; zero/negative amounts; tampered
policy_hashalone; and the subtle one — a different credential replaying the same intent through theaction_idset), I added the tests, then deleted the replay protection from the executor and watched both replay tests fail before restoring it byte-identical. Green tests that can't fail are decoration; these are load-bearing. - Rejections are receipts, not panics. Contract errors trap and roll back — which would destroy the artifact trail for blocked actions. So security decisions return a rejection receipt and emit
ActionRejected(reason); only genuine usage errors trap. Success and failure both leave evidence, which is the product's whole posture. - The revocation gate caught my own mistake. While wiring the live SDK demo, my script built the approved set with different members than the on-chain policy — and the contract rejected the (otherwise valid) proof with
STALEROOT. Annoying for five minutes, then satisfying: that rejection is exactly the mechanism that makes revoked users fail. - Addresses enter the field exactly once, canonically.
Address::to_payload()(the raw 32-byte id) → big-endian → reduce mod r, identical in Rust and TS, locked by a dedicatedfield_reductionvector — because a value above the modulus that two implementations reduce differently would be an unfindable heisenbug. The needed SDK feature (hazmat-address) is used solely for hashing, never for auth.
Honest disclosure is a scoring criterion, not a weakness. Items move up only when they truly are real.
| Capability | How it's backed |
|---|---|
| On-chain ZK verification | Real. Noir/UltraHonk proof verified by the deployed contract (pinned ultrahonk_soroban_verifier), VK immutable at deploy — gated a live 100 USDC payment (tx). |
| Proof generation | Real, local — nargo + bb on the user's machine; the credential secret never leaves it. |
| Canonical hashing | Real host Poseidon2 on-chain; byte-identical Rust ↔ TS ↔ Noir, CI-gated by shared vectors. |
| Asset movement | Real testnet SAC transfers via the escrow model — the contract holds test tokens and releases contract → recipient. Labeled a reference execution adapter: authorized-transfer (sender → recipient in the same tx) is the production path. |
| Replay prevention | Real, on-chain, both sets (nullifier + action_id) — proven live and mutation-tested. |
| Revocation | Real — root rotation + versioning, stale proofs rejected on-chain. In-circuit non-membership is roadmap, claimed nowhere. |
| Two apps, one engine | Real — same credential, two policies/domains, two unlinkable nullifiers (examples/unlinkability.mjs). |
| Credential issuer | Reference only. Admitting a user is the eligibility decision; there is no real KYC, sanctions screening, or government-database check behind it. |
mock-verifier feature |
Test-only build flag for the fast 26-test logic suite. Never in the deployed wasm. |
| Audit · mainnet · legal claims | Not done, not claimed. Testnet research software. |
Measured, not estimated — full detail in BENCHMARKS.md:
| Metric | Value |
|---|---|
| Circuit | 73 ACIR opcodes · 1,540 gates |
| Witness + proof generation (Apple Silicon) | ~0.6 s + ~0.27 s |
| Proof · VK · public inputs | 14,592 B · 1,760 B · 160 B |
| Contract wasm (real verifier) | 44,957 B |
verify_and_execute with real ZK — fee charged |
364,475 stroops (~0.036 XLM) |
| Same flow, no ZK (placeholder baseline) | 244,159 stroops |
| Incremental cost of on-chain ZK verification | ~120,316 stroops ≈ 0.012 XLM |
On-chain UltraHonk verification fits inside a normal payment transaction on testnet — no elevated limits.
Full statement in SECURITY.md. The short version:
Protects against: identity leakage · unapproved users (non-members can't produce proofs — circuit-tested) · revoked users (stale-root rejection) · replay, including cross-credential intent replay · proofs copied to a different recipient / amount / asset / contract / network · cross-app linkage of one credential at the artifact layer · frontend bypass (the contract is authoritative) · fake "verified" claims (no success path without an on-chain proof).
Does not protect against (this version): a malicious or compromised issuer · a compromised user device · wallet/funding/IP/timing metadata correlation · fake off-chain identity checks before issuance · production sanctions compliance · pre-audit contract or circuit bugs.
Trust assumptions: the deployer set the correct VK (readable via vk()); the issuer honestly reflects real-world eligibility; the pinned verifier crate and Stellar's Poseidon2/BN254 host functions are correct.
- Contract: Rust ·
soroban-sdk26.1 (Protocol 27,hazmat-address+alloc) ·soroban-poseidon·ultrahonk_soroban_verifier(pinned rev). - Circuit: Noir 1.0.0-beta.9 · Barretenberg
bb0.87.0 (UltraHonk, keccak oracle) ·noir-lang/poseidonv0.2.0. - Off-chain: TypeScript (strict) ·
@zkpassport/poseidon2·@stellar/stellar-sdkv16 (spec-driven client) · npm workspaces · Vitest. - CI: GitHub Actions — Rust tests (both configs) + clippy
-D warnings· TS build/test/typecheck ·nargo test. - Frontend: Next.js 16 demo UI in
frontend/(separate workstream).
contracts/nullis/ # the ONE Soroban contract
src/
lib.rs # surface: constructor(admin, network_id, vk) · create_policy ·
# rotate_root · disable_policy · get_policy · is_spent · vk ·
# verify_and_execute
hash.rs # canonical Poseidon2 hashes + addr_to_field (consensus-critical)
policy.rs · nullifier.rs # registry + versioning · nullifier/action_id spent-sets
verifier.rs # real UltraHonk verify (default) | mock (test feature)
executor.rs # the atomic primitive, exact check order
receipt.rs · events.rs # Privacy Receipt · canonical #[contractevent]s
test.rs # 26-test logic suite incl. the full negative suite
tests/zk_verifier.rs # a REAL proof verifies; tampered proof/inputs rejected
tests/fixtures/nullis/ # committed proof + vk + public_inputs (bb 0.87.0)
circuits/nullis/ # Noir circuit + 5 soundness tests (nargo test)
packages/
core/ # @nullis/core — hashing + test-vectors.json (the cross-impl gate)
issuer/ # @nullis/issuer — commitments · Merkle approved-set · rotation
sdk/ # @nullis/sdk — buildRequest + Nullis one-line client
cli/ # @nullis/cli — policy validate · receipt
examples/
remittance/ · rwa-access/ # two apps, one engine, one credential
unlinkability.mjs # the side-by-side nullifier moment → artifacts/demo-results.json
scripts/
live-real-zk.mjs # tree + witness + Prover.toml + invoke inputs for a live proof
live-sdk-demo.mjs # fresh proof → one-line SDK call → VERIFIED (against testnet)
gen-circuit-input.mjs # circuit inputs from the real issuer/core (parity check)
demo-e2e.mjs # npm run demo:e2e
docs/ # proof-design.md · public-private-inputs.md
artifacts/ # testnet-transactions.json · demo-results.json
EVIDENCE.md · SECURITY.md · BENCHMARKS.md · submission-evidence.json
frontend/ # Next.js demo UI (separate workstream)
Prerequisites: Rust (with the wasm32v1-none target), Node 20+. For proving: Noir + Barretenberg.
# 1. TypeScript workspace — hashing, issuer, SDK, CLI
npm install && npm run build
npm test # 24 tests incl. the cross-impl hash gate
# 2. Contract — real-ZK path (verifies a committed real proof on-chain) + logic suite
cargo test -p nullis-contract # 10 tests: real proof verifies · tampering rejected
cargo test -p nullis-contract --features mock-verifier # 26 tests: full negative suite
# 3. Circuit — soundness
curl -L https://raw.githubusercontent.com/noir-lang/noirup/main/install | bash
noirup -v 1.0.0-beta.9
cd circuits/nullis && nargo test # 5 tests: accepts member; rejects everything else
# 4. Everything + live evidence links, one command
npm run demo:e2e
# 5. The two-apps unlinkability demo (no network needed)
node examples/unlinkability.mjs
# 6. Reproduce a LIVE real-ZK payment (needs bb 0.87.0 + a funded testnet key)
node scripts/live-real-zk.mjs
cd circuits/nullis && nargo execute && bb prove --scheme ultra_honk --oracle_hash keccak \
--bytecode_path target/nullis.json --witness_path target/nullis.gz \
--output_path target --output_format bytes_and_fields
stellar contract invoke --id CBVZ3XJQ… --source <you> --network testnet -- verify_and_execute \
--policy_id 1001 --proof-file-path circuits/nullis/target/proof \
--public_inputs-file-path public_inputs.json --action-file-path action.json65 tests across four layers, plus CI on every push:
| Layer | Count | What it proves |
|---|---|---|
| Contract — real-ZK path | 10 | A real UltraHonk proof of the full circuit verifies on-chain; a tampered proof and a tampered public input are rejected; canonical hashes match the locked golden vectors. |
| Contract — logic suite | 26 | The full negative suite: valid succeeds · bad proof · replay (both sets, incl. cross-credential) · changed recipient/amount/asset/action-type · zero & negative amounts · over-max · stale root (revoked) · tampered policy_hash · expired · disabled · cross-network/contract · two apps → different nullifiers. |
Circuit (nargo test) |
5 | Soundness: accepts a valid member; rejects a non-member secret, tampered Merkle path, wrong nullifier, non-binary path bit. |
| TypeScript | 24 | The cross-impl hash gate (byte-identical to on-chain) · issuer Merkle build/witness/revocation · SDK request building + mock-proof vector · CLI validation + receipt rendering. |
Two disciplines beyond the counts: the replay tests were mutation-verified (protection removed → tests fail → restored), and the live testnet transactions are the one integration test that can't be tautological — reality executed them.
Stated, not built — in honesty-order:
- In-circuit sparse-Merkle non-membership revocation (removes the witness-refresh cost of root rotation).
- Authorized-transfer executor (
sender → recipientin the same tx) replacing the escrow reference adapter. - Production credential issuers (real KYC attestation), multi-issuer governance, sanctions oracle.
- Selective audit envelope — encrypted issuer/compliance metadata decryptable only by an authorized auditor.
- Policy templates · recursive proof aggregation · security audit · mainnet.
Prove the policy. Execute the action. Never expose the person.