// Claim-based airdrop realm with Merkle proofs. // // Why Merkle rather than an on-chain list: // - the beneficiary list can run to tens of thousands of rows; writing it // on-chain costs gas and storage deposit in proportion; // - here only 32 bytes go on-chain (the root), plus one record per ACTUAL // claim. Whoever never claims costs nothing. // // Operating flow: // 1. deploy this realm; // 2. fund it by transferring GRC20 tokens to it (its address is returned by // RealmAddress()); // 3. the admin publishes root, leaf count and deadline block with SetCampaign; // 4. each beneficiary calls Claim with their own proof; // 5. after the deadline the admin recovers the residue with Sweep. // // The Merkle leaf is the string "
|", UTF-8 // encoded. The tree is Tendermint's "simple tree" (gno's crypto/merkle): // leaf = SHA256(0x00||data), node = SHA256(0x01||left||right), split at the // largest power of two below n. The generator in tools/merkle produces // compatible proofs. package gnomic_airdrop import ( "chain" "math" "chain/runtime" "chain/runtime/unsafe" "crypto/merkle" "encoding/hex" "strconv" "strings" "time" "gno.land/p/nt/avl/v0" "gno.land/p/nt/ownable/v0" "gno.land/p/nt/ufmt/v0" "gno.land/r/nt/grc20reg/v0" // Realm of the distributed token. Transfers go through the registry to stay // decoupled, but BURNING needs the real realm: the registry exposes // Transfer/Approve/TransferFrom, not Burn. scripts/render.sh rewrites this // path with the actual token's one. token "gno.land/r/nym-thegnomic001/gnomic" ) // Ownable holds the administrative authority over the campaign. var Ownable *ownable.Ownable // tokenKey is the key of the distributed token. It is a variable rather than a // constant only so that tests can register one of their own; in production it // keeps the config.gno value for the realm's whole life. var tokenKey = defaultTokenKey // claimWindowSeconds is a variable for the same reason: tests shrink or widen // it. In production it keeps the config.gno value for the realm's whole life. var claimWindowSeconds = defaultClaimWindowSeconds // withdrawCooldownSeconds likewise: tests switch it off, production keeps it. var withdrawCooldownSeconds = defaultWithdrawCooldownSeconds var ( root []byte // Merkle root of the current campaign leafCount int // total number of leaves (needed to verify) endHeight int64 // block past which no one can claim (0 = no limit) epoch int // bumped on every new campaign // claimed is the register of grants: "
" -> *grant. // // It is also the ONLY defence against double claiming. A Merkle proof shows // you are entitled, but it is reusable forever: only this register knows // that you have already collected. // // CAREFUL: never delete an entry, not even a settled one to reclaim its // storage deposit. It looks like a harmless optimisation and instead // reopens double claiming: someone who already withdrew everything would // present the same proof to a register that has forgotten them. claimed avl.Tree // campaignStart is when the current campaign opened: linear release runs // from there for EVERYONE, not from the moment of the individual claim. // Otherwise a late claimer would vest late, and there would be a reason to // rush to claim just to start the clock sooner. // // Every grant keeps a copy in grant.start: opening a new campaign must not // disturb the vesting already under way for whoever claimed in the // previous one. campaignStart int64 // outstanding is the sum of what has been granted but not yet withdrawn. // It protects beneficiaries: Sweep cannot touch this part. outstanding int64 // sealed makes opening further campaigns impossible. It is the equivalent // of DropOwnership on the token: a "one-off" airdrop announced in words // stays a promise; sealed in code it becomes a property anyone can verify // with a query. sealed bool totalClaimed int64 // total actually paid out totalGranted int64 // total granted (including what has not vested yet) totalBurned int64 // total destroyed: instant exits, renunciations, settlement settledBurned int64 // burned by SettleUnclaimed settledToFund int64 // sent to the ecosystem fund by SettleUnclaimed totalToFund int64 // sent to the ecosystem fund by voluntary forfeits claimCount int realmAddr address ) // grant is the entitlement a beneficiary obtained in a campaign. // It always holds that total == withdrawn + burned + toFund + (not yet released). // // Grants are keyed by address ALONE, not by "epoch:address": tying the key to // the epoch made the previous campaign's grants unreachable as soon as a new // one opened — the beneficiary could no longer withdraw and the tokens stayed // counted in outstanding, so not even Sweep could recover them. type grant struct { epoch int // campaign it was granted in start int64 // start of this grant's vesting total int64 // total amount granted withdrawn int64 // how much has already been transferred burned int64 // how much was destroyed (instant exit or forfeit) toFund int64 // how much went to the ecosystem fund (forfeit) lastWithdraw int64 // unix time of the last payout; drives the cooldown } // settled reports whether nothing is left to pay out on the grant. func (g *grant) settled() bool { return g.total <= g.withdrawn+g.burned+g.toFund } // closed reports whether the grant was ended by an instant exit or a forfeit. func (g *grant) closed() bool { return g.burned > 0 || g.toFund > 0 } const ( SealEvent = "AirdropSeal" SettleEvent = "AirdropSettleUnclaimed" ClaimEvent = "AirdropClaim" WithdrawEvent = "AirdropWithdraw" ForfeitEvent = "AirdropForfeit" CampaignEvent = "AirdropCampaign" ) const bpsDenominator = 10000 func init(cur realm) { admin := address(adminAddress) if !admin.IsValid() { admin = cur.Previous().Address() } if !admin.IsValid() { admin = unsafe.OriginCaller() } if !admin.IsValid() { panic("airdrop: cannot determine the administrator") } Ownable = ownable.NewWithAddress(admin) realmAddr = cur.Address() // The registry key and the imported realm must name the SAME token. // Transfers go through the registry (by key) while burns call the imported // realm directly: if the two ever disagreed the airdrop would pay out one // token and destroy another, and nothing would notice until the first // forfeit. Deriving the key from the import makes them equal by // construction; the constant survives only to make a wrong TOKEN_KEY in // config.env fail here, at deploy, instead of silently in a year. tokenKey = token.TokenKey() if defaultTokenKey != tokenKey { panic("airdrop: config.gno names " + defaultTokenKey + " but the imported token realm is " + tokenKey) } } // --------------------------------------------------------------------------- // Administration // --------------------------------------------------------------------------- // SetCampaign publishes a new campaign: rootHex is the Merkle root in hex // (32 bytes), total the number of leaves in the tree, end the deadline block // (0 = no deadline). // // Publishing a new campaign resets the claim history: whoever appears on both // lists can claim again. That is deliberate — it allows recurring airdrops on // the same realm without a redeploy. func SetCampaign(cur realm, rootHex string, total int, end int64) { Ownable.AssertOwnedBy(cur.Previous().Address()) if sealed { panic("airdrop: campaign is sealed, no further campaign can be opened") } raw, err := hex.DecodeString(strings.TrimPrefix(rootHex, "0x")) if err != nil { panic("airdrop: invalid root: " + err.Error()) } if len(raw) != 32 { panic("airdrop: the root must be 32 bytes") } if total <= 0 { panic("airdrop: the leaf count must be positive") } if end != 0 && end <= runtime.ChainHeight() { panic("airdrop: the deadline must be in the future") } root = raw leafCount = total endHeight = end epoch++ campaignStart = time.Now().Unix() chain.Emit( CampaignEvent, "epoch", strconv.Itoa(epoch), "root", hex.EncodeToString(raw), "leaves", strconv.Itoa(total), "end", strconv.FormatInt(end, 10), ) } // CloseCampaign stops claims immediately. func CloseCampaign(cur realm) { Ownable.AssertOwnedBy(cur.Previous().Address()) // Dopo il sigillo non si chiude piu' niente. Il sigillo esiste per dire // "questi termini sono definitivi", e lasciare in mano al proprietario un // interruttore che toglie a tutti la possibilita' di rivendicare lo // contraddiceva: bastava chiudere e aspettare i dodici mesi perche' meta' // del non rivendicato bruciasse e meta' finisse al fondo. Prima del // sigillo la chiusura serve — e' il modo di rimediare a una radice // sbagliata — dopo non ha piu' nessun uso legittimo. if sealed { panic("airdrop: the campaign is sealed, it cannot be closed") } root = nil leafCount = 0 endHeight = 0 } // SealCampaign closes off the possibility of opening new campaigns forever. // Irreversible: no code path sets sealed back to false. Call it once the // published root is the final one. // // Until then the owner can fix a mistake by republishing the root; afterwards // the airdrop is provably one-off and anyone can check with IsSealed(). func SealCampaign(cur realm) { Ownable.AssertOwnedBy(cur.Previous().Address()) if sealed { panic("airdrop: already sealed") } if len(root) != 32 { panic("airdrop: no published root to seal") } sealed = true chain.Emit(SealEvent, "epoch", strconv.Itoa(epoch), "root", hex.EncodeToString(root)) } // SettleUnclaimed disposes of whatever was never claimed, once the claim // window has elapsed: half is burned, the other half goes to the team fund. // // The rule is enforced here rather than promised: there is no Sweep and no // BurnRemaining, so the owner cannot pocket the residue nor destroy all of it. // Settlement waits for the window even if CloseCampaign stopped claims // earlier, and grants already made stay untouchable — only the free part // (balance minus outstanding) is settled. Anyone may call it: it is a public // service, not a privilege. func SettleUnclaimed(cur realm) { if campaignStart == 0 { panic("airdrop: no campaign was ever opened") } if time.Now().Unix() < campaignStart+claimWindowSeconds { panic("airdrop: the claim window has not elapsed yet") } if isOpen() { panic("airdrop: the campaign is still open") } free := Balance() - outstanding if free <= 0 { panic("airdrop: no unclaimed residue to settle") } team := address(ecosystemFundAddress) if !team.IsValid() { panic("airdrop: ecosystem fund address is not configured") } burn := free / 2 toTeam := free - burn totalBurned = addSat(totalBurned, burn) settledBurned = addSat(settledBurned, burn) settledToFund = addSat(settledToFund, toTeam) // Le due guardie non sono pedanteria: con un residuo di UNA unita' base // burn vale zero, e il ledger rifiuta un rogo di zero. Senza il controllo // la liquidazione fallirebbe per sempre proprio sul residuo piu' probabile // di tutti, quello lasciato dal troncamento, e quell'unita' resterebbe // chiusa nel realm senza che nessuno possa piu' toccarla. if burn > 0 { token.Burn(cross(cur), burn) } if toTeam > 0 { grc20reg.Transfer(0, cur, tokenKey, team, toTeam) } chain.Emit(SettleEvent, "burned", strconv.FormatInt(burn, 10), "to_team", strconv.FormatInt(toTeam, 10), "team", team.String()) } // TransferOwnership hands administration of the campaign over. func TransferOwnership(cur realm, newOwner address) { if err := Ownable.TransferOwnership(0, cur, newOwner); err != nil { panic(err.Error()) } } // --------------------------------------------------------------------------- // Claiming // --------------------------------------------------------------------------- // Claim pays amount tokens to the caller, if the Merkle proof // (index, auntsHex) shows that "|" is a leaf of the published // tree. // // auntsHex is the hex concatenation of the sibling hashes, 32 bytes each, just // as produced by tools/merkle. func Claim(cur realm, amount int64, index int, auntsHex string) { caller := cur.Previous().Address() if !cur.Previous().IsUserCall() { panic("airdrop: a claim must come from a user account") } claimFor(cur, caller, amount, index, auntsHex) } // claimFor is the body of Claim. It is unexported on purpose: a transaction // can only reach exported functions, so from outside the realm the only way in // is Claim, which always uses the caller's own address. The tests, being in // the same package, still exercise the registration logic directly. func claimFor(cur realm, beneficiary address, amount int64, index int, auntsHex string) { registerGrant(beneficiary, amount, index, auntsHex) releaseIfAny(cur, beneficiary) } // There is deliberately no "claim on behalf of" entry point. // // It used to exist, so that a third party could pay the gas for someone: the // tokens went to the beneficiary either way, so it looked harmless. It is not. // Registering the grant is what SPENDS the one choice this airdrop allows: the // beneficiary can no longer take the instant 30%, nor renounce, because the // vesting path has already been picked for them. A valid Merkle proof proves // entitlement, never consent, and every proof is public. // // WithdrawFor stays: it only moves what has already vested to its owner, and // takes no decision away from anyone. // ClaimInstant is the first of the two alternatives to Claim: the instant // share (instantBps, 30%) is paid at once and the rest (70%) is given up the // same way a forfeit is — half burned, half to the ecosystem fund — whenever // it is chosen, even after the whole grant would have vested. The share is // larger than Claim's immediate 10% on purpose: it makes the shortcut a real // choice rather than a punishment. It cannot be undone // and suits someone who prefers a certain fraction today to the whole a year // from now. The residue is destroyed, to every holder's benefit. // // Only the person concerned can choose it: there is no "on behalf of" form. func ClaimInstant(cur realm, amount int64, index int, auntsHex string) { if !cur.Previous().IsUserCall() { panic("airdrop: a claim must come from a user account") } beneficiary := cur.Previous().Address() registerGrant(beneficiary, amount, index, auntsHex) g := grantOf(beneficiary) fund := address(ecosystemFundAddress) if !fund.IsValid() { panic("airdrop: ecosystem fund address is not configured") } immediate := shareOf(g.total, instantBps) rest := g.total - immediate burn := rest / 2 toFund := rest - burn g.withdrawn = immediate g.burned = burn g.toFund = toFund g.lastWithdraw = time.Now().Unix() outstanding -= g.total totalClaimed = addSat(totalClaimed, immediate) totalBurned = addSat(totalBurned, burn) totalToFund = addSat(totalToFund, toFund) if immediate > 0 { grc20reg.Transfer(0, cur, tokenKey, beneficiary, immediate) } if burn > 0 { // Burn acts on the caller's balance: this realm holds the escrow. token.Burn(cross(cur), burn) } if toFund > 0 { grc20reg.Transfer(0, cur, tokenKey, fund, toFund) } chain.Emit(WithdrawEvent, "epoch", strconv.Itoa(epoch), "to", beneficiary.String(), "amount", strconv.FormatInt(immediate, 10), "remaining", "0") chain.Emit(ForfeitEvent, "epoch", strconv.Itoa(epoch), "who", beneficiary.String(), "burned", strconv.FormatInt(burn, 10), "to_fund", strconv.FormatInt(toFund, 10)) } // Forfeit is the second alternative to Claim: the beneficiary gives the grant // up without taking anything. Half is burned, half goes to the ecosystem fund — // the same split SettleUnclaimed applies to whoever never acted at all. // // The proof is still required and the grant is still recorded: without that, // the same address could forfeit and then claim. The record makes it final. // Only the person concerned can choose it: there is no "on behalf of" form. func Forfeit(cur realm, amount int64, index int, auntsHex string) { if !cur.Previous().IsUserCall() { panic("airdrop: a forfeit must come from a user account") } beneficiary := cur.Previous().Address() fund := address(ecosystemFundAddress) if !fund.IsValid() { panic("airdrop: ecosystem fund address is not configured") } registerGrant(beneficiary, amount, index, auntsHex) g := grantOf(beneficiary) burn := g.total / 2 toFund := g.total - burn g.burned = burn g.toFund = toFund outstanding -= g.total totalBurned = addSat(totalBurned, burn) totalToFund = addSat(totalToFund, toFund) if burn > 0 { token.Burn(cross(cur), burn) } if toFund > 0 { grc20reg.Transfer(0, cur, tokenKey, fund, toFund) } chain.Emit(ForfeitEvent, "epoch", strconv.Itoa(epoch), "who", beneficiary.String(), "burned", strconv.FormatInt(burn, 10), "to_fund", strconv.FormatInt(toFund, 10)) } // immediateOf is the share Claim unlocks at once: immediateBps/10000 of total. func immediateOf(total int64) int64 { return shareOf(total, immediateBps) } // shareOf is bps/10000 of total, in two steps to stay inside int64. func shareOf(total int64, bps int) int64 { v := total/bpsDenominator*int64(bps) + total%bpsDenominator*int64(bps)/bpsDenominator if v > total { v = total } return v } // registerGrant checks the proof and records the grant. It transfers nothing. func registerGrant(beneficiary address, amount int64, index int, auntsHex string) { if !isOpen() { panic("airdrop: no open campaign") } if !beneficiary.IsValid() { panic("airdrop: invalid beneficiary") } if amount <= 0 { panic("airdrop: invalid amount") } if index < 0 || index >= leafCount { panic("airdrop: index out of range") } key := beneficiary.String() if prev := grantOf(beneficiary); prev != nil { if prev.epoch == epoch { panic("airdrop: already claimed") } // A previous grant still vesting must not be overwritten: that would be // a silent loss for the beneficiary. It has to be closed first, with // Withdraw once vesting is over or with Forfeit. if !prev.settled() { panic("airdrop: an earlier grant is still running, close it first") } } aunts, err := hex.DecodeString(strings.TrimPrefix(auntsHex, "0x")) if err != nil { panic("airdrop: invalid proof: " + err.Error()) } if len(aunts)%32 != 0 { panic("airdrop: the proof must be a multiple of 32 bytes") } leaf := []byte(Leaf(beneficiary, amount)) if !merkle.VerifySimpleProof(root, leaf, index, leafCount, aunts) { panic("airdrop: invalid Merkle proof") } // The realm must be able to cover the new grant ON TOP of those already made. if Balance()-outstanding < amount { panic("airdrop: insufficient funds in the realm") } claimed.Set(key, &grant{epoch: epoch, start: campaignStart, total: amount}) outstanding += amount totalGranted = addSat(totalGranted, amount) claimCount++ chain.Emit( ClaimEvent, "epoch", strconv.Itoa(epoch), "to", beneficiary.String(), "amount", strconv.FormatInt(amount, 10), ) } // Withdraw transfers to the caller the vested, not-yet-withdrawn part. func Withdraw(cur realm) { release(cur, cur.Previous().Address()) } // WithdrawFor is like Withdraw but on another beneficiary's behalf: the tokens // still go to beneficiary, whoever sends the transaction only pays the gas. func WithdrawFor(cur realm, beneficiary address) { release(cur, beneficiary) } // release transfers the vested share. It is the public form: when there is // nothing to withdraw it says so, instead of silently letting through a // transaction that did nothing. func release(cur realm, beneficiary address) { if releaseIfAny(cur, beneficiary) == 0 { panic("airdrop: nothing to withdraw right now") } } // releaseIfAny is the internal form: it transfers whatever has vested and // returns the amount, or 0 if nothing has vested yet. It serves the paths where // "nothing to withdraw" is normal rather than an error: a claim whose immediate // share is zero, and a forfeit, whose point is to burn the residue rather than // to collect. // Payouts are rate-limited: one every withdrawCooldownSeconds per grant, the // claim itself counting as the first. The very first payout is never delayed. func releaseIfAny(cur realm, beneficiary address) int64 { g := grantOf(beneficiary) if g == nil { panic("airdrop: no grant for this address") } if g.closed() { panic("airdrop: grant closed by an instant exit or a forfeit") } amount := vestedOf(g) - g.withdrawn if amount <= 0 { return 0 } now := time.Now().Unix() if g.lastWithdraw > 0 && now < g.lastWithdraw+withdrawCooldownSeconds { panic("airdrop: next withdrawal available in " + strconv.FormatInt(g.lastWithdraw+withdrawCooldownSeconds-now, 10) + " seconds") } g.lastWithdraw = now g.withdrawn += amount outstanding -= amount totalClaimed = addSat(totalClaimed, amount) grc20reg.Transfer(0, cur, tokenKey, beneficiary, amount) chain.Emit( WithdrawEvent, "epoch", strconv.Itoa(epoch), "to", beneficiary.String(), "amount", strconv.FormatInt(amount, 10), "remaining", strconv.FormatInt(g.total-g.withdrawn-g.burned-g.toFund, 10), ) return amount } // vestedOf computes how much of a grant has vested: // // vested = immediate + rest * elapsed / duration // // where immediate is immediateBps/10000 of the total. The division is done in // two steps (quotient and remainder) because rest*elapsed would overflow int64 // for large amounts: 1e13 * 3.15e7 exceeds 9.2e18. func vestedOf(g *grant) int64 { immediate := immediateOf(g.total) rest := g.total - immediate if rest == 0 || vestingSeconds <= 0 { return g.total } elapsed := time.Now().Unix() - g.start if elapsed <= 0 { return immediate } if elapsed >= vestingSeconds { return g.total } linear := rest/vestingSeconds*elapsed + rest%vestingSeconds*elapsed/vestingSeconds return immediate + linear } // addSat adds without ever wrapping. These counters only grow, across every // campaign, and nothing bounds them to the supply: a statistic that turns // negative would be bad, one that blocks a withdrawal would be worse. func addSat(a, b int64) int64 { if b > 0 && a > math.MaxInt64-b { return math.MaxInt64 } return a + b } func grantOf(addr address) *grant { v := claimed.Get(addr.String()) if v == nil { return nil } return v.(*grant) } // --------------------------------------------------------------------------- // Reads // --------------------------------------------------------------------------- // Leaf returns the canonical Merkle-leaf encoding for the (address, amount) // pair. It must match byte for byte the one used by the off-chain generator. func Leaf(addr address, amount int64) string { return addr.String() + "|" + strconv.FormatInt(amount, 10) } // RealmAddress is the address to fund with the airdrop tokens. func RealmAddress() address { return realmAddr } // Balance is the token balance the realm currently holds. func Balance() int64 { token := grc20reg.Get(tokenKey) if token == nil { return 0 } return token.BalanceOf(realmAddr) } // Root returns the current Merkle root in hex. func Root() string { return hex.EncodeToString(root) } // Epoch is the number of the current campaign. func Epoch() int { return epoch } // IsSealed reports whether the campaign has been sealed: if true, no new // campaign can ever be opened. func IsSealed() bool { return sealed } // LeafCount is the number of leaves in the published tree. func LeafCount() int { return leafCount } // EndHeight is the deadline block (0 = no deadline). func EndHeight() int64 { return endHeight } // IsOpen reports whether claims are currently accepted. func IsOpen() bool { return isOpen() } // HasClaimed reports whether addr has already claimed in the current campaign. func HasClaimed(addr address) bool { g := grantOf(addr) return g != nil && g.epoch == epoch } // HasGrant reports whether addr holds a grant, from this campaign or an // earlier one. func HasGrant(addr address) bool { return grantOf(addr) != nil } // GrantEpoch returns the campaign in which addr obtained its grant. func GrantEpoch(addr address) int { g := grantOf(addr) if g == nil { return 0 } return g.epoch } // VestingStartOf returns when vesting starts for addr's grant. func VestingStartOf(addr address) int64 { g := grantOf(addr) if g == nil { return 0 } return g.start } // ClaimedAmount returns addr's total grant, vested or not. func ClaimedAmount(addr address) int64 { g := grantOf(addr) if g == nil { return 0 } return g.total } // Vested returns how much of addr's grant has vested so far. A closed grant // (instant exit or forfeit) will never vest anything more: it reports what // was paid, not where the curve would be. func Vested(addr address) int64 { g := grantOf(addr) if g == nil { return 0 } if g.closed() { return g.withdrawn } return vestedOf(g) } // InstantBps is the share ClaimInstant pays at once, in hundredths of a percent. func InstantBps() int { return instantBps } // Withdrawn returns how much addr has already withdrawn. func Withdrawn(addr address) int64 { g := grantOf(addr) if g == nil { return 0 } return g.withdrawn } // Withdrawable returns how much addr can withdraw right now. func Withdrawable(addr address) int64 { g := grantOf(addr) if g == nil || g.closed() { return 0 } // During the cooldown the answer is zero, not "what has vested": this // function says what Withdraw would pay right now, and right now it would // refuse. NextWithdrawAt says when that changes. if g.lastWithdraw > 0 && time.Now().Unix() < g.lastWithdraw+withdrawCooldownSeconds { return 0 } return vestedOf(g) - g.withdrawn } // NextWithdrawAt returns the unix time from which addr may withdraw again // (0 when there is no grant, the grant is closed, or no cooldown is running). func NextWithdrawAt(addr address) int64 { g := grantOf(addr) if g == nil || g.closed() || g.lastWithdraw == 0 { return 0 } return g.lastWithdraw + withdrawCooldownSeconds } // WithdrawCooldown is the minimum interval between two payouts, in seconds. func WithdrawCooldown() int64 { return withdrawCooldownSeconds } // BurnedOf returns how much of addr's grant was destroyed. func BurnedOf(addr address) int64 { g := grantOf(addr) if g == nil { return 0 } return g.burned } // FundOf returns how much of addr's grant went to the ecosystem fund. func FundOf(addr address) int64 { g := grantOf(addr) if g == nil { return 0 } return g.toFund } // TotalToFund is what voluntary forfeits sent to the ecosystem fund. func TotalToFund() int64 { return totalToFund } // TotalBurned is the total destroyed: instant exits, renunciations and the // settlement of the unclaimed residue. func TotalBurned() int64 { return totalBurned } // ClaimWindowEnd is the moment (unix) claims stop being accepted for the // current campaign; SettleUnclaimed becomes callable from then on. func ClaimWindowEnd() int64 { return campaignStart + claimWindowSeconds } // EcosystemFund is where the non-burned half of every forfeit goes. func EcosystemFund() address { return address(ecosystemFundAddress) } // SettledBurned and SettledToFund report what SettleUnclaimed did. func SettledBurned() int64 { return settledBurned } func SettledToFund() int64 { return settledToFund } // Outstanding is the total granted and not yet withdrawn: the part of the // realm's balance that Sweep cannot touch. func Outstanding() int64 { return outstanding } // TotalGranted is the total of all grants made, vested or not. func TotalGranted() int64 { return totalGranted } // VestingStart is the moment (unix) the CURRENT campaign's linear release runs // from. Grants from earlier campaigns keep their own, readable with // VestingStartOf. func VestingStart() int64 { return campaignStart } // VestingEnd is the moment (unix) at which everything granted in the current // campaign counts as fully vested. func VestingEnd() int64 { return campaignStart + vestingSeconds } // ImmediateBps is the share unlocked at once, in hundredths of a percent. func ImmediateBps() int { return immediateBps } // TotalClaimed is the total actually transferred to beneficiaries. func TotalClaimed() int64 { return totalClaimed } // ClaimCount is the number of successful claims. func ClaimCount() int { return claimCount } func isOpen() bool { if len(root) != 32 || leafCount == 0 { return false } if endHeight != 0 && runtime.ChainHeight() > endHeight { return false } // The claim window is part of the deal: after it, no claim is accepted and // the residue is settled by SettleUnclaimed. if claimWindowSeconds > 0 && time.Now().Unix() >= campaignStart+claimWindowSeconds { return false } return true } func Render(path string) string { parts := strings.Split(path, "/") if len(parts) == 2 && parts[0] == "claimed" { addr := address(parts[1]) if !addr.IsValid() { return "invalid address\n" } g := grantOf(addr) if g == nil { return "no grant for this address\n" } if g.closed() { return ufmt.Sprintf( "granted: %d\nwithdrawn: %d\nburned: %d\nto fund: %d\nstatus: closed\n", g.total, g.withdrawn, g.burned, g.toFund) } return ufmt.Sprintf( "granted: %d\nvested: %d\nwithdrawn: %d\navailable now: %d\n", g.total, vestedOf(g), g.withdrawn, vestedOf(g)-g.withdrawn) } if path != "" { return "404\n" } s := ufmt.Sprintf("# %s\n\n", campaignName) s += "| | |\n|---|---|\n" s += ufmt.Sprintf("| Token | `%s` |\n", tokenKey) s += ufmt.Sprintf("| Realm address | `%s` |\n", realmAddr.String()) s += ufmt.Sprintf("| Available balance | %d |\n", Balance()) s += ufmt.Sprintf("| Campaign | #%d |\n", epoch) s += ufmt.Sprintf("| Status | %s |\n", statusLabel()) s += ufmt.Sprintf("| Sealed | %s |\n", sealLabel()) s += ufmt.Sprintf("| Merkle root | `%s` |\n", Root()) s += ufmt.Sprintf("| Leaves | %d |\n", leafCount) s += ufmt.Sprintf("| Deadline (block) | %d |\n", endHeight) s += ufmt.Sprintf("| Claims | %d |\n", claimCount) s += ufmt.Sprintf("| Granted | %d |\n", totalGranted) s += ufmt.Sprintf("| Total paid out | %d |\n", totalClaimed) s += ufmt.Sprintf("| Burned | %d |\n", totalBurned) s += ufmt.Sprintf("| To the ecosystem fund (forfeits) | %d |\n", totalToFund) s += ufmt.Sprintf("| Still owed | %d |\n", outstanding) s += ufmt.Sprintf("| Current block | %d |\n", runtime.ChainHeight()) s += "\n## Release\n\n" s += ufmt.Sprintf("- Unlocked immediately on claim: **%d%%**\n", immediateBps/100) s += ufmt.Sprintf("- The remaining **%d%%** vests linearly over %d days\n", 100-immediateBps/100, vestingSeconds/86400) s += ufmt.Sprintf("- Withdrawals: at most one every %d hours per address, the claim counting as the first\n", withdrawCooldownSeconds/3600) 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", instantBps/100, 100-instantBps/100) s += "- Or `Forfeit`: nothing paid, half burned, half to the ecosystem fund\n" 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", claimWindowSeconds/86400, ecosystemFundAddress) if settledBurned > 0 || settledToFund > 0 { s += ufmt.Sprintf("- Settled: %d burned, %d to the ecosystem fund\n", settledBurned, settledToFund) } if campaignStart > 0 { s += ufmt.Sprintf("- Start: %d, end: %d (unix)\n", campaignStart, VestingEnd()) elapsed := time.Now().Unix() - campaignStart pct := int64(100) if elapsed < vestingSeconds { pct = elapsed * 100 / vestingSeconds } if pct < 0 { pct = 0 } s += ufmt.Sprintf("- Progress: **%d%%**\n", pct) } s += "\nStatus of an address: `:claimed/
`\n" return s } // sealLabel describes in Render whether the airdrop is provably one-off. func sealLabel() string { if sealed { return "**yes, one-off**: no new campaign is possible any more" } return "no: the owner can still republish the root" } func statusLabel() string { if isOpen() { return "**open**" } if len(root) == 32 { return "expired" } return "closed" }