Add proposal creation and list (#61)

This commit is contained in:
Szymon Szlachtowicz 2021-09-13 17:02:29 +02:00 committed by GitHub
parent e492997c83
commit 26cb1823d9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 436 additions and 112 deletions

View File

@ -12,7 +12,7 @@ const deploy = async () => {
const provider = ethers.getDefaultProvider(process.env.ETHEREUM_PROVIDER)
const wallet = new ethers.Wallet(privateKey, provider)
const votingContract = await deployContract(wallet, VotingContract,[process.env.ETHEREUM_TOKEN_ADDRESS])
const votingContract = await deployContract(wallet, VotingContract,[process.env.ETHEREUM_TOKEN_ADDRESS,1000])
console.log(`Voting contract deployed with address: ${votingContract.address}`)
}

View File

@ -41,6 +41,7 @@
"typescript": "^4.3.5"
},
"dependencies": {
"@status-waku-voting/contracts": "^0.0.1",
"eth-sig-util": "^3.0.1",
"ethers": "^5.4.4",
"js-waku": "^0.11.0",

View File

@ -0,0 +1,83 @@
import { Waku } from 'js-waku'
import { WakuMessage } from 'js-waku'
import { Provider } from '@ethersproject/providers'
type WakuMessageStore = {
topic: string
hashMap: { [id: string]: boolean }
arr: any[]
updateFunction: (msg: WakuMessage[]) => void
}
type WakuMessageStores = {
[messageType: string]: WakuMessageStore
}
export class WakuMessaging {
protected appName: string
protected waku: Waku
public tokenAddress: string
protected provider: Provider
protected chainId = 0
protected wakuMessages: WakuMessageStores = {}
protected observers: { callback: (msg: WakuMessage) => void; topics: string[] }[] = []
protected constructor(appName: string, tokenAddress: string, waku: Waku, provider: Provider, chainId: number) {
this.appName = appName
this.tokenAddress = tokenAddress
this.waku = waku
this.provider = provider
this.chainId = chainId
}
public cleanUp() {
this.observers.forEach((observer) => this.waku.relay.deleteObserver(observer.callback, observer.topics))
this.wakuMessages = {}
}
protected async setObserver() {
await Promise.all(
Object.values(this.wakuMessages).map(async (msgObj) => {
const storeMessages = await this.waku?.store.queryHistory([msgObj.topic])
if (storeMessages) {
msgObj.updateFunction(storeMessages)
}
this.waku.relay.addObserver((msg) => msgObj.updateFunction([msg]), [msgObj.topic])
this.observers.push({ callback: (msg) => msgObj.updateFunction([msg]), topics: [msgObj.topic] })
})
)
}
protected decodeMsgAndSetArray<T extends { id: string; timestamp: number }>(
messages: WakuMessage[],
decode: (payload: Uint8Array | undefined, timestamp: Date | undefined, chainId: number) => T | undefined,
msgObj: WakuMessageStore,
filterFunction?: (e: T) => boolean
) {
messages
.map((msg) => decode(msg.payload, msg.timestamp, this.chainId))
.sort((a, b) => ((a?.timestamp ?? new Date(0)) > (b?.timestamp ?? new Date(0)) ? 1 : -1))
.forEach((e) => {
if (e) {
if (filterFunction ? filterFunction(e) : true && !msgObj.hashMap?.[e.id]) {
msgObj.arr.unshift(e)
msgObj.hashMap[e.id] = true
}
}
})
}
protected async sendWakuMessage<T extends { encode: () => Uint8Array | undefined; timestamp: number }>(
msgObj: WakuMessageStore,
decodedMsg: T | undefined
) {
const payload = decodedMsg?.encode()
if (payload && decodedMsg) {
const wakuMessage = await WakuMessage.fromBytes(payload, msgObj.topic, {
timestamp: new Date(decodedMsg.timestamp),
})
await this.waku?.relay.send(wakuMessage)
msgObj.updateFunction([wakuMessage])
}
}
}

View File

@ -7,7 +7,7 @@ import { WakuMessage } from 'js-waku'
import { TimedPollVoteMsg } from '../models/TimedPollVoteMsg'
import { DetailedTimedPoll } from '../models/DetailedTimedPoll'
import { createWaku } from '../utils/createWaku'
import { WakuVoting } from './WakuVoting'
import { WakuMessaging } from './WakuMessaging'
import { Provider } from '@ethersproject/providers'
import { Contract } from '@ethersproject/contracts'
import { Interface } from '@ethersproject/abi'
@ -23,7 +23,7 @@ export enum MESSEGAGE_SENDING_RESULT {
pollNotFound = 3,
}
export class WakuPolling extends WakuVoting {
export class WakuPolling extends WakuMessaging {
protected multicall: string
protected constructor(

View File

@ -1,83 +1,102 @@
import { Waku } from 'js-waku'
import { WakuMessage } from 'js-waku'
import { VotingContract } from '@status-waku-voting/contracts/abi'
import { WakuMessaging } from './WakuMessaging'
import { Contract, Wallet, BigNumber } from 'ethers'
import { Waku, WakuMessage } from 'js-waku'
import { Provider } from '@ethersproject/abstract-provider'
import { createWaku } from '../utils/createWaku'
import { Provider } from '@ethersproject/providers'
import { JsonRpcSigner } from '@ethersproject/providers'
import { VoteMsg } from '../models/VoteMsg'
type WakuMessageStore = {
topic: string
hashMap: { [id: string]: boolean }
arr: any[]
updateFunction: (msg: WakuMessage[]) => void
const ABI = [
'function aggregate(tuple(address target, bytes callData)[] calls) view returns (uint256 blockNumber, bytes[] returnData)',
]
export class WakuVoting extends WakuMessaging {
private multicall: Contract
private votingContract: Contract
constructor(
appName: string,
votingContract: Contract,
token: string,
waku: Waku,
provider: Provider,
chainId: number,
multicallAddress: string
) {
super(appName, token, waku, provider, chainId)
this.votingContract = votingContract
this.multicall = new Contract(multicallAddress, ABI, this.provider)
this.wakuMessages['vote'] = {
topic: `/${this.appName}/waku-voting/votes/proto/`,
hashMap: {},
arr: [],
updateFunction: (msg: WakuMessage[]) =>
this.decodeMsgAndSetArray(
msg,
(payload, timestamp, chainId) => VoteMsg.decode(payload, timestamp, chainId, this.votingContract.address),
this.wakuMessages['vote']
),
}
}
type WakuMessageStores = {
[messageType: string]: WakuMessageStore
}
export class WakuVoting {
protected appName: string
protected waku: Waku
public tokenAddress: string
protected provider: Provider
protected chainId = 0
protected wakuMessages: WakuMessageStores = {}
protected observers: { callback: (msg: WakuMessage) => void; topics: string[] }[] = []
protected constructor(appName: string, tokenAddress: string, waku: Waku, provider: Provider, chainId: number) {
this.appName = appName
this.tokenAddress = tokenAddress
this.waku = waku
this.provider = provider
this.chainId = chainId
}
public cleanUp() {
this.observers.forEach((observer) => this.waku.relay.deleteObserver(observer.callback, observer.topics))
this.wakuMessages = {}
}
protected async setObserver() {
await Promise.all(
Object.values(this.wakuMessages).map(async (msgObj) => {
const storeMessages = await this.waku?.store.queryHistory([msgObj.topic])
if (storeMessages) {
msgObj.updateFunction(storeMessages)
}
this.waku.relay.addObserver((msg) => msgObj.updateFunction([msg]), [msgObj.topic])
this.observers.push({ callback: (msg) => msgObj.updateFunction([msg]), topics: [msgObj.topic] })
})
public static async create(
appName: string,
contractAddress: string,
provider: Provider,
multicall: string,
waku?: Waku
) {
const network = await provider.getNetwork()
const votingContract = new Contract(contractAddress, VotingContract.abi, provider)
const tokenAddress = await votingContract.token()
return new WakuVoting(
appName,
votingContract,
tokenAddress,
await createWaku(waku),
provider,
network.chainId,
multicall
)
}
protected decodeMsgAndSetArray<T extends { id: string; timestamp: number }>(
messages: WakuMessage[],
decode: (payload: Uint8Array | undefined, timestamp: Date | undefined, chainId: number) => T | undefined,
msgObj: WakuMessageStore,
filterFunction?: (e: T) => boolean
public async createVote(
signer: JsonRpcSigner | Wallet,
question: string,
descripiton: string,
tokenAmount: BigNumber
) {
messages
.map((msg) => decode(msg.payload, msg.timestamp, this.chainId))
.sort((a, b) => ((a?.timestamp ?? new Date(0)) > (b?.timestamp ?? new Date(0)) ? 1 : -1))
.forEach((e) => {
if (e) {
if (filterFunction ? filterFunction(e) : true && !msgObj.hashMap?.[e.id]) {
msgObj.arr.unshift(e)
msgObj.hashMap[e.id] = true
}
}
})
this.votingContract = await this.votingContract.connect(signer)
await this.votingContract.initializeVotingRoom(question, descripiton, tokenAmount)
}
protected async sendWakuMessage<T extends { encode: () => Uint8Array | undefined; timestamp: number }>(
msgObj: WakuMessageStore,
decodedMsg: T | undefined
private lastPolls: any[] = []
private lastGetPollsBlockNumber = 0
public async getVotes() {
const blockNumber = await this.provider.getBlockNumber()
if (blockNumber != this.lastGetPollsBlockNumber) {
this.lastGetPollsBlockNumber = blockNumber
this.lastPolls = await this.votingContract.getVotingRooms()
}
return this.lastPolls
}
public async sendVote(
signer: JsonRpcSigner | Wallet,
roomId: number,
selectedAnswer: number,
tokenAmount: BigNumber
) {
const payload = decodedMsg?.encode()
if (payload && decodedMsg) {
const wakuMessage = await WakuMessage.fromBytes(payload, msgObj.topic, {
timestamp: new Date(decodedMsg.timestamp),
})
await this.waku?.relay.send(wakuMessage)
msgObj.updateFunction([wakuMessage])
}
const vote = await VoteMsg._createWithSignFunction(
signer,
roomId,
selectedAnswer,
this.chainId,
tokenAmount,
this.votingContract.address
)
await this.sendWakuMessage(this.wakuMessages['vote'], vote)
}
}

View File

@ -1,4 +1,4 @@
import { WakuPolling } from './classes/WakuPolling'
import { WakuMessaging } from './classes/WakuMessaging'
import { WakuVoting } from './classes/WakuVoting'
export { WakuVoting, WakuPolling }
export { WakuMessaging, WakuPolling, WakuVoting }

View File

@ -0,0 +1,163 @@
import { utils } from 'ethers'
import protons, { Vote } from 'protons'
import { BigNumber, Wallet } from 'ethers'
import { JsonRpcSigner } from '@ethersproject/providers'
import { createSignFunction } from '../utils/createSignFunction'
import { verifySignature } from '../utils/verifySignature'
const proto = protons(`
message Vote {
bytes voter = 1;
int64 timestamp = 2;
int64 answer = 3;
bytes roomId = 4;
bytes tokenAmount = 5;
bytes signature = 6;
}
`)
type Message = {
roomIdAndType: string
tokenAmount: string
voter: string
}
export function createSignMsgParams(message: Message, chainId: number, verifyingContract: string) {
const msgParams: any = {
domain: {
name: 'Waku proposal',
version: '1',
chainId,
verifyingContract,
},
message: {
...message,
},
primaryType: 'Vote',
types: {
EIP712Domain: [
{ name: 'name', type: 'string' },
{ name: 'version', type: 'string' },
{ name: 'chainId', type: 'uint256' },
{ name: 'verifyingContract', type: 'address' },
],
Vote: [
{ name: 'roomIdAndType', type: 'string' },
{ name: 'tokenAmount', type: 'string' },
{ name: 'voter', type: 'string' },
],
},
}
return msgParams
}
export class VoteMsg {
public roomId: number
public voter: string
public timestamp: number
public answer: number
public tokenAmount: BigNumber
public signature: string
public id: string
public chainId: number
constructor(
signature: string,
roomId: number,
voter: string,
answer: number,
tokenAmount: BigNumber,
chainId: number,
timestamp: number
) {
this.id = utils.id([voter, timestamp, signature].join())
this.roomId = roomId
this.voter = voter
this.timestamp = timestamp
this.answer = answer
this.tokenAmount = tokenAmount
this.signature = signature
this.chainId = chainId
}
static async _createWithSignFunction(
signer: JsonRpcSigner | Wallet,
roomId: number,
answer: number,
chainId: number,
tokenAmount: BigNumber,
contractAddress: string
): Promise<VoteMsg | undefined> {
const signFunction = createSignFunction(signer)
const voter = await signer.getAddress()
const msg = {
roomIdAndType: BigNumber.from(roomId).mul(2).add(answer).toHexString(),
tokenAmount: tokenAmount.toHexString(),
voter,
}
const params = [msg.voter, JSON.stringify(createSignMsgParams(msg, chainId, contractAddress))]
const signature = await signFunction(params)
if (signature) {
return new VoteMsg(signature, roomId, voter, answer, tokenAmount, chainId, Date.now())
} else {
return undefined
}
}
encode() {
try {
const voteProto: Vote = {
voter: utils.arrayify(this.voter),
timestamp: this.timestamp,
answer: this.answer,
tokenAmount: utils.arrayify(this.tokenAmount),
roomId: utils.arrayify(BigNumber.from(this.roomId)),
signature: utils.arrayify(this.signature),
}
return proto.Vote.encode(voteProto)
} catch {
return undefined
}
}
static decode(
rawPayload: Uint8Array | undefined,
timestamp: Date | undefined,
chainId: number,
contractAddress: string,
verifyFunction?: (params: any, address: string) => boolean
) {
try {
const payload = proto.Vote.decode(rawPayload)
if (!timestamp || !payload.timestamp || timestamp?.getTime() != payload.timestamp) {
return undefined
}
const signature = utils.hexlify(payload.signature)
const msg = {
roomIdAndType: BigNumber.from(payload.roomId).mul(2).add(payload.answer).toHexString(),
tokenAmount: utils.hexlify(payload.tokenAmount),
voter: utils.getAddress(utils.hexlify(payload.voter)),
}
const params = {
data: createSignMsgParams(msg, chainId, contractAddress),
sig: signature,
}
if (verifyFunction ? !verifyFunction : !verifySignature(params, msg.voter)) {
return undefined
}
return new VoteMsg(
signature,
BigNumber.from(payload.roomId).toNumber(),
utils.getAddress(utils.hexlify(payload.voter)),
payload.answer,
BigNumber.from(payload.tokenAmount),
chainId,
payload.timestamp
)
} catch {
return undefined
}
}
}

View File

@ -1,7 +1,7 @@
import { MockProvider } from '@ethereum-waffle/provider'
import { expect } from 'chai'
import { Waku } from 'js-waku'
import { WakuVoting } from '../src'
import { WakuMessaging } from '../src'
describe('WakuVoting', () => {
it('success', async () => {

View File

@ -23,6 +23,15 @@ declare module 'protons' {
signature: Uint8Array
}
export type Vote = {
voter: Uint8Array
timestamp:number
answer: number
roomId: Uint8Array
tokenAmount: Uint8Array
signature: Uint8Array
}
function protons(init: string): {
PollInit: {
encode: (pollInit: PollInit) => Uint8Array,
@ -32,6 +41,10 @@ declare module 'protons' {
encode: (timedPollVote: TimedPollVote) => Uint8Array,
decode: (payload: Uint8Array | undefined) => TimedPollVote
}
Vote:{
encode: (vote: Vote) => Uint8Array,
decode: (payload: Uint8Array | undefined) => Vote
}
}
export = protons
}

View File

@ -35,6 +35,7 @@
"@status-waku-voting/core": "^0.1.0",
"@status-waku-voting/proposal-hooks": "^0.1.0",
"@status-waku-voting/react-components": "^0.1.0",
"@usedapp/core": "^0.4.7",
"ethers": "^5.4.4",
"humanize-duration": "^3.27.0",
"react": "^17.0.2",

View File

@ -3,16 +3,18 @@ import styled from 'styled-components'
import { ProposalHeader } from './ProposalHeader'
import { blueTheme } from '@status-waku-voting/react-components/dist/esm/src/style/themes'
import { ProposalList } from './ProposalList'
import { VotingEmpty } from './VotingEmpty'
import { NotificationItem } from './NotificationItem'
import { WakuVoting } from '@status-waku-voting/core'
export function Proposal() {
type ProposalProps = {
wakuVoting: WakuVoting
}
export function Proposal({ wakuVoting }: ProposalProps) {
return (
<ProposalWrapper>
<VotingEmpty theme={blueTheme} />
<ProposalHeader theme={blueTheme} />
<ProposalList theme={blueTheme} />
{/* <VotingEmpty theme={blueTheme} /> */}
<ProposalHeader theme={blueTheme} wakuVoting={wakuVoting} />
<ProposalList theme={blueTheme} wakuVoting={wakuVoting} />
<NotificationItem text={'Proposal you finalized will be settled after 10 confirmations.'} address={'#'} />
</ProposalWrapper>
)

View File

@ -5,13 +5,16 @@ import { Modal, Networks, CreateButton } from '@status-waku-voting/react-compone
import { Theme } from '@status-waku-voting/react-components/dist/esm/src/style/themes'
import { ProposeModal } from './ProposeModal'
import { ProposeVoteModal } from './ProposeVoteModal'
import { WakuVoting } from '@status-waku-voting/core'
import { BigNumber } from 'ethers'
type ProposalHeaderProps = {
theme: Theme
wakuVoting: WakuVoting
}
export function ProposalHeader({ theme }: ProposalHeaderProps) {
const { activateBrowserWallet, account } = useEthers()
export function ProposalHeader({ theme, wakuVoting }: ProposalHeaderProps) {
const { activateBrowserWallet, account, library } = useEthers()
const [selectConnect, setSelectConnect] = useState(false)
const [showProposeModal, setShowProposeModal] = useState(false)
const [showProposeVoteModal, setShowProposeVoteModal] = useState(false)
@ -46,6 +49,7 @@ export function ProposalHeader({ theme }: ProposalHeaderProps) {
{showProposeVoteModal && (
<Modal heading="Create proposal" theme={theme} setShowModal={setShowProposeVoteModal}>
<ProposeVoteModal
wakuVoting={wakuVoting}
title={title}
text={text}
availableAmount={6524354}
@ -57,7 +61,12 @@ export function ProposalHeader({ theme }: ProposalHeaderProps) {
)}
{account ? (
<CreateButton theme={theme} onClick={() => setShowProposeModal(true)}>
<CreateButton
theme={theme}
onClick={() => {
setShowProposeModal(true)
}}
>
Create proposal
</CreateButton>
) : (

View File

@ -1,32 +1,30 @@
import React from 'react'
import React, { useEffect, useState } from 'react'
import styled from 'styled-components'
import { Theme } from '@status-waku-voting/react-components'
import { ProposalCard } from './ProposalCard'
import { WakuVoting } from '@status-waku-voting/core'
import { VotingEmpty } from './VotingEmpty'
type ProposalListProps = {
theme: Theme
wakuVoting: WakuVoting
}
export function ProposalList({ theme }: ProposalListProps) {
export function ProposalList({ theme, wakuVoting }: ProposalListProps) {
const [votes, setVotes] = useState<any[]>([])
useEffect(() => {
const interval = setInterval(async () => {
setVotes(await wakuVoting.getVotes())
}, 10000)
return () => clearInterval(interval)
}, [])
return (
<List>
<ProposalCard
heading={'This is a very long, explainative and sophisticated title for a proposal.'}
text={
'This is a longer description of the proposal. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque interdum rutrum sodales. Nullam mattis fermentum libero, non volutpat. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque interdum rutrum sodales. Nullam mattis fermentum libero. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque interdum rutrum sodales. Nullam mattis fermentum libero.'
}
address={'#'}
vote={2345678}
voteWinner={2}
theme={theme}
/>
<ProposalCard
heading={'Short proposal title'}
text={
'This is a shorter description of the proposal. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque interdum rutrum sodales.'
}
address={'#'}
theme={theme}
/>
{votes.map((vote, idx) => {
return <ProposalCard heading={vote[2]} text={vote[3]} address={'#'} theme={theme} key={idx} />
})}
{votes && votes?.length === 0 && <VotingEmpty wakuVoting={wakuVoting} theme={theme} />}
</List>
)
}

View File

@ -1,11 +1,15 @@
import { WakuVoting } from '@status-waku-voting/core'
import { useEthers } from '@usedapp/core'
import React, { useState } from 'react'
import styled from 'styled-components'
import { ProposingBtn } from './Buttons'
import { CardHeading, CardText } from './ProposalInfo'
import { ProposingData } from './ProposeModal'
import { VotePropose } from './VotePropose'
import { BigNumber } from 'ethers'
interface ProposeVoteModalProps {
wakuVoting: WakuVoting
availableAmount: number
title: string
text: string
@ -15,6 +19,7 @@ interface ProposeVoteModalProps {
}
export function ProposeVoteModal({
wakuVoting,
availableAmount,
title,
text,
@ -22,6 +27,7 @@ export function ProposeVoteModal({
setTitle,
setText,
}: ProposeVoteModalProps) {
const { library } = useEthers()
const [proposingAmount, setProposingAmount] = useState(0)
return (
<ProposingData>
@ -38,7 +44,10 @@ export function ProposeVoteModal({
<ProposingBtn
onClick={() => {
setShowModal(false), setTitle(''), setText('')
if (library) wakuVoting.createVote(library.getSigner(), title, text, BigNumber.from(proposingAmount))
setShowModal(false)
setTitle('')
setText('')
}}
>
Create proposal

View File

@ -4,12 +4,14 @@ import styled from 'styled-components'
import { CreateButton, Modal, Networks, Theme } from '@status-waku-voting/react-components'
import { ProposeModal } from './ProposeModal'
import { ProposeVoteModal } from './ProposeVoteModal'
import { WakuVoting } from '@status-waku-voting/core'
type VotingEmptyProps = {
theme: Theme
wakuVoting: WakuVoting
}
export function VotingEmpty({ theme }: VotingEmptyProps) {
export function VotingEmpty({ wakuVoting, theme }: VotingEmptyProps) {
const { account, activateBrowserWallet } = useEthers()
const [selectConnect, setSelectConnect] = useState(false)
const [showProposeModal, setShowProposeModal] = useState(false)
@ -46,6 +48,7 @@ export function VotingEmpty({ theme }: VotingEmptyProps) {
{showProposeVoteModal && (
<Modal heading="Create proposal" theme={theme} setShowModal={setShowProposeVoteModal}>
<ProposeVoteModal
wakuVoting={wakuVoting}
title={title}
text={text}
availableAmount={6524354}

View File

@ -1,3 +0,0 @@
export function useTest() {
console.log('test')
}

View File

@ -0,0 +1,24 @@
import { WakuVoting } from '@status-waku-voting/core'
import React, { useEffect, useState } from 'react'
import { providers } from 'ethers'
export function useWakuProposal() {
;(window as any).ethereum.on('chainChanged', () => window.location.reload())
const [waku, setWaku] = useState<WakuVoting | undefined>(undefined)
useEffect(() => {
const createWaku = async () => {
const provider = new providers.Web3Provider((window as any).ethereum)
const wak = await WakuVoting.create(
'test',
'0x5795A64A70cde4073DBa9EEBC5C6b675B15C815a',
provider,
'0x53c43764255c17bd724f74c4ef150724ac50a3ed'
)
setWaku(wak)
}
createWaku()
}, [])
return waku
}

View File

@ -1,3 +1,3 @@
import { useTest } from './hooks/useTest'
import { useWakuProposal } from './hooks/useWakuProposal'
export { useTest }
export { useWakuProposal }

View File

@ -1,5 +1,5 @@
import React from 'react'
import { useTest } from '@status-waku-voting/proposal-hooks'
import { useWakuProposal } from '@status-waku-voting/proposal-hooks'
import { Proposal } from '@status-waku-voting/proposal-components'
import { TopBar, GlobalStyle } from '@status-waku-voting/react-components'
import votingIcon from './assets/images/voting.svg'
@ -26,6 +26,8 @@ const config = {
function Proposals() {
const { account, library, activateBrowserWallet, deactivate } = useEthers()
const waku = useWakuProposal()
return (
<Wrapper>
<TopBar
@ -36,7 +38,7 @@ function Proposals() {
account={account}
deactivate={deactivate}
/>
<Proposal />
{waku && <Proposal wakuVoting={waku} />}
</Wrapper>
)
}