pado.gno
16.86 Kb · 693 lines
1// Package pado is a launchpad template with no trade tax.
2//
3// One deployment is one realm and one WUGNOT purse. A creator copies this
4// package into their own namespace with MsgAddPackage (gno.land/r/<creator>/pado).
5// That purse is not padv3 and not any other creator's pad. This package cannot
6// spawn those realms itself.
7//
8// Inside one realm, each token has its own raise target and its own quote
9// reserve. Sell and SwapSell pay only from that token's reserve. The bank
10// balance is still a single number; the books are what keep launches apart.
11//
12// Create locks raiseUgnot. Buy and Sell take no fee. At the target, Graduate
13// moves that token's quote into an internal pool in this same realm. There is
14// no Gnoswap list and no zDex list, and the owner cannot withdraw the raise.
15//
16// Payment: the user wugnot.Transfer's to this realm, then calls Buy or SwapBuy
17// with the same amount. A partial fill leaves the rest as that caller's prepaid
18// credit (ClaimWugnot). Pass the amount you transferred. Coins that sit here
19// without a Buy can be locked by the next Buy caller, same as padv3.
20package pado
21
22import (
23 "chain"
24 "chain/runtime"
25 "chain/runtime/unsafe"
26 "strconv"
27 "strings"
28
29 "gno.land/p/g1n4pl5uc4yt5r96m9w6fmdznx3x0jyg8l6arhmt/pado/ammmath"
30 "gno.land/p/nt/avl/v0"
31 "gno.land/p/nt/grc20/v0"
32 "gno.land/p/nt/seqid/v0"
33 "gno.land/r/gnoland/wugnot"
34)
35
36var (
37 launches avl.Tree // id -> *launch
38 bySymbol avl.Tree // symbol -> id
39 nextID seqid.ID
40 nextTokenID seqid.ID
41
42 padAddr address
43 owner address
44
45 prepaid avl.Tree // caller -> int64 claimable WUGNOT
46 totalPrepaid int64
47
48 // testSkipBanker is set only by resetState in tests. Production stays false.
49 testSkipBanker bool
50 testBank int64
51)
52
53func init(cur realm) {
54 padAddr = cur.Address()
55 caller := unsafe.OriginCaller()
56 if caller.IsValid() {
57 owner = caller
58 }
59}
60
61// Init sets the owner when deploy did not capture one. One shot.
62func Init(cur realm) {
63 if !cur.Previous().IsUserCall() {
64 panic("pado: must be EOA MsgCall")
65 }
66 if owner.IsValid() {
67 panic("pado: owner already set")
68 }
69 owner = cur.Previous().Address()
70 chain.Emit("Owner", "owner", owner.String())
71}
72
73// TransferAdmin rotates the launchpad owner. The raise stays in the realm.
74func TransferAdmin(cur realm, next address) {
75 requireOwner(cur)
76 if !next.IsValid() {
77 panic("pado: bad owner")
78 }
79 owner = next
80 chain.Emit("Owner", "owner", owner.String())
81}
82
83// Create opens one token. raiseUgnot is the quote this curve locks before
84// graduate, in ugnot. Only the realm owner may call. The value is fixed here.
85func Create(cur realm, name, symbol, uri string, raiseUgnot int64) string {
86 requireOwner(cur)
87 name = strings.TrimSpace(name)
88 symbol = strings.TrimSpace(symbol)
89 if len(uri) > MaxURILen {
90 panic("pado: uri too long")
91 }
92 if strings.Contains(uri, "\n") || strings.Contains(uri, "\r") {
93 panic("pado: bad uri")
94 }
95 if bySymbol.Has(symbol) {
96 panic("pado: symbol taken")
97 }
98 vu0, target := seedForRaise(raiseUgnot)
99 id := formatID(nextID.Next())
100 token, ledger := grc20.NewToken(name, symbol, 0, nextTokenID.Next(), cur)
101 l := &launch{
102 id: id,
103 name: name,
104 symbol: symbol,
105 uri: uri,
106 creator: cur.Previous().Address(),
107 status: StatusCurve,
108 created: runtime.ChainHeight(),
109 requestedRaise: raiseUgnot,
110 raiseUgnot: target,
111 virtualUgnot0: vu0,
112 virtualUgnot: vu0,
113 virtualToken: VirtualToken0,
114 token: token,
115 ledger: ledger,
116 }
117 launches.Set(id, l)
118 bySymbol.Set(symbol, id)
119 chain.Emit("Create",
120 "id", id,
121 "symbol", symbol,
122 "creator", l.creator.String(),
123 "requested", strconv.FormatInt(raiseUgnot, 10),
124 "raise", strconv.FormatInt(target, 10),
125 "virtualUgnot0", strconv.FormatInt(vu0, 10),
126 )
127 return id
128}
129
130// Buy spends up to amount WUGNOT on the curve and mints tokens to the caller.
131// No fee. A fill that reaches the raise graduates inside this call.
132func Buy(cur realm, id string, amount, minTokensOut int64) int64 {
133 payer := requireEOA(cur)
134 l := mustLaunch(id)
135 if amount <= 0 {
136 panic("pado: amount must be positive")
137 }
138 if minTokensOut < 0 {
139 panic("pado: minOut negative")
140 }
141 creditOrigin(cur)
142 lockPrepaid(payer, amount)
143
144 if l.status != StatusCurve {
145 panic("pado: not on curve (use SwapBuy)")
146 }
147 need := l.raiseUgnot - l.raisedUgnot
148 left := CurveSupply - l.realSold
149 if need <= 0 || left <= 0 {
150 if ready(l) {
151 graduate(l)
152 }
153 return 0
154 }
155
156 used := amount
157 if used > need {
158 used = need
159 }
160 maxIn := ammmath.MaxNetInForTokenOut(l.virtualUgnot, l.virtualToken, left)
161 if maxIn <= 0 {
162 panic("pado: no curve capacity")
163 }
164 if used > maxIn {
165 used = maxIn
166 }
167 tokens, _, newVT := ammmath.BuyTokens(l.virtualUgnot, l.virtualToken, used)
168 for tokens > left && used > 1 {
169 used--
170 tokens, _, newVT = ammmath.BuyTokens(l.virtualUgnot, l.virtualToken, used)
171 }
172 if tokens <= 0 || tokens > left {
173 panic("pado: cannot fill curve")
174 }
175 if minTokensOut > 0 && tokens < minTokensOut {
176 panic("pado: tokens below minOut")
177 }
178 checkSnipe(l, payer, tokens)
179
180 subPrepaid(payer, used)
181 l.raisedUgnot += used
182 l.virtualUgnot = l.virtualUgnot0 + l.raisedUgnot
183 l.virtualToken = newVT
184 l.realSold += tokens
185 mint(l, payer, tokens)
186 chain.Emit("Buy",
187 "id", id,
188 "buyer", payer.String(),
189 "ugnot", strconv.FormatInt(used, 10),
190 "tokens", strconv.FormatInt(tokens, 10),
191 )
192 if ready(l) {
193 graduate(l)
194 }
195 return tokens
196}
197
198// Sell burns curve tokens and returns the quote this launch holds. No fee.
199// Payout is capped at this launch's raised quote, so a rounding step cannot
200// spend another launch's WUGNOT or the virtual seed.
201func Sell(cur realm, id string, tokensIn, minUgnotOut int64) int64 {
202 seller := requireEOA(cur)
203 l := mustLaunch(id)
204 if l.status != StatusCurve {
205 panic("pado: not on curve (use SwapSell)")
206 }
207 if tokensIn <= 0 {
208 panic("pado: tokensIn must be positive")
209 }
210 if minUgnotOut < 0 {
211 panic("pado: minOut negative")
212 }
213 if bal(l, seller) < tokensIn {
214 panic("pado: insufficient token balance")
215 }
216 quote, _, newVT := ammmath.SellTokens(l.virtualUgnot, l.virtualToken, tokensIn)
217 if quote > l.raisedUgnot {
218 quote = l.raisedUgnot
219 }
220 if quote <= 0 {
221 panic("pado: no quote out")
222 }
223 if minUgnotOut > 0 && quote < minUgnotOut {
224 panic("pado: ugnot below minOut")
225 }
226 l.raisedUgnot -= quote
227 l.virtualUgnot = l.virtualUgnot0 + l.raisedUgnot
228 l.virtualToken = newVT
229 l.realSold -= tokensIn
230 if l.realSold < 0 {
231 l.realSold = 0
232 }
233 burn(l, seller, tokensIn)
234 payOut(cur, seller, quote)
235 chain.Emit("Sell",
236 "id", id,
237 "seller", seller.String(),
238 "tokens", strconv.FormatInt(tokensIn, 10),
239 "ugnot", strconv.FormatInt(quote, 10),
240 )
241 return quote
242}
243
244// Graduate moves a filled curve into this realm's internal pool.
245// The quote stays on this token's books. Anyone may call it once ready.
246func Graduate(cur realm, id string) {
247 requireEOA(cur)
248 l := mustLaunch(id)
249 if l.status != StatusCurve {
250 panic("pado: already graduated")
251 }
252 if !ready(l) {
253 panic("pado: not ready to graduate")
254 }
255 graduate(l)
256}
257
258// SwapBuy buys from the post-graduate pool. No fee.
259func SwapBuy(cur realm, id string, amount, minTokensOut int64) int64 {
260 buyer := requireEOA(cur)
261 l := mustLaunch(id)
262 if l.status != StatusGraduated {
263 panic("pado: not graduated")
264 }
265 if amount <= 0 {
266 panic("pado: amount must be positive")
267 }
268 if minTokensOut < 0 {
269 panic("pado: minOut negative")
270 }
271 creditOrigin(cur)
272 lockPrepaid(buyer, amount)
273 tokens, newU, newT := ammmath.PoolSwapUgnotForToken(l.poolUgnot, l.poolToken, amount, 0)
274 if minTokensOut > 0 && tokens < minTokensOut {
275 panic("pado: tokens below minOut")
276 }
277 subPrepaid(buyer, amount)
278 l.poolUgnot = newU
279 l.poolToken = newT
280 mint(l, buyer, tokens)
281 chain.Emit("SwapBuy",
282 "id", id,
283 "buyer", buyer.String(),
284 "ugnot", strconv.FormatInt(amount, 10),
285 "tokens", strconv.FormatInt(tokens, 10),
286 )
287 return tokens
288}
289
290// SwapSell sells into the post-graduate pool. No fee.
291// Pays only from this launch's pool quote.
292func SwapSell(cur realm, id string, tokensIn, minUgnotOut int64) int64 {
293 seller := requireEOA(cur)
294 l := mustLaunch(id)
295 if l.status != StatusGraduated {
296 panic("pado: not graduated")
297 }
298 if tokensIn <= 0 {
299 panic("pado: tokensIn must be positive")
300 }
301 if minUgnotOut < 0 {
302 panic("pado: minOut negative")
303 }
304 if bal(l, seller) < tokensIn {
305 panic("pado: insufficient token balance")
306 }
307 quote, newU, newT := ammmath.PoolSwapTokenForUgnot(l.poolUgnot, l.poolToken, tokensIn)
308 if quote > l.poolUgnot {
309 panic("pado: pool quote")
310 }
311 if minUgnotOut > 0 && quote < minUgnotOut {
312 panic("pado: ugnot below minOut")
313 }
314 l.poolUgnot = newU
315 l.poolToken = newT
316 burn(l, seller, tokensIn)
317 payOut(cur, seller, quote)
318 chain.Emit("SwapSell",
319 "id", id,
320 "seller", seller.String(),
321 "tokens", strconv.FormatInt(tokensIn, 10),
322 "ugnot", strconv.FormatInt(quote, 10),
323 )
324 return quote
325}
326
327// ClaimWugnot withdraws the caller's prepaid remainder. It does not withdraw
328// a launch's raised quote or pool.
329func ClaimWugnot(cur realm) int64 {
330 user := requireEOA(cur)
331 amt := prepaidOf(user)
332 if amt <= 0 {
333 return 0
334 }
335 subPrepaid(user, amt)
336 payOut(cur, user, amt)
337 chain.Emit("ClaimWugnot", "user", user.String(), "amount", strconv.FormatInt(amt, 10))
338 return amt
339}
340
341// Transfer moves this realm's token. The quote reserve does not move.
342func Transfer(cur realm, id string, to address, amount int64) {
343 from := requireEOA(cur)
344 l := mustLaunch(id)
345 if err := l.ledger.Transfer(from, to, amount); err != nil {
346 panic(err.Error())
347 }
348 chain.Emit("Transfer", "id", id, "from", from.String(), "to", to.String(), "amount", strconv.FormatInt(amount, 10))
349}
350
351// Approve lets spender TransferFrom the caller's tokens.
352func Approve(cur realm, id string, spender address, amount int64) {
353 caller := requireEOA(cur)
354 l := mustLaunch(id)
355 if err := l.ledger.Approve(caller, spender, amount); err != nil {
356 panic(err.Error())
357 }
358}
359
360// TransferFrom spends the caller's allowance.
361func TransferFrom(cur realm, id string, from, to address, amount int64) {
362 spender := requireEOA(cur)
363 l := mustLaunch(id)
364 if err := l.ledger.TransferFrom(from, spender, to, amount); err != nil {
365 panic(err.Error())
366 }
367 chain.Emit("TransferFrom", "id", id, "from", from.String(), "to", to.String(), "amount", strconv.FormatInt(amount, 10))
368}
369
370func Owner() address { return owner }
371func RealmAddr() address { return padAddr }
372func RaiseOf(id string) int64 {
373 return mustLaunch(id).raiseUgnot
374}
375func RequestedRaiseOf(id string) int64 {
376 return mustLaunch(id).requestedRaise
377}
378func RaisedOf(id string) int64 { return mustLaunch(id).raisedUgnot }
379func StatusOf(id string) int { return mustLaunch(id).status }
380func SoldOf(id string) int64 { return mustLaunch(id).realSold }
381func VirtualOf(id string) (vu0, vu, vt int64) {
382 l := mustLaunch(id)
383 return l.virtualUgnot0, l.virtualUgnot, l.virtualToken
384}
385func PoolOf(id string) (ugnot, tokens int64) {
386 l := mustLaunch(id)
387 return l.poolUgnot, l.poolToken
388}
389func BalanceOf(id string, addr address) int64 {
390 return bal(mustLaunch(id), addr)
391}
392func Allowance(id string, who, spender address) int64 {
393 return mustLaunch(id).token.Allowance(who, spender)
394}
395func PrepaidOf(addr address) int64 { return prepaidOf(addr) }
396func ResolveSymbol(symbol string) string {
397 v := bySymbol.Get(symbol)
398 if v == nil {
399 return ""
400 }
401 id, ok := v.(string)
402 if !ok {
403 panic("pado: bad symbol index")
404 }
405 return id
406}
407
408// launch is one token book. token and ledger stay unexported so other
409// packages cannot mint.
410type launch struct {
411 id string
412 name string
413 symbol string
414 uri string
415 creator address
416 status int
417 created int64
418 requestedRaise int64
419 raiseUgnot int64
420 virtualUgnot0 int64
421 virtualUgnot int64
422 virtualToken int64
423 raisedUgnot int64
424 realSold int64
425 poolUgnot int64
426 poolToken int64
427 leftover int64
428 token *grc20.Token
429 ledger *grc20.PrivateLedger
430 snipe avl.Tree
431}
432
433func seedForRaise(raise int64) (vu0, target int64) {
434 if raise < MinRaiseUgnot || raise > MaxRaiseUgnot {
435 panic("pado: raise out of range")
436 }
437 span := VirtualToken0 - CurveSupply
438 vu0 = ammmath.MulDiv(raise, span, CurveSupply)
439 if vu0 <= 0 {
440 panic("pado: raise too small for curve")
441 }
442 target = ammmath.MulDiv(vu0, CurveSupply, span)
443 if target <= 0 || target > raise {
444 panic("pado: raise not reachable")
445 }
446 return vu0, target
447}
448
449func ready(l *launch) bool {
450 if l.raisedUgnot >= l.raiseUgnot && l.raiseUgnot > 0 {
451 return true
452 }
453 return l.realSold >= CurveSupply && l.raisedUgnot > 0
454}
455
456func graduate(l *launch) {
457 if l.status != StatusCurve {
458 return
459 }
460 poolU := l.raisedUgnot
461 if poolU <= 0 {
462 panic("pado: empty pool quote")
463 }
464 remaining := TotalSupply - l.realSold
465 if remaining <= 0 {
466 panic("pado: no tokens for pool")
467 }
468 poolT := remaining
469 if l.virtualUgnot > 0 && l.virtualToken > 0 {
470 needed := ammmath.MulDiv(poolU, l.virtualToken, l.virtualUgnot)
471 if needed > 0 && needed < remaining {
472 poolT = needed
473 }
474 }
475 l.poolUgnot = poolU
476 l.poolToken = poolT
477 l.leftover = remaining - poolT
478 l.raisedUgnot = 0
479 l.virtualUgnot = 0
480 l.virtualToken = 0
481 l.status = StatusGraduated
482 chain.Emit("Graduated",
483 "id", l.id,
484 "poolUgnot", strconv.FormatInt(poolU, 10),
485 "poolToken", strconv.FormatInt(poolT, 10),
486 "leftover", strconv.FormatInt(l.leftover, 10),
487 )
488}
489
490func requireEOA(cur realm) address {
491 if !cur.Previous().IsUserCall() {
492 panic("pado: must be EOA MsgCall")
493 }
494 caller := cur.Previous().Address()
495 if !caller.IsValid() {
496 panic("pado: bad caller")
497 }
498 return caller
499}
500
501func requireOwner(cur realm) {
502 caller := requireEOA(cur)
503 if !owner.IsValid() || caller != owner {
504 panic("pado: owner only")
505 }
506}
507
508func mustLaunch(id string) *launch {
509 v := launches.Get(id)
510 if v == nil {
511 panic("pado: unknown launch")
512 }
513 l, ok := v.(*launch)
514 if !ok || l == nil {
515 panic("pado: bad launch")
516 }
517 return l
518}
519
520func bal(l *launch, addr address) int64 {
521 if l.token == nil {
522 panic("pado: missing token")
523 }
524 return l.token.BalanceOf(addr)
525}
526
527func mint(l *launch, addr address, amount int64) {
528 if l.ledger == nil {
529 panic("pado: missing ledger")
530 }
531 if err := l.ledger.Mint(addr, amount); err != nil {
532 panic(err.Error())
533 }
534}
535
536func burn(l *launch, addr address, amount int64) {
537 if err := l.ledger.Burn(addr, amount); err != nil {
538 panic(err.Error())
539 }
540}
541
542func checkSnipe(l *launch, buyer address, tokens int64) {
543 if runtime.ChainHeight()-l.created >= AntiSnipeHeights {
544 return
545 }
546 maxTok := TotalSupply * AntiSnipeMaxBuyBPS / 10000
547 prev := snipeOf(l, buyer)
548 if prev+tokens > maxTok {
549 panic("pado: anti-snipe max buy")
550 }
551 l.snipe.Set(buyer.String(), prev+tokens)
552}
553
554func snipeOf(l *launch, buyer address) int64 {
555 v := l.snipe.Get(buyer.String())
556 if v == nil {
557 return 0
558 }
559 n, ok := v.(int64)
560 if !ok {
561 panic("pado: bad snipe book")
562 }
563 return n
564}
565
566func reservedWugnot() int64 {
567 n := totalPrepaid
568 launches.Iterate("", "", func(_ string, value any) bool {
569 l, ok := value.(*launch)
570 if !ok || l == nil {
571 panic("pado: bad launch")
572 }
573 if l.status == StatusCurve {
574 n += l.raisedUgnot
575 }
576 if l.status == StatusGraduated {
577 n += l.poolUgnot
578 }
579 return false
580 })
581 return n
582}
583
584func bankBal() int64 {
585 if testSkipBanker {
586 return testBank
587 }
588 return wugnot.BalanceOf(padAddr)
589}
590
591func freeWugnot() int64 {
592 have := bankBal()
593 res := reservedWugnot()
594 if have < res {
595 panic("pado: reserve exceeds balance")
596 }
597 return have - res
598}
599
600// creditOrigin attributes this tx's ugnot to the caller in tests, where
601// OriginSend stands in for a prior wugnot.Transfer.
602func creditOrigin(cur realm) {
603 if !testSkipBanker {
604 return
605 }
606 sent := unsafe.OriginSend().AmountOf(DenomUgnot)
607 if sent < 0 {
608 panic("pado: bad origin send")
609 }
610 if sent == 0 {
611 return
612 }
613 addPrepaid(cur.Previous().Address(), sent)
614 testBank += sent
615}
616
617func lockPrepaid(user address, amount int64) {
618 have := prepaidOf(user)
619 if have >= amount {
620 return
621 }
622 need := amount - have
623 if freeWugnot() < need {
624 panic("pado: transfer WUGNOT to this realm, then buy")
625 }
626 addPrepaid(user, need)
627}
628
629func prepaidOf(addr address) int64 {
630 if !addr.IsValid() {
631 return 0
632 }
633 v := prepaid.Get(addr.String())
634 if v == nil {
635 return 0
636 }
637 n, ok := v.(int64)
638 if !ok {
639 panic("pado: bad prepaid book")
640 }
641 return n
642}
643
644func addPrepaid(addr address, amount int64) {
645 if amount <= 0 {
646 return
647 }
648 next := prepaidOf(addr) + amount
649 prepaid.Set(addr.String(), next)
650 totalPrepaid += amount
651}
652
653func subPrepaid(addr address, amount int64) {
654 if amount <= 0 {
655 return
656 }
657 have := prepaidOf(addr)
658 if have < amount {
659 panic("pado: prepaid underfunded")
660 }
661 next := have - amount
662 if next == 0 {
663 prepaid.Remove(addr.String())
664 } else {
665 prepaid.Set(addr.String(), next)
666 }
667 totalPrepaid -= amount
668}
669
670func payOut(cur realm, to address, amount int64) {
671 if amount <= 0 {
672 return
673 }
674 if freeWugnot() < amount {
675 panic("pado: insufficient free WUGNOT")
676 }
677 if testSkipBanker {
678 testBank -= amount
679 if testBank < 0 {
680 panic("pado: negative test bank")
681 }
682 return
683 }
684 wugnot.Transfer(cross(cur), to, amount)
685}
686
687func formatID(n seqid.ID) string {
688 s := strconv.FormatUint(uint64(n), 10)
689 for len(s) < 7 {
690 s = "0" + s
691 }
692 return s
693}