From b1a98fab9a4b95589458e6da3a3c836e18005581 Mon Sep 17 00:00:00 2001 From: Chris Hibbert Date: Mon, 21 Oct 2024 17:58:29 -0700 Subject: [PATCH] bug: half the committee members casting ballots is not a quorum --- packages/governance/src/committee.js | 5 +- .../test/unitTests/committee.test.js | 173 +++++++++++++++++- 2 files changed, 171 insertions(+), 7 deletions(-) diff --git a/packages/governance/src/committee.js b/packages/governance/src/committee.js index 8eb967d75fb..78441d926ae 100644 --- a/packages/governance/src/committee.js +++ b/packages/governance/src/committee.js @@ -1,6 +1,5 @@ import { makeStoredPublishKit } from '@agoric/notifier'; import { M } from '@agoric/store'; -import { natSafeMath } from '@agoric/zoe/src/contractSupport/index.js'; import { E } from '@endo/eventual-send'; import { StorageNodeShape } from '@agoric/internal'; @@ -20,8 +19,6 @@ import { prepareVoterKit } from './voterKit.js'; * @import {ElectorateCreatorFacet, CommitteeElectoratePublic, QuestionDetails, OutcomeRecord, AddQuestion} from './types.js'; */ -const { ceilDivide } = natSafeMath; - /** * @typedef { ElectorateCreatorFacet & { * getVoterInvitations: () => Promise>[] @@ -139,7 +136,7 @@ export const start = (zcf, privateArgs, baggage) => { const quorumThreshold = quorumRule => { switch (quorumRule) { case QuorumRule.MAJORITY: - return ceilDivide(committeeSize, 2); + return Math.ceil((committeeSize + 1) / 2); case QuorumRule.ALL: return committeeSize; case QuorumRule.NO_QUORUM: diff --git a/packages/governance/test/unitTests/committee.test.js b/packages/governance/test/unitTests/committee.test.js index 5e50c9d300e..39ff48d7832 100644 --- a/packages/governance/test/unitTests/committee.test.js +++ b/packages/governance/test/unitTests/committee.test.js @@ -27,7 +27,9 @@ const dirname = path.dirname(new URL(import.meta.url).pathname); const electorateRoot = `${dirname}/../../src/committee.js`; const counterRoot = `${dirname}/../../src/binaryVoteCounter.js`; -const setupContract = async () => { +const setupContract = async ( + terms = { committeeName: 'illuminati', committeeSize: 13 }, +) => { const zoe = makeZoeForTest(); const mockChainStorageRoot = makeMockChainStorageRoot(); @@ -45,7 +47,6 @@ const setupContract = async () => { E(zoe).install(electorateBundle), E(zoe).install(counterBundle), ]); - const terms = { committeeName: 'illuminati', committeeSize: 13 }; const electorateStartResult = await E(zoe).startInstance( electorateInstallation, {}, @@ -56,7 +57,12 @@ const setupContract = async () => { }, ); - return { counterInstallation, electorateStartResult, mockChainStorageRoot }; + return { + counterInstallation, + electorateStartResult, + mockChainStorageRoot, + zoe, + }; }; test('committee-open no questions', async t => { @@ -233,3 +239,164 @@ test('committee-open question:mixed, with snapshot', async t => { }; await documentStorageSchema(t, mockChainStorageRoot, doc); }); + +const setUpVoterAndVote = async (invitation, zoe, qHandle, choice) => { + const seat = E(zoe).offer(invitation); + const { voter } = E.get(E(seat).getOfferResult()); + return E(voter).castBallotFor(qHandle, [choice]); +}; + +test('committee-tie outcome', async t => { + const { + electorateStartResult: { creatorFacet }, + counterInstallation: counter, + zoe, + } = await setupContract({ committeeName: 'halfDozen', committeeSize: 6 }); + + const timer = buildZoeManualTimer(t.log); + + const positions = [harden({ text: 'guilty' }), harden({ text: 'innocent' })]; + const questionSpec = coerceQuestionSpec( + harden({ + method: ChoiceMethod.UNRANKED, + issue: { text: 'guilt' }, + positions, + electionType: ElectionType.SURVEY, + maxChoices: 1, + maxWinners: 1, + closingRule: { + timer, + deadline: 2n, + }, + quorumRule: QuorumRule.MAJORITY, + tieOutcome: positions[1], + }), + ); + + const qResult = await E(creatorFacet).addQuestion(counter, questionSpec); + + const invites = await E(creatorFacet).getVoterInvitations(); + const votes = []; + for (const i of [...Array(6).keys()]) { + votes.push( + setUpVoterAndVote( + invites[i], + zoe, + qResult.questionHandle, + positions[i % 2], + ), + ); + } + + await Promise.all(votes); + await E(timer).tick(); + await E(timer).tick(); + + // if half vote each way, the tieOutcome prevails + await E.when(E(qResult.publicFacet).getOutcome(), async outcomes => + t.deepEqual(outcomes, { + text: 'innocent', + }), + ); +}); + +test('committee-half vote', async t => { + const { + electorateStartResult: { creatorFacet }, + counterInstallation: counter, + zoe, + } = await setupContract({ committeeName: 'halfDozen', committeeSize: 6 }); + + const timer = buildZoeManualTimer(t.log); + + const positions = [harden({ text: 'guilty' }), harden({ text: 'innocent' })]; + const questionSpec = coerceQuestionSpec( + harden({ + method: ChoiceMethod.UNRANKED, + issue: { text: 'guilt' }, + positions, + electionType: ElectionType.SURVEY, + maxChoices: 1, + maxWinners: 1, + closingRule: { + timer, + deadline: 2n, + }, + quorumRule: QuorumRule.MAJORITY, + tieOutcome: positions[1], + }), + ); + + const qResult = await E(creatorFacet).addQuestion(counter, questionSpec); + + const invites = await E(creatorFacet).getVoterInvitations(); + const votes = []; + for (const i of [...Array(3).keys()]) { + votes.push( + setUpVoterAndVote(invites[i], zoe, qResult.questionHandle, positions[0]), + ); + } + + await Promise.all(votes); + await E(timer).tick(); + await E(timer).tick(); + + // if only half the voters vote, there is no quorum + await E.when( + E(qResult.publicFacet).getOutcome(), + async _outcomes => { + t.fail('expect no quorum'); + }, + e => { + t.is(e, 'No quorum'); + }, + ); +}); + +test('committee-half plus one vote', async t => { + const { + electorateStartResult: { creatorFacet }, + counterInstallation: counter, + zoe, + } = await setupContract({ committeeName: 'halfDozen', committeeSize: 6 }); + + const timer = buildZoeManualTimer(t.log); + + const positions = [harden({ text: 'guilty' }), harden({ text: 'innocent' })]; + const questionSpec = coerceQuestionSpec( + harden({ + method: ChoiceMethod.UNRANKED, + issue: { text: 'guilt' }, + positions, + electionType: ElectionType.SURVEY, + maxChoices: 1, + maxWinners: 1, + closingRule: { + timer, + deadline: 2n, + }, + quorumRule: QuorumRule.MAJORITY, + tieOutcome: positions[1], + }), + ); + + const qResult = await E(creatorFacet).addQuestion(counter, questionSpec); + + const invites = await E(creatorFacet).getVoterInvitations(); + const votes = []; + for (const i of [...Array(4).keys()]) { + votes.push( + setUpVoterAndVote(invites[i], zoe, qResult.questionHandle, positions[0]), + ); + } + + await Promise.all(votes); + await E(timer).tick(); + await E(timer).tick(); + + await E.when(E(qResult.publicFacet).getOutcome(), async outcomes => + t.deepEqual(outcomes, { + text: 'guilty', + }), + ); +});