Skip to content

Enoch208/nullis

Repository files navigation

Nullis

Policy-as-code for private on-chain finance — prove you're allowed, execute the exact action, never expose the person.

ci Tests Stellar ZK Stack

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 ↗

▶ Watch the 3-min demo ↗  ·  Live app ↗  ·  Announcement on X ↗


Table of contents


The problem

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.

What I built

One engine, five layers, all real and testnet-proven:

  1. 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.
  2. 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.
  3. 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.
  4. An SDK (@nullis/sdk) that makes the whole thing a one-liner — it builds the action-bound public inputs and submits verify_and_execute with all encoding derived from the on-chain contract spec.
  5. A CLI + evidence packagenullis 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.

Architecture

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"]]
Loading

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).

verify_and_execute, step by step

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
Loading

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 claim-safety split — what proves what

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
Loading

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.

Cryptographic design

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
Loading

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.

The circuit

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:

  1. commitment = Poseidon2([credential_secret]) — possession.
  2. 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).
  3. 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.

Cross-app unlinkability — two apps, one engine

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
Loading

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.

What the app learns vs never receives

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 identifier

Revocation

Shipped 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.

The Privacy Receipt

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…077563

The 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.

Live on Stellar testnet — the evidence

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.

SDK & CLI

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 moved

The 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)

Engineering decisions & the hard problems

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-verifier test 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-poseidon is built to match noir-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, and nargo execute runs on issuer-generated inputs that exercise all three sponge arities (1, 2, and 4 inputs).
  • The wasm build failed with no global memory allocator the moment the real verifier landed — the UltraHonk math needs a heap, and no_std Soroban contracts don't have one by default. Fix: soroban-sdk's alloc feature, 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_execute as a read call. Simulation found no caller auth entry — correct, because the contract self-authorizes its own escrow transfer — so signAndSend() 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_hash alone; and the subtle one — a different credential replaying the same intent through the action_id set), 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 dedicated field_reduction vector — 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.

What's real vs mocked — the honesty table

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.

Benchmarks

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.

Threat model

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.

Tech stack

  • Contract: Rust · soroban-sdk 26.1 (Protocol 27, hazmat-address + alloc) · soroban-poseidon · ultrahonk_soroban_verifier (pinned rev).
  • Circuit: Noir 1.0.0-beta.9 · Barretenberg bb 0.87.0 (UltraHonk, keccak oracle) · noir-lang/poseidon v0.2.0.
  • Off-chain: TypeScript (strict) · @zkpassport/poseidon2 · @stellar/stellar-sdk v16 (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).

Project layout

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)

Run it locally

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.json

Tests

65 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.

Roadmap

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 → recipient in 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.

About

Nullis is a policy-as-code execution layer for private Stellar finance—prove eligibility in zero knowledge, bind it to an exact action, and execute on Soroban without exposing identity.

Resources

Security policy

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors