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

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}