- {'/'} {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.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)}
- />
-
-
-
- }
- onChange={e => {
- setSelectedAsset(fungibleAssetsWithBalance[Number(e.target.value)]);
- }}
- value={selectedAssetIndex}
- >
- {fungibleAssetsWithBalance.map((asset, index) => (
-
- ))}
-
-
-
-
-
- }
- onChange={e => setFrequency(e.target.value as PayrollFrequency)}
- >
-
-
-
-
-
-
-
- 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.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;
|