Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

v1 source realm

Package merkledrop is a Merkle-gated airdrop that actually moves GNOT.

Readme View source

merkledrop v1

A Merkle-gated airdrop that actually moves GNOT.

Successor to v0, which the daily pipeline generated and which is deployed on mainnet. v0 works, but it is a demonstration rather than a drop, and its proof scheme is safe by accident. Built on p/moul/x/merkle/v0.

What changed, and why

1. Domain-separated leaves. 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 able to 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. Widen the amount, change the address form, add a field, and the forgery goes live with no visible diff at the call site. That is not a property to rely on. v1 tags leaves 0x00 and inner nodes 0x01, so the two preimage spaces cannot overlap at any length.

2. Proofs 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; the verifier rebuilds the tree shape from them, so a proof cannot be replayed at another index and one of the wrong length is rejected rather than folded. Depth is capped at merkle.MaxDepth, so an untrusted caller does not choose the length of the loop.

3. A settable root and a closing height. v0's root is a const: the drop can never be re-rooted, extended or ended. v1's owner sets root, leaf count and an optional closing height, and can sweep the remainder once it closes.

4. 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: TestUnderfundedDropDoesNotBurnAnAllocation pins that an underfunded drop does not consume an allocation.

Leaf encoding

The whole interface to an off-chain generator:

leaf = "gno.land/r/moul/x/daily/merkledrop/v1|<index>|<address>|<amount>"
tree = Tendermint simple tree, leaf tagged 0x00, inner tagged 0x01

The pkgpath is in the preimage, so a proof for this drop cannot be replayed against another realm using the same shape. Reproduce the tree with p/moul/x/merkle/v0, or with any Tendermint implementation.

Using it

SetDrop(rootHex, totalLeaves, closesAtHeight)   // owner only; 0 = never closes
Verify(index, addr, amount, proof)              // free check before spending gas
Claim(index, amount, proof)                     // pays out
Sweep()                                         // owner, after the drop closes

Fund the drop by sending ugnot to Address(), the realm's own package address.

The seeded example drop

Deployed with four example allocations so the drop can be exercised without a generator, and so the page is not empty. A live drop commits only a root and stores no allocation list: that is the whole point of a Merkle drop. SetDrop clears the seeded list.

Root: 6964baa99da23b9d8c76774c6f1122b250040e29c1d75823dda15c043dc65755

DemoProof(index) hands out the proof for a seeded allocation, and /r/moul/x/daily/merkledrop/v1:proof/<index> renders it with its leaf.

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.

That is not a hole: 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.

Note on v0

v0's README lists an allocation for g1manfred47kzduec920z88wfr64ylksmdcedar8. moul's actual address is ...cedlf5. v1 uses the real one.


Part of moul/gno-contracts — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage.

Dependency graph:

gno.land/r/moul/x/daily/merkledrop/v1 dependency graph

🧪 Highly experimental — potentially vibe-coded. Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: DISCLAIMER.

Overview

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.

Constants 2

const Denom

1const Denom = "ugnot"
source

Denom is the only coin this drop pays in.

const Owner

1const Owner = address("g1manfred47kzduec920z88wfr64ylksmdcedlf5")
source

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.

Variables 2

var Ownable, root, total, closesAt, claimed, paid, demo

 1var (
 2	// Ownable holds the address allowed to set the drop and sweep it.
 3	Ownable *ownable.Ownable
 4
 5	root     []byte   // the committed allocation root; nil means no drop
 6	total    int      // leaf count the root commits to
 7	closesAt int64    // chain height after which claims are refused; 0 = never
 8	claimed  avl.Tree // padded index -> claimer address string
 9	paid     int64    // running total of ugnot sent
10
11	// demo holds the allocations of the seeded example drop, so Render can
12	// show working proofs. A real drop commits only a root and leaves this
13	// empty: the whole point of a Merkle drop is not storing the allocations.
14	demo []Allocation
15)
source

var ErrNoDrop, ErrClosed, ErrClaimed, ErrBadProof, ErrUnfunded, ErrStillOpen, ErrBadRoot, ErrBadTotal, ErrBadAmount

 1var (
 2	ErrNoDrop    = errors.New("merkledrop: no drop is configured")
 3	ErrClosed    = errors.New("merkledrop: the drop has closed")
 4	ErrClaimed   = errors.New("merkledrop: already claimed")
 5	ErrBadProof  = errors.New("merkledrop: invalid proof")
 6	ErrUnfunded  = errors.New("merkledrop: the drop cannot cover this claim")
 7	ErrStillOpen = errors.New("merkledrop: the drop has not closed yet")
 8	ErrBadRoot   = errors.New("merkledrop: root must be 32 bytes of hex")
 9	ErrBadTotal  = errors.New("merkledrop: total must be positive")
10	ErrBadAmount = errors.New("merkledrop: amount must be positive")
11)
source

Functions 15

func Claim

crossing Action
1func Claim(cur realm, index int, amount int64, proof string)
source

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 ClosesAt

Action
1func ClosesAt() int64
source

ClosesAt returns the last height a claim is accepted, or 0 for never.

func DemoProof

Action
1func DemoProof(index int) string
source

DemoProof returns the proof for one seeded allocation, so the drop can be exercised end to end without an off-chain generator. It is empty once a real drop replaces the seeded one.

func HasClaimed

Action
1func HasClaimed(index int) bool
source

HasClaimed reports whether the allocation at index was claimed.

func IsClosed

Action
1func IsClosed() bool
source

IsClosed reports whether the drop is past its closing height.

func Leaf

Action
1func Leaf(index int, addr address, amount int64) string
source

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.

Example
1leaf = "<pkgpath>|<index>|<address>|<amount>"

func Render

1func Render(path string) string
source

Render serves the drop overview at "" and one allocation at "proof/<index>".

func Root

Action
1func Root() string
source

Root returns the committed root, hex-encoded, or "" when no drop is set.

func SetDrop

crossing Action
1func SetDrop(cur realm, rootHex string, totalLeaves int, closesAtHeight int64)
source

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 Sweep

crossing Action
1func Sweep(cur realm)
source

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 Total

Action
1func Total() int
source

Total returns the number of allocations the root commits to.

func Verify

Action
1func Verify(index int, addr address, amount int64, proof string) bool
source

Verify checks an allocation against the committed root without claiming it, so a recipient can confirm a proof before spending gas on Claim.

Types 1

type Allocation

struct
1type Allocation struct {
2	Address address
3	Amount  int64
4}
source

Allocation is one entry of a drop: who may claim, and how much.

Imports 10

Source Files 6