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

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