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.

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}