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

memepad.gno

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