diff --git a/packages/governance/README.md b/packages/governance/README.md index 2ca99a7f87d..7608acde1fe 100644 --- a/packages/governance/README.md +++ b/packages/governance/README.md @@ -184,10 +184,9 @@ 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. +ShareHolders 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 diff --git a/packages/governance/src/binaryVoteCounter.js b/packages/governance/src/binaryVoteCounter.js index a24ab07f603..1a44a3cd92b 100644 --- a/packages/governance/src/binaryVoteCounter.js +++ b/packages/governance/src/binaryVoteCounter.js @@ -62,7 +62,9 @@ const makeBinaryVoteCounter = (questionSpec, threshold, instance) => { let isOpen = true; const positions = questionSpec.positions; + /** @type { PromiseRecord } */ const outcomePromise = makePromiseKit(); + /** @type { PromiseRecord } */ 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 @@ -110,6 +112,7 @@ const makeBinaryVoteCounter = (questionSpec, threshold, instance) => { } }); + /** @type { VoteStatistics } */ const stats = { spoiled, votes: allBallots.entries().length, @@ -169,6 +172,7 @@ const makeBinaryVoteCounter = (questionSpec, threshold, instance) => { // It schedules the closing of the vote, finally inserting the contract // instance in the publicFacet before returning public and creator facets. +/** @param { ContractFacet } zcf */ 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 diff --git a/packages/governance/src/committee.js b/packages/governance/src/committee.js index 295260f5c81..f8794b9c76d 100644 --- a/packages/governance/src/committee.js +++ b/packages/governance/src/committee.js @@ -3,12 +3,17 @@ 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'; +import { + startCounter, + getOpenQuestions, + getQuestion, + getPoserInvitation, +} from './electorateTools.js'; const { ceilDivide } = natSafeMath; @@ -24,29 +29,10 @@ const { ceilDivide } = natSafeMath; * @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', () => { @@ -92,45 +78,28 @@ const start = zcf => { } }; - /** @type {QuestionTerms} */ - const voteCounterTerms = { + return startCounter( + zcf, 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 }; + quorumThreshold(questionSpec.quorumRule), + voteCounter, + allQuestions, + publication, + ); }; - /** @type {ElectoratePublic} */ + /** @type {CommitteeElectoratePublic} */ const publicFacet = Far('publicFacet', { getQuestionSubscription: () => subscription, - getOpenQuestions, + getOpenQuestions: () => getOpenQuestions(allQuestions), getName: () => committeeName, getInstance: zcf.getInstance, - getQuestion: questionHandleP => - E.when(questionHandleP, questionHandle => - E(allQuestions.get(questionHandle).publicFacet).getQuestion(), - ), + getQuestion: handleP => getQuestion(handleP, allQuestions), }); - const getPoserInvitation = () => { - const questionPoserHandler = () => Far(`questionPoser`, { addQuestion }); - return zcf.makeInvitation(questionPoserHandler, `questionPoser`); - }; - - /** @type {ElectorateCreatorFacet} */ + /** @type {CommitteeElectorateCreatorFacet} */ const creatorFacet = Far('adminFacet', { - getPoserInvitation, + getPoserInvitation: () => getPoserInvitation(zcf, addQuestion), addQuestion, getVoterInvitations: () => invitations, getQuestionSubscription: () => subscription, diff --git a/packages/governance/src/registrarTools.js b/packages/governance/src/electorateTools.js similarity index 70% rename from packages/governance/src/registrarTools.js rename to packages/governance/src/electorateTools.js index 41847bc266d..8dde46fc2a2 100644 --- a/packages/governance/src/registrarTools.js +++ b/packages/governance/src/electorateTools.js @@ -4,6 +4,11 @@ import { E } from '@agoric/eventual-send'; import { allComparable } from '@agoric/same-structure'; import { Far } from '@agoric/marshal'; +/** + * Start up a new Counter for a question + * + * @type {StartCounter} + */ const startCounter = async ( zcf, questionSpec, @@ -12,16 +17,17 @@ const startCounter = async ( questionStore, publication, ) => { - const ballotCounterTerms = { + const voteCounterTerms = { questionSpec, electorate: zcf.getInstance(), quorumThreshold, }; // facets of the voteCounter. creatorInvitation and adminFacet not used + /** @type {{ creatorFacet: VoteCounterCreatorFacet, publicFacet: VoteCounterPublicFacet, instance: Instance }} */ const { creatorFacet, publicFacet, instance } = await E( zcf.getZoeService(), - ).startInstance(voteCounter, {}, ballotCounterTerms); + ).startInstance(voteCounter, {}, voteCounterTerms); const details = await E(publicFacet).getDetails(); const { deadline } = questionSpec.closingRule; publication.updateState(details); @@ -34,11 +40,13 @@ const startCounter = async ( return { creatorFacet, publicFacet, instance, deadline, questionHandle }; }; +/** @param {Store, QuestionRecord>} questionStore */ const getOpenQuestions = async questionStore => { - const isOpenPQuestions = questionStore.keys().map(key => { - const { publicFacet } = questionStore.get(key); - return [E(publicFacet).isOpen(), key]; - }); + const isOpenPQuestions = questionStore + .entries() + .map(([key, { publicFacet }]) => { + return [E(publicFacet).isOpen(), key]; + }); const isOpenQuestions = await allComparable(harden(isOpenPQuestions)); return isOpenQuestions @@ -46,11 +54,19 @@ const getOpenQuestions = async questionStore => { .map(([_open, key]) => key); }; +/** + * @param {ERef>} questionHandleP + * @param {Store, QuestionRecord>} questionStore + */ const getQuestion = (questionHandleP, questionStore) => E.when(questionHandleP, questionHandle => E(questionStore.get(questionHandle).publicFacet).getQuestion(), ); +/** + * @param {ContractFacet} zcf + * @param {AddQuestion} addQuestion + */ const getPoserInvitation = (zcf, addQuestion) => { const questionPoserHandler = () => Far(`questionPoser`, { addQuestion }); return zcf.makeInvitation(questionPoserHandler, `questionPoser`); diff --git a/packages/governance/src/internalTypes.js b/packages/governance/src/internalTypes.js index 06df0008409..c0ca3c1b578 100644 --- a/packages/governance/src/internalTypes.js +++ b/packages/governance/src/internalTypes.js @@ -4,3 +4,14 @@ * @property {VoteCounterPublicFacet} publicFacet * @property {Timestamp} deadline */ + +/** + * @callback StartCounter + * @param {ContractFacet} zcf + * @param {QuestionSpec} questionSpec + * @param {unknown} quorumThreshold + * @param {ERef} voteCounter + * @param {Store, QuestionRecord>} questionStore + * @param {IterationObserver} publication + * @returns {AddQuestionReturn} + */ diff --git a/packages/governance/src/question.js b/packages/governance/src/question.js index 119a54998da..aa533e82d83 100644 --- a/packages/governance/src/question.js +++ b/packages/governance/src/question.js @@ -187,19 +187,20 @@ const buildUnrankedQuestion = (questionSpec, counterInstance) => { }); }; +harden(buildUnrankedQuestion); harden(ChoiceMethod); -harden(QuorumRule); harden(ElectionType); harden(looksLikeIssueForType); +harden(looksLikeQuestionSpec); harden(positionIncluded); -harden(buildUnrankedQuestion); +harden(QuorumRule); export { + buildUnrankedQuestion, ChoiceMethod, ElectionType, - QuorumRule, + looksLikeIssueForType, looksLikeQuestionSpec, positionIncluded, - looksLikeIssueForType, - buildUnrankedQuestion, + QuorumRule, }; diff --git a/packages/governance/src/shareHolders.js b/packages/governance/src/shareHolders.js new file mode 100644 index 00000000000..6ae7bac647b --- /dev/null +++ b/packages/governance/src/shareHolders.js @@ -0,0 +1,113 @@ +// @ts-check + +import { E } from '@agoric/eventual-send'; +import { Far } from '@agoric/marshal'; +import { makeSubscriptionKit } from '@agoric/notifier'; +import { makeStore } from '@agoric/store'; +import { AmountMath, AssetKind } from '@agoric/ertp'; +import { + startCounter, + getOpenQuestions, + getQuestion, + getPoserInvitation, +} from './electorateTools'; + +const { details: X } = assert; + +// shareHolders is an Electorate that relies on an attestation contract to +// validate ownership of voting shares. The electorate provides voting facets +// corresponding to the attestations to ensure that only valid holders of shares +// have the ability to vote. + +// The attestation contract is responsible for ensuring that each votable share +// has a persistent handle that survives through extending the duration of the +// lien and augmenting the number of shares it represents. This contract makes +// that persistent handle visible to ballotCounters. + +/** @type {ContractStartFn} */ +const start = zcf => { + const { + brands: { Attestation: attestationBrand }, + } = zcf.getTerms(); + const empty = AmountMath.makeEmpty(attestationBrand, AssetKind.SET); + + /** @type {Store, QuestionRecord>} */ + const allQuestions = makeStore('Question'); + const { subscription, publication } = makeSubscriptionKit(); + + /** @param { SetValue } attestations */ + const makeVoterInvitation = attestations => { + // TODO: The UI will probably want something better, but I don't know what. + const voterDescription = attestations.reduce((desc, amount) => { + const { address, amountLiened } = amount; + return `${desc} ${address}/${amountLiened}`; + }, 'Address/Amount:'); + + // The electorate doesn't know the clock, but it believes the times are + // comparable between the clock for voting deadlines and lien expirations. + + return Far(`a voter ${voterDescription}`, { + /** + * @param {Handle<'Question'>} questionHandle + * @param {Position[]} positions + */ + castBallotFor: (questionHandle, positions) => { + const { voteCap, deadline } = allQuestions.get(questionHandle); + return attestations + .filter(({ expiration }) => expiration > deadline) + .forEach(({ amountLiened, handle }) => { + return E(voteCap).submitVote(handle, positions, amountLiened); + }); + }, + }); + }; + + /** @type {OfferHandler} */ + const vote = seat => { + /** @type {Amount} */ + const attestation = seat.getAmountAllocated('Attestation'); + assert( + AmountMath.isGTE(attestation, empty, attestationBrand), + X`There was no attestation escrowed`, + ); + // Give the user their attestation payment back + seat.exit(); + + assert.typeof(attestation.value, 'object'); // entailed by isGTE on empty SET + return makeVoterInvitation(attestation.value); + }; + + /** @type {AddQuestion} */ + const addQuestion = async (voteCounter, questionSpec) => { + return startCounter( + zcf, + questionSpec, + 0n, + voteCounter, + allQuestions, + publication, + ); + }; + + /** @type {ClaimsElectoratePublic} */ + const publicFacet = Far('publicFacet', { + getQuestionSubscription: () => subscription, + getOpenQuestions: () => getOpenQuestions(allQuestions), + getInstance: zcf.getInstance, + getQuestion: handle => getQuestion(handle, allQuestions), + makeVoterInvitation: () => zcf.makeInvitation(vote, 'attestation vote'), + }); + + /** @type {ShareholdersCreatorFacet} */ + const creatorFacet = Far('creatorFacet', { + getPoserInvitation: () => getPoserInvitation(zcf, addQuestion), + addQuestion, + getQuestionSubscription: () => subscription, + getPublicFacet: () => publicFacet, + }); + + return { publicFacet, creatorFacet }; +}; + +harden(start); +export { start }; diff --git a/packages/governance/src/types.js b/packages/governance/src/types.js index 8783b0a2199..281b0d4ee47 100644 --- a/packages/governance/src/types.js +++ b/packages/governance/src/types.js @@ -138,13 +138,13 @@ /** * @typedef {Object} PositionCount - * @property {string} position - * @property {number} tally + * @property {Position} position + * @property {bigint} total */ /** * @typedef {Object} VoteStatistics - * @property {number} spoiled + * @property {bigint} spoiled * @property {number} votes * @property {PositionCount[]} results */ @@ -240,13 +240,28 @@ * @param {bigint=} weight */ +/** + * @callback GetOpenQuestions + * @returns {Promise[]>} + */ + +/** + * @callback GetQuestion + * @param {Handle<'Question'>} h + * @returns {Promise} + */ + /** * @typedef {Object} ElectoratePublic * @property {() => Subscription} getQuestionSubscription - * @property {() => Promise[]>} getOpenQuestions, - * @property {() => string} getName + * @property {GetOpenQuestions} getOpenQuestions, * @property {() => Instance} getInstance - * @property {(h: Handle<'Question'>) => Promise} getQuestion + * @property {GetQuestion} getQuestion + */ + +/** + * @typedef { ElectoratePublic & {makeVoterInvitation: () => ERef} } ClaimsElectoratePublic + * @typedef { ElectoratePublic & {getName: () => string} } CommitteeElectoratePublic */ /** @@ -260,12 +275,32 @@ * 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 { ElectorateCreatorFacet & { + * getVoterInvitations: () => Promise[] + * }} CommitteeElectorateCreatorFacet + */ + +/** + * @typedef { ElectorateCreatorFacet & {addQuestion: AddQuestion} } ShareholdersCreatorFacet + */ + +/** + * @typedef {Object} GetVoterInvitations + * @property {() => Invitation[]} getVoterInvitations + */ + +/** + * @typedef {Object} VoterFacet - a facet that the Electorate should hold + * tightly. It allows specification of the vote's weight, so the Electorate + * should distribute an attenuated wrapper that doesn't make that available! + * @property {SubmitVote} submitVote + */ + /** * @typedef {Object} ClosingRule * @property {ERef} timer @@ -283,20 +318,17 @@ * @property {VoteCounterPublicFacet} publicFacet * @property {VoteCounterCreatorFacet} creatorFacet * @property {Instance} instance + * @property {Timestamp} deadline + * @property {Handle<'Question'>} questionHandle */ /** * @callback AddQuestion - * @param {Installation} voteCounter + * @param {ERef} voteCounter * @param {QuestionSpec} questionSpec * @returns {Promise} */ -/** - * @typedef QuestionCreator - * @property {AddQuestion} addQuestion - */ - /** * @callback CreateQuestion * diff --git a/packages/governance/test/unitTests/test-shareHolders.js b/packages/governance/test/unitTests/test-shareHolders.js new file mode 100644 index 00000000000..66e7b109ff0 --- /dev/null +++ b/packages/governance/test/unitTests/test-shareHolders.js @@ -0,0 +1,388 @@ +// @ts-check + +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import '@agoric/zoe/exported.js'; + +import path from 'path'; +// eslint-disable-next-line import/no-extraneous-dependencies +import bundleSource from '@agoric/bundle-source'; +import fakeVatAdmin from '@agoric/zoe/tools/fakeVatAdmin.js'; +import { E } from '@agoric/eventual-send'; +import { makeIssuerKit, AssetKind, AmountMath } from '@agoric/ertp'; +import { makeHandle } from '@agoric/zoe/src/makeHandle.js'; + +import buildManualTimer from '@agoric/zoe/tools/manualTimer.js'; +import { Nat } from '@agoric/nat'; +import { makeZoeKit } from '@agoric/zoe'; +import { + ElectionType, + ChoiceMethod, + QuorumRule, + looksLikeQuestionSpec, +} from '../../src/question.js'; + +const filename = new URL(import.meta.url).pathname; +const dirname = path.dirname(filename); + +const shareHoldersRoot = `${dirname}/../../src/shareHolders.js`; +const binaryCounterRoot = `${dirname}/../../src/binaryVoteCounter.js`; + +/** + * @param {string} sourceRoot + * @param {ERef} zoe + */ +const makeInstall = (sourceRoot, zoe) => { + const bundle = bundleSource(sourceRoot); + console.log(`installing ${sourceRoot}`); + return E.when(bundle, b => E(zoe).install(b)); +}; + +const makeAttestation = (handle, amountLiened, address, expiration) => + harden([{ handle, amountLiened, address, expiration }]); + +/** + * @param {Address} addr + * @param {NatValue} amountLiened + * @param {Timestamp} expiration + */ +const attest = (addr, amountLiened, expiration) => { + Nat(amountLiened); + Nat(expiration); + const handle = makeHandle('Attestation'); + return makeAttestation(handle, amountLiened, addr, expiration); +}; + +/** + * @param {Issue} issue + * @param {Position[]} positions + * @param {ERef} timer + * @param {Timestamp} deadline + */ +const makeDefaultBallotSpec = (issue, positions, timer, deadline) => { + const questionSpec = looksLikeQuestionSpec({ + method: ChoiceMethod.UNRANKED, + issue, + positions, + electionType: ElectionType.ELECTION, + maxChoices: 1, + closingRule: { + timer, + deadline, + }, + quorumRule: QuorumRule.NO_QUORUM, + tieOutcome: positions[1], + }); + + return questionSpec; +}; + +const { zoeService } = makeZoeKit(fakeVatAdmin); +const feePurse = E(zoeService).makeFeePurse(); +const zoe = E(zoeService).bindDefaultFeePurse(feePurse); + +const electorateInstall = makeInstall(shareHoldersRoot, zoe); +const counterInstall = makeInstall(binaryCounterRoot, zoe); + +/** + * @param {Mint} attestationMint + * @param {ClaimsElectoratePublic} publicElectorate + * @param {Amount} attestation + */ +const offerToVoteSeat = (attestationMint, publicElectorate, attestation) => { + const attestation1 = attestationMint.mintPayment(attestation); + const proposal = harden({ + give: { Attestation: attestation }, + want: {}, + }); + return E(zoe).offer(E(publicElectorate).makeVoterInvitation(), proposal, { + Attestation: attestation1, + }); +}; + +/** + * @param {Mint} mint + * @param {ClaimsElectoratePublic} publicFacet + * @param {Amount} attestAmmount + */ +const voterFacet = (mint, publicFacet, attestAmmount) => { + return E(offerToVoteSeat(mint, publicFacet, attestAmmount)).getOfferResult(); +}; + +/** + * @param {Timer} timer + * @param {ShareholdersCreatorFacet} creatorFacet + */ +const addDeposeQuestion = async (timer, creatorFacet) => { + const depose = harden({ text: 'Replace the CEO?' }); + const deposePositions = [ + harden({ text: 'Yes, replace' }), + harden({ text: 'no change' }), + ]; + const deposeSpec = makeDefaultBallotSpec(depose, deposePositions, timer, 2n); + const { publicFacet: deposeCounter, questionHandle } = await E( + creatorFacet, + ).addQuestion(counterInstall, deposeSpec); + return { deposePositions, deposeCounter, questionHandle }; +}; + +const addDividendQuestion = async (timer, creatorFacet) => { + const dividend = harden({ text: 'Raise the dividend?' }); + const divPositions = [ + harden({ text: 'Raise dividend to $0.70' }), + harden({ text: 'Raise dividend to $0.50' }), + ]; + const dividendSpec = makeDefaultBallotSpec(dividend, divPositions, timer, 4n); + const { publicFacet: dividendCounter, questionHandle } = await E( + creatorFacet, + ).addQuestion(counterInstall, dividendSpec); + return { divPositions, dividendCounter, questionHandle }; +}; + +test('shareHolders attestation returned on simpleInvite', async t => { + const { issuer, brand, mint } = makeIssuerKit('attestations', AssetKind.SET); + + const { publicFacet: electoratePub } = await E(zoe).startInstance( + electorateInstall, + { + Attestation: issuer, + }, + ); + + const attest1 = AmountMath.make(brand, attest('a', 37n, 5n)); + const voteSeat = offerToVoteSeat(mint, electoratePub, attest1); + const aPurse = issuer.makeEmptyPurse(); + await aPurse.deposit(await E(voteSeat).getPayout('Attestation')); + + t.deepEqual(await E(aPurse).getCurrentAmount(), attest1); +}); + +test('shareHolders attestation to vote', async t => { + const { issuer, brand, mint } = makeIssuerKit('attestations', AssetKind.SET); + const timer = buildManualTimer(console.log); + const { publicFacet, creatorFacet } = await E( + zoe, + ).startInstance(electorateInstall, { Attestation: issuer }); + + const attest1 = AmountMath.make(brand, attest('a', 37n, 5n)); + const voteSeat = voterFacet(mint, publicFacet, attest1); + + const { + deposePositions, + deposeCounter, + questionHandle, + } = await addDeposeQuestion(timer, creatorFacet); + + await E(voteSeat).castBallotFor(questionHandle, [deposePositions[1]]); + + await E(timer).tick(); + await E(timer).tick(); + + await E.when(E(deposeCounter).getOutcome(), outcome => { + t.is(outcome, deposePositions[1]); + }).catch(e => t.fail(e)); +}); + +test('shareHolders reuse across questions', async t => { + const { issuer, brand, mint } = makeIssuerKit('attestations', AssetKind.SET); + const timer = buildManualTimer(console.log); + const { publicFacet, creatorFacet } = await E( + zoe, + ).startInstance(electorateInstall, { Attestation: issuer }); + + const attest1 = AmountMath.make(brand, attest('a', 37n, 5n)); + const voteFacet1 = voterFacet(mint, publicFacet, attest1); + const attest2 = AmountMath.make(brand, attest('a', 13n, 6n)); + const voteFacet2 = voterFacet(mint, publicFacet, attest2); + const { + deposePositions, + deposeCounter, + questionHandle: h1, + } = await addDeposeQuestion(timer, creatorFacet); + + const { + divPositions, + dividendCounter, + questionHandle: h2, + } = await addDividendQuestion(timer, creatorFacet); + + const deposeBallot1 = [deposePositions[1]]; + const divBallot0 = [divPositions[0]]; + + const vote1dep1 = E(voteFacet1).castBallotFor(h1, deposeBallot1); + const vote2dep1 = E(voteFacet2).castBallotFor(h1, deposeBallot1); + const vote1div0 = E(voteFacet1).castBallotFor(h2, divBallot0); + const vote2div0 = E(voteFacet2).castBallotFor(h2, divBallot0); + const t1 = E(timer).tick(); + + await Promise.all([vote1dep1, vote2dep1, vote1div0, vote2div0, t1]); + await Promise.all([E(timer).tick(), E(timer).tick(), E(timer).tick()]); + + const deposeOutcome = await E(deposeCounter).getOutcome(); + t.is(deposeOutcome, deposePositions[1]); + + await E(timer).tick(); + const dividendOutcome = await E(dividendCounter).getOutcome(); + t.is(dividendOutcome, divPositions[0]); + + const dividendTally = await E(dividendCounter).getStats(); + t.deepEqual(dividendTally, { + spoiled: 0n, + votes: 2, + results: [ + { position: divPositions[0], total: 50n }, + { position: divPositions[1], total: 0n }, + ], + }); + const deposeTally = await E(deposeCounter).getStats(); + t.deepEqual(deposeTally, { + spoiled: 0n, + votes: 2, + results: [ + { position: deposePositions[0], total: 0n }, + { position: deposePositions[1], total: 50n }, + ], + }); +}); + +test('shareHolders expiring attestations', async t => { + const { issuer, brand, mint } = makeIssuerKit('attestations', AssetKind.SET); + const timer = buildManualTimer(console.log); + const { publicFacet, creatorFacet } = await E( + zoe, + ).startInstance(electorateInstall, { Attestation: issuer }); + + // deadlines: depose: 2, dividends: 4 + // voter 1 votes won't count; voter 2 can't vote on dividends. + const attest1 = AmountMath.make(brand, attest('a', 37n, 1n)); + const voteFacet1 = await voterFacet(mint, publicFacet, attest1); + const attest2 = AmountMath.make(brand, attest('a', 13n, 3n)); + const voteFacet2 = await voterFacet(mint, publicFacet, attest2); + const attest3 = AmountMath.make(brand, attest('a', 7n, 5n)); + const voteFacet3 = await voterFacet(mint, publicFacet, attest3); + + const { + deposePositions, + deposeCounter, + questionHandle: deposeQuestionHandle, + } = await addDeposeQuestion(timer, creatorFacet); + + const { + divPositions, + dividendCounter, + questionHandle: dividendQuestionHandle, + } = await addDividendQuestion(timer, creatorFacet); + + const deposeBallot0 = [deposePositions[0]]; + const deposeBallot1 = [deposePositions[1]]; + const divBallot0 = [divPositions[0]]; + const divBallot1 = [divPositions[1]]; + + const vote1dep1 = E(voteFacet1).castBallotFor( + deposeQuestionHandle, + deposeBallot1, + ); + const vote1div0 = E(voteFacet1).castBallotFor( + dividendQuestionHandle, + divBallot0, + ); + const vote2dep1 = E(voteFacet2).castBallotFor( + deposeQuestionHandle, + deposeBallot1, + ); + const vote2div0 = E(voteFacet2).castBallotFor( + dividendQuestionHandle, + divBallot0, + ); + const vote3div1 = E(voteFacet3).castBallotFor( + dividendQuestionHandle, + divBallot1, + ); + const vote3dep0 = E(voteFacet3).castBallotFor( + deposeQuestionHandle, + deposeBallot0, + ); + + await Promise.all([vote1dep1, vote2dep1, vote1div0, vote2div0]); + await Promise.all([vote3dep0, vote3div1]); + await Promise.all([E(timer).tick(), E(timer).tick()]); + await Promise.all([E(timer).tick(), E(timer).tick()]); + + const deposeOutcome = await E(deposeCounter).getOutcome(); + t.is(deposeOutcome, deposePositions[1]); + const deposeTally = await E(deposeCounter).getStats(); + t.deepEqual(deposeTally, { + spoiled: 0n, + votes: 2, + results: [ + { position: deposePositions[0], total: 7n }, + { position: deposePositions[1], total: 13n }, + ], + }); + + const dividendOutcome = await E(dividendCounter).getOutcome(); + t.is(dividendOutcome, divPositions[1]); + const dividendTally = await E(dividendCounter).getStats(); + t.deepEqual(dividendTally, { + spoiled: 0n, + votes: 1, + results: [ + { position: divPositions[0], total: 0n }, + { position: divPositions[1], total: 7n }, + ], + }); +}); + +test('shareHolders bundle/split attestations', async t => { + const { issuer, brand, mint } = makeIssuerKit('attestations', AssetKind.SET); + const timer = buildManualTimer(console.log); + const { publicFacet, creatorFacet } = await E( + zoe, + ).startInstance(electorateInstall, { Attestation: issuer }); + + // deadline: depose: 2 + const handleShared = makeHandle('Attestation'); + const handle4 = makeHandle('Attestation'); + const handle7 = makeHandle('Attestation'); + const claim2 = makeAttestation(handleShared, 2n, 'a', 3n)[0]; + const claim4 = makeAttestation(handle4, 4n, 'a', 7n)[0]; + const claim7 = makeAttestation(handle7, 7n, 'a', 3n)[0]; + const claim7Later = makeAttestation(handle7, 7n, 'a', 10n)[0]; + const claim14 = makeAttestation(handleShared, 14n, 'a', 7n)[0]; + + const attest2and4 = AmountMath.make(brand, [claim2, claim4]); + const voteFacet2and4 = await voterFacet(mint, publicFacet, attest2and4); + const attest4and7 = AmountMath.make(brand, [claim4, claim7]); + const voteFacet4and7 = await voterFacet(mint, publicFacet, attest4and7); + const attestUpdate = AmountMath.make(brand, [claim7Later, claim14]); + const voteFacetUpdate = await voterFacet(mint, publicFacet, attestUpdate); + + const { + deposePositions, + deposeCounter, + questionHandle, + } = await addDeposeQuestion(timer, creatorFacet); + + const deposeBallot0 = [deposePositions[0]]; + const deposeBallot1 = [deposePositions[1]]; + + await E(voteFacet2and4).castBallotFor(questionHandle, deposeBallot1); + await E(voteFacet4and7).castBallotFor(questionHandle, deposeBallot0); + await E(voteFacetUpdate).castBallotFor(questionHandle, deposeBallot1); + // 2n voted [1], then added capital and voted 14n [1] + // 4n voted [1] then [0] + // 7n voted [0] then [1] + + await Promise.all([E(timer).tick(), E(timer).tick()]); + + const deposeOutcome = await E(deposeCounter).getOutcome(); + t.is(deposeOutcome, deposePositions[1]); + const deposeTally = await E(deposeCounter).getStats(); + t.deepEqual(deposeTally, { + spoiled: 0n, + votes: 3, + results: [ + { position: deposePositions[0], total: 4n }, + { position: deposePositions[1], total: 21n }, + ], + }); +}); diff --git a/packages/zoe/src/contracts/attestation/expiring/expiringHelpers.js b/packages/zoe/src/contracts/attestation/expiring/expiringHelpers.js index e6fc69bfc15..b7a48ebba93 100644 --- a/packages/zoe/src/contracts/attestation/expiring/expiringHelpers.js +++ b/packages/zoe/src/contracts/attestation/expiring/expiringHelpers.js @@ -38,7 +38,7 @@ const hasExpired = (expiration, currentTime) => expiration < currentTime; * @param {Amount} amountLiened - the amount of the underlying asset to put * a lien on * @param {Timestamp} expiration - * @param {Handle<'attestation'>} handle - the unique handle + * @param {Handle<'Attestation'>} handle - the unique handle * @returns {ExpiringAttElem} */ const makeAttestationElem = (address, amountLiened, expiration, handle) => { diff --git a/packages/zoe/src/contracts/attestation/expiring/expiringNFT.js b/packages/zoe/src/contracts/attestation/expiring/expiringNFT.js index 15a12df677f..a645a98e3f1 100644 --- a/packages/zoe/src/contracts/attestation/expiring/expiringNFT.js +++ b/packages/zoe/src/contracts/attestation/expiring/expiringNFT.js @@ -60,7 +60,7 @@ const setupAttestation = async (attestationTokenName, empty, zcf) => { const amountToLien = validateInputs(externalBrand, address, amount); assert.typeof(expiration, 'bigint'); - const handle = makeHandle('attestation'); + const handle = makeHandle('Attestation'); const attestationElem = makeAttestationElem( address, diff --git a/packages/zoe/src/contracts/attestation/types.js b/packages/zoe/src/contracts/attestation/types.js index ddc101ef767..d5176b4ce8c 100644 --- a/packages/zoe/src/contracts/attestation/types.js +++ b/packages/zoe/src/contracts/attestation/types.js @@ -89,7 +89,7 @@ * @property {Address} address * @property {Amount} amountLiened * @property {Timestamp} expiration - * @property {Handle<'attestation'>} handle + * @property {Handle<'Attestation'>} handle */ /** diff --git a/packages/zoe/src/contracts/exported.js b/packages/zoe/src/contracts/exported.js index 8c5166dd8fb..31af0bc67de 100644 --- a/packages/zoe/src/contracts/exported.js +++ b/packages/zoe/src/contracts/exported.js @@ -3,3 +3,4 @@ import './loan/types.js'; import './multipoolAutoswap/types.js'; import './priceAggregatorTypes.js'; import './callSpread/types.js'; +import './attestation/types.js'; diff --git a/packages/zoe/test/unitTests/contracts/attestation/exampleVotingUsage.js b/packages/zoe/test/unitTests/contracts/attestation/exampleVotingUsage.js index b6fff2182bc..9f08d56e044 100644 --- a/packages/zoe/test/unitTests/contracts/attestation/exampleVotingUsage.js +++ b/packages/zoe/test/unitTests/contracts/attestation/exampleVotingUsage.js @@ -18,7 +18,7 @@ const start = zcf => { brands: { Attestation: attestationBrand }, } = zcf.getTerms(); - /** @type {Store, { seat: ZCFSeat, + /** @type {Store, { seat: ZCFSeat, * expiration: Timestamp, amountLiened: Amount}>} */ const storedAttestations = makeStore(); diff --git a/packages/zoe/test/unitTests/contracts/attestation/expiringNFT/test-addToLiened.js b/packages/zoe/test/unitTests/contracts/attestation/expiringNFT/test-addToLiened.js index c4a8b0b4c30..ebb55181498 100644 --- a/packages/zoe/test/unitTests/contracts/attestation/expiringNFT/test-addToLiened.js +++ b/packages/zoe/test/unitTests/contracts/attestation/expiringNFT/test-addToLiened.js @@ -14,8 +14,8 @@ test('add for same address', async t => { const store = makeLegacyMap('address'); const address = 'address1'; - const handle1 = makeHandle('attestation'); - const handle2 = makeHandle('attestation'); + const handle1 = makeHandle('Attestation'); + const handle2 = makeHandle('Attestation'); const { brand } = makeIssuerKit('external'); const amountLiened = AmountMath.make(brand, 0n); @@ -45,8 +45,8 @@ test('add for multiple addresses', async t => { const expiration = 0n; const address1 = 'address1'; const address2 = 'address2'; - const handle1 = makeHandle('attestation'); - const handle2 = makeHandle('attestation'); + const handle1 = makeHandle('Attestation'); + const handle2 = makeHandle('Attestation'); const elem1 = { address: address1, handle: handle1, diff --git a/packages/zoe/test/unitTests/contracts/attestation/expiringNFT/test-extendExpiration.js b/packages/zoe/test/unitTests/contracts/attestation/expiringNFT/test-extendExpiration.js index f4a28e49a47..03852e17400 100644 --- a/packages/zoe/test/unitTests/contracts/attestation/expiringNFT/test-extendExpiration.js +++ b/packages/zoe/test/unitTests/contracts/attestation/expiringNFT/test-extendExpiration.js @@ -26,13 +26,13 @@ const doTest = ( 'address', amountLiened, 4n, - makeHandle('attestation'), + makeHandle('Attestation'), ); const elem2 = makeAttestationElem( 'address', amountLiened, 5n, - makeHandle('attestation'), + makeHandle('Attestation'), ); let currentAllocation; diff --git a/packages/zoe/test/unitTests/contracts/attestation/expiringNFT/test-unlienIfExpired.js b/packages/zoe/test/unitTests/contracts/attestation/expiringNFT/test-unlienIfExpired.js index c49cd3efb2d..e70a6a6ff6e 100644 --- a/packages/zoe/test/unitTests/contracts/attestation/expiringNFT/test-unlienIfExpired.js +++ b/packages/zoe/test/unitTests/contracts/attestation/expiringNFT/test-unlienIfExpired.js @@ -53,21 +53,21 @@ test(`store has address with all non-expired values`, async t => { address, bld10, 10n, - makeHandle('attestation'), + makeHandle('Attestation'), ); const elem2 = makeAttestationElem( address, bld20, 10n, - makeHandle('attestation'), + makeHandle('Attestation'), ); const elem3 = makeAttestationElem( address, bld40, 10n, - makeHandle('attestation'), + makeHandle('Attestation'), ); store.init(address, [elem1, elem2, elem3]); @@ -98,21 +98,21 @@ test(`store has address with one expired`, async t => { address, bld10, 1n, - makeHandle('attestation'), + makeHandle('Attestation'), ); const elem2 = makeAttestationElem( address, bld20, 10n, - makeHandle('attestation'), + makeHandle('Attestation'), ); const elem3 = makeAttestationElem( address, bld40, 10n, - makeHandle('attestation'), + makeHandle('Attestation'), ); store.init(address, [elem1, elem2, elem3]); diff --git a/packages/zoe/test/unitTests/contracts/attestation/expiringNFT/test-updateLien.js b/packages/zoe/test/unitTests/contracts/attestation/expiringNFT/test-updateLien.js index e33358fdcb0..57fb5526306 100644 --- a/packages/zoe/test/unitTests/contracts/attestation/expiringNFT/test-updateLien.js +++ b/packages/zoe/test/unitTests/contracts/attestation/expiringNFT/test-updateLien.js @@ -36,7 +36,7 @@ test(`no old records`, async t => { address, amountLiened, 1n, - makeHandle('attestation'), + makeHandle('Attestation'), ); t.throws(() => updateLien(store, newAttestationElem), { @@ -49,7 +49,7 @@ test(`old records don't match`, async t => { const store = makeStore('address'); const address = 'address'; - const handle = makeHandle('attestation'); + const handle = makeHandle('Attestation'); const { brand: externalBrand } = makeIssuerKit('external'); const amountLiened = AmountMath.make(externalBrand, 10n); const oldAttestation = makeAttestationElem(address, amountLiened, 1n, handle); @@ -60,7 +60,7 @@ test(`old records don't match`, async t => { address, amountLiened, 5n, - makeHandle('attestation'), + makeHandle('Attestation'), ); t.throws(() => updateLien(store, newAttestationElem), { @@ -73,7 +73,7 @@ test(`happy path`, async t => { const store = makeStore('address'); const address = 'address'; - const handle = makeHandle('attestation'); + const handle = makeHandle('Attestation'); const { brand: externalBrand } = makeIssuerKit('external'); const amountLiened = AmountMath.make(externalBrand, 10n); @@ -87,7 +87,7 @@ test(`happy path`, async t => { address, amountLiened, 1n, - makeHandle('attestation'), + makeHandle('Attestation'), ); store.init(address, [oldAttestation1, oldAttestation2]); diff --git a/packages/zoe/test/unitTests/contracts/attestation/test-exampleVotingUsage.js b/packages/zoe/test/unitTests/contracts/attestation/test-exampleVotingUsage.js index f17c75b3549..ff2ffff8a34 100644 --- a/packages/zoe/test/unitTests/contracts/attestation/test-exampleVotingUsage.js +++ b/packages/zoe/test/unitTests/contracts/attestation/test-exampleVotingUsage.js @@ -57,13 +57,13 @@ test('exampleVotingUsage', async t => { }; // A normal vote which is weighted at 40 tokens and expires at 5n - const firstHandle = makeHandle('attestation'); + const firstHandle = makeHandle('Attestation'); const bld40 = AmountMath.make(bldIssuerKit.brand, 40n); const elem1 = makeAttestationElem('myaddress', bld40, 5n, firstHandle); await doVote(AmountMath.make(issuerKit.brand, [elem1]), 'Yes'); // Another vote, but which expires at 1n - const secondHandle = makeHandle('attestation'); + const secondHandle = makeHandle('Attestation'); const bld3 = AmountMath.make(bldIssuerKit.brand, 3n); const elem2 = makeAttestationElem('myaddress', bld3, 1n, secondHandle); await doVote(AmountMath.make(issuerKit.brand, [elem2]), 'No'); @@ -74,7 +74,7 @@ test('exampleVotingUsage', async t => { await doVote(AmountMath.make(issuerKit.brand, [elem3]), 'Maybe'); // This vote should not be counted as it is expired. - const thirdHandle = makeHandle('attestation'); + const thirdHandle = makeHandle('Attestation'); const bld5 = AmountMath.make(bldIssuerKit.brand, 5n); const elem4 = makeAttestationElem('myaddress', bld5, 1n, thirdHandle); await doVote(AmountMath.make(issuerKit.brand, [elem4]), 'Should not count');