memepad.gno
65.11 Kb · 2180 lines
1// Package pad is gnomemepad: a self-contained meme launchpad for gno.land.
2//
3// CreateWithVenue (bond + venue-aware GNS escrow) -> GRC20 + WUGNOT curve -> Graduate
4// -> internal CPMM; then either:
5// gnoswap: RetryList / RetryListGnoswap (escrowed GNS + WUGNOT inventory)
6// zdex: ReleaseListSeed -> EOA CreatePool -> ConfirmZdexList
7//
8// Production: Buy/Sell/Swap* use WUGNOT (push-pay + prepaid credits).
9// Payment: user wugnot.Transfer(pad, amount) then Buy.
10// Create fee: gnoswap preference locks ListFeeGns; zdex preference locks 0.
11// DefaultListVenue source = zdex; Pearl may SetDefaultListVenue(gnoswap) after deploy.
12// Unit tests (testSkipBanker): OriginSend ugnot as collateral units.
13// Tokens are real GRC20 (mint on buy, burn on sell).
14package padv2
15
16import (
17 "chain"
18 "chain/banker"
19 "chain/runtime"
20 "chain/runtime/unsafe"
21 "strconv"
22 "strings"
23
24 "gno.land/p/nt/grc20/v0"
25 ammmath "gno.land/p/g1n4pl5uc4yt5r96m9w6fmdznx3x0jyg8l6arhmt/gnomi/ammmathv2"
26 "gno.land/p/nt/avl/v0"
27 "gno.land/p/nt/seqid/v0"
28 "gno.land/r/nt/grc20reg/v0"
29 "gno.land/r/gnoland/wugnot"
30 "gno.land/r/gnoswap/gns"
31 // bond: create-bond policy (promo / normal). Separate package - pad upgrades
32 // do not replace bond schedule. Deploy prepare rewrites to personal path.
33 createbond "gno.land/r/g1n4pl5uc4yt5r96m9w6fmdznx3x0jyg8l6arhmt/gnomi/bond"
34 // pointsv2: optional trade/create awards (off by default until SetPointsEnabled).
35 // Deploy prepare rewrites this import to the Sapphire personal-namespace path.
36 pointsv2 "gno.land/r/g1n4pl5uc4yt5r96m9w6fmdznx3x0jyg8l6arhmt/gnomi/pointsv2"
37)
38
39var (
40 launches avl.Tree // id -> *Launch
41 bySymbol avl.Tree // symbol -> id string
42 nextID seqid.ID
43 nextTokenID seqid.ID // GRC20 identity sequence (shared for all launches)
44 // padAddr: this realm's package address (set in init) - for inventory funding.
45 padAddr address
46 // protocolAddr: treasury set by Init (first EOA). Receives protocol fee share.
47 protocolAddr address
48 // protocolFees: WUGNOT-backed trade fees on pad, claimable / pushable to protocolAddr.
49 protocolFees int64
50 // protocolBondFees: bank ugnot from create BondFee (not WUGNOT; realms cannot Deposit).
51 protocolBondFees int64
52 // protocolFeesPaid: lifetime ugnot already sent to protocolAddr (stats only).
53 protocolFeesPaid int64
54 inited bool
55 // testSkipBanker: when true, sendUgnot is a no-op (unit tests without funded realm bank).
56 // Always false in production.
57 testSkipBanker bool
58 // testForceGnoswapList: unit tests only - stub tryListOnGnoswap succeeds without Sapphire deps.
59 // Always false in production (gnoswap_list_full ignores it).
60 testForceGnoswapList bool
61 // testForceZdexList: unit tests only - stub tryListOnZdex succeeds.
62 testForceZdexList bool
63 // pointsEnabled: when true, notify pointsv2 after Create / Buy / Sell / Swap*.
64 // Admin must also AllowPad(this package) on pointsv2.
65 pointsEnabled bool
66 // Live mutable economics (protocol-gated). Seeded from consts in Init.
67 // Changing graduation mid-flight affects ALL StatusCurve launches
68 // (readyToGraduate / Buy raise cap). Graduated+listed launches are unaffected
69 // (PoolUgnot/PoolToken already fixed at Graduate).
70 graduationUgnot int64
71 listFeeGnsLive int64
72 // wugnotCredit: EOA address string -> int64 prepaid WUGNOT claimable by user.
73 // totalWugnotCredit: sum of all credits (included in reservedWugnot).
74 // Flow: Transfer WUGNOT to pad (raises free) -> Buy auto-locks free into credit
75 // and spends; overpay refund goes back to credit; ClaimWugnot withdraws credit.
76 wugnotCredit avl.Tree
77 totalWugnotCredit int64
78 // totalListFeeGns: sum of per-launch ListFeeGns escrow (reserved GNS on pad).
79 // freeGns = gns.BalanceOf(pad) - totalListFeeGns.
80 totalListFeeGns int64
81)
82
83func init(cur realm) {
84 padAddr = cur.Address()
85}
86
87// Trade is one price sample for charts (capped history per launch).
88type Trade struct {
89 Height int64
90 Side int // TradeSideBuy | TradeSideSell | TradeSideOpen
91 Ugnot int64
92 Tokens int64
93 Price int64 // ugnot per token * 1e6 after the trade
94}
95
96// Launch is one meme market: curve phase then locked pool phase.
97// token/ledger are unexported so external packages cannot Mint/Burn via field access.
98type Launch struct {
99 ID string
100 Name string
101 Symbol string
102 URI string
103 Creator address
104 Status int
105 Created int64 // block height
106
107 // GRC20 (mint/burn only via pad-owned private ledger)
108 token *grc20.Token
109 ledger *grc20.PrivateLedger
110 TokenID string // Token.ID() - registry / Gnoswap identity
111
112 // Virtual curve reserves
113 VirtualUgnot int64
114 VirtualToken int64
115 RealSold int64 // tokens sold on curve (<= CurveSupply)
116 RaisedUgnot int64 // net ugnot collateral in curve (excl. fee vaults)
117
118 // Real pool (post-grad); LP permanently locked - no remove path
119 // PoolToken is pad-internal reserve sized to curve spot (not always all unsold).
120 // LeftoverTokens = (TotalSupply - RealSold) - PoolToken; minted to pad at list, not LP'd.
121 PoolUgnot int64
122 PoolToken int64
123 LeftoverTokens int64
124
125 CreatorFees int64
126 BondUgnot int64
127 BondRefunded bool
128 UniqueBuyers avl.Tree // address -> true
129 BuyerCount int
130 // snipeBought: address -> cumulative tokens bought during anti-snipe window
131 snipeBought avl.Tree
132
133 // Gnoswap listing state
134 GnoswapReady bool // graduated; token is listable / listed
135 GnoswapListed bool // true when CreatePool+Mint succeeded on Gnoswap
136 GnoswapNote string // human status / failure reason
137 GnoswapPoolPath string
138 GnoswapPositionID uint64
139 // FeeWugnotSpent / LiqWugnotUsed: inventory spent at graduate (1:1 vs raised ugnot notionally)
140 FeeWugnotSpent int64
141 LiqWugnotUsed int64
142
143 // ListFeeGns: Create-time GNS escrow for Gnoswap CreatePool fee (padv20+).
144 // Consumed on successful list; ClaimListFee refunds creator if still unlisted.
145 ListFeeGns int64
146 ListFeeConsumed bool // true after list paid CreatePool fee from escrow
147
148 // ListVenue: which DEX adapter succeeded (e.g. "zdex" / "gnoswap"). Empty if unlisted.
149 ListVenue string
150 // PreferredVenue: chosen at Create (or SetPreferredVenue). Used when RetryList venueId == "".
151 PreferredVenue string
152 // ListSeedReleased: PoolUgnot (WUGNOT) + PoolToken (minted GRC20) sent to creator for EOA zdex CreatePool.
153 ListSeedReleased bool
154
155 // Chart history (ordered AVL keys)
156 Trades avl.Tree // tradeKey -> *Trade
157 NextTrade int64
158}
159
160// Init sets the protocol treasury. First EOA caller becomes fee recipient
161// (protocolAddr). Protocol trade fees accrue on-pad until ClaimProtocolFees
162// (treasury only) or PushProtocolFees (anyone may push to treasury).
163// Creator fees always need ClaimCreatorFees by the token creator.
164//
165// Deploy note: call Init with the wallet that should receive protocol fees
166// (or TransferProtocol later). Gnoswap CreatePool GNS fee is paid to Gnoswap,
167// not to this treasury.
168func Init(cur realm) {
169 if inited {
170 panic("pad: already initialized")
171 }
172 if !cur.Previous().IsUserCall() {
173 panic("pad: EOA only")
174 }
175 protocolAddr = cur.Previous().Address()
176 graduationUgnot = GraduationThreshold // seed from const default
177 listFeeGnsLive = ListFeeGns
178 if graduationUgnot <= 0 {
179 panic("pad: default graduation misconfigured")
180 }
181 ensureListVenuesSeeded()
182 inited = true
183 chain.Emit("Init",
184 "protocol", protocolAddr.String(),
185 "graduationUgnot", strconv.FormatInt(graduationUgnot, 10),
186 "listFeeGns", strconv.FormatInt(listFeeGnsLive, 10),
187 "defaultListVenue", DefaultListVenue(),
188 )
189}
190
191func requireProtocol(cur realm) {
192 requireInit()
193 if !cur.Previous().IsUserCall() {
194 panic("pad: EOA only")
195 }
196 if cur.Previous().Address() != protocolAddr {
197 panic("pad: not protocol")
198 }
199}
200
201// graduationThreshold is the live raise target (ugnot). Falls back to const if unset.
202func graduationThreshold() int64 {
203 if graduationUgnot > 0 {
204 return graduationUgnot
205 }
206 return GraduationThreshold
207}
208
209// SetGraduationThreshold updates the live raise target (ugnot, 1 GNOT = 1e6).
210// Protocol/deploy wallet only. Affects open curve launches (remaining raise / ready).
211func SetGraduationThreshold(cur realm, ugnot int64) {
212 requireProtocol(cur)
213 if ugnot <= 0 {
214 panic("pad: graduation must be positive")
215 }
216 old := graduationThreshold()
217 graduationUgnot = ugnot
218 chain.Emit("SetGraduationThreshold",
219 "from", strconv.FormatInt(old, 10),
220 "to", strconv.FormatInt(ugnot, 10),
221 )
222}
223
224// GraduationThresholdLive returns the live raise target (ugnot) for UIs/qeval.
225func GraduationThresholdLive() int64 {
226 return graduationThreshold()
227}
228
229// SetListFeeGns updates Create-time GNS escrow required for new launches.
230// Protocol/deploy wallet only. Does not change already-escrowed launches.
231func SetListFeeGns(cur realm, gns int64) {
232 requireProtocol(cur)
233 if gns < 0 {
234 panic("pad: list fee must be non-negative")
235 }
236 old := requiredListFeeGns()
237 listFeeGnsLive = gns
238 chain.Emit("SetListFeeGns",
239 "from", strconv.FormatInt(old, 10),
240 "to", strconv.FormatInt(gns, 10),
241 )
242}
243
244// creditProtocol accrues protocol ugnot liability on the pad realm.
245// Cash stays in pad until ClaimProtocolFees / PushProtocolFees.
246func creditProtocol(amt int64) {
247 if amt <= 0 {
248 return
249 }
250 protocolFees += amt
251}
252
253// creditBondFee books create-bond bank ugnot as platform fee (not WUGNOT-reserved).
254func creditBondFee(amt int64) {
255 if amt <= 0 {
256 return
257 }
258 protocolBondFees += amt
259}
260
261func requireInit() {
262 if !inited {
263 panic("pad: call Init first")
264 }
265}
266
267// SetPointsEnabled toggles pointsv2 notifications (protocol admin only).
268// pointsv2 must AllowPad(this package path) or OnTrade/OnCreate will panic and revert the trade.
269func SetPointsEnabled(cur realm, on bool) {
270 requireProtocol(cur)
271 pointsEnabled = on
272 chain.Emit("SetPointsEnabled", "on", strconv.FormatBool(on))
273}
274
275// PointsEnabled reports whether pad notifies pointsv2 after trades/creates.
276func PointsEnabled() bool {
277 return pointsEnabled
278}
279
280func notifyTrade(cur realm, trader address, id string, side int64, volumeUgnot int64) {
281 if !pointsEnabled || testSkipBanker {
282 return
283 }
284 _ = pointsv2.OnTrade(cross(cur), trader, id, side, volumeUgnot)
285}
286
287func notifyCreate(cur realm, creator address, id string) {
288 if !pointsEnabled || testSkipBanker {
289 return
290 }
291 _ = pointsv2.OnCreate(cross(cur), creator, id)
292}
293
294func mustLaunch(id string) *Launch {
295 // Sapphire avl.Tree.Get returns a single any (nil if missing).
296 l, ok := launches.Get(id).(*Launch)
297 if !ok {
298 panic("pad: unknown launch")
299 }
300 return l
301}
302
303func balOf(l *Launch, addr address) int64 {
304 if l == nil || l.token == nil {
305 return 0
306 }
307 return l.token.BalanceOf(addr)
308}
309
310// addBal mints (delta>0) or burns (delta<0) GRC20 via pad-owned PrivateLedger.
311func addBal(l *Launch, addr address, delta int64) {
312 if l == nil || l.ledger == nil {
313 panic("pad: missing GRC20 ledger")
314 }
315 if delta == 0 {
316 return
317 }
318 if delta > 0 {
319 if err := l.ledger.Mint(addr, delta); err != nil {
320 panic("pad: mint: " + err.Error())
321 }
322 return
323 }
324 if err := l.ledger.Burn(addr, -delta); err != nil {
325 panic("pad: burn: " + err.Error())
326 }
327}
328
329func requireMinOut(got, minOut int64, what string) {
330 if minOut < 0 {
331 panic("pad: minOut must be non-negative")
332 }
333 if minOut > 0 && got < minOut {
334 panic("pad: " + what + " below minOut (slippage)")
335 }
336}
337
338func snipeBoughtOf(l *Launch, buyer address) int64 {
339 v := l.snipeBought.Get(buyer.String())
340 if v == nil {
341 return 0
342 }
343 n, ok := v.(int64)
344 if !ok {
345 return 0
346 }
347 return n
348}
349
350func checkAndAddSnipe(l *Launch, buyer address, tokensOut int64) {
351 height := runtime.ChainHeight()
352 if height-l.Created >= AntiSnipeHeights {
353 return
354 }
355 maxTok := TotalSupply * AntiSnipeMaxBuyBPS / 10000
356 prev := snipeBoughtOf(l, buyer)
357 if prev+tokensOut > maxTok {
358 panic("pad: anti-snipe cumulative max buy exceeded")
359 }
360 l.snipeBought.Set(buyer.String(), prev+tokensOut)
361}
362
363func sendUgnot(cur realm, to address, amount int64) {
364 if amount <= 0 {
365 return
366 }
367 if testSkipBanker {
368 return
369 }
370 bk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
371 bk.SendCoins(cur.Address(), to, chain.Coins{{Denom: DenomUgnot, Amount: amount}})
372}
373
374func requireUserPayment(cur realm) int64 {
375 if !cur.Previous().IsUserCall() {
376 panic("pad: must be EOA MsgCall")
377 }
378 sent := unsafe.OriginSend().AmountOf(DenomUgnot)
379 if sent <= 0 {
380 panic("pad: need ugnot -send")
381 }
382 return sent
383}
384
385// --- WUGNOT collateral (production curve / internal CPMM) ---
386
387// reservedWugnot is WUGNOT already committed (markets/fees/user prepaid credits).
388// Excludes bank-only liabilities (create bond ugnot).
389func reservedWugnot() int64 {
390 reserved := protocolFees + totalWugnotCredit
391 launches.Iterate("", "", func(_ string, value any) bool {
392 l := value.(*Launch)
393 reserved += l.CreatorFees
394 if l.Status == StatusCurve {
395 reserved += l.RaisedUgnot
396 }
397 // Internal CPMM holds WUGNOT when not listed and seed not released to EOA.
398 if l.Status == StatusGraduated && !l.GnoswapListed && !l.ListSeedReleased {
399 reserved += l.PoolUgnot
400 }
401 return false
402 })
403 return reserved
404}
405
406// freeWugnotOnPad is WUGNOT on pad above reserved commitments.
407// After user wugnot.Transfer(pad, amt), free rises; Buy locks free into caller credit.
408func freeWugnotOnPad() int64 {
409 have := wugnot.BalanceOf(padAddr)
410 free := have - reservedWugnot()
411 if free < 0 {
412 return 0
413 }
414 return free
415}
416
417// FreeWugnot is free|have|reserved|totalCredit for UI preflight.
418func FreeWugnot() string {
419 have := wugnot.BalanceOf(padAddr)
420 res := reservedWugnot()
421 free := have - res
422 if free < 0 {
423 free = 0
424 }
425 return strconv.FormatInt(free, 10) + "|" +
426 strconv.FormatInt(have, 10) + "|" +
427 strconv.FormatInt(res, 10) + "|" +
428 strconv.FormatInt(totalWugnotCredit, 10)
429}
430
431func prepaidOf(addr address) int64 {
432 if !addr.IsValid() {
433 return 0
434 }
435 v := wugnotCredit.Get(addr.String())
436 if v == nil {
437 return 0
438 }
439 n, ok := v.(int64)
440 if !ok {
441 return 0
442 }
443 return n
444}
445
446func addPrepaid(addr address, amount int64) {
447 if amount <= 0 || !addr.IsValid() {
448 return
449 }
450 next := prepaidOf(addr) + amount
451 wugnotCredit.Set(addr.String(), next)
452 totalWugnotCredit += amount
453}
454
455func subPrepaid(addr address, amount int64) {
456 if amount <= 0 {
457 return
458 }
459 have := prepaidOf(addr)
460 if have < amount {
461 panic("pad: prepaid WUGNOT underfunded")
462 }
463 next := have - amount
464 if next == 0 {
465 wugnotCredit.Remove(addr.String())
466 } else {
467 wugnotCredit.Set(addr.String(), next)
468 }
469 totalWugnotCredit -= amount
470}
471
472// PrepaidBalance returns caller's claimable WUGNOT credit on this pad.
473func PrepaidBalance(cur realm) int64 {
474 if !cur.Previous().IsUserCall() {
475 panic("pad: must be EOA MsgCall")
476 }
477 return prepaidOf(cur.Previous().Address())
478}
479
480// PrepaidOf returns claimable credit for an address (read helper).
481func PrepaidOf(addr string) int64 {
482 return prepaidOf(address(addr))
483}
484
485// ClaimWugnot withdraws all of the caller's prepaid WUGNOT credit to their wallet.
486// Safe after Buy overpay or after graduate/list — excess stays claimable until claimed.
487func ClaimWugnot(cur realm) int64 {
488 requireInit()
489 if !cur.Previous().IsUserCall() {
490 panic("pad: must be EOA MsgCall")
491 }
492 user := cur.Previous().Address()
493 amt := prepaidOf(user)
494 if amt <= 0 {
495 return 0
496 }
497 subPrepaid(user, amt)
498 payWugnotOut(cur, user, amt)
499 chain.Emit("ClaimWugnot",
500 "user", user.String(),
501 "amount", strconv.FormatInt(amt, 10),
502 )
503 return amt
504}
505
506// takeWugnotIn collects amount WUGNOT as payment for Buy/SwapBuy.
507//
508// 1) Auto-lock free float into caller's prepaid (user must Transfer to pad first).
509// 2) Spend amount from prepaid (overpay stays as credit via creditRefund).
510// No TransferFrom.
511//
512// Tests (testSkipBanker): OriginSend ugnot as synthetic collateral units.
513func takeWugnotIn(cur realm, amount int64) int64 {
514 if !cur.Previous().IsUserCall() {
515 panic("pad: must be EOA MsgCall")
516 }
517 if testSkipBanker {
518 // Local tests: OriginSend ugnot stands in for WUGNOT units.
519 return requireUserPayment(cur)
520 }
521 if amount <= 0 {
522 panic("pad: amountWugnot must be positive")
523 }
524 user := cur.Previous().Address()
525 have := prepaidOf(user)
526 if have < amount {
527 need := amount - have
528 free := freeWugnotOnPad()
529 if free < need {
530 havePad := wugnot.BalanceOf(padAddr)
531 res := reservedWugnot()
532 panic("pad: Transfer " + strconv.FormatInt(need, 10) +
533 " more WUGNOT to pad then Buy (free=" + strconv.FormatInt(free, 10) +
534 " prepaid=" + strconv.FormatInt(have, 10) +
535 " have=" + strconv.FormatInt(havePad, 10) +
536 " reserved=" + strconv.FormatInt(res, 10) +
537 " needTotal=" + strconv.FormatInt(amount, 10) + ")")
538 }
539 // Lock free into this caller's prepaid (raises reserved, lowers free).
540 addPrepaid(user, need)
541 }
542 subPrepaid(user, amount)
543 return amount
544}
545
546// creditRefund keeps unused Buy WUGNOT as claimable prepaid (not instant wallet refund).
547// User calls ClaimWugnot after buy / after list when convenient.
548func creditRefund(buyer address, amount int64) {
549 if testSkipBanker || amount <= 0 {
550 return
551 }
552 addPrepaid(buyer, amount)
553}
554
555// payWugnotOut sends WUGNOT from pad to user (production).
556// Tests: pay ugnot via banker when testSkipBanker.
557func payWugnotOut(cur realm, to address, amount int64) {
558 if amount <= 0 {
559 return
560 }
561 if testSkipBanker {
562 sendUgnot(cur, to, amount)
563 return
564 }
565 have := wugnot.BalanceOf(cur.Address())
566 if have < amount {
567 amount = have
568 }
569 if amount > 0 {
570 wugnot.Transfer(cross(cur), to, amount)
571 }
572}
573
574func noteBuyer(l *Launch, buyer address) {
575 k := buyer.String()
576 if l.UniqueBuyers.Has(k) {
577 return
578 }
579 l.UniqueBuyers.Set(k, true)
580 l.BuyerCount++
581}
582
583func tradeKey(n int64) string {
584 s := strconv.FormatInt(n, 10)
585 for len(s) < 12 {
586 s = "0" + s
587 }
588 return s
589}
590
591// spotPriceScaled returns ugnot/token * 1e6 from current curve or pool reserves.
592func spotPriceScaled(l *Launch) int64 {
593 if l.Status == StatusGraduated {
594 if l.PoolToken <= 0 {
595 return 0
596 }
597 return l.PoolUgnot * 1000000 / l.PoolToken
598 }
599 if l.VirtualToken <= 0 {
600 return 0
601 }
602 return l.VirtualUgnot * 1000000 / l.VirtualToken
603}
604
605func recordTrade(l *Launch, side int, ugnot, tokens int64) {
606 l.NextTrade++
607 t := &Trade{
608 Height: runtime.ChainHeight(),
609 Side: side,
610 Ugnot: ugnot,
611 Tokens: tokens,
612 Price: spotPriceScaled(l),
613 }
614 l.Trades.Set(tradeKey(l.NextTrade), t)
615 // Ring buffer: drop oldest while over cap.
616 for l.Trades.Size() > MaxTradeHistory {
617 oldest := ""
618 l.Trades.Iterate("", "", func(k string, _ any) bool {
619 oldest = k
620 return true // stop
621 })
622 if oldest == "" {
623 break
624 }
625 l.Trades.Remove(oldest)
626 }
627}
628
629// maybeRefundBond is a no-op: create bond is a platform fee (credited to
630// protocol at Create). Kept so Buy call sites stay stable across cuts.
631func maybeRefundBond(_ realm, _ *Launch) {}
632
633// requiredCreateBond returns ugnot the creator must send.
634// Production: createbond.CurrentBondUgnot() (promo or normal).
635// Unit tests (testSkipBanker): local CreateBondUgnot constant.
636func requiredCreateBond() int64 {
637 if testSkipBanker {
638 return CreateBondUgnot
639 }
640 return createbond.CurrentBondUgnot()
641}
642
643// CreateBondRequired is a public alias for UIs / qeval (same as requiredCreateBond).
644func CreateBondRequired() int64 {
645 return requiredCreateBond()
646}
647
648// requiredListFeeGns returns GNS base units the creator must pre-fund (free on pad).
649// Live value from SetListFeeGns; falls back to ListFeeGns const.
650func requiredListFeeGns() int64 {
651 if listFeeGnsLive > 0 {
652 return listFeeGnsLive
653 }
654 if ListFeeGns > 0 {
655 return ListFeeGns
656 }
657 return 100_000_000
658}
659
660// ListFeeRequired is a public alias for UIs / qeval.
661func ListFeeRequired() int64 {
662 return requiredListFeeGns()
663}
664
665// freeGnsOnPad is GNS on pad above per-launch list-fee escrow.
666func freeGnsOnPad() int64 {
667 if testSkipBanker {
668 // Tests skip GNS inventory checks.
669 return requiredListFeeGns()
670 }
671 have := gns.BalanceOf(padAddr)
672 free := have - totalListFeeGns
673 if free < 0 {
674 return 0
675 }
676 return free
677}
678
679// FreeGns returns free|have|reserved for UI preflight (GNS list fee).
680func FreeGns() string {
681 have := int64(0)
682 if !testSkipBanker {
683 have = gns.BalanceOf(padAddr)
684 }
685 free := freeGnsOnPad()
686 return strconv.FormatInt(free, 10) + "|" +
687 strconv.FormatInt(have, 10) + "|" +
688 strconv.FormatInt(totalListFeeGns, 10)
689}
690
691// lockListFeeEscrow earmarks free GNS for this launch's CreatePool fee.
692func lockListFeeEscrow(l *Launch, amt int64) {
693 if l == nil || amt <= 0 {
694 return
695 }
696 if freeGnsOnPad() < amt {
697 have := int64(0)
698 if !testSkipBanker {
699 have = gns.BalanceOf(padAddr)
700 }
701 panic("pad: Transfer " + strconv.FormatInt(amt, 10) +
702 " GNS to pad then Create (free=" + strconv.FormatInt(freeGnsOnPad(), 10) +
703 " have=" + strconv.FormatInt(have, 10) +
704 " reserved=" + strconv.FormatInt(totalListFeeGns, 10) + ")")
705 }
706 totalListFeeGns += amt
707 l.ListFeeGns = amt
708 l.ListFeeConsumed = false
709}
710
711// listFeeForVenue: gnoswap → ListFeeRequired; zdex / other → 0.
712func listFeeForVenue(venueId string) int64 {
713 if normalizeVenueID(venueId) == VenueGnoswap {
714 return requiredListFeeGns()
715 }
716 return 0
717}
718
719// ListFeeRequiredFor is venue-aware Create fee for UI preflight.
720func ListFeeRequiredFor(venueId string) int64 {
721 ensureListVenuesSeeded()
722 return listFeeForVenue(venueId)
723}
724
725// unlockListFeeEscrow clears launch GNS escrow; refund sends GNS back to creator.
726func unlockListFeeEscrow(cur realm, l *Launch, refund bool) {
727 if l == nil || l.ListFeeGns <= 0 || l.ListFeeConsumed {
728 return
729 }
730 amt := l.ListFeeGns
731 l.ListFeeGns = 0
732 totalListFeeGns -= amt
733 if totalListFeeGns < 0 {
734 totalListFeeGns = 0
735 }
736 if refund && !testSkipBanker && amt > 0 {
737 have := gns.BalanceOf(padAddr)
738 if have < amt {
739 amt = have
740 }
741 if amt > 0 {
742 gns.Transfer(cross(cur), l.Creator, amt)
743 }
744 }
745 chain.Emit("ListFeeUnlocked",
746 "id", l.ID,
747 "amount", strconv.FormatInt(amt, 10),
748 "refund", strconv.FormatBool(refund),
749 )
750}
751
752// syncListFeeForVenue adjusts escrow after PreferredVenue change (pre-list only).
753func syncListFeeForVenue(cur realm, l *Launch, vid string) {
754 if l == nil || l.ListFeeConsumed || l.GnoswapListed || l.ListVenue != "" {
755 return
756 }
757 want := listFeeForVenue(vid)
758 if l.ListFeeGns == want {
759 return
760 }
761 if l.ListFeeGns > 0 {
762 unlockListFeeEscrow(cur, l, true)
763 }
764 lockListFeeEscrow(l, want)
765}
766
767// consumeListFeeEscrow clears launch escrow after successful Gnoswap list
768// (CreatePool spent feeNeed GNS from pad balance).
769func consumeListFeeEscrow(l *Launch) {
770 if l == nil || l.ListFeeGns <= 0 {
771 return
772 }
773 amt := l.ListFeeGns
774 totalListFeeGns -= amt
775 if totalListFeeGns < 0 {
776 totalListFeeGns = 0
777 }
778 l.ListFeeGns = 0
779 l.ListFeeConsumed = true
780 chain.Emit("ListFeeConsumed", "id", l.ID, "amount", strconv.FormatInt(amt, 10))
781}
782
783// ClaimListFee refunds Create-time GNS escrow to the token creator if still unlisted.
784// Use when graduate/list will not complete or creator abandons listing.
785func ClaimListFee(cur realm, id string) int64 {
786 requireInit()
787 if !cur.Previous().IsUserCall() {
788 panic("pad: must be EOA MsgCall")
789 }
790 l := mustLaunch(id)
791 caller := cur.Previous().Address()
792 if caller != l.Creator {
793 panic("pad: only creator may claim list fee")
794 }
795 if l.GnoswapListed {
796 panic("pad: already listed - list fee spent")
797 }
798 if l.ListFeeConsumed {
799 panic("pad: list fee already consumed")
800 }
801 amt := l.ListFeeGns
802 if amt <= 0 {
803 return 0
804 }
805 l.ListFeeGns = 0
806 totalListFeeGns -= amt
807 if totalListFeeGns < 0 {
808 totalListFeeGns = 0
809 }
810 if !testSkipBanker && amt > 0 {
811 have := gns.BalanceOf(padAddr)
812 if have < amt {
813 amt = have
814 }
815 if amt > 0 {
816 gns.Transfer(cross(cur), l.Creator, amt)
817 }
818 }
819 chain.Emit("ClaimListFee",
820 "id", id,
821 "creator", l.Creator.String(),
822 "amount", strconv.FormatInt(amt, 10),
823 )
824 return amt
825}
826
827// Create deploys a fair-launch meme. Create bond (bond realm / fallback const) is a
828// platform fee credited to protocol immediately (not refundable escrow).
829// Also locks ListFeeGns free GNS when venue requires it (creator Transfer GNS first).
830// No pre-mint; all tradeable float starts on the bonding curve.
831func Create(cur realm, name, symbol, uri string) string {
832 requireInit()
833 sent := requireUserPayment(cur)
834 bondNeed := requiredCreateBond()
835 if bondNeed <= 0 {
836 panic("pad: create bond misconfigured")
837 }
838 if sent < bondNeed {
839 panic("pad: create bond underpaid")
840 }
841 if name == "" || symbol == "" {
842 panic("pad: name and symbol required")
843 }
844 if len(symbol) > 12 {
845 panic("pad: symbol too long")
846 }
847 if bySymbol.Has(symbol) {
848 panic("pad: symbol taken")
849 }
850 extra := sent - bondNeed
851 if extra > 0 {
852 creditProtocol(extra)
853 }
854
855 creator := cur.Previous().Address()
856 id := nextID.Next().String()
857
858 // Real GRC20 bound to this pad realm (mint/burn only via pad ledger).
859 // Decimals=0: whole-token units (matches existing trade amounts).
860 token, ledger := grc20.NewToken(name, symbol, 0, nextTokenID.Next(), cur)
861
862 // Adena (and Gnoswap registries) resolve tokens ONLY via grc20reg under key
863 // packagePath.SYMBOL - Token.ID() itself is packagePath.SYMBOL.seq and is
864 // rejected as "Invalid path" if pasted into Adena without registration.
865 // Skip in unit tests (testSkipBanker); production always registers.
866 regKey := ""
867 if !testSkipBanker {
868 regKey = grc20reg.Register(cross(cur), token, symbol)
869 }
870
871 ensureListVenuesSeeded()
872 pref := DefaultListVenue()
873 return finishCreate(cur, id, name, symbol, uri, creator, token, ledger, regKey, bondNeed, pref)
874}
875
876// CreateWithVenue is Create + PreferredVenue in one MsgCall (fee escrow follows venue).
877// venueId: "zdex" → no GNS list escrow; "gnoswap" → ListFeeRequired GNS.
878func CreateWithVenue(cur realm, name, symbol, uri, venueId string) string {
879 requireInit()
880 sent := requireUserPayment(cur)
881 bondNeed := requiredCreateBond()
882 if bondNeed <= 0 {
883 panic("pad: create bond misconfigured")
884 }
885 if sent < bondNeed {
886 panic("pad: create bond underpaid")
887 }
888 name = strings.TrimSpace(name)
889 symbol = strings.TrimSpace(symbol)
890 uri = strings.TrimSpace(uri)
891 if name == "" || symbol == "" {
892 panic("pad: name and symbol required")
893 }
894 if len(symbol) > 12 {
895 panic("pad: symbol too long")
896 }
897 if bySymbol.Has(symbol) {
898 panic("pad: symbol taken")
899 }
900 extra := sent - bondNeed
901 if extra > 0 {
902 creditProtocol(extra)
903 }
904 creator := cur.Previous().Address()
905 id := nextID.Next().String()
906 token, ledger := grc20.NewToken(name, symbol, 0, nextTokenID.Next(), cur)
907 regKey := ""
908 if !testSkipBanker {
909 regKey = grc20reg.Register(cross(cur), token, symbol)
910 }
911 ensureListVenuesSeeded()
912 vid := normalizeVenueID(venueId)
913 if !venueEnabled(vid) {
914 panic("pad: venue disabled or unknown")
915 }
916 return finishCreate(cur, id, name, symbol, uri, creator, token, ledger, regKey, bondNeed, vid)
917}
918
919func finishCreate(
920 cur realm,
921 id, name, symbol, uri string,
922 creator address,
923 token *grc20.Token,
924 ledger *grc20.PrivateLedger,
925 regKey string,
926 bondNeed int64,
927 pref string,
928) string {
929 l := &Launch{
930 ID: id,
931 Name: name,
932 Symbol: symbol,
933 URI: uri,
934 Creator: creator,
935 Status: StatusCurve,
936 Created: runtime.ChainHeight(),
937 token: token,
938 ledger: ledger,
939 TokenID: token.ID(),
940 VirtualUgnot: VirtualUgnot0,
941 VirtualToken: VirtualToken0,
942 UniqueBuyers: avl.Tree{},
943 snipeBought: avl.Tree{},
944 BondUgnot: 0, // create fee already credited to protocol below
945 BondRefunded: true, // no creator refund path
946 Trades: avl.Tree{},
947 PreferredVenue: pref,
948 }
949 // Create bond = platform fee (bank ugnot, not WUGNOT escrow). Claim via ClaimProtocolFees.
950 if bondNeed > 0 {
951 creditBondFee(bondNeed)
952 chain.Emit("BondFee",
953 "id", id,
954 "amount", strconv.FormatInt(bondNeed, 10),
955 "creator", creator.String(),
956 )
957 }
958 // Venue-aware list fee: zdex → 0 GNS; gnoswap → ListFeeRequired.
959 lockListFeeEscrow(l, listFeeForVenue(pref))
960 recordTrade(l, TradeSideOpen, 0, 0)
961 launches.Set(id, l)
962 bySymbol.Set(symbol, id)
963
964 chain.Emit("Created",
965 "id", id,
966 "symbol", symbol,
967 "creator", creator.String(),
968 "token", l.TokenID,
969 "reg", regKey,
970 "listFeeGns", strconv.FormatInt(l.ListFeeGns, 10),
971 "preferredVenue", pref,
972 "bondFee", strconv.FormatInt(bondNeed, 10),
973 )
974 notifyCreate(cur, creator, id)
975 return id
976}
977
978// AdenaPathOf returns the grc20reg / Adena token key: packagePath.SYMBOL
979// (Token.ID is packagePath.SYMBOL.seq - Adena rejects that form).
980func AdenaPathOf(id string) string {
981 l := mustLaunch(id)
982 return adenaKeyFromTokenID(l.TokenID, l.Symbol)
983}
984
985// adenaKeyFromTokenID strips the trailing .seq from Token.ID when present.
986func adenaKeyFromTokenID(tokenID, symbol string) string {
987 if tokenID == "" {
988 return ""
989 }
990 // Token.ID = packagePath.symbol.seq -> registry key = packagePath.symbol
991 suffix := "." + symbol + "."
992 if i := strings.LastIndex(tokenID, suffix); i >= 0 {
993 // packagePath + "." + symbol
994 return tokenID[:i] + "." + symbol
995 }
996 // Already packagePath.symbol or unknown layout
997 if strings.HasSuffix(tokenID, "."+symbol) {
998 return tokenID
999 }
1000 return tokenID
1001}
1002
1003// maxGrossForNetIn finds largest gross ugnot <= sentMax whose fee-split netIn <= maxNet.
1004func maxGrossForNetIn(maxNet, sentMax int64) int64 {
1005 if maxNet <= 0 || sentMax <= 0 {
1006 return 0
1007 }
1008 lo, hi := int64(0), sentMax
1009 for lo < hi {
1010 mid := (lo + hi + 1) / 2
1011 f := ammmath.ApplyFee(mid, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
1012 net := f.Net + f.Remainder
1013 if net <= maxNet {
1014 lo = mid
1015 } else {
1016 hi = mid - 1
1017 }
1018 }
1019 return lo
1020}
1021
1022// readyToGraduate is true when raise met the threshold, or the entire curve
1023// float is sold (sold-out escape: threshold may be unreachable with bad virtuals).
1024func readyToGraduate(l *Launch) bool {
1025 if l == nil || l.Status != StatusCurve {
1026 return false
1027 }
1028 if l.RaisedUgnot <= 0 {
1029 return false
1030 }
1031 if ammmath.CanGraduate(l.RaisedUgnot, graduationThreshold()) {
1032 return true
1033 }
1034 // Curve exhausted before threshold: still graduate with whatever was raised
1035 // so the market is never permanently stuck on Buy/Graduate.
1036 return l.RealSold >= CurveSupply
1037}
1038
1039// Buy spends WUGNOT on the bonding curve; credits tokens.
1040// amountWugnot: max to spend (may overpay). UI: Deposit + Transfer(pad, amt) + Buy.
1041// Overpay stays as claimable prepaid — ClaimWugnot anytime (incl. after list).
1042// minTokensOut: slippage floor (0 = disabled). Auto-graduates at threshold or sold-out.
1043//
1044// Production: collateral is real WUGNOT on pad -> Graduate can auto-list Gnoswap.
1045// Tests (testSkipBanker): amountWugnot ignored; OriginSend ugnot is used as units.
1046//
1047// Last-fill (no overshoot):
1048// 1. Cap net so RaisedUgnot never exceeds graduationThreshold() (refund excess WUGNOT).
1049// 2. Cap by remaining curve tokens (CurveSupply - RealSold).
1050//
1051// If the curve is already sold out (or raise already filled), Buy refunds the full
1052// take and graduates when ready - no panic so users are not stuck mid-tx.
1053func Buy(cur realm, id string, amountWugnot, minTokensOut int64) int64 {
1054 requireInit()
1055 sent := takeWugnotIn(cur, amountWugnot)
1056 l := mustLaunch(id)
1057 if l.Status != StatusCurve {
1058 panic("pad: not on curve (use SwapBuy)")
1059 }
1060 buyer := cur.Previous().Address()
1061
1062 remainingTok := CurveSupply - l.RealSold
1063 needRaise := graduationThreshold() - l.RaisedUgnot
1064 // Already complete: refund payment and graduate (sold-out or raise-filled).
1065 if remainingTok <= 0 || needRaise <= 0 {
1066 if !readyToGraduate(l) {
1067 // Edge: zero raise with empty float should not happen in production.
1068 if remainingTok <= 0 {
1069 panic("pad: curve sold out with no raise")
1070 }
1071 panic("pad: raise filled - call Graduate")
1072 }
1073 if sent > 0 {
1074 // Keep as claimable credit (ClaimWugnot) — safe after graduate/list too.
1075 creditRefund(buyer, sent)
1076 chain.Emit("BuyRefund",
1077 "id", id,
1078 "buyer", buyer.String(),
1079 "refund", strconv.FormatInt(sent, 10),
1080 "toCredit", "1",
1081 )
1082 }
1083 graduate(cur, l)
1084 return 0
1085 }
1086
1087 usedGross := sent
1088 fee := ammmath.ApplyFee(usedGross, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
1089 // Net enters curve; remainder boosts virtual ugnot (stays as collateral).
1090 netIn := fee.Net + fee.Remainder
1091
1092 // Max net allowed: min(user net, remaining raise, remaining tokens).
1093 maxNet := netIn
1094 if maxNet > needRaise {
1095 maxNet = needRaise
1096 }
1097 maxNetTok := ammmath.MaxNetInForTokenOut(l.VirtualUgnot, l.VirtualToken, remainingTok)
1098 if maxNetTok > 0 && maxNet > maxNetTok {
1099 maxNet = maxNetTok
1100 }
1101 if maxNet <= 0 {
1102 panic("pad: no fill capacity remaining")
1103 }
1104
1105 // Clamp gross + recompute fee when caps bind (last-fill refund path).
1106 if maxNet < netIn {
1107 usedGross = maxGrossForNetIn(maxNet, sent)
1108 if usedGross <= 0 {
1109 panic("pad: buy too small for remaining fill")
1110 }
1111 fee = ammmath.ApplyFee(usedGross, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
1112 netIn = fee.Net + fee.Remainder
1113 if netIn > maxNet {
1114 netIn = maxNet
1115 }
1116 }
1117
1118 tokensOut, newVU, newVT := ammmath.BuyTokens(l.VirtualUgnot, l.VirtualToken, netIn)
1119 // Integer edge: step down net until tokens <= remaining curve supply.
1120 for tokensOut > remainingTok && netIn > 1 {
1121 netIn--
1122 tokensOut, newVU, newVT = ammmath.BuyTokens(l.VirtualUgnot, l.VirtualToken, netIn)
1123 }
1124 if tokensOut > remainingTok || tokensOut <= 0 {
1125 panic("pad: cannot fill remaining curve supply")
1126 }
1127 // If net was reduced further, shrink usedGross so refund is correct.
1128 if netIn < maxNet || usedGross < sent {
1129 // Re-derive gross that yields this netIn (<= sent).
1130 g2 := maxGrossForNetIn(netIn, sent)
1131 if g2 > 0 && g2 < usedGross {
1132 usedGross = g2
1133 fee = ammmath.ApplyFee(usedGross, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
1134 // Keep curve netIn as simulated (may be slightly below fee.Net+Rem).
1135 }
1136 }
1137 // Hard safety: never overshoot graduation raise after this buy.
1138 if l.RaisedUgnot+netIn > graduationThreshold() {
1139 netIn = graduationThreshold() - l.RaisedUgnot
1140 if netIn <= 0 {
1141 panic("pad: raise filled - call Graduate")
1142 }
1143 tokensOut, newVU, newVT = ammmath.BuyTokens(l.VirtualUgnot, l.VirtualToken, netIn)
1144 for tokensOut > remainingTok && netIn > 1 {
1145 netIn--
1146 tokensOut, newVU, newVT = ammmath.BuyTokens(l.VirtualUgnot, l.VirtualToken, netIn)
1147 }
1148 if tokensOut <= 0 {
1149 panic("pad: cannot fill remaining raise")
1150 }
1151 usedGross = maxGrossForNetIn(netIn, sent)
1152 if usedGross <= 0 {
1153 panic("pad: buy too small for remaining raise")
1154 }
1155 fee = ammmath.ApplyFee(usedGross, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
1156 }
1157
1158 refund := sent - usedGross
1159 if refund > 0 {
1160 // Overpay stays claimable — user ClaimWugnot anytime (incl. after list).
1161 creditRefund(buyer, refund)
1162 }
1163
1164 requireMinOut(tokensOut, minTokensOut, "tokens out")
1165 checkAndAddSnipe(l, buyer, tokensOut)
1166
1167 // Mutate only after all checks pass.
1168 // Fees stay as WUGNOT on pad (liabilities); netIn is raised collateral for LP.
1169 l.CreatorFees += fee.Creator
1170 creditProtocol(fee.Protocol)
1171 l.VirtualUgnot = newVU
1172 l.VirtualToken = newVT
1173 l.RealSold += tokensOut
1174 l.RaisedUgnot += netIn
1175 // Invariant: raise never exceeds threshold after Buy.
1176 if l.RaisedUgnot > graduationThreshold() {
1177 panic("pad: raise overshoot invariant")
1178 }
1179
1180 addBal(l, buyer, tokensOut)
1181 noteBuyer(l, buyer)
1182 maybeRefundBond(cur, l)
1183 recordTrade(l, TradeSideBuy, usedGross, tokensOut)
1184
1185 chain.Emit("Buy",
1186 "id", id,
1187 "buyer", buyer.String(),
1188 "ugnot", strconv.FormatInt(usedGross, 10),
1189 "wugnot", strconv.FormatInt(usedGross, 10),
1190 "tokens", strconv.FormatInt(tokensOut, 10),
1191 )
1192 if refund > 0 {
1193 chain.Emit("BuyRefund",
1194 "id", id,
1195 "buyer", buyer.String(),
1196 "refund", strconv.FormatInt(refund, 10),
1197 )
1198 }
1199 notifyTrade(cur, buyer, id, 0, usedGross)
1200
1201 if readyToGraduate(l) {
1202 graduate(cur, l)
1203 }
1204 return tokensOut
1205}
1206
1207// RemainingRaiseUgnot is net ugnot still needed to hit graduationThreshold() (0 if met/over).
1208func RemainingRaiseUgnot(id string) int64 {
1209 l := mustLaunch(id)
1210 if l.Status != StatusCurve {
1211 return 0
1212 }
1213 if l.RaisedUgnot >= graduationThreshold() {
1214 return 0
1215 }
1216 return graduationThreshold() - l.RaisedUgnot
1217}
1218
1219// Sell burns curve tokens and pays WUGNOT (fee on output).
1220// minWugnotOut: slippage floor (0 = disabled). User may wugnot.Withdraw to ugnot.
1221func Sell(cur realm, id string, tokensIn, minWugnotOut int64) int64 {
1222 requireInit()
1223 if !cur.Previous().IsUserCall() {
1224 panic("pad: must be EOA MsgCall")
1225 }
1226 if tokensIn <= 0 {
1227 panic("pad: tokensIn must be positive")
1228 }
1229 l := mustLaunch(id)
1230 if l.Status != StatusCurve {
1231 panic("pad: not on curve (use SwapSell)")
1232 }
1233 seller := cur.Previous().Address()
1234 if balOf(l, seller) < tokensIn {
1235 panic("pad: insufficient token balance")
1236 }
1237
1238 gross, newVU, newVT := ammmath.SellTokens(l.VirtualUgnot, l.VirtualToken, tokensIn)
1239 fee := ammmath.ApplyFeeOnOutput(gross, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
1240 requireMinOut(fee.Net, minWugnotOut, "wugnot out")
1241
1242 // Full gross left virtual reserves; retain fee in virtual ugnot (cash stays in realm).
1243 l.VirtualUgnot = newVU + fee.Fee
1244 l.VirtualToken = newVT
1245 l.RealSold -= tokensIn
1246 if l.RealSold < 0 {
1247 l.RealSold = 0
1248 }
1249
1250 // User receives net; creator+protocol become fee liabilities (leave Raised).
1251 payOut := fee.Net + fee.Creator + fee.Protocol
1252 if l.RaisedUgnot >= payOut {
1253 l.RaisedUgnot -= payOut
1254 } else {
1255 l.RaisedUgnot = 0
1256 }
1257 l.CreatorFees += fee.Creator
1258 creditProtocol(fee.Protocol)
1259
1260 addBal(l, seller, -tokensIn)
1261 payWugnotOut(cur, seller, fee.Net)
1262 recordTrade(l, TradeSideSell, fee.Net, tokensIn)
1263
1264 chain.Emit("Sell",
1265 "id", id,
1266 "seller", seller.String(),
1267 "tokens", strconv.FormatInt(tokensIn, 10),
1268 "ugnot", strconv.FormatInt(fee.Net, 10),
1269 "wugnot", strconv.FormatInt(fee.Net, 10),
1270 )
1271 notifyTrade(cur, seller, id, 1, fee.Net)
1272 return fee.Net
1273}
1274
1275// Graduate permissionlessly moves a ready curve into a permanently locked CPMM.
1276// Ready when RaisedUgnot >= graduationThreshold(), or when RealSold >= CurveSupply
1277// with RaisedUgnot > 0 (sold-out before threshold - escape hatch for unreachable raise).
1278func Graduate(cur realm, id string) {
1279 requireInit()
1280 l := mustLaunch(id)
1281 if l.Status != StatusCurve {
1282 panic("pad: already graduated")
1283 }
1284 if !readyToGraduate(l) {
1285 panic("pad: not ready to graduate (need raise threshold or curve sold out)")
1286 }
1287 graduate(cur, l)
1288}
1289
1290func graduate(cur realm, l *Launch) {
1291 if l.Status != StatusCurve {
1292 return
1293 }
1294 // Liquidity capital = all raised GNOT. Token side is sized to the current
1295 // bonding-curve spot so internal CPMM / Gnoswap open ~ last curve trade
1296 // (seamless graduate). Dumping ALL unsold tokens made DEX spot << curve exit.
1297 poolU := l.RaisedUgnot
1298 if poolU <= 0 {
1299 panic("pad: empty pool ugnot")
1300 }
1301 remaining := TotalSupply - l.RealSold
1302 if remaining <= 0 {
1303 panic("pad: no remaining tokens for liquidity")
1304 }
1305 // tokensForLP = raised * VirtualToken / VirtualUgnot (same units as reserves).
1306 // Use MulDiv: poolU * VT can overflow int64 at mainnet VU0 (~102k GNOT) scale.
1307 poolT := remaining
1308 if l.VirtualUgnot > 0 && l.VirtualToken > 0 && poolU > 0 {
1309 needed := ammmath.MulDiv(poolU, l.VirtualToken, l.VirtualUgnot)
1310 if needed > 0 && needed < remaining {
1311 poolT = needed
1312 }
1313 }
1314
1315 l.PoolUgnot = poolU
1316 l.PoolToken = poolT
1317 l.LeftoverTokens = remaining - poolT
1318 l.RaisedUgnot = 0
1319 l.VirtualUgnot = 0
1320 l.VirtualToken = 0
1321 l.Status = StatusGraduated
1322
1323 // Legacy: any residual BondUgnot (pre-fee-cut launches) → bank bond fee at graduate.
1324 if !l.BondRefunded && l.BondUgnot > 0 {
1325 creditBondFee(l.BondUgnot)
1326 l.BondUgnot = 0
1327 l.BondRefunded = true
1328 }
1329
1330 // Mark graduation on chart at pool spot (matches curve exit when sized above).
1331 recordTrade(l, TradeSideOpen, poolU, poolT)
1332
1333 // NEVER auto-list inside Buy/Graduate.
1334 // Gnoswap CreatePool/Mint does WUGNOT Approve+TransferFrom; realm spender
1335 // frame often panics "insufficient allowance" and REVERTS the entire Buy
1336 // (including the curve fill that triggered graduation). List is a separate
1337 // EOA call: pre-fund pad WUGNOT/GNS then RetryListGnoswap.
1338 // Unit tests may still force list via testForceGnoswapList.
1339 l.GnoswapReady = true
1340 listed := false
1341 if testForceGnoswapList {
1342 listed = listOnGnoswapWithFunding(cur, l, poolU, poolT)
1343 }
1344 if !listed {
1345 // Internal CPMM: PoolToken is pad-accounting reserve (not minted GRC20).
1346 // Circulating = user balances; pool side is virtual reserve PoolToken.
1347 if l.GnoswapNote == "" {
1348 if l.PreferredVenue == VenueZdex {
1349 l.GnoswapNote = "internal CPMM; ReleaseListSeed → EOA zdex CreatePool → ConfirmZdexList"
1350 } else {
1351 l.GnoswapNote = "internal CPMM; Transfer WUGNOT+GNS to pad then RetryListGnoswap"
1352 }
1353 }
1354 chain.Emit("Graduated",
1355 "id", l.ID,
1356 "poolUgnot", strconv.FormatInt(poolU, 10),
1357 "poolToken", strconv.FormatInt(poolT, 10),
1358 "token", l.TokenID,
1359 "gnoswap_listed", "0",
1360 )
1361 return
1362 }
1363 // Listed on Gnoswap (test-only path): capital is in the CL position (NFT owned by pad).
1364 // Internal SwapBuy/Sell disabled (PoolUgnot/PoolToken kept as listing record).
1365 chain.Emit("Graduated",
1366 "id", l.ID,
1367 "poolUgnot", strconv.FormatInt(poolU, 10),
1368 "poolToken", strconv.FormatInt(poolT, 10),
1369 "token", l.TokenID,
1370 "gnoswap_listed", "1",
1371 "poolPath", l.GnoswapPoolPath,
1372 "positionId", strconv.FormatUint(l.GnoswapPositionID, 10),
1373 )
1374}
1375
1376// SetPreferredVenue sets the DEX target used when RetryList(id, "") is called.
1377// Creator or protocol only; locked once listed. Syncs Create-time GNS escrow
1378// (gnoswap locks ListFeeRequired; zdex unlocks/refunds escrow).
1379func SetPreferredVenue(cur realm, id, venueId string) {
1380 requireInit()
1381 if !cur.Previous().IsUserCall() {
1382 panic("pad: must be EOA MsgCall")
1383 }
1384 l := mustLaunch(id)
1385 caller := cur.Previous().Address()
1386 if caller != l.Creator && caller != protocolAddr {
1387 panic("pad: creator or protocol only")
1388 }
1389 if l.GnoswapListed || l.ListVenue != "" {
1390 panic("pad: already listed")
1391 }
1392 ensureListVenuesSeeded()
1393 vid := normalizeVenueID(venueId)
1394 if !venueEnabled(vid) {
1395 panic("pad: venue disabled or unknown")
1396 }
1397 l.PreferredVenue = vid
1398 syncListFeeForVenue(cur, l, vid)
1399 chain.Emit("SetPreferredVenue",
1400 "id", id,
1401 "venue", vid,
1402 "listFeeGns", strconv.FormatInt(l.ListFeeGns, 10),
1403 )
1404}
1405
1406// PreferredVenueOf returns the Create-time / SetPreferredVenue target.
1407func PreferredVenueOf(id string) string {
1408 return mustLaunch(id).PreferredVenue
1409}
1410
1411// ListSeedReleasedOf reports whether ReleaseListSeed already ran.
1412func ListSeedReleasedOf(id string) bool {
1413 return mustLaunch(id).ListSeedReleased
1414}
1415
1416// ReleaseListSeed sends graduated internal-CPMM seed to the creator for EOA zdex CreatePool:
1417// - PoolUgnot as WUGNOT Transfer to creator (EOA may wugnot.Withdraw → ugnot OriginSend)
1418// - PoolToken minted as GRC20 to creator (was pad-accounting only)
1419// PoolUgnot/PoolToken kept as listing-size record; reservedWugnot skips via ListSeedReleased.
1420// Disables internal SwapBuy/Sell. Creator or protocol may call; assets always go to creator.
1421func ReleaseListSeed(cur realm, id string) {
1422 requireInit()
1423 if !cur.Previous().IsUserCall() {
1424 panic("pad: must be EOA MsgCall")
1425 }
1426 l := mustLaunch(id)
1427 caller := cur.Previous().Address()
1428 if caller != l.Creator && caller != protocolAddr {
1429 panic("pad: creator or protocol only")
1430 }
1431 if l.Status != StatusGraduated {
1432 panic("pad: not graduated")
1433 }
1434 if l.GnoswapListed || l.ListVenue != "" {
1435 panic("pad: already listed")
1436 }
1437 if l.ListSeedReleased {
1438 panic("pad: list seed already released")
1439 }
1440 poolU := l.PoolUgnot
1441 poolT := l.PoolToken
1442 if poolU <= 0 || poolT <= 0 {
1443 panic("pad: empty internal pool")
1444 }
1445 // Fail-closed: must fully fund creator before killing internal CPMM.
1446 if !testSkipBanker {
1447 have := wugnot.BalanceOf(padAddr)
1448 if have < poolU {
1449 panic("pad: underfunded WUGNOT for list seed release")
1450 }
1451 }
1452 // Mark released before Transfer so reservedWugnot drops in same tx.
1453 // Keep PoolUgnot/PoolToken as listing-size record (reservedWugnot skips via ListSeedReleased).
1454 l.ListSeedReleased = true
1455 l.LiqWugnotUsed = poolU
1456 l.GnoswapNote = "list seed released to creator for zdex CreatePool; then ConfirmZdexList"
1457 if !testSkipBanker {
1458 wugnot.Transfer(cross(cur), l.Creator, poolU)
1459 } else {
1460 payWugnotOut(cur, l.Creator, poolU)
1461 }
1462 addBal(l, l.Creator, poolT)
1463 chain.Emit("ReleaseListSeed",
1464 "id", id,
1465 "to", l.Creator.String(),
1466 "wugnot", strconv.FormatInt(poolU, 10),
1467 "tokens", strconv.FormatInt(poolT, 10),
1468 )
1469}
1470
1471// ConfirmZdexList marks a launch listed on zdex after EOA CreatePool succeeded.
1472// poolId e.g. "ugnot|SYMBOL" (must be non-empty; no on-chain pool verify).
1473// Creator or protocol. Prefer ReleaseListSeed first when pad still holds LP seed.
1474func ConfirmZdexList(cur realm, id, poolId string) {
1475 requireInit()
1476 if !cur.Previous().IsUserCall() {
1477 panic("pad: must be EOA MsgCall")
1478 }
1479 l := mustLaunch(id)
1480 caller := cur.Previous().Address()
1481 if caller != l.Creator && caller != protocolAddr {
1482 panic("pad: creator or protocol only")
1483 }
1484 if l.Status != StatusGraduated {
1485 panic("pad: not graduated")
1486 }
1487 if l.GnoswapListed || l.ListVenue != "" {
1488 panic("pad: already listed")
1489 }
1490 // Avoid marking listed while pad still holds unreleased LP capital.
1491 if !l.ListSeedReleased && (l.PoolUgnot > 0 || l.PoolToken > 0) {
1492 panic("pad: ReleaseListSeed first (or empty pool)")
1493 }
1494 poolId = strings.TrimSpace(poolId)
1495 if poolId == "" {
1496 panic("pad: empty poolId")
1497 }
1498 l.ListVenue = VenueZdex
1499 l.GnoswapListed = true // UI/compat: "listed on DEX"
1500 l.GnoswapPoolPath = poolId
1501 l.GnoswapPositionID = 0
1502 if l.LiqWugnotUsed == 0 {
1503 l.LiqWugnotUsed = l.PoolUgnot
1504 }
1505 l.GnoswapNote = "confirmed zdex list; pool=" + poolId
1506 chain.Emit("ConfirmZdexList",
1507 "id", id,
1508 "poolPath", poolId,
1509 "symbol", l.Symbol,
1510 "venue", VenueZdex,
1511 )
1512 chain.Emit("ListedRetry",
1513 "id", id,
1514 "venue", VenueZdex,
1515 "poolPath", poolId,
1516 "positionId", "0",
1517 "poolUgnot", strconv.FormatInt(l.PoolUgnot, 10),
1518 "poolToken", strconv.FormatInt(l.PoolToken, 10),
1519 )
1520}
1521
1522// RetryList lists a graduated launch on the chosen venue.
1523// Permissionless EOA MsgCall. Uses PoolUgnot / PoolToken (curve-spot sized).
1524// venueId: empty → PreferredVenue → DefaultListVenue(); unknown/disabled → soft-fail note.
1525func RetryList(cur realm, id, venueId string) bool {
1526 requireInit()
1527 if !cur.Previous().IsUserCall() {
1528 panic("pad: must be EOA MsgCall")
1529 }
1530 l := mustLaunch(id)
1531 if l.Status != StatusGraduated {
1532 panic("pad: not graduated")
1533 }
1534 if l.GnoswapListed || l.ListVenue != "" {
1535 panic("pad: already listed")
1536 }
1537 if l.ListSeedReleased {
1538 panic("pad: list seed released - use ConfirmZdexList after EOA CreatePool")
1539 }
1540 poolU := l.PoolUgnot
1541 poolT := l.PoolToken
1542 if poolU <= 0 || poolT <= 0 {
1543 panic("pad: empty internal pool")
1544 }
1545 raw := strings.TrimSpace(venueId)
1546 if raw == "" && l.PreferredVenue != "" {
1547 raw = l.PreferredVenue
1548 }
1549 vid := normalizeVenueID(raw)
1550 ok := tryListVenue(cur, l, vid, poolU, poolT)
1551 if ok {
1552 chain.Emit("ListedRetry",
1553 "id", l.ID,
1554 "venue", l.ListVenue,
1555 "poolPath", l.GnoswapPoolPath,
1556 "positionId", strconv.FormatUint(l.GnoswapPositionID, 10),
1557 "poolUgnot", strconv.FormatInt(poolU, 10),
1558 "poolToken", strconv.FormatInt(poolT, 10),
1559 )
1560 // Compat event name for indexers that listen for GnoswapListedRetry.
1561 if l.ListVenue == VenueGnoswap {
1562 chain.Emit("GnoswapListedRetry",
1563 "id", l.ID,
1564 "poolPath", l.GnoswapPoolPath,
1565 "positionId", strconv.FormatUint(l.GnoswapPositionID, 10),
1566 "poolUgnot", strconv.FormatInt(poolU, 10),
1567 "poolToken", strconv.FormatInt(poolT, 10),
1568 )
1569 }
1570 } else if l.GnoswapNote == "" {
1571 l.GnoswapNote = "retry list failed - check ListNeed / ListNeedFor; venue=" + vid
1572 }
1573 return ok
1574}
1575
1576// RetryListGnoswap is the Gnoswap-specific wrapper (compat for existing UI).
1577func RetryListGnoswap(cur realm, id string) bool {
1578 return RetryList(cur, id, VenueGnoswap)
1579}
1580
1581// TokenIDOf returns the GRC20 Token.ID() for a launch.
1582func TokenIDOf(id string) string {
1583 return mustLaunch(id).TokenID
1584}
1585
1586// GRC20Bank returns the underlying *grc20.Token for interop (metadata / external DEX).
1587// Does not expose PrivateLedger - mint/burn stay pad-only.
1588func GRC20Bank(id string) *grc20.Token {
1589 l := mustLaunch(id)
1590 if l.token == nil {
1591 panic("pad: no token")
1592 }
1593 return l.token
1594}
1595
1596// SwapBuy buys tokens from the graduated internal pool with WUGNOT.
1597// amountWugnot: max to spend (Approve pad). minTokensOut: slippage (0 = off).
1598// Disabled when listed on Gnoswap (trade via router).
1599func SwapBuy(cur realm, id string, amountWugnot, minTokensOut int64) int64 {
1600 requireInit()
1601 sent := takeWugnotIn(cur, amountWugnot)
1602 l := mustLaunch(id)
1603 if l.Status != StatusGraduated {
1604 panic("pad: not graduated (use Buy)")
1605 }
1606 if l.GnoswapListed || l.ListVenue != "" {
1607 panic("pad: listed on DEX - trade via router, not pad SwapBuy")
1608 }
1609 if l.ListSeedReleased {
1610 panic("pad: list seed released - finish zdex CreatePool + ConfirmZdexList")
1611 }
1612 buyer := cur.Previous().Address()
1613
1614 fee := ammmath.ApplyFee(sent, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
1615 tokensOut, newPU, newPT := ammmath.PoolSwapUgnotForToken(
1616 l.PoolUgnot, l.PoolToken, fee.Net, fee.Remainder,
1617 )
1618 requireMinOut(tokensOut, minTokensOut, "tokens out")
1619
1620 // Refund unused gross if pool math used less (rare); fees from sent.
1621 // Pool swap uses fee.Net into pool; full sent stays as fee+pool contribution.
1622 l.CreatorFees += fee.Creator
1623 creditProtocol(fee.Protocol)
1624 l.PoolUgnot = newPU
1625 l.PoolToken = newPT
1626 addBal(l, buyer, tokensOut)
1627 noteBuyer(l, buyer)
1628 recordTrade(l, TradeSideBuy, sent, tokensOut)
1629
1630 chain.Emit("SwapBuy",
1631 "id", id,
1632 "buyer", buyer.String(),
1633 "ugnot", strconv.FormatInt(sent, 10),
1634 "wugnot", strconv.FormatInt(sent, 10),
1635 "tokens", strconv.FormatInt(tokensOut, 10),
1636 )
1637 notifyTrade(cur, buyer, id, 0, sent)
1638 return tokensOut
1639}
1640
1641// SwapSell sells tokens into the graduated pool for WUGNOT.
1642// minWugnotOut: slippage floor (0 = disabled).
1643func SwapSell(cur realm, id string, tokensIn, minWugnotOut int64) int64 {
1644 requireInit()
1645 if !cur.Previous().IsUserCall() {
1646 panic("pad: must be EOA MsgCall")
1647 }
1648 if tokensIn <= 0 {
1649 panic("pad: tokensIn must be positive")
1650 }
1651 l := mustLaunch(id)
1652 if l.Status != StatusGraduated {
1653 panic("pad: not graduated (use Sell)")
1654 }
1655 if l.GnoswapListed || l.ListVenue != "" {
1656 panic("pad: listed on DEX - trade via router, not pad SwapSell")
1657 }
1658 if l.ListSeedReleased {
1659 panic("pad: list seed released - finish zdex CreatePool + ConfirmZdexList")
1660 }
1661 seller := cur.Previous().Address()
1662 if balOf(l, seller) < tokensIn {
1663 panic("pad: insufficient token balance")
1664 }
1665
1666 gross, newPU, newPT := ammmath.PoolSwapTokenForUgnot(l.PoolUgnot, l.PoolToken, tokensIn)
1667 fee := ammmath.ApplyFeeOnOutput(gross, FeeBPS, CreatorFeeShareBPS, ProtocolFeeShareBPS)
1668 requireMinOut(fee.Net, minWugnotOut, "wugnot out")
1669
1670 // Retain fee in pool ugnot (cash stays); user gets net WUGNOT.
1671 l.PoolUgnot = newPU + fee.Fee
1672 l.PoolToken = newPT
1673 l.CreatorFees += fee.Creator
1674 creditProtocol(fee.Protocol)
1675
1676 addBal(l, seller, -tokensIn)
1677 payWugnotOut(cur, seller, fee.Net)
1678 recordTrade(l, TradeSideSell, fee.Net, tokensIn)
1679
1680 chain.Emit("SwapSell",
1681 "id", id,
1682 "seller", seller.String(),
1683 "tokens", strconv.FormatInt(tokensIn, 10),
1684 "ugnot", strconv.FormatInt(fee.Net, 10),
1685 "wugnot", strconv.FormatInt(fee.Net, 10),
1686 )
1687 notifyTrade(cur, seller, id, 1, fee.Net)
1688 return fee.Net
1689}
1690
1691// Transfer moves GRC20 tokens between addresses (user-initiated).
1692func Transfer(cur realm, id string, to address, amount int64) {
1693 requireInit()
1694 if !cur.Previous().IsUserCall() {
1695 panic("pad: must be EOA MsgCall")
1696 }
1697 if amount <= 0 {
1698 panic("pad: amount must be positive")
1699 }
1700 if !to.IsValid() {
1701 panic("pad: invalid to")
1702 }
1703 l := mustLaunch(id)
1704 from := cur.Previous().Address()
1705 if from == to {
1706 panic("pad: self transfer")
1707 }
1708 if l.ledger == nil {
1709 panic("pad: no GRC20 ledger")
1710 }
1711 if err := l.ledger.Transfer(from, to, amount); err != nil {
1712 panic("pad: transfer: " + err.Error())
1713 }
1714 chain.Emit("Transfer", "id", id, "from", from.String(), "to", to.String(),
1715 "amount", strconv.FormatInt(amount, 10))
1716}
1717
1718// Approve sets GRC20 allowance so DEX/contracts can TransferFrom.
1719func Approve(cur realm, id string, spender address, amount int64) {
1720 requireInit()
1721 if !cur.Previous().IsUserCall() {
1722 panic("pad: must be EOA MsgCall")
1723 }
1724 if !spender.IsValid() {
1725 panic("pad: invalid spender")
1726 }
1727 l := mustLaunch(id)
1728 if l.ledger == nil {
1729 panic("pad: no GRC20 ledger")
1730 }
1731 owner := cur.Previous().Address()
1732 if err := l.ledger.Approve(owner, spender, amount); err != nil {
1733 panic("pad: approve: " + err.Error())
1734 }
1735 chain.Emit("Approval", "id", id, "owner", owner.String(), "spender", spender.String(),
1736 "amount", strconv.FormatInt(amount, 10))
1737}
1738
1739// TransferFrom spends allowance: spender = MsgCall EOA caller.
1740// Enables DEX / routers that hold allowance from Approve.
1741func TransferFrom(cur realm, id string, from, to address, amount int64) {
1742 requireInit()
1743 if !cur.Previous().IsUserCall() {
1744 panic("pad: must be EOA MsgCall")
1745 }
1746 if amount <= 0 {
1747 panic("pad: amount must be positive")
1748 }
1749 if !from.IsValid() || !to.IsValid() {
1750 panic("pad: invalid address")
1751 }
1752 if from == to {
1753 panic("pad: self transfer")
1754 }
1755 l := mustLaunch(id)
1756 if l.ledger == nil {
1757 panic("pad: no GRC20 ledger")
1758 }
1759 spender := cur.Previous().Address()
1760 if err := l.ledger.TransferFrom(from, spender, to, amount); err != nil {
1761 panic("pad: transferFrom: " + err.Error())
1762 }
1763 chain.Emit("TransferFrom", "id", id, "from", from.String(), "to", to.String(),
1764 "spender", spender.String(), "amount", strconv.FormatInt(amount, 10))
1765}
1766
1767// ClaimCreatorFees withdraws accrued creator fees for a launch.
1768// Only the token creator may claim. Fees stay on pad until claimed.
1769func ClaimCreatorFees(cur realm, id string) int64 {
1770 requireInit()
1771 if !cur.Previous().IsUserCall() {
1772 panic("pad: must be EOA MsgCall")
1773 }
1774 l := mustLaunch(id)
1775 caller := cur.Previous().Address()
1776 if caller != l.Creator {
1777 panic("pad: not creator")
1778 }
1779 amt := l.CreatorFees
1780 if amt <= 0 {
1781 return 0
1782 }
1783 l.CreatorFees = 0
1784 // Production: fees accrue as WUGNOT on pad. Tests pay ugnot via payWugnotOut.
1785 payWugnotOut(cur, caller, amt)
1786 chain.Emit("ClaimCreator", "id", id, "amount", strconv.FormatInt(amt, 10))
1787 return amt
1788}
1789
1790// payoutProtocolFees sends all pending protocol fees to protocolAddr.
1791// Trade fees (WUGNOT) + create BondFee (bank ugnot). Shared by Claim/Push.
1792func payoutProtocolFees(cur realm) int64 {
1793 wAmt := protocolFees
1794 bAmt := protocolBondFees
1795 amt := wAmt + bAmt
1796 if amt <= 0 {
1797 return 0
1798 }
1799 if !protocolAddr.IsValid() {
1800 panic("pad: protocol address unset")
1801 }
1802 protocolFees = 0
1803 protocolBondFees = 0
1804 protocolFeesPaid += amt
1805 if wAmt > 0 {
1806 payWugnotOut(cur, protocolAddr, wAmt)
1807 }
1808 if bAmt > 0 {
1809 sendUgnot(cur, protocolAddr, bAmt)
1810 }
1811 chain.Emit("ClaimProtocol",
1812 "to", protocolAddr.String(),
1813 "amount", strconv.FormatInt(amt, 10),
1814 "wugnot", strconv.FormatInt(wAmt, 10),
1815 "bondUgnot", strconv.FormatInt(bAmt, 10),
1816 )
1817 return amt
1818}
1819
1820// ClaimProtocolFees withdraws pending protocol fees to protocolAddr.
1821// Only the current protocol treasury key may call (same wallet that Init'd,
1822// unless TransferProtocol was used).
1823func ClaimProtocolFees(cur realm) int64 {
1824 requireInit()
1825 if !cur.Previous().IsUserCall() {
1826 panic("pad: must be EOA MsgCall")
1827 }
1828 if cur.Previous().Address() != protocolAddr {
1829 panic("pad: not protocol")
1830 }
1831 return payoutProtocolFees(cur)
1832}
1833
1834// PushProtocolFees sends pending protocol fees to protocolAddr.
1835// Permissionless: anyone may call so treasury can be paid without the protocol
1836// key signing (still only pays the configured protocolAddr).
1837func PushProtocolFees(cur realm) int64 {
1838 requireInit()
1839 if !cur.Previous().IsUserCall() {
1840 panic("pad: must be EOA MsgCall")
1841 }
1842 return payoutProtocolFees(cur)
1843}
1844
1845// TransferProtocol rotates the protocol fee recipient (current protocol only).
1846// Pending protocolFees stay on pad until claimed/pushed to the *new* address.
1847func TransferProtocol(cur realm, newAddr address) {
1848 requireInit()
1849 if !cur.Previous().IsUserCall() {
1850 panic("pad: must be EOA MsgCall")
1851 }
1852 if cur.Previous().Address() != protocolAddr {
1853 panic("pad: not protocol")
1854 }
1855 if !newAddr.IsValid() {
1856 panic("pad: invalid new protocol address")
1857 }
1858 if newAddr == protocolAddr {
1859 panic("pad: same protocol address")
1860 }
1861 old := protocolAddr
1862 protocolAddr = newAddr
1863 chain.Emit("TransferProtocol", "from", old.String(), "to", newAddr.String())
1864}
1865
1866// ProtocolAddress returns the current protocol treasury address (bech32).
1867func ProtocolAddress() string {
1868 return protocolAddr.String()
1869}
1870
1871// ProtocolFeesPaid returns lifetime ugnot already paid out to the treasury.
1872func ProtocolFeesPaid() int64 {
1873 return protocolFeesPaid
1874}
1875
1876// FeeInfo returns protocolAddr|pendingUgnot|paidUgnot for UIs.
1877func FeeInfo() string {
1878 return protocolAddr.String() + "|" +
1879 strconv.FormatInt(protocolFees+protocolBondFees, 10) + "|" +
1880 strconv.FormatInt(protocolFeesPaid, 10)
1881}
1882
1883// PadAddress returns this pad realm's bech32 package address (fund WUGNOT here).
1884func PadAddress() string {
1885 return padAddr.String()
1886}
1887
1888// AdminInfo is a single-line dashboard snapshot for the ops UI:
1889//
1890// protocolAddr|pendingFees|paidFees|reservedUgnot|launchCount|pointsOn|inited|padAddr
1891//
1892// pointsOn/inited are 0|1.
1893func AdminInfo() string {
1894 pts := "0"
1895 if pointsEnabled {
1896 pts = "1"
1897 }
1898 ini := "0"
1899 if inited {
1900 ini = "1"
1901 }
1902 return protocolAddr.String() + "|" +
1903 strconv.FormatInt(protocolFees+protocolBondFees, 10) + "|" +
1904 strconv.FormatInt(protocolFeesPaid, 10) + "|" +
1905 strconv.FormatInt(reservedUgnot(), 10) + "|" +
1906 strconv.Itoa(launches.Size()) + "|" +
1907 pts + "|" +
1908 ini + "|" +
1909 padAddr.String()
1910}
1911
1912// IsProtocol reports whether addr is the current treasury (for UI gating).
1913func IsProtocol(addr string) bool {
1914 if !inited || !protocolAddr.IsValid() {
1915 return false
1916 }
1917 return protocolAddr.String() == addr
1918}
1919
1920// reservedUgnot is bank ugnot the pad must keep (create BondFee + legacy BondUgnot).
1921// Curve / CPMM / trade protocolFees are WUGNOT — see reservedWugnot.
1922func reservedUgnot() int64 {
1923 reserved := protocolBondFees
1924 launches.Iterate("", "", func(_ string, value any) bool {
1925 l := value.(*Launch)
1926 if !l.BondRefunded {
1927 reserved += l.BondUgnot
1928 }
1929 return false
1930 })
1931 return reserved
1932}
1933
1934// ReservedUgnot is ugnot the pad must keep for markets + pending claims.
1935func ReservedUgnot() int64 {
1936 return reservedUgnot()
1937}
1938
1939// freeUgnot reports bank ugnot above reserved liabilities (0 if short/test).
1940func freeUgnot(cur realm) int64 {
1941 if testSkipBanker {
1942 return 0
1943 }
1944 bk := banker.NewBanker(banker.BankerTypeReadonly, cur)
1945 bal := bk.GetCoins(cur.Address()).AmountOf(DenomUgnot)
1946 free := bal - reservedUgnot()
1947 if free < 0 {
1948 return 0
1949 }
1950 return free
1951}
1952
1953// WithdrawProtocolUgnot lets the treasury pull free ugnot from the pad bank
1954// (e.g. raised backlog after Gnoswap list, to re-wrap as WUGNOT inventory).
1955// Capped by free balance; panics if amount > free.
1956func WithdrawProtocolUgnot(cur realm, amount int64) int64 {
1957 requireInit()
1958 if !cur.Previous().IsUserCall() {
1959 panic("pad: must be EOA MsgCall")
1960 }
1961 if cur.Previous().Address() != protocolAddr {
1962 panic("pad: not protocol")
1963 }
1964 if amount <= 0 {
1965 panic("pad: amount must be positive")
1966 }
1967 free := freeUgnot(cur)
1968 if amount > free {
1969 panic("pad: amount exceeds free ugnot (reserved for markets/fees)")
1970 }
1971 sendUgnot(cur, protocolAddr, amount)
1972 chain.Emit("WithdrawProtocolUgnot",
1973 "to", protocolAddr.String(),
1974 "amount", strconv.FormatInt(amount, 10),
1975 "freeLeft", strconv.FormatInt(free-amount, 10),
1976 )
1977 return amount
1978}
1979
1980// --- read helpers (non-crossing) ---
1981
1982func BalanceOf(id string, owner address) int64 {
1983 return balOf(mustLaunch(id), owner)
1984}
1985
1986// Allowance returns how many tokens `spender` may TransferFrom from `owner`
1987// (same GRC20 semantics as wugnot/gns.Allowance, keyed by launch id).
1988func Allowance(id string, owner, spender address) int64 {
1989 l := mustLaunch(id)
1990 if l.token == nil {
1991 return 0
1992 }
1993 if !owner.IsValid() || !spender.IsValid() {
1994 return 0
1995 }
1996 return l.token.Allowance(owner, spender)
1997}
1998
1999// ListBuyers returns unique buyer addresses (one per line), capped for query size.
2000// Only addresses that bought at least once on this pad (UniqueBuyers). Not full GRC20 holders
2001// who received tokens via transfer.
2002func ListBuyers(id string) string {
2003 l := mustLaunch(id)
2004 const maxN = 100
2005 out := ""
2006 n := 0
2007 l.UniqueBuyers.Iterate("", "", func(key string, _ any) bool {
2008 if n >= maxN {
2009 return true
2010 }
2011 if out != "" {
2012 out += "\n"
2013 }
2014 out += key
2015 n++
2016 return false
2017 })
2018 return out
2019}
2020
2021func GetStatus(id string) int {
2022 return mustLaunch(id).Status
2023}
2024
2025func GetRaised(id string) int64 {
2026 return mustLaunch(id).RaisedUgnot
2027}
2028
2029func GetPool(id string) (ugnot, token int64) {
2030 l := mustLaunch(id)
2031 return l.PoolUgnot, l.PoolToken
2032}
2033
2034func GetCreatorFees(id string) int64 {
2035 return mustLaunch(id).CreatorFees
2036}
2037
2038func ProtocolFees() int64 {
2039 return protocolFees + protocolBondFees
2040}
2041
2042// ProtocolBondFees returns pending create-bond bank ugnot (subset of ProtocolFees).
2043func ProtocolBondFees() int64 {
2044 return protocolBondFees
2045}
2046
2047func LaunchCount() int {
2048 return launches.Size()
2049}
2050
2051func ResolveSymbol(symbol string) string {
2052 s, ok := bySymbol.Get(symbol).(string)
2053 if !ok {
2054 return ""
2055 }
2056 return s
2057}
2058
2059// ListIDs returns newline-separated launch IDs (sorted by AVL key / creation order).
2060func ListIDs() string {
2061 out := ""
2062 launches.Iterate("", "", func(key string, _ any) bool {
2063 if out != "" {
2064 out += "\n"
2065 }
2066 out += key
2067 return false
2068 })
2069 return out
2070}
2071
2072// LaunchInfo returns a single-line pipe-delimited summary for UIs/indexers:
2073//
2074// id|name|symbol|status|raised|sold|buyers|creatorFees|poolUgnot|poolToken|uri|creator|virtualUgnot|virtualToken|created|tokenID|gnoswapReady|gnoswapListed|gnoswapPoolPath|gnoswapNote|listVenue
2075//
2076// status: 0=curve 1=graduated; gnoswapReady/listed: 0|1
2077// gnoswapNote: optional (padv12+); pipes stripped for delimiter safety.
2078// listVenue: optional (padv23+); empty if unlisted.
2079func LaunchInfo(id string) string {
2080 l := mustLaunch(id)
2081 gs := "0"
2082 if l.GnoswapReady {
2083 gs = "1"
2084 }
2085 gl := "0"
2086 if l.GnoswapListed {
2087 gl = "1"
2088 }
2089 note := strings.ReplaceAll(l.GnoswapNote, "|", "/")
2090 venue := strings.ReplaceAll(l.ListVenue, "|", "/")
2091 return l.ID + "|" +
2092 l.Name + "|" +
2093 l.Symbol + "|" +
2094 strconv.Itoa(l.Status) + "|" +
2095 strconv.FormatInt(l.RaisedUgnot, 10) + "|" +
2096 strconv.FormatInt(l.RealSold, 10) + "|" +
2097 strconv.Itoa(l.BuyerCount) + "|" +
2098 strconv.FormatInt(l.CreatorFees, 10) + "|" +
2099 strconv.FormatInt(l.PoolUgnot, 10) + "|" +
2100 strconv.FormatInt(l.PoolToken, 10) + "|" +
2101 l.URI + "|" +
2102 l.Creator.String() + "|" +
2103 strconv.FormatInt(l.VirtualUgnot, 10) + "|" +
2104 strconv.FormatInt(l.VirtualToken, 10) + "|" +
2105 strconv.FormatInt(l.Created, 10) + "|" +
2106 l.TokenID + "|" +
2107 gs + "|" +
2108 gl + "|" +
2109 l.GnoswapPoolPath + "|" +
2110 note + "|" +
2111 venue
2112}
2113
2114// ParamsInfo returns parameters for UI display.
2115// total|curve|poolSeed|gradThreshold|feeBps|createBond|listFeeGns
2116// createBond is live from bond realm when not in unit-test mode.
2117// listFeeGns: Create-time GNS escrow required (padv20+).
2118func ParamsInfo() string {
2119 return strconv.FormatInt(TotalSupply, 10) + "|" +
2120 strconv.FormatInt(CurveSupply, 10) + "|" +
2121 strconv.FormatInt(PoolSeed, 10) + "|" +
2122 strconv.FormatInt(graduationThreshold(), 10) + "|" +
2123 strconv.FormatInt(FeeBPS, 10) + "|" +
2124 strconv.FormatInt(requiredCreateBond(), 10) + "|" +
2125 strconv.FormatInt(requiredListFeeGns(), 10)
2126}
2127
2128// TradeHistory returns newline-separated chart points:
2129//
2130// height|side|ugnot|tokens|priceScaled
2131//
2132// side: 0=buy 1=sell 2=open/graduate. Ordered oldest -> newest.
2133func TradeHistory(id string) string {
2134 l := mustLaunch(id)
2135 out := ""
2136 l.Trades.Iterate("", "", func(_ string, value any) bool {
2137 t := value.(*Trade)
2138 line := strconv.FormatInt(t.Height, 10) + "|" +
2139 strconv.Itoa(t.Side) + "|" +
2140 strconv.FormatInt(t.Ugnot, 10) + "|" +
2141 strconv.FormatInt(t.Tokens, 10) + "|" +
2142 strconv.FormatInt(t.Price, 10)
2143 if out != "" {
2144 out += "\n"
2145 }
2146 out += line
2147 return false
2148 })
2149 return out
2150}
2151
2152// TradeCount returns number of stored chart samples for a launch.
2153func TradeCount(id string) int {
2154 return mustLaunch(id).Trades.Size()
2155}
2156
2157// resetForTest clears package state between unit tests.
2158func resetForTest() {
2159 launches = avl.Tree{}
2160 bySymbol = avl.Tree{}
2161 nextID = 0
2162 nextTokenID = 0
2163 var zero address
2164 protocolAddr = zero
2165 // padAddr is set in package init - do not clear (realm address is fixed).
2166 protocolFees = 0
2167 protocolBondFees = 0
2168 protocolFeesPaid = 0
2169 inited = false
2170 pointsEnabled = false
2171 graduationUgnot = 0
2172 listFeeGnsLive = 0
2173 testSkipBanker = true // unit tests skip banker; integration/chain tests leave false
2174 testForceGnoswapList = false
2175 testForceZdexList = false
2176 totalListFeeGns = 0
2177 totalWugnotCredit = 0
2178 wugnotCredit = avl.Tree{}
2179 resetListVenuesForTest()
2180}