diff --git a/src/components/pages/Roles/RoleCard.tsx b/src/components/pages/Roles/RoleCard.tsx index cd68b3f5f..e4a9013ca 100644 --- a/src/components/pages/Roles/RoleCard.tsx +++ b/src/components/pages/Roles/RoleCard.tsx @@ -10,7 +10,13 @@ import { Card } from '../../ui/cards/Card'; import EtherscanLink from '../../ui/links/EtherscanLink'; import Avatar from '../../ui/page/Header/Avatar'; import EditBadge from './EditBadge'; -import { RoleEditProps, RoleProps, SablierPayroll, SablierVesting } from './types'; +import { + RoleEditProps, + RoleProps, + SablierPayroll, + SablierVesting, + frequencyOptions, +} from './types'; export function AvatarAndRoleName({ wearerAddress, @@ -92,13 +98,13 @@ function PayrollAndVesting({ my="0.5rem" > {payrollData.asset.symbol} - {payrollData.payrollAmount} + {payrollData.amount.value} - {'/'} {payrollData.payrollSchedule} + {'/'} {t(`${frequencyOptions[payrollData.paymentFrequency]}Short`)} diff --git a/src/components/pages/Roles/RolesTable.tsx b/src/components/pages/Roles/RolesTable.tsx index 74c9616a4..281b8c760 100644 --- a/src/components/pages/Roles/RolesTable.tsx +++ b/src/components/pages/Roles/RolesTable.tsx @@ -12,7 +12,14 @@ import EtherscanLink from '../../ui/links/EtherscanLink'; import Avatar from '../../ui/page/Header/Avatar'; import EditBadge from './EditBadge'; import { RoleCardLoading, RoleCardNoRoles } from './RolePageCard'; -import { RoleEditProps, RoleFormValues, RoleProps, SablierPayroll, SablierVesting } from './types'; +import { + RoleEditProps, + RoleFormValues, + RoleProps, + SablierPayroll, + SablierVesting, + frequencyOptions, +} from './types'; function RolesHeader({ addHiddenColumn }: { addHiddenColumn?: boolean }) { const { t } = useTranslation(['roles']); @@ -124,7 +131,7 @@ function MemberColumn({ wearerAddress }: { wearerAddress: string | undefined }) } function PayrollColumn({ payrollData }: { payrollData: SablierPayroll | undefined }) { - const { t } = useTranslation(['daoCreate']); + const { t } = useTranslation(['roles', 'daoCreate']); return ( @@ -136,13 +143,13 @@ function PayrollColumn({ payrollData }: { payrollData: SablierPayroll | undefine my="0.5rem" > {payrollData.asset.symbol} - {payrollData.payrollAmount} + {payrollData.amount.value} - {'/'} {payrollData.payrollSchedule} + {'/'} {t(`${frequencyOptions[payrollData.paymentFrequency]}Short`)} @@ -168,7 +175,7 @@ function PayrollColumn({ payrollData }: { payrollData: SablierPayroll | undefine textStyle="body-base" color="neutral-6" > - {t('n/a')} + {t('n/a', { ns: 'daoCreate' })} )} @@ -366,13 +373,14 @@ export function RolesEditTable({ handleRoleClick }: { handleRoleClick: (hatId: H {values.hats.map(role => ( { setFieldValue('roleEditing', role); handleRoleClick(role.id); }} editStatus={role.editedRole?.status} - {...role} + payrollData={role.payroll} /> ))} diff --git a/src/components/pages/Roles/forms/RoleFormInfo.tsx b/src/components/pages/Roles/forms/RoleFormInfo.tsx index 6f47be50f..5c7ac4405 100644 --- a/src/components/pages/Roles/forms/RoleFormInfo.tsx +++ b/src/components/pages/Roles/forms/RoleFormInfo.tsx @@ -19,7 +19,7 @@ export default function RoleFormInfo() { borderRadius="0.5rem" > - + {({ field, form: { setFieldValue, setFieldTouched }, @@ -52,7 +52,7 @@ export default function RoleFormInfo() { - + {({ field, form: { setFieldValue, setFieldTouched }, @@ -86,7 +86,7 @@ export default function RoleFormInfo() { - + {({ field, form: { setFieldValue, setFieldTouched }, diff --git a/src/components/pages/Roles/forms/RoleFormPayroll.tsx b/src/components/pages/Roles/forms/RoleFormPayroll.tsx index e663ef0fb..9715786f6 100644 --- a/src/components/pages/Roles/forms/RoleFormPayroll.tsx +++ b/src/components/pages/Roles/forms/RoleFormPayroll.tsx @@ -36,6 +36,7 @@ import { CARD_SHADOW, TOOLTIP_MAXW } from '../../../../constants/common'; import { useFractal } from '../../../../providers/App/AppProvider'; import { BigIntValuePair } from '../../../../types'; import { DEFAULT_DATE_FORMAT, formatUSD } from '../../../../utils'; +import { MOCK_MORALIS_ETH_ADDRESS } from '../../../../utils/address'; import DraggableDrawer from '../../../ui/containers/DraggableDrawer'; import { BigIntInput } from '../../../ui/forms/BigIntInput'; import LabelWrapper from '../../../ui/forms/LabelWrapper'; @@ -104,7 +105,9 @@ function AssetSelector() { const { treasury: { assetsFungible }, } = useFractal(); - const fungibleAssetsWithBalance = assetsFungible.filter(asset => parseFloat(asset.balance) > 0); + const fungibleAssetsWithBalance = assetsFungible.filter( + asset => parseFloat(asset.balance) > 0 && asset.tokenAddress !== MOCK_MORALIS_ETH_ADDRESS, // Can't stream native token + ); const { values, setFieldValue } = useFormikContext(); const selectedAsset = values.roleEditing?.payroll?.asset; return ( @@ -455,7 +458,6 @@ function PaymentStartDatePicker() { ); } -// @todo @dev is this frequency or period??? function PaymentFrequency() { const { t } = useTranslation(['roles']); return ( diff --git a/src/components/pages/Roles/types.tsx b/src/components/pages/Roles/types.tsx index 3290d930c..47dd179ba 100644 --- a/src/components/pages/Roles/types.tsx +++ b/src/components/pages/Roles/types.tsx @@ -20,17 +20,17 @@ export interface SablierVesting { } export interface SablierPayroll { - payrollSchedule: string; - payrollAmount: string; - payrollAmountUSD: string; - payrollStartDate: string; - payrollEndDate: string; asset: { address: Address; - symbol: string; name: string; - iconUri: string; + symbol: string; + decimals: number; + logo: string; }; + amount: BigIntValuePair; + paymentFrequency: Frequency; + paymentStartDate: Date; + paymentFrequencyNumber: number; } export interface RoleProps { editStatus?: EditBadgeStatus; @@ -81,24 +81,10 @@ export interface EditedRole { status: EditBadgeStatus; } -export interface RoleFormPayrollValue { - asset: { - address: Address; - name: string; - symbol: string; - decimals: number; - logo: string; - }; - amount: BigIntValuePair; - paymentFrequency: string; - paymentStartDate: Date; - paymentFrequencyNumber: number; -} - export interface RoleValue extends Omit { wearer: string; editedRole?: EditedRole; - payroll?: RoleFormPayrollValue; + payroll?: SablierPayroll; } export interface RoleFormValues { diff --git a/src/components/ui/stream/PayrollStreamBuilder.tsx b/src/components/ui/stream/PayrollStreamBuilder.tsx deleted file mode 100644 index b84af33f6..000000000 --- a/src/components/ui/stream/PayrollStreamBuilder.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import { Box, Button, Flex, VStack, Select } from '@chakra-ui/react'; -import { CaretDown } from '@phosphor-icons/react'; -import { useCallback, useEffect, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; -import { Address } from 'viem'; -import { useAccount } from 'wagmi'; -import { DAO_ROUTES } from '../../../constants/routes'; -import useSubmitProposal from '../../../hooks/DAO/proposal/useSubmitProposal'; -import useCreateSablierStream from '../../../hooks/streams/useCreateSablierStream'; -import { useFractal } from '../../../providers/App/AppProvider'; -import { useNetworkConfig } from '../../../providers/NetworkConfig/NetworkConfigProvider'; -import { TokenBalance } from '../../../types'; -import { PayrollFrequency } from '../../../types/sablier'; -import { InputComponent } from '../forms/InputComponent'; -import LabelWrapper from '../forms/LabelWrapper'; -import Divider from '../utils/Divider'; - -export default function PayrollStreamBuilder() { - const { address: account } = useAccount(); - const { - node: { daoAddress, safe }, - treasury: { assetsFungible }, - } = useFractal(); - const { addressPrefix } = useNetworkConfig(); - const [recipient, setRecipient] = useState
(account); - const [totalAmount, setTotalAmount] = useState('25000'); - const [startDate, setStartDate] = useState(Math.round(Date.now() / 1000) + 60 * 15); // Unix timestamp. 15 minutes from moment of proposal creation by default for development purposes - const [frequency, setFrequency] = useState('monthly'); - const [months, setMonths] = useState(0); // Total number of months - // @todo - seems like good chunk of this logic can be re-used between payroll/vesting stream building and send assets modal - const fungibleAssetsWithBalance = assetsFungible.filter(asset => parseFloat(asset.balance) > 0); - const [selectedAsset, setSelectedAsset] = useState(fungibleAssetsWithBalance[0]); - const selectedAssetIndex = fungibleAssetsWithBalance.findIndex( - asset => asset.tokenAddress === selectedAsset?.tokenAddress, - ); - const { prepareCreateTranchedLockupProposal } = useCreateSablierStream(); - - const { submitProposal } = useSubmitProposal(); - const navigate = useNavigate(); - const { t } = useTranslation('proposal'); - - const successCallback = useCallback(() => { - if (daoAddress) { - navigate(DAO_ROUTES.proposals.relative(addressPrefix, daoAddress)); - } - }, [addressPrefix, daoAddress, navigate]); - - useEffect(() => { - if (fungibleAssetsWithBalance.length) { - setSelectedAsset(fungibleAssetsWithBalance[0]); - } - }, [fungibleAssetsWithBalance]); - - const handleSubmitProposal = useCallback(() => { - if (startDate && recipient && selectedAsset && frequency && months > 0) { - const proposalData = prepareCreateTranchedLockupProposal({ - months, - frequency, - totalAmount, - asset: selectedAsset, - recipient, - startDate, - }); - submitProposal({ - nonce: safe?.nonce, - pendingToastMessage: t('proposalCreatePendingToastMessage'), - successToastMessage: t('proposalCreateSuccessToastMessage'), - failedToastMessage: t('proposalCreateFailureToastMessage'), - successCallback, - proposalData, - }); - } - }, [ - frequency, - months, - prepareCreateTranchedLockupProposal, - recipient, - safe?.nonce, - selectedAsset, - startDate, - submitProposal, - successCallback, - t, - totalAmount, - ]); - - const inputBasicProps = { - testId: '', - isRequired: true, - inputContainerProps: { - width: '100%', - }, - gridContainerProps: { - width: '100%', - display: 'flex', - flexWrap: 'wrap' as const, - }, - }; - - return ( - - - - setRecipient(event.target.value as Address)} - /> - - - setTotalAmount(event.target.value)} - /> - - - - - - - - - - - - - setStartDate(parseInt(event.target.value))} - /> - - - setMonths(parseInt(event.target.value))} - /> - - - - - - ); -} diff --git a/src/hooks/schemas/roles/useRolesSchema.ts b/src/hooks/schemas/roles/useRolesSchema.ts index c56f8375b..9c22dec58 100644 --- a/src/hooks/schemas/roles/useRolesSchema.ts +++ b/src/hooks/schemas/roles/useRolesSchema.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import * as Yup from 'yup'; -import { Frequency, RoleFormPayrollValue, RoleValue } from '../../../components/pages/Roles/types'; +import { Frequency, SablierPayroll, RoleValue } from '../../../components/pages/Roles/types'; import { useValidationAddress } from '../common/useValidationAddress'; export const useRolesSchema = () => { @@ -27,7 +27,7 @@ export const useRolesSchema = () => { .default(undefined) .nullable() .when({ - is: (payroll: RoleFormPayrollValue) => payroll !== undefined, + is: (payroll: SablierPayroll) => payroll !== undefined, then: _payrollSchema => _payrollSchema.shape({ asset: Yup.object().shape({ diff --git a/src/hooks/streams/useCreateSablierStream.ts b/src/hooks/streams/useCreateSablierStream.ts index de0985aa8..75eed84fa 100644 --- a/src/hooks/streams/useCreateSablierStream.ts +++ b/src/hooks/streams/useCreateSablierStream.ts @@ -1,11 +1,11 @@ import { useCallback } from 'react'; -import { getAddress, zeroAddress, encodeFunctionData, erc20Abi, Address } from 'viem'; +import { getAddress, zeroAddress, encodeFunctionData, erc20Abi, Address, Hex } from 'viem'; import SablierV2BatchAbi from '../../assets/abi/SablierV2Batch'; +import { Frequency, SablierPayroll } from '../../components/pages/Roles/types'; import { useFractal } from '../../providers/App/AppProvider'; import { useNetworkConfig } from '../../providers/NetworkConfig/NetworkConfigProvider'; import { TokenBalance, ProposalExecuteData } from '../../types'; import { - PayrollFrequency, StreamAbsoluteSchedule, StreamRelativeSchedule, StreamSchedule, @@ -15,10 +15,10 @@ const SECONDS_IN_HOUR = 60 * 60; const SECONDS_IN_DAY = 24 * SECONDS_IN_HOUR; type DynamicOrTranchedStreamInputs = { - months: number; - frequency: PayrollFrequency; + frequencyNumber: number; + frequency: Frequency; totalAmount: string; - asset: TokenBalance; + asset: SablierPayroll['asset']; recipient: Address; startDate: number; }; @@ -32,12 +32,7 @@ type LinearStreamInputs = { }; export default function useCreateSablierStream() { const { - contracts: { - sablierV2LockupDynamic, - sablierV2LockupTranched, - sablierV2LockupLinear, - sablierV2Batch, - }, + contracts: { sablierV2LockupTranched, sablierV2LockupLinear, sablierV2Batch }, } = useNetworkConfig(); const { node: { daoAddress }, @@ -73,7 +68,7 @@ export default function useCreateSablierStream() { const prepareDynamicOrTranchedStream = useCallback( ({ - months, + frequencyNumber, frequency, totalAmount, asset, @@ -82,42 +77,40 @@ export default function useCreateSablierStream() { }: DynamicOrTranchedStreamInputs) => { const exponent = 10n ** BigInt(asset.decimals); const totalAmountInTokenDecimals = BigInt(totalAmount) * exponent; - let totalSegments = months; - if (frequency === 'weekly') { - // @todo - obviously this isn't correct and we need proper calculation of how many weeks are in the amount of months entered - totalSegments = months * 4; - } else if (frequency === 'biweekly') { - // @todo - again, not correct - need to get exact number of 2-weeks cycles from the total number of months - totalSegments = months * 2; - } - const segmentAmount = totalAmountInTokenDecimals / BigInt(totalSegments); + const segmentAmount = totalAmountInTokenDecimals / BigInt(frequencyNumber); // Sablier sets startTime to block.timestamp - so we need to simulate startTime through streaming 0 tokens at first segment till startDate - const segments: { amount: bigint; exponent: bigint; duration: number }[] = [ - { amount: 0n, exponent, duration: Math.round(startDate - Date.now() / 1000) }, + const tranches: { amount: bigint; exponent: bigint; duration: number }[] = [ + { amount: 0n, exponent, duration: Math.round((startDate - Date.now()) / 1000) }, ]; let days = 30; - if (frequency === 'weekly') { + if (frequency === Frequency.Weekly) { days = 7; - } else if (frequency === 'biweekly') { + } else if (frequency === Frequency.EveryTwoWeeks) { days = 14; } const duration = days * SECONDS_IN_DAY; - for (let i = 1; i <= totalSegments; i++) { - segments.push({ + for (let i = 1; i <= frequencyNumber; i++) { + tranches.push({ amount: segmentAmount, exponent, duration, }); } + const totalTranchesAmount = tranches.reduce((prev, curr) => prev + curr.amount, 0n); + if (totalTranchesAmount < totalAmountInTokenDecimals) { + // @dev We can't always equally divide between tranches, so we're putting the leftover into the very last tranche + tranches[tranches.length - 1].amount += totalAmountInTokenDecimals - totalTranchesAmount; + } + const tokenCalldata = prepareStreamTokenCallData(totalAmountInTokenDecimals); const basicStreamData = prepareBasicStreamData(recipient, totalAmountInTokenDecimals); const assembledStream = { ...basicStreamData, - segments, // Segments array of tuples + tranches, // Tranches array of tuples }; return { tokenCalldata, assembledStream }; @@ -164,61 +157,44 @@ export default function useCreateSablierStream() { [prepareBasicStreamData, prepareStreamTokenCallData], ); - const prepareCreateDynamicLockupProposal = useCallback( - (inputs: DynamicOrTranchedStreamInputs) => { - const { asset, recipient } = inputs; - const tokenAddress = getAddress(asset.tokenAddress); - const { tokenCalldata, assembledStream } = prepareDynamicOrTranchedStream(inputs); - - const sablierBatchCalldata = encodeFunctionData({ - abi: SablierV2BatchAbi, - functionName: 'createWithDurationsLD', - args: [sablierV2LockupDynamic, tokenAddress, [assembledStream]], - }); - - const proposalData: ProposalExecuteData = { - targets: [tokenAddress, sablierV2Batch], - values: [0n, 0n], - calldatas: [tokenCalldata, sablierBatchCalldata], - metaData: { - title: 'Create Payroll Stream for Role', - description: `This madafaking rocket science proposal will create AI Blockchain Crypto Currency Bitcoin BUIDL HODL Sablier V2 Stream of $$$ flowing to ${recipient}`, - documentationUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', - }, - }; + const prepareBatchTranchedStreamCreation = useCallback( + (tranchedStreams: SablierPayroll[], recipients: Address[]) => { + if (tranchedStreams.length !== recipients.length) { + throw new Error( + 'Parameters mismatch. Amount of created streams has to match amount of recipients', + ); + } - return proposalData; - }, - [sablierV2Batch, sablierV2LockupDynamic, prepareDynamicOrTranchedStream], - ); + const preparedStreamCreationTransactions: { calldata: Hex; targetAddress: Address }[] = []; + const preparedTokenApprovalsTransactions: { calldata: Hex; tokenAddress: Address }[] = []; + + tranchedStreams.forEach((streamData, index) => { + const recipient = recipients[index]; + const tokenAddress = streamData.asset.address; + // @todo - Smarter way would be to batch token approvals and streams creation, and not just build single approval + creation transactions for each stream + const { tokenCalldata, assembledStream } = prepareDynamicOrTranchedStream({ + recipient, + totalAmount: streamData.amount.value, + asset: streamData.asset, + frequencyNumber: streamData.paymentFrequencyNumber, + frequency: streamData.paymentFrequency, + startDate: streamData.paymentStartDate.getTime(), + }); - const prepareCreateTranchedLockupProposal = useCallback( - (inputs: DynamicOrTranchedStreamInputs) => { - const { asset, recipient } = inputs; - const tokenAddress = getAddress(asset.tokenAddress); - const { - tokenCalldata, - assembledStream: { segments: tranches, ...assembledTranchedStream }, - } = prepareDynamicOrTranchedStream(inputs); + const sablierBatchCalldata = encodeFunctionData({ + abi: SablierV2BatchAbi, + functionName: 'createWithDurationsLT', // Another option would be to use createWithTimestampsLT. Essentially they're doing the same, `WithDurations` just simpler for usage + args: [sablierV2LockupTranched, tokenAddress, [assembledStream]], + }); - const sablierBatchCalldata = encodeFunctionData({ - abi: SablierV2BatchAbi, - functionName: 'createWithDurationsLT', - args: [sablierV2LockupTranched, tokenAddress, [{ ...assembledTranchedStream, tranches }]], + preparedStreamCreationTransactions.push({ + calldata: sablierBatchCalldata, + targetAddress: sablierV2Batch, + }); + preparedTokenApprovalsTransactions.push({ calldata: tokenCalldata, tokenAddress }); }); - const proposalData: ProposalExecuteData = { - targets: [tokenAddress, sablierV2Batch], - values: [0n, 0n], - calldatas: [tokenCalldata, sablierBatchCalldata], - metaData: { - title: 'Create Payroll Stream for Role', - description: `This madafaking rocket science proposal will create AI Blockchain Crypto Currency Bitcoin BUIDL HODL Sablier V2 Stream of $$$ flowing to ${recipient}`, - documentationUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', - }, - }; - - return proposalData; + return { preparedStreamCreationTransactions, preparedTokenApprovalsTransactions }; }, [prepareDynamicOrTranchedStream, sablierV2Batch, sablierV2LockupTranched], ); @@ -251,8 +227,7 @@ export default function useCreateSablierStream() { ); return { - prepareCreateDynamicLockupProposal, - prepareCreateTranchedLockupProposal, prepareCreateLinearLockupProposal, + prepareBatchTranchedStreamCreation, }; } diff --git a/src/hooks/utils/rolesProposalFunctions.ts b/src/hooks/utils/rolesProposalFunctions.ts deleted file mode 100644 index 2e6f7e5e0..000000000 --- a/src/hooks/utils/rolesProposalFunctions.ts +++ /dev/null @@ -1,357 +0,0 @@ -import { zeroAddress, Address, encodeFunctionData, getAddress, Hex } from 'viem'; -import DecentHatsAbi from '../../assets/abi/DecentHats_0_1_0_Abi'; -import GnosisSafeL2 from '../../assets/abi/GnosisSafeL2'; -import { HatsAbi } from '../../assets/abi/HatsAbi'; -import HatsAccount1ofNAbi from '../../assets/abi/HatsAccount1ofN'; -import { - EditBadgeStatus, - HatStruct, - HatWearerChangedParams, - RoleValue, -} from '../../components/pages/Roles/types'; -import { DecentRoleHat } from '../../state/useRolesState'; -import { CreateProposalMetadata } from '../../types'; -import { SENTINEL_MODULE } from '../../utils/address'; - -const hatsDetailsBuilder = (data: { name: string; description: string }) => { - return JSON.stringify({ - type: '1.0', - data, - }); -}; - -const predictHatId = ({ adminHatId, hatsCount }: { adminHatId: Hex; hatsCount: number }) => { - // 1 byte = 8 bits = 2 string characters - const adminLevelBinary = adminHatId.slice(0, 14); // Top Admin ID 1 byte 0x + 4 bytes (tree ID) + next **16 bits** (admin level ID) - - // Each next level is next **16 bits** - // Since we're operating only with direct child of top level admin - we don't care about nested levels - // @dev At least for now? - const newSiblingId = (hatsCount + 1).toString(16).padStart(4, '0'); - - // Total length of Hat ID is **32 bytes** + 2 bytes for 0x - return BigInt(`${adminLevelBinary}${newSiblingId}`.padEnd(66, '0')); -}; - -const prepareAddHatsTxArgs = (addedHats: HatStruct[], adminHatId: Hex, topHatAccount: Address) => { - const admins: bigint[] = []; - const details: string[] = []; - const maxSupplies: number[] = []; - const eligibilityModules: Address[] = []; - const toggleModules: Address[] = []; - const mutables: boolean[] = []; - const imageURIs: string[] = []; - - addedHats.forEach(hat => { - admins.push(BigInt(adminHatId)); - details.push(hat.details); - maxSupplies.push(hat.maxSupply); - eligibilityModules.push(topHatAccount); - toggleModules.push(topHatAccount); - mutables.push(hat.isMutable); - imageURIs.push(hat.imageURI); - }); - - return [ - admins, - details, - maxSupplies, - eligibilityModules, - toggleModules, - mutables, - imageURIs, - ] as const; -}; - -const prepareMintHatsTxArgs = (addedHats: HatStruct[], adminHatId: Hex, hatsCount: number) => { - const hatIds: bigint[] = []; - const wearers: Address[] = []; - - addedHats.forEach((hat, i) => { - const predictedHatId = predictHatId({ - adminHatId, - // Each predicted hat id is based on the current hat count, plus however many hat id have been predicted so far - hatsCount: hatsCount + i, - }); - hatIds.push(predictedHatId); - wearers.push(hat.wearer); - }); - - return [hatIds, wearers] as const; -}; - -/** - * Given a list of edited hats, chunk them up into separate lists denoting the type of edit and the relevant updated data. - * This is to prepare the data for the creation of a proposal to edit hats, in `prepareCreateTopHatProposal` and `prepareEditHatsProposal`. - * @param editedHats The edited hats to be parsed. Form values from the roles form. - * @param getHat A function to get the hat details from the state, given a hat ID. Used to get the current wearer of a hat when the wearer is updated. - * @param uploadHatDescription A function to upload the hat description to IPFS. Returns the IPFS hash of the uploaded description, to be used to set the hat's details when it's created or updated. - */ -export const parseEditedHatsFormValues = async ( - editedHats: RoleValue[], - topHatAccount: Address, - getHat: (hatId: Hex) => DecentRoleHat | null, - uploadHatDescription: (hatDescription: string) => Promise, -) => { - // Parse added hats - const addedHats: HatStruct[] = await Promise.all( - editedHats - .filter(hat => hat.editedRole?.status === EditBadgeStatus.New) - .map(async hat => { - const details = await uploadHatDescription( - hatsDetailsBuilder({ - name: hat.name, - description: hat.description, - }), - ); - - return { - eligibility: topHatAccount, - toggle: topHatAccount, - maxSupply: 1, - details, - imageURI: '', - isMutable: true, - wearer: getAddress(hat.wearer), - }; - }), - ); - - // Parse removed hats - const removedHatIds = editedHats - .filter(hat => hat.editedRole?.status === EditBadgeStatus.Removed) - .map(hat => hat.id); - - // Parse member changed hats - const memberChangedHats: HatWearerChangedParams[] = editedHats - .filter( - hat => - hat.editedRole?.status === EditBadgeStatus.Updated && - hat.editedRole.fieldNames.includes('member'), - ) - .map(hat => ({ - id: hat.id, - currentWearer: getAddress(getHat(hat.id)!.wearer), - newWearer: getAddress(hat.wearer), - })) - .filter(hat => hat.currentWearer !== hat.newWearer); - - // Parse role details changed hats (name and/or description updated) - const roleDetailsChangedHats = await Promise.all( - editedHats - .filter( - hat => - hat.editedRole?.status === EditBadgeStatus.Updated && - (hat.editedRole.fieldNames.includes('roleName') || - hat.editedRole.fieldNames.includes('roleDescription')), - ) - .map(async hat => ({ - id: hat.id, - details: await uploadHatDescription( - hatsDetailsBuilder({ - name: hat.name, - description: hat.description, - }), - ), - })), - ); - - return { - addedHats, - removedHatIds, - memberChangedHats, - roleDetailsChangedHats, - }; -}; - -/** - * Prepare the data for a proposal add new hats to a safe that has never used the roles feature before. - * This proposal will create a new top hat, an admin hat under it, and any added hats under the admin hat. - * - * @param proposalMetadata The metadata for the proposal. - * @param addedHats The hat roles to be added to the safe. - * @param safeAddress The address of the safe. - * @param uploadHatDescription A function to upload the hat description to IPFS. Returns the IPFS hash of the uploaded description, to be used to set the hat's details. - */ -export const prepareCreateTopHatProposalData = async ( - proposalMetadata: CreateProposalMetadata, - addedHats: HatStruct[], - safeAddress: Address, - uploadHatDescription: (hatDescription: string) => Promise, - safeName: string, - decentHatsAddress: Address, - hatsAddress: Address, - hatsAccountImplementation: Address, - registry: Address, - keyValuePairs: Address, -) => { - const enableModuleData = encodeFunctionData({ - abi: GnosisSafeL2, - functionName: 'enableModule', - args: [decentHatsAddress], - }); - - const disableModuleData = encodeFunctionData({ - abi: GnosisSafeL2, - functionName: 'disableModule', - args: [SENTINEL_MODULE, decentHatsAddress], - }); - - const topHatDetails = await uploadHatDescription( - hatsDetailsBuilder({ - name: safeName, - description: '', - }), - ); - const adminHatDetails = await uploadHatDescription( - hatsDetailsBuilder({ - name: 'Admin', - description: '', - }), - ); - - const adminHat: HatStruct = { - maxSupply: 1, - details: adminHatDetails, - imageURI: '', - isMutable: true, - wearer: zeroAddress, - }; - - const createAndDeclareTreeData = encodeFunctionData({ - abi: DecentHatsAbi, - functionName: 'createAndDeclareTree', - args: [ - { - hatsProtocol: hatsAddress, - hatsAccountImplementation, - registry, - keyValuePairs, - topHatDetails, - topHatImageURI: '', - adminHat, - hats: addedHats, - }, - ], - }); - - return { - targets: [safeAddress, decentHatsAddress, safeAddress], - calldatas: [enableModuleData, createAndDeclareTreeData, disableModuleData], - metaData: proposalMetadata, - values: [0n, 0n, 0n], - }; -}; - -/** - * Prepare the data for a proposal to edit hats on a safe. - * - * @param proposalMetadata - * @param edits All the different updates that would be made to the safe's roles if this proposal is executed. - */ -export const prepareEditHatsProposalData = ( - proposalMetadata: CreateProposalMetadata, - edits: { - addedHats: HatStruct[]; - removedHatIds: Hex[]; - memberChangedHats: HatWearerChangedParams[]; - roleDetailsChangedHats: { - id: Address; - details: string; - }[]; - }, - adminHatId: Hex, - topHatAccount: Address, - hatsCount: number, - hatsContractAddress: Address, -) => { - const { addedHats, removedHatIds, memberChangedHats, roleDetailsChangedHats } = edits; - - const createAndMintHatsTxs: Hex[] = []; - let removeHatTxs: Hex[] = []; - let transferHatTxs: Hex[] = []; - let hatDetailsChangedTxs: Hex[] = []; - - if (addedHats.length) { - // First, prepare a single tx to create all the hats - const createHatsTx = encodeFunctionData({ - abi: HatsAbi, - functionName: 'batchCreateHats', - args: prepareAddHatsTxArgs(addedHats, adminHatId, topHatAccount), - }); - - // Finally, prepare a single tx to mint all the hats to the wearers - const mintHatsTx = encodeFunctionData({ - abi: HatsAbi, - functionName: 'batchMintHats', - args: prepareMintHatsTxArgs(addedHats, adminHatId, hatsCount), - }); - - // Push these two txs to the included txs array. - // They will be executed in order: add all hats first, then mint all hats to their respective wearers. - createAndMintHatsTxs.push(createHatsTx); - createAndMintHatsTxs.push(mintHatsTx); - } - - if (removedHatIds.length) { - removeHatTxs = removedHatIds.map(hatId => - // make transaction proxy through erc6551 contract - encodeFunctionData({ - abi: HatsAccount1ofNAbi, - functionName: 'execute', - args: [ - hatsContractAddress, - 0n, - encodeFunctionData({ - abi: HatsAbi, - functionName: 'setHatStatus', - args: [BigInt(hatId), false], - }), - 0, - ], - }), - ); - } - - if (memberChangedHats.length) { - transferHatTxs = memberChangedHats.map(({ id, currentWearer, newWearer }) => { - return encodeFunctionData({ - abi: HatsAbi, - functionName: 'transferHat', - args: [BigInt(id), currentWearer, newWearer], - }); - }); - } - - if (roleDetailsChangedHats.length) { - hatDetailsChangedTxs = roleDetailsChangedHats.map(({ id, details }) => { - return encodeFunctionData({ - abi: HatsAbi, - functionName: 'changeHatDetails', - args: [BigInt(id), details], - }); - }); - } - - return { - targets: [ - ...createAndMintHatsTxs.map(() => hatsContractAddress), - ...removeHatTxs.map(() => topHatAccount), - ...transferHatTxs.map(() => hatsContractAddress), - ...hatDetailsChangedTxs.map(() => hatsContractAddress), - ], - calldatas: [ - ...createAndMintHatsTxs, - ...removeHatTxs, - ...transferHatTxs, - ...hatDetailsChangedTxs, - ], - metaData: proposalMetadata, - values: [ - ...createAndMintHatsTxs.map(() => 0n), - ...removeHatTxs.map(() => 0n), - ...transferHatTxs.map(() => 0n), - ...hatDetailsChangedTxs.map(() => 0n), - ], - }; -}; diff --git a/src/hooks/utils/useCreateRoles.ts b/src/hooks/utils/useCreateRoles.ts new file mode 100644 index 000000000..a98012c5b --- /dev/null +++ b/src/hooks/utils/useCreateRoles.ts @@ -0,0 +1,495 @@ +import { FormikHelpers } from 'formik'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { toast } from 'react-toastify'; +import { zeroAddress, Address, encodeFunctionData, getAddress, Hex } from 'viem'; +import DecentHatsAbi from '../../assets/abi/DecentHats_0_1_0_Abi'; +import GnosisSafeL2 from '../../assets/abi/GnosisSafeL2'; +import { HatsAbi } from '../../assets/abi/HatsAbi'; +import HatsAccount1ofNAbi from '../../assets/abi/HatsAccount1ofN'; +import { + EditBadgeStatus, + HatStruct, + HatWearerChangedParams, + RoleValue, + RoleFormValues, +} from '../../components/pages/Roles/types'; +import { DAO_ROUTES } from '../../constants/routes'; +import { useFractal } from '../../providers/App/AppProvider'; +import useIPFSClient from '../../providers/App/hooks/useIPFSClient'; +import { useNetworkConfig } from '../../providers/NetworkConfig/NetworkConfigProvider'; +import { useRolesState } from '../../state/useRolesState'; +import { CreateProposalMetadata, ProposalExecuteData } from '../../types'; +import { SENTINEL_MODULE } from '../../utils/address'; +import useSubmitProposal from '../DAO/proposal/useSubmitProposal'; +import useCreateSablierStream from '../streams/useCreateSablierStream'; + +const hatsDetailsBuilder = (data: { name: string; description: string }) => { + return JSON.stringify({ + type: '1.0', + data, + }); +}; + +const predictHatId = ({ adminHatId, hatsCount }: { adminHatId: Hex; hatsCount: number }) => { + // 1 byte = 8 bits = 2 string characters + const adminLevelBinary = adminHatId.slice(0, 14); // Top Admin ID 1 byte 0x + 4 bytes (tree ID) + next **16 bits** (admin level ID) + + // Each next level is next **16 bits** + // Since we're operating only with direct child of top level admin - we don't care about nested levels + // @dev At least for now? + const newSiblingId = (hatsCount + 1).toString(16).padStart(4, '0'); + + // Total length of Hat ID is **32 bytes** + 2 bytes for 0x + return BigInt(`${adminLevelBinary}${newSiblingId}`.padEnd(66, '0')); +}; + +const prepareAddHatsTxArgs = (addedHats: HatStruct[], adminHatId: Hex, topHatAccount: Address) => { + const admins: bigint[] = []; + const details: string[] = []; + const maxSupplies: number[] = []; + const eligibilityModules: Address[] = []; + const toggleModules: Address[] = []; + const mutables: boolean[] = []; + const imageURIs: string[] = []; + + addedHats.forEach(hat => { + admins.push(BigInt(adminHatId)); + details.push(hat.details); + maxSupplies.push(hat.maxSupply); + eligibilityModules.push(topHatAccount); + toggleModules.push(topHatAccount); + mutables.push(hat.isMutable); + imageURIs.push(hat.imageURI); + }); + + return [ + admins, + details, + maxSupplies, + eligibilityModules, + toggleModules, + mutables, + imageURIs, + ] as const; +}; + +const prepareMintHatsTxArgs = (addedHats: HatStruct[], adminHatId: Hex, hatsCount: number) => { + const hatIds: bigint[] = []; + const wearers: Address[] = []; + + addedHats.forEach((hat, i) => { + const predictedHatId = predictHatId({ + adminHatId, + // Each predicted hat id is based on the current hat count, plus however many hat id have been predicted so far + hatsCount: hatsCount + i, + }); + hatIds.push(predictedHatId); + wearers.push(hat.wearer); + }); + + return [hatIds, wearers] as const; +}; + +export default function useCreateRoles() { + const { + node: { safe, daoAddress, daoName }, + } = useFractal(); + const { hatsTree, hatsTreeId, getHat } = useRolesState(); + const { + addressPrefix, + contracts: { + hatsProtocol, + decentHatsMasterCopy, + hatsAccount1ofNMasterCopy, + erc6551Registry, + keyValuePairs, + }, + } = useNetworkConfig(); + + const { t } = useTranslation(['roles', 'navigation', 'modals', 'common']); + + const { submitProposal } = useSubmitProposal(); + const { prepareBatchTranchedStreamCreation } = useCreateSablierStream(); + const ipfsClient = useIPFSClient(); + + const navigate = useNavigate(); + const submitProposalSuccessCallback = useCallback(() => { + if (daoAddress) { + navigate(DAO_ROUTES.proposals.relative(addressPrefix, daoAddress)); + } + }, [daoAddress, addressPrefix, navigate]); + + const uploadHatDescription = useCallback( + async (hatDescription: string) => { + const { Hash } = await ipfsClient.add(hatDescription); + return `ipfs://${Hash}`; + }, + [ipfsClient], + ); + + const parseEditedHatsFormValues = useCallback( + async (editedHats: RoleValue[]) => { + // Parse added hats + const addedHats: HatStruct[] = await Promise.all( + editedHats + .filter(hat => hat.editedRole?.status === EditBadgeStatus.New) + .map(async hat => { + const details = await uploadHatDescription( + hatsDetailsBuilder({ + name: hat.name, + description: hat.description, + }), + ); + + return { + eligibility: hatsTree?.topHat.smartAddress, + toggle: hatsTree?.topHat.smartAddress, + maxSupply: 1, + details, + imageURI: '', + isMutable: true, + wearer: getAddress(hat.wearer), + }; + }), + ); + + // Parse removed hats + const removedHatIds = editedHats + .filter(hat => hat.editedRole?.status === EditBadgeStatus.Removed) + .map(hat => hat.id); + + // Parse member changed hats + const memberChangedHats: HatWearerChangedParams[] = editedHats + .filter( + hat => + hat.editedRole?.status === EditBadgeStatus.Updated && + hat.editedRole.fieldNames.includes('member'), + ) + .map(hat => ({ + id: hat.id, + currentWearer: getAddress(getHat(hat.id)!.wearer), + newWearer: getAddress(hat.wearer), + })) + .filter(hat => hat.currentWearer !== hat.newWearer); + + // Parse role details changed hats (name and/or description updated) + const roleDetailsChangedHats = await Promise.all( + editedHats + .filter( + hat => + hat.editedRole?.status === EditBadgeStatus.Updated && + (hat.editedRole.fieldNames.includes('roleName') || + hat.editedRole.fieldNames.includes('roleDescription')), + ) + .map(async hat => ({ + id: hat.id, + details: await uploadHatDescription( + hatsDetailsBuilder({ + name: hat.name, + description: hat.description, + }), + ), + })), + ); + + // Parse role with added payroll hats + const rolePayrollAddedHats = [ + ...editedHats.filter( + hat => hat.editedRole?.status === EditBadgeStatus.Updated && !!hat.payroll, + ), + ]; + + return { + addedHats, + removedHatIds, + memberChangedHats, + roleDetailsChangedHats, + rolePayrollAddedHats, + }; + }, + [getHat, hatsTree?.topHat.smartAddress, uploadHatDescription], + ); + + const prepareCreateTopHatProposalData = useCallback( + async (proposalMetadata: CreateProposalMetadata, addedHats: HatStruct[]) => { + if (!daoAddress) { + throw new Error('Can not create top hat without DAO Address'); + } + + const decentHatsAddress = getAddress(decentHatsMasterCopy); + const enableModuleData = encodeFunctionData({ + abi: GnosisSafeL2, + functionName: 'enableModule', + args: [decentHatsAddress], + }); + + const disableModuleData = encodeFunctionData({ + abi: GnosisSafeL2, + functionName: 'disableModule', + args: [SENTINEL_MODULE, decentHatsAddress], + }); + + const topHatDetails = await uploadHatDescription( + hatsDetailsBuilder({ + name: daoName || daoAddress, + description: '', + }), + ); + const adminHatDetails = await uploadHatDescription( + hatsDetailsBuilder({ + name: 'Admin', + description: '', + }), + ); + + const adminHat: HatStruct = { + maxSupply: 1, + details: adminHatDetails, + imageURI: '', + isMutable: true, + wearer: zeroAddress, + }; + + const createAndDeclareTreeData = encodeFunctionData({ + abi: DecentHatsAbi, + functionName: 'createAndDeclareTree', + args: [ + { + hatsProtocol, + hatsAccountImplementation: hatsAccount1ofNMasterCopy, + registry: erc6551Registry, + keyValuePairs: getAddress(keyValuePairs), + topHatDetails, + topHatImageURI: '', + adminHat, + hats: addedHats, + }, + ], + }); + + return { + targets: [daoAddress, decentHatsAddress, daoAddress], + calldatas: [enableModuleData, createAndDeclareTreeData, disableModuleData], + metaData: proposalMetadata, + values: [0n, 0n, 0n], + }; + }, + [ + daoAddress, + decentHatsMasterCopy, + uploadHatDescription, + daoName, + hatsProtocol, + hatsAccount1ofNMasterCopy, + erc6551Registry, + keyValuePairs, + ], + ); + + const prepareEditHatsProposalData = useCallback( + ( + proposalMetadata: CreateProposalMetadata, + edits: { + addedHats: HatStruct[]; + removedHatIds: Hex[]; + memberChangedHats: HatWearerChangedParams[]; + roleDetailsChangedHats: { + id: Address; + details: string; + }[]; + rolePayrollAddedHats: RoleValue[]; + }, + ) => { + if (!hatsTree) { + throw new Error('Can not edit hats without Hats Tree!'); + } + + const adminHatId = hatsTree.adminHat.id; + const topHatAccount = hatsTree.topHat.smartAddress; + const { + addedHats, + removedHatIds, + memberChangedHats, + roleDetailsChangedHats, + rolePayrollAddedHats, + } = edits; + + const createAndMintHatsTxs: Hex[] = []; + let removeHatTxs: Hex[] = []; + let transferHatTxs: Hex[] = []; + let hatDetailsChangedTxs: Hex[] = []; + let hatPayrollAddedTxs: { calldata: Hex; targetAddress: Address }[] = []; + let hatPayrollTokenApprovalTxs: { calldata: Hex; tokenAddress: Address }[] = []; + + if (addedHats.length) { + // First, prepare a single tx to create all the hats + const createHatsTx = encodeFunctionData({ + abi: HatsAbi, + functionName: 'batchCreateHats', + args: prepareAddHatsTxArgs(addedHats, adminHatId, topHatAccount), + }); + + // Finally, prepare a single tx to mint all the hats to the wearers + const mintHatsTx = encodeFunctionData({ + abi: HatsAbi, + functionName: 'batchMintHats', + args: prepareMintHatsTxArgs(addedHats, adminHatId, hatsTree.roleHatsTotalCount), + }); + + // Push these two txs to the included txs array. + // They will be executed in order: add all hats first, then mint all hats to their respective wearers. + createAndMintHatsTxs.push(createHatsTx); + createAndMintHatsTxs.push(mintHatsTx); + } + + if (removedHatIds.length) { + removeHatTxs = removedHatIds.map(hatId => + // make transaction proxy through erc6551 contract + encodeFunctionData({ + abi: HatsAccount1ofNAbi, + functionName: 'execute', + args: [ + hatsProtocol, + 0n, + encodeFunctionData({ + abi: HatsAbi, + functionName: 'setHatStatus', + args: [BigInt(hatId), false], + }), + 0, + ], + }), + ); + } + + if (memberChangedHats.length) { + transferHatTxs = memberChangedHats.map(({ id, currentWearer, newWearer }) => { + return encodeFunctionData({ + abi: HatsAbi, + functionName: 'transferHat', + args: [BigInt(id), currentWearer, newWearer], + }); + }); + } + + if (roleDetailsChangedHats.length) { + hatDetailsChangedTxs = roleDetailsChangedHats.map(({ id, details }) => { + return encodeFunctionData({ + abi: HatsAbi, + functionName: 'changeHatDetails', + args: [BigInt(id), details], + }); + }); + } + + if (rolePayrollAddedHats.length) { + const streamsData = rolePayrollAddedHats.map(role => role.payroll!); + const recipients = rolePayrollAddedHats.map(role => role.smartAddress); + const preparedPayrollTransactions = prepareBatchTranchedStreamCreation( + streamsData, + recipients, + ); + + hatPayrollTokenApprovalTxs = preparedPayrollTransactions.preparedTokenApprovalsTransactions; + hatPayrollAddedTxs = preparedPayrollTransactions.preparedStreamCreationTransactions; + } + + const proposalTransactions = { + targets: [ + ...createAndMintHatsTxs.map(() => hatsProtocol), + ...removeHatTxs.map(() => topHatAccount), + ...transferHatTxs.map(() => hatsProtocol), + ...hatDetailsChangedTxs.map(() => hatsProtocol), + ...hatPayrollTokenApprovalTxs.map(({ tokenAddress }) => tokenAddress), + ...hatPayrollAddedTxs.map(({ targetAddress }) => targetAddress), + ], + calldatas: [ + ...createAndMintHatsTxs, + ...removeHatTxs, + ...transferHatTxs, + ...hatDetailsChangedTxs, + ...hatPayrollTokenApprovalTxs.map(({ calldata }) => calldata), + ...hatPayrollAddedTxs.map(({ calldata }) => calldata), + ], + metaData: proposalMetadata, + values: [ + ...createAndMintHatsTxs.map(() => 0n), + ...removeHatTxs.map(() => 0n), + ...transferHatTxs.map(() => 0n), + ...hatDetailsChangedTxs.map(() => 0n), + ...hatPayrollTokenApprovalTxs.map(() => 0n), + ...hatPayrollAddedTxs.map(() => 0n), + ], + }; + + return proposalTransactions; + }, + [hatsProtocol, hatsTree, prepareBatchTranchedStreamCreation], + ); + + const createRolesEditProposal = useCallback( + async (values: RoleFormValues, formikHelpers: FormikHelpers) => { + const { setSubmitting } = formikHelpers; + setSubmitting(true); + if (!safe) { + setSubmitting(false); + throw new Error('Cannot create Roles proposal without known Safe'); + } + + try { + // filter to hats that have been modified (ie includes `editedRole` prop) + const modifiedHats = values.hats.filter(hat => !!hat.editedRole); + let proposalData: ProposalExecuteData; + + const editedHatStructs = await parseEditedHatsFormValues(modifiedHats); + + if (!hatsTreeId) { + // This safe has no top hat, so we prepare a proposal to create one. This will also create an admin hat, + // along with any other hats that are added. + proposalData = await prepareCreateTopHatProposalData( + values.proposalMetadata, + editedHatStructs.addedHats, + ); + } else { + if (!hatsTree) { + throw new Error('Cannot edit Roles without a HatsTree'); + } + // This safe has a top hat, so we prepare a proposal to edit the hats that have changed. + proposalData = prepareEditHatsProposalData(values.proposalMetadata, editedHatStructs); + } + + // All done, submit the proposal! + await submitProposal({ + proposalData, + nonce: values.customNonce ?? safe.nextNonce, + pendingToastMessage: t('proposalCreatePendingToastMessage', { ns: 'proposal' }), + successToastMessage: t('proposalCreateSuccessToastMessage', { ns: 'proposal' }), + failedToastMessage: t('proposalCreateFailureToastMessage', { ns: 'proposal' }), + successCallback: submitProposalSuccessCallback, + }); + } catch (e) { + console.error(e); + toast(t('encodingFailedMessage', { ns: 'proposal' })); + } finally { + formikHelpers.setSubmitting(false); + } + }, + [ + safe, + parseEditedHatsFormValues, + hatsTreeId, + submitProposal, + t, + submitProposalSuccessCallback, + prepareCreateTopHatProposalData, + hatsTree, + prepareEditHatsProposalData, + ], + ); + + return { + createRolesEditProposal, + }; +} diff --git a/src/i18n/locales/en/roles.json b/src/i18n/locales/en/roles.json index a43047bb1..d5a568eeb 100644 --- a/src/i18n/locales/en/roles.json +++ b/src/i18n/locales/en/roles.json @@ -45,6 +45,9 @@ "monthly": "Monthly", "everyTwoWeeks": "Every Two Weeks", "weekly": "Weekly", + "monthlyShort": "mo", + "everyTwoWeeksShort": "2w", + "weeklyShort": "w", "months": "Months", "weeks": "Weeks" } diff --git a/src/mocks/roles.ts b/src/mocks/roles.ts deleted file mode 100644 index b17f2ea8b..000000000 --- a/src/mocks/roles.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { SablierPayroll, SablierVesting } from '../components/pages/Roles/types'; - -export const mockSablierAsset = { - symbol: 'USDC', - address: '0x73d219b3881e481394da6b5008a081d623992200', // Sepolia USDC, - name: 'USD Coin', - iconUri: 'https://cryptologos.cc/logos/usd-coin-usdc-logo.png?v=032', -} as const; - -export const mockPayroll: SablierPayroll = { - payrollSchedule: 'Monthly', - payrollAmount: '5000', - payrollAmountUSD: '$5,000', - payrollStartDate: '2024-06-09, 12:30-06', - payrollEndDate: '2025-06-09, 12:30-06', - asset: mockSablierAsset, -}; - -export const mockVesting: SablierVesting = { - vestingSchedule: 'Monthly', - vestingAmount: '5000', - vestingAmountUSD: '$5,000', - vestingStartDate: '2024-06-09, 12:30-06', - vestingEndDate: '2025-06-09, 12:30-06', - asset: mockSablierAsset, -}; diff --git a/src/pages/daos/[daoAddress]/roles/details/PayrollAndVesting.tsx b/src/pages/daos/[daoAddress]/roles/details/PayrollAndVesting.tsx index c36f53067..ec0297ef2 100644 --- a/src/pages/daos/[daoAddress]/roles/details/PayrollAndVesting.tsx +++ b/src/pages/daos/[daoAddress]/roles/details/PayrollAndVesting.tsx @@ -12,7 +12,11 @@ import { import { CaretRight, CaretDown } from '@phosphor-icons/react'; import { ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; -import { SablierPayroll, SablierVesting } from '../../../../../components/pages/Roles/types'; +import { + SablierPayroll, + SablierVesting, + frequencyOptions, +} from '../../../../../components/pages/Roles/types'; type AccordionItemRowProps = { title: string; @@ -82,7 +86,7 @@ export default function PayrollAndVesting({ alignItems="center" > {payrollData.asset.symbol} - {payrollData.payrollAmount} {payrollData.asset.symbol} + {payrollData.amount.value} {payrollData.asset.symbol} - {payrollData.payrollAmountUSD} + {/* @todo - show amount in USD based off price of the asset */} + {payrollData.amount.value} diff --git a/src/pages/daos/[daoAddress]/roles/edit/index.tsx b/src/pages/daos/[daoAddress]/roles/edit/index.tsx index 1e49bfee6..296d5a530 100644 --- a/src/pages/daos/[daoAddress]/roles/edit/index.tsx +++ b/src/pages/daos/[daoAddress]/roles/edit/index.tsx @@ -1,11 +1,10 @@ import { Box, Button, Flex, Show } from '@chakra-ui/react'; import { Plus } from '@phosphor-icons/react'; -import { Formik, FormikHelpers } from 'formik'; -import { useCallback, useMemo } from 'react'; +import { Formik } from 'formik'; +import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Outlet, useNavigate } from 'react-router-dom'; -import { toast } from 'react-toastify'; -import { Hex, getAddress, zeroAddress } from 'viem'; +import { Hex } from 'viem'; import { RoleCardEdit } from '../../../../../components/pages/Roles/RoleCard'; import { RoleCardLoading, @@ -19,49 +18,24 @@ import { } from '../../../../../components/pages/Roles/types'; import PageHeader from '../../../../../components/ui/page/Header/PageHeader'; import { DAO_ROUTES } from '../../../../../constants/routes'; -import useSubmitProposal from '../../../../../hooks/DAO/proposal/useSubmitProposal'; import { useRolesSchema } from '../../../../../hooks/schemas/roles/useRolesSchema'; -import { - parseEditedHatsFormValues, - prepareCreateTopHatProposalData, - prepareEditHatsProposalData, -} from '../../../../../hooks/utils/rolesProposalFunctions'; +import useCreateRoles from '../../../../../hooks/utils/useCreateRoles'; import { useFractal } from '../../../../../providers/App/AppProvider'; -import useIPFSClient from '../../../../../providers/App/hooks/useIPFSClient'; import { useNetworkConfig } from '../../../../../providers/NetworkConfig/NetworkConfigProvider'; import { useRolesState } from '../../../../../state/useRolesState'; -import { ProposalExecuteData } from '../../../../../types'; function RolesEdit() { const { t } = useTranslation(['roles', 'navigation', 'modals', 'common']); const { - node: { daoAddress, safe, daoName }, + node: { daoAddress, safe }, } = useFractal(); - const { - addressPrefix, - contracts: { - hatsProtocol, - decentHatsMasterCopy, - hatsAccount1ofNMasterCopy, - erc6551Registry, - keyValuePairs, - }, - } = useNetworkConfig(); + const { addressPrefix } = useNetworkConfig(); const { rolesSchema } = useRolesSchema(); - const { hatsTree, hatsTreeId, getHat } = useRolesState(); + const { hatsTree } = useRolesState(); const navigate = useNavigate(); - - const { submitProposal } = useSubmitProposal(); - - const ipfsClient = useIPFSClient(); - - const submitProposalSuccessCallback = useCallback(() => { - if (daoAddress) { - navigate(DAO_ROUTES.proposals.relative(addressPrefix, daoAddress)); - } - }, [daoAddress, addressPrefix, navigate]); + const { createRolesEditProposal } = useCreateRoles(); function generateRoleProposalTitle({ formValues }: { formValues: RoleFormValues }) { const filteredHats = formValues.hats.filter(hat => !!hat.editedRole); @@ -86,96 +60,6 @@ function RolesEdit() { return [addedHatsText, updatedHatsText, removedHatsText].filter(Boolean).join('. '); } - const createRolesEditProposal = useCallback( - async (values: RoleFormValues, formikHelpers: FormikHelpers) => { - const { setSubmitting } = formikHelpers; - setSubmitting(true); - if (!safe) { - setSubmitting(false); - throw new Error('Cannot create Roles proposal without known Safe'); - } - - try { - // filter to hats that have been modified (ie includes `editedRole` prop) - const modifiedHats = values.hats.filter(hat => !!hat.editedRole); - let proposalData: ProposalExecuteData; - - const uploadHatDescriptionCallback = async (hatDescription: string) => { - const { Hash } = await ipfsClient.add(hatDescription); - return `ipfs://${Hash}`; - }; - - const editedHatStructs = await parseEditedHatsFormValues( - modifiedHats, - hatsTree?.topHat.smartAddress ?? zeroAddress, // dev: "should never be the zero address in practice" - getHat, - uploadHatDescriptionCallback, - ); - - if (!hatsTreeId) { - // This safe has no top hat, so we prepare a proposal to create one. This will also create an admin hat, - // along with any other hats that are added. - proposalData = await prepareCreateTopHatProposalData( - values.proposalMetadata, - editedHatStructs.addedHats, - getAddress(safe.address), - uploadHatDescriptionCallback, - daoName ?? safe.address, - getAddress(decentHatsMasterCopy), - hatsProtocol, - hatsAccount1ofNMasterCopy, - erc6551Registry, - getAddress(keyValuePairs), - ); - } else { - if (!hatsTree) { - throw new Error('Cannot edit Roles without a HatsTree'); - } - // This safe has a top hat, so we prepare a proposal to edit the hats that have changed. - proposalData = prepareEditHatsProposalData( - values.proposalMetadata, - editedHatStructs, - hatsTree.adminHat.id, - hatsTree.topHat.smartAddress, - hatsTree.roleHatsTotalCount, - hatsProtocol, - ); - } - - // All done, submit the proposal! - await submitProposal({ - proposalData, - nonce: values.customNonce ?? safe.nextNonce, - pendingToastMessage: t('proposalCreatePendingToastMessage', { ns: 'proposal' }), - successToastMessage: t('proposalCreateSuccessToastMessage', { ns: 'proposal' }), - failedToastMessage: t('proposalCreateFailureToastMessage', { ns: 'proposal' }), - successCallback: submitProposalSuccessCallback, - }); - } catch (e) { - console.error(e); - toast(t('encodingFailedMessage', { ns: 'proposal' })); - } finally { - formikHelpers.setSubmitting(false); - } - }, - [ - daoName, - decentHatsMasterCopy, - erc6551Registry, - getHat, - hatsAccount1ofNMasterCopy, - hatsProtocol, - hatsTree, - hatsTreeId, - ipfsClient, - keyValuePairs, - safe, - submitProposal, - submitProposalSuccessCallback, - t, - ], - ); - const initialValues = useMemo(() => { return { proposalMetadata: { diff --git a/src/router.tsx b/src/router.tsx index b97fe3471..46dd2bca0 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -2,7 +2,6 @@ import { wrapCreateBrowserRouter } from '@sentry/react'; import { createBrowserRouter, redirect } from 'react-router-dom'; import { ModalProvider } from './components/ui/modals/ModalProvider'; import Layout from './components/ui/page/Layout'; -import PayrollStreamBuilder from './components/ui/stream/PayrollStreamBuilder'; import VestingStreamBuilder from './components/ui/stream/VestingStreamBuilder'; import { BASE_ROUTES, DAO_ROUTES } from './constants/routes'; import FourOhFourPage from './pages/404'; @@ -96,10 +95,6 @@ export const router = (addressPrefix: string) => path: 'details', element: , }, - { - path: 'payroll', - element: , - }, { path: 'vesting', element: , diff --git a/src/types/sablier.ts b/src/types/sablier.ts index 1e8f3568c..800b5d701 100644 --- a/src/types/sablier.ts +++ b/src/types/sablier.ts @@ -1,5 +1,3 @@ -export type PayrollFrequency = 'monthly' | 'biweekly' | 'weekly'; - export type StreamRelativeSchedule = { years: number; days: number; hours: number }; export type StreamAbsoluteSchedule = { startDate: number; endDate: number }; export type StreamSchedule = StreamRelativeSchedule | StreamAbsoluteSchedule;