memepad.gno
39.50 Kb · 1366 lines
1// Package pad is gnomemepad: a self-contained meme launchpad for gno.land.
2//
3// Create → GRC20 + bonding curve → Graduate
4// → remaining tokens + raised GNOT seed liquidity
5// → auto Gnoswap CreatePool + full-range Mint when pad has WUGNOT inventory
6// (part of inventory swaps WUGNOT→GNS for CreatePool fee)
7// → else locked internal CPMM fallback (test / missing inventory)
8//
9// Tokens are real GRC20 (mint on buy, burn on sell). Hybrid of Pump.fun + permanent LP lock.
10package padv11
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 ammmath "gno.land/p/g1mv0052e7r6s09f5t9xsqf00nj3tqsgt9dg52jr/gnomemepad/ammmathv2"
22 "gno.land/p/nt/avl/v0"
23 "gno.land/p/nt/seqid/v0"
24 "gno.land/r/demo/defi/grc20reg"
25 // bond: create-bond policy (promo / normal). Separate package — pad upgrades
26 // do not replace bond schedule. Deploy prepare rewrites to personal path.
27 createbond "gno.land/r/g1mv0052e7r6s09f5t9xsqf00nj3tqsgt9dg52jr/gnomemepad/bond"
28 // pointsv2: optional trade/create awards (off by default until SetPointsEnabled).
29 // Deploy prepare rewrites this import to the Sapphire personal-namespace path.
30 pointsv2 "gno.land/r/g1mv0052e7r6s09f5t9xsqf00nj3tqsgt9dg52jr/gnomemepad/pointsv2"
31)
32
33var (
34 launches avl.Tree // id -> *Launch
35 bySymbol avl.Tree // symbol -> id string
36 nextID seqid.ID
37 nextTokenID seqid.ID // GRC20 identity sequence (shared for all launches)
38 // padAddr: this realm's package address (set in init) — for inventory funding.
39 padAddr address
40 // protocolAddr: treasury set by Init (first EOA). Receives protocol fee share.
41 protocolAddr address
42 // protocolFees: ugnot still on pad, claimable / pushable to protocolAddr.
43 protocolFees int64
44 // protocolFeesPaid: lifetime ugnot already sent to protocolAddr (stats only).
45 protocolFeesPaid int64
46 inited bool
47 // testSkipBanker: when true, sendUgnot is a no-op (unit tests without funded realm bank).
48 // Always false in production.
49 testSkipBanker bool
50 // pointsEnabled: when true, notify pointsv2 after Create / Buy / Sell / Swap*.
51 // Admin must also AllowPad(this package) on pointsv2.
52 pointsEnabled bool
53)
54
55func init(cur realm) {
56 padAddr = cur.Address()
57}
58
59// Trade is one price sample for charts (capped history per launch).
60type Trade struct {
61 Height int64
62 Side int // TradeSideBuy | TradeSideSell | TradeSideOpen
63 Ugnot int64
64 Tokens int64
65 Price int64 // ugnot per token * 1e6 after the trade
66}
67
68// Launch is one meme market: curve phase then locked pool phase.
69// token/ledger are unexported so external packages cannot Mint/Burn via field access.
70type Launch struct {
71 ID string
72 Name string
73 Symbol string
74 URI string
75 Creator address
76 Status int
77 Created int64 // block height
78
79 // GRC20 (mint/burn only via pad-owned private ledger)
80 token *grc20.Token
81 ledger *grc20.PrivateLedger
82 TokenID string // Token.ID() — registry / Gnoswap identity
83
84 // Virtual curve reserves
85 VirtualUgnot int64
86 VirtualToken int64
87 RealSold int64 // tokens sold on curve (≤ CurveSupply)
88 RaisedUgnot int64 // net ugnot collateral in curve (excl. fee vaults)
89
90 // Real pool (post-grad); LP permanently locked — no remove path
91 // PoolToken is pad-internal reserve (not the same as GRC20 total supply).
92 PoolUgnot int64
93 PoolToken int64
94
95 CreatorFees int64
96 BondUgnot int64
97 BondRefunded bool
98 UniqueBuyers avl.Tree // address -> true
99 BuyerCount int
100 // snipeBought: address -> cumulative tokens bought during anti-snipe window
101 snipeBought avl.Tree
102
103 // Gnoswap listing state
104 GnoswapReady bool // graduated; token is listable / listed
105 GnoswapListed bool // true when CreatePool+Mint succeeded on Gnoswap
106 GnoswapNote string // human status / failure reason
107 GnoswapPoolPath string
108 GnoswapPositionID uint64
109 // FeeWugnotSpent / LiqWugnotUsed: inventory spent at graduate (1:1 vs raised ugnot notionally)
110 FeeWugnotSpent int64
111 LiqWugnotUsed int64
112
113 // Chart history (ordered AVL keys)
114 Trades avl.Tree // tradeKey -> *Trade
115 NextTrade int64
116}
117
118// Init sets the protocol treasury. First EOA caller becomes fee recipient
119// (protocolAddr). Protocol trade fees accrue on-pad until ClaimProtocolFees
120// (treasury only) or PushProtocolFees (anyone may push to treasury).
121// Creator fees always need ClaimCreatorFees by the token creator.
122//
123// Deploy note: call Init with the wallet that should receive protocol fees
124// (or TransferProtocol later). Gnoswap CreatePool GNS fee is paid to Gnoswap,
125// not to this treasury.
126func Init(cur realm) {
127 if inited {
128 panic("pad: already initialized")
129 }
130 if !cur.Previous().IsUserCall() {
131 panic("pad: EOA only")
132 }
133 protocolAddr = cur.Previous().Address()
134 inited = true
135 chain.Emit("Init", "protocol", protocolAddr.String())
136}
137
138// creditProtocol accrues protocol ugnot liability on the pad realm.
139// Cash stays in pad until ClaimProtocolFees / PushProtocolFees.
140func creditProtocol(amt int64) {
141 if amt <= 0 {
142 return
143 }
144 protocolFees += amt
145}
146
147func requireInit() {
148 if !inited {
149 panic("pad: call Init first")
150 }
151}
152
153// SetPointsEnabled toggles pointsv2 notifications (protocol admin only).
154// pointsv2 must AllowPad(this package path) or OnTrade/OnCreate will panic and revert the trade.
155func SetPointsEnabled(cur realm, on bool) {
156 requireInit()
157 if !cur.Previous().IsUserCall() {
158 panic("pad: EOA only")
159 }
160 if cur.Previous().Address() != protocolAddr {
161 panic("pad: not protocol")
162 }
163 pointsEnabled = on
164 chain.Emit("SetPointsEnabled", "on", strconv.FormatBool(on))
165}
166
167// PointsEnabled reports whether pad notifies pointsv2 after trades/creates.
168func PointsEnabled() bool {
169 return pointsEnabled
170}
171
172func notifyTrade(cur realm, trader address, id string, side int64, volumeUgnot int64) {
173 if !pointsEnabled || testSkipBanker {
174 return
175 }
176 _ = pointsv2.OnTrade(cross(cur), trader, id, side, volumeUgnot)
177}
178
179func notifyCreate(cur realm, creator address, id string) {
180 if !pointsEnabled || testSkipBanker {
181 return
182 }
183 _ = pointsv2.OnCreate(cross(cur), creator, id)
184}
185
186func mustLaunch(id string) *Launch {
187 // Sapphire avl.Tree.Get returns a single any (nil if missing).
188 l, ok := launches.Get(id).(*Launch)
189 if !ok {
190 panic("pad: unknown launch")
191 }
192 return l
193}
194
195func balOf(l *Launch, addr address) int64 {
196 if l == nil || l.token == nil {
197 return 0
198 }
199 return l.token.BalanceOf(addr)
200}
201
202// addBal mints (delta>0) or burns (delta<0) GRC20 via pad-owned PrivateLedger.
203func addBal(l *Launch, addr address, delta int64) {
204 if l == nil || l.ledger == nil {
205 panic("pad: missing GRC20 ledger")
206 }
207 if delta == 0 {
208 return
209 }
210 if delta > 0 {
211 if err := l.ledger.Mint(addr, delta); err != nil {
212 panic("pad: mint: " + err.Error())
213 }
214 return
215 }
216 if err := l.ledger.Burn(addr, -delta); err != nil {
217 panic("pad: burn: " + err.Error())
218 }
219}
220
221func requireMinOut(got, minOut int64, what string) {
222 if minOut < 0 {
223 panic("pad: minOut must be non-negative")
224 }
225 if minOut > 0 && got < minOut {
226 panic("pad: " + what + " below minOut (slippage)")
227 }
228}
229
230func snipeBoughtOf(l *Launch, buyer address) int64 {
231 v := l.snipeBought.Get(buyer.String())
232 if v == nil {
233 return 0
234 }
235 n, ok := v.(int64)
236 if !ok {
237 return 0
238 }
239 return n
240}
241
242func checkAndAddSnipe(l *Launch, buyer address, tokensOut int64) {
243 height := runtime.ChainHeight()
244 if height-l.Created >= AntiSnipeHeights {
245 return
246 }
247 maxTok := TotalSupply * AntiSnipeMaxBuyBPS / 10000
248 prev := snipeBoughtOf(l, buyer)
249 if prev+tokensOut > maxTok {
250 panic("pad: anti-snipe cumulative max buy exceeded")
251 }
252 l.snipeBought.Set(buyer.String(), prev+tokensOut)
253}
254
255func sendUgnot(cur realm, to address, amount int64) {
256 if amount <= 0 {
257 return
258 }
259 if testSkipBanker {
260 return
261 }
262 bk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
263 bk.SendCoins(cur.Address(), to, chain.Coins{{Denom: DenomUgnot, Amount: amount}})
264}
265
266func requireUserPayment(cur realm) int64 {
267 if !cur.Previous().IsUserCall() {
268 panic("pad: must be EOA MsgCall")
269 }
270 sent := unsafe.OriginSend().AmountOf(DenomUgnot)
271 if sent <= 0 {
272 panic("pad: need ugnot -send")
273 }
274 return sent
275}
276
277func noteBuyer(l *Launch, buyer address) {
278 k := buyer.String()
279 if l.UniqueBuyers.Has(k) {
280 return
281 }
282 l.UniqueBuyers.Set(k, true)
283 l.BuyerCount++
284}
285
286func tradeKey(n int64) string {
287 s := strconv.FormatInt(n, 10)
288 for len(s) < 12 {
289 s = "0" + s
290 }
291 return s
292}
293
294// spotPriceScaled returns ugnot/token * 1e6 from current curve or pool reserves.
295func spotPriceScaled(l *Launch) int64 {
296 if l.Status == StatusGraduated {
297 if l.PoolToken <= 0 {
298 return 0
299 }
300 return l.PoolUgnot * 1000000 / l.PoolToken
301 }
302 if l.VirtualToken <= 0 {
303 return 0
304 }
305 return l.VirtualUgnot * 1000000 / l.VirtualToken
306}
307
308func recordTrade(l *Launch, side int, ugnot, tokens int64) {
309 l.NextTrade++
310 t := &Trade{
311 Height: runtime.ChainHeight(),
312 Side: side,
313 Ugnot: ugnot,
314 Tokens: tokens,
315 Price: spotPriceScaled(l),
316 }
317 l.Trades.Set(tradeKey(l.NextTrade), t)
318 // Ring buffer: drop oldest while over cap.
319 for l.Trades.Size() > MaxTradeHistory {
320 oldest := ""
321 l.Trades.Iterate("", "", func(k string, _ any) bool {
322 oldest = k
323 return true // stop
324 })
325 if oldest == "" {
326 break
327 }
328 l.Trades.Remove(oldest)
329 }
330}
331
332func maybeRefundBond(cur realm, l *Launch) {
333 if l.BondRefunded || l.BondUgnot <= 0 {
334 return
335 }
336 if l.BuyerCount < BondRefundBuyers {
337 return
338 }
339 // Quality gate: pure sybil micro-buys cannot refund bond.
340 if l.RaisedUgnot < BondRefundMinRaised {
341 return
342 }
343 if runtime.ChainHeight()-l.Created > BondRefundMaxHeights {
344 return
345 }
346 amt := l.BondUgnot
347 l.BondUgnot = 0
348 l.BondRefunded = true
349 sendUgnot(cur, l.Creator, amt)
350 chain.Emit("BondRefund", "id", l.ID, "amount", strconv.FormatInt(amt, 10))
351}
352
353// requiredCreateBond returns ugnot the creator must send.
354// Production: createbond.CurrentBondUgnot() (promo or normal).
355// Unit tests (testSkipBanker): local CreateBondUgnot constant.
356func requiredCreateBond() int64 {
357 if testSkipBanker {
358 return CreateBondUgnot
359 }
360 return createbond.CurrentBondUgnot()
361}
362
363// CreateBondRequired is a public alias for UIs / qeval (same as requiredCreateBond).
364func CreateBondRequired() int64 {
365 return requiredCreateBond()
366}
367
368// Create deploys a fair-launch meme. Bond amount from bond realm (or fallback const).
369// No pre-mint; all tradeable float starts on the bonding curve.
370func Create(cur realm, name, symbol, uri string) string {
371 requireInit()
372 sent := requireUserPayment(cur)
373 bondNeed := requiredCreateBond()
374 if bondNeed <= 0 {
375 panic("pad: create bond misconfigured")
376 }
377 if sent < bondNeed {
378 panic("pad: create bond underpaid")
379 }
380 if name == "" || symbol == "" {
381 panic("pad: name and symbol required")
382 }
383 if len(symbol) > 12 {
384 panic("pad: symbol too long")
385 }
386 if bySymbol.Has(symbol) {
387 panic("pad: symbol taken")
388 }
389 extra := sent - bondNeed
390 if extra > 0 {
391 creditProtocol(extra)
392 }
393
394 creator := cur.Previous().Address()
395 id := nextID.Next().String()
396
397 // Real GRC20 bound to this pad realm (mint/burn only via pad ledger).
398 // Decimals=0: whole-token units (matches existing trade amounts).
399 token, ledger := grc20.NewToken(name, symbol, 0, nextTokenID.Next(), cur)
400
401 // Adena (and Gnoswap registries) resolve tokens ONLY via grc20reg under key
402 // packagePath.SYMBOL — Token.ID() itself is packagePath.SYMBOL.seq and is
403 // rejected as "Invalid path" if pasted into Adena without registration.
404 // Skip in unit tests (testSkipBanker); production always registers.
405 regKey := ""
406 if !testSkipBanker {
407 regKey = grc20reg.Register(cross(cur), token, symbol)
408 }
409
410 l := &Launch{
411 ID: id,
412 Name: name,
413 Symbol: symbol,
414 URI: uri,
415 Creator: creator,
416 Status: StatusCurve,
417 Created: runtime.ChainHeight(),
418 token: token,
419 ledger: ledger,
420 TokenID: token.ID(),
421 VirtualUgnot: VirtualUgnot0,
422 VirtualToken: VirtualToken0,
423 UniqueBuyers: avl.Tree{},
424 snipeBought: avl.Tree{},
425 BondUgnot: bondNeed,
426 Trades: avl.Tree{},
427 }
428 // Open mark for charts (initial virtual spot).
429 recordTrade(l, TradeSideOpen, 0, 0)
430 launches.Set(id, l)
431 bySymbol.Set(symbol, id)
432
433 chain.Emit("Created",
434 "id", id,
435 "symbol", symbol,
436 "creator", creator.String(),
437 "token", l.TokenID,
438 "reg", regKey,
439 )
440 notifyCreate(cur, creator, id)
441 return id
442}
443
444// AdenaPathOf returns the grc20reg / Adena token key: packagePath.SYMBOL
445// (Token.ID is packagePath.SYMBOL.seq — Adena rejects that form).
446func AdenaPathOf(id string) string {
447 l := mustLaunch(id)
448 return adenaKeyFromTokenID(l.TokenID, l.Symbol)
449}
450
451// adenaKeyFromTokenID strips the trailing .seq from Token.ID when present.
452func adenaKeyFromTokenID(tokenID, symbol string) string {
453 if tokenID == "" {
454 return ""
455 }
456 // Token.ID = packagePath.symbol.seq → registry key = packagePath.symbol
457 suffix := "." + symbol + "."
458 if i := strings.LastIndex(tokenID, suffix); i >= 0 {
459 // packagePath + "." + symbol
460 return tokenID[:i] + "." + symbol
461 }
462 // Already packagePath.symbol or unknown layout
463 if strings.HasSuffix(tokenID, "."+symbol) {
464 return tokenID
465 }
466 return tokenID
467}
468
469// maxGrossForNetIn finds largest gross ugnot ≤ sentMax whose fee-split netIn ≤ maxNet.
470func maxGrossForNetIn(maxNet, sentMax int64) int64 {
471 if maxNet <= 0 || sentMax <= 0 {
472 return 0
473 }
474 lo, hi := int64(0), sentMax
475 for lo < hi {
476 mid := (lo + hi + 1) / 2
477 f := ammmath.ApplyFee(mid, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
478 net := f.Net + f.Remainder
479 if net <= maxNet {
480 lo = mid
481 } else {
482 hi = mid - 1
483 }
484 }
485 return lo
486}
487
488// readyToGraduate is true when raise met the threshold, or the entire curve
489// float is sold (sold-out escape: threshold may be unreachable with bad virtuals).
490func readyToGraduate(l *Launch) bool {
491 if l == nil || l.Status != StatusCurve {
492 return false
493 }
494 if l.RaisedUgnot <= 0 {
495 return false
496 }
497 if ammmath.CanGraduate(l.RaisedUgnot, GraduationThreshold) {
498 return true
499 }
500 // Curve exhausted before threshold: still graduate with whatever was raised
501 // so the market is never permanently stuck on Buy/Graduate.
502 return l.RealSold >= CurveSupply
503}
504
505// Buy spends -send ugnot on the bonding curve; credits tokens to the caller.
506// minTokensOut: slippage floor (0 = disabled). Auto-graduates at threshold or sold-out.
507//
508// Last-fill (no overshoot):
509// 1. Cap net ugnot so RaisedUgnot never exceeds GraduationThreshold (refund excess).
510// 2. Cap by remaining curve tokens (CurveSupply - RealSold), same as before.
511// Concurrent large buys serialize per-tx; each fill only the remaining raise/tokens.
512//
513// If the curve is already sold out (or raise already filled), Buy refunds the full
514// send and graduates when ready — no panic so users are not stuck mid-tx.
515func Buy(cur realm, id string, minTokensOut int64) int64 {
516 requireInit()
517 sent := requireUserPayment(cur)
518 l := mustLaunch(id)
519 if l.Status != StatusCurve {
520 panic("pad: not on curve (use SwapBuy)")
521 }
522 buyer := cur.Previous().Address()
523
524 remainingTok := CurveSupply - l.RealSold
525 needRaise := GraduationThreshold - l.RaisedUgnot
526 // Already complete: refund payment and graduate (sold-out or raise-filled).
527 if remainingTok <= 0 || needRaise <= 0 {
528 if !readyToGraduate(l) {
529 // Edge: zero raise with empty float should not happen in production.
530 if remainingTok <= 0 {
531 panic("pad: curve sold out with no raise")
532 }
533 panic("pad: raise filled — call Graduate")
534 }
535 if sent > 0 {
536 sendUgnot(cur, buyer, sent)
537 chain.Emit("BuyRefund",
538 "id", id,
539 "buyer", buyer.String(),
540 "refund", strconv.FormatInt(sent, 10),
541 )
542 }
543 graduate(cur, l)
544 return 0
545 }
546
547 usedGross := sent
548 fee := ammmath.ApplyFee(usedGross, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
549 // Net enters curve; remainder boosts virtual ugnot (stays as collateral).
550 netIn := fee.Net + fee.Remainder
551
552 // Max net allowed: min(user net, remaining raise, remaining tokens).
553 maxNet := netIn
554 if maxNet > needRaise {
555 maxNet = needRaise
556 }
557 maxNetTok := ammmath.MaxNetInForTokenOut(l.VirtualUgnot, l.VirtualToken, remainingTok)
558 if maxNetTok > 0 && maxNet > maxNetTok {
559 maxNet = maxNetTok
560 }
561 if maxNet <= 0 {
562 panic("pad: no fill capacity remaining")
563 }
564
565 // Clamp gross + recompute fee when caps bind (last-fill refund path).
566 if maxNet < netIn {
567 usedGross = maxGrossForNetIn(maxNet, sent)
568 if usedGross <= 0 {
569 panic("pad: buy too small for remaining fill")
570 }
571 fee = ammmath.ApplyFee(usedGross, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
572 netIn = fee.Net + fee.Remainder
573 if netIn > maxNet {
574 netIn = maxNet
575 }
576 }
577
578 tokensOut, newVU, newVT := ammmath.BuyTokens(l.VirtualUgnot, l.VirtualToken, netIn)
579 // Integer edge: step down net until tokens ≤ remaining curve supply.
580 for tokensOut > remainingTok && netIn > 1 {
581 netIn--
582 tokensOut, newVU, newVT = ammmath.BuyTokens(l.VirtualUgnot, l.VirtualToken, netIn)
583 }
584 if tokensOut > remainingTok || tokensOut <= 0 {
585 panic("pad: cannot fill remaining curve supply")
586 }
587 // If net was reduced further, shrink usedGross so refund is correct.
588 if netIn < maxNet || usedGross < sent {
589 // Re-derive gross that yields this netIn (≤ sent).
590 g2 := maxGrossForNetIn(netIn, sent)
591 if g2 > 0 && g2 < usedGross {
592 usedGross = g2
593 fee = ammmath.ApplyFee(usedGross, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
594 // Keep curve netIn as simulated (may be slightly below fee.Net+Rem).
595 }
596 }
597 // Hard safety: never overshoot graduation raise after this buy.
598 if l.RaisedUgnot+netIn > GraduationThreshold {
599 netIn = GraduationThreshold - l.RaisedUgnot
600 if netIn <= 0 {
601 panic("pad: raise filled — call Graduate")
602 }
603 tokensOut, newVU, newVT = ammmath.BuyTokens(l.VirtualUgnot, l.VirtualToken, netIn)
604 for tokensOut > remainingTok && netIn > 1 {
605 netIn--
606 tokensOut, newVU, newVT = ammmath.BuyTokens(l.VirtualUgnot, l.VirtualToken, netIn)
607 }
608 if tokensOut <= 0 {
609 panic("pad: cannot fill remaining raise")
610 }
611 usedGross = maxGrossForNetIn(netIn, sent)
612 if usedGross <= 0 {
613 panic("pad: buy too small for remaining raise")
614 }
615 fee = ammmath.ApplyFee(usedGross, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
616 }
617
618 refund := sent - usedGross
619 if refund > 0 {
620 sendUgnot(cur, buyer, refund)
621 }
622
623 requireMinOut(tokensOut, minTokensOut, "tokens out")
624 checkAndAddSnipe(l, buyer, tokensOut)
625
626 // Mutate only after all checks pass.
627 l.CreatorFees += fee.Creator
628 creditProtocol(fee.Protocol)
629 l.VirtualUgnot = newVU
630 l.VirtualToken = newVT
631 l.RealSold += tokensOut
632 l.RaisedUgnot += netIn
633 // Invariant: raise never exceeds threshold after Buy.
634 if l.RaisedUgnot > GraduationThreshold {
635 panic("pad: raise overshoot invariant")
636 }
637
638 addBal(l, buyer, tokensOut)
639 noteBuyer(l, buyer)
640 maybeRefundBond(cur, l)
641 recordTrade(l, TradeSideBuy, usedGross, tokensOut)
642
643 chain.Emit("Buy",
644 "id", id,
645 "buyer", buyer.String(),
646 "ugnot", strconv.FormatInt(usedGross, 10),
647 "tokens", strconv.FormatInt(tokensOut, 10),
648 )
649 if refund > 0 {
650 chain.Emit("BuyRefund",
651 "id", id,
652 "buyer", buyer.String(),
653 "refund", strconv.FormatInt(refund, 10),
654 )
655 }
656 notifyTrade(cur, buyer, id, 0, usedGross)
657
658 if readyToGraduate(l) {
659 graduate(cur, l)
660 }
661 return tokensOut
662}
663
664// RemainingRaiseUgnot is net ugnot still needed to hit GraduationThreshold (0 if met/over).
665func RemainingRaiseUgnot(id string) int64 {
666 l := mustLaunch(id)
667 if l.Status != StatusCurve {
668 return 0
669 }
670 if l.RaisedUgnot >= GraduationThreshold {
671 return 0
672 }
673 return GraduationThreshold - l.RaisedUgnot
674}
675
676// Sell burns curve tokens and pays ugnot (fee on output).
677// minUgnotOut: slippage floor (0 = disabled).
678func Sell(cur realm, id string, tokensIn, minUgnotOut int64) int64 {
679 requireInit()
680 if !cur.Previous().IsUserCall() {
681 panic("pad: must be EOA MsgCall")
682 }
683 if tokensIn <= 0 {
684 panic("pad: tokensIn must be positive")
685 }
686 l := mustLaunch(id)
687 if l.Status != StatusCurve {
688 panic("pad: not on curve (use SwapSell)")
689 }
690 seller := cur.Previous().Address()
691 if balOf(l, seller) < tokensIn {
692 panic("pad: insufficient token balance")
693 }
694
695 gross, newVU, newVT := ammmath.SellTokens(l.VirtualUgnot, l.VirtualToken, tokensIn)
696 fee := ammmath.ApplyFeeOnOutput(gross, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
697 requireMinOut(fee.Net, minUgnotOut, "ugnot out")
698
699 // Full gross left virtual reserves; retain fee in virtual ugnot (cash stays in realm).
700 l.VirtualUgnot = newVU + fee.Fee
701 l.VirtualToken = newVT
702 l.RealSold -= tokensIn
703 if l.RealSold < 0 {
704 l.RealSold = 0
705 }
706
707 // User receives net; creator+protocol become fee liabilities (leave Raised).
708 payOut := fee.Net + fee.Creator + fee.Protocol
709 if l.RaisedUgnot >= payOut {
710 l.RaisedUgnot -= payOut
711 } else {
712 l.RaisedUgnot = 0
713 }
714 l.CreatorFees += fee.Creator
715 creditProtocol(fee.Protocol)
716
717 addBal(l, seller, -tokensIn)
718 sendUgnot(cur, seller, fee.Net)
719 recordTrade(l, TradeSideSell, fee.Net, tokensIn)
720
721 chain.Emit("Sell",
722 "id", id,
723 "seller", seller.String(),
724 "tokens", strconv.FormatInt(tokensIn, 10),
725 "ugnot", strconv.FormatInt(fee.Net, 10),
726 )
727 notifyTrade(cur, seller, id, 1, fee.Net)
728 return fee.Net
729}
730
731// Graduate permissionlessly moves a ready curve into a permanently locked CPMM.
732// Ready when RaisedUgnot >= GraduationThreshold, or when RealSold >= CurveSupply
733// with RaisedUgnot > 0 (sold-out before threshold — escape hatch for unreachable raise).
734func Graduate(cur realm, id string) {
735 requireInit()
736 l := mustLaunch(id)
737 if l.Status != StatusCurve {
738 panic("pad: already graduated")
739 }
740 if !readyToGraduate(l) {
741 panic("pad: not ready to graduate (need raise threshold or curve sold out)")
742 }
743 graduate(cur, l)
744}
745
746func graduate(cur realm, l *Launch) {
747 if l.Status != StatusCurve {
748 return
749 }
750 // Liquidity capital = all raised GNOT + every token not already sold to buyers.
751 // (Formerly only PoolSeed tokens entered the pool; unsold curve supply was dead.)
752 poolU := l.RaisedUgnot
753 if poolU <= 0 {
754 panic("pad: empty pool ugnot")
755 }
756 remaining := TotalSupply - l.RealSold
757 if remaining <= 0 {
758 panic("pad: no remaining tokens for liquidity")
759 }
760 poolT := remaining
761
762 l.PoolUgnot = poolU
763 l.PoolToken = poolT
764 l.RaisedUgnot = 0
765 l.VirtualUgnot = 0
766 l.VirtualToken = 0
767 l.Status = StatusGraduated
768
769 // Forfeit unrefunded bond to protocol at graduation if still locked.
770 if !l.BondRefunded && l.BondUgnot > 0 {
771 creditProtocol(l.BondUgnot)
772 l.BondUgnot = 0
773 l.BondRefunded = true
774 }
775
776 // Mark graduation on chart at pool spot.
777 recordTrade(l, TradeSideOpen, poolU, poolT)
778
779 // Prefer atomic Gnoswap listing: remaining tokens + raised-sized WUGNOT inventory,
780 // with a slice of WUGNOT swapped to GNS for CreatePool fee.
781 // Falls back to locked internal CPMM when inventory/test mode cannot list.
782 l.GnoswapReady = true
783 listed := false
784 if !testSkipBanker {
785 listed = tryListOnGnoswap(cur, l, poolU, poolT)
786 }
787 if !listed {
788 // Internal CPMM: PoolToken is pad-accounting reserve (not minted GRC20).
789 // Circulating = user balances; pool side is virtual reserve PoolToken.
790 if l.GnoswapNote == "" {
791 l.GnoswapNote = "internal CPMM; fund pad WUGNOT inventory then retry is N/A (already graduated)"
792 }
793 chain.Emit("Graduated",
794 "id", l.ID,
795 "poolUgnot", strconv.FormatInt(poolU, 10),
796 "poolToken", strconv.FormatInt(poolT, 10),
797 "token", l.TokenID,
798 "gnoswap_listed", "0",
799 )
800 return
801 }
802 // Listed on Gnoswap: capital is in the CL position (NFT owned by pad).
803 // Internal SwapBuy/Sell disabled (PoolUgnot/PoolToken kept as listing record).
804 chain.Emit("Graduated",
805 "id", l.ID,
806 "poolUgnot", strconv.FormatInt(poolU, 10),
807 "poolToken", strconv.FormatInt(poolT, 10),
808 "token", l.TokenID,
809 "gnoswap_listed", "1",
810 "poolPath", l.GnoswapPoolPath,
811 "positionId", strconv.FormatUint(l.GnoswapPositionID, 10),
812 )
813}
814
815// TokenIDOf returns the GRC20 Token.ID() for a launch.
816func TokenIDOf(id string) string {
817 return mustLaunch(id).TokenID
818}
819
820// GRC20Bank returns the underlying *grc20.Token for interop (metadata / external DEX).
821// Does not expose PrivateLedger — mint/burn stay pad-only.
822func GRC20Bank(id string) *grc20.Token {
823 l := mustLaunch(id)
824 if l.token == nil {
825 panic("pad: no token")
826 }
827 return l.token
828}
829
830// SwapBuy buys tokens from the graduated pool with -send ugnot.
831// minTokensOut: slippage floor (0 = disabled).
832// Disabled when the launch was auto-listed on Gnoswap (trade there instead).
833func SwapBuy(cur realm, id string, minTokensOut int64) int64 {
834 requireInit()
835 sent := requireUserPayment(cur)
836 l := mustLaunch(id)
837 if l.Status != StatusGraduated {
838 panic("pad: not graduated (use Buy)")
839 }
840 if l.GnoswapListed {
841 panic("pad: listed on Gnoswap — trade via router, not pad SwapBuy")
842 }
843 buyer := cur.Previous().Address()
844
845 fee := ammmath.ApplyFee(sent, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
846 tokensOut, newPU, newPT := ammmath.PoolSwapUgnotForToken(
847 l.PoolUgnot, l.PoolToken, fee.Net, fee.Remainder,
848 )
849 requireMinOut(tokensOut, minTokensOut, "tokens out")
850
851 l.CreatorFees += fee.Creator
852 creditProtocol(fee.Protocol)
853 l.PoolUgnot = newPU
854 l.PoolToken = newPT
855 addBal(l, buyer, tokensOut)
856 noteBuyer(l, buyer)
857 recordTrade(l, TradeSideBuy, sent, tokensOut)
858
859 chain.Emit("SwapBuy",
860 "id", id,
861 "buyer", buyer.String(),
862 "ugnot", strconv.FormatInt(sent, 10),
863 "tokens", strconv.FormatInt(tokensOut, 10),
864 )
865 notifyTrade(cur, buyer, id, 0, sent)
866 return tokensOut
867}
868
869// SwapSell sells tokens into the graduated pool for ugnot.
870// minUgnotOut: slippage floor (0 = disabled).
871func SwapSell(cur realm, id string, tokensIn, minUgnotOut int64) int64 {
872 requireInit()
873 if !cur.Previous().IsUserCall() {
874 panic("pad: must be EOA MsgCall")
875 }
876 if tokensIn <= 0 {
877 panic("pad: tokensIn must be positive")
878 }
879 l := mustLaunch(id)
880 if l.Status != StatusGraduated {
881 panic("pad: not graduated (use Sell)")
882 }
883 if l.GnoswapListed {
884 panic("pad: listed on Gnoswap — trade via router, not pad SwapSell")
885 }
886 seller := cur.Previous().Address()
887 if balOf(l, seller) < tokensIn {
888 panic("pad: insufficient token balance")
889 }
890
891 gross, newPU, newPT := ammmath.PoolSwapTokenForUgnot(l.PoolUgnot, l.PoolToken, tokensIn)
892 fee := ammmath.ApplyFeeOnOutput(gross, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
893 requireMinOut(fee.Net, minUgnotOut, "ugnot out")
894
895 // Retain fee in pool ugnot (cash stays); user gets net.
896 l.PoolUgnot = newPU + fee.Fee
897 l.PoolToken = newPT
898 l.CreatorFees += fee.Creator
899 creditProtocol(fee.Protocol)
900
901 addBal(l, seller, -tokensIn)
902 sendUgnot(cur, seller, fee.Net)
903 recordTrade(l, TradeSideSell, fee.Net, tokensIn)
904
905 chain.Emit("SwapSell",
906 "id", id,
907 "seller", seller.String(),
908 "tokens", strconv.FormatInt(tokensIn, 10),
909 "ugnot", strconv.FormatInt(fee.Net, 10),
910 )
911 notifyTrade(cur, seller, id, 1, fee.Net)
912 return fee.Net
913}
914
915// Transfer moves GRC20 tokens between addresses (user-initiated).
916func Transfer(cur realm, id string, to address, amount int64) {
917 requireInit()
918 if !cur.Previous().IsUserCall() {
919 panic("pad: must be EOA MsgCall")
920 }
921 if amount <= 0 {
922 panic("pad: amount must be positive")
923 }
924 if !to.IsValid() {
925 panic("pad: invalid to")
926 }
927 l := mustLaunch(id)
928 from := cur.Previous().Address()
929 if from == to {
930 panic("pad: self transfer")
931 }
932 if l.ledger == nil {
933 panic("pad: no GRC20 ledger")
934 }
935 if err := l.ledger.Transfer(from, to, amount); err != nil {
936 panic("pad: transfer: " + err.Error())
937 }
938 chain.Emit("Transfer", "id", id, "from", from.String(), "to", to.String(),
939 "amount", strconv.FormatInt(amount, 10))
940}
941
942// Approve sets GRC20 allowance so DEX/contracts can TransferFrom.
943func Approve(cur realm, id string, spender address, amount int64) {
944 requireInit()
945 if !cur.Previous().IsUserCall() {
946 panic("pad: must be EOA MsgCall")
947 }
948 if !spender.IsValid() {
949 panic("pad: invalid spender")
950 }
951 l := mustLaunch(id)
952 if l.ledger == nil {
953 panic("pad: no GRC20 ledger")
954 }
955 owner := cur.Previous().Address()
956 if err := l.ledger.Approve(owner, spender, amount); err != nil {
957 panic("pad: approve: " + err.Error())
958 }
959 chain.Emit("Approval", "id", id, "owner", owner.String(), "spender", spender.String(),
960 "amount", strconv.FormatInt(amount, 10))
961}
962
963// TransferFrom spends allowance: spender = MsgCall EOA caller.
964// Enables DEX / routers that hold allowance from Approve.
965func TransferFrom(cur realm, id string, from, to address, amount int64) {
966 requireInit()
967 if !cur.Previous().IsUserCall() {
968 panic("pad: must be EOA MsgCall")
969 }
970 if amount <= 0 {
971 panic("pad: amount must be positive")
972 }
973 if !from.IsValid() || !to.IsValid() {
974 panic("pad: invalid address")
975 }
976 if from == to {
977 panic("pad: self transfer")
978 }
979 l := mustLaunch(id)
980 if l.ledger == nil {
981 panic("pad: no GRC20 ledger")
982 }
983 spender := cur.Previous().Address()
984 if err := l.ledger.TransferFrom(from, spender, to, amount); err != nil {
985 panic("pad: transferFrom: " + err.Error())
986 }
987 chain.Emit("TransferFrom", "id", id, "from", from.String(), "to", to.String(),
988 "spender", spender.String(), "amount", strconv.FormatInt(amount, 10))
989}
990
991// ClaimCreatorFees withdraws accrued creator fees for a launch.
992// Only the token creator may claim. Fees stay on pad until claimed.
993func ClaimCreatorFees(cur realm, id string) int64 {
994 requireInit()
995 if !cur.Previous().IsUserCall() {
996 panic("pad: must be EOA MsgCall")
997 }
998 l := mustLaunch(id)
999 caller := cur.Previous().Address()
1000 if caller != l.Creator {
1001 panic("pad: not creator")
1002 }
1003 amt := l.CreatorFees
1004 if amt <= 0 {
1005 return 0
1006 }
1007 l.CreatorFees = 0
1008 sendUgnot(cur, caller, amt)
1009 chain.Emit("ClaimCreator", "id", id, "amount", strconv.FormatInt(amt, 10))
1010 return amt
1011}
1012
1013// payoutProtocolFees sends all pending protocolFees to protocolAddr.
1014// Shared by ClaimProtocolFees and PushProtocolFees.
1015func payoutProtocolFees(cur realm) int64 {
1016 amt := protocolFees
1017 if amt <= 0 {
1018 return 0
1019 }
1020 if !protocolAddr.IsValid() {
1021 panic("pad: protocol address unset")
1022 }
1023 protocolFees = 0
1024 protocolFeesPaid += amt
1025 sendUgnot(cur, protocolAddr, amt)
1026 chain.Emit("ClaimProtocol",
1027 "to", protocolAddr.String(),
1028 "amount", strconv.FormatInt(amt, 10),
1029 )
1030 return amt
1031}
1032
1033// ClaimProtocolFees withdraws pending protocol fees to protocolAddr.
1034// Only the current protocol treasury key may call (same wallet that Init'd,
1035// unless TransferProtocol was used).
1036func ClaimProtocolFees(cur realm) int64 {
1037 requireInit()
1038 if !cur.Previous().IsUserCall() {
1039 panic("pad: must be EOA MsgCall")
1040 }
1041 if cur.Previous().Address() != protocolAddr {
1042 panic("pad: not protocol")
1043 }
1044 return payoutProtocolFees(cur)
1045}
1046
1047// PushProtocolFees sends pending protocol fees to protocolAddr.
1048// Permissionless: anyone may call so treasury can be paid without the protocol
1049// key signing (still only pays the configured protocolAddr).
1050func PushProtocolFees(cur realm) int64 {
1051 requireInit()
1052 if !cur.Previous().IsUserCall() {
1053 panic("pad: must be EOA MsgCall")
1054 }
1055 return payoutProtocolFees(cur)
1056}
1057
1058// TransferProtocol rotates the protocol fee recipient (current protocol only).
1059// Pending protocolFees stay on pad until claimed/pushed to the *new* address.
1060func TransferProtocol(cur realm, newAddr address) {
1061 requireInit()
1062 if !cur.Previous().IsUserCall() {
1063 panic("pad: must be EOA MsgCall")
1064 }
1065 if cur.Previous().Address() != protocolAddr {
1066 panic("pad: not protocol")
1067 }
1068 if !newAddr.IsValid() {
1069 panic("pad: invalid new protocol address")
1070 }
1071 if newAddr == protocolAddr {
1072 panic("pad: same protocol address")
1073 }
1074 old := protocolAddr
1075 protocolAddr = newAddr
1076 chain.Emit("TransferProtocol", "from", old.String(), "to", newAddr.String())
1077}
1078
1079// ProtocolAddress returns the current protocol treasury address (bech32).
1080func ProtocolAddress() string {
1081 return protocolAddr.String()
1082}
1083
1084// ProtocolFeesPaid returns lifetime ugnot already paid out to the treasury.
1085func ProtocolFeesPaid() int64 {
1086 return protocolFeesPaid
1087}
1088
1089// FeeInfo returns protocolAddr|pendingUgnot|paidUgnot for UIs.
1090func FeeInfo() string {
1091 return protocolAddr.String() + "|" +
1092 strconv.FormatInt(protocolFees, 10) + "|" +
1093 strconv.FormatInt(protocolFeesPaid, 10)
1094}
1095
1096// PadAddress returns this pad realm's bech32 package address (fund WUGNOT here).
1097func PadAddress() string {
1098 return padAddr.String()
1099}
1100
1101// AdminInfo is a single-line dashboard snapshot for the ops UI:
1102//
1103// protocolAddr|pendingFees|paidFees|reservedUgnot|launchCount|pointsOn|inited|padAddr
1104//
1105// pointsOn/inited are 0|1.
1106func AdminInfo() string {
1107 pts := "0"
1108 if pointsEnabled {
1109 pts = "1"
1110 }
1111 ini := "0"
1112 if inited {
1113 ini = "1"
1114 }
1115 return protocolAddr.String() + "|" +
1116 strconv.FormatInt(protocolFees, 10) + "|" +
1117 strconv.FormatInt(protocolFeesPaid, 10) + "|" +
1118 strconv.FormatInt(reservedUgnot(), 10) + "|" +
1119 strconv.Itoa(launches.Size()) + "|" +
1120 pts + "|" +
1121 ini + "|" +
1122 padAddr.String()
1123}
1124
1125// IsProtocol reports whether addr is the current treasury (for UI gating).
1126func IsProtocol(addr string) bool {
1127 if !inited || !protocolAddr.IsValid() {
1128 return false
1129 }
1130 return protocolAddr.String() == addr
1131}
1132
1133// reservedUgnot is ugnot the pad must keep to honor user/creator liabilities
1134// and active markets (curve raised, internal CPMM, bonds, pending fees).
1135func reservedUgnot() int64 {
1136 reserved := protocolFees
1137 launches.Iterate("", "", func(_ string, value any) bool {
1138 l := value.(*Launch)
1139 reserved += l.CreatorFees
1140 if !l.BondRefunded {
1141 reserved += l.BondUgnot
1142 }
1143 if l.Status == StatusCurve {
1144 reserved += l.RaisedUgnot
1145 }
1146 // Internal CPMM (fallback when not Gnoswap-listed) holds real ugnot.
1147 if l.Status == StatusGraduated && !l.GnoswapListed {
1148 reserved += l.PoolUgnot
1149 }
1150 return false
1151 })
1152 return reserved
1153}
1154
1155// ReservedUgnot is ugnot the pad must keep for markets + pending claims.
1156func ReservedUgnot() int64 {
1157 return reservedUgnot()
1158}
1159
1160// freeUgnot reports bank ugnot above reserved liabilities (0 if short/test).
1161func freeUgnot(cur realm) int64 {
1162 if testSkipBanker {
1163 return 0
1164 }
1165 bk := banker.NewBanker(banker.BankerTypeReadonly, cur)
1166 bal := bk.GetCoins(cur.Address()).AmountOf(DenomUgnot)
1167 free := bal - reservedUgnot()
1168 if free < 0 {
1169 return 0
1170 }
1171 return free
1172}
1173
1174// WithdrawProtocolUgnot lets the treasury pull free ugnot from the pad bank
1175// (e.g. raised backlog after Gnoswap list, to re-wrap as WUGNOT inventory).
1176// Capped by free balance; panics if amount > free.
1177func WithdrawProtocolUgnot(cur realm, amount int64) int64 {
1178 requireInit()
1179 if !cur.Previous().IsUserCall() {
1180 panic("pad: must be EOA MsgCall")
1181 }
1182 if cur.Previous().Address() != protocolAddr {
1183 panic("pad: not protocol")
1184 }
1185 if amount <= 0 {
1186 panic("pad: amount must be positive")
1187 }
1188 free := freeUgnot(cur)
1189 if amount > free {
1190 panic("pad: amount exceeds free ugnot (reserved for markets/fees)")
1191 }
1192 sendUgnot(cur, protocolAddr, amount)
1193 chain.Emit("WithdrawProtocolUgnot",
1194 "to", protocolAddr.String(),
1195 "amount", strconv.FormatInt(amount, 10),
1196 "freeLeft", strconv.FormatInt(free-amount, 10),
1197 )
1198 return amount
1199}
1200
1201// --- read helpers (non-crossing) ---
1202
1203func BalanceOf(id string, owner address) int64 {
1204 return balOf(mustLaunch(id), owner)
1205}
1206
1207// ListBuyers returns unique buyer addresses (one per line), capped for query size.
1208// Only addresses that bought at least once on this pad (UniqueBuyers). Not full GRC20 holders
1209// who received tokens via transfer.
1210func ListBuyers(id string) string {
1211 l := mustLaunch(id)
1212 const maxN = 100
1213 out := ""
1214 n := 0
1215 l.UniqueBuyers.Iterate("", "", func(key string, _ any) bool {
1216 if n >= maxN {
1217 return true
1218 }
1219 if out != "" {
1220 out += "\n"
1221 }
1222 out += key
1223 n++
1224 return false
1225 })
1226 return out
1227}
1228
1229func GetStatus(id string) int {
1230 return mustLaunch(id).Status
1231}
1232
1233func GetRaised(id string) int64 {
1234 return mustLaunch(id).RaisedUgnot
1235}
1236
1237func GetPool(id string) (ugnot, token int64) {
1238 l := mustLaunch(id)
1239 return l.PoolUgnot, l.PoolToken
1240}
1241
1242func GetCreatorFees(id string) int64 {
1243 return mustLaunch(id).CreatorFees
1244}
1245
1246func ProtocolFees() int64 {
1247 return protocolFees
1248}
1249
1250func LaunchCount() int {
1251 return launches.Size()
1252}
1253
1254func ResolveSymbol(symbol string) string {
1255 s, ok := bySymbol.Get(symbol).(string)
1256 if !ok {
1257 return ""
1258 }
1259 return s
1260}
1261
1262// ListIDs returns newline-separated launch IDs (sorted by AVL key / creation order).
1263func ListIDs() string {
1264 out := ""
1265 launches.Iterate("", "", func(key string, _ any) bool {
1266 if out != "" {
1267 out += "\n"
1268 }
1269 out += key
1270 return false
1271 })
1272 return out
1273}
1274
1275// LaunchInfo returns a single-line pipe-delimited summary for UIs/indexers:
1276//
1277// id|name|symbol|status|raised|sold|buyers|creatorFees|poolUgnot|poolToken|uri|creator|virtualUgnot|virtualToken|created|tokenID|gnoswapReady|gnoswapListed|gnoswapPoolPath
1278//
1279// status: 0=curve 1=graduated; gnoswapReady/listed: 0|1
1280func LaunchInfo(id string) string {
1281 l := mustLaunch(id)
1282 gs := "0"
1283 if l.GnoswapReady {
1284 gs = "1"
1285 }
1286 gl := "0"
1287 if l.GnoswapListed {
1288 gl = "1"
1289 }
1290 return l.ID + "|" +
1291 l.Name + "|" +
1292 l.Symbol + "|" +
1293 strconv.Itoa(l.Status) + "|" +
1294 strconv.FormatInt(l.RaisedUgnot, 10) + "|" +
1295 strconv.FormatInt(l.RealSold, 10) + "|" +
1296 strconv.Itoa(l.BuyerCount) + "|" +
1297 strconv.FormatInt(l.CreatorFees, 10) + "|" +
1298 strconv.FormatInt(l.PoolUgnot, 10) + "|" +
1299 strconv.FormatInt(l.PoolToken, 10) + "|" +
1300 l.URI + "|" +
1301 l.Creator.String() + "|" +
1302 strconv.FormatInt(l.VirtualUgnot, 10) + "|" +
1303 strconv.FormatInt(l.VirtualToken, 10) + "|" +
1304 strconv.FormatInt(l.Created, 10) + "|" +
1305 l.TokenID + "|" +
1306 gs + "|" +
1307 gl + "|" +
1308 l.GnoswapPoolPath
1309}
1310
1311// ParamsInfo returns parameters for UI display.
1312// total|curve|poolSeed|gradThreshold|feeBps|createBond
1313// createBond is live from bond realm when not in unit-test mode.
1314func ParamsInfo() string {
1315 return strconv.FormatInt(TotalSupply, 10) + "|" +
1316 strconv.FormatInt(CurveSupply, 10) + "|" +
1317 strconv.FormatInt(PoolSeed, 10) + "|" +
1318 strconv.FormatInt(GraduationThreshold, 10) + "|" +
1319 strconv.FormatInt(FeeBPS, 10) + "|" +
1320 strconv.FormatInt(requiredCreateBond(), 10)
1321}
1322
1323// TradeHistory returns newline-separated chart points:
1324//
1325// height|side|ugnot|tokens|priceScaled
1326//
1327// side: 0=buy 1=sell 2=open/graduate. Ordered oldest → newest.
1328func TradeHistory(id string) string {
1329 l := mustLaunch(id)
1330 out := ""
1331 l.Trades.Iterate("", "", func(_ string, value any) bool {
1332 t := value.(*Trade)
1333 line := strconv.FormatInt(t.Height, 10) + "|" +
1334 strconv.Itoa(t.Side) + "|" +
1335 strconv.FormatInt(t.Ugnot, 10) + "|" +
1336 strconv.FormatInt(t.Tokens, 10) + "|" +
1337 strconv.FormatInt(t.Price, 10)
1338 if out != "" {
1339 out += "\n"
1340 }
1341 out += line
1342 return false
1343 })
1344 return out
1345}
1346
1347// TradeCount returns number of stored chart samples for a launch.
1348func TradeCount(id string) int {
1349 return mustLaunch(id).Trades.Size()
1350}
1351
1352// resetForTest clears package state between unit tests.
1353func resetForTest() {
1354 launches = avl.Tree{}
1355 bySymbol = avl.Tree{}
1356 nextID = 0
1357 nextTokenID = 0
1358 var zero address
1359 protocolAddr = zero
1360 // padAddr is set in package init — do not clear (realm address is fixed).
1361 protocolFees = 0
1362 protocolFeesPaid = 0
1363 inited = false
1364 pointsEnabled = false
1365 testSkipBanker = true // unit tests skip banker; integration/chain tests leave false
1366}