From 27f756107eea2e75f284fa3613823ef24576e693 Mon Sep 17 00:00:00 2001 From: Moody Salem Date: Thu, 1 Jul 2021 14:44:02 -0500 Subject: [PATCH] refactor: logs hook (#1941) * feat(logs): add an infrastructure for fetching logs in a declarative way * use the logs hook in the vote page, first pass * fix comment * bit of cleanup * unused imports * improve loading indicator on vote page * some testnet behavior improvements * fix loader state * loading state nit * show correct indexes * remove the unnecessary retry code * first pass at the slice * no throws * loading indicator should go away if not connected * use the logs slice for the logs hook state * style changes per cal's request --- src/components/vote/VoteModal.tsx | 3 +- src/constants/addresses.ts | 23 ++- src/constants/governance.ts | 22 +- src/hooks/useContract.ts | 28 +-- src/index.tsx | 2 + src/pages/CreateProposal/index.tsx | 6 +- src/pages/Vote/VotePage.tsx | 11 +- src/pages/Vote/index.tsx | 20 +- src/pages/Vote/styled.tsx | 50 ++++- src/state/governance/hooks.ts | 310 +++++++++++----------------- src/state/index.ts | 2 + src/state/logs/hooks.ts | 76 +++++++ src/state/logs/slice.ts | 87 ++++++++ src/state/logs/updater.ts | 66 ++++++ src/state/logs/utils.ts | 38 ++++ src/state/multicall/actions.test.ts | 24 +-- src/state/multicall/actions.ts | 38 +--- src/state/multicall/hooks.ts | 67 +----- src/state/multicall/reducer.ts | 2 +- src/state/multicall/updater.tsx | 9 +- src/state/multicall/utils.ts | 28 +++ 21 files changed, 517 insertions(+), 395 deletions(-) create mode 100644 src/state/logs/hooks.ts create mode 100644 src/state/logs/slice.ts create mode 100644 src/state/logs/updater.ts create mode 100644 src/state/logs/utils.ts create mode 100644 src/state/multicall/utils.ts diff --git a/src/components/vote/VoteModal.tsx b/src/components/vote/VoteModal.tsx index 445adb1318..56a01fa141 100644 --- a/src/components/vote/VoteModal.tsx +++ b/src/components/vote/VoteModal.tsx @@ -13,7 +13,6 @@ import Circle from '../../assets/images/blue-loader.svg' import { useVoteCallback, useUserVotes } from '../../state/governance/hooks' import { ExternalLink } from '../../theme/components' import { formatCurrencyAmount } from 'utils/formatCurrencyAmount' -import { CurrencyAmount, Token } from '@uniswap/sdk-core' import { Trans } from '@lingui/macro' const ContentWrapper = styled(AutoColumn)` @@ -50,7 +49,7 @@ export default function VoteModal({ isOpen, onDismiss, proposalId, support }: Vo }: { voteCallback: (proposalId: string | undefined, support: boolean) => Promise | undefined } = useVoteCallback() - const availableVotes: CurrencyAmount | undefined = useUserVotes() + const { votes: availableVotes } = useUserVotes() // monitor call to help UI loading state const [hash, setHash] = useState() diff --git a/src/constants/addresses.ts b/src/constants/addresses.ts index 4b7c56671e..30f4200fd6 100644 --- a/src/constants/addresses.ts +++ b/src/constants/addresses.ts @@ -14,16 +14,19 @@ export const MULTICALL2_ADDRESSES: AddressMap = { export const V2_FACTORY_ADDRESSES: AddressMap = constructSameAddressMap(V2_FACTORY_ADDRESS) export const V2_ROUTER_ADDRESS: AddressMap = constructSameAddressMap('0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D') -// most current governance contract address should always be the 0 index -// only support governance on mainnet -export const GOVERNANCE_ADDRESSES: AddressMap[] = [ - { - [SupportedChainId.MAINNET]: '0xC4e172459f1E7939D522503B81AFAaC1014CE6F6', - }, - { - [SupportedChainId.MAINNET]: '0x5e4be8Bc9637f0EAA1A755019e06A68ce081D58F', - }, -] +/** + * The older V0 governance account + */ +export const GOVERNANCE_ALPHA_V0_ADDRESSES: AddressMap = constructSameAddressMap( + '0x5e4be8Bc9637f0EAA1A755019e06A68ce081D58F' +) +/** + * The latest governor alpha that is currently admin of timelock + */ +export const GOVERNANCE_ALPHA_V1_ADDRESSES: AddressMap = { + [SupportedChainId.MAINNET]: '0xC4e172459f1E7939D522503B81AFAaC1014CE6F6', +} + export const TIMELOCK_ADDRESS: AddressMap = constructSameAddressMap('0x1a9C8182C09F50C8318d769245beA52c32BE35BC') export const MERKLE_DISTRIBUTOR_ADDRESS: AddressMap = { diff --git a/src/constants/governance.ts b/src/constants/governance.ts index e7f3b80637..829577324f 100644 --- a/src/constants/governance.ts +++ b/src/constants/governance.ts @@ -1,23 +1,17 @@ -import { GOVERNANCE_ADDRESSES, TIMELOCK_ADDRESS, UNI_ADDRESS } from './addresses' +import { + GOVERNANCE_ALPHA_V0_ADDRESSES, + GOVERNANCE_ALPHA_V1_ADDRESSES, + TIMELOCK_ADDRESS, + UNI_ADDRESS, +} from './addresses' import { SupportedChainId } from './chains' -// returns { [address]: `Governance (V${n})`} for each address in GOVERNANCE_ADDRESSES except the current, which gets no version indicator -const governanceContracts = (): Record => - GOVERNANCE_ADDRESSES.reduce( - (acc, addressMap, i) => ({ - ...acc, - [addressMap[SupportedChainId.MAINNET]]: `Governance${ - i === 0 ? '' : ` (V${GOVERNANCE_ADDRESSES.length - 1 - i})` - }`, - }), - {} - ) - export const COMMON_CONTRACT_NAMES: Record = { [SupportedChainId.MAINNET]: { [UNI_ADDRESS[SupportedChainId.MAINNET]]: 'UNI', [TIMELOCK_ADDRESS[SupportedChainId.MAINNET]]: 'Timelock', - ...governanceContracts(), + [GOVERNANCE_ALPHA_V0_ADDRESSES[SupportedChainId.MAINNET]]: 'Governance (V0)', + [GOVERNANCE_ALPHA_V1_ADDRESSES[SupportedChainId.MAINNET]]: 'Governance', }, } diff --git a/src/hooks/useContract.ts b/src/hooks/useContract.ts index ddbbdbb8f0..af337e6c01 100644 --- a/src/hooks/useContract.ts +++ b/src/hooks/useContract.ts @@ -27,12 +27,13 @@ import { V3_CORE_FACTORY_ADDRESSES, V3_MIGRATOR_ADDRESSES, ARGENT_WALLET_DETECTOR_ADDRESS, - GOVERNANCE_ADDRESSES, MERKLE_DISTRIBUTOR_ADDRESS, MULTICALL2_ADDRESSES, V2_ROUTER_ADDRESS, ENS_REGISTRAR_ADDRESSES, SOCKS_CONTROLLER_ADDRESSES, + GOVERNANCE_ALPHA_V0_ADDRESSES, + GOVERNANCE_ALPHA_V1_ADDRESSES, } from 'constants/addresses' import { abi as NFTPositionManagerABI } from '@uniswap/v3-periphery/artifacts/contracts/NonfungiblePositionManager.sol/NonfungiblePositionManager.json' import { useMemo } from 'react' @@ -116,25 +117,16 @@ export function useMerkleDistributorContract() { return useContract(MERKLE_DISTRIBUTOR_ADDRESS, MERKLE_DISTRIBUTOR_ABI, true) } -export function useGovernanceContracts(): (Contract | null)[] { - const { library, account, chainId } = useActiveWeb3React() - - return useMemo(() => { - if (!library || !chainId) { - return [] - } - - return GOVERNANCE_ADDRESSES.filter((addressMap) => Boolean(addressMap[chainId])).map((addressMap) => { - try { - return getContract(addressMap[chainId], GOVERNANCE_ABI, library, account ? account : undefined) - } catch (error) { - console.error('Failed to get contract', error) - return null - } - }) - }, [library, chainId, account]) +export function useGovernanceV0Contract(): Contract | null { + return useContract(GOVERNANCE_ALPHA_V0_ADDRESSES, GOVERNANCE_ABI, true) } +export function useGovernanceV1Contract(): Contract | null { + return useContract(GOVERNANCE_ALPHA_V1_ADDRESSES, GOVERNANCE_ABI, true) +} + +export const useLatestGovernanceContract = useGovernanceV1Contract + export function useUniContract() { const { chainId } = useActiveWeb3React() return useContract(chainId ? UNI[chainId]?.address : undefined, UNI_ABI, true) diff --git a/src/index.tsx b/src/index.tsx index 17cdb8d0d4..2bdfb59e81 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -16,6 +16,7 @@ import * as serviceWorkerRegistration from './serviceWorkerRegistration' import ApplicationUpdater from './state/application/updater' import ListsUpdater from './state/lists/updater' import MulticallUpdater from './state/multicall/updater' +import LogsUpdater from './state/logs/updater' import TransactionUpdater from './state/transactions/updater' import UserUpdater from './state/user/updater' import ThemeProvider, { ThemedGlobalStyle } from './theme' @@ -57,6 +58,7 @@ function Updaters() { + ) } diff --git a/src/pages/CreateProposal/index.tsx b/src/pages/CreateProposal/index.tsx index 4591ab4e2e..16b40d4c5d 100644 --- a/src/pages/CreateProposal/index.tsx +++ b/src/pages/CreateProposal/index.tsx @@ -86,9 +86,9 @@ const AutonomousProposalCTA = styled.div` export default function CreateProposal() { const { account, chainId } = useActiveWeb3React() - const latestProposalId = useLatestProposalId(account ?? '0x0000000000000000000000000000000000000000') ?? '0' + const latestProposalId = useLatestProposalId(account ?? undefined) ?? '0' const latestProposalData = useProposalData(0, latestProposalId) - const availableVotes: CurrencyAmount | undefined = useUserVotes() + const { votes: availableVotes } = useUserVotes() const proposalThreshold: CurrencyAmount | undefined = useProposalThreshold() const [modalOpen, setModalOpen] = useState(false) @@ -261,7 +261,7 @@ ${bodyValue} theme.bg4}; + background: ${({ theme }) => theme.bg0}; border-radius: 12px; padding: 1.5rem; position: relative; max-width: 640px; width: 100%; ` + const ArrowWrapper = styled(StyledInternalLink)` display: flex; align-items: center; @@ -174,7 +175,7 @@ export default function VotePage({ availableVotes && JSBI.greaterThan(availableVotes.quotient, JSBI.BigInt(0)) && proposalData && - proposalData.status === ProposalState.Active + proposalData.status === ProposalState.ACTIVE const uniBalance: CurrencyAmount | undefined = useTokenBalance( account ?? undefined, @@ -211,9 +212,7 @@ export default function VotePage({ All Proposals - {proposalData && ( - {ProposalState[proposalData.status]} - )} + {proposalData && } {proposalData?.title} @@ -228,7 +227,7 @@ export default function VotePage({ )} - {proposalData && proposalData.status === ProposalState.Active && !showVotingButtons && ( + {proposalData && proposalData.status === ProposalState.ACTIVE && !showVotingButtons && ( diff --git a/src/pages/Vote/index.tsx b/src/pages/Vote/index.tsx index 9612314ed0..d6349f87ab 100644 --- a/src/pages/Vote/index.tsx +++ b/src/pages/Vote/index.tsx @@ -11,13 +11,7 @@ import { ButtonPrimary } from '../../components/Button' import { Button } from 'rebass/styled-components' import { darken } from 'polished' import { CardBGImage, CardNoise, CardSection, DataCard } from '../../components/earn/styled' -import { - ProposalData, - ProposalState, - useAllProposalData, - useUserDelegatee, - useUserVotes, -} from '../../state/governance/hooks' +import { ProposalData, useAllProposalData, useUserDelegatee, useUserVotes } from '../../state/governance/hooks' import DelegateModal from '../../components/vote/DelegateModal' import { useTokenBalance } from '../../state/wallet/hooks' import { useActiveWeb3React } from '../../hooks/web3' @@ -119,10 +113,10 @@ export default function Vote() { const toggleDelegateModal = useToggleDelegateModal() // get data to list all proposals - const allProposals: ProposalData[] = useAllProposalData() + const { data: allProposals, loading: loadingProposals } = useAllProposalData() // user data - const availableVotes: CurrencyAmount | undefined = useUserVotes() + const { loading: loadingAvailableVotes, votes: availableVotes } = useUserVotes() const uniBalance: CurrencyAmount | undefined = useTokenBalance( account ?? undefined, chainId ? UNI[chainId] : undefined @@ -134,8 +128,6 @@ export default function Vote() { uniBalance && JSBI.notEqual(uniBalance.quotient, JSBI.BigInt(0)) && userDelegatee === ZERO_ADDRESS ) - const maxGovernorIndex = allProposals.reduce((max, p) => Math.max(p.governorIndex, max), 0) - return ( <> @@ -184,7 +176,7 @@ export default function Vote() { Proposals - {(!allProposals || allProposals.length === 0) && !availableVotes && } + {loadingProposals || loadingAvailableVotes ? : null} {showUnlockVoting ? ( - {maxGovernorIndex - p.governorIndex}.{p.id} + {p.governorIndex}.{p.id} {p.title} - {ProposalState[p.status]} + ) })} diff --git a/src/pages/Vote/styled.tsx b/src/pages/Vote/styled.tsx index 4e8240f274..1e56959962 100644 --- a/src/pages/Vote/styled.tsx +++ b/src/pages/Vote/styled.tsx @@ -1,25 +1,49 @@ +import { Trans } from '@lingui/macro' import styled, { DefaultTheme } from 'styled-components' import { ProposalState } from '../../state/governance/hooks' const handleColorType = (status: ProposalState, theme: DefaultTheme) => { switch (status) { - case ProposalState.Pending: - case ProposalState.Active: + case ProposalState.PENDING: + case ProposalState.ACTIVE: return theme.blue1 - case ProposalState.Succeeded: - case ProposalState.Executed: + case ProposalState.SUCCEEDED: + case ProposalState.EXECUTED: return theme.green1 - case ProposalState.Defeated: + case ProposalState.DEFEATED: return theme.red1 - case ProposalState.Queued: - case ProposalState.Canceled: - case ProposalState.Expired: + case ProposalState.QUEUED: + case ProposalState.CANCELED: + case ProposalState.EXPIRED: default: return theme.text3 } } -export const ProposalStatus = styled.span<{ status: ProposalState }>` +function StatusText({ status }: { status: ProposalState }) { + switch (status) { + case ProposalState.PENDING: + return Pending + case ProposalState.ACTIVE: + return Active + case ProposalState.SUCCEEDED: + return Succeeded + case ProposalState.EXECUTED: + return Executed + case ProposalState.DEFEATED: + return Defeated + case ProposalState.QUEUED: + return Queued + case ProposalState.CANCELED: + return Canceled + case ProposalState.EXPIRED: + return Expired + default: + return Undetermined + } +} + +const StyledProposalContainer = styled.span<{ status: ProposalState }>` font-size: 0.825rem; font-weight: 600; padding: 0.5rem; @@ -30,3 +54,11 @@ export const ProposalStatus = styled.span<{ status: ProposalState }>` justify-self: flex-end; text-transform: uppercase; ` + +export function ProposalStatus({ status }: { status: ProposalState }) { + return ( + + + + ) +} diff --git a/src/state/governance/hooks.ts b/src/state/governance/hooks.ts index 621087c6f9..bf8095f815 100644 --- a/src/state/governance/hooks.ts +++ b/src/state/governance/hooks.ts @@ -1,20 +1,25 @@ import { TransactionResponse } from '@ethersproject/providers' +import { t } from '@lingui/macro' import { abi as GOV_ABI } from '@uniswap/governance/build/GovernorAlpha.json' import { CurrencyAmount, Token } from '@uniswap/sdk-core' -import { GOVERNANCE_ADDRESSES } from 'constants/addresses' -import { SupportedChainId } from 'constants/chains' import { UNISWAP_GRANTS_PROPOSAL_DESCRIPTION } from 'constants/proposals/uniswap_grants_proposal_description' -import { BigNumber, ethers, utils } from 'ethers' -import { isAddress } from 'ethers/lib/utils' -import { useGovernanceContracts, useUniContract } from 'hooks/useContract' +import { Contract } from 'ethers' +import { defaultAbiCoder, formatUnits, Interface, isAddress } from 'ethers/lib/utils' +import { + useGovernanceV0Contract, + useGovernanceV1Contract, + useLatestGovernanceContract, + useUniContract, +} from 'hooks/useContract' import { useActiveWeb3React } from 'hooks/web3' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useMemo } from 'react' import { calculateGasMargin } from 'utils/calculateGasMargin' +import { SupportedChainId } from '../../constants/chains' import { UNISWAP_GRANTS_START_BLOCK } from '../../constants/proposals' import { UNI } from '../../constants/tokens' -import { useMultipleContractMultipleData, useSingleCallResult } from '../multicall/hooks' +import { useLogs } from '../logs/hooks' +import { useSingleCallResult, useSingleContractMultipleData } from '../multicall/hooks' import { useTransactionAdder } from '../transactions/hooks' -import { t } from '@lingui/macro' interface ProposalDetail { target: string @@ -45,208 +50,148 @@ export interface CreateProposalData { } export enum ProposalState { - Undetermined = -1, - Pending, - Active, - Canceled, - Defeated, - Succeeded, - Queued, - Expired, - Executed, + UNDETERMINED = -1, + PENDING, + ACTIVE, + CANCELED, + DEFEATED, + SUCCEEDED, + QUEUED, + EXPIRED, + EXECUTED, } -const GovernanceInterface = new ethers.utils.Interface(GOV_ABI) +const GovernanceInterface = new Interface(GOV_ABI) + // get count of all proposals made in the latest governor contract -function useLatestProposalCount(): number | undefined { - const govContracts = useGovernanceContracts() +function useProposalCount(contract: Contract | null): number | undefined { + const { result } = useSingleCallResult(contract, 'proposalCount') - const res = useSingleCallResult(govContracts[0], 'proposalCount') - - if (res?.result?.[0]) { - return (res.result[0] as BigNumber).toNumber() - } - - return undefined + return result?.[0]?.toNumber() } +interface FormattedProposalLog { + description: string + details: { target: string; functionSig: string; callData: string }[] +} /** * Need proposal events to get description data emitted from * new proposal event. */ -function useDataFromEventLogs(): - | { - description: string - details: { target: string; functionSig: string; callData: string }[] - }[][] - | undefined { - const { library, chainId } = useActiveWeb3React() - const [formattedEvents, setFormattedEvents] = - useState<{ description: string; details: { target: string; functionSig: string; callData: string }[] }[][]>() - - const govContracts = useGovernanceContracts() - +function useFormattedProposalCreatedLogs(contract: Contract | null): FormattedProposalLog[] | undefined { // create filters for ProposalCreated events - const filters = useMemo( - () => - govContracts?.filter((govContract) => !!govContract)?.length > 0 - ? govContracts - .filter((govContract): govContract is ethers.Contract => !!govContract) - .map((contract) => ({ - ...contract.filters.ProposalCreated(), - fromBlock: 10861678, // TODO could optimize this on a per-contract basis, this is the safe value - toBlock: 'latest', - })) - : undefined, - [govContracts] - ) + const filter = useMemo(() => contract?.filters?.ProposalCreated(), [contract]) - // clear logs on chainId change - useEffect(() => { - return () => { - setFormattedEvents(undefined) - } - }, [chainId]) + const useLogsResult = useLogs(filter) - useEffect(() => { - if (!filters || !library) return - let stale = false - - if (!formattedEvents) { - Promise.all(filters.map((filter) => library.getLogs(filter))) - .then((governanceContractsProposalEvents) => { - if (stale) return - - const formattedEventData = governanceContractsProposalEvents.map((proposalEvents) => { - return proposalEvents.map((event) => { - const eventParsed = GovernanceInterface.parseLog(event).args - return { - description: eventParsed.description, - details: eventParsed.targets.map((target: string, i: number) => { - const signature = eventParsed.signatures[i] - const [name, types] = signature.substr(0, signature.length - 1).split('(') - const calldata = eventParsed.calldatas[i] - const decoded = utils.defaultAbiCoder.decode(types.split(','), calldata) - return { - target, - functionSig: name, - callData: decoded.join(', '), - } - }), - } - }) - }) - - setFormattedEvents(formattedEventData) - }) - .catch((error) => { - if (stale) return - - console.error('Failed to fetch proposals', error) - setFormattedEvents(undefined) - }) - - return () => { - stale = true + return useMemo(() => { + return useLogsResult?.logs?.map((log) => { + const parsed = GovernanceInterface.parseLog(log).args + return { + description: parsed.description, + details: parsed.targets.map((target: string, i: number) => { + const signature = parsed.signatures[i] + const [name, types] = signature.substr(0, signature.length - 1).split('(') + const calldata = parsed.calldatas[i] + const decoded = defaultAbiCoder.decode(types.split(','), calldata) + return { + target, + functionSig: name, + callData: decoded.join(', '), + } + }), } - } + }) + }, [useLogsResult]) +} - return - }, [filters, library, formattedEvents, chainId]) +const V0_PROPOSAL_IDS = [[1], [2], [3], [4]] - return formattedEvents +function countToIndices(count: number | undefined) { + return typeof count === 'number' ? new Array(count).fill(0).map((_, i) => [i + 1]) : [] } // get data for all past and active proposals -export function useAllProposalData(): ProposalData[] { +export function useAllProposalData(): { data: ProposalData[]; loading: boolean } { const { chainId } = useActiveWeb3React() - const proposalCount = useLatestProposalCount() + const gov0 = useGovernanceV0Contract() + const gov1 = useGovernanceV1Contract() - const addresses = useMemo(() => { - return chainId === SupportedChainId.MAINNET ? GOVERNANCE_ADDRESSES.map((addressMap) => addressMap[chainId]) : [] - }, [chainId]) + const proposalCount0 = useProposalCount(gov0) + const proposalCount1 = useProposalCount(gov1) - const proposalIndexes = useMemo(() => { - return chainId === SupportedChainId.MAINNET - ? [ - typeof proposalCount === 'number' ? new Array(proposalCount).fill(0).map((_, i) => [i + 1]) : [], // dynamic for current governor alpha - [[1], [2], [3], [4]], // hardcoded for governor alpha V0 - ] - : [] - }, [chainId, proposalCount]) + const gov0ProposalIndexes = useMemo(() => { + return chainId === SupportedChainId.MAINNET ? V0_PROPOSAL_IDS : countToIndices(proposalCount0) + }, [chainId, proposalCount0]) + const gov1ProposalIndexes = useMemo(() => { + return countToIndices(proposalCount1) + }, [proposalCount1]) - // get all proposal entities - const allProposalsCallData = useMultipleContractMultipleData( - addresses, - GovernanceInterface, - 'proposals', - proposalIndexes - ) + const proposalsV0 = useSingleContractMultipleData(gov0, 'proposals', gov0ProposalIndexes) + const proposalsV1 = useSingleContractMultipleData(gov1, 'proposals', gov1ProposalIndexes) // get all proposal states - const allProposalStatesCallData = useMultipleContractMultipleData( - addresses, - GovernanceInterface, - 'state', - proposalIndexes - ) + const proposalStatesV0 = useSingleContractMultipleData(gov0, 'state', gov0ProposalIndexes) + const proposalStatesV1 = useSingleContractMultipleData(gov1, 'state', gov1ProposalIndexes) // get metadata from past events - const allFormattedEvents = useDataFromEventLogs() + const formattedLogsV0 = useFormattedProposalCreatedLogs(gov0) + const formattedLogsV1 = useFormattedProposalCreatedLogs(gov1) // early return until events are fetched - if (!allFormattedEvents) return [] - - const results: ProposalData[][] = [] - - for ( - let governanceContractIndex = 0; - governanceContractIndex < allProposalsCallData.length; - governanceContractIndex++ - ) { - const proposalsCallData = allProposalsCallData[governanceContractIndex] - const proposalStatesCallData = allProposalStatesCallData[governanceContractIndex] - const formattedEvents = allFormattedEvents[governanceContractIndex] + return useMemo(() => { + const proposalsCallData = proposalsV0.concat(proposalsV1) + const proposalStatesCallData = proposalStatesV0.concat(proposalStatesV1) + const formattedLogs = (formattedLogsV0 ?? []).concat(formattedLogsV1 ?? []) if ( - !proposalsCallData?.every((p) => Boolean(p.result)) || - !proposalStatesCallData?.every((p) => Boolean(p.result)) || - !formattedEvents?.every((p) => Boolean(p)) + proposalsCallData.some((p) => p.loading) || + proposalStatesCallData.some((p) => p.loading) || + (gov0 && !formattedLogsV0) || + (gov1 && !formattedLogsV1) ) { - results.push([]) - continue + return { data: [], loading: true } } - results.push( - proposalsCallData.map((proposal, i) => { - let description = formattedEvents[i].description + return { + data: proposalsCallData.map((proposal, i) => { + let description = formattedLogs[i]?.description const startBlock = parseInt(proposal?.result?.startBlock?.toString()) if (startBlock === UNISWAP_GRANTS_START_BLOCK) { description = UNISWAP_GRANTS_PROPOSAL_DESCRIPTION } return { id: proposal?.result?.id.toString(), - title: description?.split(/# |\n/g)[1] ?? 'Untitled', - description: description ?? 'No description.', + title: description?.split(/# |\n/g)[1] ?? t`Untitled`, + description: description ?? t`No description.`, proposer: proposal?.result?.proposer, - status: proposalStatesCallData[i]?.result?.[0] ?? ProposalState.Undetermined, - forCount: parseFloat(ethers.utils.formatUnits(proposal?.result?.forVotes.toString(), 18)), - againstCount: parseFloat(ethers.utils.formatUnits(proposal?.result?.againstVotes.toString(), 18)), + status: proposalStatesCallData[i]?.result?.[0] ?? ProposalState.UNDETERMINED, + forCount: parseFloat(formatUnits(proposal?.result?.forVotes?.toString() ?? 0, 18)), + againstCount: parseFloat(formatUnits(proposal?.result?.againstVotes?.toString() ?? 0, 18)), startBlock, endBlock: parseInt(proposal?.result?.endBlock?.toString()), - details: formattedEvents[i].details, - governorIndex: governanceContractIndex, + details: formattedLogs[i]?.details, + governorIndex: i >= gov0ProposalIndexes.length ? 1 : 0, } - }) - ) - } - - return results.reverse().flat() + }), + loading: false, + } + }, [ + formattedLogsV0, + formattedLogsV1, + gov0, + gov0ProposalIndexes.length, + gov1, + proposalStatesV0, + proposalStatesV1, + proposalsV0, + proposalsV1, + ]) } export function useProposalData(governorIndex: number, id: string): ProposalData | undefined { - const allProposalData = useAllProposalData() - return allProposalData?.filter((p) => p.governorIndex === governorIndex)?.find((p) => p.id === id) + const { data } = useAllProposalData() + return data.filter((p) => p.governorIndex === governorIndex)?.find((p) => p.id === id) } // get the users delegatee if it exists @@ -258,14 +203,16 @@ export function useUserDelegatee(): string { } // gets the users current votes -export function useUserVotes(): CurrencyAmount | undefined { +export function useUserVotes(): { loading: boolean; votes: CurrencyAmount | undefined } { const { account, chainId } = useActiveWeb3React() const uniContract = useUniContract() // check for available votes - const uni = chainId ? UNI[chainId] : undefined - const votes = useSingleCallResult(uniContract, 'getCurrentVotes', [account ?? undefined])?.result?.[0] - return votes && uni ? CurrencyAmount.fromRawAmount(uni, votes) : undefined + const { result, loading } = useSingleCallResult(uniContract, 'getCurrentVotes', [account ?? undefined]) + return useMemo(() => { + const uni = chainId ? UNI[chainId] : undefined + return { loading, votes: uni && result ? CurrencyAmount.fromRawAmount(uni, result?.[0]) : undefined } + }, [chainId, loading, result]) } // fetch available votes as of block (usually proposal start block) @@ -296,7 +243,7 @@ export function useDelegateCallback(): (delegatee: string | undefined) => undefi .delegate(...args, { value: null, gasLimit: calculateGasMargin(estimatedGasLimit) }) .then((response: TransactionResponse) => { addTransaction(response, { - summary: `Delegated votes`, + summary: t`Delegated votes`, }) return response.hash }) @@ -311,8 +258,8 @@ export function useVoteCallback(): { } { const { account } = useActiveWeb3React() - const govContracts = useGovernanceContracts() - const latestGovernanceContract = govContracts ? govContracts[0] : null + const latestGovernanceContract = useLatestGovernanceContract() + const addTransaction = useTransactionAdder() const voteCallback = useCallback( @@ -340,11 +287,10 @@ export function useCreateProposalCallback(): ( ) => undefined | Promise { const { account } = useActiveWeb3React() - const govContracts = useGovernanceContracts() - const latestGovernanceContract = govContracts ? govContracts[0] : null + const latestGovernanceContract = useLatestGovernanceContract() const addTransaction = useTransactionAdder() - const createProposalCallback = useCallback( + return useCallback( (createProposalData: CreateProposalData | undefined) => { if (!account || !latestGovernanceContract || !createProposalData) return undefined @@ -369,27 +315,19 @@ export function useCreateProposalCallback(): ( }, [account, addTransaction, latestGovernanceContract] ) - - return createProposalCallback } -export function useLatestProposalId(address: string): string | undefined { - const govContracts = useGovernanceContracts() - const latestGovernanceContract = govContracts ? govContracts[0] : null - const res = useSingleCallResult(latestGovernanceContract, 'latestProposalIds', [address]) +export function useLatestProposalId(address: string | undefined): string | undefined { + const govContractV1 = useGovernanceV1Contract() + const res = useSingleCallResult(govContractV1, 'latestProposalIds', [address]) - if (res?.result?.[0]) { - return (res.result[0] as BigNumber).toString() - } - - return undefined + return res?.result?.[0]?.toString() } export function useProposalThreshold(): CurrencyAmount | undefined { const { chainId } = useActiveWeb3React() - const govContracts = useGovernanceContracts() - const latestGovernanceContract = govContracts ? govContracts[0] : null + const latestGovernanceContract = useLatestGovernanceContract() const res = useSingleCallResult(latestGovernanceContract, 'proposalThreshold') const uni = chainId ? UNI[chainId] : undefined diff --git a/src/state/index.ts b/src/state/index.ts index b7aeda4733..4d6975ee0c 100644 --- a/src/state/index.ts +++ b/src/state/index.ts @@ -11,6 +11,7 @@ import mintV3 from './mint/v3/reducer' import lists from './lists/reducer' import burn from './burn/reducer' import burnV3 from './burn/v3/reducer' +import logs from './logs/slice' import multicall from './multicall/reducer' import { api } from './data/slice' @@ -28,6 +29,7 @@ const store = configureStore({ burnV3, multicall, lists, + logs, [api.reducerPath]: api.reducer, }, middleware: (getDefaultMiddleware) => diff --git a/src/state/logs/hooks.ts b/src/state/logs/hooks.ts new file mode 100644 index 0000000000..84862a4487 --- /dev/null +++ b/src/state/logs/hooks.ts @@ -0,0 +1,76 @@ +import { useBlockNumber } from '../application/hooks' +import { useEffect, useMemo } from 'react' +import { useActiveWeb3React } from '../../hooks/web3' +import { useAppDispatch, useAppSelector } from '../hooks' +import { addListener, removeListener } from './slice' +import { EventFilter, filterToKey, Log } from './utils' + +enum LogsState { + // The filter is invalid + INVALID, + // The logs are being loaded + LOADING, + // Logs are from a previous block number + SYNCING, + // Tried to fetch logs but received an error + ERROR, + // Logs have been fetched as of the latest block number + SYNCED, +} + +export interface UseLogsResult { + logs: Log[] | undefined + state: LogsState +} + +/** + * Returns the logs for the given filter as of the latest block, re-fetching from the library every block. + * @param filter The logs filter, without `blockHash`, `fromBlock` or `toBlock` defined. + */ +export function useLogs(filter: EventFilter | undefined): UseLogsResult { + const { chainId } = useActiveWeb3React() + const blockNumber = useBlockNumber() + + const logs = useAppSelector((state) => state.logs) + const dispatch = useAppDispatch() + + useEffect(() => { + if (!filter || !chainId) return + + dispatch(addListener({ chainId, filter })) + return () => { + dispatch(removeListener({ chainId, filter })) + } + }, [chainId, dispatch, filter]) + + const filterKey = useMemo(() => (filter ? filterToKey(filter) : undefined), [filter]) + + return useMemo(() => { + if (!chainId || !filterKey || !blockNumber) + return { + logs: undefined, + state: LogsState.INVALID, + } + + const state = logs[chainId]?.[filterKey] + const result = state?.results + if (!result) { + return { + state: LogsState.LOADING, + logs: undefined, + } + } + + if (result.error) { + return { + state: LogsState.ERROR, + logs: undefined, + } + } + + return { + state: result.blockNumber >= blockNumber ? LogsState.SYNCED : LogsState.SYNCING, + logs: result.logs, + } + }, [blockNumber, chainId, filterKey, logs]) +} diff --git a/src/state/logs/slice.ts b/src/state/logs/slice.ts new file mode 100644 index 0000000000..d399838d56 --- /dev/null +++ b/src/state/logs/slice.ts @@ -0,0 +1,87 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { EventFilter, filterToKey, Log } from './utils' + +export interface LogsState { + [chainId: number]: { + [filterKey: string]: { + listeners: number + fetchingBlockNumber?: number + results?: + | { + blockNumber: number + logs: Log[] + error?: undefined + } + | { + blockNumber: number + logs?: undefined + error: true + } + } + } +} + +const slice = createSlice({ + name: 'logs', + initialState: {} as LogsState, + reducers: { + addListener(state, { payload: { chainId, filter } }: PayloadAction<{ chainId: number; filter: EventFilter }>) { + if (!state[chainId]) state[chainId] = {} + const key = filterToKey(filter) + if (!state[chainId][key]) + state[chainId][key] = { + listeners: 1, + } + else state[chainId][key].listeners++ + }, + fetchingLogs( + state, + { + payload: { chainId, filters, blockNumber }, + }: PayloadAction<{ chainId: number; filters: EventFilter[]; blockNumber: number }> + ) { + if (!state[chainId]) return + for (const filter of filters) { + const key = filterToKey(filter) + if (!state[chainId][key]) continue + state[chainId][key].fetchingBlockNumber = blockNumber + } + }, + fetchedLogs( + state, + { + payload: { chainId, filter, results }, + }: PayloadAction<{ chainId: number; filter: EventFilter; results: { blockNumber: number; logs: Log[] } }> + ) { + if (!state[chainId]) return + const key = filterToKey(filter) + const fetchState = state[chainId][key] + if (!fetchState || (fetchState.results && fetchState.results.blockNumber > results.blockNumber)) return + fetchState.results = results + }, + fetchedLogsError( + state, + { + payload: { chainId, filter, blockNumber }, + }: PayloadAction<{ chainId: number; blockNumber: number; filter: EventFilter }> + ) { + if (!state[chainId]) return + const key = filterToKey(filter) + const fetchState = state[chainId][key] + if (!fetchState || (fetchState.results && fetchState.results.blockNumber > blockNumber)) return + fetchState.results = { + blockNumber, + error: true, + } + }, + removeListener(state, { payload: { chainId, filter } }: PayloadAction<{ chainId: number; filter: EventFilter }>) { + if (!state[chainId]) return + const key = filterToKey(filter) + if (!state[chainId][key]) return + state[chainId][key].listeners-- + }, + }, +}) + +export default slice.reducer +export const { addListener, removeListener, fetchedLogs, fetchedLogsError, fetchingLogs } = slice.actions diff --git a/src/state/logs/updater.ts b/src/state/logs/updater.ts new file mode 100644 index 0000000000..51cce40e06 --- /dev/null +++ b/src/state/logs/updater.ts @@ -0,0 +1,66 @@ +import { useEffect, useMemo } from 'react' +import { useActiveWeb3React } from '../../hooks/web3' +import { useBlockNumber } from '../application/hooks' +import { useAppDispatch, useAppSelector } from '../hooks' +import { fetchedLogs, fetchedLogsError, fetchingLogs } from './slice' +import { EventFilter, keyToFilter } from './utils' + +export default function Updater(): null { + const dispatch = useAppDispatch() + const state = useAppSelector((state) => state.logs) + const { chainId, library } = useActiveWeb3React() + + const blockNumber = useBlockNumber() + + const filtersNeedFetch: EventFilter[] = useMemo(() => { + if (!chainId || typeof blockNumber !== 'number') return [] + + const active = state[chainId] + if (!active) return [] + + return Object.keys(active) + .filter((key) => { + const { fetchingBlockNumber, results, listeners } = active[key] + if (listeners === 0) return false + if (typeof fetchingBlockNumber === 'number' && fetchingBlockNumber >= blockNumber) return false + if (results && typeof results.blockNumber === 'number' && results.blockNumber >= blockNumber) return false + return true + }) + .map((key) => keyToFilter(key)) + }, [blockNumber, chainId, state]) + + useEffect(() => { + if (!library || !chainId || typeof blockNumber !== 'number' || filtersNeedFetch.length === 0) return + + dispatch(fetchingLogs({ chainId, filters: filtersNeedFetch, blockNumber })) + filtersNeedFetch.forEach((filter) => { + library + .getLogs({ + ...filter, + fromBlock: 0, + toBlock: blockNumber, + }) + .then((logs) => { + dispatch( + fetchedLogs({ + chainId, + filter, + results: { logs, blockNumber }, + }) + ) + }) + .catch((error) => { + console.error('Failed to get logs', filter, error) + dispatch( + fetchedLogsError({ + chainId, + filter, + blockNumber, + }) + ) + }) + }) + }, [blockNumber, chainId, dispatch, filtersNeedFetch, library]) + + return null +} diff --git a/src/state/logs/utils.ts b/src/state/logs/utils.ts new file mode 100644 index 0000000000..e650218b9a --- /dev/null +++ b/src/state/logs/utils.ts @@ -0,0 +1,38 @@ +export interface EventFilter { + address?: string + topics?: Array | null> +} + +export interface Log { + topics: Array + data: string +} + +/** + * Converts a filter to the corresponding string key + * @param filter the filter to convert + */ +export function filterToKey(filter: EventFilter): string { + return `${filter.address ?? ''}:${ + filter.topics?.map((topic) => (topic ? (Array.isArray(topic) ? topic.join(';') : topic) : '\0'))?.join('-') ?? '' + }` +} + +/** + * Convert a filter key to the corresponding filter + * @param key key to convert + */ +export function keyToFilter(key: string): EventFilter { + const pcs = key.split(':') + const address = pcs[0] + const topics = pcs[1].split('-').map((topic) => { + const parts = topic.split(';') + if (parts.length === 1) return parts[0] + return parts + }) + + return { + address: address.length === 0 ? undefined : address, + topics, + } +} diff --git a/src/state/multicall/actions.test.ts b/src/state/multicall/actions.test.ts index 8d2b816cbb..7ec7a16f90 100644 --- a/src/state/multicall/actions.test.ts +++ b/src/state/multicall/actions.test.ts @@ -1,4 +1,4 @@ -import { parseCallKey, toCallKey } from './actions' +import { parseCallKey, toCallKey } from './utils' describe('actions', () => { describe('#parseCallKey', () => { @@ -11,9 +11,6 @@ describe('actions', () => { callData: 'abc', }) }) - it('throws for invalid format', () => { - expect(() => parseCallKey('abc')).toThrow('Invalid call key: abc') - }) it('throws for uppercase calldata', () => { expect(parseCallKey('0x6b175474e89094c44da98b954eedeac495271d0f-0xabcD')).toEqual({ address: '0x6b175474e89094c44da98b954eedeac495271d0f', @@ -29,25 +26,6 @@ describe('actions', () => { }) describe('#toCallKey', () => { - it('throws for invalid address', () => { - expect(() => toCallKey({ callData: '0x', address: '0x' })).toThrow('Invalid address: 0x') - }) - it('throws for invalid calldata', () => { - expect(() => - toCallKey({ - address: '0x6b175474e89094c44da98b954eedeac495271d0f', - callData: 'abc', - }) - ).toThrow('Invalid hex: abc') - }) - it('throws for uppercase hex', () => { - expect(() => - toCallKey({ - address: '0x6b175474e89094c44da98b954eedeac495271d0f', - callData: '0xabcD', - }) - ).toThrow('Invalid hex: 0xabcD') - }) it('concatenates address to data', () => { expect(toCallKey({ address: '0x6b175474e89094c44da98b954eedeac495271d0f', callData: '0xabcd' })).toEqual( '0x6b175474e89094c44da98b954eedeac495271d0f-0xabcd' diff --git a/src/state/multicall/actions.ts b/src/state/multicall/actions.ts index 7693410be4..6f641674d0 100644 --- a/src/state/multicall/actions.ts +++ b/src/state/multicall/actions.ts @@ -1,41 +1,5 @@ import { createAction } from '@reduxjs/toolkit' - -export interface Call { - address: string - callData: string - gasRequired?: number -} - -const ADDRESS_REGEX = /^0x[a-fA-F0-9]{40}$/ -const LOWER_HEX_REGEX = /^0x[a-f0-9]*$/ -export function toCallKey(call: Call): string { - if (!ADDRESS_REGEX.test(call.address)) { - throw new Error(`Invalid address: ${call.address}`) - } - if (!LOWER_HEX_REGEX.test(call.callData)) { - throw new Error(`Invalid hex: ${call.callData}`) - } - let key = `${call.address}-${call.callData}` - if (call.gasRequired) { - if (!Number.isSafeInteger(call.gasRequired)) { - throw new Error(`Invalid number: ${call.gasRequired}`) - } - key += `-${call.gasRequired}` - } - return key -} - -export function parseCallKey(callKey: string): Call { - const pcs = callKey.split('-') - if (![2, 3].includes(pcs.length)) { - throw new Error(`Invalid call key: ${callKey}`) - } - return { - address: pcs[0], - callData: pcs[1], - ...(pcs[2] ? { gasRequired: Number.parseInt(pcs[2]) } : {}), - } -} +import { Call } from './utils' export interface ListenerOptions { // how often this data should be fetched, by default 1 diff --git a/src/state/multicall/hooks.ts b/src/state/multicall/hooks.ts index 36eded1ece..058b091e1c 100644 --- a/src/state/multicall/hooks.ts +++ b/src/state/multicall/hooks.ts @@ -5,14 +5,8 @@ import { useEffect, useMemo } from 'react' import { useAppDispatch, useAppSelector } from 'state/hooks' import { useActiveWeb3React } from '../../hooks/web3' import { useBlockNumber } from '../application/hooks' -import { - addMulticallListeners, - Call, - ListenerOptions, - parseCallKey, - removeMulticallListeners, - toCallKey, -} from './actions' +import { addMulticallListeners, ListenerOptions, removeMulticallListeners } from './actions' +import { Call, parseCallKey, toCallKey } from './utils' export interface Result extends ReadonlyArray { readonly [key: string]: any @@ -234,63 +228,6 @@ export function useMultipleContractSingleData( }, [fragment, results, contractInterface, latestBlockNumber]) } -export function useMultipleContractMultipleData( - addresses: (string | undefined)[], - contractInterface: Interface, - methodName: string, - callInputs?: OptionalMethodInputs[][], - options?: ListenerOptions, - gasRequired?: number -): CallState[][] { - const fragment = useMemo(() => contractInterface.getFunction(methodName), [contractInterface, methodName]) - - const calls = useMemo(() => { - return addresses.map((address, i) => { - const passesChecks = address && contractInterface && fragment - - if (!passesChecks) { - return [] - } - - return callInputs - ? callInputs[i].map((inputs) => { - const callData: string | undefined = isValidMethodArgs(inputs) - ? contractInterface.encodeFunctionData(fragment, inputs) - : undefined - return address && callData - ? { - address, - callData, - ...(gasRequired ? { gasRequired } : {}), - } - : undefined - }) - : [] - }) - }, [addresses, callInputs, contractInterface, fragment, gasRequired]) - - const callResults = useCallsData(calls.flat(), options) - - const latestBlockNumber = useBlockNumber() - - return useMemo(() => { - const callInputLengths = callInputs ? callInputs.map((inputArray) => inputArray.length) : [] - const unformatedResults = callResults.map((result) => - toCallState(result, contractInterface, fragment, latestBlockNumber) - ) - - return callInputLengths.map((length) => { - let j = 0 - const indexElements: any = [] - while (j < length) { - indexElements.push(unformatedResults.shift()) - j++ - } - return indexElements - }) - }, [fragment, callInputs, callResults, contractInterface, latestBlockNumber]) -} - export function useSingleCallResult( contract: Contract | null | undefined, methodName: string, diff --git a/src/state/multicall/reducer.ts b/src/state/multicall/reducer.ts index ee218526a1..1a33eb3522 100644 --- a/src/state/multicall/reducer.ts +++ b/src/state/multicall/reducer.ts @@ -4,9 +4,9 @@ import { errorFetchingMulticallResults, fetchingMulticallResults, removeMulticallListeners, - toCallKey, updateMulticallResults, } from './actions' +import { toCallKey } from './utils' export interface MulticallState { callListeners?: { diff --git a/src/state/multicall/updater.tsx b/src/state/multicall/updater.tsx index d52c21c8de..2b79e5ce2c 100644 --- a/src/state/multicall/updater.tsx +++ b/src/state/multicall/updater.tsx @@ -8,14 +8,9 @@ import { retry, RetryableError } from '../../utils/retry' import { updateBlockNumber } from '../application/actions' import { useBlockNumber } from '../application/hooks' import { AppState } from '../index' -import { - Call, - errorFetchingMulticallResults, - fetchingMulticallResults, - parseCallKey, - updateMulticallResults, -} from './actions' +import { errorFetchingMulticallResults, fetchingMulticallResults, updateMulticallResults } from './actions' import { useAppDispatch, useAppSelector } from 'state/hooks' +import { Call, parseCallKey } from './utils' /** * Fetches a chunk of calls, enforcing a minimum block number constraint diff --git a/src/state/multicall/utils.ts b/src/state/multicall/utils.ts new file mode 100644 index 0000000000..3127adbb8d --- /dev/null +++ b/src/state/multicall/utils.ts @@ -0,0 +1,28 @@ +export interface Call { + address: string + callData: string + gasRequired?: number +} + +export function toCallKey(call: Call): string { + let key = `${call.address}-${call.callData}` + if (call.gasRequired) { + if (!Number.isSafeInteger(call.gasRequired)) { + throw new Error(`Invalid number: ${call.gasRequired}`) + } + key += `-${call.gasRequired}` + } + return key +} + +export function parseCallKey(callKey: string): Call { + const pcs = callKey.split('-') + if (![2, 3].includes(pcs.length)) { + throw new Error(`Invalid call key: ${callKey}`) + } + return { + address: pcs[0], + callData: pcs[1], + ...(pcs[2] ? { gasRequired: Number.parseInt(pcs[2]) } : {}), + } +}