-
Notifications
You must be signed in to change notification settings - Fork 23
Solution: LP-0003 - Private Allowlist / Airdrop Distributor #44
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Timidan
wants to merge
9
commits into
logos-co:master
Choose a base branch
from
Timidan:master
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+122
−0
Open
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
b6c3115
Add documentation for DistributionX solution LP-0003
Timidan dc95d04
Remove demo video and GitHub Actions placeholders
Timidan bda195d
Potential fix for pull request finding
Timidan a1df7ba
Add submitter name to LP-0003 solution
Timidan 7a2b68a
Revise LP-0003 solution details and evidence links
Timidan fcf1ed7
Merge branch 'logos-co:master' into master
Timidan f209d8e
Update claim eligibility criteria in LP-0003
Timidan 9304bc2
Format submission line in LP-0003 document
Timidan 1731a60
Update privacy claims and instructions in LP-0003
Timidan File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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`. | ||
|
|
||
| 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). | ||
|
Timidan marked this conversation as resolved.
|
||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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