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

fair.gno

20.32 Kb · 516 lines
  1package sales
  2
  3import (
  4	"strconv"
  5	"strings"
  6	"time"
  7
  8	"chain"
  9	"gno.land/p/samcrew/launchpad/currency/v1"
 10	"gno.land/p/samcrew/launchpad/fairmath/v1"
 11	"gno.land/p/samcrew/launchpad/meta/v1"
 12	sm "gno.land/p/samcrew/launchpad/safemath/v1"
 13	"gno.land/r/samcrew/launchpad/config/v1"
 14	"gno.land/r/samcrew/launchpad/tokens/v1"
 15)
 16
 17// FairSaleRecord fixes the sale terms and records funded whole-lot orders.
 18// Settlement splits GrossQuote into the primary fee, owed to the protocol, and
 19// CreatorQuote, owed to the creator: the fee is GrossQuote minus CreatorQuote.
 20// RefundPaid and TokensClaimed count what buyer claims have taken out of this
 21// sale, so no claim can pay more than the sale itself owes.
 22type FairSaleRecord struct {
 23	Creator          address
 24	QuoteCurrency    string
 25	ConfigVersion    int64
 26	PrimaryFeeBPS    int64
 27	Allocation       int64
 28	LotSize          int64
 29	WalletCapLots    int64
 30	Caps             fairmath.Caps
 31	Schedule         fairmath.Schedule
 32	AllowlistRoot    string
 33	HoldingGates     string
 34	TotalLots        int64
 35	TotalDeposits    int64
 36	HardClosedAt     int64
 37	Cancelled        bool
 38	Settled          bool
 39	Succeeded        bool
 40	CloseAt          int64
 41	ClosePrice       int64
 42	SoldTokens       int64
 43	GrossQuote       int64
 44	CreatorQuote     int64
 45	ProceedsReleased bool
 46	RefundQuote      int64
 47	UnsoldTokens     int64
 48	RefundPaid       int64
 49	TokensClaimed    int64
 50}
 51
 52type FairBuyerRecord struct {
 53	Lots    int64
 54	Deposit int64
 55	Claimed bool
 56}
 57
 58var (
 59	fairEscrowLiability   = map[string]int64{}
 60	fairRefundLiability   = map[string]int64{}
 61	fairProceedsLiability = map[string]int64{}
 62)
 63
 64// openFairSale gates a new sale on the live config, pins its primary fee,
 65// bounds the most quote it can ever hold in escrow, and collects its creation
 66// fee, which it returns.
 67func openFairSale(cur realm, r *FairSaleRecord) int64 {
 68	config.AssertNewAction(config.LaneFairSale, r.QuoteCurrency, r.ConfigVersion)
 69	p := config.GetVersion(r.ConfigVersion)
 70	for _, c := range p.Currencies {
 71		if c.Key == r.QuoteCurrency {
 72			if sm.Mul(r.Caps.HardLots, r.Schedule.StartPrice) > c.FairSaleRaiseCap {
 73				panic("launchpad/sales: fair sale exceeds raise cap")
 74			}
 75			r.PrimaryFeeBPS = p.PrimaryFeeBPS
 76			collectCreationFee(cur, r.QuoteCurrency, c.FairSaleCreationFee)
 77			return c.FairSaleCreationFee
 78		}
 79	}
 80	panic("launchpad/sales: currency has no fair-sale terms")
 81}
 82
 83func fairTerms(creator address, saleAllocation, lotSize, hardLots,
 84	walletCapLots, softQuote int64, quoteCurrency string, expectedVersion int64,
 85	start, end, startPrice, floorPrice, decrement, intervalSeconds int64) FairSaleRecord {
 86	if strings.HasPrefix(quoteCurrency, launchpadTokenRegistryPrefix) {
 87		panic("launchpad/sales: launchpad token cannot be quote currency")
 88	}
 89	schedule := fairmath.Schedule{
 90		Start: start, End: end, StartPrice: startPrice,
 91		FloorPrice: floorPrice, Decrement: decrement,
 92		IntervalSeconds: intervalSeconds,
 93	}
 94	schedule.Validate()
 95	if start <= time.Now().Unix() {
 96		panic("launchpad/sales: fair sale must start in the future")
 97	}
 98	caps := fairmath.Caps{HardLots: hardLots, SoftQuote: softQuote}
 99	caps.Validate(saleAllocation, lotSize, startPrice)
100	if walletCapLots <= 0 || walletCapLots > hardLots {
101		panic("launchpad/sales: invalid fair wallet cap")
102	}
103	return FairSaleRecord{
104		Creator: creator, QuoteCurrency: quoteCurrency,
105		ConfigVersion: expectedVersion, Allocation: saleAllocation,
106		LotSize: lotSize, WalletCapLots: walletCapLots,
107		Caps: caps, Schedule: schedule,
108	}
109}
110
111func emitFairCreated(id, funding string, r FairSaleRecord, fee int64) {
112	chain.Emit("LaunchpadFairSaleCreated", "id", id,
113		"creator", r.Creator.String(), "currency", r.QuoteCurrency,
114		"allocation", strconv.FormatInt(r.Allocation, 10),
115		"lot_size", strconv.FormatInt(r.LotSize, 10),
116		"hard_lots", strconv.FormatInt(r.Caps.HardLots, 10),
117		"wallet_cap_lots", strconv.FormatInt(r.WalletCapLots, 10),
118		"soft_quote", strconv.FormatInt(r.Caps.SoftQuote, 10),
119		"allowlist_root", r.AllowlistRoot,
120		"holding_gates", r.HoldingGates,
121		"primary_fee_bps", strconv.FormatInt(r.PrimaryFeeBPS, 10),
122		"funding", funding, "fee", strconv.FormatInt(fee, 10))
123}
124
125// CreateFairSale mints a fixed-supply fair-sale token and encumbers its whole-
126// lot allocation in sales custody. The creator receives the initial supply
127// remainder.
128func CreateFairSale(cur realm, name, ticker string, decimals int,
129	initialSupply, saleAllocation, lotSize, hardLots, walletCapLots, softQuote int64,
130	quoteCurrency string, expectedVersion int64,
131	start, end, startPrice, floorPrice, decrement, intervalSeconds int64,
132	description, image, website, xHandle, telegram string) string {
133	return createFairSale(cur, name, ticker, decimals,
134		initialSupply, saleAllocation, lotSize, hardLots, walletCapLots, softQuote,
135		quoteCurrency, expectedVersion,
136		start, end, startPrice, floorPrice, decrement, intervalSeconds,
137		description, image, website, xHandle, telegram, "", "", "")
138}
139
140// CreateGatedFairSale binds a precomputed token ID to a Merkle root and/or
141// token-holding gates. The expected ID prevents an intervening creation from
142// silently attaching the root to a different token.
143func CreateGatedFairSale(cur realm, name, ticker string, decimals int,
144	initialSupply, saleAllocation, lotSize, hardLots, walletCapLots, softQuote int64,
145	quoteCurrency string, expectedVersion int64,
146	start, end, startPrice, floorPrice, decrement, intervalSeconds int64,
147	description, image, website, xHandle, telegram,
148	expectedID, allowlistRoot, holdingGates string) string {
149	return createFairSale(cur, name, ticker, decimals,
150		initialSupply, saleAllocation, lotSize, hardLots, walletCapLots, softQuote,
151		quoteCurrency, expectedVersion,
152		start, end, startPrice, floorPrice, decrement, intervalSeconds,
153		description, image, website, xHandle, telegram,
154		expectedID, allowlistRoot, holdingGates)
155}
156
157func createFairSale(cur realm, name, ticker string, decimals int,
158	initialSupply, saleAllocation, lotSize, hardLots, walletCapLots, softQuote int64,
159	quoteCurrency string, expectedVersion int64,
160	start, end, startPrice, floorPrice, decrement, intervalSeconds int64,
161	description, image, website, xHandle, telegram,
162	expectedID, allowlistRoot, holdingGates string) string {
163	creator := directCaller(cur)
164	r := fairTerms(creator, saleAllocation, lotSize, hardLots,
165		walletCapLots, softQuote, quoteCurrency, expectedVersion,
166		start, end, startPrice, floorPrice, decrement, intervalSeconds)
167	validateFairEligibility(allowlistRoot, holdingGates)
168	if allowlistRoot != "" && expectedID == "" {
169		panic("launchpad/sales: fair allowlist token ID required")
170	}
171	if expectedID != "" && expectedID != "T"+strconv.FormatInt(sm.Add(tokens.Count(), 1), 10) {
172		panic("launchpad/sales: fair token ID changed")
173	}
174	r.AllowlistRoot = allowlistRoot
175	r.HoldingGates = holdingGates
176	if initialSupply <= 0 || saleAllocation > initialSupply {
177		panic("launchpad/sales: fair allocation exceeds supply")
178	}
179	fee := openFairSale(cur, &r)
180	id := tokens.Create(cross(cur), creator, tokens.ModeFairSale, name, ticker,
181		decimals, initialSupply, initialSupply, quoteCurrency, expectedVersion,
182		description, image, website, xHandle, telegram)
183	if expectedID != "" && id != expectedID {
184		panic("launchpad/sales: fair token ID changed")
185	}
186	remainder := sm.Sub(initialSupply, saleAllocation)
187	if remainder > 0 {
188		tokens.Transfer(cross(cur), id, creator, remainder)
189	}
190	fairSales.Set(id, r)
191	setTokenOwed(id, sm.Add(tokenOwed(id), saleAllocation))
192	if tokens.BalanceOf(id, chain.PackageAddress(salesPath)) != tokenOwed(id) {
193		panic("launchpad/sales: token custody mismatch")
194	}
195	emitFairCreated(id, "minted", r, fee)
196	return id
197}
198
199// CreateFairSaleFromToken locks an existing direct token's exact creator-funded
200// allocation. Other token liabilities remain encumbered in the same custody
201// balance. One token can have at most one fair sale.
202func CreateFairSaleFromToken(cur realm, id string,
203	saleAllocation, lotSize, hardLots, walletCapLots, softQuote int64,
204	quoteCurrency string, expectedVersion int64,
205	start, end, startPrice, floorPrice, decrement, intervalSeconds int64) {
206	createFairSaleFromToken(cur, id,
207		saleAllocation, lotSize, hardLots, walletCapLots, softQuote,
208		quoteCurrency, expectedVersion,
209		start, end, startPrice, floorPrice, decrement, intervalSeconds, "", "")
210}
211
212func CreateGatedFairSaleFromToken(cur realm, id string,
213	saleAllocation, lotSize, hardLots, walletCapLots, softQuote int64,
214	quoteCurrency string, expectedVersion int64,
215	start, end, startPrice, floorPrice, decrement, intervalSeconds int64,
216	allowlistRoot, holdingGates string) {
217	createFairSaleFromToken(cur, id,
218		saleAllocation, lotSize, hardLots, walletCapLots, softQuote,
219		quoteCurrency, expectedVersion,
220		start, end, startPrice, floorPrice, decrement, intervalSeconds,
221		allowlistRoot, holdingGates)
222}
223
224func createFairSaleFromToken(cur realm, id string,
225	saleAllocation, lotSize, hardLots, walletCapLots, softQuote int64,
226	quoteCurrency string, expectedVersion int64,
227	start, end, startPrice, floorPrice, decrement, intervalSeconds int64,
228	allowlistRoot, holdingGates string) {
229	creator := directCaller(cur)
230	info := tokens.GetInfo(id)
231	if info.Creator != creator ||
232		(info.Mode != tokens.ModeDirectFixed && info.Mode != tokens.ModeDirectCap) ||
233		fairSales.Has(id) {
234		panic("launchpad/sales: existing fair token denied")
235	}
236	r := fairTerms(creator, saleAllocation, lotSize, hardLots,
237		walletCapLots, softQuote, quoteCurrency, expectedVersion,
238		start, end, startPrice, floorPrice, decrement, intervalSeconds)
239	validateFairEligibility(allowlistRoot, holdingGates)
240	r.AllowlistRoot = allowlistRoot
241	r.HoldingGates = holdingGates
242	fee := openFairSale(cur, &r)
243	tokens.TransferFrom(cross(cur), id, creator,
244		chain.PackageAddress(salesPath), saleAllocation)
245	fairSales.Set(id, r)
246	setTokenOwed(id, sm.Add(tokenOwed(id), saleAllocation))
247	if tokens.BalanceOf(id, chain.PackageAddress(salesPath)) < tokenOwed(id) {
248		panic("launchpad/sales: token custody shortfall")
249	}
250	emitFairCreated(id, "deposited", r, fee)
251}
252
253func GetFairSale(id string) FairSaleRecord {
254	r, ok := fairSales.Get(id).(FairSaleRecord)
255	if !ok {
256		panic("launchpad/sales: fair sale not found")
257	}
258	return r
259}
260
261func GetFairBuyer(id string, buyer address) FairBuyerRecord {
262	if !meta.ValidAddress(buyer) {
263		panic("launchpad/sales: invalid address")
264	}
265	GetFairSale(id)
266	b, _ := fairBuyers.Get(buyerKey(id, buyer)).(FairBuyerRecord)
267	return b
268}
269
270// assertWithinSale runs on every exit of a settled sale. Quote and tokens sit
271// in balances pooled across sales, and the custody checks compare a balance
272// with totals only: an exit that overpaid one sale while lowering the totals
273// by as much would pass them, paid with other sales' money. Settlement
274// asserted GrossQuote + RefundQuote = TotalDeposits, so with these bounds a
275// sale never pays out more quote than it took in, nor more tokens than it
276// sold. Settlement also sets CreatorQuote to GrossQuote minus the fee, so the
277// last bound cannot fail on a record settlement wrote; it stays as a defence
278// should a later change break that.
279func assertWithinSale(r FairSaleRecord) {
280	if r.RefundPaid > r.RefundQuote || r.TokensClaimed > r.SoldTokens ||
281		r.CreatorQuote > r.GrossQuote {
282		panic("launchpad/sales: fair sale overpaid")
283	}
284}
285
286// SettleFair closes a sale at its recorded hard-cap fill or scheduled end.
287// It returns every unsold token to the creator and partitions deposits into
288// buyer refunds, the creator's proceeds and the protocol's primary fee. The
289// fee joins the bucket SweepFees pays; nothing is paid out here, so neither
290// receiver can hold up settlement or buyer claims.
291func SettleFair(cur realm, id string) {
292	if !cur.IsCurrent() {
293		panic("launchpad/sales: stale realm context")
294	}
295	currency.AssertNoCoins(0, cur)
296	r := GetFairSale(id)
297	if r.Cancelled {
298		panic("launchpad/sales: fair settlement unavailable")
299	}
300	if r.Settled {
301		return
302	}
303	if r.HardClosedAt == 0 && time.Now().Unix() < r.Schedule.End {
304		panic("launchpad/sales: fair settlement unavailable")
305	}
306	closeAt := r.Schedule.End
307	if r.HardClosedAt != 0 {
308		closeAt = r.HardClosedAt
309	}
310	price := r.Schedule.PriceAt(closeAt)
311	result := fairmath.Clear(r.Caps, r.Allocation, r.LotSize,
312		r.TotalLots, r.TotalDeposits, price)
313	if sm.Add(result.GrossQuote, result.RefundQuote) != r.TotalDeposits ||
314		sm.Add(result.SoldTokens, result.UnsoldTokens) != r.Allocation {
315		panic("launchpad/sales: fair settlement partition mismatch")
316	}
317	r.Settled = true
318	r.Succeeded = result.Succeeded
319	r.CloseAt = closeAt
320	r.ClosePrice = price
321	r.SoldTokens = result.SoldTokens
322	r.GrossQuote = result.GrossQuote
323	primaryFee := sm.BPS(result.GrossQuote, r.PrimaryFeeBPS)
324	r.CreatorQuote = sm.Sub(result.GrossQuote, primaryFee)
325	r.RefundQuote = result.RefundQuote
326	r.UnsoldTokens = result.UnsoldTokens
327	fairSales.Set(id, r)
328	key := r.QuoteCurrency
329	fairEscrowLiability[key] = sm.Sub(fairEscrowLiability[key], r.TotalDeposits)
330	fairRefundLiability[key] = sm.Add(fairRefundLiability[key], result.RefundQuote)
331	fairProceedsLiability[key] = sm.Add(fairProceedsLiability[key], r.CreatorQuote)
332	feeLiability[key] = sm.Add(feeLiability[key], primaryFee)
333	setTokenOwed(id, sm.Sub(tokenOwed(id), result.UnsoldTokens))
334	if result.UnsoldTokens > 0 {
335		tokens.Transfer(cross(cur), id, r.Creator, result.UnsoldTokens)
336	}
337	if tokens.BalanceOf(id, chain.PackageAddress(salesPath)) < tokenOwed(id) {
338		panic("launchpad/sales: token custody shortfall")
339	}
340	assertQuoteCovered(cur, key)
341	chain.Emit("LaunchpadFairSettled", "id", id, "currency", key,
342		"succeeded", strconv.FormatBool(result.Succeeded),
343		"close_price", strconv.FormatInt(price, 10),
344		"gross_quote", strconv.FormatInt(result.GrossQuote, 10),
345		"primary_fee_quote", strconv.FormatInt(primaryFee, 10),
346		"creator_quote", strconv.FormatInt(r.CreatorQuote, 10),
347		"refund_quote", strconv.FormatInt(result.RefundQuote, 10),
348		"unsold_tokens", strconv.FormatInt(result.UnsoldTokens, 10))
349}
350
351// ClaimFair lets anyone release a buyer's recorded tokens and quote refund
352// to that buyer. Claims never expire and ignore current pause/listing.
353func ClaimFair(cur realm, id string, buyer address) {
354	if !cur.IsCurrent() {
355		panic("launchpad/sales: stale realm context")
356	}
357	currency.AssertNoCoins(0, cur)
358	r := GetFairSale(id)
359	b := GetFairBuyer(id, buyer)
360	if !r.Settled || b.Lots <= 0 || b.Claimed {
361		panic("launchpad/sales: fair claim unavailable")
362	}
363	claim := fairmath.Claim(r.Succeeded, b.Lots, b.Deposit,
364		r.LotSize, r.ClosePrice)
365	r.RefundPaid = sm.Add(r.RefundPaid, claim.RefundQuote)
366	r.TokensClaimed = sm.Add(r.TokensClaimed, claim.Tokens)
367	assertWithinSale(r)
368	fairSales.Set(id, r)
369	b.Claimed = true
370	fairBuyers.Set(buyerKey(id, buyer), b)
371	key := r.QuoteCurrency
372	fairRefundLiability[key] = sm.Sub(fairRefundLiability[key], claim.RefundQuote)
373	setTokenOwed(id, sm.Sub(tokenOwed(id), claim.Tokens))
374	if claim.Tokens > 0 {
375		tokens.Transfer(cross(cur), id, buyer, claim.Tokens)
376	}
377	if claim.RefundQuote > 0 {
378		payQuote(cur, key, buyer, claim.RefundQuote)
379	}
380	if tokens.BalanceOf(id, chain.PackageAddress(salesPath)) < tokenOwed(id) {
381		panic("launchpad/sales: token custody shortfall")
382	}
383	assertQuoteCovered(cur, key)
384	chain.Emit("LaunchpadFairClaimed", "id", id, "buyer", buyer.String(),
385		"tokens", strconv.FormatInt(claim.Tokens, 10),
386		"refund_quote", strconv.FormatInt(claim.RefundQuote, 10))
387}
388
389// ReleaseFairProceeds pays a succeeded sale's creator what settlement
390// recorded for them, once, and returns the amount. Anyone can call it; the
391// receiver is always the recorded creator. It stays open while the lane is
392// paused or the currency delisted.
393func ReleaseFairProceeds(cur realm, id string) int64 {
394	if !cur.IsCurrent() {
395		panic("launchpad/sales: stale realm context")
396	}
397	currency.AssertNoCoins(0, cur)
398	r := GetFairSale(id)
399	if !r.Settled || !r.Succeeded || r.ProceedsReleased {
400		panic("launchpad/sales: fair proceeds unavailable")
401	}
402	r.ProceedsReleased = true
403	assertWithinSale(r)
404	fairSales.Set(id, r)
405	key := r.QuoteCurrency
406	fairProceedsLiability[key] = sm.Sub(fairProceedsLiability[key], r.CreatorQuote)
407	// A succeeded sale raised at least its soft cap and the fee is at most 5%,
408	// so there is always something to pay.
409	payQuote(cur, key, r.Creator, r.CreatorQuote)
410	assertQuoteCovered(cur, key)
411	chain.Emit("LaunchpadFairProceedsReleased", "id", id,
412		"creator", r.Creator.String(), "currency", key,
413		"creator_quote", strconv.FormatInt(r.CreatorQuote, 10),
414		"actor", cur.Previous().Address().String())
415	return r.CreatorQuote
416}
417
418// ContributeFair escrows exact quote atoms for whole token lots at this
419// transaction's schedule price. The direct caller is both payer and buyer,
420// and maxDeposit is the most the order may cost. A native order attaches
421// exactly maxDeposit and gets maxDeposit minus the cost back in the same
422// transaction; a token order pulls only the cost from the buyer's allowance.
423func ContributeFair(cur realm, id string, lots, maxDeposit int64) {
424	contributeFair(cur, id, lots, maxDeposit, "")
425}
426
427// ContributeFairWithProof verifies the recorded allowlist and holding gates
428// against the direct buyer's live address and balances before collecting quote.
429func ContributeFairWithProof(cur realm, id string, lots, maxDeposit int64, proof string) {
430	contributeFair(cur, id, lots, maxDeposit, proof)
431}
432
433func contributeFair(cur realm, id string, lots, maxDeposit int64, proof string) {
434	buyer := directCaller(cur)
435	r := GetFairSale(id)
436	now := time.Now().Unix()
437	if r.Cancelled || r.Settled || r.HardClosedAt != 0 || now < r.Schedule.Start || now >= r.Schedule.End {
438		panic("launchpad/sales: fair sale closed")
439	}
440	// The sale's own terms are pinned in its record and the buyer bounds the
441	// price with maxDeposit, so no config version is involved here.
442	config.AssertLaneOpen(config.LaneFairSale, r.QuoteCurrency)
443	if lots <= 0 || maxDeposit <= 0 {
444		panic("launchpad/sales: invalid fair order")
445	}
446	b := GetFairBuyer(id, buyer)
447	nextBuyerLots := sm.Add(b.Lots, lots)
448	if nextBuyerLots > r.WalletCapLots {
449		panic("launchpad/sales: fair wallet cap exceeded")
450	}
451	requireFairEligibility(id, r, buyer, proof)
452	nextTotalLots, hardClosed := r.Caps.AddLots(r.TotalLots, lots)
453	order := fairmath.QuoteOrder(lots, r.LotSize, r.Schedule.PriceAt(now))
454	if order.Deposit > maxDeposit {
455		panic("launchpad/sales: fair quote exceeds buyer bound")
456	}
457	if r.QuoteCurrency == config.NativeCurrency {
458		// Coins are attached when the buyer signs, and a falling price can step
459		// down before the block: the buyer attaches its bound, exactly, and gets
460		// back at once what the order does not cost.
461		currency.CollectNative(0, cur, maxDeposit)
462		if change := maxDeposit - order.Deposit; change > 0 {
463			currency.PayNative(0, cur, buyer, change)
464		}
465	} else {
466		collectQuote(cur, r.QuoteCurrency, order.Deposit)
467	}
468	b.Lots = nextBuyerLots
469	b.Deposit = sm.Add(b.Deposit, order.Deposit)
470	fairBuyers.Set(buyerKey(id, buyer), b)
471	r.TotalLots = nextTotalLots
472	r.TotalDeposits = sm.Add(r.TotalDeposits, order.Deposit)
473	if hardClosed {
474		r.HardClosedAt = now
475	}
476	fairSales.Set(id, r)
477	fairEscrowLiability[r.QuoteCurrency] = sm.Add(fairEscrowLiability[r.QuoteCurrency], order.Deposit)
478	// The admin's brake on what the realm may hold in a currency. Only an
479	// inflow reads it; exits never do.
480	if owedIn(r.QuoteCurrency) > config.GetSalesOwedCeiling(r.QuoteCurrency) {
481		panic("launchpad/sales: sales owed ceiling reached")
482	}
483	assertQuoteCovered(cur, r.QuoteCurrency)
484	chain.Emit("LaunchpadFairContribution", "id", id, "buyer", buyer.String(),
485		"lots", strconv.FormatInt(lots, 10),
486		"price", strconv.FormatInt(order.Deposit/lots, 10),
487		"deposit", strconv.FormatInt(order.Deposit, 10),
488		"total_lots", strconv.FormatInt(r.TotalLots, 10))
489	if hardClosed {
490		chain.Emit("LaunchpadFairHardClosed", "id", id,
491			"closed_at", strconv.FormatInt(now, 10))
492	}
493}
494
495// CancelFairSale returns the entire unsold allocation to its creator while
496// there are no contributions. It remains callable if the new-sale lane is
497// paused or the quote currency has since been delisted. The creation fee is
498// not returned.
499func CancelFairSale(cur realm, id string) {
500	creator := directCaller(cur)
501	currency.AssertNoCoins(0, cur)
502	r := GetFairSale(id)
503	if r.Creator != creator || r.Cancelled || r.Settled || r.TotalLots != 0 || r.TotalDeposits != 0 {
504		panic("launchpad/sales: fair cancellation denied")
505	}
506	r.Cancelled = true
507	fairSales.Set(id, r)
508	setTokenOwed(id, sm.Sub(tokenOwed(id), r.Allocation))
509	tokens.Transfer(cross(cur), id, creator, r.Allocation)
510	if tokens.BalanceOf(id, chain.PackageAddress(salesPath)) < tokenOwed(id) {
511		panic("launchpad/sales: token custody shortfall")
512	}
513	chain.Emit("LaunchpadFairSaleCancelled", "id", id,
514		"creator", creator.String(),
515		"returned", strconv.FormatInt(r.Allocation, 10))
516}