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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}