diff --git a/solutions/LP-0005.md b/solutions/LP-0005.md new file mode 100644 index 0000000..2efc942 --- /dev/null +++ b/solutions/LP-0005.md @@ -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).