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}