From 9cfb94ece58d42c663cce99ae603af7bcd25a4f7 Mon Sep 17 00:00:00 2001 From: Szymon Szlachtowicz <38212223+Szymx95@users.noreply.github.com> Date: Tue, 10 Aug 2021 18:04:40 +0200 Subject: [PATCH] Introduce timed poll voting (#7) --- packages/core/README.md | 22 +++++- packages/core/src/index.ts | 29 +++++++- packages/core/src/models/TimedPollVoteMsg.ts | 74 +++++++++++++++++++ .../core/src/utils/proto/TimedPollVote.ts | 50 +++++++++++++ .../core/test/models/TimedPollVoteMsg.test.ts | 49 ++++++++++++ .../test/utils/proto/TimedPollVote.test.ts | 43 +++++++++++ packages/core/types/protons/index.d.ts | 13 ++++ 7 files changed, 278 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/models/TimedPollVoteMsg.ts create mode 100644 packages/core/src/utils/proto/TimedPollVote.ts create mode 100644 packages/core/test/models/TimedPollVoteMsg.test.ts create mode 100644 packages/core/test/utils/proto/TimedPollVote.test.ts diff --git a/packages/core/README.md b/packages/core/README.md index f51da73..62926ca 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -18,8 +18,9 @@ objects of type WakuVoting expose functions: createTimedPoll(signer: JsonRpcSigner, question:string, answers: string[], pollType: enum, minToken?: BigNumber, endTime?: number) getTimedPolls() -sendTimedPollVote(signer: JsonRpcSigner, pollHash: string, selectedAnswer:number, sntAmount?: number) +sendTimedPollVote(signer: JsonRpcSigner, id: string, selectedAnswer:number, tokenAmount?: number) +getTimedPollVotes(id: string) ## Polls @@ -47,3 +48,22 @@ message PollInit { bytes signature = 8 // signature of all above fields } ``` + +### Voting on timed poll + +To vote on poll user has to send waku message on topic: + +`/{dapp name}/waku-polling/votes/proto` + +Proto fields for poll vote + +```proto +message TimedPollVote { + bytes id = 1; // id of a poll + bytes voter = 2; // Address of a voter + int64 timestamp = 3; // Timestamp of a waku message + int64 answer = 4; // specified poll answer + optional bytes tokenAmount = 5; // amount of token used for WEIGHTED voting + bytes signature = 6; // signature of all above fields +} +``` \ No newline at end of file diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 34e025c..df24d65 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -5,13 +5,15 @@ import { PollType } from './types/PollType' import { BigNumber, Wallet } from 'ethers' import PollInit from './utils/proto/PollInit' import { WakuMessage } from 'js-waku' +import { TimedPollVoteMsg } from './models/TimedPollVoteMsg' +import TimedPollVote from './utils/proto/TimedPollVote' class WakuVoting { private appName: string private waku: Waku | undefined public tokenAddress: string private pollInitTopic: string - + private timedPollVoteTopic: string private static async createWaku() { const waku = await Waku.create() const nodes = await getStatusFleetNodes() @@ -29,6 +31,7 @@ class WakuVoting { this.appName = appName this.tokenAddress = tokenAddress this.pollInitTopic = `/${this.appName}/waku-polling/timed-polls-init/proto/` + this.timedPollVoteTopic = `/${this.appName}/waku-polling/votes/proto/` this.waku = waku } @@ -64,6 +67,30 @@ class WakuVoting { .map((msg) => PollInit.decode(msg.payload, msg.timestamp)) .filter((poll): poll is PollInitMsg => !!poll) } + + public async sendTimedPollVote( + signer: JsonRpcSigner | Wallet, + id: string, + selectedAnswer: number, + tokenAmount?: BigNumber + ) { + const pollVote = await TimedPollVoteMsg.create(signer, id, selectedAnswer, tokenAmount) + const payload = TimedPollVote.encode(pollVote) + if (payload && pollVote) { + const wakuMessage = await WakuMessage.fromBytes(payload, this.timedPollVoteTopic, { + timestamp: new Date(pollVote.timestamp), + }) + await this.waku?.relay.send(wakuMessage) + } + } + + public async getTimedPollVotes(id: string) { + const messages = await this.waku?.store.queryHistory({ contentTopics: [this.timedPollVoteTopic] }) + return messages + ?.filter((e): e is WakuMessage & { payload: Uint8Array } => !!e?.payload) + .map((msg) => TimedPollVote.decode(msg.payload, msg.timestamp)) + .filter((poll): poll is TimedPollVoteMsg => !!poll && poll.id === id) + } } export default WakuVoting diff --git a/packages/core/src/models/TimedPollVoteMsg.ts b/packages/core/src/models/TimedPollVoteMsg.ts new file mode 100644 index 0000000..2e535e0 --- /dev/null +++ b/packages/core/src/models/TimedPollVoteMsg.ts @@ -0,0 +1,74 @@ +import { BigNumber, utils } from 'ethers' +import { JsonRpcSigner } from '@ethersproject/providers' +import { TimedPollVote } from 'protons' +import { Wallet } from 'ethers' + +export class TimedPollVoteMsg { + public id: string + public voter: string + public timestamp: number + public answer: number + public tokenAmount?: BigNumber + public signature: string + + private constructor( + id: string, + voter: string, + timestamp: number, + answer: number, + signature: string, + tokenAmount?: BigNumber + ) { + this.id = id + this.voter = voter + this.timestamp = timestamp + this.answer = answer + this.tokenAmount = tokenAmount + + this.signature = signature + } + + static async create( + signer: JsonRpcSigner | Wallet, + id: string, + answer: number, + tokenAmount?: BigNumber + ): Promise { + const voter = await signer.getAddress() + const timestamp = Date.now() + + const msg: (string | number | BigNumber)[] = [id, voter, timestamp, answer] + const types = ['bytes32', 'address', 'uint256', 'uint64'] + if (tokenAmount) { + msg.push(tokenAmount) + types.push('uint256') + } + + const packedData = utils.arrayify(utils.solidityPack(types, msg)) + const signature = await signer.signMessage(packedData) + return new TimedPollVoteMsg(id, voter, timestamp, answer, signature, tokenAmount) + } + + static fromProto(payload: TimedPollVote) { + const id = utils.hexlify(payload.id) + const voter = utils.getAddress(utils.hexlify(payload.voter)) + const timestamp = payload.timestamp + const answer = payload.answer + const signature = utils.hexlify(payload.signature) + const tokenAmount = payload.tokenAmount ? BigNumber.from(payload.tokenAmount) : undefined + + const msg: (string | number | BigNumber)[] = [id, voter, timestamp, answer] + const types = ['bytes32', 'address', 'uint256', 'uint64'] + if (tokenAmount) { + msg.push(tokenAmount) + types.push('uint256') + } + + const packedData = utils.arrayify(utils.solidityPack(types, msg)) + const verifiedAddress = utils.verifyMessage(packedData, signature) + if (verifiedAddress != voter) { + return undefined + } + return new TimedPollVoteMsg(id, voter, timestamp, answer, signature, tokenAmount) + } +} diff --git a/packages/core/src/utils/proto/TimedPollVote.ts b/packages/core/src/utils/proto/TimedPollVote.ts new file mode 100644 index 0000000..17524f9 --- /dev/null +++ b/packages/core/src/utils/proto/TimedPollVote.ts @@ -0,0 +1,50 @@ +import protons, { TimedPollVote } from 'protons' +import { utils } from 'ethers' +import { TimedPollVoteMsg } from '../../models/TimedPollVoteMsg' + +const proto = protons(` +message TimedPollVote { + bytes id = 1; // id of a poll + bytes voter = 2; // Address of a voter + int64 timestamp = 3; // Timestamp of a waku message + int64 answer = 4; // specified poll answer + optional bytes tokenAmount = 5; // amount of token used for WEIGHTED voting + bytes signature = 6; // signature of all above fields +} +`) + +export function encode(timedPollVote: TimedPollVoteMsg) { + try { + const arrayify = utils.arrayify + const voteProto: TimedPollVote = { + id: arrayify(timedPollVote.id), + voter: arrayify(timedPollVote.voter), + timestamp: timedPollVote.timestamp, + answer: timedPollVote.answer, + tokenAmount: timedPollVote.tokenAmount ? arrayify(timedPollVote.tokenAmount) : undefined, + signature: arrayify(timedPollVote.signature), + } + + return proto.TimedPollVote.encode(voteProto) + } catch { + return undefined + } +} + +export function decode(payload: Uint8Array, timestamp: Date | undefined) { + try { + const msg = proto.TimedPollVote.decode(payload) + if (!timestamp || timestamp.getTime() != msg.timestamp) { + return undefined + } + if (msg.id && msg.voter && msg.timestamp && msg.answer != undefined && msg.signature) { + return TimedPollVoteMsg.fromProto(msg) + } + } catch { + return undefined + } + + return undefined +} + +export default { encode, decode } diff --git a/packages/core/test/models/TimedPollVoteMsg.test.ts b/packages/core/test/models/TimedPollVoteMsg.test.ts new file mode 100644 index 0000000..661aaa0 --- /dev/null +++ b/packages/core/test/models/TimedPollVoteMsg.test.ts @@ -0,0 +1,49 @@ +import { expect } from 'chai' +import { TimedPollVoteMsg } from '../../src/models/TimedPollVoteMsg' +import { MockProvider } from 'ethereum-waffle' +import { BigNumber, utils } from 'ethers' + +describe('TimedPollVoteMsg', () => { + const provider = new MockProvider() + const [alice] = provider.getWallets() + const pollId = '0x14c336ef626274f156d094fc1d7ffad2bbc83cccc9817598dd55e42a86b56b72' + it('success', async () => { + const poll = await TimedPollVoteMsg.create(alice, pollId, 0) + + expect(poll).to.not.be.undefined + expect(poll.voter).to.eq(alice.address) + expect(poll.answer).to.eq(0) + expect(poll.id).to.be.eq(pollId) + expect(poll.tokenAmount).to.be.undefined + + const msg: (string | number | BigNumber)[] = [poll.id, poll.voter, poll.timestamp, poll.answer] + const types = ['bytes32', 'address', 'uint256', 'uint64'] + const packedData = utils.arrayify(utils.solidityPack(types, msg)) + + const verifiedAddress = utils.verifyMessage(packedData, poll.signature) + expect(verifiedAddress).to.eq(alice.address) + }) + + it('success token amount', async () => { + const poll = await TimedPollVoteMsg.create(alice, pollId, 1, BigNumber.from(100)) + + expect(poll).to.not.be.undefined + expect(poll.voter).to.eq(alice.address) + expect(poll.answer).to.eq(1) + expect(poll.id).to.be.eq(pollId) + expect(poll.tokenAmount).to.deep.eq(BigNumber.from(100)) + + const msg: (string | number | BigNumber | undefined)[] = [ + poll.id, + poll.voter, + poll.timestamp, + poll.answer, + poll?.tokenAmount, + ] + const types = ['bytes32', 'address', 'uint256', 'uint64', 'uint256'] + const packedData = utils.arrayify(utils.solidityPack(types, msg)) + + const verifiedAddress = utils.verifyMessage(packedData, poll.signature) + expect(verifiedAddress).to.eq(alice.address) + }) +}) diff --git a/packages/core/test/utils/proto/TimedPollVote.test.ts b/packages/core/test/utils/proto/TimedPollVote.test.ts new file mode 100644 index 0000000..e02d59b --- /dev/null +++ b/packages/core/test/utils/proto/TimedPollVote.test.ts @@ -0,0 +1,43 @@ +import { expect } from 'chai' +import TimedPollVote from '../../../src/utils/proto/TimedPollVote' +import { BigNumber } from 'ethers' +import { TimedPollVoteMsg } from '../../../src/models/TimedPollVoteMsg' +import { MockProvider } from 'ethereum-waffle' + +describe('TimedPollVote', () => { + const provider = new MockProvider() + const [alice] = provider.getWallets() + const pollId = '0x14c336ef626274f156d094fc1d7ffad2bbc83cccc9817598dd55e42a86b56b72' + + it('success', async () => { + const data = await TimedPollVoteMsg.create(alice, pollId, 0) + const payload = TimedPollVote.encode(data) + + expect(payload).to.not.be.undefined + if (payload) { + expect(TimedPollVote.decode(payload, new Date(data.timestamp))).to.deep.eq(data) + } + }) + + it('random decode', async () => { + expect(TimedPollVote.decode(new Uint8Array([12, 12, 3, 32, 31, 212, 31, 32, 23]), new Date(10))).to.be.undefined + }) + + it('random data', async () => { + expect(TimedPollVote.encode({ sadf: '0x0' } as unknown as TimedPollVoteMsg)).to.be.undefined + }) + + it('data with token', async () => { + const data = await TimedPollVoteMsg.create(alice, pollId, 0, BigNumber.from(120)) + + const payload = TimedPollVote.encode(data) + + expect(payload).to.not.be.undefined + if (payload) { + expect(TimedPollVote.decode(payload, new Date(data.timestamp))).to.deep.eq({ + ...data, + tokenAmount: BigNumber.from(120), + }) + } + }) +}) diff --git a/packages/core/types/protons/index.d.ts b/packages/core/types/protons/index.d.ts index 5354378..4b49f74 100644 --- a/packages/core/types/protons/index.d.ts +++ b/packages/core/types/protons/index.d.ts @@ -14,11 +14,24 @@ declare module 'protons' { signature: Uint8Array } + export type TimedPollVote = { + id: Uint8Array + voter: Uint8Array + timestamp: number + answer: number + tokenAmount?: Uint8Array + signature: Uint8Array + } + function protons(init: string): { PollInit: { encode: (pollInit: PollInit) => Uint8Array, decode: (payload: Uint8Array) => PollInit } + TimedPollVote:{ + encode: (timedPollVote: TimedPollVote) => Uint8Array, + decode: (payload: Uint8Array) => TimedPollVote + } } export = protons } \ No newline at end of file