diff --git a/src/components/ProposalBuilder/ProposalActionCard.tsx b/src/components/ProposalBuilder/ProposalActionCard.tsx index 09057e1d36..137d9b42c8 100644 --- a/src/components/ProposalBuilder/ProposalActionCard.tsx +++ b/src/components/ProposalBuilder/ProposalActionCard.tsx @@ -3,25 +3,80 @@ import { ArrowsDownUp, CheckSquare, Trash } from '@phosphor-icons/react'; import { useTranslation } from 'react-i18next'; import { formatUnits, getAddress, zeroAddress } from 'viem'; import PencilWithLineIcon from '../../assets/theme/custom/icons/PencilWithLineIcon'; -import { useGetAccountName } from '../../hooks/utils/useGetAccountName'; import { useFractal } from '../../providers/App/AppProvider'; import { useProposalActionsStore } from '../../store/actions/useProposalActionsStore'; import { CreateProposalAction, ProposalActionType } from '../../types/proposalBuilder'; import { Card } from '../ui/cards/Card'; -import { SendAssetsData } from '../ui/modals/SendAssetsModal'; +import { SendAssetsActionCard } from '../ui/cards/SendAssetsActionCard'; export function SendAssetsAction({ - index, action, onRemove, }: { - index: number; - action: SendAssetsData; - onRemove: (index: number) => void; + action: CreateProposalAction; + onRemove: () => void; +}) { + const { + treasury: { assetsFungible }, + } = useFractal(); + const destinationAddress = action.transactions[0].parameters[0].value + ? getAddress(action.transactions[0].parameters[0].value) + : zeroAddress; + const transferAmount = BigInt(action.transactions[0].parameters[1].value || '0'); + + if (!destinationAddress || !transferAmount) { + return null; + } + + // @todo: This does not work for native asset + const actionAsset = assetsFungible.find( + asset => getAddress(asset.tokenAddress) === getAddress(action.transactions[0].targetAddress), + ); + + if (!actionAsset) { + return null; + } + + return ( + + ); +} + +export function AirdropAction({ + action, + onRemove, +}: { + action: CreateProposalAction; + onRemove: () => void; }) { const { t } = useTranslation('common'); - const { displayName } = useGetAccountName(action.destinationAddress); + const { + treasury: { assetsFungible }, + } = useFractal(); + const totalAmountString = action.transactions[1].parameters[2].value?.slice(1, -1); + const totalAmount = BigInt( + totalAmountString?.split(',').reduce((acc, curr) => acc + BigInt(curr), 0n) || '0', + ); + const recipientsCount = action.transactions[1].parameters[1].value?.split(',').length || 0; + // First transaction in the airdrop proposal will be approval transaction, which is called on the token + // Thus we can find the asset by looking at the target address of the first transaction + + const actionAsset = assetsFungible.find( + asset => getAddress(asset.tokenAddress) === getAddress(action.transactions[0].targetAddress), + ); + + if (!actionAsset) { + return null; + } return ( @@ -35,20 +90,20 @@ export function SendAssetsAction({ h="1.5rem" color="lilac-0" /> - {t('transfer')} + {t('airdrop')} - {formatUnits(action.transferAmount, action.asset.decimals)} {action.asset.symbol} + {formatUnits(totalAmount, actionAsset.decimals)} {actionAsset.symbol} {t('to').toLowerCase()} - {displayName} + + {recipientsCount} {t('recipients')} + @@ -67,36 +122,18 @@ export function ProposalActionCard({ canBeDeleted: boolean; }) { const { removeAction } = useProposalActionsStore(); - const { - treasury: { assetsFungible }, - } = useFractal(); if (action.actionType === ProposalActionType.TRANSFER) { - const destinationAddress = action.transactions[0].parameters[0].value - ? getAddress(action.transactions[0].parameters[0].value) - : zeroAddress; - const transferAmount = BigInt(action.transactions[0].parameters[1].value || '0'); - if (!destinationAddress || !transferAmount) { - return null; - } - - const actionAsset = assetsFungible.find( - asset => getAddress(asset.tokenAddress) === destinationAddress, - ); - - if (!actionAsset) { - return null; - } return ( removeAction(index)} + /> + ); + } else if (action.actionType === ProposalActionType.AIRDROP) { + return ( + removeAction(index)} /> ); } diff --git a/src/components/ProposalBuilder/ProposalBuilder.tsx b/src/components/ProposalBuilder/ProposalBuilder.tsx new file mode 100644 index 0000000000..20f85b6030 --- /dev/null +++ b/src/components/ProposalBuilder/ProposalBuilder.tsx @@ -0,0 +1,269 @@ +import { Box, Flex, Grid, GridItem } from '@chakra-ui/react'; +import { ArrowLeft } from '@phosphor-icons/react'; +import { Formik, FormikProps } from 'formik'; +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; +import { toast } from 'sonner'; +import { DAO_ROUTES } from '../../constants/routes'; +import { logError } from '../../helpers/errorLogging'; +import useSubmitProposal from '../../hooks/DAO/proposal/useSubmitProposal'; +import useCreateProposalSchema from '../../hooks/schemas/proposalBuilder/useCreateProposalSchema'; +import { useCanUserCreateProposal } from '../../hooks/utils/useCanUserSubmitProposal'; +import { useFractal } from '../../providers/App/AppProvider'; +import { useNetworkConfigStore } from '../../providers/NetworkConfig/useNetworkConfigStore'; +import { useDaoInfoStore } from '../../store/daoInfo/useDaoInfoStore'; +import { BigIntValuePair, CreateProposalSteps, ProposalExecuteData } from '../../types'; +import { + CreateProposalForm, + CreateProposalTransaction, + CreateSablierProposalForm, + Stream, +} from '../../types/proposalBuilder'; +import { CustomNonceInput } from '../ui/forms/CustomNonceInput'; +import { Crumb } from '../ui/page/Header/Breadcrumbs'; +import PageHeader from '../ui/page/Header/PageHeader'; +import ProposalDetails from './ProposalDetails'; +import ProposalMetadata, { ProposalMetadataTypeProps } from './ProposalMetadata'; + +export function ShowNonceInputOnMultisig({ + nonce, + nonceOnChange, +}: { + nonce: number | undefined; + nonceOnChange: (nonce?: string) => void; +}) { + const { + governance: { isAzorius }, + } = useFractal(); + + if (isAzorius) { + return null; + } + + return ( + + + + ); +} + +interface ProposalBuilderProps { + pageHeaderTitle: string; + pageHeaderBreadcrumbs: Crumb[]; + pageHeaderButtonClickHandler: () => void; + proposalMetadataTypeProps: ProposalMetadataTypeProps; + actionsExperience: React.ReactNode | null; + stepButtons: ({ + formErrors, + createProposalBlocked, + }: { + formErrors: boolean; + createProposalBlocked: boolean; + }) => React.ReactNode; + transactionsDetails: + | ((transactions: CreateProposalTransaction[]) => React.ReactNode) + | null; + templateDetails: ((title: string) => React.ReactNode) | null; + streamsDetails: ((streams: Stream[]) => React.ReactNode) | null; + prepareProposalData: (values: CreateProposalForm) => Promise; + initialValues: CreateProposalForm; + contentRoute: ( + formikProps: FormikProps, + pendingCreateTx: boolean, + nonce: number | undefined, + ) => React.ReactNode; +} + +export function ProposalBuilder({ + pageHeaderTitle, + pageHeaderBreadcrumbs, + pageHeaderButtonClickHandler, + proposalMetadataTypeProps, + actionsExperience, + stepButtons, + transactionsDetails, + templateDetails, + streamsDetails, + initialValues, + prepareProposalData, + contentRoute, +}: ProposalBuilderProps) { + const navigate = useNavigate(); + const location = useLocation(); + const { t } = useTranslation(['proposalTemplate', 'proposal']); + + const paths = location.pathname.split('/'); + const step = (paths[paths.length - 1] || paths[paths.length - 2]) as + | CreateProposalSteps + | undefined; + const { safe } = useDaoInfoStore(); + const safeAddress = safe?.address; + + const { addressPrefix } = useNetworkConfigStore(); + const { submitProposal, pendingCreateTx } = useSubmitProposal(); + const { canUserCreateProposal } = useCanUserCreateProposal(); + const { createProposalValidation } = useCreateProposalSchema(); + + const successCallback = () => { + if (safeAddress) { + // Redirecting to home page so that user will see newly created Proposal + navigate(DAO_ROUTES.dao.relative(addressPrefix, safeAddress)); + } + }; + + useEffect(() => { + if (safeAddress && (!step || !Object.values(CreateProposalSteps).includes(step))) { + navigate(DAO_ROUTES.proposalNew.relative(addressPrefix, safeAddress), { replace: true }); + } + }, [safeAddress, step, navigate, addressPrefix]); + + return ( + + validationSchema={createProposalValidation} + initialValues={initialValues} + enableReinitialize + onSubmit={async values => { + if (!canUserCreateProposal) { + toast.error(t('errorNotProposer', { ns: 'common' })); + } + + try { + const proposalData = await prepareProposalData(values); + if (proposalData) { + submitProposal({ + proposalData, + nonce: values?.nonce, + pendingToastMessage: t('proposalCreatePendingToastMessage', { ns: 'proposal' }), + successToastMessage: t('proposalCreateSuccessToastMessage', { ns: 'proposal' }), + failedToastMessage: t('proposalCreateFailureToastMessage', { ns: 'proposal' }), + successCallback, + }); + } + } catch (e) { + logError(e); + toast.error(t('encodingFailedMessage', { ns: 'proposal' })); + } + }} + > + {(formikProps: FormikProps) => { + const { + handleSubmit, + values: { + proposalMetadata: { title, description }, + transactions, + nonce, + }, + } = formikProps; + + if (!safeAddress) { + return; + } + + const trimmedTitle = title.trim(); + + const createProposalButtonDisabled = + !canUserCreateProposal || + !!formikProps.errors.transactions || + !!formikProps.errors.nonce || + pendingCreateTx; + + return ( +
+ + + + + + + + + } + /> + {contentRoute(formikProps, pendingCreateTx, nonce)} + + } + /> + + + {actionsExperience} + {stepButtons({ + formErrors: !!formikProps.errors.proposalMetadata, + createProposalBlocked: createProposalButtonDisabled, + })} + + + + + + + +
+ ); + }} + + ); +} diff --git a/src/components/ProposalBuilder/ProposalDetails.tsx b/src/components/ProposalBuilder/ProposalDetails.tsx index 2efe17163e..76a325e671 100644 --- a/src/components/ProposalBuilder/ProposalDetails.tsx +++ b/src/components/ProposalBuilder/ProposalDetails.tsx @@ -1,14 +1,14 @@ import { Avatar, Box, HStack, Text, VStack } from '@chakra-ui/react'; -import { FormikProps } from 'formik'; import { Fragment, PropsWithChildren, useState } from 'react'; import { useTranslation } from 'react-i18next'; import '../../assets/css/Markdown.css'; -import { CreateProposalForm, ProposalBuilderMode } from '../../types/proposalBuilder'; +import { BigIntValuePair } from '../../types/common'; +import { CreateProposalTransaction, Stream } from '../../types/proposalBuilder'; import Markdown from '../ui/proposal/Markdown'; import CeleryButtonWithIcon from '../ui/utils/CeleryButtonWithIcon'; import Divider from '../ui/utils/Divider'; -export function TransactionValueContainer({ children }: PropsWithChildren<{}>) { +function TransactionValueContainer({ children }: PropsWithChildren<{}>) { return ( ) { ); } -export default function ProposalTemplateDetails({ - values: { proposalMetadata, transactions }, - mode, -}: FormikProps & { mode: ProposalBuilderMode }) { +export function TransactionsDetails({ + transactions, +}: { + transactions: CreateProposalTransaction[]; +}) { + const { t } = useTranslation(['proposalTemplate', 'proposal']); + + return ( + <> + {transactions.map((transaction, i) => { + const valueBiggerThanZero = transaction.ethValue.bigintValue + ? transaction.ethValue.bigintValue > 0n + : false; + return ( + + {t('labelTargetAddress', { ns: 'proposal' })} + {transaction.targetAddress && ( + {transaction.targetAddress} + )} + + {t('labelFunctionName', { ns: 'proposal' })} + {transaction.functionName && ( + {transaction.functionName} + )} + {transaction.parameters.map((parameter, parameterIndex) => ( + + {t('parameter')} + {parameter.signature && ( + {parameter.signature} + )} + {!!parameter.label ? parameter.label : t('value')} + {(parameter.label || parameter.value) && ( + + {parameter.value || t('userInput')} + + )} + + ))} + + + {t('eth')} + + {valueBiggerThanZero ? transaction.ethValue.value : 'n/a'} + + + + ); + })} + + ); +} + +export function StreamsDetails({ streams }: { streams: Stream[] }) { + const { t } = useTranslation(['proposalTemplate', 'proposal']); + + return ( + <> + {streams.map((stream, idx) => ( + + {t('labelRecipientAddress', { ns: 'proposal' })} + {stream.recipientAddress && ( + {stream.recipientAddress} + )} + + {t('labelTotalAmount', { ns: 'proposal' })} + {stream.totalAmount && ( + {stream.totalAmount.value} + )} + + {t('labelTranches', { ns: 'proposal' })} + {stream.tranches.map((tranche, trancheIdx) => ( + + {t('labelTrancheAmount', { ns: 'proposal' })} + {tranche.amount.value} + {t('labelTrancheDuration', { ns: 'proposal' })} + {tranche.duration.value} + + ))} + + ))} + + ); +} + +export function TemplateDetails({ title }: { title: string }) { + const { t } = useTranslation(['proposalTemplate', 'proposal']); + + return ( + + {t('previewThumnbail')} + fullTitle.slice(0, 2)} + /> + + ); +} + +export default function ProposalDetails({ + title, + description, + transactionsDetails, + templateDetails, + streamsDetails, +}: { + title: string; + description: string; + transactionsDetails: React.ReactNode; + templateDetails: React.ReactNode; + streamsDetails: React.ReactNode; +}) { const { t } = useTranslation(['proposalTemplate', 'proposal']); - const trimmedTitle = proposalMetadata.title?.trim(); const [descriptionCollapsed, setDescriptionCollapsed] = useState(true); return ( @@ -48,83 +162,26 @@ export default function ProposalTemplateDetails({ textAlign="right" width="66%" > - {trimmedTitle} + {title} - {mode === ProposalBuilderMode.TEMPLATE && ( - - {t('previewThumnbail')} - {trimmedTitle && ( - title.slice(0, 2)} - /> - )} - - )} + {templateDetails} {t('proposalTemplateDescription')} - {proposalMetadata.description && ( - setDescriptionCollapsed(prevState => !prevState)} - text={t(descriptionCollapsed ? 'show' : 'hide', { ns: 'common' })} - /> - )} + setDescriptionCollapsed(prevState => !prevState)} + text={t(descriptionCollapsed ? 'show' : 'hide', { ns: 'common' })} + /> {!descriptionCollapsed && ( )} - {transactions.map((transaction, i) => { - const valueBiggerThanZero = transaction.ethValue.bigintValue - ? transaction.ethValue.bigintValue > 0n - : false; - return ( - - {t('labelTargetAddress', { ns: 'proposal' })} - {transaction.targetAddress && ( - {transaction.targetAddress} - )} - - {t('labelFunctionName', { ns: 'proposal' })} - {transaction.functionName && ( - {transaction.functionName} - )} - {transaction.parameters.map((parameter, parameterIndex) => ( - - {t('parameter')} - {parameter.signature && ( - {parameter.signature} - )} - {!!parameter.label ? parameter.label : t('value')} - {(parameter.label || parameter.value) && ( - - {parameter.value || t('userInput')} - - )} - - ))} - - - {t('eth')} - - {valueBiggerThanZero ? transaction.ethValue.value : 'n/a'} - - - - ); - })} + {transactionsDetails} + {streamsDetails} ); diff --git a/src/components/ProposalBuilder/ProposalMetadata.tsx b/src/components/ProposalBuilder/ProposalMetadata.tsx index e763a2abcb..6c0a621458 100644 --- a/src/components/ProposalBuilder/ProposalMetadata.tsx +++ b/src/components/ProposalBuilder/ProposalMetadata.tsx @@ -1,21 +1,45 @@ import { VStack } from '@chakra-ui/react'; import { FormikProps } from 'formik'; +import { TFunction } from 'i18next'; import { useTranslation } from 'react-i18next'; -import { CreateProposalForm, ProposalBuilderMode } from '../../types/proposalBuilder'; +import { CreateProposalForm } from '../../types/proposalBuilder'; import { InputComponent, TextareaComponent } from '../ui/forms/InputComponent'; +export interface ProposalMetadataTypeProps { + titleLabel: string; + titleHelper: string; + descriptionLabel: string; + descriptionHelper: string; +} + +export const DEFAULT_PROPOSAL_METADATA_TYPE_PROPS = ( + t: TFunction<[string, string, string], undefined>, +): ProposalMetadataTypeProps => ({ + titleLabel: t('proposalTitle', { ns: 'proposal' }), + titleHelper: t('proposalTitleHelper', { ns: 'proposal' }), + descriptionLabel: t('proposalDescription', { ns: 'proposal' }), + descriptionHelper: t('proposalDescriptionHelper', { ns: 'proposal' }), +}); + +export const TEMPLATE_PROPOSAL_METADATA_TYPE_PROPS = ( + t: TFunction<[string, string, string], undefined>, +): ProposalMetadataTypeProps => ({ + titleLabel: t('proposalTemplateTitle', { ns: 'proposalTemplate' }), + titleHelper: t('proposalTemplateTitleHelperText', { ns: 'proposalTemplate' }), + descriptionLabel: t('proposalTemplateDescription', { ns: 'proposalTemplate' }), + descriptionHelper: t('proposalTemplateDescriptionHelperText', { ns: 'proposalTemplate' }), +}); + export interface ProposalMetadataProps extends FormikProps { - mode: ProposalBuilderMode; + typeProps: ProposalMetadataTypeProps; } export default function ProposalMetadata({ values: { proposalMetadata }, setFieldValue, - mode, + typeProps, }: ProposalMetadataProps) { - const { t } = useTranslation(['proposalTemplate', 'proposal', 'common']); - const isProposalMode = - mode === ProposalBuilderMode.PROPOSAL || mode === ProposalBuilderMode.PROPOSAL_WITH_ACTIONS; + const { t } = useTranslation(['proposal']); return ( ) => void; + index: number; + pendingTransaction: boolean; +}) { + const publicClient = useNetworkPublicClient(); + const [tokenDecimals, setTokenDecimals] = useState(0); + const [rawTokenBalance, setRawTokenBalnace] = useState(0n); + const [tokenBalanceFormatted, setTokenBalanceFormatted] = useState(''); + const [expandedIndecies, setExpandedIndecies] = useState([0]); + const { safe } = useDaoInfoStore(); + const { t } = useTranslation(['proposal', 'common']); + + const safeAddress = safe?.address; + + useEffect(() => { + const fetchFormattedTokenBalance = async () => { + if (safeAddress && stream.tokenAddress && isAddress(stream.tokenAddress)) { + const tokenContract = getContract({ + abi: erc20Abi, + client: publicClient, + address: stream.tokenAddress, + }); + const [tokenBalance, decimals, symbol, name] = await Promise.all([ + tokenContract.read.balanceOf([safeAddress]), + tokenContract.read.decimals(), + tokenContract.read.symbol(), + tokenContract.read.name(), + ]); + setTokenDecimals(decimals); + setRawTokenBalnace(tokenBalance); + if (tokenBalance > 0n) { + const balanceFormatted = formatUnits(tokenBalance, decimals); + setTokenBalanceFormatted(`${balanceFormatted} ${symbol} (${name})`); + } + } + }; + + fetchFormattedTokenBalance(); + }, [safeAddress, publicClient, stream.tokenAddress]); + return ( + + + + {t('example', { ns: 'common' })}: + 0x4168592... + + } + isInvalid={!!stream.tokenAddress && !isAddress(stream.tokenAddress)} + value={stream.tokenAddress} + testId="stream.tokenAddress" + onChange={e => handleUpdateStream(index, { tokenAddress: e.target.value })} + /> + + + {t('example', { ns: 'common' })}: + 0x4168592... + + } + isInvalid={!!stream.recipientAddress && !isAddress(stream.recipientAddress)} + value={stream.recipientAddress} + testId="stream.recipientAddress" + onChange={e => handleUpdateStream(index, { recipientAddress: e.target.value })} + /> + + + {t('example', { ns: 'common' })}: + 10000 + + } + isRequired + > + handleUpdateStream(index, { totalAmount: value })} + decimalPlaces={tokenDecimals} + maxValue={rawTokenBalance} + /> + + + + + handleUpdateStream(index, { cancelable: !stream.cancelable })} + /> + {t('cancelable')} + + {t('streamCancelableHelper')} + + + + handleUpdateStream(index, { transferable: !stream.transferable })} + /> + {t('transferable')} + + {t('streamTransferableHelper')} + + + + + {stream.tranches.map((tranche, trancheIndex) => ( + + {({ isExpanded }) => ( + <> + + {/* STREAM TRANCHE HEADER */} + + { + setExpandedIndecies(indexArray => { + if (indexArray.includes(trancheIndex)) { + const newTxArr = [...indexArray]; + newTxArr.splice(newTxArr.indexOf(trancheIndex), 1); + return newTxArr; + } else { + return [...indexArray, trancheIndex]; + } + }); + }} + p={0} + textStyle="heading-small" + color="lilac-0" + > + + + {isExpanded ? : } + {t('tranche', { index: trancheIndex + 1 })} + + + + + {/* Remove tranche button */} + {trancheIndex !== 0 || stream.tranches.length !== 1 ? ( + } + aria-label={t('removeTranche')} + variant="unstyled" + onClick={() => + handleUpdateStream(index, { + tranches: stream.tranches.filter( + (_, removedTrancheIndex) => removedTrancheIndex !== trancheIndex, + ), + }) + } + minWidth="auto" + color="lilac-0" + _disabled={{ opacity: 0.4, cursor: 'default' }} + sx={{ '&:disabled:hover': { color: 'inherit', opacity: 0.4 } }} + isDisabled={pendingTransaction} + /> + ) : ( + + )} + + + {/* STREAM TRANCHE SECTION */} + + + + + + + {t('example', { ns: 'common' })}:{' '} + 1000 + + + } + > + + handleUpdateStream(index, { + tranches: stream.tranches.map((item, updatedTrancheIndex) => + updatedTrancheIndex === trancheIndex + ? { ...item, amount: value } + : item, + ), + }) + } + /> + + + + + + {t('trancheDurationHelper')} + {index === 0 && '. ' + t('trancheDurationHelperFirstTranche')} + + + {t('example', { ns: 'common' })}:{' '} + + {SECONDS_IN_DAY * 30} (1 month) + + + + } + > + + handleUpdateStream(index, { + tranches: stream.tranches.map((item, updatedTrancheIndex) => + updatedTrancheIndex === trancheIndex + ? { ...item, duration: value } + : item, + ), + }) + } + /> + + + + + + + + + {!isExpanded && ( + + )} + + {/* ADD TRANCHE BUTTON */} + {trancheIndex === stream.tranches.length - 1 && ( + { + handleUpdateStream(index, { + tranches: [...stream.tranches, DEFAULT_TRANCHE], + }); + setExpandedIndecies([stream.tranches.length]); + scrollToBottom(100, 'smooth'); + }} + icon={Plus} + text={t('addTranche')} + /> + )} + + )} + + ))} + + + + + ); +} diff --git a/src/components/ProposalBuilder/ProposalStreams.tsx b/src/components/ProposalBuilder/ProposalStreams.tsx new file mode 100644 index 0000000000..790584759f --- /dev/null +++ b/src/components/ProposalBuilder/ProposalStreams.tsx @@ -0,0 +1,135 @@ +import { + Accordion, + AccordionButton, + AccordionItem, + Alert, + Box, + Divider, + Flex, + IconButton, + Text, +} from '@chakra-ui/react'; +import { CaretDown, CaretRight, MinusCircle, Plus, WarningCircle } from '@phosphor-icons/react'; +import { useTranslation } from 'react-i18next'; +import { Stream } from '../../types/proposalBuilder'; +import { scrollToBottom } from '../../utils/ui'; +import CeleryButtonWithIcon from '../ui/utils/CeleryButtonWithIcon'; +import { ProposalStream } from './ProposalStream'; +import { DEFAULT_STREAM } from './constants'; + +export function ProposalStreams({ + pendingTransaction, + values: { streams }, + setFieldValue, +}: { + pendingTransaction: boolean; + values: { streams: Stream[] }; + setFieldValue: (field: string, value: any) => void; +}) { + const { t } = useTranslation(['proposal', 'proposalTemplate', 'common']); + const handleRemoveStream = (streamIndex: number) => { + setFieldValue( + 'streams', + streams.filter((_, index) => index !== streamIndex), + ); + }; + const handleUpdateStream = (streamIndex: number, values: Partial) => { + setFieldValue( + 'streams', + streams.map((item, index) => (streamIndex === index ? { ...item, ...values } : item)), + ); + }; + return ( + + + {streams.map((stream, index) => ( + + {({ isExpanded }) => ( + + + + {isExpanded ? : } + + {t('streamTitle', { index: index + 1, type: t(`${stream.type}Stream`) })} + + + {index !== 0 || + (streams.length !== 1 && ( + } + aria-label="Remove stream" + variant="unstyled" + onClick={() => handleRemoveStream(index)} + minWidth="auto" + color="lilac-0" + _disabled={{ opacity: 0.4, cursor: 'default' }} + sx={{ '&:disabled:hover': { color: 'inherit', opacity: 0.4 } }} + isDisabled={pendingTransaction} + /> + ))} + + + + + + + + + {t('streamStartNotice')} + + + + + )} + + ))} + + + + { + setFieldValue('streams', [...streams, DEFAULT_STREAM]); + scrollToBottom(100, 'smooth'); + }} + isDisabled={pendingTransaction} + icon={Plus} + text={t('addStream')} + /> + + + ); +} diff --git a/src/components/ProposalBuilder/ProposalTransaction.tsx b/src/components/ProposalBuilder/ProposalTransaction.tsx index 0799b6ddf4..d53e2a0d34 100644 --- a/src/components/ProposalBuilder/ProposalTransaction.tsx +++ b/src/components/ProposalBuilder/ProposalTransaction.tsx @@ -1,20 +1,20 @@ import { - VStack, - HStack, - Text, + Accordion, + AccordionButton, + AccordionItem, + AccordionPanel, Box, Flex, + HStack, IconButton, - Accordion, - AccordionPanel, - AccordionItem, - AccordionButton, Radio, + Text, + VStack, } from '@chakra-ui/react'; -import { Plus, MinusCircle, CaretDown, CaretRight } from '@phosphor-icons/react'; +import { CaretDown, CaretRight, MinusCircle, Plus } from '@phosphor-icons/react'; import { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { CreateProposalTransaction, ProposalBuilderMode } from '../../types/proposalBuilder'; +import { CreateProposalTransaction } from '../../types/proposalBuilder'; import { scrollToBottom } from '../../utils/ui'; import ABISelector, { ABIElement } from '../ui/forms/ABISelector'; import ExampleLabel from '../ui/forms/ExampleLabel'; @@ -30,7 +30,7 @@ interface ProposalTransactionProps { txAddressError?: string; txFunctionError?: string; setFieldValue: (field: string, value: any, shouldValidate?: boolean | undefined) => void; - mode: ProposalBuilderMode; + isProposalMode: boolean; } export default function ProposalTransaction({ @@ -40,9 +40,8 @@ export default function ProposalTransaction({ txAddressError, txFunctionError, setFieldValue, - mode, + isProposalMode, }: ProposalTransactionProps) { - const isProposalMode = mode === ProposalBuilderMode.PROPOSAL; const { t } = useTranslation(['proposal', 'proposalTemplate', 'common']); const [expandedIndecies, setExpandedIndecies] = useState([0]); @@ -193,7 +192,7 @@ export default function ProposalTransaction({ setFieldValue( `transactions.${transactionIndex}.parameters`, transaction.parameters.filter( - (parameterToRemove, parameterToRemoveIndex) => + (_parameterToRemove, parameterToRemoveIndex) => parameterToRemoveIndex !== i, ), ) diff --git a/src/components/ProposalBuilder/ProposalTransactions.tsx b/src/components/ProposalBuilder/ProposalTransactions.tsx index a7e29f9f86..336fef1314 100644 --- a/src/components/ProposalBuilder/ProposalTransactions.tsx +++ b/src/components/ProposalBuilder/ProposalTransactions.tsx @@ -14,18 +14,14 @@ import { FormikErrors, FormikProps } from 'formik'; import { Dispatch, SetStateAction } from 'react'; import { useTranslation } from 'react-i18next'; import { BigIntValuePair } from '../../types'; -import { - CreateProposalForm, - CreateProposalTransaction, - ProposalBuilderMode, -} from '../../types/proposalBuilder'; +import { CreateProposalForm, CreateProposalTransaction } from '../../types/proposalBuilder'; import ProposalTransaction from './ProposalTransaction'; interface ProposalTransactionsProps extends FormikProps { pendingTransaction: boolean; expandedIndecies: number[]; setExpandedIndecies: Dispatch>; - mode: ProposalBuilderMode; + isProposalMode: boolean; } export default function ProposalTransactions({ values: { transactions }, @@ -34,7 +30,7 @@ export default function ProposalTransactions({ pendingTransaction, expandedIndecies, setExpandedIndecies, - mode, + isProposalMode, }: ProposalTransactionsProps) { const { t } = useTranslation(['proposal', 'proposalTemplate', 'common']); @@ -120,7 +116,7 @@ export default function ProposalTransactions({ transactionIndex={index} setFieldValue={setFieldValue} transactionPending={pendingTransaction} - mode={mode} + isProposalMode={isProposalMode} /> diff --git a/src/components/ProposalBuilder/ProposalTransactionsForm.tsx b/src/components/ProposalBuilder/ProposalTransactionsForm.tsx index a68f9c3ddf..f442beb8a4 100644 --- a/src/components/ProposalBuilder/ProposalTransactionsForm.tsx +++ b/src/components/ProposalBuilder/ProposalTransactionsForm.tsx @@ -3,7 +3,7 @@ import { Plus } from '@phosphor-icons/react'; import { FormikProps } from 'formik'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { CreateProposalForm, ProposalBuilderMode } from '../../types/proposalBuilder'; +import { CreateProposalForm } from '../../types/proposalBuilder'; import { scrollToBottom } from '../../utils/ui'; import CeleryButtonWithIcon from '../ui/utils/CeleryButtonWithIcon'; import Divider from '../ui/utils/Divider'; @@ -13,7 +13,7 @@ import { DEFAULT_PROPOSAL_TRANSACTION } from './constants'; interface ProposalTransactionsFormProps extends FormikProps { pendingTransaction: boolean; safeNonce?: number; - mode: ProposalBuilderMode; + isProposalMode: boolean; } export default function ProposalTransactionsForm(props: ProposalTransactionsFormProps) { diff --git a/src/components/ProposalBuilder/StepButtons.tsx b/src/components/ProposalBuilder/StepButtons.tsx index 274cbae5dc..f0e24bde03 100644 --- a/src/components/ProposalBuilder/StepButtons.tsx +++ b/src/components/ProposalBuilder/StepButtons.tsx @@ -1,44 +1,81 @@ import { Button, Flex, Icon } from '@chakra-ui/react'; import { CaretLeft, CaretRight } from '@phosphor-icons/react'; -import { FormikProps } from 'formik'; import { useTranslation } from 'react-i18next'; -import { Route, Routes, useLocation, useNavigate } from 'react-router-dom'; -import { useCanUserCreateProposal } from '../../hooks/utils/useCanUserSubmitProposal'; +import { Route, Routes, useNavigate } from 'react-router-dom'; import { useDaoInfoStore } from '../../store/daoInfo/useDaoInfoStore'; import { CreateProposalSteps } from '../../types'; -import { CreateProposalForm, ProposalBuilderMode } from '../../types/proposalBuilder'; -interface StepButtonsProps extends FormikProps { - pendingTransaction: boolean; - safeNonce?: number; - mode: ProposalBuilderMode; +interface StepButtonsProps { + metadataStepButtons: React.ReactNode; + transactionsStepButtons: React.ReactNode; } -export default function StepButtons(props: StepButtonsProps) { +export function PreviousButton({ prevStepUrl }: { prevStepUrl: string }) { const navigate = useNavigate(); - const location = useLocation(); - const { safe } = useDaoInfoStore(); - const { - mode, - pendingTransaction, - errors: { - transactions: transactionsError, - nonce: nonceError, - proposalMetadata: proposalMetadataError, - }, - values: { proposalMetadata }, - } = props; const { t } = useTranslation(['common', 'proposal']); - const { canUserCreateProposal } = useCanUserCreateProposal(); + + return ( + + ); +} + +export function NextButton({ + nextStepUrl, + isDisabled, +}: { + nextStepUrl: string; + isDisabled: boolean; +}) { + const navigate = useNavigate(); + const { t } = useTranslation(['common', 'proposal']); + + return ( + + ); +} + +export function CreateProposalButton({ isDisabled }: { isDisabled: boolean }) { + const { t } = useTranslation(['common', 'proposal']); + + return ( + + ); +} + +export default function StepButtons(props: StepButtonsProps) { + const { safe } = useDaoInfoStore(); + const { metadataStepButtons, transactionsStepButtons } = props; if (!safe?.address) { return null; } - // @dev these prevStepUrl and nextStepUrl calculation is done this way to universally build URL for the next/prev steps both for proposal builder and proposal template builder - const prevStepUrl = `${location.pathname.replace(`${CreateProposalSteps.TRANSACTIONS}`, `${CreateProposalSteps.METADATA}`)}${location.search}`; - const nextStepUrl = `${location.pathname.replace(`${CreateProposalSteps.METADATA}`, `${CreateProposalSteps.TRANSACTIONS}`)}${location.search}`; - return ( - {t('createProposal', { ns: 'proposal' })} - - ) : ( - - ) - } + element={metadataStepButtons} /> - - - - } + element={transactionsStepButtons} /> diff --git a/src/components/ProposalBuilder/constants.ts b/src/components/ProposalBuilder/constants.ts index 644c703897..59f3b76f37 100644 --- a/src/components/ProposalBuilder/constants.ts +++ b/src/components/ProposalBuilder/constants.ts @@ -1,4 +1,4 @@ -import { CreateProposalTransaction } from '../../types/proposalBuilder'; +import { CreateProposalTransaction, Stream, Tranche } from '../../types/proposalBuilder'; export const DEFAULT_PROPOSAL_TRANSACTION: CreateProposalTransaction = { targetAddress: '', @@ -21,3 +21,40 @@ export const DEFAULT_PROPOSAL = { }, transactions: [DEFAULT_PROPOSAL_TRANSACTION], }; + +export const SECONDS_IN_DAY = 60 * 60 * 24; + +export const DEFAULT_TRANCHE: Tranche = { + amount: { + value: '0', + bigintValue: 0n, + }, + duration: { + value: (SECONDS_IN_DAY * 14).toString(), + bigintValue: BigInt(SECONDS_IN_DAY * 14), + }, +}; + +export const DEFAULT_STREAM: Stream = { + type: 'tranched', + tokenAddress: '', + recipientAddress: '', + startDate: new Date(), + tranches: [DEFAULT_TRANCHE], + totalAmount: { + value: '0', + bigintValue: 0n, + }, + cancelable: true, + transferable: false, +}; + +export const DEFAULT_SABLIER_PROPOSAL = { + nonce: undefined, + proposalMetadata: { + title: '', + description: '', + }, + streams: [DEFAULT_STREAM], + transactions: [], +}; diff --git a/src/components/ProposalBuilder/index.tsx b/src/components/ProposalBuilder/index.tsx deleted file mode 100644 index a5409d19e4..0000000000 --- a/src/components/ProposalBuilder/index.tsx +++ /dev/null @@ -1,313 +0,0 @@ -import { Box, Flex, Grid, GridItem, Text } from '@chakra-ui/react'; -import { ArrowLeft } from '@phosphor-icons/react'; -import { Formik, FormikProps } from 'formik'; -import { useEffect } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; -import { toast } from 'sonner'; -import { BASE_ROUTES, DAO_ROUTES } from '../../constants/routes'; -import { logError } from '../../helpers/errorLogging'; -import useSubmitProposal from '../../hooks/DAO/proposal/useSubmitProposal'; -import useCreateProposalSchema from '../../hooks/schemas/proposalBuilder/useCreateProposalSchema'; -import { useCanUserCreateProposal } from '../../hooks/utils/useCanUserSubmitProposal'; -import { useFractal } from '../../providers/App/AppProvider'; -import { useNetworkConfigStore } from '../../providers/NetworkConfig/useNetworkConfigStore'; -import { useProposalActionsStore } from '../../store/actions/useProposalActionsStore'; -import { useDaoInfoStore } from '../../store/daoInfo/useDaoInfoStore'; -import { CreateProposalSteps, ProposalExecuteData } from '../../types'; -import { - CreateProposalForm, - ProposalActionType, - ProposalBuilderMode, -} from '../../types/proposalBuilder'; -import { CustomNonceInput } from '../ui/forms/CustomNonceInput'; -import { AddActions } from '../ui/modals/AddActions'; -import { SendAssetsData } from '../ui/modals/SendAssetsModal'; -import PageHeader from '../ui/page/Header/PageHeader'; -import { ProposalActionCard } from './ProposalActionCard'; -import ProposalDetails from './ProposalDetails'; -import ProposalMetadata from './ProposalMetadata'; -import ProposalTransactionsForm from './ProposalTransactionsForm'; -import StepButtons from './StepButtons'; - -interface ProposalBuilderProps { - mode: ProposalBuilderMode; - prepareProposalData: (values: CreateProposalForm) => Promise; - initialValues: CreateProposalForm; -} - -export function ProposalBuilder({ - mode, - initialValues, - prepareProposalData, -}: ProposalBuilderProps) { - const navigate = useNavigate(); - const location = useLocation(); - const { t } = useTranslation(['proposalTemplate', 'proposal']); - - const paths = location.pathname.split('/'); - const step = (paths[paths.length - 1] || paths[paths.length - 2]) as - | CreateProposalSteps - | undefined; - const isProposalMode = - mode === ProposalBuilderMode.PROPOSAL || mode === ProposalBuilderMode.PROPOSAL_WITH_ACTIONS; - - const { - governance: { isAzorius }, - } = useFractal(); - const { safe } = useDaoInfoStore(); - const safeAddress = safe?.address; - - const { addressPrefix } = useNetworkConfigStore(); - const { submitProposal, pendingCreateTx } = useSubmitProposal(); - const { canUserCreateProposal } = useCanUserCreateProposal(); - const { createProposalValidation } = useCreateProposalSchema(); - const { addAction, actions, resetActions } = useProposalActionsStore(); - - const handleAddSendAssetsAction = (data: SendAssetsData) => { - addAction({ - actionType: ProposalActionType.TRANSFER, - content: <>, - transactions: [ - { - targetAddress: data.asset.tokenAddress, - ethValue: { - bigintValue: 0n, - value: '0', - }, - functionName: 'transfer', - parameters: [ - { signature: 'address', value: data.destinationAddress }, - { signature: 'uint256', value: data.transferAmount.toString() }, - ], - }, - ], - }); - }; - - const successCallback = () => { - if (safeAddress) { - // Redirecting to home page so that user will see newly created Proposal - navigate(DAO_ROUTES.dao.relative(addressPrefix, safeAddress)); - } - }; - - useEffect(() => { - if (safeAddress && (!step || !Object.values(CreateProposalSteps).includes(step))) { - navigate(DAO_ROUTES.proposalNew.relative(addressPrefix, safeAddress), { replace: true }); - } - }, [safeAddress, step, navigate, addressPrefix]); - - return ( - - validationSchema={createProposalValidation} - initialValues={initialValues} - enableReinitialize - onSubmit={async values => { - if (!canUserCreateProposal) { - toast.error(t('errorNotProposer', { ns: 'common' })); - } - - try { - const proposalData = await prepareProposalData(values); - if (proposalData) { - submitProposal({ - proposalData, - nonce: values?.nonce, - pendingToastMessage: t('proposalCreatePendingToastMessage', { ns: 'proposal' }), - successToastMessage: t('proposalCreateSuccessToastMessage', { ns: 'proposal' }), - failedToastMessage: t('proposalCreateFailureToastMessage', { ns: 'proposal' }), - successCallback, - }); - } - } catch (e) { - logError(e); - toast.error(t('encodingFailedMessage', { ns: 'proposal' })); - } - }} - > - {(formikProps: FormikProps) => { - const { handleSubmit } = formikProps; - - if (!safeAddress) { - return; - } - - return ( -
- - { - if (mode === ProposalBuilderMode.PROPOSAL_WITH_ACTIONS && actions.length > 0) { - resetActions(); - } - navigate( - safeAddress - ? isProposalMode - ? DAO_ROUTES.proposals.relative(addressPrefix, safeAddress) - : DAO_ROUTES.proposalTemplates.relative(addressPrefix, safeAddress) - : BASE_ROUTES.landing, - ); - }, - }} - /> - - - - - - - } - /> - - - {!isAzorius && ( - - - formikProps.setFieldValue('nonce', newNonce) - } - align="end" - renderTrimmed={false} - /> - - )} - - } - /> - - } - /> - - - {mode === ProposalBuilderMode.PROPOSAL_WITH_ACTIONS && ( - - - - {t('actions', { ns: 'actions' })} - - {actions.map((action, index) => { - return ( - 1} - /> - ); - })} - - - - - - )} - - - - - - - - -
- ); - }} - - ); -} diff --git a/src/components/ProposalTemplates/ExampleTemplateCard.tsx b/src/components/ProposalTemplates/ExampleTemplateCard.tsx new file mode 100644 index 0000000000..6e15dccd2f --- /dev/null +++ b/src/components/ProposalTemplates/ExampleTemplateCard.tsx @@ -0,0 +1,43 @@ +import { Avatar, Flex, Text } from '@chakra-ui/react'; +import ContentBox from '../ui/containers/ContentBox'; +import Markdown from '../ui/proposal/Markdown'; + +type ExampleTemplateCardProps = { + title: string; + description: string; + onProposalTemplateClick: () => void; +}; + +export default function ExampleTemplateCard({ + title, + description, + onProposalTemplateClick, +}: ExampleTemplateCardProps) { + return ( + + + _title.slice(0, 2)} + textStyle="heading-large" + color="white-0" + /> + + + {title} + + + + ); +} diff --git a/src/components/Roles/forms/RoleFormCreateProposal.tsx b/src/components/Roles/forms/RoleFormCreateProposal.tsx index 512c7ae0b3..b6a246a3a4 100644 --- a/src/components/Roles/forms/RoleFormCreateProposal.tsx +++ b/src/components/Roles/forms/RoleFormCreateProposal.tsx @@ -13,7 +13,7 @@ import { RoleDetailsDrawerEditingRoleHatProp, RoleFormValues, } from '../../../types/roles'; -import { SendAssetsAction } from '../../ProposalBuilder/ProposalActionCard'; +import { SendAssetsActionCard } from '../../ui/cards/SendAssetsActionCard'; import { CustomNonceInput } from '../../ui/forms/CustomNonceInput'; import { InputComponent, TextareaComponent } from '../../ui/forms/InputComponent'; import { AddActions } from '../../ui/modals/AddActions'; @@ -238,14 +238,13 @@ export function RoleFormCreateProposal({ close }: { close: () => void }) { ); })} {values.actions.map((action, index) => ( - { + onRemove={() => { setFieldValueTopLevel( 'actions', - values.actions.filter((_, i) => i !== idx), + values.actions.filter((_, i) => i !== index), ); }} /> diff --git a/src/components/ui/cards/SendAssetsActionCard.tsx b/src/components/ui/cards/SendAssetsActionCard.tsx new file mode 100644 index 0000000000..075dade88a --- /dev/null +++ b/src/components/ui/cards/SendAssetsActionCard.tsx @@ -0,0 +1,48 @@ +import { Button, Card, Flex, Icon, Text } from '@chakra-ui/react'; +import { ArrowsDownUp, Trash } from '@phosphor-icons/react'; +import { useTranslation } from 'react-i18next'; +import { formatUnits } from 'viem'; +import { useGetAccountName } from '../../../hooks/utils/useGetAccountName'; +import { SendAssetsData } from '../modals/SendAssetsModal'; + +export function SendAssetsActionCard({ + action, + onRemove, +}: { + action: SendAssetsData; + onRemove: () => void; +}) { + const { t } = useTranslation('common'); + const { displayName } = useGetAccountName(action.destinationAddress); + return ( + + + + + {t('transfer')} + + {formatUnits(action.transferAmount, action.asset.decimals)} {action.asset.symbol} + + {t('to').toLowerCase()} + {displayName} + + + + + ); +} diff --git a/src/components/ui/modals/AirdropModal.tsx b/src/components/ui/modals/AirdropModal.tsx new file mode 100644 index 0000000000..9e8478f9f0 --- /dev/null +++ b/src/components/ui/modals/AirdropModal.tsx @@ -0,0 +1,327 @@ +import { Box, Button, Flex, HStack, IconButton, Select, Text } from '@chakra-ui/react'; +import { CaretDown, MinusCircle, Plus } from '@phosphor-icons/react'; +import { Field, FieldAttributes, FieldProps, Form, Formik } from 'formik'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Address, getAddress, isAddress } from 'viem'; +import { usePublicClient } from 'wagmi'; +import * as Yup from 'yup'; +import { useFractal } from '../../../providers/App/AppProvider'; +import { useDaoInfoStore } from '../../../store/daoInfo/useDaoInfoStore'; +import { BigIntValuePair, TokenBalance } from '../../../types'; +import { formatCoinFromAsset } from '../../../utils'; +import { validateENSName } from '../../../utils/url'; +import { BigIntInput } from '../forms/BigIntInput'; +import { CustomNonceInput } from '../forms/CustomNonceInput'; +import { AddressInput } from '../forms/EthAddressInput'; +import LabelWrapper from '../forms/LabelWrapper'; +import Divider from '../utils/Divider'; + +interface AirdropFormValues { + selectedAsset: TokenBalance; + recipients: { + address: string; + amount: BigIntValuePair; + }[]; +} + +export interface AirdropData { + recipients: { + address: Address; + amount: bigint; + }[]; + asset: TokenBalance; + nonceInput: number | undefined; // this is only releveant when the caller action results in a proposal +} + +export function AirdropModal({ + submitButtonText, + showNonceInput, + close, + airdropData, +}: { + submitButtonText: string; + showNonceInput: boolean; + close: () => void; + airdropData: (airdropData: AirdropData) => void; +}) { + const { + treasury: { assetsFungible }, + } = useFractal(); + const { safe } = useDaoInfoStore(); + + const publicClient = usePublicClient(); + const { t } = useTranslation(['modals', 'common']); + + const fungibleAssetsWithBalance = assetsFungible.filter(asset => parseFloat(asset.balance) > 0); + const [nonceInput, setNonceInput] = useState(safe!.nextNonce); + + const airdropValidationSchema = Yup.object().shape({ + selectedAsset: Yup.object() + .shape({ + tokenAddress: Yup.string().required(), + name: Yup.string().required(), + symbol: Yup.string().required(), + decimals: Yup.number().required(), + balance: Yup.string().required(), + }) + .required(), + recipients: Yup.array() + .of( + Yup.object() + .shape({ + address: Yup.string().required(), + amount: Yup.object() + .shape({ + value: Yup.string().required(), + }) + .required(), + }) + .required(), + ) + .required(), + }); + + const handleAirdropSubmit = async (values: AirdropFormValues) => { + airdropData({ + recipients: await Promise.all( + values.recipients.map(async recipient => { + let destAddress = recipient.address; + if (!isAddress(destAddress) && validateENSName(recipient.address) && publicClient) { + const ensAddress = await publicClient.getEnsAddress({ name: recipient.address }); + if (ensAddress === null) { + throw new Error('Invalid ENS name'); + } + destAddress = ensAddress; + } + return { + address: getAddress(destAddress), + amount: recipient.amount.bigintValue!, + }; + }), + ), + asset: values.selectedAsset, + nonceInput, + }); + + close(); + }; + return ( + + + initialValues={{ + selectedAsset: fungibleAssetsWithBalance[0], + recipients: [{ address: '', amount: { bigintValue: 0n, value: '0' } }], + }} + onSubmit={handleAirdropSubmit} + validationSchema={airdropValidationSchema} + > + {({ errors, values, setFieldValue, handleSubmit }) => { + const totalAmount = values.recipients.reduce( + (acc, recipient) => acc + (recipient.amount.bigintValue || 0n), + 0n, + ); + const overDraft = totalAmount > BigInt(values.selectedAsset.balance); + const isSubmitDisabled = !values.recipients || totalAmount === 0n || overDraft; + const selectedAssetIndex = fungibleAssetsWithBalance.findIndex( + asset => asset.tokenAddress === values.selectedAsset.tokenAddress, + ); + + return ( +
+ + {/* ASSET SELECT */} + + {({ field }: FieldAttributes>) => ( + + + + )} + + + + {/* AVAILABLE BALANCE HINT */} + + + {t('selectSublabel', { + balance: formatCoinFromAsset(values.selectedAsset, false), + })} + + + + + + {/* RECIPIENTS INPUTS */} + + {({ + field, + }: FieldAttributes>) => + field.value.map((recipient, index) => { + return ( + + + { + setFieldValue( + 'recipients', + field.value.map((r, i) => { + if (i === index) { + return { ...r, address: e.target.value }; + } + return r; + }), + ); + }} + value={recipient.address} + /> + + + { + setFieldValue( + 'recipients', + field.value.map((r, i) => { + if (i === index) { + return { ...r, amount: value }; + } + return r; + }), + ); + }} + parentFormikValue={recipient.amount} + decimalPlaces={values.selectedAsset.decimals} + placeholder="0" + maxValue={ + BigInt(values.selectedAsset.balance) - + BigInt(totalAmount) + + BigInt(recipient.amount.bigintValue || 0n) + } + isInvalid={overDraft} + errorBorderColor="red-0" + /> + + {/* Remove parameter button */} + {index !== 0 || values.recipients.length !== 1 ? ( + } + aria-label={t('removeRecipientLabel')} + variant="unstyled" + onClick={() => + setFieldValue( + `recipients`, + values.recipients.filter( + (_recipientToRemove, recipientToRemoveIndex) => + recipientToRemoveIndex !== index, + ), + ) + } + minWidth="auto" + color="lilac-0" + _disabled={{ opacity: 0.4, cursor: 'default' }} + sx={{ '&:disabled:hover': { color: 'inherit', opacity: 0.4 } }} + /> + ) : ( + + )} + + ); + }) + } + + + + + + + + + {showNonceInput && ( + setNonceInput(nonce ? parseInt(nonce) : undefined)} + /> + )} + + + + ); + }} + +
+ ); +} diff --git a/src/components/ui/modals/ModalProvider.tsx b/src/components/ui/modals/ModalProvider.tsx index 87fc066a6b..47308b5b33 100644 --- a/src/components/ui/modals/ModalProvider.tsx +++ b/src/components/ui/modals/ModalProvider.tsx @@ -7,6 +7,7 @@ import AddSignerModal from '../../SafeSettings/Signers/modals/AddSignerModal'; import RemoveSignerModal from '../../SafeSettings/Signers/modals/RemoveSignerModal'; import DraggableDrawer from '../containers/DraggableDrawer'; import AddStrategyPermissionModal from './AddStrategyPermissionModal'; +import { AirdropData, AirdropModal } from './AirdropModal'; import { ConfirmDeleteStrategyModal } from './ConfirmDeleteStrategyModal'; import { ConfirmModifyGovernanceModal } from './ConfirmModifyGovernanceModal'; import { ConfirmUrlModal } from './ConfirmUrlModal'; @@ -16,6 +17,7 @@ import { ModalBase, ModalBaseSize } from './ModalBase'; import PaymentCancelConfirmModal from './PaymentCancelConfirmModal'; import { PaymentWithdrawModal } from './PaymentWithdrawModal'; import ProposalTemplateModal from './ProposalTemplateModal'; +import { SendAssetsData, SendAssetsModal } from './SendAssetsModal'; import StakeModal from './Stake'; import { UnsavedChangesWarningContent } from './UnsavedChangesWarningContent'; @@ -34,6 +36,8 @@ export enum ModalType { WITHDRAW_PAYMENT, CONFIRM_CANCEL_PAYMENT, CONFIRM_DELETE_STRATEGY, + SEND_ASSETS, + AIRDROP, } export type CurrentModal = { @@ -79,6 +83,16 @@ export type ModalPropsTypes = { [ModalType.CONFIRM_CANCEL_PAYMENT]: { onSubmit: () => void; }; + [ModalType.SEND_ASSETS]: { + onSubmit: (sendAssetData: SendAssetsData) => void; + submitButtonText: string; + showNonceInput: boolean; + }; + [ModalType.AIRDROP]: { + onSubmit: (airdropData: AirdropData) => void; + submitButtonText: string; + showNonceInput: boolean; + }; }; export interface IModalContext { @@ -243,6 +257,32 @@ export function ModalProvider({ children }: { children: ReactNode }) { case ModalType.CONFIRM_DELETE_STRATEGY: modalContent = ; break; + case ModalType.SEND_ASSETS: + modalContent = ( + { + current.props.onSubmit(data); + closeModal(); + }} + /> + ); + break; + case ModalType.AIRDROP: + modalContent = ( + { + current.props.onSubmit(data); + closeModal(); + }} + /> + ); + break; case ModalType.NONE: default: modalTitle = ''; diff --git a/src/constants/routes.ts b/src/constants/routes.ts index 489ae5315c..9cc6d5b610 100644 --- a/src/constants/routes.ts +++ b/src/constants/routes.ts @@ -82,6 +82,11 @@ export const DAO_ROUTES = { `/proposals/actions/new/metadata${getDaoQueryParam(addressPrefix, daoAddress)}`, path: 'proposals/actions/new/metadata', }, + proposalSablierNew: { + relative: (addressPrefix: string, daoAddress: string) => + `/proposals/new/sablier/metadata${getDaoQueryParam(addressPrefix, daoAddress)}`, + path: 'proposals/new/sablier/metadata', + }, settings: { relative: (addressPrefix: string, safeAddress: string) => `/settings${getDaoQueryParam(addressPrefix, safeAddress)}`, diff --git a/src/hooks/DAO/useSendAssetsActionModal.tsx b/src/hooks/DAO/useSendAssetsActionModal.tsx new file mode 100644 index 0000000000..8958e1bcf2 --- /dev/null +++ b/src/hooks/DAO/useSendAssetsActionModal.tsx @@ -0,0 +1,64 @@ +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { ModalType } from '../../components/ui/modals/ModalProvider'; +import { SendAssetsData } from '../../components/ui/modals/SendAssetsModal'; +import { useDecentModal } from '../../components/ui/modals/useDecentModal'; +import { DAO_ROUTES } from '../../constants/routes'; +import { useFractal } from '../../providers/App/AppProvider'; +import { useNetworkConfigStore } from '../../providers/NetworkConfig/useNetworkConfigStore'; +import { useProposalActionsStore } from '../../store/actions/useProposalActionsStore'; +import { useDaoInfoStore } from '../../store/daoInfo/useDaoInfoStore'; +import { ProposalActionType } from '../../types/proposalBuilder'; +import { + isNativeAsset, + prepareSendAssetsActionData, +} from '../../utils/dao/prepareSendAssetsActionData'; + +export default function useSendAssetsActionModal() { + const { safe } = useDaoInfoStore(); + const { addressPrefix } = useNetworkConfigStore(); + const { t } = useTranslation(['modals']); + const { addAction } = useProposalActionsStore(); + const navigate = useNavigate(); + const { + governance: { isAzorius }, + } = useFractal(); + const sendAssetsAction = async (sendAssetsData: SendAssetsData) => { + if (!safe?.address) { + return; + } + const isNative = isNativeAsset(sendAssetsData.asset); + const transactionData = prepareSendAssetsActionData(sendAssetsData); + addAction({ + actionType: ProposalActionType.TRANSFER, + content: <>, + transactions: [ + { + targetAddress: transactionData.target, + ethValue: { + bigintValue: transactionData.value, + value: transactionData.value.toString(), + }, + functionName: isNative ? '' : 'transfer', + parameters: isNative + ? [] + : [ + { signature: 'address', value: sendAssetsData.destinationAddress }, + { signature: 'uint256', value: sendAssetsData.transferAmount.toString() }, + ], + }, + ], + }); + navigate(DAO_ROUTES.proposalWithActionsNew.relative(addressPrefix, safe.address)); + }; + + const openSendAssetsModal = useDecentModal(ModalType.SEND_ASSETS, { + onSubmit: sendAssetsAction, + submitButtonText: t('submitProposal', { ns: 'modals' }), + showNonceInput: !isAzorius, + }); + + return { + openSendAssetsModal, + }; +} diff --git a/src/hooks/utils/useCreateRoles.ts b/src/hooks/utils/useCreateRoles.ts index 1a41131561..5dbb301270 100644 --- a/src/hooks/utils/useCreateRoles.ts +++ b/src/hooks/utils/useCreateRoles.ts @@ -1688,11 +1688,7 @@ export default function useCreateRoles() { // Add "send assets" actions to the proposal data values.actions.forEach(action => { - const actionData = prepareSendAssetsActionData({ - transferAmount: action.transferAmount, - asset: action.asset, - destinationAddress: action.destinationAddress, - }); + const actionData = prepareSendAssetsActionData(action); proposalData.targets.push(actionData.target); proposalData.values.push(actionData.value); proposalData.calldatas.push(actionData.calldata); diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 557f3ba6c7..fd36a745e1 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -120,5 +120,7 @@ "and": "and", "days": "Days", "owner": "Owner", + "recipients": "recipients", + "airdrop": "Airdrop", "pageXofY": "Page {{current}} of {{total}}" } diff --git a/src/i18n/locales/en/modals.json b/src/i18n/locales/en/modals.json index 8a4b0b3071..83a0ca5ef5 100644 --- a/src/i18n/locales/en/modals.json +++ b/src/i18n/locales/en/modals.json @@ -49,5 +49,9 @@ "confirmModifyGovernanceDescription": "Modifying your Safe's governance will have significant implications. Please be sure before proceeding.", "stakeTitle": "Stake", "removeSignerWarning": "The signer will be removed from the organization.", - "add": "Add" + "add": "Add", + "recipientsLabel": "Recipient", + "recipientsSublabel": "Enter the recipient's wallet address", + "airdropAmountSublabel": "Enter the amount of tokens to be airdropped to the recipient", + "addRecipient": "Add Recipient" } diff --git a/src/i18n/locales/en/proposal.json b/src/i18n/locales/en/proposal.json index c6e629f09b..d25a4e7d0a 100644 --- a/src/i18n/locales/en/proposal.json +++ b/src/i18n/locales/en/proposal.json @@ -144,5 +144,36 @@ "metadataFailedParsePlaceholder": "Unknown - Failed to parse metadata", "multisigNonceDuplicateErrorMessage": "Transaction with the same nonce already exists", "createFromScratch": "Start from scratch", - "browseTemplates": "Browse templates" + "browseTemplates": "Browse templates", + "tranchedStream": "Tranched", + "streamTitle": "Stream {{index}} ({{type}})", + "addStream": "Add stream", + "removeStream": "Remove stream", + "streamStartNotice": "Stream will be started in the moment of proposal execution. In order to\n\"emulate\" delay of stream start - first tranche should have amount set to\n0 with desired \"delay\" duration.", + "streamedTokenAddress": "Streamed Token Address", + "streamedTokenAddressHelper": "Treasury balance: {{balance}}", + "recipientAddress": "Recipient Address", + "recipientAddressHelper": "Who will be recipient of this stream - only owner of this address will be able to receive tokens.", + "streamTotalAmount": "Stream Total Amount", + "streamTotalAmountHelper": "The total amount of token to stream. Has to be equal to the sum of tranches amount", + "cancelable": "Cancelable", + "cancelableHelper": "If enabled, the stream can be canceled by the owner of the Safe", + "transferable": "Transferable", + "transferableHelper": "If enabled, the stream can be transferred to another address", + "tranche": "Tranche {{index}}", + "trancheHelper": "The amount of tokens to be streamed in this tranche", + "trancheAmount": "Tranche Amount", + "trancheAmountHelper": "The amount of tokens to be streamed in this tranche", + "trancheDuration": "Tranche Duration", + "trancheDurationHelper": "The duration of the tranche", + "removeTranche": "Remove tranche", + "addTranche": "Add tranche", + "trancheDurationHelperFirstTranche": "At least 1 second for the first trance.", + "labelRecipientAddress": "Recipient Address", + "labelTotalAmount": "Total Amount", + "labelTranches": "Tranches", + "labelTrancheAmount": "Tranche Amount", + "labelTrancheDuration": "Tranche Duration", + "streamCancelableHelper": "If enabled, the stream can be canceled by the owner of the Safe", + "streamTransferableHelper": "If enabled, the stream can be transferred to another address by the stream's recipient" } diff --git a/src/i18n/locales/en/proposalTemplate.json b/src/i18n/locales/en/proposalTemplate.json index fa447c097e..0cd409dbdf 100644 --- a/src/i18n/locales/en/proposalTemplate.json +++ b/src/i18n/locales/en/proposalTemplate.json @@ -32,5 +32,12 @@ "targetDAOAddressLabel": "Safe address", "targetDAOAddressHelper": "Set the location that will receive this duplicated template", "forkTemplateSubmitButton": "Review Forked Template", - "showParameters": "Show All" + "showParameters": "Show All", + "defaultTemplates": "Default Templates", + "templateTransferTitle": "Transfer", + "templateTransferDescription": "Send payment to a recipient", + "templateSablierTitle": "Stream", + "templateSablierDescription": "Stream funds to a recipient over time", + "templateAirdropTitle": "Airdrop", + "templateAirdropDescription": "Send tokens to multiple recipients" } diff --git a/src/pages/dao/proposal-templates/SafeProposalTemplatesPage.tsx b/src/pages/dao/proposal-templates/SafeProposalTemplatesPage.tsx index 2369541965..06f97af5cf 100644 --- a/src/pages/dao/proposal-templates/SafeProposalTemplatesPage.tsx +++ b/src/pages/dao/proposal-templates/SafeProposalTemplatesPage.tsx @@ -1,19 +1,27 @@ import * as amplitude from '@amplitude/analytics-browser'; -import { Box, Button, Flex, Show } from '@chakra-ui/react'; -import { useEffect } from 'react'; +import { Box, Button, Flex, Show, Text } from '@chakra-ui/react'; +import { useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { Link } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; import { AddPlus } from '../../../assets/theme/custom/icons/AddPlus'; +import ExampleTemplateCard from '../../../components/ProposalTemplates/ExampleTemplateCard'; import ProposalTemplateCard from '../../../components/ProposalTemplates/ProposalTemplateCard'; import NoDataCard from '../../../components/ui/containers/NoDataCard'; import { InfoBoxLoader } from '../../../components/ui/loaders/InfoBoxLoader'; +import { AirdropData } from '../../../components/ui/modals/AirdropModal'; +import { ModalType } from '../../../components/ui/modals/ModalProvider'; +import { useDecentModal } from '../../../components/ui/modals/useDecentModal'; import PageHeader from '../../../components/ui/page/Header/PageHeader'; +import Divider from '../../../components/ui/utils/Divider'; import { DAO_ROUTES } from '../../../constants/routes'; +import useSendAssetsActionModal from '../../../hooks/DAO/useSendAssetsActionModal'; import { useCanUserCreateProposal } from '../../../hooks/utils/useCanUserSubmitProposal'; import { analyticsEvents } from '../../../insights/analyticsEvents'; import { useFractal } from '../../../providers/App/AppProvider'; import { useNetworkConfigStore } from '../../../providers/NetworkConfig/useNetworkConfigStore'; +import { useProposalActionsStore } from '../../../store/actions/useProposalActionsStore'; import { useDaoInfoStore } from '../../../store/daoInfo/useDaoInfoStore'; +import { ProposalActionType } from '../../../types/proposalBuilder'; export function SafeProposalTemplatesPage() { useEffect(() => { @@ -26,9 +34,88 @@ export function SafeProposalTemplatesPage() { } = useFractal(); const { safe } = useDaoInfoStore(); const { canUserCreateProposal } = useCanUserCreateProposal(); - const { addressPrefix } = useNetworkConfigStore(); + const { + addressPrefix, + contracts: { disperse }, + } = useNetworkConfigStore(); + const navigate = useNavigate(); + const { addAction } = useProposalActionsStore(); const safeAddress = safe?.address; + const { openSendAssetsModal } = useSendAssetsActionModal(); + + const handleAirdropSubmit = (data: AirdropData) => { + if (!safeAddress) return; + + const totalAmount = data.recipients.reduce((acc, recipient) => acc + recipient.amount, 0n); + addAction({ + actionType: ProposalActionType.AIRDROP, + content: <>, + transactions: [ + { + targetAddress: data.asset.tokenAddress, + ethValue: { + bigintValue: 0n, + value: '0', + }, + functionName: 'approve', + parameters: [ + { signature: 'address', value: disperse }, + { signature: 'uint256', value: totalAmount.toString() }, + ], + }, + { + targetAddress: data.asset.tokenAddress, + ethValue: { + bigintValue: 0n, + value: '0', + }, + functionName: 'disperseToken', + parameters: [ + { signature: 'address', value: data.asset.tokenAddress }, + { + signature: 'address[]', + value: `[${data.recipients.map(recipient => recipient.address).join(',')}]`, + }, + { + signature: 'uint256[]', + value: `[${data.recipients.map(recipient => recipient.amount.toString()).join(',')}]`, + }, + ], + }, + ], + }); + + navigate(DAO_ROUTES.proposalWithActionsNew.relative(addressPrefix, safeAddress)); + }; + + const openAirdropModal = useDecentModal(ModalType.AIRDROP, { + onSubmit: handleAirdropSubmit, + submitButtonText: t('submitProposal', { ns: 'modals' }), + showNonceInput: false, + }); + + const EXAMPLE_TEMPLATES = useMemo(() => { + if (!safeAddress) return []; + return [ + { + title: t('templateAirdropTitle', { ns: 'proposalTemplate' }), + description: t('templateAirdropDescription', { ns: 'proposalTemplate' }), + onProposalTemplateClick: openAirdropModal, + }, + { + title: t('templateSablierTitle', { ns: 'proposalTemplate' }), + description: t('templateSablierDescription', { ns: 'proposalTemplate' }), + onProposalTemplateClick: () => + navigate(DAO_ROUTES.proposalSablierNew.relative(addressPrefix, safeAddress)), + }, + { + title: t('templateTransferTitle', { ns: 'proposalTemplate' }), + description: t('templateTransferDescription', { ns: 'proposalTemplate' }), + onProposalTemplateClick: openSendAssetsModal, + }, + ]; + }, [t, openSendAssetsModal, navigate, safeAddress, addressPrefix, openAirdropModal]); return (
@@ -75,6 +162,31 @@ export function SafeProposalTemplatesPage() { /> )} + + + {t('defaultTemplates', { ns: 'proposalTemplate' })} + + + {EXAMPLE_TEMPLATES.map((exampleTemplate, i) => ( + + ))} +
); } diff --git a/src/pages/dao/proposal-templates/new/SafeCreateProposalTemplatePage.tsx b/src/pages/dao/proposal-templates/new/SafeCreateProposalTemplatePage.tsx index 976becebb1..8aa0cf2db7 100644 --- a/src/pages/dao/proposal-templates/new/SafeCreateProposalTemplatePage.tsx +++ b/src/pages/dao/proposal-templates/new/SafeCreateProposalTemplatePage.tsx @@ -1,13 +1,31 @@ import * as amplitude from '@amplitude/analytics-browser'; -import { useEffect, useState, useMemo } from 'react'; -import { useSearchParams } from 'react-router-dom'; -import { ProposalBuilder } from '../../../../components/ProposalBuilder'; +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Route, useLocation, useNavigate, useSearchParams } from 'react-router-dom'; +import { + ProposalBuilder, + ShowNonceInputOnMultisig, +} from '../../../../components/ProposalBuilder/ProposalBuilder'; +import { + TemplateDetails, + TransactionsDetails, +} from '../../../../components/ProposalBuilder/ProposalDetails'; +import { TEMPLATE_PROPOSAL_METADATA_TYPE_PROPS } from '../../../../components/ProposalBuilder/ProposalMetadata'; +import ProposalTransactionsForm from '../../../../components/ProposalBuilder/ProposalTransactionsForm'; +import StepButtons, { + CreateProposalButton, + NextButton, + PreviousButton, +} from '../../../../components/ProposalBuilder/StepButtons'; import { DEFAULT_PROPOSAL } from '../../../../components/ProposalBuilder/constants'; +import { DAO_ROUTES } from '../../../../constants/routes'; import { logError } from '../../../../helpers/errorLogging'; import useCreateProposalTemplate from '../../../../hooks/DAO/proposal/useCreateProposalTemplate'; import { analyticsEvents } from '../../../../insights/analyticsEvents'; import useIPFSClient from '../../../../providers/App/hooks/useIPFSClient'; -import { ProposalBuilderMode, ProposalTemplate } from '../../../../types/proposalBuilder'; +import { useNetworkConfigStore } from '../../../../providers/NetworkConfig/useNetworkConfigStore'; +import { useDaoInfoStore } from '../../../../store/daoInfo/useDaoInfoStore'; +import { CreateProposalSteps, ProposalTemplate } from '../../../../types/proposalBuilder'; export function SafeCreateProposalTemplatePage() { useEffect(() => { @@ -26,6 +44,8 @@ export function SafeCreateProposalTemplatePage() { () => searchParams?.get('templateIndex'), [searchParams], ); + const { safe } = useDaoInfoStore(); + const { addressPrefix } = useNetworkConfigStore(); useEffect(() => { const loadInitialTemplate = async () => { @@ -58,11 +78,87 @@ export function SafeCreateProposalTemplatePage() { loadInitialTemplate(); }, [defaultProposalTemplatesHash, defaultProposalTemplateIndex, ipfsClient]); + const location = useLocation(); + const { t } = useTranslation('proposalTemplate'); + const navigate = useNavigate(); + + const prevStepUrl = `${location.pathname.replace(CreateProposalSteps.TRANSACTIONS, CreateProposalSteps.METADATA)}${location.search}`; + const nextStepUrl = `${location.pathname.replace(CreateProposalSteps.METADATA, CreateProposalSteps.TRANSACTIONS)}${location.search}`; + + const pageHeaderBreadcrumbs = [ + { + terminus: t('proposalTemplates', { ns: 'breadcrumbs' }), + path: DAO_ROUTES.proposalTemplates.relative(addressPrefix, safe?.address ?? ''), + }, + { + terminus: t('proposalTemplateNew', { ns: 'breadcrumbs' }), + path: '', + }, + ]; + + const pageHeaderButtonClickHandler = () => { + navigate(DAO_ROUTES.proposalTemplates.relative(addressPrefix, safe?.address ?? '')); + }; + + const stepButtons = ({ + formErrors, + createProposalBlocked, + }: { + formErrors: boolean; + createProposalBlocked: boolean; + }) => { + return ( + + } + transactionsStepButtons={ + <> + + + + } + /> + ); + }; + return ( } + templateDetails={title => } + streamsDetails={null} initialValues={initialProposalTemplate} prepareProposalData={prepareProposalTemplateProposal} + contentRoute={(formikProps, pendingCreateTx, nonce) => { + return ( + + + formikProps.setFieldValue('nonce', newNonce)} + /> + + } + /> + ); + }} /> ); } diff --git a/src/pages/dao/proposals/actions/new/SafeProposalWithActionsCreatePage.tsx b/src/pages/dao/proposals/actions/new/SafeProposalWithActionsCreatePage.tsx index 42f96c639b..d16d9914ea 100644 --- a/src/pages/dao/proposals/actions/new/SafeProposalWithActionsCreatePage.tsx +++ b/src/pages/dao/proposals/actions/new/SafeProposalWithActionsCreatePage.tsx @@ -1,16 +1,92 @@ import * as amplitude from '@amplitude/analytics-browser'; -import { Center } from '@chakra-ui/react'; -import { useEffect } from 'react'; -import { ProposalBuilder } from '../../../../../components/ProposalBuilder'; +import { Center, Flex, Text } from '@chakra-ui/react'; +import { useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Route, useLocation, useNavigate } from 'react-router-dom'; +import { ProposalActionCard } from '../../../../../components/ProposalBuilder/ProposalActionCard'; +import { + ProposalBuilder, + ShowNonceInputOnMultisig, +} from '../../../../../components/ProposalBuilder/ProposalBuilder'; +import { TransactionsDetails } from '../../../../../components/ProposalBuilder/ProposalDetails'; +import { DEFAULT_PROPOSAL_METADATA_TYPE_PROPS } from '../../../../../components/ProposalBuilder/ProposalMetadata'; +import ProposalTransactionsForm from '../../../../../components/ProposalBuilder/ProposalTransactionsForm'; +import StepButtons, { + CreateProposalButton, + PreviousButton, +} from '../../../../../components/ProposalBuilder/StepButtons'; import { DEFAULT_PROPOSAL } from '../../../../../components/ProposalBuilder/constants'; import { BarLoader } from '../../../../../components/ui/loaders/BarLoader'; +import { AddActions } from '../../../../../components/ui/modals/AddActions'; +import { SendAssetsData } from '../../../../../components/ui/modals/SendAssetsModal'; import { useHeaderHeight } from '../../../../../constants/common'; +import { DAO_ROUTES } from '../../../../../constants/routes'; import { usePrepareProposal } from '../../../../../hooks/DAO/proposal/usePrepareProposal'; import { analyticsEvents } from '../../../../../insights/analyticsEvents'; import { useFractal } from '../../../../../providers/App/AppProvider'; +import { useNetworkConfigStore } from '../../../../../providers/NetworkConfig/useNetworkConfigStore'; import { useProposalActionsStore } from '../../../../../store/actions/useProposalActionsStore'; import { useDaoInfoStore } from '../../../../../store/daoInfo/useDaoInfoStore'; -import { ProposalBuilderMode } from '../../../../../types'; +import { CreateProposalSteps, ProposalActionType } from '../../../../../types'; + +function ActionsExperience() { + const { t } = useTranslation('actions'); + const { actions, addAction } = useProposalActionsStore(); + + const handleAddSendAssetsAction = (data: SendAssetsData) => { + addAction({ + actionType: ProposalActionType.TRANSFER, + content: <>, + transactions: [ + { + targetAddress: data.asset.tokenAddress, + ethValue: { + bigintValue: 0n, + value: '0', + }, + functionName: 'transfer', + parameters: [ + { signature: 'address', value: data.destinationAddress }, + { signature: 'uint256', value: data.transferAmount.toString() }, + ], + }, + ], + }); + }; + + return ( + + + + {t('actions', { ns: 'actions' })} + + {actions.map((action, index) => { + return ( + 1} + /> + ); + })} + + + + + + ); +} export function SafeProposalWithActionsCreatePage() { useEffect(() => { @@ -23,8 +99,14 @@ export function SafeProposalWithActionsCreatePage() { const { prepareProposal } = usePrepareProposal(); const { getTransactions } = useProposalActionsStore(); + const transactions = useMemo(() => getTransactions(), [getTransactions]); + const { addressPrefix } = useNetworkConfigStore(); const HEADER_HEIGHT = useHeaderHeight(); + const location = useLocation(); + const { t } = useTranslation('proposal'); + const navigate = useNavigate(); + const { resetActions } = useProposalActionsStore(); if (!type || !safe?.address || !safe) { return ( @@ -34,15 +116,76 @@ export function SafeProposalWithActionsCreatePage() { ); } + const prevStepUrl = `${location.pathname.replace(CreateProposalSteps.TRANSACTIONS, CreateProposalSteps.METADATA)}${location.search}`; + + const pageHeaderBreadcrumbs = [ + { + terminus: t('proposals', { ns: 'breadcrumbs' }), + path: DAO_ROUTES.proposals.relative(addressPrefix, safe.address), + }, + { + terminus: t('proposalNew', { ns: 'breadcrumbs' }), + path: '', + }, + ]; + + const pageHeaderButtonClickHandler = () => { + resetActions(); + navigate(DAO_ROUTES.proposals.relative(addressPrefix, safe.address)); + }; + + const stepButtons = ({ createProposalBlocked }: { createProposalBlocked: boolean }) => { + return ( + } + transactionsStepButtons={ + <> + + + + } + /> + ); + }; + return ( } + stepButtons={stepButtons} + transactionsDetails={_transactions => } + templateDetails={null} + streamsDetails={null} + proposalMetadataTypeProps={DEFAULT_PROPOSAL_METADATA_TYPE_PROPS(t)} prepareProposalData={prepareProposal} + contentRoute={(formikProps, pendingCreateTx, nonce) => { + return ( + + + formikProps.setFieldValue('nonce', newNonce)} + /> + + } + /> + ); + }} /> ); } diff --git a/src/pages/dao/proposals/new/SafeProposalCreatePage.tsx b/src/pages/dao/proposals/new/SafeProposalCreatePage.tsx index cefed2f1d6..763c5fe746 100644 --- a/src/pages/dao/proposals/new/SafeProposalCreatePage.tsx +++ b/src/pages/dao/proposals/new/SafeProposalCreatePage.tsx @@ -1,15 +1,30 @@ import * as amplitude from '@amplitude/analytics-browser'; import { Center } from '@chakra-ui/react'; import { useEffect } from 'react'; -import { ProposalBuilder } from '../../../../components/ProposalBuilder'; +import { useTranslation } from 'react-i18next'; +import { Route, useLocation, useNavigate } from 'react-router-dom'; +import { + ProposalBuilder, + ShowNonceInputOnMultisig, +} from '../../../../components/ProposalBuilder/ProposalBuilder'; +import { TransactionsDetails } from '../../../../components/ProposalBuilder/ProposalDetails'; +import { DEFAULT_PROPOSAL_METADATA_TYPE_PROPS } from '../../../../components/ProposalBuilder/ProposalMetadata'; +import ProposalTransactionsForm from '../../../../components/ProposalBuilder/ProposalTransactionsForm'; +import StepButtons, { + CreateProposalButton, + NextButton, + PreviousButton, +} from '../../../../components/ProposalBuilder/StepButtons'; import { DEFAULT_PROPOSAL } from '../../../../components/ProposalBuilder/constants'; import { BarLoader } from '../../../../components/ui/loaders/BarLoader'; import { useHeaderHeight } from '../../../../constants/common'; +import { DAO_ROUTES } from '../../../../constants/routes'; import { usePrepareProposal } from '../../../../hooks/DAO/proposal/usePrepareProposal'; import { analyticsEvents } from '../../../../insights/analyticsEvents'; import { useFractal } from '../../../../providers/App/AppProvider'; +import { useNetworkConfigStore } from '../../../../providers/NetworkConfig/useNetworkConfigStore'; import { useDaoInfoStore } from '../../../../store/daoInfo/useDaoInfoStore'; -import { ProposalBuilderMode } from '../../../../types'; +import { CreateProposalSteps } from '../../../../types'; export function SafeProposalCreatePage() { useEffect(() => { @@ -21,7 +36,12 @@ export function SafeProposalCreatePage() { const { safe } = useDaoInfoStore(); const { prepareProposal } = usePrepareProposal(); + const { addressPrefix } = useNetworkConfigStore(); + const HEADER_HEIGHT = useHeaderHeight(); + const location = useLocation(); + const { t } = useTranslation('proposal'); + const navigate = useNavigate(); if (!type || !safe?.address || !safe) { return ( @@ -31,11 +51,83 @@ export function SafeProposalCreatePage() { ); } + const prevStepUrl = `${location.pathname.replace(CreateProposalSteps.TRANSACTIONS, CreateProposalSteps.METADATA)}${location.search}`; + const nextStepUrl = `${location.pathname.replace(CreateProposalSteps.METADATA, CreateProposalSteps.TRANSACTIONS)}${location.search}`; + + const pageHeaderBreadcrumbs = [ + { + terminus: t('proposals', { ns: 'breadcrumbs' }), + path: DAO_ROUTES.proposals.relative(addressPrefix, safe.address), + }, + { + terminus: t('proposalNew', { ns: 'breadcrumbs' }), + path: '', + }, + ]; + + const pageHeaderButtonClickHandler = () => { + navigate(DAO_ROUTES.proposals.relative(addressPrefix, safe.address)); + }; + + const stepButtons = ({ + formErrors, + createProposalBlocked, + }: { + formErrors: boolean; + createProposalBlocked: boolean; + }) => { + return ( + + } + transactionsStepButtons={ + <> + + + + } + /> + ); + }; + return ( } + templateDetails={null} + streamsDetails={null} prepareProposalData={prepareProposal} + contentRoute={(formikProps, pendingCreateTx, nonce) => { + return ( + + + formikProps.setFieldValue('nonce', newNonce)} + /> + + } + /> + ); + }} /> ); } diff --git a/src/pages/dao/proposals/new/sablier/SafeSablierProposalCreatePage.tsx b/src/pages/dao/proposals/new/sablier/SafeSablierProposalCreatePage.tsx index fc30bab71d..fd0515ab43 100644 --- a/src/pages/dao/proposals/new/sablier/SafeSablierProposalCreatePage.tsx +++ b/src/pages/dao/proposals/new/sablier/SafeSablierProposalCreatePage.tsx @@ -1,900 +1,126 @@ import * as amplitude from '@amplitude/analytics-browser'; -import { - Accordion, - AccordionButton, - AccordionItem, - AccordionPanel, - Alert, - Box, - Button, - Center, - Checkbox, - Divider, - Flex, - Grid, - GridItem, - HStack, - Icon, - IconButton, - Text, - VStack, -} from '@chakra-ui/react'; -import { - CaretDown, - CaretLeft, - CaretRight, - MinusCircle, - Plus, - Trash, - WarningCircle, -} from '@phosphor-icons/react'; +import { Center } from '@chakra-ui/react'; import groupBy from 'lodash.groupby'; -import { - Dispatch, - FormEvent, - SetStateAction, - useCallback, - useEffect, - useMemo, - useState, -} from 'react'; +import { useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; -import { toast } from 'sonner'; -import { - Address, - encodeFunctionData, - erc20Abi, - formatUnits, - getAddress, - getContract, - Hash, - isAddress, - zeroAddress, -} from 'viem'; +import { Route, useLocation, useNavigate } from 'react-router-dom'; +import { Address, encodeFunctionData, erc20Abi, getAddress, Hash, zeroAddress } from 'viem'; import SablierV2BatchAbi from '../../../../../assets/abi/SablierV2Batch'; -import { BigIntInput } from '../../../../../components/ui/forms/BigIntInput'; -import ExampleLabel from '../../../../../components/ui/forms/ExampleLabel'; import { - InputComponent, - LabelComponent, - TextareaComponent, -} from '../../../../../components/ui/forms/InputComponent'; + ProposalBuilder, + ShowNonceInputOnMultisig, +} from '../../../../../components/ProposalBuilder/ProposalBuilder'; +import { StreamsDetails } from '../../../../../components/ProposalBuilder/ProposalDetails'; +import { DEFAULT_PROPOSAL_METADATA_TYPE_PROPS } from '../../../../../components/ProposalBuilder/ProposalMetadata'; +import { ProposalStreams } from '../../../../../components/ProposalBuilder/ProposalStreams'; +import StepButtons, { + CreateProposalButton, + NextButton, + PreviousButton, +} from '../../../../../components/ProposalBuilder/StepButtons'; +import { DEFAULT_SABLIER_PROPOSAL } from '../../../../../components/ProposalBuilder/constants'; import { BarLoader } from '../../../../../components/ui/loaders/BarLoader'; -import PageHeader from '../../../../../components/ui/page/Header/PageHeader'; -import Markdown from '../../../../../components/ui/proposal/Markdown'; -import CeleryButtonWithIcon from '../../../../../components/ui/utils/CeleryButtonWithIcon'; import { useHeaderHeight } from '../../../../../constants/common'; -import { BASE_ROUTES, DAO_ROUTES } from '../../../../../constants/routes'; -import useSubmitProposal from '../../../../../hooks/DAO/proposal/useSubmitProposal'; -import useNetworkPublicClient from '../../../../../hooks/useNetworkPublicClient'; -import { useCanUserCreateProposal } from '../../../../../hooks/utils/useCanUserSubmitProposal'; +import { DAO_ROUTES } from '../../../../../constants/routes'; import { analyticsEvents } from '../../../../../insights/analyticsEvents'; import { useFractal } from '../../../../../providers/App/AppProvider'; import { useNetworkConfigStore } from '../../../../../providers/NetworkConfig/useNetworkConfigStore'; import { useDaoInfoStore } from '../../../../../store/daoInfo/useDaoInfoStore'; -import { BigIntValuePair, CreateProposalSteps } from '../../../../../types'; -import { scrollToBottom } from '../../../../../utils/ui'; - -const SECONDS_IN_DAY = 60 * 60 * 24; - -function StepButtons({ - values: { proposalMetadata }, - pendingTransaction, - isSubmitDisabled, -}: { - values: { proposalMetadata: { title: string; description?: string } }; - pendingTransaction: boolean; - isSubmitDisabled: boolean; -}) { - const { safe } = useDaoInfoStore(); - const { canUserCreateProposal } = useCanUserCreateProposal(); - const navigate = useNavigate(); - const location = useLocation(); - const { t } = useTranslation(['common', 'proposal']); - - if (!safe?.address) { - return null; - } - - // @dev these prevStepUrl and nextStepUrl calculation is done this way to universally build URL for the next/prev steps both for proposal builder and proposal template builder - const prevStepUrl = `${location.pathname.replace(`${CreateProposalSteps.TRANSACTIONS}`, `${CreateProposalSteps.METADATA}`)}${location.search}`; - const nextStepUrl = `${location.pathname.replace(`${CreateProposalSteps.METADATA}`, `${CreateProposalSteps.TRANSACTIONS}`)}${location.search}`; - - return ( - - - navigate(nextStepUrl)} - isDisabled={!proposalMetadata.title} - px="2rem" - > - {t('next', { ns: 'common' })} - - - } - /> - - - - - } - /> - - - ); -} - -function ProposalDetails({ - values: { proposalMetadata }, -}: { - values: { proposalMetadata: { title: string; description: string } }; -}) { - const { t } = useTranslation(['proposalTemplate', 'proposal']); - const trimmedTitle = proposalMetadata.title?.trim(); - const [descriptionCollapsed, setDescriptionCollapsed] = useState(true); - - return ( - - - {t('preview')} - - - {t('previewTitle')} - - {trimmedTitle} - - - - {t('proposalTemplateDescription')} - {proposalMetadata.description && ( - setDescriptionCollapsed(prevState => !prevState)} - text={t(descriptionCollapsed ? 'show' : 'hide', { ns: 'common' })} - /> - )} - - {!descriptionCollapsed && ( - - )} - - - - ); -} - -function ProposalMetadata({ - values: { proposalMetadata }, - setTitle, - setDescription, -}: { - values: { proposalMetadata: { title: string; description: string } }; - setTitle: Dispatch>; - setDescription: Dispatch>; -}) { - const { t } = useTranslation(['proposalTemplate', 'proposal', 'common']); - - return ( - - setTitle(e.target.value)} - testId="metadata.title" - maxLength={50} - /> - setDescription(e.target.value)} - rows={12} - /> - - ); -} - -type Tranche = { - amount: BigIntValuePair; - duration: BigIntValuePair; -}; - -const DEFAULT_TRANCHE: Tranche = { - amount: { - value: '0', - bigintValue: 0n, - }, - duration: { - value: (SECONDS_IN_DAY * 14).toString(), - bigintValue: BigInt(SECONDS_IN_DAY * 14), - }, -}; -const DEFAULT_STREAM: Stream = { - type: 'tranched', - tokenAddress: '', - recipientAddress: '', - startDate: new Date(), - tranches: [DEFAULT_TRANCHE], - totalAmount: { - value: '0', - bigintValue: 0n, - }, - cancelable: true, - transferable: false, -}; - -type Stream = { - type: 'tranched'; - tokenAddress: string; - recipientAddress: string; - startDate: Date; - tranches: Tranche[]; - totalAmount: BigIntValuePair; - cancelable: boolean; - transferable: boolean; -}; - -function StreamBuilder({ - stream, - handleUpdateStream, - index, - pendingTransaction, -}: { - stream: Stream; - handleUpdateStream: (streamIndex: number, values: Partial) => void; - index: number; - pendingTransaction: boolean; -}) { - const publicClient = useNetworkPublicClient(); - const [tokenDecimals, setTokenDecimals] = useState(0); - const [rawTokenBalance, setRawTokenBalnace] = useState(0n); - const [tokenBalanceFormatted, setTokenBalanceFormatted] = useState(''); - const [expandedIndecies, setExpandedIndecies] = useState([0]); - const { safe } = useDaoInfoStore(); - const { t } = useTranslation(['common']); - - const safeAddress = safe?.address; - - useEffect(() => { - const fetchFormattedTokenBalance = async () => { - if (safeAddress && stream.tokenAddress && isAddress(stream.tokenAddress)) { - const tokenContract = getContract({ - abi: erc20Abi, - client: publicClient, - address: stream.tokenAddress, - }); - const [tokenBalance, decimals, symbol, name] = await Promise.all([ - tokenContract.read.balanceOf([safeAddress]), - tokenContract.read.decimals(), - tokenContract.read.symbol(), - tokenContract.read.name(), - ]); - setTokenDecimals(decimals); - setRawTokenBalnace(tokenBalance); - if (tokenBalance > 0n) { - const balanceFormatted = formatUnits(tokenBalance, decimals); - setTokenBalanceFormatted(`${balanceFormatted} ${symbol} (${name})`); - } - } - }; - - fetchFormattedTokenBalance(); - }, [safeAddress, publicClient, stream.tokenAddress]); - return ( - - - - {t('example', { ns: 'common' })}: - 0x4168592... - - } - isInvalid={!!stream.tokenAddress && !isAddress(stream.tokenAddress)} - value={stream.tokenAddress} - testId="stream.tokenAddress" - onChange={e => handleUpdateStream(index, { tokenAddress: e.target.value })} - /> - - - {t('example', { ns: 'common' })}: - 0x4168592... - - } - isInvalid={!!stream.recipientAddress && !isAddress(stream.recipientAddress)} - value={stream.recipientAddress} - testId="stream.recipientAddress" - onChange={e => handleUpdateStream(index, { recipientAddress: e.target.value })} - /> - - - {t('example', { ns: 'common' })}: - 10000 - - } - isRequired - > - handleUpdateStream(index, { totalAmount: value })} - decimalPlaces={tokenDecimals} - maxValue={rawTokenBalance} - /> - - - - - handleUpdateStream(index, { cancelable: !stream.cancelable })} - /> - Cancelable - - Can this stream be cancelled by DAO in the future? - - - - handleUpdateStream(index, { transferable: !stream.transferable })} - /> - Transferable - - - Can this stream be transferred by the recipient to another recipient? - - - - - - {stream.tranches.map((tranche, trancheIndex) => ( - - {({ isExpanded }) => ( - <> - - {/* STREAM TRANCHE HEADER */} - - { - setExpandedIndecies(indexArray => { - if (indexArray.includes(trancheIndex)) { - const newTxArr = [...indexArray]; - newTxArr.splice(newTxArr.indexOf(trancheIndex), 1); - return newTxArr; - } else { - return [...indexArray, trancheIndex]; - } - }); - }} - p={0} - textStyle="heading-small" - color="lilac-0" - > - - - {isExpanded ? : } - Tranche {trancheIndex + 1} - - - - - {/* Remove tranche button */} - {trancheIndex !== 0 || stream.tranches.length !== 1 ? ( - } - aria-label="Remove tranche" - variant="unstyled" - onClick={() => - handleUpdateStream(index, { - tranches: stream.tranches.filter( - (_, removedTrancheIndex) => removedTrancheIndex !== trancheIndex, - ), - }) - } - minWidth="auto" - color="lilac-0" - _disabled={{ opacity: 0.4, cursor: 'default' }} - sx={{ '&:disabled:hover': { color: 'inherit', opacity: 0.4 } }} - isDisabled={pendingTransaction} - /> - ) : ( - - )} - - - {/* STREAM TRANCHE SECTION */} - - - - - - - {t('example', { ns: 'common' })}:{' '} - 1000 - - - } - > - - handleUpdateStream(index, { - tranches: stream.tranches.map((item, updatedTrancheIndex) => - updatedTrancheIndex === trancheIndex - ? { ...item, amount: value } - : item, - ), - }) - } - /> - - - - - - Duration in seconds - {index === 0 && '. At least 1 second for the first trance.'} - - - {t('example', { ns: 'common' })}:{' '} - - {SECONDS_IN_DAY * 30} (1 month) - - - - } - > - - handleUpdateStream(index, { - tranches: stream.tranches.map((item, updatedTrancheIndex) => - updatedTrancheIndex === trancheIndex - ? { ...item, duration: value } - : item, - ), - }) - } - /> - - - - - - - - - {!isExpanded && ( - - )} - - {/* ADD TRANCHE BUTTON */} - {trancheIndex === stream.tranches.length - 1 && ( - { - handleUpdateStream(index, { - tranches: [...stream.tranches, DEFAULT_TRANCHE], - }); - setExpandedIndecies([stream.tranches.length]); - scrollToBottom(100, 'smooth'); - }} - icon={Plus} - text="Add tranche" - /> - )} - - )} - - ))} - - - - - ); -} - -function StreamsBuilder({ - streams, - setStreams, - pendingTransaction, -}: { - streams: Stream[]; - setStreams: Dispatch>; - pendingTransaction: boolean; -}) { - const handleUpdateStream = (streamIndex: number, values: Partial) => { - setStreams(prevState => - prevState.map((item, index) => (streamIndex === index ? { ...item, ...values } : item)), - ); - }; - return ( - - - {streams.map((stream, index) => ( - - {({ isExpanded }) => ( - - - - {isExpanded ? : } - - Stream {index + 1} ({stream.type}) - - - {index !== 0 || - (streams.length !== 1 && ( - } - aria-label="Remove stream" - variant="unstyled" - onClick={() => - setStreams(prevState => - prevState.filter((_, filteredIndex) => filteredIndex !== index), - ) - } - minWidth="auto" - color="lilac-0" - _disabled={{ opacity: 0.4, cursor: 'default' }} - sx={{ '&:disabled:hover': { color: 'inherit', opacity: 0.4 } }} - isDisabled={pendingTransaction} - /> - ))} - - - - - - - - - Stream will be started in the moment of proposal execution. In order to - {` "emulate"`} delay of stream start - first tranche should have amount set to - 0 with desired {`"delay"`} duration. - - - - - )} - - ))} - - - - { - setStreams(prevState => [...prevState, DEFAULT_STREAM]); - scrollToBottom(100, 'smooth'); - }} - isDisabled={pendingTransaction} - icon={Plus} - text="Add stream" - /> - - - ); -} +import { + CreateProposalForm, + CreateProposalSteps, + CreateSablierProposalForm, +} from '../../../../../types'; export function SafeSablierProposalCreatePage() { useEffect(() => { amplitude.track(analyticsEvents.SablierProposalCreatePageOpened); }, []); - const { governance: { type }, } = useFractal(); - const { safe } = useDaoInfoStore(); - const { submitProposal, pendingCreateTx } = useSubmitProposal(); - const { canUserCreateProposal } = useCanUserCreateProposal(); const { addressPrefix, contracts: { sablierV2Batch, sablierV2LockupTranched }, } = useNetworkConfigStore(); + const { safe } = useDaoInfoStore(); + const { t } = useTranslation('proposal'); const navigate = useNavigate(); - const { t } = useTranslation(['proposalTemplate', 'proposal']); - const [title, setTitle] = useState(''); - const [description, setDescription] = useState(''); - const [streams, setStreams] = useState([DEFAULT_STREAM]); - const HEADER_HEIGHT = useHeaderHeight(); - - const safeAddress = safe?.address; - const successCallback = () => { - if (safeAddress) { - // Redirecting to proposals page so that user will see Proposal for Proposal Template creation - navigate(DAO_ROUTES.proposals.relative(addressPrefix, safeAddress)); - } - }; - - const values = useMemo( - () => ({ proposalMetadata: { title, description } }), - [title, description], - ); - - const prepareProposalData = useCallback(async () => { - if (!safeAddress) { - throw new Error('Can not create stream without DAO address set'); - } - const targets: Address[] = []; - const txValues: bigint[] = []; - const calldatas: Hash[] = []; - - const groupedStreams = groupBy(streams, 'tokenAddress'); - - Object.keys(groupedStreams).forEach(token => { - const tokenAddress = getAddress(token); - const tokenStreams = groupedStreams[token]; - const approvedTotal = tokenStreams.reduce( - (prev, curr) => prev + (curr.totalAmount.bigintValue || 0n), - 0n, - ); - const approveCalldata = encodeFunctionData({ - abi: erc20Abi, - functionName: 'approve', - args: [sablierV2Batch, approvedTotal], - }); - targets.push(tokenAddress); - txValues.push(0n); - calldatas.push(approveCalldata); + const prepareProposalData = useCallback( + async (values: CreateProposalForm | CreateSablierProposalForm) => { + const { streams, proposalMetadata } = values as CreateSablierProposalForm; + if (!safe?.address) { + throw new Error('Can not create stream without DAO address set'); + } else if (!streams) { + throw new Error('Can not create streams without streams values set'); + } + const targets: Address[] = []; + const txValues: bigint[] = []; + const calldatas: Hash[] = []; + + const groupedStreams = groupBy(streams, 'tokenAddress'); + + Object.keys(groupedStreams).forEach(token => { + const tokenAddress = getAddress(token); + const tokenStreams = groupedStreams[token]; + const approvedTotal = tokenStreams.reduce( + (prev, curr) => prev + (curr.totalAmount.bigintValue || 0n), + 0n, + ); + const approveCalldata = encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [sablierV2Batch, approvedTotal], + }); - const createStreamsCalldata = encodeFunctionData({ - abi: SablierV2BatchAbi, - functionName: 'createWithDurationsLT', - args: [ - sablierV2LockupTranched, - tokenAddress, - tokenStreams.map(stream => ({ - sender: safeAddress, - recipient: getAddress(stream.recipientAddress), - totalAmount: stream.totalAmount.bigintValue!, - broker: { - account: zeroAddress, - fee: 0n, - }, - cancelable: stream.cancelable, - transferable: stream.transferable, - tranches: stream.tranches.map(tranche => ({ - amount: tranche.amount.bigintValue!, - duration: Number(tranche.duration.bigintValue!), + targets.push(tokenAddress); + txValues.push(0n); + calldatas.push(approveCalldata); + + const createStreamsCalldata = encodeFunctionData({ + abi: SablierV2BatchAbi, + functionName: 'createWithDurationsLT', + args: [ + sablierV2LockupTranched, + tokenAddress, + tokenStreams.map(stream => ({ + sender: safe?.address, + recipient: getAddress(stream.recipientAddress), + totalAmount: stream.totalAmount.bigintValue!, + broker: { + account: zeroAddress, + fee: 0n, + }, + cancelable: stream.cancelable, + transferable: stream.transferable, + tranches: stream.tranches.map(tranche => ({ + amount: tranche.amount.bigintValue!, + duration: Number(tranche.duration.bigintValue!), + })), })), - })), - ], - }); - - targets.push(sablierV2Batch); - txValues.push(0n); - calldatas.push(createStreamsCalldata); - }); - - return { - targets, - values: txValues, - calldatas, - metaData: values.proposalMetadata, - }; - }, [values, streams, sablierV2Batch, sablierV2LockupTranched, safeAddress]); - - const handleSubmit = async (event: FormEvent) => { - event.preventDefault(); - if (!canUserCreateProposal) { - toast.error(t('errorNotProposer', { ns: 'common' })); - } - - try { - const proposalData = await prepareProposalData(); - if (proposalData) { - submitProposal({ - proposalData, - nonce: safe?.nextNonce, - pendingToastMessage: t('proposalCreatePendingToastMessage', { ns: 'proposal' }), - successToastMessage: t('proposalCreateSuccessToastMessage', { ns: 'proposal' }), - failedToastMessage: t('proposalCreateFailureToastMessage', { ns: 'proposal' }), - successCallback, + ], }); - } - } catch (e) { - console.error(e); - toast.error(t('encodingFailedMessage', { ns: 'proposal' })); - } - }; - const invalidStreams = useMemo( - () => - streams.filter(stream => { - if (!stream.recipientAddress || !stream.tokenAddress || !stream.totalAmount) { - return true; - } - const invalidTranches = stream.tranches.filter((tranche, index) => { - if ( - index === 0 && - (!tranche.duration.bigintValue || !(tranche.duration.bigintValue > 0n)) - ) { - return true; - } - }); - if (invalidTranches.length > 0) { - return true; - } - return false; - }), - [streams], + targets.push(sablierV2Batch); + txValues.push(0n); + calldatas.push(createStreamsCalldata); + }); + + return { + targets, + values: txValues, + calldatas, + metaData: proposalMetadata, + }; + }, + [sablierV2Batch, sablierV2LockupTranched, safe?.address], ); - if (!type || !safeAddress || !safe) { + const HEADER_HEIGHT = useHeaderHeight(); + const location = useLocation(); + + if (!type || !safe?.address || !safe) { return (
@@ -902,99 +128,82 @@ export function SafeSablierProposalCreatePage() { ); } + const prevStepUrl = `${location.pathname.replace(CreateProposalSteps.TRANSACTIONS, CreateProposalSteps.METADATA)}${location.search}`; + const nextStepUrl = `${location.pathname.replace(CreateProposalSteps.METADATA, CreateProposalSteps.TRANSACTIONS)}${location.search}`; + + const pageHeaderBreadcrumbs = [ + { + terminus: t('proposals', { ns: 'breadcrumbs' }), + path: DAO_ROUTES.proposals.relative(addressPrefix, safe.address), + }, + { + terminus: t('proposalNew', { ns: 'breadcrumbs' }), + path: '', + }, + ]; + + const pageHeaderButtonClickHandler = () => { + navigate(DAO_ROUTES.proposals.relative(addressPrefix, safe.address)); + }; + + const stepButtons = ({ + formErrors, + createProposalBlocked, + }: { + formErrors: boolean; + createProposalBlocked: boolean; + }) => { + return ( + + } + transactionsStepButtons={ + <> + + + + } + /> + ); + }; + return ( -
- - - navigate( - safeAddress - ? DAO_ROUTES.proposals.relative(addressPrefix, safeAddress) - : BASE_ROUTES.landing, - ), - isDisabled: pendingCreateTx, - }} - /> - - - - - - - } - /> - - } - /> - - } - /> - - - 0} - /> - - - - - - - -
+ } + prepareProposalData={prepareProposalData} + contentRoute={(formikProps, pendingCreateTx, nonce) => { + return ( + + + formikProps.setFieldValue('nonce', newNonce)} + /> + + } + /> + ); + }} + /> ); } diff --git a/src/pages/dao/treasury/SafeTreasuryPage.tsx b/src/pages/dao/treasury/SafeTreasuryPage.tsx index bbad65aeb7..05364b99d3 100644 --- a/src/pages/dao/treasury/SafeTreasuryPage.tsx +++ b/src/pages/dao/treasury/SafeTreasuryPage.tsx @@ -1,8 +1,7 @@ import * as amplitude from '@amplitude/analytics-browser'; -import { Box, Divider, Flex, Grid, GridItem, Show, useDisclosure } from '@chakra-ui/react'; +import { Box, Divider, Flex, Grid, GridItem, Show } from '@chakra-ui/react'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; import { Assets } from '../../../components/DAOTreasury/components/Assets'; import { PaginationButton, @@ -10,27 +9,17 @@ import { Transactions, } from '../../../components/DAOTreasury/components/Transactions'; import { TitledInfoBox } from '../../../components/ui/containers/TitledInfoBox'; -import { ModalBase } from '../../../components/ui/modals/ModalBase'; -import { SendAssetsData, SendAssetsModal } from '../../../components/ui/modals/SendAssetsModal'; import PageHeader from '../../../components/ui/page/Header/PageHeader'; -import { DAO_ROUTES } from '../../../constants/routes'; +import useSendAssetsActionModal from '../../../hooks/DAO/useSendAssetsActionModal'; import { useCanUserCreateProposal } from '../../../hooks/utils/useCanUserSubmitProposal'; import { analyticsEvents } from '../../../insights/analyticsEvents'; import { useFractal } from '../../../providers/App/AppProvider'; -import { useNetworkConfigStore } from '../../../providers/NetworkConfig/useNetworkConfigStore'; -import { useProposalActionsStore } from '../../../store/actions/useProposalActionsStore'; import { useDaoInfoStore } from '../../../store/daoInfo/useDaoInfoStore'; -import { ProposalActionType } from '../../../types'; -import { - isNativeAsset, - prepareSendAssetsActionData, -} from '../../../utils/dao/prepareSendAssetsActionData'; export function SafeTreasuryPage() { useEffect(() => { amplitude.track(analyticsEvents.TreasuryPageOpened); }, []); - const { safe } = useDaoInfoStore(); const { treasury: { assetsFungible, transfers }, } = useFractal(); @@ -38,10 +27,7 @@ export function SafeTreasuryPage() { const [shownTransactions, setShownTransactions] = useState(20); const { t } = useTranslation(['treasury', 'modals']); const { canUserCreateProposal } = useCanUserCreateProposal(); - const { isOpen, onOpen, onClose } = useDisclosure(); - const { addAction } = useProposalActionsStore(); - const navigate = useNavigate(); - const { addressPrefix } = useNetworkConfigStore(); + const hasAnyBalanceOfAnyFungibleTokens = assetsFungible.reduce((p, c) => p + BigInt(c.balance), 0n) > 0n; @@ -49,41 +35,7 @@ export function SafeTreasuryPage() { const totalTransfers = transfers?.length || 0; const showLoadMoreTransactions = totalTransfers > shownTransactions && shownTransactions < 100; - - const sendAssetsAction = async (sendAssetsData: SendAssetsData) => { - if (!safe?.address) { - return; - } - const isNative = isNativeAsset(sendAssetsData.asset); - const transactionData = prepareSendAssetsActionData({ - transferAmount: sendAssetsData.transferAmount, - asset: sendAssetsData.asset, - destinationAddress: sendAssetsData.destinationAddress, - }); - addAction({ - actionType: ProposalActionType.TRANSFER, - content: <>, - transactions: [ - { - targetAddress: transactionData.calldata, - ethValue: { - bigintValue: transactionData.value, - value: transactionData.value.toString(), - }, - functionName: isNative ? '' : 'transfer', - parameters: isNative - ? [] - : [ - { signature: 'address', value: sendAssetsData.destinationAddress }, - { signature: 'uint256', value: sendAssetsData.transferAmount.toString() }, - ], - }, - ], - }); - navigate(DAO_ROUTES.proposalWithActionsNew.relative(addressPrefix, safe.address)); - - onClose(); - }; + const { openSendAssetsModal } = useSendAssetsActionModal(); return ( @@ -104,7 +56,7 @@ export function SafeTreasuryPage() { showSendButton ? { children: t('buttonSendAssets'), - onClick: onOpen, + onClick: openSendAssetsModal, } : undefined } @@ -163,18 +115,6 @@ export function SafeTreasuryPage() { - - - ); } diff --git a/src/providers/NetworkConfig/networks/base.ts b/src/providers/NetworkConfig/networks/base.ts index 0ccb3258d6..e1370fba4b 100644 --- a/src/providers/NetworkConfig/networks/base.ts +++ b/src/providers/NetworkConfig/networks/base.ts @@ -97,6 +97,7 @@ export const baseConfig: NetworkConfig = { sablierV2LockupDynamic: '0xF9E9eD67DD2Fab3b3ca024A2d66Fcf0764d36742', sablierV2LockupTranched: '0xf4937657Ed8B3f3cB379Eed47b8818eE947BEb1e', sablierV2LockupLinear: '0x4CB16D4153123A74Bc724d161050959754f378D8', + disperse: '0xD152f549545093347A162Dce210e7293f1452150', }, staking: {}, moralis: { diff --git a/src/providers/NetworkConfig/networks/mainnet.ts b/src/providers/NetworkConfig/networks/mainnet.ts index 09f9316155..89ca091b48 100644 --- a/src/providers/NetworkConfig/networks/mainnet.ts +++ b/src/providers/NetworkConfig/networks/mainnet.ts @@ -97,6 +97,7 @@ export const mainnetConfig: NetworkConfig = { sablierV2LockupDynamic: '0x9DeaBf7815b42Bf4E9a03EEc35a486fF74ee7459', sablierV2LockupTranched: '0xf86B359035208e4529686A1825F2D5BeE38c28A8', sablierV2LockupLinear: '0x3962f6585946823440d274aD7C719B02b49DE51E', + disperse: '0xD152f549545093347A162Dce210e7293f1452150', }, staking: { lido: { diff --git a/src/providers/NetworkConfig/networks/optimism.ts b/src/providers/NetworkConfig/networks/optimism.ts index 9fdee24387..05ea2e9aca 100644 --- a/src/providers/NetworkConfig/networks/optimism.ts +++ b/src/providers/NetworkConfig/networks/optimism.ts @@ -97,6 +97,7 @@ export const optimismConfig: NetworkConfig = { sablierV2LockupDynamic: '0x4994325F8D4B4A36Bd643128BEb3EC3e582192C0', sablierV2LockupTranched: '0x90952912a50079bef00D5F49c975058d6573aCdC', sablierV2LockupLinear: '0x5C22471A86E9558ed9d22235dD5E0429207ccf4B', + disperse: '0xD152f549545093347A162Dce210e7293f1452150', }, staking: {}, moralis: { diff --git a/src/providers/NetworkConfig/networks/polygon.ts b/src/providers/NetworkConfig/networks/polygon.ts index 84d6da1fb1..eaa980a01a 100644 --- a/src/providers/NetworkConfig/networks/polygon.ts +++ b/src/providers/NetworkConfig/networks/polygon.ts @@ -97,6 +97,7 @@ export const polygonConfig: NetworkConfig = { sablierV2LockupDynamic: '0x4994325F8D4B4A36Bd643128BEb3EC3e582192C0', sablierV2LockupTranched: '0xBF67f0A1E847564D0eFAD475782236D3Fa7e9Ec2', sablierV2LockupLinear: '0x8D4dDc187a73017a5d7Cef733841f55115B13762', + disperse: '0xD152f549545093347A162Dce210e7293f1452150', }, staking: {}, moralis: { diff --git a/src/providers/NetworkConfig/networks/sepolia.ts b/src/providers/NetworkConfig/networks/sepolia.ts index 6db0dc938e..61b387dff0 100644 --- a/src/providers/NetworkConfig/networks/sepolia.ts +++ b/src/providers/NetworkConfig/networks/sepolia.ts @@ -97,6 +97,7 @@ export const sepoliaConfig: NetworkConfig = { sablierV2LockupDynamic: '0x73BB6dD3f5828d60F8b3dBc8798EB10fbA2c5636', sablierV2LockupTranched: '0x3a1beA13A8C24c0EA2b8fAE91E4b2762A59D7aF5', sablierV2LockupLinear: '0x3E435560fd0a03ddF70694b35b673C25c65aBB6C', + disperse: '0xD152f549545093347A162Dce210e7293f1452150', }, staking: {}, moralis: { diff --git a/src/providers/NetworkConfig/web3-modal.config.ts b/src/providers/NetworkConfig/web3-modal.config.ts index 2d9e4e2d61..038666c3ee 100644 --- a/src/providers/NetworkConfig/web3-modal.config.ts +++ b/src/providers/NetworkConfig/web3-modal.config.ts @@ -33,9 +33,6 @@ export const wagmiConfig = defaultWagmiConfig({ projectId: walletConnectProjectId, metadata, transports: supportedNetworks.reduce(transportsReducer, {}), - batch: { - multicall: true, - }, }); if (walletConnectProjectId) { diff --git a/src/types/network.ts b/src/types/network.ts index 879daf9106..845e407548 100644 --- a/src/types/network.ts +++ b/src/types/network.ts @@ -68,6 +68,7 @@ export type NetworkConfig = { sablierV2LockupDynamic: Address; sablierV2LockupTranched: Address; sablierV2LockupLinear: Address; + disperse: Address; }; staking: { lido?: { diff --git a/src/types/proposalBuilder.ts b/src/types/proposalBuilder.ts index 0c1ab94959..3facd1d20b 100644 --- a/src/types/proposalBuilder.ts +++ b/src/types/proposalBuilder.ts @@ -23,20 +23,32 @@ export type CreateProposalMetadata = { documentationUrl?: string; }; -export enum ProposalBuilderMode { - // @dev - this is temporary mode. - // Probably will be removed in the future and actions are will be there by default. - // UI / UX for this globally is in flux. - PROPOSAL_WITH_ACTIONS = 'PROPOSAL_WITH_ACTIONS', - PROPOSAL = 'PROPOSAL', - TEMPLATE = 'TEMPLATE', -} export type CreateProposalForm = { transactions: CreateProposalTransaction[]; proposalMetadata: CreateProposalMetadata; nonce?: number; }; +export type Tranche = { + amount: BigIntValuePair; + duration: BigIntValuePair; +}; + +export type Stream = { + type: 'tranched'; + tokenAddress: string; + recipientAddress: string; + startDate: Date; + tranches: Tranche[]; + totalAmount: BigIntValuePair; + cancelable: boolean; + transferable: boolean; +}; + +export type CreateSablierProposalForm = { + streams: Stream[]; +} & CreateProposalForm; + export type ProposalTemplate = { transactions: CreateProposalTransaction[]; } & CreateProposalMetadata; @@ -46,6 +58,7 @@ export enum ProposalActionType { EDIT = 'edit', DELETE = 'delete', TRANSFER = 'transfer', + AIRDROP = 'airdrop', } export interface ProposalActionsStoreData { diff --git a/src/utils/dao/prepareSendAssetsActionData.ts b/src/utils/dao/prepareSendAssetsActionData.ts index 746a3231ad..fa3c650597 100644 --- a/src/utils/dao/prepareSendAssetsActionData.ts +++ b/src/utils/dao/prepareSendAssetsActionData.ts @@ -32,7 +32,7 @@ export const prepareSendAssetsActionData = ({ } const actionData = { - target: target, + target, value: isNative ? transferAmount : 0n, calldata, };