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.

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}