@dust-dice/verifier 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/verify.ts CHANGED
@@ -1,3 +1,4 @@
1
+ #!/usr/bin/env node
1
2
  // Copyright (C) Shielded Technologies
2
3
  // SPDX-License-Identifier: Apache-2.0
3
4
 
@@ -143,48 +144,85 @@ class Checks {
143
144
  }
144
145
  }
145
146
 
146
- /** One entry of the public log, with the contract state it produced. */
147
- interface Step {
148
- action: ContractAction;
147
+ /**
148
+ * One TRANSACTION of the public log: the calls it carried, and the state it produced.
149
+ *
150
+ * Grouped by transaction rather than by call because a FAST turn is ONE transaction carrying the
151
+ * whole turn -- open, resolve, hold, resolve, hold, resolve, score, up to seven calls -- and the
152
+ * chain never held a state between them for anyone to read. Such a turn is verified against the
153
+ * state the transaction produced instead (`verifyMergedTurn` below), which needs no "before"
154
+ * state at all and is therefore sound however many seats settle in the same block.
155
+ */
156
+ interface LogGroup {
157
+ blockHeight: number;
158
+ txHash: string;
159
+ /** Entry points this one transaction carried, in whatever order the indexer returned them. */
160
+ entryPoints: string[];
161
+ /** Contract state after the block this transaction landed in. */
149
162
  led: TableLedger;
163
+ /** Another transaction touched this table in the same block. */
164
+ sharesBlock: boolean;
150
165
  }
151
166
 
167
+ /** The calls a turn is made of, merged into one transaction or spread over several. */
168
+ const TURN_CALLS = new Set([
169
+ 'playerMove',
170
+ 'resolveRoll1',
171
+ 'resolveRoll2',
172
+ 'resolveRoll3',
173
+ 'resolveReroll',
174
+ ]);
175
+
176
+ /**
177
+ * Causal order for two transactions that landed in the same block. Not a guess: the contract's
178
+ * own preconditions force it -- a seat joins before it can move, every live seat must have
179
+ * scored before `closeRound` is accepted, and `settle` precedes the `redeem`s it funds.
180
+ */
181
+ const GROUP_RANK: Record<string, number> = {
182
+ join: 0,
183
+ playerMove: 1,
184
+ resolveRoll1: 1,
185
+ resolveRoll2: 1,
186
+ resolveRoll3: 1,
187
+ resolveReroll: 1,
188
+ eliminate: 2,
189
+ closeRound: 3,
190
+ settle: 4,
191
+ redeem: 5,
192
+ abortTable: 6,
193
+ };
194
+
152
195
  /**
153
- * Read the table's whole history: every action, and the state it left behind.
196
+ * Read the table's whole history: every transaction, and the state it left behind.
154
197
  *
155
- * One `queryContractState` per action, pinned to that action's block. `contractStateObservable`
156
- * is not usable for this -- it misses rapid successive updates and its first emission may
157
- * predate the write being read (bugs-found.md §0 #10).
198
+ * One `queryContractState` per transaction, pinned to its block. `contractStateObservable` is
199
+ * not usable for this -- it misses rapid successive updates and its first emission may predate
200
+ * the write being read (bugs-found.md §0 #10).
158
201
  */
159
- async function readHistory(address: string): Promise<Step[]> {
160
- const actions = await contractActions(address);
161
- const steps: Step[] = [];
162
- for (const action of actions) {
163
- if (action.kind === 'ContractDeploy') continue;
164
- steps.push({ action, led: await readTableLedger(address, action.blockHeight) });
165
- }
202
+ async function readHistory(address: string): Promise<LogGroup[]> {
203
+ const actions = (await contractActions(address)).filter((a) => a.kind !== 'ContractDeploy');
204
+ const byTx = new Map<string, ContractAction[]>();
205
+ for (const a of actions) byTx.set(a.txHash, [...(byTx.get(a.txHash) ?? []), a]);
166
206
 
167
- // The replay reads each step's PREVIOUS state to learn what the call was told (which seat was
168
- // at which stage, which boxes were filled, which mask was pending). That is only sound while
169
- // at most one call to this table lands per block: two in one block would both resolve to the
170
- // state after both, and the second step's "before" would be wrong.
171
- //
172
- // A table CAN take several calls in one block -- that is the whole point of the concurrency
173
- // work, measured at six in docs/concurrency-probe.md -- so this is a real limitation of
174
- // per-block state replay and not a property of the contract. The demo driver is strictly
175
- // sequential precisely so that its games stay verifiable by this method; a table played by six
176
- // independent clients may not be. Saying so is much better than silently producing wrong
177
- // answers.
178
- const heights = steps.map((s) => s.action.blockHeight);
179
- const collisions = heights.filter((h, i) => heights.indexOf(h) !== i);
180
- if (collisions.length > 0) {
181
- throw new Error(
182
- `two or more calls to this table share block ${[...new Set(collisions)].join(', ')}. ` +
183
- 'The per-block state replay cannot separate them, so this table cannot be verified by ' +
184
- 'this method. (This is a limitation of the verifier, not a fault in the game.)',
185
- );
207
+ const groups: LogGroup[] = [];
208
+ for (const [txHash, calls] of byTx) {
209
+ groups.push({
210
+ blockHeight: calls[0]!.blockHeight,
211
+ txHash,
212
+ entryPoints: calls.map((x) => x.entryPoint ?? '?'),
213
+ led: await readTableLedger(address, calls[0]!.blockHeight),
214
+ sharesBlock: false,
215
+ });
186
216
  }
187
- return steps;
217
+ groups.sort((a, b) =>
218
+ a.blockHeight !== b.blockHeight
219
+ ? a.blockHeight - b.blockHeight
220
+ : (GROUP_RANK[a.entryPoints[0] ?? '?'] ?? 9) - (GROUP_RANK[b.entryPoints[0] ?? '?'] ?? 9),
221
+ );
222
+ for (const g of groups) {
223
+ g.sharesBlock = groups.some((o) => o !== g && o.blockHeight === g.blockHeight);
224
+ }
225
+ return groups;
188
226
  }
189
227
 
190
228
  /** The seat whose `seatTurn` entry changed between two states, or -1. */
@@ -257,14 +295,16 @@ async function verify(address: string, verbose: boolean): Promise<number> {
257
295
  ` mode ${final.fastMode ? 'FAST (turn ordering operator-attested)' : 'on-chain (ordering chain-proven)'}`,
258
296
  );
259
297
 
260
- c.ok(
261
- 'the table reached settlement',
262
- final.phase === Table.Phase.settled,
263
- `phase is ${Table.Phase[final.phase]}; only a settled table reveals its seed`,
264
- );
298
+ // A table that never settled is NOT a failed verification: an aborted or still-playing table
299
+ // has not revealed its seed, so there is nothing to replay yet. Saying "FAILED" here would
300
+ // read as an accusation about a game that simply is not over.
265
301
  if (final.phase !== Table.Phase.settled) {
266
- c.summary();
267
- return 1;
302
+ console.log(
303
+ `\nNOT VERIFIABLE YET: this table is ${Table.Phase[final.phase]}. Only a settled table ` +
304
+ 'reveals the seed,\nand without the seed no roll can be re-derived. Nothing here says ' +
305
+ 'the game was dishonest.',
306
+ );
307
+ return 3;
268
308
  }
269
309
 
270
310
  // ---------------------------------------------------------------- 1. the seed opens the commit
@@ -296,8 +336,9 @@ async function verify(address: string, verbose: boolean): Promise<number> {
296
336
  );
297
337
 
298
338
  // ------------------------------------------------------------------------- walk the public log
299
- const steps = await readHistory(address);
300
- console.log(`\n── the public log: ${steps.length} calls ──`);
339
+ const groups = await readHistory(address);
340
+ const callCount = groups.reduce((n, g) => n + g.entryPoints.length, 0);
341
+ console.log(`\n── the public log: ${callCount} calls in ${groups.length} transaction(s) ──`);
301
342
 
302
343
  let digest = genesisDigestTs(tableId);
303
344
  const seats: SeatReplay[] = Array.from({ length: seatCount }, () => ({
@@ -315,15 +356,182 @@ async function verify(address: string, verbose: boolean): Promise<number> {
315
356
  let closes = 0;
316
357
  let eliminations = 0;
317
358
 
318
- /** State immediately before the step being examined -- the previous step's, or the deploy's. */
319
- const before = (i: number): TableLedger | undefined => (i === 0 ? undefined : steps[i - 1]!.led);
359
+ /** State immediately before the group being examined -- the previous group's, or the deploy's. */
360
+ const before = (i: number): TableLedger | undefined => (i === 0 ? undefined : groups[i - 1]!.led);
361
+ /** Turn calls this replay could not separate. Non-empty means the verdict is not a clean one. */
362
+ const unseparable: string[] = [];
363
+ /** Seats whose redemption has already been accounted for, so a second one is not double-read. */
364
+ const redeemed = new Set<number>();
365
+
366
+ /**
367
+ * A whole turn in ONE transaction -- the fast path, and the shape every fast table has.
368
+ *
369
+ * The chain never stored a state between the calls, so there is no "before" to diff. It does
370
+ * not need one: everything the turn consumed is still on the ledger afterwards -- the seat's
371
+ * entropy, the mixed entropy latched at roll 1, both hold masks, the last roll (`SeatTurn`) --
372
+ * and how many rolls the turn took is the transaction's own call count (one `resolveRoll1`
373
+ * plus N `resolveReroll` is N+1 rolls, against N+2 `playerMove`s: open, N holds, score).
374
+ *
375
+ * The roll chain is a hash chain: roll 2 is derived from roll 1 under the player's mask, roll 3
376
+ * from roll 2. So if roll 1 or roll 2 were not what the operator claimed, the LAST roll could
377
+ * not match the one the chain stored. Checking the end of the chain checks all of it.
378
+ *
379
+ * Reading only this seat's fields is what makes it sound when two seats settle in the same
380
+ * block: their turns touch disjoint cells, and the digest this replay folds is its own.
381
+ */
382
+ const verifyMergedTurn = (g: LogGroup): void => {
383
+ const led = g.led;
384
+ const moves = g.entryPoints.filter((k) => k === 'playerMove').length;
385
+ const firsts = g.entryPoints.filter((k) => k === 'resolveRoll1').length;
386
+ const rerolls = g.entryPoints.filter(
387
+ (k) => k === 'resolveReroll' || k === 'resolveRoll2' || k === 'resolveRoll3',
388
+ ).length;
389
+ const rolls = firsts + rerolls;
390
+ const where = `tx ${g.txHash.slice(0, 10)}… (block ${g.blockHeight})`;
391
+ if (firsts !== 1 || moves !== rolls + 1) {
392
+ c.ok(
393
+ `${where}: reads as one turn`,
394
+ false,
395
+ `${moves} playerMove + ${firsts} resolveRoll1 + ${rerolls} reroll is not ` +
396
+ 'open/(hold,resolve)*/score',
397
+ );
398
+ return;
399
+ }
400
+
401
+ // WHICH SEAT. The candidates are the seats whose chain card holds a box this replay has not
402
+ // applied yet. With one candidate there is nothing to choose; with two (two seats settling
403
+ // in the same block) the roll derivation itself decides, since a wrong pairing cannot
404
+ // reproduce the stored roll.
405
+ const filled = (card: Scorecard): number => card.scores.filter((x) => x !== null).length;
406
+ const candidates: number[] = [];
407
+ for (let s = 0; s < seatCount; s++) {
408
+ if (filled(seatCard(led, s)) > filled(seats[s]!.card)) candidates.push(s);
409
+ }
410
+ if (candidates.length === 0) {
411
+ c.ok(`${where}: a seat's card gained a box`, false, 'no seat advanced');
412
+ return;
413
+ }
414
+
415
+ /** Re-derive the whole roll chain for one candidate; returns the final dice, or null. */
416
+ const deriveFor = (
417
+ seat: number,
418
+ ): { round: number; mixed: Uint8Array; dice: number[] } | null => {
419
+ const turn = led.seatTurn.lookup(BigInt(seat));
420
+ const round = Number(turn.round);
421
+ const mixed = mixEntropyTs(turn.entropy, digest);
422
+ let dice = firstRollTs(tableId, seed, mixed, round);
423
+ for (let step = 1; step <= rerolls; step++) {
424
+ const mask = [...(step === 1 ? turn.hold1 : turn.hold2).bits];
425
+ dice = rerollUnderMaskTs(tableId, seed, mixed, round, step, mask, dice);
426
+ }
427
+ return arrayEq(diceToArray(turn.roll), dice) ? { round, mixed, dice } : null;
428
+ };
429
+
430
+ let seat = candidates[0]!;
431
+ let derived = deriveFor(seat);
432
+ if (derived === null && candidates.length > 1) {
433
+ for (const alt of candidates.slice(1)) {
434
+ const d = deriveFor(alt);
435
+ if (d !== null) {
436
+ seat = alt;
437
+ derived = d;
438
+ break;
439
+ }
440
+ }
441
+ }
442
+ const turn = led.seatTurn.lookup(BigInt(seat));
443
+ const round = Number(turn.round);
444
+ const r = seats[seat]!;
445
+ opens += 1;
446
+ holds += rolls - 1;
447
+ rollChecks += rolls;
448
+
449
+ c.ok(
450
+ `seat ${seat} r${round}: mixed entropy folds the frozen round digest`,
451
+ same(turn.mixed, mixEntropyTs(turn.entropy, digest)),
452
+ `chain ${hex(turn.mixed)}`,
453
+ );
454
+ c.ok(
455
+ `seat ${seat} r${round}: all ${rolls} roll(s) re-derive, chained under the player's masks`,
456
+ derived !== null,
457
+ derived === null
458
+ ? `chain stored ${diceToArray(turn.roll)} after ${rolls} roll(s); no derivation matches`
459
+ : '',
460
+ );
461
+ if (derived === null) return;
462
+ r.roll = derived.dice;
463
+ r.rolls = rolls;
464
+ r.mixed = derived.mixed;
465
+
466
+ // SCORE. The category is whichever box the chain filled that this replay had not, and the
467
+ // placement is recomputed with the contract-canonical rules engine from the DERIVED dice.
468
+ const chainAfter = seatCard(led, seat);
469
+ const category = chainAfter.scores.findIndex((x, k) => x !== null && r.card.scores[k] === null);
470
+ c.ok(
471
+ `seat ${seat} r${round}: score filled exactly one new category`,
472
+ category >= 0 && filled(chainAfter) === filled(r.card) + 1,
473
+ `category index ${category}`,
474
+ );
475
+ if (category >= 0) {
476
+ scores += 1;
477
+ r.card = applyScore(r.card, category as Category, derived.dice as unknown as RefDice);
478
+ for (let k = 0; k < CATEGORY_COUNT; k++) {
479
+ c.ok(
480
+ `seat ${seat} r${round}: box ${k}`,
481
+ r.card.scores[k] === chainAfter.scores[k],
482
+ `replay ${r.card.scores[k]} vs chain ${chainAfter.scores[k]}`,
483
+ );
484
+ }
485
+ c.ok(
486
+ `seat ${seat} r${round}: running total`,
487
+ BigInt(grandTotal(r.card)) === led.seatProgress.lookup(BigInt(seat)).total,
488
+ `replay ${grandTotal(r.card)} vs chain ${led.seatProgress.lookup(BigInt(seat)).total}`,
489
+ );
490
+ c.ok(
491
+ `seat ${seat} r${round}: the dice the chain stored are the ones replayed`,
492
+ arrayEq(diceToArray(led.seatProgress.lookup(BigInt(seat)).dice), derived.dice),
493
+ `chain ${diceToArray(led.seatProgress.lookup(BigInt(seat)).dice)} vs replay ${derived.dice}`,
494
+ );
495
+ c.ok(
496
+ `seat ${seat} r${round}: score advances the seat past the round`,
497
+ Number(led.seatProgress.lookup(BigInt(seat)).round) === round + 1,
498
+ );
499
+ if (round === FINAL_ROUND) r.finishedAtRound = round;
500
+ }
501
+ };
320
502
 
321
- for (let i = 0; i < steps.length; i++) {
322
- const { action, led } = steps[i]!;
503
+ for (let i = 0; i < groups.length; i++) {
504
+ const g = groups[i]!;
505
+ const led = g.led;
323
506
  const prev = before(i);
507
+ const isTurn = g.entryPoints.every((k) => TURN_CALLS.has(k));
324
508
 
325
- switch (action.entryPoint) {
509
+ // A turn merged into one transaction: checked against the state it produced.
510
+ if (isTurn && g.entryPoints.length > 1) {
511
+ verifyMergedTurn(g);
512
+ continue;
513
+ }
514
+ // A SINGLE turn call sharing its block with another transaction. The per-call replay below
515
+ // needs the state before the call, and in a shared block the previous group's state is the
516
+ // state after both. Recording that is honest; checking against the wrong state is not.
517
+ if (isTurn && g.sharesBlock) {
518
+ unseparable.push(`${g.entryPoints[0]} in block ${g.blockHeight}`);
519
+ continue;
520
+ }
521
+
522
+ switch (g.entryPoints[0]) {
326
523
  case 'join': {
524
+ // A CALL THAT LANDED AND DID NOTHING. A transaction whose fallible section fails is
525
+ // still recorded in the public log, so a rejected join appears here with the seat count
526
+ // unmoved. Folding the digest for it would corrupt the chain from that point on (this
527
+ // replay counts joins itself rather than trusting one action to be one seat).
528
+ if (Number(led.seatCount) <= joins) {
529
+ console.log(
530
+ ` (a join in block ${g.blockHeight} left the seat count at ${joins} -- it landed ` +
531
+ 'on chain but its fallible section failed; nothing to replay)',
532
+ );
533
+ break;
534
+ }
327
535
  // Seat order is join order, so the seat this call took is the one that did not exist
328
536
  // before it. The digest binds the seat's payout address and its entropy commitment.
329
537
  const seat = Number(led.seatCount) - 1;
@@ -449,7 +657,7 @@ async function verify(address: string, verbose: boolean): Promise<number> {
449
657
  const turn = led.seatTurn.lookup(BigInt(seat));
450
658
  const chainDice = diceToArray(turn.roll);
451
659
 
452
- if (action.entryPoint === 'resolveRoll1') {
660
+ if (g.entryPoints[0] === 'resolveRoll1') {
453
661
  // Roll 1 hashes the seat's declared entropy against the digest FROZEN AT ROUND OPEN --
454
662
  // which is the digest the replay is holding right now, because it only advances at a
455
663
  // closeRound. Getting that ordering wrong is the easiest way to make an unverifiable
@@ -523,8 +731,18 @@ async function verify(address: string, verbose: boolean): Promise<number> {
523
731
  }
524
732
 
525
733
  case 'closeRound': {
526
- if (!prev) break;
527
- const round = Number(prev.openRound);
734
+ // The round is the replay's own count of closes, so a close that landed without effect
735
+ // cannot shift the chain. `roundResults` is read AFTER the close, which is equivalent:
736
+ // `closeRound` only reads `seatProgress` (it writes the digest, the round and the
737
+ // deadline), so the per-seat cells it folded are unchanged by it.
738
+ const round = closes;
739
+ if (Number(led.openRound) !== round + 1) {
740
+ console.log(
741
+ ` (a closeRound in block ${g.blockHeight} left openRound at ${led.openRound} -- ` +
742
+ 'it landed on chain without effect; nothing to replay)',
743
+ );
744
+ break;
745
+ }
528
746
  // The ONE place the digest advances. All six slots, in SEAT ORDER, read at this block --
529
747
  // which is what makes the replay independent of the order the chain saw the moves in.
530
748
  digest = roundDigestTs(digest, round, seatCount, roundResults(led));
@@ -543,21 +761,29 @@ async function verify(address: string, verbose: boolean): Promise<number> {
543
761
  }
544
762
 
545
763
  case 'eliminate': {
546
- if (!prev) break;
547
- const round = Number(prev.openRound);
764
+ // From the AFTER state, per seat: the seat this call took out is one the chain marks
765
+ // eliminated that the replay has not marked yet. The alternative — diffing against the
766
+ // previous group — is wrong the moment two seats are eliminated in one block, which is
767
+ // exactly what a table nobody is playing does.
548
768
  let seat = -1;
549
769
  for (let s = 0; s < seatCount; s++) {
550
- if (
551
- !prev.seatProgress.lookup(BigInt(s)).eliminated &&
552
- led.seatProgress.lookup(BigInt(s)).eliminated
553
- ) {
770
+ if (!seats[s]!.eliminated && led.seatProgress.lookup(BigInt(s)).eliminated) {
554
771
  seat = s;
772
+ break;
555
773
  }
556
774
  }
557
775
  if (seat < 0) {
558
- c.ok('eliminate marked exactly one seat', false);
776
+ console.log(
777
+ ` (an eliminate in block ${g.blockHeight} marked no new seat -- it landed on ` +
778
+ 'chain without effect, or the replay had already accounted for it)',
779
+ );
559
780
  break;
560
781
  }
782
+ // The round the seat owed when it was taken out. NOT `seatProgress.round`, which an
783
+ // elimination sets to `roundCount()` to mean "finished", and not the chain's `openRound`,
784
+ // which a closeRound in the same block would already have advanced. The replay's own
785
+ // count of closed rounds is the open round by construction.
786
+ const round = closes;
561
787
  eliminations += 1;
562
788
  seats[seat]!.eliminated = true;
563
789
  seats[seat]!.finishedAtRound = Number.POSITIVE_INFINITY;
@@ -569,8 +795,7 @@ async function verify(address: string, verbose: boolean): Promise<number> {
569
795
  // tells us how the seat left).
570
796
  const timeoutPenalty = (final.tier * BigInt(round + 1)) / 13n;
571
797
  const resignPenalty = (final.tier * BigInt(round)) / 13n;
572
- const delta =
573
- led.seatRedeemable.lookup(BigInt(seat)) - prev.seatRedeemable.lookup(BigInt(seat));
798
+ const delta = led.seatRedeemable.lookup(BigInt(seat));
574
799
  const penalty = delta === final.tier - resignPenalty ? resignPenalty : timeoutPenalty;
575
800
  const how =
576
801
  penalty === resignPenalty && resignPenalty !== timeoutPenalty ? 'resigned' : 'timed out';
@@ -580,11 +805,17 @@ async function verify(address: string, verbose: boolean): Promise<number> {
580
805
  delta === refund,
581
806
  `expected +${refund} (penalty ${penalty}, ${how})`,
582
807
  );
583
- c.ok(
584
- `seat ${seat}: the penalty stayed in the pot`,
585
- led.pot === prev.pot - refund,
586
- `pot ${prev.pot} -> ${led.pot}, expected -${refund}`,
587
- );
808
+ // The pot delta needs the state before this one call, so it is only checked where that
809
+ // state is readable one transaction in the block. Where it is not, nothing is lost:
810
+ // the custody invariant below runs after every transaction and already pins the pot to
811
+ // `tier x seats` minus everything owed and paid, which is the claim that matters.
812
+ if (prev !== undefined && !g.sharesBlock) {
813
+ c.ok(
814
+ `seat ${seat}: the penalty stayed in the pot`,
815
+ led.pot === prev.pot - refund,
816
+ `pot ${prev.pot} -> ${led.pot}, expected -${refund}`,
817
+ );
818
+ }
588
819
  c.ok(
589
820
  `seat ${seat}: carries the never-finished sentinel`,
590
821
  led.seatProgress.lookup(BigInt(seat)).finishedAtRound === 65535n,
@@ -600,7 +831,7 @@ async function verify(address: string, verbose: boolean): Promise<number> {
600
831
  break;
601
832
 
602
833
  default:
603
- console.log(` (unrecognised entry point '${action.entryPoint ?? '?'}' -- ignored)`);
834
+ console.log(` (unrecognised entry point '${g.entryPoints[0] ?? '?'}' -- ignored)`);
604
835
  }
605
836
 
606
837
  // The custody invariant, at every single step: while the table holds the money, every atom
@@ -614,7 +845,7 @@ async function verify(address: string, verbose: boolean): Promise<number> {
614
845
  paid += led.seatPaid.lookup(BigInt(s));
615
846
  }
616
847
  c.ok(
617
- `step ${i} (${action.entryPoint}): custody invariant`,
848
+ `tx ${i} (${g.entryPoints.join('+')}): custody invariant`,
618
849
  led.pot + owed + paid === final.tier * led.seatCount,
619
850
  `pot ${led.pot} + owed ${owed} + paid ${paid} != tier x ${led.seatCount}`,
620
851
  );
@@ -691,18 +922,18 @@ async function verify(address: string, verbose: boolean): Promise<number> {
691
922
 
692
923
  // -------------------------------------------------------------------------------- the payout
693
924
  console.log('\n── the payout ──');
694
- const settleAction = steps.find((st) => st.action.entryPoint === 'settle')?.action;
695
- if (!settleAction) {
925
+ const settleGroup = groups.find((gr) => gr.entryPoints.includes('settle'));
926
+ if (!settleGroup) {
696
927
  c.ok('the settle transaction is in the log', false);
697
928
  } else {
698
- const tx = await transactionByHash(settleAction.txHash);
929
+ const tx = await transactionByHash(settleGroup.txHash);
699
930
  const winnerAddr = final.seatIdentity.lookup(final.winnerSeatIndex).addr.bytes;
700
931
  const rakeAddr = final.rakeAddress.bytes;
701
932
  // `settle` pays out exactly the POT, which is the stakes minus whatever left it as an
702
933
  // eliminated seat's refund. Read from the state just before the settle rather than assumed
703
934
  // to be tier x seatCount, because an elimination moves money out of the pot.
704
- const settleStep = steps.findIndex((st) => st.action.entryPoint === 'settle');
705
- const potBefore = steps[settleStep - 1]!.led.pot;
935
+ const settleAt = groups.indexOf(settleGroup);
936
+ const potBefore = groups[settleAt - 1]!.led.pot;
706
937
  const q = potBefore / 100n;
707
938
 
708
939
  const spentByUsers = sumNative(tx.unshieldedSpentOutputs);
@@ -735,27 +966,32 @@ async function verify(address: string, verbose: boolean): Promise<number> {
735
966
  }
736
967
 
737
968
  // ------------------------------------------------------------------------------- redemptions
738
- const redeems = steps.filter((st) => st.action.entryPoint === 'redeem');
969
+ const redeems = groups.filter((gr) => gr.entryPoints.includes('redeem'));
739
970
  if (redeems.length > 0) {
740
971
  console.log(`\n── ${redeems.length} redemption(s) ──`);
741
- for (const st of redeems) {
742
- const i = steps.indexOf(st);
743
- const prev = steps[i - 1]!.led;
972
+ for (const gr of redeems) {
973
+ // The seat and the amount both come from the state AFTER the redeem: `seatPaid` is what
974
+ // the contract recorded paying, and it is per seat. Reading "what it was owed" from the
975
+ // previous group instead would be wrong the moment two seats redeem in one block, which
976
+ // is exactly what an aborted table does.
744
977
  let seat = -1;
745
978
  for (let s = 0; s < seatCount; s++) {
746
979
  if (
747
- prev.seatRedeemable.lookup(BigInt(s)) > 0n &&
748
- st.led.seatRedeemable.lookup(BigInt(s)) === 0n
980
+ !redeemed.has(s) &&
981
+ gr.led.seatPaid.lookup(BigInt(s)) > 0n &&
982
+ gr.led.seatRedeemable.lookup(BigInt(s)) === 0n
749
983
  ) {
750
984
  seat = s;
985
+ break;
751
986
  }
752
987
  }
753
988
  if (seat < 0) {
754
989
  c.ok('a redeem zeroed exactly one seat', false);
755
990
  continue;
756
991
  }
757
- const owed = prev.seatRedeemable.lookup(BigInt(seat));
758
- const tx = await transactionByHash(st.action.txHash);
992
+ redeemed.add(seat);
993
+ const owed = gr.led.seatPaid.lookup(BigInt(seat));
994
+ const tx = await transactionByHash(gr.txHash);
759
995
  const addr = final.seatIdentity.lookup(BigInt(seat)).addr.bytes;
760
996
  console.log(` seat ${seat} redeemed ${owed}`);
761
997
  c.ok(
@@ -773,9 +1009,22 @@ async function verify(address: string, verbose: boolean): Promise<number> {
773
1009
  }
774
1010
  }
775
1011
 
1012
+ // Steps this method genuinely could not separate. Not a failure of the game and not a pass
1013
+ // either: an on-chain table whose seats moved in the same block leaves those individual calls
1014
+ // unattributable, and saying so beats both a false alarm and a false clean bill.
1015
+ if (unseparable.length > 0) {
1016
+ console.log(
1017
+ `\n── ${unseparable.length} call(s) this replay could not separate ──\n` +
1018
+ unseparable.map((u) => ` ${u}`).join('\n') +
1019
+ '\n Each shared its block with another transaction, so the state before it is not\n' +
1020
+ ' readable. Everything else above was checked. A FAST table never lands here: its\n' +
1021
+ ' whole turn is one transaction and is verified as a unit.',
1022
+ );
1023
+ }
776
1024
  c.summary();
777
1025
  console.log(`\n(${c.count} checks in total)`);
778
- return c.failed.length === 0 ? 0 : 1;
1026
+ if (c.failed.length > 0) return 1;
1027
+ return unseparable.length > 0 ? 2 : 0;
779
1028
  }
780
1029
 
781
1030
  /**
@@ -803,7 +1052,7 @@ const address = process.argv[2];
803
1052
  if (!address) {
804
1053
  console.error(
805
1054
  'usage: npm run verify -w cli -- <table-address> [--verbose]\n\n' +
806
- 'Replays a settled Yahtzee table from the chain alone: every roll re-derived under the\n' +
1055
+ 'Replays a settled Dust Dice table from the chain alone: every roll re-derived under the\n' +
807
1056
  'hold masks the players sent, every score recomputed, the winner and the payout\n' +
808
1057
  're-confirmed.',
809
1058
  );