memepad.gno
27.37 Kb · 983 lines
1// Package pad is gnomemepad: a self-contained meme launchpad for gno.land.
2//
3// Direction A — factory IS the market (no external AMM required for MVP):
4//
5// Create → GRC20 token + bonding curve → Graduate (atomic locked CPMM)
6// → GRC20 is listable on Gnoswap (external DEX); pad keeps locked pool too
7//
8// Tokens are real GRC20 (mint on buy, burn on sell) so holders can transfer and
9// later provide liquidity on Gnoswap. Hybrid of Pump.fun + permanent LP lock.
10package padv7
11
12import (
13 "chain"
14 "chain/banker"
15 "chain/runtime"
16 "chain/runtime/unsafe"
17 "strconv"
18 "strings"
19
20 "gno.land/p/demo/tokens/grc20"
21 "gno.land/p/g1mv0052e7r6s09f5t9xsqf00nj3tqsgt9dg52jr/gnomemepad/ammmath"
22 "gno.land/p/nt/avl/v0"
23 "gno.land/p/nt/seqid/v0"
24 "gno.land/r/demo/defi/grc20reg"
25 // pointsv2: optional trade/create awards (off by default until SetPointsEnabled).
26 // Deploy prepare rewrites this import to the Sapphire personal-namespace path.
27 pointsv2 "gno.land/r/g1mv0052e7r6s09f5t9xsqf00nj3tqsgt9dg52jr/gnomemepad/pointsv2"
28)
29
30var (
31 launches avl.Tree // id -> *Launch
32 bySymbol avl.Tree // symbol -> id string
33 nextID seqid.ID
34 nextTokenID seqid.ID // GRC20 identity sequence (shared for all launches)
35 protocolAddr address
36 protocolFees int64
37 inited bool
38 // testSkipBanker: when true, sendUgnot is a no-op (unit tests without funded realm bank).
39 // Always false in production.
40 testSkipBanker bool
41 // pointsEnabled: when true, notify pointsv2 after Create / Buy / Sell / Swap*.
42 // Admin must also AllowPad(this package) on pointsv2.
43 pointsEnabled bool
44)
45
46// Trade is one price sample for charts (capped history per launch).
47type Trade struct {
48 Height int64
49 Side int // TradeSideBuy | TradeSideSell | TradeSideOpen
50 Ugnot int64
51 Tokens int64
52 Price int64 // ugnot per token * 1e6 after the trade
53}
54
55// Launch is one meme market: curve phase then locked pool phase.
56// token/ledger are unexported so external packages cannot Mint/Burn via field access.
57type Launch struct {
58 ID string
59 Name string
60 Symbol string
61 URI string
62 Creator address
63 Status int
64 Created int64 // block height
65
66 // GRC20 (mint/burn only via pad-owned private ledger)
67 token *grc20.Token
68 ledger *grc20.PrivateLedger
69 TokenID string // Token.ID() — registry / Gnoswap identity
70
71 // Virtual curve reserves
72 VirtualUgnot int64
73 VirtualToken int64
74 RealSold int64 // tokens sold on curve (≤ CurveSupply)
75 RaisedUgnot int64 // net ugnot collateral in curve (excl. fee vaults)
76
77 // Real pool (post-grad); LP permanently locked — no remove path
78 // PoolToken is pad-internal reserve (not the same as GRC20 total supply).
79 PoolUgnot int64
80 PoolToken int64
81
82 CreatorFees int64
83 BondUgnot int64
84 BondRefunded bool
85 UniqueBuyers avl.Tree // address -> true
86 BuyerCount int
87 // snipeBought: address -> cumulative tokens bought during anti-snipe window
88 snipeBought avl.Tree
89
90 // Gnoswap listing readiness (GRC20 exists from Create; pool on Gnoswap is external)
91 GnoswapReady bool // true after graduate — token may be paired on Gnoswap
92 GnoswapNote string
93
94 // Chart history (ordered AVL keys)
95 Trades avl.Tree // tradeKey -> *Trade
96 NextTrade int64
97}
98
99// Init sets the protocol treasury. First EOA caller becomes fee recipient.
100func Init(cur realm) {
101 if inited {
102 panic("pad: already initialized")
103 }
104 if !cur.Previous().IsUserCall() {
105 panic("pad: EOA only")
106 }
107 protocolAddr = cur.Previous().Address()
108 inited = true
109 chain.Emit("Init", "protocol", protocolAddr.String())
110}
111
112func requireInit() {
113 if !inited {
114 panic("pad: call Init first")
115 }
116}
117
118// SetPointsEnabled toggles pointsv2 notifications (protocol admin only).
119// pointsv2 must AllowPad(this package path) or OnTrade/OnCreate will panic and revert the trade.
120func SetPointsEnabled(cur realm, on bool) {
121 requireInit()
122 if !cur.Previous().IsUserCall() {
123 panic("pad: EOA only")
124 }
125 if cur.Previous().Address() != protocolAddr {
126 panic("pad: not protocol")
127 }
128 pointsEnabled = on
129 chain.Emit("SetPointsEnabled", "on", strconv.FormatBool(on))
130}
131
132// PointsEnabled reports whether pad notifies pointsv2 after trades/creates.
133func PointsEnabled() bool {
134 return pointsEnabled
135}
136
137func notifyTrade(cur realm, trader address, id string, side int64, volumeUgnot int64) {
138 if !pointsEnabled || testSkipBanker {
139 return
140 }
141 _ = pointsv2.OnTrade(cross(cur), trader, id, side, volumeUgnot)
142}
143
144func notifyCreate(cur realm, creator address, id string) {
145 if !pointsEnabled || testSkipBanker {
146 return
147 }
148 _ = pointsv2.OnCreate(cross(cur), creator, id)
149}
150
151func mustLaunch(id string) *Launch {
152 // Sapphire avl.Tree.Get returns a single any (nil if missing).
153 l, ok := launches.Get(id).(*Launch)
154 if !ok {
155 panic("pad: unknown launch")
156 }
157 return l
158}
159
160func balOf(l *Launch, addr address) int64 {
161 if l == nil || l.token == nil {
162 return 0
163 }
164 return l.token.BalanceOf(addr)
165}
166
167// addBal mints (delta>0) or burns (delta<0) GRC20 via pad-owned PrivateLedger.
168func addBal(l *Launch, addr address, delta int64) {
169 if l == nil || l.ledger == nil {
170 panic("pad: missing GRC20 ledger")
171 }
172 if delta == 0 {
173 return
174 }
175 if delta > 0 {
176 if err := l.ledger.Mint(addr, delta); err != nil {
177 panic("pad: mint: " + err.Error())
178 }
179 return
180 }
181 if err := l.ledger.Burn(addr, -delta); err != nil {
182 panic("pad: burn: " + err.Error())
183 }
184}
185
186func requireMinOut(got, minOut int64, what string) {
187 if minOut < 0 {
188 panic("pad: minOut must be non-negative")
189 }
190 if minOut > 0 && got < minOut {
191 panic("pad: " + what + " below minOut (slippage)")
192 }
193}
194
195func snipeBoughtOf(l *Launch, buyer address) int64 {
196 v := l.snipeBought.Get(buyer.String())
197 if v == nil {
198 return 0
199 }
200 n, ok := v.(int64)
201 if !ok {
202 return 0
203 }
204 return n
205}
206
207func checkAndAddSnipe(l *Launch, buyer address, tokensOut int64) {
208 height := runtime.ChainHeight()
209 if height-l.Created >= AntiSnipeHeights {
210 return
211 }
212 maxTok := TotalSupply * AntiSnipeMaxBuyBPS / 10000
213 prev := snipeBoughtOf(l, buyer)
214 if prev+tokensOut > maxTok {
215 panic("pad: anti-snipe cumulative max buy exceeded")
216 }
217 l.snipeBought.Set(buyer.String(), prev+tokensOut)
218}
219
220func sendUgnot(cur realm, to address, amount int64) {
221 if amount <= 0 {
222 return
223 }
224 if testSkipBanker {
225 return
226 }
227 bk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
228 bk.SendCoins(cur.Address(), to, chain.Coins{{Denom: DenomUgnot, Amount: amount}})
229}
230
231func requireUserPayment(cur realm) int64 {
232 if !cur.Previous().IsUserCall() {
233 panic("pad: must be EOA MsgCall")
234 }
235 sent := unsafe.OriginSend().AmountOf(DenomUgnot)
236 if sent <= 0 {
237 panic("pad: need ugnot -send")
238 }
239 return sent
240}
241
242func noteBuyer(l *Launch, buyer address) {
243 k := buyer.String()
244 if l.UniqueBuyers.Has(k) {
245 return
246 }
247 l.UniqueBuyers.Set(k, true)
248 l.BuyerCount++
249}
250
251func tradeKey(n int64) string {
252 s := strconv.FormatInt(n, 10)
253 for len(s) < 12 {
254 s = "0" + s
255 }
256 return s
257}
258
259// spotPriceScaled returns ugnot/token * 1e6 from current curve or pool reserves.
260func spotPriceScaled(l *Launch) int64 {
261 if l.Status == StatusGraduated {
262 if l.PoolToken <= 0 {
263 return 0
264 }
265 return l.PoolUgnot * 1000000 / l.PoolToken
266 }
267 if l.VirtualToken <= 0 {
268 return 0
269 }
270 return l.VirtualUgnot * 1000000 / l.VirtualToken
271}
272
273func recordTrade(l *Launch, side int, ugnot, tokens int64) {
274 l.NextTrade++
275 t := &Trade{
276 Height: runtime.ChainHeight(),
277 Side: side,
278 Ugnot: ugnot,
279 Tokens: tokens,
280 Price: spotPriceScaled(l),
281 }
282 l.Trades.Set(tradeKey(l.NextTrade), t)
283 // Ring buffer: drop oldest while over cap.
284 for l.Trades.Size() > MaxTradeHistory {
285 oldest := ""
286 l.Trades.Iterate("", "", func(k string, _ any) bool {
287 oldest = k
288 return true // stop
289 })
290 if oldest == "" {
291 break
292 }
293 l.Trades.Remove(oldest)
294 }
295}
296
297func maybeRefundBond(cur realm, l *Launch) {
298 if l.BondRefunded || l.BondUgnot <= 0 {
299 return
300 }
301 if l.BuyerCount < BondRefundBuyers {
302 return
303 }
304 // Quality gate: pure sybil micro-buys cannot refund bond.
305 if l.RaisedUgnot < BondRefundMinRaised {
306 return
307 }
308 if runtime.ChainHeight()-l.Created > BondRefundMaxHeights {
309 return
310 }
311 amt := l.BondUgnot
312 l.BondUgnot = 0
313 l.BondRefunded = true
314 sendUgnot(cur, l.Creator, amt)
315 chain.Emit("BondRefund", "id", l.ID, "amount", strconv.FormatInt(amt, 10))
316}
317
318// Create deploys a fair-launch meme. Requires CreateBondUgnot via -send.
319// No pre-mint; all tradeable float starts on the bonding curve.
320func Create(cur realm, name, symbol, uri string) string {
321 requireInit()
322 sent := requireUserPayment(cur)
323 if sent < CreateBondUgnot {
324 panic("pad: create bond underpaid")
325 }
326 if name == "" || symbol == "" {
327 panic("pad: name and symbol required")
328 }
329 if len(symbol) > 12 {
330 panic("pad: symbol too long")
331 }
332 if bySymbol.Has(symbol) {
333 panic("pad: symbol taken")
334 }
335 extra := sent - CreateBondUgnot
336 if extra > 0 {
337 protocolFees += extra
338 }
339
340 creator := cur.Previous().Address()
341 id := nextID.Next().String()
342
343 // Real GRC20 bound to this pad realm (mint/burn only via pad ledger).
344 // Decimals=0: whole-token units (matches existing trade amounts).
345 token, ledger := grc20.NewToken(name, symbol, 0, nextTokenID.Next(), cur)
346
347 // Adena (and Gnoswap registries) resolve tokens ONLY via grc20reg under key
348 // packagePath.SYMBOL — Token.ID() itself is packagePath.SYMBOL.seq and is
349 // rejected as "Invalid path" if pasted into Adena without registration.
350 // Skip in unit tests (testSkipBanker); production always registers.
351 regKey := ""
352 if !testSkipBanker {
353 regKey = grc20reg.Register(cross(cur), token, symbol)
354 }
355
356 l := &Launch{
357 ID: id,
358 Name: name,
359 Symbol: symbol,
360 URI: uri,
361 Creator: creator,
362 Status: StatusCurve,
363 Created: runtime.ChainHeight(),
364 token: token,
365 ledger: ledger,
366 TokenID: token.ID(),
367 VirtualUgnot: VirtualUgnot0,
368 VirtualToken: VirtualToken0,
369 UniqueBuyers: avl.Tree{},
370 snipeBought: avl.Tree{},
371 BondUgnot: CreateBondUgnot,
372 Trades: avl.Tree{},
373 }
374 // Open mark for charts (initial virtual spot).
375 recordTrade(l, TradeSideOpen, 0, 0)
376 launches.Set(id, l)
377 bySymbol.Set(symbol, id)
378
379 chain.Emit("Created",
380 "id", id,
381 "symbol", symbol,
382 "creator", creator.String(),
383 "token", l.TokenID,
384 "reg", regKey,
385 )
386 notifyCreate(cur, creator, id)
387 return id
388}
389
390// AdenaPathOf returns the grc20reg / Adena token key: packagePath.SYMBOL
391// (Token.ID is packagePath.SYMBOL.seq — Adena rejects that form).
392func AdenaPathOf(id string) string {
393 l := mustLaunch(id)
394 return adenaKeyFromTokenID(l.TokenID, l.Symbol)
395}
396
397// adenaKeyFromTokenID strips the trailing .seq from Token.ID when present.
398func adenaKeyFromTokenID(tokenID, symbol string) string {
399 if tokenID == "" {
400 return ""
401 }
402 // Token.ID = packagePath.symbol.seq → registry key = packagePath.symbol
403 suffix := "." + symbol + "."
404 if i := strings.LastIndex(tokenID, suffix); i >= 0 {
405 // packagePath + "." + symbol
406 return tokenID[:i] + "." + symbol
407 }
408 // Already packagePath.symbol or unknown layout
409 if strings.HasSuffix(tokenID, "."+symbol) {
410 return tokenID
411 }
412 return tokenID
413}
414
415// Buy spends -send ugnot on the bonding curve; credits tokens to the caller.
416// minTokensOut: slippage floor (0 = disabled). Auto-graduates at threshold.
417// Oversized buys that would exceed remaining curve supply panic (no silent dust).
418func Buy(cur realm, id string, minTokensOut int64) int64 {
419 requireInit()
420 sent := requireUserPayment(cur)
421 l := mustLaunch(id)
422 if l.Status != StatusCurve {
423 panic("pad: not on curve (use SwapBuy)")
424 }
425 buyer := cur.Previous().Address()
426
427 remaining := CurveSupply - l.RealSold
428 if remaining <= 0 {
429 panic("pad: curve sold out — call Graduate")
430 }
431
432 fee := ammmath.ApplyFee(sent, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
433 // Net enters curve; remainder boosts virtual ugnot (stays as collateral).
434 netIn := fee.Net + fee.Remainder
435
436 tokensOut, newVU, newVT := ammmath.BuyTokens(l.VirtualUgnot, l.VirtualToken, netIn)
437 // Hard fail instead of partial fill + dust (audit Y2).
438 if tokensOut > remaining {
439 panic("pad: buy exceeds remaining curve supply — reduce amount")
440 }
441 requireMinOut(tokensOut, minTokensOut, "tokens out")
442 checkAndAddSnipe(l, buyer, tokensOut)
443
444 // Mutate only after all checks pass.
445 l.CreatorFees += fee.Creator
446 protocolFees += fee.Protocol
447 l.VirtualUgnot = newVU
448 l.VirtualToken = newVT
449 l.RealSold += tokensOut
450 l.RaisedUgnot += netIn
451
452 addBal(l, buyer, tokensOut)
453 noteBuyer(l, buyer)
454 maybeRefundBond(cur, l)
455 recordTrade(l, TradeSideBuy, sent, tokensOut)
456
457 chain.Emit("Buy",
458 "id", id,
459 "buyer", buyer.String(),
460 "ugnot", strconv.FormatInt(sent, 10),
461 "tokens", strconv.FormatInt(tokensOut, 10),
462 )
463 notifyTrade(cur, buyer, id, 0, sent)
464
465 if ammmath.CanGraduate(l.RaisedUgnot, GraduationThreshold) || l.RealSold >= CurveSupply {
466 if l.RaisedUgnot >= GraduationThreshold {
467 graduate(cur, l)
468 }
469 }
470 return tokensOut
471}
472
473// Sell burns curve tokens and pays ugnot (fee on output).
474// minUgnotOut: slippage floor (0 = disabled).
475func Sell(cur realm, id string, tokensIn, minUgnotOut int64) int64 {
476 requireInit()
477 if !cur.Previous().IsUserCall() {
478 panic("pad: must be EOA MsgCall")
479 }
480 if tokensIn <= 0 {
481 panic("pad: tokensIn must be positive")
482 }
483 l := mustLaunch(id)
484 if l.Status != StatusCurve {
485 panic("pad: not on curve (use SwapSell)")
486 }
487 seller := cur.Previous().Address()
488 if balOf(l, seller) < tokensIn {
489 panic("pad: insufficient token balance")
490 }
491
492 gross, newVU, newVT := ammmath.SellTokens(l.VirtualUgnot, l.VirtualToken, tokensIn)
493 fee := ammmath.ApplyFeeOnOutput(gross, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
494 requireMinOut(fee.Net, minUgnotOut, "ugnot out")
495
496 // Full gross left virtual reserves; retain fee in virtual ugnot (cash stays in realm).
497 l.VirtualUgnot = newVU + fee.Fee
498 l.VirtualToken = newVT
499 l.RealSold -= tokensIn
500 if l.RealSold < 0 {
501 l.RealSold = 0
502 }
503
504 // User receives net; creator+protocol become fee liabilities (leave Raised).
505 payOut := fee.Net + fee.Creator + fee.Protocol
506 if l.RaisedUgnot >= payOut {
507 l.RaisedUgnot -= payOut
508 } else {
509 l.RaisedUgnot = 0
510 }
511 l.CreatorFees += fee.Creator
512 protocolFees += fee.Protocol
513
514 addBal(l, seller, -tokensIn)
515 sendUgnot(cur, seller, fee.Net)
516 recordTrade(l, TradeSideSell, fee.Net, tokensIn)
517
518 chain.Emit("Sell",
519 "id", id,
520 "seller", seller.String(),
521 "tokens", strconv.FormatInt(tokensIn, 10),
522 "ugnot", strconv.FormatInt(fee.Net, 10),
523 )
524 notifyTrade(cur, seller, id, 1, fee.Net)
525 return fee.Net
526}
527
528// Graduate permissionlessly moves a ready curve into a permanently locked CPMM.
529func Graduate(cur realm, id string) {
530 requireInit()
531 l := mustLaunch(id)
532 if l.Status != StatusCurve {
533 panic("pad: already graduated")
534 }
535 if !ammmath.CanGraduate(l.RaisedUgnot, GraduationThreshold) {
536 panic("pad: threshold not met")
537 }
538 graduate(cur, l)
539}
540
541func graduate(cur realm, l *Launch) {
542 if l.Status != StatusCurve {
543 return
544 }
545 // Seed pool: all curve collateral + remaining pool seed tokens.
546 // Curve unsold tokens are not minted; only PoolSeed enters the pool.
547 // Raised ugnot becomes pool ugnot (fee vaults stay separate liabilities).
548 poolU := l.RaisedUgnot
549 if poolU <= 0 {
550 panic("pad: empty pool ugnot")
551 }
552 poolT := PoolSeed
553 // Optional: if curve sold less than CurveSupply, unsold is never minted (dead supply).
554 // Real circulating = RealSold + PoolSeed after minting pool to realm accounting.
555
556 l.PoolUgnot = poolU
557 l.PoolToken = poolT
558 l.RaisedUgnot = 0
559 l.VirtualUgnot = 0
560 l.VirtualToken = 0
561 l.Status = StatusGraduated
562
563 // Pool tokens are held by the launch (not an address) — PoolToken reserve.
564 // Circulating user balances remain; total effective supply = sum(balances)+PoolToken.
565
566 // Forfeit unrefunded bond to protocol at graduation if still locked.
567 if !l.BondRefunded && l.BondUgnot > 0 {
568 protocolFees += l.BondUgnot
569 l.BondUgnot = 0
570 l.BondRefunded = true
571 }
572
573 // Mark graduation on chart at pool spot.
574 recordTrade(l, TradeSideOpen, poolU, poolT)
575
576 // GRC20 already exists and is transferable. Mark ready for external Gnoswap pool.
577 // Pad keeps a locked internal CPMM; Gnoswap listing is a separate permissionless pool
578 // seeded with GNOT + this GRC20 by the community/creator (see Guide).
579 l.GnoswapReady = true
580 l.GnoswapNote = "create GNOT/" + l.Symbol + " pool on Gnoswap with Token.ID=" + l.TokenID
581
582 chain.Emit("Graduated",
583 "id", l.ID,
584 "poolUgnot", strconv.FormatInt(poolU, 10),
585 "poolToken", strconv.FormatInt(poolT, 10),
586 "token", l.TokenID,
587 "gnoswap_ready", "1",
588 )
589 _ = cur // banker not needed; ugnot already on realm
590}
591
592// TokenIDOf returns the GRC20 Token.ID() for a launch.
593func TokenIDOf(id string) string {
594 return mustLaunch(id).TokenID
595}
596
597// GRC20Bank returns the underlying *grc20.Token for interop (metadata / external DEX).
598// Does not expose PrivateLedger — mint/burn stay pad-only.
599func GRC20Bank(id string) *grc20.Token {
600 l := mustLaunch(id)
601 if l.token == nil {
602 panic("pad: no token")
603 }
604 return l.token
605}
606
607// SwapBuy buys tokens from the graduated pool with -send ugnot.
608// minTokensOut: slippage floor (0 = disabled).
609func SwapBuy(cur realm, id string, minTokensOut int64) int64 {
610 requireInit()
611 sent := requireUserPayment(cur)
612 l := mustLaunch(id)
613 if l.Status != StatusGraduated {
614 panic("pad: not graduated (use Buy)")
615 }
616 buyer := cur.Previous().Address()
617
618 fee := ammmath.ApplyFee(sent, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
619 tokensOut, newPU, newPT := ammmath.PoolSwapUgnotForToken(
620 l.PoolUgnot, l.PoolToken, fee.Net, fee.Remainder,
621 )
622 requireMinOut(tokensOut, minTokensOut, "tokens out")
623
624 l.CreatorFees += fee.Creator
625 protocolFees += fee.Protocol
626 l.PoolUgnot = newPU
627 l.PoolToken = newPT
628 addBal(l, buyer, tokensOut)
629 noteBuyer(l, buyer)
630 recordTrade(l, TradeSideBuy, sent, tokensOut)
631
632 chain.Emit("SwapBuy",
633 "id", id,
634 "buyer", buyer.String(),
635 "ugnot", strconv.FormatInt(sent, 10),
636 "tokens", strconv.FormatInt(tokensOut, 10),
637 )
638 notifyTrade(cur, buyer, id, 0, sent)
639 return tokensOut
640}
641
642// SwapSell sells tokens into the graduated pool for ugnot.
643// minUgnotOut: slippage floor (0 = disabled).
644func SwapSell(cur realm, id string, tokensIn, minUgnotOut int64) int64 {
645 requireInit()
646 if !cur.Previous().IsUserCall() {
647 panic("pad: must be EOA MsgCall")
648 }
649 if tokensIn <= 0 {
650 panic("pad: tokensIn must be positive")
651 }
652 l := mustLaunch(id)
653 if l.Status != StatusGraduated {
654 panic("pad: not graduated (use Sell)")
655 }
656 seller := cur.Previous().Address()
657 if balOf(l, seller) < tokensIn {
658 panic("pad: insufficient token balance")
659 }
660
661 gross, newPU, newPT := ammmath.PoolSwapTokenForUgnot(l.PoolUgnot, l.PoolToken, tokensIn)
662 fee := ammmath.ApplyFeeOnOutput(gross, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
663 requireMinOut(fee.Net, minUgnotOut, "ugnot out")
664
665 // Retain fee in pool ugnot (cash stays); user gets net.
666 l.PoolUgnot = newPU + fee.Fee
667 l.PoolToken = newPT
668 l.CreatorFees += fee.Creator
669 protocolFees += fee.Protocol
670
671 addBal(l, seller, -tokensIn)
672 sendUgnot(cur, seller, fee.Net)
673 recordTrade(l, TradeSideSell, fee.Net, tokensIn)
674
675 chain.Emit("SwapSell",
676 "id", id,
677 "seller", seller.String(),
678 "tokens", strconv.FormatInt(tokensIn, 10),
679 "ugnot", strconv.FormatInt(fee.Net, 10),
680 )
681 notifyTrade(cur, seller, id, 1, fee.Net)
682 return fee.Net
683}
684
685// Transfer moves GRC20 tokens between addresses (user-initiated).
686func Transfer(cur realm, id string, to address, amount int64) {
687 requireInit()
688 if !cur.Previous().IsUserCall() {
689 panic("pad: must be EOA MsgCall")
690 }
691 if amount <= 0 {
692 panic("pad: amount must be positive")
693 }
694 if !to.IsValid() {
695 panic("pad: invalid to")
696 }
697 l := mustLaunch(id)
698 from := cur.Previous().Address()
699 if from == to {
700 panic("pad: self transfer")
701 }
702 if l.ledger == nil {
703 panic("pad: no GRC20 ledger")
704 }
705 if err := l.ledger.Transfer(from, to, amount); err != nil {
706 panic("pad: transfer: " + err.Error())
707 }
708 chain.Emit("Transfer", "id", id, "from", from.String(), "to", to.String(),
709 "amount", strconv.FormatInt(amount, 10))
710}
711
712// Approve sets GRC20 allowance so DEX/contracts can TransferFrom.
713func Approve(cur realm, id string, spender address, amount int64) {
714 requireInit()
715 if !cur.Previous().IsUserCall() {
716 panic("pad: must be EOA MsgCall")
717 }
718 if !spender.IsValid() {
719 panic("pad: invalid spender")
720 }
721 l := mustLaunch(id)
722 if l.ledger == nil {
723 panic("pad: no GRC20 ledger")
724 }
725 owner := cur.Previous().Address()
726 if err := l.ledger.Approve(owner, spender, amount); err != nil {
727 panic("pad: approve: " + err.Error())
728 }
729 chain.Emit("Approval", "id", id, "owner", owner.String(), "spender", spender.String(),
730 "amount", strconv.FormatInt(amount, 10))
731}
732
733// TransferFrom spends allowance: spender = MsgCall EOA caller.
734// Enables DEX / routers that hold allowance from Approve.
735func TransferFrom(cur realm, id string, from, to address, amount int64) {
736 requireInit()
737 if !cur.Previous().IsUserCall() {
738 panic("pad: must be EOA MsgCall")
739 }
740 if amount <= 0 {
741 panic("pad: amount must be positive")
742 }
743 if !from.IsValid() || !to.IsValid() {
744 panic("pad: invalid address")
745 }
746 if from == to {
747 panic("pad: self transfer")
748 }
749 l := mustLaunch(id)
750 if l.ledger == nil {
751 panic("pad: no GRC20 ledger")
752 }
753 spender := cur.Previous().Address()
754 if err := l.ledger.TransferFrom(from, spender, to, amount); err != nil {
755 panic("pad: transferFrom: " + err.Error())
756 }
757 chain.Emit("TransferFrom", "id", id, "from", from.String(), "to", to.String(),
758 "spender", spender.String(), "amount", strconv.FormatInt(amount, 10))
759}
760
761// ClaimCreatorFees withdraws accrued creator fees for a launch.
762func ClaimCreatorFees(cur realm, id string) int64 {
763 requireInit()
764 if !cur.Previous().IsUserCall() {
765 panic("pad: must be EOA MsgCall")
766 }
767 l := mustLaunch(id)
768 caller := cur.Previous().Address()
769 if caller != l.Creator {
770 panic("pad: not creator")
771 }
772 amt := l.CreatorFees
773 if amt <= 0 {
774 return 0
775 }
776 l.CreatorFees = 0
777 sendUgnot(cur, caller, amt)
778 chain.Emit("ClaimCreator", "id", id, "amount", strconv.FormatInt(amt, 10))
779 return amt
780}
781
782// ClaimProtocolFees withdraws protocol treasury fees.
783func ClaimProtocolFees(cur realm) int64 {
784 requireInit()
785 if !cur.Previous().IsUserCall() {
786 panic("pad: must be EOA MsgCall")
787 }
788 caller := cur.Previous().Address()
789 if caller != protocolAddr {
790 panic("pad: not protocol")
791 }
792 amt := protocolFees
793 if amt <= 0 {
794 return 0
795 }
796 protocolFees = 0
797 sendUgnot(cur, caller, amt)
798 chain.Emit("ClaimProtocol", "amount", strconv.FormatInt(amt, 10))
799 return amt
800}
801
802// TransferProtocol rotates the protocol fee recipient (current protocol only).
803func TransferProtocol(cur realm, newAddr address) {
804 requireInit()
805 if !cur.Previous().IsUserCall() {
806 panic("pad: must be EOA MsgCall")
807 }
808 if cur.Previous().Address() != protocolAddr {
809 panic("pad: not protocol")
810 }
811 if !newAddr.IsValid() {
812 panic("pad: invalid new protocol address")
813 }
814 if newAddr == protocolAddr {
815 panic("pad: same protocol address")
816 }
817 old := protocolAddr
818 protocolAddr = newAddr
819 chain.Emit("TransferProtocol", "from", old.String(), "to", newAddr.String())
820}
821
822// ProtocolAddress returns the current protocol treasury address.
823func ProtocolAddress() string {
824 return protocolAddr.String()
825}
826
827// --- read helpers (non-crossing) ---
828
829func BalanceOf(id string, owner address) int64 {
830 return balOf(mustLaunch(id), owner)
831}
832
833// ListBuyers returns unique buyer addresses (one per line), capped for query size.
834// Only addresses that bought at least once on this pad (UniqueBuyers). Not full GRC20 holders
835// who received tokens via transfer.
836func ListBuyers(id string) string {
837 l := mustLaunch(id)
838 const maxN = 100
839 out := ""
840 n := 0
841 l.UniqueBuyers.Iterate("", "", func(key string, _ any) bool {
842 if n >= maxN {
843 return true
844 }
845 if out != "" {
846 out += "\n"
847 }
848 out += key
849 n++
850 return false
851 })
852 return out
853}
854
855func GetStatus(id string) int {
856 return mustLaunch(id).Status
857}
858
859func GetRaised(id string) int64 {
860 return mustLaunch(id).RaisedUgnot
861}
862
863func GetPool(id string) (ugnot, token int64) {
864 l := mustLaunch(id)
865 return l.PoolUgnot, l.PoolToken
866}
867
868func GetCreatorFees(id string) int64 {
869 return mustLaunch(id).CreatorFees
870}
871
872func ProtocolFees() int64 {
873 return protocolFees
874}
875
876func LaunchCount() int {
877 return launches.Size()
878}
879
880func ResolveSymbol(symbol string) string {
881 s, ok := bySymbol.Get(symbol).(string)
882 if !ok {
883 return ""
884 }
885 return s
886}
887
888// ListIDs returns newline-separated launch IDs (sorted by AVL key / creation order).
889func ListIDs() string {
890 out := ""
891 launches.Iterate("", "", func(key string, _ any) bool {
892 if out != "" {
893 out += "\n"
894 }
895 out += key
896 return false
897 })
898 return out
899}
900
901// LaunchInfo returns a single-line pipe-delimited summary for UIs/indexers:
902//
903// id|name|symbol|status|raised|sold|buyers|creatorFees|poolUgnot|poolToken|uri|creator|virtualUgnot|virtualToken|created|tokenID|gnoswapReady
904//
905// status: 0=curve 1=graduated; gnoswapReady: 0|1
906func LaunchInfo(id string) string {
907 l := mustLaunch(id)
908 gs := "0"
909 if l.GnoswapReady {
910 gs = "1"
911 }
912 return l.ID + "|" +
913 l.Name + "|" +
914 l.Symbol + "|" +
915 strconv.Itoa(l.Status) + "|" +
916 strconv.FormatInt(l.RaisedUgnot, 10) + "|" +
917 strconv.FormatInt(l.RealSold, 10) + "|" +
918 strconv.Itoa(l.BuyerCount) + "|" +
919 strconv.FormatInt(l.CreatorFees, 10) + "|" +
920 strconv.FormatInt(l.PoolUgnot, 10) + "|" +
921 strconv.FormatInt(l.PoolToken, 10) + "|" +
922 l.URI + "|" +
923 l.Creator.String() + "|" +
924 strconv.FormatInt(l.VirtualUgnot, 10) + "|" +
925 strconv.FormatInt(l.VirtualToken, 10) + "|" +
926 strconv.FormatInt(l.Created, 10) + "|" +
927 l.TokenID + "|" +
928 gs
929}
930
931// ParamsInfo returns fixed MVP parameters for UI display.
932// total|curve|poolSeed|gradThreshold|feeBps|createBond
933func ParamsInfo() string {
934 return strconv.FormatInt(TotalSupply, 10) + "|" +
935 strconv.FormatInt(CurveSupply, 10) + "|" +
936 strconv.FormatInt(PoolSeed, 10) + "|" +
937 strconv.FormatInt(GraduationThreshold, 10) + "|" +
938 strconv.FormatInt(FeeBPS, 10) + "|" +
939 strconv.FormatInt(CreateBondUgnot, 10)
940}
941
942// TradeHistory returns newline-separated chart points:
943//
944// height|side|ugnot|tokens|priceScaled
945//
946// side: 0=buy 1=sell 2=open/graduate. Ordered oldest → newest.
947func TradeHistory(id string) string {
948 l := mustLaunch(id)
949 out := ""
950 l.Trades.Iterate("", "", func(_ string, value any) bool {
951 t := value.(*Trade)
952 line := strconv.FormatInt(t.Height, 10) + "|" +
953 strconv.Itoa(t.Side) + "|" +
954 strconv.FormatInt(t.Ugnot, 10) + "|" +
955 strconv.FormatInt(t.Tokens, 10) + "|" +
956 strconv.FormatInt(t.Price, 10)
957 if out != "" {
958 out += "\n"
959 }
960 out += line
961 return false
962 })
963 return out
964}
965
966// TradeCount returns number of stored chart samples for a launch.
967func TradeCount(id string) int {
968 return mustLaunch(id).Trades.Size()
969}
970
971// resetForTest clears package state between unit tests.
972func resetForTest() {
973 launches = avl.Tree{}
974 bySymbol = avl.Tree{}
975 nextID = 0
976 nextTokenID = 0
977 var zero address
978 protocolAddr = zero
979 protocolFees = 0
980 inited = false
981 pointsEnabled = false
982 testSkipBanker = true // unit tests skip banker; integration/chain tests leave false
983}