airdrop.gno
31.53 Kb · 909 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}