Skip to content
87 changes: 87 additions & 0 deletions solutions/LP-0003.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# 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
- **Program ID:** `d7f401fde733a4ac2b54f4fa909de9e2c86d2f2fd9e256498efea527ade52e85`

## 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).
- [ ] At least 2 distinct distributions deployed on LEZ testnet with 20 combined claims. **Partial:** program deployed (tx `5e1b1952…c5df`, program ID `d7f401fd…2e85`) and two distinct distributions initialized on the hosted testnet `https://testnet.lez.logos.co` (accounts `213a015c…9878` and `ad4009d1…1215`, txs `ac74f7e1…68df` / `83c4f97d…cd76`, supplies 1,000,000 and 500,000) -- see `docs/TESTNET_EVIDENCE.md` in the repo. On-chain claims are blocked by an LEZ platform constraint (public transactions carry no RISC0 receipts, so the claim-proof assumption cannot be resolved); the fix is submitting claims via the LEZ privacy-preserving transaction path, in progress.
- [ ] 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` executes in ~5-9 ms of zkVM executor time on the sequencer (well under the 32M-cycle public execution budget); the receipt-verifying `claim` is pending the privacy-path rework. RISC0 claim proof generation at `RISC0_DEV_MODE=0`: approximately 8-12 minutes on a 2024 MacBook Pro (M3). 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.
Loading