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}