exchange_test.gno
43.66 Kb · 1109 lines
1package game31
2
3// Exchange unit, attack and invariant tests. See docs/EXCHANGE_SECURITY_REVIEW.md
4// for the threat → code → test map.
5//
6// gno test caveat: revive() catches a panic but does NOT roll back realm
7// state. The exchange validates everything before its first write, so a
8// failing call must leave state untouched — several tests assert exactly that
9// (snapshot before, compare after).
10
11import (
12 "chain"
13 "strconv"
14 "strings"
15 "testing"
16 "time"
17)
18
19// ── helpers ──────────────────────────────────────────────────────────────────
20
21// exOpen finalizes the migration and unpauses for one test; the returned
22// func restores the previous global state (other test files depend on it).
23func exOpen() func() {
24 setup()
25 wasFinal, wasPaused, wasGame := migrationFinalized, exPaused, paused
26 migrationFinalized, exPaused, paused = true, false, false
27 return func() { migrationFinalized, exPaused, paused = wasFinal, wasPaused, wasGame }
28}
29
30
31// trader: owns wood+stone (can trade, receives up to rarity 3).
32func trader(name string) address {
33 a := ta(name)
34 ownedTools.Set(a.String(), "starter-twig,twig,wood,stone")
35 return a
36}
37
38func dl() int64 { return time.Now().Unix() + 600 }
39
40// panicMsg runs f and returns its panic message ("" if none). For internal
41// (non-crossing) helpers, where revive() does not apply.
42func panicMsg(f func()) (msg string) {
43 defer func() {
44 if r := recover(); r != nil {
45 msg = errOf(r)
46 }
47 }()
48 f()
49 return ""
50}
51
52func errOf(r any) string {
53 switch v := r.(type) {
54 case nil:
55 return ""
56 case string:
57 return v
58 case error:
59 return v.Error()
60 }
61 return "?"
62}
63
64// coinTotal = every unit of `coin` the realm accounts for: all balances,
65// pool reserves, order escrow, admin fees and the casino pool.
66func coinTotal(coin string) uint64 {
67 tot := uint64(0)
68 balances.Iterate("", "", func(k string, v any) bool {
69 if strings.HasSuffix(k, ":"+coin) {
70 n, _ := strconv.ParseUint(v.(string), 10, 64)
71 tot += n
72 }
73 return false
74 })
75 pools.Iterate("", "", func(_ string, v any) bool {
76 p := v.(*pool)
77 if p.coin == coin {
78 tot += p.rCoin
79 }
80 if coin == hubCoin {
81 tot += p.rHub
82 }
83 return false
84 })
85 forEachOrder(func(o *order) {
86 if o.give == coin {
87 tot += o.remGive
88 }
89 })
90 return tot + getU(&adminFees, coin) + getU(&casinoPool, coin)
91}
92
93func lpSum(coin string) uint64 {
94 s := uint64(0)
95 lpPositions.Iterate(coin+":", coin+";", func(_ string, v any) bool {
96 s += v.(*lpPos).shares
97 return false
98 })
99 return s
100}
101
102// stateSig is a cheap fingerprint of everything the exchange owns + balances.
103func stateSig(coins ...string) string {
104 s := ""
105 for _, c := range coins {
106 s += c + "=" + u(coinTotal(c)) + ";"
107 if pools.Has(c) {
108 p := getPool(c)
109 s += u(p.rCoin) + "/" + u(p.rHub) + "/" + u(p.shares) + ";"
110 }
111 }
112 return s + "orders=" + u(openOrderTotal) + ";book=" + strconv.Itoa(sideTotal()) + ";next=" + u(nextOrderId)
113}
114
115func balSig(addrs []address, coins []string) string {
116 s := ""
117 for _, a := range addrs {
118 for _, c := range coins {
119 s += u(bal(a, c)) + ","
120 }
121 }
122 return s
123}
124
125// ── gates ────────────────────────────────────────────────────────────────────
126
127func TestEx_Gates(cur realm, t *testing.T) {
128 restore := exOpen()
129 defer restore()
130 // seedPool: the caller must already act as `lp` (testing.SetRealm can't be
131 // switched from inside a closure). Pools are created by the admin API in
132 // TestEx_Gates; here the empty pool record is written directly.
133 seedPool := func(coin string, lp address, coinAmt, grinAmt uint64) uint64 {
134 if !pools.Has(coin) {
135 pools.Set(coin, &pool{coin: coin})
136 }
137 give(lp, coin, coinAmt)
138 give(lp, "grin", grinAmt)
139 return AddLiquidity(cross(cur), coin, coinAmt, grinAmt, 0, dl())
140 }
141 lp := trader("gate-lp")
142 testing.SetRealm(testing.NewUserRealm(lp))
143 seedPool("joy", lp, 10000, 10000)
144
145 // trading only after FinalizeMigration
146 migrationFinalized = false
147 testing.SetRealm(testing.NewUserRealm(lp))
148 mustAbort(t, "before finalize", revive(func() { SwapExactIn(cross(cur), "grin", "joy", 10, 0, dl()) }), "FinalizeMigration")
149 mustAbort(t, "transfer before finalize", revive(func() { Transfer(cross(cur), "grin", ta("x").String(), "1") }), "FinalizeMigration")
150 migrationFinalized = true
151
152 // no tool requirement: a Fist-only account (no tools at all) can trade
153 noob := ta("gate-noob")
154 give(noob, "grin", 100)
155 testing.SetRealm(testing.NewUserRealm(noob))
156 if got := SwapExactIn(cross(cur), "grin", "joy", 10, 1, dl()); got == 0 || bal(noob, "joy") != got {
157 t.Errorf("fist-only swap: got %d", got)
158 }
159
160
161 // deadlines are mandatory and enforced
162 mustAbort(t, "no deadline", revive(func() { SwapExactIn(cross(cur), "grin", "joy", 10, 0, 0) }), "deadline required")
163 mustAbort(t, "deadline passed", revive(func() { SwapExactIn(cross(cur), "grin", "joy", 10, 0, time.Now().Unix()-1) }), "deadline passed")
164
165 // realm (code) callers are rejected — no cross-realm surface
166 testing.SetRealm(testing.NewCodeRealm("gno.land/r/evil/proxy"))
167 mustAbort(t, "realm caller swap", revive(func() { SwapExactIn(cross(cur), "grin", "joy", 10, 0, dl()) }), "directly from a user")
168 mustAbort(t, "realm caller remove", revive(func() { RemoveLiquidity(cross(cur), "joy", 1, 0, 0, dl()) }), "directly from a user")
169
170 // fake coins and overflow-sized inputs
171 testing.SetRealm(testing.NewUserRealm(lp))
172 mustAbort(t, "fake coin", revive(func() { SwapExactIn(cross(cur), "grin", "fake-coin", 10, 0, dl()) }), "unknown coin")
173 mustAbort(t, "fake give", revive(func() { PlaceOrder(cross(cur), "fake-coin", 100, "grin", 100, 0) }), "unknown coin")
174 mustAbort(t, "huge amount", revive(func() { SwapExactIn(cross(cur), "grin", "joy", maxAmount+1, 0, dl()) }), "between 1 and")
175 mustAbort(t, "max uint", revive(func() { PlaceOrder(cross(cur), "grin", ^uint64(0), "joy", 1, 0) }), "between 1 and")
176 mustAbort(t, "zero", revive(func() { AddLiquidity(cross(cur), "joy", 0, 10, 0, dl()) }), "between 1 and")
177 mustAbort(t, "no pool", revive(func() { SwapExactIn(cross(cur), "grin", "cool", 10, 0, dl()) }), "no pool")
178 mustAbort(t, "coin→coin needs route", revive(func() { SwapExactIn(cross(cur), "joy", "cool", 10, 0, dl()) }), "one side must be grin")
179
180 // only the admin creates/pauses pools
181 mustAbort(t, "player CreatePool", revive(func() { CreatePool(cross(cur), "cool") }), "admin only")
182 mustAbort(t, "player pause", revive(func() { SetExchangePaused(cross(cur), true) }), "admin only")
183 mustAbort(t, "player fee withdraw", revive(func() { WithdrawProtocolFees(cross(cur), "grin") }), "admin only")
184 testing.SetRealm(testing.NewUserRealm(admin))
185 mustAbort(t, "grin pool", revive(func() { CreatePool(cross(cur), "grin") }), "hub coin")
186 mustAbort(t, "fake pool", revive(func() { CreatePool(cross(cur), "fake-coin") }), "unknown coin")
187 mustAbort(t, "dup pool", revive(func() { CreatePool(cross(cur), "joy") }), "pool exists")
188}
189
190// ── pools ────────────────────────────────────────────────────────────────────
191
192func TestEx_PoolLifecycleAndFees(cur realm, t *testing.T) {
193 restore := exOpen()
194 defer restore()
195 // seedPool: the caller must already act as `lp` (testing.SetRealm can't be
196 // switched from inside a closure). Pools are created by the admin API in
197 // TestEx_Gates; here the empty pool record is written directly.
198 seedPool := func(coin string, lp address, coinAmt, grinAmt uint64) uint64 {
199 if !pools.Has(coin) {
200 pools.Set(coin, &pool{coin: coin})
201 }
202 give(lp, coin, coinAmt)
203 give(lp, "grin", grinAmt)
204 return AddLiquidity(cross(cur), coin, coinAmt, grinAmt, 0, dl())
205 }
206 lp, tr := trader("pl-lp"), trader("pl-tr")
207 sig0 := u(coinTotal("cool")) + "/" + u(coinTotal("grin"))
208
209 // first deposit: any ratio (open economy), 1000 shares burned
210 testing.SetRealm(testing.NewUserRealm(lp))
211 minted := seedPool("cool", lp, 1000, 1500) // √(1.5M)=1224
212 p := getPool("cool")
213 if minted != 224 || p.shares != 1224 || lpSum("cool")+minLiquidity != p.shares {
214 t.Errorf("first deposit minted=%d shares=%d lpSum=%d", minted, p.shares, lpSum("cool"))
215 }
216 // under 2000 grin depth: no swaps yet
217 give(tr, "grin", 100000)
218 give(tr, "cool", 100000)
219 testing.SetRealm(testing.NewUserRealm(tr))
220 mustAbort(t, "filling pool", revive(func() { SwapExactIn(cross(cur), "grin", "cool", 10, 0, dl()) }), "not open for swaps")
221 if QuoteSwap("grin", "cool", 10) != "0" {
222 t.Errorf("quote on filling pool")
223 }
224 // later deposit at the pool ratio
225 give(lp, "cool", 10000)
226 give(lp, "grin", 15000)
227 testing.SetRealm(testing.NewUserRealm(lp))
228 AddLiquidity(cross(cur), "cool", 10000, 15000, 1, dl())
229 if p.rHub < minOpenDepthGrin || lpSum("cool")+minLiquidity != p.shares {
230 t.Errorf("after add: %s lpSum=%d", PoolInfo("cool"), lpSum("cool"))
231 }
232
233 // swap grin→cool: fee 0.5% (ceil), proto 0.15% (floor) split 50/50
234 testing.SetRealm(testing.NewUserRealm(tr))
235 q, _ := strconv.ParseUint(QuoteSwap("grin", "cool", 1000), 10, 64)
236 k0 := isqrt128(p.rCoin, p.rHub)
237 adm0, cas0 := getU(&adminFees, "grin"), getU(&casinoPool, "grin")
238 out := SwapExactIn(cross(cur), "grin", "cool", 1000, q, dl())
239 if out != q || out == 0 {
240 t.Errorf("out=%d quote=%d", out, q)
241 }
242 if getU(&adminFees, "grin")-adm0 != 0 || getU(&casinoPool, "grin")-cas0 != 1 {
243 // proto = floor(1000×15/10000) = 1 → admin floor(0.5)=0, casino 1
244 t.Errorf("fee split admin+%d casino+%d", getU(&adminFees, "grin")-adm0, getU(&casinoPool, "grin")-cas0)
245 }
246 if isqrt128(p.rCoin, p.rHub) < k0 {
247 t.Errorf("k decreased")
248 }
249 // bigger swap the other way: proto 15 → admin 7, casino 8
250 adm0, cas0 = getU(&adminFees, "cool"), getU(&casinoPool, "cool")
251 SwapExactIn(cross(cur), "cool", "grin", 10000, 1, dl())
252 if getU(&adminFees, "cool")-adm0 != 7 || getU(&casinoPool, "cool")-cas0 != 8 {
253 t.Errorf("fee split cool admin+%d casino+%d", getU(&adminFees, "cool")-adm0, getU(&casinoPool, "cool")-cas0)
254 }
255
256 // slippage revert changes nothing (validate-then-write)
257 before := stateSig("cool", "grin") + balSig([]address{tr}, []string{"cool", "grin"})
258 mustAbort(t, "minOut", revive(func() { SwapExactIn(cross(cur), "grin", "cool", 1000, 1_000_000, dl()) }), "slippage")
259 mustAbort(t, "insufficient", revive(func() { SwapExactIn(cross(cur), "grin", "cool", maxAmount, 0, dl()) }), "insufficient")
260 if after := stateSig("cool", "grin") + balSig([]address{tr}, []string{"cool", "grin"}); after != before {
261 t.Errorf("failed swap changed state:\n%s\n%s", before, after)
262 }
263
264 // LP exits fully after warm-up: gets ≥ deposit in value terms thanks to fees
265 testing.SkipHeights(200) // > 600 s
266 testing.SetRealm(testing.NewUserRealm(lp))
267 pos := lpPositions.Get("cool:" + lp.String()).(*lpPos)
268 oc, og := RemoveLiquidity(cross(cur), "cool", pos.shares, 0, 0, dl())
269 if lpPositions.Has("cool:"+lp.String()) || p.shares != minLiquidity {
270 t.Errorf("position not closed, shares=%d", p.shares)
271 }
272 if oc == 0 || og == 0 || p.rCoin == 0 || p.rHub == 0 {
273 t.Errorf("remove out %d/%d, pool left %d/%d", oc, og, p.rCoin, p.rHub)
274 }
275 // conservation across the whole test
276 _ = sig0
277 if lpSum("cool")+minLiquidity != p.shares {
278 t.Errorf("lp sum")
279 }
280}
281
282// Instant unstake and cancel must work while paused, and for banned players.
283func TestEx_PauseNeverTrapsFunds(cur realm, t *testing.T) {
284 restore := exOpen()
285 defer restore()
286 // seedPool: the caller must already act as `lp` (testing.SetRealm can't be
287 // switched from inside a closure). Pools are created by the admin API in
288 // TestEx_Gates; here the empty pool record is written directly.
289 seedPool := func(coin string, lp address, coinAmt, grinAmt uint64) uint64 {
290 if !pools.Has(coin) {
291 pools.Set(coin, &pool{coin: coin})
292 }
293 give(lp, coin, coinAmt)
294 give(lp, "grin", grinAmt)
295 return AddLiquidity(cross(cur), coin, coinAmt, grinAmt, 0, dl())
296 }
297 lp := trader("pz-lp")
298 testing.SetRealm(testing.NewUserRealm(lp))
299 seedPool("think", lp, 5000, 5000)
300 give(lp, "grin", 1000)
301 testing.SetRealm(testing.NewUserRealm(lp))
302 id := PlaceOrder(cross(cur), "grin", 100, "think", 90, 0)
303
304 testing.SetRealm(testing.NewUserRealm(admin))
305 SetExchangePaused(cross(cur), true)
306 SetPoolPaused(cross(cur), "think", true)
307 SetPaused(cross(cur), true) // whole game paused too
308 BanAddress(cross(cur), lp.String(), "test", 0)
309
310 testing.SetRealm(testing.NewUserRealm(lp))
311 mustAbort(t, "swap paused", revive(func() { SwapExactIn(cross(cur), "grin", "think", 10, 0, dl()) }), "paused")
312 mustAbort(t, "add paused", revive(func() { AddLiquidity(cross(cur), "think", 10, 10, 0, dl()) }), "paused")
313 mustAbort(t, "order paused", revive(func() { PlaceOrder(cross(cur), "grin", 100, "think", 90, 0) }), "paused")
314
315 g0 := bal(lp, "grin")
316 CancelOrder(cross(cur), id) // works paused + banned
317 if bal(lp, "grin") != g0+100 {
318 t.Errorf("cancel refund %d", bal(lp, "grin")-g0)
319 }
320 pos := lpPositions.Get("think:" + lp.String()).(*lpPos)
321 oc, og := RemoveLiquidity(cross(cur), "think", pos.shares, 1, 1, dl()) // works paused + banned
322 if oc == 0 || og == 0 {
323 t.Errorf("remove while paused gave %d/%d", oc, og)
324 }
325 // Transfer is the player's own money: works paused and banned
326 Transfer(cross(cur), "grin", trader("pz-friend").String(), "1")
327
328 // fee withdrawal is never blocked either
329 testing.SetRealm(testing.NewUserRealm(admin))
330 if getU(&adminFees, "grin") > 0 {
331 WithdrawProtocolFees(cross(cur), "grin")
332 }
333 UnbanAddress(cross(cur), lp.String())
334 SetPaused(cross(cur), false)
335 SetExchangePaused(cross(cur), false)
336 SetPoolPaused(cross(cur), "think", false)
337}
338
339// Anti-JIT: a deposit that leaves within 600 s gets no fees.
340func TestEx_FeeSkimmingJIT(cur realm, t *testing.T) {
341 restore := exOpen()
342 defer restore()
343 // seedPool: the caller must already act as `lp` (testing.SetRealm can't be
344 // switched from inside a closure). Pools are created by the admin API in
345 // TestEx_Gates; here the empty pool record is written directly.
346 seedPool := func(coin string, lp address, coinAmt, grinAmt uint64) uint64 {
347 if !pools.Has(coin) {
348 pools.Set(coin, &pool{coin: coin})
349 }
350 give(lp, coin, coinAmt)
351 give(lp, "grin", grinAmt)
352 return AddLiquidity(cross(cur), coin, coinAmt, grinAmt, 0, dl())
353 }
354 lp, jit, tr := trader("jit-lp"), trader("jit-jit"), trader("jit-tr")
355 testing.SetRealm(testing.NewUserRealm(lp))
356 seedPool("sleep", lp, 20000, 20000)
357 p := getPool("sleep")
358
359 // JIT adds 2× the pool, a burst of swaps happens, JIT leaves at once
360 give(jit, "sleep", 40000)
361 give(jit, "grin", 40000)
362 testing.SetRealm(testing.NewUserRealm(jit))
363 sh := AddLiquidity(cross(cur), "sleep", 40000, 40000, 1, dl())
364 give(tr, "grin", 50000)
365 give(tr, "sleep", 50000)
366 testing.SetRealm(testing.NewUserRealm(tr))
367 for i := 0; i < 10; i++ {
368 SwapExactIn(cross(cur), "grin", "sleep", 3000, 1, dl())
369 SwapExactIn(cross(cur), "sleep", "grin", 3000, 1, dl())
370 }
371 testing.SetRealm(testing.NewUserRealm(jit))
372 oc, og := RemoveLiquidity(cross(cur), "sleep", sh, 0, 0, dl())
373 // value at the current pool price, in grin: JIT must not gain
374 valOut := og + mulDiv(oc, p.rHub, p.rCoin)
375 valIn := 40000 + mulDiv(40000, p.rHub, p.rCoin)
376 if valOut > valIn || isqrt128(oc, og) > sh+1 {
377 t.Errorf("JIT skimmed fees: out %d > in %d (√ %d vs shares %d)", valOut, valIn, isqrt128(oc, og), sh)
378 }
379 // the long-term LP (past warm-up) keeps the fees
380 testing.SkipHeights(200)
381 testing.SetRealm(testing.NewUserRealm(lp))
382 pos := lpPositions.Get("sleep:" + lp.String()).(*lpPos)
383 oc2, og2 := RemoveLiquidity(cross(cur), "sleep", pos.shares, 0, 0, dl())
384 // IL-free measure: √(coin×grin) out vs the 19000 shares' √k at entry (1.0/share)
385 if isqrt128(oc2, og2) <= 19000 {
386 t.Errorf("LP earned nothing: %d/%d", oc2, og2)
387 }
388}
389
390// First-depositor share inflation and donation attacks.
391func TestEx_InflationAndDonation(cur realm, t *testing.T) {
392 restore := exOpen()
393 defer restore()
394 att, vic := trader("inf-att"), trader("inf-vic")
395 testing.SetRealm(testing.NewUserRealm(admin))
396 CreatePool(cross(cur), "party")
397 give(att, "party", 10_000_000)
398 give(att, "grin", 10_000_000)
399 testing.SetRealm(testing.NewUserRealm(att))
400 mustAbort(t, "dust first deposit", revive(func() { AddLiquidity(cross(cur), "party", 1000, 1000, 0, dl()) }), "too small")
401 AddLiquidity(cross(cur), "party", 1001, 1001, 0, dl()) // 1 share for the attacker
402 p := getPool("party")
403 // donation: Transfer to the realm address only credits a balance entry,
404 // reserves are internal counters and don't move.
405 realmAddr := chain.PackageAddress(realmPkgPath).String()
406 Transfer(cross(cur), "grin", realmAddr, "5000000")
407 if p.rHub != 1001 || p.rCoin != 1001 {
408 t.Errorf("donation moved reserves: %s", PoolInfo("party"))
409 }
410 // victim deposits normally and gets a proportional number of shares
411 give(vic, "party", 100000)
412 give(vic, "grin", 100000)
413 testing.SetRealm(testing.NewUserRealm(vic))
414 sh := AddLiquidity(cross(cur), "party", 100000, 100000, 1, dl())
415 if sh < 99000 {
416 t.Errorf("victim got only %d shares for 100k/100k (pool %s)", sh, PoolInfo("party"))
417 }
418 // the attacker can't extract more than they put in
419 testing.SetRealm(testing.NewUserRealm(att))
420 testing.SkipHeights(200)
421 a0c, a0g := bal(att, "party"), bal(att, "grin")
422 RemoveLiquidity(cross(cur), "party", 1, 0, 0, dl())
423 if bal(att, "party")-a0c > 1 || bal(att, "grin")-a0g > 1 {
424 t.Errorf("attacker extracted %d/%d", bal(att, "party")-a0c, bal(att, "grin")-a0g)
425 }
426}
427
428// Sandwich: the victim's minOut (quote − 1%) makes the front-run revert the victim.
429func TestEx_SandwichWithMinOut(cur realm, t *testing.T) {
430 restore := exOpen()
431 defer restore()
432 // seedPool: the caller must already act as `lp` (testing.SetRealm can't be
433 // switched from inside a closure). Pools are created by the admin API in
434 // TestEx_Gates; here the empty pool record is written directly.
435 seedPool := func(coin string, lp address, coinAmt, grinAmt uint64) uint64 {
436 if !pools.Has(coin) {
437 pools.Set(coin, &pool{coin: coin})
438 }
439 give(lp, coin, coinAmt)
440 give(lp, "grin", grinAmt)
441 return AddLiquidity(cross(cur), coin, coinAmt, grinAmt, 0, dl())
442 }
443 lp, att, vic := trader("sw-lp"), trader("sw-att"), trader("sw-vic")
444 testing.SetRealm(testing.NewUserRealm(lp))
445 seedPool("hot", lp, 100000, 100000)
446 give(vic, "grin", 10000)
447 give(att, "grin", 1_000_000)
448 testing.SetRealm(testing.NewUserRealm(vic))
449 q, _ := strconv.ParseUint(QuoteSwap("grin", "hot", 5000), 10, 64)
450 minOut := q * 99 / 100
451 // attacker front-runs with a big buy
452 testing.SetRealm(testing.NewUserRealm(att))
453 SwapExactIn(cross(cur), "grin", "hot", 50000, 1, dl())
454 testing.SetRealm(testing.NewUserRealm(vic))
455 before := stateSig("hot", "grin") + balSig([]address{vic}, []string{"hot", "grin"})
456 mustAbort(t, "sandwiched victim", revive(func() { SwapExactIn(cross(cur), "grin", "hot", 5000, minOut, dl()) }), "slippage")
457 if stateSig("hot", "grin")+balSig([]address{vic}, []string{"hot", "grin"}) != before {
458 t.Errorf("victim's failed swap changed state")
459 }
460 // open economy: the big trade itself is allowed (no impact cap)
461 testing.SetRealm(testing.NewUserRealm(att))
462 give(att, "hot", 1_000_000)
463 SwapExactIn(cross(cur), "hot", "grin", 1_000_000, 1, dl()) // dump 10× the pool
464}
465
466// Round trips and add→remove never profit (rounding favors the pool).
467func TestEx_RoundingFavorsPool(cur realm, t *testing.T) {
468 restore := exOpen()
469 defer restore()
470 // seedPool: the caller must already act as `lp` (testing.SetRealm can't be
471 // switched from inside a closure). Pools are created by the admin API in
472 // TestEx_Gates; here the empty pool record is written directly.
473 seedPool := func(coin string, lp address, coinAmt, grinAmt uint64) uint64 {
474 if !pools.Has(coin) {
475 pools.Set(coin, &pool{coin: coin})
476 }
477 give(lp, coin, coinAmt)
478 give(lp, "grin", grinAmt)
479 return AddLiquidity(cross(cur), coin, coinAmt, grinAmt, 0, dl())
480 }
481 lp, tr := trader("rd-lp"), trader("rd-tr")
482 testing.SetRealm(testing.NewUserRealm(lp))
483 seedPool("wow", lp, 50000, 70000)
484 testing.SetRealm(testing.NewUserRealm(tr))
485 for amt := uint64(1); amt <= 3000; amt = amt*3 + 1 {
486 give(tr, "grin", amt)
487 g0 := bal(tr, "grin")
488 w0 := bal(tr, "wow")
489 if q := QuoteSwap("grin", "wow", amt); q == "0" {
490 continue
491 }
492 out := SwapExactIn(cross(cur), "grin", "wow", amt, 1, dl())
493 if QuoteSwap("wow", "grin", out) != "0" {
494 SwapExactIn(cross(cur), "wow", "grin", out, 0+1, dl())
495 }
496 if bal(tr, "grin")+bal(tr, "wow") > g0+w0 && bal(tr, "grin") > g0 {
497 t.Errorf("round trip of %d profited: grin %d→%d", amt, g0, bal(tr, "grin"))
498 }
499 }
500 // add then remove immediately never returns more than deposited
501 give(tr, "wow", 777)
502 give(tr, "grin", 999)
503 c0, g0 := bal(tr, "wow"), bal(tr, "grin")
504 sh := AddLiquidity(cross(cur), "wow", 777, 999, 1, dl())
505 RemoveLiquidity(cross(cur), "wow", sh, 0, 0, dl())
506 if bal(tr, "wow") > c0 || bal(tr, "grin") > g0 {
507 t.Errorf("add/remove profited: %d/%d → %d/%d", c0, g0, bal(tr, "wow"), bal(tr, "grin"))
508 }
509}
510
511// ── order book ───────────────────────────────────────────────────────────────
512
513func TestEx_OrderBookLifecycle(cur realm, t *testing.T) {
514 restore := exOpen()
515 defer restore()
516 mk, tk, other := trader("ob-mk"), trader("ob-tk"), trader("ob-ot")
517 give(mk, "grin", 10000)
518 give(mk, "rock", 1000)
519 give(tk, "rock", 10000)
520 give(tk, "grin", 10000)
521
522 // "I want 10 rock, I pay 100 grin" — escrow + 2 grin post fee (1/1)
523 testing.SetRealm(testing.NewUserRealm(mk))
524 adm0, cas0 := getU(&adminFees, "grin"), getU(&casinoPool, "grin")
525 g0 := bal(mk, "grin")
526 id := PlaceOrder(cross(cur), "grin", 100, "rock", 10, 0)
527 if bal(mk, "grin") != g0-102 || getU(&adminFees, "grin")-adm0 != 1 || getU(&casinoPool, "grin")-cas0 != 1 {
528 t.Errorf("place: grin %d→%d fees %d/%d", g0, bal(mk, "grin"), getU(&adminFees, "grin")-adm0, getU(&casinoPool, "grin")-cas0)
529 }
530 if !strings.Contains(OrdersOf(mk.String()), `"id":"`+u(id)+`"`) {
531 t.Errorf("OrdersOf: %s", OrdersOf(mk.String()))
532 }
533 mustAbort(t, "too small", revive(func() { PlaceOrder(cross(cur), "grin", 49, "rock", 10, 0) }), "50 grin")
534 mustAbort(t, "expiry > 7d", revive(func() { PlaceOrder(cross(cur), "grin", 100, "rock", 10, 8*86400) }), "7 days")
535 mustAbort(t, "same coin", revive(func() { PlaceOrder(cross(cur), "grin", 100, "grin", 10, 0) }), "differ")
536
537 // other players can't cancel it
538 testing.SetRealm(testing.NewUserRealm(other))
539 mustAbort(t, "cancel by other", revive(func() { CancelOrder(cross(cur), id) }), "not your order")
540
541 // self-match: the maker's own order is skipped, so nothing fills
542 testing.SetRealm(testing.NewUserRealm(mk))
543 mustAbort(t, "self match", revive(func() { FillOrders(cross(cur), "rock", "grin", 5, 1, 0, dl()) }), "slippage")
544
545 // partial fill: taker pays 4 rock (+ceil fee 1) → gets floor(4×100/10)=40 grin
546 testing.SetRealm(testing.NewUserRealm(tk))
547 r0, tg0 := bal(tk, "rock"), bal(tk, "grin")
548 mr0 := bal(mk, "rock")
549 got := FillOrders(cross(cur), "rock", "grin", 5, 40, 0, dl())
550 if got != 40 || bal(tk, "rock") != r0-5 || bal(tk, "grin") != tg0+40 || bal(mk, "rock") != mr0+4 {
551 t.Errorf("partial fill got=%d rock -%d maker +%d", got, r0-bal(tk, "rock"), bal(mk, "rock")-mr0)
552 }
553 o := findOrder(id)
554 if o == nil || o.remGive != 60 || o.remWant != 6 {
555 t.Errorf("after partial: %s", Order(mk.String(), id))
556 }
557 // next fill (6 rock) completes it; remaining 60 grin
558 got = FillOrders(cross(cur), "rock", "grin", 100, 1, 0, dl())
559 if got != 60 || findOrder(id) != nil {
560 t.Errorf("full fill got=%d order=%s", got, Order(mk.String(), id))
561 }
562 // double cancel / cancel of a filled order
563 testing.SetRealm(testing.NewUserRealm(mk))
564 mustAbort(t, "cancel filled", revive(func() { CancelOrder(cross(cur), id) }), "no such open order")
565 id2 := PlaceOrder(cross(cur), "grin", 200, "rock", 20, 0)
566 g1 := bal(mk, "grin")
567 CancelOrder(cross(cur), id2)
568 if bal(mk, "grin") != g1+200 {
569 t.Errorf("cancel refund %d", bal(mk, "grin")-g1)
570 }
571 mustAbort(t, "double cancel", revive(func() { CancelOrder(cross(cur), id2) }), "no such open order")
572}
573
574func TestEx_OrderPriorityExpiryAndSpam(cur realm, t *testing.T) {
575 restore := exOpen()
576 defer restore()
577 a, b, tk := trader("op-a"), trader("op-b"), trader("op-tk")
578 give(a, "grin", 100000)
579 give(b, "grin", 100000)
580 give(tk, "fire", 100000)
581 // a asks 2 fire per 100 grin, b asks 1 fire per 100 grin (better for takers)
582 testing.SetRealm(testing.NewUserRealm(a))
583 ida := PlaceOrder(cross(cur), "grin", 100, "fire", 2, 0)
584 testing.SetRealm(testing.NewUserRealm(b))
585 idb := PlaceOrder(cross(cur), "grin", 100, "fire", 1, 3600)
586 if !strings.HasPrefix(BookDepth("fire", "grin", 10, 0), `[{"id":"`+u(idb)+`"`) {
587 t.Errorf("best price not first: %s", BookDepth("fire", "grin", 10, 0))
588 }
589 // b's order expires: a fill skips it, refunds b, fills a instead
590 testing.SkipHeights(721) // > 3600 s
591 bg := bal(b, "grin")
592 testing.SetRealm(testing.NewUserRealm(tk))
593 got := FillOrders(cross(cur), "fire", "grin", 3, 100, 0, dl())
594 if got != 100 || findOrder(idb) != nil || bal(b, "grin") != bg+100 || findOrder(ida) != nil {
595 t.Errorf("expired handling got=%d b+%d", got, bal(b, "grin")-bg)
596 }
597 // filling an expired-only book gets nothing and reverts
598 testing.SetRealm(testing.NewUserRealm(b))
599 idb2 := PlaceOrder(cross(cur), "grin", 100, "fire", 1, 60)
600 testing.SkipHeights(20)
601 testing.SetRealm(testing.NewUserRealm(tk))
602 mustAbort(t, "expired only", revive(func() { FillOrders(cross(cur), "fire", "grin", 3, 1, 0, dl()) }), "slippage")
603 if findOrder(idb2) == nil {
604 t.Errorf("revert path must not have closed the order")
605 }
606 SweepExpiredOrders(cross(cur), "fire", "grin")
607 if findOrder(idb2) != nil {
608 t.Errorf("sweep did not close the expired order")
609 }
610 // spam: max 20 open orders per maker
611 testing.SetRealm(testing.NewUserRealm(a))
612 for i := 0; i < 20; i++ {
613 PlaceOrder(cross(cur), "grin", 50, "fire", uint64(1+i), 0)
614 }
615 mustAbort(t, "21st order", revive(func() { PlaceOrder(cross(cur), "grin", 50, "fire", 1, 0) }), "max 20")
616 // fills are bounded: ≤ 10 per call even with 20 resting orders
617 testing.SetRealm(testing.NewUserRealm(tk))
618 n0 := int(openOrderTotal)
619 FillOrders(cross(cur), "fire", "grin", 100000, 1, 50, dl())
620 if n0-int(openOrderTotal) > maxFillsPerTx {
621 t.Errorf("filled %d orders in one call", n0-int(openOrderTotal))
622 }
623}
624
625// ── router ───────────────────────────────────────────────────────────────────
626
627func TestEx_RouterBookThenPool(cur realm, t *testing.T) {
628 restore := exOpen()
629 defer restore()
630 // seedPool: the caller must already act as `lp` (testing.SetRealm can't be
631 // switched from inside a closure). Pools are created by the admin API in
632 // TestEx_Gates; here the empty pool record is written directly.
633 seedPool := func(coin string, lp address, coinAmt, grinAmt uint64) uint64 {
634 if !pools.Has(coin) {
635 pools.Set(coin, &pool{coin: coin})
636 }
637 give(lp, coin, coinAmt)
638 give(lp, "grin", grinAmt)
639 return AddLiquidity(cross(cur), coin, coinAmt, grinAmt, 0, dl())
640 }
641 lp, mk, tr := trader("rt-lp"), trader("rt-mk"), trader("rt-tr")
642 testing.SetRealm(testing.NewUserRealm(lp))
643 seedPool("seedling", lp, 100000, 100000)
644 testing.SetRealm(testing.NewUserRealm(lp))
645 seedPool("leaves", lp, 100000, 100000)
646 // a cheap ask on the book: 1000 seedling for 500 grin (pool price ~1:1)
647 give(mk, "seedling", 1000)
648 give(mk, "grin", 10)
649 testing.SetRealm(testing.NewUserRealm(mk))
650 PlaceOrder(cross(cur), "seedling", 1000, "grin", 500, 0)
651 give(tr, "grin", 5000)
652 q := QuoteRoute(tr.String(), "grin", "seedling", 2000, 0)
653 if !strings.Contains(q, `"bookFills":1`) {
654 t.Errorf("router ignored the cheaper book: %s", q)
655 }
656 testing.SetRealm(testing.NewUserRealm(tr))
657 outStr := q[strings.Index(q, `"out":"`)+7:]
658 want, _ := strconv.ParseUint(outStr[:strings.Index(outStr, `"`)], 10, 64)
659 out := SwapRoute(cross(cur), "grin", "seedling", 2000, want, 0, dl())
660 if out != want {
661 t.Errorf("route out %d != quote %d", out, want)
662 }
663 // coin → coin through two pools
664 give(tr, "leaves", 1000)
665 out2 := SwapRoute(cross(cur), "leaves", "seedling", 1000, 1, 0, dl())
666 if out2 == 0 || out2 >= 1000 {
667 t.Errorf("two-hop out %d", out2)
668 }
669 before := stateSig("seedling", "leaves", "grin")
670 mustAbort(t, "route minOut", revive(func() { SwapRoute(cross(cur), "grin", "seedling", 100, 1000, 0, dl()) }), "slippage")
671 if stateSig("seedling", "leaves", "grin") != before {
672 t.Errorf("failed route changed state")
673 }
674}
675
676// ── admin can't touch user funds ─────────────────────────────────────────────
677
678func TestEx_AdminCannotTouchUserFunds(cur realm, t *testing.T) {
679 restore := exOpen()
680 defer restore()
681 // seedPool: the caller must already act as `lp` (testing.SetRealm can't be
682 // switched from inside a closure). Pools are created by the admin API in
683 // TestEx_Gates; here the empty pool record is written directly.
684 seedPool := func(coin string, lp address, coinAmt, grinAmt uint64) uint64 {
685 if !pools.Has(coin) {
686 pools.Set(coin, &pool{coin: coin})
687 }
688 give(lp, coin, coinAmt)
689 give(lp, "grin", grinAmt)
690 return AddLiquidity(cross(cur), coin, coinAmt, grinAmt, 0, dl())
691 }
692 lp, mk := trader("ad-lp"), trader("ad-mk")
693 testing.SetRealm(testing.NewUserRealm(lp))
694 seedPool("fallen", lp, 30000, 30000)
695 give(mk, "grin", 1000)
696 testing.SetRealm(testing.NewUserRealm(mk))
697 PlaceOrder(cross(cur), "grin", 300, "fallen", 300, 0)
698 users := []address{lp, mk}
699 coins := []string{"fallen", "grin"}
700 before := stateSig(coins...) + balSig(users, coins)
701
702 // every admin entry point, called by the admin
703 testing.SetRealm(testing.NewUserRealm(admin))
704 SetExchangePaused(cross(cur), true)
705 SetExchangePaused(cross(cur), false)
706 SetPoolPaused(cross(cur), "fallen", true)
707 SetPoolPaused(cross(cur), "fallen", false)
708 SetPaused(cross(cur), true)
709 SetPaused(cross(cur), false)
710 BanAddress(cross(cur), mk.String(), "x", 0)
711 UnbanAddress(cross(cur), mk.String())
712 SetVeinMax(cross(cur), "fallen", "999999999999")
713 SetPassPrice(cross(cur), 7_000_000)
714 mustAbort(t, "migrate after finalize", revive(func() {
715 MigrateBatch(cross(cur), "steal", "balance", `[["`+admin.String()+`","grin","999999"]]`)
716 }), "finalized")
717 if after := stateSig(coins...) + balSig(users, coins); after != before {
718 t.Errorf("admin calls changed user funds:\n%s\n%s", before, after)
719 }
720 // fee withdrawal moves only the admin's own ledger
721 if getU(&adminFees, "grin") > 0 {
722 c0 := getU(&casinoPool, "grin")
723 WithdrawProtocolFees(cross(cur), "grin")
724 if getU(&casinoPool, "grin") != c0 || getU(&adminFees, "grin") != 0 {
725 t.Errorf("fee withdraw touched the casino pool")
726 }
727 }
728 // WithdrawFunds can't take donated / player GNOT — only unwithdrawn pass revenue
729 realmAddr := chain.PackageAddress(realmPkgPath)
730 testing.IssueCoins(realmAddr, chain.Coins{chain.NewCoin("ugnot", 50_000_000)})
731 owed := WithdrawableFunds()
732 mustAbort(t, "withdraw donation", revive(func() { WithdrawFunds(cross(cur), admin.String(), owed+1) }), "unwithdrawn pass revenue")
733}
734
735// ── casino pool caps ─────────────────────────────────────────────────────────
736
737func seedCasino(coin string, amt uint64) {
738 setU(&casinoPool, coin, amt)
739 casinoReserved.Remove(coin)
740 casinoDay.Remove(coin)
741}
742
743
744func TestEx_CheckedMath(t *testing.T) {
745 if !strings.Contains(panicMsg(func() { addChk(^uint64(0), 1) }), "overflow") {
746 t.Errorf("addChk")
747 }
748 if !strings.Contains(panicMsg(func() { subChk(1, 2) }), "underflow") {
749 t.Errorf("subChk")
750 }
751 if !strings.Contains(panicMsg(func() { mulDiv(^uint64(0), ^uint64(0), 1) }), "overflow") {
752 t.Errorf("mulDiv")
753 }
754 if !strings.Contains(panicMsg(func() { mulDiv(1, 1, 0) }), "division by zero") {
755 t.Errorf("mulDiv /0")
756 }
757 if !strings.Contains(panicMsg(func() { addU(&balances, "x-overflow", ^uint64(0)); addU(&balances, "x-overflow", 1) }), "overflow") {
758 t.Errorf("addU must not wrap")
759 }
760 balances.Remove("x-overflow")
761 if isqrt128(^uint64(0), ^uint64(0)) != ^uint64(0)-1 && isqrt128(^uint64(0), ^uint64(0)) != ^uint64(0) {
762 t.Errorf("isqrt128 max = %d", isqrt128(^uint64(0), ^uint64(0)))
763 }
764 for _, c := range [][2]uint64{{1, 1}, {2, 8}, {1000, 1000}, {123456789, 987654321}, {1 << 40, 1 << 40}, {maxAmount, maxAmount}} {
765 s := isqrt128(c[0], c[1])
766 if mulLess(c[0], c[1], s, s) || !mulLess(c[0], c[1], s+1, s+1) {
767 t.Errorf("isqrt128(%d×%d)=%d", c[0], c[1], s)
768 }
769 }
770 if priceKey(1, 2) >= priceKey(1, 1) || priceKey(3, 1) <= priceKey(2, 1) || len(priceKey(maxAmount, 1)) != 24 {
771 t.Errorf("priceKey ordering")
772 }
773}
774
775// ── invariant random walk ────────────────────────────────────────────────────
776
777type xrng struct{ s uint64 }
778
779func (r *xrng) next(n uint64) uint64 {
780 r.s = r.s*6364136223846793005 + 1442695040888963407
781 return (r.s >> 33) % n
782}
783
784func TestEx_InvariantRandomWalk(cur realm, t *testing.T) {
785 restore := exOpen()
786 defer restore()
787 // seedPool: the caller must already act as `lp` (testing.SetRealm can't be
788 // switched from inside a closure). Pools are created by the admin API in
789 // TestEx_Gates; here the empty pool record is written directly.
790 seedPool := func(coin string, lp address, coinAmt, grinAmt uint64) uint64 {
791 if !pools.Has(coin) {
792 pools.Set(coin, &pool{coin: coin})
793 }
794 give(lp, coin, coinAmt)
795 give(lp, "grin", grinAmt)
796 return AddLiquidity(cross(cur), coin, coinAmt, grinAmt, 0, dl())
797 }
798 coins := []string{"melt", "tear", "grin"}
799 names := []string{"iw-0", "iw-1", "iw-2", "iw-3"}
800 var us []address
801 for _, n := range names {
802 a := trader(n)
803 us = append(us, a)
804 for _, c := range coins {
805 give(a, c, 2_000_000)
806 }
807 }
808 testing.SetRealm(testing.NewUserRealm(us[0]))
809 seedPool("melt", us[0], 200000, 200000)
810 testing.SetRealm(testing.NewUserRealm(us[1]))
811 seedPool("tear", us[1], 100000, 300000)
812 tot := map[string]uint64{}
813 for _, c := range coins {
814 tot[c] = coinTotal(c)
815 }
816 rkps := map[string]uint64{"melt": rootKPerShare(getPool("melt")), "tear": rootKPerShare(getPool("tear"))}
817 type res3 struct{ c, h, s uint64 }
818 prev := map[string]res3{}
819 for _, c := range []string{"melt", "tear"} {
820 p := getPool(c)
821 prev[c] = res3{p.rCoin, p.rHub, p.shares}
822 }
823 rng := &xrng{s: 42}
824 ok, failed := 0, 0
825 for step := 0; step < 400; step++ {
826 a := us[rng.next(4)]
827 testing.SetRealm(testing.NewUserRealm(a))
828 pc := coins[rng.next(2)]
829 amt := 1 + rng.next(20000)
830 op := rng.next(7)
831 isSwap := false
832 r := revive(func() {
833 switch op {
834 case 0:
835 SwapExactIn(cross(cur), "grin", pc, amt, 1, dl())
836 isSwap = true
837 case 1:
838 SwapExactIn(cross(cur), pc, "grin", amt, 1, dl())
839 isSwap = true
840 case 2:
841 AddLiquidity(cross(cur), pc, amt, amt*2, 1, dl())
842 case 3:
843 key := pc + ":" + a.String()
844 if lpPositions.Has(key) {
845 sh := lpPositions.Get(key).(*lpPos).shares
846 RemoveLiquidity(cross(cur), pc, 1+rng.next(sh), 0, 0, dl())
847 }
848 case 4:
849 PlaceOrder(cross(cur), coins[rng.next(3)], 100+amt, coins[rng.next(3)], 1+rng.next(5000), int64(60+rng.next(3600)))
850 case 5:
851 FillOrders(cross(cur), coins[rng.next(3)], coins[rng.next(3)], amt, 1, int(1+rng.next(10)), dl())
852 case 6:
853 SwapRoute(cross(cur), coins[rng.next(3)], coins[rng.next(3)], amt, 1, int(rng.next(10)), dl())
854 }
855 })
856 if r != nil {
857 failed++
858 } else {
859 ok++
860 }
861 testing.SkipHeights(int64(rng.next(30)))
862 // I1 conservation per coin (also proves failed calls wrote nothing)
863 for _, c := range coins {
864 if got := coinTotal(c); got != tot[c] {
865 t.Fatalf("step %d op %d (%s): %s total %d != %d", step, op, errOf(r), c, got, tot[c])
866 }
867 }
868 for _, c := range []string{"melt", "tear"} {
869 p := getPool(c)
870 // I2 LP shares sum to the total (minus the burned 1000)
871 if lpSum(c)+minLiquidity != p.shares {
872 t.Fatalf("step %d: %s lpSum %d + 1000 != %d", step, c, lpSum(c), p.shares)
873 }
874 // I3 reserves never empty while shares exist
875 if p.rCoin == 0 || p.rHub == 0 {
876 t.Fatalf("step %d: %s reserves emptied", step, c)
877 }
878 // I4 a swap (shares unchanged) never decreases k — exact 128-bit check
879 pv := prev[c]
880 if p.shares == pv.s && mulLess(p.rCoin, p.rHub, pv.c, pv.h) {
881 t.Fatalf("step %d op %d: %s k decreased on a swap", step, op, c)
882 }
883 // I4b √k per share never decreases beyond isqrt's floor (≤ 1e9/S + 1 units)
884 now := rootKPerShare(p)
885 if now+rootKScale/p.shares+1 < rkps[c] {
886 t.Fatalf("step %d op %d swap=%t: %s √k/share fell %d → %d", step, op, isSwap, c, rkps[c], now)
887 }
888 rkps[c] = now
889 prev[c] = res3{p.rCoin, p.rHub, p.shares}
890 }
891 // I5 no open order is empty (can't overfill)
892 nOrders, nSide := 0, 0
893 forEachOrder(func(o *order) {
894 nOrders++
895 if o.remGive == 0 || o.remWant == 0 {
896 t.Fatalf("step %d: empty open order %d", step, o.id)
897 }
898 // I6 every open order has exactly its book entry
899 s := sideStr(sideKey(o.want, o.give))
900 e := sideEntry(o)
901 i := sideSearch(s, e[:sideSortLen]) * sideEntryLen
902 if i+sideEntryLen > len(s) || s[i:i+sideEntryLen] != e {
903 t.Fatalf("step %d: order %d missing from its book side", step, o.id)
904 }
905 })
906 sides.Iterate("", "", func(_ string, v any) bool {
907 s := v.(string)
908 nSide += len(s) / sideEntryLen
909 // I7 each side is strictly sorted (price, then id)
910 for i := sideEntryLen; i < len(s); i += sideEntryLen {
911 if s[i-sideEntryLen:i-sideEntryLen+sideSortLen] >= s[i:i+sideSortLen] {
912 t.Fatalf("step %d: book side out of order", step)
913 }
914 }
915 return false
916 })
917 if nOrders != nSide || uint64(nOrders) != openOrderTotal {
918 t.Fatalf("step %d: orders %d, book entries %d, counter %d", step, nOrders, nSide, openOrderTotal)
919 }
920 }
921 if ok < 100 {
922 t.Errorf("random walk too degenerate: %d ok, %d failed", ok, failed)
923 }
924}
925
926// ── order storage test helpers (production looks orders up per maker) ──────
927
928func forEachOrder(fn func(o *order)) {
929 makerBooks.Iterate("", "", func(maker string, _ any) bool {
930 for _, r := range makerRecs(maker) {
931 fn(decodeOrder(maker, r))
932 }
933 return false
934 })
935}
936
937func findOrder(id uint64) *order {
938 var found *order
939 forEachOrder(func(o *order) {
940 if o.id == id {
941 found = o
942 }
943 })
944 return found
945}
946
947func sideTotal() int {
948 n := 0
949 sides.Iterate("", "", func(_ string, v any) bool {
950 n += len(v.(string)) / sideEntryLen
951 return false
952 })
953 return n
954}
955
956// A book side holds ≤ maxSideOrders. When full, a worse-or-equal price is
957// refused and a better one evicts the worst order, refunding only its maker.
958// Storage stays bounded and nobody can lock a side with absurd prices.
959func TestEx_BookSideCapAndEviction(cur realm, t *testing.T) {
960 restore := exOpen()
961 defer restore()
962 var worstMaker address
963 var worstId uint64
964 n := 0
965 for m := 0; n < maxSideOrders; m++ {
966 mk := trader("cap-" + strconv.Itoa(m))
967 give(mk, "grin", 1000000)
968 testing.SetRealm(testing.NewUserRealm(mk))
969 for i := 0; i < int(maxOpenOrders) && n < maxSideOrders; i++ {
970 // price rises with n: the last one placed is the worst for takers
971 id := PlaceOrder(cross(cur), "grin", 100, "joy", uint64(100+n), 0)
972 worstMaker, worstId = mk, id
973 n++
974 }
975 }
976 if sideCount("joy", "grin") != maxSideOrders {
977 t.Fatalf("side count %d", sideCount("joy", "grin"))
978 }
979 late := trader("cap-late")
980 give(late, "grin", 10000)
981 testing.SetRealm(testing.NewUserRealm(late))
982 sig := stateSig("grin", "joy")
983 mustAbort(t, "worse price on full side", revive(func() { PlaceOrder(cross(cur), "grin", 100, "joy", 100000, 0) }), "book side full")
984 mustAbort(t, "equal-to-worst price", revive(func() { PlaceOrder(cross(cur), "grin", 100, "joy", uint64(100+maxSideOrders-1), 0) }), "book side full")
985 if stateSig("grin", "joy") != sig {
986 t.Errorf("refused order changed state")
987 }
988 wg := bal(worstMaker, "grin")
989 id := PlaceOrder(cross(cur), "grin", 100, "joy", 50, 0)
990 if sideCount("joy", "grin") != maxSideOrders || findOrder(worstId) != nil || findOrder(id) == nil {
991 t.Errorf("eviction failed: count=%d", sideCount("joy", "grin"))
992 }
993 if bal(worstMaker, "grin") != wg+100 {
994 t.Errorf("evicted maker refund %d", bal(worstMaker, "grin")-wg)
995 }
996 if !strings.HasPrefix(BookDepth("joy", "grin", 1, 0), `[{"id":"`+u(id)+`"`) {
997 t.Errorf("new best order not first: %s", BookDepth("joy", "grin", 1, 0))
998 }
999}
1000
1001func mustPanicEx(t *testing.T, what string, f func(), want string) {
1002 msg := panicMsg(f)
1003 if msg == "" || !strings.Contains(msg, want) {
1004 t.Errorf("%s: want panic containing %q, got %q", what, want, msg)
1005 }
1006}
1007
1008// No receive cap (Simion, 2026-10-11): every coin is freely transferable and
1009// tradable. A Fist-only player (hands, no tools) receives an r6 coin by Send,
1010// sends it on and swaps for more; stone traders (formerly capped at r3) buy r6
1011// by swap, list an order that WANTS r6 and receive r6 by fill.
1012func TestEx_NoReceiveCap(cur realm, t *testing.T) {
1013 restore := exOpen()
1014 defer restore()
1015 r6 := "dragon"
1016 if coinRarity[r6] != 6 {
1017 t.Fatalf("%s is not r6", r6)
1018 }
1019 lp := trader("free-lp")
1020 if !pools.Has(r6) {
1021 pools.Set(r6, &pool{coin: r6})
1022 }
1023 give(lp, r6, 200)
1024 give(lp, "grin", 20100) // 20000 staked + posting fees
1025 testing.SetRealm(testing.NewUserRealm(lp))
1026 AddLiquidity(cross(cur), r6, 100, 20000, 0, dl())
1027
1028 // Send: Fist-only player receives r6 and sends some back
1029 fist := ta("free-fist")
1030 Transfer(cross(cur), r6, fist.String(), "10")
1031 if bal(fist, r6) != 10 {
1032 t.Errorf("fist got %d %s, want 10", bal(fist, r6), r6)
1033 }
1034 testing.SetRealm(testing.NewUserRealm(fist))
1035 Transfer(cross(cur), r6, lp.String(), "3")
1036 if bal(fist, r6) != 7 {
1037 t.Errorf("fist after send-on: %d", bal(fist, r6))
1038 }
1039 // no tool gate either: the Fist-only player buys more r6 by swap
1040 give(fist, "grin", 1000)
1041 if got := SwapExactIn(cross(cur), "grin", r6, 500, 1, dl()); got == 0 {
1042 t.Errorf("fist-only swap into r6 returned 0")
1043 }
1044
1045 // Swap: stone-only trader buys r6
1046 st := trader("free-stone")
1047 give(st, "grin", 5000)
1048 testing.SetRealm(testing.NewUserRealm(st))
1049 if got := SwapExactIn(cross(cur), "grin", r6, 1000, 1, dl()); got == 0 || bal(st, r6) != got {
1050 t.Errorf("stone swap into r6: got %d bal %d", got, bal(st, r6))
1051 }
1052 // Order that wants r6 is accepted
1053 PlaceOrder(cross(cur), "grin", 600, r6, 1, 0)
1054
1055 // Fill: lp lists r6 for grin; another stone-only trader fills and receives r6
1056 testing.SetRealm(testing.NewUserRealm(lp))
1057 PlaceOrder(cross(cur), r6, 2, "grin", 500, 0)
1058 st2 := trader("free-stone2")
1059 give(st2, "grin", 1000)
1060 testing.SetRealm(testing.NewUserRealm(st2))
1061 if got := FillOrders(cross(cur), "grin", r6, 600, 1, 5, dl()); got != 2 || bal(st2, r6) != 2 {
1062 t.Errorf("fill into r6: got %d bal %d", got, bal(st2, r6))
1063 }
1064}
1065
1066// Trading is open to every account (Simion, 2026-10-11): a Fist-only player
1067// (no tools at all) swaps, posts an order, fills an order, stakes and unstakes.
1068func TestEx_FistOnlyTrades(cur realm, t *testing.T) {
1069 restore := exOpen()
1070 defer restore()
1071 lp := ta("fo-lp") // no tools either
1072 if !pools.Has("joy") {
1073 pools.Set("joy", &pool{coin: "joy"})
1074 }
1075 give(lp, "joy", 10200)
1076 give(lp, "grin", 10100)
1077 testing.SetRealm(testing.NewUserRealm(lp))
1078 AddLiquidity(cross(cur), "joy", 10000, 10000, 1, dl())
1079 PlaceOrder(cross(cur), "joy", 100, "grin", 120, 0) // for the Fist-only player to fill
1080
1081 fist := ta("fo-fist")
1082 if ownedTools.Has(fist.String()) {
1083 t.Fatalf("test player must own no tools")
1084 }
1085 give(fist, "grin", 5000)
1086 testing.SetRealm(testing.NewUserRealm(fist))
1087 // swap
1088 if got := SwapExactIn(cross(cur), "grin", "joy", 500, 1, dl()); got == 0 || bal(fist, "joy") != got {
1089 t.Errorf("swap: got %d bal %d", got, bal(fist, "joy"))
1090 }
1091 // post (other side of the book, so no self-match with the fill below)
1092 if id := PlaceOrder(cross(cur), "grin", 300, "joy", 100, 0); id == 0 || openOrderCount(fist.String()) != 1 {
1093 t.Errorf("post: id %d open %d", id, openOrderCount(fist.String()))
1094 }
1095 // fill lp's joy order
1096 j0 := bal(fist, "joy")
1097 if got := FillOrders(cross(cur), "grin", "joy", 125, 1, 5, dl()); got == 0 || bal(fist, "joy")-j0 != got {
1098 t.Errorf("fill: got %d", got)
1099 }
1100 // stake and unstake
1101 sh := AddLiquidity(cross(cur), "joy", 200, 1000, 1, dl())
1102 if sh == 0 {
1103 t.Errorf("stake minted 0 shares")
1104 }
1105 c, g := RemoveLiquidity(cross(cur), "joy", sh, 1, 1, dl())
1106 if c == 0 || g == 0 {
1107 t.Errorf("unstake returned %d joy %d grin", c, g)
1108 }
1109}