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

63.75 Kb · 2152 lines
   1// Package pad is gnomemepad: a self-contained meme launchpad for gno.land.
   2//
   3//	CreateWithVenue (bond + venue-aware GNS escrow) -> GRC20 + WUGNOT curve -> Graduate
   4//	  -> internal CPMM; then either:
   5//	     gnoswap: RetryList / RetryListGnoswap (escrowed GNS + WUGNOT inventory)
   6//	     zdex:    ReleaseListSeed -> EOA CreatePool -> ConfirmZdexList
   7//
   8// Production: Buy/Sell/Swap* use WUGNOT (push-pay + prepaid credits).
   9// Payment: user wugnot.Transfer(pad, amount) then Buy.
  10// Create fee: gnoswap preference locks ListFeeGns; zdex preference locks 0.
  11// DefaultListVenue source = zdex; Pearl may SetDefaultListVenue(gnoswap) after deploy.
  12// Unit tests (testSkipBanker): OriginSend ugnot as collateral units.
  13// Tokens are real GRC20 (mint on buy, burn on sell).
  14package pad
  15
  16import (
  17	"chain"
  18	"chain/banker"
  19	"chain/runtime"
  20	"chain/runtime/unsafe"
  21	"strconv"
  22	"strings"
  23
  24	"gno.land/p/nt/grc20/v0"
  25	"gno.land/p/g1n4pl5uc4yt5r96m9w6fmdznx3x0jyg8l6arhmt/gnomi/ammmath"
  26	"gno.land/p/nt/avl/v0"
  27	"gno.land/p/nt/seqid/v0"
  28	"gno.land/r/nt/grc20reg/v0"
  29	"gno.land/r/gnoland/wugnot"
  30	"gno.land/r/gnoswap/gns"
  31	// bond: create-bond policy (promo / normal). Separate package - pad upgrades
  32	// do not replace bond schedule. Deploy prepare rewrites to personal path.
  33	createbond "gno.land/r/g1n4pl5uc4yt5r96m9w6fmdznx3x0jyg8l6arhmt/gnomi/bond"
  34	// pointsv2: optional trade/create awards (off by default until SetPointsEnabled).
  35	// Deploy prepare rewrites this import to the Sapphire personal-namespace path.
  36	pointsv2 "gno.land/r/g1n4pl5uc4yt5r96m9w6fmdznx3x0jyg8l6arhmt/gnomi/pointsv2"
  37)
  38
  39var (
  40	launches     avl.Tree // id -> *Launch
  41	bySymbol     avl.Tree // symbol -> id string
  42	nextID       seqid.ID
  43	nextTokenID  seqid.ID // GRC20 identity sequence (shared for all launches)
  44	// padAddr: this realm's package address (set in init) - for inventory funding.
  45	padAddr address
  46	// protocolAddr: treasury set by Init (first EOA). Receives protocol fee share.
  47	protocolAddr address
  48	// protocolFees: ugnot still on pad, claimable / pushable to protocolAddr.
  49	protocolFees int64
  50	// protocolFeesPaid: lifetime ugnot already sent to protocolAddr (stats only).
  51	protocolFeesPaid int64
  52	inited           bool
  53	// testSkipBanker: when true, sendUgnot is a no-op (unit tests without funded realm bank).
  54	// Always false in production.
  55	testSkipBanker bool
  56	// testForceGnoswapList: unit tests only - stub tryListOnGnoswap succeeds without Sapphire deps.
  57	// Always false in production (gnoswap_list_full ignores it).
  58	testForceGnoswapList bool
  59	// testForceZdexList: unit tests only - stub tryListOnZdex succeeds.
  60	testForceZdexList bool
  61	// pointsEnabled: when true, notify pointsv2 after Create / Buy / Sell / Swap*.
  62	// Admin must also AllowPad(this package) on pointsv2.
  63	pointsEnabled bool
  64	// Live mutable economics (protocol-gated). Seeded from consts in Init.
  65	// Changing graduation mid-flight affects open curve launches' ready/remaining.
  66	graduationUgnot int64
  67	listFeeGnsLive  int64
  68	// wugnotCredit: EOA address string -> int64 prepaid WUGNOT claimable by user.
  69	// totalWugnotCredit: sum of all credits (included in reservedWugnot).
  70	// Flow: Transfer WUGNOT to pad (raises free) -> Buy auto-locks free into credit
  71	// and spends; overpay refund goes back to credit; ClaimWugnot withdraws credit.
  72	wugnotCredit      avl.Tree
  73	totalWugnotCredit int64
  74	// totalListFeeGns: sum of per-launch ListFeeGns escrow (reserved GNS on pad).
  75	// freeGns = gns.BalanceOf(pad) - totalListFeeGns.
  76	totalListFeeGns int64
  77)
  78
  79func init(cur realm) {
  80	padAddr = cur.Address()
  81}
  82
  83// Trade is one price sample for charts (capped history per launch).
  84type Trade struct {
  85	Height int64
  86	Side   int // TradeSideBuy | TradeSideSell | TradeSideOpen
  87	Ugnot  int64
  88	Tokens int64
  89	Price  int64 // ugnot per token * 1e6 after the trade
  90}
  91
  92// Launch is one meme market: curve phase then locked pool phase.
  93// token/ledger are unexported so external packages cannot Mint/Burn via field access.
  94type Launch struct {
  95	ID      string
  96	Name    string
  97	Symbol  string
  98	URI     string
  99	Creator address
 100	Status  int
 101	Created int64 // block height
 102
 103	// GRC20 (mint/burn only via pad-owned private ledger)
 104	token   *grc20.Token
 105	ledger  *grc20.PrivateLedger
 106	TokenID string // Token.ID() - registry / Gnoswap identity
 107
 108	// Virtual curve reserves
 109	VirtualUgnot int64
 110	VirtualToken int64
 111	RealSold     int64 // tokens sold on curve (<= CurveSupply)
 112	RaisedUgnot  int64 // net ugnot collateral in curve (excl. fee vaults)
 113
 114	// Real pool (post-grad); LP permanently locked - no remove path
 115	// PoolToken is pad-internal reserve sized to curve spot (not always all unsold).
 116	// LeftoverTokens = (TotalSupply - RealSold) - PoolToken; minted to pad at list, not LP'd.
 117	PoolUgnot     int64
 118	PoolToken     int64
 119	LeftoverTokens int64
 120
 121	CreatorFees  int64
 122	BondUgnot    int64
 123	BondRefunded bool
 124	UniqueBuyers avl.Tree // address -> true
 125	BuyerCount   int
 126	// snipeBought: address -> cumulative tokens bought during anti-snipe window
 127	snipeBought avl.Tree
 128
 129	// Gnoswap listing state
 130	GnoswapReady      bool   // graduated; token is listable / listed
 131	GnoswapListed     bool   // true when CreatePool+Mint succeeded on Gnoswap
 132	GnoswapNote       string // human status / failure reason
 133	GnoswapPoolPath   string
 134	GnoswapPositionID uint64
 135	// FeeWugnotSpent / LiqWugnotUsed: inventory spent at graduate (1:1 vs raised ugnot notionally)
 136	FeeWugnotSpent int64
 137	LiqWugnotUsed  int64
 138
 139	// ListFeeGns: Create-time GNS escrow for Gnoswap CreatePool fee (padv20+).
 140	// Consumed on successful list; ClaimListFee refunds creator if still unlisted.
 141	ListFeeGns      int64
 142	ListFeeConsumed bool // true after list paid CreatePool fee from escrow
 143
 144	// ListVenue: which DEX adapter succeeded (e.g. "zdex" / "gnoswap"). Empty if unlisted.
 145	ListVenue string
 146	// PreferredVenue: chosen at Create (or SetPreferredVenue). Used when RetryList venueId == "".
 147	PreferredVenue string
 148	// ListSeedReleased: PoolUgnot (WUGNOT) + PoolToken (minted GRC20) sent to creator for EOA zdex CreatePool.
 149	ListSeedReleased bool
 150
 151	// Chart history (ordered AVL keys)
 152	Trades    avl.Tree // tradeKey -> *Trade
 153	NextTrade int64
 154}
 155
 156// Init sets the protocol treasury. First EOA caller becomes fee recipient
 157// (protocolAddr). Protocol trade fees accrue on-pad until ClaimProtocolFees
 158// (treasury only) or PushProtocolFees (anyone may push to treasury).
 159// Creator fees always need ClaimCreatorFees by the token creator.
 160//
 161// Deploy note: call Init with the wallet that should receive protocol fees
 162// (or TransferProtocol later). Gnoswap CreatePool GNS fee is paid to Gnoswap,
 163// not to this treasury.
 164func Init(cur realm) {
 165	if inited {
 166		panic("pad: already initialized")
 167	}
 168	if !cur.Previous().IsUserCall() {
 169		panic("pad: EOA only")
 170	}
 171	protocolAddr = cur.Previous().Address()
 172	graduationUgnot = GraduationThreshold // seed from const default
 173	listFeeGnsLive = ListFeeGns
 174	if graduationUgnot <= 0 {
 175		panic("pad: default graduation misconfigured")
 176	}
 177	ensureListVenuesSeeded()
 178	inited = true
 179	chain.Emit("Init",
 180		"protocol", protocolAddr.String(),
 181		"graduationUgnot", strconv.FormatInt(graduationUgnot, 10),
 182		"listFeeGns", strconv.FormatInt(listFeeGnsLive, 10),
 183		"defaultListVenue", DefaultListVenue(),
 184	)
 185}
 186
 187func requireProtocol(cur realm) {
 188	requireInit()
 189	if !cur.Previous().IsUserCall() {
 190		panic("pad: EOA only")
 191	}
 192	if cur.Previous().Address() != protocolAddr {
 193		panic("pad: not protocol")
 194	}
 195}
 196
 197// graduationThreshold is the live raise target (ugnot). Falls back to const if unset.
 198func graduationThreshold() int64 {
 199	if graduationUgnot > 0 {
 200		return graduationUgnot
 201	}
 202	return GraduationThreshold
 203}
 204
 205// SetGraduationThreshold updates the live raise target (ugnot, 1 GNOT = 1e6).
 206// Protocol/deploy wallet only. Affects open curve launches (remaining raise / ready).
 207func SetGraduationThreshold(cur realm, ugnot int64) {
 208	requireProtocol(cur)
 209	if ugnot <= 0 {
 210		panic("pad: graduation must be positive")
 211	}
 212	old := graduationThreshold()
 213	graduationUgnot = ugnot
 214	chain.Emit("SetGraduationThreshold",
 215		"from", strconv.FormatInt(old, 10),
 216		"to", strconv.FormatInt(ugnot, 10),
 217	)
 218}
 219
 220// GraduationThresholdLive returns the live raise target (ugnot) for UIs/qeval.
 221func GraduationThresholdLive() int64 {
 222	return graduationThreshold()
 223}
 224
 225// SetListFeeGns updates Create-time GNS escrow required for new launches.
 226// Protocol/deploy wallet only. Does not change already-escrowed launches.
 227func SetListFeeGns(cur realm, gns int64) {
 228	requireProtocol(cur)
 229	if gns < 0 {
 230		panic("pad: list fee must be non-negative")
 231	}
 232	old := requiredListFeeGns()
 233	listFeeGnsLive = gns
 234	chain.Emit("SetListFeeGns",
 235		"from", strconv.FormatInt(old, 10),
 236		"to", strconv.FormatInt(gns, 10),
 237	)
 238}
 239
 240// creditProtocol accrues protocol ugnot liability on the pad realm.
 241// Cash stays in pad until ClaimProtocolFees / PushProtocolFees.
 242func creditProtocol(amt int64) {
 243	if amt <= 0 {
 244		return
 245	}
 246	protocolFees += amt
 247}
 248
 249func requireInit() {
 250	if !inited {
 251		panic("pad: call Init first")
 252	}
 253}
 254
 255// SetPointsEnabled toggles pointsv2 notifications (protocol admin only).
 256// pointsv2 must AllowPad(this package path) or OnTrade/OnCreate will panic and revert the trade.
 257func SetPointsEnabled(cur realm, on bool) {
 258	requireProtocol(cur)
 259	pointsEnabled = on
 260	chain.Emit("SetPointsEnabled", "on", strconv.FormatBool(on))
 261}
 262
 263// PointsEnabled reports whether pad notifies pointsv2 after trades/creates.
 264func PointsEnabled() bool {
 265	return pointsEnabled
 266}
 267
 268func notifyTrade(cur realm, trader address, id string, side int64, volumeUgnot int64) {
 269	if !pointsEnabled || testSkipBanker {
 270		return
 271	}
 272	_ = pointsv2.OnTrade(cross(cur), trader, id, side, volumeUgnot)
 273}
 274
 275func notifyCreate(cur realm, creator address, id string) {
 276	if !pointsEnabled || testSkipBanker {
 277		return
 278	}
 279	_ = pointsv2.OnCreate(cross(cur), creator, id)
 280}
 281
 282func mustLaunch(id string) *Launch {
 283	// Sapphire avl.Tree.Get returns a single any (nil if missing).
 284	l, ok := launches.Get(id).(*Launch)
 285	if !ok {
 286		panic("pad: unknown launch")
 287	}
 288	return l
 289}
 290
 291func balOf(l *Launch, addr address) int64 {
 292	if l == nil || l.token == nil {
 293		return 0
 294	}
 295	return l.token.BalanceOf(addr)
 296}
 297
 298// addBal mints (delta>0) or burns (delta<0) GRC20 via pad-owned PrivateLedger.
 299func addBal(l *Launch, addr address, delta int64) {
 300	if l == nil || l.ledger == nil {
 301		panic("pad: missing GRC20 ledger")
 302	}
 303	if delta == 0 {
 304		return
 305	}
 306	if delta > 0 {
 307		if err := l.ledger.Mint(addr, delta); err != nil {
 308			panic("pad: mint: " + err.Error())
 309		}
 310		return
 311	}
 312	if err := l.ledger.Burn(addr, -delta); err != nil {
 313		panic("pad: burn: " + err.Error())
 314	}
 315}
 316
 317func requireMinOut(got, minOut int64, what string) {
 318	if minOut < 0 {
 319		panic("pad: minOut must be non-negative")
 320	}
 321	if minOut > 0 && got < minOut {
 322		panic("pad: " + what + " below minOut (slippage)")
 323	}
 324}
 325
 326func snipeBoughtOf(l *Launch, buyer address) int64 {
 327	v := l.snipeBought.Get(buyer.String())
 328	if v == nil {
 329		return 0
 330	}
 331	n, ok := v.(int64)
 332	if !ok {
 333		return 0
 334	}
 335	return n
 336}
 337
 338func checkAndAddSnipe(l *Launch, buyer address, tokensOut int64) {
 339	height := runtime.ChainHeight()
 340	if height-l.Created >= AntiSnipeHeights {
 341		return
 342	}
 343	maxTok := TotalSupply * AntiSnipeMaxBuyBPS / 10000
 344	prev := snipeBoughtOf(l, buyer)
 345	if prev+tokensOut > maxTok {
 346		panic("pad: anti-snipe cumulative max buy exceeded")
 347	}
 348	l.snipeBought.Set(buyer.String(), prev+tokensOut)
 349}
 350
 351func sendUgnot(cur realm, to address, amount int64) {
 352	if amount <= 0 {
 353		return
 354	}
 355	if testSkipBanker {
 356		return
 357	}
 358	bk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
 359	bk.SendCoins(cur.Address(), to, chain.Coins{{Denom: DenomUgnot, Amount: amount}})
 360}
 361
 362func requireUserPayment(cur realm) int64 {
 363	if !cur.Previous().IsUserCall() {
 364		panic("pad: must be EOA MsgCall")
 365	}
 366	sent := unsafe.OriginSend().AmountOf(DenomUgnot)
 367	if sent <= 0 {
 368		panic("pad: need ugnot -send")
 369	}
 370	return sent
 371}
 372
 373// --- WUGNOT collateral (production curve / internal CPMM) ---
 374
 375// reservedWugnot is WUGNOT already committed (markets/fees/user prepaid credits).
 376// Excludes bank-only liabilities (create bond ugnot).
 377func reservedWugnot() int64 {
 378	reserved := protocolFees + totalWugnotCredit
 379	launches.Iterate("", "", func(_ string, value any) bool {
 380		l := value.(*Launch)
 381		reserved += l.CreatorFees
 382		if l.Status == StatusCurve {
 383			reserved += l.RaisedUgnot
 384		}
 385		// Internal CPMM holds WUGNOT when not listed and seed not released to EOA.
 386		if l.Status == StatusGraduated && !l.GnoswapListed && !l.ListSeedReleased {
 387			reserved += l.PoolUgnot
 388		}
 389		return false
 390	})
 391	return reserved
 392}
 393
 394// freeWugnotOnPad is WUGNOT on pad above reserved commitments.
 395// After user wugnot.Transfer(pad, amt), free rises; Buy locks free into caller credit.
 396func freeWugnotOnPad() int64 {
 397	have := wugnot.BalanceOf(padAddr)
 398	free := have - reservedWugnot()
 399	if free < 0 {
 400		return 0
 401	}
 402	return free
 403}
 404
 405// FreeWugnot is free|have|reserved|totalCredit for UI preflight.
 406func FreeWugnot() string {
 407	have := wugnot.BalanceOf(padAddr)
 408	res := reservedWugnot()
 409	free := have - res
 410	if free < 0 {
 411		free = 0
 412	}
 413	return strconv.FormatInt(free, 10) + "|" +
 414		strconv.FormatInt(have, 10) + "|" +
 415		strconv.FormatInt(res, 10) + "|" +
 416		strconv.FormatInt(totalWugnotCredit, 10)
 417}
 418
 419func prepaidOf(addr address) int64 {
 420	if !addr.IsValid() {
 421		return 0
 422	}
 423	v := wugnotCredit.Get(addr.String())
 424	if v == nil {
 425		return 0
 426	}
 427	n, ok := v.(int64)
 428	if !ok {
 429		return 0
 430	}
 431	return n
 432}
 433
 434func addPrepaid(addr address, amount int64) {
 435	if amount <= 0 || !addr.IsValid() {
 436		return
 437	}
 438	next := prepaidOf(addr) + amount
 439	wugnotCredit.Set(addr.String(), next)
 440	totalWugnotCredit += amount
 441}
 442
 443func subPrepaid(addr address, amount int64) {
 444	if amount <= 0 {
 445		return
 446	}
 447	have := prepaidOf(addr)
 448	if have < amount {
 449		panic("pad: prepaid WUGNOT underfunded")
 450	}
 451	next := have - amount
 452	if next == 0 {
 453		wugnotCredit.Remove(addr.String())
 454	} else {
 455		wugnotCredit.Set(addr.String(), next)
 456	}
 457	totalWugnotCredit -= amount
 458}
 459
 460// PrepaidBalance returns caller's claimable WUGNOT credit on this pad.
 461func PrepaidBalance(cur realm) int64 {
 462	if !cur.Previous().IsUserCall() {
 463		panic("pad: must be EOA MsgCall")
 464	}
 465	return prepaidOf(cur.Previous().Address())
 466}
 467
 468// PrepaidOf returns claimable credit for an address (read helper).
 469func PrepaidOf(addr string) int64 {
 470	return prepaidOf(address(addr))
 471}
 472
 473// ClaimWugnot withdraws all of the caller's prepaid WUGNOT credit to their wallet.
 474// Safe after Buy overpay or after graduate/list — excess stays claimable until claimed.
 475func ClaimWugnot(cur realm) int64 {
 476	requireInit()
 477	if !cur.Previous().IsUserCall() {
 478		panic("pad: must be EOA MsgCall")
 479	}
 480	user := cur.Previous().Address()
 481	amt := prepaidOf(user)
 482	if amt <= 0 {
 483		return 0
 484	}
 485	subPrepaid(user, amt)
 486	payWugnotOut(cur, user, amt)
 487	chain.Emit("ClaimWugnot",
 488		"user", user.String(),
 489		"amount", strconv.FormatInt(amt, 10),
 490	)
 491	return amt
 492}
 493
 494// takeWugnotIn collects amount WUGNOT as payment for Buy/SwapBuy.
 495//
 496// 1) Auto-lock free float into caller's prepaid (user must Transfer to pad first).
 497// 2) Spend amount from prepaid (overpay stays as credit via creditRefund).
 498// No TransferFrom.
 499//
 500// Tests (testSkipBanker): OriginSend ugnot as synthetic collateral units.
 501func takeWugnotIn(cur realm, amount int64) int64 {
 502	if !cur.Previous().IsUserCall() {
 503		panic("pad: must be EOA MsgCall")
 504	}
 505	if testSkipBanker {
 506		// Local tests: OriginSend ugnot stands in for WUGNOT units.
 507		return requireUserPayment(cur)
 508	}
 509	if amount <= 0 {
 510		panic("pad: amountWugnot must be positive")
 511	}
 512	user := cur.Previous().Address()
 513	have := prepaidOf(user)
 514	if have < amount {
 515		need := amount - have
 516		free := freeWugnotOnPad()
 517		if free < need {
 518			havePad := wugnot.BalanceOf(padAddr)
 519			res := reservedWugnot()
 520			panic("pad: Transfer " + strconv.FormatInt(need, 10) +
 521				" more WUGNOT to pad then Buy (free=" + strconv.FormatInt(free, 10) +
 522				" prepaid=" + strconv.FormatInt(have, 10) +
 523				" have=" + strconv.FormatInt(havePad, 10) +
 524				" reserved=" + strconv.FormatInt(res, 10) +
 525				" needTotal=" + strconv.FormatInt(amount, 10) + ")")
 526		}
 527		// Lock free into this caller's prepaid (raises reserved, lowers free).
 528		addPrepaid(user, need)
 529	}
 530	subPrepaid(user, amount)
 531	return amount
 532}
 533
 534// creditRefund keeps unused Buy WUGNOT as claimable prepaid (not instant wallet refund).
 535// User calls ClaimWugnot after buy / after list when convenient.
 536func creditRefund(buyer address, amount int64) {
 537	if testSkipBanker || amount <= 0 {
 538		return
 539	}
 540	addPrepaid(buyer, amount)
 541}
 542
 543// payWugnotOut sends WUGNOT from pad to user (production).
 544// Tests: pay ugnot via banker when testSkipBanker.
 545func payWugnotOut(cur realm, to address, amount int64) {
 546	if amount <= 0 {
 547		return
 548	}
 549	if testSkipBanker {
 550		sendUgnot(cur, to, amount)
 551		return
 552	}
 553	have := wugnot.BalanceOf(cur.Address())
 554	if have < amount {
 555		amount = have
 556	}
 557	if amount > 0 {
 558		wugnot.Transfer(cross(cur), to, amount)
 559	}
 560}
 561
 562func noteBuyer(l *Launch, buyer address) {
 563	k := buyer.String()
 564	if l.UniqueBuyers.Has(k) {
 565		return
 566	}
 567	l.UniqueBuyers.Set(k, true)
 568	l.BuyerCount++
 569}
 570
 571func tradeKey(n int64) string {
 572	s := strconv.FormatInt(n, 10)
 573	for len(s) < 12 {
 574		s = "0" + s
 575	}
 576	return s
 577}
 578
 579// spotPriceScaled returns ugnot/token * 1e6 from current curve or pool reserves.
 580func spotPriceScaled(l *Launch) int64 {
 581	if l.Status == StatusGraduated {
 582		if l.PoolToken <= 0 {
 583			return 0
 584		}
 585		return l.PoolUgnot * 1000000 / l.PoolToken
 586	}
 587	if l.VirtualToken <= 0 {
 588		return 0
 589	}
 590	return l.VirtualUgnot * 1000000 / l.VirtualToken
 591}
 592
 593func recordTrade(l *Launch, side int, ugnot, tokens int64) {
 594	l.NextTrade++
 595	t := &Trade{
 596		Height: runtime.ChainHeight(),
 597		Side:   side,
 598		Ugnot:  ugnot,
 599		Tokens: tokens,
 600		Price:  spotPriceScaled(l),
 601	}
 602	l.Trades.Set(tradeKey(l.NextTrade), t)
 603	// Ring buffer: drop oldest while over cap.
 604	for l.Trades.Size() > MaxTradeHistory {
 605		oldest := ""
 606		l.Trades.Iterate("", "", func(k string, _ any) bool {
 607			oldest = k
 608			return true // stop
 609		})
 610		if oldest == "" {
 611			break
 612		}
 613		l.Trades.Remove(oldest)
 614	}
 615}
 616
 617func maybeRefundBond(cur realm, l *Launch) {
 618	if l.BondRefunded || l.BondUgnot <= 0 {
 619		return
 620	}
 621	if l.BuyerCount < BondRefundBuyers {
 622		return
 623	}
 624	// Quality gate: pure sybil micro-buys cannot refund bond.
 625	if l.RaisedUgnot < BondRefundMinRaised {
 626		return
 627	}
 628	if runtime.ChainHeight()-l.Created > BondRefundMaxHeights {
 629		return
 630	}
 631	amt := l.BondUgnot
 632	l.BondUgnot = 0
 633	l.BondRefunded = true
 634	sendUgnot(cur, l.Creator, amt)
 635	chain.Emit("BondRefund", "id", l.ID, "amount", strconv.FormatInt(amt, 10))
 636}
 637
 638// requiredCreateBond returns ugnot the creator must send.
 639// Production: createbond.CurrentBondUgnot() (promo or normal).
 640// Unit tests (testSkipBanker): local CreateBondUgnot constant.
 641func requiredCreateBond() int64 {
 642	if testSkipBanker {
 643		return CreateBondUgnot
 644	}
 645	return createbond.CurrentBondUgnot()
 646}
 647
 648// CreateBondRequired is a public alias for UIs / qeval (same as requiredCreateBond).
 649func CreateBondRequired() int64 {
 650	return requiredCreateBond()
 651}
 652
 653// requiredListFeeGns returns GNS base units the creator must pre-fund (free on pad).
 654// Live value from SetListFeeGns; falls back to ListFeeGns const.
 655func requiredListFeeGns() int64 {
 656	if listFeeGnsLive > 0 {
 657		return listFeeGnsLive
 658	}
 659	if ListFeeGns > 0 {
 660		return ListFeeGns
 661	}
 662	return 100_000_000
 663}
 664
 665// ListFeeRequired is a public alias for UIs / qeval.
 666func ListFeeRequired() int64 {
 667	return requiredListFeeGns()
 668}
 669
 670// freeGnsOnPad is GNS on pad above per-launch list-fee escrow.
 671func freeGnsOnPad() int64 {
 672	if testSkipBanker {
 673		// Tests skip GNS inventory checks.
 674		return requiredListFeeGns()
 675	}
 676	have := gns.BalanceOf(padAddr)
 677	free := have - totalListFeeGns
 678	if free < 0 {
 679		return 0
 680	}
 681	return free
 682}
 683
 684// FreeGns returns free|have|reserved for UI preflight (GNS list fee).
 685func FreeGns() string {
 686	have := int64(0)
 687	if !testSkipBanker {
 688		have = gns.BalanceOf(padAddr)
 689	}
 690	free := freeGnsOnPad()
 691	return strconv.FormatInt(free, 10) + "|" +
 692		strconv.FormatInt(have, 10) + "|" +
 693		strconv.FormatInt(totalListFeeGns, 10)
 694}
 695
 696// lockListFeeEscrow earmarks free GNS for this launch's CreatePool fee.
 697func lockListFeeEscrow(l *Launch, amt int64) {
 698	if l == nil || amt <= 0 {
 699		return
 700	}
 701	if freeGnsOnPad() < amt {
 702		have := int64(0)
 703		if !testSkipBanker {
 704			have = gns.BalanceOf(padAddr)
 705		}
 706		panic("pad: Transfer " + strconv.FormatInt(amt, 10) +
 707			" GNS to pad then Create (free=" + strconv.FormatInt(freeGnsOnPad(), 10) +
 708			" have=" + strconv.FormatInt(have, 10) +
 709			" reserved=" + strconv.FormatInt(totalListFeeGns, 10) + ")")
 710	}
 711	totalListFeeGns += amt
 712	l.ListFeeGns = amt
 713	l.ListFeeConsumed = false
 714}
 715
 716// listFeeForVenue: gnoswap → ListFeeRequired; zdex / other → 0.
 717func listFeeForVenue(venueId string) int64 {
 718	if normalizeVenueID(venueId) == VenueGnoswap {
 719		return requiredListFeeGns()
 720	}
 721	return 0
 722}
 723
 724// ListFeeRequiredFor is venue-aware Create fee for UI preflight.
 725func ListFeeRequiredFor(venueId string) int64 {
 726	ensureListVenuesSeeded()
 727	return listFeeForVenue(venueId)
 728}
 729
 730// unlockListFeeEscrow clears launch GNS escrow; refund sends GNS back to creator.
 731func unlockListFeeEscrow(cur realm, l *Launch, refund bool) {
 732	if l == nil || l.ListFeeGns <= 0 || l.ListFeeConsumed {
 733		return
 734	}
 735	amt := l.ListFeeGns
 736	l.ListFeeGns = 0
 737	totalListFeeGns -= amt
 738	if totalListFeeGns < 0 {
 739		totalListFeeGns = 0
 740	}
 741	if refund && !testSkipBanker && amt > 0 {
 742		have := gns.BalanceOf(padAddr)
 743		if have < amt {
 744			amt = have
 745		}
 746		if amt > 0 {
 747			gns.Transfer(cross(cur), l.Creator, amt)
 748		}
 749	}
 750	chain.Emit("ListFeeUnlocked",
 751		"id", l.ID,
 752		"amount", strconv.FormatInt(amt, 10),
 753		"refund", strconv.FormatBool(refund),
 754	)
 755}
 756
 757// syncListFeeForVenue adjusts escrow after PreferredVenue change (pre-list only).
 758func syncListFeeForVenue(cur realm, l *Launch, vid string) {
 759	if l == nil || l.ListFeeConsumed || l.GnoswapListed || l.ListVenue != "" {
 760		return
 761	}
 762	want := listFeeForVenue(vid)
 763	if l.ListFeeGns == want {
 764		return
 765	}
 766	if l.ListFeeGns > 0 {
 767		unlockListFeeEscrow(cur, l, true)
 768	}
 769	lockListFeeEscrow(l, want)
 770}
 771
 772// consumeListFeeEscrow clears launch escrow after successful Gnoswap list
 773// (CreatePool spent feeNeed GNS from pad balance).
 774func consumeListFeeEscrow(l *Launch) {
 775	if l == nil || l.ListFeeGns <= 0 {
 776		return
 777	}
 778	amt := l.ListFeeGns
 779	totalListFeeGns -= amt
 780	if totalListFeeGns < 0 {
 781		totalListFeeGns = 0
 782	}
 783	l.ListFeeGns = 0
 784	l.ListFeeConsumed = true
 785	chain.Emit("ListFeeConsumed", "id", l.ID, "amount", strconv.FormatInt(amt, 10))
 786}
 787
 788// ClaimListFee refunds Create-time GNS escrow to the token creator if still unlisted.
 789// Use when graduate/list will not complete or creator abandons listing.
 790func ClaimListFee(cur realm, id string) int64 {
 791	requireInit()
 792	if !cur.Previous().IsUserCall() {
 793		panic("pad: must be EOA MsgCall")
 794	}
 795	l := mustLaunch(id)
 796	caller := cur.Previous().Address()
 797	if caller != l.Creator {
 798		panic("pad: only creator may claim list fee")
 799	}
 800	if l.GnoswapListed {
 801		panic("pad: already listed - list fee spent")
 802	}
 803	if l.ListFeeConsumed {
 804		panic("pad: list fee already consumed")
 805	}
 806	amt := l.ListFeeGns
 807	if amt <= 0 {
 808		return 0
 809	}
 810	l.ListFeeGns = 0
 811	totalListFeeGns -= amt
 812	if totalListFeeGns < 0 {
 813		totalListFeeGns = 0
 814	}
 815	if !testSkipBanker && amt > 0 {
 816		have := gns.BalanceOf(padAddr)
 817		if have < amt {
 818			amt = have
 819		}
 820		if amt > 0 {
 821			gns.Transfer(cross(cur), l.Creator, amt)
 822		}
 823	}
 824	chain.Emit("ClaimListFee",
 825		"id", id,
 826		"creator", l.Creator.String(),
 827		"amount", strconv.FormatInt(amt, 10),
 828	)
 829	return amt
 830}
 831
 832// Create deploys a fair-launch meme. Bond amount from bond realm (or fallback const).
 833// Also locks ListFeeGns free GNS (creator must Transfer GNS to pad first).
 834// No pre-mint; all tradeable float starts on the bonding curve.
 835func Create(cur realm, name, symbol, uri string) string {
 836	requireInit()
 837	sent := requireUserPayment(cur)
 838	bondNeed := requiredCreateBond()
 839	if bondNeed <= 0 {
 840		panic("pad: create bond misconfigured")
 841	}
 842	if sent < bondNeed {
 843		panic("pad: create bond underpaid")
 844	}
 845	if name == "" || symbol == "" {
 846		panic("pad: name and symbol required")
 847	}
 848	if len(symbol) > 12 {
 849		panic("pad: symbol too long")
 850	}
 851	if bySymbol.Has(symbol) {
 852		panic("pad: symbol taken")
 853	}
 854	extra := sent - bondNeed
 855	if extra > 0 {
 856		creditProtocol(extra)
 857	}
 858
 859	creator := cur.Previous().Address()
 860	id := nextID.Next().String()
 861
 862	// Real GRC20 bound to this pad realm (mint/burn only via pad ledger).
 863	// Decimals=0: whole-token units (matches existing trade amounts).
 864	token, ledger := grc20.NewToken(name, symbol, 0, nextTokenID.Next(), cur)
 865
 866	// Adena (and Gnoswap registries) resolve tokens ONLY via grc20reg under key
 867	// packagePath.SYMBOL - Token.ID() itself is packagePath.SYMBOL.seq and is
 868	// rejected as "Invalid path" if pasted into Adena without registration.
 869	// Skip in unit tests (testSkipBanker); production always registers.
 870	regKey := ""
 871	if !testSkipBanker {
 872		regKey = grc20reg.Register(cross(cur), token, symbol)
 873	}
 874
 875	ensureListVenuesSeeded()
 876	pref := DefaultListVenue()
 877	return finishCreate(cur, id, name, symbol, uri, creator, token, ledger, regKey, bondNeed, pref)
 878}
 879
 880// CreateWithVenue is Create + PreferredVenue in one MsgCall (fee escrow follows venue).
 881// venueId: "zdex" → no GNS list escrow; "gnoswap" → ListFeeRequired GNS.
 882func CreateWithVenue(cur realm, name, symbol, uri, venueId string) string {
 883	requireInit()
 884	sent := requireUserPayment(cur)
 885	bondNeed := requiredCreateBond()
 886	if bondNeed <= 0 {
 887		panic("pad: create bond misconfigured")
 888	}
 889	if sent < bondNeed {
 890		panic("pad: create bond underpaid")
 891	}
 892	name = strings.TrimSpace(name)
 893	symbol = strings.TrimSpace(symbol)
 894	uri = strings.TrimSpace(uri)
 895	if name == "" || symbol == "" {
 896		panic("pad: name and symbol required")
 897	}
 898	if len(symbol) > 12 {
 899		panic("pad: symbol too long")
 900	}
 901	if bySymbol.Has(symbol) {
 902		panic("pad: symbol taken")
 903	}
 904	extra := sent - bondNeed
 905	if extra > 0 {
 906		creditProtocol(extra)
 907	}
 908	creator := cur.Previous().Address()
 909	id := nextID.Next().String()
 910	token, ledger := grc20.NewToken(name, symbol, 0, nextTokenID.Next(), cur)
 911	regKey := ""
 912	if !testSkipBanker {
 913		regKey = grc20reg.Register(cross(cur), token, symbol)
 914	}
 915	ensureListVenuesSeeded()
 916	vid := normalizeVenueID(venueId)
 917	if !venueEnabled(vid) {
 918		panic("pad: venue disabled or unknown")
 919	}
 920	return finishCreate(cur, id, name, symbol, uri, creator, token, ledger, regKey, bondNeed, vid)
 921}
 922
 923func finishCreate(
 924	cur realm,
 925	id, name, symbol, uri string,
 926	creator address,
 927	token *grc20.Token,
 928	ledger *grc20.PrivateLedger,
 929	regKey string,
 930	bondNeed int64,
 931	pref string,
 932) string {
 933	l := &Launch{
 934		ID:             id,
 935		Name:           name,
 936		Symbol:         symbol,
 937		URI:            uri,
 938		Creator:        creator,
 939		Status:         StatusCurve,
 940		Created:        runtime.ChainHeight(),
 941		token:          token,
 942		ledger:         ledger,
 943		TokenID:        token.ID(),
 944		VirtualUgnot:   VirtualUgnot0,
 945		VirtualToken:   VirtualToken0,
 946		UniqueBuyers:   avl.Tree{},
 947		snipeBought:    avl.Tree{},
 948		BondUgnot:      bondNeed,
 949		Trades:         avl.Tree{},
 950		PreferredVenue: pref,
 951	}
 952	// Venue-aware list fee: zdex → 0 GNS; gnoswap → ListFeeRequired.
 953	lockListFeeEscrow(l, listFeeForVenue(pref))
 954	recordTrade(l, TradeSideOpen, 0, 0)
 955	launches.Set(id, l)
 956	bySymbol.Set(symbol, id)
 957
 958	chain.Emit("Created",
 959		"id", id,
 960		"symbol", symbol,
 961		"creator", creator.String(),
 962		"token", l.TokenID,
 963		"reg", regKey,
 964		"listFeeGns", strconv.FormatInt(l.ListFeeGns, 10),
 965		"preferredVenue", pref,
 966	)
 967	notifyCreate(cur, creator, id)
 968	return id
 969}
 970
 971// AdenaPathOf returns the grc20reg / Adena token key: packagePath.SYMBOL
 972// (Token.ID is packagePath.SYMBOL.seq - Adena rejects that form).
 973func AdenaPathOf(id string) string {
 974	l := mustLaunch(id)
 975	return adenaKeyFromTokenID(l.TokenID, l.Symbol)
 976}
 977
 978// adenaKeyFromTokenID strips the trailing .seq from Token.ID when present.
 979func adenaKeyFromTokenID(tokenID, symbol string) string {
 980	if tokenID == "" {
 981		return ""
 982	}
 983	// Token.ID = packagePath.symbol.seq -> registry key = packagePath.symbol
 984	suffix := "." + symbol + "."
 985	if i := strings.LastIndex(tokenID, suffix); i >= 0 {
 986		// packagePath + "." + symbol
 987		return tokenID[:i] + "." + symbol
 988	}
 989	// Already packagePath.symbol or unknown layout
 990	if strings.HasSuffix(tokenID, "."+symbol) {
 991		return tokenID
 992	}
 993	return tokenID
 994}
 995
 996// maxGrossForNetIn finds largest gross ugnot <= sentMax whose fee-split netIn <= maxNet.
 997func maxGrossForNetIn(maxNet, sentMax int64) int64 {
 998	if maxNet <= 0 || sentMax <= 0 {
 999		return 0
1000	}
1001	lo, hi := int64(0), sentMax
1002	for lo < hi {
1003		mid := (lo + hi + 1) / 2
1004		f := ammmath.ApplyFee(mid, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
1005		net := f.Net + f.Remainder
1006		if net <= maxNet {
1007			lo = mid
1008		} else {
1009			hi = mid - 1
1010		}
1011	}
1012	return lo
1013}
1014
1015// readyToGraduate is true when raise met the threshold, or the entire curve
1016// float is sold (sold-out escape: threshold may be unreachable with bad virtuals).
1017func readyToGraduate(l *Launch) bool {
1018	if l == nil || l.Status != StatusCurve {
1019		return false
1020	}
1021	if l.RaisedUgnot <= 0 {
1022		return false
1023	}
1024	if ammmath.CanGraduate(l.RaisedUgnot, graduationThreshold()) {
1025		return true
1026	}
1027	// Curve exhausted before threshold: still graduate with whatever was raised
1028	// so the market is never permanently stuck on Buy/Graduate.
1029	return l.RealSold >= CurveSupply
1030}
1031
1032// Buy spends WUGNOT on the bonding curve; credits tokens.
1033// amountWugnot: max to spend (may overpay). UI: Deposit + Transfer(pad, amt) + Buy.
1034// Overpay stays as claimable prepaid — ClaimWugnot anytime (incl. after list).
1035// minTokensOut: slippage floor (0 = disabled). Auto-graduates at threshold or sold-out.
1036//
1037// Production: collateral is real WUGNOT on pad -> Graduate can auto-list Gnoswap.
1038// Tests (testSkipBanker): amountWugnot ignored; OriginSend ugnot is used as units.
1039//
1040// Last-fill (no overshoot):
1041//  1. Cap net so RaisedUgnot never exceeds graduationThreshold() (refund excess WUGNOT).
1042//  2. Cap by remaining curve tokens (CurveSupply - RealSold).
1043//
1044// If the curve is already sold out (or raise already filled), Buy refunds the full
1045// take and graduates when ready - no panic so users are not stuck mid-tx.
1046func Buy(cur realm, id string, amountWugnot, minTokensOut int64) int64 {
1047	requireInit()
1048	sent := takeWugnotIn(cur, amountWugnot)
1049	l := mustLaunch(id)
1050	if l.Status != StatusCurve {
1051		panic("pad: not on curve (use SwapBuy)")
1052	}
1053	buyer := cur.Previous().Address()
1054
1055	remainingTok := CurveSupply - l.RealSold
1056	needRaise := graduationThreshold() - l.RaisedUgnot
1057	// Already complete: refund payment and graduate (sold-out or raise-filled).
1058	if remainingTok <= 0 || needRaise <= 0 {
1059		if !readyToGraduate(l) {
1060			// Edge: zero raise with empty float should not happen in production.
1061			if remainingTok <= 0 {
1062				panic("pad: curve sold out with no raise")
1063			}
1064			panic("pad: raise filled - call Graduate")
1065		}
1066		if sent > 0 {
1067			// Keep as claimable credit (ClaimWugnot) — safe after graduate/list too.
1068			creditRefund(buyer, sent)
1069			chain.Emit("BuyRefund",
1070				"id", id,
1071				"buyer", buyer.String(),
1072				"refund", strconv.FormatInt(sent, 10),
1073				"toCredit", "1",
1074			)
1075		}
1076		graduate(cur, l)
1077		return 0
1078	}
1079
1080	usedGross := sent
1081	fee := ammmath.ApplyFee(usedGross, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
1082	// Net enters curve; remainder boosts virtual ugnot (stays as collateral).
1083	netIn := fee.Net + fee.Remainder
1084
1085	// Max net allowed: min(user net, remaining raise, remaining tokens).
1086	maxNet := netIn
1087	if maxNet > needRaise {
1088		maxNet = needRaise
1089	}
1090	maxNetTok := ammmath.MaxNetInForTokenOut(l.VirtualUgnot, l.VirtualToken, remainingTok)
1091	if maxNetTok > 0 && maxNet > maxNetTok {
1092		maxNet = maxNetTok
1093	}
1094	if maxNet <= 0 {
1095		panic("pad: no fill capacity remaining")
1096	}
1097
1098	// Clamp gross + recompute fee when caps bind (last-fill refund path).
1099	if maxNet < netIn {
1100		usedGross = maxGrossForNetIn(maxNet, sent)
1101		if usedGross <= 0 {
1102			panic("pad: buy too small for remaining fill")
1103		}
1104		fee = ammmath.ApplyFee(usedGross, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
1105		netIn = fee.Net + fee.Remainder
1106		if netIn > maxNet {
1107			netIn = maxNet
1108		}
1109	}
1110
1111	tokensOut, newVU, newVT := ammmath.BuyTokens(l.VirtualUgnot, l.VirtualToken, netIn)
1112	// Integer edge: step down net until tokens <= remaining curve supply.
1113	for tokensOut > remainingTok && netIn > 1 {
1114		netIn--
1115		tokensOut, newVU, newVT = ammmath.BuyTokens(l.VirtualUgnot, l.VirtualToken, netIn)
1116	}
1117	if tokensOut > remainingTok || tokensOut <= 0 {
1118		panic("pad: cannot fill remaining curve supply")
1119	}
1120	// If net was reduced further, shrink usedGross so refund is correct.
1121	if netIn < maxNet || usedGross < sent {
1122		// Re-derive gross that yields this netIn (<= sent).
1123		g2 := maxGrossForNetIn(netIn, sent)
1124		if g2 > 0 && g2 < usedGross {
1125			usedGross = g2
1126			fee = ammmath.ApplyFee(usedGross, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
1127			// Keep curve netIn as simulated (may be slightly below fee.Net+Rem).
1128		}
1129	}
1130	// Hard safety: never overshoot graduation raise after this buy.
1131	if l.RaisedUgnot+netIn > graduationThreshold() {
1132		netIn = graduationThreshold() - l.RaisedUgnot
1133		if netIn <= 0 {
1134			panic("pad: raise filled - call Graduate")
1135		}
1136		tokensOut, newVU, newVT = ammmath.BuyTokens(l.VirtualUgnot, l.VirtualToken, netIn)
1137		for tokensOut > remainingTok && netIn > 1 {
1138			netIn--
1139			tokensOut, newVU, newVT = ammmath.BuyTokens(l.VirtualUgnot, l.VirtualToken, netIn)
1140		}
1141		if tokensOut <= 0 {
1142			panic("pad: cannot fill remaining raise")
1143		}
1144		usedGross = maxGrossForNetIn(netIn, sent)
1145		if usedGross <= 0 {
1146			panic("pad: buy too small for remaining raise")
1147		}
1148		fee = ammmath.ApplyFee(usedGross, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
1149	}
1150
1151	refund := sent - usedGross
1152	if refund > 0 {
1153		// Overpay stays claimable — user ClaimWugnot anytime (incl. after list).
1154		creditRefund(buyer, refund)
1155	}
1156
1157	requireMinOut(tokensOut, minTokensOut, "tokens out")
1158	checkAndAddSnipe(l, buyer, tokensOut)
1159
1160	// Mutate only after all checks pass.
1161	// Fees stay as WUGNOT on pad (liabilities); netIn is raised collateral for LP.
1162	l.CreatorFees += fee.Creator
1163	creditProtocol(fee.Protocol)
1164	l.VirtualUgnot = newVU
1165	l.VirtualToken = newVT
1166	l.RealSold += tokensOut
1167	l.RaisedUgnot += netIn
1168	// Invariant: raise never exceeds threshold after Buy.
1169	if l.RaisedUgnot > graduationThreshold() {
1170		panic("pad: raise overshoot invariant")
1171	}
1172
1173	addBal(l, buyer, tokensOut)
1174	noteBuyer(l, buyer)
1175	maybeRefundBond(cur, l)
1176	recordTrade(l, TradeSideBuy, usedGross, tokensOut)
1177
1178	chain.Emit("Buy",
1179		"id", id,
1180		"buyer", buyer.String(),
1181		"ugnot", strconv.FormatInt(usedGross, 10),
1182		"wugnot", strconv.FormatInt(usedGross, 10),
1183		"tokens", strconv.FormatInt(tokensOut, 10),
1184	)
1185	if refund > 0 {
1186		chain.Emit("BuyRefund",
1187			"id", id,
1188			"buyer", buyer.String(),
1189			"refund", strconv.FormatInt(refund, 10),
1190		)
1191	}
1192	notifyTrade(cur, buyer, id, 0, usedGross)
1193
1194	if readyToGraduate(l) {
1195		graduate(cur, l)
1196	}
1197	return tokensOut
1198}
1199
1200// RemainingRaiseUgnot is net ugnot still needed to hit graduationThreshold() (0 if met/over).
1201func RemainingRaiseUgnot(id string) int64 {
1202	l := mustLaunch(id)
1203	if l.Status != StatusCurve {
1204		return 0
1205	}
1206	if l.RaisedUgnot >= graduationThreshold() {
1207		return 0
1208	}
1209	return graduationThreshold() - l.RaisedUgnot
1210}
1211
1212// Sell burns curve tokens and pays WUGNOT (fee on output).
1213// minWugnotOut: slippage floor (0 = disabled). User may wugnot.Withdraw to ugnot.
1214func Sell(cur realm, id string, tokensIn, minWugnotOut int64) int64 {
1215	requireInit()
1216	if !cur.Previous().IsUserCall() {
1217		panic("pad: must be EOA MsgCall")
1218	}
1219	if tokensIn <= 0 {
1220		panic("pad: tokensIn must be positive")
1221	}
1222	l := mustLaunch(id)
1223	if l.Status != StatusCurve {
1224		panic("pad: not on curve (use SwapSell)")
1225	}
1226	seller := cur.Previous().Address()
1227	if balOf(l, seller) < tokensIn {
1228		panic("pad: insufficient token balance")
1229	}
1230
1231	gross, newVU, newVT := ammmath.SellTokens(l.VirtualUgnot, l.VirtualToken, tokensIn)
1232	fee := ammmath.ApplyFeeOnOutput(gross, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
1233	requireMinOut(fee.Net, minWugnotOut, "wugnot out")
1234
1235	// Full gross left virtual reserves; retain fee in virtual ugnot (cash stays in realm).
1236	l.VirtualUgnot = newVU + fee.Fee
1237	l.VirtualToken = newVT
1238	l.RealSold -= tokensIn
1239	if l.RealSold < 0 {
1240		l.RealSold = 0
1241	}
1242
1243	// User receives net; creator+protocol become fee liabilities (leave Raised).
1244	payOut := fee.Net + fee.Creator + fee.Protocol
1245	if l.RaisedUgnot >= payOut {
1246		l.RaisedUgnot -= payOut
1247	} else {
1248		l.RaisedUgnot = 0
1249	}
1250	l.CreatorFees += fee.Creator
1251	creditProtocol(fee.Protocol)
1252
1253	addBal(l, seller, -tokensIn)
1254	payWugnotOut(cur, seller, fee.Net)
1255	recordTrade(l, TradeSideSell, fee.Net, tokensIn)
1256
1257	chain.Emit("Sell",
1258		"id", id,
1259		"seller", seller.String(),
1260		"tokens", strconv.FormatInt(tokensIn, 10),
1261		"ugnot", strconv.FormatInt(fee.Net, 10),
1262		"wugnot", strconv.FormatInt(fee.Net, 10),
1263	)
1264	notifyTrade(cur, seller, id, 1, fee.Net)
1265	return fee.Net
1266}
1267
1268// Graduate permissionlessly moves a ready curve into a permanently locked CPMM.
1269// Ready when RaisedUgnot >= graduationThreshold(), or when RealSold >= CurveSupply
1270// with RaisedUgnot > 0 (sold-out before threshold - escape hatch for unreachable raise).
1271func Graduate(cur realm, id string) {
1272	requireInit()
1273	l := mustLaunch(id)
1274	if l.Status != StatusCurve {
1275		panic("pad: already graduated")
1276	}
1277	if !readyToGraduate(l) {
1278		panic("pad: not ready to graduate (need raise threshold or curve sold out)")
1279	}
1280	graduate(cur, l)
1281}
1282
1283func graduate(cur realm, l *Launch) {
1284	if l.Status != StatusCurve {
1285		return
1286	}
1287	// Liquidity capital = all raised GNOT. Token side is sized to the current
1288	// bonding-curve spot so internal CPMM / Gnoswap open ~ last curve trade
1289	// (seamless graduate). Dumping ALL unsold tokens made DEX spot << curve exit.
1290	poolU := l.RaisedUgnot
1291	if poolU <= 0 {
1292		panic("pad: empty pool ugnot")
1293	}
1294	remaining := TotalSupply - l.RealSold
1295	if remaining <= 0 {
1296		panic("pad: no remaining tokens for liquidity")
1297	}
1298	// tokensForLP = raised * VirtualToken / VirtualUgnot  (same units as reserves)
1299	poolT := remaining
1300	if l.VirtualUgnot > 0 && l.VirtualToken > 0 {
1301		needed := poolU * l.VirtualToken / l.VirtualUgnot
1302		if needed > 0 && needed < remaining {
1303			poolT = needed
1304		}
1305	}
1306
1307	l.PoolUgnot = poolU
1308	l.PoolToken = poolT
1309	l.LeftoverTokens = remaining - poolT
1310	l.RaisedUgnot = 0
1311	l.VirtualUgnot = 0
1312	l.VirtualToken = 0
1313	l.Status = StatusGraduated
1314
1315	// Forfeit unrefunded bond to protocol at graduation if still locked.
1316	if !l.BondRefunded && l.BondUgnot > 0 {
1317		creditProtocol(l.BondUgnot)
1318		l.BondUgnot = 0
1319		l.BondRefunded = true
1320	}
1321
1322	// Mark graduation on chart at pool spot (matches curve exit when sized above).
1323	recordTrade(l, TradeSideOpen, poolU, poolT)
1324
1325	// NEVER auto-list inside Buy/Graduate.
1326	// Gnoswap CreatePool/Mint does WUGNOT Approve+TransferFrom; realm spender
1327	// frame often panics "insufficient allowance" and REVERTS the entire Buy
1328	// (including the curve fill that triggered graduation). List is a separate
1329	// EOA call: pre-fund pad WUGNOT/GNS then RetryListGnoswap.
1330	// Unit tests may still force list via testForceGnoswapList.
1331	l.GnoswapReady = true
1332	listed := false
1333	if testForceGnoswapList {
1334		listed = listOnGnoswapWithFunding(cur, l, poolU, poolT)
1335	}
1336	if !listed {
1337		// Internal CPMM: PoolToken is pad-accounting reserve (not minted GRC20).
1338		// Circulating = user balances; pool side is virtual reserve PoolToken.
1339		if l.GnoswapNote == "" {
1340			if l.PreferredVenue == VenueZdex {
1341				l.GnoswapNote = "internal CPMM; ReleaseListSeed → EOA zdex CreatePool → ConfirmZdexList"
1342			} else {
1343				l.GnoswapNote = "internal CPMM; Transfer WUGNOT+GNS to pad then RetryListGnoswap"
1344			}
1345		}
1346		chain.Emit("Graduated",
1347			"id", l.ID,
1348			"poolUgnot", strconv.FormatInt(poolU, 10),
1349			"poolToken", strconv.FormatInt(poolT, 10),
1350			"token", l.TokenID,
1351			"gnoswap_listed", "0",
1352		)
1353		return
1354	}
1355	// Listed on Gnoswap (test-only path): capital is in the CL position (NFT owned by pad).
1356	// Internal SwapBuy/Sell disabled (PoolUgnot/PoolToken kept as listing record).
1357	chain.Emit("Graduated",
1358		"id", l.ID,
1359		"poolUgnot", strconv.FormatInt(poolU, 10),
1360		"poolToken", strconv.FormatInt(poolT, 10),
1361		"token", l.TokenID,
1362		"gnoswap_listed", "1",
1363		"poolPath", l.GnoswapPoolPath,
1364		"positionId", strconv.FormatUint(l.GnoswapPositionID, 10),
1365	)
1366}
1367
1368// SetPreferredVenue sets the DEX target used when RetryList(id, "") is called.
1369// Creator or protocol only; locked once listed. Syncs Create-time GNS escrow
1370// (gnoswap locks ListFeeRequired; zdex unlocks/refunds escrow).
1371func SetPreferredVenue(cur realm, id, venueId string) {
1372	requireInit()
1373	if !cur.Previous().IsUserCall() {
1374		panic("pad: must be EOA MsgCall")
1375	}
1376	l := mustLaunch(id)
1377	caller := cur.Previous().Address()
1378	if caller != l.Creator && caller != protocolAddr {
1379		panic("pad: creator or protocol only")
1380	}
1381	if l.GnoswapListed || l.ListVenue != "" {
1382		panic("pad: already listed")
1383	}
1384	ensureListVenuesSeeded()
1385	vid := normalizeVenueID(venueId)
1386	if !venueEnabled(vid) {
1387		panic("pad: venue disabled or unknown")
1388	}
1389	l.PreferredVenue = vid
1390	syncListFeeForVenue(cur, l, vid)
1391	chain.Emit("SetPreferredVenue",
1392		"id", id,
1393		"venue", vid,
1394		"listFeeGns", strconv.FormatInt(l.ListFeeGns, 10),
1395	)
1396}
1397
1398// PreferredVenueOf returns the Create-time / SetPreferredVenue target.
1399func PreferredVenueOf(id string) string {
1400	return mustLaunch(id).PreferredVenue
1401}
1402
1403// ListSeedReleasedOf reports whether ReleaseListSeed already ran.
1404func ListSeedReleasedOf(id string) bool {
1405	return mustLaunch(id).ListSeedReleased
1406}
1407
1408// ReleaseListSeed sends graduated internal-CPMM seed to the creator for EOA zdex CreatePool:
1409//   - PoolUgnot as WUGNOT Transfer to creator (EOA may wugnot.Withdraw → ugnot OriginSend)
1410//   - PoolToken minted as GRC20 to creator (was pad-accounting only)
1411// PoolUgnot/PoolToken kept as listing-size record; reservedWugnot skips via ListSeedReleased.
1412// Disables internal SwapBuy/Sell. Creator or protocol may call; assets always go to creator.
1413func ReleaseListSeed(cur realm, id string) {
1414	requireInit()
1415	if !cur.Previous().IsUserCall() {
1416		panic("pad: must be EOA MsgCall")
1417	}
1418	l := mustLaunch(id)
1419	caller := cur.Previous().Address()
1420	if caller != l.Creator && caller != protocolAddr {
1421		panic("pad: creator or protocol only")
1422	}
1423	if l.Status != StatusGraduated {
1424		panic("pad: not graduated")
1425	}
1426	if l.GnoswapListed || l.ListVenue != "" {
1427		panic("pad: already listed")
1428	}
1429	if l.ListSeedReleased {
1430		panic("pad: list seed already released")
1431	}
1432	poolU := l.PoolUgnot
1433	poolT := l.PoolToken
1434	if poolU <= 0 || poolT <= 0 {
1435		panic("pad: empty internal pool")
1436	}
1437	// Fail-closed: must fully fund creator before killing internal CPMM.
1438	if !testSkipBanker {
1439		have := wugnot.BalanceOf(padAddr)
1440		if have < poolU {
1441			panic("pad: underfunded WUGNOT for list seed release")
1442		}
1443	}
1444	// Mark released before Transfer so reservedWugnot drops in same tx.
1445	l.ListSeedReleased = true
1446	l.PoolUgnot = 0
1447	l.PoolToken = 0
1448	l.LiqWugnotUsed = poolU
1449	l.GnoswapNote = "list seed released to creator for zdex CreatePool; then ConfirmZdexList"
1450	if !testSkipBanker {
1451		wugnot.Transfer(cross(cur), l.Creator, poolU)
1452	} else {
1453		payWugnotOut(cur, l.Creator, poolU)
1454	}
1455	addBal(l, l.Creator, poolT)
1456	chain.Emit("ReleaseListSeed",
1457		"id", id,
1458		"to", l.Creator.String(),
1459		"wugnot", strconv.FormatInt(poolU, 10),
1460		"tokens", strconv.FormatInt(poolT, 10),
1461	)
1462}
1463
1464// ConfirmZdexList marks a launch listed on zdex after EOA CreatePool succeeded.
1465// poolId e.g. "ugnot|SYMBOL" (must be non-empty; no on-chain pool verify).
1466// Creator or protocol. Prefer ReleaseListSeed first when pad still holds LP seed.
1467func ConfirmZdexList(cur realm, id, poolId string) {
1468	requireInit()
1469	if !cur.Previous().IsUserCall() {
1470		panic("pad: must be EOA MsgCall")
1471	}
1472	l := mustLaunch(id)
1473	caller := cur.Previous().Address()
1474	if caller != l.Creator && caller != protocolAddr {
1475		panic("pad: creator or protocol only")
1476	}
1477	if l.Status != StatusGraduated {
1478		panic("pad: not graduated")
1479	}
1480	if l.GnoswapListed || l.ListVenue != "" {
1481		panic("pad: already listed")
1482	}
1483	// Avoid marking listed while pad still holds unreleased LP capital.
1484	if !l.ListSeedReleased && (l.PoolUgnot > 0 || l.PoolToken > 0) {
1485		panic("pad: ReleaseListSeed first (or empty pool)")
1486	}
1487	poolId = strings.TrimSpace(poolId)
1488	if poolId == "" {
1489		panic("pad: empty poolId")
1490	}
1491	l.ListVenue = VenueZdex
1492	l.GnoswapListed = true // UI/compat: "listed on DEX"
1493	l.GnoswapPoolPath = poolId
1494	l.GnoswapPositionID = 0
1495	if l.LiqWugnotUsed == 0 {
1496		l.LiqWugnotUsed = l.PoolUgnot
1497	}
1498	l.GnoswapNote = "confirmed zdex list; pool=" + poolId
1499	chain.Emit("ConfirmZdexList",
1500		"id", id,
1501		"poolPath", poolId,
1502		"symbol", l.Symbol,
1503		"venue", VenueZdex,
1504	)
1505	chain.Emit("ListedRetry",
1506		"id", id,
1507		"venue", VenueZdex,
1508		"poolPath", poolId,
1509		"positionId", "0",
1510		"poolUgnot", strconv.FormatInt(l.PoolUgnot, 10),
1511		"poolToken", strconv.FormatInt(l.PoolToken, 10),
1512	)
1513}
1514
1515// RetryList lists a graduated launch on the chosen venue.
1516// Permissionless EOA MsgCall. Uses PoolUgnot / PoolToken (curve-spot sized).
1517// venueId: empty → PreferredVenue → DefaultListVenue(); unknown/disabled → soft-fail note.
1518func RetryList(cur realm, id, venueId string) bool {
1519	requireInit()
1520	if !cur.Previous().IsUserCall() {
1521		panic("pad: must be EOA MsgCall")
1522	}
1523	l := mustLaunch(id)
1524	if l.Status != StatusGraduated {
1525		panic("pad: not graduated")
1526	}
1527	if l.GnoswapListed || l.ListVenue != "" {
1528		panic("pad: already listed")
1529	}
1530	if l.ListSeedReleased {
1531		panic("pad: list seed released - use ConfirmZdexList after EOA CreatePool")
1532	}
1533	poolU := l.PoolUgnot
1534	poolT := l.PoolToken
1535	if poolU <= 0 || poolT <= 0 {
1536		panic("pad: empty internal pool")
1537	}
1538	raw := strings.TrimSpace(venueId)
1539	if raw == "" && l.PreferredVenue != "" {
1540		raw = l.PreferredVenue
1541	}
1542	vid := normalizeVenueID(raw)
1543	ok := tryListVenue(cur, l, vid, poolU, poolT)
1544	if ok {
1545		chain.Emit("ListedRetry",
1546			"id", l.ID,
1547			"venue", l.ListVenue,
1548			"poolPath", l.GnoswapPoolPath,
1549			"positionId", strconv.FormatUint(l.GnoswapPositionID, 10),
1550			"poolUgnot", strconv.FormatInt(poolU, 10),
1551			"poolToken", strconv.FormatInt(poolT, 10),
1552		)
1553		// Compat event name for indexers that listen for GnoswapListedRetry.
1554		if l.ListVenue == VenueGnoswap {
1555			chain.Emit("GnoswapListedRetry",
1556				"id", l.ID,
1557				"poolPath", l.GnoswapPoolPath,
1558				"positionId", strconv.FormatUint(l.GnoswapPositionID, 10),
1559				"poolUgnot", strconv.FormatInt(poolU, 10),
1560				"poolToken", strconv.FormatInt(poolT, 10),
1561			)
1562		}
1563	} else if l.GnoswapNote == "" {
1564		l.GnoswapNote = "retry list failed - check ListNeed / ListNeedFor; venue=" + vid
1565	}
1566	return ok
1567}
1568
1569// RetryListGnoswap is the Gnoswap-specific wrapper (compat for existing UI).
1570func RetryListGnoswap(cur realm, id string) bool {
1571	return RetryList(cur, id, VenueGnoswap)
1572}
1573
1574// TokenIDOf returns the GRC20 Token.ID() for a launch.
1575func TokenIDOf(id string) string {
1576	return mustLaunch(id).TokenID
1577}
1578
1579// GRC20Bank returns the underlying *grc20.Token for interop (metadata / external DEX).
1580// Does not expose PrivateLedger - mint/burn stay pad-only.
1581func GRC20Bank(id string) *grc20.Token {
1582	l := mustLaunch(id)
1583	if l.token == nil {
1584		panic("pad: no token")
1585	}
1586	return l.token
1587}
1588
1589// SwapBuy buys tokens from the graduated internal pool with WUGNOT.
1590// amountWugnot: max to spend (Approve pad). minTokensOut: slippage (0 = off).
1591// Disabled when listed on Gnoswap (trade via router).
1592func SwapBuy(cur realm, id string, amountWugnot, minTokensOut int64) int64 {
1593	requireInit()
1594	sent := takeWugnotIn(cur, amountWugnot)
1595	l := mustLaunch(id)
1596	if l.Status != StatusGraduated {
1597		panic("pad: not graduated (use Buy)")
1598	}
1599	if l.GnoswapListed || l.ListVenue != "" {
1600		panic("pad: listed on DEX - trade via router, not pad SwapBuy")
1601	}
1602	if l.ListSeedReleased {
1603		panic("pad: list seed released - finish zdex CreatePool + ConfirmZdexList")
1604	}
1605	buyer := cur.Previous().Address()
1606
1607	fee := ammmath.ApplyFee(sent, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
1608	tokensOut, newPU, newPT := ammmath.PoolSwapUgnotForToken(
1609		l.PoolUgnot, l.PoolToken, fee.Net, fee.Remainder,
1610	)
1611	requireMinOut(tokensOut, minTokensOut, "tokens out")
1612
1613	// Refund unused gross if pool math used less (rare); fees from sent.
1614	// Pool swap uses fee.Net into pool; full sent stays as fee+pool contribution.
1615	l.CreatorFees += fee.Creator
1616	creditProtocol(fee.Protocol)
1617	l.PoolUgnot = newPU
1618	l.PoolToken = newPT
1619	addBal(l, buyer, tokensOut)
1620	noteBuyer(l, buyer)
1621	recordTrade(l, TradeSideBuy, sent, tokensOut)
1622
1623	chain.Emit("SwapBuy",
1624		"id", id,
1625		"buyer", buyer.String(),
1626		"ugnot", strconv.FormatInt(sent, 10),
1627		"wugnot", strconv.FormatInt(sent, 10),
1628		"tokens", strconv.FormatInt(tokensOut, 10),
1629	)
1630	notifyTrade(cur, buyer, id, 0, sent)
1631	return tokensOut
1632}
1633
1634// SwapSell sells tokens into the graduated pool for WUGNOT.
1635// minWugnotOut: slippage floor (0 = disabled).
1636func SwapSell(cur realm, id string, tokensIn, minWugnotOut int64) int64 {
1637	requireInit()
1638	if !cur.Previous().IsUserCall() {
1639		panic("pad: must be EOA MsgCall")
1640	}
1641	if tokensIn <= 0 {
1642		panic("pad: tokensIn must be positive")
1643	}
1644	l := mustLaunch(id)
1645	if l.Status != StatusGraduated {
1646		panic("pad: not graduated (use Sell)")
1647	}
1648	if l.GnoswapListed || l.ListVenue != "" {
1649		panic("pad: listed on DEX - trade via router, not pad SwapSell")
1650	}
1651	if l.ListSeedReleased {
1652		panic("pad: list seed released - finish zdex CreatePool + ConfirmZdexList")
1653	}
1654	seller := cur.Previous().Address()
1655	if balOf(l, seller) < tokensIn {
1656		panic("pad: insufficient token balance")
1657	}
1658
1659	gross, newPU, newPT := ammmath.PoolSwapTokenForUgnot(l.PoolUgnot, l.PoolToken, tokensIn)
1660	fee := ammmath.ApplyFeeOnOutput(gross, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
1661	requireMinOut(fee.Net, minWugnotOut, "wugnot out")
1662
1663	// Retain fee in pool ugnot (cash stays); user gets net WUGNOT.
1664	l.PoolUgnot = newPU + fee.Fee
1665	l.PoolToken = newPT
1666	l.CreatorFees += fee.Creator
1667	creditProtocol(fee.Protocol)
1668
1669	addBal(l, seller, -tokensIn)
1670	payWugnotOut(cur, seller, fee.Net)
1671	recordTrade(l, TradeSideSell, fee.Net, tokensIn)
1672
1673	chain.Emit("SwapSell",
1674		"id", id,
1675		"seller", seller.String(),
1676		"tokens", strconv.FormatInt(tokensIn, 10),
1677		"ugnot", strconv.FormatInt(fee.Net, 10),
1678		"wugnot", strconv.FormatInt(fee.Net, 10),
1679	)
1680	notifyTrade(cur, seller, id, 1, fee.Net)
1681	return fee.Net
1682}
1683
1684// Transfer moves GRC20 tokens between addresses (user-initiated).
1685func Transfer(cur realm, id string, to address, amount int64) {
1686	requireInit()
1687	if !cur.Previous().IsUserCall() {
1688		panic("pad: must be EOA MsgCall")
1689	}
1690	if amount <= 0 {
1691		panic("pad: amount must be positive")
1692	}
1693	if !to.IsValid() {
1694		panic("pad: invalid to")
1695	}
1696	l := mustLaunch(id)
1697	from := cur.Previous().Address()
1698	if from == to {
1699		panic("pad: self transfer")
1700	}
1701	if l.ledger == nil {
1702		panic("pad: no GRC20 ledger")
1703	}
1704	if err := l.ledger.Transfer(from, to, amount); err != nil {
1705		panic("pad: transfer: " + err.Error())
1706	}
1707	chain.Emit("Transfer", "id", id, "from", from.String(), "to", to.String(),
1708		"amount", strconv.FormatInt(amount, 10))
1709}
1710
1711// Approve sets GRC20 allowance so DEX/contracts can TransferFrom.
1712func Approve(cur realm, id string, spender address, amount int64) {
1713	requireInit()
1714	if !cur.Previous().IsUserCall() {
1715		panic("pad: must be EOA MsgCall")
1716	}
1717	if !spender.IsValid() {
1718		panic("pad: invalid spender")
1719	}
1720	l := mustLaunch(id)
1721	if l.ledger == nil {
1722		panic("pad: no GRC20 ledger")
1723	}
1724	owner := cur.Previous().Address()
1725	if err := l.ledger.Approve(owner, spender, amount); err != nil {
1726		panic("pad: approve: " + err.Error())
1727	}
1728	chain.Emit("Approval", "id", id, "owner", owner.String(), "spender", spender.String(),
1729		"amount", strconv.FormatInt(amount, 10))
1730}
1731
1732// TransferFrom spends allowance: spender = MsgCall EOA caller.
1733// Enables DEX / routers that hold allowance from Approve.
1734func TransferFrom(cur realm, id string, from, to address, amount int64) {
1735	requireInit()
1736	if !cur.Previous().IsUserCall() {
1737		panic("pad: must be EOA MsgCall")
1738	}
1739	if amount <= 0 {
1740		panic("pad: amount must be positive")
1741	}
1742	if !from.IsValid() || !to.IsValid() {
1743		panic("pad: invalid address")
1744	}
1745	if from == to {
1746		panic("pad: self transfer")
1747	}
1748	l := mustLaunch(id)
1749	if l.ledger == nil {
1750		panic("pad: no GRC20 ledger")
1751	}
1752	spender := cur.Previous().Address()
1753	if err := l.ledger.TransferFrom(from, spender, to, amount); err != nil {
1754		panic("pad: transferFrom: " + err.Error())
1755	}
1756	chain.Emit("TransferFrom", "id", id, "from", from.String(), "to", to.String(),
1757		"spender", spender.String(), "amount", strconv.FormatInt(amount, 10))
1758}
1759
1760// ClaimCreatorFees withdraws accrued creator fees for a launch.
1761// Only the token creator may claim. Fees stay on pad until claimed.
1762func ClaimCreatorFees(cur realm, id string) int64 {
1763	requireInit()
1764	if !cur.Previous().IsUserCall() {
1765		panic("pad: must be EOA MsgCall")
1766	}
1767	l := mustLaunch(id)
1768	caller := cur.Previous().Address()
1769	if caller != l.Creator {
1770		panic("pad: not creator")
1771	}
1772	amt := l.CreatorFees
1773	if amt <= 0 {
1774		return 0
1775	}
1776	l.CreatorFees = 0
1777	// Production: fees accrue as WUGNOT on pad. Tests pay ugnot via payWugnotOut.
1778	payWugnotOut(cur, caller, amt)
1779	chain.Emit("ClaimCreator", "id", id, "amount", strconv.FormatInt(amt, 10))
1780	return amt
1781}
1782
1783// payoutProtocolFees sends all pending protocolFees to protocolAddr.
1784// Shared by ClaimProtocolFees and PushProtocolFees.
1785func payoutProtocolFees(cur realm) int64 {
1786	amt := protocolFees
1787	if amt <= 0 {
1788		return 0
1789	}
1790	if !protocolAddr.IsValid() {
1791		panic("pad: protocol address unset")
1792	}
1793	protocolFees = 0
1794	protocolFeesPaid += amt
1795	payWugnotOut(cur, protocolAddr, amt)
1796	chain.Emit("ClaimProtocol",
1797		"to", protocolAddr.String(),
1798		"amount", strconv.FormatInt(amt, 10),
1799	)
1800	return amt
1801}
1802
1803// ClaimProtocolFees withdraws pending protocol fees to protocolAddr.
1804// Only the current protocol treasury key may call (same wallet that Init'd,
1805// unless TransferProtocol was used).
1806func ClaimProtocolFees(cur realm) int64 {
1807	requireInit()
1808	if !cur.Previous().IsUserCall() {
1809		panic("pad: must be EOA MsgCall")
1810	}
1811	if cur.Previous().Address() != protocolAddr {
1812		panic("pad: not protocol")
1813	}
1814	return payoutProtocolFees(cur)
1815}
1816
1817// PushProtocolFees sends pending protocol fees to protocolAddr.
1818// Permissionless: anyone may call so treasury can be paid without the protocol
1819// key signing (still only pays the configured protocolAddr).
1820func PushProtocolFees(cur realm) int64 {
1821	requireInit()
1822	if !cur.Previous().IsUserCall() {
1823		panic("pad: must be EOA MsgCall")
1824	}
1825	return payoutProtocolFees(cur)
1826}
1827
1828// TransferProtocol rotates the protocol fee recipient (current protocol only).
1829// Pending protocolFees stay on pad until claimed/pushed to the *new* address.
1830func TransferProtocol(cur realm, newAddr address) {
1831	requireInit()
1832	if !cur.Previous().IsUserCall() {
1833		panic("pad: must be EOA MsgCall")
1834	}
1835	if cur.Previous().Address() != protocolAddr {
1836		panic("pad: not protocol")
1837	}
1838	if !newAddr.IsValid() {
1839		panic("pad: invalid new protocol address")
1840	}
1841	if newAddr == protocolAddr {
1842		panic("pad: same protocol address")
1843	}
1844	old := protocolAddr
1845	protocolAddr = newAddr
1846	chain.Emit("TransferProtocol", "from", old.String(), "to", newAddr.String())
1847}
1848
1849// ProtocolAddress returns the current protocol treasury address (bech32).
1850func ProtocolAddress() string {
1851	return protocolAddr.String()
1852}
1853
1854// ProtocolFeesPaid returns lifetime ugnot already paid out to the treasury.
1855func ProtocolFeesPaid() int64 {
1856	return protocolFeesPaid
1857}
1858
1859// FeeInfo returns protocolAddr|pendingUgnot|paidUgnot for UIs.
1860func FeeInfo() string {
1861	return protocolAddr.String() + "|" +
1862		strconv.FormatInt(protocolFees, 10) + "|" +
1863		strconv.FormatInt(protocolFeesPaid, 10)
1864}
1865
1866// PadAddress returns this pad realm's bech32 package address (fund WUGNOT here).
1867func PadAddress() string {
1868	return padAddr.String()
1869}
1870
1871// AdminInfo is a single-line dashboard snapshot for the ops UI:
1872//
1873//	protocolAddr|pendingFees|paidFees|reservedUgnot|launchCount|pointsOn|inited|padAddr
1874//
1875// pointsOn/inited are 0|1.
1876func AdminInfo() string {
1877	pts := "0"
1878	if pointsEnabled {
1879		pts = "1"
1880	}
1881	ini := "0"
1882	if inited {
1883		ini = "1"
1884	}
1885	return protocolAddr.String() + "|" +
1886		strconv.FormatInt(protocolFees, 10) + "|" +
1887		strconv.FormatInt(protocolFeesPaid, 10) + "|" +
1888		strconv.FormatInt(reservedUgnot(), 10) + "|" +
1889		strconv.Itoa(launches.Size()) + "|" +
1890		pts + "|" +
1891		ini + "|" +
1892		padAddr.String()
1893}
1894
1895// IsProtocol reports whether addr is the current treasury (for UI gating).
1896func IsProtocol(addr string) bool {
1897	if !inited || !protocolAddr.IsValid() {
1898		return false
1899	}
1900	return protocolAddr.String() == addr
1901}
1902
1903// reservedUgnot is ugnot the pad must keep to honor user/creator liabilities
1904// and active markets (curve raised, internal CPMM, bonds, pending fees).
1905func reservedUgnot() int64 {
1906	reserved := protocolFees
1907	launches.Iterate("", "", func(_ string, value any) bool {
1908		l := value.(*Launch)
1909		reserved += l.CreatorFees
1910		if !l.BondRefunded {
1911			reserved += l.BondUgnot
1912		}
1913		if l.Status == StatusCurve {
1914			reserved += l.RaisedUgnot
1915		}
1916		// Internal CPMM (fallback when not listed / seed not released).
1917		if l.Status == StatusGraduated && !l.GnoswapListed && !l.ListSeedReleased {
1918			reserved += l.PoolUgnot
1919		}
1920		return false
1921	})
1922	return reserved
1923}
1924
1925// ReservedUgnot is ugnot the pad must keep for markets + pending claims.
1926func ReservedUgnot() int64 {
1927	return reservedUgnot()
1928}
1929
1930// freeUgnot reports bank ugnot above reserved liabilities (0 if short/test).
1931func freeUgnot(cur realm) int64 {
1932	if testSkipBanker {
1933		return 0
1934	}
1935	bk := banker.NewBanker(banker.BankerTypeReadonly, cur)
1936	bal := bk.GetCoins(cur.Address()).AmountOf(DenomUgnot)
1937	free := bal - reservedUgnot()
1938	if free < 0 {
1939		return 0
1940	}
1941	return free
1942}
1943
1944// WithdrawProtocolUgnot lets the treasury pull free ugnot from the pad bank
1945// (e.g. raised backlog after Gnoswap list, to re-wrap as WUGNOT inventory).
1946// Capped by free balance; panics if amount > free.
1947func WithdrawProtocolUgnot(cur realm, amount int64) int64 {
1948	requireInit()
1949	if !cur.Previous().IsUserCall() {
1950		panic("pad: must be EOA MsgCall")
1951	}
1952	if cur.Previous().Address() != protocolAddr {
1953		panic("pad: not protocol")
1954	}
1955	if amount <= 0 {
1956		panic("pad: amount must be positive")
1957	}
1958	free := freeUgnot(cur)
1959	if amount > free {
1960		panic("pad: amount exceeds free ugnot (reserved for markets/fees)")
1961	}
1962	sendUgnot(cur, protocolAddr, amount)
1963	chain.Emit("WithdrawProtocolUgnot",
1964		"to", protocolAddr.String(),
1965		"amount", strconv.FormatInt(amount, 10),
1966		"freeLeft", strconv.FormatInt(free-amount, 10),
1967	)
1968	return amount
1969}
1970
1971// --- read helpers (non-crossing) ---
1972
1973func BalanceOf(id string, owner address) int64 {
1974	return balOf(mustLaunch(id), owner)
1975}
1976
1977// ListBuyers returns unique buyer addresses (one per line), capped for query size.
1978// Only addresses that bought at least once on this pad (UniqueBuyers). Not full GRC20 holders
1979// who received tokens via transfer.
1980func ListBuyers(id string) string {
1981	l := mustLaunch(id)
1982	const maxN = 100
1983	out := ""
1984	n := 0
1985	l.UniqueBuyers.Iterate("", "", func(key string, _ any) bool {
1986		if n >= maxN {
1987			return true
1988		}
1989		if out != "" {
1990			out += "\n"
1991		}
1992		out += key
1993		n++
1994		return false
1995	})
1996	return out
1997}
1998
1999func GetStatus(id string) int {
2000	return mustLaunch(id).Status
2001}
2002
2003func GetRaised(id string) int64 {
2004	return mustLaunch(id).RaisedUgnot
2005}
2006
2007func GetPool(id string) (ugnot, token int64) {
2008	l := mustLaunch(id)
2009	return l.PoolUgnot, l.PoolToken
2010}
2011
2012func GetCreatorFees(id string) int64 {
2013	return mustLaunch(id).CreatorFees
2014}
2015
2016func ProtocolFees() int64 {
2017	return protocolFees
2018}
2019
2020func LaunchCount() int {
2021	return launches.Size()
2022}
2023
2024func ResolveSymbol(symbol string) string {
2025	s, ok := bySymbol.Get(symbol).(string)
2026	if !ok {
2027		return ""
2028	}
2029	return s
2030}
2031
2032// ListIDs returns newline-separated launch IDs (sorted by AVL key / creation order).
2033func ListIDs() string {
2034	out := ""
2035	launches.Iterate("", "", func(key string, _ any) bool {
2036		if out != "" {
2037			out += "\n"
2038		}
2039		out += key
2040		return false
2041	})
2042	return out
2043}
2044
2045// LaunchInfo returns a single-line pipe-delimited summary for UIs/indexers:
2046//
2047//	id|name|symbol|status|raised|sold|buyers|creatorFees|poolUgnot|poolToken|uri|creator|virtualUgnot|virtualToken|created|tokenID|gnoswapReady|gnoswapListed|gnoswapPoolPath|gnoswapNote|listVenue
2048//
2049// status: 0=curve 1=graduated; gnoswapReady/listed: 0|1
2050// gnoswapNote: optional (padv12+); pipes stripped for delimiter safety.
2051// listVenue: optional (padv23+); empty if unlisted.
2052func LaunchInfo(id string) string {
2053	l := mustLaunch(id)
2054	gs := "0"
2055	if l.GnoswapReady {
2056		gs = "1"
2057	}
2058	gl := "0"
2059	if l.GnoswapListed {
2060		gl = "1"
2061	}
2062	note := strings.ReplaceAll(l.GnoswapNote, "|", "/")
2063	venue := strings.ReplaceAll(l.ListVenue, "|", "/")
2064	return l.ID + "|" +
2065		l.Name + "|" +
2066		l.Symbol + "|" +
2067		strconv.Itoa(l.Status) + "|" +
2068		strconv.FormatInt(l.RaisedUgnot, 10) + "|" +
2069		strconv.FormatInt(l.RealSold, 10) + "|" +
2070		strconv.Itoa(l.BuyerCount) + "|" +
2071		strconv.FormatInt(l.CreatorFees, 10) + "|" +
2072		strconv.FormatInt(l.PoolUgnot, 10) + "|" +
2073		strconv.FormatInt(l.PoolToken, 10) + "|" +
2074		l.URI + "|" +
2075		l.Creator.String() + "|" +
2076		strconv.FormatInt(l.VirtualUgnot, 10) + "|" +
2077		strconv.FormatInt(l.VirtualToken, 10) + "|" +
2078		strconv.FormatInt(l.Created, 10) + "|" +
2079		l.TokenID + "|" +
2080		gs + "|" +
2081		gl + "|" +
2082		l.GnoswapPoolPath + "|" +
2083		note + "|" +
2084		venue
2085}
2086
2087// ParamsInfo returns parameters for UI display.
2088// total|curve|poolSeed|gradThreshold|feeBps|createBond|listFeeGns
2089// createBond is live from bond realm when not in unit-test mode.
2090// listFeeGns: Create-time GNS escrow required (padv20+).
2091func ParamsInfo() string {
2092	return strconv.FormatInt(TotalSupply, 10) + "|" +
2093		strconv.FormatInt(CurveSupply, 10) + "|" +
2094		strconv.FormatInt(PoolSeed, 10) + "|" +
2095		strconv.FormatInt(graduationThreshold(), 10) + "|" +
2096		strconv.FormatInt(FeeBPS, 10) + "|" +
2097		strconv.FormatInt(requiredCreateBond(), 10) + "|" +
2098		strconv.FormatInt(requiredListFeeGns(), 10)
2099}
2100
2101// TradeHistory returns newline-separated chart points:
2102//
2103//	height|side|ugnot|tokens|priceScaled
2104//
2105// side: 0=buy 1=sell 2=open/graduate. Ordered oldest -> newest.
2106func TradeHistory(id string) string {
2107	l := mustLaunch(id)
2108	out := ""
2109	l.Trades.Iterate("", "", func(_ string, value any) bool {
2110		t := value.(*Trade)
2111		line := strconv.FormatInt(t.Height, 10) + "|" +
2112			strconv.Itoa(t.Side) + "|" +
2113			strconv.FormatInt(t.Ugnot, 10) + "|" +
2114			strconv.FormatInt(t.Tokens, 10) + "|" +
2115			strconv.FormatInt(t.Price, 10)
2116		if out != "" {
2117			out += "\n"
2118		}
2119		out += line
2120		return false
2121	})
2122	return out
2123}
2124
2125// TradeCount returns number of stored chart samples for a launch.
2126func TradeCount(id string) int {
2127	return mustLaunch(id).Trades.Size()
2128}
2129
2130// resetForTest clears package state between unit tests.
2131func resetForTest() {
2132	launches = avl.Tree{}
2133	bySymbol = avl.Tree{}
2134	nextID = 0
2135	nextTokenID = 0
2136	var zero address
2137	protocolAddr = zero
2138	// padAddr is set in package init - do not clear (realm address is fixed).
2139	protocolFees = 0
2140	protocolFeesPaid = 0
2141	inited = false
2142	pointsEnabled = false
2143	graduationUgnot = 0
2144	listFeeGnsLive = 0
2145	testSkipBanker = true // unit tests skip banker; integration/chain tests leave false
2146	testForceGnoswapList = false
2147	testForceZdexList = false
2148	totalListFeeGns = 0
2149	totalWugnotCredit = 0
2150	wugnotCredit = avl.Tree{}
2151	resetListVenuesForTest()
2152}