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.91 Kb · 2196 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 padv3
  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// Fail-closed: never silent-clip; never spend reserved raise/pool/prepaid/fees.
 557// Tests: pay ugnot via banker when testSkipBanker.
 558func payWugnotOut(cur realm, to address, amount int64) {
 559	if amount <= 0 {
 560		return
 561	}
 562	if testSkipBanker {
 563		sendUgnot(cur, to, amount)
 564		return
 565	}
 566	free := freeWugnotOnPad()
 567	if free < amount {
 568		panic("pad: insufficient free WUGNOT")
 569	}
 570	wugnot.Transfer(cross(cur), to, amount)
 571}
 572
 573func noteBuyer(l *Launch, buyer address) {
 574	k := buyer.String()
 575	if l.UniqueBuyers.Has(k) {
 576		return
 577	}
 578	l.UniqueBuyers.Set(k, true)
 579	l.BuyerCount++
 580}
 581
 582func tradeKey(n int64) string {
 583	s := strconv.FormatInt(n, 10)
 584	for len(s) < 12 {
 585		s = "0" + s
 586	}
 587	return s
 588}
 589
 590// spotPriceScaled returns ugnot/token * 1e6 from current curve or pool reserves.
 591func spotPriceScaled(l *Launch) int64 {
 592	if l.Status == StatusGraduated {
 593		if l.PoolToken <= 0 {
 594			return 0
 595		}
 596		return l.PoolUgnot * 1000000 / l.PoolToken
 597	}
 598	if l.VirtualToken <= 0 {
 599		return 0
 600	}
 601	return l.VirtualUgnot * 1000000 / l.VirtualToken
 602}
 603
 604func recordTrade(l *Launch, side int, ugnot, tokens int64) {
 605	l.NextTrade++
 606	t := &Trade{
 607		Height: runtime.ChainHeight(),
 608		Side:   side,
 609		Ugnot:  ugnot,
 610		Tokens: tokens,
 611		Price:  spotPriceScaled(l),
 612	}
 613	l.Trades.Set(tradeKey(l.NextTrade), t)
 614	// Ring buffer: drop oldest while over cap.
 615	for l.Trades.Size() > MaxTradeHistory {
 616		oldest := ""
 617		l.Trades.Iterate("", "", func(k string, _ any) bool {
 618			oldest = k
 619			return true // stop
 620		})
 621		if oldest == "" {
 622			break
 623		}
 624		l.Trades.Remove(oldest)
 625	}
 626}
 627
 628// maybeRefundBond is a no-op: create bond is a platform fee (credited to
 629// protocol at Create). Kept so Buy call sites stay stable across cuts.
 630func maybeRefundBond(_ realm, _ *Launch) {}
 631
 632// requiredCreateBond returns ugnot the creator must send.
 633// Production: createbond.CurrentBondUgnot() (promo or normal).
 634// Unit tests (testSkipBanker): local CreateBondUgnot constant.
 635func requiredCreateBond() int64 {
 636	if testSkipBanker {
 637		return CreateBondUgnot
 638	}
 639	return createbond.CurrentBondUgnot()
 640}
 641
 642// CreateBondRequired is a public alias for UIs / qeval (same as requiredCreateBond).
 643func CreateBondRequired() int64 {
 644	return requiredCreateBond()
 645}
 646
 647// requiredListFeeGns returns GNS base units the creator must pre-fund (free on pad).
 648// Live value from SetListFeeGns; falls back to ListFeeGns const.
 649func requiredListFeeGns() int64 {
 650	if listFeeGnsLive > 0 {
 651		return listFeeGnsLive
 652	}
 653	if ListFeeGns > 0 {
 654		return ListFeeGns
 655	}
 656	return 100_000_000
 657}
 658
 659// ListFeeRequired is a public alias for UIs / qeval.
 660func ListFeeRequired() int64 {
 661	return requiredListFeeGns()
 662}
 663
 664// freeGnsOnPad is GNS on pad above per-launch list-fee escrow.
 665func freeGnsOnPad() int64 {
 666	if testSkipBanker {
 667		// Tests skip GNS inventory checks.
 668		return requiredListFeeGns()
 669	}
 670	have := gns.BalanceOf(padAddr)
 671	free := have - totalListFeeGns
 672	if free < 0 {
 673		return 0
 674	}
 675	return free
 676}
 677
 678// FreeGns returns free|have|reserved for UI preflight (GNS list fee).
 679func FreeGns() string {
 680	have := int64(0)
 681	if !testSkipBanker {
 682		have = gns.BalanceOf(padAddr)
 683	}
 684	free := freeGnsOnPad()
 685	return strconv.FormatInt(free, 10) + "|" +
 686		strconv.FormatInt(have, 10) + "|" +
 687		strconv.FormatInt(totalListFeeGns, 10)
 688}
 689
 690// lockListFeeEscrow earmarks free GNS for this launch's CreatePool fee.
 691func lockListFeeEscrow(l *Launch, amt int64) {
 692	if l == nil || amt <= 0 {
 693		return
 694	}
 695	if freeGnsOnPad() < amt {
 696		have := int64(0)
 697		if !testSkipBanker {
 698			have = gns.BalanceOf(padAddr)
 699		}
 700		panic("pad: Transfer " + strconv.FormatInt(amt, 10) +
 701			" GNS to pad then Create (free=" + strconv.FormatInt(freeGnsOnPad(), 10) +
 702			" have=" + strconv.FormatInt(have, 10) +
 703			" reserved=" + strconv.FormatInt(totalListFeeGns, 10) + ")")
 704	}
 705	totalListFeeGns += amt
 706	l.ListFeeGns = amt
 707	l.ListFeeConsumed = false
 708}
 709
 710// listFeeForVenue: gnoswap → ListFeeRequired; zdex / other → 0.
 711func listFeeForVenue(venueId string) int64 {
 712	if normalizeVenueID(venueId) == VenueGnoswap {
 713		return requiredListFeeGns()
 714	}
 715	return 0
 716}
 717
 718// ListFeeRequiredFor is venue-aware Create fee for UI preflight.
 719func ListFeeRequiredFor(venueId string) int64 {
 720	ensureListVenuesSeeded()
 721	return listFeeForVenue(venueId)
 722}
 723
 724// unlockListFeeEscrow clears launch GNS escrow; refund sends GNS back to creator.
 725func unlockListFeeEscrow(cur realm, l *Launch, refund bool) {
 726	if l == nil || l.ListFeeGns <= 0 || l.ListFeeConsumed {
 727		return
 728	}
 729	amt := l.ListFeeGns
 730	l.ListFeeGns = 0
 731	totalListFeeGns -= amt
 732	if totalListFeeGns < 0 {
 733		totalListFeeGns = 0
 734	}
 735	if refund && !testSkipBanker && amt > 0 {
 736		have := gns.BalanceOf(padAddr)
 737		if have < amt {
 738			amt = have
 739		}
 740		if amt > 0 {
 741			gns.Transfer(cross(cur), l.Creator, amt)
 742		}
 743	}
 744	chain.Emit("ListFeeUnlocked",
 745		"id", l.ID,
 746		"amount", strconv.FormatInt(amt, 10),
 747		"refund", strconv.FormatBool(refund),
 748	)
 749}
 750
 751// syncListFeeForVenue adjusts escrow after PreferredVenue change (pre-list only).
 752func syncListFeeForVenue(cur realm, l *Launch, vid string) {
 753	if l == nil || l.ListFeeConsumed || l.GnoswapListed || l.ListVenue != "" {
 754		return
 755	}
 756	want := listFeeForVenue(vid)
 757	if l.ListFeeGns == want {
 758		return
 759	}
 760	if l.ListFeeGns > 0 {
 761		unlockListFeeEscrow(cur, l, true)
 762	}
 763	lockListFeeEscrow(l, want)
 764}
 765
 766// consumeListFeeEscrow clears launch escrow after successful Gnoswap list
 767// (CreatePool spent feeNeed GNS from pad balance).
 768func consumeListFeeEscrow(l *Launch) {
 769	if l == nil || l.ListFeeGns <= 0 {
 770		return
 771	}
 772	amt := l.ListFeeGns
 773	totalListFeeGns -= amt
 774	if totalListFeeGns < 0 {
 775		totalListFeeGns = 0
 776	}
 777	l.ListFeeGns = 0
 778	l.ListFeeConsumed = true
 779	chain.Emit("ListFeeConsumed", "id", l.ID, "amount", strconv.FormatInt(amt, 10))
 780}
 781
 782// ClaimListFee refunds Create-time GNS escrow to the token creator if still unlisted.
 783// Use when graduate/list will not complete or creator abandons listing.
 784func ClaimListFee(cur realm, id string) int64 {
 785	requireInit()
 786	if !cur.Previous().IsUserCall() {
 787		panic("pad: must be EOA MsgCall")
 788	}
 789	l := mustLaunch(id)
 790	caller := cur.Previous().Address()
 791	if caller != l.Creator {
 792		panic("pad: only creator may claim list fee")
 793	}
 794	if l.GnoswapListed {
 795		panic("pad: already listed - list fee spent")
 796	}
 797	if l.ListFeeConsumed {
 798		panic("pad: list fee already consumed")
 799	}
 800	amt := l.ListFeeGns
 801	if amt <= 0 {
 802		return 0
 803	}
 804	l.ListFeeGns = 0
 805	totalListFeeGns -= amt
 806	if totalListFeeGns < 0 {
 807		totalListFeeGns = 0
 808	}
 809	if !testSkipBanker && amt > 0 {
 810		have := gns.BalanceOf(padAddr)
 811		if have < amt {
 812			amt = have
 813		}
 814		if amt > 0 {
 815			gns.Transfer(cross(cur), l.Creator, amt)
 816		}
 817	}
 818	chain.Emit("ClaimListFee",
 819		"id", id,
 820		"creator", l.Creator.String(),
 821		"amount", strconv.FormatInt(amt, 10),
 822	)
 823	return amt
 824}
 825
 826// Create deploys a fair-launch meme. Create bond (bond realm / fallback const) is a
 827// platform fee credited to protocol immediately (not refundable escrow).
 828// Also locks ListFeeGns free GNS when venue requires it (creator Transfer GNS first).
 829// No pre-mint; all tradeable float starts on the bonding curve.
 830func Create(cur realm, name, symbol, uri string) string {
 831	requireInit()
 832	sent := requireUserPayment(cur)
 833	bondNeed := requiredCreateBond()
 834	if bondNeed <= 0 {
 835		panic("pad: create bond misconfigured")
 836	}
 837	if sent < bondNeed {
 838		panic("pad: create bond underpaid")
 839	}
 840	if name == "" || symbol == "" {
 841		panic("pad: name and symbol required")
 842	}
 843	if len(symbol) > 12 {
 844		panic("pad: symbol too long")
 845	}
 846	if bySymbol.Has(symbol) {
 847		panic("pad: symbol taken")
 848	}
 849	creator := cur.Previous().Address()
 850	extra := sent - bondNeed
 851	// Bank ugnot overpay must never book into WUGNOT protocolFees (phantom liability).
 852	if extra > 0 {
 853		sendUgnot(cur, creator, extra)
 854	}
 855
 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	creator := cur.Previous().Address()
 901	extra := sent - bondNeed
 902	// Bank ugnot overpay must never book into WUGNOT protocolFees (phantom liability).
 903	if extra > 0 {
 904		sendUgnot(cur, creator, extra)
 905	}
 906	id := nextID.Next().String()
 907	token, ledger := grc20.NewToken(name, symbol, 0, nextTokenID.Next(), cur)
 908	regKey := ""
 909	if !testSkipBanker {
 910		regKey = grc20reg.Register(cross(cur), token, symbol)
 911	}
 912	ensureListVenuesSeeded()
 913	vid := normalizeVenueID(venueId)
 914	if !venueEnabled(vid) {
 915		panic("pad: venue disabled or unknown")
 916	}
 917	return finishCreate(cur, id, name, symbol, uri, creator, token, ledger, regKey, bondNeed, vid)
 918}
 919
 920func finishCreate(
 921	cur realm,
 922	id, name, symbol, uri string,
 923	creator address,
 924	token *grc20.Token,
 925	ledger *grc20.PrivateLedger,
 926	regKey string,
 927	bondNeed int64,
 928	pref string,
 929) string {
 930	l := &Launch{
 931		ID:             id,
 932		Name:           name,
 933		Symbol:         symbol,
 934		URI:            uri,
 935		Creator:        creator,
 936		Status:         StatusCurve,
 937		Created:        runtime.ChainHeight(),
 938		token:          token,
 939		ledger:         ledger,
 940		TokenID:        token.ID(),
 941		VirtualUgnot:   VirtualUgnot0,
 942		VirtualToken:   VirtualToken0,
 943		UniqueBuyers:   avl.Tree{},
 944		snipeBought:    avl.Tree{},
 945		BondUgnot:      0,    // create fee already credited to protocol below
 946		BondRefunded:   true, // no creator refund path
 947		Trades:         avl.Tree{},
 948		PreferredVenue: pref,
 949	}
 950	// Create bond = platform fee (bank ugnot, not WUGNOT escrow). Claim via ClaimProtocolFees.
 951	if bondNeed > 0 {
 952		creditBondFee(bondNeed)
 953		chain.Emit("BondFee",
 954			"id", id,
 955			"amount", strconv.FormatInt(bondNeed, 10),
 956			"creator", creator.String(),
 957		)
 958	}
 959	// Venue-aware list fee: zdex → 0 GNS; gnoswap → ListFeeRequired.
 960	lockListFeeEscrow(l, listFeeForVenue(pref))
 961	recordTrade(l, TradeSideOpen, 0, 0)
 962	launches.Set(id, l)
 963	bySymbol.Set(symbol, id)
 964
 965	chain.Emit("Created",
 966		"id", id,
 967		"symbol", symbol,
 968		"creator", creator.String(),
 969		"token", l.TokenID,
 970		"reg", regKey,
 971		"listFeeGns", strconv.FormatInt(l.ListFeeGns, 10),
 972		"preferredVenue", pref,
 973		"bondFee", strconv.FormatInt(bondNeed, 10),
 974	)
 975	notifyCreate(cur, creator, id)
 976	return id
 977}
 978
 979// AdenaPathOf returns the grc20reg / Adena token key: packagePath.SYMBOL
 980// (Token.ID is packagePath.SYMBOL.seq - Adena rejects that form).
 981func AdenaPathOf(id string) string {
 982	l := mustLaunch(id)
 983	return adenaKeyFromTokenID(l.TokenID, l.Symbol)
 984}
 985
 986// adenaKeyFromTokenID strips the trailing .seq from Token.ID when present.
 987func adenaKeyFromTokenID(tokenID, symbol string) string {
 988	if tokenID == "" {
 989		return ""
 990	}
 991	// Token.ID = packagePath.symbol.seq -> registry key = packagePath.symbol
 992	suffix := "." + symbol + "."
 993	if i := strings.LastIndex(tokenID, suffix); i >= 0 {
 994		// packagePath + "." + symbol
 995		return tokenID[:i] + "." + symbol
 996	}
 997	// Already packagePath.symbol or unknown layout
 998	if strings.HasSuffix(tokenID, "."+symbol) {
 999		return tokenID
1000	}
1001	return tokenID
1002}
1003
1004// maxGrossForNetIn finds largest gross ugnot <= sentMax whose fee-split netIn <= maxNet.
1005func maxGrossForNetIn(maxNet, sentMax int64) int64 {
1006	if maxNet <= 0 || sentMax <= 0 {
1007		return 0
1008	}
1009	lo, hi := int64(0), sentMax
1010	for lo < hi {
1011		mid := (lo + hi + 1) / 2
1012		f := ammmath.ApplyFee(mid, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
1013		net := f.Net + f.Remainder
1014		if net <= maxNet {
1015			lo = mid
1016		} else {
1017			hi = mid - 1
1018		}
1019	}
1020	return lo
1021}
1022
1023// readyToGraduate is true when raise met the threshold, or the entire curve
1024// float is sold (sold-out escape: threshold may be unreachable with bad virtuals).
1025func readyToGraduate(l *Launch) bool {
1026	if l == nil || l.Status != StatusCurve {
1027		return false
1028	}
1029	if l.RaisedUgnot <= 0 {
1030		return false
1031	}
1032	if ammmath.CanGraduate(l.RaisedUgnot, graduationThreshold()) {
1033		return true
1034	}
1035	// Curve exhausted before threshold: still graduate with whatever was raised
1036	// so the market is never permanently stuck on Buy/Graduate.
1037	return l.RealSold >= CurveSupply
1038}
1039
1040// Buy spends WUGNOT on the bonding curve; credits tokens.
1041// amountWugnot: max to spend (may overpay). UI: Deposit + Transfer(pad, amt) + Buy.
1042// Overpay stays as claimable prepaid — ClaimWugnot anytime (incl. after list).
1043// minTokensOut: slippage floor (0 = disabled). Auto-graduates at threshold or sold-out.
1044//
1045// Production: collateral is real WUGNOT on pad -> Graduate can auto-list Gnoswap.
1046// Tests (testSkipBanker): amountWugnot ignored; OriginSend ugnot is used as units.
1047//
1048// Last-fill (no overshoot):
1049//  1. Cap net so RaisedUgnot never exceeds graduationThreshold() (refund excess WUGNOT).
1050//  2. Cap by remaining curve tokens (CurveSupply - RealSold).
1051//
1052// If the curve is already sold out (or raise already filled), Buy refunds the full
1053// take and graduates when ready - no panic so users are not stuck mid-tx.
1054func Buy(cur realm, id string, amountWugnot, minTokensOut int64) int64 {
1055	requireInit()
1056	sent := takeWugnotIn(cur, amountWugnot)
1057	l := mustLaunch(id)
1058	if l.Status != StatusCurve {
1059		panic("pad: not on curve (use SwapBuy)")
1060	}
1061	buyer := cur.Previous().Address()
1062
1063	remainingTok := CurveSupply - l.RealSold
1064	needRaise := graduationThreshold() - l.RaisedUgnot
1065	// Already complete: refund payment and graduate (sold-out or raise-filled).
1066	if remainingTok <= 0 || needRaise <= 0 {
1067		if !readyToGraduate(l) {
1068			// Edge: zero raise with empty float should not happen in production.
1069			if remainingTok <= 0 {
1070				panic("pad: curve sold out with no raise")
1071			}
1072			panic("pad: raise filled - call Graduate")
1073		}
1074		if sent > 0 {
1075			// Keep as claimable credit (ClaimWugnot) — safe after graduate/list too.
1076			creditRefund(buyer, sent)
1077			chain.Emit("BuyRefund",
1078				"id", id,
1079				"buyer", buyer.String(),
1080				"refund", strconv.FormatInt(sent, 10),
1081				"toCredit", "1",
1082			)
1083		}
1084		graduate(cur, l)
1085		return 0
1086	}
1087
1088	usedGross := sent
1089	fee := ammmath.ApplyFee(usedGross, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
1090	// Net enters curve; remainder boosts virtual ugnot (stays as collateral).
1091	netIn := fee.Net + fee.Remainder
1092
1093	// Max net allowed: min(user net, remaining raise, remaining tokens).
1094	maxNet := netIn
1095	if maxNet > needRaise {
1096		maxNet = needRaise
1097	}
1098	maxNetTok := ammmath.MaxNetInForTokenOut(l.VirtualUgnot, l.VirtualToken, remainingTok)
1099	if maxNetTok > 0 && maxNet > maxNetTok {
1100		maxNet = maxNetTok
1101	}
1102	if maxNet <= 0 {
1103		panic("pad: no fill capacity remaining")
1104	}
1105
1106	// Clamp gross + recompute fee when caps bind (last-fill refund path).
1107	if maxNet < netIn {
1108		usedGross = maxGrossForNetIn(maxNet, sent)
1109		if usedGross <= 0 {
1110			panic("pad: buy too small for remaining fill")
1111		}
1112		fee = ammmath.ApplyFee(usedGross, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
1113		netIn = fee.Net + fee.Remainder
1114		if netIn > maxNet {
1115			netIn = maxNet
1116		}
1117	}
1118
1119	tokensOut, newVU, newVT := ammmath.BuyTokens(l.VirtualUgnot, l.VirtualToken, netIn)
1120	// Integer edge: step down net until tokens <= remaining curve supply.
1121	for tokensOut > remainingTok && netIn > 1 {
1122		netIn--
1123		tokensOut, newVU, newVT = ammmath.BuyTokens(l.VirtualUgnot, l.VirtualToken, netIn)
1124	}
1125	if tokensOut > remainingTok || tokensOut <= 0 {
1126		panic("pad: cannot fill remaining curve supply")
1127	}
1128	// If net was reduced further, shrink usedGross so refund is correct.
1129	if netIn < maxNet || usedGross < sent {
1130		// Re-derive gross that yields this netIn (<= sent).
1131		g2 := maxGrossForNetIn(netIn, sent)
1132		if g2 > 0 && g2 < usedGross {
1133			usedGross = g2
1134			fee = ammmath.ApplyFee(usedGross, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
1135			// Keep curve netIn as simulated (may be slightly below fee.Net+Rem).
1136		}
1137	}
1138	// Hard safety: never overshoot graduation raise after this buy.
1139	if l.RaisedUgnot+netIn > graduationThreshold() {
1140		netIn = graduationThreshold() - l.RaisedUgnot
1141		if netIn <= 0 {
1142			panic("pad: raise filled - call Graduate")
1143		}
1144		tokensOut, newVU, newVT = ammmath.BuyTokens(l.VirtualUgnot, l.VirtualToken, netIn)
1145		for tokensOut > remainingTok && netIn > 1 {
1146			netIn--
1147			tokensOut, newVU, newVT = ammmath.BuyTokens(l.VirtualUgnot, l.VirtualToken, netIn)
1148		}
1149		if tokensOut <= 0 {
1150			panic("pad: cannot fill remaining raise")
1151		}
1152		usedGross = maxGrossForNetIn(netIn, sent)
1153		if usedGross <= 0 {
1154			panic("pad: buy too small for remaining raise")
1155		}
1156		fee = ammmath.ApplyFee(usedGross, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
1157	}
1158
1159	refund := sent - usedGross
1160	if refund > 0 {
1161		// Overpay stays claimable — user ClaimWugnot anytime (incl. after list).
1162		creditRefund(buyer, refund)
1163	}
1164
1165	requireMinOut(tokensOut, minTokensOut, "tokens out")
1166	checkAndAddSnipe(l, buyer, tokensOut)
1167
1168	// Mutate only after all checks pass.
1169	// Fees stay as WUGNOT on pad (liabilities); netIn is raised collateral for LP.
1170	l.CreatorFees += fee.Creator
1171	creditProtocol(fee.Protocol)
1172	l.VirtualUgnot = newVU
1173	l.VirtualToken = newVT
1174	l.RealSold += tokensOut
1175	l.RaisedUgnot += netIn
1176	// Invariant: raise never exceeds threshold after Buy.
1177	if l.RaisedUgnot > graduationThreshold() {
1178		panic("pad: raise overshoot invariant")
1179	}
1180
1181	addBal(l, buyer, tokensOut)
1182	noteBuyer(l, buyer)
1183	maybeRefundBond(cur, l)
1184	recordTrade(l, TradeSideBuy, usedGross, tokensOut)
1185
1186	chain.Emit("Buy",
1187		"id", id,
1188		"buyer", buyer.String(),
1189		"ugnot", strconv.FormatInt(usedGross, 10),
1190		"wugnot", strconv.FormatInt(usedGross, 10),
1191		"tokens", strconv.FormatInt(tokensOut, 10),
1192	)
1193	if refund > 0 {
1194		chain.Emit("BuyRefund",
1195			"id", id,
1196			"buyer", buyer.String(),
1197			"refund", strconv.FormatInt(refund, 10),
1198		)
1199	}
1200	notifyTrade(cur, buyer, id, 0, usedGross)
1201
1202	if readyToGraduate(l) {
1203		graduate(cur, l)
1204	}
1205	return tokensOut
1206}
1207
1208// RemainingRaiseUgnot is net ugnot still needed to hit graduationThreshold() (0 if met/over).
1209func RemainingRaiseUgnot(id string) int64 {
1210	l := mustLaunch(id)
1211	if l.Status != StatusCurve {
1212		return 0
1213	}
1214	if l.RaisedUgnot >= graduationThreshold() {
1215		return 0
1216	}
1217	return graduationThreshold() - l.RaisedUgnot
1218}
1219
1220// Sell burns curve tokens and pays WUGNOT (fee on output).
1221// minWugnotOut: slippage floor (0 = disabled). User may wugnot.Withdraw to ugnot.
1222func Sell(cur realm, id string, tokensIn, minWugnotOut int64) int64 {
1223	requireInit()
1224	if !cur.Previous().IsUserCall() {
1225		panic("pad: must be EOA MsgCall")
1226	}
1227	if tokensIn <= 0 {
1228		panic("pad: tokensIn must be positive")
1229	}
1230	l := mustLaunch(id)
1231	if l.Status != StatusCurve {
1232		panic("pad: not on curve (use SwapSell)")
1233	}
1234	seller := cur.Previous().Address()
1235	if balOf(l, seller) < tokensIn {
1236		panic("pad: insufficient token balance")
1237	}
1238
1239	gross, newVU, newVT := ammmath.SellTokens(l.VirtualUgnot, l.VirtualToken, tokensIn)
1240	fee := ammmath.ApplyFeeOnOutput(gross, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
1241	requireMinOut(fee.Net, minWugnotOut, "wugnot out")
1242
1243	// Full gross left virtual reserves; retain fee in virtual ugnot (cash stays in realm).
1244	l.VirtualUgnot = newVU + fee.Fee
1245	l.VirtualToken = newVT
1246	l.RealSold -= tokensIn
1247	if l.RealSold < 0 {
1248		l.RealSold = 0
1249	}
1250
1251	// User receives net; creator+protocol become fee liabilities (leave Raised).
1252	payOut := fee.Net + fee.Creator + fee.Protocol
1253	if l.RaisedUgnot >= payOut {
1254		l.RaisedUgnot -= payOut
1255	} else {
1256		l.RaisedUgnot = 0
1257	}
1258	l.CreatorFees += fee.Creator
1259	creditProtocol(fee.Protocol)
1260
1261	addBal(l, seller, -tokensIn)
1262	payWugnotOut(cur, seller, fee.Net)
1263	recordTrade(l, TradeSideSell, fee.Net, tokensIn)
1264
1265	chain.Emit("Sell",
1266		"id", id,
1267		"seller", seller.String(),
1268		"tokens", strconv.FormatInt(tokensIn, 10),
1269		"ugnot", strconv.FormatInt(fee.Net, 10),
1270		"wugnot", strconv.FormatInt(fee.Net, 10),
1271	)
1272	notifyTrade(cur, seller, id, 1, fee.Net)
1273	return fee.Net
1274}
1275
1276// Graduate permissionlessly moves a ready curve into a permanently locked CPMM.
1277// Ready when RaisedUgnot >= graduationThreshold(), or when RealSold >= CurveSupply
1278// with RaisedUgnot > 0 (sold-out before threshold - escape hatch for unreachable raise).
1279func Graduate(cur realm, id string) {
1280	requireInit()
1281	l := mustLaunch(id)
1282	if l.Status != StatusCurve {
1283		panic("pad: already graduated")
1284	}
1285	if !readyToGraduate(l) {
1286		panic("pad: not ready to graduate (need raise threshold or curve sold out)")
1287	}
1288	graduate(cur, l)
1289}
1290
1291func graduate(cur realm, l *Launch) {
1292	if l.Status != StatusCurve {
1293		return
1294	}
1295	// Liquidity capital = all raised GNOT. Token side is sized to the current
1296	// bonding-curve spot so internal CPMM / Gnoswap open ~ last curve trade
1297	// (seamless graduate). Dumping ALL unsold tokens made DEX spot << curve exit.
1298	poolU := l.RaisedUgnot
1299	if poolU <= 0 {
1300		panic("pad: empty pool ugnot")
1301	}
1302	remaining := TotalSupply - l.RealSold
1303	if remaining <= 0 {
1304		panic("pad: no remaining tokens for liquidity")
1305	}
1306	// tokensForLP = raised * VirtualToken / VirtualUgnot  (same units as reserves).
1307	// Use MulDiv: poolU * VT can overflow int64 at mainnet VU0 (~102k GNOT) scale.
1308	poolT := remaining
1309	if l.VirtualUgnot > 0 && l.VirtualToken > 0 && poolU > 0 {
1310		needed := ammmath.MulDiv(poolU, l.VirtualToken, l.VirtualUgnot)
1311		if needed > 0 && needed < remaining {
1312			poolT = needed
1313		}
1314	}
1315
1316	l.PoolUgnot = poolU
1317	l.PoolToken = poolT
1318	l.LeftoverTokens = remaining - poolT
1319	l.RaisedUgnot = 0
1320	l.VirtualUgnot = 0
1321	l.VirtualToken = 0
1322	l.Status = StatusGraduated
1323
1324	// Legacy: any residual BondUgnot (pre-fee-cut launches) → bank bond fee at graduate.
1325	if !l.BondRefunded && l.BondUgnot > 0 {
1326		creditBondFee(l.BondUgnot)
1327		l.BondUgnot = 0
1328		l.BondRefunded = true
1329	}
1330
1331	// Mark graduation on chart at pool spot (matches curve exit when sized above).
1332	recordTrade(l, TradeSideOpen, poolU, poolT)
1333
1334	// NEVER auto-list inside Buy/Graduate.
1335	// Gnoswap CreatePool/Mint does WUGNOT Approve+TransferFrom; realm spender
1336	// frame often panics "insufficient allowance" and REVERTS the entire Buy
1337	// (including the curve fill that triggered graduation). List is a separate
1338	// EOA call: pre-fund pad WUGNOT/GNS then RetryListGnoswap.
1339	// Unit tests may still force list via testForceGnoswapList.
1340	l.GnoswapReady = true
1341	listed := false
1342	if testForceGnoswapList {
1343		listed = listOnGnoswapWithFunding(cur, l, poolU, poolT)
1344	}
1345	if !listed {
1346		// Internal CPMM: PoolToken is pad-accounting reserve (not minted GRC20).
1347		// Circulating = user balances; pool side is virtual reserve PoolToken.
1348		if l.GnoswapNote == "" {
1349			if l.PreferredVenue == VenueZdex {
1350				l.GnoswapNote = "internal CPMM; ReleaseListSeed → EOA zdex CreatePool → ConfirmZdexList"
1351			} else {
1352				l.GnoswapNote = "internal CPMM; Transfer WUGNOT+GNS to pad then RetryListGnoswap"
1353			}
1354		}
1355		chain.Emit("Graduated",
1356			"id", l.ID,
1357			"poolUgnot", strconv.FormatInt(poolU, 10),
1358			"poolToken", strconv.FormatInt(poolT, 10),
1359			"token", l.TokenID,
1360			"gnoswap_listed", "0",
1361		)
1362		return
1363	}
1364	// Listed on Gnoswap (test-only path): capital is in the CL position (NFT owned by pad).
1365	// Internal SwapBuy/Sell disabled (PoolUgnot/PoolToken kept as listing record).
1366	chain.Emit("Graduated",
1367		"id", l.ID,
1368		"poolUgnot", strconv.FormatInt(poolU, 10),
1369		"poolToken", strconv.FormatInt(poolT, 10),
1370		"token", l.TokenID,
1371		"gnoswap_listed", "1",
1372		"poolPath", l.GnoswapPoolPath,
1373		"positionId", strconv.FormatUint(l.GnoswapPositionID, 10),
1374	)
1375}
1376
1377// SetPreferredVenue sets the DEX target used when RetryList(id, "") is called.
1378// Creator or protocol only; locked once listed. Syncs Create-time GNS escrow
1379// (gnoswap locks ListFeeRequired; zdex unlocks/refunds escrow).
1380func SetPreferredVenue(cur realm, id, venueId string) {
1381	requireInit()
1382	if !cur.Previous().IsUserCall() {
1383		panic("pad: must be EOA MsgCall")
1384	}
1385	l := mustLaunch(id)
1386	caller := cur.Previous().Address()
1387	if caller != l.Creator && caller != protocolAddr {
1388		panic("pad: creator or protocol only")
1389	}
1390	if l.GnoswapListed || l.ListVenue != "" {
1391		panic("pad: already listed")
1392	}
1393	ensureListVenuesSeeded()
1394	vid := normalizeVenueID(venueId)
1395	if !venueEnabled(vid) {
1396		panic("pad: venue disabled or unknown")
1397	}
1398	l.PreferredVenue = vid
1399	syncListFeeForVenue(cur, l, vid)
1400	chain.Emit("SetPreferredVenue",
1401		"id", id,
1402		"venue", vid,
1403		"listFeeGns", strconv.FormatInt(l.ListFeeGns, 10),
1404	)
1405}
1406
1407// PreferredVenueOf returns the Create-time / SetPreferredVenue target.
1408func PreferredVenueOf(id string) string {
1409	return mustLaunch(id).PreferredVenue
1410}
1411
1412// ListSeedReleasedOf reports whether ReleaseListSeed already ran.
1413func ListSeedReleasedOf(id string) bool {
1414	return mustLaunch(id).ListSeedReleased
1415}
1416
1417// ReleaseListSeed sends graduated internal-CPMM seed to the creator for EOA zdex CreatePool:
1418//   - PoolUgnot as WUGNOT Transfer to creator (EOA may wugnot.Withdraw → ugnot OriginSend)
1419//   - PoolToken minted as GRC20 to creator (was pad-accounting only)
1420// PoolUgnot/PoolToken kept as listing-size record; reservedWugnot skips via ListSeedReleased.
1421// Disables internal SwapBuy/Sell. Creator or protocol may call; assets always go to creator.
1422func ReleaseListSeed(cur realm, id string) {
1423	requireInit()
1424	if !cur.Previous().IsUserCall() {
1425		panic("pad: must be EOA MsgCall")
1426	}
1427	l := mustLaunch(id)
1428	caller := cur.Previous().Address()
1429	if caller != l.Creator && caller != protocolAddr {
1430		panic("pad: creator or protocol only")
1431	}
1432	if l.Status != StatusGraduated {
1433		panic("pad: not graduated")
1434	}
1435	if l.GnoswapListed || l.ListVenue != "" {
1436		panic("pad: already listed")
1437	}
1438	if l.ListSeedReleased {
1439		panic("pad: list seed already released")
1440	}
1441	poolU := l.PoolUgnot
1442	poolT := l.PoolToken
1443	if poolU <= 0 || poolT <= 0 {
1444		panic("pad: empty internal pool")
1445	}
1446	// Fail-closed: must fully fund creator before killing internal CPMM.
1447	if !testSkipBanker {
1448		have := wugnot.BalanceOf(padAddr)
1449		if have < poolU {
1450			panic("pad: underfunded WUGNOT for list seed release")
1451		}
1452	}
1453	// Mark released before Transfer so reservedWugnot drops in same tx.
1454	// Keep PoolUgnot/PoolToken as listing-size record (reservedWugnot skips via ListSeedReleased).
1455	l.ListSeedReleased = true
1456	l.LiqWugnotUsed = poolU
1457	l.GnoswapNote = "list seed released to creator for zdex CreatePool; then ConfirmZdexList"
1458	if !testSkipBanker {
1459		wugnot.Transfer(cross(cur), l.Creator, poolU)
1460	} else {
1461		payWugnotOut(cur, l.Creator, poolU)
1462	}
1463	addBal(l, l.Creator, poolT)
1464	chain.Emit("ReleaseListSeed",
1465		"id", id,
1466		"to", l.Creator.String(),
1467		"wugnot", strconv.FormatInt(poolU, 10),
1468		"tokens", strconv.FormatInt(poolT, 10),
1469	)
1470}
1471
1472// ConfirmZdexList marks a launch listed on zdex after EOA CreatePool succeeded.
1473// poolId e.g. "ugnot|SYMBOL" (must be non-empty; no on-chain pool verify).
1474// Creator or protocol. Prefer ReleaseListSeed first when pad still holds LP seed.
1475func ConfirmZdexList(cur realm, id, poolId string) {
1476	requireInit()
1477	if !cur.Previous().IsUserCall() {
1478		panic("pad: must be EOA MsgCall")
1479	}
1480	l := mustLaunch(id)
1481	caller := cur.Previous().Address()
1482	if caller != l.Creator && caller != protocolAddr {
1483		panic("pad: creator or protocol only")
1484	}
1485	if l.Status != StatusGraduated {
1486		panic("pad: not graduated")
1487	}
1488	if l.GnoswapListed || l.ListVenue != "" {
1489		panic("pad: already listed")
1490	}
1491	// Avoid marking listed while pad still holds unreleased LP capital.
1492	if !l.ListSeedReleased && (l.PoolUgnot > 0 || l.PoolToken > 0) {
1493		panic("pad: ReleaseListSeed first (or empty pool)")
1494	}
1495	poolId = strings.TrimSpace(poolId)
1496	if poolId == "" {
1497		panic("pad: empty poolId")
1498	}
1499	l.ListVenue = VenueZdex
1500	l.GnoswapListed = true // UI/compat: "listed on DEX"
1501	l.GnoswapPoolPath = poolId
1502	l.GnoswapPositionID = 0
1503	if l.LiqWugnotUsed == 0 {
1504		l.LiqWugnotUsed = l.PoolUgnot
1505	}
1506	l.GnoswapNote = "confirmed zdex list; pool=" + poolId
1507	chain.Emit("ConfirmZdexList",
1508		"id", id,
1509		"poolPath", poolId,
1510		"symbol", l.Symbol,
1511		"venue", VenueZdex,
1512	)
1513	chain.Emit("ListedRetry",
1514		"id", id,
1515		"venue", VenueZdex,
1516		"poolPath", poolId,
1517		"positionId", "0",
1518		"poolUgnot", strconv.FormatInt(l.PoolUgnot, 10),
1519		"poolToken", strconv.FormatInt(l.PoolToken, 10),
1520	)
1521}
1522
1523// RetryList lists a graduated launch on the chosen venue.
1524// Permissionless EOA MsgCall. Uses PoolUgnot / PoolToken (curve-spot sized).
1525// venueId: empty → PreferredVenue → DefaultListVenue(); unknown/disabled → soft-fail note.
1526func RetryList(cur realm, id, venueId string) bool {
1527	requireInit()
1528	if !cur.Previous().IsUserCall() {
1529		panic("pad: must be EOA MsgCall")
1530	}
1531	l := mustLaunch(id)
1532	if l.Status != StatusGraduated {
1533		panic("pad: not graduated")
1534	}
1535	if l.GnoswapListed || l.ListVenue != "" {
1536		panic("pad: already listed")
1537	}
1538	if l.ListSeedReleased {
1539		panic("pad: list seed released - use ConfirmZdexList after EOA CreatePool")
1540	}
1541	poolU := l.PoolUgnot
1542	poolT := l.PoolToken
1543	if poolU <= 0 || poolT <= 0 {
1544		panic("pad: empty internal pool")
1545	}
1546	raw := strings.TrimSpace(venueId)
1547	if raw == "" && l.PreferredVenue != "" {
1548		raw = l.PreferredVenue
1549	}
1550	vid := normalizeVenueID(raw)
1551	ok := tryListVenue(cur, l, vid, poolU, poolT)
1552	if ok {
1553		chain.Emit("ListedRetry",
1554			"id", l.ID,
1555			"venue", l.ListVenue,
1556			"poolPath", l.GnoswapPoolPath,
1557			"positionId", strconv.FormatUint(l.GnoswapPositionID, 10),
1558			"poolUgnot", strconv.FormatInt(poolU, 10),
1559			"poolToken", strconv.FormatInt(poolT, 10),
1560		)
1561		// Compat event name for indexers that listen for GnoswapListedRetry.
1562		if l.ListVenue == VenueGnoswap {
1563			chain.Emit("GnoswapListedRetry",
1564				"id", l.ID,
1565				"poolPath", l.GnoswapPoolPath,
1566				"positionId", strconv.FormatUint(l.GnoswapPositionID, 10),
1567				"poolUgnot", strconv.FormatInt(poolU, 10),
1568				"poolToken", strconv.FormatInt(poolT, 10),
1569			)
1570		}
1571	} else if l.GnoswapNote == "" {
1572		l.GnoswapNote = "retry list failed - check ListNeed / ListNeedFor; venue=" + vid
1573	}
1574	return ok
1575}
1576
1577// RetryListGnoswap is the Gnoswap-specific wrapper (compat for existing UI).
1578func RetryListGnoswap(cur realm, id string) bool {
1579	return RetryList(cur, id, VenueGnoswap)
1580}
1581
1582// TokenIDOf returns the GRC20 Token.ID() for a launch.
1583func TokenIDOf(id string) string {
1584	return mustLaunch(id).TokenID
1585}
1586
1587// GRC20Bank returns the underlying *grc20.Token for interop (metadata / external DEX).
1588// Does not expose PrivateLedger - mint/burn stay pad-only.
1589func GRC20Bank(id string) *grc20.Token {
1590	l := mustLaunch(id)
1591	if l.token == nil {
1592		panic("pad: no token")
1593	}
1594	return l.token
1595}
1596
1597// SwapBuy buys tokens from the graduated internal pool with WUGNOT.
1598// amountWugnot: max to spend (Approve pad). minTokensOut: slippage (0 = off).
1599// Disabled when listed on Gnoswap (trade via router).
1600func SwapBuy(cur realm, id string, amountWugnot, minTokensOut int64) int64 {
1601	requireInit()
1602	sent := takeWugnotIn(cur, amountWugnot)
1603	l := mustLaunch(id)
1604	if l.Status != StatusGraduated {
1605		panic("pad: not graduated (use Buy)")
1606	}
1607	if l.GnoswapListed || l.ListVenue != "" {
1608		panic("pad: listed on DEX - trade via router, not pad SwapBuy")
1609	}
1610	if l.ListSeedReleased {
1611		panic("pad: list seed released - finish zdex CreatePool + ConfirmZdexList")
1612	}
1613	buyer := cur.Previous().Address()
1614
1615	fee := ammmath.ApplyFee(sent, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
1616	tokensOut, newPU, newPT := ammmath.PoolSwapUgnotForToken(
1617		l.PoolUgnot, l.PoolToken, fee.Net, fee.Remainder,
1618	)
1619	requireMinOut(tokensOut, minTokensOut, "tokens out")
1620
1621	// Refund unused gross if pool math used less (rare); fees from sent.
1622	// Pool swap uses fee.Net into pool; full sent stays as fee+pool contribution.
1623	l.CreatorFees += fee.Creator
1624	creditProtocol(fee.Protocol)
1625	l.PoolUgnot = newPU
1626	l.PoolToken = newPT
1627	addBal(l, buyer, tokensOut)
1628	noteBuyer(l, buyer)
1629	recordTrade(l, TradeSideBuy, sent, tokensOut)
1630
1631	chain.Emit("SwapBuy",
1632		"id", id,
1633		"buyer", buyer.String(),
1634		"ugnot", strconv.FormatInt(sent, 10),
1635		"wugnot", strconv.FormatInt(sent, 10),
1636		"tokens", strconv.FormatInt(tokensOut, 10),
1637	)
1638	notifyTrade(cur, buyer, id, 0, sent)
1639	return tokensOut
1640}
1641
1642// SwapSell sells tokens into the graduated pool for WUGNOT.
1643// minWugnotOut: slippage floor (0 = disabled).
1644func SwapSell(cur realm, id string, tokensIn, minWugnotOut int64) int64 {
1645	requireInit()
1646	if !cur.Previous().IsUserCall() {
1647		panic("pad: must be EOA MsgCall")
1648	}
1649	if tokensIn <= 0 {
1650		panic("pad: tokensIn must be positive")
1651	}
1652	l := mustLaunch(id)
1653	if l.Status != StatusGraduated {
1654		panic("pad: not graduated (use Sell)")
1655	}
1656	if l.GnoswapListed || l.ListVenue != "" {
1657		panic("pad: listed on DEX - trade via router, not pad SwapSell")
1658	}
1659	if l.ListSeedReleased {
1660		panic("pad: list seed released - finish zdex CreatePool + ConfirmZdexList")
1661	}
1662	seller := cur.Previous().Address()
1663	if balOf(l, seller) < tokensIn {
1664		panic("pad: insufficient token balance")
1665	}
1666
1667	gross, newPU, newPT := ammmath.PoolSwapTokenForUgnot(l.PoolUgnot, l.PoolToken, tokensIn)
1668	fee := ammmath.ApplyFeeOnOutput(gross, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
1669	requireMinOut(fee.Net, minWugnotOut, "wugnot out")
1670
1671	// Retain fee in pool ugnot (cash stays); user gets net WUGNOT.
1672	l.PoolUgnot = newPU + fee.Fee
1673	l.PoolToken = newPT
1674	l.CreatorFees += fee.Creator
1675	creditProtocol(fee.Protocol)
1676
1677	addBal(l, seller, -tokensIn)
1678	payWugnotOut(cur, seller, fee.Net)
1679	recordTrade(l, TradeSideSell, fee.Net, tokensIn)
1680
1681	chain.Emit("SwapSell",
1682		"id", id,
1683		"seller", seller.String(),
1684		"tokens", strconv.FormatInt(tokensIn, 10),
1685		"ugnot", strconv.FormatInt(fee.Net, 10),
1686		"wugnot", strconv.FormatInt(fee.Net, 10),
1687	)
1688	notifyTrade(cur, seller, id, 1, fee.Net)
1689	return fee.Net
1690}
1691
1692// Transfer moves GRC20 tokens between addresses (user-initiated).
1693func Transfer(cur realm, id string, to address, amount int64) {
1694	requireInit()
1695	if !cur.Previous().IsUserCall() {
1696		panic("pad: must be EOA MsgCall")
1697	}
1698	if amount <= 0 {
1699		panic("pad: amount must be positive")
1700	}
1701	if !to.IsValid() {
1702		panic("pad: invalid to")
1703	}
1704	l := mustLaunch(id)
1705	from := cur.Previous().Address()
1706	if from == to {
1707		panic("pad: self transfer")
1708	}
1709	if l.ledger == nil {
1710		panic("pad: no GRC20 ledger")
1711	}
1712	if err := l.ledger.Transfer(from, to, amount); err != nil {
1713		panic("pad: transfer: " + err.Error())
1714	}
1715	chain.Emit("Transfer", "id", id, "from", from.String(), "to", to.String(),
1716		"amount", strconv.FormatInt(amount, 10))
1717}
1718
1719// Approve sets GRC20 allowance so DEX/contracts can TransferFrom.
1720func Approve(cur realm, id string, spender address, amount int64) {
1721	requireInit()
1722	if !cur.Previous().IsUserCall() {
1723		panic("pad: must be EOA MsgCall")
1724	}
1725	if !spender.IsValid() {
1726		panic("pad: invalid spender")
1727	}
1728	l := mustLaunch(id)
1729	if l.ledger == nil {
1730		panic("pad: no GRC20 ledger")
1731	}
1732	owner := cur.Previous().Address()
1733	if err := l.ledger.Approve(owner, spender, amount); err != nil {
1734		panic("pad: approve: " + err.Error())
1735	}
1736	chain.Emit("Approval", "id", id, "owner", owner.String(), "spender", spender.String(),
1737		"amount", strconv.FormatInt(amount, 10))
1738}
1739
1740// TransferFrom spends allowance: spender = MsgCall EOA caller.
1741// Enables DEX / routers that hold allowance from Approve.
1742func TransferFrom(cur realm, id string, from, to address, amount int64) {
1743	requireInit()
1744	if !cur.Previous().IsUserCall() {
1745		panic("pad: must be EOA MsgCall")
1746	}
1747	if amount <= 0 {
1748		panic("pad: amount must be positive")
1749	}
1750	if !from.IsValid() || !to.IsValid() {
1751		panic("pad: invalid address")
1752	}
1753	if from == to {
1754		panic("pad: self transfer")
1755	}
1756	l := mustLaunch(id)
1757	if l.ledger == nil {
1758		panic("pad: no GRC20 ledger")
1759	}
1760	spender := cur.Previous().Address()
1761	if err := l.ledger.TransferFrom(from, spender, to, amount); err != nil {
1762		panic("pad: transferFrom: " + err.Error())
1763	}
1764	chain.Emit("TransferFrom", "id", id, "from", from.String(), "to", to.String(),
1765		"spender", spender.String(), "amount", strconv.FormatInt(amount, 10))
1766}
1767
1768// ClaimCreatorFees withdraws accrued creator fees for a launch.
1769// Only the token creator may claim. Fees stay on pad until claimed.
1770func ClaimCreatorFees(cur realm, id string) int64 {
1771	requireInit()
1772	if !cur.Previous().IsUserCall() {
1773		panic("pad: must be EOA MsgCall")
1774	}
1775	l := mustLaunch(id)
1776	caller := cur.Previous().Address()
1777	if caller != l.Creator {
1778		panic("pad: not creator")
1779	}
1780	amt := l.CreatorFees
1781	if amt <= 0 {
1782		return 0
1783	}
1784	l.CreatorFees = 0
1785	// Production: fees accrue as WUGNOT on pad. Tests pay ugnot via payWugnotOut.
1786	payWugnotOut(cur, caller, amt)
1787	chain.Emit("ClaimCreator", "id", id, "amount", strconv.FormatInt(amt, 10))
1788	return amt
1789}
1790
1791// payoutProtocolFees sends all pending protocol fees to protocolAddr.
1792// Trade fees (WUGNOT) + create BondFee (bank ugnot). Shared by Claim/Push.
1793// Fail-closed: check free WUGNOT before clearing protocolFees (no silent clip).
1794func payoutProtocolFees(cur realm) int64 {
1795	wAmt := protocolFees
1796	bAmt := protocolBondFees
1797	amt := wAmt + bAmt
1798	if amt <= 0 {
1799		return 0
1800	}
1801	if !protocolAddr.IsValid() {
1802		panic("pad: protocol address unset")
1803	}
1804	// protocolFees is included in reservedWugnot(); temporarily treat as payable free.
1805	if wAmt > 0 && !testSkipBanker {
1806		have := wugnot.BalanceOf(padAddr)
1807		// Free excluding this protocolFees slice: have - (reserved - wAmt)
1808		res := reservedWugnot()
1809		avail := have - (res - wAmt)
1810		if avail < wAmt {
1811			panic("pad: insufficient free WUGNOT for protocol fees")
1812		}
1813	}
1814	protocolFees = 0
1815	protocolBondFees = 0
1816	protocolFeesPaid += amt
1817	if wAmt > 0 {
1818		if testSkipBanker {
1819			sendUgnot(cur, protocolAddr, wAmt)
1820		} else {
1821			wugnot.Transfer(cross(cur), protocolAddr, wAmt)
1822		}
1823	}
1824	if bAmt > 0 {
1825		sendUgnot(cur, protocolAddr, bAmt)
1826	}
1827	chain.Emit("ClaimProtocol",
1828		"to", protocolAddr.String(),
1829		"amount", strconv.FormatInt(amt, 10),
1830		"wugnot", strconv.FormatInt(wAmt, 10),
1831		"bondUgnot", strconv.FormatInt(bAmt, 10),
1832	)
1833	return amt
1834}
1835
1836// ClaimProtocolFees withdraws pending protocol fees to protocolAddr.
1837// Only the current protocol treasury key may call (same wallet that Init'd,
1838// unless TransferProtocol was used).
1839func ClaimProtocolFees(cur realm) int64 {
1840	requireInit()
1841	if !cur.Previous().IsUserCall() {
1842		panic("pad: must be EOA MsgCall")
1843	}
1844	if cur.Previous().Address() != protocolAddr {
1845		panic("pad: not protocol")
1846	}
1847	return payoutProtocolFees(cur)
1848}
1849
1850// PushProtocolFees sends pending protocol fees to protocolAddr.
1851// Permissionless: anyone may call so treasury can be paid without the protocol
1852// key signing (still only pays the configured protocolAddr).
1853func PushProtocolFees(cur realm) int64 {
1854	requireInit()
1855	if !cur.Previous().IsUserCall() {
1856		panic("pad: must be EOA MsgCall")
1857	}
1858	return payoutProtocolFees(cur)
1859}
1860
1861// TransferProtocol rotates the protocol fee recipient (current protocol only).
1862// Pending protocolFees stay on pad until claimed/pushed to the *new* address.
1863func TransferProtocol(cur realm, newAddr address) {
1864	requireInit()
1865	if !cur.Previous().IsUserCall() {
1866		panic("pad: must be EOA MsgCall")
1867	}
1868	if cur.Previous().Address() != protocolAddr {
1869		panic("pad: not protocol")
1870	}
1871	if !newAddr.IsValid() {
1872		panic("pad: invalid new protocol address")
1873	}
1874	if newAddr == protocolAddr {
1875		panic("pad: same protocol address")
1876	}
1877	old := protocolAddr
1878	protocolAddr = newAddr
1879	chain.Emit("TransferProtocol", "from", old.String(), "to", newAddr.String())
1880}
1881
1882// ProtocolAddress returns the current protocol treasury address (bech32).
1883func ProtocolAddress() string {
1884	return protocolAddr.String()
1885}
1886
1887// ProtocolFeesPaid returns lifetime ugnot already paid out to the treasury.
1888func ProtocolFeesPaid() int64 {
1889	return protocolFeesPaid
1890}
1891
1892// FeeInfo returns protocolAddr|pendingUgnot|paidUgnot for UIs.
1893func FeeInfo() string {
1894	return protocolAddr.String() + "|" +
1895		strconv.FormatInt(protocolFees+protocolBondFees, 10) + "|" +
1896		strconv.FormatInt(protocolFeesPaid, 10)
1897}
1898
1899// PadAddress returns this pad realm's bech32 package address (fund WUGNOT here).
1900func PadAddress() string {
1901	return padAddr.String()
1902}
1903
1904// AdminInfo is a single-line dashboard snapshot for the ops UI:
1905//
1906//	protocolAddr|pendingFees|paidFees|reservedUgnot|launchCount|pointsOn|inited|padAddr
1907//
1908// pointsOn/inited are 0|1.
1909func AdminInfo() string {
1910	pts := "0"
1911	if pointsEnabled {
1912		pts = "1"
1913	}
1914	ini := "0"
1915	if inited {
1916		ini = "1"
1917	}
1918	return protocolAddr.String() + "|" +
1919		strconv.FormatInt(protocolFees+protocolBondFees, 10) + "|" +
1920		strconv.FormatInt(protocolFeesPaid, 10) + "|" +
1921		strconv.FormatInt(reservedUgnot(), 10) + "|" +
1922		strconv.Itoa(launches.Size()) + "|" +
1923		pts + "|" +
1924		ini + "|" +
1925		padAddr.String()
1926}
1927
1928// IsProtocol reports whether addr is the current treasury (for UI gating).
1929func IsProtocol(addr string) bool {
1930	if !inited || !protocolAddr.IsValid() {
1931		return false
1932	}
1933	return protocolAddr.String() == addr
1934}
1935
1936// reservedUgnot is bank ugnot the pad must keep (create BondFee + legacy BondUgnot).
1937// Curve / CPMM / trade protocolFees are WUGNOT — see reservedWugnot.
1938func reservedUgnot() int64 {
1939	reserved := protocolBondFees
1940	launches.Iterate("", "", func(_ string, value any) bool {
1941		l := value.(*Launch)
1942		if !l.BondRefunded {
1943			reserved += l.BondUgnot
1944		}
1945		return false
1946	})
1947	return reserved
1948}
1949
1950// ReservedUgnot is ugnot the pad must keep for markets + pending claims.
1951func ReservedUgnot() int64 {
1952	return reservedUgnot()
1953}
1954
1955// freeUgnot reports bank ugnot above reserved liabilities (0 if short/test).
1956func freeUgnot(cur realm) int64 {
1957	if testSkipBanker {
1958		return 0
1959	}
1960	bk := banker.NewBanker(banker.BankerTypeReadonly, cur)
1961	bal := bk.GetCoins(cur.Address()).AmountOf(DenomUgnot)
1962	free := bal - reservedUgnot()
1963	if free < 0 {
1964		return 0
1965	}
1966	return free
1967}
1968
1969// WithdrawProtocolUgnot lets the treasury pull free ugnot from the pad bank
1970// (e.g. raised backlog after Gnoswap list, to re-wrap as WUGNOT inventory).
1971// Capped by free balance; panics if amount > free.
1972func WithdrawProtocolUgnot(cur realm, amount int64) int64 {
1973	requireInit()
1974	if !cur.Previous().IsUserCall() {
1975		panic("pad: must be EOA MsgCall")
1976	}
1977	if cur.Previous().Address() != protocolAddr {
1978		panic("pad: not protocol")
1979	}
1980	if amount <= 0 {
1981		panic("pad: amount must be positive")
1982	}
1983	free := freeUgnot(cur)
1984	if amount > free {
1985		panic("pad: amount exceeds free ugnot (reserved for markets/fees)")
1986	}
1987	sendUgnot(cur, protocolAddr, amount)
1988	chain.Emit("WithdrawProtocolUgnot",
1989		"to", protocolAddr.String(),
1990		"amount", strconv.FormatInt(amount, 10),
1991		"freeLeft", strconv.FormatInt(free-amount, 10),
1992	)
1993	return amount
1994}
1995
1996// --- read helpers (non-crossing) ---
1997
1998func BalanceOf(id string, owner address) int64 {
1999	return balOf(mustLaunch(id), owner)
2000}
2001
2002// Allowance returns how many tokens `spender` may TransferFrom from `owner`
2003// (same GRC20 semantics as wugnot/gns.Allowance, keyed by launch id).
2004func Allowance(id string, owner, spender address) int64 {
2005	l := mustLaunch(id)
2006	if l.token == nil {
2007		return 0
2008	}
2009	if !owner.IsValid() || !spender.IsValid() {
2010		return 0
2011	}
2012	return l.token.Allowance(owner, spender)
2013}
2014
2015// ListBuyers returns unique buyer addresses (one per line), capped for query size.
2016// Only addresses that bought at least once on this pad (UniqueBuyers). Not full GRC20 holders
2017// who received tokens via transfer.
2018func ListBuyers(id string) string {
2019	l := mustLaunch(id)
2020	const maxN = 100
2021	out := ""
2022	n := 0
2023	l.UniqueBuyers.Iterate("", "", func(key string, _ any) bool {
2024		if n >= maxN {
2025			return true
2026		}
2027		if out != "" {
2028			out += "\n"
2029		}
2030		out += key
2031		n++
2032		return false
2033	})
2034	return out
2035}
2036
2037func GetStatus(id string) int {
2038	return mustLaunch(id).Status
2039}
2040
2041func GetRaised(id string) int64 {
2042	return mustLaunch(id).RaisedUgnot
2043}
2044
2045func GetPool(id string) (ugnot, token int64) {
2046	l := mustLaunch(id)
2047	return l.PoolUgnot, l.PoolToken
2048}
2049
2050func GetCreatorFees(id string) int64 {
2051	return mustLaunch(id).CreatorFees
2052}
2053
2054func ProtocolFees() int64 {
2055	return protocolFees + protocolBondFees
2056}
2057
2058// ProtocolBondFees returns pending create-bond bank ugnot (subset of ProtocolFees).
2059func ProtocolBondFees() int64 {
2060	return protocolBondFees
2061}
2062
2063func LaunchCount() int {
2064	return launches.Size()
2065}
2066
2067func ResolveSymbol(symbol string) string {
2068	s, ok := bySymbol.Get(symbol).(string)
2069	if !ok {
2070		return ""
2071	}
2072	return s
2073}
2074
2075// ListIDs returns newline-separated launch IDs (sorted by AVL key / creation order).
2076func ListIDs() string {
2077	out := ""
2078	launches.Iterate("", "", func(key string, _ any) bool {
2079		if out != "" {
2080			out += "\n"
2081		}
2082		out += key
2083		return false
2084	})
2085	return out
2086}
2087
2088// LaunchInfo returns a single-line pipe-delimited summary for UIs/indexers:
2089//
2090//	id|name|symbol|status|raised|sold|buyers|creatorFees|poolUgnot|poolToken|uri|creator|virtualUgnot|virtualToken|created|tokenID|gnoswapReady|gnoswapListed|gnoswapPoolPath|gnoswapNote|listVenue
2091//
2092// status: 0=curve 1=graduated; gnoswapReady/listed: 0|1
2093// gnoswapNote: optional (padv12+); pipes stripped for delimiter safety.
2094// listVenue: optional (padv23+); empty if unlisted.
2095func LaunchInfo(id string) string {
2096	l := mustLaunch(id)
2097	gs := "0"
2098	if l.GnoswapReady {
2099		gs = "1"
2100	}
2101	gl := "0"
2102	if l.GnoswapListed {
2103		gl = "1"
2104	}
2105	note := strings.ReplaceAll(l.GnoswapNote, "|", "/")
2106	venue := strings.ReplaceAll(l.ListVenue, "|", "/")
2107	return l.ID + "|" +
2108		l.Name + "|" +
2109		l.Symbol + "|" +
2110		strconv.Itoa(l.Status) + "|" +
2111		strconv.FormatInt(l.RaisedUgnot, 10) + "|" +
2112		strconv.FormatInt(l.RealSold, 10) + "|" +
2113		strconv.Itoa(l.BuyerCount) + "|" +
2114		strconv.FormatInt(l.CreatorFees, 10) + "|" +
2115		strconv.FormatInt(l.PoolUgnot, 10) + "|" +
2116		strconv.FormatInt(l.PoolToken, 10) + "|" +
2117		l.URI + "|" +
2118		l.Creator.String() + "|" +
2119		strconv.FormatInt(l.VirtualUgnot, 10) + "|" +
2120		strconv.FormatInt(l.VirtualToken, 10) + "|" +
2121		strconv.FormatInt(l.Created, 10) + "|" +
2122		l.TokenID + "|" +
2123		gs + "|" +
2124		gl + "|" +
2125		l.GnoswapPoolPath + "|" +
2126		note + "|" +
2127		venue
2128}
2129
2130// ParamsInfo returns parameters for UI display.
2131// total|curve|poolSeed|gradThreshold|feeBps|createBond|listFeeGns
2132// createBond is live from bond realm when not in unit-test mode.
2133// listFeeGns: Create-time GNS escrow required (padv20+).
2134func ParamsInfo() string {
2135	return strconv.FormatInt(TotalSupply, 10) + "|" +
2136		strconv.FormatInt(CurveSupply, 10) + "|" +
2137		strconv.FormatInt(PoolSeed, 10) + "|" +
2138		strconv.FormatInt(graduationThreshold(), 10) + "|" +
2139		strconv.FormatInt(FeeBPS, 10) + "|" +
2140		strconv.FormatInt(requiredCreateBond(), 10) + "|" +
2141		strconv.FormatInt(requiredListFeeGns(), 10)
2142}
2143
2144// TradeHistory returns newline-separated chart points:
2145//
2146//	height|side|ugnot|tokens|priceScaled
2147//
2148// side: 0=buy 1=sell 2=open/graduate. Ordered oldest -> newest.
2149func TradeHistory(id string) string {
2150	l := mustLaunch(id)
2151	out := ""
2152	l.Trades.Iterate("", "", func(_ string, value any) bool {
2153		t := value.(*Trade)
2154		line := strconv.FormatInt(t.Height, 10) + "|" +
2155			strconv.Itoa(t.Side) + "|" +
2156			strconv.FormatInt(t.Ugnot, 10) + "|" +
2157			strconv.FormatInt(t.Tokens, 10) + "|" +
2158			strconv.FormatInt(t.Price, 10)
2159		if out != "" {
2160			out += "\n"
2161		}
2162		out += line
2163		return false
2164	})
2165	return out
2166}
2167
2168// TradeCount returns number of stored chart samples for a launch.
2169func TradeCount(id string) int {
2170	return mustLaunch(id).Trades.Size()
2171}
2172
2173// resetForTest clears package state between unit tests.
2174func resetForTest() {
2175	launches = avl.Tree{}
2176	bySymbol = avl.Tree{}
2177	nextID = 0
2178	nextTokenID = 0
2179	var zero address
2180	protocolAddr = zero
2181	// padAddr is set in package init - do not clear (realm address is fixed).
2182	protocolFees = 0
2183	protocolBondFees = 0
2184	protocolFeesPaid = 0
2185	inited = false
2186	pointsEnabled = false
2187	graduationUgnot = 0
2188	listFeeGnsLive = 0
2189	testSkipBanker = true // unit tests skip banker; integration/chain tests leave false
2190	testForceGnoswapList = false
2191	testForceZdexList = false
2192	totalListFeeGns = 0
2193	totalWugnotCredit = 0
2194	wugnotCredit = avl.Tree{}
2195	resetListVenuesForTest()
2196}