Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions solutions/LP-0003.md
Original file line number Diff line number Diff line change
@@ -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.
Loading