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}