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

27.37 Kb · 983 lines
  1// Package pad is gnomemepad: a self-contained meme launchpad for gno.land.
  2//
  3// Direction A — factory IS the market (no external AMM required for MVP):
  4//
  5//	Create → GRC20 token + bonding curve → Graduate (atomic locked CPMM)
  6//	→ GRC20 is listable on Gnoswap (external DEX); pad keeps locked pool too
  7//
  8// Tokens are real GRC20 (mint on buy, burn on sell) so holders can transfer and
  9// later provide liquidity on Gnoswap. Hybrid of Pump.fun + permanent LP lock.
 10package padv7
 11
 12import (
 13	"chain"
 14	"chain/banker"
 15	"chain/runtime"
 16	"chain/runtime/unsafe"
 17	"strconv"
 18	"strings"
 19
 20	"gno.land/p/demo/tokens/grc20"
 21	"gno.land/p/g1mv0052e7r6s09f5t9xsqf00nj3tqsgt9dg52jr/gnomemepad/ammmath"
 22	"gno.land/p/nt/avl/v0"
 23	"gno.land/p/nt/seqid/v0"
 24	"gno.land/r/demo/defi/grc20reg"
 25	// pointsv2: optional trade/create awards (off by default until SetPointsEnabled).
 26	// Deploy prepare rewrites this import to the Sapphire personal-namespace path.
 27	pointsv2 "gno.land/r/g1mv0052e7r6s09f5t9xsqf00nj3tqsgt9dg52jr/gnomemepad/pointsv2"
 28)
 29
 30var (
 31	launches     avl.Tree // id -> *Launch
 32	bySymbol     avl.Tree // symbol -> id string
 33	nextID       seqid.ID
 34	nextTokenID  seqid.ID // GRC20 identity sequence (shared for all launches)
 35	protocolAddr address
 36	protocolFees int64
 37	inited       bool
 38	// testSkipBanker: when true, sendUgnot is a no-op (unit tests without funded realm bank).
 39	// Always false in production.
 40	testSkipBanker bool
 41	// pointsEnabled: when true, notify pointsv2 after Create / Buy / Sell / Swap*.
 42	// Admin must also AllowPad(this package) on pointsv2.
 43	pointsEnabled bool
 44)
 45
 46// Trade is one price sample for charts (capped history per launch).
 47type Trade struct {
 48	Height int64
 49	Side   int // TradeSideBuy | TradeSideSell | TradeSideOpen
 50	Ugnot  int64
 51	Tokens int64
 52	Price  int64 // ugnot per token * 1e6 after the trade
 53}
 54
 55// Launch is one meme market: curve phase then locked pool phase.
 56// token/ledger are unexported so external packages cannot Mint/Burn via field access.
 57type Launch struct {
 58	ID      string
 59	Name    string
 60	Symbol  string
 61	URI     string
 62	Creator address
 63	Status  int
 64	Created int64 // block height
 65
 66	// GRC20 (mint/burn only via pad-owned private ledger)
 67	token   *grc20.Token
 68	ledger  *grc20.PrivateLedger
 69	TokenID string // Token.ID() — registry / Gnoswap identity
 70
 71	// Virtual curve reserves
 72	VirtualUgnot int64
 73	VirtualToken int64
 74	RealSold     int64 // tokens sold on curve (≤ CurveSupply)
 75	RaisedUgnot  int64 // net ugnot collateral in curve (excl. fee vaults)
 76
 77	// Real pool (post-grad); LP permanently locked — no remove path
 78	// PoolToken is pad-internal reserve (not the same as GRC20 total supply).
 79	PoolUgnot int64
 80	PoolToken int64
 81
 82	CreatorFees  int64
 83	BondUgnot    int64
 84	BondRefunded bool
 85	UniqueBuyers avl.Tree // address -> true
 86	BuyerCount   int
 87	// snipeBought: address -> cumulative tokens bought during anti-snipe window
 88	snipeBought avl.Tree
 89
 90	// Gnoswap listing readiness (GRC20 exists from Create; pool on Gnoswap is external)
 91	GnoswapReady bool // true after graduate — token may be paired on Gnoswap
 92	GnoswapNote  string
 93
 94	// Chart history (ordered AVL keys)
 95	Trades    avl.Tree // tradeKey -> *Trade
 96	NextTrade int64
 97}
 98
 99// Init sets the protocol treasury. First EOA caller becomes fee recipient.
100func Init(cur realm) {
101	if inited {
102		panic("pad: already initialized")
103	}
104	if !cur.Previous().IsUserCall() {
105		panic("pad: EOA only")
106	}
107	protocolAddr = cur.Previous().Address()
108	inited = true
109	chain.Emit("Init", "protocol", protocolAddr.String())
110}
111
112func requireInit() {
113	if !inited {
114		panic("pad: call Init first")
115	}
116}
117
118// SetPointsEnabled toggles pointsv2 notifications (protocol admin only).
119// pointsv2 must AllowPad(this package path) or OnTrade/OnCreate will panic and revert the trade.
120func SetPointsEnabled(cur realm, on bool) {
121	requireInit()
122	if !cur.Previous().IsUserCall() {
123		panic("pad: EOA only")
124	}
125	if cur.Previous().Address() != protocolAddr {
126		panic("pad: not protocol")
127	}
128	pointsEnabled = on
129	chain.Emit("SetPointsEnabled", "on", strconv.FormatBool(on))
130}
131
132// PointsEnabled reports whether pad notifies pointsv2 after trades/creates.
133func PointsEnabled() bool {
134	return pointsEnabled
135}
136
137func notifyTrade(cur realm, trader address, id string, side int64, volumeUgnot int64) {
138	if !pointsEnabled || testSkipBanker {
139		return
140	}
141	_ = pointsv2.OnTrade(cross(cur), trader, id, side, volumeUgnot)
142}
143
144func notifyCreate(cur realm, creator address, id string) {
145	if !pointsEnabled || testSkipBanker {
146		return
147	}
148	_ = pointsv2.OnCreate(cross(cur), creator, id)
149}
150
151func mustLaunch(id string) *Launch {
152	// Sapphire avl.Tree.Get returns a single any (nil if missing).
153	l, ok := launches.Get(id).(*Launch)
154	if !ok {
155		panic("pad: unknown launch")
156	}
157	return l
158}
159
160func balOf(l *Launch, addr address) int64 {
161	if l == nil || l.token == nil {
162		return 0
163	}
164	return l.token.BalanceOf(addr)
165}
166
167// addBal mints (delta>0) or burns (delta<0) GRC20 via pad-owned PrivateLedger.
168func addBal(l *Launch, addr address, delta int64) {
169	if l == nil || l.ledger == nil {
170		panic("pad: missing GRC20 ledger")
171	}
172	if delta == 0 {
173		return
174	}
175	if delta > 0 {
176		if err := l.ledger.Mint(addr, delta); err != nil {
177			panic("pad: mint: " + err.Error())
178		}
179		return
180	}
181	if err := l.ledger.Burn(addr, -delta); err != nil {
182		panic("pad: burn: " + err.Error())
183	}
184}
185
186func requireMinOut(got, minOut int64, what string) {
187	if minOut < 0 {
188		panic("pad: minOut must be non-negative")
189	}
190	if minOut > 0 && got < minOut {
191		panic("pad: " + what + " below minOut (slippage)")
192	}
193}
194
195func snipeBoughtOf(l *Launch, buyer address) int64 {
196	v := l.snipeBought.Get(buyer.String())
197	if v == nil {
198		return 0
199	}
200	n, ok := v.(int64)
201	if !ok {
202		return 0
203	}
204	return n
205}
206
207func checkAndAddSnipe(l *Launch, buyer address, tokensOut int64) {
208	height := runtime.ChainHeight()
209	if height-l.Created >= AntiSnipeHeights {
210		return
211	}
212	maxTok := TotalSupply * AntiSnipeMaxBuyBPS / 10000
213	prev := snipeBoughtOf(l, buyer)
214	if prev+tokensOut > maxTok {
215		panic("pad: anti-snipe cumulative max buy exceeded")
216	}
217	l.snipeBought.Set(buyer.String(), prev+tokensOut)
218}
219
220func sendUgnot(cur realm, to address, amount int64) {
221	if amount <= 0 {
222		return
223	}
224	if testSkipBanker {
225		return
226	}
227	bk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
228	bk.SendCoins(cur.Address(), to, chain.Coins{{Denom: DenomUgnot, Amount: amount}})
229}
230
231func requireUserPayment(cur realm) int64 {
232	if !cur.Previous().IsUserCall() {
233		panic("pad: must be EOA MsgCall")
234	}
235	sent := unsafe.OriginSend().AmountOf(DenomUgnot)
236	if sent <= 0 {
237		panic("pad: need ugnot -send")
238	}
239	return sent
240}
241
242func noteBuyer(l *Launch, buyer address) {
243	k := buyer.String()
244	if l.UniqueBuyers.Has(k) {
245		return
246	}
247	l.UniqueBuyers.Set(k, true)
248	l.BuyerCount++
249}
250
251func tradeKey(n int64) string {
252	s := strconv.FormatInt(n, 10)
253	for len(s) < 12 {
254		s = "0" + s
255	}
256	return s
257}
258
259// spotPriceScaled returns ugnot/token * 1e6 from current curve or pool reserves.
260func spotPriceScaled(l *Launch) int64 {
261	if l.Status == StatusGraduated {
262		if l.PoolToken <= 0 {
263			return 0
264		}
265		return l.PoolUgnot * 1000000 / l.PoolToken
266	}
267	if l.VirtualToken <= 0 {
268		return 0
269	}
270	return l.VirtualUgnot * 1000000 / l.VirtualToken
271}
272
273func recordTrade(l *Launch, side int, ugnot, tokens int64) {
274	l.NextTrade++
275	t := &Trade{
276		Height: runtime.ChainHeight(),
277		Side:   side,
278		Ugnot:  ugnot,
279		Tokens: tokens,
280		Price:  spotPriceScaled(l),
281	}
282	l.Trades.Set(tradeKey(l.NextTrade), t)
283	// Ring buffer: drop oldest while over cap.
284	for l.Trades.Size() > MaxTradeHistory {
285		oldest := ""
286		l.Trades.Iterate("", "", func(k string, _ any) bool {
287			oldest = k
288			return true // stop
289		})
290		if oldest == "" {
291			break
292		}
293		l.Trades.Remove(oldest)
294	}
295}
296
297func maybeRefundBond(cur realm, l *Launch) {
298	if l.BondRefunded || l.BondUgnot <= 0 {
299		return
300	}
301	if l.BuyerCount < BondRefundBuyers {
302		return
303	}
304	// Quality gate: pure sybil micro-buys cannot refund bond.
305	if l.RaisedUgnot < BondRefundMinRaised {
306		return
307	}
308	if runtime.ChainHeight()-l.Created > BondRefundMaxHeights {
309		return
310	}
311	amt := l.BondUgnot
312	l.BondUgnot = 0
313	l.BondRefunded = true
314	sendUgnot(cur, l.Creator, amt)
315	chain.Emit("BondRefund", "id", l.ID, "amount", strconv.FormatInt(amt, 10))
316}
317
318// Create deploys a fair-launch meme. Requires CreateBondUgnot via -send.
319// No pre-mint; all tradeable float starts on the bonding curve.
320func Create(cur realm, name, symbol, uri string) string {
321	requireInit()
322	sent := requireUserPayment(cur)
323	if sent < CreateBondUgnot {
324		panic("pad: create bond underpaid")
325	}
326	if name == "" || symbol == "" {
327		panic("pad: name and symbol required")
328	}
329	if len(symbol) > 12 {
330		panic("pad: symbol too long")
331	}
332	if bySymbol.Has(symbol) {
333		panic("pad: symbol taken")
334	}
335	extra := sent - CreateBondUgnot
336	if extra > 0 {
337		protocolFees += extra
338	}
339
340	creator := cur.Previous().Address()
341	id := nextID.Next().String()
342
343	// Real GRC20 bound to this pad realm (mint/burn only via pad ledger).
344	// Decimals=0: whole-token units (matches existing trade amounts).
345	token, ledger := grc20.NewToken(name, symbol, 0, nextTokenID.Next(), cur)
346
347	// Adena (and Gnoswap registries) resolve tokens ONLY via grc20reg under key
348	// packagePath.SYMBOL — Token.ID() itself is packagePath.SYMBOL.seq and is
349	// rejected as "Invalid path" if pasted into Adena without registration.
350	// Skip in unit tests (testSkipBanker); production always registers.
351	regKey := ""
352	if !testSkipBanker {
353		regKey = grc20reg.Register(cross(cur), token, symbol)
354	}
355
356	l := &Launch{
357		ID:           id,
358		Name:         name,
359		Symbol:       symbol,
360		URI:          uri,
361		Creator:      creator,
362		Status:       StatusCurve,
363		Created:      runtime.ChainHeight(),
364		token:        token,
365		ledger:       ledger,
366		TokenID:      token.ID(),
367		VirtualUgnot: VirtualUgnot0,
368		VirtualToken: VirtualToken0,
369		UniqueBuyers: avl.Tree{},
370		snipeBought:  avl.Tree{},
371		BondUgnot:    CreateBondUgnot,
372		Trades:       avl.Tree{},
373	}
374	// Open mark for charts (initial virtual spot).
375	recordTrade(l, TradeSideOpen, 0, 0)
376	launches.Set(id, l)
377	bySymbol.Set(symbol, id)
378
379	chain.Emit("Created",
380		"id", id,
381		"symbol", symbol,
382		"creator", creator.String(),
383		"token", l.TokenID,
384		"reg", regKey,
385	)
386	notifyCreate(cur, creator, id)
387	return id
388}
389
390// AdenaPathOf returns the grc20reg / Adena token key: packagePath.SYMBOL
391// (Token.ID is packagePath.SYMBOL.seq — Adena rejects that form).
392func AdenaPathOf(id string) string {
393	l := mustLaunch(id)
394	return adenaKeyFromTokenID(l.TokenID, l.Symbol)
395}
396
397// adenaKeyFromTokenID strips the trailing .seq from Token.ID when present.
398func adenaKeyFromTokenID(tokenID, symbol string) string {
399	if tokenID == "" {
400		return ""
401	}
402	// Token.ID = packagePath.symbol.seq → registry key = packagePath.symbol
403	suffix := "." + symbol + "."
404	if i := strings.LastIndex(tokenID, suffix); i >= 0 {
405		// packagePath + "." + symbol
406		return tokenID[:i] + "." + symbol
407	}
408	// Already packagePath.symbol or unknown layout
409	if strings.HasSuffix(tokenID, "."+symbol) {
410		return tokenID
411	}
412	return tokenID
413}
414
415// Buy spends -send ugnot on the bonding curve; credits tokens to the caller.
416// minTokensOut: slippage floor (0 = disabled). Auto-graduates at threshold.
417// Oversized buys that would exceed remaining curve supply panic (no silent dust).
418func Buy(cur realm, id string, minTokensOut int64) int64 {
419	requireInit()
420	sent := requireUserPayment(cur)
421	l := mustLaunch(id)
422	if l.Status != StatusCurve {
423		panic("pad: not on curve (use SwapBuy)")
424	}
425	buyer := cur.Previous().Address()
426
427	remaining := CurveSupply - l.RealSold
428	if remaining <= 0 {
429		panic("pad: curve sold out — call Graduate")
430	}
431
432	fee := ammmath.ApplyFee(sent, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
433	// Net enters curve; remainder boosts virtual ugnot (stays as collateral).
434	netIn := fee.Net + fee.Remainder
435
436	tokensOut, newVU, newVT := ammmath.BuyTokens(l.VirtualUgnot, l.VirtualToken, netIn)
437	// Hard fail instead of partial fill + dust (audit Y2).
438	if tokensOut > remaining {
439		panic("pad: buy exceeds remaining curve supply — reduce amount")
440	}
441	requireMinOut(tokensOut, minTokensOut, "tokens out")
442	checkAndAddSnipe(l, buyer, tokensOut)
443
444	// Mutate only after all checks pass.
445	l.CreatorFees += fee.Creator
446	protocolFees += fee.Protocol
447	l.VirtualUgnot = newVU
448	l.VirtualToken = newVT
449	l.RealSold += tokensOut
450	l.RaisedUgnot += netIn
451
452	addBal(l, buyer, tokensOut)
453	noteBuyer(l, buyer)
454	maybeRefundBond(cur, l)
455	recordTrade(l, TradeSideBuy, sent, tokensOut)
456
457	chain.Emit("Buy",
458		"id", id,
459		"buyer", buyer.String(),
460		"ugnot", strconv.FormatInt(sent, 10),
461		"tokens", strconv.FormatInt(tokensOut, 10),
462	)
463	notifyTrade(cur, buyer, id, 0, sent)
464
465	if ammmath.CanGraduate(l.RaisedUgnot, GraduationThreshold) || l.RealSold >= CurveSupply {
466		if l.RaisedUgnot >= GraduationThreshold {
467			graduate(cur, l)
468		}
469	}
470	return tokensOut
471}
472
473// Sell burns curve tokens and pays ugnot (fee on output).
474// minUgnotOut: slippage floor (0 = disabled).
475func Sell(cur realm, id string, tokensIn, minUgnotOut int64) int64 {
476	requireInit()
477	if !cur.Previous().IsUserCall() {
478		panic("pad: must be EOA MsgCall")
479	}
480	if tokensIn <= 0 {
481		panic("pad: tokensIn must be positive")
482	}
483	l := mustLaunch(id)
484	if l.Status != StatusCurve {
485		panic("pad: not on curve (use SwapSell)")
486	}
487	seller := cur.Previous().Address()
488	if balOf(l, seller) < tokensIn {
489		panic("pad: insufficient token balance")
490	}
491
492	gross, newVU, newVT := ammmath.SellTokens(l.VirtualUgnot, l.VirtualToken, tokensIn)
493	fee := ammmath.ApplyFeeOnOutput(gross, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
494	requireMinOut(fee.Net, minUgnotOut, "ugnot out")
495
496	// Full gross left virtual reserves; retain fee in virtual ugnot (cash stays in realm).
497	l.VirtualUgnot = newVU + fee.Fee
498	l.VirtualToken = newVT
499	l.RealSold -= tokensIn
500	if l.RealSold < 0 {
501		l.RealSold = 0
502	}
503
504	// User receives net; creator+protocol become fee liabilities (leave Raised).
505	payOut := fee.Net + fee.Creator + fee.Protocol
506	if l.RaisedUgnot >= payOut {
507		l.RaisedUgnot -= payOut
508	} else {
509		l.RaisedUgnot = 0
510	}
511	l.CreatorFees += fee.Creator
512	protocolFees += fee.Protocol
513
514	addBal(l, seller, -tokensIn)
515	sendUgnot(cur, seller, fee.Net)
516	recordTrade(l, TradeSideSell, fee.Net, tokensIn)
517
518	chain.Emit("Sell",
519		"id", id,
520		"seller", seller.String(),
521		"tokens", strconv.FormatInt(tokensIn, 10),
522		"ugnot", strconv.FormatInt(fee.Net, 10),
523	)
524	notifyTrade(cur, seller, id, 1, fee.Net)
525	return fee.Net
526}
527
528// Graduate permissionlessly moves a ready curve into a permanently locked CPMM.
529func Graduate(cur realm, id string) {
530	requireInit()
531	l := mustLaunch(id)
532	if l.Status != StatusCurve {
533		panic("pad: already graduated")
534	}
535	if !ammmath.CanGraduate(l.RaisedUgnot, GraduationThreshold) {
536		panic("pad: threshold not met")
537	}
538	graduate(cur, l)
539}
540
541func graduate(cur realm, l *Launch) {
542	if l.Status != StatusCurve {
543		return
544	}
545	// Seed pool: all curve collateral + remaining pool seed tokens.
546	// Curve unsold tokens are not minted; only PoolSeed enters the pool.
547	// Raised ugnot becomes pool ugnot (fee vaults stay separate liabilities).
548	poolU := l.RaisedUgnot
549	if poolU <= 0 {
550		panic("pad: empty pool ugnot")
551	}
552	poolT := PoolSeed
553	// Optional: if curve sold less than CurveSupply, unsold is never minted (dead supply).
554	// Real circulating = RealSold + PoolSeed after minting pool to realm accounting.
555
556	l.PoolUgnot = poolU
557	l.PoolToken = poolT
558	l.RaisedUgnot = 0
559	l.VirtualUgnot = 0
560	l.VirtualToken = 0
561	l.Status = StatusGraduated
562
563	// Pool tokens are held by the launch (not an address) — PoolToken reserve.
564	// Circulating user balances remain; total effective supply = sum(balances)+PoolToken.
565
566	// Forfeit unrefunded bond to protocol at graduation if still locked.
567	if !l.BondRefunded && l.BondUgnot > 0 {
568		protocolFees += l.BondUgnot
569		l.BondUgnot = 0
570		l.BondRefunded = true
571	}
572
573	// Mark graduation on chart at pool spot.
574	recordTrade(l, TradeSideOpen, poolU, poolT)
575
576	// GRC20 already exists and is transferable. Mark ready for external Gnoswap pool.
577	// Pad keeps a locked internal CPMM; Gnoswap listing is a separate permissionless pool
578	// seeded with GNOT + this GRC20 by the community/creator (see Guide).
579	l.GnoswapReady = true
580	l.GnoswapNote = "create GNOT/" + l.Symbol + " pool on Gnoswap with Token.ID=" + l.TokenID
581
582	chain.Emit("Graduated",
583		"id", l.ID,
584		"poolUgnot", strconv.FormatInt(poolU, 10),
585		"poolToken", strconv.FormatInt(poolT, 10),
586		"token", l.TokenID,
587		"gnoswap_ready", "1",
588	)
589	_ = cur // banker not needed; ugnot already on realm
590}
591
592// TokenIDOf returns the GRC20 Token.ID() for a launch.
593func TokenIDOf(id string) string {
594	return mustLaunch(id).TokenID
595}
596
597// GRC20Bank returns the underlying *grc20.Token for interop (metadata / external DEX).
598// Does not expose PrivateLedger — mint/burn stay pad-only.
599func GRC20Bank(id string) *grc20.Token {
600	l := mustLaunch(id)
601	if l.token == nil {
602		panic("pad: no token")
603	}
604	return l.token
605}
606
607// SwapBuy buys tokens from the graduated pool with -send ugnot.
608// minTokensOut: slippage floor (0 = disabled).
609func SwapBuy(cur realm, id string, minTokensOut int64) int64 {
610	requireInit()
611	sent := requireUserPayment(cur)
612	l := mustLaunch(id)
613	if l.Status != StatusGraduated {
614		panic("pad: not graduated (use Buy)")
615	}
616	buyer := cur.Previous().Address()
617
618	fee := ammmath.ApplyFee(sent, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
619	tokensOut, newPU, newPT := ammmath.PoolSwapUgnotForToken(
620		l.PoolUgnot, l.PoolToken, fee.Net, fee.Remainder,
621	)
622	requireMinOut(tokensOut, minTokensOut, "tokens out")
623
624	l.CreatorFees += fee.Creator
625	protocolFees += fee.Protocol
626	l.PoolUgnot = newPU
627	l.PoolToken = newPT
628	addBal(l, buyer, tokensOut)
629	noteBuyer(l, buyer)
630	recordTrade(l, TradeSideBuy, sent, tokensOut)
631
632	chain.Emit("SwapBuy",
633		"id", id,
634		"buyer", buyer.String(),
635		"ugnot", strconv.FormatInt(sent, 10),
636		"tokens", strconv.FormatInt(tokensOut, 10),
637	)
638	notifyTrade(cur, buyer, id, 0, sent)
639	return tokensOut
640}
641
642// SwapSell sells tokens into the graduated pool for ugnot.
643// minUgnotOut: slippage floor (0 = disabled).
644func SwapSell(cur realm, id string, tokensIn, minUgnotOut int64) int64 {
645	requireInit()
646	if !cur.Previous().IsUserCall() {
647		panic("pad: must be EOA MsgCall")
648	}
649	if tokensIn <= 0 {
650		panic("pad: tokensIn must be positive")
651	}
652	l := mustLaunch(id)
653	if l.Status != StatusGraduated {
654		panic("pad: not graduated (use Sell)")
655	}
656	seller := cur.Previous().Address()
657	if balOf(l, seller) < tokensIn {
658		panic("pad: insufficient token balance")
659	}
660
661	gross, newPU, newPT := ammmath.PoolSwapTokenForUgnot(l.PoolUgnot, l.PoolToken, tokensIn)
662	fee := ammmath.ApplyFeeOnOutput(gross, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
663	requireMinOut(fee.Net, minUgnotOut, "ugnot out")
664
665	// Retain fee in pool ugnot (cash stays); user gets net.
666	l.PoolUgnot = newPU + fee.Fee
667	l.PoolToken = newPT
668	l.CreatorFees += fee.Creator
669	protocolFees += fee.Protocol
670
671	addBal(l, seller, -tokensIn)
672	sendUgnot(cur, seller, fee.Net)
673	recordTrade(l, TradeSideSell, fee.Net, tokensIn)
674
675	chain.Emit("SwapSell",
676		"id", id,
677		"seller", seller.String(),
678		"tokens", strconv.FormatInt(tokensIn, 10),
679		"ugnot", strconv.FormatInt(fee.Net, 10),
680	)
681	notifyTrade(cur, seller, id, 1, fee.Net)
682	return fee.Net
683}
684
685// Transfer moves GRC20 tokens between addresses (user-initiated).
686func Transfer(cur realm, id string, to address, amount int64) {
687	requireInit()
688	if !cur.Previous().IsUserCall() {
689		panic("pad: must be EOA MsgCall")
690	}
691	if amount <= 0 {
692		panic("pad: amount must be positive")
693	}
694	if !to.IsValid() {
695		panic("pad: invalid to")
696	}
697	l := mustLaunch(id)
698	from := cur.Previous().Address()
699	if from == to {
700		panic("pad: self transfer")
701	}
702	if l.ledger == nil {
703		panic("pad: no GRC20 ledger")
704	}
705	if err := l.ledger.Transfer(from, to, amount); err != nil {
706		panic("pad: transfer: " + err.Error())
707	}
708	chain.Emit("Transfer", "id", id, "from", from.String(), "to", to.String(),
709		"amount", strconv.FormatInt(amount, 10))
710}
711
712// Approve sets GRC20 allowance so DEX/contracts can TransferFrom.
713func Approve(cur realm, id string, spender address, amount int64) {
714	requireInit()
715	if !cur.Previous().IsUserCall() {
716		panic("pad: must be EOA MsgCall")
717	}
718	if !spender.IsValid() {
719		panic("pad: invalid spender")
720	}
721	l := mustLaunch(id)
722	if l.ledger == nil {
723		panic("pad: no GRC20 ledger")
724	}
725	owner := cur.Previous().Address()
726	if err := l.ledger.Approve(owner, spender, amount); err != nil {
727		panic("pad: approve: " + err.Error())
728	}
729	chain.Emit("Approval", "id", id, "owner", owner.String(), "spender", spender.String(),
730		"amount", strconv.FormatInt(amount, 10))
731}
732
733// TransferFrom spends allowance: spender = MsgCall EOA caller.
734// Enables DEX / routers that hold allowance from Approve.
735func TransferFrom(cur realm, id string, from, to address, amount int64) {
736	requireInit()
737	if !cur.Previous().IsUserCall() {
738		panic("pad: must be EOA MsgCall")
739	}
740	if amount <= 0 {
741		panic("pad: amount must be positive")
742	}
743	if !from.IsValid() || !to.IsValid() {
744		panic("pad: invalid address")
745	}
746	if from == to {
747		panic("pad: self transfer")
748	}
749	l := mustLaunch(id)
750	if l.ledger == nil {
751		panic("pad: no GRC20 ledger")
752	}
753	spender := cur.Previous().Address()
754	if err := l.ledger.TransferFrom(from, spender, to, amount); err != nil {
755		panic("pad: transferFrom: " + err.Error())
756	}
757	chain.Emit("TransferFrom", "id", id, "from", from.String(), "to", to.String(),
758		"spender", spender.String(), "amount", strconv.FormatInt(amount, 10))
759}
760
761// ClaimCreatorFees withdraws accrued creator fees for a launch.
762func ClaimCreatorFees(cur realm, id string) int64 {
763	requireInit()
764	if !cur.Previous().IsUserCall() {
765		panic("pad: must be EOA MsgCall")
766	}
767	l := mustLaunch(id)
768	caller := cur.Previous().Address()
769	if caller != l.Creator {
770		panic("pad: not creator")
771	}
772	amt := l.CreatorFees
773	if amt <= 0 {
774		return 0
775	}
776	l.CreatorFees = 0
777	sendUgnot(cur, caller, amt)
778	chain.Emit("ClaimCreator", "id", id, "amount", strconv.FormatInt(amt, 10))
779	return amt
780}
781
782// ClaimProtocolFees withdraws protocol treasury fees.
783func ClaimProtocolFees(cur realm) int64 {
784	requireInit()
785	if !cur.Previous().IsUserCall() {
786		panic("pad: must be EOA MsgCall")
787	}
788	caller := cur.Previous().Address()
789	if caller != protocolAddr {
790		panic("pad: not protocol")
791	}
792	amt := protocolFees
793	if amt <= 0 {
794		return 0
795	}
796	protocolFees = 0
797	sendUgnot(cur, caller, amt)
798	chain.Emit("ClaimProtocol", "amount", strconv.FormatInt(amt, 10))
799	return amt
800}
801
802// TransferProtocol rotates the protocol fee recipient (current protocol only).
803func TransferProtocol(cur realm, newAddr address) {
804	requireInit()
805	if !cur.Previous().IsUserCall() {
806		panic("pad: must be EOA MsgCall")
807	}
808	if cur.Previous().Address() != protocolAddr {
809		panic("pad: not protocol")
810	}
811	if !newAddr.IsValid() {
812		panic("pad: invalid new protocol address")
813	}
814	if newAddr == protocolAddr {
815		panic("pad: same protocol address")
816	}
817	old := protocolAddr
818	protocolAddr = newAddr
819	chain.Emit("TransferProtocol", "from", old.String(), "to", newAddr.String())
820}
821
822// ProtocolAddress returns the current protocol treasury address.
823func ProtocolAddress() string {
824	return protocolAddr.String()
825}
826
827// --- read helpers (non-crossing) ---
828
829func BalanceOf(id string, owner address) int64 {
830	return balOf(mustLaunch(id), owner)
831}
832
833// ListBuyers returns unique buyer addresses (one per line), capped for query size.
834// Only addresses that bought at least once on this pad (UniqueBuyers). Not full GRC20 holders
835// who received tokens via transfer.
836func ListBuyers(id string) string {
837	l := mustLaunch(id)
838	const maxN = 100
839	out := ""
840	n := 0
841	l.UniqueBuyers.Iterate("", "", func(key string, _ any) bool {
842		if n >= maxN {
843			return true
844		}
845		if out != "" {
846			out += "\n"
847		}
848		out += key
849		n++
850		return false
851	})
852	return out
853}
854
855func GetStatus(id string) int {
856	return mustLaunch(id).Status
857}
858
859func GetRaised(id string) int64 {
860	return mustLaunch(id).RaisedUgnot
861}
862
863func GetPool(id string) (ugnot, token int64) {
864	l := mustLaunch(id)
865	return l.PoolUgnot, l.PoolToken
866}
867
868func GetCreatorFees(id string) int64 {
869	return mustLaunch(id).CreatorFees
870}
871
872func ProtocolFees() int64 {
873	return protocolFees
874}
875
876func LaunchCount() int {
877	return launches.Size()
878}
879
880func ResolveSymbol(symbol string) string {
881	s, ok := bySymbol.Get(symbol).(string)
882	if !ok {
883		return ""
884	}
885	return s
886}
887
888// ListIDs returns newline-separated launch IDs (sorted by AVL key / creation order).
889func ListIDs() string {
890	out := ""
891	launches.Iterate("", "", func(key string, _ any) bool {
892		if out != "" {
893			out += "\n"
894		}
895		out += key
896		return false
897	})
898	return out
899}
900
901// LaunchInfo returns a single-line pipe-delimited summary for UIs/indexers:
902//
903//	id|name|symbol|status|raised|sold|buyers|creatorFees|poolUgnot|poolToken|uri|creator|virtualUgnot|virtualToken|created|tokenID|gnoswapReady
904//
905// status: 0=curve 1=graduated; gnoswapReady: 0|1
906func LaunchInfo(id string) string {
907	l := mustLaunch(id)
908	gs := "0"
909	if l.GnoswapReady {
910		gs = "1"
911	}
912	return l.ID + "|" +
913		l.Name + "|" +
914		l.Symbol + "|" +
915		strconv.Itoa(l.Status) + "|" +
916		strconv.FormatInt(l.RaisedUgnot, 10) + "|" +
917		strconv.FormatInt(l.RealSold, 10) + "|" +
918		strconv.Itoa(l.BuyerCount) + "|" +
919		strconv.FormatInt(l.CreatorFees, 10) + "|" +
920		strconv.FormatInt(l.PoolUgnot, 10) + "|" +
921		strconv.FormatInt(l.PoolToken, 10) + "|" +
922		l.URI + "|" +
923		l.Creator.String() + "|" +
924		strconv.FormatInt(l.VirtualUgnot, 10) + "|" +
925		strconv.FormatInt(l.VirtualToken, 10) + "|" +
926		strconv.FormatInt(l.Created, 10) + "|" +
927		l.TokenID + "|" +
928		gs
929}
930
931// ParamsInfo returns fixed MVP parameters for UI display.
932// total|curve|poolSeed|gradThreshold|feeBps|createBond
933func ParamsInfo() string {
934	return strconv.FormatInt(TotalSupply, 10) + "|" +
935		strconv.FormatInt(CurveSupply, 10) + "|" +
936		strconv.FormatInt(PoolSeed, 10) + "|" +
937		strconv.FormatInt(GraduationThreshold, 10) + "|" +
938		strconv.FormatInt(FeeBPS, 10) + "|" +
939		strconv.FormatInt(CreateBondUgnot, 10)
940}
941
942// TradeHistory returns newline-separated chart points:
943//
944//	height|side|ugnot|tokens|priceScaled
945//
946// side: 0=buy 1=sell 2=open/graduate. Ordered oldest → newest.
947func TradeHistory(id string) string {
948	l := mustLaunch(id)
949	out := ""
950	l.Trades.Iterate("", "", func(_ string, value any) bool {
951		t := value.(*Trade)
952		line := strconv.FormatInt(t.Height, 10) + "|" +
953			strconv.Itoa(t.Side) + "|" +
954			strconv.FormatInt(t.Ugnot, 10) + "|" +
955			strconv.FormatInt(t.Tokens, 10) + "|" +
956			strconv.FormatInt(t.Price, 10)
957		if out != "" {
958			out += "\n"
959		}
960		out += line
961		return false
962	})
963	return out
964}
965
966// TradeCount returns number of stored chart samples for a launch.
967func TradeCount(id string) int {
968	return mustLaunch(id).Trades.Size()
969}
970
971// resetForTest clears package state between unit tests.
972func resetForTest() {
973	launches = avl.Tree{}
974	bySymbol = avl.Tree{}
975	nextID = 0
976	nextTokenID = 0
977	var zero address
978	protocolAddr = zero
979	protocolFees = 0
980	inited = false
981	pointsEnabled = false
982	testSkipBanker = true // unit tests skip banker; integration/chain tests leave false
983}