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 it5// on-chain costs gas and storage deposit in proportion;6// - here only 32 bytes go on-chain (the root), plus one record per ACTUAL7// 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 by12// 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-818// encoded. The tree is Tendermint's "simple tree" (gno's crypto/merkle):19// leaf = SHA256(0x00||data), node = SHA256(0x01||left||right), split at the20// largest power of two below n. The generator in tools/merkle produces21// compatible proofs.22package gnomic_airdrop2324import (25 "chain"26 "math"27 "chain/runtime"28 "chain/runtime/unsafe"29 "crypto/merkle"30 "encoding/hex"31 "strconv"32 "strings"33 "time"3435 "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"3940 // Realm of the distributed token. Transfers go through the registry to stay41 // decoupled, but BURNING needs the real realm: the registry exposes42 // Transfer/Approve/TransferFrom, not Burn. scripts/render.sh rewrites this43 // path with the actual token's one.44 token "gno.land/r/nym-thegnomic001/gnomic"45)4647// Ownable holds the administrative authority over the campaign.48var Ownable *ownable.Ownable4950// tokenKey is the key of the distributed token. It is a variable rather than a51// constant only so that tests can register one of their own; in production it52// keeps the config.gno value for the realm's whole life.53var tokenKey = defaultTokenKey5455// claimWindowSeconds is a variable for the same reason: tests shrink or widen56// it. In production it keeps the config.gno value for the realm's whole life.57var claimWindowSeconds = defaultClaimWindowSeconds5859// withdrawCooldownSeconds likewise: tests switch it off, production keeps it.60var withdrawCooldownSeconds = defaultWithdrawCooldownSeconds6162var (63 root []byte // Merkle root of the current campaign64 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 campaign6768 // claimed is the register of grants: "<address>" -> *grant.69 //70 // It is also the ONLY defence against double claiming. A Merkle proof shows71 // you are entitled, but it is reusable forever: only this register knows72 // that you have already collected.73 //74 // CAREFUL: never delete an entry, not even a settled one to reclaim its75 // storage deposit. It looks like a harmless optimisation and instead76 // reopens double claiming: someone who already withdrew everything would77 // present the same proof to a register that has forgotten them.78 claimed avl.Tree7980 // campaignStart is when the current campaign opened: linear release runs81 // 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 to83 // rush to claim just to start the clock sooner.84 //85 // Every grant keeps a copy in grant.start: opening a new campaign must not86 // disturb the vesting already under way for whoever claimed in the87 // previous one.88 campaignStart int648990 // outstanding is the sum of what has been granted but not yet withdrawn.91 // It protects beneficiaries: Sweep cannot touch this part.92 outstanding int649394 // sealed makes opening further campaigns impossible. It is the equivalent95 // of DropOwnership on the token: a "one-off" airdrop announced in words96 // stays a promise; sealed in code it becomes a property anyone can verify97 // with a query.98 sealed bool99100 totalClaimed int64 // total actually paid out101 totalGranted int64 // total granted (including what has not vested yet)102 totalBurned int64 // total destroyed: instant exits, renunciations, settlement103 settledBurned int64 // burned by SettleUnclaimed104 settledToFund int64 // sent to the ecosystem fund by SettleUnclaimed105 totalToFund int64 // sent to the ecosystem fund by voluntary forfeits106 claimCount int107 realmAddr address108)109110// 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 to114// the epoch made the previous campaign's grants unreachable as soon as a new115// one opened — the beneficiary could no longer withdraw and the tokens stayed116// counted in outstanding, so not even Sweep could recover them.117type grant struct {118 epoch int // campaign it was granted in119 start int64 // start of this grant's vesting120 total int64 // total amount granted121 withdrawn int64 // how much has already been transferred122 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 cooldown125}126127// 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 }129130// 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 }132133const (134 SealEvent = "AirdropSeal"135 SettleEvent = "AirdropSettleUnclaimed"136 ClaimEvent = "AirdropClaim"137 WithdrawEvent = "AirdropWithdraw"138 ForfeitEvent = "AirdropForfeit"139 CampaignEvent = "AirdropCampaign"140)141142const bpsDenominator = 10000143144func 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()157158 // The registry key and the imported realm must name the SAME token.159 // Transfers go through the registry (by key) while burns call the imported160 // realm directly: if the two ever disagreed the airdrop would pay out one161 // token and destroy another, and nothing would notice until the first162 // forfeit. Deriving the key from the import makes them equal by163 // construction; the constant survives only to make a wrong TOKEN_KEY in164 // 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}171172// ---------------------------------------------------------------------------173// Administration174// ---------------------------------------------------------------------------175176// SetCampaign publishes a new campaign: rootHex is the Merkle root in hex177// (32 bytes), total the number of leaves in the tree, end the deadline block178// (0 = no deadline).179//180// Publishing a new campaign resets the claim history: whoever appears on both181// lists can claim again. That is deliberate — it allows recurring airdrops on182// 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 }188189 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 }202203 root = raw204 leafCount = total205 endHeight = end206 epoch++207 campaignStart = time.Now().Unix()208209 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}217218// 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 dire222 // "questi termini sono definitivi", e lasciare in mano al proprietario un223 // interruttore che toglie a tutti la possibilita' di rivendicare lo224 // contraddiceva: bastava chiudere e aspettare i dodici mesi perche' meta'225 // del non rivendicato bruciasse e meta' finisse al fondo. Prima del226 // sigillo la chiusura serve — e' il modo di rimediare a una radice227 // 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 = nil232 leafCount = 0233 endHeight = 0234}235236// SealCampaign closes off the possibility of opening new campaigns forever.237// Irreversible: no code path sets sealed back to false. Call it once the238// published root is the final one.239//240// Until then the owner can fix a mistake by republishing the root; afterwards241// 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 = true251 chain.Emit(SealEvent, "epoch", strconv.Itoa(epoch), "root", hex.EncodeToString(root))252}253254// SettleUnclaimed disposes of whatever was never claimed, once the claim255// 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 no258// BurnRemaining, so the owner cannot pocket the residue nor destroy all of it.259// Settlement waits for the window even if CloseCampaign stopped claims260// earlier, and grants already made stay untouchable — only the free part261// (balance minus outstanding) is settled. Anyone may call it: it is a public262// 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() - outstanding274 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 / 2282 toTeam := free - burn283 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' base287 // burn vale zero, e il ledger rifiuta un rogo di zero. Senza il controllo288 // la liquidazione fallirebbe per sempre proprio sul residuo piu' probabile289 // di tutti, quello lasciato dal troncamento, e quell'unita' resterebbe290 // 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}302303// 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}309310// ---------------------------------------------------------------------------311// Claiming312// ---------------------------------------------------------------------------313314// Claim pays amount tokens to the caller, if the Merkle proof315// (index, auntsHex) shows that "<caller>|<amount>" is a leaf of the published316// tree.317//318// auntsHex is the hex concatenation of the sibling hashes, 32 bytes each, just319// 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}327328// claimFor is the body of Claim. It is unexported on purpose: a transaction329// can only reach exported functions, so from outside the realm the only way in330// is Claim, which always uses the caller's own address. The tests, being in331// 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}336337// 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: the340// 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: the342// beneficiary can no longer take the instant 30%, nor renounce, because the343// vesting path has already been picked for them. A valid Merkle proof proves344// entitlement, never consent, and every proof is public.345//346// WithdrawFor stays: it only moves what has already vested to its owner, and347// takes no decision away from anyone.348349// ClaimInstant is the first of the two alternatives to Claim: the instant350// share (instantBps, 30%) is paid at once and the rest (70%) is given up the351// same way a forfeit is — half burned, half to the ecosystem fund — whenever352// it is chosen, even after the whole grant would have vested. The share is353// larger than Claim's immediate 10% on purpose: it makes the shortcut a real354// choice rather than a punishment. It cannot be undone355// and suits someone who prefers a certain fraction today to the whole a year356// 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)366367 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 - immediate373 burn := rest / 2374 toFund := rest - burn375 g.withdrawn = immediate376 g.burned = burn377 g.toFund = toFund378 g.lastWithdraw = time.Now().Unix()379 outstanding -= g.total380 totalClaimed = addSat(totalClaimed, immediate)381 totalBurned = addSat(totalBurned, burn)382 totalToFund = addSat(totalToFund, toFund)383384 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}399400// Forfeit is the second alternative to Claim: the beneficiary gives the grant401// 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)418419 burn := g.total / 2420 toFund := g.total - burn421 g.burned = burn422 g.toFund = toFund423 outstanding -= g.total424 totalBurned = addSat(totalBurned, burn)425 totalToFund = addSat(totalToFund, toFund)426427 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}436437// immediateOf is the share Claim unlocks at once: immediateBps/10000 of total.438func immediateOf(total int64) int64 { return shareOf(total, immediateBps) }439440// 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)/bpsDenominator443 if v > total {444 v = total445 }446 return v447}448449// 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 }463464 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 be470 // a silent loss for the beneficiary. It has to be closed first, with471 // 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 }476477 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 }484485 leaf := []byte(Leaf(beneficiary, amount))486 if !merkle.VerifySimpleProof(root, leaf, index, leafCount, aunts) {487 panic("airdrop: invalid Merkle proof")488 }489490 // 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 }494495 claimed.Set(key, &grant{epoch: epoch, start: campaignStart, total: amount})496 outstanding += amount497 totalGranted = addSat(totalGranted, amount)498 claimCount++499500 chain.Emit(501 ClaimEvent,502 "epoch", strconv.Itoa(epoch),503 "to", beneficiary.String(),504 "amount", strconv.FormatInt(amount, 10),505 )506}507508// Withdraw transfers to the caller the vested, not-yet-withdrawn part.509func Withdraw(cur realm) {510 release(cur, cur.Previous().Address())511}512513// WithdrawFor is like Withdraw but on another beneficiary's behalf: the tokens514// still go to beneficiary, whoever sends the transaction only pays the gas.515func WithdrawFor(cur realm, beneficiary address) {516 release(cur, beneficiary)517}518519// release transfers the vested share. It is the public form: when there is520// nothing to withdraw it says so, instead of silently letting through a521// 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}527528// releaseIfAny is the internal form: it transfers whatever has vested and529// returns the amount, or 0 if nothing has vested yet. It serves the paths where530// "nothing to withdraw" is normal rather than an error: a claim whose immediate531// share is zero, and a forfeit, whose point is to burn the residue rather than532// to collect.533// Payouts are rate-limited: one every withdrawCooldownSeconds per grant, the534// 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.withdrawn544 if amount <= 0 {545 return 0546 }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 }552553 g.lastWithdraw = now554 g.withdrawn += amount555 outstanding -= amount556 totalClaimed = addSat(totalClaimed, amount)557558 grc20reg.Transfer(0, cur, tokenKey, beneficiary, amount)559560 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 amount568}569570// vestedOf computes how much of a grant has vested:571//572// vested = immediate + rest * elapsed / duration573//574// where immediate is immediateBps/10000 of the total. The division is done in575// two steps (quotient and remainder) because rest*elapsed would overflow int64576// for large amounts: 1e13 * 3.15e7 exceeds 9.2e18.577func vestedOf(g *grant) int64 {578 immediate := immediateOf(g.total)579 rest := g.total - immediate580 if rest == 0 || vestingSeconds <= 0 {581 return g.total582 }583584 elapsed := time.Now().Unix() - g.start585 if elapsed <= 0 {586 return immediate587 }588 if elapsed >= vestingSeconds {589 return g.total590 }591592 linear := rest/vestingSeconds*elapsed + rest%vestingSeconds*elapsed/vestingSeconds593 return immediate + linear594}595596// addSat adds without ever wrapping. These counters only grow, across every597// campaign, and nothing bounds them to the supply: a statistic that turns598// 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.MaxInt64602 }603 return a + b604}605606func grantOf(addr address) *grant {607 v := claimed.Get(addr.String())608 if v == nil {609 return nil610 }611 return v.(*grant)612}613614// ---------------------------------------------------------------------------615// Reads616// ---------------------------------------------------------------------------617618// 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}623624// RealmAddress is the address to fund with the airdrop tokens.625func RealmAddress() address { return realmAddr }626627// Balance is the token balance the realm currently holds.628func Balance() int64 {629 token := grc20reg.Get(tokenKey)630 if token == nil {631 return 0632 }633 return token.BalanceOf(realmAddr)634}635636// Root returns the current Merkle root in hex.637func Root() string { return hex.EncodeToString(root) }638639// Epoch is the number of the current campaign.640func Epoch() int { return epoch }641642// IsSealed reports whether the campaign has been sealed: if true, no new643// campaign can ever be opened.644func IsSealed() bool { return sealed }645646// LeafCount is the number of leaves in the published tree.647func LeafCount() int { return leafCount }648649// EndHeight is the deadline block (0 = no deadline).650func EndHeight() int64 { return endHeight }651652// IsOpen reports whether claims are currently accepted.653func IsOpen() bool { return isOpen() }654655// 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 == epoch659}660661// HasGrant reports whether addr holds a grant, from this campaign or an662// earlier one.663func HasGrant(addr address) bool { return grantOf(addr) != nil }664665// 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 0670 }671 return g.epoch672}673674// VestingStartOf returns when vesting starts for addr's grant.675func VestingStartOf(addr address) int64 {676 g := grantOf(addr)677 if g == nil {678 return 0679 }680 return g.start681}682683// ClaimedAmount returns addr's total grant, vested or not.684func ClaimedAmount(addr address) int64 {685 g := grantOf(addr)686 if g == nil {687 return 0688 }689 return g.total690}691692// Vested returns how much of addr's grant has vested so far. A closed grant693// (instant exit or forfeit) will never vest anything more: it reports what694// was paid, not where the curve would be.695func Vested(addr address) int64 {696 g := grantOf(addr)697 if g == nil {698 return 0699 }700 if g.closed() {701 return g.withdrawn702 }703 return vestedOf(g)704}705706// InstantBps is the share ClaimInstant pays at once, in hundredths of a percent.707func InstantBps() int { return instantBps }708709// Withdrawn returns how much addr has already withdrawn.710func Withdrawn(addr address) int64 {711 g := grantOf(addr)712 if g == nil {713 return 0714 }715 return g.withdrawn716}717718// 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 0723 }724 // During the cooldown the answer is zero, not "what has vested": this725 // function says what Withdraw would pay right now, and right now it would726 // refuse. NextWithdrawAt says when that changes.727 if g.lastWithdraw > 0 && time.Now().Unix() < g.lastWithdraw+withdrawCooldownSeconds {728 return 0729 }730 return vestedOf(g) - g.withdrawn731}732733// NextWithdrawAt returns the unix time from which addr may withdraw again734// (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 0739 }740 return g.lastWithdraw + withdrawCooldownSeconds741}742743// WithdrawCooldown is the minimum interval between two payouts, in seconds.744func WithdrawCooldown() int64 { return withdrawCooldownSeconds }745746// 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 0751 }752 return g.burned753}754755// 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 0760 }761 return g.toFund762}763764// TotalToFund is what voluntary forfeits sent to the ecosystem fund.765func TotalToFund() int64 { return totalToFund }766767// TotalBurned is the total destroyed: instant exits, renunciations and the768// settlement of the unclaimed residue.769func TotalBurned() int64 { return totalBurned }770771// ClaimWindowEnd is the moment (unix) claims stop being accepted for the772// current campaign; SettleUnclaimed becomes callable from then on.773func ClaimWindowEnd() int64 { return campaignStart + claimWindowSeconds }774775// EcosystemFund is where the non-burned half of every forfeit goes.776func EcosystemFund() address { return address(ecosystemFundAddress) }777778// SettledBurned and SettledToFund report what SettleUnclaimed did.779func SettledBurned() int64 { return settledBurned }780func SettledToFund() int64 { return settledToFund }781782// Outstanding is the total granted and not yet withdrawn: the part of the783// realm's balance that Sweep cannot touch.784func Outstanding() int64 { return outstanding }785786// TotalGranted is the total of all grants made, vested or not.787func TotalGranted() int64 { return totalGranted }788789// VestingStart is the moment (unix) the CURRENT campaign's linear release runs790// from. Grants from earlier campaigns keep their own, readable with791// VestingStartOf.792func VestingStart() int64 { return campaignStart }793794// VestingEnd is the moment (unix) at which everything granted in the current795// campaign counts as fully vested.796func VestingEnd() int64 { return campaignStart + vestingSeconds }797798// ImmediateBps is the share unlocked at once, in hundredths of a percent.799func ImmediateBps() int { return immediateBps }800801// TotalClaimed is the total actually transferred to beneficiaries.802func TotalClaimed() int64 { return totalClaimed }803804// ClaimCount is the number of successful claims.805func ClaimCount() int { return claimCount }806807func isOpen() bool {808 if len(root) != 32 || leafCount == 0 {809 return false810 }811 if endHeight != 0 && runtime.ChainHeight() > endHeight {812 return false813 }814 // The claim window is part of the deal: after it, no claim is accepted and815 // the residue is settled by SettleUnclaimed.816 if claimWindowSeconds > 0 && time.Now().Unix() >= campaignStart+claimWindowSeconds {817 return false818 }819 return true820}821822func 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 }845846 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() - campaignStart880 pct := int64(100)881 if elapsed < vestingSeconds {882 pct = elapsed * 100 / vestingSeconds883 }884 if pct < 0 {885 pct = 0886 }887 s += ufmt.Sprintf("- Progress: **%d%%**\n", pct)888 }889 s += "\nStatus of an address: `:claimed/<address>`\n"890 return s891}892893// 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}900901func statusLabel() string {902 if isOpen() {903 return "**open**"904 }905 if len(root) == 32 {906 return "expired"907 }908 return "closed"909}910Balance() int64
BurnedOf(addr string) int64
Claim(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, amount int64, index int, auntsHex string)
ClaimCount() int
ClaimedAmount(addr string) int64
ClaimInstant(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, amount int64, index int, auntsHex string)
ClaimWindowEnd() int64
CloseCampaign(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string})
EcosystemFund() string
EndHeight() int64
Epoch() int
Forfeit(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, amount int64, index int, auntsHex string)
FundOf(addr string) int64
GrantEpoch(addr string) int
HasClaimed(addr string) bool
HasGrant(addr string) bool
ImmediateBps() int
InstantBps() int
IsOpen() bool
IsSealed() bool
Leaf(addr string, amount int64) string
LeafCount() int
NextWithdrawAt(addr string) int64
Outstanding() int64
RealmAddress() string
Render(path string) string
Root() string
SealCampaign(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string})
SetCampaign(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, rootHex string, total int, end int64)
SettledBurned() int64
SettledToFund() int64
SettleUnclaimed(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string})
TotalBurned() int64
TotalClaimed() int64
TotalGranted() int64
TotalToFund() int64
TransferOwnership(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, newOwner string)
Vested(addr string) int64
VestingEnd() int64
VestingStart() int64
VestingStartOf(addr string) int64
Withdraw(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string})
Withdrawable(addr string) int64
WithdrawCooldown() int64
WithdrawFor(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, beneficiary string)
Withdrawn(addr string) int64
Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.
vm/qrender output, sanitized (docs/render-security.md) and displayed in an empty-sandbox iframe — scripts, forms and popups cannot run. Links stay inert in-preview; right-click to open.