Skip to content

Solution: LP-0003 - Private Allowlist / Airdrop Distributor#44

Open
Timidan wants to merge 5 commits into
logos-co:masterfrom
Timidan:master
Open

Solution: LP-0003 - Private Allowlist / Airdrop Distributor#44
Timidan wants to merge 5 commits into
logos-co:masterfrom
Timidan:master

Conversation

@Timidan
Copy link
Copy Markdown

@Timidan Timidan commented May 8, 2026

distributionx-exported.mp4

Copilot AI review requested due to automatic review settings May 8, 2026 17:40
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a solution submission document for LP-0003 (DistributionX) under the repository’s solutions/ directory, describing the approach, evidence against success criteria, and supporting materials.

Changes:

  • Introduces solutions/LP-0003.md with a full submission write-up (summary, approach, checklist, FURPS).
  • Links to external repository artifacts (README, write-up, scripts, benchmarks) as supporting evidence.
  • Includes Terms & Conditions acknowledgement section.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread solutions/LP-0003.md
Comment on lines +1 to +5
# Solution: LP-0003 — DistributionX

## 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`.
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

Comment thread solutions/LP-0003.md Outdated
Comment thread solutions/LP-0003.md
Comment thread solutions/LP-0003.md

## Repository

- Repository: [https://github.com/Timidan/dist-x](https://github.com/Timidan/dist-x)
Timidan and others added 2 commits May 8, 2026 18:47
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Added submitter information to the LP-0003 solution.
@Timidan Timidan changed the title Add documentation for DistributionX solution LP-0003 Solution: LP-0003 - Private Allowlist / Airdrop Distributor May 8, 2026
Copy link
Copy Markdown

@danisharora099 danisharora099 left a comment

Choose a reason for hiding this comment

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

Thanks for the submission. I think this needs a few evidence and documentation updates before it can be treated as satisfying LP-0003.

Blocking

  1. LEZ devnet/testnet evidence is missing or ambiguous

LP-0003 requires deployment/testing and a working demo on LEZ devnet/testnet, including CU evidence where required. The evidence currently linked appears to demonstrate localhost/private-localnet/standalone execution, and the cited successful workflow skipped testnet-e2e.

Please provide pinned LEZ devnet/testnet run evidence, transaction/RPC logs, demo/video, and CU output, or revise the submission to state that this requirement is not yet satisfied.

  1. Outside-team distribution criterion is unresolved

LP-0003 still lists the requirement for 3 outside-team testnet distributions / 30 unique claims, but the submission says this was dropped or is out of scope.

Please either provide evidence satisfying this criterion or link an authoritative waiver/spec update from the prize maintainers.

Major / Required Clarifications

  1. CI status should be job-specific

The submission says CI is “All green”, but the referenced successful run did not execute testnet-e2e, and localnet E2E appears conditionally skippable when no sequencer is configured.

Please link a workflow run where the required E2E jobs actually executed, or qualify the CI claim to the specific jobs that ran.

  1. Private-input / public-transcript semantics need documentation

The solution shows the eligible address, salt, signature, and Merkle path are absent from the Risc0 journal. However, claim_private still accepts witness-like fields through the IDL/instruction interface.

Please document, with LEZ-specific evidence, whether those values are shielded/private inputs or otherwise absent from public transaction data/logs. I’m not claiming they leak, but the current submission does not make this independently verifiable.

  1. Bucket anonymity leakage should be explicit

Because bucket_id / amount-bucket information is public, unlinkability is bounded by the anonymity set within each bucket. Please document singleton/tiny-bucket leakage and how this is avoided, mitigated, or accepted in the privacy model.

Reproducibility / Documentation

  1. Pin implementation and evidence links

The solution links to Timidan/dist-x on master, which is mutable. Please pin implementation/evidence links to an immutable commit SHA, tag, or release, ideally the reviewed SHA 09712a17568fb58ff4fddc6b13dc1271fdca3982.

  1. Clarify the double-claim invariant

The implementation appears to enforce one claim per committed nullifier/row. Please document whether “each recipient can claim once” means once per committed row/nullifier or once per recipient/address, and what prevents duplicate recipient entries with distinct salts if address-level uniqueness is required.

  1. Document salt assumptions

Since nullifier unlinkability depends on salt secrecy and entropy, please document how salts are generated, distributed, stored, and kept secret as part of the threat/privacy model.

@github-actions
Copy link
Copy Markdown

⚠️ Validation script did not produce output.

@Timidan
Copy link
Copy Markdown
Author

Timidan commented May 13, 2026

@danisharora099

Thanks for the careful review. The L-Prize team's Discord instruction (quoted below) is to submit and highlight requirements I could not meet, so this comment answers each of your points and flags the ones I am acknowledging as unmet.

Blocking 1 (LEZ devnet/testnet evidence) and Blocking 2 (outside-team distribution criterion) were both addressed by the L-Prize team on Discord, 08 May 2026 (message link):

@Timidan just submit your solution please on the repo, and highlight the requirements you could not meet.

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.

Blocking 2 is dropped per the message. Blocking 1 and the privacy property under Major 2 below are the two outstanding requirements I am highlighting as not currently met; both are in solutions/LP-0003.md Requirements Not Met / Pending.

Major 1 — CI status, qualified

My bad ,"All green" was too broad. The workflow at .github/workflows/distributionx-ci.yml (renamed from distributionx-testnet-ready.yml) runs scripts, rust, logos, and localnet-e2e on every push and PR. The first three pass on the latest commit; localnet-e2e exits skipped when DISTRIBUTIONX_LEZ_SEQUENCER_START_COMMAND is unset so the default branch is not blocked on infra. No testnet-e2e job; testnet is out of scope. Activating real-proof CI is a follow-up in the job header comment. solutions/LP-0003.md:65 has the qualified claim.

Major 2 — private input / public transcript

I think the documentation may have made an oversight here. Honest picture: the program has two claim instructions and the privacy property is not currently demonstrably met under either, for distinct reasons. Full reasoning and tracked follow-up in docs/WRITEUP.md Privacy Model.

The claim instruction keeps the witness inside the Risc0 zkVM. The ClaimJournal only carries airdrop_id, merkle_root, bucket_id, nullifier, claim_destination_commitment; instruction args are airdrop_id, nullifier, receipt_bytes, now_unix. The property would hold here. But the instruction's #[account(mut)] recipient (line 384) credits an arbitrary account, and LEZ requires the program to claim ownership of any modified default-owner account in its post-state (.scaffold/cache/repos/lez/.../nssa/src/validated_state_diff.rs:240-254). A shielded one-time destination commitment has no signer to authorize that, so the instruction fails with InvalidProgramBehavior (confirmed by running the e2e against this path: prove and verify succeeded, the on-chain claim was rejected).

The claim_private instruction verifies in-program instead, so it takes claimant_address, salt, claim_sig, merkle_siblings, merkle_path_is_right as args (IDL). The generated FFI submits via NSSATransaction::Public at every send site, so getTransaction can recover them. The credit lands on the program-owned nullifier_record PDA, sidestepping the ownership-claim rule that breaks claim. claim_private is the only path that runs end-to-end for shielded destinations; the witness is in the chain transcript. The writeup's "not in the public journal" was true narrowly (no journal under this path) but didn't bound transaction data.

Naming. claim_private does not match what the instruction does, and the misleading name is part of why this was easy to miss. A clearer identifier would be claim_inline. Not renamed in this PR because it touches the LEZ program, three IDL mirrors, the generated client and FFI, scripts, tests, and several doc sections (about 17 files); tracked alongside the privacy follow-up.

Tracked follow-up (this should independently restore the property):
Note that i'm currently working on a better fix for the property than just flipping the demo default, so the "pending" status is more accurate than "not met" for this one. it was weird how i missed this one though.

  • Wire claim_private through NSSATransaction::PrivacyPreserving instead of Public. LEZ ships the variant in the vendored sdk under .scaffold/cache/repos/lez/.../nssa/src/privacy_preserving_transaction/. Switching claim_private's send sites would shield the witness args from chain observers.

The fix touches the program, the IDL mirrors, the generated FFI, and a fresh proof generation.

Major 3 — bucket anonymity

bucket_id is public (journal.rs:7 and claim_private args); bucket_table is public init data (idl.json:61-62). Observer unlinkability is bounded by per-bucket population: at most 1/k where k is the bucket's recipient count. Singleton (k=1) reveals by amount; k=2 or 3 shrinks the set sharply. Mitigations are advisory: inspect-csv warns at k<8 and pad-csv --min-per-bucket N tops up. The on-chain program does not enforce a minimum k. Documented in WRITEUP Bucket Anonymity.

Reproducibility 1 — pinned links

Every master URL in LP-0003.md is pinned to the SHA in this PR; this comment uses the same SHA.

Reproducibility 2 — double-claim invariant

On-chain enforcement is per nullifier, not per recipient. The nullifier is H_NULL(salt) = SHA256("logos-distributionx/null-v1" || salt), bound to salt only. The NullifierRecord PDA at ["nullifier", airdrop_id, nullifier] (line 381) returns E_ALREADY_CLAIMED on a second init.

The committed Merkle leaf binds (address, bucket_id, salt), so a valid claim maps to one committed row. Two committed rows with the same address but different salts would yield two different nullifiers and both would claim. Address-level uniqueness is enforced at CSV ingest: the parser rejects duplicates with CliDuplicateAddr. So on chain once per leaf/nullifier; in practice once per address, with CSV dedup as the load-bearing piece. Documented in WRITEUP Claim Uniqueness Scope.

Reproducibility 3 — salt threat model

Salts are 32 bytes from OsRng.fill_bytes, one per row, same-bundle collisions rejected. Each row is sealed for its recipient with X25519 ECDH, HKDF-SHA-256, and ChaCha20-Poly1305; only the recipient's claim key decrypts.

Under the active claim_private path the claimant CLI writes the salt and the rest of the witness into claim.tx, so the salt is present in that file, in the relayer's claim payload, and in the chain transcript (same exposure as Major 2). A receipt-based claim would not have this property but is not runnable for shielded destinations. The wallet seed lives in target/distributionx-testnet/wallet.seed by default; KeychainSeedStore is available but not the demo default.

Relayer visibility on the active path: receipt bytes, witness block (private_claim), recipient_npk, recipient_vpk, nullifier, destination commitment, timing.

The distributor knows every salt because it built the CSV; DistributionX protects observers from the eligibility set, not the distributor. Documented in WRITEUP Salt Threat Model.


Happy to drill into any of these if I missed something.

@Timidan
Copy link
Copy Markdown
Author

Timidan commented May 13, 2026

image

@Timidan
Copy link
Copy Markdown
Author

Timidan commented May 13, 2026

  1. Private-input / public-transcript semantics need documentation

The solution shows the eligible address, salt, signature, and Merkle path are absent from the Risc0 journal. However, claim_private still accepts witness-like fields through the IDL/instruction interface.

Please document, with LEZ-specific evidence, whether those values are shielded/private inputs or otherwise absent from public transaction data/logs. I’m not claiming they leak, but the current submission does not make this independently verifiable.

This has been fixed here Timidan/dist-x@9a590ba

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants