Skip to content
122 changes: 122 additions & 0 deletions solutions/LP-0003.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# 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`.
Comment on lines +1 to +7
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback


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 active demo submits the receipt-based `claim` instruction: the program verifies a Risc0 Groth16 receipt and the journal carries only `airdrop_id`, `merkle_root`, `bucket_id`, `nullifier`, and `claim_destination_commitment`. The witness fields are private inputs to the zkVM and do not appear in the journal or the instruction data. The credit lands on the program-owned `nullifier_record` PDA, so the post-state diff does not trigger LEZ's ownership-claim rule and the instruction runs end-to-end for shielded destinations. A second instruction, `claim_private`, runs the witness verification inside the program rather than inside the zkVM; it is documented as an opt-in fallback verifier (`DISTRIBUTIONX_USE_CLAIM_PRIVATE=1`) in [docs/WRITEUP.md Privacy Model](https://github.com/Timidan/dist-x/blob/875520648b0d39091b0002dc499050d9c618572e/docs/WRITEUP.md#privacy-model) and is not used by the bounty demo flow.

## 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)
- Demo video: https://github.com/logos-co/lambda-prize/pull/44#issue-4408269105 (`RISC0_DEV_MODE=0` end-to-end narration with terminal output)

## 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` instruction with the receipt; on-chain verification checks the Groth16 receipt and the journal against airdrop state, debits the vault, and credits the nullifier PDA. A separate token-settlement transaction transfers from the nullifier PDA to the shielded destination once the claim is included.

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.

- [x] Eligible recipients can claim without revealing the eligible address in the public transcript.
Scope: this property holds for the `claim` instruction, which submits a Risc0 receipt. The witness fields (address, salt, signature, Merkle path) are private inputs to the zkVM, and the public journal carries only `airdrop_id`, `merkle_root`, `bucket_id`, `nullifier`, and `claim_destination_commitment`. The credit lands on the program-owned `nullifier_record` PDA (no arbitrary recipient account), so the instruction runs end-to-end for shielded destinations. The `claim_private` instruction is an opt-in fallback verifier whose instruction data includes the witness; it is gated behind `DISTRIBUTIONX_USE_CLAIM_PRIVATE=1` and is not used by the demo. See [docs/WRITEUP.md Privacy Model](https://github.com/Timidan/dist-x/blob/875520648b0d39091b0002dc499050d9c618572e/docs/WRITEUP.md#privacy-model).

- [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 is scoped to the `claim` path.** The `claim` instruction submits a Risc0 receipt; the witness fields are private zkVM inputs and do not appear in the receipt journal or the instruction data. The credit lands on the program-owned `nullifier_record` PDA, so the instruction runs end-to-end for shielded destinations without exposing the witness on chain. The `claim_private` instruction is an opt-in fallback verifier (`DISTRIBUTIONX_USE_CLAIM_PRIVATE=1`) whose instruction args include the witness; it is not used by the demo. Wiring `claim_private` through `NSSATransaction::PrivacyPreserving` (LEZ ships the variant in the vendored sdk) is a tracked follow-up that would restore witness privacy on that opt-in path too.

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 default `claim` path the salt stays inside the encrypted bundle and the zkVM, and `claim.tx` strips the witness; the relayer and the chain transcript do not carry the salt.

**Acknowledged naming mismatch.** The `claim_private` identifier predates this audit and does not match the instruction's actual privacy properties (it runs the opt-in 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," two requirements are flagged:

- **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).
Comment thread
Timidan marked this conversation as resolved.
Loading