exchange_pool.gno
13.45 Kb · 423 lines
1package game33
2
3// ── P2P exchange: COIN/GRIN constant-product pools ───────────────────────────
4//
5// Reserves are internal counters, never "the realm's balance": a Transfer or
6// bank send to the realm address can't move them (no donation attack).
7// Rounding: swap fee up, output down, minted shares down, withdrawals down —
8// always in the pool's favor. k (and √k per share) never decreases on a swap.
9
10import (
11 "strconv"
12 "time"
13
14 "chain"
15)
16
17type pool struct {
18 coin string
19 rCoin, rHub uint64 // reserves (coin side, grin side)
20 shares uint64 // total shares, including the burned minLiquidity
21 paused bool // per-pool: stops new swaps/deposits only
22}
23
24type lpPos struct {
25 shares uint64
26 entryRKPS uint64 // √k per share × rootKScale at (share-weighted) entry
27 depositTs int64 // share-weighted deposit time (warm-up clock)
28}
29
30func getPool(coin string) *pool {
31 if !pools.Has(coin) {
32 panic("no pool for " + coin)
33 }
34 return pools.Get(coin).(*pool)
35}
36
37func rootKPerShare(p *pool) uint64 {
38 if p.shares == 0 {
39 return 0
40 }
41 return mulDiv(isqrt128(p.rCoin, p.rHub), rootKScale, p.shares)
42}
43
44// CreatePool opens an empty COIN/GRIN pool (admin, per the launch schedule).
45// It holds nothing until players deposit; the admin can't deposit for anyone,
46// withdraw from it or close it.
47func CreatePool(cur realm, coin string) {
48 requireAdmin(cur)
49 if coin == hubCoin {
50 panic("grin is the hub coin: pools are COIN/GRIN")
51 }
52 requireCoin(coin)
53 if pools.Has(coin) {
54 panic("pool exists")
55 }
56 pools.Set(coin, &pool{coin: coin})
57 chain.Emit("PoolCreated", "coin", coin)
58}
59
60// OpenPool creates a new COIN/GRIN pool and immediately deposits the first
61// liquidity, atomically setting the opening price. Any player who holds the
62// coin and enough Grin can call this — no admin required.
63//
64// coinAmt and grinAmt set the price ratio; whoever calls this first for a coin
65// controls the opening price, so choose amounts that reflect the coin's real
66// value. 1,000 shares are permanently burned as a sandwich-resistance seed.
67// Returns the number of LP shares minted for the caller.
68func OpenPool(cur realm, coin string, coinAmt uint64, grinAmt uint64, minShares uint64, deadline int64) uint64 {
69 caller := exchangeCaller(cur)
70 requireMarketOpen(caller)
71 requireDeadline(deadline)
72 requireAmount("coinAmt", coinAmt)
73 requireAmount("grinAmt", grinAmt)
74 if coin == hubCoin {
75 panic("grin is the hub coin: pools are COIN/GRIN")
76 }
77 requireCoin(coin)
78 if pools.Has(coin) {
79 panic("pool already exists: use AddLiquidity to stake")
80 }
81 // First-deposit size check: sqrt(coinAmt*grinAmt) must exceed minLiquidity.
82 s := isqrt128(coinAmt, grinAmt)
83 if s <= minLiquidity {
84 panic("first deposit too small: sqrt(coinAmt x grinAmt) must exceed 1000")
85 }
86 minted := s - minLiquidity
87 if minted < minShares {
88 panic("slippage: shares below minimum")
89 }
90 requireBalance(caller, coin, coinAmt)
91 requireBalance(caller, hubCoin, grinAmt)
92 // ── all checks passed; writes start here ──
93 pools.Set(coin, &pool{coin: coin})
94 chain.Emit("PoolCreated", "coin", coin)
95 p := getPool(coin)
96 p.shares = addChk(minted, minLiquidity) // minLiquidity shares burned forever
97 debitBal(caller, coin, coinAmt)
98 debitBal(caller, hubCoin, grinAmt)
99 p.rCoin = coinAmt
100 p.rHub = grinAmt
101 now := time.Now().Unix()
102 rk := rootKPerShare(p)
103 lpPositions.Set(coin+":"+caller, &lpPos{shares: minted, entryRKPS: rk, depositTs: now})
104 chain.Emit("LiquidityAdded", "coin", coin, "who", caller,
105 "coinIn", u(coinAmt), "grinIn", u(grinAmt), "shares", u(minted))
106 return minted
107}
108
109// SetPoolPaused stops new swaps and deposits on one pool. Withdrawals stay open.
110func SetPoolPaused(cur realm, coin string, p bool) {
111 requireAdmin(cur)
112 getPool(coin).paused = p
113 chain.Emit("PoolPaused", "coin", coin, "paused", strconv.FormatBool(p))
114}
115
116// AddLiquidity deposits up to coinMax of `coin` and grinMax grin. The first
117// deposit sets the price (open economy: any ratio) and burns minLiquidity
118// shares; later deposits take the current ratio (never more than either cap).
119// Reverts unless at least minShares are minted.
120func AddLiquidity(cur realm, coin string, coinMax uint64, grinMax uint64, minShares uint64, deadline int64) uint64 {
121 caller := exchangeCaller(cur)
122 requireMarketOpen(caller)
123 requireDeadline(deadline)
124 requireAmount("coinMax", coinMax)
125 requireAmount("grinMax", grinMax)
126 p := getPool(coin)
127 if p.paused {
128 panic("pool paused")
129 }
130 var aCoin, aHub, minted uint64
131 if p.shares == 0 {
132 aCoin, aHub = coinMax, grinMax
133 s := isqrt128(aCoin, aHub)
134 if s <= minLiquidity {
135 panic("first deposit too small: sqrt(coin×grin) must exceed 1000")
136 }
137 minted = s - minLiquidity
138 } else {
139 aHub = grinMax
140 aCoin = mulDivUp(aHub, p.rCoin, p.rHub)
141 if aCoin > coinMax {
142 aCoin = coinMax
143 aHub = mulDivUp(aCoin, p.rHub, p.rCoin)
144 }
145 s1 := mulDiv(aCoin, p.shares, p.rCoin)
146 s2 := mulDiv(aHub, p.shares, p.rHub)
147 minted = s1
148 if s2 < minted {
149 minted = s2
150 }
151 if minted == 0 {
152 panic("deposit too small for one share")
153 }
154 }
155 if minted < minShares {
156 panic("slippage: shares below minimum")
157 }
158 requireBalance(caller, coin, aCoin)
159 requireBalance(caller, hubCoin, aHub)
160 // ── all checks passed; writes start here ──
161 if p.shares == 0 {
162 p.shares = addChk(minted, minLiquidity) // minLiquidity shares belong to nobody (burned)
163 } else {
164 p.shares = addChk(p.shares, minted)
165 }
166 debitBal(caller, coin, aCoin)
167 debitBal(caller, hubCoin, aHub)
168 p.rCoin = addChk(p.rCoin, aCoin)
169 p.rHub = addChk(p.rHub, aHub)
170
171 key := coin + ":" + caller
172 now := time.Now().Unix()
173 rk := rootKPerShare(p)
174 if lpPositions.Has(key) {
175 pos := lpPositions.Get(key).(*lpPos)
176 tot := addChk(pos.shares, minted)
177 pos.entryRKPS = addChk(mulDiv(pos.entryRKPS, pos.shares, tot), mulDivUp(rk, minted, tot))
178 // share-weighted deposit time: a top-up restarts the warm-up in proportion.
179 pos.depositTs = int64(addChk(mulDiv(uint64(pos.depositTs), pos.shares, tot), mulDivUp(uint64(now), minted, tot)))
180 pos.shares = tot
181 } else {
182 lpPositions.Set(key, &lpPos{shares: minted, entryRKPS: rk, depositTs: now})
183 }
184 chain.Emit("LiquidityAdded", "coin", coin, "who", caller,
185 "coinIn", u(aCoin), "grinIn", u(aHub), "shares", u(minted))
186 return minted
187}
188
189// lpPayout computes what `shares` of a position withdraw right now:
190// pro-rata of CURRENT reserves (floor). Within the warm-up the fee growth
191// since entry (√k/share above entry) stays in the pool for the other LPs;
192// principal is never cut below its entry-value share.
193func lpPayout(p *pool, pos *lpPos, shares uint64, now int64) (uint64, uint64) {
194 outCoin := mulDiv(shares, p.rCoin, p.shares)
195 outHub := mulDiv(shares, p.rHub, p.shares)
196 if now-pos.depositTs < warmupSecs {
197 rk := rootKPerShare(p)
198 if rk > pos.entryRKPS {
199 outCoin = mulDiv(outCoin, pos.entryRKPS, rk)
200 outHub = mulDiv(outHub, pos.entryRKPS, rk)
201 }
202 }
203 return outCoin, outHub
204}
205
206// RemoveLiquidity is instant and ALWAYS available: it works while the
207// exchange or the pool is paused, before or after a ban, and needs no tool.
208// Nobody but the position owner can call it for that position.
209func RemoveLiquidity(cur realm, coin string, shares uint64, minCoin uint64, minGrin uint64, deadline int64) (uint64, uint64) {
210 caller := exchangeCaller(cur)
211 requireDeadline(deadline)
212 p := getPool(coin)
213 key := coin + ":" + caller
214 if !lpPositions.Has(key) {
215 panic("no liquidity position in " + coin)
216 }
217 pos := lpPositions.Get(key).(*lpPos)
218 if shares == 0 || shares > pos.shares {
219 panic("invalid shares")
220 }
221 outCoin, outHub := lpPayout(p, pos, shares, time.Now().Unix())
222 if outCoin < minCoin || outHub < minGrin {
223 panic("slippage: output below minimum")
224 }
225 p.rCoin = subChk(p.rCoin, outCoin)
226 p.rHub = subChk(p.rHub, outHub)
227 p.shares = subChk(p.shares, shares)
228 pos.shares = subChk(pos.shares, shares)
229 if pos.shares == 0 {
230 lpPositions.Remove(key)
231 }
232 creditBal(caller, coin, outCoin)
233 creditBal(caller, hubCoin, outHub)
234 chain.Emit("LiquidityRemoved", "coin", coin, "who", caller,
235 "coinOut", u(outCoin), "grinOut", u(outHub), "shares", u(shares))
236 return outCoin, outHub
237}
238
239// poolQuote returns (out, protoFee) for amountIn of the input side; no writes.
240func poolQuote(p *pool, inIsHub bool, amountIn uint64) (uint64, uint64) {
241 rIn, rOut := p.rCoin, p.rHub
242 if inIsHub {
243 rIn, rOut = p.rHub, p.rCoin
244 }
245 fee := mulDivUp(amountIn, poolFeeBps, 10000)
246 proto := mulDiv(amountIn, protoFeeBps, 10000)
247 if fee >= amountIn {
248 return 0, 0
249 }
250 eff := amountIn - fee
251 out := mulDiv(rOut, eff, addChk(rIn, eff)) // floor: pool's favor
252 return out, proto
253}
254
255func poolOpenForSwaps(p *pool) bool {
256 return !p.paused && p.rHub >= minOpenDepthGrin && p.rCoin > 0
257}
258
259// poolSwap executes one hop (coin↔grin) for `caller`, crediting nothing to
260// the caller: it returns the output so routes can chain hops. The caller's
261// input must already have been debited.
262func poolSwap(p *pool, inIsHub bool, amountIn uint64) uint64 {
263 if !poolOpenForSwaps(p) {
264 panic("pool " + p.coin + " is not open for swaps (paused or under 2000 grin depth)")
265 }
266 out, proto := poolQuote(p, inIsHub, amountIn)
267 if out == 0 {
268 panic("swap too small")
269 }
270 kBefore := isqrt128(p.rCoin, p.rHub)
271 inNet := subChk(amountIn, proto)
272 if inIsHub {
273 p.rHub = addChk(p.rHub, inNet)
274 p.rCoin = subChk(p.rCoin, out)
275 accrueProtocolFee(hubCoin, proto)
276 } else {
277 p.rCoin = addChk(p.rCoin, inNet)
278 p.rHub = subChk(p.rHub, out)
279 accrueProtocolFee(p.coin, proto)
280 }
281 if p.rCoin == 0 || p.rHub == 0 {
282 panic("swap would empty the pool")
283 }
284 // Defense in depth: the invariant must hold after every swap.
285 if isqrt128(p.rCoin, p.rHub) < kBefore {
286 panic("invariant: k decreased")
287 }
288 return out
289}
290
291// SwapExactIn swaps amountIn of coinIn for at least minOut of coinOut through
292// one pool (one side must be grin). For X→Y via grin use SwapRoute.
293func SwapExactIn(cur realm, coinIn string, coinOut string, amountIn uint64, minOut uint64, deadline int64) uint64 {
294 caller := exchangeCaller(cur)
295 requireMarketOpen(caller)
296 requireDeadline(deadline)
297 requireAmount("amountIn", amountIn)
298 requireCoin(coinIn)
299 requireCoin(coinOut)
300 var p *pool
301 inIsHub := coinIn == hubCoin
302 if inIsHub && coinOut != hubCoin {
303 p = getPool(coinOut)
304 } else if !inIsHub && coinOut == hubCoin {
305 p = getPool(coinIn)
306 } else {
307 panic("one side must be grin (use SwapRoute for coin→coin)")
308 }
309 if !poolOpenForSwaps(p) {
310 panic("pool " + p.coin + " is not open for swaps (paused or under 2000 grin depth)")
311 }
312 if q, _ := poolQuote(p, inIsHub, amountIn); q == 0 || q < minOut {
313 panic("slippage: output below minimum")
314 }
315 requireBalance(caller, coinIn, amountIn)
316 // ── all checks passed; writes start here ──
317 debitBal(caller, coinIn, amountIn)
318 out := poolSwap(p, inIsHub, amountIn)
319 creditBal(caller, coinOut, out)
320 chain.Emit("Swap", "pool", p.coin, "who", caller, "in", coinIn, "out", coinOut,
321 "amountIn", u(amountIn), "amountOut", u(out))
322 return out
323}
324
325// ── views ────────────────────────────────────────────────────────────────────
326
327func poolJSON(p *pool) string {
328 return `{"coin":` + jsonStr(p.coin) + `,"rCoin":"` + u(p.rCoin) + `","rGrin":"` + u(p.rHub) +
329 `","shares":"` + u(p.shares) + `","paused":` + strconv.FormatBool(p.paused) +
330 `,"open":` + strconv.FormatBool(poolOpenForSwaps(p)) + `}`
331}
332
333func PoolInfo(coin string) string {
334 if !pools.Has(coin) {
335 return `{"coin":` + jsonStr(coin) + `,"exists":false}`
336 }
337 return poolJSON(getPool(coin))
338}
339
340// Pools lists pools in coin order (paginated, ≤ 50 per call).
341func Pools(limit int, offset int) string {
342 if limit <= 0 || limit > maxViewLimit {
343 limit = maxViewLimit
344 }
345 if offset < 0 {
346 offset = 0
347 }
348 out := "["
349 n := 0
350 pools.IterateByOffset(offset, limit, func(_ string, v any) bool {
351 if n > 0 {
352 out += ","
353 }
354 out += poolJSON(v.(*pool))
355 n++
356 return false
357 })
358 return out + "]"
359}
360
361// QuoteSwap quotes a single-pool swap; "0" if the pool can't take it.
362func QuoteSwap(coinIn string, coinOut string, amountIn uint64) string {
363 if amountIn == 0 || amountIn > maxAmount {
364 return "0"
365 }
366 c, inIsHub := coinIn, false
367 if coinIn == hubCoin {
368 c, inIsHub = coinOut, true
369 } else if coinOut != hubCoin {
370 return "0"
371 }
372 if !pools.Has(c) {
373 return "0"
374 }
375 p := getPool(c)
376 if !poolOpenForSwaps(p) {
377 return "0"
378 }
379 out, _ := poolQuote(p, inIsHub, amountIn)
380 return u(out)
381}
382
383// LpPosition shows one position and what it would withdraw right now.
384func LpPosition(coin string, addr string) string {
385 key := coin + ":" + addr
386 if !lpPositions.Has(key) || !pools.Has(coin) {
387 return `{"coin":` + jsonStr(coin) + `,"shares":"0"}`
388 }
389 pos := lpPositions.Get(key).(*lpPos)
390 p := getPool(coin)
391 now := time.Now().Unix()
392 oc, oh := lpPayout(p, pos, pos.shares, now)
393 warm := pos.depositTs + warmupSecs - now
394 if warm < 0 {
395 warm = 0
396 }
397 return `{"coin":` + jsonStr(coin) + `,"shares":"` + u(pos.shares) + `","poolShares":"` + u(p.shares) +
398 `","coinOut":"` + u(oc) + `","grinOut":"` + u(oh) + `","warmupLeft":` + strconv.FormatInt(warm, 10) + `}`
399}
400
401// LpPositions lists an address's positions (bounded: at most one per pool,
402// paginated by pool order, ≤ 50 pools scanned per call).
403func LpPositions(addr string, limit int, offset int) string {
404 if limit <= 0 || limit > maxViewLimit {
405 limit = maxViewLimit
406 }
407 if offset < 0 {
408 offset = 0
409 }
410 out := "["
411 n := 0
412 pools.IterateByOffset(offset, limit, func(coin string, _ any) bool {
413 if lpPositions.Has(coin + ":" + addr) {
414 if n > 0 {
415 out += ","
416 }
417 out += LpPosition(coin, addr)
418 n++
419 }
420 return false
421 })
422 return out + "]"
423}