Skip to content

Commit

Permalink
bug: half the committee members casting ballots is not a quorum (Agor…
Browse files Browse the repository at this point in the history
…ic#10306)

closes: Agoric#10274

## Description

Half the EC were able to pass a motion, when a majority should have been required.

### Security Considerations

Ordinary vote counting issue.

### Scaling Considerations

N/A

### Documentation Considerations

None

### Testing Considerations

Added tests for committee.

### Upgrade Considerations

The change is in the committee code. This needs to be merged before the proposal in Agoric#10164 runs. It [already installs new committee code](https://github.com/Agoric/agoric-sdk/blob/4a33accaeeba27044ab07dd04f64226de1b77759/packages/builders/scripts/inter-protocol/replace-electorate-core.js#L28).
  • Loading branch information
mergify[bot] authored Oct 24, 2024
2 parents 1cb7915 + b1a98fa commit 823c8d1
Show file tree
Hide file tree
Showing 2 changed files with 171 additions and 7 deletions.
5 changes: 1 addition & 4 deletions packages/governance/src/committee.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<Invitation<{ voter: { castBallotFor(handle: any, choice?: any, ): void}}>>[]
Expand Down Expand Up @@ -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:
Expand Down
173 changes: 170 additions & 3 deletions packages/governance/test/unitTests/committee.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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,
{},
Expand All @@ -56,7 +57,12 @@ const setupContract = async () => {
},
);

return { counterInstallation, electorateStartResult, mockChainStorageRoot };
return {
counterInstallation,
electorateStartResult,
mockChainStorageRoot,
zoe,
};
};

test('committee-open no questions', async t => {
Expand Down Expand Up @@ -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',
}),
);
});

0 comments on commit 823c8d1

Please sign in to comment.