|
| 1 | +# Merkle Drop |
| 2 | + |
| 3 | +Single-use merkle-drop component for distributing on-chain access / assets, with two interchangeable claim paths sharing one nullifier and one callback. |
| 4 | + |
| 5 | +## Features |
| 6 | + |
| 7 | +- **On-chain tree building** (`alexandria_merkle_tree` Poseidon) — operator submits raw leaf data, contract computes the root, stores it, emits per-leaf proof events for off-chain pipelines to consume. |
| 8 | +- **Two claim paths**: |
| 9 | + 1. `claim` — arcade-style, gated by an implementor-defined `get_recipient(data)`. Used for NFT-ownership, allowlists, etc. |
| 10 | + 2. `claim_with_eth_signature` — bearer credential. Leaf's `data[0]` is an EVM address; the caller submits a secp256k1 personal_sign signature over `receiver`. Used for QR-code drops. |
| 11 | +- **Single-use nullifier** per `(root, leaf_hash)` regardless of claim path. |
| 12 | +- **Configurable expiry** per tree. |
| 13 | +- **Callback hook** `on_merkledrop_claim(root, leaf, receiver, data)` for the implementing contract to mint / transfer / grant. |
| 14 | + |
| 15 | +## Architecture |
| 16 | + |
| 17 | +| Module | Purpose | |
| 18 | +|---|---| |
| 19 | +| `merkledrop_component.cairo` | Starknet component with storage, register + two claim entrypoints, hook trait | |
| 20 | +| `signature.cairo` | EIP-191 personal_sign helpers for the bearer path | |
| 21 | +| `interfaces.cairo` | Minimal `IERC721Read` so the NFT-ownership pattern can call `owner_of` without pulling `openzeppelin_token` | |
| 22 | + |
| 23 | +## Interface |
| 24 | + |
| 25 | +### `MerkledropTrait` (implementor) |
| 26 | + |
| 27 | +| Method | Purpose | |
| 28 | +|---|---| |
| 29 | +| `get_recipient(data) -> ContractAddress` | Decode `data` and return who is allowed to call `claim`. Implementations decide the binding (NFT owner, hardcoded address, multisig, …). | |
| 30 | +| `on_merkledrop_claim(root, leaf, receiver, data)` | Distribute the asset / access after verification succeeds. Same hook for both claim paths. | |
| 31 | + |
| 32 | +### `InternalImpl` (component) |
| 33 | + |
| 34 | +| Method | Purpose | |
| 35 | +|---|---| |
| 36 | +| `register(data, end) -> felt252` | Build the tree from `data: Span<Span<felt252>>`, store root, emit events. Returns the root. | |
| 37 | +| `claim(root, proofs, data, receiver)` | Arcade-style claim path. Asserts `caller == get_recipient(data)`. | |
| 38 | +| `claim_with_eth_signature(root, proofs, data, receiver, sig)` | Bearer claim path. `data[0]` is the EVM address; `sig` must verify over `receiver`. | |
| 39 | +| `is_consumed(root, leaf_hash) -> bool` | Read the nullifier. | |
| 40 | +| `tree_expiry(root) -> u64` | Read a tree's expiry (0 if not registered). | |
| 41 | + |
| 42 | +## Leaf data conventions |
| 43 | + |
| 44 | +Leaf data is `Span<felt252>` — the contract treats it opaquely except for the first few slots needed by the claim path: |
| 45 | + |
| 46 | +- **Bearer path** (`claim_with_eth_signature`): |
| 47 | + ``` |
| 48 | + [eth_address, ...payload] |
| 49 | + ``` |
| 50 | + `data[0]` must be the EVM address. Remaining slots are the implementor's payload (e.g. asset id, amount). |
| 51 | + |
| 52 | +- **Arcade path** (`claim`): |
| 53 | + Layout is fully determined by the implementor's `get_recipient`. Common shape for NFT-gating: |
| 54 | + ``` |
| 55 | + [collection, token_id_low, token_id_high, ...payload] |
| 56 | + ``` |
| 57 | + `get_recipient` reads these and returns `ERC721.owner_of(token_id)` on `collection`. |
| 58 | + |
| 59 | +A common convention is to put implementor-specific payload (e.g. `dungeon_id`) at the **end** of `data`, so both layouts can be read with `data[data.len() - 1]` without branching. |
| 60 | + |
| 61 | +## Off-chain pipeline |
| 62 | + |
| 63 | +The `register` call emits one `LeafRegistered` event per leaf containing the merkle proof. Off-chain code (e.g. a QR-generation tool) reads the tx receipt and assembles per-leaf claim URLs. See [cartridge-gg/qr-drops](https://github.com/Provable-Games/qr-drops) for a working pipeline. |
| 64 | + |
| 65 | +## Example |
| 66 | + |
| 67 | +```cairo |
| 68 | +use game_components_metagame::merkledrop::merkledrop_component::MerkledropComponent; |
| 69 | +use game_components_metagame::merkledrop::interfaces::{ |
| 70 | + IERC721ReadDispatcher, IERC721ReadDispatcherTrait, |
| 71 | +}; |
| 72 | +
|
| 73 | +#[starknet::contract] |
| 74 | +mod MyDungeon { |
| 75 | + use super::*; |
| 76 | +
|
| 77 | + component!(path: MerkledropComponent, storage: merkledrop, event: MerkledropEvent); |
| 78 | + impl MerkledropInternalImpl = MerkledropComponent::InternalImpl<ContractState>; |
| 79 | +
|
| 80 | + impl MerkledropImpl of MerkledropComponent::MerkledropTrait<ContractState> { |
| 81 | + fn get_recipient( |
| 82 | + self: @MerkledropComponent::ComponentState<ContractState>, |
| 83 | + data: Span<felt252>, |
| 84 | + ) -> starknet::ContractAddress { |
| 85 | + let collection: starknet::ContractAddress = (*data.at(0)).try_into().unwrap(); |
| 86 | + let lo: u128 = (*data.at(1)).try_into().unwrap(); |
| 87 | + let hi: u128 = (*data.at(2)).try_into().unwrap(); |
| 88 | + IERC721ReadDispatcher { contract_address: collection } |
| 89 | + .owner_of(u256 { low: lo, high: hi }) |
| 90 | + } |
| 91 | +
|
| 92 | + fn on_merkledrop_claim( |
| 93 | + ref self: MerkledropComponent::ComponentState<ContractState>, |
| 94 | + root: felt252, |
| 95 | + leaf: felt252, |
| 96 | + receiver: starknet::ContractAddress, |
| 97 | + data: Span<felt252>, |
| 98 | + ) { |
| 99 | + // Mint / transfer / grant whatever to `receiver`. |
| 100 | + } |
| 101 | + } |
| 102 | +} |
| 103 | +``` |
0 commit comments