HomeOverviewValsValidatorsGovGovernanceIBCRelayersN/AInfraInfra
/
powered by GATA HUB

r/nym-thegnomic001/gnomic_airdrop

Gno.land

Realm
Files3
Lines of code944excluding tests
Functions46exported
Storage998.2 KB99.82 GNOT deposit
DeployedSep 19, 2026block 173,104

Realm

Pathgno.land/r/nym-thegnomic001/gnomic_airdrop
Accountg1s4znuslycw…9vd78pmw
Deployerg1z0c0wrmwpz…epr0z5ml
Transaction77C096A39E…A5A0792F
airdrop.gno909 lines
1// Claim-based airdrop realm with Merkle proofs.
2//
3// Why Merkle rather than an on-chain list:
4// - the beneficiary list can run to tens of thousands of rows; writing it
5// on-chain costs gas and storage deposit in proportion;
6// - here only 32 bytes go on-chain (the root), plus one record per ACTUAL
7// claim. Whoever never claims costs nothing.
8//
9// Operating flow:
10// 1. deploy this realm;
11// 2. fund it by transferring GRC20 tokens to it (its address is returned by
12// RealmAddress());
13// 3. the admin publishes root, leaf count and deadline block with SetCampaign;
14// 4. each beneficiary calls Claim with their own proof;
15// 5. after the deadline the admin recovers the residue with Sweep.
16//
17// The Merkle leaf is the string "<address>|<amount in base units>", UTF-8
18// encoded. The tree is Tendermint's "simple tree" (gno's crypto/merkle):
19// leaf = SHA256(0x00||data), node = SHA256(0x01||left||right), split at the
20// largest power of two below n. The generator in tools/merkle produces
21// compatible proofs.
22package gnomic_airdrop
23
24import (
25 "chain"
26 "math"
27 "chain/runtime"
28 "chain/runtime/unsafe"
29 "crypto/merkle"
30 "encoding/hex"
31 "strconv"
32 "strings"
33 "time"
34
35 "gno.land/p/nt/avl/v0"
36 "gno.land/p/nt/ownable/v0"
37 "gno.land/p/nt/ufmt/v0"
38 "gno.land/r/nt/grc20reg/v0"
39
40 // Realm of the distributed token. Transfers go through the registry to stay
41 // decoupled, but BURNING needs the real realm: the registry exposes
42 // Transfer/Approve/TransferFrom, not Burn. scripts/render.sh rewrites this
43 // path with the actual token's one.
44 token "gno.land/r/nym-thegnomic001/gnomic"
45)
46
47// Ownable holds the administrative authority over the campaign.
48var Ownable *ownable.Ownable
49
50// tokenKey is the key of the distributed token. It is a variable rather than a
51// constant only so that tests can register one of their own; in production it
52// keeps the config.gno value for the realm's whole life.
53var tokenKey = defaultTokenKey
54
55// claimWindowSeconds is a variable for the same reason: tests shrink or widen
56// it. In production it keeps the config.gno value for the realm's whole life.
57var claimWindowSeconds = defaultClaimWindowSeconds
58
59// withdrawCooldownSeconds likewise: tests switch it off, production keeps it.
60var withdrawCooldownSeconds = defaultWithdrawCooldownSeconds
61
62var (
63 root []byte // Merkle root of the current campaign
64 leafCount int // total number of leaves (needed to verify)
65 endHeight int64 // block past which no one can claim (0 = no limit)
66 epoch int // bumped on every new campaign
67
68 // claimed is the register of grants: "<address>" -> *grant.
69 //
70 // It is also the ONLY defence against double claiming. A Merkle proof shows
71 // you are entitled, but it is reusable forever: only this register knows
72 // that you have already collected.
73 //
74 // CAREFUL: never delete an entry, not even a settled one to reclaim its
75 // storage deposit. It looks like a harmless optimisation and instead
76 // reopens double claiming: someone who already withdrew everything would
77 // present the same proof to a register that has forgotten them.
78 claimed avl.Tree
79
80 // campaignStart is when the current campaign opened: linear release runs
81 // from there for EVERYONE, not from the moment of the individual claim.
82 // Otherwise a late claimer would vest late, and there would be a reason to
83 // rush to claim just to start the clock sooner.
84 //
85 // Every grant keeps a copy in grant.start: opening a new campaign must not
86 // disturb the vesting already under way for whoever claimed in the
87 // previous one.
88 campaignStart int64
89
90 // outstanding is the sum of what has been granted but not yet withdrawn.
91 // It protects beneficiaries: Sweep cannot touch this part.
92 outstanding int64
93
94 // sealed makes opening further campaigns impossible. It is the equivalent
95 // of DropOwnership on the token: a "one-off" airdrop announced in words
96 // stays a promise; sealed in code it becomes a property anyone can verify
97 // with a query.
98 sealed bool
99
100 totalClaimed int64 // total actually paid out
101 totalGranted int64 // total granted (including what has not vested yet)
102 totalBurned int64 // total destroyed: instant exits, renunciations, settlement
103 settledBurned int64 // burned by SettleUnclaimed
104 settledToFund int64 // sent to the ecosystem fund by SettleUnclaimed
105 totalToFund int64 // sent to the ecosystem fund by voluntary forfeits
106 claimCount int
107 realmAddr address
108)
109
110// grant is the entitlement a beneficiary obtained in a campaign.
111// It always holds that total == withdrawn + burned + toFund + (not yet released).
112//
113// Grants are keyed by address ALONE, not by "epoch:address": tying the key to
114// the epoch made the previous campaign's grants unreachable as soon as a new
115// one opened — the beneficiary could no longer withdraw and the tokens stayed
116// counted in outstanding, so not even Sweep could recover them.
117type grant struct {
118 epoch int // campaign it was granted in
119 start int64 // start of this grant's vesting
120 total int64 // total amount granted
121 withdrawn int64 // how much has already been transferred
122 burned int64 // how much was destroyed (instant exit or forfeit)
123 toFund int64 // how much went to the ecosystem fund (forfeit)
124 lastWithdraw int64 // unix time of the last payout; drives the cooldown
125}
126
127// settled reports whether nothing is left to pay out on the grant.
128func (g *grant) settled() bool { return g.total <= g.withdrawn+g.burned+g.toFund }
129
130// closed reports whether the grant was ended by an instant exit or a forfeit.
131func (g *grant) closed() bool { return g.burned > 0 || g.toFund > 0 }
132
133const (
134 SealEvent = "AirdropSeal"
135 SettleEvent = "AirdropSettleUnclaimed"
136 ClaimEvent = "AirdropClaim"
137 WithdrawEvent = "AirdropWithdraw"
138 ForfeitEvent = "AirdropForfeit"
139 CampaignEvent = "AirdropCampaign"
140)
141
142const bpsDenominator = 10000
143
144func init(cur realm) {
145 admin := address(adminAddress)
146 if !admin.IsValid() {
147 admin = cur.Previous().Address()
148 }
149 if !admin.IsValid() {
150 admin = unsafe.OriginCaller()
151 }
152 if !admin.IsValid() {
153 panic("airdrop: cannot determine the administrator")
154 }
155 Ownable = ownable.NewWithAddress(admin)
156 realmAddr = cur.Address()
157
158 // The registry key and the imported realm must name the SAME token.
159 // Transfers go through the registry (by key) while burns call the imported
160 // realm directly: if the two ever disagreed the airdrop would pay out one
161 // token and destroy another, and nothing would notice until the first
162 // forfeit. Deriving the key from the import makes them equal by
163 // construction; the constant survives only to make a wrong TOKEN_KEY in
164 // config.env fail here, at deploy, instead of silently in a year.
165 tokenKey = token.TokenKey()
166 if defaultTokenKey != tokenKey {
167 panic("airdrop: config.gno names " + defaultTokenKey +
168 " but the imported token realm is " + tokenKey)
169 }
170}
171
172// ---------------------------------------------------------------------------
173// Administration
174// ---------------------------------------------------------------------------
175
176// SetCampaign publishes a new campaign: rootHex is the Merkle root in hex
177// (32 bytes), total the number of leaves in the tree, end the deadline block
178// (0 = no deadline).
179//
180// Publishing a new campaign resets the claim history: whoever appears on both
181// lists can claim again. That is deliberate — it allows recurring airdrops on
182// the same realm without a redeploy.
183func SetCampaign(cur realm, rootHex string, total int, end int64) {
184 Ownable.AssertOwnedBy(cur.Previous().Address())
185 if sealed {
186 panic("airdrop: campaign is sealed, no further campaign can be opened")
187 }
188
189 raw, err := hex.DecodeString(strings.TrimPrefix(rootHex, "0x"))
190 if err != nil {
191 panic("airdrop: invalid root: " + err.Error())
192 }
193 if len(raw) != 32 {
194 panic("airdrop: the root must be 32 bytes")
195 }
196 if total <= 0 {
197 panic("airdrop: the leaf count must be positive")
198 }
199 if end != 0 && end <= runtime.ChainHeight() {
200 panic("airdrop: the deadline must be in the future")
201 }
202
203 root = raw
204 leafCount = total
205 endHeight = end
206 epoch++
207 campaignStart = time.Now().Unix()
208
209 chain.Emit(
210 CampaignEvent,
211 "epoch", strconv.Itoa(epoch),
212 "root", hex.EncodeToString(raw),
213 "leaves", strconv.Itoa(total),
214 "end", strconv.FormatInt(end, 10),
215 )
216}
217
218// CloseCampaign stops claims immediately.
219func CloseCampaign(cur realm) {
220 Ownable.AssertOwnedBy(cur.Previous().Address())
221 // Dopo il sigillo non si chiude piu' niente. Il sigillo esiste per dire
222 // "questi termini sono definitivi", e lasciare in mano al proprietario un
223 // interruttore che toglie a tutti la possibilita' di rivendicare lo
224 // contraddiceva: bastava chiudere e aspettare i dodici mesi perche' meta'
225 // del non rivendicato bruciasse e meta' finisse al fondo. Prima del
226 // sigillo la chiusura serve — e' il modo di rimediare a una radice
227 // sbagliata — dopo non ha piu' nessun uso legittimo.
228 if sealed {
229 panic("airdrop: the campaign is sealed, it cannot be closed")
230 }
231 root = nil
232 leafCount = 0
233 endHeight = 0
234}
235
236// SealCampaign closes off the possibility of opening new campaigns forever.
237// Irreversible: no code path sets sealed back to false. Call it once the
238// published root is the final one.
239//
240// Until then the owner can fix a mistake by republishing the root; afterwards
241// the airdrop is provably one-off and anyone can check with IsSealed().
242func SealCampaign(cur realm) {
243 Ownable.AssertOwnedBy(cur.Previous().Address())
244 if sealed {
245 panic("airdrop: already sealed")
246 }
247 if len(root) != 32 {
248 panic("airdrop: no published root to seal")
249 }
250 sealed = true
251 chain.Emit(SealEvent, "epoch", strconv.Itoa(epoch), "root", hex.EncodeToString(root))
252}
253
254// SettleUnclaimed disposes of whatever was never claimed, once the claim
255// window has elapsed: half is burned, the other half goes to the team fund.
256//
257// The rule is enforced here rather than promised: there is no Sweep and no
258// BurnRemaining, so the owner cannot pocket the residue nor destroy all of it.
259// Settlement waits for the window even if CloseCampaign stopped claims
260// earlier, and grants already made stay untouchable — only the free part
261// (balance minus outstanding) is settled. Anyone may call it: it is a public
262// service, not a privilege.
263func SettleUnclaimed(cur realm) {
264 if campaignStart == 0 {
265 panic("airdrop: no campaign was ever opened")
266 }
267 if time.Now().Unix() < campaignStart+claimWindowSeconds {
268 panic("airdrop: the claim window has not elapsed yet")
269 }
270 if isOpen() {
271 panic("airdrop: the campaign is still open")
272 }
273 free := Balance() - outstanding
274 if free <= 0 {
275 panic("airdrop: no unclaimed residue to settle")
276 }
277 team := address(ecosystemFundAddress)
278 if !team.IsValid() {
279 panic("airdrop: ecosystem fund address is not configured")
280 }
281 burn := free / 2
282 toTeam := free - burn
283 totalBurned = addSat(totalBurned, burn)
284 settledBurned = addSat(settledBurned, burn)
285 settledToFund = addSat(settledToFund, toTeam)
286 // Le due guardie non sono pedanteria: con un residuo di UNA unita' base
287 // burn vale zero, e il ledger rifiuta un rogo di zero. Senza il controllo
288 // la liquidazione fallirebbe per sempre proprio sul residuo piu' probabile
289 // di tutti, quello lasciato dal troncamento, e quell'unita' resterebbe
290 // chiusa nel realm senza che nessuno possa piu' toccarla.
291 if burn > 0 {
292 token.Burn(cross(cur), burn)
293 }
294 if toTeam > 0 {
295 grc20reg.Transfer(0, cur, tokenKey, team, toTeam)
296 }
297 chain.Emit(SettleEvent,
298 "burned", strconv.FormatInt(burn, 10),
299 "to_team", strconv.FormatInt(toTeam, 10),
300 "team", team.String())
301}
302
303// TransferOwnership hands administration of the campaign over.
304func TransferOwnership(cur realm, newOwner address) {
305 if err := Ownable.TransferOwnership(0, cur, newOwner); err != nil {
306 panic(err.Error())
307 }
308}
309
310// ---------------------------------------------------------------------------
311// Claiming
312// ---------------------------------------------------------------------------
313
314// Claim pays amount tokens to the caller, if the Merkle proof
315// (index, auntsHex) shows that "<caller>|<amount>" is a leaf of the published
316// tree.
317//
318// auntsHex is the hex concatenation of the sibling hashes, 32 bytes each, just
319// as produced by tools/merkle.
320func Claim(cur realm, amount int64, index int, auntsHex string) {
321 caller := cur.Previous().Address()
322 if !cur.Previous().IsUserCall() {
323 panic("airdrop: a claim must come from a user account")
324 }
325 claimFor(cur, caller, amount, index, auntsHex)
326}
327
328// claimFor is the body of Claim. It is unexported on purpose: a transaction
329// can only reach exported functions, so from outside the realm the only way in
330// is Claim, which always uses the caller's own address. The tests, being in
331// the same package, still exercise the registration logic directly.
332func claimFor(cur realm, beneficiary address, amount int64, index int, auntsHex string) {
333 registerGrant(beneficiary, amount, index, auntsHex)
334 releaseIfAny(cur, beneficiary)
335}
336
337// There is deliberately no "claim on behalf of" entry point.
338//
339// It used to exist, so that a third party could pay the gas for someone: the
340// tokens went to the beneficiary either way, so it looked harmless. It is not.
341// Registering the grant is what SPENDS the one choice this airdrop allows: the
342// beneficiary can no longer take the instant 30%, nor renounce, because the
343// vesting path has already been picked for them. A valid Merkle proof proves
344// entitlement, never consent, and every proof is public.
345//
346// WithdrawFor stays: it only moves what has already vested to its owner, and
347// takes no decision away from anyone.
348
349// ClaimInstant is the first of the two alternatives to Claim: the instant
350// share (instantBps, 30%) is paid at once and the rest (70%) is given up the
351// same way a forfeit is — half burned, half to the ecosystem fund — whenever
352// it is chosen, even after the whole grant would have vested. The share is
353// larger than Claim's immediate 10% on purpose: it makes the shortcut a real
354// choice rather than a punishment. It cannot be undone
355// and suits someone who prefers a certain fraction today to the whole a year
356// from now. The residue is destroyed, to every holder's benefit.
357//
358// Only the person concerned can choose it: there is no "on behalf of" form.
359func ClaimInstant(cur realm, amount int64, index int, auntsHex string) {
360 if !cur.Previous().IsUserCall() {
361 panic("airdrop: a claim must come from a user account")
362 }
363 beneficiary := cur.Previous().Address()
364 registerGrant(beneficiary, amount, index, auntsHex)
365 g := grantOf(beneficiary)
366
367 fund := address(ecosystemFundAddress)
368 if !fund.IsValid() {
369 panic("airdrop: ecosystem fund address is not configured")
370 }
371 immediate := shareOf(g.total, instantBps)
372 rest := g.total - immediate
373 burn := rest / 2
374 toFund := rest - burn
375 g.withdrawn = immediate
376 g.burned = burn
377 g.toFund = toFund
378 g.lastWithdraw = time.Now().Unix()
379 outstanding -= g.total
380 totalClaimed = addSat(totalClaimed, immediate)
381 totalBurned = addSat(totalBurned, burn)
382 totalToFund = addSat(totalToFund, toFund)
383
384 if immediate > 0 {
385 grc20reg.Transfer(0, cur, tokenKey, beneficiary, immediate)
386 }
387 if burn > 0 {
388 // Burn acts on the caller's balance: this realm holds the escrow.
389 token.Burn(cross(cur), burn)
390 }
391 if toFund > 0 {
392 grc20reg.Transfer(0, cur, tokenKey, fund, toFund)
393 }
394 chain.Emit(WithdrawEvent, "epoch", strconv.Itoa(epoch), "to", beneficiary.String(),
395 "amount", strconv.FormatInt(immediate, 10), "remaining", "0")
396 chain.Emit(ForfeitEvent, "epoch", strconv.Itoa(epoch), "who", beneficiary.String(),
397 "burned", strconv.FormatInt(burn, 10), "to_fund", strconv.FormatInt(toFund, 10))
398}
399
400// Forfeit is the second alternative to Claim: the beneficiary gives the grant
401// up without taking anything. Half is burned, half goes to the ecosystem fund —
402// the same split SettleUnclaimed applies to whoever never acted at all.
403//
404// The proof is still required and the grant is still recorded: without that,
405// the same address could forfeit and then claim. The record makes it final.
406// Only the person concerned can choose it: there is no "on behalf of" form.
407func Forfeit(cur realm, amount int64, index int, auntsHex string) {
408 if !cur.Previous().IsUserCall() {
409 panic("airdrop: a forfeit must come from a user account")
410 }
411 beneficiary := cur.Previous().Address()
412 fund := address(ecosystemFundAddress)
413 if !fund.IsValid() {
414 panic("airdrop: ecosystem fund address is not configured")
415 }
416 registerGrant(beneficiary, amount, index, auntsHex)
417 g := grantOf(beneficiary)
418
419 burn := g.total / 2
420 toFund := g.total - burn
421 g.burned = burn
422 g.toFund = toFund
423 outstanding -= g.total
424 totalBurned = addSat(totalBurned, burn)
425 totalToFund = addSat(totalToFund, toFund)
426
427 if burn > 0 {
428 token.Burn(cross(cur), burn)
429 }
430 if toFund > 0 {
431 grc20reg.Transfer(0, cur, tokenKey, fund, toFund)
432 }
433 chain.Emit(ForfeitEvent, "epoch", strconv.Itoa(epoch), "who", beneficiary.String(),
434 "burned", strconv.FormatInt(burn, 10), "to_fund", strconv.FormatInt(toFund, 10))
435}
436
437// immediateOf is the share Claim unlocks at once: immediateBps/10000 of total.
438func immediateOf(total int64) int64 { return shareOf(total, immediateBps) }
439
440// shareOf is bps/10000 of total, in two steps to stay inside int64.
441func shareOf(total int64, bps int) int64 {
442 v := total/bpsDenominator*int64(bps) + total%bpsDenominator*int64(bps)/bpsDenominator
443 if v > total {
444 v = total
445 }
446 return v
447}
448
449// registerGrant checks the proof and records the grant. It transfers nothing.
450func registerGrant(beneficiary address, amount int64, index int, auntsHex string) {
451 if !isOpen() {
452 panic("airdrop: no open campaign")
453 }
454 if !beneficiary.IsValid() {
455 panic("airdrop: invalid beneficiary")
456 }
457 if amount <= 0 {
458 panic("airdrop: invalid amount")
459 }
460 if index < 0 || index >= leafCount {
461 panic("airdrop: index out of range")
462 }
463
464 key := beneficiary.String()
465 if prev := grantOf(beneficiary); prev != nil {
466 if prev.epoch == epoch {
467 panic("airdrop: already claimed")
468 }
469 // A previous grant still vesting must not be overwritten: that would be
470 // a silent loss for the beneficiary. It has to be closed first, with
471 // Withdraw once vesting is over or with Forfeit.
472 if !prev.settled() {
473 panic("airdrop: an earlier grant is still running, close it first")
474 }
475 }
476
477 aunts, err := hex.DecodeString(strings.TrimPrefix(auntsHex, "0x"))
478 if err != nil {
479 panic("airdrop: invalid proof: " + err.Error())
480 }
481 if len(aunts)%32 != 0 {
482 panic("airdrop: the proof must be a multiple of 32 bytes")
483 }
484
485 leaf := []byte(Leaf(beneficiary, amount))
486 if !merkle.VerifySimpleProof(root, leaf, index, leafCount, aunts) {
487 panic("airdrop: invalid Merkle proof")
488 }
489
490 // The realm must be able to cover the new grant ON TOP of those already made.
491 if Balance()-outstanding < amount {
492 panic("airdrop: insufficient funds in the realm")
493 }
494
495 claimed.Set(key, &grant{epoch: epoch, start: campaignStart, total: amount})
496 outstanding += amount
497 totalGranted = addSat(totalGranted, amount)
498 claimCount++
499
500 chain.Emit(
501 ClaimEvent,
502 "epoch", strconv.Itoa(epoch),
503 "to", beneficiary.String(),
504 "amount", strconv.FormatInt(amount, 10),
505 )
506}
507
508// Withdraw transfers to the caller the vested, not-yet-withdrawn part.
509func Withdraw(cur realm) {
510 release(cur, cur.Previous().Address())
511}
512
513// WithdrawFor is like Withdraw but on another beneficiary's behalf: the tokens
514// still go to beneficiary, whoever sends the transaction only pays the gas.
515func WithdrawFor(cur realm, beneficiary address) {
516 release(cur, beneficiary)
517}
518
519// release transfers the vested share. It is the public form: when there is
520// nothing to withdraw it says so, instead of silently letting through a
521// transaction that did nothing.
522func release(cur realm, beneficiary address) {
523 if releaseIfAny(cur, beneficiary) == 0 {
524 panic("airdrop: nothing to withdraw right now")
525 }
526}
527
528// releaseIfAny is the internal form: it transfers whatever has vested and
529// returns the amount, or 0 if nothing has vested yet. It serves the paths where
530// "nothing to withdraw" is normal rather than an error: a claim whose immediate
531// share is zero, and a forfeit, whose point is to burn the residue rather than
532// to collect.
533// Payouts are rate-limited: one every withdrawCooldownSeconds per grant, the
534// claim itself counting as the first. The very first payout is never delayed.
535func releaseIfAny(cur realm, beneficiary address) int64 {
536 g := grantOf(beneficiary)
537 if g == nil {
538 panic("airdrop: no grant for this address")
539 }
540 if g.closed() {
541 panic("airdrop: grant closed by an instant exit or a forfeit")
542 }
543 amount := vestedOf(g) - g.withdrawn
544 if amount <= 0 {
545 return 0
546 }
547 now := time.Now().Unix()
548 if g.lastWithdraw > 0 && now < g.lastWithdraw+withdrawCooldownSeconds {
549 panic("airdrop: next withdrawal available in " +
550 strconv.FormatInt(g.lastWithdraw+withdrawCooldownSeconds-now, 10) + " seconds")
551 }
552
553 g.lastWithdraw = now
554 g.withdrawn += amount
555 outstanding -= amount
556 totalClaimed = addSat(totalClaimed, amount)
557
558 grc20reg.Transfer(0, cur, tokenKey, beneficiary, amount)
559
560 chain.Emit(
561 WithdrawEvent,
562 "epoch", strconv.Itoa(epoch),
563 "to", beneficiary.String(),
564 "amount", strconv.FormatInt(amount, 10),
565 "remaining", strconv.FormatInt(g.total-g.withdrawn-g.burned-g.toFund, 10),
566 )
567 return amount
568}
569
570// vestedOf computes how much of a grant has vested:
571//
572// vested = immediate + rest * elapsed / duration
573//
574// where immediate is immediateBps/10000 of the total. The division is done in
575// two steps (quotient and remainder) because rest*elapsed would overflow int64
576// for large amounts: 1e13 * 3.15e7 exceeds 9.2e18.
577func vestedOf(g *grant) int64 {
578 immediate := immediateOf(g.total)
579 rest := g.total - immediate
580 if rest == 0 || vestingSeconds <= 0 {
581 return g.total
582 }
583
584 elapsed := time.Now().Unix() - g.start
585 if elapsed <= 0 {
586 return immediate
587 }
588 if elapsed >= vestingSeconds {
589 return g.total
590 }
591
592 linear := rest/vestingSeconds*elapsed + rest%vestingSeconds*elapsed/vestingSeconds
593 return immediate + linear
594}
595
596// addSat adds without ever wrapping. These counters only grow, across every
597// campaign, and nothing bounds them to the supply: a statistic that turns
598// negative would be bad, one that blocks a withdrawal would be worse.
599func addSat(a, b int64) int64 {
600 if b > 0 && a > math.MaxInt64-b {
601 return math.MaxInt64
602 }
603 return a + b
604}
605
606func grantOf(addr address) *grant {
607 v := claimed.Get(addr.String())
608 if v == nil {
609 return nil
610 }
611 return v.(*grant)
612}
613
614// ---------------------------------------------------------------------------
615// Reads
616// ---------------------------------------------------------------------------
617
618// Leaf returns the canonical Merkle-leaf encoding for the (address, amount)
619// pair. It must match byte for byte the one used by the off-chain generator.
620func Leaf(addr address, amount int64) string {
621 return addr.String() + "|" + strconv.FormatInt(amount, 10)
622}
623
624// RealmAddress is the address to fund with the airdrop tokens.
625func RealmAddress() address { return realmAddr }
626
627// Balance is the token balance the realm currently holds.
628func Balance() int64 {
629 token := grc20reg.Get(tokenKey)
630 if token == nil {
631 return 0
632 }
633 return token.BalanceOf(realmAddr)
634}
635
636// Root returns the current Merkle root in hex.
637func Root() string { return hex.EncodeToString(root) }
638
639// Epoch is the number of the current campaign.
640func Epoch() int { return epoch }
641
642// IsSealed reports whether the campaign has been sealed: if true, no new
643// campaign can ever be opened.
644func IsSealed() bool { return sealed }
645
646// LeafCount is the number of leaves in the published tree.
647func LeafCount() int { return leafCount }
648
649// EndHeight is the deadline block (0 = no deadline).
650func EndHeight() int64 { return endHeight }
651
652// IsOpen reports whether claims are currently accepted.
653func IsOpen() bool { return isOpen() }
654
655// HasClaimed reports whether addr has already claimed in the current campaign.
656func HasClaimed(addr address) bool {
657 g := grantOf(addr)
658 return g != nil && g.epoch == epoch
659}
660
661// HasGrant reports whether addr holds a grant, from this campaign or an
662// earlier one.
663func HasGrant(addr address) bool { return grantOf(addr) != nil }
664
665// GrantEpoch returns the campaign in which addr obtained its grant.
666func GrantEpoch(addr address) int {
667 g := grantOf(addr)
668 if g == nil {
669 return 0
670 }
671 return g.epoch
672}
673
674// VestingStartOf returns when vesting starts for addr's grant.
675func VestingStartOf(addr address) int64 {
676 g := grantOf(addr)
677 if g == nil {
678 return 0
679 }
680 return g.start
681}
682
683// ClaimedAmount returns addr's total grant, vested or not.
684func ClaimedAmount(addr address) int64 {
685 g := grantOf(addr)
686 if g == nil {
687 return 0
688 }
689 return g.total
690}
691
692// Vested returns how much of addr's grant has vested so far. A closed grant
693// (instant exit or forfeit) will never vest anything more: it reports what
694// was paid, not where the curve would be.
695func Vested(addr address) int64 {
696 g := grantOf(addr)
697 if g == nil {
698 return 0
699 }
700 if g.closed() {
701 return g.withdrawn
702 }
703 return vestedOf(g)
704}
705
706// InstantBps is the share ClaimInstant pays at once, in hundredths of a percent.
707func InstantBps() int { return instantBps }
708
709// Withdrawn returns how much addr has already withdrawn.
710func Withdrawn(addr address) int64 {
711 g := grantOf(addr)
712 if g == nil {
713 return 0
714 }
715 return g.withdrawn
716}
717
718// Withdrawable returns how much addr can withdraw right now.
719func Withdrawable(addr address) int64 {
720 g := grantOf(addr)
721 if g == nil || g.closed() {
722 return 0
723 }
724 // During the cooldown the answer is zero, not "what has vested": this
725 // function says what Withdraw would pay right now, and right now it would
726 // refuse. NextWithdrawAt says when that changes.
727 if g.lastWithdraw > 0 && time.Now().Unix() < g.lastWithdraw+withdrawCooldownSeconds {
728 return 0
729 }
730 return vestedOf(g) - g.withdrawn
731}
732
733// NextWithdrawAt returns the unix time from which addr may withdraw again
734// (0 when there is no grant, the grant is closed, or no cooldown is running).
735func NextWithdrawAt(addr address) int64 {
736 g := grantOf(addr)
737 if g == nil || g.closed() || g.lastWithdraw == 0 {
738 return 0
739 }
740 return g.lastWithdraw + withdrawCooldownSeconds
741}
742
743// WithdrawCooldown is the minimum interval between two payouts, in seconds.
744func WithdrawCooldown() int64 { return withdrawCooldownSeconds }
745
746// BurnedOf returns how much of addr's grant was destroyed.
747func BurnedOf(addr address) int64 {
748 g := grantOf(addr)
749 if g == nil {
750 return 0
751 }
752 return g.burned
753}
754
755// FundOf returns how much of addr's grant went to the ecosystem fund.
756func FundOf(addr address) int64 {
757 g := grantOf(addr)
758 if g == nil {
759 return 0
760 }
761 return g.toFund
762}
763
764// TotalToFund is what voluntary forfeits sent to the ecosystem fund.
765func TotalToFund() int64 { return totalToFund }
766
767// TotalBurned is the total destroyed: instant exits, renunciations and the
768// settlement of the unclaimed residue.
769func TotalBurned() int64 { return totalBurned }
770
771// ClaimWindowEnd is the moment (unix) claims stop being accepted for the
772// current campaign; SettleUnclaimed becomes callable from then on.
773func ClaimWindowEnd() int64 { return campaignStart + claimWindowSeconds }
774
775// EcosystemFund is where the non-burned half of every forfeit goes.
776func EcosystemFund() address { return address(ecosystemFundAddress) }
777
778// SettledBurned and SettledToFund report what SettleUnclaimed did.
779func SettledBurned() int64 { return settledBurned }
780func SettledToFund() int64 { return settledToFund }
781
782// Outstanding is the total granted and not yet withdrawn: the part of the
783// realm's balance that Sweep cannot touch.
784func Outstanding() int64 { return outstanding }
785
786// TotalGranted is the total of all grants made, vested or not.
787func TotalGranted() int64 { return totalGranted }
788
789// VestingStart is the moment (unix) the CURRENT campaign's linear release runs
790// from. Grants from earlier campaigns keep their own, readable with
791// VestingStartOf.
792func VestingStart() int64 { return campaignStart }
793
794// VestingEnd is the moment (unix) at which everything granted in the current
795// campaign counts as fully vested.
796func VestingEnd() int64 { return campaignStart + vestingSeconds }
797
798// ImmediateBps is the share unlocked at once, in hundredths of a percent.
799func ImmediateBps() int { return immediateBps }
800
801// TotalClaimed is the total actually transferred to beneficiaries.
802func TotalClaimed() int64 { return totalClaimed }
803
804// ClaimCount is the number of successful claims.
805func ClaimCount() int { return claimCount }
806
807func isOpen() bool {
808 if len(root) != 32 || leafCount == 0 {
809 return false
810 }
811 if endHeight != 0 && runtime.ChainHeight() > endHeight {
812 return false
813 }
814 // The claim window is part of the deal: after it, no claim is accepted and
815 // the residue is settled by SettleUnclaimed.
816 if claimWindowSeconds > 0 && time.Now().Unix() >= campaignStart+claimWindowSeconds {
817 return false
818 }
819 return true
820}
821
822func Render(path string) string {
823 parts := strings.Split(path, "/")
824 if len(parts) == 2 && parts[0] == "claimed" {
825 addr := address(parts[1])
826 if !addr.IsValid() {
827 return "invalid address\n"
828 }
829 g := grantOf(addr)
830 if g == nil {
831 return "no grant for this address\n"
832 }
833 if g.closed() {
834 return ufmt.Sprintf(
835 "granted: %d\nwithdrawn: %d\nburned: %d\nto fund: %d\nstatus: closed\n",
836 g.total, g.withdrawn, g.burned, g.toFund)
837 }
838 return ufmt.Sprintf(
839 "granted: %d\nvested: %d\nwithdrawn: %d\navailable now: %d\n",
840 g.total, vestedOf(g), g.withdrawn, vestedOf(g)-g.withdrawn)
841 }
842 if path != "" {
843 return "404\n"
844 }
845
846 s := ufmt.Sprintf("# %s\n\n", campaignName)
847 s += "| | |\n|---|---|\n"
848 s += ufmt.Sprintf("| Token | `%s` |\n", tokenKey)
849 s += ufmt.Sprintf("| Realm address | `%s` |\n", realmAddr.String())
850 s += ufmt.Sprintf("| Available balance | %d |\n", Balance())
851 s += ufmt.Sprintf("| Campaign | #%d |\n", epoch)
852 s += ufmt.Sprintf("| Status | %s |\n", statusLabel())
853 s += ufmt.Sprintf("| Sealed | %s |\n", sealLabel())
854 s += ufmt.Sprintf("| Merkle root | `%s` |\n", Root())
855 s += ufmt.Sprintf("| Leaves | %d |\n", leafCount)
856 s += ufmt.Sprintf("| Deadline (block) | %d |\n", endHeight)
857 s += ufmt.Sprintf("| Claims | %d |\n", claimCount)
858 s += ufmt.Sprintf("| Granted | %d |\n", totalGranted)
859 s += ufmt.Sprintf("| Total paid out | %d |\n", totalClaimed)
860 s += ufmt.Sprintf("| Burned | %d |\n", totalBurned)
861 s += ufmt.Sprintf("| To the ecosystem fund (forfeits) | %d |\n", totalToFund)
862 s += ufmt.Sprintf("| Still owed | %d |\n", outstanding)
863 s += ufmt.Sprintf("| Current block | %d |\n", runtime.ChainHeight())
864 s += "\n## Release\n\n"
865 s += ufmt.Sprintf("- Unlocked immediately on claim: **%d%%**\n", immediateBps/100)
866 s += ufmt.Sprintf("- The remaining **%d%%** vests linearly over %d days\n",
867 100-immediateBps/100, vestingSeconds/86400)
868 s += ufmt.Sprintf("- Withdrawals: at most one every %d hours per address, the claim counting as the first\n", withdrawCooldownSeconds/3600)
869 s += ufmt.Sprintf("- Alternatively `ClaimInstant` pays exactly %d%% at once; of the other %d%%, half is **burned** and half goes to the ecosystem fund\n",
870 instantBps/100, 100-instantBps/100)
871 s += "- Or `Forfeit`: nothing paid, half burned, half to the ecosystem fund\n"
872 s += ufmt.Sprintf("- Claims close %d days after opening; what was never claimed is then settled: **half burned, half to the ecosystem fund** (`%s`)\n",
873 claimWindowSeconds/86400, ecosystemFundAddress)
874 if settledBurned > 0 || settledToFund > 0 {
875 s += ufmt.Sprintf("- Settled: %d burned, %d to the ecosystem fund\n", settledBurned, settledToFund)
876 }
877 if campaignStart > 0 {
878 s += ufmt.Sprintf("- Start: %d, end: %d (unix)\n", campaignStart, VestingEnd())
879 elapsed := time.Now().Unix() - campaignStart
880 pct := int64(100)
881 if elapsed < vestingSeconds {
882 pct = elapsed * 100 / vestingSeconds
883 }
884 if pct < 0 {
885 pct = 0
886 }
887 s += ufmt.Sprintf("- Progress: **%d%%**\n", pct)
888 }
889 s += "\nStatus of an address: `:claimed/<address>`\n"
890 return s
891}
892
893// sealLabel describes in Render whether the airdrop is provably one-off.
894func sealLabel() string {
895 if sealed {
896 return "**yes, one-off**: no new campaign is possible any more"
897 }
898 return "no: the owner can still republish the root"
899}
900
901func statusLabel() string {
902 if isOpen() {
903 return "**open**"
904 }
905 if len(root) == 32 {
906 return "expired"
907 }
908 return "closed"
909}
FunctionParametersReturnsKind
SetCampaign(rootHex string, total int, end int64)-Crossing
CloseCampaign()-Crossing
SealCampaign()-Crossing
SettleUnclaimed()-Crossing
TransferOwnership(newOwner string)-Crossing
Claim(amount int64, index int, auntsHex string)-Crossing
ClaimInstant(amount int64, index int, auntsHex string)-Crossing
Forfeit(amount int64, index int, auntsHex string)-Crossing
Withdraw()-Crossing
WithdrawFor(beneficiary string)-Crossing
Leaf(addr string, amount int64)string-
RealmAddress()string-
1–12 of 461/4

Gnomic Airdrop

| | | |---|---| | Token | gno.land/r/nym-thegnomic001/gnomic.GNOMIC | | Realm address | g1s4znuslycwhj3d0fcuj4e5qq4sm6rj9vd78pmw | | Available balance | 14902292524156 | | Campaign | #1 | | Status | open | | Sealed | yes, one-off: no new campaign is possible any more | | Merkle root | cd0acec2cbf03fa9ecb78a2fc20487f28ddd48d0f6ca83682a5b4d5a5a1b40c9 | | Leaves | 12107 | | Deadline (block) | 0 | | Claims | 305 | | Granted | 524412629107 | | Total paid out | 66199454046 | | Burned | 15754010891 | | To the ecosystem fund (forfeits) | 15754010907 | | Still owed | 426705153263 | | Current block | 814198 |

Release

  • Unlocked immediately on claim: 10%
  • The remaining 90% vests linearly over 365 days
  • Withdrawals: at most one every 24 hours per address, the claim counting as the first
  • Alternatively ClaimInstant pays exactly 30% at once; of the other 70%, half is burned and half goes to the ecosystem fund
  • Or Forfeit: nothing paid, half burned, half to the ecosystem fund
  • Claims close 365 days after opening; what was never claimed is then settled: half burned, half to the ecosystem fund (g14slrjauvzpx578ker2wtt2jzs0c4mzslcpjcqf)
  • Start: 1789851184, end: 1821387184 (unix)
  • Progress: 6%

Status of an address: :claimed/<address>

Loading calls…
Calls432from 306 accounts
FunctionCallerStatusBlockAge
Withdrawg1snc9zq…d4pnx9Success804,7149h ago
Withdrawg1cc48d8…l8t029Success734,3303d ago
Withdrawg1fq7vr6…5frll0Success707,4884d ago
Withdrawg108gvrs…pq5ugqSuccess673,0925d ago
Withdrawg14wseuc…mvts3fSuccess671,4325d ago
Claimg1my4sez…8s4dgjSuccess656,8956d ago
Withdrawg1v9w49r…lrgam5Success654,7336d ago
Withdrawg12t2q70…xepmvdSuccess646,4636d ago
Withdrawg1znazju…8g4v9dSuccess645,9636d ago
Withdrawg108gvrs…pq5ugqSuccess638,8517d ago
Withdrawg1v9w49r…lrgam5Success628,1977d ago
Withdrawg10lrjej…rf3sfvSuccess620,9037d ago
1–12 of 4321/36