@dust-dice/verifier 0.4.0
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 +42 -0
- package/src/config.ts +19 -0
- package/src/contracts.ts +199 -0
- package/src/indexer.ts +228 -0
- package/src/verify.ts +814 -0
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dust-dice/verifier",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"type": "module",
|
|
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
|
+
"exports": {
|
|
7
|
+
"./src/*": "./src/*"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"verify": "node src/verify.ts",
|
|
11
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@dust-dice/api": "0.4.0",
|
|
15
|
+
"@dust-dice/contract": "0.4.0",
|
|
16
|
+
"@midnight-ntwrk/compact-runtime": "0.19.0",
|
|
17
|
+
"@midnight-ntwrk/midnight-js-indexer-public-data-provider": "5.0.0-beta.7",
|
|
18
|
+
"@midnight-ntwrk/midnight-js-network-id": "5.0.0-beta.7",
|
|
19
|
+
"@midnight-ntwrk/midnight-js-protocol": "5.0.0-beta.7",
|
|
20
|
+
"@midnight-ntwrk/midnight-js-types": "5.0.0-beta.7"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^26.4.0",
|
|
24
|
+
"typescript": "^6.0.3"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"src"
|
|
31
|
+
],
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "https://github.com/chrispalaskas/dust-dice-contract.git",
|
|
35
|
+
"directory": "verifier"
|
|
36
|
+
},
|
|
37
|
+
"license": "Apache-2.0",
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=22.6"
|
|
40
|
+
},
|
|
41
|
+
"//engines": "src/verify.ts runs as TypeScript under Node's type stripping (default from Node 23; --experimental-strip-types on 22.6+)."
|
|
42
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Copyright (C) Shielded Technologies
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* The verifier's configuration: which network to read, and where the compiled contract lives.
|
|
5
|
+
* Nothing here is a secret or a game parameter — the verifier only READS the chain.
|
|
6
|
+
*/
|
|
7
|
+
import * as path from 'node:path';
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
9
|
+
import { resolveNetwork, type NetworkConfig } from '@dust-dice/api/node';
|
|
10
|
+
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
|
|
13
|
+
/** UNDEPLOYED (the local devnet) unless the MIDNIGHT_* environment variables say otherwise. */
|
|
14
|
+
export const NETWORK: NetworkConfig = resolveNetwork();
|
|
15
|
+
|
|
16
|
+
const CONTRACT_ROOT = path.dirname(require.resolve('@dust-dice/contract/package.json'));
|
|
17
|
+
/** compactc output for the two deployable contracts (`npm run compact -w contract`). */
|
|
18
|
+
export const MANAGED_TABLE = path.join(CONTRACT_ROOT, 'src', 'managed', 'table');
|
|
19
|
+
export const MANAGED_LOBBY = path.join(CONTRACT_ROOT, 'src', 'managed', 'lobby');
|
package/src/contracts.ts
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Copyright (C) Shielded Technologies
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The `compiledContract` values `deployContract`/`findDeployedContract` take, and one-shot
|
|
6
|
+
* reads of a table's public ledger.
|
|
7
|
+
*
|
|
8
|
+
* On midnight-js 5.x a `compiledContract` is NOT a bare `new Contract(witnesses)`:
|
|
9
|
+
* `deployContract` calls compact-js's `createContract`, which reads a hidden `CompactContext`
|
|
10
|
+
* off the value and needs `.ctor` and `.witnesses` on it. Passing a Contract instance fails
|
|
11
|
+
* deep inside proving with `TypeError: Cannot read properties of undefined (reading 'ctor')`,
|
|
12
|
+
* which names neither the argument nor the missing wrapper. The wrapper is
|
|
13
|
+
* `CompiledContract.make(tag, Ctor).pipe(...)`; both contracts here declare witnesses, so the
|
|
14
|
+
* combinator is `withWitnesses` (`withVacantWitnesses` is for contracts that declare none).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { CompiledContract } from '@midnight-ntwrk/midnight-js-protocol/compact-js';
|
|
18
|
+
import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider';
|
|
19
|
+
import {
|
|
20
|
+
Table,
|
|
21
|
+
Lobby,
|
|
22
|
+
tableWitnesses,
|
|
23
|
+
lobbyWitnesses,
|
|
24
|
+
type TablePrivateState,
|
|
25
|
+
type LobbyPrivateState,
|
|
26
|
+
} from '@dust-dice/contract';
|
|
27
|
+
|
|
28
|
+
import { NETWORK } from './config.ts';
|
|
29
|
+
|
|
30
|
+
export { Table, Lobby };
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The exported circuit names, spelled out.
|
|
34
|
+
*
|
|
35
|
+
* `createProviders` is generic in this and it MUST be instantiated with the concrete union, not
|
|
36
|
+
* left to infer `string`: midnight-js ties a provider's circuit-id type to the contract's own
|
|
37
|
+
* `ProvableCircuitId`, so a `string` here fails every `deployContract`/`findDeployedContract`
|
|
38
|
+
* overload with "Type 'string' is not assignable to type 'ProvableCircuitId<...>'" -- an error
|
|
39
|
+
* that points at the providers argument and says nothing about the type parameter that caused
|
|
40
|
+
* it.
|
|
41
|
+
*/
|
|
42
|
+
export type TableCircuitId =
|
|
43
|
+
| 'join'
|
|
44
|
+
| 'playerMove'
|
|
45
|
+
| 'resolveRoll1'
|
|
46
|
+
| 'resolveReroll'
|
|
47
|
+
| 'closeRound'
|
|
48
|
+
| 'eliminate'
|
|
49
|
+
| 'settle'
|
|
50
|
+
| 'redeem'
|
|
51
|
+
| 'abortTable';
|
|
52
|
+
|
|
53
|
+
export type LobbyCircuitId = 'openTableAt' | 'tableFilled';
|
|
54
|
+
|
|
55
|
+
export const CompiledTableContract = CompiledContract.make<Table.Contract<TablePrivateState>>(
|
|
56
|
+
'Table',
|
|
57
|
+
Table.Contract<TablePrivateState>,
|
|
58
|
+
).pipe(
|
|
59
|
+
CompiledContract.withWitnesses(tableWitnesses),
|
|
60
|
+
CompiledContract.withCompiledFileAssets('./table'),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
export const CompiledLobbyContract = CompiledContract.make<Lobby.Contract<LobbyPrivateState>>(
|
|
64
|
+
'Lobby',
|
|
65
|
+
Lobby.Contract<LobbyPrivateState>,
|
|
66
|
+
).pipe(
|
|
67
|
+
CompiledContract.withWitnesses(lobbyWitnesses),
|
|
68
|
+
CompiledContract.withCompiledFileAssets('./lobby'),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
export type TableLedger = Table.Ledger;
|
|
72
|
+
export type LobbyLedger = Lobby.Ledger;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Read a table's ledger with a ONE-SHOT query.
|
|
76
|
+
*
|
|
77
|
+
* Deliberately not `contractStateObservable`: that observable misses rapid successive updates
|
|
78
|
+
* and its first emission may predate the write being checked, so every read-after-write in this
|
|
79
|
+
* driver -- and there is one after almost every transaction -- has to use `queryContractState`.
|
|
80
|
+
* (bugs-found.md §0 #10; the same reason probes/gate0/src/step.ts reads this way.)
|
|
81
|
+
*
|
|
82
|
+
* `blockHeight` pins the read to a past block, which is what the chain-only verifier walks the
|
|
83
|
+
* game with.
|
|
84
|
+
*/
|
|
85
|
+
export async function readTableLedger(address: string, blockHeight?: number): Promise<TableLedger> {
|
|
86
|
+
const pdp = indexerPublicDataProvider(NETWORK.indexer, NETWORK.indexerWS);
|
|
87
|
+
const state =
|
|
88
|
+
blockHeight === undefined
|
|
89
|
+
? await pdp.queryContractState(address)
|
|
90
|
+
: await pdp.queryContractState(address, { type: 'blockHeight', blockHeight });
|
|
91
|
+
if (!state) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`no contract state at ${address}${blockHeight === undefined ? '' : ` @ block ${blockHeight}`}`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return Table.ledger(state.data);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function readLobbyLedger(address: string): Promise<LobbyLedger> {
|
|
100
|
+
const pdp = indexerPublicDataProvider(NETWORK.indexer, NETWORK.indexerWS);
|
|
101
|
+
const state = await pdp.queryContractState(address);
|
|
102
|
+
if (!state) throw new Error(`no contract state at ${address}`);
|
|
103
|
+
return Lobby.ledger(state.data);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** `Dice` as the plain five-element array everything else in this repo speaks. */
|
|
107
|
+
export function diceToArray(d: Table.Dice): number[] {
|
|
108
|
+
return [d.d0, d.d1, d.d2, d.d3, d.d4].map(Number);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------------------
|
|
112
|
+
// The shape of a game, restated for the driver and the verifier
|
|
113
|
+
// ---------------------------------------------------------------------------------------
|
|
114
|
+
//
|
|
115
|
+
// These mirror `table.compact` and are restated here rather than imported because the generated
|
|
116
|
+
// bindings spell them as plain `Uint<8>` values (they are not Compact `enum`s) and because both
|
|
117
|
+
// the driver and the chain-only verifier need them. See docs/table-interface.md.
|
|
118
|
+
|
|
119
|
+
/** Rounds per seat: 0..12, thirteen of them, one category each. `roundCount()`. */
|
|
120
|
+
export const ROUND_COUNT = 13;
|
|
121
|
+
|
|
122
|
+
/** The last round a seat plays. Completing it completes the scorecard. */
|
|
123
|
+
export const FINAL_ROUND = 12;
|
|
124
|
+
|
|
125
|
+
/** `playerMove` kinds. */
|
|
126
|
+
export const MOVE_OPEN = 0;
|
|
127
|
+
export const MOVE_HOLD = 1;
|
|
128
|
+
export const MOVE_SCORE = 2;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* `seatTurn.stage`.
|
|
132
|
+
*
|
|
133
|
+
* EVEN STAGES ARE OWED BY THE PLAYER, ODD ONES BY THE OPERATOR. That split is what the driver
|
|
134
|
+
* dispatches on and what `eliminate` and `abortTable` divide on in the contract.
|
|
135
|
+
*/
|
|
136
|
+
export const STAGE = {
|
|
137
|
+
idle: 0,
|
|
138
|
+
awaitRoll1: 1,
|
|
139
|
+
rolled1: 2,
|
|
140
|
+
awaitRoll2: 3,
|
|
141
|
+
rolled2: 4,
|
|
142
|
+
awaitRoll3: 5,
|
|
143
|
+
rolled3: 6,
|
|
144
|
+
} as const;
|
|
145
|
+
|
|
146
|
+
/** Is this seat's next move the player's? */
|
|
147
|
+
export const playerOwes = (stage: number): boolean => stage % 2 === 0;
|
|
148
|
+
|
|
149
|
+
/** The canonical "no mask" sentinel every `playerMove` kind but `hold` must carry. */
|
|
150
|
+
export const NO_MASK: boolean[] = [false, false, false, false, false];
|
|
151
|
+
|
|
152
|
+
/** The canonical "no entropy" sentinel every kind but `open` must carry. */
|
|
153
|
+
export const ZERO_BYTES32 = (): Uint8Array => new Uint8Array(32);
|
|
154
|
+
|
|
155
|
+
/** One seat's position, as the driver reads it off the ledger. */
|
|
156
|
+
export interface SeatView {
|
|
157
|
+
seat: number;
|
|
158
|
+
/** The next round this seat owes; `ROUND_COUNT` means finished or eliminated. */
|
|
159
|
+
round: number;
|
|
160
|
+
stage: number;
|
|
161
|
+
eliminated: boolean;
|
|
162
|
+
/** The dice as of the most recent resolved roll of the current turn. */
|
|
163
|
+
roll: number[];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function seatView(led: TableLedger, seat: number): SeatView {
|
|
167
|
+
const prog = led.seatProgress.lookup(BigInt(seat));
|
|
168
|
+
const turn = led.seatTurn.lookup(BigInt(seat));
|
|
169
|
+
return {
|
|
170
|
+
seat,
|
|
171
|
+
round: Number(prog.round),
|
|
172
|
+
stage: Number(turn.stage),
|
|
173
|
+
eliminated: prog.eliminated,
|
|
174
|
+
roll: diceToArray(turn.roll),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Every seated slot's view, in seat order. */
|
|
179
|
+
export function seatViews(led: TableLedger): SeatView[] {
|
|
180
|
+
return Array.from({ length: Number(led.seatCount) }, (_, s) => seatView(led, s));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The penalty split `eliminate` requires: `q * 13 + rem == tier * (round + 1)`, `rem < 13`.
|
|
185
|
+
*
|
|
186
|
+
* Compact has no division, so the caller supplies the quotient and remainder and the circuit
|
|
187
|
+
* checks the Euclidean identity -- which has exactly one solution, so supplying them grants no
|
|
188
|
+
* discretion. `round + 1` reconciles the contract's 0-based rounds with the design note's
|
|
189
|
+
* 1..13 numbering: a seat that never played at all forfeits a thirteenth, not nothing.
|
|
190
|
+
*/
|
|
191
|
+
export function penaltySplit(tier: bigint, round: number): { q: bigint; rem: bigint } {
|
|
192
|
+
const numer = tier * BigInt(round + 1);
|
|
193
|
+
return { q: numer / 13n, rem: numer % 13n };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The per-seat 1% rake on `tier` that `abortTable` requires, on every path. */
|
|
197
|
+
export function perSeatRake(tier: bigint): { q: bigint; rem: bigint } {
|
|
198
|
+
return { q: tier / 100n, rem: tier % 100n };
|
|
199
|
+
}
|
package/src/indexer.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Copyright (C) Shielded Technologies
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Direct indexer GraphQL access, for evidence the wallet cannot be asked to provide.
|
|
6
|
+
*
|
|
7
|
+
* The wallet facade's own `state().unshielded.balances` is the natural place to read a balance,
|
|
8
|
+
* but it is the same component that built the transactions -- so on its own it is a weak witness
|
|
9
|
+
* that custody really happened, and during Gate 0 it misreported a pre-stake balance by
|
|
10
|
+
* 4,900,000 in exactly this situation (docs/bugs-found.md §9). PER-TRANSACTION UTXO MOVEMENT IS
|
|
11
|
+
* THE ONLY TRUSTWORTHY MEASURE, and it is what these queries read:
|
|
12
|
+
*
|
|
13
|
+
* - `contractAction(address){ unshieldedBalances }` what the LEDGER says the contract holds
|
|
14
|
+
* - `transactions(offset:{hash}){ unshieldedCreatedOutputs / unshieldedSpentOutputs }`
|
|
15
|
+
* who actually received or spent what, with
|
|
16
|
+
* `registeredForDustGeneration` per created
|
|
17
|
+
* UTXO (the designation question)
|
|
18
|
+
* - `transactions(offset:{hash}){ raw }` the transaction's byte size, which is what
|
|
19
|
+
* the `OutsideTimeToDismiss` admission check
|
|
20
|
+
* measures
|
|
21
|
+
* - `contract(address){ actions }` the table's whole public history, which is
|
|
22
|
+
* all the chain-only verifier is given
|
|
23
|
+
*
|
|
24
|
+
* There is no user-address balance query in this indexer's schema (checked by introspecting
|
|
25
|
+
* `__schema.queryType.fields`: only `bridgeBalance(address)` takes an address, and that is the
|
|
26
|
+
* Cardano bridge, not NIGHT), so per-address totals are derived from the UTXO sets above.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { NETWORK } from './config.ts';
|
|
30
|
+
|
|
31
|
+
export interface GqlUnshieldedUtxo {
|
|
32
|
+
owner: string;
|
|
33
|
+
tokenType: string;
|
|
34
|
+
value: string;
|
|
35
|
+
intentHash: string;
|
|
36
|
+
outputIndex: number;
|
|
37
|
+
registeredForDustGeneration: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface GqlTransaction {
|
|
41
|
+
hash: string;
|
|
42
|
+
/** Hex of the whole transaction as the node saw it. `length / 2` is the size it checked. */
|
|
43
|
+
raw: string;
|
|
44
|
+
block: { height: number; timestamp: string };
|
|
45
|
+
unshieldedCreatedOutputs: GqlUnshieldedUtxo[];
|
|
46
|
+
unshieldedSpentOutputs: GqlUnshieldedUtxo[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** One entry in a contract's public history. `entryPoint` is absent on the deploy. */
|
|
50
|
+
export interface ContractAction {
|
|
51
|
+
kind: 'ContractDeploy' | 'ContractCall' | 'ContractUpdate';
|
|
52
|
+
entryPoint?: string;
|
|
53
|
+
txHash: string;
|
|
54
|
+
blockHeight: number;
|
|
55
|
+
blockTimestamp: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function gql<T>(query: string, variables: Record<string, unknown> = {}): Promise<T> {
|
|
59
|
+
const res = await fetch(NETWORK.indexer, {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: { 'Content-Type': 'application/json' },
|
|
62
|
+
body: JSON.stringify({ query, variables }),
|
|
63
|
+
});
|
|
64
|
+
if (!res.ok) throw new Error(`indexer HTTP ${res.status}: ${await res.text()}`);
|
|
65
|
+
const body = (await res.json()) as { data?: T; errors?: { message: string }[] };
|
|
66
|
+
if (body.errors?.length) {
|
|
67
|
+
throw new Error(`indexer GraphQL: ${body.errors.map((e) => e.message).join('; ')}`);
|
|
68
|
+
}
|
|
69
|
+
if (!body.data) throw new Error('indexer returned no data');
|
|
70
|
+
return body.data;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The native (NIGHT) token type is 32 zero bytes. Matches Compact's `nativeToken()`. */
|
|
74
|
+
export const NATIVE_TOKEN_HEX = '00'.repeat(32);
|
|
75
|
+
|
|
76
|
+
const isNative = (u: { tokenType: string }): boolean =>
|
|
77
|
+
u.tokenType.replace(/^0x/, '').toLowerCase() === NATIVE_TOKEN_HEX;
|
|
78
|
+
|
|
79
|
+
export function sumNative(utxos: GqlUnshieldedUtxo[]): bigint {
|
|
80
|
+
return utxos.filter(isNative).reduce((acc, u) => acc + BigInt(u.value), 0n);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function sumNativeFor(utxos: GqlUnshieldedUtxo[], owner: string): bigint {
|
|
84
|
+
return sumNative(utxos.filter((u) => u.owner === owner));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The contract's own unshielded balances as the LEDGER sees them, keyed by token-type hex.
|
|
89
|
+
*
|
|
90
|
+
* `unshieldedBalances` hangs off `ContractAction`, NOT off `Contract` -- `contract(address)`
|
|
91
|
+
* returns a `Contract` whose only fields are address/state/maintenanceAuthority/actions.
|
|
92
|
+
* `contractAction(address)` returns the latest action, which is what carries the running
|
|
93
|
+
* balances, and all three action variants declare the field so it selects on the interface.
|
|
94
|
+
*/
|
|
95
|
+
export async function contractUnshieldedBalances(address: string): Promise<Record<string, bigint>> {
|
|
96
|
+
const data = await gql<{
|
|
97
|
+
contractAction: { unshieldedBalances: { tokenType: string; amount: string }[] } | null;
|
|
98
|
+
}>(
|
|
99
|
+
`query ($address: HexEncoded!) {
|
|
100
|
+
contractAction(address: $address) { unshieldedBalances { tokenType amount } }
|
|
101
|
+
}`,
|
|
102
|
+
{ address },
|
|
103
|
+
);
|
|
104
|
+
const out: Record<string, bigint> = {};
|
|
105
|
+
for (const b of data.contractAction?.unshieldedBalances ?? [])
|
|
106
|
+
out[b.tokenType] = BigInt(b.amount);
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function nativeBalance(balances: Record<string, bigint>): bigint {
|
|
111
|
+
for (const [k, v] of Object.entries(balances)) {
|
|
112
|
+
if (k.replace(/^0x/, '').toLowerCase() === NATIVE_TOKEN_HEX) return v;
|
|
113
|
+
}
|
|
114
|
+
return 0n;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* One transaction by hash, with its UTXO deltas and its raw bytes.
|
|
119
|
+
*
|
|
120
|
+
* Polls: the indexer trails the node slightly, so a hash returned by `submitTransaction` is not
|
|
121
|
+
* necessarily queryable the same instant.
|
|
122
|
+
*/
|
|
123
|
+
export async function transactionByHash(
|
|
124
|
+
hash: string,
|
|
125
|
+
{ attempts = 40, delayMs = 1000 } = {},
|
|
126
|
+
): Promise<GqlTransaction> {
|
|
127
|
+
const q = `query ($hash: HexEncoded!) {
|
|
128
|
+
transactions(offset: { hash: $hash }) {
|
|
129
|
+
hash
|
|
130
|
+
raw
|
|
131
|
+
block { height timestamp }
|
|
132
|
+
unshieldedCreatedOutputs {
|
|
133
|
+
owner tokenType value intentHash outputIndex registeredForDustGeneration
|
|
134
|
+
}
|
|
135
|
+
unshieldedSpentOutputs {
|
|
136
|
+
owner tokenType value intentHash outputIndex registeredForDustGeneration
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}`;
|
|
140
|
+
for (let i = 0; i < attempts; i++) {
|
|
141
|
+
const data = await gql<{ transactions: GqlTransaction[] }>(q, { hash });
|
|
142
|
+
if (data.transactions.length > 0) return data.transactions[0]!;
|
|
143
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
144
|
+
}
|
|
145
|
+
throw new Error(`transaction ${hash} not indexed after ${attempts} attempts`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Byte size of a transaction as the node measured it, from the indexer's `raw` hex. */
|
|
149
|
+
export function transactionBytes(tx: GqlTransaction): number {
|
|
150
|
+
const raw = tx.raw.startsWith('0x') ? tx.raw.slice(2) : tx.raw;
|
|
151
|
+
return raw.length / 2;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The node's dismiss-time allowance for a transaction of `bytes`, in milliseconds.
|
|
156
|
+
*
|
|
157
|
+
* `max(min_time_to_dismiss, time_to_dismiss_per_byte x size)` with ledger-9.1.0.0-rc.3's
|
|
158
|
+
* `INITIAL_LIMITS`: 15.000 ms and 0.002 ms/byte. A transaction is admissible only if this
|
|
159
|
+
* exceeds its dismiss cost, which is ~15.97 ms of fixed cryptographic constants -- hence the
|
|
160
|
+
* ~7,984-byte floor (docs/gate0-report.md).
|
|
161
|
+
*/
|
|
162
|
+
export const allowanceMs = (bytes: number): number => Math.max(15.0, 0.002 * bytes);
|
|
163
|
+
|
|
164
|
+
/** The floor a contract call must clear to be admitted at all, in bytes. */
|
|
165
|
+
export const ADMISSION_FLOOR_BYTES = 7984;
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* A contract's whole public history, oldest first.
|
|
169
|
+
*
|
|
170
|
+
* This is the ONLY thing `verify.ts` is given beyond the table's address: every join, every
|
|
171
|
+
* turn and the settlement are recovered from these actions and the contract states they point
|
|
172
|
+
* at. The indexer returns them newest-first, so they are reversed here.
|
|
173
|
+
*/
|
|
174
|
+
export async function contractActions(address: string, limit = 1000): Promise<ContractAction[]> {
|
|
175
|
+
type Row = {
|
|
176
|
+
__typename: ContractAction['kind'];
|
|
177
|
+
entryPoint?: string;
|
|
178
|
+
transaction: { hash: string; block: { height: number; timestamp: string } };
|
|
179
|
+
};
|
|
180
|
+
const data = await gql<{ contract: { actions: Row[] } | null }>(
|
|
181
|
+
`query ($address: HexEncoded!, $limit: Int!) {
|
|
182
|
+
contract(address: $address) {
|
|
183
|
+
actions(limit: $limit) {
|
|
184
|
+
__typename
|
|
185
|
+
... on ContractCall { entryPoint transaction { hash block { height timestamp } } }
|
|
186
|
+
... on ContractDeploy { transaction { hash block { height timestamp } } }
|
|
187
|
+
... on ContractUpdate { transaction { hash block { height timestamp } } }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}`,
|
|
191
|
+
{ address, limit },
|
|
192
|
+
);
|
|
193
|
+
if (!data.contract) throw new Error(`no contract at ${address}`);
|
|
194
|
+
return data.contract.actions
|
|
195
|
+
.map((a) => ({
|
|
196
|
+
kind: a.__typename,
|
|
197
|
+
entryPoint: a.entryPoint,
|
|
198
|
+
txHash: a.transaction.hash,
|
|
199
|
+
blockHeight: a.transaction.block.height,
|
|
200
|
+
blockTimestamp: Number(a.transaction.block.timestamp),
|
|
201
|
+
}))
|
|
202
|
+
.reverse();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Current chain tip, for sanity-checking that blocks are being produced. */
|
|
206
|
+
export async function tip(): Promise<{ height: number; timestamp: number }> {
|
|
207
|
+
const data = await gql<{ block: { height: number; timestamp: string } | null }>(
|
|
208
|
+
`{ block { height timestamp } }`,
|
|
209
|
+
);
|
|
210
|
+
if (!data.block) throw new Error('chain has no tip block');
|
|
211
|
+
return { height: data.block.height, timestamp: Number(data.block.timestamp) };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The `now` a state-advancing circuit should declare, in seconds since the epoch.
|
|
216
|
+
*
|
|
217
|
+
* Read off the CHAIN's latest block rather than from the local wall clock, and that choice is
|
|
218
|
+
* deliberate. `stampTime` traps the declared value in `(blockTime - 600, blockTime]`
|
|
219
|
+
* (table.compact, decision 5), so `now` must not exceed the block time of the block the
|
|
220
|
+
* transaction lands in. The tip's timestamp is by construction less than or equal to that of
|
|
221
|
+
* any later block, so it always satisfies the upper bound, and the transaction lands a few
|
|
222
|
+
* blocks later -- far inside the 600 s slack. A local clock a second fast would fail
|
|
223
|
+
* `blockTimeGte` instead, with an assertion message about time that says nothing about clocks.
|
|
224
|
+
*/
|
|
225
|
+
export async function chainNowSecs(): Promise<bigint> {
|
|
226
|
+
const { timestamp } = await tip();
|
|
227
|
+
return BigInt(Math.floor(timestamp / 1000));
|
|
228
|
+
}
|
package/src/verify.ts
ADDED
|
@@ -0,0 +1,814 @@
|
|
|
1
|
+
// Copyright (C) Shielded Technologies
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The settlement verifier: replay a whole game from the chain and nothing else.
|
|
6
|
+
*
|
|
7
|
+
* npm run verify -w cli -- <table-address>
|
|
8
|
+
*
|
|
9
|
+
* WHAT IT IS GIVEN: a contract address. Nothing else -- no seeds, no player secrets, no run
|
|
10
|
+
* state, no artefacts from the process that played the game. It talks to a public indexer.
|
|
11
|
+
* Anyone can run it against anyone's table.
|
|
12
|
+
*
|
|
13
|
+
* WHAT IT PROVES. Once `settle` has revealed the seed, every die of every roll is a
|
|
14
|
+
* deterministic function of public data, so the whole game can be recomputed and compared:
|
|
15
|
+
*
|
|
16
|
+
* 1. the revealed seed opens the commitment the table was deployed with -- which was made
|
|
17
|
+
* before any player existed, so the operator could not have chosen it to suit the dice;
|
|
18
|
+
* 2. every roll of every turn, re-derived from (tableId, seed, mixed entropy, round, roll
|
|
19
|
+
* index) through the same byte ladder the circuit uses, matches the dice the chain
|
|
20
|
+
* published -- and every REROLL is re-derived under the hold mask the player actually sent,
|
|
21
|
+
* merged left to right over the rerolled positions;
|
|
22
|
+
* 3. the ROUND DIGEST CHAIN reproduces exactly, from the genesis digest through every join and
|
|
23
|
+
* every round close. This is the part that makes 2 worth anything: every seat in round r
|
|
24
|
+
* hashes the digest as it stood when round r OPENED, so no roll can be checked without
|
|
25
|
+
* having replayed every earlier round in order;
|
|
26
|
+
* 4. every score, recomputed from the dice with api/src/rules.ts -- the contract-canonical
|
|
27
|
+
* rules engine, not the circuit -- matches the scorecard the chain holds, box by box,
|
|
28
|
+
* including the upper bonus and the Yahtzee bonuses;
|
|
29
|
+
* 5. the winner the tie-break selects, among SURVIVORS, is the seat the chain paid;
|
|
30
|
+
* 6. the settle transaction spent ZERO user inputs and created exactly the two expected
|
|
31
|
+
* outputs, to the addresses recorded at join and at construction.
|
|
32
|
+
*
|
|
33
|
+
* WHAT IT CANNOT PROVE, stated honestly: that each seat's published entropy really is
|
|
34
|
+
* `H(sk_s, tableId, round)` for the secret committed at join. That binding is what the
|
|
35
|
+
* `playerMove` circuit asserts in zero knowledge, and it is unverifiable from public data by
|
|
36
|
+
* construction -- if it were verifiable, `sk_s` would be public. The verifier confirms that the
|
|
37
|
+
* chain accepted a proof of it, which is the whole point of the proof existing.
|
|
38
|
+
*
|
|
39
|
+
* -------------------------------------------------------------------------------------------
|
|
40
|
+
* HOW IT READS A GAME THAT HAS NO CURSOR
|
|
41
|
+
* -------------------------------------------------------------------------------------------
|
|
42
|
+
*
|
|
43
|
+
* The indexer gives the table's whole action list -- one entry per transaction, with an entry
|
|
44
|
+
* point and a block height -- and the contract's public state can be read at any block. So the
|
|
45
|
+
* verifier walks the actions in order and reads the state each one produced.
|
|
46
|
+
*
|
|
47
|
+
* TWO THINGS ARE HARDER THAN THEY WERE, and both are solved by diffing state rather than by
|
|
48
|
+
* being told:
|
|
49
|
+
*
|
|
50
|
+
* - `playerMove` is ONE entry point for THREE moves. Which one it was is recovered from the
|
|
51
|
+
* seat's stage transition: idle -> awaitRoll1 is an open, rolled{1,2} -> awaitRoll{2,3} is a
|
|
52
|
+
* hold, rolled{1,2,3} -> idle is a score. The verifier also has to work out WHICH SEAT
|
|
53
|
+
* moved, which it does by finding the one seat whose entry changed.
|
|
54
|
+
* - the CATEGORY a player chose is not stored in the ledger (it is a circuit argument), so it
|
|
55
|
+
* is recovered by diffing the seat's scorecard across the score. That is a stronger check
|
|
56
|
+
* than being told: the verifier finds the box that changed AND recomputes what belongs in
|
|
57
|
+
* it.
|
|
58
|
+
*
|
|
59
|
+
* The HOLD MASKS, by contrast, are readable: `seatTurn.hold1` and `hold2` are ledger state, so
|
|
60
|
+
* the verifier reads the mask the player sent and re-derives the reroll under it. A mask that
|
|
61
|
+
* did not produce the published dice fails check 2.
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
import {
|
|
65
|
+
applyScore,
|
|
66
|
+
CATEGORY_COUNT,
|
|
67
|
+
emptyScorecard,
|
|
68
|
+
grandTotal,
|
|
69
|
+
type Category,
|
|
70
|
+
type Dice as RefDice,
|
|
71
|
+
type Scorecard,
|
|
72
|
+
} from '@dust-dice/api';
|
|
73
|
+
import {
|
|
74
|
+
emptyRoundResult,
|
|
75
|
+
firstRollTs,
|
|
76
|
+
genesisDigestTs,
|
|
77
|
+
joinDigestTs,
|
|
78
|
+
mixEntropyTs,
|
|
79
|
+
rerollUnderMaskTs,
|
|
80
|
+
roundDigestTs,
|
|
81
|
+
seedCommitmentTs,
|
|
82
|
+
type RoundResultTs,
|
|
83
|
+
} from '@dust-dice/contract';
|
|
84
|
+
|
|
85
|
+
import { setNetworkId } from '@midnight-ntwrk/midnight-js-network-id';
|
|
86
|
+
import { userAddressBytes } from '@dust-dice/api/node';
|
|
87
|
+
|
|
88
|
+
import { NETWORK } from './config.ts';
|
|
89
|
+
import {
|
|
90
|
+
diceToArray,
|
|
91
|
+
FINAL_ROUND,
|
|
92
|
+
readTableLedger,
|
|
93
|
+
ROUND_COUNT,
|
|
94
|
+
STAGE,
|
|
95
|
+
Table,
|
|
96
|
+
type TableLedger,
|
|
97
|
+
} from './contracts.ts';
|
|
98
|
+
import {
|
|
99
|
+
contractActions,
|
|
100
|
+
sumNative,
|
|
101
|
+
sumNativeFor,
|
|
102
|
+
transactionByHash,
|
|
103
|
+
type ContractAction,
|
|
104
|
+
} from './indexer.ts';
|
|
105
|
+
|
|
106
|
+
const hex = (b: Uint8Array): string => Buffer.from(b).toString('hex');
|
|
107
|
+
const same = (a: Uint8Array, b: Uint8Array): boolean => hex(a) === hex(b);
|
|
108
|
+
const MAX_SEATS = 6;
|
|
109
|
+
|
|
110
|
+
class Checks {
|
|
111
|
+
private failures: string[] = [];
|
|
112
|
+
private passes = 0;
|
|
113
|
+
private readonly verbose: boolean;
|
|
114
|
+
|
|
115
|
+
constructor(verbose: boolean) {
|
|
116
|
+
this.verbose = verbose;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
ok(what: string, condition: boolean, detail = ''): void {
|
|
120
|
+
if (condition) {
|
|
121
|
+
this.passes += 1;
|
|
122
|
+
if (this.verbose) console.log(` PASS ${what}`);
|
|
123
|
+
} else {
|
|
124
|
+
this.failures.push(`${what}${detail ? ` -- ${detail}` : ''}`);
|
|
125
|
+
console.log(` FAIL ${what}${detail ? ` -- ${detail}` : ''}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
get failed(): string[] {
|
|
130
|
+
return this.failures;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
get count(): number {
|
|
134
|
+
return this.passes + this.failures.length;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
summary(): void {
|
|
138
|
+
console.log(
|
|
139
|
+
`\n${this.failures.length === 0 ? 'VERIFIED' : 'VERIFICATION FAILED'}: ` +
|
|
140
|
+
`${this.passes} checks passed, ${this.failures.length} failed`,
|
|
141
|
+
);
|
|
142
|
+
for (const f of this.failures) console.log(` - ${f}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** One entry of the public log, with the contract state it produced. */
|
|
147
|
+
interface Step {
|
|
148
|
+
action: ContractAction;
|
|
149
|
+
led: TableLedger;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Read the table's whole history: every action, and the state it left behind.
|
|
154
|
+
*
|
|
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).
|
|
158
|
+
*/
|
|
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
|
+
}
|
|
166
|
+
|
|
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
|
+
);
|
|
186
|
+
}
|
|
187
|
+
return steps;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The seat whose `seatTurn` entry changed between two states, or -1. */
|
|
191
|
+
function movedSeat(prev: TableLedger, led: TableLedger): number {
|
|
192
|
+
for (let s = 0; s < Number(led.seatCount); s++) {
|
|
193
|
+
const a = prev.seatTurn.lookup(BigInt(s));
|
|
194
|
+
const b = led.seatTurn.lookup(BigInt(s));
|
|
195
|
+
if (a.stage !== b.stage) return s;
|
|
196
|
+
}
|
|
197
|
+
// A score returns the stage to idle from a non-idle value, so it is caught above. A move that
|
|
198
|
+
// changed nothing at all is not a move.
|
|
199
|
+
return -1;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function seatCard(led: TableLedger, seat: number): Scorecard {
|
|
203
|
+
const c = led.seatCard.lookup(BigInt(seat));
|
|
204
|
+
return {
|
|
205
|
+
scores: c.filled.map((f, i) => (f ? Number(c.scores[i]) : null)),
|
|
206
|
+
yahtzeeBonuses: Number(c.yahtzeeBonuses),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function arrayEq(a: readonly number[], b: readonly number[]): boolean {
|
|
211
|
+
return a.length === b.length && a.every((x, i) => x === b[i]);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** All six slots' contributions to a round digest, read at the block the round closed. */
|
|
215
|
+
function roundResults(led: TableLedger): RoundResultTs[] {
|
|
216
|
+
return Array.from({ length: MAX_SEATS }, (_, s) => {
|
|
217
|
+
if (s >= Number(led.seatCount)) return emptyRoundResult();
|
|
218
|
+
const p = led.seatProgress.lookup(BigInt(s));
|
|
219
|
+
return { dice: diceToArray(p.dice), out: p.eliminated };
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** What the verifier is tracking for one seat as it replays. */
|
|
224
|
+
interface SeatReplay {
|
|
225
|
+
card: Scorecard;
|
|
226
|
+
/** The mixed entropy of the turn in progress, latched at roll 1. */
|
|
227
|
+
mixed?: Uint8Array;
|
|
228
|
+
/** The dice as the replay believes them, after each resolved roll. */
|
|
229
|
+
roll: number[];
|
|
230
|
+
/** How many rolls the current turn has resolved. */
|
|
231
|
+
rolls: number;
|
|
232
|
+
eliminated: boolean;
|
|
233
|
+
finishedAtRound: number;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function verify(address: string, verbose: boolean): Promise<number> {
|
|
237
|
+
// `userAddressBytes` decodes bech32m, which is network-tagged; nothing here builds a wallet,
|
|
238
|
+
// so the network id has to be set explicitly.
|
|
239
|
+
setNetworkId(NETWORK.networkId);
|
|
240
|
+
const c = new Checks(verbose);
|
|
241
|
+
console.log(`Verifying table ${address}\n`);
|
|
242
|
+
|
|
243
|
+
const final = await readTableLedger(address);
|
|
244
|
+
const seatCount = Number(final.seatCount);
|
|
245
|
+
const tableId = final.tableId;
|
|
246
|
+
|
|
247
|
+
console.log('── table, as deployed ──');
|
|
248
|
+
console.log(` tableId ${hex(tableId)}`);
|
|
249
|
+
console.log(` tier ${final.tier} seats ${final.seatLimit}`);
|
|
250
|
+
console.log(` seedCommitment ${hex(final.seedCommitment)}`);
|
|
251
|
+
console.log(` phase ${Table.Phase[final.phase]}`);
|
|
252
|
+
// The mode changes what "verified" covers (docs/fast-turn-design.md): dice and payouts verify
|
|
253
|
+
// identically in both, but hold-before-reveal ORDERING is chain-proven only on an on-chain-mode
|
|
254
|
+
// table — a fast table's turn lands as one composed transaction, so its ordering is the
|
|
255
|
+
// operator's attestation.
|
|
256
|
+
console.log(
|
|
257
|
+
` mode ${final.fastMode ? 'FAST (turn ordering operator-attested)' : 'on-chain (ordering chain-proven)'}`,
|
|
258
|
+
);
|
|
259
|
+
|
|
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
|
+
);
|
|
265
|
+
if (final.phase !== Table.Phase.settled) {
|
|
266
|
+
c.summary();
|
|
267
|
+
return 1;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---------------------------------------------------------------- 1. the seed opens the commit
|
|
271
|
+
console.log('\n── the revealed seed ──');
|
|
272
|
+
const seed = final.revealedSeed;
|
|
273
|
+
console.log(` seed ${hex(seed)}`);
|
|
274
|
+
|
|
275
|
+
// An all-zero `revealedSeed` on a SETTLED table is not a missing field -- it is the contract's
|
|
276
|
+
// marker for a game that was force-settled past the table deadline with no valid seed. The
|
|
277
|
+
// payout was still fully determined by public state and every roll was proven in its own
|
|
278
|
+
// transaction while the game was live, but the game cannot be REPLAYED offline, which is
|
|
279
|
+
// exactly what this tool does. Say so and stop, rather than reporting a failure that suggests
|
|
280
|
+
// the chain did something wrong.
|
|
281
|
+
if (seed.every((b) => b === 0)) {
|
|
282
|
+
console.log(
|
|
283
|
+
'\n This table was FORCE-SETTLED: the operator never revealed a valid seed before the\n' +
|
|
284
|
+
' table deadline, so `settle` paid the winner computed from public state and left\n' +
|
|
285
|
+
' `revealedSeed` at zero. The rolls cannot be re-derived offline. Nothing here is\n' +
|
|
286
|
+
' wrong; this game is simply unverifiable after the fact.',
|
|
287
|
+
);
|
|
288
|
+
c.summary();
|
|
289
|
+
return 1;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
c.ok(
|
|
293
|
+
'the revealed seed opens the commitment the table was deployed with',
|
|
294
|
+
same(seedCommitmentTs(tableId, seed), final.seedCommitment),
|
|
295
|
+
`H(tableId, seed) = ${hex(seedCommitmentTs(tableId, seed))}`,
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
// ------------------------------------------------------------------------- walk the public log
|
|
299
|
+
const steps = await readHistory(address);
|
|
300
|
+
console.log(`\n── the public log: ${steps.length} calls ──`);
|
|
301
|
+
|
|
302
|
+
let digest = genesisDigestTs(tableId);
|
|
303
|
+
const seats: SeatReplay[] = Array.from({ length: seatCount }, () => ({
|
|
304
|
+
card: emptyScorecard(),
|
|
305
|
+
roll: [1, 1, 1, 1, 1],
|
|
306
|
+
rolls: 0,
|
|
307
|
+
eliminated: false,
|
|
308
|
+
finishedAtRound: Number.POSITIVE_INFINITY,
|
|
309
|
+
}));
|
|
310
|
+
let joins = 0;
|
|
311
|
+
let opens = 0;
|
|
312
|
+
let holds = 0;
|
|
313
|
+
let scores = 0;
|
|
314
|
+
let rollChecks = 0;
|
|
315
|
+
let closes = 0;
|
|
316
|
+
let eliminations = 0;
|
|
317
|
+
|
|
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);
|
|
320
|
+
|
|
321
|
+
for (let i = 0; i < steps.length; i++) {
|
|
322
|
+
const { action, led } = steps[i]!;
|
|
323
|
+
const prev = before(i);
|
|
324
|
+
|
|
325
|
+
switch (action.entryPoint) {
|
|
326
|
+
case 'join': {
|
|
327
|
+
// Seat order is join order, so the seat this call took is the one that did not exist
|
|
328
|
+
// before it. The digest binds the seat's payout address and its entropy commitment.
|
|
329
|
+
const seat = Number(led.seatCount) - 1;
|
|
330
|
+
const id = led.seatIdentity.lookup(BigInt(seat));
|
|
331
|
+
digest = joinDigestTs(digest, seat, id.addr.bytes, id.keyCommit);
|
|
332
|
+
joins += 1;
|
|
333
|
+
c.ok(
|
|
334
|
+
`join ${seat}: digest chain`,
|
|
335
|
+
same(led.roundDigest, digest),
|
|
336
|
+
`chain ${hex(led.roundDigest)} vs replay ${hex(digest)}`,
|
|
337
|
+
);
|
|
338
|
+
c.ok(
|
|
339
|
+
`join ${seat}: pot rose by exactly the tier`,
|
|
340
|
+
led.pot === final.tier * BigInt(seat + 1),
|
|
341
|
+
`pot ${led.pot}`,
|
|
342
|
+
);
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
case 'playerMove': {
|
|
347
|
+
if (!prev) break;
|
|
348
|
+
const seat = movedSeat(prev, led);
|
|
349
|
+
if (seat < 0) {
|
|
350
|
+
c.ok('playerMove changed exactly one seat', false, 'no seat changed stage');
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
const round = Number(prev.openRound);
|
|
354
|
+
const wasStage = Number(prev.seatTurn.lookup(BigInt(seat)).stage);
|
|
355
|
+
const nowStage = Number(led.seatTurn.lookup(BigInt(seat)).stage);
|
|
356
|
+
const r = seats[seat]!;
|
|
357
|
+
|
|
358
|
+
if (wasStage === STAGE.idle && nowStage === STAGE.awaitRoll1) {
|
|
359
|
+
// OPEN. The turn's mixed entropy is not latched until roll 1, but the entropy the
|
|
360
|
+
// player declared is on chain now, and it is what roll 1 will hash.
|
|
361
|
+
opens += 1;
|
|
362
|
+
r.rolls = 0;
|
|
363
|
+
c.ok(
|
|
364
|
+
`seat ${seat} r${round}: open records the round`,
|
|
365
|
+
Number(led.seatTurn.lookup(BigInt(seat)).round) === round,
|
|
366
|
+
);
|
|
367
|
+
c.ok(
|
|
368
|
+
`seat ${seat} r${round}: open clears both hold masks`,
|
|
369
|
+
led.seatTurn.lookup(BigInt(seat)).hold1.bits.every((b) => !b) &&
|
|
370
|
+
led.seatTurn.lookup(BigInt(seat)).hold2.bits.every((b) => !b),
|
|
371
|
+
);
|
|
372
|
+
} else if (nowStage === STAGE.awaitRoll2 || nowStage === STAGE.awaitRoll3) {
|
|
373
|
+
// HOLD. The mask is ledger state, so it is read rather than guessed -- and the reroll
|
|
374
|
+
// it produces is checked against it when the resolve lands.
|
|
375
|
+
holds += 1;
|
|
376
|
+
const which = nowStage === STAGE.awaitRoll2 ? 'hold1' : 'hold2';
|
|
377
|
+
const mask = led.seatTurn.lookup(BigInt(seat))[which].bits;
|
|
378
|
+
c.ok(
|
|
379
|
+
`seat ${seat} r${round}: ${which} landed in its own cell`,
|
|
380
|
+
mask.length === 5,
|
|
381
|
+
`mask ${mask.map((b) => (b ? 1 : 0)).join('')}`,
|
|
382
|
+
);
|
|
383
|
+
} else if (nowStage === STAGE.idle) {
|
|
384
|
+
// SCORE. The category is recovered by diffing the card, then recomputed.
|
|
385
|
+
scores += 1;
|
|
386
|
+
const chainBefore = seatCard(prev, seat);
|
|
387
|
+
const chainAfter = seatCard(led, seat);
|
|
388
|
+
const category = chainAfter.scores.findIndex(
|
|
389
|
+
(s, k) => s !== null && chainBefore.scores[k] === null,
|
|
390
|
+
);
|
|
391
|
+
c.ok(
|
|
392
|
+
`seat ${seat} r${round}: score filled exactly one new category`,
|
|
393
|
+
category >= 0 &&
|
|
394
|
+
chainAfter.scores.filter((s) => s !== null).length ===
|
|
395
|
+
chainBefore.scores.filter((s) => s !== null).length + 1,
|
|
396
|
+
`category index ${category}`,
|
|
397
|
+
);
|
|
398
|
+
if (category >= 0) {
|
|
399
|
+
// The dice scored are the ones the replay derived for this turn -- NOT read from the
|
|
400
|
+
// chain. That is what makes this a check of the dice rather than of the bookkeeping.
|
|
401
|
+
const dice = r.roll;
|
|
402
|
+
// Recompute the placement with the CONTRACT-CANONICAL rules engine. If the chain and
|
|
403
|
+
// api/src/rules.ts ever disagree about a box, one of them is wrong -- and the rules
|
|
404
|
+
// engine is the specification.
|
|
405
|
+
r.card = applyScore(r.card, category as Category, dice as unknown as RefDice);
|
|
406
|
+
for (let k = 0; k < CATEGORY_COUNT; k++) {
|
|
407
|
+
c.ok(
|
|
408
|
+
`seat ${seat} r${round}: box ${k}`,
|
|
409
|
+
r.card.scores[k] === chainAfter.scores[k],
|
|
410
|
+
`replay ${r.card.scores[k]} vs chain ${chainAfter.scores[k]}`,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
c.ok(
|
|
414
|
+
`seat ${seat} r${round}: running total`,
|
|
415
|
+
BigInt(grandTotal(r.card)) === led.seatProgress.lookup(BigInt(seat)).total,
|
|
416
|
+
`replay ${grandTotal(r.card)} vs chain ${led.seatProgress.lookup(BigInt(seat)).total}`,
|
|
417
|
+
);
|
|
418
|
+
c.ok(
|
|
419
|
+
`seat ${seat} r${round}: the dice the chain stored are the ones replayed`,
|
|
420
|
+
arrayEq(diceToArray(led.seatProgress.lookup(BigInt(seat)).dice), dice),
|
|
421
|
+
`chain ${diceToArray(led.seatProgress.lookup(BigInt(seat)).dice)} vs replay ${dice}`,
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
c.ok(
|
|
425
|
+
`seat ${seat} r${round}: score advances the seat past the round`,
|
|
426
|
+
Number(led.seatProgress.lookup(BigInt(seat)).round) === round + 1,
|
|
427
|
+
);
|
|
428
|
+
if (round === FINAL_ROUND) r.finishedAtRound = round;
|
|
429
|
+
} else {
|
|
430
|
+
c.ok(
|
|
431
|
+
`seat ${seat} r${round}: recognised playerMove`,
|
|
432
|
+
false,
|
|
433
|
+
`stage ${wasStage} -> ${nowStage}`,
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
break;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
case 'resolveRoll1':
|
|
440
|
+
case 'resolveReroll': {
|
|
441
|
+
if (!prev) break;
|
|
442
|
+
const seat = movedSeat(prev, led);
|
|
443
|
+
if (seat < 0) {
|
|
444
|
+
c.ok('a resolve changed exactly one seat', false, 'no seat changed stage');
|
|
445
|
+
break;
|
|
446
|
+
}
|
|
447
|
+
const round = Number(prev.openRound);
|
|
448
|
+
const r = seats[seat]!;
|
|
449
|
+
const turn = led.seatTurn.lookup(BigInt(seat));
|
|
450
|
+
const chainDice = diceToArray(turn.roll);
|
|
451
|
+
|
|
452
|
+
if (action.entryPoint === 'resolveRoll1') {
|
|
453
|
+
// Roll 1 hashes the seat's declared entropy against the digest FROZEN AT ROUND OPEN --
|
|
454
|
+
// which is the digest the replay is holding right now, because it only advances at a
|
|
455
|
+
// closeRound. Getting that ordering wrong is the easiest way to make an unverifiable
|
|
456
|
+
// game, so the latched value is checked too.
|
|
457
|
+
const entropy = prev.seatTurn.lookup(BigInt(seat)).entropy;
|
|
458
|
+
const mixed = mixEntropyTs(entropy, digest);
|
|
459
|
+
r.mixed = mixed;
|
|
460
|
+
c.ok(
|
|
461
|
+
`seat ${seat} r${round}: mixed entropy`,
|
|
462
|
+
same(turn.mixed, mixed),
|
|
463
|
+
`chain ${hex(turn.mixed)} vs replay ${hex(mixed)}`,
|
|
464
|
+
);
|
|
465
|
+
const expected = firstRollTs(tableId, seed, mixed, round);
|
|
466
|
+
c.ok(
|
|
467
|
+
`seat ${seat} r${round}: roll 1`,
|
|
468
|
+
arrayEq(chainDice, expected),
|
|
469
|
+
`chain ${chainDice} vs replay ${expected}`,
|
|
470
|
+
);
|
|
471
|
+
r.roll = expected;
|
|
472
|
+
r.rolls = 1;
|
|
473
|
+
} else {
|
|
474
|
+
// WHICH reroll this is comes from the seat's stage before the call -- the same place
|
|
475
|
+
// the circuit reads it. There is one entry point for both rerolls, so the log does not
|
|
476
|
+
// say, and inferring it from the stage is both necessary and a stronger check.
|
|
477
|
+
const step =
|
|
478
|
+
Number(prev.seatTurn.lookup(BigInt(seat)).stage) === STAGE.awaitRoll2 ? 1 : 2;
|
|
479
|
+
const mask = (
|
|
480
|
+
step === 1
|
|
481
|
+
? prev.seatTurn.lookup(BigInt(seat)).hold1
|
|
482
|
+
: prev.seatTurn.lookup(BigInt(seat)).hold2
|
|
483
|
+
).bits;
|
|
484
|
+
if (!r.mixed) {
|
|
485
|
+
c.ok(`seat ${seat} r${round}: roll ${step + 1} has a latched mix`, false);
|
|
486
|
+
break;
|
|
487
|
+
}
|
|
488
|
+
// THE reroll check: the fresh roll is consumed LEFT TO RIGHT over the positions the
|
|
489
|
+
// mask does not keep. A verifier that merged positionally would agree on every mask
|
|
490
|
+
// whose held set is a prefix and diverge on every other one.
|
|
491
|
+
const expected = rerollUnderMaskTs(tableId, seed, r.mixed, round, step, mask, r.roll);
|
|
492
|
+
c.ok(
|
|
493
|
+
`seat ${seat} r${round}: roll ${step + 1} under mask ${mask.map((b) => (b ? 1 : 0)).join('')}`,
|
|
494
|
+
arrayEq(chainDice, expected),
|
|
495
|
+
`chain ${chainDice} vs replay ${expected}`,
|
|
496
|
+
);
|
|
497
|
+
// A held die must be byte-identical across the reroll -- the property the mask exists
|
|
498
|
+
// for, checked independently of the derivation above.
|
|
499
|
+
for (let d = 0; d < 5; d++) {
|
|
500
|
+
if (mask[d] === true) {
|
|
501
|
+
c.ok(
|
|
502
|
+
`seat ${seat} r${round}: held die ${d} survived roll ${step + 1}`,
|
|
503
|
+
chainDice[d] === r.roll[d],
|
|
504
|
+
`${r.roll[d]} -> ${chainDice[d]}`,
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
r.roll = expected;
|
|
509
|
+
r.rolls += 1;
|
|
510
|
+
}
|
|
511
|
+
c.ok(
|
|
512
|
+
`seat ${seat} r${round}: dice are all in 1..6`,
|
|
513
|
+
chainDice.every((d) => d >= 1 && d <= 6),
|
|
514
|
+
chainDice.join(','),
|
|
515
|
+
);
|
|
516
|
+
c.ok(
|
|
517
|
+
`seat ${seat} r${round}: no resolve moved the round digest`,
|
|
518
|
+
same(led.roundDigest, digest),
|
|
519
|
+
'only closeRound advances it',
|
|
520
|
+
);
|
|
521
|
+
rollChecks += 1;
|
|
522
|
+
break;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
case 'closeRound': {
|
|
526
|
+
if (!prev) break;
|
|
527
|
+
const round = Number(prev.openRound);
|
|
528
|
+
// The ONE place the digest advances. All six slots, in SEAT ORDER, read at this block --
|
|
529
|
+
// which is what makes the replay independent of the order the chain saw the moves in.
|
|
530
|
+
digest = roundDigestTs(digest, round, seatCount, roundResults(led));
|
|
531
|
+
closes += 1;
|
|
532
|
+
c.ok(
|
|
533
|
+
`round ${round}: digest chain`,
|
|
534
|
+
same(led.roundDigest, digest),
|
|
535
|
+
`chain ${hex(led.roundDigest)} vs replay ${hex(digest)}`,
|
|
536
|
+
);
|
|
537
|
+
c.ok(
|
|
538
|
+
`round ${round}: openRound advanced`,
|
|
539
|
+
Number(led.openRound) === round + 1,
|
|
540
|
+
`chain ${led.openRound}`,
|
|
541
|
+
);
|
|
542
|
+
break;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
case 'eliminate': {
|
|
546
|
+
if (!prev) break;
|
|
547
|
+
const round = Number(prev.openRound);
|
|
548
|
+
let seat = -1;
|
|
549
|
+
for (let s = 0; s < seatCount; s++) {
|
|
550
|
+
if (
|
|
551
|
+
!prev.seatProgress.lookup(BigInt(s)).eliminated &&
|
|
552
|
+
led.seatProgress.lookup(BigInt(s)).eliminated
|
|
553
|
+
) {
|
|
554
|
+
seat = s;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
if (seat < 0) {
|
|
558
|
+
c.ok('eliminate marked exactly one seat', false);
|
|
559
|
+
break;
|
|
560
|
+
}
|
|
561
|
+
eliminations += 1;
|
|
562
|
+
seats[seat]!.eliminated = true;
|
|
563
|
+
seats[seat]!.finishedAtRound = Number.POSITIVE_INFINITY;
|
|
564
|
+
|
|
565
|
+
// The penalty rule, recomputed rather than read. A timeout charges round + 1; a
|
|
566
|
+
// voluntary resignation is charged one round less — the open round does not count
|
|
567
|
+
// (`voluntary` is a public circuit argument, but the cheapest verification is the money
|
|
568
|
+
// itself: exactly one of the two schedules matches the redeemable delta, and which one
|
|
569
|
+
// tells us how the seat left).
|
|
570
|
+
const timeoutPenalty = (final.tier * BigInt(round + 1)) / 13n;
|
|
571
|
+
const resignPenalty = (final.tier * BigInt(round)) / 13n;
|
|
572
|
+
const delta =
|
|
573
|
+
led.seatRedeemable.lookup(BigInt(seat)) - prev.seatRedeemable.lookup(BigInt(seat));
|
|
574
|
+
const penalty = delta === final.tier - resignPenalty ? resignPenalty : timeoutPenalty;
|
|
575
|
+
const how =
|
|
576
|
+
penalty === resignPenalty && resignPenalty !== timeoutPenalty ? 'resigned' : 'timed out';
|
|
577
|
+
const refund = final.tier - penalty;
|
|
578
|
+
c.ok(
|
|
579
|
+
`seat ${seat}: eliminated at round ${round} keeps tier - penalty`,
|
|
580
|
+
delta === refund,
|
|
581
|
+
`expected +${refund} (penalty ${penalty}, ${how})`,
|
|
582
|
+
);
|
|
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
|
+
);
|
|
588
|
+
c.ok(
|
|
589
|
+
`seat ${seat}: carries the never-finished sentinel`,
|
|
590
|
+
led.seatProgress.lookup(BigInt(seat)).finishedAtRound === 65535n,
|
|
591
|
+
);
|
|
592
|
+
console.log(` (seat ${seat} ${how} at round ${round}: penalty ${penalty})`);
|
|
593
|
+
break;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
case 'settle':
|
|
597
|
+
case 'redeem':
|
|
598
|
+
case 'abortTable':
|
|
599
|
+
// Handled below, against the final state and the transaction.
|
|
600
|
+
break;
|
|
601
|
+
|
|
602
|
+
default:
|
|
603
|
+
console.log(` (unrecognised entry point '${action.entryPoint ?? '?'}' -- ignored)`);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// The custody invariant, at every single step: while the table holds the money, every atom
|
|
607
|
+
// it ever received is either in the pot or owed to a seat. The contract asserts this
|
|
608
|
+
// in-circuit at three points; this checks it after every transaction, from public state.
|
|
609
|
+
if (led.phase === Table.Phase.filling || led.phase === Table.Phase.playing) {
|
|
610
|
+
let owed = 0n;
|
|
611
|
+
let paid = 0n;
|
|
612
|
+
for (let s = 0; s < MAX_SEATS; s++) {
|
|
613
|
+
owed += led.seatRedeemable.lookup(BigInt(s));
|
|
614
|
+
paid += led.seatPaid.lookup(BigInt(s));
|
|
615
|
+
}
|
|
616
|
+
c.ok(
|
|
617
|
+
`step ${i} (${action.entryPoint}): custody invariant`,
|
|
618
|
+
led.pot + owed + paid === final.tier * led.seatCount,
|
|
619
|
+
`pot ${led.pot} + owed ${owed} + paid ${paid} != tier x ${led.seatCount}`,
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
console.log(
|
|
625
|
+
`\n── replayed ${joins} joins, ${opens} opens, ${holds} holds, ${scores} scores, ` +
|
|
626
|
+
`${rollChecks} rolls, ${closes} round closes, ${eliminations} eliminations ──`,
|
|
627
|
+
);
|
|
628
|
+
c.ok('every seat joined', joins === seatCount, `${joins} joins for ${seatCount} seats`);
|
|
629
|
+
// On a walkover the survivor's in-flight turn is legitimately cut off by the settle: opened,
|
|
630
|
+
// dice possibly delivered, never scored. At most ONE such dangling open, and only theirs.
|
|
631
|
+
const danglingAllowed = seats.filter((s) => !s.eliminated).length === 1 ? 1 : 0;
|
|
632
|
+
c.ok(
|
|
633
|
+
'every live seat scored in every round',
|
|
634
|
+
opens - scores <= danglingAllowed && opens >= scores,
|
|
635
|
+
`${opens} turns opened, ${scores} scored (walkover allows ${danglingAllowed} dangling)`,
|
|
636
|
+
);
|
|
637
|
+
// A settled table need not have closed all thirteen rounds any more: the walkover (settle's
|
|
638
|
+
// `activeSeats == 1` disjunct) legitimately ends a game the moment one active seat remains.
|
|
639
|
+
// What must still hold is that the game either ran to the end or ended as a walkover — anything
|
|
640
|
+
// shorter with two or more survivors is a settle the contract should have refused.
|
|
641
|
+
const survivors = seats.filter((s) => !s.eliminated).length;
|
|
642
|
+
c.ok(
|
|
643
|
+
'the game ran to completion or ended as a walkover',
|
|
644
|
+
closes === ROUND_COUNT || survivors === 1,
|
|
645
|
+
`${closes} rounds closed of ${ROUND_COUNT}, ${survivors} non-eliminated seat(s)`,
|
|
646
|
+
);
|
|
647
|
+
c.ok(
|
|
648
|
+
'the final digest matches the chain',
|
|
649
|
+
same(final.roundDigest, digest),
|
|
650
|
+
`chain ${hex(final.roundDigest)} vs replay ${hex(digest)}`,
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
// -------------------------------------------------------------------------------- the winner
|
|
654
|
+
console.log('\n── the winner ──');
|
|
655
|
+
const totals = seats.map((s) => grandTotal(s.card));
|
|
656
|
+
for (let seat = 0; seat < seatCount; seat++) {
|
|
657
|
+
const chainTotal = final.seatProgress.lookup(BigInt(seat)).total;
|
|
658
|
+
console.log(
|
|
659
|
+
` seat ${seat}: replay total ${totals[seat]}, chain total ${chainTotal}` +
|
|
660
|
+
`${seats[seat]!.eliminated ? ' (eliminated)' : ''}`,
|
|
661
|
+
);
|
|
662
|
+
c.ok(
|
|
663
|
+
`seat ${seat}: final total`,
|
|
664
|
+
BigInt(totals[seat]!) === chainTotal,
|
|
665
|
+
`replay ${totals[seat]} vs chain ${chainTotal}`,
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// ELIMINATED SEATS CANNOT WIN. They have already been handed back `tier - penalty`; paying
|
|
670
|
+
// one the pot as well would pay it twice. Among survivors the order is highest total, then a
|
|
671
|
+
// completer over a non-completer, then the lowest seat -- and since every survivor completes
|
|
672
|
+
// at the same round, in practice it is total then seat index.
|
|
673
|
+
let expectedWinner = -1;
|
|
674
|
+
for (let seat = 0; seat < seatCount; seat++) {
|
|
675
|
+
if (seats[seat]!.eliminated) continue;
|
|
676
|
+
if (expectedWinner < 0) {
|
|
677
|
+
expectedWinner = seat;
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
const better =
|
|
681
|
+
totals[seat]! > totals[expectedWinner]! ||
|
|
682
|
+
(totals[seat] === totals[expectedWinner] &&
|
|
683
|
+
seats[seat]!.finishedAtRound < seats[expectedWinner]!.finishedAtRound);
|
|
684
|
+
if (better) expectedWinner = seat;
|
|
685
|
+
}
|
|
686
|
+
c.ok(
|
|
687
|
+
'the chain paid the seat the tie-break selects among survivors',
|
|
688
|
+
BigInt(expectedWinner) === final.winnerSeatIndex,
|
|
689
|
+
`replay ${expectedWinner} vs chain ${final.winnerSeatIndex}`,
|
|
690
|
+
);
|
|
691
|
+
|
|
692
|
+
// -------------------------------------------------------------------------------- the payout
|
|
693
|
+
console.log('\n── the payout ──');
|
|
694
|
+
const settleAction = steps.find((st) => st.action.entryPoint === 'settle')?.action;
|
|
695
|
+
if (!settleAction) {
|
|
696
|
+
c.ok('the settle transaction is in the log', false);
|
|
697
|
+
} else {
|
|
698
|
+
const tx = await transactionByHash(settleAction.txHash);
|
|
699
|
+
const winnerAddr = final.seatIdentity.lookup(final.winnerSeatIndex).addr.bytes;
|
|
700
|
+
const rakeAddr = final.rakeAddress.bytes;
|
|
701
|
+
// `settle` pays out exactly the POT, which is the stakes minus whatever left it as an
|
|
702
|
+
// eliminated seat's refund. Read from the state just before the settle rather than assumed
|
|
703
|
+
// 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;
|
|
706
|
+
const q = potBefore / 100n;
|
|
707
|
+
|
|
708
|
+
const spentByUsers = sumNative(tx.unshieldedSpentOutputs);
|
|
709
|
+
const created = sumNative(tx.unshieldedCreatedOutputs);
|
|
710
|
+
console.log(` settle tx ${tx.hash} in block ${tx.block.height}`);
|
|
711
|
+
for (const u of tx.unshieldedCreatedOutputs) {
|
|
712
|
+
console.log(` created ${u.value} to ${u.owner.slice(0, 24)}…`);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// THE decisive row, and the same one that made Gate 0's `payOut` conclusive: a transaction
|
|
716
|
+
// that spends no user inputs while creating real native NIGHT can only be paying out of the
|
|
717
|
+
// contract's own balance. That is custody, demonstrated rather than asserted.
|
|
718
|
+
c.ok('settle spent ZERO user inputs', spentByUsers === 0n, `${spentByUsers} spent by users`);
|
|
719
|
+
c.ok(
|
|
720
|
+
'settle created exactly the pot',
|
|
721
|
+
created === potBefore,
|
|
722
|
+
`created ${created}, pot ${potBefore}`,
|
|
723
|
+
);
|
|
724
|
+
c.ok(
|
|
725
|
+
'the winner was paid pot - q, at the address recorded at join',
|
|
726
|
+
sumNativeFor(tx.unshieldedCreatedOutputs, addressOf(tx, winnerAddr)) === potBefore - q,
|
|
727
|
+
`expected ${potBefore - q}`,
|
|
728
|
+
);
|
|
729
|
+
c.ok(
|
|
730
|
+
'the rake was paid q, at the address sealed at construction',
|
|
731
|
+
sumNativeFor(tx.unshieldedCreatedOutputs, addressOf(tx, rakeAddr)) === q,
|
|
732
|
+
`expected ${q}`,
|
|
733
|
+
);
|
|
734
|
+
c.ok('the pot is empty afterwards', final.pot === 0n, `pot field ${final.pot}`);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// ------------------------------------------------------------------------------- redemptions
|
|
738
|
+
const redeems = steps.filter((st) => st.action.entryPoint === 'redeem');
|
|
739
|
+
if (redeems.length > 0) {
|
|
740
|
+
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;
|
|
744
|
+
let seat = -1;
|
|
745
|
+
for (let s = 0; s < seatCount; s++) {
|
|
746
|
+
if (
|
|
747
|
+
prev.seatRedeemable.lookup(BigInt(s)) > 0n &&
|
|
748
|
+
st.led.seatRedeemable.lookup(BigInt(s)) === 0n
|
|
749
|
+
) {
|
|
750
|
+
seat = s;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
if (seat < 0) {
|
|
754
|
+
c.ok('a redeem zeroed exactly one seat', false);
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
const owed = prev.seatRedeemable.lookup(BigInt(seat));
|
|
758
|
+
const tx = await transactionByHash(st.action.txHash);
|
|
759
|
+
const addr = final.seatIdentity.lookup(BigInt(seat)).addr.bytes;
|
|
760
|
+
console.log(` seat ${seat} redeemed ${owed}`);
|
|
761
|
+
c.ok(
|
|
762
|
+
`redeem seat ${seat}: paid exactly what it was owed, to its join-time address`,
|
|
763
|
+
sumNativeFor(tx.unshieldedCreatedOutputs, addressOf(tx, addr)) === owed,
|
|
764
|
+
`expected ${owed}`,
|
|
765
|
+
);
|
|
766
|
+
// RECORDED, NOT ASSERTED, and the asymmetry with `settle` above is deliberate. The
|
|
767
|
+
// decisive custody claim is the amount and the recipient, which are asserted. Whether the
|
|
768
|
+
// CALLER also spent native inputs depends on how its wallet happened to fund the fee --
|
|
769
|
+
// `settle` was measured at zero in the previous E2E run while `abortTable`, called by the
|
|
770
|
+
// same wallet, was not. Asserting zero here would make the verifier fail for a reason that
|
|
771
|
+
// says nothing about the contract.
|
|
772
|
+
console.log(` (caller spent ${sumNative(tx.unshieldedSpentOutputs)} of its own inputs)`);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
c.summary();
|
|
777
|
+
console.log(`\n(${c.count} checks in total)`);
|
|
778
|
+
return c.failed.length === 0 ? 0 : 1;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Match a raw 32-byte payout key against the bech32m owner strings the indexer reports.
|
|
783
|
+
*
|
|
784
|
+
* The contract stores the raw key (a circuit argument cannot be a bech32m string); the indexer
|
|
785
|
+
* reports the encoded address. Rather than re-implement the encoding here, the raw key is
|
|
786
|
+
* matched against whichever created output's owner decodes to it -- and the decoding is the
|
|
787
|
+
* wallet SDK's, via `@dust-dice/api/node`'s `userAddressBytes`, which is pure key math and needs
|
|
788
|
+
* no wallet.
|
|
789
|
+
*/
|
|
790
|
+
function addressOf(tx: { unshieldedCreatedOutputs: { owner: string }[] }, raw: Uint8Array): string {
|
|
791
|
+
const target = hex(raw);
|
|
792
|
+
for (const u of tx.unshieldedCreatedOutputs) {
|
|
793
|
+
try {
|
|
794
|
+
if (hex(userAddressBytes(u.owner)) === target) return u.owner;
|
|
795
|
+
} catch {
|
|
796
|
+
/* not an address this build can decode; skip */
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
return '<no created output belongs to this address>';
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const address = process.argv[2];
|
|
803
|
+
if (!address) {
|
|
804
|
+
console.error(
|
|
805
|
+
'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' +
|
|
807
|
+
'hold masks the players sent, every score recomputed, the winner and the payout\n' +
|
|
808
|
+
're-confirmed.',
|
|
809
|
+
);
|
|
810
|
+
process.exit(2);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
process.exitCode = await verify(address, process.argv.includes('--verbose'));
|
|
814
|
+
process.exit(process.exitCode);
|