Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add indicator for pending proposal #1998

Merged
merged 21 commits into from
Jun 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 2 additions & 8 deletions src/components/Proposals/ProposalsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@ export function ProposalsList({ proposals }: { proposals: FractalProposal[] }) {
} = useFractal();
const { canUserCreateProposal } = useCanUserCreateProposal();
const { addressPrefix } = useNetworkConfig();

const { t } = useTranslation('proposal');
const { t } = useTranslation(['proposal']);
return (
<Flex
flexDirection="column"
Expand All @@ -44,12 +43,7 @@ export function ProposalsList({ proposals }: { proposals: FractalProposal[] }) {
<EmptyBox emptyText={t('emptyProposals')}>
{canUserCreateProposal && daoAddress && (
<Link to={DAO_ROUTES.proposalNew.relative(addressPrefix, daoAddress)}>
<Button
variant="text"
textStyle="text-xl-mono-bold"
>
{t('createProposal')}
</Button>
<Button variant="text">{t('createProposal')}</Button>
</Link>
)}
</EmptyBox>
Expand Down
3 changes: 2 additions & 1 deletion src/hooks/DAO/loaders/governance/useAzoriusListeners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const proposalCreatedEventListener = (
decode: (value: string, to: string, data?: string | undefined) => Promise<DecodedTransaction[]>,
dispatch: Dispatch<FractalActions>,
): TypedListener<ProposalCreatedEvent> => {
return async (_strategyAddress, proposalId, proposer, transactions, metadata) => {
return async (_strategyAddress, proposalId, proposer, transactions, metadata, createdEvent) => {
if (!metadata) {
return;
}
Expand Down Expand Up @@ -68,6 +68,7 @@ const proposalCreatedEventListener = (
};

const proposal = await mapProposalCreatedEventToProposal(
createdEvent,
erc20StrategyContract,
erc721StrategyContract,
strategyType,
Expand Down
3 changes: 3 additions & 0 deletions src/hooks/DAO/loaders/governance/useAzoriusProposals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,8 @@ export const useAzoriusProposals = () => {
payload: true,
});

// TODO: `SET_LOADING_PROPOSALS` seems like the kind of action to be called once all proposals are finished loading.
// So, it's strange that this function takes a single proposals as an argument.
const completeProposalLoad = (proposal: AzoriusProposal) => {
if (currentAzoriusAddress.current !== azoriusContractAddress) {
// The DAO has changed, don't load the just-fetched proposal,
Expand Down Expand Up @@ -232,6 +234,7 @@ export const useAzoriusProposals = () => {
}

const proposal = await mapProposalCreatedEventToProposal(
proposalCreatedEvent,
_erc20StrategyContract,
_erc721StrategyContract,
_strategyType,
Expand Down
33 changes: 27 additions & 6 deletions src/hooks/DAO/proposal/useSubmitProposal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ADDRESS_MULTISIG_METADATA } from '../../../constants/common';
import { buildSafeAPIPost, encodeMultiSend } from '../../../helpers';
import { logError } from '../../../helpers/errorLogging';
import { useFractal } from '../../../providers/App/AppProvider';
import { FractalGovernanceAction } from '../../../providers/App/governance/action';
import useIPFSClient from '../../../providers/App/hooks/useIPFSClient';
import { useSafeAPI } from '../../../providers/App/hooks/useSafeAPI';
import { useEthersProvider } from '../../../providers/Ethers/hooks/useEthersProvider';
Expand Down Expand Up @@ -70,6 +71,7 @@ export default function useSubmitProposal() {
node: { safe, fractalModules },
guardContracts: { freezeVotingContractAddress },
governanceContracts: { ozLinearVotingContractAddress, erc721LinearVotingContractAddress },
action,
} = useFractal();
const baseContracts = useSafeContracts();
const safeAPI = useSafeAPI();
Expand All @@ -91,6 +93,16 @@ export default function useSubmitProposal() {
const { chain, safeBaseURL, addressPrefix } = useNetworkConfig();
const ipfsClient = useIPFSClient();

const pendingProposalAdd = useCallback(
(txHash: string) => {
action.dispatch({
type: FractalGovernanceAction.PENDING_PROPOSAL_ADD,
payload: txHash,
});
},
[action],
);

const submitMultisigProposal = useCallback(
async ({
pendingToastMessage,
Expand Down Expand Up @@ -173,7 +185,7 @@ export default function useSubmitProposal() {
}

const safeContract = GnosisSafeL2__factory.connect(safeAddress, signerOrProvider);
await axios.post(
const response = await axios.post(
buildSafeApiUrl(safeBaseURL, `/safes/${safeAddress}/multisig-transactions/`),
await buildSafeAPIPost(
safeContract,
Expand All @@ -188,8 +200,13 @@ export default function useSubmitProposal() {
},
),
);
await new Promise(resolve => setTimeout(resolve, 1000));

const responseData = JSON.parse(response.config.data);
const txHash = responseData.contractTransactionHash;
pendingProposalAdd(txHash);

await loadDAOProposals();

if (successCallback) {
successCallback(addressPrefix, safeAddress);
}
Expand All @@ -204,12 +221,13 @@ export default function useSubmitProposal() {
}
},
[
baseContracts,
signerOrProvider,
safeBaseURL,
chain,
chain.id,
loadDAOProposals,
pendingProposalAdd,
ipfsClient,
baseContracts,
addressPrefix,
],
);
Expand Down Expand Up @@ -246,7 +264,7 @@ export default function useSubmitProposal() {
}));

// @todo: Implement voting strategy proposal selection when/if we will support multiple strategies on single Azorius instance
await (
const receipt = await (
await azoriusContract.submitProposal(
votingStrategyAddress,
'0x',
Expand All @@ -260,6 +278,9 @@ export default function useSubmitProposal() {
).wait();
toast.dismiss(toastId);
toast(successToastMessage);

pendingProposalAdd(receipt.transactionHash);

if (successCallback) {
successCallback(addressPrefix, safeAddress!);
}
Expand All @@ -271,7 +292,7 @@ export default function useSubmitProposal() {
setPendingCreateTx(false);
}
},
[provider, addressPrefix],
[provider, pendingProposalAdd, addressPrefix],
);

const submitProposal: SubmitProposalFunction = useCallback(
Expand Down
5 changes: 4 additions & 1 deletion src/hooks/utils/useSafeTransactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,10 @@ export const useSafeTransactions = () => {
: undefined,
proposalId: eventSafeTxHash,
targets,
transactionHash: multiSigTransaction.transactionHash,
// @todo typing for `multiSigTransaction.transactionHash` is misleading, as ` multiSigTransaction.transactionHash` is not always defined (if ever). Need to tighten up the typing here.
transactionHash:
multiSigTransaction.transactionHash ||
(transaction as SafeMultisigTransactionWithTransfersResponse).safeTxHash,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this needed? The code builds for me without this change.

Logically, I suppose the reason why it would needed is if transactionHash is the empty string (Typescript says it's type is simply string).

empty string is falsey, so safeTxHash will be used in the || conditional.

Smells to me like maybe this is an opportunity to tighten up some typing.

Do you follow where my head's at? and if so do you agree? If so, Please add a TODO comment here for someone searching the codebase later for things to do.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is needed, yep. The typing in that case is misleading, and multiSigTransaction.transactionHash turns out to always be an empty string (or was it undefined?). I chose not to replace the line with transactionHash: (transaction as SafeMultisigTransactionWithTransfersResponse).safeTxHash in case there's some legacy reasoning behind the choice in the first place.

I'll add the todo for the team to revisit this.

data: data,
state: null,
nonce: eventNonce,
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/en/proposal.json
Original file line number Diff line number Diff line change
Expand Up @@ -136,5 +136,6 @@
"shielded": "Shielded",
"shutterPrivacy": "Shielded",
"shutterVotesHidden": "Results hidden during voting",
"totalVotes": "Total votes"
"totalVotes": "Total votes",
"pendingProposalNotice": "Pending proposal still processing"
}
28 changes: 28 additions & 0 deletions src/pages/DAOController.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,45 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Outlet } from 'react-router-dom';
import { toast } from 'react-toastify';
import useDAOController from '../hooks/DAO/useDAOController';
import { useUpdateSafeData } from '../hooks/utils/useUpdateSafeData';
import { useFractal } from '../providers/App/AppProvider';
import LoadingProblem from './LoadingProblem';

const useTemporaryProposals = () => {
const {
governance: { pendingProposals },
} = useFractal();
const { t } = useTranslation(['proposal']);

useEffect(() => {
if (pendingProposals === null || pendingProposals.length === 0) {
return;
}

const toastId = toast.info(t('pendingProposalNotice'), {
autoClose: false,
closeOnClick: false,
draggable: false,
closeButton: false,
});

return () => {
toast.dismiss(toastId);
};
}, [t, pendingProposals]);
};

export default function DAOController() {
const { errorLoading, wrongNetwork, invalidQuery } = useDAOController();
useUpdateSafeData();
const {
node: { daoName },
} = useFractal();

useTemporaryProposals();

useEffect(() => {
if (daoName) {
document.title = `${import.meta.env.VITE_APP_NAME} | ${daoName}`;
Expand Down
5 changes: 5 additions & 0 deletions src/providers/App/governance/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export enum FractalGovernanceAction {
SET_CLAIMING_CONTRACT = 'SET_CLAIMING_CONTRACT',
RESET_TOKEN_ACCOUNT_DATA = 'RESET_TOKEN_ACCOUNT_DATA',
SET_UNDERLYING_TOKEN_DATA = 'SET_UNDERLYING_TOKEN_DATA',
PENDING_PROPOSAL_ADD = 'PENDING_PROPOSAL_ADD',
}

export enum DecentGovernanceAction {
Expand Down Expand Up @@ -119,6 +120,10 @@ export type FractalGovernanceActions =
| {
type: FractalGovernanceAction.RESET_TOKEN_ACCOUNT_DATA;
}
| {
type: FractalGovernanceAction.PENDING_PROPOSAL_ADD;
payload: string;
}
| DecentGovernanceActions;

export type DecentGovernanceActions =
Expand Down
42 changes: 41 additions & 1 deletion src/providers/App/governance/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const initialGovernanceState: FractalGovernance = {
loadingProposals: true,
allProposalsLoaded: false,
proposals: null,
pendingProposals: null,
proposalTemplates: null,
type: undefined,
votingStrategy: undefined,
Expand All @@ -30,6 +31,17 @@ export const initialVotesTokenAccountData = {
isDelegatesSet: null,
};

const createPendingProposals = (
pendingProposals: string[] | null,
proposalIds: string[],
): string[] | null => {
if (pendingProposals === null || proposalIds.length === 0) {
return null;
}

return pendingProposals.filter(p => !proposalIds.includes(p));
};

export const governanceReducer = (state: FractalGovernance, action: FractalGovernanceActions) => {
const { proposals } = state;
switch (action.type) {
Expand All @@ -43,17 +55,30 @@ export const governanceReducer = (state: FractalGovernance, action: FractalGover
return {
...state,
proposals: [
// dev: what this does, is takes an array of proposals. the payload should, in practice,
// never be Snapshot Proposals objects.
// Then we create a new proposals list, by appending the payload to what already exists,
// while at the same time filtering out all of the non-Snapshot proposals from
// the existing list. This "refrehes" the non Snapshot propsoals while leaving Snapshot
// proposals alone.
...action.payload,
...(proposals || []).filter(
proposal => !!(proposal as SnapshotProposal).snapshotProposalId,
),
],
pendingProposals: createPendingProposals(
state.pendingProposals,
action.payload.map(p => p.transactionHash),
),
};
}
case FractalGovernanceAction.SET_AZORIUS_PROPOSAL: {
return {
...state,
proposals: [...(proposals || []), action.payload],
pendingProposals: createPendingProposals(state.pendingProposals, [
action.payload.transactionHash,
]),
};
}
case FractalGovernanceAction.SET_PROPOSAL_TEMPLATES: {
Expand All @@ -66,7 +91,16 @@ export const governanceReducer = (state: FractalGovernance, action: FractalGover
};
}
case FractalGovernanceAction.SET_SNAPSHOT_PROPOSALS:
return { ...state, proposals: [...(proposals || []), ...action.payload] };
// TODO: what happens if this is called after some azorius or multisig proposals have already loaded in?
// I would expect that we lose them. Worth some investigation into when this action is called.
return {
...state,
proposals: [...(proposals || []), ...action.payload],
pendingProposals: createPendingProposals(
state.pendingProposals,
action.payload.map(p => p.transactionHash),
),
};
case FractalGovernanceAction.UPDATE_PROPOSALS_NEW:
return {
...state,
Expand All @@ -77,6 +111,9 @@ export const governanceReducer = (state: FractalGovernance, action: FractalGover
(proposal, index, array) =>
index === array.findIndex(p => p.proposalId === proposal.proposalId),
),
pendingProposals: createPendingProposals(state.pendingProposals, [
action.payload.transactionHash,
]),
};
case FractalGovernanceAction.UPDATE_NEW_AZORIUS_ERC20_VOTE: {
const { proposalId, voter, support, votesSummary, weight } = action.payload;
Expand Down Expand Up @@ -172,6 +209,9 @@ export const governanceReducer = (state: FractalGovernance, action: FractalGover
const { votesToken } = state as AzoriusGovernance;
return { ...state, votesToken: { ...votesToken, ...initialVotesTokenAccountData } };
}
case FractalGovernanceAction.PENDING_PROPOSAL_ADD: {
return { ...state, pendingProposals: [action.payload, ...(state.pendingProposals || [])] };
}
// Decent Governance only
case DecentGovernanceAction.SET_LOCKED_TOKEN_ACCOUNT_DATA: {
const { lockedVotesToken } = state as DecentGovernance;
Expand Down
3 changes: 2 additions & 1 deletion src/types/fractal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ export interface ActivityBase {
eventDate: Date;
eventType: ActivityEventType;
transaction?: ActivityTransactionType;
transactionHash?: string | null;
transactionHash: string;
}

export type Activity = TreasuryActivity | MultisigProposal | AzoriusProposal | SnapshotProposal;
Expand Down Expand Up @@ -306,6 +306,7 @@ export interface Governance {
loadingProposals: boolean;
allProposalsLoaded: boolean;
proposals: FractalProposal[] | null;
pendingProposals: string[] | null;
proposalTemplates?: ProposalTemplate[] | null;
tokenClaimContract?: ERC20Claim;
}
Expand Down
15 changes: 13 additions & 2 deletions src/utils/azorius.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import {
LinearERC20Voting,
LinearERC721Voting,
} from '@fractal-framework/fractal-contracts';
import { ProposalExecutedEvent } from '@fractal-framework/fractal-contracts/dist/typechain-types/contracts/azorius/Azorius';
import {
ProposalCreatedEvent,
ProposalExecutedEvent,
} from '@fractal-framework/fractal-contracts/dist/typechain-types/contracts/azorius/Azorius';
import { VotedEvent as ERC20VotedEvent } from '@fractal-framework/fractal-contracts/dist/typechain-types/contracts/azorius/LinearERC20Voting';
import { VotedEvent as ERC721VotedEvent } from '@fractal-framework/fractal-contracts/dist/typechain-types/contracts/azorius/LinearERC721Voting';
import { SafeMultisigTransactionWithTransfersResponse } from '@safe-global/safe-service-client';
Expand Down Expand Up @@ -149,6 +152,7 @@ const getProposalVotes = (
};

export const mapProposalCreatedEventToProposal = async (
createdEvent: ProposalCreatedEvent,
erc20StrategyContract: LinearERC20Voting | undefined,
erc721StrategyContract: LinearERC721Voting | undefined,
strategyType: VotingStrategyType,
Expand Down Expand Up @@ -217,7 +221,14 @@ export const mapProposalCreatedEventToProposal = async (
const executedEvent = (await executedEvents)?.find(
event => BigInt(event.args[0]) === proposalId,
);
transactionHash = executedEvent?.transactionHash;

if (!executedEvent) {
throw new Error('Proposal state is EXECUTED, but no event found');
}

transactionHash = executedEvent.transactionHash;
} else {
transactionHash = createdEvent.transactionHash;
}
Comment on lines +230 to 232
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And then I guess I do have an outstanding question at the moment... what is going on here with finding a new use for this transactionHash property with a brand new createdEvent object?


const proposal: AzoriusProposal = {
Expand Down