@dust-dice/verifier 0.4.1 → 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/package.json +7 -4
- package/src/verify.ts +330 -82
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dust-dice/verifier",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Independent verifier for Dust Dice: replays every roll of a finished table from the chain's own record \u2014 no operator, no trust \u2014 and the shared read-side helpers (compiled-contract handles, indexer queries).",
|
|
6
6
|
"exports": {
|
|
@@ -28,8 +28,6 @@
|
|
|
28
28
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@dust-dice/api": "0.4.0",
|
|
32
|
-
"@dust-dice/contract": "0.4.0",
|
|
33
31
|
"@midnight-ntwrk/compact-runtime": "0.19.0",
|
|
34
32
|
"@midnight-ntwrk/midnight-js-indexer-public-data-provider": "5.0.0-beta.7",
|
|
35
33
|
"@midnight-ntwrk/midnight-js-network-id": "5.0.0-beta.7",
|
|
@@ -60,5 +58,10 @@
|
|
|
60
58
|
"types": "dist/index.d.ts",
|
|
61
59
|
"bin": {
|
|
62
60
|
"dust-dice-verify": "dist/verify.js"
|
|
63
|
-
}
|
|
61
|
+
},
|
|
62
|
+
"peerDependencies": {
|
|
63
|
+
"@dust-dice/api": ">=0.4.0",
|
|
64
|
+
"@dust-dice/contract": ">=0.4.1"
|
|
65
|
+
},
|
|
66
|
+
"//peerDependencies": "The verifier resolves the compiled contract artifacts from wherever @dust-dice/contract is installed next to it. As regular dependencies with exact pins, npm nested a second copy of the contract package under the verifier whenever the consumer's version differed \u2014 and that copy was the one its config found (dust-dice bug #33). Peers make the consumer's single copy the only one."
|
|
64
67
|
}
|
package/src/verify.ts
CHANGED
|
@@ -144,48 +144,85 @@ class Checks {
|
|
|
144
144
|
}
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
/**
|
|
148
|
-
|
|
149
|
-
|
|
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. */
|
|
150
162
|
led: TableLedger;
|
|
163
|
+
/** Another transaction touched this table in the same block. */
|
|
164
|
+
sharesBlock: boolean;
|
|
151
165
|
}
|
|
152
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
|
+
|
|
153
195
|
/**
|
|
154
|
-
* Read the table's whole history: every
|
|
196
|
+
* Read the table's whole history: every transaction, and the state it left behind.
|
|
155
197
|
*
|
|
156
|
-
* One `queryContractState` per
|
|
157
|
-
*
|
|
158
|
-
*
|
|
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).
|
|
159
201
|
*/
|
|
160
|
-
async function readHistory(address: string): Promise<
|
|
161
|
-
const actions = await contractActions(address);
|
|
162
|
-
const
|
|
163
|
-
for (const
|
|
164
|
-
if (action.kind === 'ContractDeploy') continue;
|
|
165
|
-
steps.push({ action, led: await readTableLedger(address, action.blockHeight) });
|
|
166
|
-
}
|
|
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]);
|
|
167
206
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
// independent clients may not be. Saying so is much better than silently producing wrong
|
|
178
|
-
// answers.
|
|
179
|
-
const heights = steps.map((s) => s.action.blockHeight);
|
|
180
|
-
const collisions = heights.filter((h, i) => heights.indexOf(h) !== i);
|
|
181
|
-
if (collisions.length > 0) {
|
|
182
|
-
throw new Error(
|
|
183
|
-
`two or more calls to this table share block ${[...new Set(collisions)].join(', ')}. ` +
|
|
184
|
-
'The per-block state replay cannot separate them, so this table cannot be verified by ' +
|
|
185
|
-
'this method. (This is a limitation of the verifier, not a fault in the game.)',
|
|
186
|
-
);
|
|
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
|
+
});
|
|
187
216
|
}
|
|
188
|
-
|
|
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;
|
|
189
226
|
}
|
|
190
227
|
|
|
191
228
|
/** The seat whose `seatTurn` entry changed between two states, or -1. */
|
|
@@ -258,14 +295,16 @@ async function verify(address: string, verbose: boolean): Promise<number> {
|
|
|
258
295
|
` mode ${final.fastMode ? 'FAST (turn ordering operator-attested)' : 'on-chain (ordering chain-proven)'}`,
|
|
259
296
|
);
|
|
260
297
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
`phase is ${Table.Phase[final.phase]}; only a settled table reveals its seed`,
|
|
265
|
-
);
|
|
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.
|
|
266
301
|
if (final.phase !== Table.Phase.settled) {
|
|
267
|
-
|
|
268
|
-
|
|
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;
|
|
269
308
|
}
|
|
270
309
|
|
|
271
310
|
// ---------------------------------------------------------------- 1. the seed opens the commit
|
|
@@ -297,8 +336,9 @@ async function verify(address: string, verbose: boolean): Promise<number> {
|
|
|
297
336
|
);
|
|
298
337
|
|
|
299
338
|
// ------------------------------------------------------------------------- walk the public log
|
|
300
|
-
const
|
|
301
|
-
|
|
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) ──`);
|
|
302
342
|
|
|
303
343
|
let digest = genesisDigestTs(tableId);
|
|
304
344
|
const seats: SeatReplay[] = Array.from({ length: seatCount }, () => ({
|
|
@@ -316,15 +356,182 @@ async function verify(address: string, verbose: boolean): Promise<number> {
|
|
|
316
356
|
let closes = 0;
|
|
317
357
|
let eliminations = 0;
|
|
318
358
|
|
|
319
|
-
/** State immediately before the
|
|
320
|
-
const before = (i: number): TableLedger | undefined => (i === 0 ? undefined :
|
|
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
|
+
};
|
|
321
502
|
|
|
322
|
-
for (let i = 0; i <
|
|
323
|
-
const
|
|
503
|
+
for (let i = 0; i < groups.length; i++) {
|
|
504
|
+
const g = groups[i]!;
|
|
505
|
+
const led = g.led;
|
|
324
506
|
const prev = before(i);
|
|
507
|
+
const isTurn = g.entryPoints.every((k) => TURN_CALLS.has(k));
|
|
325
508
|
|
|
326
|
-
|
|
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]) {
|
|
327
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
|
+
}
|
|
328
535
|
// Seat order is join order, so the seat this call took is the one that did not exist
|
|
329
536
|
// before it. The digest binds the seat's payout address and its entropy commitment.
|
|
330
537
|
const seat = Number(led.seatCount) - 1;
|
|
@@ -450,7 +657,7 @@ async function verify(address: string, verbose: boolean): Promise<number> {
|
|
|
450
657
|
const turn = led.seatTurn.lookup(BigInt(seat));
|
|
451
658
|
const chainDice = diceToArray(turn.roll);
|
|
452
659
|
|
|
453
|
-
if (
|
|
660
|
+
if (g.entryPoints[0] === 'resolveRoll1') {
|
|
454
661
|
// Roll 1 hashes the seat's declared entropy against the digest FROZEN AT ROUND OPEN --
|
|
455
662
|
// which is the digest the replay is holding right now, because it only advances at a
|
|
456
663
|
// closeRound. Getting that ordering wrong is the easiest way to make an unverifiable
|
|
@@ -524,8 +731,18 @@ async function verify(address: string, verbose: boolean): Promise<number> {
|
|
|
524
731
|
}
|
|
525
732
|
|
|
526
733
|
case 'closeRound': {
|
|
527
|
-
|
|
528
|
-
|
|
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
|
+
}
|
|
529
746
|
// The ONE place the digest advances. All six slots, in SEAT ORDER, read at this block --
|
|
530
747
|
// which is what makes the replay independent of the order the chain saw the moves in.
|
|
531
748
|
digest = roundDigestTs(digest, round, seatCount, roundResults(led));
|
|
@@ -544,21 +761,29 @@ async function verify(address: string, verbose: boolean): Promise<number> {
|
|
|
544
761
|
}
|
|
545
762
|
|
|
546
763
|
case 'eliminate': {
|
|
547
|
-
|
|
548
|
-
|
|
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.
|
|
549
768
|
let seat = -1;
|
|
550
769
|
for (let s = 0; s < seatCount; s++) {
|
|
551
|
-
if (
|
|
552
|
-
!prev.seatProgress.lookup(BigInt(s)).eliminated &&
|
|
553
|
-
led.seatProgress.lookup(BigInt(s)).eliminated
|
|
554
|
-
) {
|
|
770
|
+
if (!seats[s]!.eliminated && led.seatProgress.lookup(BigInt(s)).eliminated) {
|
|
555
771
|
seat = s;
|
|
772
|
+
break;
|
|
556
773
|
}
|
|
557
774
|
}
|
|
558
775
|
if (seat < 0) {
|
|
559
|
-
|
|
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
|
+
);
|
|
560
780
|
break;
|
|
561
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;
|
|
562
787
|
eliminations += 1;
|
|
563
788
|
seats[seat]!.eliminated = true;
|
|
564
789
|
seats[seat]!.finishedAtRound = Number.POSITIVE_INFINITY;
|
|
@@ -570,8 +795,7 @@ async function verify(address: string, verbose: boolean): Promise<number> {
|
|
|
570
795
|
// tells us how the seat left).
|
|
571
796
|
const timeoutPenalty = (final.tier * BigInt(round + 1)) / 13n;
|
|
572
797
|
const resignPenalty = (final.tier * BigInt(round)) / 13n;
|
|
573
|
-
const delta =
|
|
574
|
-
led.seatRedeemable.lookup(BigInt(seat)) - prev.seatRedeemable.lookup(BigInt(seat));
|
|
798
|
+
const delta = led.seatRedeemable.lookup(BigInt(seat));
|
|
575
799
|
const penalty = delta === final.tier - resignPenalty ? resignPenalty : timeoutPenalty;
|
|
576
800
|
const how =
|
|
577
801
|
penalty === resignPenalty && resignPenalty !== timeoutPenalty ? 'resigned' : 'timed out';
|
|
@@ -581,11 +805,17 @@ async function verify(address: string, verbose: boolean): Promise<number> {
|
|
|
581
805
|
delta === refund,
|
|
582
806
|
`expected +${refund} (penalty ${penalty}, ${how})`,
|
|
583
807
|
);
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
)
|
|
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
|
+
}
|
|
589
819
|
c.ok(
|
|
590
820
|
`seat ${seat}: carries the never-finished sentinel`,
|
|
591
821
|
led.seatProgress.lookup(BigInt(seat)).finishedAtRound === 65535n,
|
|
@@ -601,7 +831,7 @@ async function verify(address: string, verbose: boolean): Promise<number> {
|
|
|
601
831
|
break;
|
|
602
832
|
|
|
603
833
|
default:
|
|
604
|
-
console.log(` (unrecognised entry point '${
|
|
834
|
+
console.log(` (unrecognised entry point '${g.entryPoints[0] ?? '?'}' -- ignored)`);
|
|
605
835
|
}
|
|
606
836
|
|
|
607
837
|
// The custody invariant, at every single step: while the table holds the money, every atom
|
|
@@ -615,7 +845,7 @@ async function verify(address: string, verbose: boolean): Promise<number> {
|
|
|
615
845
|
paid += led.seatPaid.lookup(BigInt(s));
|
|
616
846
|
}
|
|
617
847
|
c.ok(
|
|
618
|
-
`
|
|
848
|
+
`tx ${i} (${g.entryPoints.join('+')}): custody invariant`,
|
|
619
849
|
led.pot + owed + paid === final.tier * led.seatCount,
|
|
620
850
|
`pot ${led.pot} + owed ${owed} + paid ${paid} != tier x ${led.seatCount}`,
|
|
621
851
|
);
|
|
@@ -692,18 +922,18 @@ async function verify(address: string, verbose: boolean): Promise<number> {
|
|
|
692
922
|
|
|
693
923
|
// -------------------------------------------------------------------------------- the payout
|
|
694
924
|
console.log('\n── the payout ──');
|
|
695
|
-
const
|
|
696
|
-
if (!
|
|
925
|
+
const settleGroup = groups.find((gr) => gr.entryPoints.includes('settle'));
|
|
926
|
+
if (!settleGroup) {
|
|
697
927
|
c.ok('the settle transaction is in the log', false);
|
|
698
928
|
} else {
|
|
699
|
-
const tx = await transactionByHash(
|
|
929
|
+
const tx = await transactionByHash(settleGroup.txHash);
|
|
700
930
|
const winnerAddr = final.seatIdentity.lookup(final.winnerSeatIndex).addr.bytes;
|
|
701
931
|
const rakeAddr = final.rakeAddress.bytes;
|
|
702
932
|
// `settle` pays out exactly the POT, which is the stakes minus whatever left it as an
|
|
703
933
|
// eliminated seat's refund. Read from the state just before the settle rather than assumed
|
|
704
934
|
// to be tier x seatCount, because an elimination moves money out of the pot.
|
|
705
|
-
const
|
|
706
|
-
const potBefore =
|
|
935
|
+
const settleAt = groups.indexOf(settleGroup);
|
|
936
|
+
const potBefore = groups[settleAt - 1]!.led.pot;
|
|
707
937
|
const q = potBefore / 100n;
|
|
708
938
|
|
|
709
939
|
const spentByUsers = sumNative(tx.unshieldedSpentOutputs);
|
|
@@ -736,27 +966,32 @@ async function verify(address: string, verbose: boolean): Promise<number> {
|
|
|
736
966
|
}
|
|
737
967
|
|
|
738
968
|
// ------------------------------------------------------------------------------- redemptions
|
|
739
|
-
const redeems =
|
|
969
|
+
const redeems = groups.filter((gr) => gr.entryPoints.includes('redeem'));
|
|
740
970
|
if (redeems.length > 0) {
|
|
741
971
|
console.log(`\n── ${redeems.length} redemption(s) ──`);
|
|
742
|
-
for (const
|
|
743
|
-
|
|
744
|
-
|
|
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.
|
|
745
977
|
let seat = -1;
|
|
746
978
|
for (let s = 0; s < seatCount; s++) {
|
|
747
979
|
if (
|
|
748
|
-
|
|
749
|
-
|
|
980
|
+
!redeemed.has(s) &&
|
|
981
|
+
gr.led.seatPaid.lookup(BigInt(s)) > 0n &&
|
|
982
|
+
gr.led.seatRedeemable.lookup(BigInt(s)) === 0n
|
|
750
983
|
) {
|
|
751
984
|
seat = s;
|
|
985
|
+
break;
|
|
752
986
|
}
|
|
753
987
|
}
|
|
754
988
|
if (seat < 0) {
|
|
755
989
|
c.ok('a redeem zeroed exactly one seat', false);
|
|
756
990
|
continue;
|
|
757
991
|
}
|
|
758
|
-
|
|
759
|
-
const
|
|
992
|
+
redeemed.add(seat);
|
|
993
|
+
const owed = gr.led.seatPaid.lookup(BigInt(seat));
|
|
994
|
+
const tx = await transactionByHash(gr.txHash);
|
|
760
995
|
const addr = final.seatIdentity.lookup(BigInt(seat)).addr.bytes;
|
|
761
996
|
console.log(` seat ${seat} redeemed ${owed}`);
|
|
762
997
|
c.ok(
|
|
@@ -774,9 +1009,22 @@ async function verify(address: string, verbose: boolean): Promise<number> {
|
|
|
774
1009
|
}
|
|
775
1010
|
}
|
|
776
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
|
+
}
|
|
777
1024
|
c.summary();
|
|
778
1025
|
console.log(`\n(${c.count} checks in total)`);
|
|
779
|
-
|
|
1026
|
+
if (c.failed.length > 0) return 1;
|
|
1027
|
+
return unseparable.length > 0 ? 2 : 0;
|
|
780
1028
|
}
|
|
781
1029
|
|
|
782
1030
|
/**
|
|
@@ -804,7 +1052,7 @@ const address = process.argv[2];
|
|
|
804
1052
|
if (!address) {
|
|
805
1053
|
console.error(
|
|
806
1054
|
'usage: npm run verify -w cli -- <table-address> [--verbose]\n\n' +
|
|
807
|
-
'Replays a settled
|
|
1055
|
+
'Replays a settled Dust Dice table from the chain alone: every roll re-derived under the\n' +
|
|
808
1056
|
'hold masks the players sent, every score recomputed, the winner and the payout\n' +
|
|
809
1057
|
're-confirmed.',
|
|
810
1058
|
);
|