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.

bubblerumble4_test.gno

34.81 Kb · 925 lines
  1package bubblerumble4
  2
  3import (
  4	"chain"
  5	"chain/banker"
  6	"chain/runtime"
  7	"crypto/sha256"
  8	"encoding/hex"
  9	"strings"
 10	"testing"
 11	"time"
 12)
 13
 14const realmPath = "gno.land/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubblerumble4"
 15
 16var (
 17	alice = address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5")
 18	bob   = address("g1dmt3sa5ucvecxuhf3j6ne5r0e3z4x7h6c03xc0")
 19	carol = address("g1akeqsvhucjt8gf5yupyzjxsjd29wv8fayng37c")
 20	dave  = address("g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr") // also the admin
 21)
 22
 23func aborts(t *testing.T, want string, f func()) {
 24	t.Helper()
 25	r := revive(f)
 26	if r == nil {
 27		t.Fatalf("expected an abort containing %q, got none", want)
 28	}
 29	msg := ""
 30	switch e := r.(type) {
 31	case error:
 32		msg = e.Error()
 33	case string:
 34		msg = e
 35	}
 36	if !strings.Contains(msg, want) {
 37		t.Fatalf("abort %q does not contain %q", msg, want)
 38	}
 39}
 40
 41func held(who address) int64 {
 42	return banker.NewReadonlyBanker().GetCoins(who).AmountOf("ugnot")
 43}
 44
 45// fund attaches ugnot to the next call the way a transaction would. The caller
 46// is set with testing.SetRealm in the test body: it binds to the calling frame.
 47func fund(ugnot int64) {
 48	if ugnot > 0 {
 49		testing.IssueCoins(chain.PackageAddress(realmPath), chain.Coins{{"ugnot", ugnot}})
 50		testing.SetOriginSend(chain.Coins{{"ugnot", ugnot}})
 51	} else {
 52		testing.SetOriginSend(nil)
 53	}
 54}
 55
 56// heldAtReset is what the realm held when the scenario began: coins from
 57// earlier scenarios stay in the test realm after reset() forgets their pools.
 58var heldAtReset int64
 59
 60func reset() {
 61	pools = nil
 62	players = map[address]*Player{}
 63	teamPaid = map[string]int64{}
 64	teamWon = map[string]int64{}
 65	teamSize = map[string]int64{}
 66	shotsOn = true
 67	keeper, keeperSeen, commits, head, voided, late = "", time.Time{}, nil, 0, 0, 0
 68	heldAtReset = held(chain.PackageAddress(realmPath))
 69}
 70
 71// the keeper in these tests is carol; dave (the admin) names her, and she commits
 72func keeperUp(cur realm, t *testing.T, secrets ...[]byte) {
 73	t.Helper()
 74	fund(0)
 75	testing.SetRealm(testing.NewUserRealm(dave))
 76	SetKeeper(cross(cur), carol)
 77	if len(secrets) > 0 {
 78		commitSecrets(cur, secrets...)
 79	} else {
 80		fund(0)
 81		testing.SetRealm(testing.NewUserRealm(carol))
 82		Heartbeat(cross(cur))
 83	}
 84}
 85
 86func hashOf(secret []byte) string { sum := sha256.Sum256(secret); return hex.EncodeToString(sum[:]) }
 87
 88func commitSecrets(cur realm, secrets ...[]byte) {
 89	hs := []string{}
 90	for _, s := range secrets {
 91		hs = append(hs, hashOf(s))
 92	}
 93	fund(0)
 94	testing.SetRealm(testing.NewUserRealm(carol))
 95	Commit(cross(cur), strings.Join(hs, ","))
 96}
 97
 98func secret(n byte) []byte { return []byte{n, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31} }
 99
100// a pool with dave's first bid, alice's second carrying a shot; the shot is sold when sold is true (else why says
101// what the realm should have refused it for)
102func shotPool(cur realm, t *testing.T, shot int64, sold bool, why ...string) (int64, *Pool) {
103	t.Helper()
104	fund(0)
105	testing.SetRealm(testing.NewUserRealm(dave))
106	id := CreatePool(cross(cur), "shots", 3600, 1_000_000, 0)
107	p := pool(id)
108	fund(5_000_000)
109	testing.SetRealm(testing.NewUserRealm(dave))
110	Bid(cross(cur), id, "", "", 0)
111	a0 := held(alice)
112	if len(why) > 0 {
113		if got := p.shotRefusal(alice, shot); !strings.Contains(got, why[0]) {
114			t.Fatalf("refusal %q, want %q", got, why[0])
115		}
116	}
117	fund(1_414_214 + shot)
118	testing.SetRealm(testing.NewUserRealm(alice))
119	Bid(cross(cur), id, "", "", shot)
120	if p.Bids != 2 {
121		t.Fatalf("the bid did not land: %+v", *p)
122	}
123	if sold && (len(p.Shots) != 1 || p.ShotEscrow != shot) {
124		t.Fatalf("the shot was not sold: shots %d escrow %d", len(p.Shots), p.ShotEscrow)
125	}
126	// (a test's fund() issues the coins to the realm; alice's own balance only ever sees what comes back)
127	if !sold && (len(p.Shots) != 0 || p.ShotEscrow != 0 || held(alice)-a0 != shot || p.find(alice).Paid != 1_414_214) {
128		t.Fatalf("the shot should have come back: shots %d escrow %d alice %d paid %d", len(p.Shots), p.ShotEscrow, held(alice)-a0, p.find(alice).Paid)
129	}
130	return id, p
131}
132
133// The conservation check: every ugnot the realm took in during the scenario is
134// either in an open pot or credited to somebody.
135func realmHeld() int64 { return held(chain.PackageAddress(realmPath)) - heldAtReset }
136
137func owedAll() int64 {
138	var sum int64
139	for _, p := range pools {
140		if !p.Paid {
141			sum += p.Pot + p.ShotEscrow
142		}
143	}
144	for _, pl := range players {
145		sum += pl.Owed
146	}
147	return sum
148}
149
150func conserved(t *testing.T) {
151	t.Helper()
152	if realmHeld() != owedAll() {
153		t.Fatalf("realm holds %d, pots and credits account for %d", realmHeld(), owedAll())
154	}
155}
156
157func owed(a address) int64 { return player(a).Owed }
158
159func TestSplitSelfRebidFlagLockAndWithdraw(cur realm, t *testing.T) {
160	reset()
161	fund(0)
162	testing.SetRealm(testing.NewUserRealm(dave))
163	id := CreatePool(cross(cur), "split", 3600, 1_000_000, 0)
164	p := pool(id)
165	if p.Life != 10*3600 {
166		t.Fatalf("default life is ten windows: %d", p.Life)
167	}
168
169	// first bid: dividends and the inviter's cut fall back into the pot, the creator is paid 5% at once
170	d0 := held(dave)
171	fund(1_000_000)
172	testing.SetRealm(testing.NewUserRealm(alice))
173	Bid(cross(cur), id, "", "kr", 0)
174	if p.Pot != 950_000 || held(dave)-d0 != 50_000 || p.DivPaid != 0 || p.FirstBidAt.IsZero() {
175		t.Fatalf("first bid: pot %d creator +%d div %d", p.Pot, held(dave)-d0, p.DivPaid)
176	}
177
178	// bob, invited by alice, bids 2 GNOT: alice is credited all the dividends and paid 10% as inviter at once
179	a0 := held(alice)
180	fund(2_000_000)
181	testing.SetRealm(testing.NewUserRealm(bob))
182	Bid(cross(cur), id, alice.String(), "us", 0)
183	if owed(alice) != 600_000 || held(alice)-a0 != 200_000 || p.Pot != 950_000+2_000_000-600_000-200_000-100_000 {
184		t.Fatalf("second bid: alice owed %d, +%d in hand, pot %d", owed(alice), held(alice)-a0, p.Pot)
185	}
186	if player(bob).Referrer != alice || player(alice).Recruits != 1 || p.find(alice).Earned != 600_000 {
187		t.Fatalf("inviter: %+v", *player(bob))
188	}
189
190	// bob rebids on himself, switching his flag to KR: allowed; his dividends go
191	// to alice, not himself; his flag in THIS pool stays US (locked at his first bid)
192	b1 := held(bob)
193	fund(2_000_000)
194	testing.SetRealm(testing.NewUserRealm(bob))
195	Bid(cross(cur), id, "", "kr", 0)
196	if p.LastBidder != bob || p.Bids != 3 || owed(alice) != 1_200_000 || owed(bob) != 0 || b1-held(bob) != 0 {
197		t.Fatalf("self rebid: last %s bids %d alice owed %d bob owed %d", p.LastBidder, p.Bids, owed(alice), owed(bob))
198	}
199	if player(bob).Flag != "KR" || p.find(bob).Flag != "US" || p.History[2].Flag != "US" {
200		t.Fatalf("flag lock: player %s member %s history %s", player(bob).Flag, p.find(bob).Flag, p.History[2].Flag)
201	}
202	conserved(t)
203
204	// alice withdraws what she is owed; a second withdraw has nothing
205	a1 := held(alice)
206	fund(0)
207	testing.SetRealm(testing.NewUserRealm(alice))
208	if got := Withdraw(cross(cur)); got != 1_200_000 || held(alice)-a1 != 1_200_000 || owed(alice) != 0 || player(alice).Withdrawn != 1_200_000 {
209		t.Fatalf("withdraw: %d, +%d, owed %d", got, held(alice)-a1, owed(alice))
210	}
211	aborts(t, "nothing to withdraw", func() { Withdraw(cross(cur)) })
212	conserved(t)
213
214	// in a new pool, bob's locked flag is his current one
215	fund(0)
216	testing.SetRealm(testing.NewUserRealm(dave))
217	id2 := CreatePool(cross(cur), "next", 3600, 1_000_000, 0)
218	fund(1_000_000)
219	testing.SetRealm(testing.NewUserRealm(bob))
220	Bid(cross(cur), id2, "", "", 0)
221	if pool(id2).find(bob).Flag != "KR" {
222		t.Fatalf("new pool flag %s", pool(id2).find(bob).Flag)
223	}
224}
225
226func TestBoostFloor(t *testing.T) {
227	if c := ClockFor(3600, 100, 1000); c != 360 {
228		t.Fatalf("ten times the price on an hour: %d", c)
229	}
230	if c := ClockFor(600, 100, 1000); c != MinClock {
231		t.Fatalf("ten times the price on ten minutes floors at five: %d", c)
232	}
233	if c := ClockFor(60, 100, 100); c != 60 {
234		t.Fatalf("a plain bid on a one-minute window keeps the window: %d", c)
235	}
236	if c := ClockFor(60, 100, 1000); c != 60 {
237		t.Fatalf("no boost can shorten a window under the floor: %d", c)
238	}
239	if mulDiv(80_000_000_000, 3_000_000_000, 7_000_000_000) != 34_285_714_285 {
240		t.Fatal("mulDiv on numbers whose product passes int64")
241	}
242}
243
244func TestLastDaySlidesToProRata(cur realm, t *testing.T) {
245	reset()
246	fund(0)
247	testing.SetRealm(testing.NewUserRealm(dave))
248	// a one-hour window and a 90-minute life: the life ends before the second clock would
249	id := CreatePool(cross(cur), "slide", 3600, 1_000_000, 5400)
250	p := pool(id)
251	fund(1_000_000)
252	testing.SetRealm(testing.NewUserRealm(alice))
253	Bid(cross(cur), id, "", "FR", 0)
254	if p.LiveBps() != 10000 {
255		t.Fatalf("fresh pool live %d", p.LiveBps())
256	}
257	// 50 minutes pass (5s a block), bob bids exactly the price (more would shorten
258	// his clock): 2400 of 5400 seconds of life are left
259	testing.SkipHeights(600)
260	fund(1_414_214)
261	testing.SetRealm(testing.NewUserRealm(bob))
262	Bid(cross(cur), id, "", "US", 0)
263	if p.LiveBps() != 10000*2400/5400 {
264		t.Fatalf("live %d", p.LiveBps())
265	}
266	if !p.Deadline().Equal(p.LastDay()) {
267		t.Fatalf("deadline %v should be the last day %v, not the clock", p.Deadline(), p.LastDay())
268	}
269	// the last day comes with time still on bob's clock: the pool is over anyway
270	testing.SkipHeights(600)
271	if !p.Over() || p.LiveBps() != 0 {
272		t.Fatalf("over %v live %d", p.Over(), p.LiveBps())
273	}
274	pot := p.Pot
275	a0, b0 := owed(alice), owed(bob) // alice already holds dividends from bob's bid
276	fund(0)
277	testing.SetRealm(testing.NewUserRealm(carol))
278	Claim(cross(cur), id)
279	// bob (US) won alone under his flag, so the team share goes to the other bidder,
280	// alice; nothing is live, so the rest is split by paid-in
281	cut := pot * TeamShare / 100
282	rest := pot - cut
283	total := p.find(alice).Paid + p.find(bob).Paid
284	wantA := cut + rest*p.find(alice).Paid/total
285	if owed(alice)-a0 != wantA || owed(bob)-b0 != pot-wantA {
286		t.Fatalf("alice +%d (want %d) bob +%d (want %d)", owed(alice)-a0, wantA, owed(bob)-b0, pot-wantA)
287	}
288	if p.TeamPaid != cut || p.SharedPaid != rest*p.find(alice).Paid/total || p.WinnerPaid != pot-wantA {
289		t.Fatalf("bookkeeping: team %d shared %d winner %d", p.TeamPaid, p.SharedPaid, p.WinnerPaid)
290	}
291	conserved(t)
292
293	// a pool that closes early with a single bidder credits them everything
294	reset()
295	fund(0)
296	testing.SetRealm(testing.NewUserRealm(dave))
297	id = CreatePool(cross(cur), "quick", 60, 1_000_000, 7200)
298	p = pool(id)
299	fund(1_000_000)
300	testing.SetRealm(testing.NewUserRealm(alice))
301	Bid(cross(cur), id, "", "FR", 0)
302	testing.SkipHeights(13) // 65s: the clock ran out, the life barely started
303	fund(0)
304	testing.SetRealm(testing.NewUserRealm(bob))
305	Claim(cross(cur), id)
306	if owed(alice) != p.Pot || p.SharedPaid != 0 || p.TeamPaid != 0 {
307		t.Fatalf("solo: alice owed %d of %d, shared %d team %d", owed(alice), p.Pot, p.SharedPaid, p.TeamPaid)
308	}
309	conserved(t)
310}
311
312func TestFlagWar(cur realm, t *testing.T) {
313	reset()
314	fund(0)
315	testing.SetRealm(testing.NewUserRealm(dave))
316	id := CreatePool(cross(cur), "team", 3600, 1_000_000, 0)
317	p := pool(id)
318	fund(20_000_000)
319	testing.SetRealm(testing.NewUserRealm(dave))
320	Bid(cross(cur), id, "", "FR", 0)
321	fund(1_414_214)
322	testing.SetRealm(testing.NewUserRealm(alice))
323	Bid(cross(cur), id, "", "FR", 0)
324	fund(1_732_051)
325	testing.SetRealm(testing.NewUserRealm(carol))
326	Bid(cross(cur), id, "", "US", 0)
327	fund(2_000_000)
328	testing.SetRealm(testing.NewUserRealm(alice))
329	Bid(cross(cur), id, "", "", 0) // alice on top, FR
330	testing.SkipHeights(721)
331	pot := p.Pot
332	live := p.LiveBps()
333	if live == 0 || live == 10000 {
334		t.Fatalf("live %d should be partway", live)
335	}
336	a0, d0, c0 := owed(alice), owed(dave), owed(carol)
337	fund(0)
338	testing.SetRealm(testing.NewUserRealm(bob))
339	Claim(cross(cur), id)
340	// the team share goes to dave, the only OTHER FR bidder; the winner takes none of it
341	cut := pot * TeamShare / 100
342	rest := pot - cut
343	shared := rest - rest*live/10000
344	total := p.find(alice).Paid + p.find(dave).Paid + p.find(carol).Paid
345	daveShare, carolShare := shared*p.find(dave).Paid/total, shared*p.find(carol).Paid/total
346	if owed(dave)-d0 != cut+daveShare {
347		t.Fatalf("dave: +%d want team %d + share %d", owed(dave)-d0, cut, daveShare)
348	}
349	if owed(carol)-c0 != carolShare {
350		t.Fatalf("carol (US) gets only her pro-rata share: +%d want %d", owed(carol)-c0, carolShare)
351	}
352	if owed(alice)-a0 != pot-cut-daveShare-carolShare {
353		t.Fatalf("alice: +%d want %d", owed(alice)-a0, pot-cut-daveShare-carolShare)
354	}
355	if p.WinnerFlag != "FR" || teamWon["FR"] != pot || p.TeamPaid != cut || p.SharedPaid != daveShare+carolShare {
356		t.Fatalf("bookkeeping: %+v", *p)
357	}
358	conserved(t)
359	aborts(t, "already paid out", func() { Claim(cross(cur), id) })
360
361	// a private flag buys nothing: a winner nobody else flew with hands the team
362	// share to all the other bidders, pro-rata
363	reset()
364	fund(0)
365	testing.SetRealm(testing.NewUserRealm(dave))
366	id = CreatePool(cross(cur), "lone", 60, 1_000_000, 0)
367	p = pool(id)
368	fund(1_000_000)
369	testing.SetRealm(testing.NewUserRealm(alice))
370	Bid(cross(cur), id, "", "FR", 0)
371	fund(1_414_214)
372	testing.SetRealm(testing.NewUserRealm(carol))
373	Bid(cross(cur), id, "", "US", 0)
374	fund(1_732_051)
375	testing.SetRealm(testing.NewUserRealm(bob))
376	Bid(cross(cur), id, "", "AQ", 0) // bob, alone under AQ, on top
377	testing.SkipHeights(13)
378	pot = p.Pot
379	a0, c0, b0 := owed(alice), owed(carol), owed(bob)
380	fund(0)
381	testing.SetRealm(testing.NewUserRealm(dave))
382	Claim(cross(cur), id)
383	cut = pot * TeamShare / 100
384	others := p.find(alice).Paid + p.find(carol).Paid
385	// the life (ten minutes) had 65 seconds on it: a sliver of the rest is shared too
386	rest = pot - cut
387	shared = rest - rest*p.LiveBps()/10000
388	total = others + p.find(bob).Paid
389	wantA := cut*p.find(alice).Paid/others + shared*p.find(alice).Paid/total
390	wantC := cut*p.find(carol).Paid/others + shared*p.find(carol).Paid/total
391	if owed(alice)-a0 != wantA || owed(carol)-c0 != wantC || owed(bob)-b0 != pot-wantA-wantC {
392		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)
393	}
394	conserved(t)
395}
396
397func TestRosterCapAndCreateBounds(cur realm, t *testing.T) {
398	reset()
399	fund(0)
400	testing.SetRealm(testing.NewUserRealm(dave))
401	id := CreatePool(cross(cur), "cap", 3600, 1_000, 0)
402	p := pool(id)
403	p.Roster = nil
404	for i := 0; i < RosterMax; i++ {
405		p.Roster = append(p.Roster, &Member{Addr: address("g1x" + itoa(int64(i))), Paid: 1})
406	}
407	fund(1_000)
408	testing.SetRealm(testing.NewUserRealm(alice))
409	Bid(cross(cur), id, "", "", 0)
410	if len(p.Roster) != RosterMax || p.Roster[len(p.Roster)-1].Addr != alice || p.Roster[0].Addr != address("g1x1") {
411		t.Fatalf("roster cap: len %d first %s last %s", len(p.Roster), p.Roster[0].Addr, p.Roster[len(p.Roster)-1].Addr)
412	}
413	fund(0)
414	testing.SetRealm(testing.NewUserRealm(dave))
415	aborts(t, "life must be at least the window", func() { CreatePool(cross(cur), "x", 7200, 1_000_000, 3600) })
416	aborts(t, "life must be between", func() { CreatePool(cross(cur), "x", 3600, 1_000_000, 31*24*3600) })
417	id = CreatePool(cross(cur), "x", 20*24*3600, 1_000_000, 0)
418	if pool(id).Life != MaxLife {
419		t.Fatalf("ten windows past the maximum life is the maximum: %d", pool(id).Life)
420	}
421	id = CreatePool(cross(cur), "y", 10, 1_000_000, 0)
422	if pool(id).Life != MinLife {
423		t.Fatalf("ten windows under the minimum life is the minimum: %d", pool(id).Life)
424	}
425}
426
427
428func TestPkgPath(t *testing.T) {
429	if !strings.HasSuffix(pkgPath, "/bubblerumble4") || realmPath != pkgPath {
430		t.Fatalf("pkgPath %q realmPath %q", pkgPath, realmPath)
431	}
432}
433
434func TestKeeperAndCommits(cur realm, t *testing.T) {
435	reset()
436	// the keeper is named by the deployer alone
437	fund(0)
438	testing.SetRealm(testing.NewUserRealm(alice))
439	aborts(t, "only the deployer", func() { SetKeeper(cross(cur), alice) })
440	fund(0)
441	testing.SetRealm(testing.NewUserRealm(dave))
442	aborts(t, "bad address", func() { SetKeeper(cross(cur), address("nope")) })
443	// nobody commits or heartbeats but the keeper — not even the deployer
444	fund(0)
445	testing.SetRealm(testing.NewUserRealm(dave))
446	aborts(t, "only the keeper", func() { Commit(cross(cur), hashOf(secret(1))) })
447	aborts(t, "only the keeper", func() { Heartbeat(cross(cur)) })
448	keeperUp(cur, t, secret(1), secret(2))
449	if len(commits) != 2 || head != 0 || keeperSeen.IsZero() || commitHead() != hashOf(secret(1)) {
450		t.Fatalf("commits %d head %d seen %v", len(commits), head, keeperSeen)
451	}
452	fund(0)
453	testing.SetRealm(testing.NewUserRealm(alice))
454	aborts(t, "only the keeper", func() { Commit(cross(cur), hashOf(secret(3))) })
455	fund(0)
456	testing.SetRealm(testing.NewUserRealm(carol))
457	aborts(t, "64 hex", func() { Commit(cross(cur), "abc") })
458	aborts(t, "already committed", func() { Commit(cross(cur), hashOf(secret(2))) })
459	many := []string{}
460	for i := 0; i < CommitBatch+1; i++ {
461		many = append(many, hashOf(secret(byte(100+i))))
462	}
463	aborts(t, "hashes", func() { Commit(cross(cur), strings.Join(many, ",")) })
464	// the unused list, for a keeper that starts and must know what it holds
465	if c := Render("commits"); !strings.Contains(c, hashOf(secret(1))+"\",\""+hashOf(secret(2))) {
466		t.Fatalf("commits: %s", c)
467	}
468	// retire clamps, is the keeper's or the deployer's, and is no sign of life
469	seen := keeperSeen
470	testing.SkipHeights(2)
471	fund(0)
472	testing.SetRealm(testing.NewUserRealm(alice))
473	aborts(t, "only the keeper or the deployer", func() { Retire(cross(cur), 1) })
474	fund(0)
475	testing.SetRealm(testing.NewUserRealm(dave))
476	Retire(cross(cur), 5)
477	if len(commits) != 0 || keeperSeen != seen {
478		t.Fatalf("retire: %d left, seen moved %v", len(commits), keeperSeen != seen)
479	}
480	// a new keeper drops what the old one queued
481	commitSecrets(cur, secret(7))
482	fund(0)
483	testing.SetRealm(testing.NewUserRealm(dave))
484	SetKeeper(cross(cur), bob)
485	if len(commits) != 0 || keeper != bob {
486		t.Fatalf("set keeper: %d commits, keeper %s", len(commits), keeper)
487	}
488	// the queue's bound
489	reset()
490	keeperUp(cur, t)
491	for i := 0; i < MaxCommits/CommitBatch; i++ {
492		hs := []string{}
493		for j := 0; j < CommitBatch; j++ {
494			hs = append(hs, hashOf([]byte{byte(i), byte(j), 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32}))
495		}
496		fund(0)
497		testing.SetRealm(testing.NewUserRealm(carol))
498		Commit(cross(cur), strings.Join(hs, ","))
499	}
500	fund(0)
501	testing.SetRealm(testing.NewUserRealm(carol))
502	aborts(t, "at most", func() { Commit(cross(cur), hashOf(secret(9))) })
503	// compaction: past CommitCompact used ones, the queue is rebuilt from the head
504	head = CommitCompact + 1
505	fund(0)
506	testing.SetRealm(testing.NewUserRealm(carol))
507	Commit(cross(cur), hashOf(secret(9)))
508	if head != 0 || len(commits) != MaxCommits-CommitCompact-1+1 {
509		t.Fatalf("compaction: head %d len %d", head, len(commits))
510	}
511}
512
513func TestShotSoldPricedOnTake(cur realm, t *testing.T) {
514	reset()
515	keeperUp(cur, t, secret(1))
516	id, p := shotPool(cur, t, 1_000_000, true)
517	sh := p.Shots[0]
518	// the odds: on the pot a pop would pay from (the shot in, the tip out), against the most alice could take —
519	// dave alone is the team, so the 35% cut counts as hers, and nothing has slid: the take is the pot
520	potThen := p.Pot + 1_000_000 - RevealTip
521	if sh.Take != potThen || sh.Bps != ShotBps(1_000_000-RevealTip, potThen) || sh.Bps == 0 || sh.Bps > ShotMaxBps {
522		t.Fatalf("shot: %+v pot %d", *sh, p.Pot)
523	}
524	if sh.Hash != commits[0] || head != 1 || sh.CommitH != runtime.ChainHeight() || p.find(alice).Paid != 1_414_214+1_000_000 {
525		t.Fatalf("commitment: %+v head %d", *sh, head)
526	}
527	if _, cut := p.payout(alice, potThen, false); p.Take(alice, potThen) != potThen-0 || cut != potThen*TeamShare/100 {
528		t.Fatalf("take %d cut %d of %d", p.Take(alice, potThen), cut, potThen)
529	}
530	conserved(t)
531	_ = id
532}
533
534func TestShotComesBack(cur realm, t *testing.T) {
535	// no commitment free: the bid stands, the shot's money comes back
536	reset()
537	keeperUp(cur, t)
538	shotPool(cur, t, 1_000_000, false, "no commitment free")
539	// the keeper not seen for KeeperFresh: the same
540	reset()
541	keeperUp(cur, t, secret(1))
542	testing.SkipHeights(KeeperFresh/5 + 1)
543	shotPool(cur, t, 1_000_000, false, "not been seen")
544	// under MinShot
545	reset()
546	keeperUp(cur, t, secret(1))
547	shotPool(cur, t, MinShot-1, false, "smallest shot")
548	// shots off
549	reset()
550	keeperUp(cur, t, secret(1))
551	fund(0)
552	testing.SetRealm(testing.NewUserRealm(dave))
553	SetShots(cross(cur), false)
554	shotPool(cur, t, 1_000_000, false, "shots are off")
555	// the keeper itself never shoots
556	reset()
557	keeperUp(cur, t, secret(1))
558	fund(0)
559	testing.SetRealm(testing.NewUserRealm(dave))
560	id := CreatePool(cross(cur), "keeper", 3600, 1_000_000, 0)
561	p := pool(id)
562	fund(1_000_000 + 1_000_000)
563	testing.SetRealm(testing.NewUserRealm(carol))
564	Bid(cross(cur), id, "", "", 1_000_000)
565	if len(p.Shots) != 0 || p.Bids != 1 || p.find(carol).Paid != 1_000_000 {
566		t.Fatalf("the keeper's shot should come back: %+v", *p)
567	}
568	// a shot that buys no chance at all: a pot so large that MinShot is under one basis point
569	reset()
570	keeperUp(cur, t, secret(1))
571	fund(0)
572	testing.SetRealm(testing.NewUserRealm(dave))
573	id = CreatePool(cross(cur), "huge", 3600, 1_000_000, 0)
574	p = pool(id)
575	fund(2_000_000_000_000) // a 2,000,000 GNOT seed
576	testing.SetRealm(testing.NewUserRealm(dave))
577	Bid(cross(cur), id, "", "", 0)
578	fund(1_414_214 + MinShot)
579	testing.SetRealm(testing.NewUserRealm(alice))
580	Bid(cross(cur), id, "", "", MinShot)
581	if len(p.Shots) != 0 || p.ShotEscrow != 0 || p.Bids != 2 || p.find(alice).Paid != 1_414_214 {
582		t.Fatalf("a chanceless shot should come back: %+v", *p)
583	}
584	conserved(t)
585	// the winner's share slid under the floor: a 1h window, a 90-minute life; bob keeps the clock alive at 50 minutes,
586	// and 70 minutes in the live share is 2222 bps
587	reset()
588	keeperUp(cur, t, secret(1))
589	fund(0)
590	testing.SetRealm(testing.NewUserRealm(dave))
591	id = CreatePool(cross(cur), "late life", 3600, 1_000_000, 5400)
592	p = pool(id)
593	fund(1_000_000)
594	testing.SetRealm(testing.NewUserRealm(dave))
595	Bid(cross(cur), id, "", "", 0)
596	testing.SkipHeights(600) // 50 minutes
597	fund(1_414_214)
598	testing.SetRealm(testing.NewUserRealm(bob))
599	Bid(cross(cur), id, "", "", 0)
600	testing.SkipHeights(240) // 70 minutes in all
601	fund(0)
602	testing.SetRealm(testing.NewUserRealm(carol))
603	Heartbeat(cross(cur))
604	if p.LiveBps() >= LiveFloor || p.Over() {
605		t.Fatalf("live %d over %v", p.LiveBps(), p.Over())
606	}
607	if got := p.shotRefusal(alice, 1_000_000); !strings.Contains(got, "slid under a quarter") {
608		t.Fatalf("refusal %q", got)
609	}
610	fund(1_732_051 + 1_000_000)
611	testing.SetRealm(testing.NewUserRealm(alice))
612	Bid(cross(cur), id, "", "", 1_000_000)
613	if len(p.Shots) != 0 || p.ShotEscrow != 0 || p.Bids != 3 {
614		t.Fatalf("a shot in the last quarter should come back: %+v", *p)
615	}
616	conserved(t)
617}
618
619func TestRevealHitOrMiss(cur realm, t *testing.T) {
620	reset()
621	keeperUp(cur, t, secret(1))
622	id, p := shotPool(cur, t, 1_000_000, true)
623	sh := p.Shots[0]
624	s1 := hex.EncodeToString(secret(1))
625	// too early: nothing to reveal yet
626	fund(0)
627	testing.SetRealm(testing.NewUserRealm(carol))
628	aborts(t, "nothing revealed", func() { Reveal(cross(cur), itoa(id)+":1:"+s1) })
629	testing.SkipHeights(ShotDelay)
630	h := runtime.ChainHeight()
631	fund(0)
632	testing.SetRealm(testing.NewUserRealm(carol))
633	aborts(t, "nothing revealed", func() { Reveal(cross(cur), itoa(id)+":1:"+hex.EncodeToString(secret(2))) })
634	aborts(t, "pool:shot:secret", func() { Reveal(cross(cur), "1:1") })
635	aborts(t, "no such shot", func() { Reveal(cross(cur), itoa(id)+":2:"+s1) })
636	pot0, k0 := p.Pot, owed(carol)
637	want := shotHits(seedFor(id, sh, secret(1), h), sh.Bps) // the same inputs the realm hashes, in the same block
638	fund(0)
639	testing.SetRealm(testing.NewUserRealm(bob)) // anyone may reveal; the tip is still the keeper's
640	Reveal(cross(cur), itoa(id)+":1:"+s1)
641	if !sh.Resolved || sh.Void || sh.Won != want || sh.DrawH != h || sh.Revealer != bob || p.ShotEscrow != 0 || p.Pot != pot0+1_000_000-RevealTip || owed(carol)-k0 != RevealTip || p.Late != 0 {
642		t.Fatalf("revealed: %+v pot %d→%d carol +%d", *sh, pot0, p.Pot, owed(carol)-k0)
643	}
644	if want {
645		if !p.Popped || p.LastBidder != alice || !p.Over() || p.Misses != 0 || player(alice).Hits != 1 {
646			t.Fatalf("a hit pops: %+v", *p)
647		}
648	} else if p.Popped || p.LastBidder != alice || p.Misses != 1 {
649		t.Fatalf("a miss is counted and changes nothing else: %+v", *p)
650	}
651	fund(0)
652	testing.SetRealm(testing.NewUserRealm(carol))
653	aborts(t, "nothing revealed", func() { Reveal(cross(cur), itoa(id)+":1:"+s1) }) // twice: skipped
654	conserved(t)
655	// the draw itself: over every seed in a cycle, the chance is exactly bps in 10000
656	hits := 0
657	for s := uint64(0); s < 10000; s++ {
658		if shotHits(s, 32) {
659			hits++
660		}
661	}
662	if hits != 32 {
663		t.Fatalf("32 bps should hit 32 of 10000 seeds, got %d", hits)
664	}
665	// the two outcomes, made sure of: a shot that cannot miss pops the pool and Claim pays the shooter as any
666	// winner (dave, the only other bidder, is the team); one that cannot hit is a miss and the pool goes on
667	for _, sure := range []int64{10000, 0} {
668		reset()
669		keeperUp(cur, t, secret(1))
670		id, p := shotPool(cur, t, 1_000_000, true)
671		p.Shots[0].Bps = sure
672		testing.SkipHeights(ShotDelay)
673		pot0 := p.Pot
674		fund(0)
675		testing.SetRealm(testing.NewUserRealm(carol))
676		Reveal(cross(cur), itoa(id)+":1:"+hex.EncodeToString(secret(1)))
677		if sure == 0 {
678			if p.Popped || p.Misses != 1 || p.Over() || p.Pot != pot0+1_000_000-RevealTip {
679				t.Fatalf("sure miss: %+v", *p)
680			}
681			conserved(t)
682			continue
683		}
684		if !p.Popped || p.LastBidder != alice || !p.Over() || player(alice).Hits != 1 || p.Pot != pot0+1_000_000-RevealTip {
685			t.Fatalf("sure hit: %+v", *p)
686		}
687		// a bid after the pop is refunded whole, shot included
688		b0 := held(bob)
689		fund(2_000_000 + 500_000)
690		testing.SetRealm(testing.NewUserRealm(bob))
691		Bid(cross(cur), id, "", "", 500_000)
692		if held(bob)-b0 != 2_500_000 || p.Bids != 2 {
693			t.Fatalf("after the pop: bob %d, bids %d", held(bob)-b0, p.Bids)
694		}
695		fund(0)
696		testing.SetRealm(testing.NewUserRealm(bob))
697		Claim(cross(cur), id)
698		// the team's cut to dave, and the sliver of the slide the few blocks since the first bid moved his way too
699		cut := p.Pot * TeamShare / 100
700		if owed(alice) != p.Pot-cut-p.SharedPaid || p.TeamPaid != cut || p.WinnerPaid != owed(alice) || owed(dave) != cut+424_264+p.SharedPaid || p.SharedPaid > cut/100 {
701			t.Fatalf("popped payout: alice owed %d of %d, team %d, shared %d, dave %d", owed(alice), p.Pot, p.TeamPaid, p.SharedPaid, owed(dave))
702		}
703		conserved(t)
704	}
705}
706
707func TestRevealOrderAndMany(cur realm, t *testing.T) {
708	reset()
709	keeperUp(cur, t, secret(1), secret(2))
710	id, p := shotPool(cur, t, 1_000_000, true)
711	fund(1_732_051 + 1_000_000)
712	testing.SetRealm(testing.NewUserRealm(bob))
713	Bid(cross(cur), id, "", "", 1_000_000)
714	if len(p.Shots) != 2 || p.Shots[1].Hash != commits[1] || head != 2 || p.ShotEscrow != 2_000_000 {
715		t.Fatalf("two shots: %d escrow %d head %d", len(p.Shots), p.ShotEscrow, head)
716	}
717	testing.SkipHeights(ShotDelay)
718	s1, s2 := hex.EncodeToString(secret(1)), hex.EncodeToString(secret(2))
719	// the second alone waits for the first
720	fund(0)
721	testing.SetRealm(testing.NewUserRealm(carol))
722	aborts(t, "nothing revealed", func() { Reveal(cross(cur), itoa(id)+":2:"+s2) })
723	if p.Shots[1].Resolved {
724		t.Fatal("out of order")
725	}
726	// both in one call, in order; a hit on the first closes the pool and voids the second as closed
727	fund(0)
728	testing.SetRealm(testing.NewUserRealm(carol))
729	Reveal(cross(cur), itoa(id)+":1:"+s1+","+itoa(id)+":2:"+s2)
730	if !p.Shots[0].Resolved || !p.Shots[1].Resolved || p.ShotEscrow != 0 {
731		t.Fatalf("both: %+v %+v", *p.Shots[0], *p.Shots[1])
732	}
733	if p.Shots[0].Won && (!p.Shots[1].Void || p.Voided != 1) {
734		t.Fatalf("a shot after the pop is void: %+v", *p.Shots[1])
735	}
736	if !p.Shots[0].Won && p.Shots[1].Void {
737		t.Fatalf("the second should have been drawn: %+v", *p.Shots[1])
738	}
739	conserved(t)
740	// and made sure of: a first shot that cannot miss voids the second as closed, in the same call
741	reset()
742	keeperUp(cur, t, secret(1), secret(2))
743	id, p = shotPool(cur, t, 1_000_000, true)
744	fund(1_732_051 + 1_000_000)
745	testing.SetRealm(testing.NewUserRealm(bob))
746	Bid(cross(cur), id, "", "", 1_000_000)
747	p.Shots[0].Bps = 10000
748	testing.SkipHeights(ShotDelay)
749	pot0 := p.Pot
750	fund(0)
751	testing.SetRealm(testing.NewUserRealm(carol))
752	Reveal(cross(cur), itoa(id)+":1:"+s1+","+itoa(id)+":2:"+s2)
753	if !p.Popped || !p.Shots[1].Void || p.Voided != 1 || p.ShotEscrow != 0 || p.Pot != pot0+2_000_000-RevealTip {
754		t.Fatalf("pop then void: %+v %+v pot %d→%d", *p.Shots[0], *p.Shots[1], pot0, p.Pot)
755	}
756	conserved(t)
757}
758
759func TestVoidsAndLate(cur realm, t *testing.T) {
760	// unrevealed past the window: Settle voids it into the pot, no tip, a miss
761	reset()
762	keeperUp(cur, t, secret(1), secret(2), secret(3))
763	id, p := shotPool(cur, t, 1_000_000, true)
764	testing.SkipHeights(ShotDelay + ShotWindow + 1)
765	pot0, k0 := p.Pot, owed(carol)
766	fund(0)
767	testing.SetRealm(testing.NewUserRealm(bob))
768	Settle(cross(cur), id)
769	sh := p.Shots[0]
770	if !sh.Resolved || !sh.Void || sh.Won || p.Misses != 1 || p.Voided != 1 || voided != 1 || p.Pot != pot0+1_000_000 || p.ShotEscrow != 0 || owed(carol) != k0 {
771		t.Fatalf("void: %+v pot %d→%d", *sh, pot0, p.Pot)
772	}
773	// a reveal that comes late is a void too, whoever sends it
774	fund(1_732_051 + 1_000_000)
775	testing.SetRealm(testing.NewUserRealm(alice))
776	Bid(cross(cur), id, "", "", 1_000_000)
777	testing.SkipHeights(ShotDelay + ShotWindow + 1)
778	fund(0)
779	testing.SetRealm(testing.NewUserRealm(carol))
780	aborts(t, "nothing revealed", func() { Reveal(cross(cur), itoa(id)+":2:"+hex.EncodeToString(secret(2))) }) // on chain the abort reverts its own settling
781	fund(0)
782	testing.SetRealm(testing.NewUserRealm(carol))
783	Settle(cross(cur), id)
784	if !p.Shots[1].Void || p.Voided != 2 || p.Misses != 2 || owed(carol) != k0 {
785		t.Fatalf("late reveal: %+v", *p.Shots[1])
786	}
787	// the last block of the window still draws, and counts as late
788	fund(2_000_000 + 1_000_000)
789	testing.SetRealm(testing.NewUserRealm(alice))
790	Bid(cross(cur), id, "", "", 1_000_000)
791	testing.SkipHeights(ShotDelay + ShotWindow)
792	fund(0)
793	testing.SetRealm(testing.NewUserRealm(carol))
794	Reveal(cross(cur), itoa(id)+":3:"+hex.EncodeToString(secret(3)))
795	if !p.Shots[2].Resolved || p.Shots[2].Void || p.Late != 1 || late != 1 || p.Shots[2].DrawH != runtime.ChainHeight() {
796		t.Fatalf("window end: %+v late %d", *p.Shots[2], p.Late)
797	}
798	conserved(t)
799
800	// a pool that closes with a shot pending: Claim settles it first, into the pot the winner takes
801	reset()
802	keeperUp(cur, t, secret(4))
803	fund(0)
804	testing.SetRealm(testing.NewUserRealm(dave))
805	id = CreatePool(cross(cur), "closing", 60, 1_000_000, 0)
806	p = pool(id)
807	fund(1_000_000)
808	testing.SetRealm(testing.NewUserRealm(dave))
809	Bid(cross(cur), id, "", "", 0)
810	// a shot that would be due: planted, since MinClock keeps a real one clear of the close
811	p.Shots = append(p.Shots, &Shot{N: 1, Bidder: alice, Paid: 500_000, Take: p.Pot, Bps: 100, CommitH: runtime.ChainHeight(), Hash: commits[0]})
812	p.ShotEscrow += 500_000
813	testing.IssueCoins(chain.PackageAddress(realmPath), chain.Coins{{"ugnot", 500_000}})
814	testing.SkipHeights(13) // the clock ran out
815	fund(0)
816	testing.SetRealm(testing.NewUserRealm(carol))
817	aborts(t, "nothing revealed", func() { Reveal(cross(cur), itoa(id)+":1:"+hex.EncodeToString(secret(4))) }) // the pool closed first
818	fund(0)
819	testing.SetRealm(testing.NewUserRealm(carol))
820	Settle(cross(cur), id)
821	if !p.Shots[0].Void || p.ShotEscrow != 0 || p.Pot != 950_000+500_000 { // the creator's 5% left the pot at the bid
822		t.Fatalf("closed first: %+v pot %d", *p.Shots[0], p.Pot)
823	}
824	fund(0)
825	testing.SetRealm(testing.NewUserRealm(bob))
826	Claim(cross(cur), id)
827	if owed(dave) != p.Pot || !p.Paid {
828		t.Fatalf("the pot, shot included, is the winner's: owed %d of %d", owed(dave), p.Pot)
829	}
830	conserved(t)
831}
832
833func TestTakeCountsTeamAndSlide(cur realm, t *testing.T) {
834	reset()
835	keeperUp(cur, t, secret(1))
836	// alice under TV, bob under TV with a small bid, dave flagless: a pop for alice pays her the pot less what has
837	// slid; the team's 35% goes to bob, and the take counts it as hers
838	fund(0)
839	testing.SetRealm(testing.NewUserRealm(dave))
840	id := CreatePool(cross(cur), "team", 3600, 1_000_000, 7200)
841	p := pool(id)
842	fund(1_000_000)
843	testing.SetRealm(testing.NewUserRealm(dave))
844	Bid(cross(cur), id, "", "", 0)
845	fund(1_414_214)
846	testing.SetRealm(testing.NewUserRealm(bob))
847	Bid(cross(cur), id, "", "TV", 0)
848	fund(1_732_051)
849	testing.SetRealm(testing.NewUserRealm(alice))
850	Bid(cross(cur), id, "", "TV", 0)
851	pot := p.Pot
852	toWinner, cut := p.payout(alice, pot, false)
853	if cut != pot*TeamShare/100 || toWinner != pot-cut || p.Take(alice, pot) != pot {
854		t.Fatalf("fresh pool: toWinner %d cut %d take %d of %d", toWinner, cut, p.Take(alice, pot), pot)
855	}
856	// halfway through the life, half the rest has slid to the roster by paid-in: the take is the pot less the others' part
857	testing.SkipHeights(720)
858	if p.LiveBps() > 5000 || p.LiveBps() < 4990 {
859		t.Fatalf("live %d", p.LiveBps())
860	}
861	toWinner, cut = p.payout(alice, pot, false)
862	rest := pot - cut
863	shared := rest - mulDiv(rest, p.LiveBps(), 10000)
864	var total, others int64
865	for _, m := range p.Roster {
866		total += m.Paid
867		if m.Addr != alice {
868			others += mulDiv(shared, m.Paid, total)
869		}
870	}
871	_ = others
872	if p.Take(alice, pot) != toWinner+cut || toWinner >= pot-cut || p.Take(alice, pot) <= pot/2 {
873		t.Fatalf("slid: toWinner %d cut %d take %d of %d", toWinner, cut, p.Take(alice, pot), pot)
874	}
875	// a shooter alone on the roster takes everything
876	reset()
877	keeperUp(cur, t, secret(1))
878	fund(0)
879	testing.SetRealm(testing.NewUserRealm(dave))
880	id = CreatePool(cross(cur), "alone", 3600, 1_000_000, 0)
881	p = pool(id)
882	fund(1_000_000 + 1_000_000)
883	testing.SetRealm(testing.NewUserRealm(alice))
884	Bid(cross(cur), id, "", "", 1_000_000)
885	if len(p.Shots) != 1 || p.Shots[0].Take != p.Pot+1_000_000-RevealTip {
886		t.Fatalf("alone: %+v pot %d", *p.Shots[0], p.Pot)
887	}
888	conserved(t)
889}
890
891func TestRender(cur realm, t *testing.T) {
892	reset()
893	keeperUp(cur, t, secret(1))
894	fund(0)
895	testing.SetRealm(testing.NewUserRealm(dave))
896	id := CreatePool(cross(cur), "r", 3600, 1_000_000, 0)
897	fund(1_000_000)
898	testing.SetRealm(testing.NewUserRealm(alice))
899	Bid(cross(cur), id, "", "FR", 0)
900	fund(1_414_214 + 200_000)
901	testing.SetRealm(testing.NewUserRealm(bob))
902	Bid(cross(cur), id, alice.String(), "US", 200_000)
903	all := Render("json")
904	for _, want := range []string{"\"split\":{\"pot\":55,\"div\":30,\"ref\":10,\"creator\":5,\"team\":35,\"roster\":100,\"shotMaxBps\":100,\"shotFair\":8000,\"shotDelay\":3,\"shotWindow\":2,\"minClock\":300,\"minShot\":100000,\"revealTip\":20000,\"keeperFresh\":300,\"liveFloor\":2500}", "\"shotsOn\":true", "\"keeper\":\"" + carol.String() + "\"", "\"keeperSeen\":", "\"commitsFree\":0,\"commitHead\":\"\",\"voided\":0,\"late\":0", "\"bidders\":2", "\"flag\":\"US\"", "\"liveBps\":", "\"lastDay\":", "\"shots\":1,\"misses\":0,\"voided\":0,\"late\":0,\"pending\":1,\"escrow\":200000", "\"height\":"} {
905		if !strings.Contains(all, want) {
906			t.Errorf("json missing %s in %s", want, all)
907		}
908	}
909	one := Render("json/1")
910	for _, want := range []string{"\"shot\":200000", "\"shotlog\":[{\"n\":1", "\"commit\":\"" + hashOf(secret(1)) + "\"", "\"revealAt\":", "\"revealBy\":", "\"hit\":false,\"void\":false", "\"take\":"} {
911		if !strings.Contains(one, want) {
912			t.Errorf("detail missing %s in %s", want, one)
913		}
914	}
915	me := Render("me/" + alice.String())
916	if !strings.Contains(me, "\"owed\":424264") || !strings.Contains(me, "\"withdrawn\":0") {
917		t.Errorf("me: %s", me)
918	}
919	if !strings.Contains(Render(""), "Bubble Rumble v4") || !strings.Contains(Render("1"), "a 0.2 GNOT shot") || !strings.Contains(Render("1"), "unrevealed: 0") {
920		t.Errorf("markdown: %s", Render("1"))
921	}
922	if !strings.Contains(Render("commits"), "\"commits\":[]") {
923		t.Errorf("commits: %s", Render("commits"))
924	}
925}