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

39.50 Kb · 1366 lines
   1// Package pad is gnomemepad: a self-contained meme launchpad for gno.land.
   2//
   3//	Create → GRC20 + bonding curve → Graduate
   4//	  → remaining tokens + raised GNOT seed liquidity
   5//	  → auto Gnoswap CreatePool + full-range Mint when pad has WUGNOT inventory
   6//	    (part of inventory swaps WUGNOT→GNS for CreatePool fee)
   7//	  → else locked internal CPMM fallback (test / missing inventory)
   8//
   9// Tokens are real GRC20 (mint on buy, burn on sell). Hybrid of Pump.fun + permanent LP lock.
  10package padv11
  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	ammmath "gno.land/p/g1mv0052e7r6s09f5t9xsqf00nj3tqsgt9dg52jr/gnomemepad/ammmathv2"
  22	"gno.land/p/nt/avl/v0"
  23	"gno.land/p/nt/seqid/v0"
  24	"gno.land/r/demo/defi/grc20reg"
  25	// bond: create-bond policy (promo / normal). Separate package — pad upgrades
  26	// do not replace bond schedule. Deploy prepare rewrites to personal path.
  27	createbond "gno.land/r/g1mv0052e7r6s09f5t9xsqf00nj3tqsgt9dg52jr/gnomemepad/bond"
  28	// pointsv2: optional trade/create awards (off by default until SetPointsEnabled).
  29	// Deploy prepare rewrites this import to the Sapphire personal-namespace path.
  30	pointsv2 "gno.land/r/g1mv0052e7r6s09f5t9xsqf00nj3tqsgt9dg52jr/gnomemepad/pointsv2"
  31)
  32
  33var (
  34	launches     avl.Tree // id -> *Launch
  35	bySymbol     avl.Tree // symbol -> id string
  36	nextID       seqid.ID
  37	nextTokenID  seqid.ID // GRC20 identity sequence (shared for all launches)
  38	// padAddr: this realm's package address (set in init) — for inventory funding.
  39	padAddr address
  40	// protocolAddr: treasury set by Init (first EOA). Receives protocol fee share.
  41	protocolAddr address
  42	// protocolFees: ugnot still on pad, claimable / pushable to protocolAddr.
  43	protocolFees int64
  44	// protocolFeesPaid: lifetime ugnot already sent to protocolAddr (stats only).
  45	protocolFeesPaid int64
  46	inited           bool
  47	// testSkipBanker: when true, sendUgnot is a no-op (unit tests without funded realm bank).
  48	// Always false in production.
  49	testSkipBanker bool
  50	// pointsEnabled: when true, notify pointsv2 after Create / Buy / Sell / Swap*.
  51	// Admin must also AllowPad(this package) on pointsv2.
  52	pointsEnabled bool
  53)
  54
  55func init(cur realm) {
  56	padAddr = cur.Address()
  57}
  58
  59// Trade is one price sample for charts (capped history per launch).
  60type Trade struct {
  61	Height int64
  62	Side   int // TradeSideBuy | TradeSideSell | TradeSideOpen
  63	Ugnot  int64
  64	Tokens int64
  65	Price  int64 // ugnot per token * 1e6 after the trade
  66}
  67
  68// Launch is one meme market: curve phase then locked pool phase.
  69// token/ledger are unexported so external packages cannot Mint/Burn via field access.
  70type Launch struct {
  71	ID      string
  72	Name    string
  73	Symbol  string
  74	URI     string
  75	Creator address
  76	Status  int
  77	Created int64 // block height
  78
  79	// GRC20 (mint/burn only via pad-owned private ledger)
  80	token   *grc20.Token
  81	ledger  *grc20.PrivateLedger
  82	TokenID string // Token.ID() — registry / Gnoswap identity
  83
  84	// Virtual curve reserves
  85	VirtualUgnot int64
  86	VirtualToken int64
  87	RealSold     int64 // tokens sold on curve (≤ CurveSupply)
  88	RaisedUgnot  int64 // net ugnot collateral in curve (excl. fee vaults)
  89
  90	// Real pool (post-grad); LP permanently locked — no remove path
  91	// PoolToken is pad-internal reserve (not the same as GRC20 total supply).
  92	PoolUgnot int64
  93	PoolToken int64
  94
  95	CreatorFees  int64
  96	BondUgnot    int64
  97	BondRefunded bool
  98	UniqueBuyers avl.Tree // address -> true
  99	BuyerCount   int
 100	// snipeBought: address -> cumulative tokens bought during anti-snipe window
 101	snipeBought avl.Tree
 102
 103	// Gnoswap listing state
 104	GnoswapReady      bool   // graduated; token is listable / listed
 105	GnoswapListed     bool   // true when CreatePool+Mint succeeded on Gnoswap
 106	GnoswapNote       string // human status / failure reason
 107	GnoswapPoolPath   string
 108	GnoswapPositionID uint64
 109	// FeeWugnotSpent / LiqWugnotUsed: inventory spent at graduate (1:1 vs raised ugnot notionally)
 110	FeeWugnotSpent int64
 111	LiqWugnotUsed  int64
 112
 113	// Chart history (ordered AVL keys)
 114	Trades    avl.Tree // tradeKey -> *Trade
 115	NextTrade int64
 116}
 117
 118// Init sets the protocol treasury. First EOA caller becomes fee recipient
 119// (protocolAddr). Protocol trade fees accrue on-pad until ClaimProtocolFees
 120// (treasury only) or PushProtocolFees (anyone may push to treasury).
 121// Creator fees always need ClaimCreatorFees by the token creator.
 122//
 123// Deploy note: call Init with the wallet that should receive protocol fees
 124// (or TransferProtocol later). Gnoswap CreatePool GNS fee is paid to Gnoswap,
 125// not to this treasury.
 126func Init(cur realm) {
 127	if inited {
 128		panic("pad: already initialized")
 129	}
 130	if !cur.Previous().IsUserCall() {
 131		panic("pad: EOA only")
 132	}
 133	protocolAddr = cur.Previous().Address()
 134	inited = true
 135	chain.Emit("Init", "protocol", protocolAddr.String())
 136}
 137
 138// creditProtocol accrues protocol ugnot liability on the pad realm.
 139// Cash stays in pad until ClaimProtocolFees / PushProtocolFees.
 140func creditProtocol(amt int64) {
 141	if amt <= 0 {
 142		return
 143	}
 144	protocolFees += amt
 145}
 146
 147func requireInit() {
 148	if !inited {
 149		panic("pad: call Init first")
 150	}
 151}
 152
 153// SetPointsEnabled toggles pointsv2 notifications (protocol admin only).
 154// pointsv2 must AllowPad(this package path) or OnTrade/OnCreate will panic and revert the trade.
 155func SetPointsEnabled(cur realm, on bool) {
 156	requireInit()
 157	if !cur.Previous().IsUserCall() {
 158		panic("pad: EOA only")
 159	}
 160	if cur.Previous().Address() != protocolAddr {
 161		panic("pad: not protocol")
 162	}
 163	pointsEnabled = on
 164	chain.Emit("SetPointsEnabled", "on", strconv.FormatBool(on))
 165}
 166
 167// PointsEnabled reports whether pad notifies pointsv2 after trades/creates.
 168func PointsEnabled() bool {
 169	return pointsEnabled
 170}
 171
 172func notifyTrade(cur realm, trader address, id string, side int64, volumeUgnot int64) {
 173	if !pointsEnabled || testSkipBanker {
 174		return
 175	}
 176	_ = pointsv2.OnTrade(cross(cur), trader, id, side, volumeUgnot)
 177}
 178
 179func notifyCreate(cur realm, creator address, id string) {
 180	if !pointsEnabled || testSkipBanker {
 181		return
 182	}
 183	_ = pointsv2.OnCreate(cross(cur), creator, id)
 184}
 185
 186func mustLaunch(id string) *Launch {
 187	// Sapphire avl.Tree.Get returns a single any (nil if missing).
 188	l, ok := launches.Get(id).(*Launch)
 189	if !ok {
 190		panic("pad: unknown launch")
 191	}
 192	return l
 193}
 194
 195func balOf(l *Launch, addr address) int64 {
 196	if l == nil || l.token == nil {
 197		return 0
 198	}
 199	return l.token.BalanceOf(addr)
 200}
 201
 202// addBal mints (delta>0) or burns (delta<0) GRC20 via pad-owned PrivateLedger.
 203func addBal(l *Launch, addr address, delta int64) {
 204	if l == nil || l.ledger == nil {
 205		panic("pad: missing GRC20 ledger")
 206	}
 207	if delta == 0 {
 208		return
 209	}
 210	if delta > 0 {
 211		if err := l.ledger.Mint(addr, delta); err != nil {
 212			panic("pad: mint: " + err.Error())
 213		}
 214		return
 215	}
 216	if err := l.ledger.Burn(addr, -delta); err != nil {
 217		panic("pad: burn: " + err.Error())
 218	}
 219}
 220
 221func requireMinOut(got, minOut int64, what string) {
 222	if minOut < 0 {
 223		panic("pad: minOut must be non-negative")
 224	}
 225	if minOut > 0 && got < minOut {
 226		panic("pad: " + what + " below minOut (slippage)")
 227	}
 228}
 229
 230func snipeBoughtOf(l *Launch, buyer address) int64 {
 231	v := l.snipeBought.Get(buyer.String())
 232	if v == nil {
 233		return 0
 234	}
 235	n, ok := v.(int64)
 236	if !ok {
 237		return 0
 238	}
 239	return n
 240}
 241
 242func checkAndAddSnipe(l *Launch, buyer address, tokensOut int64) {
 243	height := runtime.ChainHeight()
 244	if height-l.Created >= AntiSnipeHeights {
 245		return
 246	}
 247	maxTok := TotalSupply * AntiSnipeMaxBuyBPS / 10000
 248	prev := snipeBoughtOf(l, buyer)
 249	if prev+tokensOut > maxTok {
 250		panic("pad: anti-snipe cumulative max buy exceeded")
 251	}
 252	l.snipeBought.Set(buyer.String(), prev+tokensOut)
 253}
 254
 255func sendUgnot(cur realm, to address, amount int64) {
 256	if amount <= 0 {
 257		return
 258	}
 259	if testSkipBanker {
 260		return
 261	}
 262	bk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
 263	bk.SendCoins(cur.Address(), to, chain.Coins{{Denom: DenomUgnot, Amount: amount}})
 264}
 265
 266func requireUserPayment(cur realm) int64 {
 267	if !cur.Previous().IsUserCall() {
 268		panic("pad: must be EOA MsgCall")
 269	}
 270	sent := unsafe.OriginSend().AmountOf(DenomUgnot)
 271	if sent <= 0 {
 272		panic("pad: need ugnot -send")
 273	}
 274	return sent
 275}
 276
 277func noteBuyer(l *Launch, buyer address) {
 278	k := buyer.String()
 279	if l.UniqueBuyers.Has(k) {
 280		return
 281	}
 282	l.UniqueBuyers.Set(k, true)
 283	l.BuyerCount++
 284}
 285
 286func tradeKey(n int64) string {
 287	s := strconv.FormatInt(n, 10)
 288	for len(s) < 12 {
 289		s = "0" + s
 290	}
 291	return s
 292}
 293
 294// spotPriceScaled returns ugnot/token * 1e6 from current curve or pool reserves.
 295func spotPriceScaled(l *Launch) int64 {
 296	if l.Status == StatusGraduated {
 297		if l.PoolToken <= 0 {
 298			return 0
 299		}
 300		return l.PoolUgnot * 1000000 / l.PoolToken
 301	}
 302	if l.VirtualToken <= 0 {
 303		return 0
 304	}
 305	return l.VirtualUgnot * 1000000 / l.VirtualToken
 306}
 307
 308func recordTrade(l *Launch, side int, ugnot, tokens int64) {
 309	l.NextTrade++
 310	t := &Trade{
 311		Height: runtime.ChainHeight(),
 312		Side:   side,
 313		Ugnot:  ugnot,
 314		Tokens: tokens,
 315		Price:  spotPriceScaled(l),
 316	}
 317	l.Trades.Set(tradeKey(l.NextTrade), t)
 318	// Ring buffer: drop oldest while over cap.
 319	for l.Trades.Size() > MaxTradeHistory {
 320		oldest := ""
 321		l.Trades.Iterate("", "", func(k string, _ any) bool {
 322			oldest = k
 323			return true // stop
 324		})
 325		if oldest == "" {
 326			break
 327		}
 328		l.Trades.Remove(oldest)
 329	}
 330}
 331
 332func maybeRefundBond(cur realm, l *Launch) {
 333	if l.BondRefunded || l.BondUgnot <= 0 {
 334		return
 335	}
 336	if l.BuyerCount < BondRefundBuyers {
 337		return
 338	}
 339	// Quality gate: pure sybil micro-buys cannot refund bond.
 340	if l.RaisedUgnot < BondRefundMinRaised {
 341		return
 342	}
 343	if runtime.ChainHeight()-l.Created > BondRefundMaxHeights {
 344		return
 345	}
 346	amt := l.BondUgnot
 347	l.BondUgnot = 0
 348	l.BondRefunded = true
 349	sendUgnot(cur, l.Creator, amt)
 350	chain.Emit("BondRefund", "id", l.ID, "amount", strconv.FormatInt(amt, 10))
 351}
 352
 353// requiredCreateBond returns ugnot the creator must send.
 354// Production: createbond.CurrentBondUgnot() (promo or normal).
 355// Unit tests (testSkipBanker): local CreateBondUgnot constant.
 356func requiredCreateBond() int64 {
 357	if testSkipBanker {
 358		return CreateBondUgnot
 359	}
 360	return createbond.CurrentBondUgnot()
 361}
 362
 363// CreateBondRequired is a public alias for UIs / qeval (same as requiredCreateBond).
 364func CreateBondRequired() int64 {
 365	return requiredCreateBond()
 366}
 367
 368// Create deploys a fair-launch meme. Bond amount from bond realm (or fallback const).
 369// No pre-mint; all tradeable float starts on the bonding curve.
 370func Create(cur realm, name, symbol, uri string) string {
 371	requireInit()
 372	sent := requireUserPayment(cur)
 373	bondNeed := requiredCreateBond()
 374	if bondNeed <= 0 {
 375		panic("pad: create bond misconfigured")
 376	}
 377	if sent < bondNeed {
 378		panic("pad: create bond underpaid")
 379	}
 380	if name == "" || symbol == "" {
 381		panic("pad: name and symbol required")
 382	}
 383	if len(symbol) > 12 {
 384		panic("pad: symbol too long")
 385	}
 386	if bySymbol.Has(symbol) {
 387		panic("pad: symbol taken")
 388	}
 389	extra := sent - bondNeed
 390	if extra > 0 {
 391		creditProtocol(extra)
 392	}
 393
 394	creator := cur.Previous().Address()
 395	id := nextID.Next().String()
 396
 397	// Real GRC20 bound to this pad realm (mint/burn only via pad ledger).
 398	// Decimals=0: whole-token units (matches existing trade amounts).
 399	token, ledger := grc20.NewToken(name, symbol, 0, nextTokenID.Next(), cur)
 400
 401	// Adena (and Gnoswap registries) resolve tokens ONLY via grc20reg under key
 402	// packagePath.SYMBOL — Token.ID() itself is packagePath.SYMBOL.seq and is
 403	// rejected as "Invalid path" if pasted into Adena without registration.
 404	// Skip in unit tests (testSkipBanker); production always registers.
 405	regKey := ""
 406	if !testSkipBanker {
 407		regKey = grc20reg.Register(cross(cur), token, symbol)
 408	}
 409
 410	l := &Launch{
 411		ID:           id,
 412		Name:         name,
 413		Symbol:       symbol,
 414		URI:          uri,
 415		Creator:      creator,
 416		Status:       StatusCurve,
 417		Created:      runtime.ChainHeight(),
 418		token:        token,
 419		ledger:       ledger,
 420		TokenID:      token.ID(),
 421		VirtualUgnot: VirtualUgnot0,
 422		VirtualToken: VirtualToken0,
 423		UniqueBuyers: avl.Tree{},
 424		snipeBought:  avl.Tree{},
 425		BondUgnot:    bondNeed,
 426		Trades:       avl.Tree{},
 427	}
 428	// Open mark for charts (initial virtual spot).
 429	recordTrade(l, TradeSideOpen, 0, 0)
 430	launches.Set(id, l)
 431	bySymbol.Set(symbol, id)
 432
 433	chain.Emit("Created",
 434		"id", id,
 435		"symbol", symbol,
 436		"creator", creator.String(),
 437		"token", l.TokenID,
 438		"reg", regKey,
 439	)
 440	notifyCreate(cur, creator, id)
 441	return id
 442}
 443
 444// AdenaPathOf returns the grc20reg / Adena token key: packagePath.SYMBOL
 445// (Token.ID is packagePath.SYMBOL.seq — Adena rejects that form).
 446func AdenaPathOf(id string) string {
 447	l := mustLaunch(id)
 448	return adenaKeyFromTokenID(l.TokenID, l.Symbol)
 449}
 450
 451// adenaKeyFromTokenID strips the trailing .seq from Token.ID when present.
 452func adenaKeyFromTokenID(tokenID, symbol string) string {
 453	if tokenID == "" {
 454		return ""
 455	}
 456	// Token.ID = packagePath.symbol.seq → registry key = packagePath.symbol
 457	suffix := "." + symbol + "."
 458	if i := strings.LastIndex(tokenID, suffix); i >= 0 {
 459		// packagePath + "." + symbol
 460		return tokenID[:i] + "." + symbol
 461	}
 462	// Already packagePath.symbol or unknown layout
 463	if strings.HasSuffix(tokenID, "."+symbol) {
 464		return tokenID
 465	}
 466	return tokenID
 467}
 468
 469// maxGrossForNetIn finds largest gross ugnot ≤ sentMax whose fee-split netIn ≤ maxNet.
 470func maxGrossForNetIn(maxNet, sentMax int64) int64 {
 471	if maxNet <= 0 || sentMax <= 0 {
 472		return 0
 473	}
 474	lo, hi := int64(0), sentMax
 475	for lo < hi {
 476		mid := (lo + hi + 1) / 2
 477		f := ammmath.ApplyFee(mid, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
 478		net := f.Net + f.Remainder
 479		if net <= maxNet {
 480			lo = mid
 481		} else {
 482			hi = mid - 1
 483		}
 484	}
 485	return lo
 486}
 487
 488// readyToGraduate is true when raise met the threshold, or the entire curve
 489// float is sold (sold-out escape: threshold may be unreachable with bad virtuals).
 490func readyToGraduate(l *Launch) bool {
 491	if l == nil || l.Status != StatusCurve {
 492		return false
 493	}
 494	if l.RaisedUgnot <= 0 {
 495		return false
 496	}
 497	if ammmath.CanGraduate(l.RaisedUgnot, GraduationThreshold) {
 498		return true
 499	}
 500	// Curve exhausted before threshold: still graduate with whatever was raised
 501	// so the market is never permanently stuck on Buy/Graduate.
 502	return l.RealSold >= CurveSupply
 503}
 504
 505// Buy spends -send ugnot on the bonding curve; credits tokens to the caller.
 506// minTokensOut: slippage floor (0 = disabled). Auto-graduates at threshold or sold-out.
 507//
 508// Last-fill (no overshoot):
 509//  1. Cap net ugnot so RaisedUgnot never exceeds GraduationThreshold (refund excess).
 510//  2. Cap by remaining curve tokens (CurveSupply - RealSold), same as before.
 511// Concurrent large buys serialize per-tx; each fill only the remaining raise/tokens.
 512//
 513// If the curve is already sold out (or raise already filled), Buy refunds the full
 514// send and graduates when ready — no panic so users are not stuck mid-tx.
 515func Buy(cur realm, id string, minTokensOut int64) int64 {
 516	requireInit()
 517	sent := requireUserPayment(cur)
 518	l := mustLaunch(id)
 519	if l.Status != StatusCurve {
 520		panic("pad: not on curve (use SwapBuy)")
 521	}
 522	buyer := cur.Previous().Address()
 523
 524	remainingTok := CurveSupply - l.RealSold
 525	needRaise := GraduationThreshold - l.RaisedUgnot
 526	// Already complete: refund payment and graduate (sold-out or raise-filled).
 527	if remainingTok <= 0 || needRaise <= 0 {
 528		if !readyToGraduate(l) {
 529			// Edge: zero raise with empty float should not happen in production.
 530			if remainingTok <= 0 {
 531				panic("pad: curve sold out with no raise")
 532			}
 533			panic("pad: raise filled — call Graduate")
 534		}
 535		if sent > 0 {
 536			sendUgnot(cur, buyer, sent)
 537			chain.Emit("BuyRefund",
 538				"id", id,
 539				"buyer", buyer.String(),
 540				"refund", strconv.FormatInt(sent, 10),
 541			)
 542		}
 543		graduate(cur, l)
 544		return 0
 545	}
 546
 547	usedGross := sent
 548	fee := ammmath.ApplyFee(usedGross, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
 549	// Net enters curve; remainder boosts virtual ugnot (stays as collateral).
 550	netIn := fee.Net + fee.Remainder
 551
 552	// Max net allowed: min(user net, remaining raise, remaining tokens).
 553	maxNet := netIn
 554	if maxNet > needRaise {
 555		maxNet = needRaise
 556	}
 557	maxNetTok := ammmath.MaxNetInForTokenOut(l.VirtualUgnot, l.VirtualToken, remainingTok)
 558	if maxNetTok > 0 && maxNet > maxNetTok {
 559		maxNet = maxNetTok
 560	}
 561	if maxNet <= 0 {
 562		panic("pad: no fill capacity remaining")
 563	}
 564
 565	// Clamp gross + recompute fee when caps bind (last-fill refund path).
 566	if maxNet < netIn {
 567		usedGross = maxGrossForNetIn(maxNet, sent)
 568		if usedGross <= 0 {
 569			panic("pad: buy too small for remaining fill")
 570		}
 571		fee = ammmath.ApplyFee(usedGross, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
 572		netIn = fee.Net + fee.Remainder
 573		if netIn > maxNet {
 574			netIn = maxNet
 575		}
 576	}
 577
 578	tokensOut, newVU, newVT := ammmath.BuyTokens(l.VirtualUgnot, l.VirtualToken, netIn)
 579	// Integer edge: step down net until tokens ≤ remaining curve supply.
 580	for tokensOut > remainingTok && netIn > 1 {
 581		netIn--
 582		tokensOut, newVU, newVT = ammmath.BuyTokens(l.VirtualUgnot, l.VirtualToken, netIn)
 583	}
 584	if tokensOut > remainingTok || tokensOut <= 0 {
 585		panic("pad: cannot fill remaining curve supply")
 586	}
 587	// If net was reduced further, shrink usedGross so refund is correct.
 588	if netIn < maxNet || usedGross < sent {
 589		// Re-derive gross that yields this netIn (≤ sent).
 590		g2 := maxGrossForNetIn(netIn, sent)
 591		if g2 > 0 && g2 < usedGross {
 592			usedGross = g2
 593			fee = ammmath.ApplyFee(usedGross, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
 594			// Keep curve netIn as simulated (may be slightly below fee.Net+Rem).
 595		}
 596	}
 597	// Hard safety: never overshoot graduation raise after this buy.
 598	if l.RaisedUgnot+netIn > GraduationThreshold {
 599		netIn = GraduationThreshold - l.RaisedUgnot
 600		if netIn <= 0 {
 601			panic("pad: raise filled — call Graduate")
 602		}
 603		tokensOut, newVU, newVT = ammmath.BuyTokens(l.VirtualUgnot, l.VirtualToken, netIn)
 604		for tokensOut > remainingTok && netIn > 1 {
 605			netIn--
 606			tokensOut, newVU, newVT = ammmath.BuyTokens(l.VirtualUgnot, l.VirtualToken, netIn)
 607		}
 608		if tokensOut <= 0 {
 609			panic("pad: cannot fill remaining raise")
 610		}
 611		usedGross = maxGrossForNetIn(netIn, sent)
 612		if usedGross <= 0 {
 613			panic("pad: buy too small for remaining raise")
 614		}
 615		fee = ammmath.ApplyFee(usedGross, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
 616	}
 617
 618	refund := sent - usedGross
 619	if refund > 0 {
 620		sendUgnot(cur, buyer, refund)
 621	}
 622
 623	requireMinOut(tokensOut, minTokensOut, "tokens out")
 624	checkAndAddSnipe(l, buyer, tokensOut)
 625
 626	// Mutate only after all checks pass.
 627	l.CreatorFees += fee.Creator
 628	creditProtocol(fee.Protocol)
 629	l.VirtualUgnot = newVU
 630	l.VirtualToken = newVT
 631	l.RealSold += tokensOut
 632	l.RaisedUgnot += netIn
 633	// Invariant: raise never exceeds threshold after Buy.
 634	if l.RaisedUgnot > GraduationThreshold {
 635		panic("pad: raise overshoot invariant")
 636	}
 637
 638	addBal(l, buyer, tokensOut)
 639	noteBuyer(l, buyer)
 640	maybeRefundBond(cur, l)
 641	recordTrade(l, TradeSideBuy, usedGross, tokensOut)
 642
 643	chain.Emit("Buy",
 644		"id", id,
 645		"buyer", buyer.String(),
 646		"ugnot", strconv.FormatInt(usedGross, 10),
 647		"tokens", strconv.FormatInt(tokensOut, 10),
 648	)
 649	if refund > 0 {
 650		chain.Emit("BuyRefund",
 651			"id", id,
 652			"buyer", buyer.String(),
 653			"refund", strconv.FormatInt(refund, 10),
 654		)
 655	}
 656	notifyTrade(cur, buyer, id, 0, usedGross)
 657
 658	if readyToGraduate(l) {
 659		graduate(cur, l)
 660	}
 661	return tokensOut
 662}
 663
 664// RemainingRaiseUgnot is net ugnot still needed to hit GraduationThreshold (0 if met/over).
 665func RemainingRaiseUgnot(id string) int64 {
 666	l := mustLaunch(id)
 667	if l.Status != StatusCurve {
 668		return 0
 669	}
 670	if l.RaisedUgnot >= GraduationThreshold {
 671		return 0
 672	}
 673	return GraduationThreshold - l.RaisedUgnot
 674}
 675
 676// Sell burns curve tokens and pays ugnot (fee on output).
 677// minUgnotOut: slippage floor (0 = disabled).
 678func Sell(cur realm, id string, tokensIn, minUgnotOut int64) int64 {
 679	requireInit()
 680	if !cur.Previous().IsUserCall() {
 681		panic("pad: must be EOA MsgCall")
 682	}
 683	if tokensIn <= 0 {
 684		panic("pad: tokensIn must be positive")
 685	}
 686	l := mustLaunch(id)
 687	if l.Status != StatusCurve {
 688		panic("pad: not on curve (use SwapSell)")
 689	}
 690	seller := cur.Previous().Address()
 691	if balOf(l, seller) < tokensIn {
 692		panic("pad: insufficient token balance")
 693	}
 694
 695	gross, newVU, newVT := ammmath.SellTokens(l.VirtualUgnot, l.VirtualToken, tokensIn)
 696	fee := ammmath.ApplyFeeOnOutput(gross, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
 697	requireMinOut(fee.Net, minUgnotOut, "ugnot out")
 698
 699	// Full gross left virtual reserves; retain fee in virtual ugnot (cash stays in realm).
 700	l.VirtualUgnot = newVU + fee.Fee
 701	l.VirtualToken = newVT
 702	l.RealSold -= tokensIn
 703	if l.RealSold < 0 {
 704		l.RealSold = 0
 705	}
 706
 707	// User receives net; creator+protocol become fee liabilities (leave Raised).
 708	payOut := fee.Net + fee.Creator + fee.Protocol
 709	if l.RaisedUgnot >= payOut {
 710		l.RaisedUgnot -= payOut
 711	} else {
 712		l.RaisedUgnot = 0
 713	}
 714	l.CreatorFees += fee.Creator
 715	creditProtocol(fee.Protocol)
 716
 717	addBal(l, seller, -tokensIn)
 718	sendUgnot(cur, seller, fee.Net)
 719	recordTrade(l, TradeSideSell, fee.Net, tokensIn)
 720
 721	chain.Emit("Sell",
 722		"id", id,
 723		"seller", seller.String(),
 724		"tokens", strconv.FormatInt(tokensIn, 10),
 725		"ugnot", strconv.FormatInt(fee.Net, 10),
 726	)
 727	notifyTrade(cur, seller, id, 1, fee.Net)
 728	return fee.Net
 729}
 730
 731// Graduate permissionlessly moves a ready curve into a permanently locked CPMM.
 732// Ready when RaisedUgnot >= GraduationThreshold, or when RealSold >= CurveSupply
 733// with RaisedUgnot > 0 (sold-out before threshold — escape hatch for unreachable raise).
 734func Graduate(cur realm, id string) {
 735	requireInit()
 736	l := mustLaunch(id)
 737	if l.Status != StatusCurve {
 738		panic("pad: already graduated")
 739	}
 740	if !readyToGraduate(l) {
 741		panic("pad: not ready to graduate (need raise threshold or curve sold out)")
 742	}
 743	graduate(cur, l)
 744}
 745
 746func graduate(cur realm, l *Launch) {
 747	if l.Status != StatusCurve {
 748		return
 749	}
 750	// Liquidity capital = all raised GNOT + every token not already sold to buyers.
 751	// (Formerly only PoolSeed tokens entered the pool; unsold curve supply was dead.)
 752	poolU := l.RaisedUgnot
 753	if poolU <= 0 {
 754		panic("pad: empty pool ugnot")
 755	}
 756	remaining := TotalSupply - l.RealSold
 757	if remaining <= 0 {
 758		panic("pad: no remaining tokens for liquidity")
 759	}
 760	poolT := remaining
 761
 762	l.PoolUgnot = poolU
 763	l.PoolToken = poolT
 764	l.RaisedUgnot = 0
 765	l.VirtualUgnot = 0
 766	l.VirtualToken = 0
 767	l.Status = StatusGraduated
 768
 769	// Forfeit unrefunded bond to protocol at graduation if still locked.
 770	if !l.BondRefunded && l.BondUgnot > 0 {
 771		creditProtocol(l.BondUgnot)
 772		l.BondUgnot = 0
 773		l.BondRefunded = true
 774	}
 775
 776	// Mark graduation on chart at pool spot.
 777	recordTrade(l, TradeSideOpen, poolU, poolT)
 778
 779	// Prefer atomic Gnoswap listing: remaining tokens + raised-sized WUGNOT inventory,
 780	// with a slice of WUGNOT swapped to GNS for CreatePool fee.
 781	// Falls back to locked internal CPMM when inventory/test mode cannot list.
 782	l.GnoswapReady = true
 783	listed := false
 784	if !testSkipBanker {
 785		listed = tryListOnGnoswap(cur, l, poolU, poolT)
 786	}
 787	if !listed {
 788		// Internal CPMM: PoolToken is pad-accounting reserve (not minted GRC20).
 789		// Circulating = user balances; pool side is virtual reserve PoolToken.
 790		if l.GnoswapNote == "" {
 791			l.GnoswapNote = "internal CPMM; fund pad WUGNOT inventory then retry is N/A (already graduated)"
 792		}
 793		chain.Emit("Graduated",
 794			"id", l.ID,
 795			"poolUgnot", strconv.FormatInt(poolU, 10),
 796			"poolToken", strconv.FormatInt(poolT, 10),
 797			"token", l.TokenID,
 798			"gnoswap_listed", "0",
 799		)
 800		return
 801	}
 802	// Listed on Gnoswap: capital is in the CL position (NFT owned by pad).
 803	// Internal SwapBuy/Sell disabled (PoolUgnot/PoolToken kept as listing record).
 804	chain.Emit("Graduated",
 805		"id", l.ID,
 806		"poolUgnot", strconv.FormatInt(poolU, 10),
 807		"poolToken", strconv.FormatInt(poolT, 10),
 808		"token", l.TokenID,
 809		"gnoswap_listed", "1",
 810		"poolPath", l.GnoswapPoolPath,
 811		"positionId", strconv.FormatUint(l.GnoswapPositionID, 10),
 812	)
 813}
 814
 815// TokenIDOf returns the GRC20 Token.ID() for a launch.
 816func TokenIDOf(id string) string {
 817	return mustLaunch(id).TokenID
 818}
 819
 820// GRC20Bank returns the underlying *grc20.Token for interop (metadata / external DEX).
 821// Does not expose PrivateLedger — mint/burn stay pad-only.
 822func GRC20Bank(id string) *grc20.Token {
 823	l := mustLaunch(id)
 824	if l.token == nil {
 825		panic("pad: no token")
 826	}
 827	return l.token
 828}
 829
 830// SwapBuy buys tokens from the graduated pool with -send ugnot.
 831// minTokensOut: slippage floor (0 = disabled).
 832// Disabled when the launch was auto-listed on Gnoswap (trade there instead).
 833func SwapBuy(cur realm, id string, minTokensOut int64) int64 {
 834	requireInit()
 835	sent := requireUserPayment(cur)
 836	l := mustLaunch(id)
 837	if l.Status != StatusGraduated {
 838		panic("pad: not graduated (use Buy)")
 839	}
 840	if l.GnoswapListed {
 841		panic("pad: listed on Gnoswap — trade via router, not pad SwapBuy")
 842	}
 843	buyer := cur.Previous().Address()
 844
 845	fee := ammmath.ApplyFee(sent, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
 846	tokensOut, newPU, newPT := ammmath.PoolSwapUgnotForToken(
 847		l.PoolUgnot, l.PoolToken, fee.Net, fee.Remainder,
 848	)
 849	requireMinOut(tokensOut, minTokensOut, "tokens out")
 850
 851	l.CreatorFees += fee.Creator
 852	creditProtocol(fee.Protocol)
 853	l.PoolUgnot = newPU
 854	l.PoolToken = newPT
 855	addBal(l, buyer, tokensOut)
 856	noteBuyer(l, buyer)
 857	recordTrade(l, TradeSideBuy, sent, tokensOut)
 858
 859	chain.Emit("SwapBuy",
 860		"id", id,
 861		"buyer", buyer.String(),
 862		"ugnot", strconv.FormatInt(sent, 10),
 863		"tokens", strconv.FormatInt(tokensOut, 10),
 864	)
 865	notifyTrade(cur, buyer, id, 0, sent)
 866	return tokensOut
 867}
 868
 869// SwapSell sells tokens into the graduated pool for ugnot.
 870// minUgnotOut: slippage floor (0 = disabled).
 871func SwapSell(cur realm, id string, tokensIn, minUgnotOut int64) int64 {
 872	requireInit()
 873	if !cur.Previous().IsUserCall() {
 874		panic("pad: must be EOA MsgCall")
 875	}
 876	if tokensIn <= 0 {
 877		panic("pad: tokensIn must be positive")
 878	}
 879	l := mustLaunch(id)
 880	if l.Status != StatusGraduated {
 881		panic("pad: not graduated (use Sell)")
 882	}
 883	if l.GnoswapListed {
 884		panic("pad: listed on Gnoswap — trade via router, not pad SwapSell")
 885	}
 886	seller := cur.Previous().Address()
 887	if balOf(l, seller) < tokensIn {
 888		panic("pad: insufficient token balance")
 889	}
 890
 891	gross, newPU, newPT := ammmath.PoolSwapTokenForUgnot(l.PoolUgnot, l.PoolToken, tokensIn)
 892	fee := ammmath.ApplyFeeOnOutput(gross, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
 893	requireMinOut(fee.Net, minUgnotOut, "ugnot out")
 894
 895	// Retain fee in pool ugnot (cash stays); user gets net.
 896	l.PoolUgnot = newPU + fee.Fee
 897	l.PoolToken = newPT
 898	l.CreatorFees += fee.Creator
 899	creditProtocol(fee.Protocol)
 900
 901	addBal(l, seller, -tokensIn)
 902	sendUgnot(cur, seller, fee.Net)
 903	recordTrade(l, TradeSideSell, fee.Net, tokensIn)
 904
 905	chain.Emit("SwapSell",
 906		"id", id,
 907		"seller", seller.String(),
 908		"tokens", strconv.FormatInt(tokensIn, 10),
 909		"ugnot", strconv.FormatInt(fee.Net, 10),
 910	)
 911	notifyTrade(cur, seller, id, 1, fee.Net)
 912	return fee.Net
 913}
 914
 915// Transfer moves GRC20 tokens between addresses (user-initiated).
 916func Transfer(cur realm, id string, to address, amount int64) {
 917	requireInit()
 918	if !cur.Previous().IsUserCall() {
 919		panic("pad: must be EOA MsgCall")
 920	}
 921	if amount <= 0 {
 922		panic("pad: amount must be positive")
 923	}
 924	if !to.IsValid() {
 925		panic("pad: invalid to")
 926	}
 927	l := mustLaunch(id)
 928	from := cur.Previous().Address()
 929	if from == to {
 930		panic("pad: self transfer")
 931	}
 932	if l.ledger == nil {
 933		panic("pad: no GRC20 ledger")
 934	}
 935	if err := l.ledger.Transfer(from, to, amount); err != nil {
 936		panic("pad: transfer: " + err.Error())
 937	}
 938	chain.Emit("Transfer", "id", id, "from", from.String(), "to", to.String(),
 939		"amount", strconv.FormatInt(amount, 10))
 940}
 941
 942// Approve sets GRC20 allowance so DEX/contracts can TransferFrom.
 943func Approve(cur realm, id string, spender address, amount int64) {
 944	requireInit()
 945	if !cur.Previous().IsUserCall() {
 946		panic("pad: must be EOA MsgCall")
 947	}
 948	if !spender.IsValid() {
 949		panic("pad: invalid spender")
 950	}
 951	l := mustLaunch(id)
 952	if l.ledger == nil {
 953		panic("pad: no GRC20 ledger")
 954	}
 955	owner := cur.Previous().Address()
 956	if err := l.ledger.Approve(owner, spender, amount); err != nil {
 957		panic("pad: approve: " + err.Error())
 958	}
 959	chain.Emit("Approval", "id", id, "owner", owner.String(), "spender", spender.String(),
 960		"amount", strconv.FormatInt(amount, 10))
 961}
 962
 963// TransferFrom spends allowance: spender = MsgCall EOA caller.
 964// Enables DEX / routers that hold allowance from Approve.
 965func TransferFrom(cur realm, id string, from, to address, amount int64) {
 966	requireInit()
 967	if !cur.Previous().IsUserCall() {
 968		panic("pad: must be EOA MsgCall")
 969	}
 970	if amount <= 0 {
 971		panic("pad: amount must be positive")
 972	}
 973	if !from.IsValid() || !to.IsValid() {
 974		panic("pad: invalid address")
 975	}
 976	if from == to {
 977		panic("pad: self transfer")
 978	}
 979	l := mustLaunch(id)
 980	if l.ledger == nil {
 981		panic("pad: no GRC20 ledger")
 982	}
 983	spender := cur.Previous().Address()
 984	if err := l.ledger.TransferFrom(from, spender, to, amount); err != nil {
 985		panic("pad: transferFrom: " + err.Error())
 986	}
 987	chain.Emit("TransferFrom", "id", id, "from", from.String(), "to", to.String(),
 988		"spender", spender.String(), "amount", strconv.FormatInt(amount, 10))
 989}
 990
 991// ClaimCreatorFees withdraws accrued creator fees for a launch.
 992// Only the token creator may claim. Fees stay on pad until claimed.
 993func ClaimCreatorFees(cur realm, id string) int64 {
 994	requireInit()
 995	if !cur.Previous().IsUserCall() {
 996		panic("pad: must be EOA MsgCall")
 997	}
 998	l := mustLaunch(id)
 999	caller := cur.Previous().Address()
1000	if caller != l.Creator {
1001		panic("pad: not creator")
1002	}
1003	amt := l.CreatorFees
1004	if amt <= 0 {
1005		return 0
1006	}
1007	l.CreatorFees = 0
1008	sendUgnot(cur, caller, amt)
1009	chain.Emit("ClaimCreator", "id", id, "amount", strconv.FormatInt(amt, 10))
1010	return amt
1011}
1012
1013// payoutProtocolFees sends all pending protocolFees to protocolAddr.
1014// Shared by ClaimProtocolFees and PushProtocolFees.
1015func payoutProtocolFees(cur realm) int64 {
1016	amt := protocolFees
1017	if amt <= 0 {
1018		return 0
1019	}
1020	if !protocolAddr.IsValid() {
1021		panic("pad: protocol address unset")
1022	}
1023	protocolFees = 0
1024	protocolFeesPaid += amt
1025	sendUgnot(cur, protocolAddr, amt)
1026	chain.Emit("ClaimProtocol",
1027		"to", protocolAddr.String(),
1028		"amount", strconv.FormatInt(amt, 10),
1029	)
1030	return amt
1031}
1032
1033// ClaimProtocolFees withdraws pending protocol fees to protocolAddr.
1034// Only the current protocol treasury key may call (same wallet that Init'd,
1035// unless TransferProtocol was used).
1036func ClaimProtocolFees(cur realm) int64 {
1037	requireInit()
1038	if !cur.Previous().IsUserCall() {
1039		panic("pad: must be EOA MsgCall")
1040	}
1041	if cur.Previous().Address() != protocolAddr {
1042		panic("pad: not protocol")
1043	}
1044	return payoutProtocolFees(cur)
1045}
1046
1047// PushProtocolFees sends pending protocol fees to protocolAddr.
1048// Permissionless: anyone may call so treasury can be paid without the protocol
1049// key signing (still only pays the configured protocolAddr).
1050func PushProtocolFees(cur realm) int64 {
1051	requireInit()
1052	if !cur.Previous().IsUserCall() {
1053		panic("pad: must be EOA MsgCall")
1054	}
1055	return payoutProtocolFees(cur)
1056}
1057
1058// TransferProtocol rotates the protocol fee recipient (current protocol only).
1059// Pending protocolFees stay on pad until claimed/pushed to the *new* address.
1060func TransferProtocol(cur realm, newAddr address) {
1061	requireInit()
1062	if !cur.Previous().IsUserCall() {
1063		panic("pad: must be EOA MsgCall")
1064	}
1065	if cur.Previous().Address() != protocolAddr {
1066		panic("pad: not protocol")
1067	}
1068	if !newAddr.IsValid() {
1069		panic("pad: invalid new protocol address")
1070	}
1071	if newAddr == protocolAddr {
1072		panic("pad: same protocol address")
1073	}
1074	old := protocolAddr
1075	protocolAddr = newAddr
1076	chain.Emit("TransferProtocol", "from", old.String(), "to", newAddr.String())
1077}
1078
1079// ProtocolAddress returns the current protocol treasury address (bech32).
1080func ProtocolAddress() string {
1081	return protocolAddr.String()
1082}
1083
1084// ProtocolFeesPaid returns lifetime ugnot already paid out to the treasury.
1085func ProtocolFeesPaid() int64 {
1086	return protocolFeesPaid
1087}
1088
1089// FeeInfo returns protocolAddr|pendingUgnot|paidUgnot for UIs.
1090func FeeInfo() string {
1091	return protocolAddr.String() + "|" +
1092		strconv.FormatInt(protocolFees, 10) + "|" +
1093		strconv.FormatInt(protocolFeesPaid, 10)
1094}
1095
1096// PadAddress returns this pad realm's bech32 package address (fund WUGNOT here).
1097func PadAddress() string {
1098	return padAddr.String()
1099}
1100
1101// AdminInfo is a single-line dashboard snapshot for the ops UI:
1102//
1103//	protocolAddr|pendingFees|paidFees|reservedUgnot|launchCount|pointsOn|inited|padAddr
1104//
1105// pointsOn/inited are 0|1.
1106func AdminInfo() string {
1107	pts := "0"
1108	if pointsEnabled {
1109		pts = "1"
1110	}
1111	ini := "0"
1112	if inited {
1113		ini = "1"
1114	}
1115	return protocolAddr.String() + "|" +
1116		strconv.FormatInt(protocolFees, 10) + "|" +
1117		strconv.FormatInt(protocolFeesPaid, 10) + "|" +
1118		strconv.FormatInt(reservedUgnot(), 10) + "|" +
1119		strconv.Itoa(launches.Size()) + "|" +
1120		pts + "|" +
1121		ini + "|" +
1122		padAddr.String()
1123}
1124
1125// IsProtocol reports whether addr is the current treasury (for UI gating).
1126func IsProtocol(addr string) bool {
1127	if !inited || !protocolAddr.IsValid() {
1128		return false
1129	}
1130	return protocolAddr.String() == addr
1131}
1132
1133// reservedUgnot is ugnot the pad must keep to honor user/creator liabilities
1134// and active markets (curve raised, internal CPMM, bonds, pending fees).
1135func reservedUgnot() int64 {
1136	reserved := protocolFees
1137	launches.Iterate("", "", func(_ string, value any) bool {
1138		l := value.(*Launch)
1139		reserved += l.CreatorFees
1140		if !l.BondRefunded {
1141			reserved += l.BondUgnot
1142		}
1143		if l.Status == StatusCurve {
1144			reserved += l.RaisedUgnot
1145		}
1146		// Internal CPMM (fallback when not Gnoswap-listed) holds real ugnot.
1147		if l.Status == StatusGraduated && !l.GnoswapListed {
1148			reserved += l.PoolUgnot
1149		}
1150		return false
1151	})
1152	return reserved
1153}
1154
1155// ReservedUgnot is ugnot the pad must keep for markets + pending claims.
1156func ReservedUgnot() int64 {
1157	return reservedUgnot()
1158}
1159
1160// freeUgnot reports bank ugnot above reserved liabilities (0 if short/test).
1161func freeUgnot(cur realm) int64 {
1162	if testSkipBanker {
1163		return 0
1164	}
1165	bk := banker.NewBanker(banker.BankerTypeReadonly, cur)
1166	bal := bk.GetCoins(cur.Address()).AmountOf(DenomUgnot)
1167	free := bal - reservedUgnot()
1168	if free < 0 {
1169		return 0
1170	}
1171	return free
1172}
1173
1174// WithdrawProtocolUgnot lets the treasury pull free ugnot from the pad bank
1175// (e.g. raised backlog after Gnoswap list, to re-wrap as WUGNOT inventory).
1176// Capped by free balance; panics if amount > free.
1177func WithdrawProtocolUgnot(cur realm, amount int64) int64 {
1178	requireInit()
1179	if !cur.Previous().IsUserCall() {
1180		panic("pad: must be EOA MsgCall")
1181	}
1182	if cur.Previous().Address() != protocolAddr {
1183		panic("pad: not protocol")
1184	}
1185	if amount <= 0 {
1186		panic("pad: amount must be positive")
1187	}
1188	free := freeUgnot(cur)
1189	if amount > free {
1190		panic("pad: amount exceeds free ugnot (reserved for markets/fees)")
1191	}
1192	sendUgnot(cur, protocolAddr, amount)
1193	chain.Emit("WithdrawProtocolUgnot",
1194		"to", protocolAddr.String(),
1195		"amount", strconv.FormatInt(amount, 10),
1196		"freeLeft", strconv.FormatInt(free-amount, 10),
1197	)
1198	return amount
1199}
1200
1201// --- read helpers (non-crossing) ---
1202
1203func BalanceOf(id string, owner address) int64 {
1204	return balOf(mustLaunch(id), owner)
1205}
1206
1207// ListBuyers returns unique buyer addresses (one per line), capped for query size.
1208// Only addresses that bought at least once on this pad (UniqueBuyers). Not full GRC20 holders
1209// who received tokens via transfer.
1210func ListBuyers(id string) string {
1211	l := mustLaunch(id)
1212	const maxN = 100
1213	out := ""
1214	n := 0
1215	l.UniqueBuyers.Iterate("", "", func(key string, _ any) bool {
1216		if n >= maxN {
1217			return true
1218		}
1219		if out != "" {
1220			out += "\n"
1221		}
1222		out += key
1223		n++
1224		return false
1225	})
1226	return out
1227}
1228
1229func GetStatus(id string) int {
1230	return mustLaunch(id).Status
1231}
1232
1233func GetRaised(id string) int64 {
1234	return mustLaunch(id).RaisedUgnot
1235}
1236
1237func GetPool(id string) (ugnot, token int64) {
1238	l := mustLaunch(id)
1239	return l.PoolUgnot, l.PoolToken
1240}
1241
1242func GetCreatorFees(id string) int64 {
1243	return mustLaunch(id).CreatorFees
1244}
1245
1246func ProtocolFees() int64 {
1247	return protocolFees
1248}
1249
1250func LaunchCount() int {
1251	return launches.Size()
1252}
1253
1254func ResolveSymbol(symbol string) string {
1255	s, ok := bySymbol.Get(symbol).(string)
1256	if !ok {
1257		return ""
1258	}
1259	return s
1260}
1261
1262// ListIDs returns newline-separated launch IDs (sorted by AVL key / creation order).
1263func ListIDs() string {
1264	out := ""
1265	launches.Iterate("", "", func(key string, _ any) bool {
1266		if out != "" {
1267			out += "\n"
1268		}
1269		out += key
1270		return false
1271	})
1272	return out
1273}
1274
1275// LaunchInfo returns a single-line pipe-delimited summary for UIs/indexers:
1276//
1277//	id|name|symbol|status|raised|sold|buyers|creatorFees|poolUgnot|poolToken|uri|creator|virtualUgnot|virtualToken|created|tokenID|gnoswapReady|gnoswapListed|gnoswapPoolPath
1278//
1279// status: 0=curve 1=graduated; gnoswapReady/listed: 0|1
1280func LaunchInfo(id string) string {
1281	l := mustLaunch(id)
1282	gs := "0"
1283	if l.GnoswapReady {
1284		gs = "1"
1285	}
1286	gl := "0"
1287	if l.GnoswapListed {
1288		gl = "1"
1289	}
1290	return l.ID + "|" +
1291		l.Name + "|" +
1292		l.Symbol + "|" +
1293		strconv.Itoa(l.Status) + "|" +
1294		strconv.FormatInt(l.RaisedUgnot, 10) + "|" +
1295		strconv.FormatInt(l.RealSold, 10) + "|" +
1296		strconv.Itoa(l.BuyerCount) + "|" +
1297		strconv.FormatInt(l.CreatorFees, 10) + "|" +
1298		strconv.FormatInt(l.PoolUgnot, 10) + "|" +
1299		strconv.FormatInt(l.PoolToken, 10) + "|" +
1300		l.URI + "|" +
1301		l.Creator.String() + "|" +
1302		strconv.FormatInt(l.VirtualUgnot, 10) + "|" +
1303		strconv.FormatInt(l.VirtualToken, 10) + "|" +
1304		strconv.FormatInt(l.Created, 10) + "|" +
1305		l.TokenID + "|" +
1306		gs + "|" +
1307		gl + "|" +
1308		l.GnoswapPoolPath
1309}
1310
1311// ParamsInfo returns parameters for UI display.
1312// total|curve|poolSeed|gradThreshold|feeBps|createBond
1313// createBond is live from bond realm when not in unit-test mode.
1314func ParamsInfo() string {
1315	return strconv.FormatInt(TotalSupply, 10) + "|" +
1316		strconv.FormatInt(CurveSupply, 10) + "|" +
1317		strconv.FormatInt(PoolSeed, 10) + "|" +
1318		strconv.FormatInt(GraduationThreshold, 10) + "|" +
1319		strconv.FormatInt(FeeBPS, 10) + "|" +
1320		strconv.FormatInt(requiredCreateBond(), 10)
1321}
1322
1323// TradeHistory returns newline-separated chart points:
1324//
1325//	height|side|ugnot|tokens|priceScaled
1326//
1327// side: 0=buy 1=sell 2=open/graduate. Ordered oldest → newest.
1328func TradeHistory(id string) string {
1329	l := mustLaunch(id)
1330	out := ""
1331	l.Trades.Iterate("", "", func(_ string, value any) bool {
1332		t := value.(*Trade)
1333		line := strconv.FormatInt(t.Height, 10) + "|" +
1334			strconv.Itoa(t.Side) + "|" +
1335			strconv.FormatInt(t.Ugnot, 10) + "|" +
1336			strconv.FormatInt(t.Tokens, 10) + "|" +
1337			strconv.FormatInt(t.Price, 10)
1338		if out != "" {
1339			out += "\n"
1340		}
1341		out += line
1342		return false
1343	})
1344	return out
1345}
1346
1347// TradeCount returns number of stored chart samples for a launch.
1348func TradeCount(id string) int {
1349	return mustLaunch(id).Trades.Size()
1350}
1351
1352// resetForTest clears package state between unit tests.
1353func resetForTest() {
1354	launches = avl.Tree{}
1355	bySymbol = avl.Tree{}
1356	nextID = 0
1357	nextTokenID = 0
1358	var zero address
1359	protocolAddr = zero
1360	// padAddr is set in package init — do not clear (realm address is fixed).
1361	protocolFees = 0
1362	protocolFeesPaid = 0
1363	inited = false
1364	pointsEnabled = false
1365	testSkipBanker = true // unit tests skip banker; integration/chain tests leave false
1366}