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

18.63 Kb · 736 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):
  4//
  5//	Create → bonding curve (virtual CPMM, fair mint) → Graduate (atomic)
  6//	→ locked real CPMM pool (permanent LP, no remove) → creator/protocol fees
  7//
  8// Hybrid of Pump.fun (curve + graduation) and Noxa Fun (no external migration,
  9// permanent lock) adapted to Gno: banker push payments, on-chain Render discovery.
 10package pad
 11
 12import (
 13	"chain"
 14	"chain/banker"
 15	"chain/runtime"
 16	"chain/runtime/unsafe"
 17	"strconv"
 18
 19	"gno.land/p/g1mv0052e7r6s09f5t9xsqf00nj3tqsgt9dg52jr/gnomemepad/ammmath"
 20	"gno.land/p/nt/avl/v0"
 21	"gno.land/p/nt/seqid/v0"
 22)
 23
 24var (
 25	launches     avl.Tree // id -> *Launch
 26	bySymbol     avl.Tree // symbol -> id string
 27	nextID       seqid.ID
 28	protocolAddr address
 29	protocolFees int64
 30	inited       bool
 31	// testSkipBanker: when true, sendUgnot is a no-op (unit tests without funded realm bank).
 32	// Always false in production.
 33	testSkipBanker bool
 34)
 35
 36// Trade is one price sample for charts (capped history per launch).
 37type Trade struct {
 38	Height int64
 39	Side   int // TradeSideBuy | TradeSideSell | TradeSideOpen
 40	Ugnot  int64
 41	Tokens int64
 42	Price  int64 // ugnot per token * 1e6 after the trade
 43}
 44
 45// Launch is one meme market: curve phase then locked pool phase.
 46type Launch struct {
 47	ID      string
 48	Name    string
 49	Symbol  string
 50	URI     string
 51	Creator address
 52	Status  int
 53	Created int64 // block height
 54
 55	// Virtual curve reserves
 56	VirtualUgnot int64
 57	VirtualToken int64
 58	RealSold     int64 // tokens sold on curve (≤ CurveSupply)
 59	RaisedUgnot  int64 // net ugnot collateral in curve (excl. fee vaults)
 60
 61	// Real pool (post-grad); LP permanently locked — no remove path
 62	PoolUgnot int64
 63	PoolToken int64
 64
 65	// Token balances: bech32 -> int64 (factory-managed ledger)
 66	Balances avl.Tree
 67
 68	CreatorFees  int64
 69	BondUgnot    int64
 70	BondRefunded bool
 71	UniqueBuyers avl.Tree
 72	BuyerCount   int
 73
 74	// Chart history (ordered AVL keys)
 75	Trades    avl.Tree // tradeKey -> *Trade
 76	NextTrade int64
 77}
 78
 79// Init sets the protocol treasury. First EOA caller becomes fee recipient.
 80func Init(cur realm) {
 81	if inited {
 82		panic("pad: already initialized")
 83	}
 84	if !cur.Previous().IsUserCall() {
 85		panic("pad: EOA only")
 86	}
 87	protocolAddr = cur.Previous().Address()
 88	inited = true
 89	chain.Emit("Init", "protocol", protocolAddr.String())
 90}
 91
 92func requireInit() {
 93	if !inited {
 94		panic("pad: call Init first")
 95	}
 96}
 97
 98func mustLaunch(id string) *Launch {
 99	// Sapphire avl.Tree.Get returns a single any (nil if missing).
100	l, ok := launches.Get(id).(*Launch)
101	if !ok {
102		panic("pad: unknown launch")
103	}
104	return l
105}
106
107func balOf(l *Launch, addr address) int64 {
108	// nil / wrong type => zero balance
109	v, ok := l.Balances.Get(addr.String()).(int64)
110	if !ok {
111		return 0
112	}
113	return v
114}
115
116func setBal(l *Launch, addr address, amount int64) {
117	if amount < 0 {
118		panic("pad: negative balance")
119	}
120	key := addr.String()
121	if amount == 0 {
122		l.Balances.Remove(key)
123		return
124	}
125	l.Balances.Set(key, amount)
126}
127
128func addBal(l *Launch, addr address, delta int64) {
129	curBal := balOf(l, addr)
130	n := curBal + delta
131	if delta > 0 && n < curBal {
132		panic("pad: balance overflow")
133	}
134	if n < 0 {
135		panic("pad: insufficient balance")
136	}
137	setBal(l, addr, n)
138}
139
140func sendUgnot(cur realm, to address, amount int64) {
141	if amount <= 0 {
142		return
143	}
144	if testSkipBanker {
145		return
146	}
147	bk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
148	bk.SendCoins(cur.Address(), to, chain.Coins{{Denom: DenomUgnot, Amount: amount}})
149}
150
151func requireUserPayment(cur realm) int64 {
152	if !cur.Previous().IsUserCall() {
153		panic("pad: must be EOA MsgCall")
154	}
155	sent := unsafe.OriginSend().AmountOf(DenomUgnot)
156	if sent <= 0 {
157		panic("pad: need ugnot -send")
158	}
159	return sent
160}
161
162func noteBuyer(l *Launch, buyer address) {
163	k := buyer.String()
164	if l.UniqueBuyers.Has(k) {
165		return
166	}
167	l.UniqueBuyers.Set(k, true)
168	l.BuyerCount++
169}
170
171func tradeKey(n int64) string {
172	s := strconv.FormatInt(n, 10)
173	for len(s) < 12 {
174		s = "0" + s
175	}
176	return s
177}
178
179// spotPriceScaled returns ugnot/token * 1e6 from current curve or pool reserves.
180func spotPriceScaled(l *Launch) int64 {
181	if l.Status == StatusGraduated {
182		if l.PoolToken <= 0 {
183			return 0
184		}
185		return l.PoolUgnot * 1000000 / l.PoolToken
186	}
187	if l.VirtualToken <= 0 {
188		return 0
189	}
190	return l.VirtualUgnot * 1000000 / l.VirtualToken
191}
192
193func recordTrade(l *Launch, side int, ugnot, tokens int64) {
194	l.NextTrade++
195	t := &Trade{
196		Height: runtime.ChainHeight(),
197		Side:   side,
198		Ugnot:  ugnot,
199		Tokens: tokens,
200		Price:  spotPriceScaled(l),
201	}
202	l.Trades.Set(tradeKey(l.NextTrade), t)
203	// Ring buffer: drop oldest while over cap.
204	for l.Trades.Size() > MaxTradeHistory {
205		oldest := ""
206		l.Trades.Iterate("", "", func(k string, _ any) bool {
207			oldest = k
208			return true // stop
209		})
210		if oldest == "" {
211			break
212		}
213		l.Trades.Remove(oldest)
214	}
215}
216
217func maybeRefundBond(cur realm, l *Launch) {
218	if l.BondRefunded || l.BondUgnot <= 0 {
219		return
220	}
221	if l.BuyerCount < BondRefundBuyers {
222		return
223	}
224	if runtime.ChainHeight()-l.Created > BondRefundMaxHeights {
225		return
226	}
227	amt := l.BondUgnot
228	l.BondUgnot = 0
229	l.BondRefunded = true
230	sendUgnot(cur, l.Creator, amt)
231	chain.Emit("BondRefund", "id", l.ID, "amount", strconv.FormatInt(amt, 10))
232}
233
234// Create deploys a fair-launch meme. Requires CreateBondUgnot via -send.
235// No pre-mint; all tradeable float starts on the bonding curve.
236func Create(cur realm, name, symbol, uri string) string {
237	requireInit()
238	sent := requireUserPayment(cur)
239	if sent < CreateBondUgnot {
240		panic("pad: create bond underpaid")
241	}
242	if name == "" || symbol == "" {
243		panic("pad: name and symbol required")
244	}
245	if len(symbol) > 12 {
246		panic("pad: symbol too long")
247	}
248	if bySymbol.Has(symbol) {
249		panic("pad: symbol taken")
250	}
251	extra := sent - CreateBondUgnot
252	if extra > 0 {
253		protocolFees += extra
254	}
255
256	creator := cur.Previous().Address()
257	id := nextID.Next().String()
258	l := &Launch{
259		ID:           id,
260		Name:         name,
261		Symbol:       symbol,
262		URI:          uri,
263		Creator:      creator,
264		Status:       StatusCurve,
265		Created:      runtime.ChainHeight(),
266		VirtualUgnot: VirtualUgnot0,
267		VirtualToken: VirtualToken0,
268		Balances:     avl.Tree{},
269		UniqueBuyers: avl.Tree{},
270		BondUgnot:    CreateBondUgnot,
271		Trades:       avl.Tree{},
272	}
273	// Open mark for charts (initial virtual spot).
274	recordTrade(l, TradeSideOpen, 0, 0)
275	launches.Set(id, l)
276	bySymbol.Set(symbol, id)
277
278	chain.Emit("Created", "id", id, "symbol", symbol, "creator", creator.String())
279	return id
280}
281
282// Buy spends -send ugnot on the bonding curve; credits tokens to the caller.
283// Auto-graduates when RaisedUgnot >= GraduationThreshold.
284func Buy(cur realm, id string) int64 {
285	requireInit()
286	sent := requireUserPayment(cur)
287	l := mustLaunch(id)
288	if l.Status != StatusCurve {
289		panic("pad: not on curve (use SwapBuy)")
290	}
291	buyer := cur.Previous().Address()
292	height := runtime.ChainHeight()
293
294	remaining := CurveSupply - l.RealSold
295	if remaining <= 0 {
296		panic("pad: curve sold out — call Graduate")
297	}
298
299	fee := ammmath.ApplyFee(sent, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
300	// Net enters curve; remainder boosts virtual ugnot (stays as collateral).
301	netIn := fee.Net + fee.Remainder
302
303	tokensOut, newVU, newVT := ammmath.BuyTokens(l.VirtualUgnot, l.VirtualToken, netIn)
304	// Cap at remaining curve float (large buys can outrun CurveSupply on virtual CPMM).
305	if tokensOut > remaining {
306		lo, hi := int64(1), netIn
307		bestTok, bestVU, bestVT, bestNet := int64(0), l.VirtualUgnot, l.VirtualToken, int64(0)
308		for lo <= hi {
309			mid := (lo + hi) / 2
310			tok, vu, vt := ammmath.BuyTokens(l.VirtualUgnot, l.VirtualToken, mid)
311			if tok <= remaining {
312				bestTok, bestVU, bestVT, bestNet = tok, vu, vt, mid
313				lo = mid + 1
314			} else {
315				hi = mid - 1
316			}
317		}
318		if bestTok <= 0 {
319			panic("pad: buy too large for remaining curve supply")
320		}
321		tokensOut, newVU, newVT, netIn = bestTok, bestVU, bestVT, bestNet
322		// MVP: full `sent` still paid; excess stays in realm as protocol dust.
323		_ = sent
324	}
325	if height-l.Created < AntiSnipeHeights {
326		maxTok := TotalSupply * AntiSnipeMaxBuyBPS / 10000
327		if tokensOut > maxTok {
328			panic("pad: anti-snipe max buy exceeded")
329		}
330	}
331
332	// Mutate only after all checks pass.
333	l.CreatorFees += fee.Creator
334	protocolFees += fee.Protocol
335	l.VirtualUgnot = newVU
336	l.VirtualToken = newVT
337	l.RealSold += tokensOut
338	raisedAdd := netIn
339	if raisedAdd > fee.Net+fee.Remainder {
340		raisedAdd = fee.Net + fee.Remainder
341	}
342	l.RaisedUgnot += raisedAdd
343
344	addBal(l, buyer, tokensOut)
345	noteBuyer(l, buyer)
346	maybeRefundBond(cur, l)
347	recordTrade(l, TradeSideBuy, sent, tokensOut)
348
349	chain.Emit("Buy",
350		"id", id,
351		"buyer", buyer.String(),
352		"ugnot", strconv.FormatInt(sent, 10),
353		"tokens", strconv.FormatInt(tokensOut, 10),
354	)
355
356	if ammmath.CanGraduate(l.RaisedUgnot, GraduationThreshold) || l.RealSold >= CurveSupply {
357		if l.RaisedUgnot >= GraduationThreshold {
358			graduate(cur, l)
359		}
360	}
361	return tokensOut
362}
363
364// Sell burns curve tokens and pays ugnot (fee on output).
365func Sell(cur realm, id string, tokensIn int64) int64 {
366	requireInit()
367	if !cur.Previous().IsUserCall() {
368		panic("pad: must be EOA MsgCall")
369	}
370	if tokensIn <= 0 {
371		panic("pad: tokensIn must be positive")
372	}
373	l := mustLaunch(id)
374	if l.Status != StatusCurve {
375		panic("pad: not on curve (use SwapSell)")
376	}
377	seller := cur.Previous().Address()
378	if balOf(l, seller) < tokensIn {
379		panic("pad: insufficient token balance")
380	}
381
382	gross, newVU, newVT := ammmath.SellTokens(l.VirtualUgnot, l.VirtualToken, tokensIn)
383	fee := ammmath.ApplyFeeOnOutput(gross, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
384
385	// Full gross left virtual reserves; retain fee in virtual ugnot (cash stays in realm).
386	l.VirtualUgnot = newVU + fee.Fee
387	l.VirtualToken = newVT
388	l.RealSold -= tokensIn
389	if l.RealSold < 0 {
390		l.RealSold = 0
391	}
392
393	// User receives net; creator+protocol become fee liabilities (leave Raised).
394	payOut := fee.Net + fee.Creator + fee.Protocol
395	if l.RaisedUgnot >= payOut {
396		l.RaisedUgnot -= payOut
397	} else {
398		l.RaisedUgnot = 0
399	}
400	// remainder stays inside Raised/virtual (already in fee.Fee re-add portion for remainder+vaults)
401	// Raised should still include remainder: payOut excludes remainder ✓
402	l.CreatorFees += fee.Creator
403	protocolFees += fee.Protocol
404
405	addBal(l, seller, -tokensIn)
406	sendUgnot(cur, seller, fee.Net)
407	recordTrade(l, TradeSideSell, fee.Net, tokensIn)
408
409	chain.Emit("Sell",
410		"id", id,
411		"seller", seller.String(),
412		"tokens", strconv.FormatInt(tokensIn, 10),
413		"ugnot", strconv.FormatInt(fee.Net, 10),
414	)
415	return fee.Net
416}
417
418// Graduate permissionlessly moves a ready curve into a permanently locked CPMM.
419func Graduate(cur realm, id string) {
420	requireInit()
421	l := mustLaunch(id)
422	if l.Status != StatusCurve {
423		panic("pad: already graduated")
424	}
425	if !ammmath.CanGraduate(l.RaisedUgnot, GraduationThreshold) {
426		panic("pad: threshold not met")
427	}
428	graduate(cur, l)
429}
430
431func graduate(cur realm, l *Launch) {
432	if l.Status != StatusCurve {
433		return
434	}
435	// Seed pool: all curve collateral + remaining pool seed tokens.
436	// Curve unsold tokens are not minted; only PoolSeed enters the pool.
437	// Raised ugnot becomes pool ugnot (fee vaults stay separate liabilities).
438	poolU := l.RaisedUgnot
439	if poolU <= 0 {
440		panic("pad: empty pool ugnot")
441	}
442	poolT := PoolSeed
443	// Optional: if curve sold less than CurveSupply, unsold is never minted (dead supply).
444	// Real circulating = RealSold + PoolSeed after minting pool to realm accounting.
445
446	l.PoolUgnot = poolU
447	l.PoolToken = poolT
448	l.RaisedUgnot = 0
449	l.VirtualUgnot = 0
450	l.VirtualToken = 0
451	l.Status = StatusGraduated
452
453	// Pool tokens are held by the launch (not an address) — PoolToken reserve.
454	// Circulating user balances remain; total effective supply = sum(balances)+PoolToken.
455
456	// Forfeit unrefunded bond to protocol at graduation if still locked.
457	if !l.BondRefunded && l.BondUgnot > 0 {
458		protocolFees += l.BondUgnot
459		l.BondUgnot = 0
460		l.BondRefunded = true
461	}
462
463	// Mark graduation on chart at pool spot.
464	recordTrade(l, TradeSideOpen, poolU, poolT)
465
466	chain.Emit("Graduated",
467		"id", l.ID,
468		"poolUgnot", strconv.FormatInt(poolU, 10),
469		"poolToken", strconv.FormatInt(poolT, 10),
470	)
471	_ = cur // banker not needed; ugnot already on realm
472}
473
474// SwapBuy buys tokens from the graduated pool with -send ugnot.
475func SwapBuy(cur realm, id string) int64 {
476	requireInit()
477	sent := requireUserPayment(cur)
478	l := mustLaunch(id)
479	if l.Status != StatusGraduated {
480		panic("pad: not graduated (use Buy)")
481	}
482	buyer := cur.Previous().Address()
483
484	fee := ammmath.ApplyFee(sent, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
485	l.CreatorFees += fee.Creator
486	protocolFees += fee.Protocol
487
488	tokensOut, newPU, newPT := ammmath.PoolSwapUgnotForToken(
489		l.PoolUgnot, l.PoolToken, fee.Net, fee.Remainder,
490	)
491	l.PoolUgnot = newPU
492	l.PoolToken = newPT
493	addBal(l, buyer, tokensOut)
494	noteBuyer(l, buyer)
495	recordTrade(l, TradeSideBuy, sent, tokensOut)
496
497	chain.Emit("SwapBuy",
498		"id", id,
499		"buyer", buyer.String(),
500		"ugnot", strconv.FormatInt(sent, 10),
501		"tokens", strconv.FormatInt(tokensOut, 10),
502	)
503	return tokensOut
504}
505
506// SwapSell sells tokens into the graduated pool for ugnot.
507func SwapSell(cur realm, id string, tokensIn int64) int64 {
508	requireInit()
509	if !cur.Previous().IsUserCall() {
510		panic("pad: must be EOA MsgCall")
511	}
512	if tokensIn <= 0 {
513		panic("pad: tokensIn must be positive")
514	}
515	l := mustLaunch(id)
516	if l.Status != StatusGraduated {
517		panic("pad: not graduated (use Sell)")
518	}
519	seller := cur.Previous().Address()
520	if balOf(l, seller) < tokensIn {
521		panic("pad: insufficient token balance")
522	}
523
524	gross, newPU, newPT := ammmath.PoolSwapTokenForUgnot(l.PoolUgnot, l.PoolToken, tokensIn)
525	fee := ammmath.ApplyFeeOnOutput(gross, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
526
527	// Retain fee in pool ugnot (cash stays); user gets net.
528	l.PoolUgnot = newPU + fee.Fee
529	l.PoolToken = newPT
530	l.CreatorFees += fee.Creator
531	protocolFees += fee.Protocol
532
533	addBal(l, seller, -tokensIn)
534	sendUgnot(cur, seller, fee.Net)
535	recordTrade(l, TradeSideSell, fee.Net, tokensIn)
536
537	chain.Emit("SwapSell",
538		"id", id,
539		"seller", seller.String(),
540		"tokens", strconv.FormatInt(tokensIn, 10),
541		"ugnot", strconv.FormatInt(fee.Net, 10),
542	)
543	return fee.Net
544}
545
546// Transfer moves factory-ledger tokens between addresses.
547func Transfer(cur realm, id string, to address, amount int64) {
548	requireInit()
549	if !cur.Previous().IsUserCall() {
550		panic("pad: must be EOA MsgCall")
551	}
552	if amount <= 0 {
553		panic("pad: amount must be positive")
554	}
555	if !to.IsValid() {
556		panic("pad: invalid to")
557	}
558	l := mustLaunch(id)
559	from := cur.Previous().Address()
560	if from == to {
561		panic("pad: self transfer")
562	}
563	addBal(l, from, -amount)
564	addBal(l, to, amount)
565	chain.Emit("Transfer", "id", id, "from", from.String(), "to", to.String(),
566		"amount", strconv.FormatInt(amount, 10))
567}
568
569// ClaimCreatorFees withdraws accrued creator fees for a launch.
570func ClaimCreatorFees(cur realm, id string) int64 {
571	requireInit()
572	if !cur.Previous().IsUserCall() {
573		panic("pad: must be EOA MsgCall")
574	}
575	l := mustLaunch(id)
576	caller := cur.Previous().Address()
577	if caller != l.Creator {
578		panic("pad: not creator")
579	}
580	amt := l.CreatorFees
581	if amt <= 0 {
582		return 0
583	}
584	l.CreatorFees = 0
585	sendUgnot(cur, caller, amt)
586	chain.Emit("ClaimCreator", "id", id, "amount", strconv.FormatInt(amt, 10))
587	return amt
588}
589
590// ClaimProtocolFees withdraws protocol treasury fees.
591func ClaimProtocolFees(cur realm) int64 {
592	requireInit()
593	if !cur.Previous().IsUserCall() {
594		panic("pad: must be EOA MsgCall")
595	}
596	caller := cur.Previous().Address()
597	if caller != protocolAddr {
598		panic("pad: not protocol")
599	}
600	amt := protocolFees
601	if amt <= 0 {
602		return 0
603	}
604	protocolFees = 0
605	sendUgnot(cur, caller, amt)
606	chain.Emit("ClaimProtocol", "amount", strconv.FormatInt(amt, 10))
607	return amt
608}
609
610// --- read helpers (non-crossing) ---
611
612func BalanceOf(id string, owner address) int64 {
613	return balOf(mustLaunch(id), owner)
614}
615
616func GetStatus(id string) int {
617	return mustLaunch(id).Status
618}
619
620func GetRaised(id string) int64 {
621	return mustLaunch(id).RaisedUgnot
622}
623
624func GetPool(id string) (ugnot, token int64) {
625	l := mustLaunch(id)
626	return l.PoolUgnot, l.PoolToken
627}
628
629func GetCreatorFees(id string) int64 {
630	return mustLaunch(id).CreatorFees
631}
632
633func ProtocolFees() int64 {
634	return protocolFees
635}
636
637func LaunchCount() int {
638	return launches.Size()
639}
640
641func ResolveSymbol(symbol string) string {
642	s, ok := bySymbol.Get(symbol).(string)
643	if !ok {
644		return ""
645	}
646	return s
647}
648
649// ListIDs returns newline-separated launch IDs (sorted by AVL key / creation order).
650func ListIDs() string {
651	out := ""
652	launches.Iterate("", "", func(key string, _ any) bool {
653		if out != "" {
654			out += "\n"
655		}
656		out += key
657		return false
658	})
659	return out
660}
661
662// LaunchInfo returns a single-line pipe-delimited summary for UIs/indexers:
663//
664//	id|name|symbol|status|raised|sold|buyers|creatorFees|poolUgnot|poolToken|uri|creator|virtualUgnot|virtualToken|created
665//
666// status: 0=curve 1=graduated
667func LaunchInfo(id string) string {
668	l := mustLaunch(id)
669	return l.ID + "|" +
670		l.Name + "|" +
671		l.Symbol + "|" +
672		strconv.Itoa(l.Status) + "|" +
673		strconv.FormatInt(l.RaisedUgnot, 10) + "|" +
674		strconv.FormatInt(l.RealSold, 10) + "|" +
675		strconv.Itoa(l.BuyerCount) + "|" +
676		strconv.FormatInt(l.CreatorFees, 10) + "|" +
677		strconv.FormatInt(l.PoolUgnot, 10) + "|" +
678		strconv.FormatInt(l.PoolToken, 10) + "|" +
679		l.URI + "|" +
680		l.Creator.String() + "|" +
681		strconv.FormatInt(l.VirtualUgnot, 10) + "|" +
682		strconv.FormatInt(l.VirtualToken, 10) + "|" +
683		strconv.FormatInt(l.Created, 10)
684}
685
686// ParamsInfo returns fixed MVP parameters for UI display.
687// total|curve|poolSeed|gradThreshold|feeBps|createBond
688func ParamsInfo() string {
689	return strconv.FormatInt(TotalSupply, 10) + "|" +
690		strconv.FormatInt(CurveSupply, 10) + "|" +
691		strconv.FormatInt(PoolSeed, 10) + "|" +
692		strconv.FormatInt(GraduationThreshold, 10) + "|" +
693		strconv.FormatInt(FeeBPS, 10) + "|" +
694		strconv.FormatInt(CreateBondUgnot, 10)
695}
696
697// TradeHistory returns newline-separated chart points:
698//
699//	height|side|ugnot|tokens|priceScaled
700//
701// side: 0=buy 1=sell 2=open/graduate. Ordered oldest → newest.
702func TradeHistory(id string) string {
703	l := mustLaunch(id)
704	out := ""
705	l.Trades.Iterate("", "", func(_ string, value any) bool {
706		t := value.(*Trade)
707		line := strconv.FormatInt(t.Height, 10) + "|" +
708			strconv.Itoa(t.Side) + "|" +
709			strconv.FormatInt(t.Ugnot, 10) + "|" +
710			strconv.FormatInt(t.Tokens, 10) + "|" +
711			strconv.FormatInt(t.Price, 10)
712		if out != "" {
713			out += "\n"
714		}
715		out += line
716		return false
717	})
718	return out
719}
720
721// TradeCount returns number of stored chart samples for a launch.
722func TradeCount(id string) int {
723	return mustLaunch(id).Trades.Size()
724}
725
726// resetForTest clears package state between unit tests.
727func resetForTest() {
728	launches = avl.Tree{}
729	bySymbol = avl.Tree{}
730	nextID = 0
731	var zero address
732	protocolAddr = zero
733	protocolFees = 0
734	inited = false
735	testSkipBanker = true // unit tests skip banker; integration/chain tests leave false
736}