@@ -39,17 +39,11 @@
diff --git a/constants/variables.js b/constants/variables.js
index 8789410..35ab9b1 100644
--- a/constants/variables.js
+++ b/constants/variables.js
@@ -95,3 +95,5 @@ export const DUMMY_NONCE = '0x11111111111111111111111111111111111111111111111111
export const DUMMY_WITHDRAW_DATA =
'0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111'
+
+export const CHUNK_COUNT_PER_BATCH_REQUEST = 200
diff --git a/langs/en.json b/langs/en.json
index 16d9c65..947c01f 100644
--- a/langs/en.json
+++ b/langs/en.json
@@ -283,6 +283,17 @@
"description": "Description is required"
}
},
+ "proposalComment": {
+ "modal-title": "Title: Proposal #{id}",
+ "modal-subtitle": "Please explain: Why are you for or against this proposal?",
+ "form-contact": "Contact",
+ "form-contact-placeholder": "Enter contact (optional)",
+ "form-contact-tooltip": "Contact (optional) may be a nickname in forum, email, telegram, twitter or other",
+ "form-message": "Message",
+ "form-message-placeholder": "Enter message",
+ "form-message-opt-placeholder": "Enter message (optional)",
+ "form-message-required": "Message required"
+ },
"executed": "Executed",
"proposalDoesNotExist": "The proposal doesn't exist. Please go back to the list.",
"errorPage": {
diff --git a/networkConfig.js b/networkConfig.js
index cbc67f6..787d931 100644
--- a/networkConfig.js
+++ b/networkConfig.js
@@ -116,6 +116,7 @@ export default {
ensSubdomainKey: 'mainnet-tornado',
pollInterval: 15,
constants: {
+ GOVERNANCE_BLOCK: 11474695,
NOTE_ACCOUNT_BLOCK: 11842486,
ENCRYPTED_NOTES_BLOCK: 14248730,
MINING_BLOCK_TIME: 15
@@ -534,6 +535,7 @@ export default {
ensSubdomainKey: 'goerli-tornado',
pollInterval: 15,
constants: {
+ GOVERNANCE_BLOCK: 3945171,
NOTE_ACCOUNT_BLOCK: 4131375,
ENCRYPTED_NOTES_BLOCK: 4131375,
MINING_BLOCK_TIME: 15
diff --git a/services/index.js b/services/index.js
index 3e136c2..81d23fc 100644
--- a/services/index.js
+++ b/services/index.js
@@ -9,6 +9,7 @@ export * from './events'
export { default as graph } from './graph'
export { default as schema } from './schema'
export { default as walletConnectConnector } from './walletConnect'
+export * from './lookupAddress'
// eslint-disable-next-line no-undef
window.graph = graph
diff --git a/services/lookupAddress.js b/services/lookupAddress.js
new file mode 100644
index 0000000..66ac81c
--- /dev/null
+++ b/services/lookupAddress.js
@@ -0,0 +1,114 @@
+// from https://github.com/ChainSafe/web3.js/issues/2683#issuecomment-547348416
+
+import namehash from 'eth-ens-namehash'
+import { BigNumber, utils } from 'ethers'
+import ABI from 'web3-eth-ens/lib/resources/ABI/Resolver'
+import uniq from 'lodash/uniq'
+import chunk from 'lodash/chunk'
+import { CHUNK_COUNT_PER_BATCH_REQUEST } from '@/constants'
+
+export const createBatchRequestCallback = (resolve, reject) => (error, data) => {
+ if (error) {
+ reject(error)
+ } else {
+ resolve(data)
+ }
+}
+
+const CACHE = {}
+
+const createFetchNodeAddresses = (registryContract, batch) => async (address) => {
+ const addressLower = address.toLowerCase()
+
+ const node = addressLower.substr(2) + '.addr.reverse'
+ const nodeHash = namehash.hash(node)
+ let nodeAddress = null
+
+ if (!CACHE[addressLower]) {
+ try {
+ nodeAddress = await new Promise((resolve, reject) => {
+ const callback = createBatchRequestCallback(resolve, reject)
+ const requestData = registryContract.methods.resolver(nodeHash).call.request(callback)
+ batch.add(requestData)
+ })
+
+ if (+nodeAddress === 0) nodeAddress = null
+ } catch (error) {
+ console.error(`Error resolve ens for "${address}"`, error.message)
+ // do nothing
+ }
+ }
+
+ return {
+ addressLower,
+ address,
+ nodeHash,
+ nodeAddress
+ }
+}
+
+const createFetchEnsNames = (web3, batch, results) => async (data) => {
+ const { address, addressLower, nodeHash, nodeAddress } = data
+ if (!nodeAddress) return results
+
+ if (CACHE[addressLower]) {
+ results[address] = CACHE[addressLower]
+ return results
+ }
+
+ const nodeContract = new web3.eth.Contract(ABI, nodeAddress)
+
+ try {
+ const ensName = await new Promise((resolve, reject) => {
+ const callback = createBatchRequestCallback(resolve, reject)
+ const requestData = nodeContract.methods.name(nodeHash).call.request(callback)
+ batch.add(requestData)
+ })
+
+ const isZeroAddress =
+ ensName.trim().length && utils.isAddress(ensName) && BigNumber.from(ensName).isZero()
+
+ if (isZeroAddress) return results
+
+ CACHE[addressLower] = ensName
+ results[address] = ensName
+
+ return results
+ } catch (error) {
+ console.error(`Error lookupAddress ens for "${address}"`, error.message)
+ return results
+ }
+}
+
+export const lookupAddressesRequest = async (addressList, web3, registryContract) => {
+ const fetchNodeAddressesBatch = new web3.BatchRequest()
+ const fetchNodeAddresses = createFetchNodeAddresses(registryContract, fetchNodeAddressesBatch)
+ const fetchNodeAddressesPromises = uniq(addressList).map(fetchNodeAddresses)
+ fetchNodeAddressesBatch.execute()
+
+ const nodeAddresses = await Promise.all(fetchNodeAddressesPromises)
+
+ const results = {}
+ const fetchEnsNamesBatch = new web3.BatchRequest()
+ const fetchEnsNames = createFetchEnsNames(web3, fetchEnsNamesBatch, results)
+ const fetchEnsNamesPromises = nodeAddresses.map(fetchEnsNames)
+ fetchEnsNamesBatch.execute()
+
+ await Promise.all(fetchEnsNamesPromises)
+ return results
+}
+
+export const lookupAddresses = async (addressList, web3) => {
+ const registryContract = await web3.eth.ens.registry.contract
+ // web3.eth.ens._lastSyncCheck = Date.now() // - need for test in fork
+
+ const addressListChunks = chunk(addressList, CHUNK_COUNT_PER_BATCH_REQUEST)
+ let results = {}
+
+ for await (const list of addressListChunks) {
+ const result = await lookupAddressesRequest(list, web3, registryContract)
+ results = { ...results, ...result }
+ }
+
+ return results
+}
diff --git a/store/governance/gov.js b/store/governance/gov.js
index 1237f50..5e7f69d 100644
--- a/store/governance/gov.js
+++ b/store/governance/gov.js
@@ -1,11 +1,13 @@
/* eslint-disable no-console */
/* eslint-disable import/order */
+
import Web3 from 'web3'
+import { utils } from 'ethers'
import { ToastProgrammatic as Toast } from 'buefy'
import networkConfig from '@/networkConfig'
-import ERC20ABI from '@/abis/Governance.abi.json'
+import GovernanceABI from '@/abis/Governance.abi.json'
import AggregatorABI from '@/abis/Aggregator.abi.json'
const { numberToHex, toWei, fromWei, toBN, hexToNumber, hexToNumberString } = require('web3-utils')
@@ -25,6 +27,7 @@ const state = () => {
status: null
},
isFetchingProposals: true,
+ isCastingVote: false,
proposals: [],
voterReceipts: [],
hasActiveProposals: false,
@@ -39,13 +42,20 @@ const state = () => {
}
const getters = {
- govContract: (state, getters, rootState) => ({ netId }) => {
- const config = networkConfig[`netId${netId}`]
+ getConfig: (state, getters, rootState) => ({ netId }) => {
+ return networkConfig[`netId${netId}`]
+ },
+ getWeb3: (state, getters, rootState) => ({ netId }) => {
const { url } = rootState.settings[`netId${netId}`].rpc
+ return new Web3(url)
+ },
+ govContract: (state, getters, rootState) => ({ netId }) => {
+ const config = getters.getConfig({ netId })
const address = config['governance.contract.tornadocash.eth']
if (address) {
- const web3 = new Web3(url)
- return new web3.eth.Contract(ERC20ABI, address)
+ const web3 = getters.getWeb3({ netId })
+ const contract = new web3.eth.Contract(GovernanceABI, address)
+ return contract
}
return null
@@ -94,6 +104,9 @@ const mutations = {
SAVE_FETCHING_PROPOSALS(state, status) {
this._vm.$set(state, 'isFetchingProposals', status)
},
+ SAVE_CASTING_VOTE(state, status) {
+ this._vm.$set(state, 'isCastingVote', status)
+ },
SAVE_LOCKED_BALANCE(state, { balance }) {
this._vm.$set(state, 'lockedBalance', balance)
},
@@ -152,6 +165,7 @@ const proposalIntervalConstants = [
// 'VOTING_DELAY',
'VOTING_PERIOD'
]
+
const govConstants = ['PROPOSAL_THRESHOLD', 'QUORUM_VOTES']
const actions = {
@@ -331,28 +345,45 @@ const actions = {
})
}
},
- async castVote({ getters, rootGetters, commit, rootState, dispatch, state }, { id, support }) {
+ async castVote(context, payload) {
+ const { getters, rootGetters, commit, rootState, dispatch, state } = context
+ const { id, support, contact = '', message = '' } = payload
+
+ commit('SAVE_CASTING_VOTE', true)
+
try {
const { ethAccount } = rootState.metamask
const netId = rootGetters['metamask/netId']
const govInstance = getters.govContract({ netId })
const delegators = [...state.delegators]
+ const web3 = getters.getWeb3({ netId })
if (toBN(state.lockedBalance).gt(toBN('0'))) {
delegators.push(ethAccount)
}
- const gas = await govInstance.methods
- .castDelegatedVote(delegators, id, support)
- .estimateGas({ from: ethAccount, value: 0 })
- const data = await govInstance.methods.castDelegatedVote(delegators, id, support).encodeABI()
+ const data = govInstance.methods.castDelegatedVote(delegators, id, support).encodeABI()
+ let dataWithTail = data
+
+ if (contact || message) {
+ const value = JSON.stringify([contact, message])
+ const tail = utils.defaultAbiCoder.encode(['string'], [value])
+ dataWithTail = utils.hexConcat([data, tail])
+ }
+
+ const gas = await web3.eth.estimateGas({
+ from: ethAccount,
+ to: govInstance._address,
+ value: 0,
+ data: dataWithTail
+ })
const callParams = {
method: 'eth_sendTransaction',
params: {
to: govInstance._address,
gas: numberToHex(gas + 30000),
- data
+ data: dataWithTail
},
watcherParams: {
title: support ? 'votingFor' : 'votingAgainst',
@@ -392,6 +423,7 @@ const actions = {
)
} finally {
dispatch('loading/disable', {}, { root: true })
+ commit('SAVE_CASTING_VOTE', false)
}
},
async executeProposal({ getters, rootGetters, commit, rootState, dispatch }, { id }) {
@@ -619,6 +651,7 @@ const actions = {
const netId = rootGetters['metamask/netId']
const aggregatorContract = getters.aggregatorContract
const govInstance = getters.govContract({ netId })
+ const config = getters.getConfig({ netId })
if (!govInstance) {
return
@@ -626,7 +659,7 @@ const actions = {
const [events, statuses] = await Promise.all([
govInstance.getPastEvents('ProposalCreated', {
- fromBlock: 0,
+ fromBlock: config.constants.GOVERNANCE_BLOCK,
toBlock: 'latest'
}),
aggregatorContract.methods.getAllProposals(govInstance._address).call()
@@ -663,7 +696,7 @@ const actions = {
}
proposals = events
- .map(({ returnValues }, index) => {
+ .map(({ returnValues, blockNumber }, index) => {
const id = Number(returnValues.id)
const { state, startTime, endTime, forVotes, againstVotes } = statuses[index]
const { title, description } = parseDescription({ id, text: returnValues.description })
@@ -677,6 +710,7 @@ const actions = {
endTime: Number(endTime),
startTime: Number(startTime),
status: ProposalState[Number(state)],
+ blockNumber,
results: {
for: fromWei(forVotes),
against: fromWei(againstVotes)
@@ -767,6 +801,7 @@ const actions = {
}
const netId = rootGetters['metamask/netId']
+ const config = getters.getConfig({ netId })
const aggregatorContract = getters.aggregatorContract
const govInstance = getters.govContract({ netId })
@@ -774,14 +809,14 @@ const actions = {
filter: {
to: ethAccount
},
- fromBlock: 0,
+ fromBlock: config.constants.GOVERNANCE_BLOCK,
toBlock: 'latest'
})
let undelegatedAccs = await govInstance.getPastEvents('Undelegated', {
filter: {
from: ethAccount
},
- fromBlock: 0,
+ fromBlock: config.constants.GOVERNANCE_BLOCK,
toBlock: 'latest'
})
delegatedAccs = delegatedAccs.map((acc) => acc.returnValues.account)
diff --git a/store/governance/proposal.js b/store/governance/proposal.js
new file mode 100644
index 0000000..be2df91
--- /dev/null
+++ b/store/governance/proposal.js
@@ -0,0 +1,269 @@
+/* eslint-disable no-console */
+/* eslint-disable import/order */
+
+import { utils } from 'ethers'
+import uniqBy from 'lodash/uniqBy'
+import chunk from 'lodash/chunk'
+
+import { lookupAddresses, createBatchRequestCallback } from '@/services'
+import { CHUNK_COUNT_PER_BATCH_REQUEST } from '@/constants'
+
+const { toWei, fromWei, toBN } = require('web3-utils')
+
+const CACHE_TX = {}
+const CACHE_BLOCK = {}
+
+const parseComment = (calldata, govInstance) => {
+ const empty = { contact: '', message: '' }
+ if (!calldata || !govInstance) return empty
+
+ const methodLength = 4 // length of castDelegatedVote method
+ const result = utils.defaultAbiCoder.decode(
+ ['address[]', 'uint256', 'bool'],
+ utils.hexDataSlice(calldata, methodLength)
+ )
+ const data = govInstance.methods.castDelegatedVote(...result).encodeABI()
+ const dataLength = utils.hexDataLength(data)
+
+ try {
+ const str = utils.defaultAbiCoder.decode(['string'], utils.hexDataSlice(calldata, dataLength))
+ const [contact, message] = JSON.parse(str)
+ return { contact, message }
+ } catch {
+ return empty
+ }
+}
+
+const createProposalComment = (resultAll, votedEvent) => {
+ const { transactionHash, returnValues, blockNumber } = votedEvent
+ const { voter } = returnValues
+
+ const comment = parseComment()
+
+ const percentage =
+ toBN(votedEvent.returnValues.votes)
+ .mul(toBN(10000))
+ .divRound(resultAll)
+ .toNumber() / 100
+
+ return {
+ id: `${transactionHash}-${voter}`,
+ percentage,
+ ...returnValues,
+ votes: fromWei(returnValues.votes),
+ transactionHash,
+ blockNumber,
+
+ ...comment,
+
+ ens: {
+ delegator: null,
+ voter: null
+ },
+ delegator: null,
+ timestamp: null
+ }
+}
+
+const createFetchCommentWithMessage = (web3, batch, govInstance) => async (proposalComment) => {
+ const { transactionHash, voter, blockNumber } = proposalComment
+
+ if (!CACHE_TX[transactionHash]) {
+ CACHE_TX[transactionHash] = new Promise((resolve, reject) => {
+ const callback = createBatchRequestCallback(resolve, reject)
+ batch.add(web3.eth.getTransaction.request(transactionHash, callback))
+ })
+ }
+
+ if (!CACHE_BLOCK[blockNumber]) {
+ CACHE_BLOCK[blockNumber] = new Promise((resolve, reject) => {
+ const callback = createBatchRequestCallback(resolve, reject)
+ batch.add(web3.eth.getBlock.request(blockNumber, callback))
+ })
+ }
+
+ try {
+ const [tx, blockInfo] = await Promise.all([CACHE_TX[transactionHash], CACHE_BLOCK[blockNumber]])
+
+ const isMaybeHasComment = voter === tx.from
+ const comment = parseComment(isMaybeHasComment ? tx.input : null, govInstance)
+
+ return {
+ ...proposalComment,
+ ...comment,
+
+ delegator: voter === tx.from ? null : tx.from,
+ timestamp: blockInfo.timestamp
+ }
+ } catch (error) {
+ CACHE_TX[transactionHash] = null
+ CACHE_BLOCK[blockNumber] = null
+ return proposalComment
+ }
+}
+
+const state = () => {
+ return {
+ isFetchingComments: false,
+ isFetchingMessages: false,
+ ensNames: {},
+ comments: []
+ }
+}
+
+const getters = {
+ comments: (state) => {
+ const { ensNames } = state
+ let comments = state.comments.slice()
+
+ comments.sort((a, b) => b.blockNumber - a.blockNumber)
+ comments = uniqBy(comments, 'voter')
+ comments.sort((a, b) => b.percentage - a.percentage)
+
+ comments = comments.map((data) => ({
+ ...data,
+ ens: {
+ delegator: ensNames[data.delegator],
+ voter: ensNames[data.voter]
+ }
+ }))
+
+ return comments
+ }
+}
+
+const mutations = {
+ SAVE_FETCHING_COMMENTS(state, status) {
+ state.isFetchingComments = status
+ },
+ SAVE_FETCHING_MESSAGES(state, status) {
+ state.isFetchingMessages = status
+ },
+ SAVE_ENS_NAMES(state, ensNames) {
+ state.ensNames = { ...state.ensNames, ...ensNames }
+ },
+ SAVE_COMMENTS(state, comments) {
+ state.comments = comments
+ }
+}
+
+const actions = {
+ async fetchComments(context, proposal) {
+ const { commit, dispatch, state } = context
+ let { comments } = state
+ let newComments = []
+
+ if (comments[0]?.id !== proposal.id) {
+ commit('SAVE_COMMENTS', [])
+ comments = []
+ }
+
+ commit('SAVE_FETCHING_COMMENTS', true)
+ newComments = await dispatch('fetchVotedEvents', { proposal, comments })
+ commit('SAVE_FETCHING_COMMENTS', false)
+
+ if (!newComments) return
+ commit('SAVE_COMMENTS', newComments.concat(comments))
+ dispatch('fetchEnsNames', { comments: newComments })
+
+ commit('SAVE_FETCHING_MESSAGES', true)
+ // TODO: TC-163 - add pagination
+ newComments = await dispatch('fetchCommentsMessages', { comments: newComments })
+ commit('SAVE_FETCHING_MESSAGES', false)
+
+ if (!newComments) return
+ commit('SAVE_COMMENTS', newComments.concat(comments))
+ },
+ async fetchVotedEvents(context, { proposal, comments }) {
+ const { rootGetters } = context
+ let { blockNumber: fromBlock } = proposal
+
+ const netId = rootGetters['metamask/netId']
+ const govInstance = rootGetters['governance/gov/govContract']({ netId })
+
+ if (comments[0]?.id === proposal.id) {
+ fromBlock = comments[0].blockNumber + 1
+ }
+
+ try {
+ let votedEvents = await govInstance.getPastEvents('Voted', {
+ filter: {
+ // support: [false],
+ proposalId: proposal.id
+ },
+ fromBlock,
+ toBlock: 'latest'
+ })
+
+ console.log('fetchVotedEvents', votedEvents.length)
+
+ votedEvents = votedEvents.sort((a, b) => b.blockNumber - a.blockNumber)
+ votedEvents = uniqBy(votedEvents, 'returnValues.voter')
+
+ console.log('fetchVotedEvents uniq', votedEvents.length)
+
+ const resultAll = toBN(toWei(proposal.results.for)).add(toBN(toWei(proposal.results.against)))
+ let newComments = votedEvents.map((votedEvent) => createProposalComment(resultAll, votedEvent))
+ newComments = newComments.concat(comments)
+ return newComments
+ } catch (e) {
+ console.error('fetchVotedEvents', e.message)
+ return null
+ }
+ },
+ async fetchCommentsMessages(context, { comments }) {
+ const { rootGetters } = context
+
+ const netId = rootGetters['metamask/netId']
+ const govInstance = rootGetters['governance/gov/govContract']({ netId })
+ const web3 = rootGetters['governance/gov/getWeb3']({ netId })
+ const commentListChunks = chunk(comments, CHUNK_COUNT_PER_BATCH_REQUEST)
+
+ let results = []
+
+ try {
+ for await (const list of commentListChunks) {
+ const batch = new web3.BatchRequest()
+ const fetchCommentsWithMessages = createFetchCommentWithMessage(web3, batch, govInstance)
+ const promises = list.map(fetchCommentsWithMessages)
+ batch.execute()
+ const result = await Promise.all(promises)
+
+ results = results.concat(result)
+ }
+
+ return results
+ } catch (e) {
+ console.error('fetchCommentsMessages', e.message)
+ }
+ },
+ async fetchEnsNames(context, { comments }) {
+ const { rootGetters, commit } = context
+
+ const netId = rootGetters['metamask/netId']
+ const web3 = rootGetters['governance/gov/getWeb3']({ netId })
+
+ try {
+ const addresses = comments
+ .map((_) => _.voter)
+ .flat()
+ .filter(Boolean)
+
+ console.log('fetchEnsNames', addresses.length)
+
+ const ensNames = await lookupAddresses(addresses, web3)
+
+ commit('SAVE_ENS_NAMES', ensNames)
+ } catch (e) {
+ console.error('fetchEnsNames', e.message)
+ }
+ }
+}
+
+export default {
+ namespaced: true,
+ state,
+ getters,
+ mutations,
+ actions
+}
diff --git a/utils/stringUtils.js b/utils/stringUtils.js
index a7a885b..38b9a5b 100644
--- a/utils/stringUtils.js
+++ b/utils/stringUtils.js
@@ -10,6 +10,30 @@ export const sliceAddress = (address) => {
return '0x' + hashRender(address.slice(2))
}
+export const sliceEnsName = (name, size = 4, separator = '...') => {
+ const chars = [...name]
+
+ const last = name
+ .split('.')
+ .pop()
+ .slice(-size)
+
+ if (chars[0]?.length === 2 && last) {
+ // 🐵🍆💦.eth -> 🐵🍆💦.eth
+ if (chars.length - 4 <= 4) return name
+
+ // 🦍🦍🦍🦍🦍🦍🦍.eth -> 🦍🦍🦍...eth
+ return [].concat(chars.slice(0, 3), separator, last).join('')
+ }
+
+ if (chars.length <= 2 * size + 2 + separator.length) return name
+ if (!name.includes('.')) return sliceAddress(name, size, separator)
+
+ return last.length
+ ? [].concat(chars.slice(0, 2 * size - last.length), separator, last).join('')
+ : [].concat(chars.slice(0, size), separator, chars.slice(-size)).join('')
+}
+
const semVerRegex = /^(?
0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?0|[1-9]\d*)(?:-(?(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
export const parseSemanticVersion = (version) => {