From 1c81b31b75918bdaeca88f560ba88400e054473b Mon Sep 17 00:00:00 2001 From: Josh Levine Date: Fri, 1 May 2020 19:30:43 -0400 Subject: [PATCH] add irv ranked choice tally method to gov polling service --- packages/dai-plugin-governance/package.json | 1 + .../src/GovPollingService.js | 106 ++++++++++++++++++ .../src/GovQueryApiService.js | 27 ++++- .../src/utils/helpers.js | 82 +++++++++++++- .../integration/govQueryApiService.test.js | 5 + 5 files changed, 214 insertions(+), 7 deletions(-) diff --git a/packages/dai-plugin-governance/package.json b/packages/dai-plugin-governance/package.json index e28981934..02a3033d7 100644 --- a/packages/dai-plugin-governance/package.json +++ b/packages/dai-plugin-governance/package.json @@ -12,6 +12,7 @@ "@makerdao/currency": "^0.9.5", "@makerdao/services-core": "^0.9.5", "assert": "^2.0.0", + "bignumber": "^1.1.0", "ramda": "^0.25.0", "web3-utils": "^1.0.0-beta.36" }, diff --git a/packages/dai-plugin-governance/src/GovPollingService.js b/packages/dai-plugin-governance/src/GovPollingService.js index 93166ab77..170b314d0 100644 --- a/packages/dai-plugin-governance/src/GovPollingService.js +++ b/packages/dai-plugin-governance/src/GovPollingService.js @@ -1,6 +1,7 @@ import { PrivateService } from '@makerdao/services-core'; import { POLLING } from './utils/constants'; import { MKR } from './utils/constants'; +import BigNumber from 'bignumber.js'; const POSTGRES_MAX_INT = 2147483647; @@ -92,6 +93,111 @@ export default class GovPollingService extends PrivateService { return MKR(weights.reduce((acc, cur) => acc + cur.mkrSupport, 0)); } + async getTallyRankedChoiceIrv(pollId) { + const { endDate } = await this._getPoll(pollId); + const endUnix = Math.floor(endDate / 1000); + const votes = await this.get('govQueryApi').getMkrSupportRankedChoice( + pollId, + endUnix + ); + const totalMkrParticipation = votes.reduce( + (acc, cur) => BigNumber(cur.mkrSupport || 0).plus(acc), + BigNumber(0) + ); + + const tally = { rounds: 1, winner: null, options: {} }; + const defaultOptionObj = { + firstChoice: BigNumber(0), + transfer: BigNumber(0), + winner: false, + eliminated: false + }; + + // run the first round + votes.forEach(vote => { + vote.choice = vote.ballot.pop(); + if (!tally.options[vote.choice]) + tally.options[vote.choice] = { ...defaultOptionObj }; + + tally.options[vote.choice].firstChoice = BigNumber( + tally.options[vote.choice].firstChoice + ).plus(vote.mkrSupport || 0); + }); + + // does any candidate have the majority after the first round? + Object.entries(tally.options).forEach(([option, { firstChoice }]) => { + if (firstChoice.gt(totalMkrParticipation.div(2))) tally.winner = option; + }); + + // if so, we're done. Return the winner + if (tally.winner) { + tally.options[tally.winner].winner = true; + return tally; + } + + // if we couldn't find a winner based on first preferences, run additionaly irv rounds until we find one + while (!tally.winner) { + tally.rounds++; + + // eliminate the weakest candidate + const [optionToEliminate] = Object.entries(tally.options).reduce( + (prv, cur) => { + const [, prvVotes] = prv; + const [, curVotes] = cur; + if ( + curVotes.firstChoice + .add(curVotes.transfer) + .lt(prvVotes.firstChoice.add(prvVotes.transfer)) + ) + return cur; + return prv; + } + ); + + tally.options[optionToEliminate].eliminated = true; + tally.options[optionToEliminate].transfer = BigNumber(0); + + // a vote needs to be moved if... + // 1) it's currently for the eliminated candidate + // 2) there's another choice further down in the voter's preference list + const votesToBeMoved = votes + .filter(vote => vote.choice === optionToEliminate) + .filter(vote => vote.ballot[vote.ballot.length - 1] !== '0'); + + // move votes to the next choice on their preference list + votesToBeMoved.forEach(vote => { + vote.choice = vote.ballot.pop(); + tally.options[vote.choice].transfer = BigNumber( + tally.options[vote.choice].transfer + ).plus(vote.mkrSupport || 0); + }); + + // look for a candidate with the majority + Object.entries(tally.options).forEach( + ([option, { firstChoice, transfer }]) => { + if (firstChoice.add(transfer).gt(totalMkrParticipation.div(2))) + tally.winner = option; + } + ); + + // sanity checks + if (Object.keys(tally.options).length === 2) { + // dead tie. this seems super unlikely, but it should be here for completeness + // return the tally without declaring a winner + return tally; + } + if (Object.keys(tally.options).length === 1) { + // this shouldn't happen + throw new Error(`Invalid ranked choice tally ${tally.options}`); + } + + // if we couldn't find one, go for another round + } + + tally.options[tally.winner].winner = true; + return tally; + } + async getPercentageMkrVoted(pollId) { const [voted, total] = await Promise.all([ this.getMkrAmtVoted(pollId), diff --git a/packages/dai-plugin-governance/src/GovQueryApiService.js b/packages/dai-plugin-governance/src/GovQueryApiService.js index aa7cd2c88..d0ff4253f 100644 --- a/packages/dai-plugin-governance/src/GovQueryApiService.js +++ b/packages/dai-plugin-governance/src/GovQueryApiService.js @@ -1,6 +1,11 @@ import { PublicService } from '@makerdao/services-core'; import assert from 'assert'; -import { netIdtoSpockUrl, netIdtoSpockUrlStaging } from './utils/helpers'; +import { + netIdtoSpockUrl, + netIdtoSpockUrlStaging, + toBuffer, + paddedArray +} from './utils/helpers'; export default class QueryApi extends PublicService { constructor(name = 'govQueryApi') { @@ -115,6 +120,26 @@ export default class QueryApi extends PublicService { return response.timeToBlockNumber.nodes[0]; } + async getMkrSupportRankedChoice(pollId, unixTime) { + const query = `{voteMkrWeightsAtTimeRankedChoice(argPollId: ${pollId}, argUnix: ${unixTime}){ + nodes{ + optionIdRaw + mkrSupport + } + } + }`; + const response = await this.getQueryResponseMemoized(this.serverUrl, query); + + return response.voteMkrWeightsAtTimeRankedChoice.nodes.map(vote => { + const ballotBuffer = toBuffer(vote.optionIdRaw, { endian: 'little' }); + const ballot = paddedArray(32 - ballotBuffer.length, ballotBuffer); + return { + ...vote, + ballot + }; + }); + } + async getMkrSupport(pollId, unixTime) { const query = `{voteOptionMkrWeightsAtTime(argPollId: ${pollId}, argUnix: ${unixTime}){ nodes{ diff --git a/packages/dai-plugin-governance/src/utils/helpers.js b/packages/dai-plugin-governance/src/utils/helpers.js index a43c9e484..aa9a707e0 100644 --- a/packages/dai-plugin-governance/src/utils/helpers.js +++ b/packages/dai-plugin-governance/src/utils/helpers.js @@ -1,10 +1,6 @@ import { createGetCurrency } from '@makerdao/currency'; -import { - MKR, - STAGING_MAINNET_URL, - KOVAN_URL, - MAINNET_URL -} from './constants'; +import BigNumber from 'bignumber.js'; +import { MKR, STAGING_MAINNET_URL, KOVAN_URL, MAINNET_URL } from './constants'; /** * @desc get network name @@ -47,3 +43,77 @@ export const netIdtoSpockUrlStaging = id => { }; export const getCurrency = createGetCurrency({ MKR }); + +export const paddedArray = (k, value) => + Array.from({ length: k }) + .map(() => 0) + .concat(...value); + +export const toBuffer = (number, opts) => { + let buf; + let len; + let endian; + let hex = new BigNumber(number).toString(16); + let size; + let hx; + + if (!opts) { + opts = {}; + } + + endian = { 1: 'big', '-1': 'little' }[opts.endian] || opts.endian || 'big'; + + if (hex.charAt(0) === '-') { + throw new Error('Converting negative numbers to Buffers not supported yet'); + } + + size = opts.size === 'auto' ? Math.ceil(hex.length / 2) : opts.size || 1; + + len = Math.ceil(hex.length / (2 * size)) * size; + buf = Buffer.alloc(len); + + // Zero-pad the hex string so the chunks are all `size` long + while (hex.length < 2 * len) { + hex = `0${hex}`; + } + + hx = hex.split(new RegExp(`(.{${2 * size}})`)).filter(s => s.length > 0); + + hx.forEach((chunk, i) => { + for (var j = 0; j < size; j++) { + var ix = i * size + (endian === 'big' ? j : size - j - 1); + buf[ix] = parseInt(chunk.slice(j * 2, j * 2 + 2), 16); + } + }); + + return buf; +}; + +export const fromBuffer = (buf, opts) => { + if (!opts) { + opts = {}; + } + + var endian = + { 1: 'big', '-1': 'little' }[opts.endian] || opts.endian || 'big'; + + var size = opts.size === 'auto' ? Math.ceil(buf.length) : opts.size || 1; + + if (buf.length % size !== 0) { + throw new RangeError( + `Buffer length (${buf.length}) must be a multiple of size (${size})` + ); + } + + var hex = []; + for (var i = 0; i < buf.length; i += size) { + var chunk = []; + for (var j = 0; j < size; j++) { + chunk.push(buf[i + (endian === 'big' ? j : size - j - 1)]); + } + + hex.push(chunk.map(c => (c < 16 ? '0' : '') + c.toString(16)).join('')); + } + + return new BigNumber(hex.join(''), 16); +}; diff --git a/packages/dai-plugin-governance/test/integration/govQueryApiService.test.js b/packages/dai-plugin-governance/test/integration/govQueryApiService.test.js index 35c1ce5a0..819224f8f 100644 --- a/packages/dai-plugin-governance/test/integration/govQueryApiService.test.js +++ b/packages/dai-plugin-governance/test/integration/govQueryApiService.test.js @@ -33,6 +33,11 @@ test('get mkr weight by option', async () => { console.log('weights', weights); }); +test('get ranked choice mkr weight by option', async () => { + const votes = await service.getMkrSupportRankedChoice(1, 999999999); + console.log('votes', votes); +}); + test('get block number', async () => { const num = await service.getBlockNumber(1511634513); console.log('num', num);