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_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}