Skip to content

Commit 7ee1dbe

Browse files
immortal-tofuisaacdecodedobatirou
authored
fix(host-contracts): align confirm context previous committee quorum (#3051)
* fix(host-contracts): align confirm context previous-committee quorum * feat(test-suite): adjust the KMS tx-sender down scenario * chore(common): trigger CI * chore(test-suite): simplify test comments * Update host-contracts/contracts/ProtocolConfig.sol Co-authored-by: Oba <obatirou@gmail.com> --------- Co-authored-by: Isaac Herrera <isaac.herrera@zama.ai> Co-authored-by: Oba <obatirou@gmail.com>
1 parent 6c32322 commit 7ee1dbe

12 files changed

Lines changed: 86 additions & 55 deletions

File tree

host-contracts/contracts/ProtocolConfig.sol

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -304,12 +304,18 @@ contract ProtocolConfig is IProtocolConfig, UUPSUpgradeableEmptyProxy, ACLOwnabl
304304
$.contextState[contextId] = ContextState.Pending;
305305
_createPendingEpoch(contextId);
306306

307-
// Cache the previous-context confirmation quorum needed by confirmKmsContextCreation.
308-
// `n - t + 1` (n = previous committee size, t = previous MPC fault threshold) so no t-subset ratifies alone.
309-
$.contextCreationPreviousTxSenderThreshold[contextId] =
310-
$.kmsNodesForContext[previousContextId].length -
311-
$.mpcThresholdForContext[previousContextId] +
312-
1;
307+
// Cache the number of previous-committee confirmations confirmKmsContextCreation requires.
308+
// The previous committee has `n` nodes, of which at most `t` (its MPC threshold) are assumed
309+
// faulty — crashed, offline, or malicious. The quorum must be:
310+
// - more than `t`, so faulty nodes can never approve a switch on their own;
311+
// - at most `n - t`, because if `t` nodes stay silent only `n - t` confirmations ever
312+
// arrive — anything higher lets a dead node block the switch forever.
313+
// `n - t` satisfies `n - t >= t + 1` under the `n = 3t + 1` topology the KMS core
314+
// requires; the contract itself does not enforce the topology.
315+
// Floored at 1 so the degenerate `t = n` config cannot make the quorum zero.
316+
uint256 previousQuorum = $.kmsNodesForContext[previousContextId].length -
317+
$.mpcThresholdForContext[previousContextId];
318+
$.contextCreationPreviousTxSenderThreshold[contextId] = previousQuorum > 0 ? previousQuorum : 1;
313319

314320
// Store context anchor and emit NewKmsContext event.
315321
$.contextAnchors[contextId] = KmsContextAnchor({
@@ -371,7 +377,7 @@ contract ProtocolConfig is IProtocolConfig, UUPSUpgradeableEmptyProxy, ACLOwnabl
371377

372378
emit KmsContextCreationConfirmation(kmsContextId, msg.sender, isPreviousTxSender, isNewTxSender);
373379

374-
// All new nodes + (n - t + 1) previous nodes confirm to tell Connectors the epoch transition may start.
380+
// All new nodes + (n - t) previous nodes confirm to tell Connectors the epoch transition may start.
375381
if (_hasContextCreationQuorum(kmsContextId)) {
376382
$.contextState[kmsContextId] = ContextState.Created;
377383
// The context-switch created this context and its epoch as a paired (Pending, Pending). The DAO

host-contracts/rust_bindings/src/protocol_config.rs

Lines changed: 4 additions & 4 deletions
Large diffs are not rendered by default.

host-contracts/tasks/kmsContext.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,7 @@ export interface KmsContextSwitchStatus {
270270
newTxSendersOutstanding?: string[];
271271
previousTxSendersConfirmed?: string[];
272272
previousConfirmationCount?: number;
273-
previousTxSenderThreshold?: number; // the (n - t + 1) old-side quorum target
273+
previousTxSenderThreshold?: number; // the (n - t) old-side quorum target
274274
contextCreationQuorumReached?: boolean;
275275
stuckBelowPreviousThreshold?: boolean;
276276

@@ -293,7 +293,7 @@ function outstanding(expected: string[], confirmed: Set<string>): string[] {
293293
// plus the current active-context view. ProtocolConfig exposes no getter for the intermediate
294294
// `Created` state or the live confirmation tally, and the new (pending) context's signer set is not
295295
// readable via `getKmsSignersForContext` until it is `Active` — so the new set is taken from the
296-
// `NewKmsContext` event, while the old-side `(n - t + 1)` target is read from views on the still-
296+
// `NewKmsContext` event, while the old-side `(n - t)` target is read from views on the still-
297297
// active previous context.
298298
export async function inspectKmsContextSwitch(
299299
hre: HardhatRuntimeEnvironment,
@@ -388,10 +388,10 @@ async function fillContextSwitch(
388388
const newTxSenders = newContextEvent.args.kmsNodeParams.map((node) => checksum(node.txSenderAddress));
389389
status.newTxSenders = newTxSenders;
390390

391-
// Old-side (n - t + 1) target: the previous context is still active, so its views are readable.
391+
// Old-side (n - t) target (floored at 1): the previous context is still active, so its views are readable.
392392
const previousSigners: string[] = await pc.getKmsSignersForContext(previousContextId);
393393
const previousMpcThreshold: bigint = await pc.getMpcThresholdForContext(previousContextId);
394-
status.previousTxSenderThreshold = previousSigners.length - Number(previousMpcThreshold) + 1;
394+
status.previousTxSenderThreshold = Math.max(previousSigners.length - Number(previousMpcThreshold), 1);
395395

396396
// Tally creation confirmations from events.
397397
const creationConfirmations = await pc.queryFilter(
@@ -511,10 +511,10 @@ function printStatus(status: KmsContextSwitchStatus): void {
511511
}
512512
console.log(
513513
'previous tx senders confirmed:',
514-
`${status.previousConfirmationCount} (need >= ${status.previousTxSenderThreshold} = n - t + 1)`,
514+
`${status.previousConfirmationCount} (need >= ${status.previousTxSenderThreshold} = n - t)`,
515515
);
516516
if (status.stuckBelowPreviousThreshold) {
517-
console.log(' ⚠ stuck below the (n - t + 1) old-side confirmation target');
517+
console.log(' ⚠ stuck below the (n - t) old-side confirmation target');
518518
}
519519
console.log('creation quorum reached:', status.contextCreationQuorumReached);
520520
}

host-contracts/test/kmsVerifier/kmsVerifier.t.sol

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,11 +100,10 @@ contract KMSVerifierTest is HostContractsDeployerTestUtils {
100100
}
101101

102102
function _activatePendingSingleSignerContext(uint256 contextId, uint256 epochId, uint256 pk) internal {
103-
// Previous committee has 3 nodes (tx-senders 0xA1/0xA2/0xA3); the single new node reuses 0xA1,
104-
// so confirming all three previous tx-senders covers the new-signer side via 0xA1 too.
103+
// Previous committee has 3 nodes with mpc=1, so the previous-side quorum is n - t = 2. The
104+
// single new node reuses 0xA1, whose confirmation also covers the new-signer side.
105105
_confirmContextCreation(contextId, address(0xA1));
106106
_confirmContextCreation(contextId, address(0xA2));
107-
_confirmContextCreation(contextId, address(0xA3));
108107
_confirmEpochActivation(contextId, epochId, pk, address(0xA1), 0, 0);
109108
}
110109

host-contracts/test/protocolConfig/protocolConfig.t.sol

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1119,8 +1119,11 @@ contract ProtocolConfigTest is HostContractsDeployerTestUtils {
11191119
assertEq(protocolConfig.getKmsGenThresholdForContext(KMS_CONTEXT_COUNTER_BASE + 2), 1);
11201120
}
11211121

1122+
/// @dev Previous-side quorum boundary, both sides: with n = 4 previous nodes and t = 2, the
1123+
/// target is n - t = 2. All-new + n - t - 1 previous confirmations stay Pending, the
1124+
/// (n - t)-th completes creation (NewKmsEpoch), and any later one hits the Created state.
11221125
function test_confirmKmsContextCreationUsesNewSignersAndOldQuorum() public {
1123-
_setupDefaultWithMpcThreshold(3);
1126+
_setupDefaultWithMpcThreshold(2);
11241127
KmsNodeParams[] memory nodes = _makeKmsNodeParams(2);
11251128
nodes[0].txSenderAddress = address(0xC1);
11261129
nodes[0].signerAddress = address(0xB2);
@@ -1150,6 +1153,11 @@ contract ProtocolConfigTest is HostContractsDeployerTestUtils {
11501153
);
11511154
vm.prank(kmsTxSender1);
11521155
protocolConfig.confirmKmsContextCreation(newContextId);
1156+
1157+
// The context left Pending on the (n - t)-th previous confirmation; a further one is rejected.
1158+
vm.prank(kmsTxSender2);
1159+
vm.expectRevert(abi.encodeWithSelector(IProtocolConfig.KmsContextNotPending.selector, newContextId));
1160+
protocolConfig.confirmKmsContextCreation(newContextId);
11531161
}
11541162

11551163
function test_confirmKmsContextCreationRequiresAllNewSigners() public {

host-contracts/test/tasks/kmsContext.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ describe('KMS context tasks', function () {
239239
// ---------------------------------------------------------------------------
240240

241241
describe('context-switch status', function () {
242-
// Old context: 3 nodes, mpc=2 -> previous-side creation target (n - t + 1) = 2.
242+
// Old context: 3 nodes, mpc=2 -> previous-side creation target (n - t) = 1.
243243
// New context: 2 nodes, mpc=1. Old/new committees are disjoint so confirmations partition cleanly.
244244
let proxyAddress: string;
245245
let protocolConfig: ProtocolConfig;
@@ -329,7 +329,7 @@ describe('KMS context tasks', function () {
329329
publicDecryption: 1,
330330
userDecryption: 1,
331331
kmsGen: 1,
332-
mpc: 2, // -> previousTxSenderThreshold = 3 - 2 + 1 = 2
332+
mpc: 2, // -> previousTxSenderThreshold = 3 - 2 = 1
333333
});
334334
protocolConfig = (await ethers.getContractAt('ProtocolConfig', proxyAddress)) as unknown as ProtocolConfig;
335335
});
@@ -353,22 +353,22 @@ describe('KMS context tasks', function () {
353353
expect(result.contextCreationQuorumReached).to.equal(false);
354354
});
355355

356-
it('surfaces the (n - t + 1) old-side target and flags being stuck below it', async function () {
356+
it('surfaces the (n - t) old-side target and flags being stuck below it', async function () {
357357
const contextId = await defineSwitch();
358-
await confirmCreation(contextId, [...newTxSenders, oldTxSenders[0]]);
358+
await confirmCreation(contextId, [...newTxSenders]);
359359

360360
const result = await inspectKmsContextSwitch(hre, proxyAddress, 0);
361361
expect(result.contextState).to.equal('PENDING');
362362
expect(result.newTxSendersOutstanding).to.have.lengthOf(0);
363-
expect(result.previousTxSenderThreshold).to.equal(2);
364-
expect(result.previousConfirmationCount).to.equal(1);
363+
expect(result.previousTxSenderThreshold).to.equal(1);
364+
expect(result.previousConfirmationCount).to.equal(0);
365365
expect(result.stuckBelowPreviousThreshold).to.equal(true);
366366
expect(result.contextCreationQuorumReached).to.equal(false);
367367
});
368368

369369
it('reports CREATED once the creation quorum is reached, with the epoch still PENDING', async function () {
370370
const contextId = await defineSwitch();
371-
const epochId = await confirmCreation(contextId, [...newTxSenders, oldTxSenders[0], oldTxSenders[1]]);
371+
const epochId = await confirmCreation(contextId, [...newTxSenders, oldTxSenders[0]]);
372372
expect(epochId, 'creation quorum should emit NewKmsEpoch').to.not.be.undefined;
373373

374374
const result = await inspectKmsContextSwitch(hre, proxyAddress, 0);
@@ -382,7 +382,7 @@ describe('KMS context tasks', function () {
382382

383383
it('reports fully live once the epoch is activated', async function () {
384384
const contextId = await defineSwitch();
385-
const epochId = await confirmCreation(contextId, [...newTxSenders, oldTxSenders[0], oldTxSenders[1]]);
385+
const epochId = await confirmCreation(contextId, [...newTxSenders, oldTxSenders[0]]);
386386
await confirmActivation(epochId!, newTxSenders);
387387

388388
const result = await inspectKmsContextSwitch(hre, proxyAddress, 0);

host-contracts/test/tasks/taskHelpers.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,8 +180,8 @@ export async function buildControllableKmsCommittee(): Promise<ControllableKmsCo
180180
});
181181
return {
182182
nodes: [node(txSender0, signer0, 0), node(txSender1, signer1, 1)],
183-
// mpc threshold 1 keeps the previous-signer creation quorum at the full set, satisfied by reusing
184-
// the same committee for the rotated context.
183+
// Reusing the same committee for the rotated context satisfies both creation-quorum sides
184+
// (all new tx-senders + n - t previous) with the same two confirmations.
185185
thresholds: { publicDecryption: 1, userDecryption: 1, kmsGen: 1, mpc: 1 },
186186
signerSigners: [signer0, signer1],
187187
txSenderSigners: [txSender0, txSender1],

library-solidity/test/onchainPublicDecrypt/OnchainPublicDecrypt.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -141,14 +141,9 @@ describe('OnchainPublicDecrypt', function () {
141141
},
142142
];
143143
const resetThresholds = { publicDecryption: 1, userDecryption: 1, kmsGen: 1, mpc: 1 };
144-
await activateNewKmsContext(
145-
protocolConfig,
146-
deployer,
147-
resetNodes,
148-
resetThresholds,
149-
[accounts[2], accounts[3]],
150-
[accounts[2]],
151-
);
144+
// accounts[2] alone completes the creation quorum: it is the only new tx-sender and its
145+
// confirmation also covers the previous side's n - t = 1 target (3 nodes, mpc = 2).
146+
await activateNewKmsContext(protocolConfig, deployer, resetNodes, resetThresholds, [accounts[2]], [accounts[2]]);
152147
expect(await protocolConfig.getPublicDecryptionThreshold()).to.equal(1);
153148
expect(await kmsVerifier.getKmsSigners()).to.deep.equal([signerAddresses[0]]);
154149
});

test-suite/fhevm/src/commands/kms-context-switch.ts

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
* Drives the two ProtocolConfig KMS-context lifecycle flows end to end on a threshold-mode cluster and
55
* proves the KMS reacts to the emitted events:
66
* 1. NewKmsContext — broadcast `defineNewKmsContextAndEpoch` (same committee, so no new signing
7-
* keys are needed), wait for the new context to become the on-chain active one, then decrypt.
7+
* keys are needed; on a spare-core cluster, a node swap with the dropped node's tx-sender
8+
* stopped first), wait for the new context to become the on-chain active one, then decrypt.
89
* 2. NewKmsEpoch — broadcast `defineNewEpochForCurrentKmsContext` (same context, new epoch), wait
910
* for the new epoch to activate, then decrypt again.
1011
*
@@ -22,12 +23,13 @@
2223
import { PreflightError } from "../errors";
2324
import { castCall, resolveKmsGenerationTarget, waitForContainer } from "../flow/readiness";
2425
import { stepComposeTask } from "../flow/runtime-compose";
25-
import { reconstructionThreshold } from "../kms-party";
26+
import { kmsTxSenderName, reconstructionThreshold } from "../kms-party";
2627
import type { State } from "../types";
2728
import {
2829
type DecryptionRunner,
2930
partyContainers,
3031
setRunning,
32+
waitForContainersStopped,
3133
waitForPartiesRunning,
3234
waitForPartiesStopped,
3335
} from "./kms-generation";
@@ -104,11 +106,13 @@ const committeeSwapPlan = (kms: State["scenario"]["kms"]) => {
104106

105107
/**
106108
* NewKmsContext step. On a cluster with a spare core this is a genuine node swap: the new context
107-
* drops a committee node and promotes the spare, keeping n = committeeSize so the threshold stays
108-
* valid. The dropped node reshares as Set 1 (outgoing), the spare as Set 2 (incoming), the rest as
109-
* both. Without a spare it is a same-committee reshare. Activation already gates on the spare:
110-
* it requires ALL new-committee signers to confirm, so the context id only advances once the
111-
* spare has reshared and submitted confirmKmsContextCreation.
109+
* drops a committee node and promotes the spare, keeping n = committeeSize. The dropped node's
110+
* tx-sender is stopped before the broadcast, so the switch must complete on the n − t
111+
* previous-side quorum without its confirmation — a node that cannot transact must not veto its
112+
* own removal. The rest of the node stays up because the reshare still needs its core (the KMS
113+
* core cannot yet reshare around an absent outgoing party); upgrade to a full party stop once it
114+
* can. Without a spare it is a same-committee reshare. Activation gates on ALL new-committee
115+
* signers, so the context id only advances once the spare has reshared and confirmed.
112116
*/
113117
const switchKmsContext = async (
114118
state: State,
@@ -122,9 +126,19 @@ const switchKmsContext = async (
122126
const hostEnv: Record<string, string> = isSwap ? { HOST_SC_CONTEXT_ENV: "host-sc-swap.env" } : {};
123127
const gatewayEnv: Record<string, string> = isSwap ? { GATEWAY_SC_CONTEXT_ENV: "gateway-sc-swap.env" } : {};
124128

129+
if (isSwap) {
130+
// Stopped before the broadcast and left down: the new committee does not include the node.
131+
const droppedTxSenders = dropped.map((party) => kmsTxSenderName(party));
132+
console.log(
133+
`[kms-context-switch] stopping dropped node(s) ${dropped.join(",")} tx-sender before the switch — a node that cannot confirm must not block its own replacement…`,
134+
);
135+
await setRunning(droppedTxSenders, "stop");
136+
await waitForContainersStopped(droppedTxSenders);
137+
}
138+
125139
console.log(
126140
isSwap
127-
? `[kms-context-switch] broadcasting defineNewKmsContextAndEpoch — node swap (drop ${dropped.join(",")}, add ${added.join(",")}, keep ${continuing.join(",")})…`
141+
? `[kms-context-switch] broadcasting defineNewKmsContextAndEpoch — node swap (drop ${dropped.join(",")} with tx-sender stopped, add ${added.join(",")}, keep ${continuing.join(",")})…`
128142
: "[kms-context-switch] broadcasting defineNewKmsContextAndEpoch (NewKmsContext, same committee)…",
129143
);
130144
await stepComposeTask("host-sc", state, ["host-sc-context-switch"], { noDeps: true, env: hostEnv });

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

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
*/
1010
import { PreflightError } from "../errors";
1111
import { castCall, dockerInspect, probeBootstrap, resolveKmsGenerationTarget, waitForContainer } from "../flow/readiness";
12-
import { kmsConnectorPrefix, kmsCoreName, reconstructionThreshold } from "../kms-party";
12+
import { kmsConnectorPrefix, kmsCoreName, kmsTxSenderName, reconstructionThreshold } from "../kms-party";
1313
import type { State } from "../types";
1414
import { withHexPrefix } from "../utils/fs";
1515
import { run } from "../utils/process";
@@ -20,7 +20,7 @@ export type DecryptionRunner = (label: string, opts?: { expectFailure?: boolean
2020
/** Every container that belongs to one KMS party (its core + its connector tier). */
2121
export const partyContainers = (party: number) => {
2222
const connector = kmsConnectorPrefix(party);
23-
return [kmsCoreName(party), `${connector}-gw-listener`, `${connector}-kms-worker`, `${connector}-tx-sender`];
23+
return [kmsCoreName(party), `${connector}-gw-listener`, `${connector}-kms-worker`, kmsTxSenderName(party)];
2424
};
2525

2626
/**
@@ -59,14 +59,19 @@ const waitForContainerStopped = async (container: string) => {
5959
);
6060
};
6161

62-
/** Confirms every stopped party is genuinely down before a quorum verdict is read. `setRunning`
62+
/** Confirms every listed container is genuinely down before a verdict is read. `setRunning`
6363
* tolerates stop failures for idempotency, so without this check a silently no-op'd stop would
64-
* probe with too many live parties and misdiagnose "2t+1 not enforced". */
64+
* probe with too many live containers and misdiagnose the scenario. */
65+
export const waitForContainersStopped = async (containers: string[]) => {
66+
for (const container of containers) {
67+
await waitForContainerStopped(container);
68+
}
69+
};
70+
71+
/** Confirms every stopped party is genuinely down before a quorum verdict is read. */
6572
export const waitForPartiesStopped = async (parties: number[]) => {
6673
for (const party of parties) {
67-
for (const container of partyContainers(party)) {
68-
await waitForContainerStopped(container);
69-
}
74+
await waitForContainersStopped(partyContainers(party));
7075
}
7176
};
7277

0 commit comments

Comments
 (0)