diff --git a/packages/governance/src/electedCommittee.js b/packages/governance/src/electedCommittee.js new file mode 100644 index 00000000000..780efea9d3b --- /dev/null +++ b/packages/governance/src/electedCommittee.js @@ -0,0 +1,223 @@ +import { makeStoredPublishKit } from '@agoric/notifier'; +import { M, makeHeapFarInstance, makeStore } from '@agoric/store'; +import { natSafeMath } from '@agoric/zoe/src/contractSupport/index.js'; +import { makeHandle } from '@agoric/zoe/src/makeHandle'; +import { E } from '@endo/eventual-send'; +import { + startCounter, + getPoserInvitation, + getOpenQuestions, + getQuestion, +} from './electorateTools'; +import { QuorumRule } from './question'; +import { + ElectorateCreatorI, + ElectoratePublicI, + PositionShape, + QuestionHandleShape, +} from './typeGuards'; + +const { ceilDivide } = natSafeMath; + +const quorumThreshold = (quorumRule, committeeSize) => { + switch (quorumRule) { + case QuorumRule.MAJORITY: + return ceilDivide(committeeSize, 2); + case QuorumRule.ALL: + return committeeSize; + case QuorumRule.NO_QUORUM: + return 0; + default: + throw Error(`${quorumRule} is not a recognized quorum rule`); + } +}; + +/** + * + * @param {*} zcf + * @param {*} privateArgs + */ +const start = (zcf, privateArgs) => { + assert(privateArgs?.storageNode, 'Missing storageNode'); + assert(privateArgs?.marshaller, 'Missing marshaller'); + + const questionNode = E(privateArgs.storageNode).makeChildNode( + 'latestQuestion', + ); + + const electionQuestionNode = E(privateArgs.storageNode).makeChildNode( + 'latestElectionQuestion', + ); + + /** @type {StoredPublishKit} */ + const { subscriber: questionsSubscriber, publisher: questionsPublisher } = + makeStoredPublishKit(questionNode, privateArgs.marshaller); + + /** @type {StoredPublishKit} */ + const { + subscriber: electionQuestionsSubscriber, + publisher: electionQuestionsPublisher, + } = makeStoredPublishKit(electionQuestionNode, privateArgs.marshaller); + + /** @type {Store, import('./electorateTools.js').QuestionRecord>} */ + const allQuestions = makeStore('Question'); + + /** @type {Store, import('./electorateTools.js').QuestionRecord>} */ + const allElectionQuestions = makeStore('ElectionQuestion'); + + const makeElectorInvitation = index => { + /** @type {OfferHandler} */ + const offerHandler = seat => { + const voterHandle = makeHandle('Voter'); + seat.exit(); + + const VoterI = M.interface('voter', { + castBallotFor: M.call( + QuestionHandleShape, + M.arrayOf(PositionShape), + ).returns(M.promise()), + }); + const InvitationMakerI = M.interface('invitationMaker', { + makeVoteInvitation: M.call( + M.arrayOf(PositionShape), + QuestionHandleShape, + ).returns(M.promise()), + }); + + return harden({ + voter: makeHeapFarInstance(`voter${index}`, VoterI, { + castBallotFor(questionHandle, positions) { + const { voteCap } = allQuestions.get(questionHandle); + return E(voteCap).submitVote(voterHandle, positions, 1n); + }, + }), + invitationMakers: makeHeapFarInstance( + 'invitation makers', + InvitationMakerI, + { + makeVoteInvitation(positions, questionHandle) { + const continuingVoteHandler = cSeat => { + cSeat.exit(); + const { voteCap } = allQuestions.get(questionHandle); + return E(voteCap).submitVote(voterHandle, positions, 1n); + }; + + return zcf.makeInvitation(continuingVoteHandler, 'vote'); + }, + }, + ), + }); + }; + + return zcf.makeInvitation(offerHandler, `Voter${index}`); + }; + + const { committeeName, committeeSize } = zcf.getTerms(); + + const electorateInvitations = harden( + [...Array(committeeSize).keys()].map(makeElectorInvitation), + ); + + const publicFacet = makeHeapFarInstance( + 'Committee publicFacet', + ElectoratePublicI, + { + getQuestionSubscriber() { + return questionsSubscriber; + }, + getElectionQuestionSubscriber() { + return electionQuestionsSubscriber; + }, + getOpenQuestions() { + return getOpenQuestions(allQuestions); + }, + getElectionQuestions() { + return getOpenQuestions(allElectionQuestions); + }, + getName() { + return committeeName; + }, + getInstance() { + return zcf.getInstance(); + }, + getElectionQuestion(handleP) { + return getQuestion(handleP, allElectionQuestions); + }, + getQuestion(handleP) { + return getQuestion(handleP, allQuestions); + }, + }, + ); + + const creatorFacet = makeHeapFarInstance( + 'Committee creatorFacet', + ElectorateCreatorI, + { + getPoserInvitation() { + return getPoserInvitation(zcf, async (voteCounter, questionSpec) => + creatorFacet.addQuestion(voteCounter, questionSpec), + ); + }, + startCommitteeElection(voteCounter, questionSpec) { + const outcomeNode = E(privateArgs.storageNode).makeChildNode( + 'latestElectionOutcome', + ); + + /** @type {StoredPublishKit} */ + const { publisher: outcomePublisher } = makeStoredPublishKit( + outcomeNode, + privateArgs.marshaller, + ); + + return startCounter( + zcf, + questionSpec, + quorumThreshold(questionSpec.quorumRule, committeeSize), + voteCounter, + allElectionQuestions, + electionQuestionsPublisher, + outcomePublisher, + ); + }, + /** @type {AddQuestion} */ + async addQuestion(voteCounter, questionSpec) { + const outcomeNode = E(privateArgs.storageNode).makeChildNode( + 'latestOutcome', + ); + + /** @type {StoredPublishKit} */ + const { publisher: outcomePublisher } = makeStoredPublishKit( + outcomeNode, + privateArgs.marshaller, + ); + + return startCounter( + zcf, + questionSpec, + quorumThreshold(questionSpec.quorumRule), + voteCounter, + allQuestions, + questionsPublisher, + outcomePublisher, + ); + }, + getVoterInvitations() { + return electorateInvitations; + }, + getQuestionSubscriber() { + return questionsSubscriber; + }, + getElectionQuestionSubscriber() { + return electionQuestionsSubscriber; + }, + getPublicFacet() { + return publicFacet; + }, + }, + ); + + return { publicFacet, creatorFacet }; +}; + +harden(start); +export { start }; diff --git a/packages/governance/src/question.js b/packages/governance/src/question.js index e55a1b8e0e2..7c6a95219b3 100644 --- a/packages/governance/src/question.js +++ b/packages/governance/src/question.js @@ -54,6 +54,7 @@ const coerceQuestionSpec = ({ closingRule, quorumRule, tieOutcome, + winOutcome, }) => { const question = harden({ method, @@ -65,6 +66,7 @@ const coerceQuestionSpec = ({ closingRule, quorumRule, tieOutcome, + winOutcome, }); fit(question, QuestionSpecShape); diff --git a/packages/governance/src/slateVoteCounter.js b/packages/governance/src/slateVoteCounter.js new file mode 100644 index 00000000000..0518495ddf7 --- /dev/null +++ b/packages/governance/src/slateVoteCounter.js @@ -0,0 +1,210 @@ +import { makePromiseKit } from '@endo/promise-kit'; +import { makeHeapFarInstance, keyEQ, makeStore } from '@agoric/store'; +import { E } from '@endo/eventual-send'; + +import { + buildQuestion, + ChoiceMethod, + coerceQuestionSpec, + positionIncluded, +} from './question.js'; +import { scheduleClose } from './closingRule.js'; +import { + VoteCounterAdminI, + VoteCounterCloseI, + VoteCounterPublicI, +} from './typeGuards.js'; +import { makeQuorumCounter } from './quorumCounter.js'; +import { q } from '@agoric/assert'; + +const { details: X } = assert; + +const validateSlateQuestionSpec = questionSpec => { + coerceQuestionSpec(questionSpec); + + questionSpec.maxChoices === 1 || + assert.fail(X`Can only choose 1 item on a question`); + questionSpec.winOutcome || + assert.fail(X`Must specify win outcome on question`); + questionSpec.method === ChoiceMethod.UNRANKED || + assert.fail(X`${questionSpec.method} must be UNRANKED`); + questionSpec.positions[0].text === 'yes' || + assert.fail(X`First position must be yes`); + questionSpec.positions[1].text === 'no' || + assert.fail(X`Second position must be no`); +}; + +/** @type {BuildVoteCounter} */ +const makeSlateVoteCounter = (questionSpec, threshold, instance, publisher) => { + validateSlateQuestionSpec(questionSpec); + + const question = buildQuestion(questionSpec, instance); + const details = question.getDetails(); + + let isOpen = true; + const positions = questionSpec.positions; + + /** @type { Position } */ + const winOutcome = details.winOutcome; + /** @type { PromiseRecord } */ + const outcomePromise = makePromiseKit(); + /** @type { PromiseRecord } */ + const tallyPromise = makePromiseKit(); + + /** + * @typedef {object} RecordedBallot + * @property {Position[]} chosen + * @property {bigint} shares + */ + /** @type {Store,RecordedBallot> } */ + const allBallots = makeStore('voterHandle'); + + const countVotes = () => { + assert(!isOpen, X`can't count votes while the election is open`); + + let spoiled = 0n; + const tally = [0n, 0n]; + + for (const { chosen } of allBallots.values()) { + const choice = positions.findIndex(p => keyEQ(p, chosen[0])); + if (choice < 0) { + spoiled += 1n; + } else { + tally[choice] += 1n; + } + } + + /** @type { VoteStatistics } */ + const stats = { + spoiled, + votes: allBallots.getSize(), + results: [ + { position: positions[0], total: tally[0] }, + { position: positions[1], total: tally[1] }, + ], + }; + + tallyPromise.resolve(stats); + + if (!makeQuorumCounter(threshold).check(stats)) { + outcomePromise.reject('No quorum'); + /** @type {OutcomeRecord} */ + const voteOutcome = { + question: details.questionHandle, + outcome: 'fail', + reason: 'No quorum', + }; + E(publisher).publish(voteOutcome); + return; + } + + if (tally[0] > tally[1]) { + outcomePromise.resolve(winOutcome); + } else { + outcomePromise.reject('Rejected'); + /** @type {OutcomeRecord} */ + const voteOutcome = { + question: details.questionHandle, + outcome: 'fail', + reason: 'Rejected', + }; + E(publisher).publish(voteOutcome); + return; + } + + E.when(outcomePromise.promise, position => { + /** @type {OutcomeRecord} */ + const voteOutcome = { + question: details.questionHandle, + position, + outcome: 'win', + }; + return E(publisher).publish(voteOutcome); + }); + }; + + const closeFacet = makeHeapFarInstance( + 'SlateVoteCounter close', + VoteCounterCloseI, + { + closeVoting() { + isOpen = false; + countVotes(); + }, + }, + ); + + const creatorFacet = makeHeapFarInstance( + 'SlateVoteCounter creator', + VoteCounterAdminI, + { + submitVote(voterHandle, chosen, shares = 1n) { + assert(chosen.length === 1, 'only 1 position allowed'); + const [position] = chosen; + positionIncluded(positions, position) || + assert.fail( + X`The specified choice is not a legal position: ${position}.`, + ); + + const completedBallot = harden({ chosen: [position], shares }); + allBallots.has(voterHandle) + ? allBallots.set(voterHandle, completedBallot) + : allBallots.init(voterHandle, completedBallot); + return completedBallot; + }, + }, + ); + + const publicFacet = makeHeapFarInstance( + 'SlateVoteCounter public', + VoteCounterPublicI, + { + getQuestion() { + return question; + }, + isOpen() { + return isOpen; + }, + getOutcome() { + return outcomePromise.promise; + }, + getStats() { + return tallyPromise.promise; + }, + getDetails() { + return details; + }, + getInstance() { + return instance; + }, + }, + ); + + return harden({ + creatorFacet, + publicFacet, + closeFacet, + }); +}; + +/** + * @param {ZCF<{questionSpec: QuestionSpec, quorumThreshold: bigint}>} zcf + * @param {{outcomePublisher: Publisher}} outcomePublisher + */ +const start = (zcf, { outcomePublisher }) => { + const { questionSpec, quorumThreshold } = zcf.getTerms(); + + const { publicFacet, creatorFacet, closeFacet } = makeSlateVoteCounter( + questionSpec, + quorumThreshold, + zcf.getInstance(), + outcomePublisher, + ); + + scheduleClose(questionSpec.closingRule, () => closeFacet.closeVoting()); + + return { publicFacet, creatorFacet }; +}; + +harden(makeSlateVoteCounter); +export { makeSlateVoteCounter, start }; diff --git a/packages/governance/src/typeGuards.js b/packages/governance/src/typeGuards.js index 3bce9615947..b07fb9a553b 100644 --- a/packages/governance/src/typeGuards.js +++ b/packages/governance/src/typeGuards.js @@ -128,6 +128,9 @@ export const SimplePositionsShape = harden([ YesSimplePositionShape, NoSimplePositionShape, ]); + +export const CandidateShape = M.arrayOf(M.any()); + export const SimpleIssueShape = SimpleSpecShape; export const SimpleQuestionSpecShape = harden({ method: ChoiceMethodShape, @@ -139,6 +142,7 @@ export const SimpleQuestionSpecShape = harden({ closingRule: ClosingRuleShape, quorumRule: QuorumRuleShape, tieOutcome: NoSimplePositionShape, + winOutcome: CandidateShape, }); export const SimpleQuestionDetailsShape = harden({ ...SimpleQuestionSpecShape, @@ -180,12 +184,17 @@ export const QuestionDetailsShape = M.or( SimpleQuestionDetailsShape, ); +const QuestionShape = M.remotable('Question'); + export const ElectoratePublicI = M.interface('Committee PublicFacet', { + getElectionQuestionSubscriber: M.call().returns(QuestionShape), getQuestionSubscriber: M.call().returns(SubscriberShape), getOpenQuestions: M.call().returns(M.promise()), + getElectionQuestions: M.call().returns(M.promise()), getName: M.call().returns(M.string()), getInstance: M.call().returns(InstanceHandleShape), getQuestion: M.call(QuestionHandleShape).returns(M.promise()), + getElectionQuestion: M.call(QuestionHandleShape).returns(M.promise()) }); const ElectoratePublicShape = M.remotable('ElectoratePublic'); @@ -194,8 +203,13 @@ export const ElectorateCreatorI = M.interface('Committee AdminFacet', { addQuestion: M.call(InstanceHandleShape, QuestionSpecShape).returns( M.promise(), ), + startCommitteeElection: M.call( + InstanceHandleShape, + QuestionSpecShape, + ).returns(M.promise()), getVoterInvitations: M.call().returns(M.arrayOf(M.promise())), getQuestionSubscriber: M.call().returns(SubscriberShape), + getElectionQuestionSubscriber: M.call().returns(QuestionShape), getPublicFacet: M.call().returns(ElectoratePublicShape), }); @@ -209,7 +223,6 @@ export const QuestionI = M.interface('Question', { getVoteCounter: M.call().returns(InstanceHandleShape), getDetails: M.call().returns(QuestionDetailsShape), }); -const QuestionShape = M.remotable('Question'); export const BinaryVoteCounterPublicI = M.interface( 'BinaryVoteCounter PublicFacet', diff --git a/packages/governance/src/types.js b/packages/governance/src/types.js index fb57a6680c4..761d19da668 100644 --- a/packages/governance/src/types.js +++ b/packages/governance/src/types.js @@ -83,15 +83,21 @@ * @property {string} text */ +/** + * @typedef {object} CandidatePosition + * @property {string} name + * @property {string} address + */ + /** * @typedef { TextPosition | ChangeParamsPosition | NoChangeParamsPosition | InvokeApiPosition | DontInvokeApiPosition | - * OfferFilterPosition | NoChangeOfferFilterPosition | InvokeApiPosition } Position + * OfferFilterPosition | NoChangeOfferFilterPosition | InvokeApiPosition | CandidatePosition[] } Position */ /** * @typedef {{ question: Handle<'Question'> } & ( * { outcome: 'win', position: Position } | - * { outcome: 'fail', reason: 'No quorum' } + * { outcome: 'fail', reason: 'No quorum' | 'Rejected' } * )} OutcomeRecord */ @@ -116,6 +122,7 @@ * @property {ClosingRule} closingRule * @property {QuorumRule} quorumRule * @property {Position} tieOutcome + * @property {Position} [winOutcome] */ /** diff --git a/packages/governance/test/unitTests/test-electedCommittee.js b/packages/governance/test/unitTests/test-electedCommittee.js new file mode 100644 index 00000000000..a18b364437d --- /dev/null +++ b/packages/governance/test/unitTests/test-electedCommittee.js @@ -0,0 +1,182 @@ +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import { E } from '@endo/eventual-send'; +import { makeMockChainStorageRoot } from '@agoric/vats/tools/storage-test-utils.js'; +import { makeZoeKit } from '@agoric/zoe'; +import { makeBoard } from '@agoric/vats/src/lib-board.js'; +import fakeVatAdmin from '@agoric/zoe/tools/fakeVatAdmin.js'; +import bundleSource from '@endo/bundle-source'; +import path from 'path'; +import { makeHandle } from '@agoric/zoe/src/makeHandle.js'; +import buildManualTimer from '@agoric/zoe/tools/manualTimer.js'; +import { eventLoopIteration } from '@agoric/notifier/tools/testSupports.js'; + +import { + ChoiceMethod, + ElectionType, + QuorumRule, + coerceQuestionSpec, +} from '../../src/index.js'; + +const filename = new URL(import.meta.url).pathname; +const dirname = path.dirname(filename); + +const electorateRoot = `${dirname}/../../src/electedCommittee.js`; +const slateCounterRoot = `${dirname}/../../src/slateVoteCounter.js`; +const counterRoot = `${dirname}/../../src/binaryVoteCounter.js`; + +const setupContract = async () => { + const { zoeService: zoe } = makeZoeKit(fakeVatAdmin); + + const mockChainStorageRoot = makeMockChainStorageRoot(); + + const [electorateBundle, counterBundle, slateCounterBundle] = + await Promise.all([ + bundleSource(electorateRoot), + bundleSource(counterRoot), + bundleSource(slateCounterRoot), + ]); + + const [ + electorateInstallation, + counterInstallation, + slateCounterInstallation, + ] = await Promise.all([ + E(zoe).install(electorateBundle), + E(zoe).install(counterBundle), + E(zoe).install(slateCounterBundle), + ]); + + const terms = { committeeName: 'illuminati', committeeSize: 5 }; + const electorateStartResult = await E(zoe).startInstance( + electorateInstallation, + {}, + terms, + { + storageNode: mockChainStorageRoot.makeChildNode('thisElectorate'), + marshaller: makeBoard().getReadonlyMarshaller(), + }, + ); + + return { + counterInstallation, + slateCounterInstallation, + electorateStartResult, + mockChainStorageRoot, + }; +}; + +test('committee-elected elect members', async t => { + const { + electorateStartResult: { creatorFacet, publicFacet }, + slateCounterInstallation, + mockChainStorageRoot, + } = await setupContract(); + + const timer = buildManualTimer(t.log); + + const candidates = [ + { name: 'Bob', address: 'agoric1ldmtatp24qlllgxmrsjzcpe20fvlkp448zcuce' }, + { name: 'Alice', address: 'agoric140dmkrz2e42ergjj7gyvejhzmjzurvqeq82ang' }, + { + name: 'Charlie', + address: 'agoric1w8wktaur4zf8qmmtn3n7x3r0jhsjkjntcm3u6h', + }, + ]; + + const positions = [harden({ text: 'yes' }), harden({ text: 'no' })]; + + const questionSpec = coerceQuestionSpec({ + method: ChoiceMethod.UNRANKED, + issue: harden({ text: 'Election for bakery committee members' }), + positions, + electionType: ElectionType.SURVEY, + maxChoices: 1, + maxWinners: 1, + closingRule: { + timer, + deadline: 1n, + }, + quorumRule: QuorumRule.NO_QUORUM, + tieOutcome: positions[1], + winOutcome: candidates, + }); + + const { publicFacet: counterPublicFacet, creatorFacet: counterCreatorFacet } = + await E(creatorFacet).startCommitteeElection( + slateCounterInstallation, + questionSpec, + ); + + const electionQuestions = await publicFacet.getElectionQuestions(); + const electionQuestion = E(publicFacet).getElectionQuestion( + electionQuestions[0], + ); + + const electionDetails = await E(electionQuestion).getDetails(); + + const issue = electionDetails.issue; + t.deepEqual(issue.text, 'Election for bakery committee members'); + t.deepEqual( + mockChainStorageRoot.getBody( + 'mockChainStorageRoot.thisElectorate.latestElectionQuestion', + ), + { + closingRule: { + deadline: 1n, + timer: { + iface: 'Alleged: ManualTimer', + }, + }, + counterInstance: { + iface: 'Alleged: InstanceHandle', + }, + electionType: 'survey', + issue: { + text: 'Election for bakery committee members', + }, + maxChoices: 1, + maxWinners: 1, + method: 'unranked', + positions: [ + { + text: 'yes', + }, + { + text: 'no', + }, + ], + questionHandle: { + iface: 'Alleged: QuestionHandle', + }, + quorumRule: 'no_quorum', + tieOutcome: { + text: 'no', + }, + winOutcome: [ + { + address: 'agoric1ldmtatp24qlllgxmrsjzcpe20fvlkp448zcuce', + name: 'Bob', + }, + { + address: 'agoric140dmkrz2e42ergjj7gyvejhzmjzurvqeq82ang', + name: 'Alice', + }, + { + address: 'agoric1w8wktaur4zf8qmmtn3n7x3r0jhsjkjntcm3u6h', + name: 'Charlie', + }, + ], + }, + ); + + const aliceSeat = makeHandle('Voter'); + const pos = counterPublicFacet.getQuestion().getDetails().positions; + await E(counterCreatorFacet).submitVote(aliceSeat, [pos[0]]); + + await E(timer).tick(); + await E(timer).tick(); + await E(timer).tick(); + + const outcome = await E(counterPublicFacet).getOutcome(); + t.deepEqual(outcome, candidates); +});