From 9b169f3c9235fd7026fd768433ea63bdc1524a0c Mon Sep 17 00:00:00 2001 From: HusseinAdeiza Date: Sun, 27 Sep 2026 01:12:29 +0200 Subject: [PATCH] fix(voting): report unanimous timeout and violation votes accurately The unanimity short circuit in getMajorityVote only branched on Agree and sent every other unanimous vote to MajorityDisagree. A round where all validators timed out, or all reported a deterministic violation, was therefore recorded as MajorityDisagree, which drives the wrong fee and validator rotation handling downstream. The Python mirror of the same decision procedure, determine_consensus_from_votes in backend/consensus/utils.py, already gets this right: it checks timeout_count against the majority threshold and returns ConsensusResult.TIMEOUT. The Solidity has an extra unanimity short circuit the Python does not, and that short circuit is what drops the Timeout and DeterministicViolation outcomes. This is therefore a bug fix rather than a consensus rule change, and the two implementations now agree. Unanimous Agree still returns MajorityAgree and unanimous Disagree still returns MajorityDisagree, unchanged. Those two match the existing behavior and the Python mirror, which maps agree_count and effective_disagree above the majority to MAJORITY_AGREE and MAJORITY_DISAGREE. A unanimous NotVoted vote also keeps its existing MajorityDisagree result, so no other VoteType variant changes result as a side effect. Refs #1769 --- .../v2_contracts/transactions/Voting.sol | 6 + .../voting/001_voting_majority_vote.test.js | 116 ++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 hardhat/test/voting/001_voting_majority_vote.test.js diff --git a/hardhat/contracts/v2_contracts/transactions/Voting.sol b/hardhat/contracts/v2_contracts/transactions/Voting.sol index c4f91a37d..798bdec7a 100644 --- a/hardhat/contracts/v2_contracts/transactions/Voting.sol +++ b/hardhat/contracts/v2_contracts/transactions/Voting.sol @@ -27,6 +27,12 @@ contract Voting { if (maxVotes == validatorCount) { if (majorityVote == ITransactions.VoteType.Agree) { result = ITransactions.ResultType.MajorityAgree; + } else if (majorityVote == ITransactions.VoteType.Timeout) { + result = ITransactions.ResultType.Timeout; + } else if ( + majorityVote == ITransactions.VoteType.DeterministicViolation + ) { + result = ITransactions.ResultType.DeterministicViolation; } else { result = ITransactions.ResultType.MajorityDisagree; } diff --git a/hardhat/test/voting/001_voting_majority_vote.test.js b/hardhat/test/voting/001_voting_majority_vote.test.js new file mode 100644 index 000000000..51005240c --- /dev/null +++ b/hardhat/test/voting/001_voting_majority_vote.test.js @@ -0,0 +1,116 @@ +const { expect } = require("chai"); +const { ethers } = require("hardhat"); + +const VoteType = { + NotVoted: 0, + Agree: 1, + Disagree: 2, + Timeout: 3, + DeterministicViolation: 4, +}; + +const ResultType = { + Idle: 0, + Agree: 1, + Disagree: 2, + Timeout: 3, + DeterministicViolation: 4, + NoMajority: 5, + MajorityAgree: 6, + MajorityDisagree: 7, +}; + +const resultNames = Object.fromEntries( + Object.entries(ResultType).map(([name, value]) => [value, name]) +); + +function roundData(votes) { + return { + round: 1, + leaderIndex: 0, + votesCommitted: votes.length, + votesRevealed: votes.length, + appealBond: 0, + rotationsLeft: 0, + result: ResultType.Idle, + roundValidators: votes.map(() => ethers.ZeroAddress), + validatorVotesHash: votes.map(() => ethers.ZeroHash), + validatorVotes: votes, + }; +} + +describe("Voting.getMajorityVote", function () { + let voting; + + before(async function () { + const factory = await ethers.getContractFactory("Voting"); + voting = await factory.deploy(); + await voting.waitForDeployment(); + }); + + it("returns Timeout for a unanimous timeout vote", async function () { + const votes = Array(5).fill(VoteType.Timeout); + const result = await voting.getMajorityVote(roundData(votes)); + expect(resultNames[Number(result)]).to.equal("Timeout"); + }); + + it("returns DeterministicViolation for a unanimous violation vote", async function () { + const votes = Array(5).fill(VoteType.DeterministicViolation); + const result = await voting.getMajorityVote(roundData(votes)); + expect(resultNames[Number(result)]).to.equal("DeterministicViolation"); + }); + + it("still returns MajorityAgree for a unanimous agree vote", async function () { + const votes = Array(5).fill(VoteType.Agree); + const result = await voting.getMajorityVote(roundData(votes)); + expect(resultNames[Number(result)]).to.equal("MajorityAgree"); + }); + + it("still returns MajorityDisagree for a unanimous disagree vote", async function () { + const votes = Array(5).fill(VoteType.Disagree); + const result = await voting.getMajorityVote(roundData(votes)); + expect(resultNames[Number(result)]).to.equal("MajorityDisagree"); + }); + + it("still returns MajorityDisagree for a unanimous not-voted vote", async function () { + const votes = Array(5).fill(VoteType.NotVoted); + const result = await voting.getMajorityVote(roundData(votes)); + expect(resultNames[Number(result)]).to.equal("MajorityDisagree"); + }); + + it("returns Timeout for a non-unanimous majority timeout vote", async function () { + const votes = [ + VoteType.Timeout, + VoteType.Timeout, + VoteType.Timeout, + VoteType.Agree, + VoteType.Disagree, + ]; + const result = await voting.getMajorityVote(roundData(votes)); + expect(resultNames[Number(result)]).to.equal("Timeout"); + }); + + it("returns DeterministicViolation for a non-unanimous majority violation vote", async function () { + const votes = [ + VoteType.DeterministicViolation, + VoteType.DeterministicViolation, + VoteType.DeterministicViolation, + VoteType.Agree, + VoteType.Disagree, + ]; + const result = await voting.getMajorityVote(roundData(votes)); + expect(resultNames[Number(result)]).to.equal("DeterministicViolation"); + }); + + it("returns NoMajority when no vote type reaches a majority", async function () { + const votes = [ + VoteType.Agree, + VoteType.Agree, + VoteType.Disagree, + VoteType.Disagree, + VoteType.Timeout, + ]; + const result = await voting.getMajorityVote(roundData(votes)); + expect(resultNames[Number(result)]).to.equal("NoMajority"); + }); +});