Skip to content

Commit f2adb21

Browse files
starknetdevclaude
andauthored
feat(metagame): add merkledrop component (#111)
* feat(metagame): add merkledrop component Reusable Starknet component for single-use merkle-drop claims with two interchangeable credential paths sharing one nullifier and one callback: - claim(root, proofs, data, receiver) — arcade-style. The implementing contract overrides get_recipient(data) to decide who is allowed to claim. Examples: caller must currently own a specific NFT (ERC721 owner_of lookup on (collection, token_id) committed in the leaf), pre-registered Starknet address, custom validator. - claim_with_eth_signature(root, proofs, data, receiver, sig) — bearer credential. The leaf's data[0] is an EVM address; the caller submits a secp256k1 personal_sign signature over receiver. Used for QR-code drops where the bearer key is the credential. Tree construction is on-chain (alexandria Poseidon). The operator submits raw leaf data via register(); the component computes the root, stores it, and emits one LeafRegistered event per leaf carrying the proof. Off-chain pipelines (e.g. QR generators) parse these events from the registration receipt to assemble per-leaf claim URLs. Built and tested with snforge 0.58.1. 7 tests passing (eth-sig round-trip with the cartridge-gg/merkle_drop canonical vectors, NFT ownership round-trip via mock_call on owner_of, nullifier, signature binding, unknown-tree, non-owner). CI matrix + codecov updated (16 → 17 modules). * feat(merkledrop): add register_root for off-chain tree construction For large campaigns (1k+ leaves), the on-chain register() path hits Starknet's 1000-events-per-tx limit because it emits one LeafRegistered per leaf. register_root(root, end) lets operators precompute the tree off-chain (using the same Poseidon scheme this component verifies against) and submit just the 32-byte root. O(1) gas regardless of leaf count, single MerkleTreeCreated event with leaf_count=0. Both claim paths (claim, claim_with_eth_signature) verify against the stored root identically to the on-chain-build path -- the component doesn't care HOW the root was computed, only that the supplied proof + leaf produce it. Gas (from snforge l2_gas summary on the test dungeon): register 100 leaves ~23.9M l2_gas register_root - ~0.6M l2_gas (40x cheaper, constant) 11 tests pass (was 7, +4 new). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4756757 commit f2adb21

17 files changed

Lines changed: 875 additions & 1 deletion

.github/workflows/main-ci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,10 @@ jobs:
203203
module: ticket_booth
204204
runner: ubuntu-latest-4
205205
fuzzer_runs: 256
206+
- package: game_components_metagame
207+
module: merkledrop
208+
runner: ubuntu-latest-4
209+
fuzzer_runs: 256
206210
# Economy
207211
- package: game_components_economy
208212
module: tokenomics

.github/workflows/pr-ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,7 @@ jobs:
307307
add game_components_metagame entry_fee ubuntu-latest-4 256
308308
add game_components_metagame prize ubuntu-latest-4 256
309309
add game_components_metagame ticket_booth ubuntu-latest-4 256
310+
add game_components_metagame merkledrop ubuntu-latest-4 256
310311
fi
311312
if [ "$NEED_ECONOMY" = "true" ]; then
312313
add game_components_economy tokenomics ubuntu-latest-4 256

Scarb.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ openzeppelin_token = { git = "https://github.com/OpenZeppelin/cairo-contracts.gi
4242
openzeppelin_interfaces = { git = "https://github.com/OpenZeppelin/cairo-contracts.git", tag = "v3.0.0" }
4343
ekubo = { git = "https://github.com/EkuboProtocol/starknet-contracts.git", tag = "v4.0.1" }
4444
metagame_extensions_interfaces = { git = "https://github.com/Provable-Games/metagame_extensions.git", tag = "v0.1.4" }
45+
alexandria_merkle_tree = { git = "https://github.com/keep-starknet-strange/alexandria.git", tag = "v0.9.0" }
4546

4647
[dependencies]
4748
starknet.workspace = true

codecov.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ codecov:
33
notify:
44
# Must equal package count in .github/workflows/main-ci.yml matrix
55
# See AGENTS.md "CI Configuration" section when adding packages
6-
after_n_builds: 16
6+
after_n_builds: 17
77

88
comment:
99
layout: "diff, files, header, footer"

packages/metagame/Scarb.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ edition.workspace = true
99
starknet.workspace = true
1010
openzeppelin_interfaces.workspace = true
1111
openzeppelin_introspection.workspace = true
12+
alexandria_merkle_tree.workspace = true
1213
game_components_embeddable_game_standard = { path = "../embeddable_game_standard" }
1314
game_components_interfaces = { path = "../interfaces" }
1415
game_components_utilities = { path = "../utilities" }

packages/metagame/src/lib.cairo

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ pub mod entry_fee;
22
pub mod entry_requirement;
33
pub mod gpp;
44
pub mod leaderboard;
5+
pub mod merkledrop;
56
pub mod prize;
67
pub mod registration;
78
pub mod ticket_booth;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
pub mod interfaces;
2+
pub mod merkledrop_component;
3+
pub mod signature;
4+
5+
#[cfg(test)]
6+
mod tests;
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
## Merkle Drop Module
2+
3+
The `merkledrop` module provides a reusable Starknet component for issuing single-use claims against a merkle-tree commitment, supporting two interchangeable credential paths.
4+
5+
### Features
6+
7+
- On-chain tree construction via alexandria Poseidon merkle tree (operator submits raw leaf data; component computes root, stores it, emits per-leaf proof events).
8+
- Single-use nullifier per `(root, leaf_hash)` shared across both claim paths.
9+
- Configurable tree expiry timestamp.
10+
- Two claim paths via the same component:
11+
1. `claim` (arcade-style, implementor-defined recipient binding).
12+
2. `claim_with_eth_signature` (bearer credential, EIP-191 personal_sign).
13+
- `on_merkledrop_claim` callback for the implementing contract to mint / transfer / grant.
14+
15+
### Architecture
16+
17+
- **MerkledropComponent** (`merkledrop_component.cairo`): Starknet component with storage, register entrypoint, two claim entrypoints, hook trait, and view helpers.
18+
- **Signature helpers** (`signature.cairo`): EIP-191 personal_sign verification with the message format `"Claim on starknet with: 0x{recipient:x}"` (matches `viem.signMessage` output and `cartridge-gg/merkle_drop` upstream).
19+
- **Interfaces** (`interfaces.cairo`): Minimal `IERC721Read` so NFT-ownership bindings can call `owner_of` without dragging in `openzeppelin_token`.
20+
21+
### Implementor contract (`MerkledropTrait`)
22+
23+
| Method | Purpose |
24+
|---|---|
25+
| `get_recipient(data)` | Called by `claim` to decode `data` and return the address allowed to claim. NFT-gating: `ERC721.owner_of(token_id)` on the collection at `data[0]`. |
26+
| `on_merkledrop_claim(root, leaf, receiver, data)` | Distribute the asset/access. Same hook for both claim paths. |
27+
28+
### Leaf data conventions
29+
30+
`data` is `Span<felt252>` and the component treats it opaquely except for path-specific prefixes:
31+
32+
- Bearer path: `[eth_address, ...payload]`
33+
- Arcade path (NFT-gating example): `[collection, token_id_low, token_id_high, ...payload]`
34+
35+
A common convention is to keep payload at the end so the hook can extract it via `data[data.len() - 1]` without branching on the path.
36+
37+
### Testing
38+
39+
Unit tests live in `tests/test_merkledrop.cairo` and use snforge's `mock_call` to fake `owner_of` for the NFT path. The bearer path uses the same canonical test vector (`pk=0x420`, recipient `0x07db9cc...`, `v=28, r=0x8a..., s=0x20b6...`) as `cartridge-gg/merkle_drop`.
40+
41+
Run: `snforge test merkledrop`.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
AGENTS.md
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
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

Comments
 (0)