Transaction
FBCFFB41F3F07F…B1B7B72896A9
Block 652,459 · index 1 · indexed
Summary
- Hash
- FBCFFB41F3F07F23207F573FB4D5163FF7322FA912F1F495DD88B1B7B72896A9
- Block
- 652,459
- Size
- 56992 bytes
- Gas used
- 77,930,366 / 190,000,000
- Fee
- 190000 ugnot
- Status
- success
Messages
- Attached funds
- 19000000 ugnot
Arguments · 19
- #1sales
- #2airdrop.gno
- #3package sales import ( "strconv" "strings" "chain" "gno.land/p/samcrew/launchpad/currency/v1" "gno.land/p/samcrew/launchpad/merkle/v1" "gno.land/p/samcrew/launchpad/meta/v1" sm "gno.land/p/samcrew/launchpad/safemath/v1" "gno.land/r/samcrew/launchpad/config/v1" "gno.land/r/samcrew/launchpad/tokens/v1" ) const maxAirdropProofBytes = merkle.MaxDepth*64 + merkle.MaxDepth - 1 // AirdropRecord keeps the whole funded allocation encumbered until recipients // claim. Claims never expire, and nothing sweeps or returns an unclaimed // remainder. type AirdropRecord struct { Root string Total int64 Claimed int64 } func requireAirdropRoot(root string) { h, err := merkle.ParseHash(root) if err != nil || merkle.FormatHash(h) != root || h == (merkle.Hash{}) { panic("launchpad/sales: invalid airdrop root") } } // CreateAirdrop funds one immutable campaign for a token from its creator's // allowance. Its Merkle leaves are sha256(0x00 || // "launchpad-airdrop-v1|<token-id>|<index>|<beneficiary>|<amount>"). // The token ID in every leaf prevents a proof from authorizing a claim on a // different launch, even if two creators publish the same tree shape. func CreateAirdrop(cur realm, id, root string, total int64) { creator := directCaller(cur) currency.AssertNoCoins(0, cur) info := tokens.GetInfo(id) if info.Creator != creator || total <= 0 || airdropOf(id).Total != 0 { panic("launchpad/sales: creator airdrop only") } requireAirdropRoot(root) config.AssertLaneOpen(config.LaneAirdrop, info.CurrencyKey) tokens.TransferFrom(cross(cur), id, creator, chain.PackageAddress(salesPath), total) airdrops.Set(id, AirdropRecord{Root: root, Total: total}) setTokenOwed(id, sm.Add(tokenOwed(id), total)) if tokens.BalanceOf(id, chain.PackageAddress(salesPath)) < tokenOwed(id) { panic("launchpad/sales: token custody shortfall") } chain.Emit("LaunchpadAirdropCreated", "id", id, "creator", creator.String(), "root", root, "total", strconv.FormatInt(total, 10)) } func GetAirdrop(id string) AirdropRecord { r := airdropOf(id) if r.Total == 0 { panic("launchpad/sales: airdrop not found") } return r } func AirdropClaimed(id string, index int) bool { if index < 0 { panic("launchpad/sales: invalid airdrop index") } return airdropClaims.Has(indexKey(id, index)) } // ClaimAirdrop remains available while the airdrop lane is paused. Anyone can // submit a proof; the destination is always the leaf's beneficiary. A leaf // whose beneficiary is not written as the chain writes a caller's address can // never be claimed: the token ledger would credit a balance no caller can // reach. func ClaimAirdrop(cur realm, id string, index int, beneficiary address, amount int64, proof string) { if !cur.IsCurrent() { panic("launchpad/sales: stale realm context") } currency.AssertNoCoins(0, cur) if index < 0 || amount <= 0 || len(proof) > maxAirdropProofBytes || !meta.ValidAddress(beneficiary) || beneficiary == chain.PackageAddress(salesPath) { panic("launchpad/sales: invalid airdrop claim") } r := GetAirdrop(id) key := indexKey(id, index) if airdropClaims.Has(key) { panic("launchpad/sales: airdrop already claimed") } h, _ := merkle.ParseHash(r.Root) p, err := merkle.ParseProof(proof) if err != nil { panic("launchpad/sales: invalid airdrop proof") } if proof != "" { parts := strings.Split(proof, ",") for i, sibling := range p { if merkle.FormatHash(sibling) != parts[i] { panic("launchpad/sales: invalid airdrop proof") } } } leaf := merkle.Leaf([]byte("launchpad-airdrop-v1|" + id + "|" + strconv.Itoa(index) + "|" + beneficiary.String() + "|" + strconv.FormatInt(amount, 10))) if !merkle.Verify(h, leaf, p) || sm.Add(r.Claimed, amount) > r.Total { panic("launchpad/sales: airdrop proof or allocation denied") } r.Claimed = sm.Add(r.Claimed, amount) airdropClaims.Set(key, true) airdrops.Set(id, r) setTokenOwed(id, sm.Sub(tokenOwed(id), amount)) tokens.Transfer(cross(cur), id, beneficiary, amount) if tokens.BalanceOf(id, chain.PackageAddress(salesPath)) < tokenOwed(id) { panic("launchpad/sales: token custody shortfall") } chain.Emit("LaunchpadAirdropClaimed", "id", id, "index", strconv.Itoa(index), "beneficiary", beneficiary.String(), "amount", strconv.FormatInt(amount, 10)) }
- #4allocations.gno
- #5package sales import ( "strconv" "strings" "chain" "gno.land/p/samcrew/launchpad/meta/v1" sm "gno.land/p/samcrew/launchpad/safemath/v1" "gno.land/p/samcrew/launchpad/vesting/v1" ) const ( maxAllocationBytes = 8192 maxAllocations = 50 ) type allocation struct { beneficiary address amount int64 start int64 cliff int64 duration int64 revocable bool } func decimal(s string) int64 { if s == "" || (len(s) > 1 && s[0] == '0') { panic("launchpad/sales: noncanonical allocation decimal") } for i := 0; i < len(s); i++ { if s[i] < '0' || s[i] > '9' { panic("launchpad/sales: noncanonical allocation decimal") } } v, err := strconv.ParseInt(s, 10, 64) if err != nil { panic("launchpad/sales: allocation decimal out of range") } return v } // parseAllocations accepts a single bounded, canonical MsgCall argument. // Each record is address:amount:start:cliff:duration:revocable, with the // address order providing both a stable encoding and duplicate rejection. func parseAllocations(encoded string) ([]allocation, int64) { if len(encoded) > maxAllocationBytes { panic("launchpad/sales: allocations too large") } if encoded == "" { return nil, 0 } records := strings.Split(encoded, ";") if len(records) > maxAllocations { panic("launchpad/sales: too many allocations") } out := make([]allocation, 0, len(records)) var total int64 previous := "" for _, record := range records { fields := strings.Split(record, ":") if len(fields) != 6 || fields[0] <= previous { panic("launchpad/sales: invalid allocation order or fields") } who := address(fields[0]) if !meta.ValidAddress(who) || who == chain.PackageAddress(salesPath) { panic("launchpad/sales: invalid allocation address") } a := allocation{ beneficiary: who, amount: decimal(fields[1]), start: decimal(fields[2]), cliff: decimal(fields[3]), duration: decimal(fields[4]), } if a.amount == 0 || (fields[5] != "0" && fields[5] != "1") { panic("launchpad/sales: invalid allocation amount or revocability") } a.revocable = fields[5] == "1" if a.duration == 0 { if a.start != 0 || a.cliff != 0 || a.revocable { panic("launchpad/sales: invalid immediate allocation") } } else { vesting.Schedule{Total: a.amount, Start: a.start, Cliff: a.cliff, Duration: a.duration}.Validate() } total = sm.Add(total, a.amount) out = append(out, a) previous = fields[0] } return out, total }
- #6eligibility.gno
- #7package sales import ( "strconv" "strings" "gno.land/p/samcrew/launchpad/merkle/v1" "gno.land/r/samcrew/launchpad/tokens/v1" ) const maxFairHoldingGateBytes = 256 const maxFairProofBytes = merkle.MaxDepth*64 + merkle.MaxDepth - 1 type fairHoldingGate struct { TokenID string Minimum int64 } // Gates are at most three canonical, strictly token-ID-ordered records: // token:T<n>:<positive base units>. Collection gates remain unavailable until // the collection realm and its BalanceOf interface are implemented. func parseFairHoldingGates(spec string) []fairHoldingGate { if spec == "" { return nil } if len(spec) > maxFairHoldingGateBytes { panic("launchpad/sales: invalid fair holding gates") } parts := strings.Split(spec, ";") if len(parts) > 3 { panic("launchpad/sales: invalid fair holding gates") } gates := make([]fairHoldingGate, len(parts)) previous := "" for i, part := range parts { fields := strings.Split(part, ":") if len(fields) != 3 || fields[0] != "token" || fields[1] == "" || (previous != "" && fields[1] <= previous) { panic("launchpad/sales: invalid fair holding gates") } minimum, err := strconv.ParseInt(fields[2], 10, 64) if err != nil || minimum <= 0 || strconv.FormatInt(minimum, 10) != fields[2] { panic("launchpad/sales: invalid fair holding gates") } tokens.GetInfo(fields[1]) gates[i] = fairHoldingGate{TokenID: fields[1], Minimum: minimum} previous = fields[1] } return gates } func validateFairEligibility(root, gates string) { if root != "" { h, err := merkle.ParseHash(root) if err != nil || merkle.FormatHash(h) != root || h == (merkle.Hash{}) { panic("launchpad/sales: invalid fair allowlist root") } } parseFairHoldingGates(gates) } // The leaf is sha256(0x00 || "launchpad-fair-v1|<token-id>|<buyer>"). // The token ID prevents a valid proof from being reused for a different sale. func requireFairEligibility(id string, r FairSaleRecord, buyer address, proof string) { if r.AllowlistRoot == "" { if proof != "" { panic("launchpad/sales: fair proof not expected") } } else { if len(proof) > maxFairProofBytes { panic("launchpad/sales: invalid fair allowlist proof") } parsed, err := merkle.ParseProof(proof) if err != nil { panic("launchpad/sales: invalid fair allowlist proof") } if proof != "" { parts := strings.Split(proof, ",") for i, sibling := range parsed { if merkle.FormatHash(sibling) != parts[i] { panic("launchpad/sales: invalid fair allowlist proof") } } } root, _ := merkle.ParseHash(r.AllowlistRoot) leaf := merkle.Leaf([]byte("launchpad-fair-v1|" + id + "|" + buyer.String())) if !merkle.Verify(root, leaf, parsed) { panic("launchpad/sales: fair allowlist denied") } } for _, gate := range parseFairHoldingGates(r.HoldingGates) { if tokens.BalanceOf(gate.TokenID, buyer) < gate.Minimum { panic("launchpad/sales: fair holding gate denied") } } }
- #8fair.gno
- #9package sales import ( "strconv" "strings" "time" "chain" "gno.land/p/samcrew/launchpad/currency/v1" "gno.land/p/samcrew/launchpad/fairmath/v1" "gno.land/p/samcrew/launchpad/meta/v1" sm "gno.land/p/samcrew/launchpad/safemath/v1" "gno.land/r/samcrew/launchpad/config/v1" "gno.land/r/samcrew/launchpad/tokens/v1" ) // FairSaleRecord fixes the sale terms and records funded whole-lot orders. // Settlement splits GrossQuote into the primary fee, owed to the protocol, and // CreatorQuote, owed to the creator: the fee is GrossQuote minus CreatorQuote. // RefundPaid and TokensClaimed count what buyer claims have taken out of this // sale, so no claim can pay more than the sale itself owes. type FairSaleRecord struct { Creator address QuoteCurrency string ConfigVersion int64 PrimaryFeeBPS int64 Allocation int64 LotSize int64 WalletCapLots int64 Caps fairmath.Caps Schedule fairmath.Schedule AllowlistRoot string HoldingGates string TotalLots int64 TotalDeposits int64 HardClosedAt int64 Cancelled bool Settled bool Succeeded bool CloseAt int64 ClosePrice int64 SoldTokens int64 GrossQuote int64 CreatorQuote int64 ProceedsReleased bool RefundQuote int64 UnsoldTokens int64 RefundPaid int64 TokensClaimed int64 } type FairBuyerRecord struct { Lots int64 Deposit int64 Claimed bool } var ( fairEscrowLiability = map[string]int64{} fairRefundLiability = map[string]int64{} fairProceedsLiability = map[string]int64{} ) // openFairSale gates a new sale on the live config, pins its primary fee, // bounds the most quote it can ever hold in escrow, and collects its creation // fee, which it returns. func openFairSale(cur realm, r *FairSaleRecord) int64 { config.AssertNewAction(config.LaneFairSale, r.QuoteCurrency, r.ConfigVersion) p := config.GetVersion(r.ConfigVersion) for _, c := range p.Currencies { if c.Key == r.QuoteCurrency { if sm.Mul(r.Caps.HardLots, r.Schedule.StartPrice) > c.FairSaleRaiseCap { panic("launchpad/sales: fair sale exceeds raise cap") } r.PrimaryFeeBPS = p.PrimaryFeeBPS collectCreationFee(cur, r.QuoteCurrency, c.FairSaleCreationFee) return c.FairSaleCreationFee } } panic("launchpad/sales: currency has no fair-sale terms") } func fairTerms(creator address, saleAllocation, lotSize, hardLots, walletCapLots, softQuote int64, quoteCurrency string, expectedVersion int64, start, end, startPrice, floorPrice, decrement, intervalSeconds int64) FairSaleRecord { if strings.HasPrefix(quoteCurrency, launchpadTokenRegistryPrefix) { panic("launchpad/sales: launchpad token cannot be quote currency") } schedule := fairmath.Schedule{ Start: start, End: end, StartPrice: startPrice, FloorPrice: floorPrice, Decrement: decrement, IntervalSeconds: intervalSeconds, } schedule.Validate() if start <= time.Now().Unix() { panic("launchpad/sales: fair sale must start in the future") } caps := fairmath.Caps{HardLots: hardLots, SoftQuote: softQuote} caps.Validate(saleAllocation, lotSize, startPrice) if walletCapLots <= 0 || walletCapLots > hardLots { panic("launchpad/sales: invalid fair wallet cap") } return FairSaleRecord{ Creator: creator, QuoteCurrency: quoteCurrency, ConfigVersion: expectedVersion, Allocation: saleAllocation, LotSize: lotSize, WalletCapLots: walletCapLots, Caps: caps, Schedule: schedule, } } func emitFairCreated(id, funding string, r FairSaleRecord, fee int64) { chain.Emit("LaunchpadFairSaleCreated", "id", id, "creator", r.Creator.String(), "currency", r.QuoteCurrency, "allocation", strconv.FormatInt(r.Allocation, 10), "lot_size", strconv.FormatInt(r.LotSize, 10), "hard_lots", strconv.FormatInt(r.Caps.HardLots, 10), "wallet_cap_lots", strconv.FormatInt(r.WalletCapLots, 10), "soft_quote", strconv.FormatInt(r.Caps.SoftQuote, 10), "allowlist_root", r.AllowlistRoot, "holding_gates", r.HoldingGates, "primary_fee_bps", strconv.FormatInt(r.PrimaryFeeBPS, 10), "funding", funding, "fee", strconv.FormatInt(fee, 10)) } // CreateFairSale mints a fixed-supply fair-sale token and encumbers its whole- // lot allocation in sales custody. The creator receives the initial supply // remainder. func CreateFairSale(cur realm, name, ticker string, decimals int, initialSupply, saleAllocation, lotSize, hardLots, walletCapLots, softQuote int64, quoteCurrency string, expectedVersion int64, start, end, startPrice, floorPrice, decrement, intervalSeconds int64, description, image, website, xHandle, telegram string) string { return createFairSale(cur, name, ticker, decimals, initialSupply, saleAllocation, lotSize, hardLots, walletCapLots, softQuote, quoteCurrency, expectedVersion, start, end, startPrice, floorPrice, decrement, intervalSeconds, description, image, website, xHandle, telegram, "", "", "") } // CreateGatedFairSale binds a precomputed token ID to a Merkle root and/or // token-holding gates. The expected ID prevents an intervening creation from // silently attaching the root to a different token. func CreateGatedFairSale(cur realm, name, ticker string, decimals int, initialSupply, saleAllocation, lotSize, hardLots, walletCapLots, softQuote int64, quoteCurrency string, expectedVersion int64, start, end, startPrice, floorPrice, decrement, intervalSeconds int64, description, image, website, xHandle, telegram, expectedID, allowlistRoot, holdingGates string) string { return createFairSale(cur, name, ticker, decimals, initialSupply, saleAllocation, lotSize, hardLots, walletCapLots, softQuote, quoteCurrency, expectedVersion, start, end, startPrice, floorPrice, decrement, intervalSeconds, description, image, website, xHandle, telegram, expectedID, allowlistRoot, holdingGates) } func createFairSale(cur realm, name, ticker string, decimals int, initialSupply, saleAllocation, lotSize, hardLots, walletCapLots, softQuote int64, quoteCurrency string, expectedVersion int64, start, end, startPrice, floorPrice, decrement, intervalSeconds int64, description, image, website, xHandle, telegram, expectedID, allowlistRoot, holdingGates string) string { creator := directCaller(cur) r := fairTerms(creator, saleAllocation, lotSize, hardLots, walletCapLots, softQuote, quoteCurrency, expectedVersion, start, end, startPrice, floorPrice, decrement, intervalSeconds) validateFairEligibility(allowlistRoot, holdingGates) if allowlistRoot != "" && expectedID == "" { panic("launchpad/sales: fair allowlist token ID required") } if expectedID != "" && expectedID != "T"+strconv.FormatInt(sm.Add(tokens.Count(), 1), 10) { panic("launchpad/sales: fair token ID changed") } r.AllowlistRoot = allowlistRoot r.HoldingGates = holdingGates if initialSupply <= 0 || saleAllocation > initialSupply { panic("launchpad/sales: fair allocation exceeds supply") } fee := openFairSale(cur, &r) id := tokens.Create(cross(cur), creator, tokens.ModeFairSale, name, ticker, decimals, initialSupply, initialSupply, quoteCurrency, expectedVersion, description, image, website, xHandle, telegram) if expectedID != "" && id != expectedID { panic("launchpad/sales: fair token ID changed") } remainder := sm.Sub(initialSupply, saleAllocation) if remainder > 0 { tokens.Transfer(cross(cur), id, creator, remainder) } fairSales.Set(id, r) setTokenOwed(id, sm.Add(tokenOwed(id), saleAllocation)) if tokens.BalanceOf(id, chain.PackageAddress(salesPath)) != tokenOwed(id) { panic("launchpad/sales: token custody mismatch") } emitFairCreated(id, "minted", r, fee) return id } // CreateFairSaleFromToken locks an existing direct token's exact creator-funded // allocation. Other token liabilities remain encumbered in the same custody // balance. One token can have at most one fair sale. func CreateFairSaleFromToken(cur realm, id string, saleAllocation, lotSize, hardLots, walletCapLots, softQuote int64, quoteCurrency string, expectedVersion int64, start, end, startPrice, floorPrice, decrement, intervalSeconds int64) { createFairSaleFromToken(cur, id, saleAllocation, lotSize, hardLots, walletCapLots, softQuote, quoteCurrency, expectedVersion, start, end, startPrice, floorPrice, decrement, intervalSeconds, "", "") } func CreateGatedFairSaleFromToken(cur realm, id string, saleAllocation, lotSize, hardLots, walletCapLots, softQuote int64, quoteCurrency string, expectedVersion int64, start, end, startPrice, floorPrice, decrement, intervalSeconds int64, allowlistRoot, holdingGates string) { createFairSaleFromToken(cur, id, saleAllocation, lotSize, hardLots, walletCapLots, softQuote, quoteCurrency, expectedVersion, start, end, startPrice, floorPrice, decrement, intervalSeconds, allowlistRoot, holdingGates) } func createFairSaleFromToken(cur realm, id string, saleAllocation, lotSize, hardLots, walletCapLots, softQuote int64, quoteCurrency string, expectedVersion int64, start, end, startPrice, floorPrice, decrement, intervalSeconds int64, allowlistRoot, holdingGates string) { creator := directCaller(cur) info := tokens.GetInfo(id) if info.Creator != creator || (info.Mode != tokens.ModeDirectFixed && info.Mode != tokens.ModeDirectCap) || fairSales.Has(id) { panic("launchpad/sales: existing fair token denied") } r := fairTerms(creator, saleAllocation, lotSize, hardLots, walletCapLots, softQuote, quoteCurrency, expectedVersion, start, end, startPrice, floorPrice, decrement, intervalSeconds) validateFairEligibility(allowlistRoot, holdingGates) r.AllowlistRoot = allowlistRoot r.HoldingGates = holdingGates fee := openFairSale(cur, &r) tokens.TransferFrom(cross(cur), id, creator, chain.PackageAddress(salesPath), saleAllocation) fairSales.Set(id, r) setTokenOwed(id, sm.Add(tokenOwed(id), saleAllocation)) if tokens.BalanceOf(id, chain.PackageAddress(salesPath)) < tokenOwed(id) { panic("launchpad/sales: token custody shortfall") } emitFairCreated(id, "deposited", r, fee) } func GetFairSale(id string) FairSaleRecord { r, ok := fairSales.Get(id).(FairSaleRecord) if !ok { panic("launchpad/sales: fair sale not found") } return r } func GetFairBuyer(id string, buyer address) FairBuyerRecord { if !meta.ValidAddress(buyer) { panic("launchpad/sales: invalid address") } GetFairSale(id) b, _ := fairBuyers.Get(buyerKey(id, buyer)).(FairBuyerRecord) return b } // assertWithinSale runs on every exit of a settled sale. Quote and tokens sit // in balances pooled across sales, and the custody checks compare a balance // with totals only: an exit that overpaid one sale while lowering the totals // by as much would pass them, paid with other sales' money. Settlement // asserted GrossQuote + RefundQuote = TotalDeposits, so with these bounds a // sale never pays out more quote than it took in, nor more tokens than it // sold. Settlement also sets CreatorQuote to GrossQuote minus the fee, so the // last bound cannot fail on a record settlement wrote; it stays as a defence // should a later change break that. func assertWithinSale(r FairSaleRecord) { if r.RefundPaid > r.RefundQuote || r.TokensClaimed > r.SoldTokens || r.CreatorQuote > r.GrossQuote { panic("launchpad/sales: fair sale overpaid") } } // SettleFair closes a sale at its recorded hard-cap fill or scheduled end. // It returns every unsold token to the creator and partitions deposits into // buyer refunds, the creator's proceeds and the protocol's primary fee. The // fee joins the bucket SweepFees pays; nothing is paid out here, so neither // receiver can hold up settlement or buyer claims. func SettleFair(cur realm, id string) { if !cur.IsCurrent() { panic("launchpad/sales: stale realm context") } currency.AssertNoCoins(0, cur) r := GetFairSale(id) if r.Cancelled { panic("launchpad/sales: fair settlement unavailable") } if r.Settled { return } if r.HardClosedAt == 0 && time.Now().Unix() < r.Schedule.End { panic("launchpad/sales: fair settlement unavailable") } closeAt := r.Schedule.End if r.HardClosedAt != 0 { closeAt = r.HardClosedAt } price := r.Schedule.PriceAt(closeAt) result := fairmath.Clear(r.Caps, r.Allocation, r.LotSize, r.TotalLots, r.TotalDeposits, price) if sm.Add(result.GrossQuote, result.RefundQuote) != r.TotalDeposits || sm.Add(result.SoldTokens, result.UnsoldTokens) != r.Allocation { panic("launchpad/sales: fair settlement partition mismatch") } r.Settled = true r.Succeeded = result.Succeeded r.CloseAt = closeAt r.ClosePrice = price r.SoldTokens = result.SoldTokens r.GrossQuote = result.GrossQuote primaryFee := sm.BPS(result.GrossQuote, r.PrimaryFeeBPS) r.CreatorQuote = sm.Sub(result.GrossQuote, primaryFee) r.RefundQuote = result.RefundQuote r.UnsoldTokens = result.UnsoldTokens fairSales.Set(id, r) key := r.QuoteCurrency fairEscrowLiability[key] = sm.Sub(fairEscrowLiability[key], r.TotalDeposits) fairRefundLiability[key] = sm.Add(fairRefundLiability[key], result.RefundQuote) fairProceedsLiability[key] = sm.Add(fairProceedsLiability[key], r.CreatorQuote) feeLiability[key] = sm.Add(feeLiability[key], primaryFee) setTokenOwed(id, sm.Sub(tokenOwed(id), result.UnsoldTokens)) if result.UnsoldTokens > 0 { tokens.Transfer(cross(cur), id, r.Creator, result.UnsoldTokens) } if tokens.BalanceOf(id, chain.PackageAddress(salesPath)) < tokenOwed(id) { panic("launchpad/sales: token custody shortfall") } assertQuoteCovered(cur, key) chain.Emit("LaunchpadFairSettled", "id", id, "currency", key, "succeeded", strconv.FormatBool(result.Succeeded), "close_price", strconv.FormatInt(price, 10), "gross_quote", strconv.FormatInt(result.GrossQuote, 10), "primary_fee_quote", strconv.FormatInt(primaryFee, 10), "creator_quote", strconv.FormatInt(r.CreatorQuote, 10), "refund_quote", strconv.FormatInt(result.RefundQuote, 10), "unsold_tokens", strconv.FormatInt(result.UnsoldTokens, 10)) } // ClaimFair lets anyone release a buyer's recorded tokens and quote refund // to that buyer. Claims never expire and ignore current pause/listing. func ClaimFair(cur realm, id string, buyer address) { if !cur.IsCurrent() { panic("launchpad/sales: stale realm context") } currency.AssertNoCoins(0, cur) r := GetFairSale(id) b := GetFairBuyer(id, buyer) if !r.Settled || b.Lots <= 0 || b.Claimed { panic("launchpad/sales: fair claim unavailable") } claim := fairmath.Claim(r.Succeeded, b.Lots, b.Deposit, r.LotSize, r.ClosePrice) r.RefundPaid = sm.Add(r.RefundPaid, claim.RefundQuote) r.TokensClaimed = sm.Add(r.TokensClaimed, claim.Tokens) assertWithinSale(r) fairSales.Set(id, r) b.Claimed = true fairBuyers.Set(buyerKey(id, buyer), b) key := r.QuoteCurrency fairRefundLiability[key] = sm.Sub(fairRefundLiability[key], claim.RefundQuote) setTokenOwed(id, sm.Sub(tokenOwed(id), claim.Tokens)) if claim.Tokens > 0 { tokens.Transfer(cross(cur), id, buyer, claim.Tokens) } if claim.RefundQuote > 0 { payQuote(cur, key, buyer, claim.RefundQuote) } if tokens.BalanceOf(id, chain.PackageAddress(salesPath)) < tokenOwed(id) { panic("launchpad/sales: token custody shortfall") } assertQuoteCovered(cur, key) chain.Emit("LaunchpadFairClaimed", "id", id, "buyer", buyer.String(), "tokens", strconv.FormatInt(claim.Tokens, 10), "refund_quote", strconv.FormatInt(claim.RefundQuote, 10)) } // ReleaseFairProceeds pays a succeeded sale's creator what settlement // recorded for them, once, and returns the amount. Anyone can call it; the // receiver is always the recorded creator. It stays open while the lane is // paused or the currency delisted. func ReleaseFairProceeds(cur realm, id string) int64 { if !cur.IsCurrent() { panic("launchpad/sales: stale realm context") } currency.AssertNoCoins(0, cur) r := GetFairSale(id) if !r.Settled || !r.Succeeded || r.ProceedsReleased { panic("launchpad/sales: fair proceeds unavailable") } r.ProceedsReleased = true assertWithinSale(r) fairSales.Set(id, r) key := r.QuoteCurrency fairProceedsLiability[key] = sm.Sub(fairProceedsLiability[key], r.CreatorQuote) // A succeeded sale raised at least its soft cap and the fee is at most 5%, // so there is always something to pay. payQuote(cur, key, r.Creator, r.CreatorQuote) assertQuoteCovered(cur, key) chain.Emit("LaunchpadFairProceedsReleased", "id", id, "creator", r.Creator.String(), "currency", key, "creator_quote", strconv.FormatInt(r.CreatorQuote, 10), "actor", cur.Previous().Address().String()) return r.CreatorQuote } // ContributeFair escrows exact quote atoms for whole token lots at this // transaction's schedule price. The direct caller is both payer and buyer, // and maxDeposit is the most the order may cost. A native order attaches // exactly maxDeposit and gets maxDeposit minus the cost back in the same // transaction; a token order pulls only the cost from the buyer's allowance. func ContributeFair(cur realm, id string, lots, maxDeposit int64) { contributeFair(cur, id, lots, maxDeposit, "") } // ContributeFairWithProof verifies the recorded allowlist and holding gates // against the direct buyer's live address and balances before collecting quote. func ContributeFairWithProof(cur realm, id string, lots, maxDeposit int64, proof string) { contributeFair(cur, id, lots, maxDeposit, proof) } func contributeFair(cur realm, id string, lots, maxDeposit int64, proof string) { buyer := directCaller(cur) r := GetFairSale(id) now := time.Now().Unix() if r.Cancelled || r.Settled || r.HardClosedAt != 0 || now < r.Schedule.Start || now >= r.Schedule.End { panic("launchpad/sales: fair sale closed") } // The sale's own terms are pinned in its record and the buyer bounds the // price with maxDeposit, so no config version is involved here. config.AssertLaneOpen(config.LaneFairSale, r.QuoteCurrency) if lots <= 0 || maxDeposit <= 0 { panic("launchpad/sales: invalid fair order") } b := GetFairBuyer(id, buyer) nextBuyerLots := sm.Add(b.Lots, lots) if nextBuyerLots > r.WalletCapLots { panic("launchpad/sales: fair wallet cap exceeded") } requireFairEligibility(id, r, buyer, proof) nextTotalLots, hardClosed := r.Caps.AddLots(r.TotalLots, lots) order := fairmath.QuoteOrder(lots, r.LotSize, r.Schedule.PriceAt(now)) if order.Deposit > maxDeposit { panic("launchpad/sales: fair quote exceeds buyer bound") } if r.QuoteCurrency == config.NativeCurrency { // Coins are attached when the buyer signs, and a falling price can step // down before the block: the buyer attaches its bound, exactly, and gets // back at once what the order does not cost. currency.CollectNative(0, cur, maxDeposit) if change := maxDeposit - order.Deposit; change > 0 { currency.PayNative(0, cur, buyer, change) } } else { collectQuote(cur, r.QuoteCurrency, order.Deposit) } b.Lots = nextBuyerLots b.Deposit = sm.Add(b.Deposit, order.Deposit) fairBuyers.Set(buyerKey(id, buyer), b) r.TotalLots = nextTotalLots r.TotalDeposits = sm.Add(r.TotalDeposits, order.Deposit) if hardClosed { r.HardClosedAt = now } fairSales.Set(id, r) fairEscrowLiability[r.QuoteCurrency] = sm.Add(fairEscrowLiability[r.QuoteCurrency], order.Deposit) // The admin's brake on what the realm may hold in a currency. Only an // inflow reads it; exits never do. if owedIn(r.QuoteCurrency) > config.GetSalesOwedCeiling(r.QuoteCurrency) { panic("launchpad/sales: sales owed ceiling reached") } assertQuoteCovered(cur, r.QuoteCurrency) chain.Emit("LaunchpadFairContribution", "id", id, "buyer", buyer.String(), "lots", strconv.FormatInt(lots, 10), "price", strconv.FormatInt(order.Deposit/lots, 10), "deposit", strconv.FormatInt(order.Deposit, 10), "total_lots", strconv.FormatInt(r.TotalLots, 10)) if hardClosed { chain.Emit("LaunchpadFairHardClosed", "id", id, "closed_at", strconv.FormatInt(now, 10)) } } // CancelFairSale returns the entire unsold allocation to its creator while // there are no contributions. It remains callable if the new-sale lane is // paused or the quote currency has since been delisted. The creation fee is // not returned. func CancelFairSale(cur realm, id string) { creator := directCaller(cur) currency.AssertNoCoins(0, cur) r := GetFairSale(id) if r.Creator != creator || r.Cancelled || r.Settled || r.TotalLots != 0 || r.TotalDeposits != 0 { panic("launchpad/sales: fair cancellation denied") } r.Cancelled = true fairSales.Set(id, r) setTokenOwed(id, sm.Sub(tokenOwed(id), r.Allocation)) tokens.Transfer(cross(cur), id, creator, r.Allocation) if tokens.BalanceOf(id, chain.PackageAddress(salesPath)) < tokenOwed(id) { panic("launchpad/sales: token custody shortfall") } chain.Emit("LaunchpadFairSaleCancelled", "id", id, "creator", creator.String(), "returned", strconv.FormatInt(r.Allocation, 10)) }
- #10gnomod.toml
- #11module = "gno.land/r/samcrew/launchpad/sales/v1" gno = "0.9"
- #12sales.gno
- #13// Package sales owns launch token custody, direct-creation allocations, fair // sales and airdrops. Quote that reaches it through its entry points is owed // to someone on record: buyers (escrow and refunds), creators (settled // proceeds) or the protocol (fees). Coins sent to it any other way are owed // to no one and never leave. It has no admin and no emergency withdrawal path. package sales import ( "strconv" "strings" "time" "chain" "chain/banker" "gno.land/p/samcrew/launchpad/currency/v1" "gno.land/p/samcrew/launchpad/meta/v1" sm "gno.land/p/samcrew/launchpad/safemath/v1" "gno.land/p/samcrew/launchpad/vesting/v1" "gno.land/r/nt/grc20reg/v0" "gno.land/r/samcrew/launchpad/config/v1" "gno.land/r/samcrew/launchpad/tokens/v1" ) const salesPath = "gno.land/r/samcrew/launchpad/sales/v1" const launchpadTokenRegistryPrefix = "gno.land/r/samcrew/launchpad/tokens/v1.T" type VestingRecord struct { Beneficiary address PendingBeneficiary address Total int64 Claimed int64 Start int64 Cliff int64 Duration int64 Revocable bool Revoked bool RevokedVested int64 } var feeLiability = map[string]int64{} func directCaller(cur realm) address { if !cur.IsCurrent() || !cur.Previous().IsUserCall() { panic("launchpad/sales: direct user call required") } return cur.Previous().Address() } func directFee(version int64, key string) int64 { p := config.GetVersion(version) for _, c := range p.Currencies { if c.Key == key { if c.DirectCreationFee < 0 { panic("launchpad/sales: direct fee disabled") } return c.DirectCreationFee } } panic("launchpad/sales: currency has no direct terms") } func collectCreationFee(cur realm, key string, amount int64) { // A Launchpad token held here for vesting, airdrops or sales cannot also // back a fee or quote liability without a combined same-asset ledger. if strings.HasPrefix(key, launchpadTokenRegistryPrefix) { panic("launchpad/sales: launchpad token cannot be quote currency") } if amount == 0 { currency.AssertNoCoins(0, cur) return } collectQuote(cur, key, amount) feeLiability[key] = sm.Add(feeLiability[key], amount) assertQuoteCovered(cur, key) } // collectQuote takes amount from the immediate caller, as attached coins or // from a token allowance. Both routes pay from that caller and no one else. func collectQuote(cur realm, key string, amount int64) { if key == config.NativeCurrency { currency.CollectNative(0, cur, amount) } else { currency.CollectToken(0, cur, grc20reg.MustGet(key), amount) } } func quoteBalance(self address, key string) int64 { if key == config.NativeCurrency { return banker.NewReadonlyBanker().GetCoin(self, key) } return grc20reg.MustGet(key).BalanceOf(self) } // owedIn is everything the realm owes in one currency: fees, escrow, refunds // and proceeds. func owedIn(key string) int64 { owed := sm.Add(feeLiability[key], fairEscrowLiability[key]) owed = sm.Add(owed, fairRefundLiability[key]) return sm.Add(owed, fairProceedsLiability[key]) } // assertQuoteCovered follows every movement of quote: the realm must still // hold everything it owes in that currency. An exit ends with it; a creation // runs it right after collecting its fee. func assertQuoteCovered(cur realm, key string) { if quoteBalance(cur.Address(), key) < owedIn(key) { panic("launchpad/sales: quote custody shortfall") } } func payQuote(cur realm, key string, to address, amount int64) { if key == config.NativeCurrency { currency.PayNative(0, cur, to, amount) } else { currency.PayToken(0, cur, grc20reg.MustGet(key), to, amount) } } // SweepFees pays every fee recorded in one currency, creation fees and // fair-sale primary fees alike, to the treasury of the current config, and // returns the amount. Anyone can call it; the caller chooses neither the // receiver nor the amount. A fee is only ever recorded once a treasury is // named, and config never lets the treasury be removed. func SweepFees(cur realm, currencyKey string) int64 { if !cur.IsCurrent() { panic("launchpad/sales: stale realm context") } currency.AssertNoCoins(0, cur) amount := feeLiability[currencyKey] if amount <= 0 { panic("launchpad/sales: no fees to sweep") } treasury := config.GetVersion(config.GetCurrentVersion()).Treasury // Config refuses to name this realm as treasury. If it ever did, a native // sweep would pay the realm itself: the fees would stop being owed and // stay here for good, and anyone could trigger it. So it is refused here // as well. if treasury == cur.Address() { panic("launchpad/sales: treasury is this realm") } feeLiability[currencyKey] = 0 payQuote(cur, currencyKey, treasury, amount) assertQuoteCovered(cur, currencyKey) chain.Emit("LaunchpadFeesSwept", "currency", currencyKey, "treasury", treasury.String(), "amount", strconv.FormatInt(amount, 10), "actor", cur.Previous().Address().String()) return amount } // CreateDirect creates a direct token without an initial airdrop. The // allocation string is parsed and the whole amount partitioned before any // payment. The caller, payer and initial mint's creator are the same live // direct user. func CreateDirect(cur realm, mode, name, ticker string, decimals int, initialSupply, maxSupply int64, currencyKey string, expectedVersion int64, description, image, website, xHandle, telegram, allocations string) string { return createDirect(cur, mode, name, ticker, decimals, initialSupply, maxSupply, currencyKey, expectedVersion, description, image, website, xHandle, telegram, allocations, "", "", 0) } // CreateDirectWithAirdrop reserves part of the initial mint for one Merkle // campaign. The caller commits to the next token ID used in every leaf. If // another launch takes that ID first, this transaction rejects before payment. func CreateDirectWithAirdrop(cur realm, mode, name, ticker string, decimals int, initialSupply, maxSupply int64, currencyKey string, expectedVersion int64, description, image, website, xHandle, telegram, allocations, expectedID, airdropRoot string, airdropTotal int64) string { return createDirect(cur, mode, name, ticker, decimals, initialSupply, maxSupply, currencyKey, expectedVersion, description, image, website, xHandle, telegram, allocations, expectedID, airdropRoot, airdropTotal) } func createDirect(cur realm, mode, name, ticker string, decimals int, initialSupply, maxSupply int64, currencyKey string, expectedVersion int64, description, image, website, xHandle, telegram, allocations, expectedID, airdropRoot string, airdropTotal int64) string { creator := directCaller(cur) if mode != tokens.ModeDirectFixed && mode != tokens.ModeDirectCap { panic("launchpad/sales: direct mode required") } if airdropTotal > 0 { requireAirdropRoot(airdropRoot) nextID := "T" + strconv.FormatInt(sm.Add(tokens.Count(), 1), 10) if expectedID != nextID { panic("launchpad/sales: airdrop token ID changed") } } else if airdropTotal != 0 || expectedID != "" || airdropRoot != "" { panic("launchpad/sales: invalid airdrop allocation") } parsed, allocated := parseAllocations(allocations) committed := sm.Add(allocated, airdropTotal) if initialSupply <= 0 || committed > initialSupply { panic("launchpad/sales: allocations exceed supply") } config.AssertNewAction(config.LaneDirect, currencyKey, expectedVersion) if airdropTotal > 0 { config.AssertNewAction(config.LaneAirdrop, currencyKey, expectedVersion) } fee := directFee(expectedVersion, currencyKey) collectCreationFee(cur, currencyKey, fee) id := tokens.Create(cross(cur), creator, mode, name, ticker, decimals, initialSupply, maxSupply, currencyKey, expectedVersion, description, image, website, xHandle, telegram) if airdropTotal > 0 && id != expectedID { panic("launchpad/sales: airdrop token ID changed") } retained := airdropTotal for _, a := range parsed { if a.duration == 0 { tokens.Transfer(cross(cur), id, a.beneficiary, a.amount) continue } appendVesting(id, VestingRecord{ Beneficiary: a.beneficiary, Total: a.amount, Start: a.start, Cliff: a.cliff, Duration: a.duration, Revocable: a.revocable, }) retained = sm.Add(retained, a.amount) } if airdropTotal > 0 { airdrops.Set(id, AirdropRecord{Root: airdropRoot, Total: airdropTotal}) chain.Emit("LaunchpadAirdropCreated", "id", id, "creator", creator.String(), "root", airdropRoot, "total", strconv.FormatInt(airdropTotal, 10)) } remainder := sm.Sub(initialSupply, committed) if remainder > 0 { tokens.Transfer(cross(cur), id, creator, remainder) } setTokenOwed(id, retained) if tokens.BalanceOf(id, chain.PackageAddress(salesPath)) != retained { panic("launchpad/sales: token custody mismatch") } chain.Emit("LaunchpadDirectCreated", "id", id, "creator", creator.String(), "currency", currencyKey, "fee", strconv.FormatInt(fee, 10), "retained", strconv.FormatInt(retained, 10)) return id } // MintCappedAllocated requires the token's current mint authority to be the // immediate user or realm caller. Every new unit is assigned to an immediate or vested // allocation; there is no implicit receiver or unrecorded sales balance. // No additional creation fee is charged, and attached native coins are // rejected. Pausing the direct lane stops new mints without blocking claims. func MintCappedAllocated(cur realm, id string, amount int64, allocations string) { if !cur.IsCurrent() { panic("launchpad/sales: stale realm context") } currency.AssertNoCoins(0, cur) previous := cur.Previous() authority := previous.Address() if !authority.IsValid() || (!previous.IsUserCall() && (previous.PkgPath() == "" || authority != chain.PackageAddress(previous.PkgPath()))) { panic("launchpad/sales: immediate authority call required") } parsed, allocated := parseAllocations(allocations) if amount <= 0 || allocated != amount { panic("launchpad/sales: capped mint must be fully allocated") } // The original creator owns existing revocation rights. A successor mint // authority must not create a schedule the former authority can claw back. for _, a := range parsed { if a.revocable { panic("launchpad/sales: later mint vesting cannot be revocable") } } info := tokens.GetInfo(id) if info.Mode != tokens.ModeDirectCap || info.MintRenounced || info.MintAuthority != authority { panic("launchpad/sales: current mint authority only") } config.AssertLaneOpen(config.LaneDirect, info.CurrencyKey) tokens.MintCapped(cross(cur), id, authority, amount) retained := int64(0) for _, a := range parsed { if a.duration == 0 { tokens.Transfer(cross(cur), id, a.beneficiary, a.amount) continue } appendVesting(id, VestingRecord{ Beneficiary: a.beneficiary, Total: a.amount, Start: a.start, Cliff: a.cliff, Duration: a.duration, Revocable: a.revocable, }) retained = sm.Add(retained, a.amount) } setTokenOwed(id, sm.Add(tokenOwed(id), retained)) if tokens.BalanceOf(id, chain.PackageAddress(salesPath)) < tokenOwed(id) { panic("launchpad/sales: token custody shortfall") } chain.Emit("LaunchpadCappedMintAllocated", "id", id, "authority", authority.String(), "amount", strconv.FormatInt(amount, 10), "retained", strconv.FormatInt(retained, 10)) } func VestingCount(id string) int { return vestingCount(id) } func GetVesting(id string, index int) VestingRecord { if index < 0 || index >= vestingCount(id) { panic("launchpad/sales: vesting record not found") } return vestingAt(id, index) } func TokenLiability(id string) int64 { return tokenOwed(id) } // ClaimVested remains open even when the direct-launch lane is paused. Anyone // may trigger release, but the destination is always the recorded beneficiary. // This also permits a realm beneficiary to receive its vested tokens. func ClaimVested(cur realm, id string, index int) int64 { if !cur.IsCurrent() { panic("launchpad/sales: stale realm context") } currency.AssertNoCoins(0, cur) if index < 0 || index >= vestingCount(id) { panic("launchpad/sales: vesting record not found") } r := vestingAt(id, index) if r.PendingBeneficiary != "" { panic("launchpad/sales: beneficiary move pending") } s := vesting.Schedule{Total: r.Total, Start: r.Start, Cliff: r.Cliff, Duration: r.Duration} amount := int64(0) if r.Revoked { amount = sm.Sub(r.RevokedVested, r.Claimed) } else { amount = s.Releasable(time.Now().Unix(), r.Claimed) } if amount <= 0 { panic("launchpad/sales: nothing vested to claim") } r.Claimed = sm.Add(r.Claimed, amount) setVesting(id, index, r) setTokenOwed(id, sm.Sub(tokenOwed(id), amount)) tokens.Transfer(cross(cur), id, r.Beneficiary, amount) if tokens.BalanceOf(id, chain.PackageAddress(salesPath)) < tokenOwed(id) { panic("launchpad/sales: token custody shortfall") } chain.Emit("LaunchpadVestingClaimed", "id", id, "index", strconv.Itoa(index), "beneficiary", r.Beneficiary.String(), "actor", cur.Previous().Address().String(), "amount", strconv.FormatInt(amount, 10)) return amount } // RevokeVesting returns only unvested tokens to the original creator. The // beneficiary retains its accrued vested entitlement and claim right. func RevokeVesting(cur realm, id string, index int) int64 { creator := directCaller(cur) currency.AssertNoCoins(0, cur) if tokens.GetInfo(id).Creator != creator || index < 0 || index >= vestingCount(id) { panic("launchpad/sales: creator vesting only") } r := vestingAt(id, index) if !r.Revocable || r.Revoked { panic("launchpad/sales: vesting is not revocable") } s := vesting.Schedule{Total: r.Total, Start: r.Start, Cliff: r.Cliff, Duration: r.Duration} vested := s.VestedAt(time.Now().Unix()) unvested := sm.Sub(r.Total, vested) r.Revoked = true r.RevokedVested = vested setVesting(id, index, r) setTokenOwed(id, sm.Sub(tokenOwed(id), unvested)) if unvested > 0 { tokens.Transfer(cross(cur), id, creator, unvested) } if tokens.BalanceOf(id, chain.PackageAddress(salesPath)) < tokenOwed(id) { panic("launchpad/sales: token custody shortfall") } chain.Emit("LaunchpadVestingRevoked", "id", id, "index", strconv.Itoa(index), "creator", creator.String(), "unvested", strconv.FormatInt(unvested, 10), "vested", strconv.FormatInt(vested, 10)) return unvested } func vestingCaller(cur realm) address { if !cur.IsCurrent() { panic("launchpad/sales: stale realm context") } currency.AssertNoCoins(0, cur) return cur.Previous().Address() } // ProposeVestingBeneficiary is callable by the current beneficiary, including // a beneficiary realm. A pending move freezes claims until acceptance or // cancellation, so the old beneficiary cannot drain accrued entitlement. func ProposeVestingBeneficiary(cur realm, id string, index int, next address) { who := vestingCaller(cur) r := GetVesting(id, index) if who != r.Beneficiary || !meta.ValidAddress(next) || next == who || next == chain.PackageAddress(salesPath) { panic("launchpad/sales: beneficiary proposal denied") } r.PendingBeneficiary = next setVesting(id, index, r) chain.Emit("LaunchpadVestingBeneficiaryProposed", "id", id, "index", strconv.Itoa(index), "from", who.String(), "to", next.String()) } func CancelVestingBeneficiary(cur realm, id string, index int) { who := vestingCaller(cur) r := GetVesting(id, index) if who != r.Beneficiary || r.PendingBeneficiary == "" { panic("launchpad/sales: beneficiary cancellation denied") } r.PendingBeneficiary = "" setVesting(id, index, r) chain.Emit("LaunchpadVestingBeneficiaryCancelled", "id", id, "index", strconv.Itoa(index), "beneficiary", who.String()) } func AcceptVestingBeneficiary(cur realm, id string, index int) { who := vestingCaller(cur) r := GetVesting(id, index) if r.PendingBeneficiary == "" || who != r.PendingBeneficiary { panic("launchpad/sales: pending beneficiary only") } old := r.Beneficiary r.Beneficiary = who r.PendingBeneficiary = "" setVesting(id, index, r) chain.Emit("LaunchpadVestingBeneficiaryAccepted", "id", id, "index", strconv.Itoa(index), "from", old.String(), "to", who.String()) }
- #14store.gno
- #15package sales import ( "strconv" "gno.land/p/nt/avl/v0" ) // Per-token, per-buyer and per-claim state lives in AVL trees: a write touches // one entry and the path to it. A realm-level Go map is stored as one object, // so each write would cost gas in proportion to everything the map holds, and // a busy sale could price its own buyers out of their claims. The four // per-currency liabilities stay maps: they hold one entry per currency, and // only the config admin gives a currency terms. var ( vestings = avl.NewTree() // "<token id>:<index>" -> VestingRecord vestingCounts = avl.NewTree() // token id -> int tokenLiabilities = avl.NewTree() // token id -> int64 airdrops = avl.NewTree() // token id -> AirdropRecord airdropClaims = avl.NewTree() // "<token id>:<index>" -> true fairSales = avl.NewTree() // token id -> FairSaleRecord fairBuyers = avl.NewTree() // "<token id>:<buyer>" -> FairBuyerRecord ) // A token id is "T" and digits, so ":" separates it from what follows. func indexKey(id string, index int) string { return id + ":" + strconv.Itoa(index) } func buyerKey(id string, buyer address) string { return id + ":" + string(buyer) } func vestingCount(id string) int { n, _ := vestingCounts.Get(id).(int) return n } // vestingAt returns record index of a token, which the caller has checked // to be below vestingCount. func vestingAt(id string, index int) VestingRecord { return vestings.Get(indexKey(id, index)).(VestingRecord) } func setVesting(id string, index int, r VestingRecord) { vestings.Set(indexKey(id, index), r) } func appendVesting(id string, r VestingRecord) { n := vestingCount(id) vestings.Set(indexKey(id, n), r) vestingCounts.Set(id, n+1) } func tokenOwed(id string) int64 { v, _ := tokenLiabilities.Get(id).(int64) return v } // A token that is owed nothing has no entry. func setTokenOwed(id string, amount int64) { if amount == 0 { tokenLiabilities.Remove(id) return } tokenLiabilities.Set(id, amount) } // airdropOf returns the zero record for a token without a campaign. func airdropOf(id string) AirdropRecord { r, _ := airdrops.Get(id).(AirdropRecord) return r }
- #16views.gno
- #17package sales import ( "strconv" "chain" "gno.land/p/samcrew/launchpad/fairmath/v1" "gno.land/p/samcrew/launchpad/meta/v1" "gno.land/r/samcrew/launchpad/config/v1" "gno.land/r/samcrew/launchpad/tokens/v1" ) // Read views for launch clients. Every int64, base units and timestamps alike, // is a decimal string so JavaScript keeps it exact; booleans stay booleans and // counts that fit in an int are numbers. They change no state. func jsonInt(v int64) string { return meta.JSONString(strconv.FormatInt(v, 10)) } // fairRecordJSON reports a sale's terms and outcome. Once settled, the primary // fee is what was raised minus the creator's share. func fairRecordJSON(r FairSaleRecord) string { s := r.Schedule return "{" + `"creator":` + meta.JSONString(r.Creator.String()) + `,"quoteCurrency":` + meta.JSONString(r.QuoteCurrency) + `,"configVersion":` + jsonInt(r.ConfigVersion) + `,"primaryFeeBps":` + jsonInt(r.PrimaryFeeBPS) + `,"allocation":` + jsonInt(r.Allocation) + `,"lotSize":` + jsonInt(r.LotSize) + `,"walletCapLots":` + jsonInt(r.WalletCapLots) + `,"hardCapLots":` + jsonInt(r.Caps.HardLots) + `,"softCapQuote":` + jsonInt(r.Caps.SoftQuote) + `,"start":` + jsonInt(s.Start) + `,"end":` + jsonInt(s.End) + `,"startPrice":` + jsonInt(s.StartPrice) + `,"floorPrice":` + jsonInt(s.FloorPrice) + `,"decrement":` + jsonInt(s.Decrement) + `,"intervalSeconds":` + jsonInt(s.IntervalSeconds) + `,"allowlistRoot":` + meta.JSONString(r.AllowlistRoot) + `,"holdingGates":` + meta.JSONString(r.HoldingGates) + `,"totalLots":` + jsonInt(r.TotalLots) + `,"totalDeposits":` + jsonInt(r.TotalDeposits) + `,"hardClosedAt":` + jsonInt(r.HardClosedAt) + `,"cancelled":` + strconv.FormatBool(r.Cancelled) + `,"settled":` + strconv.FormatBool(r.Settled) + `,"succeeded":` + strconv.FormatBool(r.Succeeded) + `,"closeAt":` + jsonInt(r.CloseAt) + `,"closePrice":` + jsonInt(r.ClosePrice) + `,"soldTokens":` + jsonInt(r.SoldTokens) + `,"grossQuote":` + jsonInt(r.GrossQuote) + `,"primaryFeeQuote":` + jsonInt(r.GrossQuote-r.CreatorQuote) + `,"creatorQuote":` + jsonInt(r.CreatorQuote) + `,"proceedsReleased":` + strconv.FormatBool(r.ProceedsReleased) + `,"refundQuote":` + jsonInt(r.RefundQuote) + `,"unsoldTokens":` + jsonInt(r.UnsoldTokens) + `,"refundPaid":` + jsonInt(r.RefundPaid) + `,"tokensClaimed":` + jsonInt(r.TokensClaimed) + "}" } func airdropRecordJSON(r AirdropRecord) string { return "{" + `"root":` + meta.JSONString(r.Root) + `,"total":` + jsonInt(r.Total) + `,"claimed":` + jsonInt(r.Claimed) + "}" } // TermsJSON is what launching a token in a currency costs under the current // config version: the fee a direct creation and a fair-sale creation attach, // exactly; the most one fair sale may raise; and the primary fee a new sale // pins. A fee of -1 means the currency has no terms. A creation names this // version, so if the terms change before it lands it fails with "config // changed" and takes nothing. Whether a lane takes new launches now is // config's ActionStatusJSON. func TermsJSON(currencyKey string) string { if currencyKey == "" || len(currencyKey) > 200 { panic("launchpad/sales: invalid currency") } version := config.GetCurrentVersion() p := config.GetVersion(version) direct, fair, raiseCap := int64(-1), int64(-1), int64(0) for _, c := range p.Currencies { if c.Key == currencyKey { direct, fair, raiseCap = c.DirectCreationFee, c.FairSaleCreationFee, c.FairSaleRaiseCap } } return "{" + `"schema":"launchpad-sales-terms-v1"` + `,"version":` + jsonInt(version) + `,"currency":` + meta.JSONString(currencyKey) + `,"directCreationFee":` + jsonInt(direct) + `,"fairSaleCreationFee":` + jsonInt(fair) + `,"fairSaleRaiseCap":` + jsonInt(raiseCap) + `,"primaryFeeBps":` + jsonInt(p.PrimaryFeeBPS) + "}" } // SolvencyJSON is the realm's books in one currency: the balance it holds and // what it owes, in total and by liability. fees is what SweepFees would pay // now. Every movement of quote ends with balance >= owed, so a watcher that // sees otherwise has found a fault; what the balance holds above owed was // sent outside the entry points and is owed to no one. An empty key or one // over 200 bytes panics "invalid currency"; a key that is neither ugnot nor a // registered GRC20 aborts in the registry ("unknown token"). func SolvencyJSON(currencyKey string) string { if currencyKey == "" || len(currencyKey) > 200 { panic("launchpad/sales: invalid currency") } return "{" + `"schema":"launchpad-sales-solvency-v1"` + `,"currency":` + meta.JSONString(currencyKey) + `,"balance":` + jsonInt(quoteBalance(chain.PackageAddress(salesPath), currencyKey)) + `,"owed":` + jsonInt(owedIn(currencyKey)) + `,"fees":` + jsonInt(feeLiability[currencyKey]) + `,"escrow":` + jsonInt(fairEscrowLiability[currencyKey]) + `,"refunds":` + jsonInt(fairRefundLiability[currencyKey]) + `,"proceeds":` + jsonInt(fairProceedsLiability[currencyKey]) + "}" } // LaunchJSON is a token's launch state: the ledger's record of the token, what // sales still owes in it, how many vesting records it has, and its fair sale // and airdrop campaign, each null when the token has none. func LaunchJSON(id string) string { token := tokens.TokenJSON(id) fair := "null" if r, ok := fairSales.Get(id).(FairSaleRecord); ok { fair = fairRecordJSON(r) } airdrop := "null" if r, ok := airdrops.Get(id).(AirdropRecord); ok { airdrop = airdropRecordJSON(r) } return "{" + `"schema":"launchpad-sales-v1"` + `,"token":` + token + `,"tokenLiability":` + jsonInt(tokenOwed(id)) + `,"vestingCount":` + strconv.Itoa(vestingCount(id)) + `,"fairSale":` + fair + `,"airdrop":` + airdrop + "}" } // FairBuyerJSON is one buyer's record in a sale and, once the sale is settled // and until the buyer is paid, what ClaimFair would send them. Before // settlement both amounts are 0 and settled is false: settle first. func FairBuyerJSON(id string, buyer address) string { r := GetFairSale(id) b := GetFairBuyer(id, buyer) claimTokens, claimRefund := int64(0), int64(0) if r.Settled && b.Lots > 0 && !b.Claimed { c := fairmath.Claim(r.Succeeded, b.Lots, b.Deposit, r.LotSize, r.ClosePrice) claimTokens, claimRefund = c.Tokens, c.RefundQuote } return "{" + `"buyer":` + meta.JSONString(buyer.String()) + `,"lots":` + jsonInt(b.Lots) + `,"deposit":` + jsonInt(b.Deposit) + `,"claimableTokens":` + jsonInt(claimTokens) + `,"claimableRefund":` + jsonInt(claimRefund) + `,"settled":` + strconv.FormatBool(r.Settled) + `,"claimed":` + strconv.FormatBool(b.Claimed) + "}" } // VestingJSON is one vesting record: its schedule, who holds the claim right // and who has been proposed to take it. func VestingJSON(id string, index int) string { r := GetVesting(id, index) return "{" + `"index":` + strconv.Itoa(index) + `,"beneficiary":` + meta.JSONString(r.Beneficiary.String()) + `,"pendingBeneficiary":` + meta.JSONString(r.PendingBeneficiary.String()) + `,"total":` + jsonInt(r.Total) + `,"claimed":` + jsonInt(r.Claimed) + `,"start":` + jsonInt(r.Start) + `,"cliff":` + jsonInt(r.Cliff) + `,"duration":` + jsonInt(r.Duration) + `,"revocable":` + strconv.FormatBool(r.Revocable) + `,"revoked":` + strconv.FormatBool(r.Revoked) + `,"revokedVested":` + jsonInt(r.RevokedVested) + "}" }
- #18/gno.MemPackageType
- #19 MPUserAll
Result log
msg:0,success:true,log:,events:[]