diff --git a/solutions/LP-0003.md b/solutions/LP-0003.md new file mode 100644 index 0000000..dcbde49 --- /dev/null +++ b/solutions/LP-0003.md @@ -0,0 +1,88 @@ +# Solution: LP-0003 -- Private Airdrop + +**Submitted by:** retraca + +## Summary + +Private airdrop protocol for the Logos Execution Zone. Claimants prove Merkle membership without revealing their account identity. The receipt is bound to a single destination note (prevents relay attacks) and the nullifier prevents double-claiming. + +## Repository + +- **Repo:** https://github.com/retraca/lp-0003-private-airdrop +- **Airdrop program ID:** `641e17aa9ac2c393a01d4cdf3d12621c1816466b685e0b6993a760c16f5d2e8f` +- **Claim-circuit program ID:** `2919d161b729ec935b5ef5cc40b319fda02ad6d81df81f0245f5308b86b7fcd8` + +## Approach + +### Merkle tree design + +Leaves: `SHA256(0x00 || account_id || allocation_le)`. Internal nodes: `SHA256(0x01 || left || right)`. Domain tags prevent second-preimage attacks: an internal node hash cannot be presented as a valid leaf proof and vice versa. + +### Privacy + +`account_id` is a private RISC0 guest input and never appears in the journal. The public outputs are: the nullifier (a PRF output bound to the claimant and distributor), the allocation, the Merkle root, and the recipient note hash. The allocation is inherently public in an airdrop context -- the token amount must be verifiable by the on-chain program. + +### Anti-replay (nullifier) + +`nullifier = SHA256(account_id || distributor_id)`. A claimant who attempts to claim twice against the same distribution produces the same nullifier, which the on-chain program rejects with `ERR_NULLIFIER_SPENT`. + +### Recipient binding + +`recipient_note_hash = SHA256(recipient_note_preimage)` is committed in the RISC0 journal. The on-chain program checks `SHA256(submitted_note) == journal.recipient_note_hash` before any state mutation. A relay that intercepts the receipt and substitutes a different destination address fails with `ERR_RECIPIENT_MISMATCH`. Critically, this check runs before the nullifier is marked spent -- a failed relay attempt leaves the legitimate claimant's nullifier unconsumed. + +### Why Logos + +LEZ's program model commits state changes atomically: either the full `claim()` execution succeeds and the nullifier is marked spent, or it fails and no state changes. No operator can selectively apply state (e.g., marking a nullifier spent without transferring tokens). Only the Merkle root lives on-chain; the plaintext allocation list stays off-chain, where it can be stored on Logos Storage without being visible to the sequencer. A centralised airdrop coordinator could censor specific claimants or alter allocations post-commitment; neither is possible with an on-chain root. + +## Success Criteria Checklist + +- [x] Claimants prove Merkle membership without revealing their account identity. +- [x] Tokens cannot be claimed twice (nullifier stored on-chain, `ERR_NULLIFIER_SPENT`). +- [x] The receipt cannot be redirected to a different recipient (recipient binding via `SHA256(note)`, checked before any state mutation). +- [x] Merkle second-preimage attacks are prevented (domain-tagged leaf and internal node hashes). +- [x] Proof generation runs client-side on a standard laptop. +- [x] Full documentation and a clean public repository are delivered. +- [x] Provide a module/SDK (`sdk/src/lib.rs`: `submit_claim`, `leaf_hash`, `node_hash`). +- [x] Provide a Logos Basecamp app GUI with local build instructions and loadable assets (`basecamp-app/`: `module.json`, `index.html`, `README.md` — loads directly in Logos Basecamp, no build step). +- [x] Provide an IDL for the LEZ program (`lp-0003-private-airdrop.idl.json`). +- [x] The system handles proof generation failures gracefully. +- [x] A failed or rejected claim does not mark the claimant as having claimed (recipient binding checked before nullifier write). +- [x] Deterministic, documented error codes (7001-7006). +- [x] At least 2 distinct distributions deployed on LEZ testnet with 20 combined claims. **Complete, with REAL proofs (`RISC0_DEV_MODE=0`):** two distributions on the hosted testnet `https://testnet.lez.logos.co` (distributors `4bfe13df…ce4e` and `65fdea01…d857`, 16-leaf eligibility trees, 10 claimants each) with **all 20 claims confirmed on-chain** -- every claim a privacy-preserving transaction proven locally and confirmed before the next began. Final on-chain state per distribution: `claimed = 5500` (sum of all allocations), 10 spent nullifiers. All 24 transaction hashes (2 program deploys, 2 initializes, 20 claims) in `docs/TESTNET_EVIDENCE.md`, reproducible via `scripts/testnet_claims_run.sh`. +- [ ] Document compute unit (CU) costs on LEZ devnet/testnet. +- [ ] End-to-end integration tests against a LEZ sequencer in CI. +- [x] CI green on the default branch. +- [x] README documents end-to-end usage. +- [x] Reproducible end-to-end demo script (`demo.sh`). +- [ ] Recorded video demo with `RISC0_DEV_MODE=0` terminal output. + +## FURPS Self-Assessment + +### Functionality + +Three on-chain instructions: `initialize` (registers Merkle root, total supply; distributor ID is the account ID of the distribution account), `claim` (verifies RISC0 receipt, enforces recipient binding, checks nullifier uniqueness, transfers allocation), `query_state`. The guest proves: leaf membership in the domain-tagged Merkle tree, nullifier computation from the private account ID, and recipient note hash. Error codes 7001-7006 cover all invalid states. + +### Usability + +CLI covers the full flow: `airdrop-claim prove / verify` for offline use, and `airdrop-claim chain initialize / claim` for on-chain submission (compiled with `--features chain`). `demo.sh --dev` runs end-to-end without a chain in seconds; `demo.sh --dev --chain` adds the on-chain steps (`SEQUENCER=https://testnet.lez.logos.co` for testnet). SDK crate provides `submit_claim`, `leaf_hash`, `node_hash` for integration into other LEZ programs. The distributor builds the Merkle tree off-chain and commits only the root to LEZ. + +### Reliability + +Proof verification fails closed. Recipient binding is the first check in `claim()` -- a relay interception leaves no side effects. Spent nullifiers persist in on-chain state across restarts. A rejected claim (any error code) leaves the nullifier unconsumed; the claimant can retry. + +### Performance + +CU costs: `initialize` runs in ~4-10 ms of zkVM executor time on the sequencer (well under the 32M-cycle public execution budget). A claim costs the sequencer one succinct receipt verification (the same as any privacy-preserving transaction). Client-side claim proving at `RISC0_DEV_MODE=0`: ~7-10 minutes on Apple silicon; the 20-claim evidence run completed in ~3.2 hours on one laptop. Merkle path depth scales as `log2(N)`: 10 hops for 1000 claimants. + +### Supportability + +Integration tests in `programs/airdrop/tests/integration.rs` exercise `apply_claim` directly (no sequencer or RISC0 receipt needed): successful claim state update, nullifier-spent rejection, distributor mismatch, root mismatch, recipient mismatch, and distribution exhausted. The recipient-mismatch test verifies the nullifier remains unconsumed after a failed relay attempt. CI runs `cargo check`, `cargo clippy -D warnings`, and `cargo test` with `RISC0_DEV_MODE=1`. + +## Supporting Materials + +- Demo video: _pending testnet deployment_ +- Repository: https://github.com/retraca/lp-0003-private-airdrop + +## Terms & Conditions + +By submitting this solution, I confirm that I have read and agree to the [Terms & Conditions](../TERMS.md). The code is MIT licensed.