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_test.gno

10.25 Kb · 288 lines
  1package amm
  2
  3import (
  4	"chain"
  5	"testing"
  6
  7	"gno.land/p/nt/avl/v0"
  8	"gno.land/p/nt/grc20/v0"
  9	"gno.land/p/nt/testutils/v0"
 10	"gno.land/p/nt/uassert/v0"
 11	"gno.land/r/nt/grc20reg/v0"
 12)
 13
 14// Two throwaway tokens, minted and registered once for the whole test binary.
 15// They are created from this realm, so grc20reg keys them under this path.
 16var (
 17	tokAAA, tokBBB *grc20.Token
 18	ledAAA, ledBBB *grc20.PrivateLedger
 19	keyAAA, keyBBB string
 20)
 21
 22func init(cur realm) {
 23	tokAAA, ledAAA = grc20.NewToken("Test Alpha", "AAA", 6, 0, cur)
 24	tokBBB, ledBBB = grc20.NewToken("Test Beta", "BBB", 6, 1, cur)
 25	keyAAA = grc20reg.Register(cross(cur), tokAAA, "")
 26	keyBBB = grc20reg.Register(cross(cur), tokBBB, "")
 27}
 28
 29func self() address { return chain.PackageAddress("gno.land/r/moul/x/amm/v0") }
 30
 31// fund mints both tokens to addr and approves this realm to spend them.
 32func fund(addr address, amount int64) {
 33	ledAAA.Mint(addr, amount)
 34	ledBBB.Mint(addr, amount)
 35	ledAAA.Approve(addr, self(), amount)
 36	ledBBB.Approve(addr, self(), amount)
 37}
 38
 39// reset clears every pool so each test starts from an empty realm.
 40func reset() { pools = avl.Tree{} }
 41
 42//
 43// Pure arithmetic.
 44//
 45
 46func TestAmountOut(t *testing.T) {
 47	cases := []struct {
 48		name                   string
 49		in, resIn, resOut, want int64
 50	}{
 51		{"balanced pool, small trade", 1_000, 1_000_000, 1_000_000, 996},
 52		{"balanced pool, 1%", 10_000, 1_000_000, 1_000_000, 9_871},
 53		{"balanced pool, 10%", 100_000, 1_000_000, 1_000_000, 90_661},
 54		{"balanced pool, 50%", 500_000, 1_000_000, 1_000_000, 332_665},
 55		{"skewed pool", 100_000, 1_000_000, 4_000_000, 362_644},
 56		{"near drain", 1_000, 1, 1_000_000, 998_997},
 57		{"dust rounds to zero", 1, 1_000_000, 1_000_000, 0},
 58		{"reserves at the cap", maxReserve / 2, maxReserve / 2, maxReserve, 4_604_758_097_518_382},
 59	}
 60	for _, tc := range cases {
 61		got := AmountOut(tc.in, tc.resIn, tc.resOut)
 62		uassert.Equal(t, tc.want, got, tc.name)
 63	}
 64}
 65
 66func TestAmountOutNeverBreaksK(t *testing.T) {
 67	resIn, resOut := int64(3_000_000), int64(7_500_000)
 68	for _, in := range []int64{1, 7, 1_000, 999_999, 3_000_000} {
 69		out := AmountOut(in, resIn, resOut)
 70		uassert.True(t, out < resOut, "output must stay inside the reserve")
 71		uassert.True(t, cmpProd(resIn+in, resOut-out, resIn, resOut) >= 0, "k must not decrease")
 72	}
 73}
 74
 75func TestAmountOutGuards(cur realm, t *testing.T) {
 76	uassert.PanicsWithMessage(t, cur, "amm: amountIn must be > 0", func() {
 77		AmountOut(0, 1_000, 1_000)
 78	})
 79	uassert.PanicsWithMessage(t, cur, "amm: pool has an empty reserve", func() {
 80		AmountOut(1, 0, 1_000)
 81	})
 82	uassert.PanicsWithMessage(t, cur, "amm: reserve above cap", func() {
 83		AmountOut(1, maxReserve+1, 1_000)
 84	})
 85	uassert.PanicsWithMessage(t, cur, "amm: reserve cap exceeded", func() {
 86		AmountOut(maxReserve, maxReserve, 1_000)
 87	})
 88}
 89
 90func TestMulDiv(t *testing.T) {
 91	uassert.Equal(t, int64(0), mulDiv(0, 12345, 7))
 92	uassert.Equal(t, int64(3), mulDiv(7, 5, 10)) // floors
 93	// 2^62 * 4 is 2^64: the intermediate does not fit in int64, the result does.
 94	const big = int64(1) << 62
 95	uassert.Equal(t, big/2, mulDiv(big, 4, 8))
 96	// maxReserve * feeDen is the widest product this realm ever forms.
 97	uassert.Equal(t, int64(maxReserve), mulDiv(maxReserve, feeDen, feeDen))
 98}
 99
100func TestCmpProd(t *testing.T) {
101	const big = int64(1) << 40
102	uassert.Equal(t, 0, cmpProd(big, big, big, big))
103	uassert.Equal(t, -1, cmpProd(big, big, big, big+1))
104	uassert.Equal(t, 1, cmpProd(big, big+1, big, big))
105}
106
107//
108// Lifecycle.
109//
110
111func TestSeedSwapRemove(cur realm, t *testing.T) {
112	reset()
113	alice := testutils.TestAddress("seed-alice")
114	fund(alice, 10_000_000)
115
116	testing.SetRealm(testing.NewUserRealm(alice))
117	heldA, heldB := tokAAA.BalanceOf(self()), tokBBB.BalanceOf(self())
118
119	// Seed: the first provider sets the price and gets shares == amountA.
120	minted := AddLiquidity(cross(cur), keyAAA, keyBBB, 1_000_000, 4_000_000)
121	uassert.Equal(t, int64(1_000_000), minted)
122	uassert.Equal(t, int64(1), int64(PoolCount()))
123
124	rA, rB := Reserves(keyAAA, keyBBB)
125	uassert.Equal(t, int64(1_000_000), rA)
126	uassert.Equal(t, int64(4_000_000), rB)
127	uassert.Equal(t, heldA+1_000_000, tokAAA.BalanceOf(self()), "reserve is really held")
128
129	// Swap AAA -> BBB.
130	quoted := Quote(keyAAA, keyBBB, 100_000)
131	uassert.Equal(t, int64(362_644), quoted)
132	out := Swap(cross(cur), keyAAA, keyBBB, 100_000, 362_000)
133	uassert.Equal(t, quoted, out)
134
135	rA, rB = Reserves(keyAAA, keyBBB)
136	uassert.Equal(t, int64(1_100_000), rA)
137	uassert.Equal(t, int64(3_637_356), rB)
138	uassert.True(t, cmpProd(rA, rB, 1_000_000, 4_000_000) > 0, "fee grew k")
139
140	// Burn half the position.
141	gotA, gotB := RemoveLiquidity(cross(cur), keyAAA, keyBBB, 500_000)
142	uassert.Equal(t, int64(550_000), gotA)
143	uassert.Equal(t, int64(1_818_678), gotB)
144	uassert.Equal(t, int64(500_000), TotalShares(keyAAA, keyBBB))
145
146	// Burn the rest: the last provider out takes the whole reserve.
147	gotA, gotB = RemoveLiquidity(cross(cur), keyAAA, keyBBB, 500_000)
148	uassert.Equal(t, int64(550_000), gotA)
149	uassert.Equal(t, int64(1_818_678), gotB)
150	uassert.Equal(t, int64(0), TotalShares(keyAAA, keyBBB))
151	rA, rB = Reserves(keyAAA, keyBBB)
152	uassert.Equal(t, int64(0), rA)
153	uassert.Equal(t, int64(0), rB)
154	uassert.Equal(t, heldA, tokAAA.BalanceOf(self()), "nothing unclaimable left behind")
155	uassert.Equal(t, heldB, tokBBB.BalanceOf(self()), "nothing unclaimable left behind")
156
157	// A drained pool is reseedable, at whatever price the next provider picks.
158	uassert.Equal(t, int64(200_000), AddLiquidity(cross(cur), keyAAA, keyBBB, 200_000, 100_000))
159	rA, rB = Reserves(keyAAA, keyBBB)
160	uassert.Equal(t, int64(200_000), rA)
161	uassert.Equal(t, int64(100_000), rB)
162}
163
164func TestArgumentOrderIsSymmetric(cur realm, t *testing.T) {
165	reset()
166	alice := testutils.TestAddress("sym-alice")
167	fund(alice, 10_000_000)
168	testing.SetRealm(testing.NewUserRealm(alice))
169
170	AddLiquidity(cross(cur), keyBBB, keyAAA, 4_000_000, 1_000_000)
171	uassert.Equal(t, int64(1), int64(PoolCount()), "one pool whichever order is used")
172
173	rA, rB := Reserves(keyAAA, keyBBB)
174	uassert.Equal(t, int64(1_000_000), rA)
175	uassert.Equal(t, int64(4_000_000), rB)
176
177	rB2, rA2 := Reserves(keyBBB, keyAAA)
178	uassert.Equal(t, rA, rA2, "reserves follow the caller's order")
179	uassert.Equal(t, rB, rB2, "reserves follow the caller's order")
180}
181
182func TestSecondProviderIsTrimmedToRatio(cur realm, t *testing.T) {
183	reset()
184	alice := testutils.TestAddress("trim-alice")
185	bob := testutils.TestAddress("trim-bob")
186	fund(alice, 10_000_000)
187	fund(bob, 10_000_000)
188
189	testing.SetRealm(testing.NewUserRealm(alice))
190	AddLiquidity(cross(cur), keyAAA, keyBBB, 1_000_000, 4_000_000)
191
192	// Bob offers a badly skewed pair: 100k AAA is only worth 400k BBB, so the
193	// extra BBB is left in his wallet and his shares price off the scarce side.
194	testing.SetRealm(testing.NewUserRealm(bob))
195	beforeB := tokBBB.BalanceOf(bob)
196	minted := AddLiquidity(cross(cur), keyAAA, keyBBB, 100_000, 9_000_000)
197	uassert.Equal(t, int64(100_000), minted)
198	uassert.Equal(t, int64(400_000), beforeB-tokBBB.BalanceOf(bob), "only the ratio amount was taken")
199
200	rA, rB := Reserves(keyAAA, keyBBB)
201	uassert.Equal(t, int64(1_100_000), rA)
202	uassert.Equal(t, int64(4_400_000), rB)
203	uassert.Equal(t, int64(1_100_000), TotalShares(keyAAA, keyBBB))
204
205	// Round-trip: Bob can never take out more than he put in.
206	gotA, gotB := RemoveLiquidity(cross(cur), keyAAA, keyBBB, minted)
207	uassert.True(t, gotA <= 100_000, "no value created on the A side")
208	uassert.True(t, gotB <= 400_000, "no value created on the B side")
209}
210
211func TestDonationIsInert(cur realm, t *testing.T) {
212	reset()
213	alice := testutils.TestAddress("don-alice")
214	mallory := testutils.TestAddress("don-mallory")
215	fund(alice, 10_000_000)
216	fund(mallory, 10_000_000)
217
218	testing.SetRealm(testing.NewUserRealm(alice))
219	AddLiquidity(cross(cur), keyAAA, keyBBB, 1_000_000, 1_000_000)
220	before := Quote(keyAAA, keyBBB, 10_000)
221
222	// Mallory sends 5,000,000 AAA straight to the realm address, the classic
223	// setup for a share-inflation or price-manipulation attack.
224	heldA := tokAAA.BalanceOf(self())
225	ledAAA.Transfer(mallory, self(), 5_000_000)
226	uassert.Equal(t, heldA+5_000_000, tokAAA.BalanceOf(self()), "the balance really moved")
227
228	rA, rB := Reserves(keyAAA, keyBBB)
229	uassert.Equal(t, int64(1_000_000), rA, "reserves ignore balances")
230	uassert.Equal(t, int64(1_000_000), rB, "reserves ignore balances")
231	uassert.Equal(t, before, Quote(keyAAA, keyBBB, 10_000), "price is unchanged")
232
233	// And a later provider is priced off reserves, not off the donation.
234	testing.SetRealm(testing.NewUserRealm(mallory))
235	uassert.Equal(t, int64(1_000_000), AddLiquidity(cross(cur), keyAAA, keyBBB, 1_000_000, 1_000_000))
236}
237
238//
239// Guards.
240//
241
242func TestGuards(cur realm, t *testing.T) {
243	reset()
244	alice := testutils.TestAddress("guard-alice")
245	fund(alice, 10_000_000)
246	testing.SetRealm(testing.NewUserRealm(alice))
247
248	uassert.AbortsWithMessage(t, cur, "amm: a pool needs two different tokens", func() {
249		AddLiquidity(cross(cur), keyAAA, keyAAA, 1, 1)
250	})
251	uassert.AbortsWithMessage(t, cur, "amm: both deposit amounts must be > 0", func() {
252		AddLiquidity(cross(cur), keyAAA, keyBBB, 0, 1)
253	})
254	uassert.AbortsWithMessage(t, cur, "amm: no such pool: "+keyAAA+"~"+keyBBB, func() {
255		Swap(cross(cur), keyAAA, keyBBB, 1, 0)
256	})
257
258	AddLiquidity(cross(cur), keyAAA, keyBBB, 1_000_000, 1_000_000)
259
260	uassert.AbortsWithMessage(t, cur, "amm: slippage, output below minOut", func() {
261		Swap(cross(cur), keyAAA, keyBBB, 10_000, 10_000)
262	})
263	uassert.AbortsWithMessage(t, cur, "amm: output rounds to zero", func() {
264		Swap(cross(cur), keyAAA, keyBBB, 1, 0)
265	})
266	uassert.AbortsWithMessage(t, cur, "amm: amountIn must be > 0", func() {
267		Swap(cross(cur), keyAAA, keyBBB, 0, 0)
268	})
269	uassert.AbortsWithMessage(t, cur, "amm: insufficient shares", func() {
270		RemoveLiquidity(cross(cur), keyAAA, keyBBB, 2_000_000)
271	})
272	uassert.AbortsWithMessage(t, cur, "amm: reserve cap exceeded", func() {
273		AddLiquidity(cross(cur), keyAAA, keyBBB, maxReserve, maxReserve)
274	})
275
276	// An allowance that does not cover the deposit aborts, and nothing moved.
277	bob := testutils.TestAddress("guard-bob")
278	ledAAA.Mint(bob, 1_000_000)
279	ledBBB.Mint(bob, 1_000_000)
280	ledAAA.Approve(bob, self(), 10)
281	ledBBB.Approve(bob, self(), 1_000_000)
282	testing.SetRealm(testing.NewUserRealm(bob))
283	uassert.AbortsContains(t, cur, "insufficient allowance", func() {
284		AddLiquidity(cross(cur), keyAAA, keyBBB, 100_000, 100_000)
285	})
286	uassert.Equal(t, int64(1_000_000), tokAAA.BalanceOf(bob), "nothing moved: the first pull failed")
287	uassert.Equal(t, int64(0), SharesOf(keyAAA, keyBBB, bob))
288}