merkledrop.gno
5.12 Kb · 164 lines
1// Package merkledrop is an idiomatic gno.land port of the classic Solidity
2// MerkleDistributor airdrop (Uniswap's contract, OpenZeppelin's MerkleProof).
3//
4// A fixed merkleRoot commits to a set of (address, amount) allocations. A
5// recipient proves membership by supplying the sibling hashes on the path from
6// their leaf up to the root. Each address may claim exactly once.
7//
8// Leaf and node hashing use crypto/sha256 (available in the gno stdlib), the
9// same primitive Solidity distributors use via keccak — here sha256 keeps the
10// off-chain tree builder trivially reproducible. Internal nodes hash the two
11// children in sorted order (the OpenZeppelin "commutative" scheme), so proofs
12// carry no left/right flags.
13//
14// leaf = sha256(addr.String() + "|" + amount)
15// node = sha256(min(a,b) || max(a,b))
16//
17// Balances are pure accounting (uint64); no real coin moves — there is no
18// msg.value on gno, so this models the allocation ledger only.
19package merkledrop
20
21import (
22 "bytes"
23 "crypto/sha256"
24 "encoding/hex"
25 "strconv"
26 "strings"
27
28 "chain"
29 "chain/runtime/unsafe"
30
31 "gno.land/p/nt/avl/v0"
32)
33
34// merkleRoot commits to the airdrop allocation set. Generated off-chain with
35// the exact scheme above over the four example recipients in README.md.
36const merkleRoot = "0d00b73577019dc92924f0ae001d3e1839d51fb358adda2ad1510c39eb82b13f"
37
38// claimed maps claimer address string -> claimed amount (uint64).
39var claimed avl.Tree
40
41// totalClaimed is the running sum of all claimed allocations.
42var totalClaimed uint64
43
44// leaf computes the tree leaf for an (address, amount) allocation.
45func leaf(claimer address, amount uint64) []byte {
46 data := claimer.String() + "|" + strconv.FormatUint(amount, 10)
47 h := sha256.Sum256([]byte(data))
48 return h[:]
49}
50
51// hashPair hashes two nodes in sorted order (commutative), so the proof does
52// not need to encode child positions.
53func hashPair(a, b []byte) []byte {
54 var d []byte
55 if bytes.Compare(a, b) <= 0 {
56 d = append(append(d, a...), b...)
57 } else {
58 d = append(append(d, b...), a...)
59 }
60 h := sha256.Sum256(d)
61 return h[:]
62}
63
64// parseProof splits a comma-separated list of hex hashes into raw 32-byte
65// nodes, panicking on any malformed element.
66func parseProof(proof string) [][]byte {
67 proof = strings.TrimSpace(proof)
68 if proof == "" {
69 return nil
70 }
71 parts := strings.Split(proof, ",")
72 out := make([][]byte, 0, len(parts))
73 for _, p := range parts {
74 p = strings.TrimSpace(p)
75 if p == "" {
76 continue
77 }
78 raw, err := hex.DecodeString(p)
79 if err != nil {
80 panic("merkledrop: bad proof hash: " + p)
81 }
82 if len(raw) != sha256.Size {
83 panic("merkledrop: proof hash must be 32 bytes: " + p)
84 }
85 out = append(out, raw)
86 }
87 return out
88}
89
90// computeRoot walks the proof from the given leaf up to a candidate root and
91// returns its hex encoding.
92func computeRoot(leafHash []byte, proof [][]byte) string {
93 node := leafHash
94 for _, sib := range proof {
95 node = hashPair(node, sib)
96 }
97 return hex.EncodeToString(node)
98}
99
100// Verify reports whether (claimer, amount, proof) resolves to merkleRoot. Pure
101// and read-only — safe to call from tests and Render.
102func Verify(claimer address, amount uint64, proof string) bool {
103 got := computeRoot(leaf(claimer, amount), parseProof(proof))
104 return got == merkleRoot
105}
106
107// AmountClaimed returns how much the address has already claimed (0 if none).
108func AmountClaimed(claimer address) uint64 {
109 if !claimed.Has(claimer.String()) {
110 return 0
111 }
112 return claimed.Get(claimer.String()).(uint64)
113}
114
115// HasClaimed reports whether the address already claimed.
116func HasClaimed(claimer address) bool {
117 return claimed.Has(claimer.String())
118}
119
120// Claim proves the caller is entitled to `amount` via `proof` (comma-separated
121// hex sibling hashes) and marks the allocation claimed. Panics on a bad proof
122// or a double claim. Mirrors MerkleDistributor.claim (msg.sender is the caller).
123func Claim(cur realm, amount uint64, proof string) {
124 caller := unsafe.PreviousRealm().Address()
125 if HasClaimed(caller) {
126 panic("merkledrop: already claimed")
127 }
128 if !Verify(caller, amount, proof) {
129 panic("merkledrop: invalid proof")
130 }
131 claimed.Set(caller.String(), amount)
132 totalClaimed += amount
133
134 chain.Emit("Claimed",
135 "account", caller.String(),
136 "amount", strconv.FormatUint(amount, 10),
137 )
138}
139
140// Render shows the committed root, aggregate stats, and the list of claimers.
141func Render(path string) string {
142 var b strings.Builder
143 b.WriteString("# MerkleDrop\n\n")
144 b.WriteString("A fixed Merkle root gates a one-per-address airdrop. Prove your allocation with `Claim(amount, proof)`.\n\n")
145
146 b.WriteString("## Root\n\n")
147 b.WriteString("`" + merkleRoot + "`\n\n")
148
149 b.WriteString("## Stats\n\n")
150 b.WriteString("- Distinct claimers: " + strconv.Itoa(claimed.Size()) + "\n")
151 b.WriteString("- Total claimed: " + strconv.FormatUint(totalClaimed, 10) + "\n\n")
152
153 b.WriteString("## Claimers\n\n")
154 if claimed.Size() == 0 {
155 b.WriteString("_No claims yet._\n")
156 return b.String()
157 }
158 b.WriteString("| Address | Amount |\n|---|---|\n")
159 claimed.Iterate("", "", func(key string, value interface{}) bool {
160 b.WriteString("| " + key + " | " + strconv.FormatUint(value.(uint64), 10) + " |\n")
161 return false
162 })
163 return b.String()
164}