diff --git a/src/components/Proposals/ProposalActions/ProposalAction.tsx b/src/components/Proposals/ProposalActions/ProposalAction.tsx index c28c14db14..c3e09daced 100644 --- a/src/components/Proposals/ProposalActions/ProposalAction.tsx +++ b/src/components/Proposals/ProposalActions/ProposalAction.tsx @@ -1,17 +1,12 @@ import { Button, Flex, Text } from '@chakra-ui/react'; -import { useRouter } from 'next/navigation'; +import Link from 'next/link'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { BACKGROUND_SEMI_TRANSPARENT } from '../../../constants/common'; import { DAO_ROUTES } from '../../../constants/routes'; import useSnapshotProposal from '../../../hooks/DAO/loaders/snapshot/useSnapshotProposal'; import { useFractal } from '../../../providers/App/AppProvider'; -import { - ExtendedSnapshotProposal, - FractalProposal, - FractalProposalState, - SnapshotProposal, -} from '../../../types'; +import { ExtendedSnapshotProposal, FractalProposal, FractalProposalState } from '../../../types'; import ContentBox from '../../ui/containers/ContentBox'; import { ProposalCountdown } from '../../ui/proposal/ProposalCountdown'; import { useVoteContext } from '../ProposalVotes/context/VoteContext'; @@ -60,7 +55,6 @@ export function ProposalAction({ node: { daoAddress }, readOnly: { user, dao }, } = useFractal(); - const { push } = useRouter(); const { t } = useTranslation(); const { isSnapshotProposal } = useSnapshotProposal(proposal); const { canVote } = useVoteContext(); @@ -78,16 +72,6 @@ export function ProposalAction({ proposal.state === FractalProposalState.TIMELOCKABLE || proposal.state === FractalProposalState.TIMELOCKED)); - const handleClick = () => { - if (isSnapshotProposal) { - push( - DAO_ROUTES.proposal.relative(daoAddress, (proposal as SnapshotProposal).snapshotProposalId) - ); - } else { - push(DAO_ROUTES.proposal.relative(daoAddress, proposal.proposalId)); - } - }; - const labelKey = useMemo(() => { switch (proposal.state) { case FractalProposalState.ACTIVE: @@ -119,12 +103,17 @@ export function ProposalAction({ if (!showActionButton) { if (!expandedView) { return ( - + + ); } // This means that Proposal in state where there's no action to perform @@ -155,11 +144,16 @@ export function ProposalAction({ } return ( - + + ); } diff --git a/src/components/Proposals/ProposalInfo.tsx b/src/components/Proposals/ProposalInfo.tsx index 123481654a..e0b8ab4cd8 100644 --- a/src/components/Proposals/ProposalInfo.tsx +++ b/src/components/Proposals/ProposalInfo.tsx @@ -1,4 +1,4 @@ -import { Box, Flex, Text, Image, Button } from '@chakra-ui/react'; +import { Box, Flex, Text, Image, Button, Link } from '@chakra-ui/react'; import { Shield } from '@decent-org/fractal-ui'; import { useTranslation } from 'react-i18next'; import useSnapshotProposal from '../../hooks/DAO/loaders/snapshot/useSnapshotProposal'; @@ -41,14 +41,12 @@ export function ProposalInfo({ /> {(proposal as ExtendedSnapshotProposal).privacy === 'shutter' && ( ); diff --git a/src/hooks/DAO/loaders/snapshot/index.ts b/src/hooks/DAO/loaders/snapshot/index.ts index 855da5e2ec..4c6c14b179 100644 --- a/src/hooks/DAO/loaders/snapshot/index.ts +++ b/src/hooks/DAO/loaders/snapshot/index.ts @@ -11,10 +11,9 @@ const defaultOptions: DefaultOptions = { }, }; -const client = new ApolloClient({ - uri: 'https://hub.snapshot.org/graphql', - cache: new InMemoryCache(), - defaultOptions, -}); - -export default client; +export const createClient = (uri: string) => + new ApolloClient({ + uri: `https://${uri.includes('testnet') ? 'testnet.' : ''}hub.snapshot.org/graphql`, + cache: new InMemoryCache(), + defaultOptions, + }); diff --git a/src/hooks/DAO/loaders/snapshot/useSnapshotProposal.ts b/src/hooks/DAO/loaders/snapshot/useSnapshotProposal.ts index e6e0f15e7c..bbdd9904a6 100644 --- a/src/hooks/DAO/loaders/snapshot/useSnapshotProposal.ts +++ b/src/hooks/DAO/loaders/snapshot/useSnapshotProposal.ts @@ -1,5 +1,6 @@ import { gql } from '@apollo/client'; import { useCallback, useMemo, useState } from 'react'; +import { logError } from '../../../../helpers/errorLogging'; import { useFractal } from '../../../../providers/App/AppProvider'; import { ExtendedSnapshotProposal, @@ -9,7 +10,8 @@ import { SnapshotVote, SnapshotWeightedVotingChoice, } from '../../../../types'; -import client from './'; +import useSnapshotSpaceName from './useSnapshotSpaceName'; +import { createClient } from './'; export default function useSnapshotProposal(proposal: FractalProposal | null | undefined) { const [extendedSnapshotProposal, setExtendedSnapshotProposal] = @@ -20,6 +22,12 @@ export default function useSnapshotProposal(proposal: FractalProposal | null | u user: { address }, }, } = useFractal(); + const daoSnapshotSpaceName = useSnapshotSpaceName(); + const client = useMemo(() => { + if (daoSnapshotURL) { + return createClient(daoSnapshotURL); + } + }, [daoSnapshotURL]); const snapshotProposal = proposal as SnapshotProposal; const isSnapshotProposal = useMemo( @@ -28,7 +36,7 @@ export default function useSnapshotProposal(proposal: FractalProposal | null | u ); const loadProposal = useCallback(async () => { - if (snapshotProposal?.snapshotProposalId) { + if (snapshotProposal?.snapshotProposalId && client) { const proposalQueryResult = await client .query({ query: gql` @@ -188,17 +196,22 @@ export default function useSnapshotProposal(proposal: FractalProposal | null | u votes: votesQueryResult, } as ExtendedSnapshotProposal); } - }, [snapshotProposal?.snapshotProposalId, proposal, snapshotProposal?.state]); + }, [snapshotProposal?.snapshotProposalId, proposal, snapshotProposal?.state, client]); const loadVotingWeight = useCallback(async () => { - if (snapshotProposal?.snapshotProposalId) { + const emptyVotingWeight = { + votingWeight: 0, + votingWeightByStrategy: [0], + votingState: '', + }; + if (snapshotProposal?.snapshotProposalId && client) { const queryResult = await client .query({ query: gql` query UserVotingWeight { vp( voter: "${address}" - space: "${daoSnapshotURL}" + space: "${daoSnapshotSpaceName}" proposal: "${snapshotProposal.snapshotProposalId}" ) { vp @@ -208,6 +221,10 @@ export default function useSnapshotProposal(proposal: FractalProposal | null | u }`, }) .then(({ data: { vp } }) => { + if (!vp) { + logError('Error while retrieving Snapshot voting weight', vp); + return emptyVotingWeight; + } return { votingWeight: vp.vp, votingWeightByStrategy: vp.vp_by_strategy, @@ -218,12 +235,8 @@ export default function useSnapshotProposal(proposal: FractalProposal | null | u return queryResult; } - return { - votingWeight: 0, - votingWeightByStrategy: [0], - votingState: '', - }; - }, [address, daoSnapshotURL, snapshotProposal?.snapshotProposalId]); + return emptyVotingWeight; + }, [address, snapshotProposal?.snapshotProposalId, client, daoSnapshotSpaceName]); return { loadVotingWeight, diff --git a/src/hooks/DAO/loaders/snapshot/useSnapshotProposals.ts b/src/hooks/DAO/loaders/snapshot/useSnapshotProposals.ts index 4d660805d9..45a4d6bd92 100644 --- a/src/hooks/DAO/loaders/snapshot/useSnapshotProposals.ts +++ b/src/hooks/DAO/loaders/snapshot/useSnapshotProposals.ts @@ -1,27 +1,35 @@ import { gql } from '@apollo/client'; -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useFractal } from '../../../../providers/App/AppProvider'; import { FractalGovernanceAction } from '../../../../providers/App/governance/action'; import { ActivityEventType, FractalProposalState } from '../../../../types'; import { SnapshotProposal } from '../../../../types/daoProposal'; -import client from './'; +import useSnapshotSpaceName from './useSnapshotSpaceName'; +import { createClient } from './'; export const useSnapshotProposals = () => { const { node: { daoSnapshotURL }, action, } = useFractal(); + const daoSnapshotSpaceName = useSnapshotSpaceName(); const currentSnapshotURL = useRef(); + const client = useMemo(() => { + if (daoSnapshotURL) { + return createClient(daoSnapshotURL); + } + }, [daoSnapshotURL]); const loadSnapshotProposals = useCallback(async () => { - client - .query({ - query: gql` + if (client) { + client + .query({ + query: gql` query Proposals { proposals( first: 50, where: { - space_in: ["${daoSnapshotURL}"] + space_in: ["${daoSnapshotSpaceName}"] }, orderBy: "created", orderDirection: desc @@ -42,39 +50,40 @@ export const useSnapshotProposals = () => { } } `, - }) - .then(result => { - const proposals: SnapshotProposal[] = result.data.proposals.map((proposal: any) => { - return { - eventDate: new Date(proposal.start * 1000), - eventType: ActivityEventType.Governance, - state: - proposal.state === 'active' - ? FractalProposalState.ACTIVE - : proposal.state === 'closed' - ? FractalProposalState.CLOSED - : FractalProposalState.PENDING, + }) + .then(result => { + const proposals: SnapshotProposal[] = result.data.proposals.map((proposal: any) => { + return { + eventDate: new Date(proposal.start * 1000), + eventType: ActivityEventType.Governance, + state: + proposal.state === 'active' + ? FractalProposalState.ACTIVE + : proposal.state === 'closed' + ? FractalProposalState.CLOSED + : FractalProposalState.PENDING, - proposalId: proposal.id, - snapshotProposalId: proposal.id, - targets: [], - title: proposal.title, - description: proposal.body, - startTime: proposal.start, - endTime: proposal.end, - }; - }); + proposalId: proposal.id, + snapshotProposalId: proposal.id, + targets: [], + title: proposal.title, + description: proposal.body, + startTime: proposal.start, + endTime: proposal.end, + }; + }); - action.dispatch({ - type: FractalGovernanceAction.SET_SNAPSHOT_PROPOSALS, - payload: proposals, + action.dispatch({ + type: FractalGovernanceAction.SET_SNAPSHOT_PROPOSALS, + payload: proposals, + }); }); - }); - }, [action, daoSnapshotURL]); + } + }, [action, daoSnapshotSpaceName, client]); useEffect(() => { - if (!daoSnapshotURL || daoSnapshotURL === currentSnapshotURL.current) return; - currentSnapshotURL.current = daoSnapshotURL; + if (!daoSnapshotSpaceName || daoSnapshotSpaceName === currentSnapshotURL.current) return; + currentSnapshotURL.current = daoSnapshotSpaceName; loadSnapshotProposals(); - }, [daoSnapshotURL, loadSnapshotProposals]); + }, [daoSnapshotSpaceName, loadSnapshotProposals]); }; diff --git a/src/hooks/DAO/loaders/snapshot/useSnapshotSpaceName.ts b/src/hooks/DAO/loaders/snapshot/useSnapshotSpaceName.ts new file mode 100644 index 0000000000..478930a086 --- /dev/null +++ b/src/hooks/DAO/loaders/snapshot/useSnapshotSpaceName.ts @@ -0,0 +1,9 @@ +import { useFractal } from '../../../../providers/App/AppProvider'; + +export default function useSnapshotSpaceName() { + const { + node: { daoSnapshotURL }, + } = useFractal(); + + return daoSnapshotURL?.split('/').pop(); +} diff --git a/src/hooks/DAO/proposal/useCastVote.ts b/src/hooks/DAO/proposal/useCastVote.ts index ff5498b382..d077a7e714 100644 --- a/src/hooks/DAO/proposal/useCastVote.ts +++ b/src/hooks/DAO/proposal/useCastVote.ts @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; import { useSigner } from 'wagmi'; import { useVoteContext } from '../../../components/Proposals/ProposalVotes/context/VoteContext'; +import { logError } from '../../../helpers/errorLogging'; import { useFractal } from '../../../providers/App/AppProvider'; import { AzoriusGovernance, @@ -167,7 +168,7 @@ const useCastVote = ({ } catch (e) { toast.dismiss(toastId); toast.error('failedCastVote'); - console.error('Error while casting Snapshot vote', e); + logError('Error while casting Snapshot vote', e); } } }, diff --git a/src/hooks/DAO/proposal/useSubmitProposal.ts b/src/hooks/DAO/proposal/useSubmitProposal.ts index 9dc80667ec..073f4b03e9 100644 --- a/src/hooks/DAO/proposal/useSubmitProposal.ts +++ b/src/hooks/DAO/proposal/useSubmitProposal.ts @@ -9,7 +9,7 @@ import { BigNumber, Signer, utils } from 'ethers'; import { getAddress, isAddress } from 'ethers/lib/utils'; import { useCallback, useMemo, useState, useEffect } from 'react'; import { toast } from 'react-toastify'; -import { useSigner } from 'wagmi'; +import { useProvider, useSigner } from 'wagmi'; import { ADDRESS_MULTISIG_METADATA } from '../../../constants/common'; import { buildSafeAPIPost, encodeMultiSend } from '../../../helpers'; import { logError } from '../../../helpers/errorLogging'; @@ -24,6 +24,7 @@ import { ProposalMetadata, } from '../../../types'; import { buildSafeApiUrl, getAzoriusModuleFromModules } from '../../../utils'; +import { getAverageBlockTime } from '../../../utils/contract'; import useSignerOrProvider from '../../utils/useSignerOrProvider'; import { useFractalModules } from '../loaders/useFractalModules'; import { useDAOProposals } from '../loaders/useProposals'; @@ -61,6 +62,7 @@ export default function useSubmitProposal() { const [canUserCreateProposal, setCanUserCreateProposal] = useState(false); const loadDAOProposals = useDAOProposals(); const { data: signer } = useSigner(); + const provider = useProvider(); const { node: { safe, fractalModules }, @@ -295,6 +297,7 @@ export default function useSubmitProposal() { }); setPendingCreateTx(true); + let success = false; try { const transactions = proposalData.targets.map((target, index) => ({ to: target, @@ -316,12 +319,12 @@ export default function useSubmitProposal() { }) ) ).wait(); - await loadDAOProposals(); + success = true; + toast.dismiss(toastId); + toast(successToastMessage); if (successCallback) { successCallback(safeAddress!); } - toast.dismiss(toastId); - toast(successToastMessage); } catch (e) { toast.dismiss(toastId); toast(failedToastMessage); @@ -329,8 +332,17 @@ export default function useSubmitProposal() { } finally { setPendingCreateTx(false); } + + if (success) { + const averageBlockTime = await getAverageBlockTime(provider); + // Frequently there's an error in loadDAOProposals if we're loading the proposal immediately after proposal creation + // The error occurs because block of proposal creation not yet mined and trying to fetch underlying data of voting weight for new proposal fails with that error + // The code that throws an error: https://github.com/decent-dao/fractal-contracts/blob/develop/contracts/azorius/LinearERC20Voting.sol#L205-L211 + // So to avoid showing error toast - we're marking proposal creation as success and only then re-fetching proposals + setTimeout(loadDAOProposals, averageBlockTime * 1.5 * 1000); + } }, - [loadDAOProposals] + [loadDAOProposals, provider] ); const submitProposal = useCallback( diff --git a/src/hooks/utils/useAsyncRetry.ts b/src/hooks/utils/useAsyncRetry.ts index dc8f629169..2781cbffdd 100644 --- a/src/hooks/utils/useAsyncRetry.ts +++ b/src/hooks/utils/useAsyncRetry.ts @@ -1,4 +1,5 @@ import { useCallback } from 'react'; +import { logError } from '../../helpers/errorLogging'; function sleep(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)); @@ -23,7 +24,7 @@ export function useAsyncRetry() { return result; } } catch (error) { - console.error('Error in requestWithRetries:', error); + logError('Error in requestWithRetries:', error); } currentRetries += 1;