diff --git a/packages/governance/README.md b/packages/governance/README.md new file mode 100644 index 00000000000..2ca99a7f87d --- /dev/null +++ b/packages/governance/README.md @@ -0,0 +1,221 @@ +# Governance + +This package provides Electorates and VoteCounters to create a general +framework for governance. It has implementations for particular kinds of +electorates and different ways of tallying votes. + +The electorates and VoteCounters are self-describing and reveal what they are +connected to so that voters can verify that their votes mean what they say and +will be tabulated as expected. + +Any occasion of governance starts with the creation of an Electorate. Two kinds +exist currently that represent committees and stakeholders (Stakeholder support +is in review). The electorate may deal with many questions governing many +things, so the electorate has to exist before any questions can be posed. + +The next piece to be created is an ElectionManager. (A Contract Governor, is a +particular example, discussed below). An ElectionManager is tied to a particular +Electorate. It supports creation of questions, can manage what happens with the +results, and may limit the kinds of questions it can handle. The ElectionManager +is also responsible for specifying which VoteCounter will be used with any +particular question. Different VoteCounters will handle elections with two +positions or more, with plurality voting, single-transferable-vote, or +instant-runoff-voting. + +When a question is posed, it is only with respect to a particular Electorate, +(which identifies a collection of eligible voters) and a particular vote +counting contract. The QuestionSpec consists of `{ method, issue, positions, +electionType, maxChoices }`. The issue and positions can be strings or +structured objects. Method is one of UNRANKED and ORDER, which is sufficient to +describe all the most common kinds of votes. A vote between two candidates or +positions uses UNRANKED with a limit of one vote. ORDER will be useful for +Single Transferable Vote or Instant Runoff Voting. ElectionType distinguishes +PARAM_CHANGE, which has structured questions, from others where the issue is a +string. + +When posing a particular question to be voted on, the closingRule also has to be +specified. When voters are presented with a question to vote on, they have +access to QuestionDetails, which includes information from the QuestionSpec, the +closingRule, and the VoteCounter instance. The VoteCounter has the Electorate +in its terms, so voters can verify it. + +Voters get a voting facet via an invitation, so they're sure they're connected +to the Electorate that's responsible for this vote. They can subscribe with the +electorate to get a list of new questions. They can use the questionHandle from +the notifier to get the questionDetails. Voters cast their vote by sending their +selected list of positions to their electorate, which they know and trust. + +This structure of Electorates and VoteCounters allows voters and observers to +verify how votes will be counted, and who can vote on them, but doesn't +constrain the process of creating questions. ElectionManagers make that process +visible. ContractGovernor is a particular example of that that makes it possible +for a contract to publish details of how its parameters will be subject to +governance. + +## Electorate + +An Electorate represents a set of voters. Each voter receives an invitation +for a voterFacet, which allows voting in all elections supported by +that electorate. The Electorate starts a new VoteCounter instance for each +separate question, and gets the `creatorFacet`, which carries the `submitVote()` +method that registers votes with the voteCounter. The Electorate is responsible +for ensuring that `submitVote()` can only be called with the voter's unique +voterHandle. + +## ContractGovernor + +We want some contracts to be able to make it visible that their internal +parameters can be controlled by a public process, and allow observers to see who +has control, and how those changes can happen. To do so, the contract would +use a ParamManager to hold its mutable state. ParamManager has facets for +accessing the param values and for setting them. The governed contract would use +the access facet internally, and make that visible to anyone who should be able +to see the values, while ensuring that the private facet, which can control the +values, is only accessible to a visible ContractGovernor. The ContractGovernor +makes the Electorate visible, while tightly controlling the process of +creating new questions and presenting them to the electorate. + +The governor starts up the Contract and can see what params are subject to +governance. It provides a private facet that carries the ability to request +votes on changing particular parameters. Some day we may figure out how to make +the process and schedule of selecting parameter changes to vote on also subject +to governance, but that's too many meta-levels at this point. + +The party that has the question-creating facet of the ContractGovernor can +create a question that asks about changing a particular parameter on the +contract instance. The electorate creates new questions, and makes a new +instance of a VoteCounter so everyone can see how questions will be counted. + +Electorates have a public method to get from the questionHandle to a question. +Ballots include the questionSpec, the VoteCounter instance and closingRule. For +contract governance, the question specifies the governed contract instance, the +parameter to be changed, and the proposed new value. + +This is sufficient for voters and others to verify that the contract is managed +by the governor, the electorate is the one the governor uses, and a particular +voteCounter is in use. + +The governed contract can be inspected to verify that some parameter values are +held in a ParamManager, and that a ContractGovernor can cleanly start it up and +have exclusive access to the facet that allows the values to be set. The +contract would also make the read-only facet visible, so others can see the +current values. The initial values of the parameters, along with their types +remain visible in the contract's terms. + +### ParamManager + +`ContractGovernor` expects to work with contracts that use `ParamManager` to +manage their parameters. `buildParamManager()` is designed to be called within +the managed contract so that internal access to the parameter values is +synchronous. A separate facet allows visible management of changes to the +parameter values. + +`buildParamManager()` takes a list of parameter descriptions as its argument. +Descriptions give `{ name, type, value }` for each parameter. The parameter +values are retrieved by name. A separate facet of the paramManager allows the +holder to call `updateFoo()` to change the value. ContractGovernor wraps that +facet up so that usage can be monitored. + +The `type` part of the parameter description is a string. Current supported +values are +`{ AMOUNT, BRAND, INSTANCE, INSTALLATION, NAT, RATIO, STRING, UNKNOWN }`. +The list can be extended as we find more types that contracts want to manage. + +There's a contractHelper for the vast majority of expected clients that will +have a single set of parameters to manage. A contract only has to define the +parameters in a call to `handleParamGovernance()`, and add any needed methods +to the public and creator facets. This will + * validate that the declaration of the parameters is included in its terms, + * add the parameter retriever appropriately to the publicFacet and creatorFacet + +## Scenarios + +### Examining a Contract before use + +Governed contracts will make their governor and parameters visible, either +through the terms or the public facet. The governor, in turn, publicly shares +the electorate, which makes the list of questions visible. The questions show +their voteCounters, which makes it possible to tell how the counting will be +done. + +There isn't currently a way to verify the process of creating new questions. +We'll eventually need to spin a story that will make that more legible. +Currently, the ability to create new governance questions is provided as a +private facet that contains only the method `voteOnParamChange()`. + +When a prospective user of a contract receives a link to an instance of a +contract, they can check the terms to see if the contract names a governor. The +governor's public facet will also refer to the contract it governs. Once you +have the instance you can retrieve the installation from Zoe which allows you to +examine the source. + +The governedContract will provide the electorate, which allows you to check the +electorate, and retrieve a list of open questions. (We should add closed +questions and their resolution as well.) Each question refers to the +voteCounter it uses. + +### Participating in Governance + +Voters are managed by an Electorate. Prospective voters should only accept a +voting API as the outcome of an invitation. The invitation allows you to verify +the particular electorate instance in use. The electorate's public facet has +`getQuestionSubscription()`, which allows you to find out about new questions +for the electorate and `getOpenQuestions()` which lists questions that haven't +been resolved. + +Each question describes its subject. One field of the questionDetails is +`ElectionType`, which can be `PARAM_CHANGE`, `ELECTION`, or `SURVEY`. (I'm sure +we'll come up with more types.) When it is `PARAM_CHANGE`, the questionDetails +will also identify the contract instance, the particular parameter to be +changed, and the proposed new value. At present, all parameter change elections +are by majority vote, and if a majority doesn't vote in favor, then no change is +made. + +## Future Extensions + +The architecture is intended to support several scenarios that haven't been +filled in yet. + +### Electorates + +The initial Electorate represents a Committee, with has an opaque group of +voters. The +contract makes no attempt to make the voters legible to others. This might be +useful for a private group making a decision, or a case where a dictator has the +ability to appoint a committee that will make decisions. + +The AttestedElectorate (coming soon!) is an Electorate that gives the ability to +vote to anyone who has an Attestation payment from the Attestation contract. +Observers can't tell who the voters are, but they can validate the +qualifications to vote. + +Another plausible electorate would use the result of a public vote to give +voting facets to the election winners. There would have to be some kind of +public registration of the identities of the candidates to make them visible. + +### VoteCounters + +The only vote counter currently is the BinaryVoteCounter, which presumes +there are two positions on the ballot and assigns every vote to one or the other +or to 'spoiled'. At the end, it looks for a majority winner and announces that. +It can be configured to have one of the possible outcomes as the default +outcome. If there's a tie and no default, the winner is `undefined`. + +ContractGovernance uses this to make 'no change' be the default when voting on +parameter changes. + +We should have voteCounters for multiple candidate questions. I hope we'll +eventually have IRV (instant runoff) and various forms of proportional +representation. + +### ElectionManager + +The election manager has a role in governance, but not a specific API. The +manager's role is to make the setup of particular elections legible to voters +and other observers. The current example is the ContractGovernor, which manages +changes to contract parameters. There should also be managers that + +* take some action (i.e. add a new collateral type to the AMM) when a vote + passes. +* manage a plebiscite among stake holders to allow participants to express + opinions about the future of the chain. diff --git a/packages/governance/docs/AttackGuide.md b/packages/governance/docs/AttackGuide.md index 04e46f27de1..754a7dbe848 100644 --- a/packages/governance/docs/AttackGuide.md +++ b/packages/governance/docs/AttackGuide.md @@ -2,7 +2,7 @@ This is an incomplete list of potential weak points that an attacker might want to focus on when looking for ways to violate the integrity of the -governance system. It's here to help defenders, but "attacker's mindset" is a +governance system. It's here to help defenders, as "attacker's mindset" is a good way to focus attention for the defender. The list should correspond pretty closely to the set of assurances that the governance system aims to support. @@ -22,15 +22,16 @@ support for custom validation functions. ## Get a voter facet you shouldn't have Every module that handles voter facets should handle them carefully and ensure -they don't escape. If you can get access to a voter facet, you can cast a ballot -and override the preferences of the rightful voter. If you can manufacture voter -facets that are accepted, you can stuff the ballot box. +they don't escape. If you can get access to a voter facet (or the main voteCap +for a VoteCounter), you can cast a ballot and override the preferences of the +rightful voter. If you can manufacture voter facets that are accepted, you can +stuff the ballot box. -## Get a ballotCounter to accept votes from a single voter without replacement +## Get a voteCounter to accept votes from a single voter without replacement -I can't think of a way to evade the way BinaryBallotCounter ensures that each -ballot is only counted once, but it's something to be aware of with new -BallotCounters, and particularly in combination with new Registrars. +I can't think of a way to evade the way BinaryVoteCounter ensures that each +vote is only counted once, but it's something to be aware of with new +VoteCounters, and particularly in combination with new Electorates. ## Break notification so voters don't hear about elections @@ -40,57 +41,57 @@ provided for announcing new issues. ## Leak the ability to create new questions Currently creating new questions is seen as tightly held. If that is loosened -in a new Registrar or ElectionManager, that provides a path for spamming +in a new Electorate or ElectionManager, that provides a path for spamming voters. ## What shenanigans can be caused by creating multiple questions with the same or very similar text? -The question text used to be unique, but each ballot question now has a handle -for disambiguation. Voters ought to validate the particulars of any question -they intend to vote on. Can they be confused by corrections or replacement? Is -there a vulnerability here, or just a UI support need? +The question text used to be unique, but each question now has a +questionHandle for disambiguation. Voters ought to validate the particulars of +any question they intend to vote on. Can they be confused by corrections or +replacement? Is there a vulnerability here, or just a UI support need? -## Create a Ballot that refers to a different BallotCounter than the one the registrar will use +## Create a Question that refers to a different VoteCounter than the one the electorate will use -## Distribute ballots that don't match the official one from the Registrar +## Distribute questions that don't match the official one from the Electorate -Ballots themselves are not secure. The voter has to get a copy of the ballot -from the Registrar to have any assurance that it's valid. If someone else -provides a ballot, they can replace various pieces to fool the voter as to -what is being voted on. +Questions themselves are not secure. The voter has to get a copy of the question +from the Electorate to have any assurance that it's valid. If someone else +provides a question, they can replace various pieces to fool the voter as to +what is being voted on or how the votes will be tallied. -## Ordinary bugs in counting ballots, reporting, etc. +## Ordinary bugs in counting votes, reporting, etc. -If the code in BallotCounter, Registrar, ContractGovernor has subtle mistakes, +If the code in VoteCounter, Electorate, ContractGovernor has subtle mistakes, wrong results will obtain. -## Produce a discrepancy between Terms and actions in BallotCounter or Registrar +## Produce a discrepancy between Terms and actions in VoteCounter or Electorate -The voter's assurance that a particular ballot has the effect they expect -arises in part because the `terms` in the BallotCounter, Registrar, +The voter's assurance that a particular vote has the effect they expect +arises in part because the `terms` in the VoteCounter, Electorate, etc. dictate how those classes will act. If the code is changed to get info from hidden parameters or to ignore some of the terms, voters will be misled. ## Use a timer that is controlled by a party to the vote Everyone involved relies on the timers used to close voting being platform -timers, but the timers aren't self-revealing. Participants should compare the +timers, but timers aren't self-revealing. Participants should compare the timers to known platform-provided timers before relying on them. [A related bug has been filed](https://github.com/Agoric/agoric-sdk/issues/3748) -## Registrar allow unauthorized parties to cast ballots +## Electorate allow unauthorized parties to cast votes -Every registrar will have some notion of who the authorized voters are. They +Every electorate will have some notion of who the authorized voters are. They need to properly enforce that each voter can vote their weight once. The -current implementations only support equal weight ballots and known lists of -voters. Future Registrars and BallotCountes will support other models. The -combination of open-entry stake-holder votes with variable weight -ballotCounters will require even more diligence to ensure there are no avenues +initial implementation (Committee) supports equal weight votes and +known voters. Future Electorates and VoteCounters will support other models. +The combination of open-entry stake-holder votes with variable weight +voteCounters will require even more diligence to ensure there are no avenues for multiply counting votes. -## Registrar accidentally re-use a ballotCounter +## Electorate accidentally re-use a voteCounter -Each ballotCounter is intended to be used once. If there's a path that allows +Each voteCounter is intended to be used once. If there's a path that allows re-use, this would be a hazard. ## Does failed question creation throw detectably? @@ -118,12 +119,12 @@ than a weakness in the protocols or infrastructure. ## Get contractGovernor to leak paramManager private facet -The contractGovernor tries to ensure that the facet for calling `updateFoo()` +The contractGovernor needs to ensure that the facet for calling `updateFoo()` for a particular paramManager is only available in a visible way, but the code there is delicate. Is there a way to highjack the facet that wouldn't be detectable to voters or onlookers? -## Create ballot issue that claims to govern a contract it doesn't have control over +## Create question that claims to govern a contract it doesn't have control over It's possible to insert a layer between the contractGovernor and the paramManager or governedContract that allows external control of the @@ -135,7 +136,8 @@ obvious. Is there a way to evade that detection? ## Other Discrepancy between governedContract and ContractGovernor Are there ways to write governedContracts so they appear to be handled by -contractGovernor, but other intervention in the update parameters is possible? +contractGovernor, but other intervention in the update parameters calls is +possible? ## Can a cheating governor start up a contract with an invisible wrapper undetectably? diff --git a/packages/governance/docs/contractGovernance.png b/packages/governance/docs/contractGovernance.png new file mode 100644 index 00000000000..8bf5210d49b Binary files /dev/null and b/packages/governance/docs/contractGovernance.png differ diff --git a/packages/governance/docs/contractGovernance.puml b/packages/governance/docs/contractGovernance.puml new file mode 100644 index 00000000000..e8f30ab2c7a --- /dev/null +++ b/packages/governance/docs/contractGovernance.puml @@ -0,0 +1,47 @@ +@startuml contract governance + +package "GovernedContract Vat" <> { + Object ParamManager { + paramDesc[] { name, type, value } + -- + +getParams() + +getParam() + -updateFoo() + } + + class GovernedContract { + verifiable: Governor, params + -- + +terms: { electionManager, governedParams } + +getState() + +getContractGovernor() + -getParamManagerRetriever() + } + note left : calls buildParamManager(paramDesc);\nmakes paramMgr state public\nreturns paramMgr in creatorFacet +} + +class "ContractGovernor\n(an ElectionManager)" as ContractGovernor { + verifiable: governedInstance, electorateInstance + -- + +getElectorate() + +getGovernedContract() + +validateVoteCounter() + +validateElectorate() + +validateTimer() + -startGovernedInstance(electorate, governed, ...) +} +note left : ContractGovernor starts GovernedContract\nstartGovernedInstance() returns a tightly held facet\n with voteOnParamChange() for the creator. + +class Electorate { + Questions + === + -addQuestion() +} + +GovernedContract ..> ParamManager : creates > +GovernedContract --> ParamManager : access\nto params +ContractGovernor ..> GovernedContract : creates > +ContractGovernor --> Electorate +ContractGovernor ==> ParamManager : manages\nparams + +@enduml diff --git a/packages/governance/docs/coreArchitecture.png b/packages/governance/docs/coreArchitecture.png new file mode 100644 index 00000000000..0c15a79188e Binary files /dev/null and b/packages/governance/docs/coreArchitecture.png differ diff --git a/packages/governance/docs/coreArchitecture.puml b/packages/governance/docs/coreArchitecture.puml new file mode 100644 index 00000000000..2346b92e673 --- /dev/null +++ b/packages/governance/docs/coreArchitecture.puml @@ -0,0 +1,116 @@ +@startuml governance invitation linkages + +package Legend <> #EEEEEE { + + class ContractA { + constract terms are documented above the line + -- + accessiblePublicState + + publicMethod() + # methodShared() + - closelyHeldMethod() + } + + interface InvitationB { + verifiable invitation fields are above the line + -- + offerResults - below the line + } + + note "Contracts have a 'C' marker.\nInvitations have an 'I'.\nblue arrows show verifiable connections.\ncreator-created links are labelled" as NC +} + +package "Electorate Vat" <> { + class Electorate { + terms: committeeSize, committeeName + -- + Questions[] + +getQuestionSubscription() + +getOpenQuestions() + +getQuestion(questionHandle) + #getVoterInvitation(): (via some mechanism) + -getQuestionPoserInvitation() + -addQuestion(voteCounterInstall, question, details) + } + + note "produces VoterInvitations.\nPolymorphic over VoteCounters.\nquestions are enumerable." as N1 + Electorate .. N1 + + interface QuestionPoserInvitation { + Electorate + -- + addQuestion() + } + + interface VoterInvitation { + Electorate + -- + getVoterFacet() + } + + object VoterFacet { + --- + castBallotFor(QuestionoHandle, ...positions) + } + note "instances held by\nindividual voters" as NVF + VoterFacet . NVF + + Electorate --> VoterFacet : creates > +} + +object QuestionDetails { + Method { UNRANKED | ORDER } + Type { ParamChange | Election | Survey } + -- + issue, positions, tieOutcome, maxChoices + quorumRule + closingRule: { timer, deadline } + questionHandle + counterInstance +} + +note "QuestionDetails is a widely accessible record.\nverifiable copies are obtained from an Electorate" as N3 +QuestionDetails .. N3 + +package "VoteCounter Vat" <> { + class VoteCounter { + terms\n {questionSpec, quorum, closingRule, tieOutcome} + -- + +getDetails() + +getOutcome(): Promise + +getStats() + +getQuestion() + #countVotes() + -submitVote() + -getCreatorFacet() + } + + object VoteCap { + --- + submitVote(VoterHandle, ...positions) + } + note top: VoteCap is passed to and\ntightly held by Electorate. + + note "unaware of voter registration.\n Only Electorate hands out voterFacets" as N2 + VoteCounter .. N2 + + VoteCounter --> VoteCap : creates > +} + +class ElectionManager { + Electorate + addQuestion() +} +note top : ElectionManager is responsible\n for letting an appropriate\n party call addQuestion() + +ElectionManager -.[#blue]-|> Electorate : verifiable + +Electorate *. VoterInvitation +Electorate *. QuestionPoserInvitation +VoterInvitation -> VoterFacet +Electorate -> QuestionDetails : creates > +VoteCounter <|-.[#blue]-|> QuestionDetails : verifiable + +VoterFacet --|> VoteCap : encapsulates + +@enduml \ No newline at end of file diff --git a/packages/governance/docs/example.png b/packages/governance/docs/example.png new file mode 100644 index 00000000000..105f9e3390b Binary files /dev/null and b/packages/governance/docs/example.png differ diff --git a/packages/governance/docs/example.puml b/packages/governance/docs/example.puml new file mode 100644 index 00000000000..a91fc36a2bf --- /dev/null +++ b/packages/governance/docs/example.puml @@ -0,0 +1,53 @@ +@startuml governance example + +package "Example with Vote Invitation" <> { + class ContractGovernor { + has a committee that wlll vote on questions. + can create binary (and other) questions. + } + + class BinaryVoteCounter { + quorumThreshold, issue + questionHandle, closingRule + -- + doesn't know who's voting. + knows how to count binary questions + } + + object "Question FeesTo2Percent" as FeesTo2Percent { + Contract + Issue: set fees at 2%? + Positions + } + + object governedContract { + ContractGovernor + } + + class TreasuryGovernanceElectorate1 { + Questions: FeesTo2Percent, ... + -- + distributed voterInvitations to creator. + doesn't know how questions are created. + } + + interface memberAVoterInvitation { + TreasuryGovernanceElectorate1 + } + + object memberAVoterFacet { + TreasuryGovernanceElectorate1 + -- + castBallotFor(questionHandle, [positions]) + } +} + +ContractGovernor --> BinaryVoteCounter : responds to\noutcome > +ContractGovernor -.[#blue]-|> TreasuryGovernanceElectorate1 : verifiable +memberAVoterInvitation --> memberAVoterFacet +memberAVoterFacet --> FeesTo2Percent + +ContractGovernor ==> governedContract : creates > +FeesTo2Percent => governedContract + +@enduml diff --git a/packages/governance/exported.js b/packages/governance/exported.js new file mode 100644 index 00000000000..f4cba017ea1 --- /dev/null +++ b/packages/governance/exported.js @@ -0,0 +1 @@ +import './src/types.js'; diff --git a/packages/governance/src/ballotBuilder.js b/packages/governance/src/ballotBuilder.js deleted file mode 100644 index ab0b5c1ff4f..00000000000 --- a/packages/governance/src/ballotBuilder.js +++ /dev/null @@ -1,84 +0,0 @@ -// @ts-check - -import { assert, details as X } from '@agoric/assert'; -import { Far } from '@agoric/marshal'; - -// CHOOSE_N: voter indicates up to N they find acceptable (N might be 1). -// ORDER: voter lists their choices from most to least favorite. -// WEIGHT: voter lists their choices, each with a numerical weight. High -// numbers are most preferred. - -/** - * @type {{ - * CHOOSE_N: 'choose_n', - * ORDER: 'order', - * WEIGHT: 'weight', - * }} - */ -const ChoiceMethod = { - CHOOSE_N: 'choose_n', - ORDER: 'order', - WEIGHT: 'weight', -}; - -const buildEqualWeightBallot = ( - method, - question, - positions, - maxChoices = 0, - instance, -) => { - const choose = chosenPositions => { - assert( - chosenPositions.length <= maxChoices, - X`only ${maxChoices} position(s) allowed`, - ); - assert( - chosenPositions.every(p => positions.includes(p)), - X`Some positions in ${chosenPositions} are not valid in ${positions}`, - ); - - /** @type {CompleteEqualWeightBallot} */ - return { question, chosen: chosenPositions }; - }; - - const getDetails = () => - harden({ - method, - question, - positions, - maxChoices, - instance, - }); - - return Far('ballot details', { - getBallotCounter: () => instance, - getDetails, - choose, - }); -}; - -/** @type {BuildBallot} */ -const buildBallot = (method, question, positions, maxChoices = 0, instance) => { - assert.typeof(question, 'string'); - - switch (method) { - case ChoiceMethod.CHOOSE_N: - return buildEqualWeightBallot( - method, - question, - positions, - maxChoices, - instance, - ); - case ChoiceMethod.ORDER: - case ChoiceMethod.WEIGHT: - throw Error(`choice method ${ChoiceMethod.WEIGHT} is unimplemented`); - default: - throw Error(`choice method unrecognized`); - } -}; - -harden(buildBallot); - -export { ChoiceMethod, buildBallot }; diff --git a/packages/governance/src/binaryBallotCounter.js b/packages/governance/src/binaryBallotCounter.js deleted file mode 100644 index 9320b70c1ef..00000000000 --- a/packages/governance/src/binaryBallotCounter.js +++ /dev/null @@ -1,202 +0,0 @@ -// @ts-check - -import { assert, details as X } from '@agoric/assert'; -import { makeStore } from '@agoric/store'; -import { makePromiseKit } from '@agoric/promise-kit'; -import { Far } from '@agoric/marshal'; - -import { E } from '@agoric/eventual-send'; -import { ChoiceMethod, buildBallot } from './ballotBuilder.js'; -import { scheduleClose } from './closingRule.js'; - -const makeWeightedBallot = (ballot, shares) => harden({ ballot, shares }); - -const makeQuorumCounter = quorumThreshold => { - const check = stats => { - const votes = stats.results.reduce( - (runningTotal, { total }) => runningTotal + total, - 0n, - ); - return votes >= quorumThreshold; - }; - /** @type {QuorumCounter} */ - return Far('checker', { check }); -}; - -// Exported for testing purposes -const makeBinaryBallotCounter = ( - question, - positions, - threshold, - tieOutcome = undefined, - closingRule, - instance, -) => { - assert( - positions.length === 2, - X`Binary ballots must have exactly two positions. had ${positions.length}: ${positions}`, - ); - assert.typeof(question, 'string'); - assert.typeof(positions[0], 'string'); - assert.typeof(positions[1], 'string'); - if (tieOutcome) { - assert( - positions.includes(tieOutcome), - X`The default outcome on a tie must be one of the positions, not ${tieOutcome}`, - ); - } - - const template = buildBallot( - ChoiceMethod.CHOOSE_N, - question, - positions, - 1, - instance, - ); - const ballotDetails = template.getDetails(); - - assert( - ballotDetails.method === ChoiceMethod.CHOOSE_N, - X`Binary ballot counter only works with CHOOSE_N`, - ); - let isOpen = true; - const outcomePromise = makePromiseKit(); - const tallyPromise = makePromiseKit(); - const allBallots = makeStore('seat'); - - const recordBallot = (seat, filledBallotP, shares = 1n) => { - return E.when(filledBallotP, filledBallot => { - assert( - filledBallot.question === question, - X`Ballot not for this question ${filledBallot.question} should have been ${question}`, - ); - assert( - positions.includes(filledBallot.chosen[0]), - X`The ballot's choice is not a legal position: ${filledBallot.chosen[0]}.`, - ); - allBallots.has(seat) - ? allBallots.set(seat, makeWeightedBallot(filledBallot, shares)) - : allBallots.init(seat, makeWeightedBallot(filledBallot, shares)); - }); - }; - - const countVotes = () => { - assert(!isOpen, X`can't count votes while the election is open`); - - // ballot template has position choices; Each ballot in allBallots should - // match. count the valid ballots and report results. - let spoiled = 0n; - const tally = { - [positions[0]]: 0n, - [positions[1]]: 0n, - }; - - allBallots.values().forEach(({ ballot, shares }) => { - assert( - ballot.chosen.length === 1, - X`A binary ballot must contain exactly one choice.`, - ); - const choice = ballot.chosen[0]; - if (!ballotDetails.positions.includes(choice)) { - spoiled += shares; - } else { - tally[choice] += shares; - } - }); - - const stats = { - spoiled, - votes: allBallots.entries().length, - results: [ - { position: positions[0], total: tally[positions[0]] }, - { position: positions[1], total: tally[positions[1]] }, - ], - }; - tallyPromise.resolve(stats); - - if (!makeQuorumCounter(threshold).check(stats)) { - outcomePromise.reject('No quorum'); - return; - } - - if (tally[positions[0]] > tally[positions[1]]) { - outcomePromise.resolve(positions[0]); - } else if (tally[positions[1]] > tally[positions[0]]) { - outcomePromise.resolve(positions[1]); - } else { - outcomePromise.resolve(tieOutcome); - } - }; - - const closeVoting = () => { - isOpen = false; - countVotes(); - }; - - const sharedFacet = { - getBallotTemplate: () => template, - isOpen: () => isOpen, - getClosingRule: () => closingRule, - }; - - /** @type {VoterFacet} */ - const voterFacet = Far('voterFacet', { - ...sharedFacet, - submitVote: recordBallot, - }); - - // exposed for testing. In contracts, shouldn't be released. - const closeFacet = Far('closeFacet', { closeVoting }); - - /** @type {BallotCounterCreatorFacet} */ - const creatorFacet = Far('adminFacet', { - ...sharedFacet, - getVoterFacet: () => voterFacet, - }); - - const publicFacet = Far('preliminaryPublicFacet', { - ...sharedFacet, - getOutcome: () => outcomePromise.promise, - getStats: () => tallyPromise.promise, - }); - return { publicFacet, creatorFacet, closeFacet }; -}; - -const start = zcf => { - // There are a variety of ways of counting quorums. The parameters must be - // visible in the terms. We're doing a simple threshold here. If we wanted to - // discount abstentions, we could refactor to provide the quorumCounter as a - // component. - // TODO(hibbert) checking the quorum should be pluggable and legible. - const { - question, - positions, - quorumThreshold, - tieOutcome, - closingRule, - } = zcf.getTerms(); - - // The closeFacet is exposed for testing, but doesn't escape from a contract - const { publicFacet, creatorFacet, closeFacet } = makeBinaryBallotCounter( - question, - positions, - quorumThreshold, - tieOutcome, - closingRule, - zcf.getInstance(), - ); - - scheduleClose(closingRule, closeFacet.closeVoting); - - /** @type {BallotCounterPublicFacet} */ - const publicFacetWithGetInstance = Far('publicFacet', { - ...publicFacet, - getInstance: zcf.getInstance, - }); - return { publicFacet: publicFacetWithGetInstance, creatorFacet }; -}; - -harden(start); -harden(makeBinaryBallotCounter); - -export { makeBinaryBallotCounter, start }; diff --git a/packages/governance/src/binaryVoteCounter.js b/packages/governance/src/binaryVoteCounter.js new file mode 100644 index 00000000000..a24ab07f603 --- /dev/null +++ b/packages/governance/src/binaryVoteCounter.js @@ -0,0 +1,199 @@ +// @ts-check + +import { Far } from '@agoric/marshal'; +import { makePromiseKit } from '@agoric/promise-kit'; +import { sameStructure } from '@agoric/same-structure'; +import { makeStore } from '@agoric/store'; + +import { + ChoiceMethod, + buildUnrankedQuestion, + positionIncluded, + looksLikeQuestionSpec, +} from './question.js'; +import { scheduleClose } from './closingRule.js'; + +const { details: X } = assert; + +const makeQuorumCounter = quorumThreshold => { + const check = stats => { + const votes = stats.results.reduce( + (runningTotal, { total }) => runningTotal + total, + 0n, + ); + return votes >= quorumThreshold; + }; + /** @type {QuorumCounter} */ + return Far('checker', { check }); +}; + +const validateBinaryQuestionSpec = questionSpec => { + looksLikeQuestionSpec(questionSpec); + + const positions = questionSpec.positions; + assert( + positions.length === 2, + X`Binary questions must have exactly two positions. had ${positions.length}: ${positions}`, + ); + + assert( + questionSpec.maxChoices === 1, + X`Can only choose 1 item on a binary question`, + ); + assert( + questionSpec.method === ChoiceMethod.UNRANKED, + X`${questionSpec.method} must be UNRANKED`, + ); + // We don't check the quorumRule or quorumThreshold here. The quorumThreshold + // is provided by the Electorate that creates this voteCounter, since only it + // can translate the quorumRule to a required number of votes. +}; + +// Notice that BinaryVoteCounter is designed to run as a Zoe contract. The +// business part of the contract is extracted here so it can be tested +// independently. The standard Zoe start function is at the bottom of this file. + +/** @type {BuildVoteCounter} */ +const makeBinaryVoteCounter = (questionSpec, threshold, instance) => { + validateBinaryQuestionSpec(questionSpec); + + const question = buildUnrankedQuestion(questionSpec, instance); + const details = question.getDetails(); + + let isOpen = true; + const positions = questionSpec.positions; + const outcomePromise = makePromiseKit(); + const tallyPromise = makePromiseKit(); + // The Electorate is responsible for creating a unique seat for each voter. + // This voteCounter allows voters to re-vote, and replaces their previous + // choice with the new selection. + + /** + * @typedef {Object} RecordedBallot + * @property {Position} chosen + * @property {bigint} shares + */ + /** @type {Store,RecordedBallot> } */ + const allBallots = makeStore('voterHandle'); + + /** @type {SubmitVote} */ + const submitVote = (voterHandle, chosenPositions, shares = 1n) => { + assert(chosenPositions.length === 1, 'only 1 position allowed'); + const [position] = chosenPositions; + assert( + positionIncluded(positions, position), + X`The specified choice is not a legal position: ${position}.`, + ); + + // CRUCIAL: If the voter cast a valid ballot, we'll record it, but we need + // to make sure that each voter's vote is recorded only once. + const completedBallot = harden({ chosen: position, shares }); + allBallots.has(voterHandle) + ? allBallots.set(voterHandle, completedBallot) + : allBallots.init(voterHandle, completedBallot); + }; + + const countVotes = () => { + assert(!isOpen, X`can't count votes while the election is open`); + + // question has position choices; Each ballot in allBallots should + // match. count the valid ballots and report results. + let spoiled = 0n; + const tally = [0n, 0n]; + + allBallots.values().forEach(({ chosen, shares }) => { + const choice = positions.findIndex(p => sameStructure(p, chosen)); + if (choice < 0) { + spoiled += shares; + } else { + tally[choice] += shares; + } + }); + + const stats = { + spoiled, + votes: allBallots.entries().length, + results: [ + { position: positions[0], total: tally[0] }, + { position: positions[1], total: tally[1] }, + ], + }; + + // CRUCIAL: countVotes only gets called once for each question. We want to + // ensure that tallyPromise and outcomePromise always get resolved. The + // tally gets the results regardless of the outcome. outcomePromise gets a + // different resolution depending on whether there was no quorum to make a + // decision, or the outcome is based on a majority either way, or a tie. + tallyPromise.resolve(stats); + + if (!makeQuorumCounter(threshold).check(stats)) { + outcomePromise.reject('No quorum'); + return; + } + + if (tally[0] > tally[1]) { + outcomePromise.resolve(positions[0]); + } else if (tally[1] > tally[0]) { + outcomePromise.resolve(positions[1]); + } else { + outcomePromise.resolve(questionSpec.tieOutcome); + } + }; + + const closeVoting = () => { + isOpen = false; + countVotes(); + }; + + // exposed for testing. In contracts, shouldn't be released. + /** @type {VoteCounterCloseFacet} */ + const closeFacet = Far('closeFacet', { closeVoting }); + + /** @type {VoteCounterCreatorFacet} */ + const creatorFacet = Far('VoteCounter vote Cap', { + submitVote, + }); + + /** @type {VoteCounterPublicFacet} */ + const publicFacet = Far('preliminaryPublicFacet', { + getQuestion: () => question, + isOpen: () => isOpen, + getOutcome: () => outcomePromise.promise, + getStats: () => tallyPromise.promise, + getDetails: () => details, + }); + return { publicFacet, creatorFacet, closeFacet }; +}; + +// The contract wrapper extracts the terms and runs makeBinaryVoteCounter(). +// It schedules the closing of the vote, finally inserting the contract +// instance in the publicFacet before returning public and creator facets. + +const start = zcf => { + // There are a variety of ways of counting quorums. The parameters must be + // visible in the terms. We're doing a simple threshold here. If we wanted to + // discount abstentions, we could refactor to provide the quorumCounter as a + // component. + // TODO(hibbert) checking the quorum should be pluggable and legible. + const { questionSpec, quorumThreshold } = zcf.getTerms(); + // The closeFacet is exposed for testing, but doesn't escape from a contract + const { publicFacet, creatorFacet, closeFacet } = makeBinaryVoteCounter( + questionSpec, + quorumThreshold, + zcf.getInstance(), + ); + + scheduleClose(questionSpec.closingRule, closeFacet.closeVoting); + + /** @type {VoteCounterPublicFacet} */ + const publicFacetWithGetInstance = Far('publicFacet', { + ...publicFacet, + getInstance: zcf.getInstance, + }); + return { publicFacet: publicFacetWithGetInstance, creatorFacet }; +}; + +harden(start); +harden(makeBinaryVoteCounter); + +export { makeBinaryVoteCounter, start }; diff --git a/packages/governance/src/closingRule.js b/packages/governance/src/closingRule.js index f2999854b1a..61c55852677 100644 --- a/packages/governance/src/closingRule.js +++ b/packages/governance/src/closingRule.js @@ -4,8 +4,8 @@ // emergency votes that can close as soon as a quorum or other threshold is // reached. -import { Far } from '@agoric/marshal'; import { E } from '@agoric/eventual-send'; +import { Far } from '@agoric/marshal'; /** @type {CloseVoting} */ export const scheduleClose = (closingRule, closeVoting) => { diff --git a/packages/governance/src/committee.js b/packages/governance/src/committee.js new file mode 100644 index 00000000000..295260f5c81 --- /dev/null +++ b/packages/governance/src/committee.js @@ -0,0 +1,144 @@ +// @ts-check + +import { E } from '@agoric/eventual-send'; +import { Far } from '@agoric/marshal'; +import { makeSubscriptionKit } from '@agoric/notifier'; +import { allComparable } from '@agoric/same-structure'; +import { makeStore } from '@agoric/store'; +import { natSafeMath } from '@agoric/zoe/src/contractSupport/index.js'; + +import { makeHandle } from '@agoric/zoe/src/makeHandle'; +import { QuorumRule } from './question.js'; + +const { ceilDivide } = natSafeMath; + +/** + * Each Committee (an Electorate) represents a particular set of voters. The + * number of voters is visible in the terms. + * + * This contract creates an electorate whose membership is not visible to + * observers. There may be uses for such a structure, but it is not appropriate + * for elections where the set of voters needs to be known, unless the contract + * is used in a way that makes the distribution of voter facets visible. + * + * @type {ContractStartFn} + */ +const start = zcf => { + /** + * @typedef {Object} QuestionRecord + * @property {ERef} voteCap + * @property {VoteCounterPublicFacet} publicFacet + */ + + /** @type {Store, QuestionRecord>} */ + const allQuestions = makeStore('Question'); + const { subscription, publication } = makeSubscriptionKit(); + + const getOpenQuestions = async () => { + const isOpenPQuestions = allQuestions.keys().map(key => { + const { publicFacet } = allQuestions.get(key); + return [E(publicFacet).isOpen(), key]; + }); + + /** @type {[boolean, Handle<'Question'>][]} */ + const isOpenQuestions = await allComparable(harden(isOpenPQuestions)); + return isOpenQuestions + .filter(([open, _key]) => open) + .map(([_open, key]) => key); + }; + + const makeCommitteeVoterInvitation = index => { + /** @type {OfferHandler} */ + const offerHandler = Far('voter offerHandler', () => { + const voterHandle = makeHandle('Voter'); + return Far(`voter${index}`, { + // CRUCIAL: voteCap carries the ability to cast votes for any voter at + // any weight. It's wrapped here and given to the voter. + // + // Ensure that the voter can't get access to the unwrapped voteCap, and + // has no control over the voteHandle or weight + castBallotFor: (questionHandle, positions) => { + const { voteCap } = allQuestions.get(questionHandle); + return E(voteCap).submitVote(voterHandle, positions, 1n); + }, + }); + }); + + // https://github.com/Agoric/agoric-sdk/pull/3448/files#r704003612 + // This will produce unique descriptions because + // makeCommitteeVoterInvitation() is only called within the following loop, + // which is only called once per Electorate. + return zcf.makeInvitation(offerHandler, `Voter${index}`); + }; + + const { committeeName, committeeSize } = zcf.getTerms(); + + const invitations = harden( + [...Array(committeeSize).keys()].map(makeCommitteeVoterInvitation), + ); + + /** @type {AddQuestion} */ + const addQuestion = async (voteCounter, questionSpec) => { + const quorumThreshold = quorumRule => { + 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`); + } + }; + + /** @type {QuestionTerms} */ + const voteCounterTerms = { + questionSpec, + electorate: zcf.getInstance(), + quorumThreshold: quorumThreshold(questionSpec.quorumRule), + }; + + // facets of the vote counter. creatorInvitation and adminFacet not used + const { creatorFacet, publicFacet, instance } = await E( + zcf.getZoeService(), + ).startInstance(voteCounter, {}, voteCounterTerms); + const details = await E(publicFacet).getDetails(); + const voteCounterFacets = { voteCap: creatorFacet, publicFacet }; + allQuestions.init(details.questionHandle, voteCounterFacets); + + publication.updateState(details); + return { creatorFacet, publicFacet, instance }; + }; + + /** @type {ElectoratePublic} */ + const publicFacet = Far('publicFacet', { + getQuestionSubscription: () => subscription, + getOpenQuestions, + getName: () => committeeName, + getInstance: zcf.getInstance, + getQuestion: questionHandleP => + E.when(questionHandleP, questionHandle => + E(allQuestions.get(questionHandle).publicFacet).getQuestion(), + ), + }); + + const getPoserInvitation = () => { + const questionPoserHandler = () => Far(`questionPoser`, { addQuestion }); + return zcf.makeInvitation(questionPoserHandler, `questionPoser`); + }; + + /** @type {ElectorateCreatorFacet} */ + const creatorFacet = Far('adminFacet', { + getPoserInvitation, + addQuestion, + getVoterInvitations: () => invitations, + getQuestionSubscription: () => subscription, + getPublicFacet: () => publicFacet, + }); + + return { publicFacet, creatorFacet }; +}; + +harden(start); +export { start }; diff --git a/packages/governance/src/committeeRegistrar.js b/packages/governance/src/committeeRegistrar.js deleted file mode 100644 index c6d1309dfcf..00000000000 --- a/packages/governance/src/committeeRegistrar.js +++ /dev/null @@ -1,92 +0,0 @@ -// @ts-check - -import { Far } from '@agoric/marshal'; -import { makeNotifierKit } from '@agoric/notifier'; -import { E } from '@agoric/eventual-send'; -import { makeStore } from '@agoric/store'; -import { allComparable } from '@agoric/same-structure'; - -// Each CommitteeRegistrar represents a particular set of voters. The number of -// voters is visible in the terms. -const start = zcf => { - // Question => { voter, publicFacet } - const allQuestions = makeStore('Question'); - const { notifier, updater } = makeNotifierKit(); - const invitations = []; - - const getOpenQuestions = async () => { - const isOpenPQuestions = allQuestions.keys().map(key => { - const { publicFacet } = allQuestions.get(key); - return [E(publicFacet).isOpen(), key]; - }); - const isOpenQuestions = await allComparable(harden(isOpenPQuestions)); - return isOpenQuestions - .filter(([open, _key]) => open) - .map(([_open, key]) => key); - }; - - const makeCommitteeVoterInvitation = index => { - const handler = Far('handler', voterSeat => { - return Far(`voter${index}`, { - castBallot: ballotp => { - E.when(ballotp, ballot => { - const { voter } = allQuestions.get(ballot.question); - return E(voter).submitVote(voterSeat, ballot); - }); - }, - castBallotFor: (question, positions) => { - const { publicFacet: counter, voter } = allQuestions.get(question); - const ballotTemplate = E(counter).getBallotTemplate(); - const ballot = E(ballotTemplate).choose(positions); - return E(voter).submitVote(voterSeat, ballot); - }, - }); - }); - - return zcf.makeInvitation(handler, `Voter${index}`); - }; - - const { committeeName, committeeSize } = zcf.getTerms(); - for (let i = 0; i < committeeSize; i += 1) { - invitations[i] = makeCommitteeVoterInvitation(i); - } - - /** @type {AddQuestion} */ - const addQuestion = async (voteCounter, questionDetailsShort) => { - const questionDetails = { - ...questionDetailsShort, - registrar: zcf.getInstance(), - }; - // facets of the ballot counter. Suppress creatorInvitation and adminFacet. - const { creatorFacet, publicFacet, instance } = await E( - zcf.getZoeService(), - ).startInstance(voteCounter, {}, questionDetails); - const facets = { voter: E(creatorFacet).getVoterFacet(), publicFacet }; - - updater.updateState(questionDetails.question); - allQuestions.init(questionDetails.question, facets); - return { creatorFacet, publicFacet, instance }; - }; - - const creatorFacet = Far('adminFacet', { - addQuestion, - getVoterInvitations: () => invitations, - getQuestionNotifier: () => notifier, - }); - - const publicFacet = Far('publicFacet', { - getQuestionNotifier: () => notifier, - getOpenQuestions, - getName: () => committeeName, - getInstance: zcf.getInstance, - getDetails: name => - E(E(allQuestions.get(name).publicFacet).getBallotTemplate()).getDetails(), - getBallot: name => - E(allQuestions.get(name).publicFacet).getBallotTemplate(), - }); - - return { publicFacet, creatorFacet }; -}; - -harden(start); -export { start }; diff --git a/packages/governance/src/contractGovernor.js b/packages/governance/src/contractGovernor.js new file mode 100644 index 00000000000..c6d7cd8dce4 --- /dev/null +++ b/packages/governance/src/contractGovernor.js @@ -0,0 +1,177 @@ +// @ts-check + +import { E } from '@agoric/eventual-send'; +import { Far } from '@agoric/marshal'; + +import { setupGovernance, validateParamChangeQuestion } from './governParam.js'; + +const { details: X } = assert; + +/** @type {ValidateQuestionDetails} */ +const validateQuestionDetails = async (zoe, electorate, details) => { + const { + counterInstance, + issue: { contract: governedInstance }, + } = details; + validateParamChangeQuestion(details); + + const governorInstance = await E.get(E(zoe).getTerms(governedInstance)) + .electionManager; + const governorPublic = E(zoe).getPublicFacet(governorInstance); + + return Promise.all([ + E(governorPublic).validateVoteCounter(counterInstance), + E(governorPublic).validateElectorate(electorate), + E(governorPublic).validateTimer(details), + ]); +}; + +/** @type {ValidateQuestionFromCounter} */ +const validateQuestionFromCounter = async (zoe, electorate, voteCounter) => { + const counterPublicP = E(zoe).getPublicFacet(voteCounter); + const questionDetails = await E(counterPublicP).getDetails(); + + return validateQuestionDetails(zoe, electorate, questionDetails); +}; + +/* + * ContractManager is an ElectionManager that starts up a contract and hands its + * own creator a facet that allows them to call for votes on parameters that + * were declared by the contract. + * + * The terms for this contract include the Timer, Electorate and + * the Installation to be started, as well as an issuerKeywordRecord or terms + * needed by the governed contract. Those details for the governed contract are + * included in this contract's terms as a "governed" record. + * + * terms = { + * timer, + * electorateInstance, + * governedContractInstallation, + * governed: { + * issuerKeywordRecord: governedIssuerKeywordRecord, + * terms: governedTerms, + * }, + * }; + * + * The governedContract is responsible for supplying getParamMgrRetriever() in + * its creatorFacet. getParamMgrRetriever() takes a ParamSpecification, which + * identifies the parameter to be voted on. A minimal ParamSpecification + * specifies the key which identifies a particular paramManager (even if there's + * only one) and the parameterName. The interpretation of ParamSpecification is + * up to the contract. + * + * The contractGovenor creatorFacet includes voteOnParamChange(), + * which is used to create questions that will automatically update + * contract parameters if passed. This facet will usually be closely held. The + * creatorFacet can also be used to retrieve the governed instance, publicFacet, + * and it's creatorFacet with voteOnParamChange() omitted. + * + * The governed contract's terms include the instance of this (governing) + * contract (as electionManager) so clients will be able to look up the state + * of the governed parameters. + * + * @type {ContractStartFn} + */ +const start = async (zcf, privateArgs) => { + const zoe = zcf.getZoeService(); + const { + timer, + electorateInstance, + governedContractInstallation, + governed: { + issuerKeywordRecord: governedIssuerKeywordRecord, + terms: governedTerms, + privateArgs: privateContractArgs, + }, + } = /** @type {ContractGovernorTerms} */ zcf.getTerms(); + + const { electorateCreatorFacet } = privateArgs; + + const augmentedTerms = harden({ + ...governedTerms, + electionManager: zcf.getInstance(), + }); + const poserInvitation = E(electorateCreatorFacet).getPoserInvitation(); + + const [ + { + creatorFacet: governedCF, + instance: governedInstance, + publicFacet: governedPF, + }, + invitationDetails, + ] = await Promise.all([ + E(zoe).startInstance( + governedContractInstallation, + governedIssuerKeywordRecord, + augmentedTerms, + privateContractArgs, + ), + E(zoe).getInvitationDetails(poserInvitation), + ]); + + assert( + invitationDetails.instance === electorateInstance, + X`questionPoserInvitation didn't match supplied Electorate`, + ); + + // CRUCIAL: only governedContract should get the ability to update params + /** @type {Promise} */ + const limitedCreatorFacet = E(governedCF).getLimitedCreatorFacet(); + + const { voteOnParamChange, createdQuestion } = await setupGovernance( + E(governedCF).getParamMgrRetriever(), + E(E(zoe).offer(poserInvitation)).getOfferResult(), + governedInstance, + timer, + ); + + const validateVoteCounter = async voteCounter => { + const created = await E(createdQuestion)(voteCounter); + assert(created, X`VoteCounter was not created by this contractGovernor`); + return true; + }; + + const validateTimer = details => { + assert( + details.closingRule.timer === timer, + X`closing rule must use my timer`, + ); + return true; + }; + + const validateElectorate = async regP => { + return E.when(regP, reg => { + assert( + reg === electorateInstance, + X`Electorate doesn't match my Electorate`, + ); + return true; + }); + }; + + /** @type {GovernedContractFacetAccess} */ + const creatorFacet = Far('governor creatorFacet', { + voteOnParamChange, + getCreatorFacet: () => limitedCreatorFacet, + getInstance: () => governedInstance, + getPublicFacet: () => governedPF, + }); + + /** @type {GovernorPublic} */ + const publicFacet = Far('contract governor public', { + getElectorate: () => electorateInstance, + getGovernedContract: () => governedInstance, + validateVoteCounter, + validateElectorate, + validateTimer, + }); + + return { creatorFacet, publicFacet }; +}; + +harden(start); +harden(validateQuestionDetails); +harden(validateQuestionFromCounter); +export { start, validateQuestionDetails, validateQuestionFromCounter }; diff --git a/packages/governance/src/contractHelper.js b/packages/governance/src/contractHelper.js new file mode 100644 index 00000000000..534f829f6fe --- /dev/null +++ b/packages/governance/src/contractHelper.js @@ -0,0 +1,65 @@ +// @ts-check + +import { Far } from '@agoric/marshal'; +import { sameStructure } from '@agoric/same-structure'; + +import { buildParamManager } from './paramManager.js'; + +const { details: X, quote: q } = assert; + +/** + * Helper for the 90% of contracts that will have only a single set of + * parameters. In order to support managed parameters, a contract only has to + * * define the parameter template, which includes name, type and value + * * call handleParamGovernance() to get makePublicFacet and makeCreatorFacet + * * add any methods needed in the public and creator facets. + * + * @type {HandleParamGovernance} + */ +const handleParamGovernance = (zcf, governedParamsTemplate) => { + const terms = zcf.getTerms(); + /** @type {ParamDescriptions} */ + const governedParams = terms.main; + const { electionManager } = terms; + + assert( + sameStructure(governedParams, governedParamsTemplate), + X`Terms must include ${q(governedParamsTemplate)}, but were ${q( + governedParams, + )}`, + ); + const paramManager = buildParamManager(governedParams); + + const makePublicFacet = (originalPublicFacet = {}) => { + return Far('publicFacet', { + ...originalPublicFacet, + getSubscription: () => paramManager.getSubscription(), + getContractGovernor: () => electionManager, + getGovernedParamsValues: () => { + return { main: paramManager.getParams() }; + }, + }); + }; + + /** @type {LimitedCreatorFacet} */ + const limitedCreatorFacet = Far('governedContract creator facet', { + getContractGovernor: () => electionManager, + }); + + const makeCreatorFacet = (originalCreatorFacet = Far('creatorFacet', {})) => { + return Far('creatorFacet', { + getParamMgrRetriever: () => { + return Far('paramRetriever', { get: () => paramManager }); + }, + getInternalCreatorFacet: () => originalCreatorFacet, + getLimitedCreatorFacet: () => limitedCreatorFacet, + }); + }; + + return harden({ + makePublicFacet, + makeCreatorFacet, + }); +}; +harden(handleParamGovernance); +export { handleParamGovernance }; diff --git a/packages/governance/src/exported.js b/packages/governance/src/exported.js new file mode 100644 index 00000000000..90d2c4eeb4a --- /dev/null +++ b/packages/governance/src/exported.js @@ -0,0 +1 @@ +import './types'; diff --git a/packages/governance/src/governParam.js b/packages/governance/src/governParam.js new file mode 100644 index 00000000000..faa1f7f9a5f --- /dev/null +++ b/packages/governance/src/governParam.js @@ -0,0 +1,157 @@ +// @ts-check + +import { E } from '@agoric/eventual-send'; +import { Far } from '@agoric/marshal'; +import { makePromiseKit } from '@agoric/promise-kit'; +import { sameStructure } from '@agoric/same-structure'; + +import { q } from '@agoric/assert'; +import { + ChoiceMethod, + QuorumRule, + ElectionType, + looksLikeQuestionSpec, +} from './question.js'; +import { assertType } from './paramManager.js'; + +const { details: X } = assert; + +/** @type {MakeParamChangePositions} */ +const makeParamChangePositions = (paramSpec, proposedValue) => { + const positive = harden({ changeParam: paramSpec, proposedValue }); + const negative = harden({ noChange: paramSpec }); + return { positive, negative }; +}; + +/** @type {ValidateParamChangeQuestion} */ +const validateParamChangeQuestion = details => { + assert( + details.method === ChoiceMethod.UNRANKED, + X`ChoiceMethod must be UNRANKED, not ${details.method}`, + ); + assert( + details.electionType === ElectionType.PARAM_CHANGE, + X`ElectionType must be PARAM_CHANGE, not ${details.electionType}`, + ); + assert( + details.maxChoices === 1, + X`maxChoices must be 1, not ${details.maxChoices}`, + ); + assert( + details.quorumRule === QuorumRule.MAJORITY, + X`QuorumRule must be MAJORITY, not ${details.quorumRule}`, + ); + assert( + details.tieOutcome.noChange, + X`tieOutcome must be noChange, not ${details.tieOutcome}`, + ); +}; + +/** @type {AssertBallotConcernsQuestion} */ +const assertBallotConcernsQuestion = (paramName, questionDetails) => { + assert( + // @ts-ignore typescript isn't sure the question is a paramChangeIssue + // if it isn't, the assertion will fail. + questionDetails.issue.paramSpec.parameterName === paramName, + X`expected ${q(paramName)} to be included`, + ); +}; + +/** @type {SetupGovernance} */ +const setupGovernance = async ( + paramManagerRetriever, + poserFacet, + contractInstance, + timer, +) => { + /** @type {WeakSet} */ + const voteCounters = new WeakSet(); + + /** @type {VoteOnParamChange} */ + const voteOnParamChange = async ( + paramSpec, + proposedValue, + voteCounterInstallation, + deadline, + ) => { + const paramMgr = E(paramManagerRetriever).get(paramSpec); + const paramName = paramSpec.parameterName; + const param = await E(paramMgr).getParam(paramName); + assertType(param.type, proposedValue, paramName); + const outcomeOfUpdateP = makePromiseKit(); + + const { positive, negative } = makeParamChangePositions( + paramSpec, + proposedValue, + ); + const issue = harden({ + paramSpec, + contract: contractInstance, + proposedValue, + }); + const questionSpec = looksLikeQuestionSpec({ + method: ChoiceMethod.UNRANKED, + issue, + positions: [positive, negative], + electionType: ElectionType.PARAM_CHANGE, + maxChoices: 1, + closingRule: { timer, deadline }, + quorumRule: QuorumRule.MAJORITY, + tieOutcome: negative, + }); + + const { publicFacet: counterPublicFacet, instance: voteCounter } = await E( + poserFacet, + ).addQuestion(voteCounterInstallation, questionSpec); + + voteCounters.add(voteCounter); + + // CRUCIAL: Here we wait for the voteCounter to declare an outcome, and then + // attempt to update the value of the parameter if that's what the vote + // decided. We need to make sure that outcomeOfUpdateP is updated whatever + // happens. + // * If the vote was negative, resolve to the outcome + // * If we update the value, say so + // * If the update fails, reject the promise + // * if the vote outcome failed, reject the promise. + E(counterPublicFacet) + .getOutcome() + .then(outcome => { + if (sameStructure(positive, outcome)) { + E(paramMgr) + [`update${paramName}`](proposedValue) + .then(newValue => outcomeOfUpdateP.resolve(newValue)) + .catch(e => { + outcomeOfUpdateP.reject(e); + }); + } else { + outcomeOfUpdateP.resolve(negative); + } + }) + .catch(e => { + outcomeOfUpdateP.reject(e); + }); + + return { + outcomeOfUpdate: outcomeOfUpdateP.promise, + instance: voteCounter, + details: E(counterPublicFacet).getDetails(), + }; + }; + + return Far('paramGovernor', { + voteOnParamChange, + createdQuestion: b => voteCounters.has(b), + }); +}; + +harden(setupGovernance); +harden(makeParamChangePositions); +harden(validateParamChangeQuestion); +harden(assertBallotConcernsQuestion); +export { + setupGovernance, + makeParamChangePositions, + validateParamChangeQuestion, + assertBallotConcernsQuestion, +}; diff --git a/packages/governance/src/index.js b/packages/governance/src/index.js new file mode 100644 index 00000000000..b418b12b438 --- /dev/null +++ b/packages/governance/src/index.js @@ -0,0 +1,31 @@ +// @ts-check + +export { + ChoiceMethod, + ElectionType, + QuorumRule, + looksLikeQuestionSpec, + positionIncluded, + looksLikeIssueForType, + buildUnrankedQuestion, +} from './question.js'; + +export { + validateQuestionDetails, + validateQuestionFromCounter, +} from './contractGovernor.js'; + +export { handleParamGovernance } from './contractHelper.js'; + +export { + makeParamChangePositions, + validateParamChangeQuestion, + assertBallotConcernsQuestion, +} from './governParam.js'; + +export { ParamType, assertType } from './paramManager.js'; + +export { + assertContractGovernance, + assertContractElectorate, +} from './validators.js'; diff --git a/packages/governance/src/internalTypes.js b/packages/governance/src/internalTypes.js new file mode 100644 index 00000000000..06df0008409 --- /dev/null +++ b/packages/governance/src/internalTypes.js @@ -0,0 +1,6 @@ +/** + * @typedef {Object} QuestionRecord + * @property {ERef} voteCap + * @property {VoteCounterPublicFacet} publicFacet + * @property {Timestamp} deadline + */ diff --git a/packages/governance/src/paramManager.js b/packages/governance/src/paramManager.js index 5530bc69274..91ee435dce4 100644 --- a/packages/governance/src/paramManager.js +++ b/packages/governance/src/paramManager.js @@ -1,11 +1,13 @@ // @ts-check -import { assert, details as X } from '@agoric/assert'; import { assertIsRatio } from '@agoric/zoe/src/contractSupport/index.js'; import { AmountMath, looksLikeBrand } from '@agoric/ertp'; import { Far } from '@agoric/marshal'; import { assertKeywordName } from '@agoric/zoe/src/cleanProposal.js'; import { Nat } from '@agoric/nat'; +import { makeSubscriptionKit } from '@agoric/notifier'; + +const { details: X } = assert; /** * @type {{ @@ -18,6 +20,10 @@ import { Nat } from '@agoric/nat'; * STRING: 'string', * UNKNOWN: 'unknown', * }} + * + * UNKNOWN is an escape hatch for types we haven't added yet. If you are + * developing a new contract and use UNKNOWN, please also file an issue to ask + * us to support the new type. */ const ParamType = { AMOUNT: 'amount', @@ -29,16 +35,18 @@ const ParamType = { STRING: 'string', UNKNOWN: 'unknown', }; -harden(ParamType); +/** @type {AssertParamManagerType} */ const assertType = (type, value, name) => { switch (type) { case ParamType.AMOUNT: // It would be nice to have a clean way to assert something is an amount. + // @ts-ignore value is undifferentiated to this point AmountMath.coerce(value.brand, value); break; case ParamType.BRAND: assert( + // @ts-ignore value is undifferentiated to this point looksLikeBrand(value), X`value for ${name} must be a brand, was ${value}`, ); @@ -46,7 +54,8 @@ const assertType = (type, value, name) => { case ParamType.INSTALLATION: // TODO(3344): add a better assertion once Zoe validates installations assert( - typeof value === 'object' && !Object.getOwnPropertyNames(value).length, + typeof value === 'object' && + Object.getOwnPropertyNames(value).length === 1, X`value for ${name} must be an Installation, was ${value}`, ); break; @@ -67,8 +76,6 @@ const assertType = (type, value, name) => { case ParamType.STRING: assert.typeof(value, 'string'); break; - // This is an escape hatch for types we haven't added yet. If you need to - // use it, please file an issue and ask us to support the new type. case ParamType.UNKNOWN: break; default: @@ -76,12 +83,13 @@ const assertType = (type, value, name) => { } }; -const parse = paramDesc => { +/** @type {BuildParamManager} */ +const buildParamManager = paramDescriptions => { const typesAndValues = {}; - // manager has an updateFoo() for each Foo param. It will be returned. - const manager = {}; - - paramDesc.forEach(({ name, value, type }) => { + // updateFns will have updateFoo() for each Foo param. + const updateFns = {}; + const { publication, subscription } = makeSubscriptionKit(); + paramDescriptions.forEach(({ name, value, type }) => { // we want to create function names like updateFeeRatio(), so we insist that // the name has Keyword-nature. assertKeywordName(name); @@ -93,37 +101,47 @@ const parse = paramDesc => { assertType(type, value, name); typesAndValues[name] = { type, value }; - manager[`update${name}`] = newValue => { + + // CRUCIAL: here we're creating the update functions that can change the + // values of the governed contract's parameters. We'll return the updateFns + // to our caller. They must handle them carefully to ensure that they end up + // in appropriate hands. + updateFns[`update${name}`] = newValue => { assertType(type, newValue, name); typesAndValues[name].value = newValue; + + publication.updateState({ name, type, value }); + return newValue; }; }); + const makeDescription = name => ({ + name, + type: typesAndValues[name].type, + value: typesAndValues[name].value, + }); const getParams = () => { /** @type {Record} */ const descriptions = {}; Object.getOwnPropertyNames(typesAndValues).forEach(name => { - descriptions[name] = { - name, - type: typesAndValues[name].type, - value: typesAndValues[name].value, - }; + descriptions[name] = makeDescription(name); }); return harden(descriptions); }; + const getParam = name => harden(makeDescription(name)); - return { getParams, manager }; -}; - -/** @type {BuildParamManager} */ -const buildParamManager = paramDesc => { - const { getParams, manager } = parse(paramDesc); - + // CRUCIAL: Contracts that call buildParamManager should only export the + // resulting paramManager to their creatorFacet, where it will be picked up by + // contractGovernor. The getParams method can be shared widely. return Far('param manager', { getParams, - ...manager, + getSubscription: () => subscription, + getParam, + ...updateFns, }); }; -harden(buildParamManager); -export { ParamType, buildParamManager }; +harden(ParamType); +harden(buildParamManager); +harden(assertType); +export { ParamType, buildParamManager, assertType }; diff --git a/packages/governance/src/question.js b/packages/governance/src/question.js new file mode 100644 index 00000000000..119a54998da --- /dev/null +++ b/packages/governance/src/question.js @@ -0,0 +1,205 @@ +// @ts-check + +import { Far, passStyleOf } from '@agoric/marshal'; +import { sameStructure } from '@agoric/same-structure'; +import { makeHandle } from '@agoric/zoe/src/makeHandle.js'; +import { Nat } from '@agoric/nat'; + +import { assertType, ParamType } from './paramManager.js'; + +const { details: X, quote: q } = assert; + +// Topics being voted on are 'Questions'. Before a Question is known to a +// electorate, the parameters can be described with a QuestionSpec. Once the +// question has been presented to an Electorate, there is a QuestionDetails +// record that also includes the VoteCounter which will determine the outcome +// and the questionHandle that uniquely identifies it. + +/** + * "unranked" is more formally known as "approval" voting, but this is hard for + * people to intuit when there are only two alternatives. + * + * @type {{ + * UNRANKED: 'unranked', + * ORDER: 'order', + * }} + */ +const ChoiceMethod = { + UNRANKED: 'unranked', + ORDER: 'order', +}; + +/** @type {{ + * PARAM_CHANGE: 'param_change', + * ELECTION: 'election', + * SURVEY: 'survey', + * }} + */ +const ElectionType = { + // A parameter is named, and a new value proposed + PARAM_CHANGE: 'param_change', + // choose one or multiple winners, depending on ChoiceMethod + ELECTION: 'election', + SURVEY: 'survey', +}; + +/** @type {{ + * MAJORITY: 'majority', + * NO_QUORUM: 'no_quorum', + * ALL: 'all', + * }} + */ +const QuorumRule = { + MAJORITY: 'majority', + NO_QUORUM: 'no_quorum', + // The election isn't valid unless all voters vote + ALL: 'all', +}; + +/** @type {LooksLikeSimpleIssue} */ +const looksLikeSimpleIssue = issue => { + assert.typeof(issue, 'object', X`Issue ("${issue}") must be a record`); + assert( + issue && typeof issue.text === 'string', + X`Issue ("${issue}") must be a record with text: aString`, + ); + return undefined; +}; + +/** @type {LooksLikeParamChangeIssue} */ +const looksLikeParamChangeIssue = issue => { + assert(issue, X`argument to looksLikeParamChangeIssue cannot be null`); + assert.typeof(issue, 'object', X`Issue ("${issue}") must be a record`); + assert.typeof( + issue && issue.paramSpec, + 'object', + X`Issue ("${issue}") must be a record with paramSpec: anObject`, + ); + assert(issue && issue.proposedValue); + assertType(ParamType.INSTANCE, issue.contract, 'contract'); +}; + +/** @type {LooksLikeIssueForType} */ +const looksLikeIssueForType = (electionType, issue) => { + assert( + passStyleOf(issue) === 'copyRecord', + X`A question can only be a pass-by-copy record: ${issue}`, + ); + + switch (electionType) { + case ElectionType.SURVEY: + case ElectionType.ELECTION: + looksLikeSimpleIssue(/** @type {SimpleIssue} */ (issue)); + break; + case ElectionType.PARAM_CHANGE: + looksLikeParamChangeIssue(/** @type {ParamChangeIssue} */ (issue)); + break; + default: + throw Error(`Election type unrecognized`); + } +}; + +/** @type {PositionIncluded} */ +const positionIncluded = (positions, p) => + positions.some(e => sameStructure(e, p)); + +// QuestionSpec contains the subset of QuestionDetails that can be specified before +/** @type {LooksLikeClosingRule} */ +function looksLikeClosingRule(closingRule) { + assert(closingRule, X`argument to looksLikeClosingRule cannot be null`); + assert.typeof( + closingRule, + 'object', + X`ClosingRule ("${closingRule}") must be a record`, + ); + Nat(closingRule && closingRule.deadline); + const timer = closingRule && closingRule.timer; + assert(passStyleOf(timer) === 'remotable', X`Timer must be a timer ${timer}`); +} + +const assertEnumIncludes = (enumeration, value, name) => { + assert( + Object.getOwnPropertyNames(enumeration) + .map(k => enumeration[k]) + .includes(value), + X`Illegal ${name}: ${value}`, + ); +}; + +/** @type {LooksLikeQuestionSpec} */ +const looksLikeQuestionSpec = ({ + method, + issue, + positions, + electionType, + maxChoices, + closingRule, + quorumRule, + tieOutcome, +}) => { + looksLikeIssueForType(electionType, issue); + + assert( + positions.every( + p => passStyleOf(p) === 'copyRecord', + X`positions must be records`, + ), + ); + assert( + positionIncluded(positions, tieOutcome), + X`tieOutcome must be a legal position: ${q(tieOutcome)}`, + ); + assertEnumIncludes(QuorumRule, quorumRule, 'QuorumRule'); + assertEnumIncludes(ElectionType, electionType, 'ElectionType'); + assertEnumIncludes(ChoiceMethod, method, 'ChoiceMethod'); + assert(maxChoices > 0, X`maxChoices must be positive: ${maxChoices}`); + assert(maxChoices <= positions.length, X`Choices must not exceed length`); + + looksLikeClosingRule(closingRule); + + return harden({ + method, + issue, + positions, + maxChoices: Number(maxChoices), + electionType, + closingRule, + quorumRule, + tieOutcome, + }); +}; + +/** @type {BuildUnrankedQuestion} */ +const buildUnrankedQuestion = (questionSpec, counterInstance) => { + const questionHandle = makeHandle('Question'); + + const getDetails = () => + harden({ + ...questionSpec, + questionHandle, + counterInstance, + }); + + /** @type {Question} */ + return Far('question details', { + getVoteCounter: () => counterInstance, + getDetails, + }); +}; + +harden(ChoiceMethod); +harden(QuorumRule); +harden(ElectionType); +harden(looksLikeIssueForType); +harden(positionIncluded); +harden(buildUnrankedQuestion); + +export { + ChoiceMethod, + ElectionType, + QuorumRule, + looksLikeQuestionSpec, + positionIncluded, + looksLikeIssueForType, + buildUnrankedQuestion, +}; diff --git a/packages/governance/src/registrarTools.js b/packages/governance/src/registrarTools.js new file mode 100644 index 00000000000..41847bc266d --- /dev/null +++ b/packages/governance/src/registrarTools.js @@ -0,0 +1,64 @@ +// @ts-check + +import { E } from '@agoric/eventual-send'; +import { allComparable } from '@agoric/same-structure'; +import { Far } from '@agoric/marshal'; + +const startCounter = async ( + zcf, + questionSpec, + quorumThreshold, + voteCounter, + questionStore, + publication, +) => { + const ballotCounterTerms = { + questionSpec, + electorate: zcf.getInstance(), + quorumThreshold, + }; + + // facets of the voteCounter. creatorInvitation and adminFacet not used + const { creatorFacet, publicFacet, instance } = await E( + zcf.getZoeService(), + ).startInstance(voteCounter, {}, ballotCounterTerms); + const details = await E(publicFacet).getDetails(); + const { deadline } = questionSpec.closingRule; + publication.updateState(details); + const questionHandle = details.questionHandle; + + const voteCounterFacets = { voteCap: creatorFacet, publicFacet, deadline }; + + questionStore.init(questionHandle, voteCounterFacets); + + return { creatorFacet, publicFacet, instance, deadline, questionHandle }; +}; + +const getOpenQuestions = async questionStore => { + const isOpenPQuestions = questionStore.keys().map(key => { + const { publicFacet } = questionStore.get(key); + return [E(publicFacet).isOpen(), key]; + }); + + const isOpenQuestions = await allComparable(harden(isOpenPQuestions)); + return isOpenQuestions + .filter(([open, _key]) => open) + .map(([_open, key]) => key); +}; + +const getQuestion = (questionHandleP, questionStore) => + E.when(questionHandleP, questionHandle => + E(questionStore.get(questionHandle).publicFacet).getQuestion(), + ); + +const getPoserInvitation = (zcf, addQuestion) => { + const questionPoserHandler = () => Far(`questionPoser`, { addQuestion }); + return zcf.makeInvitation(questionPoserHandler, `questionPoser`); +}; + +harden(startCounter); +harden(getOpenQuestions); +harden(getQuestion); +harden(getPoserInvitation); + +export { startCounter, getOpenQuestions, getQuestion, getPoserInvitation }; diff --git a/packages/governance/src/types.js b/packages/governance/src/types.js index cef25746770..bfbd84ab595 100644 --- a/packages/governance/src/types.js +++ b/packages/governance/src/types.js @@ -1,75 +1,139 @@ // @ts-check /** - * @typedef { 'amount' | 'brand' | 'installation' | 'instance' | 'nat' | 'ratio' | 'string' | 'unknown' } ParamType + * @typedef { 'unranked' | 'order' } ChoiceMethod + * * UNRANKED: "unranked voting" means that the voter specifies some number of + * positions, and is endorsing them equally. + * * ORDER: The voter assigns ordinal numbers to some of the positions. The + * positions will be treated as an ordered list with no gaps. + * + * When voters are limited to choosing a single candidate, either UNRANKED or + * ORDER would work. UNRANKED has a simpler representation so we use that. */ /** - * @typedef { Amount | Brand | Installation | Instance | bigint | Ratio | string | unknown } ParamValue + * @typedef { 'param_change' | 'election' | 'survey' } ElectionType + * param_change is very specific. Survey means multiple answers are possible, + * Election means some candidates are going to "win". It's not clear these are + * orthogonal. The important distinction is that param_change has a structured + * issue, while the others have a issue presented as a string. */ /** - * @typedef { 'choose_n' | 'order' | 'weight' } ChoiceMethod + * @typedef { 'amount' | 'brand' | 'instance' | 'installation' | 'nat' | + * 'ratio' | 'string' | 'unknown' } ParamType */ /** - * @typedef {Object} ParamDescription - * @property {string} name - * @property {ParamValue} value - * @property {ParamType} type + * @typedef { 'majority' | 'all' | 'no_quorum' } QuorumRule */ /** - * @typedef {Object} ParamManagerBase - * @property {() => Record} getParams - * - * @typedef {{ [updater: string]: (arg: ParamValue) => void }} ParamManagerUpdaters - * @typedef {ParamManagerBase & ParamManagerUpdaters} ParamManagerFull + * @typedef {Object} SimpleIssue + * @property {string} text */ /** - * @typedef {Array} ParamDescriptions + * @typedef { Amount | Brand | Installation | Instance | bigint | + * Ratio | string | unknown } ParamValue */ /** - * @callback BuildParamManager - * @param {ParamDescriptions} paramDesc - * @returns {ParamManagerFull} + * @template T + * @typedef {{ type: T, name: string }} ParamRecord */ /** - * @typedef {Object} QuestionTermsShort - * BallotDetails as provided to the Registrar - * @property {ChoiceMethod} method - * @property {string} question - * @property {string[]} positions - * @property {number} maxChoices - * @property {ClosingRule} closingRule + * @typedef {ParamRecord<'amount'> & { value: Amount } | + * ParamRecord<'brand'> & { value: Brand } | + * ParamRecord<'installation'> & { value: Installation } | + * ParamRecord<'instance'> & { value: Instance } | + * ParamRecord<'nat'> & { value: bigint } | + * ParamRecord<'ratio'> & { value: Ratio } | + * ParamRecord<'string'> & { value: string } | + * ParamRecord<'unknown'> & { value: unknown } + * } ParamDescription + */ + +/** + * @typedef { SimpleIssue | ParamChangeIssue } Issue */ /** - * @typedef {Object} QuestionTerms - * BallotDetails after the Registrar adds its Instance + * @typedef {Object} QuestionTerms - QuestionSpec plus the Electorate Instance and + * a numerical threshold for the quorum. (The voteCounter doesn't know the + * size of the electorate, so the Electorate has to say what limit to enforce.) + * @property {QuestionSpec} questionSpec + * @property {number} quorumThreshold + * @property {Instance} electorate + */ + +/** + * @typedef {Object} TextPosition + * @property {string} text + */ + +/** + * @typedef { TextPosition | ChangeParamPosition | + * NoChangeParamPosition } Position + */ + +/** + * @typedef {Object} QuestionSpec + * Specification when requesting creation of a Question * @property {ChoiceMethod} method - * @property {string} question - * @property {string[]} positions + * @property {Issue} issue + * @property {Position[]} positions + * @property {ElectionType} electionType * @property {number} maxChoices * @property {ClosingRule} closingRule - * @property {Instance} registrar + * @property {QuorumRule} quorumRule + * @property {Position} tieOutcome */ + /** - * @typedef {Object} BallotDetails - * BallotDetails after the Registrar adds its Instance - * @property {ChoiceMethod} method - * @property {string} question - * @property {string[]} positions - * @property {number} maxChoices + * @typedef {Object} QuestionDetailsExtraProperties + * @property {Instance} counterInstance - instance of the VoteCounter + * @property {Handle<'Question'>} questionHandle */ /** - * @typedef {Object} Ballot - * @property {(positions: string[]) => CompletedBallot} choose - * @property {() => BallotDetails} getDetails + * @typedef {QuestionSpec & QuestionDetailsExtraProperties} QuestionDetails + * complete question details: questionSpec plus counter and questionHandle + */ + +/** + * @typedef {Object} GovernancePair + * @property {Instance} governor + * @property {Instance} governed + */ + +/** + * @typedef {Object} Question + * @property {() => Instance} getVoteCounter + * @property {() => QuestionDetails} getDetails + */ + +/** + * @typedef {Object} CompleteUnrankedQuestion + * @property {Handle<'Question'>} questionHandle + * @property {Position[]} chosen - a list of equal-weight preferred positions + */ + +// not yet in use +/** + * @typedef {Object} CompleteWeightedBallot + * @property {Handle<'Question'>} questionHandle + * @property {[Position,bigint][]} weighted - list of positions with + * weights. VoteCounter may limit weights to a range or require uniqueness. + */ + +// not yet in use +/** + * @typedef {Object} CompleteOrderedBallot + * @property {Handle<'Question'>} questionHandle + * @property {Position[]} ordered - ordered list of position from most preferred + * to least preferred */ /** @@ -87,79 +151,124 @@ /** * @typedef {Object} QuorumCounter - * @property {(VoteStatistics) => boolean} check + * @property {(stats: VoteStatistics) => boolean} check */ /** - * @callback BuildBallot - * @param {ChoiceMethod} method - * @param {string} question - * @param {string[]} positions - * @param {number} maxChoices - * @param {Instance} instance - ballotCounter instance - * @returns {Ballot} + * @callback BuildUnrankedQuestion + * @param {QuestionSpec} questionSpec + * @param {Instance} instance - voteCounter instance + * @returns {Question} */ /** - * @typedef {Object} BallotCounterCreatorFacet - * @property {() => boolean} isOpen - * @property {() => Ballot} getBallotTemplate - * @property {() => VoterFacet} getVoterFacet + * @typedef {Object} VoteCounterCreatorFacet - a facet that the Electorate should + * hold tightly. submitVote() is the core capability that allows the holder to + * specify the identity and choice of a voter. The voteCounter is making that + * available to the Electorate, which should wrap and attenuate it so each + * voter gets only the ability to cast their own vote at a weight specified by + * the electorate. + * @property {SubmitVote} submitVote */ /** - * @typedef {Object} BallotCounterPublicFacet + * @typedef {Object} VoteCounterPublicFacet * @property {() => boolean} isOpen - * @property {() => Ballot} getBallotTemplate - * @property {() => Promise} getOutcome + * @property {() => Question} getQuestion + * @property {() => Promise} getOutcome + * @property {() => QuestionDetails} getDetails * @property {() => Promise} getStats */ /** - * @typedef {Object} BallotCounterCloseFacet + * @typedef {Object} VoteCounterCloseFacet * TEST ONLY: Should not be allowed to escape from contracts * @property {() => void} closeVoting */ /** - * @typedef {Object} CompleteEqualWeightBallot - * @property {string} question - * @property {string[]} chosen - a list of equal-weight preferred positions + * @typedef {Object} VoteCounterFacets + * @property {VoteCounterPublicFacet} publicFacet + * @property {VoteCounterCreatorFacet} creatorFacet + * @property {VoteCounterCloseFacet} closeFacet */ /** - * @typedef {Object} CompleteWeightedBallot - * @property {string} question - * @property {Record[]} weighted - list of positions with weights. - * BallotCounter may limit weights to a range or require uniqueness. + * @callback BuildVoteCounter + * @param {QuestionSpec} questionSpec + * @param {bigint} threshold - questionSpec includes quorumRule; the electorate + * converts that to a number that the counter can enforce. + * @param {Instance} instance + * @returns {VoteCounterFacets} */ /** - * @typedef {Object} CompleteOrderedBallot - * @property {string} question - * @property {string[]} ordered - ordered list of position from most preferred to - * least preferred + * @callback LooksLikeQuestionSpec + * @param {unknown} allegedQuestionSpec + * @returns {QuestionSpec} + */ + +/** + * @callback LooksLikeParamChangeIssue + * @param {unknown} issue + * @returns { asserts issue is ParamChangeIssue } */ /** - * @typedef { CompleteEqualWeightBallot | CompleteOrderedBallot | CompleteWeightedBallot } CompletedBallot + * @callback LooksLikeIssueForType + * @param {ElectionType} electionType + * @param {unknown} issue + * @returns { asserts issue is Issue } + */ + +/** + * @callback LooksLikeSimpleIssue + * @param {unknown} issue + * @returns { asserts issue is SimpleIssue } + */ + +/** + * @callback LooksLikeClosingRule + * @param {unknown} closingRule + * @returns { asserts closingRule is ClosingRule } */ /** * @callback SubmitVote - * @param {Handle<'Voter'>} seat - * @param {ERef} filledBallot + * @param {Handle<'Voter'>} voterHandle + * @param {Position[]} chosenPositions * @param {bigint=} weight */ /** - * @typedef {Object} VoterFacet - * @property {SubmitVote} submitVote + * @typedef {Object} ElectoratePublic + * @property {() => Subscription} getQuestionSubscription + * @property {() => Promise[]>} getOpenQuestions, + * @property {() => string} getName + * @property {() => Instance} getInstance + * @property {(h: Handle<'Question'>) => Promise} getQuestion + */ + +/** + * @typedef {Object} PoserFacet + * @property {AddQuestion} addQuestion + */ + +/** + * @typedef {Object} ElectorateCreatorFacet + * addQuestion() can be used directly when the creator doesn't need any + * reassurance. When someone needs to connect addQuestion to the Electorate + * instance, getPoserInvitation() lets them get addQuestion with assurance. + * @property {() => Promise} getPoserInvitation + * @property {AddQuestion} addQuestion + * @property {() => Promise[]} getVoterInvitations + * @property {() => Subscription} getQuestionSubscription + * @property {() => ElectoratePublic} getPublicFacet */ /** * @typedef {Object} ClosingRule - * @property {Timer} timer + * @property {ERef} timer * @property {Timestamp} deadline */ @@ -171,14 +280,306 @@ /** * @typedef {Object} AddQuestionReturn - * @property {BallotCounterPublicFacet} publicFacet - * @property {BallotCounterCreatorFacet} creatorFacet + * @property {VoteCounterPublicFacet} publicFacet + * @property {VoteCounterCreatorFacet} creatorFacet * @property {Instance} instance */ /** * @callback AddQuestion * @param {Installation} voteCounter - * @param {QuestionTermsShort} questionDetailsShort + * @param {QuestionSpec} questionSpec * @returns {Promise} */ + +/** + * @typedef QuestionCreator + * @property {AddQuestion} addQuestion + */ + +/** + * @callback CreateQuestion + * + * @param {string} name - The name of the parameter to change + * @param {ParamValue} proposedValue - the proposed value for the named + * parameter + * @param {Installation} voteCounterInstallation - the voteCounter to + * instantiate to count votes. Expected to be a binaryVoteCounter. Other + * voteCounters might be added here, or might require separate governors. + * under management so users can trace it back and see that it would use + * this electionManager to manage parameters + * @param {Instance} contractInstance - include the instance of the contract + * @param {ClosingRule} closingRule - deadline and timer for closing voting + * @returns {Promise} + */ + +/** + * @typedef {Object} ParamChangeIssue + * @property {ParamSpecification} paramSpec + * @property {Instance} contract + * @property {ParamValue} proposedValue + */ + +/** + * @typedef {Object} ParamChangePositions + * @property {ChangeParamPosition} positive + * @property {NoChangeParamPosition} negative + */ + +/** + * @callback MakeParamChangePositions + * + * Return a record containing the positive and negative positions for a + * question on changing the param to the proposedValue. + * + * @param {ParamSpecification} paramSpec + * @param {ParamValue} proposedValue + * @returns {ParamChangePositions} + */ + +/** + * @typedef {Object} ParamChangeIssueDetails + * details for a question that can change a contract parameter + * @property {ChoiceMethod} method + * @property {ParamChangeIssue} issue + * @property {ParamChangePositions} positions + * @property {ElectionType} electionType + * @property {number} maxChoices + * @property {ClosingRule} closingRule + * @property {QuorumRule} quorumRule + * @property {NoChangeParamPosition} tieOutcome + * @property {Instance} counterInstance - instance of the VoteCounter + * @property {Handle<'Question'>} questionHandle + */ + +/** + * @typedef {Object} ParamManagerBase + * @property {() => Record} getParams + * @property {(name: string) => ParamDescription} getParam + * @property {() => Subscription} getSubscription + */ + +/** + * @typedef {{ [updater: string]: (arg: ParamValue) => void + * }} ParamManagerUpdaters + * @typedef {ParamManagerBase & ParamManagerUpdaters} ParamManagerFull + */ + +/** + * @typedef {Array} ParamDescriptions + */ + +/** + * @typedef {Record} ParameterNameList + */ + +/** + * @callback AssertParamManagerType + * @param {ParamType} type + * @param {ParamValue} value + * @param {string} name + */ + +/** + * @callback BuildParamManager - ParamManager is a facility that governed + * contracts can use to manage their visible state in a way that allows the + * ContractGovernor to update values using governance. When paramManager is + * instantiated inside the contract, the contract has synchronous access to + * the values, and clients of the contract can verify that a ContractGovernor + * can change the values in a legible way. + * @param {ParamDescriptions} paramDescriptions + * @returns {ParamManagerFull} + */ + +/** + * @typedef {Object} ChangeParamPosition + * @property {ParamSpecification} changeParam + * @property {ParamValue} proposedValue + */ + +/** + * @typedef {Object} NoChangeParamPosition + * @property {ParamSpecification} noChange + */ + +/** + * @typedef {Object} Governor + * @property {CreateQuestion} createQuestion + */ + +/** + * @typedef {Object} GovernorPublic + * @property {() => Instance} getElectorate + * @property {() => Instance} getGovernedContract + * @property {(voteCounter: Instance) => Promise} validateVoteCounter + * @property {(regP: ERef) => Promise} validateElectorate + * @property {(details: QuestionDetails) => boolean} validateTimer + */ + +/** + * @typedef {Object} ParamSpecification + * @property {string} key + * @property {string} parameterName + */ + +/** + * @typedef {Object} ParamChangeVoteResult + * @property {Instance} instance - instance of the VoteCounter + * @property {ERef} details + * @property {Promise} outcomeOfUpdate - A promise for the result + * of updating the parameter value. Primarily useful for its behavior on + * rejection. + */ + +/** + * @typedef {Object} LimitedCreatorFacet + * + * The creatorFacet for the governed contract that will be passed to the + * responsible party. It does not have access to the paramManager. + * @property {() => Instance} getContractGovernor + */ + +/** + * @typedef {Object} ContractPowerfulCreatorFacet + * + * A powerful facet that carries access to both the creatorFacet to be passed + * to the caller and the paramManager, which will be used exclusively by the + * ContractGovenor. + * @property {() => Promise} getLimitedCreatorFacet + * @property {() => ParamManagerRetriever} getParamMgrRetriever + */ + +/** + * @typedef {Object} GovernedContractFacetAccess + * @property {VoteOnParamChange} voteOnParamChange + * @property {() => Promise} getCreatorFacet - creator + * facet of the governed contract, without the tightly held ability to change + * param values. + * @property {() => any} getPublicFacet - public facet of the governed contract + * @property {() => Promise} getInstance - instance of the governed + * contract + */ + +/** + * @callback HandleParamGovernance + * @param {ContractFacet} zcf + * @param {ParamDescriptions} governedParamsTemplate + */ + +/** + * @callback AssertBallotConcernsQuestion + * @param {string} paramName + * @param {QuestionDetails} questionDetails + */ + +/** + * @typedef {Object} ParamManagerRetriever + * @property {(paramSpec: ParamSpecification) => ParamManagerFull} get + */ + +/** + * @callback VoteOnParamChange + * @param {ParamSpecification} paramSpec + * @param {ParamValue} proposedValue + * @param {Installation} voteCounterInstallation + * @param {bigint} deadline + * @returns {ParamChangeVoteResult} + */ + +/** + * @typedef {Object} ParamGovernor + * @property {VoteOnParamChange} voteOnParamChange + * @property {CreatedQuestion} createdQuestion + */ + +/** + * @callback SetupGovernance + * @param {ERef} retriever + * @param {ERef} poserFacet + * @param {Instance} contractInstance + * @param {Timer} timer + * @returns {ParamGovernor} + */ + +/** + * @callback CreatedQuestion + * Was this question created by this ContractGovernor? + * @param {Instance} questionInstance + * @returns {boolean} + */ + +/** + * @callback PositionIncluded + * @param {Position[]} positions + * @param {Position} position + * @returns {boolean} + */ + +/** + * @typedef {Object} GovernedContractTerms + * @property {Timer} timer + * @property {IssuerKeywordRecord} issuerKeywordRecord + * @property {Object} privateArgs + */ + +/** + * @typedef {Object} ContractGovernorTerms + * @property {VoteOnParamChange} timer + * @property {Instance} electorateInstance + * @property {Installation} governedContractInstallation + * @property {GovernedContractTerms} governed + */ + +/** + * @callback AssertContractGovernance + * + * @param {ERef} zoe + * @param {Instance} allegedGoverned + * @param {Instance} allegedGovernor + * @param {Installation} contractGovernorInstallation + * @returns {Promise} + */ + +/** + * @callback AssertContractElectorate - assert that the contract uses the + * electorate + * + * @param {ERef} zoe + * @param {Instance} allegedGovernor + * @param {Instance} allegedElectorate + */ + +/** + * @callback ValidateQuestionDetails + * + * Validate that the question details correspond to a parameter change question + * that the electorate hosts, and that the voteCounter and other details are + * consistent with it. + * + * @param {ERef} zoe + * @param {Instance} electorate + * @param {ParamChangeIssueDetails} details + * @returns {Promise<*>} + */ + +/** + * @callback ValidateQuestionFromCounter + * + * Validate that the questions counted by the voteCounter correspond to a + * parameter change question that the electorate hosts, and that the + * voteCounter and other details are consistent. + * + * @param {ERef} zoe + * @param {Instance} electorate + * @param {Instance} voteCounter + * @returns {Promise<*>} + */ + +/** + * @callback ValidateParamChangeQuestion + * + * Validate that the details are appropriate for an election concerning a + * parameter change for a governed contract. + * + * @param {ParamChangeIssueDetails} details + */ diff --git a/packages/governance/src/validators.js b/packages/governance/src/validators.js new file mode 100644 index 00000000000..17a6f8fee77 --- /dev/null +++ b/packages/governance/src/validators.js @@ -0,0 +1,75 @@ +// @ts-check + +import { E } from '@agoric/eventual-send'; + +const { details: X, quote: q } = assert; + +/** + * Assert that the governed contract was started by the governor. Throws if + * either direction can't be established. If the call succeeds, then the + * governor got exclusive access to the governed contract's creatorFacet, and + * can be trusted to manage its parameters. + * + * @type {AssertContractGovernance} + */ +const assertContractGovernance = async ( + zoe, + allegedGoverned, + allegedGovernor, + contractGovernorInstallation, +) => { + const allegedGovernorPF = E(zoe).getPublicFacet(allegedGovernor); + const realGovernedP = E(allegedGovernorPF).getGovernedContract(); + const allegedGovernedTermsP = E(zoe).getTerms(allegedGoverned); + + const [ + { electionManager: realGovernorInstance }, + realGovernedInstance, + ] = await Promise.all([allegedGovernedTermsP, realGovernedP]); + + assert( + allegedGovernor === realGovernorInstance, + X`The alleged governor did not match the governor retrieved from the governed contract`, + ); + + assert( + allegedGoverned === realGovernedInstance, + X`The alleged governed did not match the governed contract retrieved from the governor`, + ); + + const governorInstallationFromGoverned = await E( + zoe, + ).getInstallationForInstance(realGovernorInstance); + + assert( + governorInstallationFromGoverned === contractGovernorInstallation, + X`The governed contract is not governed by an instance of the provided installation.`, + ); + + return { governor: realGovernorInstance, governed: realGovernedInstance }; +}; + +/** + * Assert that the governor refers to the indicated electorate. + * + * @type {AssertContractElectorate} + */ +const assertContractElectorate = async ( + zoe, + allegedGovernor, + allegedElectorate, +) => { + const allegedGovernorPF = E(zoe).getPublicFacet(allegedGovernor); + const electorate = await E(allegedGovernorPF).getElectorate(); + + assert( + electorate === allegedElectorate, + X`The allegedElectorate didn't match the actual ${q(electorate)}`, + ); + + return true; +}; + +harden(assertContractGovernance); +harden(assertContractElectorate); +export { assertContractGovernance, assertContractElectorate }; diff --git a/packages/governance/test/swingsetTests/committeeBinary/bootstrap.js b/packages/governance/test/swingsetTests/committeeBinary/bootstrap.js index d5c0de5079a..306b189c5eb 100644 --- a/packages/governance/test/swingsetTests/committeeBinary/bootstrap.js +++ b/packages/governance/test/swingsetTests/committeeBinary/bootstrap.js @@ -4,112 +4,140 @@ import { E } from '@agoric/eventual-send'; import { Far } from '@agoric/marshal'; import buildManualTimer from '@agoric/zoe/tools/manualTimer.js'; +import { + ChoiceMethod, + QuorumRule, + ElectionType, + looksLikeQuestionSpec, +} from '../../../src/index.js'; + +const { quote: q } = assert; + const makeVoterVat = async (log, vats, zoe) => { const voterCreator = E(vats.voter).build(zoe); log(`=> voter vat is set up`); return voterCreator; }; -async function addQuestion(qDetails, closingTime, tools) { - const { registrarFacet, installations } = tools; - const { question, positions } = qDetails; +const createQuestion = async (qDetails, closingTime, tools, quorumRule) => { + const { electorateFacet, installations } = tools; + const { issue, positions, electionType } = qDetails; const closingRule = { timer: tools.timer, - deadline: 3n, + deadline: closingTime, }; - const ballotDetails = { - question, - positions, - quorumThreshold: 3n, - tieOutcome: undefined, - closingRule, - }; - const { instance: ballotInstance } = await E(registrarFacet).addQuestion( - installations.binaryBallotCounter, - ballotDetails, + const questionSpec = looksLikeQuestionSpec( + harden({ + method: ChoiceMethod.UNRANKED, + issue, + positions, + electionType, + maxChoices: 1, + closingRule, + quorumRule, + tieOutcome: positions[1], + }), + ); + + const { instance: counterInstance } = await E(electorateFacet).addQuestion( + installations.binaryVoteCounter, + questionSpec, ); - return ballotInstance; -} + return { counterInstance }; +}; -async function committeeBinaryStart( +const committeeBinaryStart = async ( zoe, voterCreator, timer, log, installations, -) { - const registrarTerms = { committeeName: 'TheCommittee', committeeSize: 5 }; - const { creatorFacet: registrarFacet, instance: registrarInstance } = await E( - zoe, - ).startInstance(installations.committeeRegistrar, {}, registrarTerms); - - const choose = 'Choose'; - const details = { question: choose, positions: ['Eeny', 'Meeny'] }; - const tools = { registrarFacet, installations, timer }; - const ballotInstance = await addQuestion(details, 3n, tools); - - const invitations = await E(registrarFacet).getVoterInvitations(); +) => { + const electorateTerms = { committeeName: 'TheCommittee', committeeSize: 5 }; + const { + creatorFacet: electorateFacet, + instance: electorateInstance, + } = await E(zoe).startInstance(installations.committee, {}, electorateTerms); + + const choose = { text: 'Choose' }; + const electionType = ElectionType.SURVEY; + const details = { + issue: choose, + positions: [harden({ text: 'Eeny' }), harden({ text: 'Meeny' })], + electionType, + }; + const [eeny, meeny] = details.positions; + const tools = { electorateFacet, installations, timer }; + const { counterInstance } = await createQuestion( + details, + 3n, + tools, + QuorumRule.MAJORITY, + ); + + const invitations = await E(electorateFacet).getVoterInvitations(); const details2 = await E(zoe).getInvitationDetails(invitations[2]); log( - `invitation details check: ${details2.instance === registrarInstance} ${ + `invitation details check: ${details2.instance === electorateInstance} ${ details2.description }`, ); - const aliceP = E(voterCreator).createVoter('Alice', invitations[0], 'Eeny'); - const bobP = E(voterCreator).createVoter('Bob', invitations[1], 'Meeny'); - const carolP = E(voterCreator).createVoter('Carol', invitations[2], 'Eeny'); - const daveP = E(voterCreator).createVoter('Dave', invitations[3], 'Eeny'); - const emmaP = E(voterCreator).createVoter('Emma', invitations[4], 'Meeny'); + const aliceP = E(voterCreator).createVoter('Alice', invitations[0], eeny); + const bobP = E(voterCreator).createVoter('Bob', invitations[1], meeny); + const carolP = E(voterCreator).createVoter('Carol', invitations[2], eeny); + const daveP = E(voterCreator).createVoter('Dave', invitations[3], eeny); + const emmaP = E(voterCreator).createVoter('Emma', invitations[4], meeny); const [alice] = await Promise.all([aliceP, bobP, carolP, daveP, emmaP]); // At least one voter should verify that everything is on the up-and-up - const instances = { registrarInstance, ballotInstance }; + const instances = { electorateInstance, counterInstance }; await E(alice).verifyBallot(choose, instances); await E(timer).tick(); await E(timer).tick(); await E(timer).tick(); - const publicFacet = E(zoe).getPublicFacet(ballotInstance); + const publicFacet = E(zoe).getPublicFacet(counterInstance); await E(publicFacet) .getOutcome() - .then(outcome => log(`vote outcome: ${outcome}`)) + .then(outcome => log(`vote outcome: ${q(outcome)}`)) .catch(e => log(`vote failed ${e}`)); -} +}; -async function committeeBinaryTwoQuestions( +const committeeBinaryTwoQuestions = async ( zoe, voterCreator, timer, log, installations, -) { +) => { log('starting TWO questions test'); - const registrarTerms = { committeeName: 'TheCommittee', committeeSize: 5 }; - const { creatorFacet: registrarFacet, instance: registrarInstance } = await E( - zoe, - ).startInstance(installations.committeeRegistrar, {}, registrarTerms); + const electorateTerms = { committeeName: 'TheCommittee', committeeSize: 5 }; + const { + creatorFacet: electorateFacet, + instance: electorateInstance, + } = await E(zoe).startInstance(installations.committee, {}, electorateTerms); - const invitations = await E(registrarFacet).getVoterInvitations(); + const invitations = await E(electorateFacet).getVoterInvitations(); const details2 = await E(zoe).getInvitationDetails(invitations[2]); log( - `invitation details check: ${details2.instance === registrarInstance} ${ + `invitation details check: ${details2.instance === electorateInstance} ${ details2.description }`, ); - const tools = { registrarFacet, installations, timer }; - const twoPotato = 'Two Potato'; - const onePotato = 'One Potato'; - const choose = 'Choose'; - const howHigh = 'How high?'; - const oneFoot = '1 foot'; - const twoFeet = '2 feet'; + const tools = { electorateFacet, installations, timer }; + const twoPotato = harden({ text: 'Two Potato' }); + const onePotato = harden({ text: 'One Potato' }); + const choose = { text: 'Choose' }; + const howHigh = { text: 'How high?' }; + const oneFoot = harden({ text: '1 foot' }); + const twoFeet = harden({ text: '2 feet' }); const aliceP = E(voterCreator).createMultiVoter('Alice', invitations[0], [ [choose, onePotato], @@ -132,21 +160,39 @@ async function committeeBinaryTwoQuestions( [howHigh, twoFeet], ]); - const potato = { question: choose, positions: [onePotato, twoPotato] }; - const potatoBallotInstance = await addQuestion(potato, 3n, tools); + const potato = { + issue: choose, + positions: [onePotato, twoPotato], + electionType: ElectionType.SURVEY, + }; + const { counterInstance: potatoCounterInstance } = await createQuestion( + potato, + 3n, + tools, + QuorumRule.MAJORITY, + ); - const height = { question: howHigh, positions: [oneFoot, twoFeet] }; - const heightBallotInstance = await addQuestion(height, 4n, tools); + const height = { + issue: howHigh, + positions: [oneFoot, twoFeet], + electionType: ElectionType.SURVEY, + }; + const { counterInstance: heightCounterInstance } = await createQuestion( + height, + 4n, + tools, + QuorumRule.MAJORITY, + ); const [alice, bob] = await Promise.all([aliceP, bobP, carolP, daveP, emmaP]); // At least one voter should verify that everything is on the up-and-up await E(alice).verifyBallot(choose, { - registrarInstance, - ballotInstance: potatoBallotInstance, + electorateInstance, + counterInstance: potatoCounterInstance, }); await E(bob).verifyBallot(howHigh, { - registrarInstance, - ballotInstance: heightBallotInstance, + electorateInstance, + counterInstance: heightCounterInstance, }); await E(timer).tick(); @@ -154,31 +200,32 @@ async function committeeBinaryTwoQuestions( await E(timer).tick(); await E(timer).tick(); - await E(E(zoe).getPublicFacet(potatoBallotInstance)) + await E(E(zoe).getPublicFacet(potatoCounterInstance)) .getOutcome() - .then(outcome => log(`vote outcome: ${outcome}`)) + .then(outcome => log(`vote outcome: ${q(outcome)}`)) .catch(e => log(`vote failed ${e}`)); - await E(E(zoe).getPublicFacet(heightBallotInstance)) + await E(E(zoe).getPublicFacet(heightCounterInstance)) .getOutcome() - .then(outcome => log(`vote outcome: ${outcome}`)) + .then(outcome => log(`vote outcome: ${q(outcome)}`)) .catch(e => log(`vote failed ${e}`)); -} +}; const makeBootstrap = (argv, cb, vatPowers) => async (vats, devices) => { const log = vatPowers.testLog; const vatAdminSvc = await E(vats.vatAdmin).createVatAdminService( devices.vatAdmin, ); + /** @type { ERef } */ const zoe = E(vats.zoe).buildZoe(vatAdminSvc); - const [committeeRegistrar, binaryBallotCounter] = await Promise.all([ - E(zoe).install(cb.committeeRegistrar), - E(zoe).install(cb.binaryBallotCounter), + const [committee, binaryVoteCounter] = await Promise.all([ + E(zoe).install(cb.committee), + E(zoe).install(cb.binaryVoteCounter), ]); const timer = buildManualTimer(log); - const installations = { committeeRegistrar, binaryBallotCounter }; + const installations = { committee, binaryVoteCounter }; const voterCreator = await makeVoterVat(log, vats, zoe); diff --git a/packages/governance/test/swingsetTests/committeeBinary/test-committee.js b/packages/governance/test/swingsetTests/committeeBinary/test-committee.js index f72c6f7887b..01f5efb4e86 100644 --- a/packages/governance/test/swingsetTests/committeeBinary/test-committee.js +++ b/packages/governance/test/swingsetTests/committeeBinary/test-committee.js @@ -12,7 +12,7 @@ import { buildVatController, buildKernelBundles } from '@agoric/swingset-vat'; import bundleSource from '@agoric/bundle-source'; import path from 'path'; -const CONTRACT_FILES = ['committeeRegistrar', 'binaryBallotCounter']; +const CONTRACT_FILES = ['committee', 'binaryVoteCounter']; const filename = new URL(import.meta.url).pathname; const dirname = path.dirname(filename); @@ -24,14 +24,8 @@ test.before(async t => { const contractBundles = {}; await Promise.all( CONTRACT_FILES.map(async settings => { - let bundleName; - let contractPath; - if (typeof settings === 'string') { - bundleName = settings; - contractPath = settings; - } else { - ({ bundleName, contractPath } = settings); - } + const bundleName = settings; + const contractPath = settings; const source = `${dirname}/../../../src/${contractPath}`; const bundle = await bundleSource(source); contractBundles[bundleName] = bundle; @@ -77,19 +71,19 @@ const expectedCommitteeBinaryStartLog = [ '=> voter vat is set up', '@@ schedule task for:3, currently: 0 @@', 'invitation details check: true Voter2', - 'Alice cast a ballot on Choose for Eeny', - 'Bob cast a ballot on Choose for Meeny', - 'Carol cast a ballot on Choose for Eeny', - 'Dave cast a ballot on Choose for Eeny', - 'Emma cast a ballot on Choose for Meeny', - 'Verify ballot from instance: Choose, Eeny,Meeny, choose_n', - 'Verify: q: Choose, max: 1, committee: TheCommittee', - 'Verify instances: registrar: true, counter: true', + 'Alice voted for {"text":"Eeny"}', + 'Bob voted for {"text":"Meeny"}', + 'Carol voted for {"text":"Eeny"}', + 'Dave voted for {"text":"Eeny"}', + 'Emma voted for {"text":"Meeny"}', + 'verify question from instance: {"text":"Choose"}, [{"text":"Eeny"},{"text":"Meeny"}], unranked', + 'Verify: q: {"text":"Choose"}, max: 1, committee: TheCommittee', + 'Verify instances: electorate: true, counter: true', '@@ tick:1 @@', '@@ tick:2 @@', '@@ tick:3 @@', '&& running a task scheduled for 3. &&', - 'vote outcome: Eeny', + 'vote outcome: {"text":"Eeny"}', ]; test.serial('zoe - committee binary vote - valid inputs', async t => { @@ -102,31 +96,31 @@ const expectedCommitteeBinaryTwoQuestionsLog = [ 'starting TWO questions test', 'invitation details check: true Voter2', '@@ schedule task for:3, currently: 0 @@', - 'Alice cast a ballot on Choose for One Potato', - 'Bob cast a ballot on Choose for One Potato', - 'Carol cast a ballot on Choose for Two Potato', - 'Dave cast a ballot on Choose for One Potato', - 'Emma cast a ballot on Choose for One Potato', - '@@ schedule task for:3, currently: 0 @@', - 'Alice cast a ballot on How high? for 1 foot', - 'Bob cast a ballot on How high? for 2 feet', - 'Carol cast a ballot on How high? for 1 foot', - 'Dave cast a ballot on How high? for 1 foot', - 'Emma cast a ballot on How high? for 2 feet', - 'Verify ballot from instance: Choose, One Potato,Two Potato, choose_n', - 'Verify: q: Choose, max: 1, committee: TheCommittee', - 'Verify instances: registrar: true, counter: true', - 'Verify ballot from instance: How high?, 1 foot,2 feet, choose_n', - 'Verify: q: How high?, max: 1, committee: TheCommittee', - 'Verify instances: registrar: true, counter: true', + 'Alice voted on {"text":"Choose"} for {"text":"One Potato"}', + 'Bob voted on {"text":"Choose"} for {"text":"One Potato"}', + 'Carol voted on {"text":"Choose"} for {"text":"Two Potato"}', + 'Dave voted on {"text":"Choose"} for {"text":"One Potato"}', + 'Emma voted on {"text":"Choose"} for {"text":"One Potato"}', + '@@ schedule task for:4, currently: 0 @@', + 'Alice voted on {"text":"How high?"} for {"text":"1 foot"}', + 'Bob voted on {"text":"How high?"} for {"text":"2 feet"}', + 'Carol voted on {"text":"How high?"} for {"text":"1 foot"}', + 'Dave voted on {"text":"How high?"} for {"text":"1 foot"}', + 'Emma voted on {"text":"How high?"} for {"text":"2 feet"}', + 'verify question from instance: {"text":"Choose"}, [{"text":"One Potato"},{"text":"Two Potato"}], unranked', + 'Verify: q: {"text":"Choose"}, max: 1, committee: TheCommittee', + 'Verify instances: electorate: true, counter: true', + 'verify question from instance: {"text":"How high?"}, [{"text":"1 foot"},{"text":"2 feet"}], unranked', + 'Verify: q: {"text":"How high?"}, max: 1, committee: TheCommittee', + 'Verify instances: electorate: true, counter: true', '@@ tick:1 @@', '@@ tick:2 @@', '@@ tick:3 @@', '&& running a task scheduled for 3. &&', - '&& running a task scheduled for 3. &&', '@@ tick:4 @@', - 'vote outcome: One Potato', - 'vote outcome: 1 foot', + '&& running a task scheduled for 4. &&', + 'vote outcome: {"text":"One Potato"}', + 'vote outcome: {"text":"1 foot"}', ]; test.serial('zoe - committee binary vote - TwoQuestions', async t => { diff --git a/packages/governance/test/swingsetTests/committeeBinary/vat-voter.js b/packages/governance/test/swingsetTests/committeeBinary/vat-voter.js index 075cf66e594..4f488b81cec 100644 --- a/packages/governance/test/swingsetTests/committeeBinary/vat-voter.js +++ b/packages/governance/test/swingsetTests/committeeBinary/vat-voter.js @@ -2,74 +2,84 @@ import { E } from '@agoric/eventual-send'; import { Far } from '@agoric/marshal'; -import { observeNotifier } from '@agoric/notifier'; +import { observeIteration } from '@agoric/notifier'; +import { sameStructure } from '@agoric/same-structure'; -const verify = async (log, question, registrarPublicFacet, instances) => { - const ballotTemplate = E(registrarPublicFacet).getBallot(question); - const { positions, method, question: q, maxChoices, instance } = await E( - ballotTemplate, - ).getDetails(); - log(`Verify ballot from instance: ${question}, ${positions}, ${method}`); - const c = await E(registrarPublicFacet).getName(); - log(`Verify: q: ${q}, max: ${maxChoices}, committee: ${c}`); - const registrarInstance = await E(registrarPublicFacet).getInstance(); +const { quote: q } = assert; + +const verify = async (log, issue, electoratePublicFacet, instances) => { + const questionHandles = await E(electoratePublicFacet).getOpenQuestions(); + const detailsP = questionHandles.map(h => { + const question = E(electoratePublicFacet).getQuestion(h); + return E(question).getDetails(); + }); + const detailsPlural = await Promise.all(detailsP); + const details = detailsPlural.find(d => sameStructure(d.issue, issue)); + + const { positions, method, issue: iss, maxChoices } = details; + log(`verify question from instance: ${q(issue)}, ${q(positions)}, ${method}`); + const c = await E(electoratePublicFacet).getName(); + log(`Verify: q: ${q(iss)}, max: ${maxChoices}, committee: ${c}`); + const electorateInstance = await E(electoratePublicFacet).getInstance(); log( - `Verify instances: registrar: ${registrarInstance === - instances.registrarInstance}, counter: ${instance === - instances.ballotInstance}`, + `Verify instances: electorate: ${electorateInstance === + instances.electorateInstance}, counter: ${details.counterInstance === + instances.counterInstance}`, ); }; const build = async (log, zoe) => { return Far('voter', { createVoter: async (name, invitation, choice) => { - const registrarInstance = await E(zoe).getInstance(invitation); - const registrarPublicFacet = E(zoe).getPublicFacet(registrarInstance); + const electorateInstance = await E(zoe).getInstance(invitation); + const electoratePublicFacet = E(zoe).getPublicFacet(electorateInstance); const seat = E(zoe).offer(invitation); const voteFacet = E(seat).getOfferResult(); const votingObserver = Far('voting observer', { - updateState: question => { - log(`${name} cast a ballot on ${question} for ${choice}`); - return E(voteFacet).castBallotFor(question, [choice]); + updateState: details => { + log(`${name} voted for ${q(choice)}`); + return E(voteFacet).castBallotFor(details.questionHandle, [choice]); }, }); - const notifier = E(registrarPublicFacet).getQuestionNotifier(); - observeNotifier(notifier, votingObserver); + const subscription = E(electoratePublicFacet).getQuestionSubscription(); + observeIteration(subscription, votingObserver); return Far(`Voter ${name}`, { verifyBallot: (question, instances) => - verify(log, question, registrarPublicFacet, instances), + verify(log, question, electoratePublicFacet, instances), }); }, createMultiVoter: async (name, invitation, choices) => { - const registrarInstance = await E(zoe).getInstance(invitation); - const registrarPublicFacet = E(zoe).getPublicFacet(registrarInstance); + const electorateInstance = await E(zoe).getInstance(invitation); + const electoratePublicFacet = E(zoe).getPublicFacet(electorateInstance); const seat = E(zoe).offer(invitation); const voteFacet = E(seat).getOfferResult(); - const voteMap = new Map(choices); + const voteMap = new Map(); + choices.forEach(entry => { + const [issue, position] = entry; + voteMap.set(issue.text, position); + }); const votingObserver = Far('voting observer', { - updateState: question => { - const choice = voteMap.get(question); - - log(`${name} cast a ballot on ${question} for ${choice}`); - return E(voteFacet).castBallotFor(question, [choice]); + updateState: details => { + const choice = voteMap.get(details.issue.text); + log(`${name} voted on ${q(details.issue)} for ${q(choice)}`); + return E(voteFacet).castBallotFor(details.questionHandle, [choice]); }, }); - const notifier = E(registrarPublicFacet).getQuestionNotifier(); - observeNotifier(notifier, votingObserver); + const subscription = E(electoratePublicFacet).getQuestionSubscription(); + observeIteration(subscription, votingObserver); return Far(`Voter ${name}`, { verifyBallot: (question, instances) => - verify(log, question, registrarPublicFacet, instances), + verify(log, question, electoratePublicFacet, instances), }); }, }); }; -export function buildRootObject(vatPowers) { - return Far('root', { +export const buildRootObject = vatPowers => + Far('root', { build: (...args) => build(vatPowers.testLog, ...args), }); -} diff --git a/packages/governance/test/swingsetTests/contractGovernor/bootstrap.js b/packages/governance/test/swingsetTests/contractGovernor/bootstrap.js new file mode 100644 index 00000000000..d348db8c7e4 --- /dev/null +++ b/packages/governance/test/swingsetTests/contractGovernor/bootstrap.js @@ -0,0 +1,214 @@ +// @ts-check + +import { E } from '@agoric/eventual-send'; +import { Far } from '@agoric/marshal'; +import buildManualTimer from '@agoric/zoe/tools/manualTimer.js'; +import { observeIteration } from '@agoric/notifier'; +import { governedParameterInitialValues } from './governedContract.js'; + +const { quote: q } = assert; + +/** + * @param {ERef} zoe + * @param {(string:string) => undefined} log + * @param {Record} installations + * @param {ERef} contractFacetAccess + * @returns {Promise<*>} + */ +const contractGovernorStart = async ( + zoe, + log, + installations, + contractFacetAccess, +) => { + const { details, instance, outcomeOfUpdate } = await E( + contractFacetAccess, + ).voteOnParamChange( + { key: 'main', parameterName: 'MalleableNumber' }, + 299792458n, + installations.binaryVoteCounter, + 3n, + ); + + E(E(zoe).getPublicFacet(instance)) + .getOutcome() + .then(outcome => log(`vote outcome: ${q(outcome)}`)) + .catch(e => log(`vote failed ${e}`)); + + E.when(outcomeOfUpdate, outcome => log(`updated to ${q(outcome)}`)).catch(e => + log(`update failed: ${e}`), + ); + return details; +}; + +const installContracts = async (zoe, cb) => { + const [ + committee, + binaryVoteCounter, + contractGovernor, + governedContract, + ] = await Promise.all([ + E(zoe).install(cb.committee), + E(zoe).install(cb.binaryVoteCounter), + E(zoe).install(cb.contractGovernor), + E(zoe).install(cb.governedContract), + ]); + const installations = { + committee, + binaryVoteCounter, + contractGovernor, + governedContract, + }; + return installations; +}; + +const startElectorate = async (zoe, installations) => { + const electorateTerms = { + committeeName: 'TwentyCommittee', + committeeSize: 5, + }; + const { + creatorFacet: electorateCreatorFacet, + instance: electorateInstance, + } = await E(zoe).startInstance(installations.committee, {}, electorateTerms); + return { electorateCreatorFacet, electorateInstance }; +}; + +const createVoters = async (electorateCreatorFacet, voterCreator) => { + const invitations = await E(electorateCreatorFacet).getVoterInvitations(); + + const aliceP = E(voterCreator).createVoter('Alice', invitations[0]); + const bobP = E(voterCreator).createVoter('Bob', invitations[1]); + const carolP = E(voterCreator).createVoter('Carol', invitations[2]); + const daveP = E(voterCreator).createVoter('Dave', invitations[3]); + const emmaP = E(voterCreator).createVoter('Emma', invitations[4]); + return Promise.all([aliceP, bobP, carolP, daveP, emmaP]); +}; + +const votersVote = async (detailsP, votersP, selections) => { + const [voters, details] = await Promise.all([votersP, detailsP]); + const { positions, questionHandle } = details; + + await Promise.all( + voters.map((v, i) => { + return E(v).castBallotFor(questionHandle, positions[selections[i]]); + }), + ); +}; + +const oneVoterValidate = async ( + votersP, + detailsP, + governedInstanceP, + electorateInstance, + governorInstanceP, + installations, + timer, +) => { + const [ + voters, + details, + governedInstance, + governorInstance, + ] = await Promise.all([ + votersP, + detailsP, + governedInstanceP, + governorInstanceP, + ]); + const { counterInstance } = details; + + E(voters[0]).validate( + counterInstance, + governedInstance, + electorateInstance, + governorInstance, + installations, + timer, + ); +}; + +const checkContractState = async (zoe, contractInstanceP, log) => { + const contractInstance = await contractInstanceP; + const contractPublic = E(zoe).getPublicFacet(contractInstance); + let paramValues = await E(contractPublic).getGovernedParamsValues(); + const subscription = await E(contractPublic).getSubscription(); + const paramChangeObserver = Far('param observer', { + updateState: update => { + log(`${update.name} was changed to ${q(update.value)}`); + }, + }); + observeIteration(subscription, paramChangeObserver); + + // it takes a while for the update to propagate. The second time it seems good + paramValues = await E(contractPublic).getGovernedParamsValues(); + const malleableNumber = paramValues.main.MalleableNumber; + + log(`current value of ${malleableNumber.name} is ${malleableNumber.value}`); +}; + +const makeBootstrap = (argv, cb, vatPowers) => async (vats, devices) => { + const log = vatPowers.testLog; + const vatAdminSvc = await E(vats.vatAdmin).createVatAdminService( + devices.vatAdmin, + ); + /** @type { ERef } */ + const zoe = E(vats.zoe).buildZoe(vatAdminSvc); + const installations = await installContracts(zoe, cb); + const timer = buildManualTimer(log); + const voterCreator = E(vats.voter).build(zoe); + const { electorateCreatorFacet, electorateInstance } = await startElectorate( + zoe, + installations, + ); + + log(`=> voter and electorate vats are set up`); + + const terms = { + timer, + electorateInstance, + governedContractInstallation: installations.governedContract, + governed: { + issuerKeywordRecord: {}, + terms: { main: governedParameterInitialValues }, + }, + }; + const privateArgs = { electorateCreatorFacet }; + const { creatorFacet: governor, instance: governorInstance } = await E( + zoe, + ).startInstance(installations.contractGovernor, {}, terms, privateArgs); + const governedInstance = E(governor).getInstance(); + + const [testName] = argv; + switch (testName) { + case 'contractGovernorStart': { + const votersP = createVoters(electorateCreatorFacet, voterCreator); + const detailsP = contractGovernorStart(zoe, log, installations, governor); + await votersVote(detailsP, votersP, [0, 1, 1, 0, 0]); + + await oneVoterValidate( + votersP, + detailsP, + governedInstance, + electorateInstance, + governorInstance, + installations, + timer, + ); + + await E(timer).tick(); + await E(timer).tick(); + await E(timer).tick(); + + await checkContractState(zoe, governedInstance, log); + break; + } + default: + log(`didn't find test: ${argv}`); + } +}; + +export const buildRootObject = (vatPowers, vatParameters) => { + const { argv, contractBundles: cb } = vatParameters; + return Far('root', { bootstrap: makeBootstrap(argv, cb, vatPowers) }); +}; diff --git a/packages/governance/test/swingsetTests/contractGovernor/governedContract.js b/packages/governance/test/swingsetTests/contractGovernor/governedContract.js new file mode 100644 index 00000000000..1df92f35638 --- /dev/null +++ b/packages/governance/test/swingsetTests/contractGovernor/governedContract.js @@ -0,0 +1,45 @@ +// @ts-check + +import { handleParamGovernance } from '../../../src/contractHelper.js'; +import { ParamType } from '../../../src/paramManager.js'; + +const MALLEABLE_NUMBER = 'MalleableNumber'; + +/** @type {ParameterNameList} */ +const governedParameterTerms = { + main: [MALLEABLE_NUMBER], +}; + +/** @type {ParamDescriptions} */ +const governedParameterInitialValues = [ + { + name: MALLEABLE_NUMBER, + value: 602214090000000000000000n, + type: ParamType.NAT, + }, +]; +harden(governedParameterInitialValues); + +/** @type {ContractStartFn} */ +const start = async zcf => { + const { makePublicFacet, makeCreatorFacet } = handleParamGovernance( + zcf, + governedParameterInitialValues, + ); + + return { + publicFacet: makePublicFacet({}), + creatorFacet: makeCreatorFacet({}), + }; +}; + +harden(start); +harden(MALLEABLE_NUMBER); +harden(governedParameterTerms); + +export { + start, + governedParameterTerms, + MALLEABLE_NUMBER, + governedParameterInitialValues, +}; diff --git a/packages/governance/test/swingsetTests/contractGovernor/test-governor.js b/packages/governance/test/swingsetTests/contractGovernor/test-governor.js new file mode 100644 index 00000000000..6acc28ab569 --- /dev/null +++ b/packages/governance/test/swingsetTests/contractGovernor/test-governor.js @@ -0,0 +1,107 @@ +// @ts-check + +// TODO Remove babel-standalone preinitialization +// https://github.com/endojs/endo/issues/768 +// eslint-disable-next-line import/no-extraneous-dependencies +import '@agoric/babel-standalone'; +// eslint-disable-next-line import/no-extraneous-dependencies +import '@agoric/install-ses'; +// eslint-disable-next-line import/no-extraneous-dependencies +import test from 'ava'; +import path from 'path'; + +import { buildVatController, buildKernelBundles } from '@agoric/swingset-vat'; +import bundleSource from '@agoric/bundle-source'; + +const CONTRACT_FILES = [ + 'committee', + 'contractGovernor', + 'binaryVoteCounter', + { + contractPath: '/governedContract', + bundleName: 'governedContract', + }, +]; + +const filename = new URL(import.meta.url).pathname; +const dirname = path.dirname(filename); + +test.before(async t => { + const start = Date.now(); + const kernelBundles = await buildKernelBundles(); + const step2 = Date.now(); + const contractBundles = {}; + await Promise.all( + CONTRACT_FILES.map(async settings => { + let bundleName; + let contractPath; + if (typeof settings === 'string') { + bundleName = settings; + contractPath = `/../../../src/${settings}`; + } else { + ({ bundleName, contractPath } = settings); + } + const source = `${dirname}${contractPath}`; + const bundle = await bundleSource(source); + contractBundles[bundleName] = bundle; + }), + ); + const step3 = Date.now(); + + const vats = {}; + await Promise.all( + ['voter', 'zoe'].map(async name => { + const source = `${dirname}/vat-${name}.js`; + const bundle = await bundleSource(source); + vats[name] = { bundle }; + }), + ); + const bootstrapSource = `${dirname}/bootstrap.js`; + vats.bootstrap = { + bundle: await bundleSource(bootstrapSource), + parameters: { contractBundles }, // argv will be added to this + }; + const config = { bootstrap: 'bootstrap', vats }; + config.defaultManagerType = 'xs-worker'; + + const step4 = Date.now(); + const ktime = `${(step2 - start) / 1000}s kernel`; + const ctime = `${(step3 - step2) / 1000}s contracts`; + const vtime = `${(step4 - step3) / 1000}s vats`; + const ttime = `${(step4 - start) / 1000}s total`; + console.log(`bundling: ${ktime}, ${ctime}, ${vtime}, ${ttime}`); + + // @ts-ignore + t.context.data = { kernelBundles, config }; +}); + +const main = async (t, argv) => { + const { kernelBundles, config } = t.context.data; + const controller = await buildVatController(config, argv, { kernelBundles }); + await controller.run(); + return controller.dump(); +}; + +const expectedcontractGovernorStartLog = [ + '=> voter and electorate vats are set up', + '@@ schedule task for:3, currently: 0 @@', + 'Voter Alice voted for {"changeParam":{"key":"main","parameterName":"MalleableNumber"},"proposedValue":"[299792458n]"}', + 'Voter Bob voted for {"noChange":{"key":"main","parameterName":"MalleableNumber"}}', + 'Voter Carol voted for {"noChange":{"key":"main","parameterName":"MalleableNumber"}}', + 'Voter Dave voted for {"changeParam":{"key":"main","parameterName":"MalleableNumber"},"proposedValue":"[299792458n]"}', + 'Voter Emma voted for {"changeParam":{"key":"main","parameterName":"MalleableNumber"},"proposedValue":"[299792458n]"}', + '@@ tick:1 @@', + '@@ tick:2 @@', + '@@ tick:3 @@', + '&& running a task scheduled for 3. &&', + 'vote outcome: {"changeParam":{"key":"main","parameterName":"MalleableNumber"},"proposedValue":"[299792458n]"}', + 'updated to "[299792458n]"', + 'MalleableNumber was changed to "[602214090000000000000000n]"', + 'current value of MalleableNumber is 299792458', + 'Voter Alice validated all the things', +]; + +test('zoe - contract governance', async t => { + const dump = await main(t, ['contractGovernorStart']); + t.deepEqual(dump.log, expectedcontractGovernorStartLog); +}); diff --git a/packages/governance/test/swingsetTests/contractGovernor/vat-voter.js b/packages/governance/test/swingsetTests/contractGovernor/vat-voter.js new file mode 100644 index 00000000000..04e774f0037 --- /dev/null +++ b/packages/governance/test/swingsetTests/contractGovernor/vat-voter.js @@ -0,0 +1,101 @@ +// @ts-check + +import { E } from '@agoric/eventual-send'; +import { Far } from '@agoric/marshal'; + +import { + assertContractElectorate, + assertContractGovernance, +} from '../../../src/validators.js'; +import { + validateQuestionFromCounter, + validateQuestionDetails, +} from '../../../src/contractGovernor.js'; +import { assertBallotConcernsQuestion } from '../../../src/governParam.js'; + +const { details: X, quote: q } = assert; + +const build = async (log, zoe) => { + return Far('voter', { + createVoter: async (name, invitation) => { + const seat = E(zoe).offer(invitation); + const voteFacet = E(seat).getOfferResult(); + + return Far(`Voter ${name}`, { + castBallotFor: async (questionHandle, choice) => { + log(`Voter ${name} voted for ${q(choice)}`); + return E(voteFacet).castBallotFor(questionHandle, [choice]); + }, + validate: async ( + counterInstance, + governedInstance, + electorateInstance, + governorInstance, + installations, + ) => { + const validateQuestionFromCounterP = validateQuestionFromCounter( + zoe, + electorateInstance, + counterInstance, + ); + + const contractGovernanceP = assertContractGovernance( + zoe, + governedInstance, + governorInstance, + installations.contractGovernor, + ); + + const [ + governedParam, + questionDetails, + electorateInstallation, + voteCounterInstallation, + governedInstallation, + governorInstallation, + validatedQuestion, + contractGovernance, + ] = await Promise.all([ + E.get(E(zoe).getTerms(governedInstance)).main, + E(E(zoe).getPublicFacet(counterInstance)).getDetails(), + E(zoe).getInstallationForInstance(electorateInstance), + E(zoe).getInstallationForInstance(counterInstance), + E(zoe).getInstallationForInstance(governedInstance), + E(zoe).getInstallationForInstance(governorInstance), + validateQuestionFromCounterP, + contractGovernanceP, + ]); + + assertBallotConcernsQuestion(governedParam[0].name, questionDetails); + assert(installations.binaryVoteCounter === voteCounterInstallation); + assert(installations.governedContract === governedInstallation); + assert(installations.contractGovernor === governorInstallation); + assert(installations.committee === electorateInstallation); + await assertContractElectorate( + zoe, + governorInstance, + electorateInstance, + ); + + await validateQuestionDetails( + zoe, + electorateInstance, + questionDetails, + ); + assert(validatedQuestion, X`governor failed to validate electorate`); + assert( + contractGovernance, + X`governor and governed aren't tightly linked`, + ); + + log(`Voter ${name} validated all the things`); + }, + }); + }, + }); +}; + +export const buildRootObject = vatPowers => + Far('root', { + build: (...args) => build(vatPowers.testLog, ...args), + }); diff --git a/packages/governance/test/swingsetTests/contractGovernor/vat-zoe.js b/packages/governance/test/swingsetTests/contractGovernor/vat-zoe.js new file mode 100644 index 00000000000..7544a16749c --- /dev/null +++ b/packages/governance/test/swingsetTests/contractGovernor/vat-zoe.js @@ -0,0 +1,18 @@ +// @ts-check + +import { Far } from '@agoric/marshal'; + +import { makeZoeKit } from '@agoric/zoe'; +import { E } from '@agoric/eventual-send'; + +export function buildRootObject(vatPowers) { + return Far('root', { + buildZoe: vatAdminSvc => { + const shutdownZoeVat = vatPowers.exitVatWithFailure; + const { zoeService } = makeZoeKit(vatAdminSvc, shutdownZoeVat); + const feePurse = E(zoeService).makeFeePurse(); + const zoe = E(zoeService).bindDefaultFeePurse(feePurse); + return zoe; + }, + }); +} diff --git a/packages/governance/test/unitTests/test-ballotBuilder.js b/packages/governance/test/unitTests/test-ballotBuilder.js new file mode 100644 index 00000000000..d9d58fd45dc --- /dev/null +++ b/packages/governance/test/unitTests/test-ballotBuilder.js @@ -0,0 +1,180 @@ +// @ts-check + +// TODO Remove babel-standalone preinitialization +// https://github.com/endojs/endo/issues/768 +// eslint-disable-next-line import/no-extraneous-dependencies +import '@agoric/babel-standalone'; +// eslint-disable-next-line import/no-extraneous-dependencies +import '@agoric/install-ses'; +// eslint-disable-next-line import/no-extraneous-dependencies +import test from 'ava'; +import buildManualTimer from '@agoric/zoe/tools/manualTimer.js'; +import { + looksLikeQuestionSpec, + ChoiceMethod, + ElectionType, + QuorumRule, +} from '../../src/index.js'; + +const issue = harden({ text: 'will it blend?' }); +const positions = [harden({ text: 'yes' }), harden({ text: 'no' })]; +const timer = buildManualTimer(console.log); +const closingRule = { timer, deadline: 37n }; + +test('good QuestionSpec', t => { + t.truthy( + looksLikeQuestionSpec( + harden({ + method: ChoiceMethod.UNRANKED, + issue, + positions, + electionType: ElectionType.SURVEY, + maxChoices: 2, + closingRule, + quorumRule: QuorumRule.MAJORITY, + tieOutcome: positions[1], + }), + ), + ); +}); + +test('bad Question', t => { + t.throws( + () => + looksLikeQuestionSpec( + // @ts-ignore Illegal Question + harden({ + method: ChoiceMethod.UNRANKED, + issue: 'will it blend?', + positions, + electionType: ElectionType.SURVEY, + maxChoices: 2, + closingRule, + quorumRule: QuorumRule.MAJORITY, + tieOutcome: positions[1], + }), + ), + { + message: 'A question can only be a pass-by-copy record: "will it blend?"', + }, + ); +}); + +test('bad timer', t => { + t.throws( + () => + looksLikeQuestionSpec( + // @ts-ignore Illegal timer + harden({ + method: ChoiceMethod.UNRANKED, + issue, + positions, + electionType: ElectionType.SURVEY, + maxChoices: 2, + closingRule: { timer: 37, deadline: 37n }, + quorumRule: QuorumRule.MAJORITY, + tieOutcome: positions[1], + }), + ), + { message: 'Timer must be a timer 37' }, + ); +}); + +test('bad method', t => { + t.throws( + () => + looksLikeQuestionSpec( + // @ts-ignore Illegal Method + harden({ + method: 'choose', + issue, + positions, + electionType: ElectionType.SURVEY, + maxChoices: 2, + closingRule, + quorumRule: QuorumRule.MAJORITY, + tieOutcome: positions[1], + }), + ), + { message: 'Illegal "ChoiceMethod": "choose"' }, + ); +}); + +test('bad Quorum', t => { + t.throws( + () => + looksLikeQuestionSpec( + // @ts-ignore Illegal Quorum + harden({ + method: ChoiceMethod.ORDER, + issue, + positions, + electionType: ElectionType.SURVEY, + maxChoices: 2, + closingRule, + quorumRule: 0.5, + tieOutcome: positions[1], + }), + ), + { message: 'Illegal "QuorumRule": 0.5' }, + ); +}); + +test('bad tieOutcome', t => { + t.throws( + () => + looksLikeQuestionSpec( + // @ts-ignore Illegal tieOutcome + harden({ + method: ChoiceMethod.ORDER, + issue, + positions, + electionType: ElectionType.SURVEY, + maxChoices: 2, + closingRule, + quorumRule: QuorumRule.NO_QUORUM, + tieOutcome: 'try again', + }), + ), + { message: 'tieOutcome must be a legal position: "try again"' }, + ); +}); + +test('bad maxChoices', t => { + t.throws( + () => + looksLikeQuestionSpec( + harden({ + method: ChoiceMethod.ORDER, + issue, + positions, + electionType: ElectionType.SURVEY, + maxChoices: 0, + closingRule, + quorumRule: QuorumRule.NO_QUORUM, + tieOutcome: positions[1], + }), + ), + { message: 'maxChoices must be positive: 0' }, + ); +}); + +test('bad positions', t => { + t.throws( + () => + looksLikeQuestionSpec({ + method: ChoiceMethod.ORDER, + issue, + positions: [{ text: 'yes' }, { text: 'no' }], + electionType: ElectionType.SURVEY, + maxChoices: 1, + closingRule, + quorumRule: QuorumRule.NO_QUORUM, + tieOutcome: positions[1], + }), + { + message: + 'Cannot pass non-frozen objects like {"text":"yes"}. Use harden()', + }, + ); +}); diff --git a/packages/governance/test/unitTests/test-ballotCount.js b/packages/governance/test/unitTests/test-ballotCount.js index ddebe023951..fc6214fc289 100644 --- a/packages/governance/test/unitTests/test-ballotCount.js +++ b/packages/governance/test/unitTests/test-ballotCount.js @@ -3,262 +3,313 @@ import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; import '@agoric/zoe/exported.js'; import { E } from '@agoric/eventual-send'; - +import buildManualTimer from '@agoric/zoe/tools/manualTimer.js'; import { makeHandle } from '@agoric/zoe/src/makeHandle.js'; -import { makeBinaryBallotCounter } from '../../src/binaryBallotCounter.js'; -const QUESTION = 'Fish or cut bait?'; -const FISH = 'Fish'; -const BAIT = 'Cut Bait'; +import { makeBinaryVoteCounter } from '../../src/binaryVoteCounter.js'; +import { + ChoiceMethod, + ElectionType, + QuorumRule, + looksLikeQuestionSpec, + makeParamChangePositions, +} from '../../src/index.js'; + +const ISSUE = harden({ text: 'Fish or cut bait?' }); +const FISH = harden({ text: 'Fish' }); +const BAIT = harden({ text: 'Cut Bait' }); + +const PARAM_CHANGE_SPEC = { parameterName: 'arbitrary', key: 'simple' }; +const { positive, negative } = makeParamChangePositions(PARAM_CHANGE_SPEC, 37); +const PARAM_CHANGE_ISSUE = harden({ + paramSpec: PARAM_CHANGE_SPEC, + contract: makeHandle('Instance'), + proposedValue: 37, +}); -test('binary ballot', async t => { - const { publicFacet, creatorFacet, closeFacet } = makeBinaryBallotCounter( - QUESTION, - [FISH, BAIT], - 1n, +const FAKE_CLOSING_RULE = { + timer: buildManualTimer(console.log), + deadline: 3n, +}; + +const FAKE_COUNTER_INSTANCE = makeHandle('Instance'); + +test('binary question', async t => { + const questionSpec = looksLikeQuestionSpec({ + method: ChoiceMethod.UNRANKED, + issue: ISSUE, + positions: [FISH, BAIT], + electionType: ElectionType.SURVEY, + maxChoices: 1, + closingRule: FAKE_CLOSING_RULE, + quorumRule: QuorumRule.NO_QUORUM, + tieOutcome: BAIT, + }); + const { publicFacet, creatorFacet, closeFacet } = makeBinaryVoteCounter( + questionSpec, + 0n, + FAKE_COUNTER_INSTANCE, ); - const voterFacet = E(creatorFacet).getVoterFacet(); - const aliceTemplate = publicFacet.getBallotTemplate(); + const aliceTemplate = publicFacet.getQuestion(); const aliceSeat = makeHandle('Voter'); const alicePositions = aliceTemplate.getDetails().positions; t.deepEqual(alicePositions.length, 2); t.deepEqual(alicePositions[0], FISH); - await E(voterFacet).submitVote( - aliceSeat, - aliceTemplate.choose([alicePositions[0]]), - ); + await E(creatorFacet).submitVote(aliceSeat, [alicePositions[0]]); closeFacet.closeVoting(); const outcome = await E(publicFacet).getOutcome(); t.deepEqual(outcome, FISH); }); test('binary spoiled', async t => { - const { publicFacet, creatorFacet } = makeBinaryBallotCounter( - QUESTION, - [FISH, BAIT], + const questionSpec = looksLikeQuestionSpec({ + method: ChoiceMethod.UNRANKED, + issue: ISSUE, + positions: [FISH, BAIT], + electionType: ElectionType.ELECTION, + maxChoices: 1, + closingRule: FAKE_CLOSING_RULE, + quorumRule: QuorumRule.NO_QUORUM, + tieOutcome: BAIT, + }); + const { publicFacet, creatorFacet } = makeBinaryVoteCounter( + questionSpec, 0n, + FAKE_COUNTER_INSTANCE, ); - const voterFacet = E(creatorFacet).getVoterFacet(); - const aliceTemplate = publicFacet.getBallotTemplate(); + const aliceTemplate = publicFacet.getQuestion(); const aliceSeat = makeHandle('Voter'); const alicePositions = aliceTemplate.getDetails().positions; t.deepEqual(alicePositions.length, 2); t.deepEqual(alicePositions[0], FISH); + await t.throwsAsync( - () => - E(voterFacet).submitVote(aliceSeat, { - question: QUESTION, - chosen: ['no'], - }), + () => E(creatorFacet).submitVote(aliceSeat, [harden({ text: 'no' })]), { - message: `The ballot's choice is not a legal position: "no".`, + message: `The specified choice is not a legal position: {"text":"no"}.`, }, ); }); test('binary tied', async t => { - const { publicFacet, creatorFacet, closeFacet } = makeBinaryBallotCounter( - QUESTION, - [FISH, BAIT], - 2n, - ); - const voterFacet = E(creatorFacet).getVoterFacet(); - const aliceTemplate = publicFacet.getBallotTemplate(); - const aliceSeat = makeHandle('Voter'); - const bobSeat = makeHandle('Voter'); - - const positions = aliceTemplate.getDetails().positions; - E(voterFacet).submitVote(aliceSeat, aliceTemplate.choose([positions[0]])); - await E(voterFacet).submitVote(bobSeat, aliceTemplate.choose([positions[1]])); - closeFacet.closeVoting(); - const outcome = await E(publicFacet).getOutcome(); - t.deepEqual(outcome, undefined); -}); - -test('binary tied w/fallback', async t => { - const { publicFacet, creatorFacet, closeFacet } = makeBinaryBallotCounter( - QUESTION, - [FISH, BAIT], + const questionSpec = looksLikeQuestionSpec({ + method: ChoiceMethod.UNRANKED, + issue: PARAM_CHANGE_ISSUE, + positions: [positive, negative], + electionType: ElectionType.PARAM_CHANGE, + maxChoices: 1, + closingRule: FAKE_CLOSING_RULE, + quorumRule: QuorumRule.NO_QUORUM, + tieOutcome: negative, + }); + const { publicFacet, creatorFacet, closeFacet } = makeBinaryVoteCounter( + questionSpec, 2n, - BAIT, + FAKE_COUNTER_INSTANCE, ); - const voterFacet = E(creatorFacet).getVoterFacet(); - const aliceTemplate = publicFacet.getBallotTemplate(); + const aliceTemplate = publicFacet.getQuestion(); const aliceSeat = makeHandle('Voter'); const bobSeat = makeHandle('Voter'); const positions = aliceTemplate.getDetails().positions; - E(voterFacet).submitVote(aliceSeat, aliceTemplate.choose([positions[0]])); - await E(voterFacet).submitVote(bobSeat, aliceTemplate.choose([positions[1]])); + E(creatorFacet).submitVote(aliceSeat, [positions[0]]); + await E(creatorFacet).submitVote(bobSeat, [positions[1]]); closeFacet.closeVoting(); const outcome = await E(publicFacet).getOutcome(); - t.deepEqual(outcome, BAIT); + t.deepEqual(outcome, negative); }); test('binary bad vote', async t => { - const { publicFacet, creatorFacet } = makeBinaryBallotCounter( - QUESTION, - [FISH, BAIT], - 1n, - ); - const voterFacet = E(creatorFacet).getVoterFacet(); - const aliceTemplate = publicFacet.getBallotTemplate(); - const aliceSeat = makeHandle('Voter'); - - t.throws( - () => E(voterFacet).submitVote(aliceSeat, aliceTemplate.choose(['worms'])), - { - message: - 'Some positions in ["worms"] are not valid in ["Fish","Cut Bait"]', - }, + const questionSpec = looksLikeQuestionSpec({ + method: ChoiceMethod.UNRANKED, + issue: PARAM_CHANGE_ISSUE, + positions: [positive, negative], + electionType: ElectionType.PARAM_CHANGE, + maxChoices: 1, + closingRule: FAKE_CLOSING_RULE, + quorumRule: QuorumRule.NO_QUORUM, + tieOutcome: negative, + }); + const { creatorFacet } = makeBinaryVoteCounter( + questionSpec, + 0n, + FAKE_COUNTER_INSTANCE, ); -}); - -test('binary counter does not match ballot', async t => { - const { creatorFacet } = makeBinaryBallotCounter(QUESTION, [FISH, BAIT], 1n); - const voterFacet = E(creatorFacet).getVoterFacet(); const aliceSeat = makeHandle('Voter'); - await t.throwsAsync( - () => - E(voterFacet).submitVote(aliceSeat, { - question: 'Hop, skip or jump?', - chosen: [FISH], - }), - { - message: - 'Ballot not for this question "Hop, skip or jump?" should have been "Fish or cut bait?"', - }, - ); - await t.throwsAsync( - () => - E(voterFacet).submitVote(aliceSeat, { - question: QUESTION, - chosen: ['jump'], - }), - { - message: `The ballot's choice is not a legal position: "jump".`, - }, - ); + await t.throwsAsync(() => E(creatorFacet).submitVote(aliceSeat, [BAIT]), { + message: `The specified choice is not a legal position: {"text":"Cut Bait"}.`, + }); }); test('binary no votes', async t => { - const { publicFacet, closeFacet } = makeBinaryBallotCounter( - QUESTION, - [FISH, BAIT], + const questionSpec = looksLikeQuestionSpec({ + method: ChoiceMethod.UNRANKED, + issue: PARAM_CHANGE_ISSUE, + positions: [positive, negative], + electionType: ElectionType.PARAM_CHANGE, + maxChoices: 1, + closingRule: FAKE_CLOSING_RULE, + quorumRule: QuorumRule.NO_QUORUM, + tieOutcome: negative, + }); + const { publicFacet, closeFacet } = makeBinaryVoteCounter( + questionSpec, 0n, + FAKE_COUNTER_INSTANCE, ); closeFacet.closeVoting(); const outcome = await E(publicFacet).getOutcome(); - t.deepEqual(outcome, undefined); + t.deepEqual(outcome, negative); }); test('binary varying share weights', async t => { - const { publicFacet, creatorFacet, closeFacet } = makeBinaryBallotCounter( - QUESTION, - [FISH, BAIT], + const questionSpec = looksLikeQuestionSpec({ + method: ChoiceMethod.UNRANKED, + issue: ISSUE, + positions: [positive, negative], + electionType: ElectionType.SURVEY, + maxChoices: 1, + closingRule: FAKE_CLOSING_RULE, + quorumRule: QuorumRule.NO_QUORUM, + tieOutcome: negative, + }); + const { publicFacet, creatorFacet, closeFacet } = makeBinaryVoteCounter( + questionSpec, 1n, + FAKE_COUNTER_INSTANCE, ); - const voterFacet = E(creatorFacet).getVoterFacet(); - const template = publicFacet.getBallotTemplate(); const aceSeat = makeHandle('Voter'); const austinSeat = makeHandle('Voter'); const saraSeat = makeHandle('Voter'); await Promise.all([ - E(voterFacet).submitVote(aceSeat, template.choose([FISH]), 37n), - E(voterFacet).submitVote(austinSeat, template.choose([BAIT]), 24n), - E(voterFacet).submitVote(saraSeat, template.choose([BAIT]), 11n), + E(creatorFacet).submitVote(aceSeat, [positive], 37n), + E(creatorFacet).submitVote(austinSeat, [negative], 24n), + E(creatorFacet).submitVote(saraSeat, [negative], 11n), ]); closeFacet.closeVoting(); const outcome = await E(publicFacet).getOutcome(); - t.deepEqual(outcome, 'Fish'); + t.deepEqual(outcome, positive); }); test('binary contested', async t => { - const { publicFacet, creatorFacet, closeFacet } = makeBinaryBallotCounter( - QUESTION, - [FISH, BAIT], + const questionSpec = looksLikeQuestionSpec({ + method: ChoiceMethod.UNRANKED, + issue: ISSUE, + positions: [positive, negative], + electionType: ElectionType.ELECTION, + maxChoices: 1, + closingRule: FAKE_CLOSING_RULE, + quorumRule: QuorumRule.NO_QUORUM, + tieOutcome: negative, + }); + const { publicFacet, creatorFacet, closeFacet } = makeBinaryVoteCounter( + questionSpec, 3n, + FAKE_COUNTER_INSTANCE, ); - const voterFacet = E(creatorFacet).getVoterFacet(); - const template = publicFacet.getBallotTemplate(); + const template = publicFacet.getQuestion(); const aliceSeat = makeHandle('Voter'); const bobSeat = makeHandle('Voter'); const positions = template.getDetails().positions; t.deepEqual(positions.length, 2); - E(voterFacet).submitVote(aliceSeat, template.choose([positions[0]]), 23n); - await E(voterFacet).submitVote(bobSeat, template.choose([positions[1]]), 47n); + E(creatorFacet).submitVote(aliceSeat, [positions[0]], 23n); + await E(creatorFacet).submitVote(bobSeat, [positions[1]], 47n); closeFacet.closeVoting(); const outcome = await E(publicFacet).getOutcome(); - t.deepEqual(outcome, BAIT); + t.deepEqual(outcome, negative); }); test('binary revote', async t => { - const { publicFacet, creatorFacet, closeFacet } = makeBinaryBallotCounter( - QUESTION, - [FISH, BAIT], + const questionSpec = looksLikeQuestionSpec({ + method: ChoiceMethod.UNRANKED, + issue: PARAM_CHANGE_ISSUE, + positions: [positive, negative], + electionType: ElectionType.PARAM_CHANGE, + maxChoices: 1, + closingRule: FAKE_CLOSING_RULE, + quorumRule: QuorumRule.NO_QUORUM, + tieOutcome: negative, + }); + const { publicFacet, creatorFacet, closeFacet } = makeBinaryVoteCounter( + questionSpec, 5n, + FAKE_COUNTER_INSTANCE, ); - const voterFacet = E(creatorFacet).getVoterFacet(); - const template = publicFacet.getBallotTemplate(); + const template = publicFacet.getQuestion(); const aliceSeat = makeHandle('Voter'); const bobSeat = makeHandle('Voter'); const positions = template.getDetails().positions; t.deepEqual(positions.length, 2); - E(voterFacet).submitVote(aliceSeat, template.choose([positions[0]]), 23n); - E(voterFacet).submitVote(bobSeat, template.choose([positions[1]]), 47n); - await E(voterFacet).submitVote(bobSeat, template.choose([positions[1]]), 15n); + E(creatorFacet).submitVote(aliceSeat, [positions[0]], 23n); + E(creatorFacet).submitVote(bobSeat, [positions[1]], 47n); + await E(creatorFacet).submitVote(bobSeat, [positions[1]], 15n); closeFacet.closeVoting(); const outcome = await E(publicFacet).getOutcome(); - t.deepEqual(outcome, FISH); + t.deepEqual(outcome, positive); }); -test('binary ballot too many', async t => { - const { publicFacet, creatorFacet } = makeBinaryBallotCounter( - QUESTION, - [FISH, BAIT], +test('binary question too many', async t => { + const questionSpec = looksLikeQuestionSpec({ + method: ChoiceMethod.UNRANKED, + issue: ISSUE, + positions: [FISH, BAIT], + electionType: ElectionType.SURVEY, + maxChoices: 1, + closingRule: FAKE_CLOSING_RULE, + quorumRule: QuorumRule.NO_QUORUM, + tieOutcome: BAIT, + }); + const { publicFacet, creatorFacet } = makeBinaryVoteCounter( + questionSpec, 1n, + FAKE_COUNTER_INSTANCE, ); - const voterFacet = E(creatorFacet).getVoterFacet(); - const aliceTemplate = publicFacet.getBallotTemplate(); + const aliceTemplate = publicFacet.getQuestion(); const aliceSeat = makeHandle('Voter'); const alicePositions = aliceTemplate.getDetails().positions; - t.throws( - () => - E(voterFacet).submitVote(aliceSeat, aliceTemplate.choose(alicePositions)), + await t.throwsAsync( + // @ts-ignore illegal value for testing + () => E(creatorFacet).submitVote(aliceSeat, alicePositions), { - message: 'only 1 position(s) allowed', + message: 'only 1 position allowed', }, ); }); test('binary no quorum', async t => { - const { publicFacet, creatorFacet, closeFacet } = makeBinaryBallotCounter( - QUESTION, - [FISH, BAIT], + const questionSpec = looksLikeQuestionSpec({ + method: ChoiceMethod.UNRANKED, + issue: ISSUE, + positions: [FISH, BAIT], + electionType: ElectionType.ELECTION, + maxChoices: 1, + closingRule: FAKE_CLOSING_RULE, + quorumRule: QuorumRule.NO_QUORUM, + tieOutcome: BAIT, + }); + const { publicFacet, creatorFacet, closeFacet } = makeBinaryVoteCounter( + questionSpec, 2n, + FAKE_COUNTER_INSTANCE, ); - const voterFacet = E(creatorFacet).getVoterFacet(); - const aliceTemplate = publicFacet.getBallotTemplate(); + const aliceTemplate = publicFacet.getQuestion(); const aliceSeat = makeHandle('Voter'); const positions = aliceTemplate.getDetails().positions; - await E(voterFacet).submitVote( - aliceSeat, - aliceTemplate.choose([positions[0]]), - ); + await E(creatorFacet).submitVote(aliceSeat, [positions[0]]); closeFacet.closeVoting(); await E(publicFacet) .getOutcome() @@ -267,8 +318,21 @@ test('binary no quorum', async t => { }); test('binary too many positions', async t => { - t.throws(() => makeBinaryBallotCounter(QUESTION, [FISH, BAIT, 'sleep'], 1n), { - message: - 'Binary ballots must have exactly two positions. had 3: ["Fish","Cut Bait","sleep"]', + const questionSpec = looksLikeQuestionSpec({ + method: ChoiceMethod.UNRANKED, + issue: ISSUE, + positions: [FISH, BAIT, harden({ text: 'sleep' })], + electionType: ElectionType.SURVEY, + maxChoices: 1, + closingRule: FAKE_CLOSING_RULE, + quorumRule: QuorumRule.NO_QUORUM, + tieOutcome: BAIT, }); + t.throws( + () => makeBinaryVoteCounter(questionSpec, 0n, FAKE_COUNTER_INSTANCE), + { + message: + 'Binary questions must have exactly two positions. had 3: [{"text":"Fish"},{"text":"Cut Bait"},{"text":"sleep"}]', + }, + ); }); diff --git a/packages/governance/test/unitTests/test-committee.js b/packages/governance/test/unitTests/test-committee.js index e39d97898a3..38b3879d4a8 100644 --- a/packages/governance/test/unitTests/test-committee.js +++ b/packages/governance/test/unitTests/test-committee.js @@ -11,103 +11,119 @@ import fakeVatAdmin from '@agoric/zoe/tools/fakeVatAdmin.js'; import bundleSource from '@agoric/bundle-source'; import buildManualTimer from '@agoric/zoe/tools/manualTimer.js'; -import { ChoiceMethod } from '../../src/ballotBuilder.js'; +import { + ChoiceMethod, + ElectionType, + QuorumRule, + looksLikeQuestionSpec, +} from '../../src/index.js'; const filename = new URL(import.meta.url).pathname; const dirname = path.dirname(filename); -const registrarRoot = `${dirname}/../../src/committeeRegistrar.js`; -const counterRoot = `${dirname}/../../src/binaryBallotCounter.js`; +const electorateRoot = `${dirname}/../../src/committee.js`; +const counterRoot = `${dirname}/../../src/binaryVoteCounter.js`; -async function setupContract() { +const setupContract = async () => { const { zoeService } = makeZoeKit(fakeVatAdmin); const feePurse = E(zoeService).makeFeePurse(); const zoe = E(zoeService).bindDefaultFeePurse(feePurse); // pack the contract - const [registrarBundle, counterBundle] = await Promise.all([ - bundleSource(registrarRoot), + const [electorateBundle, counterBundle] = await Promise.all([ + bundleSource(electorateRoot), bundleSource(counterRoot), ]); // install the contract - const [registrarInstallation, counterInstallation] = await Promise.all([ - E(zoe).install(registrarBundle), + const [electorateInstallation, counterInstallation] = await Promise.all([ + E(zoe).install(electorateBundle), E(zoe).install(counterBundle), ]); const terms = { committeeName: 'illuminati', committeeSize: 13 }; - const registrarStartResult = await E(zoe).startInstance( - registrarInstallation, + const electorateStartResult = await E(zoe).startInstance( + electorateInstallation, {}, terms, ); /** @type {ContractFacet} */ - return { registrarStartResult, counterInstallation }; -} + return { electorateStartResult, counterInstallation }; +}; -test('committee-open questions:none', async t => { +test('committee-open no questions', async t => { const { - registrarStartResult: { publicFacet }, + electorateStartResult: { publicFacet }, } = await setupContract(); t.deepEqual(await publicFacet.getOpenQuestions(), []); }); test('committee-open question:one', async t => { const { - registrarStartResult: { creatorFacet, publicFacet }, + electorateStartResult: { creatorFacet, publicFacet }, counterInstallation, } = await setupContract(); - const details = harden({ - method: ChoiceMethod.CHOOSE_N, - question: 'why', - positions: ['because', 'why not?'], + const positions = [harden({ text: 'because' }), harden({ text: 'why not?' })]; + const questionSpec = looksLikeQuestionSpec({ + method: ChoiceMethod.UNRANKED, + issue: harden({ text: 'why' }), + positions, + electionType: ElectionType.SURVEY, maxChoices: 1, closingRule: { timer: buildManualTimer(console.log), deadline: 2n, }, + quorumRule: QuorumRule.MAJORITY, + tieOutcome: positions[1], }); - await E(creatorFacet).addQuestion(counterInstallation, details); - t.deepEqual(await publicFacet.getOpenQuestions(), ['why']); + await E(creatorFacet).addQuestion(counterInstallation, questionSpec); + const questions = await publicFacet.getOpenQuestions(); + const question = E(publicFacet).getQuestion(questions[0]); + const questionDetails = await E(question).getDetails(); + t.deepEqual(questionDetails.issue.text, 'why'); }); test('committee-open question:mixed', async t => { const { - registrarStartResult: { creatorFacet, publicFacet }, + electorateStartResult: { creatorFacet, publicFacet }, counterInstallation, } = await setupContract(); const timer = buildManualTimer(console.log); - const details = harden({ - method: ChoiceMethod.CHOOSE_N, - question: 'why', - positions: ['because', 'why not?'], + const positions = [harden({ text: 'because' }), harden({ text: 'why not?' })]; + const questionSpec = looksLikeQuestionSpec({ + method: ChoiceMethod.UNRANKED, + issue: harden({ text: 'why' }), + positions, + electionType: ElectionType.SURVEY, maxChoices: 1, - closingRule: { - timer, - deadline: 4n, - }, + closingRule: { timer, deadline: 4n }, + quorumRule: QuorumRule.MAJORITY, + tieOutcome: positions[1], }); - await E(creatorFacet).addQuestion(counterInstallation, details); - - const details2 = harden({ - ...details, - question: 'why2', - }); - await E(creatorFacet).addQuestion(counterInstallation, details2); - - const details3 = harden({ - ...details, - question: 'why3', + await E(creatorFacet).addQuestion(counterInstallation, questionSpec); + + const questionSpec2 = { + ...questionSpec, + issue: harden({ text: 'why2' }), + closingRule: questionSpec.closingRule, + quorumRule: QuorumRule.MAJORITY, + }; + await E(creatorFacet).addQuestion(counterInstallation, questionSpec2); + + const questionSpec3 = { + ...questionSpec, + issue: harden({ text: 'why3' }), closingRule: { timer, deadline: 1n, }, - }); + quorumRule: QuorumRule.MAJORITY, + }; const { publicFacet: counterPublic } = await E(creatorFacet).addQuestion( counterInstallation, - details3, + questionSpec3, ); // We didn't add any votes. getOutcome() will eventually return a broken // promise, but not until some time after tick(). Add a .catch() for it. @@ -117,5 +133,6 @@ test('committee-open question:mixed', async t => { timer.tick(); - t.deepEqual(await publicFacet.getOpenQuestions(), ['why', 'why2']); + const questions = await publicFacet.getOpenQuestions(); + t.deepEqual(questions.length, 2); }); diff --git a/packages/governance/test/test-param-manager.js b/packages/governance/test/unitTests/test-param-manager.js similarity index 50% rename from packages/governance/test/test-param-manager.js rename to packages/governance/test/unitTests/test-param-manager.js index d434de5cbf6..290270d3568 100644 --- a/packages/governance/test/test-param-manager.js +++ b/packages/governance/test/unitTests/test-param-manager.js @@ -6,7 +6,9 @@ import { AmountMath, AssetKind, makeIssuerKit } from '@agoric/ertp'; import { makeRatio } from '@agoric/zoe/src/contractSupport/index.js'; import { makeHandle } from '@agoric/zoe/src/makeHandle.js'; -import { buildParamManager, ParamType } from '../src/paramManager.js'; +import { Far } from '@agoric/marshal'; +import { buildParamManager, ParamType } from '../../src/paramManager.js'; +import { makeParamChangePositions } from '../../src/governParam.js'; const BASIS_POINTS = 10_000n; @@ -17,10 +19,10 @@ test('params one Nat', async t => { value: 13n, type: ParamType.NAT, }; - const { getParams, updateNumber } = buildParamManager([numberDescription]); - t.deepEqual(getParams()[numberKey], numberDescription); + const { getParam, updateNumber } = buildParamManager([numberDescription]); + t.deepEqual(getParam(numberKey), numberDescription); updateNumber(42n); - t.deepEqual(getParams()[numberKey].value, 42n); + t.deepEqual(getParam(numberKey).value, 42n); t.throws( () => updateNumber(18.1), @@ -45,10 +47,10 @@ test('params one String', async t => { value: 'foo', type: ParamType.STRING, }; - const { getParams, updateString } = buildParamManager([stringDescription]); - t.deepEqual(getParams()[stringKey], stringDescription); + const { getParam, updateString } = buildParamManager([stringDescription]); + t.deepEqual(getParam(stringKey), stringDescription); updateString('bar'); - t.deepEqual(getParams()[stringKey].value, 'bar'); + t.deepEqual(getParam(stringKey).value, 'bar'); t.throws( () => updateString(18.1), @@ -67,10 +69,10 @@ test('params one Amount', async t => { value: AmountMath.makeEmpty(brand), type: ParamType.AMOUNT, }; - const { getParams, updateAmount } = buildParamManager([amountDescription]); - t.deepEqual(getParams()[amountKey], amountDescription); + const { getParam, updateAmount } = buildParamManager([amountDescription]); + t.deepEqual(getParam(amountKey), amountDescription); updateAmount(AmountMath.make(brand, [13])); - t.deepEqual(getParams()[amountKey].value, AmountMath.make(brand, [13])); + t.deepEqual(getParam(amountKey).value, AmountMath.make(brand, [13])); t.throws( () => updateAmount(18.1), @@ -88,10 +90,10 @@ test('params one BigInt', async t => { value: 314159n, type: ParamType.NAT, }; - const { getParams, updateBigint } = buildParamManager([bigIntDescription]); - t.deepEqual(getParams()[bigintKey], bigIntDescription); + const { getParam, updateBigint } = buildParamManager([bigIntDescription]); + t.deepEqual(getParam(bigintKey), bigIntDescription); updateBigint(271828182845904523536n); - t.deepEqual(getParams()[bigintKey].value, 271828182845904523536n); + t.deepEqual(getParam(bigintKey).value, 271828182845904523536n); t.throws( () => updateBigint(18.1), @@ -117,8 +119,12 @@ test('params one ratio', async t => { value: makeRatio(7n, brand), type: ParamType.RATIO, }; - const { getParams, updateRatio } = buildParamManager([ratioDescription]); - t.deepEqual(getParams()[ratioKey], ratioDescription); + + const { getParam, getParams, updateRatio } = buildParamManager([ + ratioDescription, + ]); + // t.deepEqual(getParams()[ratioKey], ratioDescription); + t.deepEqual(getParam(ratioKey), ratioDescription); updateRatio(makeRatio(701n, brand, BASIS_POINTS)); t.deepEqual( getParams()[ratioKey].value, @@ -128,7 +134,7 @@ test('params one ratio', async t => { t.throws( () => updateRatio(18.1), { - message: 'Ratio 18.1 must be a record with 2 fields.', + message: 'Expected "number" is same as "copyRecord"', }, 'value should be a ratio', ); @@ -143,10 +149,10 @@ test('params one brand', async t => { value: roseBrand, type: ParamType.BRAND, }; - const { getParams, updateBrand } = buildParamManager([brandDescription]); - t.deepEqual(getParams()[brandKey], brandDescription); + const { getParam, updateBrand } = buildParamManager([brandDescription]); + t.deepEqual(getParam(brandKey), brandDescription); updateBrand(thornBrand); - t.deepEqual(getParams()[brandKey].value, thornBrand); + t.deepEqual(getParam(brandKey).value, thornBrand); t.throws( () => updateBrand(18.1), @@ -165,10 +171,10 @@ test('params one unknown', async t => { value: stiltonBrand, type: ParamType.UNKNOWN, }; - const { getParams, updateStuff } = buildParamManager([stuffDescription]); - t.deepEqual(getParams()[stuffKey], stuffDescription); + const { getParam, updateStuff } = buildParamManager([stuffDescription]); + t.deepEqual(getParam(stuffKey), stuffDescription); updateStuff(18.1); - t.deepEqual(getParams()[stuffKey].value, 18.1); + t.deepEqual(getParam(stuffKey).value, 18.1); }); test('params one instance', async t => { @@ -181,10 +187,8 @@ test('params one instance', async t => { value: instanceHandle, type: ParamType.INSTANCE, }; - const { getParams, updateInstance } = buildParamManager([ - instanceDescription, - ]); - t.deepEqual(getParams()[instanceKey], instanceDescription); + const { getParam, updateInstance } = buildParamManager([instanceDescription]); + t.deepEqual(getParam(instanceKey), instanceDescription); t.throws( () => updateInstance(18.1), { @@ -194,23 +198,25 @@ test('params one instance', async t => { ); const handle2 = makeHandle('another Instance'); updateInstance(handle2); - t.deepEqual(getParams()[instanceKey].value, handle2); + t.deepEqual(getParam(instanceKey).value, handle2); }); test('params one installation', async t => { const installationKey = 'Installation'; // this is sufficient for the current type check. When we add // isInstallation() (#3344), we'll need to make a mockZoe. - const installationHandle = makeHandle('installation'); + const installationHandle = Far('fake Installation', { + getBundle: () => ({ obfuscated: 42 }), + }); const installationDescription = { name: installationKey, value: installationHandle, type: ParamType.INSTALLATION, }; - const { getParams, updateInstallation } = buildParamManager([ + const { getParam, updateInstallation } = buildParamManager([ installationDescription, ]); - t.deepEqual(getParams()[installationKey], installationDescription); + t.deepEqual(getParam(installationKey), installationDescription); t.throws( () => updateInstallation(18.1), { @@ -218,9 +224,11 @@ test('params one installation', async t => { }, 'value should be an installation', ); - const handle2 = makeHandle('another installation'); + const handle2 = Far('another fake Installation', { + getBundle: () => ({ condensed: '() => {})' }), + }); updateInstallation(handle2); - t.deepEqual(getParams()[installationKey].value, handle2); + t.deepEqual(getParam(installationKey).value, handle2); }); test('params duplicate entry', async t => { @@ -273,23 +281,123 @@ test('params multiple values', t => { value: 602214076000000000000000n, type: ParamType.NAT, }; - const { getParams, updateNat, updateStuff } = buildParamManager([ + const { getParams, getParam, updateNat, updateStuff } = buildParamManager([ cheeseDescription, constantDescription, ]); - t.deepEqual(getParams()[stuffKey], cheeseDescription); + t.deepEqual(getParam(stuffKey), cheeseDescription); updateStuff(18.1); const floatDescription = { name: stuffKey, value: 18.1, type: ParamType.UNKNOWN, }; - t.deepEqual(getParams()[stuffKey], floatDescription); - t.deepEqual(getParams()[natKey], constantDescription); + t.deepEqual(getParam(stuffKey), floatDescription); + t.deepEqual(getParam(natKey), constantDescription); t.deepEqual(getParams(), { Nat: constantDescription, Stuff: floatDescription, }); updateNat(299792458n); - t.deepEqual(getParams()[natKey].value, 299792458n); + t.deepEqual(getParam(natKey).value, 299792458n); +}); + +const positive = (name, val) => { + return { changeParam: name, proposedValue: val }; +}; + +const negative = name => { + return { noChange: name }; +}; + +test('positions amount', t => { + const amountSpec = { parameterName: 'amount', key: 'something' }; + const { brand } = makeIssuerKit('roses', AssetKind.SET); + const amount = AmountMath.makeEmpty(brand); + + const positions = makeParamChangePositions(amountSpec, amount); + t.deepEqual(positions.positive, positive(amountSpec, amount)); + t.deepEqual(positions.negative, negative(amountSpec)); + t.notDeepEqual(positions.positive, positive(AmountMath.make(brand, [1]))); +}); + +test('positions brand', t => { + const brandSpec = { parameterName: 'brand', key: 'params' }; + const { brand: roseBrand } = makeIssuerKit('roses', AssetKind.SET); + const { brand: thornBrand } = makeIssuerKit('thorns', AssetKind.SET); + + const positions = makeParamChangePositions(brandSpec, roseBrand); + t.deepEqual(positions.positive, positive(brandSpec, roseBrand)); + t.deepEqual(positions.negative, negative(brandSpec)); + t.not(positions.positive, positive(brandSpec, thornBrand)); +}); + +test('positions instance', t => { + const instanceSpec = { parameterName: 'instance', key: 'something' }; + // this is sufficient for the current type check. When we add + // isInstallation() (#3344), we'll need to make a mockZoe. + const instanceHandle = makeHandle('Instance'); + + const positions = makeParamChangePositions(instanceSpec, instanceHandle); + t.deepEqual(positions.positive, positive(instanceSpec, instanceHandle)); + t.deepEqual(positions.negative, negative(instanceSpec)); + t.not(positions.positive, positive(instanceSpec, makeHandle('Instance'))); +}); + +test('positions Installation', t => { + const installationSpec = { parameterName: 'installation', key: 'something' }; + // this is sufficient for the current type check. When we add + // isInstallation() (#3344), we'll need to make a mockZoe. + const installationHandle = makeHandle('Installation'); + + const positions = makeParamChangePositions( + installationSpec, + installationHandle, + ); + t.deepEqual( + positions.positive, + positive(installationSpec, installationHandle), + ); + t.deepEqual(positions.negative, negative(installationSpec)); + t.not( + positions.positive, + positive(installationSpec, makeHandle('Installation')), + ); +}); + +test('positions Nat', t => { + const natSpec = { parameterName: 'nat', key: 'something' }; + const nat = 3n; + + const positions = makeParamChangePositions(natSpec, nat); + t.deepEqual(positions.positive, positive(natSpec, nat)); + t.deepEqual(positions.negative, negative(natSpec)); + t.notDeepEqual(positions.positive, positive(natSpec, 4n)); +}); + +test('positions Ratio', t => { + const ratioSpec = { parameterName: 'ratio', key: 'something' }; + const { brand } = makeIssuerKit('elo', AssetKind.NAT); + const ratio = makeRatio(2500n, brand, 2400n); + + const positions = makeParamChangePositions(ratioSpec, ratio); + t.deepEqual(positions.positive, positive(ratioSpec, ratio)); + t.deepEqual(positions.negative, negative(ratioSpec)); + t.notDeepEqual( + positions.positive, + positive(ratioSpec, makeRatio(2500n, brand, 2200n)), + ); +}); + +test('positions string', t => { + const stringSpec = { parameterName: 'string', key: 'something' }; + const string = 'When in the course'; + + const positions = makeParamChangePositions(stringSpec, string); + t.deepEqual(positions.positive, positive(stringSpec, string)); + t.deepEqual(positions.negative, negative(stringSpec)); + t.notDeepEqual( + positions.positive, + positive(stringSpec, 'We hold these truths'), + ); }); diff --git a/packages/governance/test/unitTests/test-paramGovernance.js b/packages/governance/test/unitTests/test-paramGovernance.js new file mode 100644 index 00000000000..4f11cfe14a8 --- /dev/null +++ b/packages/governance/test/unitTests/test-paramGovernance.js @@ -0,0 +1,241 @@ +// @ts-check + +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import '@agoric/zoe/exported.js'; + +import { makeZoeKit } from '@agoric/zoe'; +import bundleSource from '@agoric/bundle-source'; +import buildManualTimer from '@agoric/zoe/tools/manualTimer.js'; +import { Far } from '@agoric/marshal'; +import { makePromiseKit } from '@agoric/promise-kit'; +import fakeVatAdmin from '@agoric/zoe/tools/fakeVatAdmin.js'; +import { E } from '@agoric/eventual-send'; + +import { makeHandle } from '@agoric/zoe/src/makeHandle.js'; +import path from 'path'; + +import { + setupGovernance, + makeParamChangePositions, +} from '../../src/governParam.js'; +import { + governedParameterInitialValues, + MALLEABLE_NUMBER, +} from '../swingsetTests/contractGovernor/governedContract.js'; + +const filename = new URL(import.meta.url).pathname; +const dirname = path.dirname(filename); + +const voteCounterRoot = `${dirname}/../../src/binaryVoteCounter.js`; +const governedRoot = `${dirname}/../swingsetTests/contractGovernor/governedContract.js`; + +const makeInstall = async (sourceRoot, zoe) => { + const bundle = await bundleSource(sourceRoot); + return E(zoe).install(bundle); +}; + +test('governParam happy path with fakes', async t => { + const { zoeService } = makeZoeKit(fakeVatAdmin); + const feePurse = E(zoeService).makeFeePurse(); + const zoe = E(zoeService).bindDefaultFeePurse(feePurse); + + const timer = buildManualTimer(console.log); + + const governedInstall = await makeInstall(governedRoot, zoe); + const voteCounterInstall = await makeInstall(voteCounterRoot, zoe); + + const governedFacets = await E(zoe).startInstance( + governedInstall, + {}, + { main: governedParameterInitialValues }, + ); + const Retriever = governedFacets.creatorFacet.getParamMgrRetriever(); + + const paramSpec = { key: 'contractParams', parameterName: MALLEABLE_NUMBER }; + const { positive } = makeParamChangePositions(paramSpec, 25n); + + const fakeCounterPublic = Far('fake voteCounter public', { + getOutcome: () => positive, + getDetails: () => undefined, + }); + const questionPoser = Far('poser', { + addQuestion: () => { + return { + publicFacet: fakeCounterPublic, + instance: makeHandle('counter'), + }; + }, + }); + + const paramGovernor = setupGovernance( + Retriever, + // @ts-ignore questionPoser is a fake + questionPoser, + governedFacets.instance, + timer, + ); + + const { outcomeOfUpdate } = await E(paramGovernor).voteOnParamChange( + paramSpec, + 25n, + voteCounterInstall, + 2n, + ); + + await E.when(outcomeOfUpdate, outcome => t.is(outcome, 25n)).catch(e => + t.fail(e), + ); + + t.deepEqual(governedFacets.publicFacet.getGovernedParamsValues(), { + main: { + MalleableNumber: { + name: MALLEABLE_NUMBER, + type: 'nat', + value: 25n, + }, + }, + }); +}); + +test('governParam no votes', async t => { + const timer = buildManualTimer(console.log); + const { zoeService } = makeZoeKit(fakeVatAdmin); + const feePurse = E(zoeService).makeFeePurse(); + const zoe = E(zoeService).bindDefaultFeePurse(feePurse); + + const voteCounterInstall = await makeInstall(voteCounterRoot, zoe); + const governedInstall = await makeInstall(governedRoot, zoe); + + const governedFacets = await E(zoe).startInstance( + governedInstall, + {}, + { main: governedParameterInitialValues }, + ); + const Retriever = governedFacets.creatorFacet.getParamMgrRetriever(); + + const paramSpec = { key: 'contractParams', parameterName: MALLEABLE_NUMBER }; + + const outcomeKit = makePromiseKit(); + outcomeKit.reject('no quorum'); + + const fakeCounterPublic = Far('fake voteCounter public', { + getOutcome: () => outcomeKit.promise, + getDetails: () => undefined, + }); + + outcomeKit.promise.catch(() => {}); + fakeCounterPublic.getOutcome().catch(() => {}); + + const questionPoser = Far('poser', { + addQuestion: () => { + return { + publicFacet: fakeCounterPublic, + instance: makeHandle('counter'), + }; + }, + }); + + const paramGovernor = setupGovernance( + Retriever, + // @ts-ignore questionPoser is a fake + questionPoser, + governedFacets.instance, + timer, + ); + + const { outcomeOfUpdate } = await E(paramGovernor).voteOnParamChange( + paramSpec, + 25n, + voteCounterInstall, + 2n, + ); + + await E.when(outcomeOfUpdate, outcome => t.fail(`${outcome}`)).catch(e => + t.is(e, 'no quorum'), + ); + + t.deepEqual(governedFacets.publicFacet.getGovernedParamsValues(), { + main: { + MalleableNumber: { + name: MALLEABLE_NUMBER, + type: 'nat', + value: 602214090000000000000000n, + }, + }, + }); +}); + +test('governParam bad update', async t => { + const { zoeService } = makeZoeKit(fakeVatAdmin); + const feePurse = E(zoeService).makeFeePurse(); + const zoe = E(zoeService).bindDefaultFeePurse(feePurse); + const timer = buildManualTimer(console.log); + + const voteCounterInstall = await makeInstall(voteCounterRoot, zoe); + const governedInstall = await makeInstall(governedRoot, zoe); + + const governedFacets = await E(zoe).startInstance( + governedInstall, + {}, + { main: governedParameterInitialValues }, + ); + const brokenParamMgr = Far('broken ParamMgr', { + getParam: () => { + return harden({ type: 'nat' }); + }, + }); + const retriever = Far('paramMgr retriever', { + get: () => brokenParamMgr, + }); + + const paramSpec = { key: 'contractParams', parameterName: MALLEABLE_NUMBER }; + const { positive } = makeParamChangePositions(paramSpec, 25n); + + const fakeDetails = { stuff: 'nonsense' }; + const fakeCounterPublic = Far('fake voteCounter public', { + getOutcome: () => positive, + getDetails: () => fakeDetails, + }); + + const fakeVoteCounter = makeHandle('vote counter'); + const questionPoser = Far('poser', { + addQuestion: () => { + return { publicFacet: fakeCounterPublic, instance: fakeVoteCounter }; + }, + }); + + const paramGovernor = setupGovernance( + // @ts-ignore retriever is a fake + retriever, + questionPoser, + governedFacets.instance, + timer, + ); + + const { details, outcomeOfUpdate } = await E(paramGovernor).voteOnParamChange( + paramSpec, + 25n, + voteCounterInstall, + 2n, + ); + // @ts-ignore details are from a fake + t.deepEqual(await details, fakeDetails); + + await t.throwsAsync( + outcomeOfUpdate, + { + message: 'target has no method "updateMalleableNumber", has ["getParam"]', + }, + 'Expected a throw', + ); + + t.deepEqual(governedFacets.publicFacet.getGovernedParamsValues(), { + main: { + MalleableNumber: { + name: MALLEABLE_NUMBER, + type: 'nat', + value: 602214090000000000000000n, + }, + }, + }); +}); diff --git a/packages/zoe/src/contractSupport/ratio.js b/packages/zoe/src/contractSupport/ratio.js index 0be4ae05925..cdeb484485a 100644 --- a/packages/zoe/src/contractSupport/ratio.js +++ b/packages/zoe/src/contractSupport/ratio.js @@ -4,6 +4,8 @@ import './types.js'; import { assert, details as X, q } from '@agoric/assert'; import { Nat } from '@agoric/nat'; import { AmountMath } from '@agoric/ertp'; +import { passStyleOf } from '@agoric/marshal'; + import { natSafeMath } from './safeMath.js'; const { multiply, floorDivide, ceilDivide, add, subtract } = natSafeMath; @@ -38,6 +40,7 @@ const PERCENT = 100n; const ratioPropertyNames = ['numerator', 'denominator']; export const assertIsRatio = ratio => { + assert.equal(passStyleOf(ratio), 'copyRecord'); const propertyNames = Object.getOwnPropertyNames(ratio); assert( propertyNames.length === 2,