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

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