Skip to content

Commit 1cb449f

Browse files
committed
test(test-utils): pin DryReplayHarness replay semantics
The harness's own suite drives a stub contract, so nothing exercised the part that matters: re-executing a real transcript in verifying mode against state that has moved. Its header pointed at a `harness invariants` describe in a contract spec to cover that, and no such describe was ever written. These cases pin it directly. The two that would silently break the whole method are `land` replaying against the CURRENT state rather than the build snapshot, which is what lets two independent builds from one snapshot both land, and `classify` rethrowing a transcript that fails against its own build snapshot instead of scoring it as a conflict. Replaying against the build state would score every case as landed, and scoring a broken transcript would report a conflict that never happened. It sits under `src/` rather than beside the harness because the `unit` project globs `src/**/*.test.ts` and is the only project that depends on a compile. The sibling suite's header now points here.
1 parent c096a7d commit 1cb449f

2 files changed

Lines changed: 168 additions & 2 deletions

File tree

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/**
2+
* `DryReplayHarness` against real ledger state.
3+
*
4+
* `test-utils/concurrency/test/DryReplayHarness.test.ts` covers the guard rails
5+
* with a stub contract, because `test:harness` must not require a compiled
6+
* build. Everything below needs a transcript the onchain runtime will actually
7+
* re-execute, so it lives here in the `unit` project, which does depend on a
8+
* compile.
9+
*
10+
* The claims are about the harness, not the token: what `apply`, `land` and
11+
* `attempt` do to the shared state, and how `attempt` decides that a failed
12+
* replay is a conflict rather than a broken transcript. The token's own
13+
* concurrency matrix uses those answers; it does not check them.
14+
*/
15+
16+
import { beforeEach, describe, expect, it } from 'vitest';
17+
import { createDryHarness } from '#test-utils/concurrency/DryReplayHarness.js';
18+
import {
19+
createParties,
20+
labelledSecret,
21+
} from '#test-utils/concurrency/parties.js';
22+
import {
23+
pureCircuits as core,
24+
ledger,
25+
Contract as MockCore,
26+
} from '../../../artifacts/MockConfidentialNoteFungibleToken/contract/index.js';
27+
import {
28+
ConfidentialNoteFungibleTokenWitnesses,
29+
createNoteWallet,
30+
type Note,
31+
type NoteWallet,
32+
} from './witnesses/ConfidentialNoteFungibleTokenWitnesses.js';
33+
34+
type PrivateState = Record<string, never>;
35+
36+
const ALICE = core.derivePk(labelledSecret('alice'));
37+
const BOB = core.derivePk(labelledSecret('bob'));
38+
39+
const NOTE_VALUE = 100n;
40+
41+
const noteParties = () =>
42+
createParties<NoteWallet, MockCore<PrivateState>>(['alice', 'bob'], {
43+
wallet: (label) => {
44+
const wallet = createNoteWallet();
45+
wallet.secretKey = labelledSecret(label);
46+
return wallet;
47+
},
48+
contract: (wallet) =>
49+
new MockCore(ConfidentialNoteFungibleTokenWitnesses(wallet)),
50+
});
51+
52+
describe('DryReplayHarness against real state', () => {
53+
let harness: ReturnType<typeof createDryHarness<PrivateState>>;
54+
let wallets: Record<string, NoteWallet>;
55+
56+
beforeEach(() => {
57+
const { parties, contracts } = noteParties();
58+
wallets = { alice: parties.alice.wallet, bob: parties.bob.wallet };
59+
harness = createDryHarness({ contracts, privateState: {} });
60+
});
61+
62+
/** Mints a note to `ownerPk` and arms `actor` to spend it. */
63+
const mintTo = async (actor: string, ownerPk: bigint): Promise<Note> => {
64+
const note = await harness.apply<Note>({
65+
actor,
66+
circuitId: '_mint',
67+
args: [ownerPk, NOTE_VALUE],
68+
});
69+
wallets[actor].inputNote = note;
70+
return note;
71+
};
72+
73+
const spend = (actor: string, ownerPk: bigint) => ({
74+
actor,
75+
circuitId: '_consumeNote',
76+
args: [ownerPk],
77+
});
78+
79+
const mintNote = (actor: string, nonce: bigint, ownerPk: bigint) => ({
80+
actor,
81+
circuitId: '_mintNote',
82+
args: [{ value: NOTE_VALUE, nonce }, ownerPk],
83+
});
84+
85+
const state = () => ledger(harness.state);
86+
87+
// -------------------------------------------------------------------------
88+
// apply
89+
// -------------------------------------------------------------------------
90+
91+
it('should return the circuit result and advance the state', async () => {
92+
const before = await harness.snapshot();
93+
94+
const note = await mintTo('alice', ALICE);
95+
96+
expect(note.value).toBe(NOTE_VALUE);
97+
expect(await harness.snapshot()).not.toBe(before);
98+
expect(state().Core__commitments.firstFree()).toBe(1n);
99+
});
100+
101+
// -------------------------------------------------------------------------
102+
// attempt: scoring a replay
103+
// -------------------------------------------------------------------------
104+
105+
it('should reject a second build that pinned the same nullifier', async () => {
106+
await mintTo('alice', ALICE);
107+
const snapshot = await harness.snapshot();
108+
const first = await harness.build(spend('alice', ALICE), snapshot);
109+
const second = await harness.build(spend('alice', ALICE), snapshot);
110+
111+
await harness.land(first);
112+
const attempt = await harness.attempt(second);
113+
114+
expect(attempt.outcome).toBe('rejected');
115+
// The reason is the runtime's own, so it must at least be there.
116+
expect(attempt.outcome === 'rejected' ? attempt.reason : '').not.toBe('');
117+
expect(state().Core__nullifiers.size()).toBe(1n);
118+
});
119+
120+
it('should let a second build that pinned a different key land', async () => {
121+
const snapshot = await harness.snapshot();
122+
const first = await harness.build(mintNote('alice', 1n, ALICE), snapshot);
123+
const second = await harness.build(mintNote('bob', 2n, BOB), snapshot);
124+
125+
await harness.land(first);
126+
127+
expect(await harness.attempt(second)).toStrictEqual({ outcome: 'landed' });
128+
expect(state().Core__commitments.firstFree()).toBe(2n);
129+
});
130+
131+
// -------------------------------------------------------------------------
132+
// classify: a conflict is not the same as a broken transcript
133+
// -------------------------------------------------------------------------
134+
135+
it('should rethrow a transcript that fails against its own build snapshot', async () => {
136+
await mintTo('alice', ALICE);
137+
const snapshot = await harness.snapshot();
138+
const pending = await harness.build(spend('alice', ALICE), snapshot);
139+
140+
await harness.land(pending);
141+
// Repointing the build snapshot at the post-spend state leaves the
142+
// transcript invalid everywhere, which is a spec or harness bug rather
143+
// than a divergence between two states.
144+
(pending as { builtOn: unknown }).builtOn = await harness.snapshot();
145+
146+
await expect(harness.attempt(pending)).rejects.toThrow();
147+
});
148+
149+
// -------------------------------------------------------------------------
150+
// land: replayed against the current state, not the build state
151+
// -------------------------------------------------------------------------
152+
153+
it('should land two independent builds from one snapshot', async () => {
154+
// Replaying the second against its build snapshot would discard the first
155+
// insert and score every case as landed.
156+
const snapshot = await harness.snapshot();
157+
const first = await harness.build(mintNote('alice', 1n, ALICE), snapshot);
158+
const second = await harness.build(mintNote('bob', 2n, BOB), snapshot);
159+
160+
await harness.land(first);
161+
await harness.land(second);
162+
163+
expect(state().Core__commitments.firstFree()).toBe(2n);
164+
expect(state().Core__issuedNonces.size()).toBe(2n);
165+
});
166+
});

contracts/test-utils/concurrency/test/DryReplayHarness.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
*
44
* Deliberately no compiled artifact here: `test:harness` does not depend on the
55
* `compile` task, so importing one would break a clean checkout. The replay
6-
* behaviour that genuinely needs real ledger state is pinned instead by the
7-
* `harness invariants` describe in a contract's own concurrency spec.
6+
* behaviour that genuinely needs real ledger state is pinned instead by
7+
* `src/token/test/DryReplayHarness.contract.test.ts`, in the `unit` project.
88
*/
99

1010
import { ContractState } from '@midnight-ntwrk/compact-runtime';

0 commit comments

Comments
 (0)