|
| 1 | +# Anonymous Airdrop - ZK Claim with Privacy |
| 2 | + |
| 3 | +## Summary |
| 4 | + |
| 5 | +- Anonymous token claims using ZK proofs for eligibility verification |
| 6 | +- Claimant identity hidden via Merkle proof + nullifier model (Groth16 proofs) |
| 7 | +- Claim amounts and recipients are public; only claimant's eligible address is private |
| 8 | +- Double-claim prevention via nullifier-derived compressed account addresses |
| 9 | +- Time-lock support for scheduled airdrops |
| 10 | + |
| 11 | +## Architecture |
| 12 | + |
| 13 | +```text |
| 14 | +┌─────────────────────────────────────────────────────────────────────────┐ |
| 15 | +│ Anonymous Airdrop Flow │ |
| 16 | +├─────────────────────────────────────────────────────────────────────────┤ |
| 17 | +│ │ |
| 18 | +│ 1. SETUP │ |
| 19 | +│ Authority creates AirdropConfig PDA with: │ |
| 20 | +│ - Eligibility Merkle root (hash of all (address, amount) pairs) │ |
| 21 | +│ - Token vault with airdrop tokens │ |
| 22 | +│ - Unlock slot for time-gating │ |
| 23 | +│ │ |
| 24 | +│ 2. ELIGIBILITY TREE (off-chain) │ |
| 25 | +│ Merkle tree of: leaf = Poseidon(eligible_address, amount) │ |
| 26 | +│ Where: eligible_address = Poseidon(private_key) │ |
| 27 | +│ │ |
| 28 | +│ 3. CLAIM │ |
| 29 | +│ Claimant generates ZK proof proving: │ |
| 30 | +│ - "I know a private key for an address in the eligibility tree" │ |
| 31 | +│ - "My nullifier is correctly derived from airdrop_id + private_key" │ |
| 32 | +│ Submits: proof + nullifier + recipient + amount │ |
| 33 | +│ Creates: NullifierAccount at nullifier-derived address │ |
| 34 | +│ Transfers: tokens from vault to recipient │ |
| 35 | +│ │ |
| 36 | +└─────────────────────────────────────────────────────────────────────────┘ |
| 37 | +``` |
| 38 | + |
| 39 | +## Privacy Properties |
| 40 | + |
| 41 | +| Property | Value | |
| 42 | +|----------|-------| |
| 43 | +| Claimant identity | Hidden (ZK proof hides which eligible address is claiming) | |
| 44 | +| Claim amount | Public (passed as instruction data) | |
| 45 | +| Recipient | Public (tokens transferred to visible address) | |
| 46 | +| Double-claim prevention | Nullifier-derived address (cryptographic) | |
| 47 | +| Trust model | Trustless (Groth16 proofs, no MPC nodes) | |
| 48 | + |
| 49 | +## Source Structure |
| 50 | + |
| 51 | +```text |
| 52 | +anonymous-airdrop/ |
| 53 | +├── program/src/ |
| 54 | +│ ├── lib.rs # Program entry, accounts, instructions |
| 55 | +│ ├── error.rs # AirdropError variants |
| 56 | +│ └── verifying_key.rs # Groth16 verifying key (generated) |
| 57 | +├── circuits/ |
| 58 | +│ ├── airdrop_claim.circom # Main circuit (5 public inputs) |
| 59 | +│ └── merkle_proof.circom # 20-level Merkle proof |
| 60 | +├── scripts/ |
| 61 | +│ ├── setup.sh # Circuit compilation + trusted setup |
| 62 | +│ └── clean.sh # Remove build artifacts |
| 63 | +├── typescript/ |
| 64 | +│ └── client.ts # Example claim client |
| 65 | +└── build.rs # Generates verifying_key.rs from JSON |
| 66 | +``` |
| 67 | + |
| 68 | +## Accounts |
| 69 | + |
| 70 | +### AirdropConfig (Solana PDA) |
| 71 | + |
| 72 | +Seeds: `[b"airdrop", airdrop_id.to_le_bytes()]` |
| 73 | + |
| 74 | +| Field | Type | Size | Description | |
| 75 | +|-------|------|------|-------------| |
| 76 | +| `airdrop_id` | `u64` | 8 | Unique airdrop identifier | |
| 77 | +| `authority` | `Pubkey` | 32 | Can deactivate airdrop | |
| 78 | +| `mint` | `Pubkey` | 32 | Token mint | |
| 79 | +| `eligibility_root` | `[u8; 32]` | 32 | Merkle root of (address, amount) pairs | |
| 80 | +| `token_vault` | `Pubkey` | 32 | Vault holding airdrop tokens | |
| 81 | +| `unlock_slot` | `u64` | 8 | Slot when tokens unlock | |
| 82 | +| `is_active` | `bool` | 1 | Whether airdrop is active | |
| 83 | +| `bump` | `u8` | 1 | PDA bump | |
| 84 | + |
| 85 | +### NullifierAccount (Compressed) |
| 86 | + |
| 87 | +Address: `derive_address([b"nullifier", nullifier])` |
| 88 | + |
| 89 | +| Field | Type | Description | |
| 90 | +|-------|------|-------------| |
| 91 | +| `nullifier` | `[u8; 32]` | `Poseidon(airdrop_id, private_key)` | |
| 92 | + |
| 93 | +## Instructions |
| 94 | + |
| 95 | +### initialize_airdrop |
| 96 | + |
| 97 | +Creates a new airdrop configuration. |
| 98 | + |
| 99 | +| Field | Value | |
| 100 | +|-------|-------| |
| 101 | +| **instruction_data** | `airdrop_id: u64`, `eligibility_root: [u8; 32]`, `unlock_slot: u64` | |
| 102 | +| **accounts** | `airdrop_config` (init PDA), `mint`, `token_vault`, `authority` (signer), `system_program` | |
| 103 | + |
| 104 | +### claim |
| 105 | + |
| 106 | +Claims tokens with ZK proof of eligibility. |
| 107 | + |
| 108 | +| Field | Value | |
| 109 | +|-------|-------| |
| 110 | +| **instruction_data** | `validity_proof`, `address_tree_info`, `output_state_tree_index`, `groth16_proof`, `nullifier`, `amount` | |
| 111 | +| **accounts** | `airdrop_config`, `token_vault`, `recipient_token_account`, `payer` (signer), `token_program`, `system_program`, + Light Protocol remaining accounts | |
| 112 | +| **constraints** | `is_active`, `current_slot >= unlock_slot`, valid Groth16 proof | |
| 113 | + |
| 114 | +### deactivate_airdrop |
| 115 | + |
| 116 | +Deactivates the airdrop (authority only). |
| 117 | + |
| 118 | +## Circuit: airdrop_claim.circom |
| 119 | + |
| 120 | +### Public Inputs (5) |
| 121 | + |
| 122 | +| # | Signal | Description | |
| 123 | +|---|--------|-------------| |
| 124 | +| 1 | `eligibilityRoot` | Merkle root of (address, amount) leaves | |
| 125 | +| 2 | `nullifier` | `Poseidon(airdropId, privateKey)` | |
| 126 | +| 3 | `recipient` | Recipient address (hashed to BN254) | |
| 127 | +| 4 | `airdropId` | Airdrop identifier (hashed to BN254) | |
| 128 | +| 5 | `amount` | Token amount (as 32-byte BE) | |
| 129 | + |
| 130 | +### Constraints |
| 131 | + |
| 132 | +```text |
| 133 | +1. eligibleAddress = Poseidon(privateKey) |
| 134 | +2. leaf = Poseidon(eligibleAddress, amount) |
| 135 | +3. nullifier = Poseidon(airdropId, privateKey) |
| 136 | +4. MerkleProof(leaf, pathElements, leafIndex) == eligibilityRoot |
| 137 | +5. recipientSquare = recipient * recipient (binds recipient to proof) |
| 138 | +``` |
| 139 | + |
| 140 | +## Client-Side Proof Generation |
| 141 | + |
| 142 | +```typescript |
| 143 | +// 1. Load private key and get Merkle proof from eligibility data |
| 144 | +const eligibleAddress = poseidon([privateKey]); |
| 145 | +const leaf = poseidon([eligibleAddress, amount]); |
| 146 | +const { pathElements, leafIndex } = getMerkleProof(eligibilityTree, leaf); |
| 147 | + |
| 148 | +// 2. Generate nullifier |
| 149 | +const nullifier = poseidon([airdropId, privateKey]); |
| 150 | + |
| 151 | +// 3. Generate ZK proof |
| 152 | +const { proof } = await snarkjs.groth16.fullProve({ |
| 153 | + eligibilityRoot: airdropConfig.eligibilityRoot, |
| 154 | + nullifier, |
| 155 | + recipient: recipientAddress, // hashed |
| 156 | + airdropId, // hashed |
| 157 | + amount, |
| 158 | + // Private |
| 159 | + privateKey, |
| 160 | + pathElements, |
| 161 | + leafIndex, |
| 162 | +}, wasmPath, zkeyPath); |
| 163 | + |
| 164 | +// 4. Submit claim (recipient can be any address - no link to eligible address) |
| 165 | +await program.methods.claim( |
| 166 | + validityProof, |
| 167 | + addressTreeInfo, |
| 168 | + outputStateTreeIndex, |
| 169 | + compressProof(proof), |
| 170 | + nullifier, |
| 171 | + amount |
| 172 | +).rpc(); |
| 173 | +``` |
| 174 | + |
| 175 | +## Security |
| 176 | + |
| 177 | +| Property | Mechanism | |
| 178 | +|----------|-----------| |
| 179 | +| Claimant anonymity | ZK proof hides which eligible address is claiming | |
| 180 | +| Eligibility | Address exists in eligibility Merkle tree with correct amount | |
| 181 | +| Double-claim prevention | Nullifier-derived compressed account address uniqueness | |
| 182 | +| Airdrop binding | Nullifier includes `airdropId` | |
| 183 | +| Front-running prevention | Recipient bound to proof | |
| 184 | +| Time-lock | `current_slot >= unlock_slot` check | |
| 185 | + |
| 186 | +## Errors |
| 187 | + |
| 188 | +| Code | Name | Cause | |
| 189 | +|------|------|-------| |
| 190 | +| 6000 | `TokensLocked` | `current_slot < unlock_slot` | |
| 191 | +| 6001 | `InvalidProof` | Groth16 verification failed | |
| 192 | +| 6002 | `AirdropNotActive` | Airdrop has been deactivated | |
| 193 | +| 6003 | `InvalidEligibilityRoot` | Root mismatch | |
| 194 | +| 6004 | `InvalidNullifier` | Nullifier computation error | |
| 195 | +| 6005 | `InvalidAddressTree` | Wrong Light Protocol address tree | |
| 196 | +| 6006 | `AccountNotEnoughKeys` | Missing Light Protocol accounts | |
| 197 | + |
| 198 | +## Setup |
| 199 | + |
| 200 | +```bash |
| 201 | +# Install circuit dependencies |
| 202 | +npm install |
| 203 | + |
| 204 | +# Run trusted setup (generates verification_key.json) |
| 205 | +./scripts/setup.sh |
| 206 | + |
| 207 | +# Build Solana program (generates verifying_key.rs) |
| 208 | +cargo build-sbf |
| 209 | +``` |
| 210 | + |
| 211 | +## Dependencies |
| 212 | + |
| 213 | +- Light Protocol SDK (compression, address derivation, CPIs) |
| 214 | +- groth16-solana (on-chain proof verification) |
| 215 | +- circomlib (Poseidon, Switcher) |
| 216 | +- snarkjs (circuit compilation, trusted setup) |
| 217 | + |
0 commit comments