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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

exchange_gnot.gno

10.72 Kb · 330 lines
  1package game33
  2
  3// ── P2P exchange: COIN/GNOT constant-product pools ───────────────────────────
  4//
  5// Mirrors exchange_pool.gno exactly, but the hub side is "gnot" (internal
  6// pseudo-coin representing deposited ugnot) instead of "grin".
  7//
  8// Pool keys use the coin ID directly (e.g. "fire", "cool") — they live in a
  9// separate AVL tree (gnotPools) so they cannot collide with the COIN/GRIN
 10// pools in the `pools` tree.
 11//
 12// Players must call DepositGnot (exchange_core.gno) first to wrap ugnot into
 13// internal "gnot" balance before they can trade or provide liquidity here.
 14// WithdrawGnot unwraps "gnot" back to real ugnot at any time.
 15//
 16// Security properties identical to exchange_pool.gno:
 17//   - All amounts uint64, all arithmetic checked (no wraps).
 18//   - Rounding always favors the pool.
 19//   - Reserves are internal counters — no banker calls here.
 20//   - RemoveGnotLiquidity works while paused or banned (it's an exit).
 21//   - minLiquidity (1000) shares burned on first deposit (sandwich resistance).
 22
 23import (
 24	"strconv"
 25	"time"
 26
 27	"chain"
 28
 29	"gno.land/p/nt/avl/v0"
 30)
 31
 32var (
 33	gnotPools       avl.Tree // coin → *pool  (reuses the pool struct from exchange_pool.gno)
 34	gnotLpPositions avl.Tree // coin+":"+addr → *lpPos
 35)
 36
 37// gnotPool returns the COIN/GNOT pool for coin, panicking if it doesn't exist.
 38func gnotPool(coin string) *pool {
 39	if !gnotPools.Has(coin) {
 40		panic("no GNOT pool for " + coin)
 41	}
 42	return gnotPools.Get(coin).(*pool)
 43}
 44
 45// ── Write functions ───────────────────────────────────────────────────────────
 46
 47// OpenGnotPool creates a new COIN/GNOT pool and seeds the first liquidity,
 48// atomically setting the opening price. Any player who holds the coin and
 49// enough deposited GNOT can call this — no admin required.
 50//
 51// coinAmt/gnotAmt set the price ratio; whoever opens the pool first controls
 52// the opening price. 1,000 shares are permanently burned as a seed.
 53func OpenGnotPool(cur realm, coin string, coinAmt uint64, gnotAmt uint64, minShares uint64, deadline int64) uint64 {
 54	caller := exchangeCaller(cur)
 55	requireMarketOpen(caller)
 56	requireDeadline(deadline)
 57	requireAmount("coinAmt", coinAmt)
 58	requireAmount("gnotAmt", gnotAmt)
 59	if coin == hubCoin {
 60		panic("grin has its own COIN/GRIN pool: use OpenPool")
 61	}
 62	if coin == gnotCoinId {
 63		panic("cannot open a GNOT/GNOT pool")
 64	}
 65	requireCoin(coin)
 66	if gnotPools.Has(coin) {
 67		panic("GNOT pool already exists: use AddGnotLiquidity to stake")
 68	}
 69	s := isqrt128(coinAmt, gnotAmt)
 70	if s <= minLiquidity {
 71		panic("first deposit too small: sqrt(coinAmt x gnotAmt) must exceed 1000")
 72	}
 73	minted := s - minLiquidity
 74	if minted < minShares {
 75		panic("slippage: shares below minimum")
 76	}
 77	requireBalance(caller, coin, coinAmt)
 78	requireBalance(caller, gnotCoinId, gnotAmt)
 79	// ── all checks passed; writes start here ──
 80	p := &pool{coin: coin}
 81	gnotPools.Set(coin, p)
 82	p.shares = addChk(minted, minLiquidity)
 83	debitBal(caller, coin, coinAmt)
 84	debitBal(caller, gnotCoinId, gnotAmt)
 85	p.rCoin = coinAmt
 86	p.rHub = gnotAmt
 87	now := time.Now().Unix()
 88	rk := rootKPerShare(p)
 89	gnotLpPositions.Set(coin+":"+caller, &lpPos{shares: minted, entryRKPS: rk, depositTs: now})
 90	chain.Emit("GnotPoolCreated", "coin", coin)
 91	chain.Emit("GnotLiquidityAdded", "coin", coin, "who", caller,
 92		"coinIn", u(coinAmt), "gnotIn", u(gnotAmt), "shares", u(minted))
 93	return minted
 94}
 95
 96// AddGnotLiquidity deposits up to coinMax of `coin` and gnotMax internal GNOT.
 97// Follows the current pool ratio. Reverts unless at least minShares are minted.
 98func AddGnotLiquidity(cur realm, coin string, coinMax uint64, gnotMax uint64, minShares uint64, deadline int64) uint64 {
 99	caller := exchangeCaller(cur)
100	requireMarketOpen(caller)
101	requireDeadline(deadline)
102	requireAmount("coinMax", coinMax)
103	requireAmount("gnotMax", gnotMax)
104	p := gnotPool(coin)
105	if p.paused {
106		panic("GNOT pool paused")
107	}
108	var aCoin, aHub, minted uint64
109	if p.shares == 0 {
110		aCoin, aHub = coinMax, gnotMax
111		s := isqrt128(aCoin, aHub)
112		if s <= minLiquidity {
113			panic("first deposit too small: sqrt(coin×gnot) must exceed 1000")
114		}
115		minted = s - minLiquidity
116	} else {
117		aHub = gnotMax
118		aCoin = mulDivUp(aHub, p.rCoin, p.rHub)
119		if aCoin > coinMax {
120			aCoin = coinMax
121			aHub = mulDivUp(aCoin, p.rHub, p.rCoin)
122		}
123		s1 := mulDiv(aCoin, p.shares, p.rCoin)
124		s2 := mulDiv(aHub, p.shares, p.rHub)
125		minted = s1
126		if s2 < minted {
127			minted = s2
128		}
129		if minted == 0 {
130			panic("deposit too small for one share")
131		}
132	}
133	if minted < minShares {
134		panic("slippage: shares below minimum")
135	}
136	requireBalance(caller, coin, aCoin)
137	requireBalance(caller, gnotCoinId, aHub)
138	// ── all checks passed; writes start here ──
139	if p.shares == 0 {
140		p.shares = addChk(minted, minLiquidity)
141	} else {
142		p.shares = addChk(p.shares, minted)
143	}
144	debitBal(caller, coin, aCoin)
145	debitBal(caller, gnotCoinId, aHub)
146	p.rCoin = addChk(p.rCoin, aCoin)
147	p.rHub = addChk(p.rHub, aHub)
148	key := coin + ":" + caller
149	now := time.Now().Unix()
150	rk := rootKPerShare(p)
151	if gnotLpPositions.Has(key) {
152		pos := gnotLpPositions.Get(key).(*lpPos)
153		tot := addChk(pos.shares, minted)
154		pos.entryRKPS = addChk(mulDiv(pos.entryRKPS, pos.shares, tot), mulDivUp(rk, minted, tot))
155		pos.depositTs = int64(addChk(mulDiv(uint64(pos.depositTs), pos.shares, tot), mulDivUp(uint64(now), minted, tot)))
156		pos.shares = tot
157	} else {
158		gnotLpPositions.Set(key, &lpPos{shares: minted, entryRKPS: rk, depositTs: now})
159	}
160	chain.Emit("GnotLiquidityAdded", "coin", coin, "who", caller,
161		"coinIn", u(aCoin), "gnotIn", u(aHub), "shares", u(minted))
162	return minted
163}
164
165// RemoveGnotLiquidity is always available — works while the exchange or pool
166// is paused. Returns (coinOut, gnotOut).
167func RemoveGnotLiquidity(cur realm, coin string, shares uint64, minCoin uint64, minGnot uint64, deadline int64) (uint64, uint64) {
168	caller := exchangeCaller(cur)
169	requireDeadline(deadline)
170	p := gnotPool(coin)
171	key := coin + ":" + caller
172	if !gnotLpPositions.Has(key) {
173		panic("no GNOT liquidity position in " + coin)
174	}
175	pos := gnotLpPositions.Get(key).(*lpPos)
176	if shares == 0 || shares > pos.shares {
177		panic("invalid shares")
178	}
179	outCoin, outHub := lpPayout(p, pos, shares, time.Now().Unix())
180	if outCoin < minCoin || outHub < minGnot {
181		panic("slippage: output below minimum")
182	}
183	p.rCoin = subChk(p.rCoin, outCoin)
184	p.rHub = subChk(p.rHub, outHub)
185	p.shares = subChk(p.shares, shares)
186	pos.shares = subChk(pos.shares, shares)
187	if pos.shares == 0 {
188		gnotLpPositions.Remove(key)
189	}
190	creditBal(caller, coin, outCoin)
191	creditBal(caller, gnotCoinId, outHub)
192	chain.Emit("GnotLiquidityRemoved", "coin", coin, "who", caller,
193		"coinOut", u(outCoin), "gnotOut", u(outHub), "shares", u(shares))
194	return outCoin, outHub
195}
196
197// poolSwapGnot executes one hop (coin↔gnot) for internal routing.
198// The caller's input must already have been debited.
199func poolSwapGnot(p *pool, inIsHub bool, amountIn uint64) uint64 {
200	if !poolOpenForSwaps(p) {
201		panic("GNOT pool " + p.coin + " is not open for swaps (paused or under 2000 gnot depth)")
202	}
203	out, proto := poolQuote(p, inIsHub, amountIn)
204	if out == 0 {
205		panic("swap too small")
206	}
207	kBefore := isqrt128(p.rCoin, p.rHub)
208	inNet := subChk(amountIn, proto)
209	if inIsHub {
210		p.rHub = addChk(p.rHub, inNet)
211		p.rCoin = subChk(p.rCoin, out)
212		accrueProtocolFee(gnotCoinId, proto)
213	} else {
214		p.rCoin = addChk(p.rCoin, inNet)
215		p.rHub = subChk(p.rHub, out)
216		accrueProtocolFee(p.coin, proto)
217	}
218	if p.rCoin == 0 || p.rHub == 0 {
219		panic("swap would empty the GNOT pool")
220	}
221	if isqrt128(p.rCoin, p.rHub) < kBefore {
222		panic("invariant: k decreased in GNOT pool")
223	}
224	return out
225}
226
227// ── View functions ────────────────────────────────────────────────────────────
228
229// GnotBalance returns the caller's internal GNOT balance (deposited ugnot).
230func GnotBalance(addr string) string {
231	return u(balOf(addr, gnotCoinId))
232}
233
234// GnotPoolInfo returns the state of the COIN/GNOT pool for `coin`.
235func GnotPoolInfo(coin string) string {
236	if !gnotPools.Has(coin) {
237		return `{"coin":` + jsonStr(coin) + `,"exists":false}`
238	}
239	p := gnotPool(coin)
240	return `{"coin":` + jsonStr(coin) + `,"rCoin":"` + u(p.rCoin) + `","rGnot":"` + u(p.rHub) +
241		`","shares":"` + u(p.shares) + `","paused":` + strconv.FormatBool(p.paused) +
242		`,"open":` + strconv.FormatBool(poolOpenForSwaps(p)) + `}`
243}
244
245// GnotPools lists COIN/GNOT pools (paginated, ≤ 50 per call).
246func GnotPools(limit int, offset int) string {
247	if limit <= 0 || limit > maxViewLimit {
248		limit = maxViewLimit
249	}
250	if offset < 0 {
251		offset = 0
252	}
253	out := "["
254	n := 0
255	gnotPools.IterateByOffset(offset, limit, func(_ string, v any) bool {
256		if n > 0 {
257			out += ","
258		}
259		p := v.(*pool)
260		out += `{"coin":` + jsonStr(p.coin) + `,"rCoin":"` + u(p.rCoin) + `","rGnot":"` + u(p.rHub) +
261			`","shares":"` + u(p.shares) + `","paused":` + strconv.FormatBool(p.paused) +
262			`,"open":` + strconv.FormatBool(poolOpenForSwaps(p)) + `}`
263		n++
264		return false
265	})
266	return out + "]"
267}
268
269// QuoteGnotSwap quotes a single GNOT-pool swap; "0" if unavailable.
270func QuoteGnotSwap(coinIn string, coinOut string, amountIn uint64) string {
271	if amountIn == 0 || amountIn > maxAmount {
272		return "0"
273	}
274	c, inIsHub := coinIn, false
275	if coinIn == gnotCoinId {
276		c, inIsHub = coinOut, true
277	} else if coinOut != gnotCoinId {
278		return "0" // neither side is gnot — not a GNOT pool swap
279	}
280	if !gnotPools.Has(c) {
281		return "0"
282	}
283	p := gnotPool(c)
284	if !poolOpenForSwaps(p) {
285		return "0"
286	}
287	out, _ := poolQuote(p, inIsHub, amountIn)
288	return u(out)
289}
290
291// GnotLpPosition shows one GNOT LP position and current withdrawal value.
292func GnotLpPosition(coin string, addr string) string {
293	key := coin + ":" + addr
294	if !gnotLpPositions.Has(key) || !gnotPools.Has(coin) {
295		return `{"coin":` + jsonStr(coin) + `,"shares":"0"}`
296	}
297	pos := gnotLpPositions.Get(key).(*lpPos)
298	p := gnotPool(coin)
299	now := time.Now().Unix()
300	oc, oh := lpPayout(p, pos, pos.shares, now)
301	warm := pos.depositTs + warmupSecs - now
302	if warm < 0 {
303		warm = 0
304	}
305	return `{"coin":` + jsonStr(coin) + `,"shares":"` + u(pos.shares) + `","poolShares":"` + u(p.shares) +
306		`","coinOut":"` + u(oc) + `","gnotOut":"` + u(oh) + `","warmupLeft":` + strconv.FormatInt(warm, 10) + `}`
307}
308
309// GnotLpPositions lists all GNOT LP positions for an address (paginated).
310func GnotLpPositions(addr string, limit int, offset int) string {
311	if limit <= 0 || limit > maxViewLimit {
312		limit = maxViewLimit
313	}
314	if offset < 0 {
315		offset = 0
316	}
317	out := "["
318	n := 0
319	gnotPools.IterateByOffset(offset, limit, func(coin string, _ any) bool {
320		if gnotLpPositions.Has(coin + ":" + addr) {
321			if n > 0 {
322				out += ","
323			}
324			out += GnotLpPosition(coin, addr)
325			n++
326		}
327		return false
328	})
329	return out + "]"
330}