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.

connect4_test.gno

17.85 Kb · 516 lines
  1package connect4_v3
  2
  3import (
  4	"chain"
  5	"chain/banker"
  6	"chain/runtime"
  7	"strconv"
  8	"testing"
  9
 10	"gno.land/p/nt/avl/v0"
 11	"gno.land/p/nt/ownable/v0"
 12	"gno.land/p/nt/testutils/v0"
 13	"gno.land/p/nt/uassert/v0"
 14)
 15
 16const stake = int64(10_000_000)
 17
 18var (
 19	alice   = testutils.TestAddress("alice")
 20	bob     = testutils.TestAddress("bob")
 21	carol   = testutils.TestAddress("carol")
 22	admin   = address("g1cvr48r7l7lkmvp77cr6zg2zhu26jgfwr0y8pew")
 23	pkgAddr = chain.PackageAddress("gno.land/r/samcrew/connect4")
 24)
 25
 26func reset() {
 27	games, active, stats = avl.Tree{}, avl.Tree{}, avl.Tree{}
 28	nextID, fee, feesCollected = 0, 100_000, 0
 29	topWins, topGnot = nil, nil
 30	owner = ownable.NewWithAddress(admin)
 31	testSession = false
 32}
 33
 34// pay sets the ugnot sent with the next call and credits the realm with it,
 35// mirroring what the chain does. testing.SetRealm must be called in the same
 36// frame that makes the cross call, so tests call it inline rather than here.
 37func pay(amount int64) {
 38	if amount == 0 {
 39		testing.SetOriginSend(nil)
 40		return
 41	}
 42	coins := chain.Coins{{"ugnot", amount}}
 43	testing.SetOriginSend(coins)
 44	testing.IssueCoins(pkgAddr, coins)
 45}
 46
 47func balance(addr address) int64 {
 48	return banker.NewReadonlyBanker().GetCoin(addr, "ugnot")
 49}
 50
 51// phrase returns a passphrase unique to the next game id.
 52func phrase() string { return "four random words " + strconv.Itoa(nextID+1) }
 53
 54// commit returns the commitment for phrase().
 55func commit() string { return sha256Hex(phrase()) }
 56
 57// accepted returns a game between alice (creator) and bob awaiting Reveal,
 58// and its passphrase.
 59func accepted(cur realm) (*Game, string) {
 60	p := phrase()
 61	testing.SetRealm(testing.NewUserRealm(alice))
 62	pay(stake)
 63	id := Offer(cross(cur), "", 10, sha256Hex(p))
 64	testing.SetRealm(testing.NewUserRealm(bob))
 65	pay(stake)
 66	Accept(cross(cur), id)
 67	return mustGet(id), p
 68}
 69
 70// started returns a revealed Playing game between alice (creator) and bob.
 71func started(cur realm) *Game {
 72	g, p := accepted(cur)
 73	testing.SetRealm(testing.NewUserRealm(alice))
 74	pay(0)
 75	Reveal(cross(cur), g.ID, p)
 76	return g
 77}
 78
 79// move plays column for whoever is on turn.
 80func move(cur realm, g *Game, column int) {
 81	testing.SetRealm(testing.NewUserRealm(g.player(g.Turn)))
 82	pay(0)
 83	Play(cross(cur), g.ID, column, g.Moves)
 84}
 85
 86// expireClock advances block time past the 90s move clock (SkipHeights is 5s/block).
 87func expireClock() { testing.SkipHeights(19) }
 88
 89func TestWinPaysPotMinusFee(cur realm, t *testing.T) {
 90	reset()
 91	g := started(cur)
 92	first := g.player(g.Turn)
 93	before := balance(first)
 94	for _, c := range []int{1, 2, 1, 2, 1, 2, 1} { // first mover stacks column 1
 95		move(cur, g, c)
 96	}
 97	uassert.Equal(t, "won", g.Status.String())
 98	uassert.Equal(t, first, g.Winner)
 99	uassert.Equal(t, 2*stake-100_000, balance(first)-before)
100	uassert.Equal(t, int64(100_000), feesCollected)
101	uassert.Equal(t, 1, statsOf(first).Wins)
102	uassert.Equal(t, 2*stake-100_000, statsOf(first).GnotWon)
103	uassert.Equal(t, 1, statsOf(g.player(3-g.Turn)).Games)
104	uassert.Equal(t, 0, active.Size())
105}
106
107func TestOfferRejects(cur realm, t *testing.T) {
108	reset()
109	testing.SetRealm(testing.NewUserRealm(alice))
110	pay(minStake - 1)
111	uassert.AbortsContains(t, cur, "stake below minimum", func() { Offer(cross(cur), "", 10, commit()) })
112	testing.SetRealm(testing.NewUserRealm(alice))
113	pay(stake)
114	uassert.AbortsContains(t, cur, "validFor", func() { Offer(cross(cur), "", 0, commit()) })
115	testing.SetRealm(testing.NewUserRealm(alice))
116	pay(stake)
117	uassert.AbortsContains(t, cur, "validFor", func() { Offer(cross(cur), "", 61, commit()) })
118	testing.SetRealm(testing.NewUserRealm(alice))
119	pay(stake)
120	uassert.AbortsContains(t, cur, "yourself", func() { Offer(cross(cur), alice, 10, commit()) })
121	testing.SetRealm(testing.NewUserRealm(alice))
122	pay(0)
123	uassert.AbortsContains(t, cur, "send exactly one", func() { Offer(cross(cur), "", 10, commit()) })
124	testing.SetRealm(testing.NewUserRealm(alice))
125	testing.SetOriginSend(chain.Coins{{"ugnot", stake}, {"foo", 1}})
126	uassert.AbortsContains(t, cur, "send exactly one", func() { Offer(cross(cur), "", 10, commit()) })
127}
128
129func TestAcceptRejects(cur realm, t *testing.T) {
130	reset()
131	testing.SetRealm(testing.NewUserRealm(alice))
132	pay(stake)
133	id := Offer(cross(cur), bob, 1, commit())
134	testing.SetRealm(testing.NewUserRealm(alice))
135	pay(stake)
136	uassert.AbortsContains(t, cur, "own offer", func() { Accept(cross(cur), id) })
137	testing.SetRealm(testing.NewUserRealm(carol))
138	pay(stake)
139	uassert.AbortsContains(t, cur, "reserved", func() { Accept(cross(cur), id) })
140	testing.SetRealm(testing.NewUserRealm(bob))
141	pay(stake - 1)
142	uassert.AbortsContains(t, cur, "must send exactly", func() { Accept(cross(cur), id) })
143	testing.SetRealm(testing.NewUserRealm(bob))
144	pay(stake + 1)
145	uassert.AbortsContains(t, cur, "must send exactly", func() { Accept(cross(cur), id) })
146	testing.SkipHeights(13) // 65s > 1 minute
147	testing.SetRealm(testing.NewUserRealm(bob))
148	pay(stake)
149	uassert.AbortsContains(t, cur, "expired", func() { Accept(cross(cur), id) })
150	testing.SetRealm(testing.NewUserRealm(bob))
151	pay(stake)
152	uassert.AbortsContains(t, cur, "not found", func() { Accept(cross(cur), 99) })
153}
154
155func TestPlayRejects(cur realm, t *testing.T) {
156	reset()
157	g := started(cur)
158	testing.SetRealm(testing.NewUserRealm(g.player(3 - g.Turn)))
159	pay(0)
160	uassert.AbortsContains(t, cur, "not your turn", func() { Play(cross(cur), g.ID, 1, g.Moves) })
161	testing.SetRealm(testing.NewUserRealm(carol))
162	pay(0)
163	uassert.AbortsContains(t, cur, "not your turn", func() { Play(cross(cur), g.ID, 1, g.Moves) })
164	testing.SetRealm(testing.NewUserRealm(g.player(g.Turn)))
165	pay(0)
166	uassert.AbortsContains(t, cur, "column must be", func() { Play(cross(cur), g.ID, 0, g.Moves) })
167	testing.SetRealm(testing.NewUserRealm(g.player(g.Turn)))
168	pay(0)
169	uassert.AbortsContains(t, cur, "column must be", func() { Play(cross(cur), g.ID, 8, g.Moves) })
170	expireClock()
171	testing.SetRealm(testing.NewUserRealm(g.player(g.Turn)))
172	pay(0)
173	uassert.AbortsContains(t, cur, "clock ran out", func() { Play(cross(cur), g.ID, 1, g.Moves) })
174}
175
176func TestFullColumnRejected(cur realm, t *testing.T) {
177	reset()
178	g := started(cur)
179	for i := 0; i < rows; i++ {
180		move(cur, g, 4) // alternating players never make 4 in a column
181	}
182	testing.SetRealm(testing.NewUserRealm(g.player(g.Turn)))
183	pay(0)
184	uassert.AbortsContains(t, cur, "column is full", func() { Play(cross(cur), g.ID, 4, g.Moves) })
185}
186
187// The creator revealed, then the first mover never moved.
188func TestTimeoutBeforeFirstMoveIsVoid(cur realm, t *testing.T) {
189	reset()
190	g := started(cur)
191	testing.SetRealm(testing.NewUserRealm(carol))
192	pay(0)
193	uassert.AbortsContains(t, cur, "has not run out", func() { ClaimTimeout(cross(cur), g.ID) })
194	a, b := balance(alice), balance(bob)
195	expireClock()
196	testing.SetRealm(testing.NewUserRealm(carol))
197	pay(0) // anyone may settle
198	ClaimTimeout(cross(cur), g.ID)
199	uassert.Equal(t, "void", g.Status.String())
200	uassert.Equal(t, stake, balance(alice)-a)
201	uassert.Equal(t, stake, balance(bob)-b)
202	uassert.Equal(t, int64(0), feesCollected)
203	uassert.Equal(t, 0, stats.Size())
204}
205
206func TestTimeoutAfterMoveForfeits(cur realm, t *testing.T) {
207	reset()
208	g := started(cur)
209	mover := g.player(g.Turn)
210	loser := g.player(3 - g.Turn)
211	move(cur, g, 1)
212	expireClock()
213	before := balance(mover)
214	testing.SetRealm(testing.NewUserRealm(mover))
215	pay(0)
216	ClaimTimeout(cross(cur), g.ID)
217	uassert.Equal(t, "won", g.Status.String())
218	uassert.Equal(t, mover, g.Winner)
219	uassert.Equal(t, 2*stake-100_000, balance(mover)-before)
220	uassert.Equal(t, int64(100_000), feesCollected)
221	uassert.Equal(t, 1, statsOf(loser).Games)
222}
223
224func TestResign(cur realm, t *testing.T) {
225	reset()
226	g := started(cur)
227	testing.SetRealm(testing.NewUserRealm(carol))
228	pay(0)
229	uassert.AbortsContains(t, cur, "not a player", func() { Resign(cross(cur), g.ID) })
230	before := balance(bob)
231	testing.SetRealm(testing.NewUserRealm(alice))
232	pay(0)
233	Resign(cross(cur), g.ID)
234	uassert.Equal(t, "won", g.Status.String())
235	uassert.Equal(t, bob, g.Winner)
236	uassert.Equal(t, 2*stake-100_000, balance(bob)-before)
237	uassert.Equal(t, int64(100_000), feesCollected)
238	uassert.Equal(t, 1, statsOf(alice).Games)
239}
240
241func TestCancel(cur realm, t *testing.T) {
242	reset()
243	testing.SetRealm(testing.NewUserRealm(alice))
244	pay(stake)
245	id := Offer(cross(cur), "", 1, commit())
246	testing.SetRealm(testing.NewUserRealm(bob))
247	pay(0)
248	uassert.AbortsContains(t, cur, "only the creator", func() { Cancel(cross(cur), id) })
249	before := balance(alice)
250	testing.SetRealm(testing.NewUserRealm(alice))
251	pay(0)
252	Cancel(cross(cur), id) // creator, before expiry
253	uassert.Equal(t, "cancelled", mustGet(id).Status.String())
254	uassert.Equal(t, stake, balance(alice)-before)
255
256	// Once expired, anyone may cancel; the refund still goes to the creator.
257	testing.SetRealm(testing.NewUserRealm(alice))
258	pay(stake)
259	id = Offer(cross(cur), "", 1, commit())
260	testing.SkipHeights(13) // 65s > 1 minute
261	before = balance(alice)
262	testing.SetRealm(testing.NewUserRealm(carol))
263	pay(0)
264	Cancel(cross(cur), id)
265	uassert.Equal(t, "cancelled", mustGet(id).Status.String())
266	uassert.Equal(t, stake, balance(alice)-before)
267
268	g := started(cur)
269	testing.SetRealm(testing.NewUserRealm(alice))
270	pay(0)
271	uassert.AbortsContains(t, cur, "not open", func() { Cancel(cross(cur), g.ID) })
272}
273
274func TestDrawRefundsBoth(cur realm, t *testing.T) {
275	reset()
276	g := started(cur)
277	// Fill every cell but the top of column 7 with a pattern that has no
278	// four in a row; player 1 owns the last cell.
279	for c := 0; c < cols; c++ {
280		for r := 0; r < rows; r++ {
281			g.Board[c][r] = byte(1 + (c/2+r)%2)
282		}
283	}
284	g.Board[6][5] = 0
285	g.Moves, g.Turn = cols*rows-1, 1
286	a, b := balance(alice), balance(bob)
287	move(cur, g, 7)
288	uassert.Equal(t, "draw", g.Status.String())
289	uassert.Equal(t, stake, balance(alice)-a)
290	uassert.Equal(t, stake, balance(bob)-b)
291	uassert.Equal(t, 1, statsOf(alice).Draws)
292}
293
294func TestFeeSnapshotAndWithdraw(cur realm, t *testing.T) {
295	reset()
296	testing.SetRealm(testing.NewUserRealm(carol))
297	pay(0)
298	uassert.AbortsContains(t, cur, "not owner", func() { SetFee(cross(cur), 0) })
299	testing.SetRealm(testing.NewUserRealm(carol))
300	pay(0)
301	uassert.AbortsContains(t, cur, "not owner", func() { WithdrawFees(cross(cur), carol) })
302	g := started(cur) // offered at the default 0.1 GNOT fee
303	testing.SetRealm(testing.NewUserRealm(admin))
304	pay(0)
305	uassert.AbortsContains(t, cur, "negative", func() { SetFee(cross(cur), -1) })
306	SetFee(cross(cur), 5_000_000)
307	testing.SetRealm(testing.NewUserRealm(alice))
308	pay(0)
309	Resign(cross(cur), g.ID)
310	uassert.Equal(t, int64(100_000), feesCollected)
311
312	before := balance(carol)
313	testing.SetRealm(testing.NewUserRealm(admin))
314	pay(0)
315	WithdrawFees(cross(cur), carol)
316	uassert.Equal(t, int64(100_000), balance(carol)-before)
317	uassert.Equal(t, int64(0), feesCollected)
318
319	// A raised fee applies to new offers, which must stake more than it.
320	testing.SetRealm(testing.NewUserRealm(alice))
321	pay(5_000_000)
322	uassert.AbortsContains(t, cur, "exceed the house fee", func() { Offer(cross(cur), "", 10, commit()) })
323}
324
325func TestFinishedGameRejects(cur realm, t *testing.T) {
326	reset()
327	g := started(cur)
328	testing.SetRealm(testing.NewUserRealm(alice))
329	pay(0)
330	Resign(cross(cur), g.ID)
331	testing.SetRealm(testing.NewUserRealm(bob))
332	pay(0)
333	uassert.AbortsContains(t, cur, "not in progress", func() { Play(cross(cur), g.ID, 1, g.Moves) })
334	uassert.AbortsContains(t, cur, "not in progress", func() { Resign(cross(cur), g.ID) })
335	expireClock()
336	testing.SetRealm(testing.NewUserRealm(bob))
337	pay(0)
338	uassert.AbortsContains(t, cur, "not in progress", func() { ClaimTimeout(cross(cur), g.ID) })
339	testing.SetRealm(testing.NewUserRealm(carol))
340	pay(stake)
341	uassert.AbortsContains(t, cur, "not open", func() { Accept(cross(cur), g.ID) })
342	testing.SetRealm(testing.NewUserRealm(carol))
343	pay(stake)
344	uassert.AbortsContains(t, cur, "invalid opponent", func() { Offer(cross(cur), "notanaddress", 10, commit()) })
345}
346
347func TestRevealPhase(cur realm, t *testing.T) {
348	reset()
349	g, p := accepted(cur)
350	uassert.Equal(t, byte(0), g.Turn)
351	for _, who := range []address{alice, bob} {
352		testing.SetRealm(testing.NewUserRealm(who))
353		pay(0)
354		uassert.AbortsContains(t, cur, "waiting for the creator", func() { Play(cross(cur), g.ID, 1, g.Moves) })
355	}
356	testing.SetRealm(testing.NewUserRealm(bob))
357	pay(0)
358	uassert.AbortsContains(t, cur, "only the creator", func() { Reveal(cross(cur), g.ID, p) })
359	testing.SetRealm(testing.NewUserRealm(alice))
360	pay(0)
361	uassert.AbortsContains(t, cur, "does not match", func() { Reveal(cross(cur), g.ID, p+"x") })
362	testing.SetRealm(testing.NewUserRealm(alice))
363	pay(0)
364	Reveal(cross(cur), g.ID, p)
365	uassert.True(t, g.Turn == 1 || g.Turn == 2)
366	testing.SetRealm(testing.NewUserRealm(alice))
367	pay(0)
368	uassert.AbortsContains(t, cur, "not awaiting a reveal", func() { Reveal(cross(cur), g.ID, p) })
369	move(cur, g, 1) // the picked first mover can play
370	uassert.Equal(t, 1, g.Moves)
371}
372
373func TestLateRevealForfeits(cur realm, t *testing.T) {
374	reset()
375	g, p := accepted(cur)
376	expireClock()
377	testing.SetRealm(testing.NewUserRealm(alice))
378	pay(0)
379	uassert.AbortsContains(t, cur, "reveal clock ran out", func() { Reveal(cross(cur), g.ID, p) })
380	before := balance(bob)
381	testing.SetRealm(testing.NewUserRealm(carol))
382	pay(0)
383	ClaimTimeout(cross(cur), g.ID)
384	uassert.Equal(t, "won", g.Status.String())
385	uassert.Equal(t, bob, g.Winner)
386	uassert.Equal(t, 2*stake-100_000, balance(bob)-before)
387	uassert.Equal(t, int64(100_000), feesCollected)
388	uassert.Equal(t, 1, statsOf(bob).Wins)
389	uassert.Equal(t, 1, statsOf(alice).Games)
390}
391
392func TestCommitmentRejects(cur realm, t *testing.T) {
393	reset()
394	good := commit()
395	for _, bad := range []string{"", good[:63], good + "0", "G" + good[1:], "A" + good[1:]} {
396		testing.SetRealm(testing.NewUserRealm(alice))
397		pay(stake)
398		uassert.AbortsContains(t, cur, "64 lowercase hex", func() { Offer(cross(cur), "", 10, bad) })
399	}
400	testing.SetRealm(testing.NewUserRealm(alice))
401	pay(stake)
402	Offer(cross(cur), "", 10, good)
403	testing.SetRealm(testing.NewUserRealm(carol))
404	pay(stake)
405	Offer(cross(cur), "", 10, good) // the same commitment may be offered again
406}
407
408func TestPaymentsRequireUserCall(cur realm, t *testing.T) {
409	reset()
410	testing.SetRealm(testing.NewCodeRealm("gno.land/r/demo/somerealm"))
411	pay(stake)
412	uassert.AbortsContains(t, cur, "only user-call", func() { Offer(cross(cur), "", 10, commit()) })
413	testing.SetRealm(testing.NewUserRealm(alice))
414	pay(stake)
415	id := Offer(cross(cur), "", 10, commit())
416	testing.SetRealm(testing.NewCodeRealm("gno.land/r/demo/somerealm"))
417	pay(stake)
418	uassert.AbortsContains(t, cur, "only user-call", func() { Accept(cross(cur), id) })
419}
420
421func TestNonPayableRejectsCoins(cur realm, t *testing.T) {
422	reset()
423	g, p := accepted(cur)
424	calls := map[string]func(){
425		"Reveal":            func() { Reveal(cross(cur), g.ID, p) },
426		"Play":              func() { Play(cross(cur), g.ID, 1, g.Moves) },
427		"ClaimTimeout":      func() { ClaimTimeout(cross(cur), g.ID) },
428		"Resign":            func() { Resign(cross(cur), g.ID) },
429		"Cancel":            func() { Cancel(cross(cur), g.ID) },
430		"SetFee":            func() { SetFee(cross(cur), 0) },
431		"WithdrawFees":      func() { WithdrawFees(cross(cur), admin) },
432		"TransferOwnership": func() { TransferOwnership(cross(cur), carol) },
433	}
434	for name, call := range calls {
435		testing.SetRealm(testing.NewUserRealm(admin))
436		pay(1)
437		uassert.AbortsContains(t, cur, "does not accept coins", call, name)
438	}
439}
440
441func TestFirstMoverDependsOnAcceptHeight(cur realm, t *testing.T) {
442	// The same passphrase, ids and players drawn at two accept heights: the
443	// creator can't fix the outcome when committing.
444	firsts := map[byte]bool{}
445	for i := 0; i < 16 && len(firsts) < 2; i++ {
446		reset()
447		testing.SkipHeights(1)
448		g, p := accepted(cur)
449		uassert.Equal(t, runtime.ChainHeight(), g.AcceptHeight)
450		testing.SetRealm(testing.NewUserRealm(alice))
451		pay(0)
452		Reveal(cross(cur), g.ID, p)
453		firsts[g.Turn] = true
454	}
455	uassert.Equal(t, 2, len(firsts))
456}
457
458func TestPlayRefusesStaleMove(cur realm, t *testing.T) {
459	reset()
460	g := started(cur)
461	stale := g.Moves
462	move(cur, g, 1)
463	testing.SetRealm(testing.NewUserRealm(g.player(g.Turn)))
464	pay(0)
465	uassert.AbortsContains(t, cur, "stale move: the game is at move 1", func() { Play(cross(cur), g.ID, 2, stale) })
466	uassert.AbortsContains(t, cur, "stale move", func() { Play(cross(cur), g.ID, 2, g.Moves+1) })
467	Play(cross(cur), g.ID, 2, g.Moves)
468	uassert.Equal(t, 2, g.Moves)
469}
470
471func TestSessionKeysCannotResignStakeOrOwn(cur realm, t *testing.T) {
472	reset()
473	g := started(cur)
474	testSession = true
475	testing.SetRealm(testing.NewUserRealm(g.player(g.Turn)))
476	pay(0)
477	uassert.AbortsContains(t, cur, "Resign needs the account's own key", func() { Resign(cross(cur), g.ID) })
478	testing.SetRealm(testing.NewUserRealm(alice))
479	pay(stake)
480	uassert.AbortsContains(t, cur, "Offer needs the account's own key", func() { Offer(cross(cur), "", 10, commit()) })
481	testing.SetRealm(testing.NewUserRealm(admin))
482	pay(0)
483	uassert.AbortsContains(t, cur, "SetFee needs", func() { SetFee(cross(cur), 0) })
484	uassert.AbortsContains(t, cur, "WithdrawFees needs", func() { WithdrawFees(cross(cur), admin) })
485	uassert.AbortsContains(t, cur, "TransferOwnership needs", func() { TransferOwnership(cross(cur), carol) })
486	// Moves stay open to sessions: that is what Quick play is for.
487	move(cur, g, 1)
488	uassert.Equal(t, 1, g.Moves)
489}
490
491func TestSessionKeysCannotAccept(cur realm, t *testing.T) {
492	reset()
493	testing.SetRealm(testing.NewUserRealm(alice))
494	pay(stake)
495	id := Offer(cross(cur), "", 10, commit())
496	testSession = true
497	testing.SetRealm(testing.NewUserRealm(bob))
498	pay(stake)
499	uassert.AbortsContains(t, cur, "Accept needs the account's own key", func() { Accept(cross(cur), id) })
500}
501
502func TestBump(t *testing.T) {
503	var b []leader
504	for i := 0; i < 12; i++ {
505		b = bump(b, testutils.TestAddress(strconv.Itoa(i)), int64(i))
506	}
507	uassert.Equal(t, topN, len(b))
508	uassert.Equal(t, int64(11), b[0].Score)
509	uassert.Equal(t, int64(2), b[9].Score)
510	b = bump(b, testutils.TestAddress("5"), 20) // already listed: moves up, not duplicated
511	uassert.Equal(t, topN, len(b))
512	uassert.Equal(t, testutils.TestAddress("5"), b[0].Addr)
513	b = bump(b, testutils.TestAddress("late"), 11) // ties keep the earlier holder first
514	uassert.Equal(t, testutils.TestAddress("11"), b[1].Addr)
515	uassert.Equal(t, testutils.TestAddress("late"), b[2].Addr)
516}