Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

amm.gno

19.15 Kb · 589 lines
  1// Package amm is a minimal constant-product automated market maker for GRC20
  2// token pairs, in a single file.
  3//
  4// It holds many pools in one realm. A pool is created implicitly by its first
  5// liquidity deposit and is addressed by the two tokens' grc20reg keys, in
  6// either order. Swaps follow x*y=k with a 30 bps fee that stays in the pool
  7// and therefore accrues to the liquidity providers.
  8//
  9// # What it is not
 10//
 11// Not an oracle: the reserve ratio is a spot price that any trader can move
 12// within a single transaction. Nothing should price off this realm.
 13// Not audited. Not a router: one hop, one pool, no path finding. Not a
 14// place to park value.
 15//
 16// # Design notes worth knowing before calling it
 17//
 18// Reserves are stored fields and are NEVER derived from BalanceOf. That is
 19// the single decision that removes Uniswap V2's MINIMUM_LIQUIDITY burn, its
 20// skim/sync pair, and the first-depositor share-inflation attack in one go:
 21// sending tokens straight to this realm's address changes no reserve, no
 22// price and no share value. The price of that is blunt and worth stating:
 23// tokens sent directly to the realm address are PERMANENTLY STUCK.
 24//
 25// The first deposit mints shares equal to the token-A amount rather than
 26// sqrt(A*B). The geometric mean is cosmetic, every later operation uses only
 27// ratios, and dropping it removes an integer square root over a 128-bit
 28// product. Later deposits mint min(A-side, B-side) with floor division on
 29// both, so an off-ratio deposit always rounds against the depositor.
 30//
 31// # v1 vs v0: LP shares are a real GRC20 token
 32//
 33// v0 tracks LP positions in a private avl ledger: smallest possible, but a
 34// position can only be held by the address that opened it and is invisible to
 35// every other contract. v1 mints one GRC20 token per pool instead, holds its
 36// PrivateLedger, and registers it with grc20reg. A position is then an
 37// ordinary fungible token: transferable, approvable, usable as collateral,
 38// and readable by any realm that knows the registry key.
 39//
 40// The cost is honest and measurable. Pool creation now also mints a token and
 41// writes a registry entry. Positions gain an allowance surface, and with it
 42// the classic GRC20 approve race. And because MsgCall cannot pass a realm
 43// argument, this realm has to re-export Transfer/Approve/TransferFrom/
 44// Allowance wrappers over the LP token for signing users, four functions that
 45// v0 does not need at all. Another REALM can skip them and move its own LP
 46// balance through grc20reg directly.
 47//
 48// All amounts are int64, as GRC20 mandates, and there is no 256-bit type in
 49// reach. Pricing therefore runs through a 128-bit mulDiv (math/bits), and
 50// every reserve is capped at maxReserve so that the plain int64 parts of the
 51// formula cannot overflow. See the comment on maxReserve for the arithmetic,
 52// and note the consequence: this AMM is unusable with 18-decimal tokens.
 53// Six to nine decimals is the practical band.
 54//
 55// Design study and full analysis: https://github.com/moul/gno-contracts/issues/135
 56package amm
 57
 58import (
 59	"chain"
 60	"math"
 61	"math/bits"
 62	"strconv"
 63
 64	"gno.land/p/nt/avl/v0"
 65	"gno.land/p/nt/grc20/v0"
 66	"gno.land/p/nt/seqid/v0"
 67	"gno.land/p/nt/ufmt/v0"
 68	"gno.land/r/nt/grc20reg/v0"
 69)
 70
 71const (
 72	// feeNum/feeDen is the swap fee kept by the pool: 997/1000, i.e. 30 bps.
 73	feeNum = 997
 74	feeDen = 1000
 75
 76	// maxReserve is the largest reserve a pool will hold, in base units.
 77	//
 78	// Pricing computes den = reserveIn*feeDen + amountIn*feeNum in plain
 79	// int64 before handing off to the 128-bit mulDiv. Capping every reserve,
 80	// and every post-swap reserve, at MaxInt64/feeDen makes that sum provably
 81	// safe:
 82	//
 83	//	den < (reserveIn + amountIn) * feeDen <= maxReserve * feeDen
 84	//	    = 9223372036854775000 <= MaxInt64 = 9223372036854775807
 85	//
 86	// AddLiquidity enforces the cap on the reserves themselves and Swap
 87	// enforces it on reserveIn+amountIn, so a pool can never even enter the
 88	// region where the formula would be unsafe.
 89	maxReserve = math.MaxInt64 / feeDen // 9223372036854775
 90)
 91
 92// pool is one token pair. keyA < keyB always holds: the pair is canonicalised
 93// on the way in, so (X,Y) and (Y,X) address the same pool.
 94type pool struct {
 95	keyA, keyB string
 96	tokA, tokB *grc20.Token
 97	resA, resB int64
 98
 99	// lp is this pool's LP token: total supply is the share supply and a
100	// holder's balance is their position. lpLedger is the minting authority
101	// and MUST NOT leak out of this file. lpKey is lp's grc20reg key.
102	lp       *grc20.Token
103	lpLedger *grc20.PrivateLedger
104	lpKey    string
105}
106
107// pools maps the pool id "keyA~keyB" -> *pool. "~" cannot occur in a
108// grc20reg key, and unlike "|" it does not break a markdown table cell.
109var pools avl.Tree
110
111// lpSeq numbers the LP tokens. It is never reset, so a symbol is never reused
112// and grc20reg's one-token-per-realm-and-symbol rule is never tripped.
113var lpSeq seqid.ID
114
115//
116// Writes.
117//
118
119// AddLiquidity deposits up to maxA of keyA and maxB of keyB and mints LP
120// shares to the caller. It creates the pool if this is its first deposit, in
121// which case the caller's amounts set the starting price and the whole of
122// maxA and maxB is taken.
123//
124// On an existing pool the deposit is trimmed to the current reserve ratio:
125// only one side is consumed in full and the remainder of the richer side is
126// left untouched, so pass the amounts you are willing to spend, not the
127// amounts you insist on spending.
128//
129// The caller must first Approve this realm's address on BOTH tokens for at
130// least the amounts that will be taken. Returns the shares minted.
131func AddLiquidity(cur realm, keyA, keyB string, maxA, maxB int64) int64 {
132	a, b, flipped := canon(keyA, keyB)
133	amtA, amtB := maxA, maxB
134	if flipped {
135		amtA, amtB = maxB, maxA
136	}
137	if amtA <= 0 || amtB <= 0 {
138		panic("amm: both deposit amounts must be > 0")
139	}
140
141	p := getPool(a, b)
142	if p == nil {
143		p = newPool(0, cur, a, b)
144		pools.Set(poolID(a, b), p)
145	}
146
147	var minted int64
148	if p.lp.TotalSupply() == 0 {
149		// First position: the depositor sets the price and the share unit is
150		// token A at seed time. No sqrt, no MINIMUM_LIQUIDITY burn.
151		minted = amtA
152	} else {
153		// Trim to the current ratio, then mint from whichever side is scarcer.
154		// Both divisions floor, and taking the minimum means an off-ratio
155		// deposit is rounded against the depositor, never against the pool.
156		if want := mulDiv(amtA, p.resB, p.resA); want <= amtB {
157			amtB = want
158		} else {
159			amtA = mulDiv(amtB, p.resA, p.resB)
160		}
161		if amtA <= 0 || amtB <= 0 {
162			panic("amm: deposit too small for the current ratio")
163		}
164		supply := p.lp.TotalSupply()
165		minted = min64(
166			mulDiv(amtA, supply, p.resA),
167			mulDiv(amtB, supply, p.resB),
168		)
169	}
170	if minted <= 0 {
171		panic("amm: deposit mints zero shares")
172	}
173	if p.resA+amtA > maxReserve || p.resB+amtB > maxReserve {
174		panic("amm: reserve cap exceeded")
175	}
176
177	provider := cur.Previous().Address()
178	self := cur.Address()
179	pull(0, cur, p.tokA, provider, self, amtA)
180	pull(0, cur, p.tokB, provider, self, amtB)
181
182	p.resA += amtA
183	p.resB += amtB
184	if err := p.lpLedger.Mint(provider, minted); err != nil {
185		panic("amm: cannot mint LP shares: " + err.Error())
186	}
187
188	chain.Emit("AddLiquidity",
189		"pool", poolID(p.keyA, p.keyB),
190		"provider", provider.String(),
191		"amountA", itoa(amtA),
192		"amountB", itoa(amtB),
193		"shares", itoa(minted),
194	)
195	return minted
196}
197
198// RemoveLiquidity burns shares held by the caller and returns the proportional
199// amounts of both tokens, in the caller's (keyA, keyB) argument order.
200//
201// Burning the pool's entire share supply pays out the whole reserve, so the
202// last provider out leaves nothing unclaimable behind and the pool can be
203// reseeded at a fresh price.
204func RemoveLiquidity(cur realm, keyA, keyB string, shares int64) (int64, int64) {
205	if shares <= 0 {
206		panic("amm: shares must be > 0")
207	}
208	a, b, flipped := canon(keyA, keyB)
209	p := mustPool(a, b)
210
211	owner := cur.Previous().Address()
212	if p.lp.BalanceOf(owner) < shares {
213		panic("amm: insufficient shares")
214	}
215
216	supply := p.lp.TotalSupply()
217	var amtA, amtB int64
218	if shares == supply {
219		amtA, amtB = p.resA, p.resB
220	} else {
221		amtA = mulDiv(shares, p.resA, supply)
222		amtB = mulDiv(shares, p.resB, supply)
223	}
224	if amtA <= 0 || amtB <= 0 {
225		panic("amm: burn would return nothing on one side")
226	}
227
228	p.resA -= amtA
229	p.resB -= amtB
230	if err := p.lpLedger.Burn(owner, shares); err != nil {
231		panic("amm: cannot burn LP shares: " + err.Error())
232	}
233
234	push(0, cur, p.tokA, owner, amtA)
235	push(0, cur, p.tokB, owner, amtB)
236
237	chain.Emit("RemoveLiquidity",
238		"pool", poolID(p.keyA, p.keyB),
239		"provider", owner.String(),
240		"amountA", itoa(amtA),
241		"amountB", itoa(amtB),
242		"shares", itoa(shares),
243	)
244	if flipped {
245		return amtB, amtA
246	}
247	return amtA, amtB
248}
249
250// Swap sells amountIn of keyIn for keyOut and aborts unless at least minOut
251// comes back. The caller must first Approve this realm's address on keyIn.
252//
253// minOut is the only protection against being sandwiched or against the pool
254// moving between quoting and execution. Pass a real bound; passing 0 means
255// accepting any price at all.
256func Swap(cur realm, keyIn, keyOut string, amountIn, minOut int64) int64 {
257	if amountIn <= 0 {
258		panic("amm: amountIn must be > 0")
259	}
260	if minOut < 0 {
261		panic("amm: minOut must be >= 0")
262	}
263	a, b, flipped := canon(keyIn, keyOut)
264	p := mustPool(a, b)
265
266	resIn, resOut := p.resA, p.resB
267	tokIn, tokOut := p.tokA, p.tokB
268	if flipped {
269		resIn, resOut = p.resB, p.resA
270		tokIn, tokOut = p.tokB, p.tokA
271	}
272
273	out := AmountOut(amountIn, resIn, resOut)
274	if out <= 0 {
275		panic("amm: output rounds to zero")
276	}
277	if out < minOut {
278		panic("amm: slippage, output below minOut")
279	}
280
281	trader := cur.Previous().Address()
282	pull(0, cur, tokIn, trader, cur.Address(), amountIn)
283
284	newIn, newOut := resIn+amountIn, resOut-out
285	// The invariant holds by construction (out is floored), so this can only
286	// fire if the pricing above is ever edited into being wrong. It is exact:
287	// both products are compared in 128 bits.
288	if cmpProd(newIn, newOut, resIn, resOut) < 0 {
289		panic("amm: constant product regression")
290	}
291	if flipped {
292		p.resB, p.resA = newIn, newOut
293	} else {
294		p.resA, p.resB = newIn, newOut
295	}
296
297	push(0, cur, tokOut, trader, out)
298
299	chain.Emit("Swap",
300		"pool", poolID(p.keyA, p.keyB),
301		"trader", trader.String(),
302		"tokenIn", tokIn.GetSymbol(),
303		"amountIn", itoa(amountIn),
304		"amountOut", itoa(out),
305	)
306	return out
307}
308
309// TransferLP moves amount of the caller's LP position to `to`.
310//
311// The LP token lives in this realm, so a signing user cannot reach it through
312// the token's own entry points the way they would for any other GRC20: there
313// are none. These four wrappers are that entry point. A REALM holding LP does
314// not need them and can go through grc20reg instead:
315//
316//	grc20reg.Transfer(0, cur, amm.LPToken(keyA, keyB), to, n)
317func TransferLP(cur realm, keyA, keyB string, to address, amount int64) {
318	a, b, _ := canon(keyA, keyB)
319	lpCheck(mustPool(a, b).lpLedger.CallerTeller().Transfer(0, cur, to, amount))
320}
321
322// ApproveLP lets spender move up to amount of the caller's LP position.
323//
324// Same approve race as any GRC20: an allowance changed from a non-zero value
325// can be spent at both the old and the new one if the holder is unlucky with
326// ordering. Set it to 0 first when lowering it.
327func ApproveLP(cur realm, keyA, keyB string, spender address, amount int64) {
328	a, b, _ := canon(keyA, keyB)
329	lpCheck(mustPool(a, b).lpLedger.CallerTeller().Approve(0, cur, spender, amount))
330}
331
332// TransferFromLP spends an allowance the owner granted to the caller.
333func TransferFromLP(cur realm, keyA, keyB string, from, to address, amount int64) {
334	a, b, _ := canon(keyA, keyB)
335	lpCheck(mustPool(a, b).lpLedger.CallerTeller().TransferFrom(0, cur, from, to, amount))
336}
337
338func lpCheck(err error) {
339	if err != nil {
340		panic("amm: LP token: " + err.Error())
341	}
342}
343
344//
345// Reads.
346//
347
348// AmountOut is the pricing function, fee included, as a pure function of the
349// two reserves. Exported so a caller can quote off-chain against reserves it
350// already holds, and so the arithmetic is testable on its own.
351func AmountOut(amountIn, reserveIn, reserveOut int64) int64 {
352	if amountIn <= 0 {
353		panic("amm: amountIn must be > 0")
354	}
355	if reserveIn <= 0 || reserveOut <= 0 {
356		panic("amm: pool has an empty reserve")
357	}
358	if reserveIn > maxReserve || reserveOut > maxReserve {
359		panic("amm: reserve above cap")
360	}
361	if amountIn > maxReserve-reserveIn {
362		panic("amm: reserve cap exceeded")
363	}
364	inFee := amountIn * feeNum
365	den := reserveIn*feeDen + inFee
366	return mulDiv(inFee, reserveOut, den)
367}
368
369// Quote prices amountIn of keyIn against the pool's live reserves. It is the
370// number Swap would return right now, which is not a promise about the next
371// block.
372func Quote(keyIn, keyOut string, amountIn int64) int64 {
373	a, b, flipped := canon(keyIn, keyOut)
374	p := mustPool(a, b)
375	if flipped {
376		return AmountOut(amountIn, p.resB, p.resA)
377	}
378	return AmountOut(amountIn, p.resA, p.resB)
379}
380
381// Reserves returns the two reserves in the caller's argument order.
382func Reserves(keyA, keyB string) (int64, int64) {
383	a, b, flipped := canon(keyA, keyB)
384	p := mustPool(a, b)
385	if flipped {
386		return p.resB, p.resA
387	}
388	return p.resA, p.resB
389}
390
391// SharesOf returns owner's LP shares, i.e. their LP token balance.
392func SharesOf(keyA, keyB string, owner address) int64 {
393	a, b, _ := canon(keyA, keyB)
394	return mustPool(a, b).lp.BalanceOf(owner)
395}
396
397// TotalShares returns the pool's LP token total supply.
398func TotalShares(keyA, keyB string) int64 {
399	a, b, _ := canon(keyA, keyB)
400	return mustPool(a, b).lp.TotalSupply()
401}
402
403// LPToken returns the grc20reg key of the pool's LP token. Hand it to any
404// realm that should read or move these positions without importing this one.
405func LPToken(keyA, keyB string) string {
406	a, b, _ := canon(keyA, keyB)
407	return mustPool(a, b).lpKey
408}
409
410// AllowanceLP reports how much of owner's LP position spender may move.
411func AllowanceLP(keyA, keyB string, owner, spender address) int64 {
412	a, b, _ := canon(keyA, keyB)
413	return mustPool(a, b).lp.Allowance(owner, spender)
414}
415
416// PoolCount returns how many pools exist.
417func PoolCount() int { return pools.Size() }
418
419// Render lists every pool, or one pool's detail when path is a "keyA~keyB"
420// pool id.
421func Render(path string) string {
422	if path != "" {
423		v := pools.Get(path)
424		if v == nil {
425			return "# 404\n\nNo pool `" + path + "`.\n"
426		}
427		p := v.(*pool)
428		out := "# " + p.tokA.GetSymbol() + " / " + p.tokB.GetSymbol() + "\n\n"
429		out += "- **" + p.tokA.GetSymbol() + "** reserve: " + itoa(p.resA) + " (`" + p.keyA + "`)\n"
430		out += "- **" + p.tokB.GetSymbol() + "** reserve: " + itoa(p.resB) + " (`" + p.keyB + "`)\n"
431		out += "- **LP token**: `" + p.lpKey + "` (" + p.lp.GetSymbol() + ")\n"
432		out += "- **LP shares**: " + itoa(p.lp.TotalSupply()) + " across " + strconv.Itoa(p.lp.KnownAccounts()) + " holder(s)\n"
433		return out
434	}
435
436	out := "# Minimal AMM\n\n"
437	out += "Constant product (`x*y=k`) with a 30 bps fee kept by the pool. Every position is a transferable GRC20 LP token. Reserves are stored, never read from balances, so sending tokens here directly does nothing and they cannot be recovered.\n\n"
438	if pools.Size() == 0 {
439		out += "_No pools yet. Call `AddLiquidity` with two `grc20reg` keys to open one._\n"
440		return out
441	}
442	out += "| pool | reserves | LP token | supply | holders |\n"
443	out += "|---|---|---|---|---|\n"
444	pools.Iterate("", "", func(key string, value any) bool {
445		p := value.(*pool)
446		out += ufmt.Sprintf("| [%s/%s](/r/moul/x/amm/v0:%s) | %d %s / %d %s | %s | %d | %d |\n",
447			p.tokA.GetSymbol(), p.tokB.GetSymbol(), key,
448			p.resA, p.tokA.GetSymbol(), p.resB, p.tokB.GetSymbol(),
449			p.lp.GetSymbol(), p.lp.TotalSupply(), p.lp.KnownAccounts())
450		return false
451	})
452	return out
453}
454
455//
456// Internals.
457//
458
459// canon sorts a caller's token pair into the pool's canonical order and
460// reports whether the caller's order was reversed.
461func canon(first, second string) (a, b string, flipped bool) {
462	if first == "" || second == "" {
463		panic("amm: empty token key")
464	}
465	if first == second {
466		panic("amm: a pool needs two different tokens")
467	}
468	if first > second {
469		return second, first, true
470	}
471	return first, second, false
472}
473
474// newPool resolves both tokens, mints this pool's LP token and registers it.
475//
476// Non-crossing (`_ int, rlm realm`): grc20.NewToken binds the token's
477// origRealm to rlm.PkgPath() under an IsCurrent assertion, and grc20reg
478// keys off the registering caller, so rlm has to stay AddLiquidity's own
479// live frame rather than a fresh one.
480//
481// The LP unit is token A at seed time, so the LP token mirrors token A's
482// decimals. The symbol is LP<n> because grc20 caps a symbol at 11 chars,
483// which "LP-" plus two 11-char symbols would blow past; the human-readable
484// pair lives in the name instead.
485func newPool(_ int, rlm realm, a, b string) *pool {
486	tokA, tokB := grc20reg.MustGet(a), grc20reg.MustGet(b)
487	id := lpSeq.Next()
488	lp, ledger := grc20.NewToken(
489		"AMM LP "+tokA.GetSymbol()+"/"+tokB.GetSymbol(),
490		"LP"+strconv.FormatUint(uint64(id), 10),
491		tokA.GetDecimals(),
492		id,
493		rlm,
494	)
495	p := &pool{keyA: a, keyB: b, tokA: tokA, tokB: tokB, lp: lp, lpLedger: ledger}
496	p.lpKey = grc20reg.Register(cross(rlm), lp, "")
497	return p
498}
499
500// poolID is the storage and render key for a canonicalised pair.
501func poolID(a, b string) string { return a + "~" + b }
502
503func getPool(a, b string) *pool {
504	v := pools.Get(poolID(a, b))
505	if v == nil {
506		return nil
507	}
508	return v.(*pool)
509}
510
511func mustPool(a, b string) *pool {
512	p := getPool(a, b)
513	if p == nil {
514		panic("amm: no such pool: " + poolID(a, b))
515	}
516	return p
517}
518
519// pull moves amount of tok from `from` into this realm, spending the
520// allowance `from` granted to this realm's address.
521//
522// Non-crossing on purpose: `_ int, rlm realm` is the only shape that keeps
523// rlm the caller's own live frame. RealmTeller binds the spender eagerly to
524// rlm.Address(), which is this realm.
525func pull(_ int, rlm realm, tok *grc20.Token, from, to address, amount int64) {
526	err := tok.RealmTeller(0, rlm).TransferFrom(0, rlm, from, to, amount)
527	if err != nil {
528		panic("amm: cannot take " + tok.GetSymbol() + ": " + err.Error())
529	}
530}
531
532// push sends amount of tok from this realm to `to`.
533func push(_ int, rlm realm, tok *grc20.Token, to address, amount int64) {
534	err := tok.RealmTeller(0, rlm).Transfer(0, rlm, to, amount)
535	if err != nil {
536		panic("amm: cannot send " + tok.GetSymbol() + ": " + err.Error())
537	}
538}
539
540// mulDiv returns floor(a*b/c) through a 128-bit intermediate, which plain
541// int64 arithmetic cannot do: a*b is routinely wider than 63 bits here even
542// when the quotient is small.
543func mulDiv(a, b, c int64) int64 {
544	if a < 0 || b < 0 {
545		panic("amm: negative operand")
546	}
547	if c <= 0 {
548		panic("amm: division by a non-positive value")
549	}
550	hi, lo := bits.Mul64(uint64(a), uint64(b))
551	// bits.Div64 panics for y <= hi; refusing here turns that into a named
552	// abort and also covers every quotient that would not fit in 64 bits.
553	if hi >= uint64(c) {
554		panic("amm: quotient overflows int64")
555	}
556	q, _ := bits.Div64(hi, lo, uint64(c))
557	if q > uint64(math.MaxInt64) {
558		panic("amm: quotient overflows int64")
559	}
560	return int64(q)
561}
562
563// cmpProd compares a*b with c*d exactly, in 128 bits, and returns -1, 0 or 1.
564func cmpProd(a, b, c, d int64) int {
565	h1, l1 := bits.Mul64(uint64(a), uint64(b))
566	h2, l2 := bits.Mul64(uint64(c), uint64(d))
567	if h1 != h2 {
568		if h1 < h2 {
569			return -1
570		}
571		return 1
572	}
573	if l1 != l2 {
574		if l1 < l2 {
575			return -1
576		}
577		return 1
578	}
579	return 0
580}
581
582func min64(a, b int64) int64 {
583	if a < b {
584		return a
585	}
586	return b
587}
588
589func itoa(v int64) string { return strconv.FormatInt(v, 10) }