diff --git a/solutions/LP-0003.md b/solutions/LP-0003.md new file mode 100644 index 0000000..0677088 --- /dev/null +++ b/solutions/LP-0003.md @@ -0,0 +1,123 @@ +# Solution: LP-0003 — DistributionX + +Submitted by: Timidan + +## Summary + +DistributionX is a private allowlist airdrop for the Logos Execution Zone (LEZ). A distributor commits an encrypted eligibility list on-chain through a Merkle root, funds a vault, and lets eligible recipients claim with a real Risc0 proof using `RISC0_DEV_MODE=0`. + +The privacy claim targeted by the bounty is that on-chain observers should not learn the eligible address, row salt, claim signature, or Merkle path from a valid claim transcript. The program defines two claim instructions and the property is acknowledged as not currently demonstrably met under either, for distinct reasons. The receipt-based `claim` instruction would keep the witness inside the Risc0 zkVM, but its `#[account(mut)] recipient` parameter requires an account the program can claim ownership of in the post-state, which a shielded one-time destination commitment cannot satisfy; the instruction therefore fails with `InvalidProgramBehavior` on the active reviewer flow. The `claim_private` instruction verifies the witness in-program rather than inside the zkVM, so the witness fields are passed as instruction args and the generated FFI sends them via `NSSATransaction::Public`, putting the witness in the public transaction transcript. The active reviewer demo uses `claim_private`. The full breakdown, with the two tracked follow-up fixes that would each independently restore the property, lives in [docs/WRITEUP.md Privacy Model](https://github.com/Timidan/dist-x/blob/875520648b0d39091b0002dc499050d9c618572e/docs/WRITEUP.md#privacy-model). + +## Repository + +- Repository: [https://github.com/Timidan/dist-x](https://github.com/Timidan/dist-x) +- Pinned commit: [`8755206`](https://github.com/Timidan/dist-x/commit/875520648b0d39091b0002dc499050d9c618572e) + +## Approach + +DistributionX separates the airdrop into three parts: eligibility commitment, private claim proof, and double-claim prevention. + +The distributor CSV is converted into encrypted bundle rows. Each row is encrypted to the intended recipient's claim key, and the chain stores only the Merkle root and bucket table metadata. This avoids publishing the full allowlist while still giving claimants a package they can scan locally. + +The claimant proves that they can decrypt one valid row, sign for the eligible key, match the committed Merkle root, derive the correct nullifier, and bind the claim to a shielded destination commitment. Risc0 is used because the prize calls for a LEZ-compatible zero-knowledge proof path, and the demo uses the real proof mode with `RISC0_DEV_MODE=0`. The reviewer demo submits the `claim_private` instruction, which the program verifies against the rebuilt journal. A receipt-based `claim` instruction is also defined in the program; it would not expose the witness in the transaction transcript, but it is not currently runnable for shielded destinations (see Summary above). + +Double claims are prevented with nullifiers. A successful claim records the nullifier so the same eligibility row cannot claim again, while observers still cannot link the nullifier back to the eligible address. + +Rejected alternatives: + +- Public Merkle airdrop: simpler, but reveals the eligible address at claim time. +- Publishing the allowlist: easy to audit, but defeats the privacy goal. +- Dev-mode or mock proofs: fast, but not valid for the bounty requirement. +- A custom non-Risc0 proof system: possible, but less aligned with the Logos/LEZ stack. + +LEZ is a good fit because the protocol needs trustless execution, local proof generation, shielded destination handling, and private claim submission. A centralized airdrop service would learn the eligibility list and claim mapping directly. + +## Success Criteria Checklist + +- [x] Distributor commits an eligibility set without revealing the full allowlist. + Evidence: [README.md](https://github.com/Timidan/dist-x/blob/875520648b0d39091b0002dc499050d9c618572e/README.md), [docs/WRITEUP.md](https://github.com/Timidan/dist-x/blob/875520648b0d39091b0002dc499050d9c618572e/docs/WRITEUP.md), encrypted bundle generation, Merkle root initialization. + +- [ ] Eligible recipients can claim without revealing the eligible address in the public transcript. + Not currently met under either claim instruction. The receipt-based `claim` would keep the witness inside the zkVM but is not runnable for shielded destinations because of LEZ's account-ownership rule. The `claim_private` instruction is runnable and is the active demo path, but it carries the witness in its instruction args. The full breakdown and the two tracked follow-up fixes that would each independently restore the property are in [docs/WRITEUP.md Privacy Model](https://github.com/Timidan/dist-x/blob/875520648b0d39091b0002dc499050d9c618572e/docs/WRITEUP.md#privacy-model). Listed below under Requirements Not Met / Pending per the L-Prize team's instruction to highlight unmet requirements. + +- [x] Each recipient can claim only once. + On-chain enforcement is per nullifier: the `NullifierRecord` PDA at seed `["nullifier", airdrop_id, nullifier]` rejects a second initialization with `E_ALREADY_CLAIMED`. Address-level uniqueness is enforced at CSV ingest by the parser in [crates/distributionx-tree/src/csv.rs](https://github.com/Timidan/dist-x/blob/875520648b0d39091b0002dc499050d9c618572e/crates/distributionx-tree/src/csv.rs#L25-L33), which rejects duplicate addresses with `CliDuplicateAddr` before the tree is built. See [docs/WRITEUP.md Claim Uniqueness Scope](https://github.com/Timidan/dist-x/blob/875520648b0d39091b0002dc499050d9c618572e/docs/WRITEUP.md#claim-uniqueness-scope). + +- [x] Real Risc0 proof path with `RISC0_DEV_MODE=0`. + Evidence: [scripts/e2e.sh](https://github.com/Timidan/dist-x/blob/875520648b0d39091b0002dc499050d9c618572e/scripts/e2e.sh) `private-localnet`, `distributionx-cli prove`, `PROVE_LOCAL_OK`, `VERIFY_OK`. + +- [x] LEZ local sequencer integration. + Evidence: [scripts/standalone-sequencer.sh](https://github.com/Timidan/dist-x/blob/875520648b0d39091b0002dc499050d9c618572e/scripts/standalone-sequencer.sh), [scripts/deploy.sh](https://github.com/Timidan/dist-x/blob/875520648b0d39091b0002dc499050d9c618572e/scripts/deploy.sh) `--localnet`, [scripts/local-submit.sh](https://github.com/Timidan/dist-x/blob/875520648b0d39091b0002dc499050d9c618572e/scripts/local-submit.sh). + +- [x] Basecamp GUI. + Evidence: [basecamp-app/](https://github.com/Timidan/dist-x/tree/875520648b0d39091b0002dc499050d9c618572e/basecamp-app), [scripts/start-basecamp.sh](https://github.com/Timidan/dist-x/blob/875520648b0d39091b0002dc499050d9c618572e/scripts/start-basecamp.sh), LGX artifacts from [scripts/package.sh](https://github.com/Timidan/dist-x/blob/875520648b0d39091b0002dc499050d9c618572e/scripts/package.sh). + +- [x] Logos module / SDK. + Evidence: [distributionx_client_module/](https://github.com/Timidan/dist-x/tree/875520648b0d39091b0002dc499050d9c618572e/distributionx_client_module). + +- [x] SPEL IDL. + Evidence: [crates/distributionx-program/idl/distributionx.json](https://github.com/Timidan/dist-x/blob/875520648b0d39091b0002dc499050d9c618572e/crates/distributionx-program/idl/distributionx.json). + +- [x] CU and benchmark report. + Evidence: [docs/bench/REPORT.md](https://github.com/Timidan/dist-x/blob/875520648b0d39091b0002dc499050d9c618572e/docs/bench/REPORT.md). + +- [x] GitHub Actions CI status. + The `scripts`, `rust`, `logos`, and `localnet-e2e` jobs run on every push and PR. `scripts`, `rust`, and `logos` pass on the latest commit. `localnet-e2e` runs `scripts/e2e.sh ci-localnet` and exits skipped on push or PR when `DISTRIBUTIONX_LEZ_SEQUENCER_START_COMMAND` is not configured, so it never reports a hard failure on the default branch when the sequencer infra is absent. There is no testnet-e2e job in CI; testnet runs are out of scope for this submission. + +## Privacy Model And Threat Model + +The full threat model lives in [docs/WRITEUP.md](https://github.com/Timidan/dist-x/blob/875520648b0d39091b0002dc499050d9c618572e/docs/WRITEUP.md). Three points the reviewer asked for explicitly: + +1. **Privacy property is not currently demonstrably met for either claim instruction.** The receipt-based `claim` would keep the witness inside the zkVM but is not runnable for shielded destinations because LEZ requires the program to claim ownership of any modified default-owner account in its post-state; a shielded one-time destination commitment has no signer to authorize that. The `claim_private` instruction is runnable and is the active demo path, but its witness fields are passed as instruction args and the generated FFI sends them via `NSSATransaction::Public`. Either of two follow-ups would restore the property independently: refactor `claim` to credit the `nullifier_record` PDA (mirroring `claim_private`'s credit pattern, no ownership-claim conflict), or wire `claim_private` through `NSSATransaction::PrivacyPreserving` (which LEZ ships in the vendored sdk). Both are tracked; see [docs/WRITEUP.md Privacy Model](https://github.com/Timidan/dist-x/blob/875520648b0d39091b0002dc499050d9c618572e/docs/WRITEUP.md#privacy-model). + +2. **Bucket anonymity is bounded by per-bucket population.** The `bucket_id` is public (it is in the journal and in the airdrop's `bucket_table`). Observer unlinkability holds with probability at most 1/k per bucket, where k is the number of eligible recipients in that bucket. A singleton bucket reveals the recipient by amount; small buckets shrink the anonymity set. The CLI's `inspect-csv` command warns when the smallest bucket has fewer than 8 recipients (`crates/distributionx-cli/src/commands.rs:1110-1114`) and the `pad-csv --min-per-bucket N` command lets a distributor top up small buckets. The on-chain program does not enforce a minimum k; the distributor chooses the bucket schedule that fits their privacy budget. + +3. **Salt secrecy depends on the encrypted bundle and the recipient's local keystore.** Salts are 32 bytes from `OsRng` per row (`crates/distributionx-tree/src/bundle.rs:44-48`). Each row is sealed for its intended recipient with X25519 ECDH and ChaCha20-Poly1305 (`crates/distributionx-tree/src/bundle.rs:68-105`). The recipient's seed lives in a `wallet.seed` file under `target/distributionx-testnet/` by default, with a keychain-backed option in `crates/distributionx-wallet-ref/src/storage.rs`. The distributor knows every salt and can precompute a nullifier-to-row mapping; DistributionX protects observers from the eligibility set, not from the distributor. Under the active `claim_private` path the salt is also written into `claim.tx` and into the transaction transcript; see point 1. + +**Acknowledged naming mismatch.** The `claim_private` identifier predates this audit and does not match the instruction's actual privacy properties (it runs the in-program verifier and does not provide observer privacy for the witness on its current submission path). A rename to a clearer name such as `claim_inline` is deferred because it touches the LEZ program, the three IDL mirrors, the generated client and FFI, scripts, tests, and several doc sections (about 17 files in total). The doc points above describe what the instruction actually does; see the "A note on naming" paragraph in [docs/WRITEUP.md Privacy Model](https://github.com/Timidan/dist-x/blob/875520648b0d39091b0002dc499050d9c618572e/docs/WRITEUP.md#privacy-model). + +## Requirements Not Met / Pending + +Per the L-Prize team's instruction on Discord (08 May 2026, [message link](https://discord.com/channels/973324189794697286/1501897314233618553/1502098264068194314)) to "submit your solution please on the repo, and highlight the requirements you could not meet," three requirements are flagged: + +- **Eligible-address privacy on chain.** Not currently demonstrably met under either claim instruction. The full reasoning and the two tracked follow-up fixes are in the Privacy Model And Threat Model section above and in [docs/WRITEUP.md Privacy Model](https://github.com/Timidan/dist-x/blob/875520648b0d39091b0002dc499050d9c618572e/docs/WRITEUP.md#privacy-model). + +- **LEZ devnet/testnet evidence.** The bounty work targets the standalone LEZ sequencer environment (`scripts/standalone-sequencer.sh`) rather than a devnet or testnet deployment. Run logs, CU values, and the recorded demo all come from the standalone environment. There is no `testnet-e2e` job in CI; the qualified CI claim is in the GitHub Actions row of this checklist. + +- **3 distributions from outside the team.** The L-Prize team dropped this requirement in the same Discord message: "I will drop the '3 distributions from people outside the team' from the requirements. We did a lot of iteration on role of L-Prize and I now agree this is not really appropriate/useful for testnet L-Prize. Adoption criterias make more sense closer and post mainnet." + +## FURPS Self-Assessment + +### Functionality + +DistributionX supports distributor initialization, encrypted bundle creation, vault funding, Risc0 proof generation, proof verification, claim submission through the `claim` instruction, duplicate-claim rejection, close flow, Basecamp operation, and CLI operation. + +### Usability + +The README gives scratch-clone instructions for building binaries and running create/claim locally. The reviewer fixture seeds make the flow reproducible without regenerating every key. Basecamp provides the visual create/fund/claim flow, while the CLI provides deterministic evidence commands. + +### Reliability + +The CLI fails closed on invalid proofs, mismatched journals, missing bundles, missing destination packets, and duplicate claims. Local submit receipts are written under `target/distributionx-testnet/receipts/` during reproduction. + +### Performance + +Real Risc0 proving is the bottleneck. On the measured local machine, Basecamp proof generation took about 35-40 minutes with `RISC0_DEV_MODE=0`, and on-chain claim finalization (Risc0 receipt verification plus token settlement) took about 20-25 minutes. CU and wall-clock measurements are documented in [docs/bench/REPORT.md](https://github.com/Timidan/dist-x/blob/875520648b0d39091b0002dc499050d9c618572e/docs/bench/REPORT.md); the row currently labelled `claim_private` is being re-recorded against the `claim` instruction to match the scoped privacy claim above. + +### Supportability + +The repo includes focused Rust crates, a Logos client module, a Basecamp app, local sequencer scripts, packaging scripts, benchmark docs, reviewer fixtures, and a system architecture diagram. Package verification is covered by [scripts/package.sh](https://github.com/Timidan/dist-x/blob/875520648b0d39091b0002dc499050d9c618572e/scripts/package.sh). + +## Supporting Materials + +- README: [README.md](https://github.com/Timidan/dist-x/blob/875520648b0d39091b0002dc499050d9c618572e/README.md) +- Technical write-up: [docs/WRITEUP.md](https://github.com/Timidan/dist-x/blob/875520648b0d39091b0002dc499050d9c618572e/docs/WRITEUP.md) +- Benchmark and CU report: [docs/bench/REPORT.md](https://github.com/Timidan/dist-x/blob/875520648b0d39091b0002dc499050d9c618572e/docs/bench/REPORT.md) +- System architecture diagram: [DistributionX.system-architecture.excalidraw](https://github.com/Timidan/dist-x/blob/875520648b0d39091b0002dc499050d9c618572e/DistributionX.system-architecture.excalidraw) +- Basecamp app: [basecamp-app/](https://github.com/Timidan/dist-x/tree/875520648b0d39091b0002dc499050d9c618572e/basecamp-app) +- Logos client module: [distributionx_client_module/](https://github.com/Timidan/dist-x/tree/875520648b0d39091b0002dc499050d9c618572e/distributionx_client_module) +- Reviewer fixture: [fixtures/reviewer-fast-path/](https://github.com/Timidan/dist-x/tree/875520648b0d39091b0002dc499050d9c618572e/fixtures/reviewer-fast-path) + +## Terms & Conditions + +By submitting this solution, I confirm that I have read and agree to the [Terms & Conditions](https://github.com/logos-co/lambda-prize/blob/master/TERMS.md).