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