Skip to content

Commit 81621dd

Browse files
committed
feat(test-suite): assert on-chain digest divergence in ciphertext-drift test
After the gw-listener confirms the drift warning, query the gateway chain for AddCiphertextMaterial events and verify the two coprocessor submissions produced distinct ciphertext digests. Reverts the earlier ConsensusWatchdog changes — the watchdog is a safety net for normal tests, not the right place for intentional drift assertions.
1 parent f90d090 commit 81621dd

File tree

3 files changed

+47
-44
lines changed

3 files changed

+47
-44
lines changed

test-suite/e2e/test/consensusWatchdog.test.ts

Lines changed: 0 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -123,39 +123,6 @@ describe('ConsensusWatchdog', function () {
123123
});
124124
});
125125

126-
describe('checkHealth — expected divergence', function () {
127-
it('should not throw when divergence is expected', async function () {
128-
const { watchdog, setBlock, setCiphertextEvents } = mockWatchdog();
129-
130-
setCiphertextEvents(
131-
[
132-
fakeEvent('0xhandle1', 1n, '0xdigestA', '0xsnsDigestA', '0xCoprocessor1'),
133-
fakeEvent('0xhandle1', 1n, '0xdigestB', '0xsnsDigestA', '0xCoprocessor2'),
134-
],
135-
[],
136-
);
137-
138-
setBlock(1);
139-
await watchdog.flush();
140-
141-
expect(() => watchdog.checkHealth(true)).to.not.throw();
142-
});
143-
144-
it('should skip stall checks when divergence is expected', async function () {
145-
const { watchdog, setBlock, setCiphertextEvents } = mockWatchdog();
146-
147-
setCiphertextEvents([fakeEvent('0xhandle1', 1n, '0xdigest', '0xsns', '0xCopro1')], []);
148-
149-
setBlock(1);
150-
await watchdog.flush();
151-
152-
const pending = (watchdog as any).pendingHandles.get('0xhandle1');
153-
pending.firstSeenAt = Date.now() - 4 * 60 * 1000;
154-
155-
expect(() => watchdog.checkHealth(true)).to.not.throw();
156-
});
157-
});
158-
159126
describe('checkHealth — stall detection', function () {
160127
it('should throw when consensus is not reached within timeout', async function () {
161128
const { watchdog, setBlock, setCiphertextEvents } = mockWatchdog();

test-suite/e2e/test/consensusWatchdog.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -320,23 +320,15 @@ export class ConsensusWatchdog {
320320
* Check for divergences (instant) and stalls (3-minute timeout).
321321
* Called in afterEach to fail the current test if consensus is broken.
322322
*/
323-
checkHealth(expectDivergence = false): void {
323+
checkHealth(): void {
324324
// Force a sync check of divergences accumulated since last poll.
325325
if (this.divergences.length > 0) {
326326
const msg = this.divergences.join('\n\n');
327327
this.divergences = [];
328328
this.divergenceKeys.clear();
329-
if (expectDivergence) {
330-
console.log(`[consensus-watchdog] Expected divergence confirmed:\n\n${msg}`);
331-
return;
332-
}
333329
throw new Error(`Consensus divergence detected:\n\n${msg}`);
334330
}
335331

336-
// When divergence is expected, the corrupted handle will never reach consensus,
337-
// so skip stall checks to avoid a false-positive timeout error.
338-
if (expectDivergence) return;
339-
340332
// Check for stalls: handles that received a first submission but no consensus within timeout.
341333
const now = Date.now();
342334

@@ -421,7 +413,7 @@ export const mochaHooks = {
421413

422414
// Force one last poll before checking health so we catch recent events.
423415
await watchdog.flush();
424-
watchdog.checkHealth(process.env.EXPECT_CIPHERTEXT_DIVERGENCE === 'true');
416+
watchdog.checkHealth();
425417
},
426418

427419
async afterAll(this: Mocha.Context) {

test-suite/fhevm/src/commands/test.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { compatPolicyForState } from "../compat/compat";
55
import { DRIFT_CLEANUP_SQL, DRIFT_INSTALL_SQL, driftDatabaseName, parseDriftInstanceIndex, parsePositiveInteger } from "../drift";
66
import { PreflightError } from "../errors";
77
import { pause, shellEscape, unpause } from "../flow/up-flow";
8+
import { hostReachableRpcUrl } from "../utils/fs";
89
import { run, runWithHeartbeat } from "../utils/process";
910
import { loadState } from "../state/state";
1011
import { topologyForState } from "../stack-spec/stack-spec";
@@ -222,6 +223,44 @@ const waitForDriftWarning = async (
222223
throw new PreflightError(`drift warning was not observed after injecting handle ${handleHex}`);
223224
};
224225

226+
/** Topic hash for AddCiphertextMaterial(bytes32 indexed ctHandle, uint256 keyId, bytes32 ciphertextDigest, bytes32 snsCiphertextDigest, address coprocessorTxSender). */
227+
const ADD_CIPHERTEXT_MATERIAL_TOPIC = "0x7249a80e5b91709d2170511b960e8a92e1d5849d200f320524dfffd8b50308f7";
228+
229+
/** Queries on-chain AddCiphertextMaterial events and asserts the two submissions have divergent digests. */
230+
const assertOnChainDivergence = async (
231+
gatewayRpcUrl: string,
232+
contractAddress: string,
233+
handleHex: string,
234+
) => {
235+
const paddedHandle = `0x${handleHex.toLowerCase().padStart(64, "0")}`;
236+
const response = await fetch(gatewayRpcUrl, {
237+
method: "POST",
238+
headers: { "content-type": "application/json" },
239+
body: JSON.stringify({
240+
jsonrpc: "2.0",
241+
id: 1,
242+
method: "eth_getLogs",
243+
params: [{ fromBlock: "0x0", toBlock: "latest", address: contractAddress, topics: [ADD_CIPHERTEXT_MATERIAL_TOPIC, paddedHandle] }],
244+
}),
245+
});
246+
if (!response.ok) {
247+
throw new PreflightError(`eth_getLogs failed: ${response.status} ${response.statusText}`);
248+
}
249+
const payload = (await response.json()) as { result?: { data: string }[] };
250+
const logs = payload.result ?? [];
251+
if (logs.length < 2) {
252+
throw new PreflightError(`expected 2+ AddCiphertextMaterial events for handle 0x${handleHex}, got ${logs.length}`);
253+
}
254+
// data layout: keyId (32B) | ciphertextDigest (32B) | snsCiphertextDigest (32B) | coprocessorTxSender (32B)
255+
// ciphertextDigest starts at byte offset 32 (chars 66..130 in the 0x-prefixed hex)
256+
const digests = logs.map((log) => log.data.slice(66, 130));
257+
const unique = new Set(digests);
258+
if (unique.size < 2) {
259+
throw new PreflightError(`on-chain AddCiphertextMaterial events show identical digests — drift not visible on chain`);
260+
}
261+
console.log(`[drift] on-chain divergence confirmed: ${logs.length} submissions with ${unique.size} distinct digest(s)`);
262+
};
263+
225264
/** Builds the `docker exec` argv used to run tests inside the test-suite container. */
226265
export const buildTestContainerArgs = (tail: string[], extraExecArgs: string[] = []) => [
227266
"docker",
@@ -542,7 +581,7 @@ export const test = async (testName: string | undefined, options: TestOptions) =
542581
postgresPassword: postgres.postgresPassword,
543582
});
544583
await runWithHeartbeat(
545-
buildTestContainerArgs(["./run-tests.sh", "-n", options.network, "-g", grepPattern], ["-e", "EXPECT_CIPHERTEXT_DIVERGENCE=true"]),
584+
buildTestContainerArgs(["./run-tests.sh", "-n", options.network, "-g", grepPattern], ["-e", "GATEWAY_RPC_URL="]),
546585
"test ciphertext-drift",
547586
);
548587
const injectedHandleHex = await injector;
@@ -552,6 +591,11 @@ export const test = async (testName: string | undefined, options: TestOptions) =
552591
pollIntervalSeconds: driftAlertPollIntervalSeconds,
553592
});
554593
console.log(`[drift] detected in ${warning.container} for injected handle 0x${injectedHandleHex}`);
594+
const ciphertextCommitsAddress = state.discovery!.gateway.CIPHERTEXT_COMMITS_ADDRESS;
595+
if (ciphertextCommitsAddress) {
596+
const gatewayRpcUrl = hostReachableRpcUrl(state.discovery!.endpoints.gateway.http);
597+
await assertOnChainDivergence(gatewayRpcUrl, ciphertextCommitsAddress, injectedHandleHex);
598+
}
555599
});
556600
}
557601
if (name === "multi-chain-isolation") {

0 commit comments

Comments
 (0)