Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions solutions/LP-0005.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# Solution: LP-0005 — Private Balance Attestation

**Submitted by:** retraca

## Summary

A zero-knowledge balance attestation protocol. A user proves their LEZ account balance exceeds a threshold without revealing the actual balance, their account ID, or their nullifier secret key. The proof is one-shot: a circuit-bound nullifier prevents the same key from re-proving to the same gating program.

Two verification paths: **on-chain** (LEZ SPEL gating program) and **off-chain** (verifier library + Logos Messaging transport: the attestation travels over the Waku relay network and a gatekeeper verifies it locally to grant token-gated group admission, no on-chain transaction).

## Repository

- **Repo:** https://github.com/retraca/lp-0005-balance-attestation
- **Program ID:** `870d3f11c6d7f2902272c9d00009e0febe7f393d95f31c0ace9bb0da113d6719`

## Approach

### Circuit design

The RISC0 guest (`circuit/guest/src/main.rs`) replicates the LEZ private account commitment format exactly:

```
NPK = SHA256("LEE/keys" || nsk || 0x07 || [0;23])
data_hash = SHA256(data)
commitment = SHA256(PREFIX || NPK || program_owner_le || balance_le || nonce_le || data_hash)
```

where `PREFIX = b"/LEE/v0.3/Commitment/\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"`.

Private inputs: `nsk`, `program_owner`, `balance`, `data`, `nonce`, Merkle proof path. Public inputs (journal): `merkle_root`, `threshold_n`, `context_id`, `presenter_pk`, `nullifier`.

The guest recomputes the commitment from private inputs, verifies Merkle membership against `merkle_root`, checks `balance >= threshold_n`, then commits the nullifier.

### On-chain verification path

The LEZ program (`programs/balance_attestation`) is written using the SPEL framework (`#[lez_program]` / `#[instruction]`). Receipt verification uses `env::verify(IMAGE_ID, &journal_words)` inside the guest binary — this is the correct LEZ-native pattern. Calling `Receipt::verify()` at the host level inflates the ELF and fails at runtime with `sys_verify_integrity: no receipt found to resolve assumption`; `env::verify()` defers verification to the assumption-checking mechanism already wired into the zkVM environment.

Logical flow: deserialise gate state → verify proof via `env::verify` → decode journal → check context match → check threshold → reject spent nullifiers → verify presenter signature → write updated state.

### Off-chain verification path

`verifier-lib/src/lib.rs` exposes `verify_attestation(receipt_bytes, image_id, expected_context_id, expected_threshold, expected_presenter_pk, presenter_sig)`. It covers: receipt verification, journal decode, context match, threshold check, presenter public key match, Ed25519 signature verification over `(context_id || merkle_root || threshold_n || nullifier)`.

The Logos Messaging transport lives in `messaging/` (`attest-msg`). A prover publishes the attestation envelope to the content topic `/lp0005/1/attest/json` via a Waku node's REST API. A gatekeeper subscribes on its own node, verifies each incoming attestation locally with `verifier-lib`, tracks spent nullifiers in a persisted state file, and publishes an admit/deny verdict to `/lp0005/1/gate-response/json`. Admission to a token-gated group ("vip-room") is the reference flow. `demo-offchain.sh` runs the full path against two relay-connected nwaku nodes: proof generation, transmission across the relay mesh, local verification, admission, and a replay attempt that is denied because the nullifier is already spent.

### Security properties

**Nullifier caller-bypass prevented.** The nullifier `SHA256("balance-attest/v1" || nsk || context_id || use_nonce)` is computed inside the zkVM guest and committed to the journal. The on-chain verifier reads `journal.nullifier` — there is no caller-supplied nullifier parameter.

**Presenter binding.** The caller signs `(context_id || merkle_root || threshold_n || nullifier)` with an Ed25519 key committed in the proof as `presenter_pk`. The verifier checks the signature; a relayer cannot present someone else's proof without the presenter's private key.

**Merkle root freshness.** `GateState.accepted_root` stores the current root. Proofs over stale roots are rejected with `ERR_STALE_ROOT`.

**Context binding.** The program reads its own account ID as `context_id` and checks `journal.context_id == context_id`. A proof generated for gate A cannot be replayed at gate B.

### Error codes

| Code | Meaning |
|------|---------|
| 5001 | ERR_PROOF_INVALID |
| 5002 | ERR_CONTEXT_MISMATCH |
| 5003 | ERR_THRESHOLD_NOT_MET |
| 5004 | ERR_SIGNATURE_INVALID |
| 5005 | ERR_STALE_ROOT |
| 5006 | ERR_NULLIFIER_SPENT |

## Success Criteria Checklist

- [x] A shielded token account holder can generate a client-side proof that their balance meets a public threshold N — `circuit/host` CLI (`balance-attest prove`) fetches the Merkle proof and runs the RISC0 prover locally.
- [x] The proof is verifiable without revealing `npk`, exact balance, or account identity — private inputs never appear in the journal; only `merkle_root`, `threshold_n`, `context_id`, `presenter_pk`, `nullifier` are committed.
- [x] The proof is bound to a specific context — `context_id` is checked on-chain against the program's own account ID; the circuit commits it in the journal so it cannot be swapped post-proof.
- [x] The proof is bound to the presenter's identity — Ed25519 signature over `(context_id || merkle_root || threshold_n || nullifier)` is verified against `journal.presenter_pk` before the nullifier is marked spent.
- [x] The circuit targets the existing LEZ private account commitment format (`SHA256(npk || program_owner || balance || nonce || SHA256(data))`) — see `circuit/guest/src/main.rs` and the commitment replication in `circuit/host/src/main.rs::compute_commitment`.
- [x] **On-chain path** — LEZ SPEL gating program (`programs/balance_attestation`) accepts and verifies the proof; the `gate` instruction guards any downstream on-chain action.
- [x] **Off-chain path via Logos Messaging** — `messaging/attest-msg` transmits the attestation over the Waku relay network; a gatekeeper verifies it locally with `verifier-lib` and grants token-gated group admission. `demo-offchain.sh` demonstrates the full flow including replay denial via nullifier tracking, across two relay-connected nwaku nodes.
- [x] A standalone consumer integration demo is included — `basecamp-app/` provides a browser-based commitment-hash tool; `demo.sh` exercises the full prove/verify CLI flow offline.
- [x] Full documentation and clean public repository — `README.md` covers deployment, CLI usage, and Basecamp app loading; `SOLUTION.md` covers circuit design and integration guide.

## FURPS Self-Assessment

### Functionality

The on-chain path is fully functional: proof generation (`balance-attest prove`), on-chain gate deployment (`initialize`), and proof submission (`gate`). The circuit correctly replicates the LEZ commitment format. Context binding, presenter binding, and nullifier deduplication all work. Error codes are distinct and documented for each failure mode.

The off-chain path is fully functional: `verify_attestation` in `verifier-lib` verifies a proof locally (receipt check, journal decode, context match, threshold check, presenter key match, signature check over `context_id || merkle_root || threshold_n || nullifier`). The `messaging/attest-msg` binary handles transmission over the Waku relay network: `send` publishes the attestation envelope and awaits the verdict; `gatekeeper` verifies incoming attestations, persists spent nullifiers across restarts, and admits or denies access to the token-gated group.

### Usability

CLI: `balance-attest prove` requires `nsk`, `program_owner`, `balance`, `threshold`, `context-id`, `presenter-sk`, and `sequencer` URL. Output is `receipt.bin` plus printed `presenter_pk`, `sig`, and `nullifier`. `balance-attest verify` checks a receipt file offline without chain access.

Basecamp app (`basecamp-app/index.html`) loads in Logos Core / Basecamp. It computes the commitment hash client-side using the Web Crypto API given `nsk`, `program_owner`, `balance`, `data`, `nonce` — all computation stays in-browser, `nsk` is never sent anywhere.

`verifier-lib` ships as a Rust crate with a single public function; its doc example is in `lib.rs`.

Two reproducible demo scripts: `demo.sh --dev` (offline prove + verify) and `demo-offchain.sh --dev` (full Logos Messaging flow against two relay-connected nwaku nodes, including the replay-denial case). Both run without modification from a clean checkout.

### Reliability

The on-chain program returns explicit error codes for all invalid-proof cases (see error code table above). `env::verify` failure, journal decode failure, context mismatch, threshold not met, invalid presenter sig, and spent nullifier each produce a distinct code. The circuit never panics on valid input formats — all fallible operations return `Result` propagated through `SpelResult`.

Off-chain: `verify_attestation` returns an `anyhow::Error` with a message for each failure case, not a panic. Private account data does not appear in error messages. The gatekeeper passes only the public failure category to the denied party; a malformed envelope, an invalid proof, a threshold failure, and a spent nullifier each produce a distinct deny reason. Gatekeeper state (spent nullifiers, members) persists to disk and survives restarts.

### Performance

Proof generation in `RISC0_DEV_MODE=1`: under 2 seconds on a laptop. Full RISC0 proof (`RISC0_DEV_MODE=0`): expected 5–20 minutes on CPU (consistent with other RISC0-based submissions on this prize). On-chain CU cost: not yet benchmarked against a running LEZ testnet sequencer — the program is not deployed on testnet. This is an open gap.

### Supportability

CI (`.github/workflows/ci.yml`) runs on push and PR. Three jobs: `rust-guest` (clippy + check for the zkVM guest), `rust-lib` (clippy + test for `programs/` and `verifier-lib`), `rust-host` (clippy + check for the CLI). All jobs green on the default branch.

Integration tests: 11 tests in `programs/balance_attestation/tests/integration.rs` cover initialize, valid gate call, nullifier reuse rejection, context mismatch, threshold failure, stale root, and wrong presenter. Tests run against the program logic directly (no live sequencer needed for CI).

The program is not deployed on LEZ testnet — `demo.sh` runs the offline prove/verify flow using `RISC0_DEV_MODE=1` and does not require a sequencer. Testnet deployment is the remaining step before human evaluation.

## Supporting Materials

- Repository: https://github.com/retraca/lp-0005-balance-attestation
- Off-chain verifier library: `verifier-lib/src/lib.rs`
- Logos Messaging transport: `messaging/src/main.rs` (`attest-msg send / gatekeeper`)
- Basecamp app: `basecamp-app/index.html`
- Offline demo: `demo.sh`
- Off-chain messaging demo: `demo-offchain.sh` (two nwaku nodes, admission + replay denial)
- IDL: `lp-0005-balance-attestation.idl.json`

## Terms & Conditions

By submitting this solution, I confirm that I have read and agree to the [Terms & Conditions](../TERMS.md).
Loading