Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

gnoswap_list.gno

18.47 Kb · 602 lines
  1package padv2
  2
  3import (
  4	"chain"
  5	"strconv"
  6	"strings"
  7	"time"
  8
  9	"gno.land/p/gnoswap/consts/v1"
 10	u256 "gno.land/p/gnoswap/uint256/v1"
 11	"gno.land/r/gnoland/wugnot"
 12	"gno.land/r/gnoswap/gns"
 13	gnspool "gno.land/r/gnoswap/pool"
 14	"gno.land/r/gnoswap/position"
 15	"gno.land/r/gnoswap/router"
 16)
 17
 18// Sapphire Gnoswap registry keys / role addresses (sapphire-1).
 19const (
 20	wugnotTokenKey       = "gno.land/r/gnoland/wugnot.wugnot"
 21	gnsTokenKey          = "gno.land/r/gnoswap/gns.GNS"
 22	gnoswapPoolAddrStr   = "g1dexaf6aqkkyr9yfy9d5up69lsn7ra80af34g5v"
 23	gnoswapRouterAddrStr = "g1vc883gshu5z7ytk5cdynhc8c2dh67pdp4cszkp"
 24)
 25
 26// listFund tracks assets pulled from the listing caller for refund / LP ugnot reimburse.
 27type listFund struct {
 28	ok              bool
 29	caller          address
 30	pulledLpWugnot  int64 // reimbursed as ugnot after successful list
 31	pulledFeeWugnot int64 // leftover WUGNOT refunded after list; full refund on fail
 32	pulledGns       int64 // CreatePool fee; consumed on success
 33}
 34
 35// tryListOnGnoswap seeds a Gnoswap CL pool with remaining meme tokens + raised-sized
 36// WUGNOT as LP. CreatePool fee (fixed GNS amount) is paid from:
 37//  1) pre-funded GNS on pad (preferred - immune to GNOT/GNS price moves), else
 38//  2) ExactOut WUGNOT->GNS using SURPLUS inventory only (above raised), so LP depth
 39//     stays equal to raised even when GNS is expensive.
 40//
 41// Returns true on success. Soft failure sets l.GnoswapNote and returns false
 42// (caller keeps internal CPMM). Does not pull from user - see prepareCallerListFunding.
 43func tryListOnGnoswap(cur realm, l *Launch, raisedUgnot, remainingTokens int64) bool {
 44	if raisedUgnot <= 0 || remainingTokens <= 0 {
 45		l.GnoswapNote = "list skip: empty capital"
 46		return false
 47	}
 48	padAddr := cur.Address()
 49	wBal := wugnot.BalanceOf(padAddr)
 50	if wBal < raisedUgnot {
 51		l.GnoswapNote = "list skip: need WUGNOT inventory >= raised for LP; have " +
 52			strconv.FormatInt(wBal, 10) + " need " + strconv.FormatInt(raisedUgnot, 10)
 53		return false
 54	}
 55
 56	feeNeed := gnspool.GetPoolCreationFee()
 57	if feeNeed <= 0 {
 58		feeNeed = 100_000_000 // 100 GNS default (6 decimals)
 59	}
 60
 61	// --- GNS for CreatePool fee ---
 62	// Require GNS already on pad. Do NOT Approve+router ExactOut mid-list:
 63	// realm spender frame often panics "insufficient allowance" and reverts the tx.
 64	// UI: gns.Transfer(pad, feeNeed) then RetryListGnoswap.
 65	gnsBal := gns.BalanceOf(padAddr)
 66	feeWugnot := int64(0)
 67	if gnsBal < feeNeed {
 68		l.GnoswapNote = "list skip: need " + strconv.FormatInt(feeNeed, 10) +
 69			" GNS base units on pad (Transfer GNS then RetryList). have " +
 70			strconv.FormatInt(gnsBal, 10) +
 71			". No mid-list WUGNOT->GNS swap (avoids allowance panic)."
 72		return false
 73	}
 74
 75	liqWugnot := raisedUgnot
 76	if wugnot.BalanceOf(padAddr) < liqWugnot {
 77		l.GnoswapNote = "list skip: WUGNOT below raised after fee path"
 78		return false
 79	}
 80
 81	// LP token amount: prefer PoolToken already sized in graduate() to curve spot.
 82	// Defensive resize if Virtuals are somehow still set (pre-graduate test paths):
 83	//   tokensForLP = raisedUgnot * VirtualToken / VirtualUgnot
 84	liqTokens := remainingTokens
 85	if l.VirtualUgnot > 0 && l.VirtualToken > 0 && liqWugnot > 0 {
 86		needed := liqWugnot * l.VirtualToken / l.VirtualUgnot
 87		if needed > 0 && needed < remainingTokens {
 88			liqTokens = needed
 89		}
 90	}
 91	if liqTokens <= 0 {
 92		l.GnoswapNote = "list skip: zero LP tokens at curve spot"
 93		return false
 94	}
 95
 96	// Mint LP slice + leftover inventory (LeftoverTokens set at graduate) onto pad.
 97	mintAmt := remainingTokens
 98	if l.LeftoverTokens > 0 {
 99		mintAmt = remainingTokens + l.LeftoverTokens
100	}
101	if mintAmt < liqTokens {
102		mintAmt = liqTokens
103	}
104	addBal(l, padAddr, mintAmt)
105
106	tokenKey := adenaKeyFromTokenID(l.TokenID, l.Symbol)
107	if tokenKey == "" {
108		l.GnoswapNote = "list skip: empty token registry key"
109		return false
110	}
111
112	// Sort token0 < token1 (Gnoswap pool key order) BEFORE sqrtPrice + CreatePool + Mint.
113	// CreatePool reorders tokens and INVERTS sqrtPriceX96 when args are out of order
114	// (newSqrt = Q192/oldSqrt). If we pass unsorted keys with a price already computed
115	// for the sorted pair, the pool is born with inverted price → Mint only takes a
116	// tiny sliver of one side (e.g. ~4 GNOT of a 10k WUGNOT raise). Always call
117	// CreatePool(t0, t1, fee, sqrt) with the same sorted pair as Mint.
118	t0, t1 := wugnotTokenKey, tokenKey
119	amt0, amt1 := liqWugnot, liqTokens
120	if strings.Compare(t0, t1) > 0 {
121		t0, t1 = t1, t0
122		amt0, amt1 = amt1, amt0
123	}
124
125	sqrtPrice := computeSqrtPriceX96(amt0, amt1)
126	if sqrtPrice == "" || sqrtPrice == "0" {
127		l.GnoswapNote = "list skip: bad sqrtPriceX96"
128		return false
129	}
130
131	poolAddr := address(gnoswapPoolAddrStr)
132	gns.Approve(cross(cur), poolAddr, feeNeed)
133	wugnot.Approve(cross(cur), poolAddr, liqWugnot)
134	if err := l.ledger.Approve(padAddr, poolAddr, liqTokens); err != nil {
135		l.GnoswapNote = "list skip: token approve failed: " + err.Error()
136		return false
137	}
138
139	// CRITICAL: sorted (t0,t1) + matching sqrtPrice (do NOT pass original unsorted keys)
140	gnspool.CreatePool(cross(cur), t0, t1, GnoswapFeeTier, sqrtPrice)
141
142	tickLower, tickUpper := alignedFullRangeTicks(GnoswapTickSpacing)
143	deadline := time.Now().Unix() + 600
144
145	posID, liqStr, a0, a1 := position.Mint(
146		cross(cur),
147		t0,
148		t1,
149		GnoswapFeeTier,
150		tickLower,
151		tickUpper,
152		strconv.FormatInt(amt0, 10),
153		strconv.FormatInt(amt1, 10),
154		"0",
155		"0",
156		deadline,
157		padAddr, // permanent lock: pad owns position NFT
158		"",
159	)
160
161	// Actual WUGNOT deposited (whichever side is WUGNOT after sort)
162	used0, _ := strconv.ParseInt(a0, 10, 64)
163	used1, _ := strconv.ParseInt(a1, 10, 64)
164	wugnotUsed := int64(0)
165	if t0 == wugnotTokenKey {
166		wugnotUsed = used0
167	} else if t1 == wugnotTokenKey {
168		wugnotUsed = used1
169	}
170	// Sanity: if Mint took << intended LP WUGNOT, mark note (still listed — pool exists)
171	if wugnotUsed > 0 && liqWugnot > 0 && wugnotUsed*2 < liqWugnot {
172		// keep going but surface under-deposit for ops
173		chain.Emit("ListUnderDeposit",
174			"id", l.ID,
175			"wantWugnot", strconv.FormatInt(liqWugnot, 10),
176			"gotWugnot", strconv.FormatInt(wugnotUsed, 10),
177		)
178	}
179
180	poolPath := t0 + ":" + t1 + ":" + strconv.FormatUint(uint64(GnoswapFeeTier), 10)
181	l.GnoswapListed = true
182	l.GnoswapPoolPath = poolPath
183	l.GnoswapPositionID = posID
184	l.ListVenue = VenueGnoswap
185	l.FeeWugnotSpent = feeWugnot
186	if wugnotUsed > 0 {
187		l.LiqWugnotUsed = wugnotUsed
188	} else {
189		l.LiqWugnotUsed = liqWugnot
190	}
191	// Release Create-time GNS escrow (fee left pad via CreatePool).
192	consumeListFeeEscrow(l)
193	leftoverTok := l.LeftoverTokens
194	if leftoverTok < 0 {
195		leftoverTok = 0
196	}
197	if remLeft := remainingTokens - liqTokens; remLeft > leftoverTok {
198		leftoverTok = remLeft
199	}
200	l.GnoswapNote = "listed pool=" + poolPath + " pos=" + strconv.FormatUint(posID, 10) +
201		" liq=" + liqStr + " a0=" + a0 + " a1=" + a1 +
202		" wugnotUsed=" + strconv.FormatInt(wugnotUsed, 10) +
203		" feeWugnot=" + strconv.FormatInt(feeWugnot, 10) +
204		" lpTokens=" + strconv.FormatInt(liqTokens, 10) +
205		" leftoverTokens=" + strconv.FormatInt(leftoverTok, 10)
206
207	chain.Emit("GnoswapListed",
208		"id", l.ID,
209		"poolPath", poolPath,
210		"positionId", strconv.FormatUint(posID, 10),
211		"feeWugnot", strconv.FormatInt(feeWugnot, 10),
212		"liqWugnot", strconv.FormatInt(liqWugnot, 10),
213		"wugnotUsed", strconv.FormatInt(wugnotUsed, 10),
214		"lpTokens", strconv.FormatInt(liqTokens, 10),
215		"leftoverTokens", strconv.FormatInt(leftoverTok, 10),
216		"tokens", strconv.FormatInt(remainingTokens, 10),
217	)
218	return true
219}
220
221// prepareCallerListFunding fills pad inventory shortfall from the EOA caller via TransferFrom.
222//
223// padv14+ WUGNOT raise: Buy already TransferFrom WUGNOT to pad, so wBal >= raised
224// at graduate in the normal case — only GNS fee (or small fee WUGNOT budget) may be short.
225//
226// Legacy ugnot-raise pads: wugnot.Deposit is EOA-only; caller wraps temp LP then reimbursed.
227func prepareCallerListFunding(cur realm, l *Launch, raisedUgnot int64) listFund {
228	caller := cur.Previous().Address()
229	padA := cur.Address()
230	out := listFund{ok: true, caller: caller}
231
232	if raisedUgnot <= 0 {
233		l.GnoswapNote = "list skip: empty raise"
234		out.ok = false
235		return out
236	}
237
238	feeNeed := gnspool.GetPoolCreationFee()
239	if feeNeed <= 0 {
240		feeNeed = 100_000_000
241	}
242
243	wBal := wugnot.BalanceOf(padA)
244	if wBal < raisedUgnot {
245		short := raisedUgnot - wBal
246		if !pullWugnotFrom(cur, caller, padA, short) {
247			l.GnoswapNote = "list skip: need temp WUGNOT wrap " +
248				strconv.FormatInt(short, 10) +
249				" ugnot units (Deposit+Approve pad). LP reimbursed from pad ugnot after list. " +
250				"Fee: " + strconv.FormatInt(feeNeed, 10) + " GNS or WUGNOT ExactOut budget."
251			out.ok = false
252			return out
253		}
254		out.pulledLpWugnot = short
255		wBal += short
256	}
257
258	gnsBal := gns.BalanceOf(padA)
259	if gnsBal < feeNeed {
260		gShort := feeNeed - gnsBal
261		if pullGnsFrom(cur, caller, padA, gShort) {
262			out.pulledGns = gShort
263		} else {
264			// No GNS from caller - pull modest WUGNOT surplus for ExactOut → GNS.
265			// Do NOT pull full GnoswapMaxFeeWugnot (5000 GNOT) — that causes
266			// InsufficientCoins for normal wallets. MaxFee still caps ExactOut spend.
267			surplus := wBal - raisedUgnot
268			if surplus < 1 {
269				feeBudget := listFeeWugnotPull()
270				if !pullWugnotFrom(cur, caller, padA, feeBudget) {
271					l.GnoswapNote = "list skip: need GNS fee " +
272						strconv.FormatInt(feeNeed, 10) +
273						" (Approve pad) OR WUGNOT fee budget " +
274						strconv.FormatInt(feeBudget, 10) +
275						" ugnot for ExactOut. Prefer holding 100 GNS."
276					// Roll back LP pull.
277					if out.pulledLpWugnot > 0 {
278						safeWugnotTransfer(cur, caller, out.pulledLpWugnot)
279						out.pulledLpWugnot = 0
280					}
281					out.ok = false
282					return out
283				}
284				out.pulledFeeWugnot = feeBudget
285			}
286		}
287	}
288	return out
289}
290
291// listFeeWugnotPull is the WUGNOT amount pulled from the listing caller for
292// ExactOut → GNS when they do not hold CreatePool fee in GNS.
293// Kept well below GnoswapMaxFeeWugnot so List does not demand 5k+ GNOT wrap.
294func listFeeWugnotPull() int64 {
295	const defaultPull int64 = 1_500_000_000 // 1500 GNOT — enough for ~100 GNS at ≤15 GNOT/GNS
296	if GnoswapMaxFeeWugnot > 0 && GnoswapMaxFeeWugnot < defaultPull {
297		return GnoswapMaxFeeWugnot
298	}
299	return defaultPull
300}
301
302// pullWugnotFrom is intentionally a no-op pull.
303//
304// GRC20 TransferFrom from a realm often panics "insufficient allowance" even when
305// Allowance(owner, pad) is set (spender frame resolves to EOA, not pad). Soft-fail
306// instead so list can fall back to notes / surplus / pre-funded pad inventory.
307//
308// UI must pre-fund pad via wugnot.Transfer(pad, amount) and gns.Transfer(pad, fee)
309// before RetryListGnoswap — never rely on Approve+TransferFrom in the same flow.
310func pullWugnotFrom(cur realm, from, to address, amount int64) bool {
311	if amount <= 0 {
312		return true
313	}
314	// Already on pad? treat as satisfied without TransferFrom.
315	if wugnot.BalanceOf(to) >= amount && from != to {
316		// Not a full check for "raised" accounting — prepareCallerListFunding
317		// already compared pad balance to raised before calling shortfall pulls.
318	}
319	_ = cur
320	_ = from
321	_ = to
322	return false
323}
324
325func pullGnsFrom(cur realm, from, to address, amount int64) bool {
326	if amount <= 0 {
327		return true
328	}
329	_ = cur
330	_ = from
331	_ = to
332	// Never TransferFrom GNS either (same spender-frame issue).
333	return false
334}
335
336func safeWugnotTransfer(cur realm, to address, amount int64) {
337	if amount <= 0 {
338		return
339	}
340	have := wugnot.BalanceOf(cur.Address())
341	if have < amount {
342		amount = have
343	}
344	if amount > 0 {
345		wugnot.Transfer(cross(cur), to, amount)
346	}
347}
348
349func safeGnsTransfer(cur realm, to address, amount int64) {
350	if amount <= 0 {
351		return
352	}
353	have := gns.BalanceOf(cur.Address())
354	if have < amount {
355		amount = have
356	}
357	if amount > 0 {
358		gns.Transfer(cross(cur), to, amount)
359	}
360}
361
362func refundCallerListFunding(cur realm, fund listFund) {
363	if !fund.caller.IsValid() {
364		return
365	}
366	safeWugnotTransfer(cur, fund.caller, fund.pulledLpWugnot+fund.pulledFeeWugnot)
367	safeGnsTransfer(cur, fund.caller, fund.pulledGns)
368}
369
370// settleCallerListFunding returns unused fee-budget WUGNOT to the caller.
371// LP shortfall pull is rare on WUGNOT-raise pads (raised already on pad); if it
372// happened, reimburse in WUGNOT from any leftover pad balance after list.
373func settleCallerListFunding(cur realm, fund listFund) {
374	if !fund.caller.IsValid() {
375		return
376	}
377	if fund.pulledLpWugnot > 0 {
378		safeWugnotTransfer(cur, fund.caller, fund.pulledLpWugnot)
379	}
380	// Leftover fee-budget WUGNOT (ExactOut may not consume full maxIn).
381	if fund.pulledFeeWugnot > 0 {
382		safeWugnotTransfer(cur, fund.caller, fund.pulledFeeWugnot)
383	}
384}
385
386// listOnGnoswapWithFunding is the full auto path: pull inventory from caller if needed,
387// CreatePool+Mint, reimburse LP ugnot / refund on failure.
388func listOnGnoswapWithFunding(cur realm, l *Launch, raisedUgnot, remainingTokens int64) bool {
389	fund := prepareCallerListFunding(cur, l, raisedUgnot)
390	if !fund.ok {
391		return false
392	}
393	ok := tryListOnGnoswap(cur, l, raisedUgnot, remainingTokens)
394	if ok {
395		settleCallerListFunding(cur, fund)
396		if fund.pulledLpWugnot > 0 || fund.pulledFeeWugnot > 0 || fund.pulledGns > 0 {
397			chain.Emit("ListFunding",
398				"id", l.ID,
399				"caller", fund.caller.String(),
400				"lpWugnot", strconv.FormatInt(fund.pulledLpWugnot, 10),
401				"feeWugnot", strconv.FormatInt(fund.pulledFeeWugnot, 10),
402				"gns", strconv.FormatInt(fund.pulledGns, 10),
403			)
404		}
405		return true
406	}
407	refundCallerListFunding(cur, fund)
408	return false
409}
410
411// listNeedOf: poolU|wHave|wNeedLp|gnsHave|gnsNeed|feeGns|feeWugnotBudget|padAddr
412func listNeedOf(l *Launch) string {
413	poolU := int64(0)
414	if l != nil && l.Status == StatusGraduated && !l.GnoswapListed {
415		poolU = l.PoolUgnot
416	}
417	padA := padAddr
418	wHave := wugnot.BalanceOf(padA)
419	gnsHave := gns.BalanceOf(padA)
420	feeGns := gnspool.GetPoolCreationFee()
421	if feeGns <= 0 {
422		feeGns = 100_000_000
423	}
424	wNeed := poolU - wHave
425	if wNeed < 0 {
426		wNeed = 0
427	}
428	gNeed := feeGns - gnsHave
429	if gNeed < 0 {
430		gNeed = 0
431	}
432	feeBud := listFeeWugnotPull()
433	// If GNS already covered, fee WUGNOT budget need is 0 for the wizard.
434	if gNeed == 0 {
435		feeBud = 0
436	} else if wHave > poolU {
437		// Existing surplus reduces fee budget to pull
438		surp := wHave - poolU
439		if surp >= feeBud {
440			feeBud = 0
441		} else {
442			feeBud = feeBud - surp
443		}
444	}
445	return strconv.FormatInt(poolU, 10) + "|" +
446		strconv.FormatInt(wHave, 10) + "|" +
447		strconv.FormatInt(wNeed, 10) + "|" +
448		strconv.FormatInt(gnsHave, 10) + "|" +
449		strconv.FormatInt(gNeed, 10) + "|" +
450		strconv.FormatInt(feeGns, 10) + "|" +
451		strconv.FormatInt(feeBud, 10) + "|" +
452		padA.String()
453}
454
455// ListNeed is the public query for the Token list wizard (default venue).
456func ListNeed(id string) string {
457	return listNeedOf(mustLaunch(id))
458}
459
460// ListNeedFor is venue-aware; only gnoswap has a Need adapter today.
461func ListNeedFor(id, venueId string) string {
462	l := mustLaunch(id)
463	vid := normalizeVenueID(venueId)
464	if vid != VenueGnoswap {
465		poolU := int64(0)
466		if l != nil && l.Status == StatusGraduated && !l.GnoswapListed {
467			poolU = l.PoolUgnot
468		}
469		return strconv.FormatInt(poolU, 10) + "|0|0|0|0|0|0|" + padAddr.String()
470	}
471	return listNeedOf(l)
472}
473
474// ListedOf reports whether the launch is listed on any venue.
475func ListedOf(id string) bool {
476	l := mustLaunch(id)
477	return l.GnoswapListed || l.ListVenue != ""
478}
479
480// ListPoolPathOf returns the pool path after listing.
481func ListPoolPathOf(id string) string {
482	return mustLaunch(id).GnoswapPoolPath
483}
484
485// ListNoteOf returns the listing status note.
486func ListNoteOf(id string) string {
487	return mustLaunch(id).GnoswapNote
488}
489
490func swapWugnotForGNS(cur realm, amountOutGNS, maxInWugnot int64) (spent int64, ok bool) {
491	if amountOutGNS <= 0 || maxInWugnot <= 0 {
492		return 0, false
493	}
494	routerAddr := address(gnoswapRouterAddrStr)
495	before := wugnot.BalanceOf(cur.Address())
496	if before < maxInWugnot {
497		maxInWugnot = before
498	}
499	if maxInWugnot <= 0 {
500		return 0, false
501	}
502	wugnot.Approve(cross(cur), routerAddr, maxInWugnot)
503
504	route := wugnotTokenKey + ":" + gnsTokenKey + ":" + strconv.FormatUint(uint64(GnoswapFeeTier), 10)
505	deadline := time.Now().Unix() + 600
506	inStr, outStr := router.ExactOutSwapRoute(
507		cross(cur),
508		wugnotTokenKey,
509		gnsTokenKey,
510		strconv.FormatInt(amountOutGNS, 10),
511		route,
512		"100",
513		strconv.FormatInt(maxInWugnot, 10),
514		deadline,
515		"",
516	)
517	_ = outStr
518	spentIn, err := strconv.ParseInt(inStr, 10, 64)
519	if err != nil || spentIn <= 0 {
520		after := wugnot.BalanceOf(cur.Address())
521		if after >= before {
522			return 0, false
523		}
524		return before - after, true
525	}
526	return spentIn, true
527}
528
529func computeSqrtPriceX96(amount0, amount1 int64) string {
530	if amount0 <= 0 || amount1 <= 0 {
531		return ""
532	}
533	a0 := u256.MustFromDecimal(strconv.FormatInt(amount0, 10))
534	a1 := u256.MustFromDecimal(strconv.FormatInt(amount1, 10))
535	num := u256.Zero().Mul(a1, consts.Q192())
536	ratio := u256.Zero().Div(num, a0)
537	sqrt := u256Sqrt(ratio)
538	if sqrt.Lt(consts.MinSqrtRatio()) {
539		return consts.MinSqrtRatio().ToString()
540	}
541	if sqrt.Gte(consts.MaxSqrtRatio()) {
542		return u256.Zero().Sub(consts.MaxSqrtRatio(), u256.One()).ToString()
543	}
544	return sqrt.ToString()
545}
546
547func u256Sqrt(x *u256.Uint) *u256.Uint {
548	if x == nil || x.IsZero() {
549		return u256.Zero()
550	}
551	if x.Eq(u256.One()) {
552		return u256.One()
553	}
554	lo := u256.Zero()
555	hi := u256.Zero().Add(x, u256.One())
556	cap128 := u256.Zero().Lsh(u256.One(), 128)
557	if hi.Gt(cap128) {
558		hi = cap128.Clone()
559	}
560	for lo.Lt(u256.Zero().Sub(hi, u256.One())) {
561		mid := u256.Zero().Div(u256.Zero().Add(lo, hi), u256.NewUint(2))
562		if mid.IsZero() {
563			lo = u256.One()
564			continue
565		}
566		sq, overflow := u256.Zero().MulOverflow(mid, mid)
567		if overflow || sq.Gt(x) {
568			hi = mid
569		} else {
570			lo = mid
571		}
572	}
573	return lo
574}
575
576func alignedFullRangeTicks(spacing int32) (int32, int32) {
577	if spacing <= 0 {
578		spacing = 60
579	}
580	tl := (GnoswapMinTick / spacing) * spacing
581	tu := (GnoswapMaxTick / spacing) * spacing
582	if tl >= tu {
583		tl = -spacing
584		tu = spacing
585	}
586	return tl, tu
587}
588
589// GnoswapListedOf reports whether a launch was auto-listed on Gnoswap.
590func GnoswapListedOf(id string) bool {
591	return mustLaunch(id).GnoswapListed
592}
593
594// GnoswapPoolPathOf returns the Gnoswap pool path after listing (or empty).
595func GnoswapPoolPathOf(id string) string {
596	return mustLaunch(id).GnoswapPoolPath
597}
598
599// GnoswapNoteOf returns the listing status note.
600func GnoswapNoteOf(id string) string {
601	return mustLaunch(id).GnoswapNote
602}