// Package merkledrop is a Merkle-gated airdrop that actually moves GNOT. // // It is the successor to r/moul/x/daily/merkledrop/v0, which was generated by // the daily pipeline and is deployed on mainnet. v0 works, but it is a // demonstration rather than a drop, and its proof scheme is safe by accident. // Everything below is what changed and why. // // # 1. The leaf scheme is domain separated // // v0 hashed leaves bare and combined nodes commutatively, the OpenZeppelin // scheme: leaf = sha256(addr|amount), node = sha256(min||max). Without a // leaf/inner tag an inner-node hash is also a valid leaf hash, so anyone who // can present a 64-byte leaf preimage can prove membership of a leaf that was // never committed. // // v0 is not exploitable, but only by arithmetic: an inner preimage is exactly // 64 bytes, and a v0 leaf preimage is at most 40 (bech32 address) + 1 + 20 // (uint64 has at most 20 digits) = 61 bytes. 61 < 64, so no collision is // reachable. Change the leaf encoding, widen the amount, use a different // address form, and the forgery goes live with no visible diff at the call // site. That is not a property to rely on. // // v1 uses gno.land/p/moul/x/merkle/v0, which is the Tendermint scheme: leaves // are tagged 0x00 and inner nodes 0x01, so the two preimage spaces cannot // overlap at any length. // // # 2. Proofs are bound to a position // // v0's proof was a bare sibling list of any length, folded until it ran out. // v1's proof carries its index and the total leaf count, and the verifier // rebuilds the tree shape from them: a proof cannot be replayed at another // index, and one of the wrong length is rejected rather than folded. The // sibling count is capped at merkle.MaxDepth, so an untrusted caller cannot // choose the length of the loop. // // # 3. The root is settable, and the drop can close // // v0's root is a `const`, so the drop can never be re-rooted, extended or // ended. v1's owner sets the root, the leaf count and an optional closing // height, and can sweep the remainder once it closes. // // # 4. It moves real coins // // v0 keeps a uint64 ledger and moves nothing; its README says so, but it sits // on mainnet reading like an airdrop. v1 sends ugnot from the realm's own // address through the banker, and refuses a claim it cannot pay rather than // marking it claimed. // // # Who the claimer is // // The claimer is PreviousRealm().Address(), the immediate caller. Called // directly by a user that is the user; called through another realm it is that // REALM. This is not a hole, because the leaf binds the address and the proof // must match the claimer, so an intermediary can only claim an allocation // granted to the intermediary itself. It does mean a wrapper realm cannot // claim on a user's behalf, which is deliberate. // // Built on gno.land/p/moul/x/merkle/v0. package merkledrop import ( "errors" "strconv" "strings" "chain" "chain/banker" "chain/runtime" "chain/runtime/unsafe" "gno.land/p/moul/x/merkle/v0" "gno.land/p/nt/avl/v0" "gno.land/p/nt/ownable/v0" ) // Denom is the only coin this drop pays in. const Denom = "ugnot" // leafPrefix is part of every leaf preimage, so a proof for one drop cannot be // replayed against another realm that happens to use the same encoding. const leafPrefix = "gno.land/r/moul/x/daily/merkledrop/v1" // Owner may set the drop and sweep it. // // Hardcoded rather than derived from the deployer at init: inside a plain // `func Test(t *testing.T)` the gno test runner reports OriginCaller() as the // EMPTY address, so an owner taken from it is the empty address in every test // and whatever deployed on chain. That divergence is exactly where an // authorization bug hides, so the address is written down instead. const Owner = address("g1manfred47kzduec920z88wfr64ylksmdcedlf5") var ( // Ownable holds the address allowed to set the drop and sweep it. Ownable *ownable.Ownable root []byte // the committed allocation root; nil means no drop total int // leaf count the root commits to closesAt int64 // chain height after which claims are refused; 0 = never claimed avl.Tree // padded index -> claimer address string paid int64 // running total of ugnot sent // demo holds the allocations of the seeded example drop, so Render can // show working proofs. A real drop commits only a root and leaves this // empty: the whole point of a Merkle drop is not storing the allocations. demo []Allocation ) // Allocation is one entry of a drop: who may claim, and how much. type Allocation struct { Address address Amount int64 } var ( ErrNoDrop = errors.New("merkledrop: no drop is configured") ErrClosed = errors.New("merkledrop: the drop has closed") ErrClaimed = errors.New("merkledrop: already claimed") ErrBadProof = errors.New("merkledrop: invalid proof") ErrUnfunded = errors.New("merkledrop: the drop cannot cover this claim") ErrStillOpen = errors.New("merkledrop: the drop has not closed yet") ErrBadRoot = errors.New("merkledrop: root must be 32 bytes of hex") ErrBadTotal = errors.New("merkledrop: total must be positive") ErrBadAmount = errors.New("merkledrop: amount must be positive") ) func init() { Ownable = ownable.NewWithAddress(Owner) seed() } // Leaf returns the exact preimage committed for one allocation. Reproduce it // off chain to rebuild the tree; it is the whole interface between the drop // and its generator. // // leaf = "||
|" func Leaf(index int, addr address, amount int64) string { return leafPrefix + "|" + strconv.Itoa(index) + "|" + addr.String() + "|" + strconv.FormatInt(amount, 10) } // SetDrop commits a new allocation root. Owner only. // // totalLeaves is the number of allocations the root commits to; the verifier // needs it to rebuild the tree shape, so a wrong value invalidates every // proof rather than weakening any. closesAtHeight is the last height at which // a claim is accepted, or 0 for a drop that never closes. // // Setting a new root abandons the previous claim ledger: a drop is a // commitment, and replacing it starts a new one. func SetDrop(cur realm, rootHex string, totalLeaves int, closesAtHeight int64) { Ownable.AssertOwnedBy(unsafe.PreviousRealm().Address()) rb, err := parseRoot(rootHex) if err != nil { panic(err.Error()) } if totalLeaves <= 0 { panic(ErrBadTotal.Error()) } root, total, closesAt = rb, totalLeaves, closesAtHeight claimed, paid, demo = avl.Tree{}, 0, nil chain.Emit("DropSet", "root", rootHex, "total", strconv.Itoa(totalLeaves), "closesAt", strconv.FormatInt(closesAtHeight, 10), ) } // Claim proves the caller is allocated amount at index and pays it out. // // proof is the comma-separated hex sibling list from the tree generator, leaf // first. It panics on a closed drop, a double claim, a bad proof, or a drop // that cannot cover the amount; nothing is marked claimed in any of those // cases. func Claim(cur realm, index int, amount int64, proof string) { if len(root) == 0 { panic(ErrNoDrop.Error()) } if IsClosed() { panic(ErrClosed.Error()) } if amount <= 0 { panic(ErrBadAmount.Error()) } if HasClaimed(index) { panic(ErrClaimed.Error()) } claimer := unsafe.PreviousRealm().Address() p, err := merkle.ParseProof(index, total, proof) if err != nil { panic(ErrBadProof.Error()) } if !p.Verify(root, []byte(Leaf(index, claimer, amount))) { panic(ErrBadProof.Error()) } // Check funds BEFORE recording the claim, so an underfunded drop does not // burn an allocation. if Balance() < amount { panic(ErrUnfunded.Error()) } claimed.Set(indexKey(index), claimer.String()) paid += amount bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur) bnk.SendCoins(Address(), claimer, chain.NewCoins(chain.NewCoin(Denom, amount))) chain.Emit("Claimed", "index", strconv.Itoa(index), "account", claimer.String(), "amount", strconv.FormatInt(amount, 10), ) } // Sweep returns whatever is left to the owner, once the drop has closed. Owner // only. A drop with no closing height can never be swept, which is the point // of setting one. func Sweep(cur realm) { owner := Ownable.Owner() Ownable.AssertOwnedBy(unsafe.PreviousRealm().Address()) if closesAt == 0 || !IsClosed() { panic(ErrStillOpen.Error()) } left := Balance() if left <= 0 { return } bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur) bnk.SendCoins(Address(), owner, chain.NewCoins(chain.NewCoin(Denom, left))) chain.Emit("Swept", "amount", strconv.FormatInt(left, 10)) } // Address returns the realm's own address, which is where the drop is funded. // Send ugnot here to fund it. func Address() address { return chain.PackageAddress(leafPrefix) } // Balance returns the ugnot the drop still holds. func Balance() int64 { return banker.NewReadonlyBanker().GetCoins(Address()).AmountOf(Denom) } // Root returns the committed root, hex-encoded, or "" when no drop is set. func Root() string { if len(root) == 0 { return "" } return hexOf(root) } // Total returns the number of allocations the root commits to. func Total() int { return total } // ClosesAt returns the last height a claim is accepted, or 0 for never. func ClosesAt() int64 { return closesAt } // IsClosed reports whether the drop is past its closing height. func IsClosed() bool { return closesAt != 0 && runtime.ChainHeight() > closesAt } // Paid returns the ugnot claimed so far. func Paid() int64 { return paid } // Claims returns the number of allocations claimed. func Claims() int { return claimed.Size() } // HasClaimed reports whether the allocation at index was claimed. func HasClaimed(index int) bool { return claimed.Has(indexKey(index)) } // ClaimedBy returns the address that claimed index, or the empty address. func ClaimedBy(index int) address { v := claimed.Get(indexKey(index)) if v == nil { return "" } return address(v.(string)) } // Verify checks an allocation against the committed root without claiming it, // so a recipient can confirm a proof before spending gas on Claim. func Verify(index int, addr address, amount int64, proof string) bool { if len(root) == 0 { return false } p, err := merkle.ParseProof(index, total, proof) if err != nil { return false } return p.Verify(root, []byte(Leaf(index, addr, amount))) } func parseRoot(s string) ([]byte, error) { p, err := merkle.ParseProof(0, 1, strings.TrimSpace(s)) if err != nil || len(p.Siblings) != 1 { return nil, ErrBadRoot } return p.Siblings[0], nil } func hexOf(b []byte) string { const digits = "0123456789abcdef" out := make([]byte, 0, len(b)*2) for _, c := range b { out = append(out, digits[c>>4], digits[c&0x0f]) } return string(out) } // indexKey pads the index so avl iteration is numeric, not lexicographic. // ufmt has no width flags, so the padding is by hand: unpadded keys sort // "0","1","10","11","2" and Render would lose the order past nine entries. func indexKey(i int) string { s := strconv.Itoa(i) for len(s) < 6 { s = "0" + s } return s }