From b670cd2b930bccfcb4a74af62dd418dac2f2d349 Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:23:57 +0900 Subject: [PATCH 1/8] prevote nil when locked on another block --- packages/consensus/source/consensus.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/consensus/source/consensus.ts b/packages/consensus/source/consensus.ts index e72bee7bd..074bda8f8 100644 --- a/packages/consensus/source/consensus.ts +++ b/packages/consensus/source/consensus.ts @@ -280,7 +280,16 @@ export class Consensus implements Contracts.Consensus.Service { this.logger.info(`Received proposal ${this.#getBlockString(proposal.blockHeader)}`, "consensus"); await this.eventDispatcher.dispatch(Events.ConsensusEvent.ProposalAccepted, this.getState()); - await this.prevote(roundState.getProcessorResult().success ? proposal.blockHeader.hash : undefined); + // A validator locked on a block must not prevote a different one + const isNotLockedOnAnotherBlock = + this.#lockedValue === undefined || + this.#lockedValue.getProposal()?.blockHeader.hash === proposal.blockHeader.hash; + + await this.prevote( + roundState.getProcessorResult().success && isNotLockedOnAnotherBlock + ? proposal.blockHeader.hash + : undefined, + ); } protected async onProposalLocked(roundState: Contracts.Consensus.RoundState): Promise { From a8042ea9ea226bb0d14ea24847b317e7c794ed68 Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:24:11 +0900 Subject: [PATCH 2/8] test coverage --- packages/consensus/source/consensus.test.ts | 265 +++++++++++++++++++- 1 file changed, 264 insertions(+), 1 deletion(-) diff --git a/packages/consensus/source/consensus.test.ts b/packages/consensus/source/consensus.test.ts index 72b386396..8b61adcee 100644 --- a/packages/consensus/source/consensus.test.ts +++ b/packages/consensus/source/consensus.test.ts @@ -731,7 +731,270 @@ describe("Consensus", ({ it, beforeEach, assert, stub, spy, clock, each // TODO: Handle on processor it("#onProposal - broadcast prevote null, if block processor throws", async ({ consensus }) => {}); - it("#onProposal - broadcast prevote null, if locked value exists", async ({ consensus }) => {}); + it("#onProposal - broadcast prevote null, if locked on another block", async ({ + consensus, + validatorSet, + validatorsRepository, + messageProcessor, + roundState, + proposer, + logger, + eventDispatcher, + }) => { + stub(roundState, "getProcessorResult").returnValue({ success: true }); + const spyValidatorSetGetRoundValidators = stub(validatorSet, "getRoundValidators").returnValue([proposer]); + + // Lock on the round-0 block via +2/3 prevotes (validator repository still returns + // undefined here, so no precommit is signed while locking). + consensus.setStep(Enums.Consensus.Step.Prevote); + await consensus.onMajorityPrevote(roundState); + assert.equal(consensus.getLockedRound(), 0); + + // A different, valid block is proposed fresh (no lock proof) in the next round. + const anotherBlock = { + hash: "anotherBlockHash", + number: 1, + round: 1, + }; + const anotherProposal = { + blockHeader: anotherBlock, + getData: () => ({ block: anotherBlock }), + round: 1, + validRound: undefined, + }; + const anotherRoundState = { + blockNumber: 1, + getProcessorResult: () => ({ success: true }), + getProposal: () => anotherProposal, + round: 1, + } as unknown as Contracts.Consensus.RoundState; + + consensus.setRound(1); + consensus.setStep(Enums.Consensus.Step.Propose); + + const prevote = { + blockNumber: 1, + round: 1, + }; + + const validator = { + prevote: () => {}, + }; + const spyValidatorPrevote = stub(validator, "prevote").resolvedValue(prevote); + + const spyValidatorsRepositoryGetValidator = stub(validatorsRepository, "getValidator").returnValue(validator); + const spyMessageProcess = spy(messageProcessor, "process"); + const getValidatorIndexByWalletAddress = stub(validatorSet, "getValidatorIndexByWalletAddress").returnValue(1); + const spyLoggerInfo = spy(logger, "info"); + const spyDispatch = spy(eventDispatcher, "dispatch"); + + await consensus.onProposal(anotherRoundState); + + spyValidatorSetGetRoundValidators.called(); + spyValidatorsRepositoryGetValidator.calledOnce(); + getValidatorIndexByWalletAddress.calledOnce(); + + spyValidatorPrevote.calledOnce(); + spyValidatorPrevote.calledWith(1, 1, 1, undefined); // nil prevote, despite the valid proposal + + spyMessageProcess.calledOnce(); + spyMessageProcess.calledWith(prevote); + + spyLoggerInfo.calledWith(`Received proposal ${1}/${1}/${anotherBlock.hash}`); + spyDispatch.calledWith(Events.ConsensusEvent.ProposalAccepted, { + blockNumber: 1, + lockedRound: 0, + round: 1, + step: Enums.Consensus.Step.Prevote, + validRound: 0, + }); + + assert.equal(consensus.getLockedRound(), 0); + assert.equal(consensus.getStep(), Enums.Consensus.Step.Prevote); + }); + + it("#onProposal - broadcast prevote block hash, if locked on the proposed block", async ({ + consensus, + validatorSet, + validatorsRepository, + messageProcessor, + roundState, + block, + proposer, + }) => { + stub(roundState, "getProcessorResult").returnValue({ success: true }); + stub(validatorSet, "getRoundValidators").returnValue([proposer]); + + // Lock on the round-0 block via +2/3 prevotes. + consensus.setStep(Enums.Consensus.Step.Prevote); + await consensus.onMajorityPrevote(roundState); + assert.equal(consensus.getLockedRound(), 0); + + const prevote = { + blockNumber: 1, + round: 0, + }; + + const validator = { + prevote: () => {}, + }; + const spyValidatorPrevote = stub(validator, "prevote").resolvedValue(prevote); + + stub(validatorsRepository, "getValidator").returnValue(validator); + const spyMessageProcess = spy(messageProcessor, "process"); + stub(validatorSet, "getValidatorIndexByWalletAddress").returnValue(1); + + // The locked round is replayed, which puts the step back to propose. + consensus.setStep(Enums.Consensus.Step.Propose); + await consensus.onProposal(roundState); + + spyValidatorPrevote.calledOnce(); + spyValidatorPrevote.calledWith(1, 1, 0, block.hash); // locked on the proposed block: prevote it + + spyMessageProcess.calledOnce(); + spyMessageProcess.calledWith(prevote); + }); + + it("#onProposal - broadcast prevote null, if the locked value was restored and its payload is not deserialized", async ({ + consensus, + validatorSet, + validatorsRepository, + messageProcessor, + proposer, + }) => { + stub(validatorSet, "getRoundValidators").returnValue([proposer]); + + // A lock as restored from consensus storage after a restart: the proposal's header is + // available, but the payload is not deserialized, so getBlock() throws. + const restoredProposal = { + blockHeader: { + hash: "restoredBlockHash", + number: 1, + round: 0, + }, + round: 0, + }; + const restoredRoundState = { + blockNumber: 1, + getBlock: () => { + throw new Error("Block is not available, because proposal is not set or deserialized"); + }, + getProcessorResult: () => ({ success: true }), + getProposal: () => restoredProposal, + round: 0, + } as unknown as Contracts.Consensus.RoundState; + + consensus.setStep(Enums.Consensus.Step.Prevote); + await consensus.onMajorityPrevote(restoredRoundState); + assert.equal(consensus.getLockedRound(), 0); + + // A different, valid block is proposed fresh in the next round. + const anotherBlock = { + hash: "anotherBlockHash", + number: 1, + round: 1, + }; + const anotherProposal = { + blockHeader: anotherBlock, + getData: () => ({ block: anotherBlock }), + round: 1, + validRound: undefined, + }; + const anotherRoundState = { + blockNumber: 1, + getProcessorResult: () => ({ success: true }), + getProposal: () => anotherProposal, + round: 1, + } as unknown as Contracts.Consensus.RoundState; + + consensus.setRound(1); + consensus.setStep(Enums.Consensus.Step.Propose); + + const prevote = { + blockNumber: 1, + round: 1, + }; + + const validator = { + prevote: () => {}, + }; + const spyValidatorPrevote = stub(validator, "prevote").resolvedValue(prevote); + + stub(validatorsRepository, "getValidator").returnValue(validator); + const spyMessageProcess = spy(messageProcessor, "process"); + stub(validatorSet, "getValidatorIndexByWalletAddress").returnValue(1); + + await consensus.onProposal(anotherRoundState); + + spyValidatorPrevote.calledOnce(); + spyValidatorPrevote.calledWith(1, 1, 1, undefined); // nil prevote, without touching the payload + + spyMessageProcess.calledOnce(); + spyMessageProcess.calledWith(prevote); + }); + + it("#onProposal - broadcast prevote null, if the locked value has no proposal", async ({ + consensus, + validatorSet, + validatorsRepository, + messageProcessor, + roundState, + proposer, + }) => { + stub(roundState, "getProcessorResult").returnValue({ success: true }); + stub(validatorSet, "getRoundValidators").returnValue([proposer]); + + consensus.setStep(Enums.Consensus.Step.Prevote); + await consensus.onMajorityPrevote(roundState); + assert.equal(consensus.getLockedRound(), 0); + + // The locked round was restored without its proposal, so the locked block is unknown. + roundState.getProposal = () => undefined; + + // A valid block is proposed fresh in the next round. + const anotherBlock = { + hash: "anotherBlockHash", + number: 1, + round: 1, + }; + const anotherProposal = { + blockHeader: anotherBlock, + getData: () => ({ block: anotherBlock }), + round: 1, + validRound: undefined, + }; + const anotherRoundState = { + blockNumber: 1, + getProcessorResult: () => ({ success: true }), + getProposal: () => anotherProposal, + round: 1, + } as unknown as Contracts.Consensus.RoundState; + + consensus.setRound(1); + consensus.setStep(Enums.Consensus.Step.Propose); + + const prevote = { + blockNumber: 1, + round: 1, + }; + + const validator = { + prevote: () => {}, + }; + const spyValidatorPrevote = stub(validator, "prevote").resolvedValue(prevote); + + stub(validatorsRepository, "getValidator").returnValue(validator); + const spyMessageProcess = spy(messageProcessor, "process"); + stub(validatorSet, "getValidatorIndexByWalletAddress").returnValue(1); + + await consensus.onProposal(anotherRoundState); + + spyValidatorPrevote.calledOnce(); + spyValidatorPrevote.calledWith(1, 1, 1, undefined); // nil prevote, the lock cannot be matched + + spyMessageProcess.calledOnce(); + spyMessageProcess.calledWith(prevote); + }); it("#onProposalLocked - broadcast prevote block hash, if block is valid and lockedRound is undefined", async ({ consensus, From 8a8640ebe33d2863a5b1185bcfb5f350a7a2e6b6 Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:55:57 +0900 Subject: [PATCH 3/8] ensure payload is deserialized on restore --- packages/consensus/source/consensus.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/consensus/source/consensus.ts b/packages/consensus/source/consensus.ts index 074bda8f8..306a33050 100644 --- a/packages/consensus/source/consensus.ts +++ b/packages/consensus/source/consensus.ts @@ -546,6 +546,10 @@ export class Consensus implements Contracts.Consensus.Service { registeredProposer: Contracts.Validator.Validator, ): Promise { if (this.#validValue) { + // A valid value restored from consensus storage still holds a serialized payload, so the + // block has to be deserialized before it can be re-proposed. Deserializing is idempotent. + await this.#validValue.getProposal()?.deserializePayload(); + this.#proposedBlock = this.#validValue.getBlock(); const lockProof = await this.#validValue.aggregatePrevotes(); From 70073ace5b1da55e261a1e6836801db7db9b62e0 Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:56:16 +0900 Subject: [PATCH 4/8] more tests --- packages/consensus/source/consensus.test.ts | 64 +++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/packages/consensus/source/consensus.test.ts b/packages/consensus/source/consensus.test.ts index 8b61adcee..501a3a79d 100644 --- a/packages/consensus/source/consensus.test.ts +++ b/packages/consensus/source/consensus.test.ts @@ -105,6 +105,7 @@ describe("Consensus", ({ it, beforeEach, assert, stub, spy, clock, each }; context.proposal = { + deserializePayload: () => {}, getData: () => ({ block: context.block, }), @@ -368,6 +369,69 @@ describe("Consensus", ({ it, beforeEach, assert, stub, spy, clock, each assert.equal(consensus.getStep(), Enums.Consensus.Step.Propose); }); + it("#startRound - local validator should propose validRound restored from storage", async ({ + consensus, + validatorsRepository, + roundStateRepository, + proposalProcessor, + block, + proposal, + proposer, + roundState, + validatorSet, + forger, + }) => { + const validator = { + propose: () => {}, + }; + + const spyForgerForgeBlock = stub(forger, "forgeBlock").resolvedValue(block); + const spyValidatorPropose = stub(validator, "propose").resolvedValue(proposal); + const spyProposalProcess = spy(proposalProcessor, "process"); + + stub(roundStateRepository, "getRoundState").returnValue({ + hasProposal: () => false, + proposer, + }); + stub(validatorsRepository, "getValidator").returnValue(validator); + stub(validatorSet, "getValidatorIndexByWalletAddress").returnValue(1); + + const lockProof = { + signature: "signature", + validators: [], + }; + + // A valid value as restored from consensus storage: the proposal payload is still + // serialized, so the block only becomes available once it is deserialized. + let isDataDeserialized = false; + const spyProposalDeserializePayload = stub(proposal, "deserializePayload").callsFake(() => { + isDataDeserialized = true; + }); + const spyRoundStateAggregatePrevotes = stub(roundState, "aggregatePrevotes").returnValue(lockProof); + const spyRoundStateGetBlock = stub(roundState, "getBlock").callsFake(() => { + if (!isDataDeserialized) { + throw new Error("Block is not available, because proposal is not set or deserialized"); + } + + return block; + }); + + consensus.setValidRound(roundState); + await consensus.startRound(1); + await consensus.onTimeoutStartRound(); + + spyProposalDeserializePayload.calledOnce(); + spyRoundStateGetBlock.calledOnce(); + spyRoundStateAggregatePrevotes.calledOnce(); + spyForgerForgeBlock.neverCalled(); + + spyValidatorPropose.calledOnce(); + spyValidatorPropose.calledWith(1, 1, 0, block, lockProof); // validator index, round, validRound, block, lockProof + + spyProposalProcess.calledOnce(); + spyProposalProcess.calledWith(proposal); + }); + it("#onTimeoutStartRound - should propose if proposal is ready", async ({ consensus, proposalProcessor, From 33cdb85bc4a70c445fc90178cbf5e0beffe5f79a Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:02:00 +0900 Subject: [PATCH 5/8] prevote the locked block even when the proof is older --- packages/consensus/source/consensus.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/consensus/source/consensus.ts b/packages/consensus/source/consensus.ts index 306a33050..ad96545da 100644 --- a/packages/consensus/source/consensus.ts +++ b/packages/consensus/source/consensus.ts @@ -280,10 +280,9 @@ export class Consensus implements Contracts.Consensus.Service { this.logger.info(`Received proposal ${this.#getBlockString(proposal.blockHeader)}`, "consensus"); await this.eventDispatcher.dispatch(Events.ConsensusEvent.ProposalAccepted, this.getState()); - // A validator locked on a block must not prevote a different one + // A validator locked on a block must not prevote a different one. const isNotLockedOnAnotherBlock = - this.#lockedValue === undefined || - this.#lockedValue.getProposal()?.blockHeader.hash === proposal.blockHeader.hash; + this.#lockedValue === undefined || this.#isLockedOnBlock(proposal.blockHeader.hash); await this.prevote( roundState.getProcessorResult().success && isNotLockedOnAnotherBlock @@ -313,7 +312,10 @@ export class Consensus implements Contracts.Consensus.Service { const lockedRound = this.getLockedRound(); - if ((!lockedRound || lockedRound <= proposal.validRound) && roundState.getProcessorResult().success) { + if ( + (!lockedRound || lockedRound <= proposal.validRound || this.#isLockedOnBlock(proposal.blockHeader.hash)) && + roundState.getProcessorResult().success + ) { await this.prevote(proposal.blockHeader.hash); } else { await this.prevote(); @@ -497,6 +499,10 @@ export class Consensus implements Contracts.Consensus.Service { }); } + #isLockedOnBlock(hash: string): boolean { + return this.#lockedValue?.getProposal()?.blockHeader.hash === hash; + } + #isInvalidRoundState(roundState: Contracts.Processor.ProcessableUnit): boolean { if (roundState.blockNumber !== this.#blockNumber) { return true; From 53747e3c79c3fdeebc4c8d67982b3d2801f6590d Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:02:53 +0900 Subject: [PATCH 6/8] prevote lock proof tests --- packages/consensus/source/consensus.test.ts | 166 +++++++++++++++++++- 1 file changed, 165 insertions(+), 1 deletion(-) diff --git a/packages/consensus/source/consensus.test.ts b/packages/consensus/source/consensus.test.ts index 501a3a79d..6f2919b08 100644 --- a/packages/consensus/source/consensus.test.ts +++ b/packages/consensus/source/consensus.test.ts @@ -1186,7 +1186,171 @@ describe("Consensus", ({ it, beforeEach, assert, stub, spy, clock, each assert.equal(consensus.getStep(), Enums.Consensus.Step.Prevote); }); - it("#onProposalLocked - broadcast prevote null, if block is valid and lockedRound is higher than validRound", async () => {}); + it("#onProposalLocked - broadcast prevote block hash, if locked on the proposed block and lockedRound is higher than validRound", async ({ + consensus, + validatorSet, + validatorsRepository, + messageProcessor, + roundState, + block, + proposal, + proposer, + }) => { + stub(roundState, "getProcessorResult").returnValue({ success: true }); + stub(validatorSet, "getRoundValidators").returnValue([proposer]); + + // Lock on the block at round 2 (the validator repository still returns undefined here, so + // no precommit is signed while locking). + consensus.setRound(2); + consensus.setStep(Enums.Consensus.Step.Prevote); + await consensus.onMajorityPrevote({ ...roundState, round: 2 }); + assert.equal(consensus.getLockedRound(), 2); + + // The same block is re-proposed at round 3, proven by prevotes from round 1 - older than + // the round this validator locked in. + proposal.lockProof = { signature: "1234", validators: [] }; + proposal.validRound = 1; + + const prevote = { + blockNumber: 1, + round: 3, + }; + + const validator = { + prevote: () => {}, + }; + const spyValidatorPrevote = stub(validator, "prevote").resolvedValue(prevote); + + stub(validatorsRepository, "getValidator").returnValue(validator); + const spyMessageProcess = spy(messageProcessor, "process"); + stub(validatorSet, "getValidatorIndexByWalletAddress").returnValue(1); + + consensus.setRound(3); + consensus.setStep(Enums.Consensus.Step.Propose); + await consensus.onProposalLocked({ ...roundState, round: 3 }); + + spyValidatorPrevote.calledOnce(); + spyValidatorPrevote.calledWith(1, 1, 3, block.hash); // locked on the proposed block: prevote it + + spyMessageProcess.calledOnce(); + spyMessageProcess.calledWith(prevote); + }); + + it("#onProposalLocked - broadcast prevote null, if locked on another block and lockedRound is higher than validRound", async ({ + consensus, + validatorSet, + validatorsRepository, + messageProcessor, + roundState, + proposer, + }) => { + stub(roundState, "getProcessorResult").returnValue({ success: true }); + stub(validatorSet, "getRoundValidators").returnValue([proposer]); + + // Lock on the round-0 block at round 2. + consensus.setRound(2); + consensus.setStep(Enums.Consensus.Step.Prevote); + await consensus.onMajorityPrevote({ ...roundState, round: 2 }); + assert.equal(consensus.getLockedRound(), 2); + + // A different block is proposed at round 3, proven by prevotes from round 1. + const anotherBlock = { + hash: "anotherBlockHash", + number: 1, + round: 3, + }; + const anotherProposal = { + blockHeader: anotherBlock, + getData: () => ({ block: anotherBlock }), + lockProof: { signature: "1234", validators: [] }, + round: 3, + validRound: 1, + }; + const anotherRoundState = { + blockNumber: 1, + getProcessorResult: () => ({ success: true }), + getProposal: () => anotherProposal, + round: 3, + } as unknown as Contracts.Consensus.RoundState; + + const prevote = { + blockNumber: 1, + round: 3, + }; + + const validator = { + prevote: () => {}, + }; + const spyValidatorPrevote = stub(validator, "prevote").resolvedValue(prevote); + + stub(validatorsRepository, "getValidator").returnValue(validator); + const spyMessageProcess = spy(messageProcessor, "process"); + stub(validatorSet, "getValidatorIndexByWalletAddress").returnValue(1); + + consensus.setRound(3); + consensus.setStep(Enums.Consensus.Step.Propose); + await consensus.onProposalLocked(anotherRoundState); + + spyValidatorPrevote.calledOnce(); + spyValidatorPrevote.calledWith(1, 1, 3, undefined); // locked on another block: nil prevote + + spyMessageProcess.calledOnce(); + spyMessageProcess.calledWith(prevote); + }); + + it("#onProposalLocked - broadcast prevote null, if locked on the proposed block but the block is invalid", async ({ + consensus, + validatorSet, + validatorsRepository, + messageProcessor, + roundState, + proposal, + proposer, + }) => { + stub(roundState, "getProcessorResult").returnValue({ success: true }); + stub(validatorSet, "getRoundValidators").returnValue([proposer]); + + // Lock on the block at round 2. + consensus.setRound(2); + consensus.setStep(Enums.Consensus.Step.Prevote); + await consensus.onMajorityPrevote({ ...roundState, round: 2 }); + assert.equal(consensus.getLockedRound(), 2); + + // The same block is re-proposed at round 3, but this time it fails processing. + proposal.lockProof = { signature: "1234", validators: [] }; + proposal.validRound = 1; + + const invalidRoundState = { + blockNumber: 1, + getProcessorResult: () => ({ success: false }), + getProposal: () => proposal, + round: 3, + } as unknown as Contracts.Consensus.RoundState; + + const prevote = { + blockNumber: 1, + round: 3, + }; + + const validator = { + prevote: () => {}, + }; + const spyValidatorPrevote = stub(validator, "prevote").resolvedValue(prevote); + + stub(validatorsRepository, "getValidator").returnValue(validator); + const spyMessageProcess = spy(messageProcessor, "process"); + stub(validatorSet, "getValidatorIndexByWalletAddress").returnValue(1); + + consensus.setRound(3); + consensus.setStep(Enums.Consensus.Step.Propose); + await consensus.onProposalLocked(invalidRoundState); + + spyValidatorPrevote.calledOnce(); + spyValidatorPrevote.calledWith(1, 1, 3, undefined); // the lock never overrides an invalid block + + spyMessageProcess.calledOnce(); + spyMessageProcess.calledWith(prevote); + }); it("#onProposalLocked - should return if step === prevote", async ({ consensus, roundState, proposal }) => { proposal.validRound = 0; From 1bb461e65a81bd6f7d52c98af993c386150c9cdd Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:10:05 +0900 Subject: [PATCH 7/8] add TODOs --- packages/consensus/source/consensus.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/consensus/source/consensus.ts b/packages/consensus/source/consensus.ts index ad96545da..e0b632657 100644 --- a/packages/consensus/source/consensus.ts +++ b/packages/consensus/source/consensus.ts @@ -543,6 +543,7 @@ export class Consensus implements Contracts.Consensus.Service { return undefined; } + // TODO: rethrowing rejects the unawaited #proposalPromise, killing the node without dispose. throw error; } } @@ -554,7 +555,11 @@ export class Consensus implements Contracts.Consensus.Service { if (this.#validValue) { // A valid value restored from consensus storage still holds a serialized payload, so the // block has to be deserialized before it can be re-proposed. Deserializing is idempotent. - await this.#validValue.getProposal()?.deserializePayload(); + const validProposal = this.#validValue.getProposal(); + // TODO: reject a proposal-less valid value at restore (bootstrapper.ts), so corrupt + // consensus state terminates on boot instead of here, mid-round. + assert.defined(validProposal); + await validProposal.deserializePayload(); this.#proposedBlock = this.#validValue.getBlock(); const lockProof = await this.#validValue.aggregatePrevotes(); @@ -661,6 +666,8 @@ export class Consensus implements Contracts.Consensus.Service { if (state) { if (state.blockNumber === this.#blockNumber) { + // TODO: run() calls startRound() next, which overwrites this with Propose. Decide + // whether the restored step should survive, or stop persisting it. this.#step = state.step; this.#round = state.round; this.#lockedValue = state.lockedValue; From da08d4c7276605af425dcb6e5980cca25d213b14 Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:12:52 +0900 Subject: [PATCH 8/8] functional tests for lock rules --- .../functional/consensus/source/lock.test.ts | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 tests/functional/consensus/source/lock.test.ts diff --git a/tests/functional/consensus/source/lock.test.ts b/tests/functional/consensus/source/lock.test.ts new file mode 100644 index 000000000..548468750 --- /dev/null +++ b/tests/functional/consensus/source/lock.test.ts @@ -0,0 +1,180 @@ +import { Consensus } from "@mainsail/consensus/distribution/consensus.js"; +import { Enums, Identifiers } from "@mainsail/constants"; +import { describe } from "@mainsail/test-runner"; +import { sleep } from "@mainsail/utils"; + +import crypto from "../config/crypto.json" with { type: "json" }; +import validators from "../config/validators.json" with { type: "json" }; +import { assertBlockHash, assertBlockNumber, assertBlockRound, assertCommitRound } from "./asserts.js"; +import { Validator } from "./contracts.js"; +import { P2PRegistry } from "./p2p.js"; +import { bootMany, bootstrapMany, runMany, setup, stopMany } from "./setup.js"; +import { getValidators, makePrecommit, prepareNodeValidators, snoozeForBlock, snoozeForRound } from "./utilities.js"; +import type { Contracts } from "@mainsail/contracts"; + +describe<{ + nodes: Contracts.Kernel.Application[]; + validators: Validator[]; + p2p: P2PRegistry; +}>("Lock", ({ beforeEach, afterEach, it, assert, stub }) => { + const totalNodes = 5; + + beforeEach(async (context) => { + context.p2p = new P2PRegistry(); + + context.nodes = []; + for (let index = 0; index < totalNodes; index++) { + context.nodes.push( + await setup(index, context.p2p, crypto, prepareNodeValidators(validators, index, totalNodes)), + ); + } + + await bootMany(context.nodes); + await bootstrapMany(context.nodes); + + context.validators = await getValidators(context.nodes[0], validators); + }); + + afterEach(async ({ nodes }) => { + await stopMany(nodes); + }); + + // Makes a node ignore the prevotes of every other validator in a single round, so it never + // reaches +2/3 there and therefore neither locks nor updates its valid value in that round. + const ignoreForeignPrevotes = ( + node: Contracts.Kernel.Application, + ownValidatorIndex: number, + round: number, + stubber: typeof stub, + ) => { + const messageProcessor = node.get( + Identifiers.Consensus.Processor.Message, + ); + const process = messageProcessor.process.bind(messageProcessor); + + stubber(messageProcessor, "process").callsFake(async (...arguments_: unknown[]) => { + const message = arguments_[0] as Contracts.Crypto.Message; + + if ( + message.type === Enums.Crypto.MessageType.Prevote && + message.round === round && + message.validatorIndex !== ownValidatorIndex + ) { + return Enums.Consensus.ProcessorResult.Skipped; + } + + return process(message, arguments_[1] as boolean | undefined); + }); + }; + + // Replaces a node's precommit with a null precommit while it is below `untilRound`, so the round + // still gathers +2/3 precommits for *something* - which keeps rounds advancing - but never +2/3 + // for the block, so no commit happens and the lock survives into the next round. + const precommitNullUntilRound = ( + node: Contracts.Kernel.Application, + validator: Validator, + untilRound: number, + p2p: P2PRegistry, + stubber: typeof stub, + ) => { + const consensus = node.get(Identifiers.Consensus.Service); + const precommit = consensus.precommit.bind(consensus); + + stubber(consensus, "precommit").callsFake(async (...arguments_: unknown[]) => { + const round = consensus.getRound(); + + if (round < untilRound) { + await p2p.broadcastMessage(await makePrecommit(node, validator, 1, round)); + return; + } + + await precommit(arguments_[0] as string | undefined); + }); + }; + + it("#onProposal - should prevote null for a fresh proposal, when locked on another block", async ({ + nodes, + validators, + p2p, + }) => { + // Node 0 is the only proposer, since the harness pins the proposer index to 0. Make it drop + // the other validators' round-0 prevotes so that it never locks: it then has no valid value + // to re-propose and forges a *fresh* block in round 1, while nodes 1-4 are locked on round 0's. + ignoreForeignPrevotes(nodes[0], 0, 0, stub); + + // 3 of 5 precommits for the round-0 block is below +2/3, so round 0 fails and the lock holds. + precommitNullUntilRound(nodes[4], validators[4], 1, p2p, stub); + + await runMany(nodes); + await snoozeForRound(nodes, 1); + + // Round 1 ends either way: honouring the lock it fails on null prevotes and round 2 starts, + // ignoring the lock the conflicting block would reach +2/3 and commit instead. + await Promise.race([snoozeForRound(nodes, 2), sleep(3000)]); + + const round0Proposal = p2p.proposals.getMessages(1, 0)[0]; + const round1Proposal = p2p.proposals.getMessages(1, 1)[0]; + assert.defined(round0Proposal); + assert.defined(round1Proposal); + + // Round 1 really is a fresh proposal, for a different block. + assert.undefined(round1Proposal.validRound); + assert.not.equal(round1Proposal.blockHeader.hash, round0Proposal.blockHeader.hash); + + // The four locked nodes prevote null; only the unlocked proposer prevotes the new block. + const round1Prevotes = p2p.prevotes.getMessages(1, 1); + assert.equal(round1Prevotes.length, totalNodes); + assert.equal( + round1Prevotes.map((prevote) => prevote.blockHash).sort(), + [round1Proposal.blockHeader.hash, undefined, undefined, undefined, undefined].sort(), + ); + + // The conflicting block is never committed - the chain is still on the genesis block. + await assertBlockNumber(nodes, 0); + }); + + it("#onProposalLocked - should prevote the locked block, when the lock proof is older than the locked round", async ({ + nodes, + validators, + p2p, + }) => { + // Node 0 proposes every round. Dropping the other validators' round-1 prevotes keeps its + // valid value at round 0, so in round 2 it re-proposes the block with validRound 0 while + // nodes 1-4 have re-locked that same block at round 1 - i.e. lockedRound > validRound. + ignoreForeignPrevotes(nodes[0], 0, 1, stub); + + // Hold rounds 0 and 1 below the precommit majority so the chain reaches round 2. + precommitNullUntilRound(nodes[3], validators[3], 2, p2p, stub); + precommitNullUntilRound(nodes[4], validators[4], 2, p2p, stub); + + await runMany(nodes); + + const committed = await Promise.race([snoozeForBlock(nodes).then(() => true), sleep(10_000).then(() => false)]); + + // Fails when a locked validator refuses to prevote the very block it is locked on: the round + // then dies on null prevotes and the proposer keeps re-proposing with the same older proof. + assert.true(committed); + + // The very same block is proposed in every round, always proven by the round-0 prevotes. + const round0Proposal = p2p.proposals.getMessages(1, 0)[0]; + for (const round of [1, 2]) { + const proposal = p2p.proposals.getMessages(1, round)[0]; + assert.defined(proposal); + assert.equal(proposal.validRound, 0); + assert.equal(proposal.blockHeader.hash, round0Proposal.blockHeader.hash); + } + + // Round 2: every node prevotes the block it is locked on, despite the older lock proof. + const round2Prevotes = p2p.prevotes.getMessages(1, 2); + assert.equal(round2Prevotes.length, totalNodes); + for (const prevote of round2Prevotes) { + assert.equal(prevote.blockHash, round0Proposal.blockHeader.hash); + } + + // So the round-0 block commits in round 2. + await assertBlockNumber(nodes, 1); + await assertBlockRound(nodes, 0); + await assertCommitRound(nodes, 2); + await assertBlockHash(nodes, round0Proposal.blockHeader.hash); + }); +});