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.

bubblerumble3_test.gno

20.84 Kb · 585 lines
  1package bubblerumble3
  2
  3import (
  4	"chain"
  5	"chain/banker"
  6	"chain/runtime"
  7	"strings"
  8	"testing"
  9)
 10
 11const realmPath = "gno.land/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubblerumble3"
 12
 13var (
 14	alice = address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5")
 15	bob   = address("g1dmt3sa5ucvecxuhf3j6ne5r0e3z4x7h6c03xc0")
 16	carol = address("g1akeqsvhucjt8gf5yupyzjxsjd29wv8fayng37c")
 17	dave  = address("g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr") // also the admin
 18)
 19
 20func aborts(t *testing.T, want string, f func()) {
 21	t.Helper()
 22	r := revive(f)
 23	if r == nil {
 24		t.Fatalf("expected an abort containing %q, got none", want)
 25	}
 26	msg := ""
 27	switch e := r.(type) {
 28	case error:
 29		msg = e.Error()
 30	case string:
 31		msg = e
 32	}
 33	if !strings.Contains(msg, want) {
 34		t.Fatalf("abort %q does not contain %q", msg, want)
 35	}
 36}
 37
 38func held(who address) int64 {
 39	return banker.NewReadonlyBanker().GetCoins(who).AmountOf("ugnot")
 40}
 41
 42// fund attaches ugnot to the next call the way a transaction would. The caller
 43// is set with testing.SetRealm in the test body: it binds to the calling frame.
 44func fund(ugnot int64) {
 45	if ugnot > 0 {
 46		testing.IssueCoins(chain.PackageAddress(realmPath), chain.Coins{{"ugnot", ugnot}})
 47		testing.SetOriginSend(chain.Coins{{"ugnot", ugnot}})
 48	} else {
 49		testing.SetOriginSend(nil)
 50	}
 51}
 52
 53// heldAtReset is what the realm held when the scenario began: coins from
 54// earlier scenarios stay in the test realm after reset() forgets their pools.
 55var heldAtReset int64
 56
 57func reset() {
 58	pools = nil
 59	players = map[address]*Player{}
 60	teamPaid = map[string]int64{}
 61	teamWon = map[string]int64{}
 62	teamSize = map[string]int64{}
 63	shotsOn = true
 64	heldAtReset = held(chain.PackageAddress(realmPath))
 65}
 66
 67// The conservation check: every ugnot the realm took in during the scenario is
 68// either in an open pot or credited to somebody.
 69func realmHeld() int64 { return held(chain.PackageAddress(realmPath)) - heldAtReset }
 70
 71func owedAll() int64 {
 72	var sum int64
 73	for _, p := range pools {
 74		if !p.Paid {
 75			sum += p.Pot
 76		}
 77	}
 78	for _, pl := range players {
 79		sum += pl.Owed
 80	}
 81	return sum
 82}
 83
 84func conserved(t *testing.T) {
 85	t.Helper()
 86	if realmHeld() != owedAll() {
 87		t.Fatalf("realm holds %d, pots and credits account for %d", realmHeld(), owedAll())
 88	}
 89}
 90
 91func owed(a address) int64 { return player(a).Owed }
 92
 93func TestSplitSelfRebidFlagLockAndWithdraw(cur realm, t *testing.T) {
 94	reset()
 95	fund(0)
 96	testing.SetRealm(testing.NewUserRealm(dave))
 97	id := CreatePool(cross(cur), "split", 3600, 1_000_000, 0)
 98	p := pool(id)
 99	if p.Life != 10*3600 {
100		t.Fatalf("default life is ten windows: %d", p.Life)
101	}
102
103	// first bid: dividends and the inviter's cut fall back into the pot, the creator is paid 5% at once
104	d0 := held(dave)
105	fund(1_000_000)
106	testing.SetRealm(testing.NewUserRealm(alice))
107	Bid(cross(cur), id, "", "kr", 0)
108	if p.Pot != 950_000 || held(dave)-d0 != 50_000 || p.DivPaid != 0 || p.FirstBidAt.IsZero() {
109		t.Fatalf("first bid: pot %d creator +%d div %d", p.Pot, held(dave)-d0, p.DivPaid)
110	}
111
112	// bob, invited by alice, bids 2 GNOT: alice is credited all the dividends and paid 10% as inviter at once
113	a0 := held(alice)
114	fund(2_000_000)
115	testing.SetRealm(testing.NewUserRealm(bob))
116	Bid(cross(cur), id, alice.String(), "us", 0)
117	if owed(alice) != 600_000 || held(alice)-a0 != 200_000 || p.Pot != 950_000+2_000_000-600_000-200_000-100_000 {
118		t.Fatalf("second bid: alice owed %d, +%d in hand, pot %d", owed(alice), held(alice)-a0, p.Pot)
119	}
120	if player(bob).Referrer != alice || player(alice).Recruits != 1 || p.find(alice).Earned != 600_000 {
121		t.Fatalf("inviter: %+v", *player(bob))
122	}
123
124	// bob rebids on himself, switching his flag to KR: allowed; his dividends go
125	// to alice, not himself; his flag in THIS pool stays US (locked at his first bid)
126	b1 := held(bob)
127	fund(2_000_000)
128	testing.SetRealm(testing.NewUserRealm(bob))
129	Bid(cross(cur), id, "", "kr", 0)
130	if p.LastBidder != bob || p.Bids != 3 || owed(alice) != 1_200_000 || owed(bob) != 0 || b1-held(bob) != 0 {
131		t.Fatalf("self rebid: last %s bids %d alice owed %d bob owed %d", p.LastBidder, p.Bids, owed(alice), owed(bob))
132	}
133	if player(bob).Flag != "KR" || p.find(bob).Flag != "US" || p.History[2].Flag != "US" {
134		t.Fatalf("flag lock: player %s member %s history %s", player(bob).Flag, p.find(bob).Flag, p.History[2].Flag)
135	}
136	conserved(t)
137
138	// alice withdraws what she is owed; a second withdraw has nothing
139	a1 := held(alice)
140	fund(0)
141	testing.SetRealm(testing.NewUserRealm(alice))
142	if got := Withdraw(cross(cur)); got != 1_200_000 || held(alice)-a1 != 1_200_000 || owed(alice) != 0 || player(alice).Withdrawn != 1_200_000 {
143		t.Fatalf("withdraw: %d, +%d, owed %d", got, held(alice)-a1, owed(alice))
144	}
145	aborts(t, "nothing to withdraw", func() { Withdraw(cross(cur)) })
146	conserved(t)
147
148	// in a new pool, bob's locked flag is his current one
149	fund(0)
150	testing.SetRealm(testing.NewUserRealm(dave))
151	id2 := CreatePool(cross(cur), "next", 3600, 1_000_000, 0)
152	fund(1_000_000)
153	testing.SetRealm(testing.NewUserRealm(bob))
154	Bid(cross(cur), id2, "", "", 0)
155	if pool(id2).find(bob).Flag != "KR" {
156		t.Fatalf("new pool flag %s", pool(id2).find(bob).Flag)
157	}
158}
159
160func TestBoostFloor(t *testing.T) {
161	if c := ClockFor(3600, 100, 1000); c != 360 {
162		t.Fatalf("ten times the price on an hour: %d", c)
163	}
164	if c := ClockFor(600, 100, 1000); c != MinClock {
165		t.Fatalf("ten times the price on ten minutes floors at five: %d", c)
166	}
167	if c := ClockFor(60, 100, 100); c != 60 {
168		t.Fatalf("a plain bid on a one-minute window keeps the window: %d", c)
169	}
170	if c := ClockFor(60, 100, 1000); c != 60 {
171		t.Fatalf("no boost can shorten a window under the floor: %d", c)
172	}
173	if mulDiv(80_000_000_000, 3_000_000_000, 7_000_000_000) != 34_285_714_285 {
174		t.Fatal("mulDiv on numbers whose product passes int64")
175	}
176}
177
178func TestLastDaySlidesToProRata(cur realm, t *testing.T) {
179	reset()
180	fund(0)
181	testing.SetRealm(testing.NewUserRealm(dave))
182	// a one-hour window and a 90-minute life: the life ends before the second clock would
183	id := CreatePool(cross(cur), "slide", 3600, 1_000_000, 5400)
184	p := pool(id)
185	fund(1_000_000)
186	testing.SetRealm(testing.NewUserRealm(alice))
187	Bid(cross(cur), id, "", "FR", 0)
188	if p.LiveBps() != 10000 {
189		t.Fatalf("fresh pool live %d", p.LiveBps())
190	}
191	// 50 minutes pass (5s a block), bob bids exactly the price (more would shorten
192	// his clock): 2400 of 5400 seconds of life are left
193	testing.SkipHeights(600)
194	fund(1_414_214)
195	testing.SetRealm(testing.NewUserRealm(bob))
196	Bid(cross(cur), id, "", "US", 0)
197	if p.LiveBps() != 10000*2400/5400 {
198		t.Fatalf("live %d", p.LiveBps())
199	}
200	if !p.Deadline().Equal(p.LastDay()) {
201		t.Fatalf("deadline %v should be the last day %v, not the clock", p.Deadline(), p.LastDay())
202	}
203	// the last day comes with time still on bob's clock: the pool is over anyway
204	testing.SkipHeights(600)
205	if !p.Over() || p.LiveBps() != 0 {
206		t.Fatalf("over %v live %d", p.Over(), p.LiveBps())
207	}
208	pot := p.Pot
209	a0, b0 := owed(alice), owed(bob) // alice already holds dividends from bob's bid
210	fund(0)
211	testing.SetRealm(testing.NewUserRealm(carol))
212	Claim(cross(cur), id)
213	// bob (US) won alone under his flag, so the team share goes to the other bidder,
214	// alice; nothing is live, so the rest is split by paid-in
215	cut := pot * TeamShare / 100
216	rest := pot - cut
217	total := p.find(alice).Paid + p.find(bob).Paid
218	wantA := cut + rest*p.find(alice).Paid/total
219	if owed(alice)-a0 != wantA || owed(bob)-b0 != pot-wantA {
220		t.Fatalf("alice +%d (want %d) bob +%d (want %d)", owed(alice)-a0, wantA, owed(bob)-b0, pot-wantA)
221	}
222	if p.TeamPaid != cut || p.SharedPaid != rest*p.find(alice).Paid/total || p.WinnerPaid != pot-wantA {
223		t.Fatalf("bookkeeping: team %d shared %d winner %d", p.TeamPaid, p.SharedPaid, p.WinnerPaid)
224	}
225	conserved(t)
226
227	// a pool that closes early with a single bidder credits them everything
228	reset()
229	fund(0)
230	testing.SetRealm(testing.NewUserRealm(dave))
231	id = CreatePool(cross(cur), "quick", 60, 1_000_000, 7200)
232	p = pool(id)
233	fund(1_000_000)
234	testing.SetRealm(testing.NewUserRealm(alice))
235	Bid(cross(cur), id, "", "FR", 0)
236	testing.SkipHeights(13) // 65s: the clock ran out, the life barely started
237	fund(0)
238	testing.SetRealm(testing.NewUserRealm(bob))
239	Claim(cross(cur), id)
240	if owed(alice) != p.Pot || p.SharedPaid != 0 || p.TeamPaid != 0 {
241		t.Fatalf("solo: alice owed %d of %d, shared %d team %d", owed(alice), p.Pot, p.SharedPaid, p.TeamPaid)
242	}
243	conserved(t)
244}
245
246func TestFlagWar(cur realm, t *testing.T) {
247	reset()
248	fund(0)
249	testing.SetRealm(testing.NewUserRealm(dave))
250	id := CreatePool(cross(cur), "team", 3600, 1_000_000, 0)
251	p := pool(id)
252	fund(20_000_000)
253	testing.SetRealm(testing.NewUserRealm(dave))
254	Bid(cross(cur), id, "", "FR", 0)
255	fund(1_414_214)
256	testing.SetRealm(testing.NewUserRealm(alice))
257	Bid(cross(cur), id, "", "FR", 0)
258	fund(1_732_051)
259	testing.SetRealm(testing.NewUserRealm(carol))
260	Bid(cross(cur), id, "", "US", 0)
261	fund(2_000_000)
262	testing.SetRealm(testing.NewUserRealm(alice))
263	Bid(cross(cur), id, "", "", 0) // alice on top, FR
264	testing.SkipHeights(721)
265	pot := p.Pot
266	live := p.LiveBps()
267	if live == 0 || live == 10000 {
268		t.Fatalf("live %d should be partway", live)
269	}
270	a0, d0, c0 := owed(alice), owed(dave), owed(carol)
271	fund(0)
272	testing.SetRealm(testing.NewUserRealm(bob))
273	Claim(cross(cur), id)
274	// the team share goes to dave, the only OTHER FR bidder; the winner takes none of it
275	cut := pot * TeamShare / 100
276	rest := pot - cut
277	shared := rest - rest*live/10000
278	total := p.find(alice).Paid + p.find(dave).Paid + p.find(carol).Paid
279	daveShare, carolShare := shared*p.find(dave).Paid/total, shared*p.find(carol).Paid/total
280	if owed(dave)-d0 != cut+daveShare {
281		t.Fatalf("dave: +%d want team %d + share %d", owed(dave)-d0, cut, daveShare)
282	}
283	if owed(carol)-c0 != carolShare {
284		t.Fatalf("carol (US) gets only her pro-rata share: +%d want %d", owed(carol)-c0, carolShare)
285	}
286	if owed(alice)-a0 != pot-cut-daveShare-carolShare {
287		t.Fatalf("alice: +%d want %d", owed(alice)-a0, pot-cut-daveShare-carolShare)
288	}
289	if p.WinnerFlag != "FR" || teamWon["FR"] != pot || p.TeamPaid != cut || p.SharedPaid != daveShare+carolShare {
290		t.Fatalf("bookkeeping: %+v", *p)
291	}
292	conserved(t)
293	aborts(t, "already paid out", func() { Claim(cross(cur), id) })
294
295	// a private flag buys nothing: a winner nobody else flew with hands the team
296	// share to all the other bidders, pro-rata
297	reset()
298	fund(0)
299	testing.SetRealm(testing.NewUserRealm(dave))
300	id = CreatePool(cross(cur), "lone", 60, 1_000_000, 0)
301	p = pool(id)
302	fund(1_000_000)
303	testing.SetRealm(testing.NewUserRealm(alice))
304	Bid(cross(cur), id, "", "FR", 0)
305	fund(1_414_214)
306	testing.SetRealm(testing.NewUserRealm(carol))
307	Bid(cross(cur), id, "", "US", 0)
308	fund(1_732_051)
309	testing.SetRealm(testing.NewUserRealm(bob))
310	Bid(cross(cur), id, "", "AQ", 0) // bob, alone under AQ, on top
311	testing.SkipHeights(13)
312	pot = p.Pot
313	a0, c0, b0 := owed(alice), owed(carol), owed(bob)
314	fund(0)
315	testing.SetRealm(testing.NewUserRealm(dave))
316	Claim(cross(cur), id)
317	cut = pot * TeamShare / 100
318	others := p.find(alice).Paid + p.find(carol).Paid
319	// the life (ten minutes) had 65 seconds on it: a sliver of the rest is shared too
320	rest = pot - cut
321	shared = rest - rest*p.LiveBps()/10000
322	total = others + p.find(bob).Paid
323	wantA := cut*p.find(alice).Paid/others + shared*p.find(alice).Paid/total
324	wantC := cut*p.find(carol).Paid/others + shared*p.find(carol).Paid/total
325	if owed(alice)-a0 != wantA || owed(carol)-c0 != wantC || owed(bob)-b0 != pot-wantA-wantC {
326		t.Fatalf("lone flag: alice +%d (want %d) carol +%d (want %d) bob +%d", owed(alice)-a0, wantA, owed(carol)-c0, wantC, owed(bob)-b0)
327	}
328	conserved(t)
329}
330
331func TestShots(cur realm, t *testing.T) {
332	reset()
333	fund(0)
334	testing.SetRealm(testing.NewUserRealm(dave))
335	id := CreatePool(cross(cur), "shots", 3600, 1_000_000, 0)
336	p := pool(id)
337	fund(5_000_000)
338	testing.SetRealm(testing.NewUserRealm(dave))
339	Bid(cross(cur), id, "", "", 0)
340
341	// the shot must leave the price covered, and must buy some chance
342	fund(1_000_000)
343	testing.SetRealm(testing.NewUserRealm(alice))
344	aborts(t, "for the bid", func() { Bid(cross(cur), id, "", "", 500_000) })
345	fund(1_414_214 + 1)
346	testing.SetRealm(testing.NewUserRealm(alice))
347	aborts(t, "too small for any chance", func() { Bid(cross(cur), id, "", "", 1) })
348	heldAtReset += 1_000_000 + 1_414_215 // coins attached to the two aborted calls never reach a real realm
349
350	// a 0.1 GNOT shot at a ~6 GNOT pot: 0.8 × 0.1/6.7 ≈ 1.2%, capped at 1%; the shot goes into the pot whole
351	pot0 := p.Pot
352	fund(1_414_214 + 100_000)
353	testing.SetRealm(testing.NewUserRealm(alice))
354	Bid(cross(cur), id, "", "", 100_000)
355	if len(p.Shots) != 1 || p.Shots[0].Bps != ShotMaxBps || p.Shots[0].Resolved {
356		t.Fatalf("shot: %+v", *p.Shots[0])
357	}
358	// dividends (30%) credited to dave, the inviter's 10% back into the pot, creator 5% out, the shot in whole
359	if toPot := int64(1_414_214 - 424_264 - 141_421 - 70_710 + 141_421); p.Pot != pot0+toPot+100_000 || owed(dave) != 424_264 {
360		t.Fatalf("pot %d, dave owed %d", p.Pot, owed(dave))
361	}
362	sh := p.Shots[0]
363	if sh.Prize != p.Pot || sh.Prize > ShotCap || p.find(alice).Paid != 1_414_214+100_000 {
364		t.Fatalf("prize %d pot %d paid %d", sh.Prize, p.Pot, p.find(alice).Paid)
365	}
366	// the odds of a small shot: 0.02 GNOT at 5 GNOT is 0.8 × 0.02/5 = 0.32%
367	if bps := ShotBps(20_000, 5_000_000); bps != 32 {
368		t.Fatalf("bps %d", bps)
369	}
370	// not drawn before the delay
371	Draw(cross(cur), id)
372	if sh.Resolved {
373		t.Fatal("drawn too early")
374	}
375	testing.SkipHeights(ShotDelay)
376	h := runtime.ChainHeight()
377	fund(0)
378	testing.SetRealm(testing.NewUserRealm(bob))
379	want := shotHits(seedFor(id, sh, h), sh.Bps) // the same inputs the realm hashes
380	Draw(cross(cur), id)
381	if !sh.Resolved || sh.DrawH != h || sh.Won != want {
382		t.Fatalf("drawn: %+v want hit=%v", *sh, want)
383	}
384	if want {
385		if !p.Popped || p.LastBidder != alice || !p.Over() || p.Misses != 0 {
386			t.Fatalf("a hit pops: %+v", *p)
387		}
388	} else if p.Popped || p.LastBidder != alice || p.Misses != 1 {
389		t.Fatalf("a miss is counted and changes nothing else: %+v", *p)
390	}
391	Draw(cross(cur), id) // a second draw is a no-op
392	if p.Misses > 1 {
393		t.Fatal("drawn twice")
394	}
395	conserved(t)
396
397	// the draw itself: over every seed in a cycle, the chance is exactly bps in 10000
398	hits := 0
399	for s := uint64(0); s < 10000; s++ {
400		if shotHits(s, 32) {
401			hits++
402		}
403	}
404	if hits != 32 {
405		t.Fatalf("32 bps should hit 32 of 10000 seeds, got %d", hits)
406	}
407
408	// a shot nobody draws in its window is void: counted as a miss, never a hit
409	reset()
410	fund(0)
411	testing.SetRealm(testing.NewUserRealm(dave))
412	id = CreatePool(cross(cur), "late", 3600, 1_000_000, 0)
413	p = pool(id)
414	fund(5_000_000)
415	testing.SetRealm(testing.NewUserRealm(dave))
416	Bid(cross(cur), id, "", "", 0)
417	fund(1_414_214 + 1_000_000)
418	testing.SetRealm(testing.NewUserRealm(alice))
419	Bid(cross(cur), id, "", "", 1_000_000) // 1% anyway
420	testing.SkipHeights(ShotDelay + ShotWindow + 1)
421	Draw(cross(cur), id)
422	if !p.Shots[0].Resolved || p.Shots[0].Won || p.Misses != 1 || p.Popped {
423		t.Fatalf("void: %+v misses %d", *p.Shots[0], p.Misses)
424	}
425	// the last block of the window still draws
426	fund(1_732_051 + 1_000_000)
427	testing.SetRealm(testing.NewUserRealm(alice))
428	Bid(cross(cur), id, "", "", 1_000_000)
429	testing.SkipHeights(ShotDelay + ShotWindow)
430	Draw(cross(cur), id)
431	if !p.Shots[1].Resolved || p.Shots[1].DrawH != runtime.ChainHeight() {
432		t.Fatalf("window end: %+v", *p.Shots[1])
433	}
434	conserved(t)
435
436	// the switch: only the admin, and a shot is refused while it is off
437	fund(0)
438	testing.SetRealm(testing.NewUserRealm(alice))
439	aborts(t, "only the deployer", func() { SetShots(cross(cur), false) })
440	fund(0)
441	testing.SetRealm(testing.NewUserRealm(dave))
442	SetShots(cross(cur), false)
443	fund(2_000_000 + 1_000_000)
444	testing.SetRealm(testing.NewUserRealm(alice))
445	aborts(t, "shots are off", func() { Bid(cross(cur), id, "", "", 1_000_000) })
446	if !strings.Contains(Render("json"), "\"shotsOn\":false") {
447		t.Error("json should say shots are off")
448	}
449}
450
451func TestSliceOnABigPot(cur realm, t *testing.T) {
452	reset()
453	fund(0)
454	testing.SetRealm(testing.NewUserRealm(dave))
455	id := CreatePool(cross(cur), "big", 3600, 1_000_000, 0)
456	p := pool(id)
457	fund(50 * ShotCap) // 500 GNOT
458	testing.SetRealm(testing.NewUserRealm(dave))
459	Bid(cross(cur), id, "", "", 0)
460	fund(1_414_214 + 1_000_000) // a 1 GNOT shot: 0.8 × 1/10 = 8%, capped at 1% of a 10 GNOT slice
461	testing.SetRealm(testing.NewUserRealm(alice))
462	Bid(cross(cur), id, "", "", 1_000_000)
463	sh := p.Shots[0]
464	if sh.Prize != ShotCap || sh.Bps != ShotMaxBps {
465		t.Fatalf("shot at a big pot: %+v", *sh)
466	}
467	testing.SkipHeights(ShotDelay)
468	h := runtime.ChainHeight()
469	fund(0)
470	testing.SetRealm(testing.NewUserRealm(bob))
471	pot0, a0 := p.Pot, owed(alice)
472	hit := shotHits(seedFor(id, sh, h), sh.Bps)
473	Draw(cross(cur), id)
474	if hit {
475		if p.Popped || p.Pot != pot0-ShotCap || owed(alice)-a0 != ShotCap || p.ShotPaid != ShotCap {
476			t.Fatalf("slice: %+v alice +%d", *p, owed(alice)-a0)
477		}
478	} else if p.Pot != pot0 || owed(alice) != a0 || p.Misses != 1 {
479		t.Fatalf("a miss at a big pot changes nothing but the tally: misses %d", p.Misses)
480	}
481	conserved(t)
482}
483
484func TestBidAfterPopRefunds(cur realm, t *testing.T) {
485	reset()
486	fund(0)
487	testing.SetRealm(testing.NewUserRealm(dave))
488	id := CreatePool(cross(cur), "pop", 3600, 1_000_000, 0)
489	p := pool(id)
490	fund(1_000_000)
491	testing.SetRealm(testing.NewUserRealm(dave))
492	Bid(cross(cur), id, "", "", 0)
493	// a shot that cannot miss is due: the next bid draws it, finds the pool popped, and refunds
494	p.Shots = append(p.Shots, &Shot{N: 1, Bidder: alice, Paid: 1, Prize: p.Pot, Bps: 10000, CommitH: runtime.ChainHeight() - ShotDelay})
495	b0 := held(bob)
496	fund(2_000_000)
497	testing.SetRealm(testing.NewUserRealm(bob))
498	Bid(cross(cur), id, "", "", 0)
499	if !p.Popped || p.LastBidder != alice || p.Bids != 1 || held(bob)-b0 != 2_000_000 { // refunded in full
500		t.Fatalf("after pop: %+v bob %d→%d", *p, b0, held(bob))
501	}
502	conserved(t)
503	// and the pop credits alice as the winner; dave, the only other bidder, is the team
504	fund(0)
505	testing.SetRealm(testing.NewUserRealm(carol))
506	Claim(cross(cur), id)
507	cut := p.Pot * TeamShare / 100
508	if owed(alice) != p.Pot-cut || p.TeamPaid != cut || owed(dave) != cut {
509		t.Fatalf("popped payout: alice owed %d of %d, team %d, dave owed %d", owed(alice), p.Pot, p.TeamPaid, owed(dave))
510	}
511	conserved(t)
512
513	// a closed, unpopped pool refuses a bid outright
514	reset()
515	fund(0)
516	testing.SetRealm(testing.NewUserRealm(dave))
517	id = CreatePool(cross(cur), "closed", 60, 1_000_000, 0)
518	fund(1_000_000)
519	testing.SetRealm(testing.NewUserRealm(dave))
520	Bid(cross(cur), id, "", "", 0)
521	testing.SkipHeights(13)
522	fund(2_000_000)
523	testing.SetRealm(testing.NewUserRealm(bob))
524	aborts(t, "pool is closed", func() { Bid(cross(cur), id, "", "", 0) })
525}
526
527func TestRosterCapAndCreateBounds(cur realm, t *testing.T) {
528	reset()
529	fund(0)
530	testing.SetRealm(testing.NewUserRealm(dave))
531	id := CreatePool(cross(cur), "cap", 3600, 1_000, 0)
532	p := pool(id)
533	p.Roster = nil
534	for i := 0; i < RosterMax; i++ {
535		p.Roster = append(p.Roster, &Member{Addr: address("g1x" + itoa(int64(i))), Paid: 1})
536	}
537	fund(1_000)
538	testing.SetRealm(testing.NewUserRealm(alice))
539	Bid(cross(cur), id, "", "", 0)
540	if len(p.Roster) != RosterMax || p.Roster[len(p.Roster)-1].Addr != alice || p.Roster[0].Addr != address("g1x1") {
541		t.Fatalf("roster cap: len %d first %s last %s", len(p.Roster), p.Roster[0].Addr, p.Roster[len(p.Roster)-1].Addr)
542	}
543	fund(0)
544	testing.SetRealm(testing.NewUserRealm(dave))
545	aborts(t, "life must be at least the window", func() { CreatePool(cross(cur), "x", 7200, 1_000_000, 3600) })
546	aborts(t, "life must be between", func() { CreatePool(cross(cur), "x", 3600, 1_000_000, 31*24*3600) })
547	id = CreatePool(cross(cur), "x", 20*24*3600, 1_000_000, 0)
548	if pool(id).Life != MaxLife {
549		t.Fatalf("ten windows past the maximum life is the maximum: %d", pool(id).Life)
550	}
551	id = CreatePool(cross(cur), "y", 10, 1_000_000, 0)
552	if pool(id).Life != MinLife {
553		t.Fatalf("ten windows under the minimum life is the minimum: %d", pool(id).Life)
554	}
555}
556
557func TestRender(cur realm, t *testing.T) {
558	reset()
559	fund(0)
560	testing.SetRealm(testing.NewUserRealm(dave))
561	id := CreatePool(cross(cur), "r", 3600, 1_000_000, 0)
562	fund(1_000_000)
563	testing.SetRealm(testing.NewUserRealm(alice))
564	Bid(cross(cur), id, "", "FR", 0)
565	fund(1_414_214 + 200_000)
566	testing.SetRealm(testing.NewUserRealm(bob))
567	Bid(cross(cur), id, alice.String(), "US", 200_000)
568	all := Render("json")
569	for _, want := range []string{"\"split\":{\"pot\":55,\"div\":30,\"ref\":10,\"creator\":5,\"team\":35,\"roster\":100,\"shotCap\":10000000,\"shotMaxBps\":100,\"shotFair\":8000,\"shotDelay\":3,\"shotWindow\":2,\"minClock\":300}", "\"shotsOn\":true", "\"bidders\":2", "\"flag\":\"US\"", "\"liveBps\":", "\"lastDay\":", "\"shots\":1,\"misses\":0,\"pending\":1", "\"height\":"} {
570		if !strings.Contains(all, want) {
571			t.Errorf("json missing %s in %s", want, all)
572		}
573	}
574	one := Render("json/1")
575	if !strings.Contains(one, "\"shot\":200000") || !strings.Contains(one, "\"shotlog\":[{\"n\":1") || !strings.Contains(one, "\"hit\":false") {
576		t.Errorf("detail: %s", one)
577	}
578	me := Render("me/" + alice.String())
579	if !strings.Contains(me, "\"owed\":424264") || !strings.Contains(me, "\"withdrawn\":0") {
580		t.Errorf("me: %s", me)
581	}
582	if !strings.Contains(Render(""), "last day") || !strings.Contains(Render("1"), "a 0.2 GNOT shot") {
583		t.Errorf("markdown: %s", Render("1"))
584	}
585}