exchange_pool.gno
11.52 Kb · 374 lines
1package game29
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// SetPoolPaused stops new swaps and deposits on one pool. Withdrawals stay open.
61func SetPoolPaused(cur realm, coin string, p bool) {
62 requireAdmin(cur)
63 getPool(coin).paused = p
64 chain.Emit("PoolPaused", "coin", coin, "paused", strconv.FormatBool(p))
65}
66
67// AddLiquidity deposits up to coinMax of `coin` and grinMax grin. The first
68// deposit sets the price (open economy: any ratio) and burns minLiquidity
69// shares; later deposits take the current ratio (never more than either cap).
70// Reverts unless at least minShares are minted.
71func AddLiquidity(cur realm, coin string, coinMax uint64, grinMax uint64, minShares uint64, deadline int64) uint64 {
72 caller := exchangeCaller(cur)
73 requireMarketOpen(caller)
74 requireDeadline(deadline)
75 requireAmount("coinMax", coinMax)
76 requireAmount("grinMax", grinMax)
77 p := getPool(coin)
78 if p.paused {
79 panic("pool paused")
80 }
81 var aCoin, aHub, minted uint64
82 if p.shares == 0 {
83 aCoin, aHub = coinMax, grinMax
84 s := isqrt128(aCoin, aHub)
85 if s <= minLiquidity {
86 panic("first deposit too small: sqrt(coin×grin) must exceed 1000")
87 }
88 minted = s - minLiquidity
89 } else {
90 aHub = grinMax
91 aCoin = mulDivUp(aHub, p.rCoin, p.rHub)
92 if aCoin > coinMax {
93 aCoin = coinMax
94 aHub = mulDivUp(aCoin, p.rHub, p.rCoin)
95 }
96 s1 := mulDiv(aCoin, p.shares, p.rCoin)
97 s2 := mulDiv(aHub, p.shares, p.rHub)
98 minted = s1
99 if s2 < minted {
100 minted = s2
101 }
102 if minted == 0 {
103 panic("deposit too small for one share")
104 }
105 }
106 if minted < minShares {
107 panic("slippage: shares below minimum")
108 }
109 requireBalance(caller, coin, aCoin)
110 requireBalance(caller, hubCoin, aHub)
111 // ── all checks passed; writes start here ──
112 if p.shares == 0 {
113 p.shares = addChk(minted, minLiquidity) // minLiquidity shares belong to nobody (burned)
114 } else {
115 p.shares = addChk(p.shares, minted)
116 }
117 debitBal(caller, coin, aCoin)
118 debitBal(caller, hubCoin, aHub)
119 p.rCoin = addChk(p.rCoin, aCoin)
120 p.rHub = addChk(p.rHub, aHub)
121
122 key := coin + ":" + caller
123 now := time.Now().Unix()
124 rk := rootKPerShare(p)
125 if lpPositions.Has(key) {
126 pos := lpPositions.Get(key).(*lpPos)
127 tot := addChk(pos.shares, minted)
128 pos.entryRKPS = addChk(mulDiv(pos.entryRKPS, pos.shares, tot), mulDivUp(rk, minted, tot))
129 // share-weighted deposit time: a top-up restarts the warm-up in proportion.
130 pos.depositTs = int64(addChk(mulDiv(uint64(pos.depositTs), pos.shares, tot), mulDivUp(uint64(now), minted, tot)))
131 pos.shares = tot
132 } else {
133 lpPositions.Set(key, &lpPos{shares: minted, entryRKPS: rk, depositTs: now})
134 }
135 chain.Emit("LiquidityAdded", "coin", coin, "who", caller,
136 "coinIn", u(aCoin), "grinIn", u(aHub), "shares", u(minted))
137 return minted
138}
139
140// lpPayout computes what `shares` of a position withdraw right now:
141// pro-rata of CURRENT reserves (floor). Within the warm-up the fee growth
142// since entry (√k/share above entry) stays in the pool for the other LPs;
143// principal is never cut below its entry-value share.
144func lpPayout(p *pool, pos *lpPos, shares uint64, now int64) (uint64, uint64) {
145 outCoin := mulDiv(shares, p.rCoin, p.shares)
146 outHub := mulDiv(shares, p.rHub, p.shares)
147 if now-pos.depositTs < warmupSecs {
148 rk := rootKPerShare(p)
149 if rk > pos.entryRKPS {
150 outCoin = mulDiv(outCoin, pos.entryRKPS, rk)
151 outHub = mulDiv(outHub, pos.entryRKPS, rk)
152 }
153 }
154 return outCoin, outHub
155}
156
157// RemoveLiquidity is instant and ALWAYS available: it works while the
158// exchange or the pool is paused, before or after a ban, and needs no tool.
159// Nobody but the position owner can call it for that position.
160func RemoveLiquidity(cur realm, coin string, shares uint64, minCoin uint64, minGrin uint64, deadline int64) (uint64, uint64) {
161 caller := exchangeCaller(cur)
162 requireDeadline(deadline)
163 p := getPool(coin)
164 key := coin + ":" + caller
165 if !lpPositions.Has(key) {
166 panic("no liquidity position in " + coin)
167 }
168 pos := lpPositions.Get(key).(*lpPos)
169 if shares == 0 || shares > pos.shares {
170 panic("invalid shares")
171 }
172 outCoin, outHub := lpPayout(p, pos, shares, time.Now().Unix())
173 if outCoin < minCoin || outHub < minGrin {
174 panic("slippage: output below minimum")
175 }
176 p.rCoin = subChk(p.rCoin, outCoin)
177 p.rHub = subChk(p.rHub, outHub)
178 p.shares = subChk(p.shares, shares)
179 pos.shares = subChk(pos.shares, shares)
180 if pos.shares == 0 {
181 lpPositions.Remove(key)
182 }
183 creditBal(caller, coin, outCoin)
184 creditBal(caller, hubCoin, outHub)
185 chain.Emit("LiquidityRemoved", "coin", coin, "who", caller,
186 "coinOut", u(outCoin), "grinOut", u(outHub), "shares", u(shares))
187 return outCoin, outHub
188}
189
190// poolQuote returns (out, protoFee) for amountIn of the input side; no writes.
191func poolQuote(p *pool, inIsHub bool, amountIn uint64) (uint64, uint64) {
192 rIn, rOut := p.rCoin, p.rHub
193 if inIsHub {
194 rIn, rOut = p.rHub, p.rCoin
195 }
196 fee := mulDivUp(amountIn, poolFeeBps, 10000)
197 proto := mulDiv(amountIn, protoFeeBps, 10000)
198 if fee >= amountIn {
199 return 0, 0
200 }
201 eff := amountIn - fee
202 out := mulDiv(rOut, eff, addChk(rIn, eff)) // floor: pool's favor
203 return out, proto
204}
205
206func poolOpenForSwaps(p *pool) bool {
207 return !p.paused && p.rHub >= minOpenDepthGrin && p.rCoin > 0
208}
209
210// poolSwap executes one hop (coin↔grin) for `caller`, crediting nothing to
211// the caller: it returns the output so routes can chain hops. The caller's
212// input must already have been debited.
213func poolSwap(p *pool, inIsHub bool, amountIn uint64) uint64 {
214 if !poolOpenForSwaps(p) {
215 panic("pool " + p.coin + " is not open for swaps (paused or under 2000 grin depth)")
216 }
217 out, proto := poolQuote(p, inIsHub, amountIn)
218 if out == 0 {
219 panic("swap too small")
220 }
221 kBefore := isqrt128(p.rCoin, p.rHub)
222 inNet := subChk(amountIn, proto)
223 if inIsHub {
224 p.rHub = addChk(p.rHub, inNet)
225 p.rCoin = subChk(p.rCoin, out)
226 accrueProtocolFee(hubCoin, proto)
227 } else {
228 p.rCoin = addChk(p.rCoin, inNet)
229 p.rHub = subChk(p.rHub, out)
230 accrueProtocolFee(p.coin, proto)
231 }
232 if p.rCoin == 0 || p.rHub == 0 {
233 panic("swap would empty the pool")
234 }
235 // Defense in depth: the invariant must hold after every swap.
236 if isqrt128(p.rCoin, p.rHub) < kBefore {
237 panic("invariant: k decreased")
238 }
239 return out
240}
241
242// SwapExactIn swaps amountIn of coinIn for at least minOut of coinOut through
243// one pool (one side must be grin). For X→Y via grin use SwapRoute.
244func SwapExactIn(cur realm, coinIn string, coinOut string, amountIn uint64, minOut uint64, deadline int64) uint64 {
245 caller := exchangeCaller(cur)
246 requireMarketOpen(caller)
247 requireDeadline(deadline)
248 requireAmount("amountIn", amountIn)
249 requireCoin(coinIn)
250 requireCoin(coinOut)
251 var p *pool
252 inIsHub := coinIn == hubCoin
253 if inIsHub && coinOut != hubCoin {
254 p = getPool(coinOut)
255 } else if !inIsHub && coinOut == hubCoin {
256 p = getPool(coinIn)
257 } else {
258 panic("one side must be grin (use SwapRoute for coin→coin)")
259 }
260 if !poolOpenForSwaps(p) {
261 panic("pool " + p.coin + " is not open for swaps (paused or under 2000 grin depth)")
262 }
263 if q, _ := poolQuote(p, inIsHub, amountIn); q == 0 || q < minOut {
264 panic("slippage: output below minimum")
265 }
266 requireBalance(caller, coinIn, amountIn)
267 // ── all checks passed; writes start here ──
268 debitBal(caller, coinIn, amountIn)
269 out := poolSwap(p, inIsHub, amountIn)
270 creditBal(caller, coinOut, out)
271 chain.Emit("Swap", "pool", p.coin, "who", caller, "in", coinIn, "out", coinOut,
272 "amountIn", u(amountIn), "amountOut", u(out))
273 return out
274}
275
276// ── views ────────────────────────────────────────────────────────────────────
277
278func poolJSON(p *pool) string {
279 return `{"coin":` + jsonStr(p.coin) + `,"rCoin":"` + u(p.rCoin) + `","rGrin":"` + u(p.rHub) +
280 `","shares":"` + u(p.shares) + `","paused":` + strconv.FormatBool(p.paused) +
281 `,"open":` + strconv.FormatBool(poolOpenForSwaps(p)) + `}`
282}
283
284func PoolInfo(coin string) string {
285 if !pools.Has(coin) {
286 return `{"coin":` + jsonStr(coin) + `,"exists":false}`
287 }
288 return poolJSON(getPool(coin))
289}
290
291// Pools lists pools in coin order (paginated, ≤ 50 per call).
292func Pools(limit int, offset int) string {
293 if limit <= 0 || limit > maxViewLimit {
294 limit = maxViewLimit
295 }
296 if offset < 0 {
297 offset = 0
298 }
299 out := "["
300 n := 0
301 pools.IterateByOffset(offset, limit, func(_ string, v any) bool {
302 if n > 0 {
303 out += ","
304 }
305 out += poolJSON(v.(*pool))
306 n++
307 return false
308 })
309 return out + "]"
310}
311
312// QuoteSwap quotes a single-pool swap; "0" if the pool can't take it.
313func QuoteSwap(coinIn string, coinOut string, amountIn uint64) string {
314 if amountIn == 0 || amountIn > maxAmount {
315 return "0"
316 }
317 c, inIsHub := coinIn, false
318 if coinIn == hubCoin {
319 c, inIsHub = coinOut, true
320 } else if coinOut != hubCoin {
321 return "0"
322 }
323 if !pools.Has(c) {
324 return "0"
325 }
326 p := getPool(c)
327 if !poolOpenForSwaps(p) {
328 return "0"
329 }
330 out, _ := poolQuote(p, inIsHub, amountIn)
331 return u(out)
332}
333
334// LpPosition shows one position and what it would withdraw right now.
335func LpPosition(coin string, addr string) string {
336 key := coin + ":" + addr
337 if !lpPositions.Has(key) || !pools.Has(coin) {
338 return `{"coin":` + jsonStr(coin) + `,"shares":"0"}`
339 }
340 pos := lpPositions.Get(key).(*lpPos)
341 p := getPool(coin)
342 now := time.Now().Unix()
343 oc, oh := lpPayout(p, pos, pos.shares, now)
344 warm := pos.depositTs + warmupSecs - now
345 if warm < 0 {
346 warm = 0
347 }
348 return `{"coin":` + jsonStr(coin) + `,"shares":"` + u(pos.shares) + `","poolShares":"` + u(p.shares) +
349 `","coinOut":"` + u(oc) + `","grinOut":"` + u(oh) + `","warmupLeft":` + strconv.FormatInt(warm, 10) + `}`
350}
351
352// LpPositions lists an address's positions (bounded: at most one per pool,
353// paginated by pool order, ≤ 50 pools scanned per call).
354func LpPositions(addr string, limit int, offset int) string {
355 if limit <= 0 || limit > maxViewLimit {
356 limit = maxViewLimit
357 }
358 if offset < 0 {
359 offset = 0
360 }
361 out := "["
362 n := 0
363 pools.IterateByOffset(offset, limit, func(coin string, _ any) bool {
364 if lpPositions.Has(coin + ":" + addr) {
365 if n > 0 {
366 out += ","
367 }
368 out += LpPosition(coin, addr)
369 n++
370 }
371 return false
372 })
373 return out + "]"
374}