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

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