@dust-dice/verifier 0.4.2 → 0.4.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dust-dice/verifier",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
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,11 +28,11 @@
28
28
  "typecheck": "tsc -p tsconfig.json --noEmit"
29
29
  },
30
30
  "dependencies": {
31
- "@midnight-ntwrk/compact-runtime": "0.19.0",
32
- "@midnight-ntwrk/midnight-js-indexer-public-data-provider": "5.0.0-beta.7",
33
- "@midnight-ntwrk/midnight-js-network-id": "5.0.0-beta.7",
34
- "@midnight-ntwrk/midnight-js-protocol": "5.0.0-beta.7",
35
- "@midnight-ntwrk/midnight-js-types": "5.0.0-beta.7"
31
+ "@midnight-ntwrk/compact-runtime": "0.16.0",
32
+ "@midnight-ntwrk/midnight-js-indexer-public-data-provider": "4.1.1",
33
+ "@midnight-ntwrk/midnight-js-network-id": "4.1.1",
34
+ "@midnight-ntwrk/midnight-js-protocol": "4.1.1",
35
+ "@midnight-ntwrk/midnight-js-types": "4.1.1"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/node": "^26.4.0",
@@ -60,8 +60,8 @@
60
60
  "dust-dice-verify": "dist/verify.js"
61
61
  },
62
62
  "peerDependencies": {
63
- "@dust-dice/api": ">=0.4.0",
64
- "@dust-dice/contract": ">=0.4.1"
63
+ "@dust-dice/api": "0.4.4",
64
+ "@dust-dice/contract": "0.4.4"
65
65
  },
66
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."
67
67
  }
package/src/contracts.ts CHANGED
@@ -15,17 +15,21 @@
15
15
  */
16
16
 
17
17
  import { CompiledContract } from '@midnight-ntwrk/midnight-js-protocol/compact-js';
18
+ import { ContractState } from '@midnight-ntwrk/midnight-js-protocol/compact-runtime';
18
19
  import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider';
19
20
  import {
20
21
  Table,
21
22
  Lobby,
22
23
  tableWitnesses,
23
24
  lobbyWitnesses,
25
+ unshieldedBalances,
26
+ type UnshieldedBalances,
24
27
  type TablePrivateState,
25
28
  type LobbyPrivateState,
26
29
  } from '@dust-dice/contract';
27
30
 
28
31
  import { NETWORK } from './config.ts';
32
+ import { contractStateHexAt } from './indexer.ts';
29
33
 
30
34
  export { Table, Lobby };
31
35
 
@@ -71,6 +75,9 @@ export const CompiledLobbyContract = CompiledContract.make<Lobby.Contract<LobbyP
71
75
  export type TableLedger = Table.Ledger;
72
76
  export type LobbyLedger = Lobby.Ledger;
73
77
 
78
+ /** Which state to read: the latest, or the one a specific transaction left behind. */
79
+ export type LedgerAt = { txHash: string };
80
+
74
81
  /**
75
82
  * Read a table's ledger with a ONE-SHOT query.
76
83
  *
@@ -79,28 +86,45 @@ export type LobbyLedger = Lobby.Ledger;
79
86
  * driver -- and there is one after almost every transaction -- has to use `queryContractState`.
80
87
  * (bugs-found.md §0 #10; the same reason probes/gate0/src/step.ts reads this way.)
81
88
  *
82
- * `blockHeight` pins the read to a past block, which is what the chain-only verifier walks the
83
- * game with.
89
+ * `at.txHash` pins the read to the state a transaction left, which is what the chain-only
90
+ * verifier walks the game with. By TRANSACTION, not by block: on this indexer (4.3.x) a block
91
+ * offset means "the action in that block", and with simultaneous rounds several of a table's
92
+ * transactions routinely share one -- a block-keyed read cannot say which it returned. The SDK
93
+ * offers no transaction-hash offset, so that read goes straight to the indexer.
84
94
  */
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
+ export async function readContractState(address: string, at?: LedgerAt): Promise<ContractState> {
96
+ if (at === undefined) {
97
+ const pdp = indexerPublicDataProvider(NETWORK.indexer, NETWORK.indexerWS);
98
+ const state = await pdp.queryContractState(address);
99
+ if (!state) throw new Error(`no contract state at ${address}`);
100
+ return state;
95
101
  }
96
- return Table.ledger(state.data);
102
+ const hex = await contractStateHexAt(address, at.txHash);
103
+ if (!hex) throw new Error(`no contract state at ${address} after tx ${at.txHash}`);
104
+ return ContractState.deserialize(Buffer.from(hex.replace(/^0x/, ''), 'hex'));
105
+ }
106
+
107
+ export async function readTableLedger(address: string, at?: LedgerAt): Promise<TableLedger> {
108
+ return Table.ledger((await readContractState(address, at)).data);
97
109
  }
98
110
 
99
111
  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);
112
+ return Lobby.ledger((await readContractState(address)).data);
113
+ }
114
+
115
+ /**
116
+ * What the LEDGER says the contract holds, for checking against the `pot` it claims.
117
+ *
118
+ * Read out of the contract's own state, NOT out of the indexer's
119
+ * `contractAction { unshieldedBalances }` -- that field answers `[]` on this indexer even for a
120
+ * table sitting on a pot, which reads as a solvency alarm instead of a balance. See
121
+ * `@dust-dice/contract`'s `custody.ts` and the test that pins it.
122
+ */
123
+ export async function contractUnshieldedBalances(
124
+ address: string,
125
+ at?: LedgerAt,
126
+ ): Promise<UnshieldedBalances> {
127
+ return unshieldedBalances(await readContractState(address, at));
104
128
  }
105
129
 
106
130
  /** `Dice` as the plain five-element array everything else in this repo speaks. */
package/src/indexer.ts CHANGED
@@ -10,7 +10,6 @@
10
10
  * 4,900,000 in exactly this situation (docs/bugs-found.md §9). PER-TRANSACTION UTXO MOVEMENT IS
11
11
  * THE ONLY TRUSTWORTHY MEASURE, and it is what these queries read:
12
12
  *
13
- * - `contractAction(address){ unshieldedBalances }` what the LEDGER says the contract holds
14
13
  * - `transactions(offset:{hash}){ unshieldedCreatedOutputs / unshieldedSpentOutputs }`
15
14
  * who actually received or spent what, with
16
15
  * `registeredForDustGeneration` per created
@@ -18,14 +17,17 @@
18
17
  * - `transactions(offset:{hash}){ raw }` the transaction's byte size, which is what
19
18
  * the `OutsideTimeToDismiss` admission check
20
19
  * measures
21
- * - `contract(address){ actions }` the table's whole public history, which is
20
+ * - `contractActions(address, offset)` subscription the table's whole public history, which is
22
21
  * all the chain-only verifier is given
22
+ * - `contractAction(address, offset:{transactionOffset})` the state one transaction left
23
23
  *
24
24
  * There is no user-address balance query in this indexer's schema (checked by introspecting
25
25
  * `__schema.queryType.fields`: only `bridgeBalance(address)` takes an address, and that is the
26
26
  * Cardano bridge, not NIGHT), so per-address totals are derived from the UTXO sets above.
27
27
  */
28
28
 
29
+ import { NATIVE_TOKEN_HEX } from '@dust-dice/contract';
30
+
29
31
  import { NETWORK } from './config.ts';
30
32
 
31
33
  export interface GqlUnshieldedUtxo {
@@ -70,8 +72,11 @@ async function gql<T>(query: string, variables: Record<string, unknown> = {}): P
70
72
  return body.data;
71
73
  }
72
74
 
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
+ * The token-type constant and the balance decode live with the contract package, next to the
77
+ * ledger layout they read; re-exported here so callers of this module keep one import.
78
+ */
79
+ export { NATIVE_TOKEN_HEX, nativeBalance } from '@dust-dice/contract';
75
80
 
76
81
  const isNative = (u: { tokenType: string }): boolean =>
77
82
  u.tokenType.replace(/^0x/, '').toLowerCase() === NATIVE_TOKEN_HEX;
@@ -84,35 +89,11 @@ export function sumNativeFor(utxos: GqlUnshieldedUtxo[], owner: string): bigint
84
89
  return sumNative(utxos.filter((u) => u.owner === owner));
85
90
  }
86
91
 
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
- }
92
+ // What the contract HOLDS is deliberately NOT read here. The schema does offer
93
+ // `contractAction { unshieldedBalances }`, and it answers `[]` for every action of every
94
+ // contract on this indexer -- tables sitting on a pot included -- so a custody check built on
95
+ // it reports a solvent table as empty, which is the shape of a solvency alarm. Custody comes
96
+ // from the contract's own state: `contracts.ts`'s `contractUnshieldedBalances`.
116
97
 
117
98
  /**
118
99
  * One transaction by hash, with its UTXO deltas and its raw bytes.
@@ -164,42 +145,151 @@ export const allowanceMs = (bytes: number): number => Math.max(15.0, 0.002 * byt
164
145
  /** The floor a contract call must clear to be admitted at all, in bytes. */
165
146
  export const ADMISSION_FLOOR_BYTES = 7984;
166
147
 
148
+ /**
149
+ * One graphql-transport-ws subscription, collected until `done` accepts a row (that row included)
150
+ * or `max` rows have arrived.
151
+ *
152
+ * A raw WebSocket on purpose: the protocol is four message types -- init, ack, subscribe, next --
153
+ * and a client library would be a second copy of what the SDK already bundles. Node's global
154
+ * WebSocket (22+, this package's floor) is all it needs.
155
+ */
156
+ function subscribeUntil<T>(
157
+ query: string,
158
+ variables: Record<string, unknown>,
159
+ field: string,
160
+ done: (row: T) => boolean,
161
+ max: number,
162
+ timeoutMs = 60_000,
163
+ ): Promise<T[]> {
164
+ return new Promise((resolve, reject) => {
165
+ const ws = new WebSocket(NETWORK.indexerWS, 'graphql-transport-ws');
166
+ const rows: T[] = [];
167
+ let settled = false;
168
+ const finish = (err?: Error): void => {
169
+ if (settled) return;
170
+ settled = true;
171
+ clearTimeout(timer);
172
+ ws.close();
173
+ if (err) reject(err);
174
+ else resolve(rows);
175
+ };
176
+ const timer = setTimeout(
177
+ () => finish(new Error(`indexer subscription ${field}: still open after ${timeoutMs} ms`)),
178
+ timeoutMs,
179
+ );
180
+ ws.onopen = () => ws.send(JSON.stringify({ type: 'connection_init' }));
181
+ ws.onerror = () => finish(new Error(`indexer subscription ${field}: websocket error`));
182
+ ws.onclose = () =>
183
+ finish(new Error(`indexer subscription ${field}: closed after ${rows.length} rows`));
184
+ ws.onmessage = (ev) => {
185
+ const m = JSON.parse(String(ev.data)) as { type: string; payload?: unknown };
186
+ if (m.type === 'connection_ack') {
187
+ ws.send(JSON.stringify({ id: '1', type: 'subscribe', payload: { query, variables } }));
188
+ } else if (m.type === 'next') {
189
+ const p = m.payload as { data?: Record<string, T>; errors?: { message: string }[] };
190
+ if (p.errors?.length) {
191
+ finish(new Error(`indexer GraphQL: ${p.errors.map((e) => e.message).join('; ')}`));
192
+ return;
193
+ }
194
+ const row = p.data?.[field];
195
+ if (row === undefined) return;
196
+ rows.push(row);
197
+ if (done(row) || rows.length >= max) finish();
198
+ } else if (m.type === 'error') {
199
+ finish(
200
+ new Error(`indexer subscription ${field}: ${JSON.stringify(m.payload).slice(0, 300)}`),
201
+ );
202
+ } else if (m.type === 'complete') {
203
+ finish();
204
+ }
205
+ };
206
+ });
207
+ }
208
+
209
+ const sameAddress = (a: string, b: string): boolean =>
210
+ a.replace(/^0x/, '').toLowerCase() === b.replace(/^0x/, '').toLowerCase();
211
+
167
212
  /**
168
213
  * A contract's whole public history, oldest first.
169
214
  *
170
215
  * This is the ONLY thing `verify.ts` is given beyond the table's address: every join, every
171
216
  * 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.
217
+ * at.
218
+ *
219
+ * The indexer this build talks to (4.3.x, the preprod line) has no query that LISTS a contract's
220
+ * actions -- the ledger-9 `contract(address){ actions }` is gone. It has the `contractActions`
221
+ * SUBSCRIPTION, which replays every action from a block offset and then stays open for new ones;
222
+ * it is what midnight-js's own provider uses. The stream has no "caught up" marker of its own,
223
+ * so the latest action is fetched first over plain HTTP and its transaction ends the collection
224
+ * -- after as many rows as that transaction has actions on this contract, because a fast turn is
225
+ * one transaction carrying up to seven calls and stopping at the first would drop the rest. The
226
+ * deploy block comes from the same query (`ContractCall.deploy`), so nothing from before the
227
+ * contract existed is streamed.
173
228
  */
174
229
  export async function contractActions(address: string, limit = 1000): Promise<ContractAction[]> {
175
230
  type Row = {
176
231
  __typename: ContractAction['kind'];
177
232
  entryPoint?: string;
178
- transaction: { hash: string; block: { height: number; timestamp: string } };
233
+ transaction: { hash: string; block: { height: number; timestamp: string | number } };
179
234
  };
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
- }
235
+ const head = await gql<{
236
+ contractAction: (Row & { deploy?: { transaction: { block: { height: number } } } }) | null;
237
+ }>(
238
+ `query ($address: HexEncoded!) {
239
+ contractAction(address: $address) {
240
+ __typename
241
+ transaction { hash block { height timestamp } }
242
+ ... on ContractCall { deploy { transaction { block { height } } } }
189
243
  }
190
244
  }`,
191
- { address, limit },
245
+ { address },
246
+ );
247
+ if (!head.contractAction) throw new Error(`no contract at ${address}`);
248
+ const latest = head.contractAction;
249
+ const fromHeight = latest.deploy?.transaction.block.height ?? latest.transaction.block.height;
250
+
251
+ const lastTx = await gql<{ transactions: { contractActions: { address: string }[] }[] }>(
252
+ `query ($hash: HexEncoded!) {
253
+ transactions(offset: { hash: $hash }) { contractActions { address } }
254
+ }`,
255
+ { hash: latest.transaction.hash },
256
+ );
257
+ const lastTxActions = (lastTx.transactions[0]?.contractActions ?? []).filter((a) =>
258
+ sameAddress(a.address, address),
259
+ ).length;
260
+
261
+ let seenOfLast = 0;
262
+ const rows = await subscribeUntil<Row>(
263
+ `subscription ($address: HexEncoded!, $offset: BlockOffset) {
264
+ contractActions(address: $address, offset: $offset) {
265
+ __typename
266
+ ... on ContractCall { entryPoint }
267
+ transaction { hash block { height timestamp } }
268
+ }
269
+ }`,
270
+ { address, offset: { height: fromHeight } },
271
+ 'contractActions',
272
+ (row) => row.transaction.hash === latest.transaction.hash && ++seenOfLast >= lastTxActions,
273
+ limit,
274
+ );
275
+ return rows.map((a) => ({
276
+ kind: a.__typename,
277
+ entryPoint: a.entryPoint,
278
+ txHash: a.transaction.hash,
279
+ blockHeight: a.transaction.block.height,
280
+ blockTimestamp: Number(a.transaction.block.timestamp),
281
+ }));
282
+ }
283
+
284
+ /** The state a specific transaction left the contract in, as the indexer's hex; null if none. */
285
+ export async function contractStateHexAt(address: string, txHash: string): Promise<string | null> {
286
+ const data = await gql<{ contractAction: { state: string } | null }>(
287
+ `query ($address: HexEncoded!, $hash: HexEncoded!) {
288
+ contractAction(address: $address, offset: { transactionOffset: { hash: $hash } }) { state }
289
+ }`,
290
+ { address, hash: txHash },
192
291
  );
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();
292
+ return data.contractAction?.state ?? null;
203
293
  }
204
294
 
205
295
  /** Current chain tip, for sanity-checking that blocks are being produced. */
package/src/verify.ts CHANGED
@@ -26,7 +26,7 @@
26
26
  * having replayed every earlier round in order;
27
27
  * 4. every score, recomputed from the dice with api/src/rules.ts -- the contract-canonical
28
28
  * rules engine, not the circuit -- matches the scorecard the chain holds, box by box,
29
- * including the upper bonus and the Yahtzee bonuses;
29
+ * including the upper bonus and the five-of-a-kind bonuses;
30
30
  * 5. the winner the tie-break selects, among SURVIVORS, is the seat the chain paid;
31
31
  * 6. the settle transaction spent ZERO user inputs and created exactly the two expected
32
32
  * outputs, to the addresses recorded at join and at construction.
@@ -195,9 +195,9 @@ const GROUP_RANK: Record<string, number> = {
195
195
  /**
196
196
  * Read the table's whole history: every transaction, and the state it left behind.
197
197
  *
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).
198
+ * One state read per transaction, pinned to that transaction. `contractStateObservable` is not
199
+ * usable for this -- it misses rapid successive updates and its first emission may predate the
200
+ * write being read (bugs-found.md §0 #10).
201
201
  */
202
202
  async function readHistory(address: string): Promise<LogGroup[]> {
203
203
  const actions = (await contractActions(address)).filter((a) => a.kind !== 'ContractDeploy');
@@ -210,7 +210,7 @@ async function readHistory(address: string): Promise<LogGroup[]> {
210
210
  blockHeight: calls[0]!.blockHeight,
211
211
  txHash,
212
212
  entryPoints: calls.map((x) => x.entryPoint ?? '?'),
213
- led: await readTableLedger(address, calls[0]!.blockHeight),
213
+ led: await readTableLedger(address, { txHash }),
214
214
  sharesBlock: false,
215
215
  });
216
216
  }
@@ -241,7 +241,7 @@ function seatCard(led: TableLedger, seat: number): Scorecard {
241
241
  const c = led.seatCard.lookup(BigInt(seat));
242
242
  return {
243
243
  scores: c.filled.map((f, i) => (f ? Number(c.scores[i]) : null)),
244
- yahtzeeBonuses: Number(c.yahtzeeBonuses),
244
+ fiveOfAKindBonuses: Number(c.fiveOfAKindBonuses),
245
245
  };
246
246
  }
247
247