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}