diff --git a/src/components/ProposalBuilder/ProposalActionCard.tsx b/src/components/ProposalBuilder/ProposalActionCard.tsx index 09057e1d3..2a875c5ca 100644 --- a/src/components/ProposalBuilder/ProposalActionCard.tsx +++ b/src/components/ProposalBuilder/ProposalActionCard.tsx @@ -6,18 +6,17 @@ import PencilWithLineIcon from '../../assets/theme/custom/icons/PencilWithLineIc import { useGetAccountName } from '../../hooks/utils/useGetAccountName'; import { useFractal } from '../../providers/App/AppProvider'; import { useProposalActionsStore } from '../../store/actions/useProposalActionsStore'; +import { TokenBalance } from '../../types'; import { CreateProposalAction, ProposalActionType } from '../../types/proposalBuilder'; import { Card } from '../ui/cards/Card'; import { SendAssetsData } from '../ui/modals/SendAssetsModal'; export function SendAssetsAction({ - index, action, onRemove, }: { - index: number; action: SendAssetsData; - onRemove: (index: number) => void; + onRemove: () => void; }) { const { t } = useTranslation('common'); const { displayName } = useGetAccountName(action.destinationAddress); @@ -46,9 +45,54 @@ export function SendAssetsAction({ color="red-0" variant="tertiary" size="sm" - onClick={() => { - onRemove(index); - }} + onClick={onRemove} + > + + + + + ); +} + +export function AirdropAction({ + totalAmount, + recipientsCount, + onRemove, + asset, +}: { + totalAmount: bigint; + recipientsCount: number; + onRemove: () => void; + asset: TokenBalance; +}) { + const { t } = useTranslation('common'); + return ( + + + + + {t('airdrop')} + + {formatUnits(totalAmount, asset.decimals)} {asset.symbol} + + {t('to').toLowerCase()} + + {recipientsCount} {t('recipients')} + + + @@ -75,12 +119,14 @@ export function ProposalActionCard({ ? 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) === destinationAddress, + asset => getAddress(asset.tokenAddress) === getAddress(action.transactions[0].targetAddress), ); if (!actionAsset) { @@ -89,14 +135,40 @@ export function ProposalActionCard({ return ( removeAction(index)} + /> + ); + } else if (action.actionType === ProposalActionType.AIRDROP) { + 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 ( + removeAction(index)} /> ); } diff --git a/src/components/ProposalBuilder/StepButtons.tsx b/src/components/ProposalBuilder/StepButtons.tsx index cb3e2bd01..3ab25401a 100644 --- a/src/components/ProposalBuilder/StepButtons.tsx +++ b/src/components/ProposalBuilder/StepButtons.tsx @@ -57,7 +57,9 @@ export default function StepButtons(props: StepButtonsProps) { type="submit" isDisabled={ !canUserCreateProposal || + !!proposalMetadataError || !!transactionsError || + !proposalMetadata.title || !!nonceError || pendingTransaction } diff --git a/src/components/ProposalTemplates/ExampleTemplateCard.tsx b/src/components/ProposalTemplates/ExampleTemplateCard.tsx new file mode 100644 index 000000000..6e15dccd2 --- /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/ui/modals/AirdropModal.tsx b/src/components/ui/modals/AirdropModal.tsx new file mode 100644 index 000000000..9e8478f9f --- /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 87fc066a6..47308b5b3 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 489ae5315..9cc6d5b61 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 000000000..8958e1bcf --- /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 6490f8349..4b7a1666e 100644 --- a/src/hooks/utils/useCreateRoles.ts +++ b/src/hooks/utils/useCreateRoles.ts @@ -1704,11 +1704,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/hooks/utils/useGetSafeName.ts b/src/hooks/utils/useGetSafeName.ts index 6884019ec..a5bb09448 100644 --- a/src/hooks/utils/useGetSafeName.ts +++ b/src/hooks/utils/useGetSafeName.ts @@ -19,7 +19,7 @@ export const getSafeName = async ( const mainnetPublicClient = createPublicClient({ chain: mainnet.chain, - transport: http(mainnet.rpcEndpoint), + transport: http(mainnet.rpcEndpoint, { batch: true }), }); const ensName = await mainnetPublicClient.getEnsName({ address }); if (ensName) { diff --git a/src/hooks/utils/useResolveENSName.ts b/src/hooks/utils/useResolveENSName.ts index 7ba84734b..098e4d4c9 100644 --- a/src/hooks/utils/useResolveENSName.ts +++ b/src/hooks/utils/useResolveENSName.ts @@ -45,7 +45,7 @@ export const useResolveENSName = () => { const mainnetPublicClient = createPublicClient({ chain: mainnet.chain, - transport: http(mainnet.rpcEndpoint), + transport: http(mainnet.rpcEndpoint, { batch: true }), }); const resolvedAddress = await mainnetPublicClient.getEnsAddress({ name: normalizedName }); if (resolvedAddress) { diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index f5d6197f8..4531cbc3c 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -118,5 +118,7 @@ "automaticChainSwitchingErrorMessage": "We couldn't automatically switch to the DAO's network. Please try to switch networks in your connected wallet.", "and": "and", "days": "Days", - "owner": "Owner" + "owner": "Owner", + "recipients": "recipients", + "airdrop": "Airdrop" } diff --git a/src/i18n/locales/en/modals.json b/src/i18n/locales/en/modals.json index 8a4b0b307..537e05781 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 Ethereum wallet address of the recipient", + "airdropAmountSublabel": "Enter the amount of tokens to be airdropped to the recipient", + "addRecipient": "Add Recipient" } diff --git a/src/i18n/locales/en/proposalTemplate.json b/src/i18n/locales/en/proposalTemplate.json index fa447c097..0cd409dbd 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 236954196..06f97af5c 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/proposals/actions/new/SafeProposalWithActionsCreatePage.tsx b/src/pages/dao/proposals/actions/new/SafeProposalWithActionsCreatePage.tsx index 42f96c639..32cc0ba3a 100644 --- a/src/pages/dao/proposals/actions/new/SafeProposalWithActionsCreatePage.tsx +++ b/src/pages/dao/proposals/actions/new/SafeProposalWithActionsCreatePage.tsx @@ -1,6 +1,6 @@ import * as amplitude from '@amplitude/analytics-browser'; import { Center } from '@chakra-ui/react'; -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { ProposalBuilder } from '../../../../../components/ProposalBuilder'; import { DEFAULT_PROPOSAL } from '../../../../../components/ProposalBuilder/constants'; import { BarLoader } from '../../../../../components/ui/loaders/BarLoader'; @@ -23,6 +23,7 @@ export function SafeProposalWithActionsCreatePage() { const { prepareProposal } = usePrepareProposal(); const { getTransactions } = useProposalActionsStore(); + const transactions = useMemo(() => getTransactions(), [getTransactions]); const HEADER_HEIGHT = useHeaderHeight(); @@ -38,7 +39,7 @@ export function SafeProposalWithActionsCreatePage() { { 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 0ccb3258d..e1370fba4 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 09f931615..89ca091b4 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 9fdee2438..05ea2e9ac 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 84d6da1fb..eaa980a01 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 6db0dc938..61b387dff 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 2d9e4e2d6..7f1a684ca 100644 --- a/src/providers/NetworkConfig/web3-modal.config.ts +++ b/src/providers/NetworkConfig/web3-modal.config.ts @@ -24,7 +24,7 @@ export const transportsReducer = ( accumulator: Record, network: NetworkConfig, ) => { - accumulator[network.chain.id] = http(network.rpcEndpoint); + accumulator[network.chain.id] = http(network.rpcEndpoint, { batch: true }); return accumulator; }; diff --git a/src/types/network.ts b/src/types/network.ts index 879daf910..845e40754 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 9fa2a9187..24cb96645 100644 --- a/src/types/proposalBuilder.ts +++ b/src/types/proposalBuilder.ts @@ -69,6 +69,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 746a3231a..fa3c65059 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, };