diff --git a/.env.development b/.env.development index e8cba4357..c31dfbf8d 100644 --- a/.env.development +++ b/.env.development @@ -18,7 +18,7 @@ VITE_FEATURE_FLAG_OSX_UPDATES=true VITE_FEATURE_FLAG_CONNECT_ANY_DAPP=true # Enable gasless plugin on DAO creation -VITE_FEATURE_FLAG_GASLESS_PLUGIN=false +VITE_FEATURE_FLAG_GASLESS_PLUGIN=true VITE_VOCDONI_ENV='stg' diff --git a/.env.staging b/.env.staging index e8cba4357..c31dfbf8d 100644 --- a/.env.staging +++ b/.env.staging @@ -18,7 +18,7 @@ VITE_FEATURE_FLAG_OSX_UPDATES=true VITE_FEATURE_FLAG_CONNECT_ANY_DAPP=true # Enable gasless plugin on DAO creation -VITE_FEATURE_FLAG_GASLESS_PLUGIN=false +VITE_FEATURE_FLAG_GASLESS_PLUGIN=true VITE_VOCDONI_ENV='stg' diff --git a/.github/workflows/ai-pr-reviewer.yml b/.github/workflows/ai-pr-reviewer.yml new file mode 100644 index 000000000..160997421 --- /dev/null +++ b/.github/workflows/ai-pr-reviewer.yml @@ -0,0 +1,31 @@ +name: Code Review + +permissions: + contents: read + pull-requests: write + +on: + pull_request: + pull_request_review_comment: + types: [created] + +concurrency: + group: + ${{ github.repository }}-${{ github.event.number || github.head_ref || + github.sha }}-${{ github.workflow }}-${{ github.event_name == + 'pull_request_review_comment' && 'pr_comment' || 'pr' }} + cancel-in-progress: ${{ github.event_name != 'pull_request_review_comment' }} + +jobs: + review: + runs-on: ubuntu-latest + steps: + - uses: coderabbitai/ai-pr-reviewer@latest + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + with: + debug: false + review_simple_changes: true + review_comment_lgtm: true + openai_heavy_model: gpt-4 diff --git a/package.json b/package.json index 43d51cc50..f22c363ea 100644 --- a/package.json +++ b/package.json @@ -36,10 +36,9 @@ "@tiptap/pm": "^2.0.3", "@tiptap/react": "^2.0.3", "@tiptap/starter-kit": "^2.0.3", - "@vocdoni/gasless-voting": "0.0.1-rc2", - "@vocdoni/gasless-voting-ethers": "0.0.1-rc1", - "@vocdoni/react-providers": "0.1.14", - "@vocdoni/sdk": "0.5.0", + "@vocdoni/gasless-voting": "0.0.1-rc21", + "@vocdoni/react-providers": "0.3.1", + "@vocdoni/sdk": "0.7.1", "@walletconnect/core": "^2.8.3", "@walletconnect/utils": "^2.8.3", "@walletconnect/web3wallet": "^1.8.2", @@ -117,7 +116,6 @@ "vite-tsconfig-paths": "^4.2.0" }, "resolutions": { - "@vocdoni/sdk": "0.5.0", "@types/react": "^18.2.23" }, "engines": { diff --git a/src/components/executionExpirationTime/index.tsx b/src/components/executionExpirationTime/index.tsx index 74af14d98..da1cf865b 100644 --- a/src/components/executionExpirationTime/index.tsx +++ b/src/components/executionExpirationTime/index.tsx @@ -43,7 +43,11 @@ const ExecutionExpirationTime: React.FC = () => { ); setValue('executionExpirationHours', '0'); setValue('executionExpirationMinutes', '0'); - } else if (value === 0 && executionExpirationHours === '0') { + } else if ( + value === 0 && + (executionExpirationHours === undefined || + executionExpirationHours === '0') + ) { setValue( 'executionExpirationHours', COMMITTEE_EXECUTION_MIN_DURATION_HOURS.toString() diff --git a/src/components/executionMultisigMinimumApproval/index.tsx b/src/components/executionMultisigMinimumApproval/index.tsx new file mode 100644 index 000000000..1a9de3e3f --- /dev/null +++ b/src/components/executionMultisigMinimumApproval/index.tsx @@ -0,0 +1,87 @@ +import { + Controller, + useFormContext, + useWatch, + ValidateResult, +} from 'react-hook-form'; +import MinimumApproval from '../multisigMinimumApproval/minimumApproval'; +import React, {useCallback, useEffect} from 'react'; +import {generateAlert} from '../multisigMinimumApproval'; +import {useTranslation} from 'react-i18next'; + +const MIN_REQUIRED_APPROVALS = 1; + +export const ExecutionMultisigMinimumApproval = () => { + const {t} = useTranslation(); + const {control, setValue, trigger} = useFormContext(); + const [committee, committeeMinimumApproval] = useWatch({ + name: ['committee', 'committeeMinimumApproval'], + }); + + const committeeCount = committee?.length ?? 0; + + const validateMinimumApproval = (value: number): ValidateResult => { + if (value > committeeCount) { + return t('errors.minimumApproval.exceedMaxThreshold'); + } else if (value <= 0) { + return t('errors.required.minApproval'); + } + return true; + }; + + const minApprovalChanged = useCallback( + ( + e: React.ChangeEvent, + onChange: React.ChangeEventHandler + ) => { + const value = Number(e.target.value); + if (value > committeeCount) { + setValue('committeeMinimumApproval', committeeCount.toString()); + e.target.value = committeeCount; + } + trigger(['committeeMinimumApproval']); + onChange(e); + }, + [committeeCount, setValue, trigger] + ); + + // This is used to update the committeeMinimumApproval when a wallet is deleted + useEffect(() => { + if (Number(committeeMinimumApproval) === 0 && committeeCount === 1) { + setValue('committeeMinimumApproval', committeeCount.toString()); + } else if (Number(committeeMinimumApproval) > committeeCount) { + setValue('committeeMinimumApproval', committeeCount.toString()); + } + }, [committeeCount, committeeMinimumApproval, setValue]); + + return ( + <> + validateMinimumApproval(value), + }} + render={({ + field: {onBlur, onChange, value, name}, + fieldState: {error}, + }) => ( + <> + ) => + minApprovalChanged(e, onChange) + } + error={generateAlert(value, committee.length, t, error)} + /> + + )} + /> + + ); +}; diff --git a/src/components/executionWidget/actions/modifyGaslessSettingsCard.tsx b/src/components/executionWidget/actions/modifyGaslessSettingsCard.tsx new file mode 100644 index 000000000..34e2200b4 --- /dev/null +++ b/src/components/executionWidget/actions/modifyGaslessSettingsCard.tsx @@ -0,0 +1,99 @@ +import React, {useMemo} from 'react'; +import {useTranslation} from 'react-i18next'; + +import {AccordionMethod} from 'components/accordionMethod'; +import {ActionCardDlContainer, Dd, Dl, Dt} from 'components/descriptionList'; +import {ActionUpdateGaslessSettings} from 'utils/types'; +import {getDHMFromSeconds} from '../../../utils/date'; +import {getErc20MinParticipation} from '../../../utils/proposals'; +import {formatUnits} from 'ethers/lib/utils'; + +export const ModifyGaslessSettingsCard: React.FC<{ + action: ActionUpdateGaslessSettings; +}> = ({action: {inputs}}) => { + const {t} = useTranslation(); + const {days, hours, minutes} = getDHMFromSeconds(inputs.minDuration); + const { + days: tallyDays, + hours: tallyHours, + minutes: tallyMinutes, + } = getDHMFromSeconds(inputs.minTallyDuration); + + const minParticipation = useMemo( + () => `≥ ${Math.round(inputs.minParticipation * 100)}% (≥ + ${getErc20MinParticipation( + inputs.minParticipation, + inputs.totalVotingWeight, + inputs.token?.decimals || 18 + )} + ${inputs.token?.symbol})`, + [ + inputs.minParticipation, + inputs.token?.decimals, + inputs.token?.symbol, + inputs.totalVotingWeight, + ] + ); + + const minProposalThreshold = inputs.minProposerVotingPower + ? t('labels.review.tokenHoldersWithTkns', { + tokenAmount: formatUnits( + inputs.minProposerVotingPower, + inputs.token?.decimals + ), + tokenSymbol: inputs.token?.symbol, + }) + : t('createDAO.step3.eligibility.anyWallet.title'); + + return ( + + +
+
{t('labels.minimumParticipation')}
+
{minParticipation}
+
+
+
{t('labels.review.proposalThreshold')}
+
{minProposalThreshold}
+
+
+
{t('labels.minimumDuration')}
+
+
+ {t('createDAO.review.days', {days})} + {t('createDAO.review.hours', {hours})} + {t('createDAO.review.minutes', {minutes})} +
+
+
+
+
{t('labels.minimumApproval')}
+
+ {inputs.minTallyApprovals}  + {t('labels.review.multisigMinimumApprovals', { + count: inputs.executionMultisigMembers?.length || 0, + })} +
+
+
+
{t('createDao.executionMultisig.executionTitle')}
+
+
+ {t('createDAO.review.days', {days: tallyDays})} + {t('createDAO.review.hours', {hours: tallyHours})} + + {t('createDAO.review.minutes', {minutes: tallyMinutes})} + +
+
+
+
+
+ ); +}; diff --git a/src/components/executionWidget/actionsFilter.tsx b/src/components/executionWidget/actionsFilter.tsx index 554fa4551..ec492e95d 100644 --- a/src/components/executionWidget/actionsFilter.tsx +++ b/src/components/executionWidget/actionsFilter.tsx @@ -12,6 +12,7 @@ import {SCCExecutionCard} from './actions/sccExecutionWidget'; import {WCActionCard} from './actions/walletConnectActionCard'; import {WithdrawCard} from './actions/withdrawCard'; import {toDisplayEns} from 'utils/library'; +import {ModifyGaslessSettingsCard} from './actions/modifyGaslessSettingsCard'; type ActionsFilterProps = { action: Action; @@ -52,6 +53,8 @@ export const ActionsFilter: React.FC = ({ return ( ); + case 'modify_gasless_voting_settings': + return ; case 'plugin_update': default: return <>; diff --git a/src/components/membersList/index.tsx b/src/components/membersList/index.tsx index 5b7cecf98..a0305cbb0 100644 --- a/src/components/membersList/index.tsx +++ b/src/components/membersList/index.tsx @@ -12,6 +12,8 @@ import styled from 'styled-components'; import {useScreen} from '@aragon/ods-old'; import {useTranslation} from 'react-i18next'; import {featureFlags} from 'utils/featureFlags'; +import {useGaslessGovernanceEnabled} from '../../hooks/useGaslessGovernanceEnabled'; +import {useDaoDetailsQuery} from '../../hooks/useDaoDetails'; type MembersListProps = { members: DaoMember[]; @@ -32,6 +34,11 @@ export const MembersList: React.FC = ({ const {isDesktop} = useScreen(); const {t} = useTranslation(); + // Gasless voting plugin support non wrapped tokens + // Used to hide delegation column in case of gasless voting plugin + const {data: daoDetails} = useDaoDetailsQuery(); + const {isGovernanceEnabled} = useGaslessGovernanceEnabled(daoDetails); + const isTokenBasedDao = token != null; const useCompactMode = isCompactMode ?? !isDesktop; const enableDelegation = @@ -94,7 +101,7 @@ export const MembersList: React.FC = ({ {t('community.listHeader.votingPower')} )} - {showDelegationHeaders && ( + {showDelegationHeaders && isGovernanceEnabled && ( {t('community.listHeader.delegations')} diff --git a/src/components/proposalList/index.tsx b/src/components/proposalList/index.tsx index 3292b64f6..02406a0f8 100644 --- a/src/components/proposalList/index.tsx +++ b/src/components/proposalList/index.tsx @@ -211,10 +211,10 @@ export function proposal2CardProps( alertMessage: translateProposalDate( proposal.status, proposal.startDate, - proposal.endDate + proposal.tallyEndDate ), - title: proposal.vochain.metadata.title.default, - description: proposal.vochain.metadata.questions[0].title.default, + title: proposal.metadata.title, + description: proposal.metadata.description, }; return {...props, ...specificProps}; } else if (isErc20VotingProposal(proposal)) { diff --git a/src/components/verificationCard/index.tsx b/src/components/verificationCard/index.tsx index 1228a18e8..4cadcf691 100644 --- a/src/components/verificationCard/index.tsx +++ b/src/components/verificationCard/index.tsx @@ -23,6 +23,7 @@ const VerificationCard: React.FC = ({tokenAddress}) => { tokenTotalSupply, tokenTotalHolders, tokenType, + votingType, ] = useWatch({ name: [ 'tokenName', @@ -30,6 +31,7 @@ const VerificationCard: React.FC = ({tokenAddress}) => { 'tokenTotalSupply', 'tokenTotalHolders', 'tokenType', + 'votingType', ], control: control, }); @@ -52,6 +54,19 @@ const VerificationCard: React.FC = ({tokenAddress}) => { const Alert = useMemo(() => { switch (tokenType) { case 'ERC-20': + if (votingType === 'gasless') { + return ( + + ); + } return ( = ({tokenAddress}) => { default: return null; } - }, [t, tokenSymbol, tokenType]); + }, [t, tokenSymbol, tokenType, votingType]); const formattedTokenTotalSupply = useMemo(() => { if (tokenTotalSupply < 100) { diff --git a/src/containers/actionBuilder/updateMinimumApproval/gaslessUpdateMinimumApproval.tsx b/src/containers/actionBuilder/updateMinimumApproval/gaslessUpdateMinimumApproval.tsx new file mode 100644 index 000000000..6026b67b1 --- /dev/null +++ b/src/containers/actionBuilder/updateMinimumApproval/gaslessUpdateMinimumApproval.tsx @@ -0,0 +1,62 @@ +import React, {useEffect} from 'react'; + +import {ActionUpdateGaslessSettings} from 'utils/types'; +import UpdateMinimumApproval, {UpdateMinimumApprovalProps} from './index'; +import {GaslessPluginVotingSettings} from '@vocdoni/gasless-voting'; +import {useDaoToken} from '../../../hooks/useDaoToken'; +import {useTokenSupply} from '../../../hooks/useTokenSupply'; +import {useFormContext} from 'react-hook-form'; + +type GaslessUpdateMinimumApprovalProps = UpdateMinimumApprovalProps & { + gaslessSettings: GaslessPluginVotingSettings; + pluginAddress: string; +}; + +/** + * This is basically a wrapper for `UpdateMinimumApproval` that adds the default gassless action values + * For the original UpdateMinimumApproval, the modify majority settings action is more simple that the + * gassless settings one, so we need to add the default values for the gassless settings + */ +export const GaslessUpdateMinimumApproval: React.FC< + GaslessUpdateMinimumApprovalProps +> = ({gaslessSettings, pluginAddress, actionIndex, ...rest}) => { + const {data: daoToken, isLoading: daoTokenLoading} = + useDaoToken(pluginAddress); + const {data: tokenSupply, isLoading: tokenSupplyIsLoading} = useTokenSupply( + daoToken?.address || '' + ); + + const {setValue} = useFormContext(); + + useEffect(() => { + if (tokenSupplyIsLoading || daoTokenLoading) return; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {minTallyApprovals, ...rest} = gaslessSettings; // Do not update the + const gaslessSettingsAction: ActionUpdateGaslessSettings = { + name: 'modify_gasless_voting_settings', + inputs: { + token: daoToken, + totalVotingWeight: tokenSupply?.raw || BigInt(0), + ...gaslessSettings, + }, + }; + + setValue(`actions.${actionIndex}`, gaslessSettingsAction); + }, [ + actionIndex, + daoToken, + daoTokenLoading, + gaslessSettings, + setValue, + tokenSupply?.raw, + tokenSupplyIsLoading, + ]); + + return ( + + ); +}; diff --git a/src/containers/actionBuilder/updateMinimumApproval/index.tsx b/src/containers/actionBuilder/updateMinimumApproval/index.tsx index d08d2f836..002ccc4d1 100644 --- a/src/containers/actionBuilder/updateMinimumApproval/index.tsx +++ b/src/containers/actionBuilder/updateMinimumApproval/index.tsx @@ -18,15 +18,16 @@ export type CurrentDaoMembers = { currentDaoMembers?: DaoMember[]; }; -type UpdateMinimumApprovalProps = ActionIndex & +export type UpdateMinimumApprovalProps = ActionIndex & CustomHeaderProps & - CurrentDaoMembers & {currentMinimumApproval?: number}; + CurrentDaoMembers & {currentMinimumApproval?: number; isGasless?: boolean}; const UpdateMinimumApproval: React.FC = ({ actionIndex, useCustomHeader = false, currentDaoMembers, currentMinimumApproval, + isGasless = false, }) => { const {t} = useTranslation(); const {network} = useNetwork(); @@ -35,7 +36,9 @@ const UpdateMinimumApproval: React.FC = ({ // form context data & hooks const {setValue, control, trigger, getValues} = useFormContext(); - const minimumApprovalKey = `actions.${actionIndex}.inputs.minApprovals`; + const minimumApprovalKey = isGasless + ? `actions.${actionIndex}.inputs.minTallyApprovals` + : `actions.${actionIndex}.inputs.minApprovals`; const minimumApproval = useWatch({ name: minimumApprovalKey, @@ -98,7 +101,14 @@ const UpdateMinimumApproval: React.FC = ({ }, [actions, getValues]); useEffect(() => { - setValue(`actions.${actionIndex}.name`, 'modify_multisig_voting_settings'); + if (isGasless) { + setValue(`actions.${actionIndex}.name`, 'modify_gasless_voting_settings'); + } else { + setValue( + `actions.${actionIndex}.name`, + 'modify_multisig_voting_settings' + ); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/src/containers/compareSettings/gaslessVoting/compareGasless.tsx b/src/containers/compareSettings/gaslessVoting/compareGasless.tsx new file mode 100644 index 000000000..4556b15d1 --- /dev/null +++ b/src/containers/compareSettings/gaslessVoting/compareGasless.tsx @@ -0,0 +1,71 @@ +import {Views} from '../index'; +import React from 'react'; +import {useFormContext} from 'react-hook-form'; +import {generatePath, useNavigate} from 'react-router-dom'; +import {EditSettings} from '../../../utils/paths'; +import {DescriptionListContainer} from '../../../components/descriptionList'; +import {useTranslation} from 'react-i18next'; +import {useNetwork} from '../../../context/network'; +import { + ReviewExecutionMultisig, + ReviewExecutionMultisigProps, +} from '../../goLive/committee'; +import {GaslessPluginVotingSettings} from '@vocdoni/gasless-voting'; +import {getDHMFromSeconds} from '../../../utils/date'; + +type CompareGaslessProps = { + daoSettings?: GaslessPluginVotingSettings; + daoAddressOrEns: string; + view: Views; +}; +export const CompareGasless: React.FC = ({ + view, + daoSettings, + daoAddressOrEns, +}) => { + const {getValues} = useFormContext(); + const navigate = useNavigate(); + const {network} = useNetwork(); + + const {t} = useTranslation(); + + const { + committeeMinimumApproval, + executionExpirationMinutes, + executionExpirationHours, + executionExpirationDays, + } = getValues(); + + let displayedInfo: ReviewExecutionMultisigProps; + if (view === 'new') { + displayedInfo = { + committee: daoSettings?.executionMultisigMembers || [], + committeeMinimumApproval, + executionExpirationMinutes, + executionExpirationHours, + executionExpirationDays, + }; + } else { + const {days, hours, minutes} = getDHMFromSeconds( + daoSettings!.minTallyDuration + ); + displayedInfo = { + committee: daoSettings?.executionMultisigMembers || [], + committeeMinimumApproval: daoSettings?.minTallyApprovals || 0, + executionExpirationMinutes: minutes!, + executionExpirationHours: hours!, + executionExpirationDays: days!, + }; + } + return ( + + navigate(generatePath(EditSettings, {network, dao: daoAddressOrEns})) + } + editLabel={t('settings.edit')} + > + + + ); +}; diff --git a/src/containers/compareSettings/index.tsx b/src/containers/compareSettings/index.tsx index cf66fe180..90e060262 100644 --- a/src/containers/compareSettings/index.tsx +++ b/src/containers/compareSettings/index.tsx @@ -7,6 +7,7 @@ import {useDaoDetailsQuery} from 'hooks/useDaoDetails'; import {useDaoToken} from 'hooks/useDaoToken'; import {PluginTypes} from 'hooks/usePluginClient'; import { + isGaslessVotingSettings, isMultisigVotingSettings, isTokenVotingSettings, useVotingSettings, @@ -17,6 +18,7 @@ import {CompareMvCommunity} from './majorityVoting/compareCommunity'; import {CompareMvGovernance} from './majorityVoting/compareGovernance'; import {CompareMsCommunity} from './multisig/compareCommunity'; import {CompareMsGovernance} from './multisig/compareGovernance'; +import {CompareGasless} from './gaslessVoting/compareGasless'; export type Views = 'old' | 'new'; @@ -97,6 +99,13 @@ const CompareSettings: React.FC = () => { /> )} + {isGaslessVotingSettings(votingSettings) && ( + + )} ); }; diff --git a/src/containers/configureCommunity/index.tsx b/src/containers/configureCommunity/index.tsx index d7e33bef3..1c3dc9cb2 100644 --- a/src/containers/configureCommunity/index.tsx +++ b/src/containers/configureCommunity/index.tsx @@ -67,7 +67,7 @@ const ConfigureCommunity: React.FC = ({ setValue('durationHours', '0'); setValue('durationMinutes', '0'); } else if (value === 0 && durationHours === '0') { - setValue('durationHours', MIN_DURATION_HOURS.toString()); + // setValue('durationHours', MIN_DURATION_HOURS.toString()); } trigger(['durationMinutes', 'durationHours', 'durationDays']); onChange(e); @@ -92,9 +92,9 @@ const ConfigureCommunity: React.FC = ({ ); } } else if (value === 0 && durationDays === '0') { - setValue('durationHours', MIN_DURATION_HOURS.toString()); - setValue('durationMinutes', '0'); - e.target.value = MIN_DURATION_HOURS.toString(); + // setValue('durationHours', MIN_DURATION_HOURS.toString()); + // setValue('durationMinutes', '0'); + // e.target.value = MIN_DURATION_HOURS.toString(); } trigger(['durationMinutes', 'durationHours', 'durationDays']); onChange(e); diff --git a/src/containers/dateTimeSelector/index.tsx b/src/containers/dateTimeSelector/index.tsx index ffa75d731..a549287b1 100644 --- a/src/containers/dateTimeSelector/index.tsx +++ b/src/containers/dateTimeSelector/index.tsx @@ -1,6 +1,5 @@ import {DateInput, DropdownInput} from '@aragon/ods-old'; import {toDate} from 'date-fns-tz'; -import format from 'date-fns/format'; import React, {useCallback, useMemo} from 'react'; import {Controller, useFormContext, useWatch} from 'react-hook-form'; import {useTranslation} from 'react-i18next'; @@ -148,9 +147,9 @@ const DateTimeSelector: React.FC = ({ validationTimeout = setTimeout(() => { // automatically correct the end date to minimum - setValue('endDate', format(minEndDateTimeMills, 'yyyy-MM-dd')); - setValue('endTime', format(minEndDateTimeMills, 'HH:mm')); - setValue('endUtc', currTimezone); + // setValue('endDate', format(minEndDateTimeMills, 'yyyy-MM-dd')); + // setValue('endTime', format(minEndDateTimeMills, 'HH:mm')); + // setValue('endUtc', currTimezone); }, CORRECTION_DELAY); } @@ -158,9 +157,9 @@ const DateTimeSelector: React.FC = ({ if (maxDurationMills !== 0 && endMills > maxEndDateTimeMills) { validationTimeout = setTimeout(() => { // automatically correct the end date to maximum - setValue('endDate', format(maxEndDateTimeMills, 'yyyy-MM-dd')); - setValue('endTime', format(maxEndDateTimeMills, 'HH:mm')); - setValue('endUtc', currTimezone); + // setValue('endDate', format(maxEndDateTimeMills, 'yyyy-MM-dd')); + // setValue('endTime', format(maxEndDateTimeMills, 'HH:mm')); + // setValue('endUtc', currTimezone); }, CORRECTION_DELAY); } diff --git a/src/containers/defineExecutionMultisig/index.tsx b/src/containers/defineExecutionMultisig/index.tsx index 05708f34b..ffe8f41a3 100644 --- a/src/containers/defineExecutionMultisig/index.tsx +++ b/src/containers/defineExecutionMultisig/index.tsx @@ -1,75 +1,33 @@ import {Label} from '@aragon/ods-old'; -import React, {useCallback, useEffect} from 'react'; -import { - Controller, - useFormContext, - useWatch, - ValidateResult, -} from 'react-hook-form'; +import React from 'react'; import {useTranslation} from 'react-i18next'; import styled from 'styled-components'; import AddCommittee from 'components/addCommittee'; import ExecutionExpirationTime from 'components/executionExpirationTime'; -import MinimumApproval from '../../components/multisigMinimumApproval/minimumApproval'; -import {generateAlert} from '../../components/multisigMinimumApproval'; +import {ExecutionMultisigMinimumApproval} from '../../components/executionMultisigMinimumApproval'; -const MIN_REQUIRED_APPROVALS = 1; +export type ConfigureExecutionMultisigProps = { + isSettingPage?: boolean; +}; -const DefineExecutionMultisig: React.FC = () => { +const DefineExecutionMultisig: React.FC = ({ + isSettingPage = false, +}) => { const {t} = useTranslation(); - const {control, setValue, trigger} = useFormContext(); - - const [committee, committeeMinimumApproval] = useWatch({ - name: ['committee', 'committeeMinimumApproval'], - }); - - const committeeCount = committee?.length ?? 0; - - const validateMinimumApproval = (value: number): ValidateResult => { - if (value > committeeCount) { - return t('errors.minimumApproval.exceedMaxThreshold'); - } else if (value <= 0) { - return t('errors.required.minApproval'); - } - return true; - }; - - const minApprovalChanged = useCallback( - ( - e: React.ChangeEvent, - onChange: React.ChangeEventHandler - ) => { - const value = Number(e.target.value); - if (value > committeeCount) { - setValue('committeeMinimumApproval', committeeCount.toString()); - e.target.value = committeeCount; - } - trigger(['committeeMinimumApproval']); - onChange(e); - }, - [committeeCount, setValue, trigger] - ); - - // This is used to update the committeeMinimumApproval when a wallet is deleted - useEffect(() => { - if (Number(committeeMinimumApproval) === 0 && committeeCount === 1) { - setValue('committeeMinimumApproval', committeeCount.toString()); - } else if (Number(committeeMinimumApproval) > committeeCount) { - setValue('committeeMinimumApproval', committeeCount.toString()); - } - }, [committeeCount, committeeMinimumApproval, setValue]); return ( <> {/*Executive committee members*/} - - + {!isSettingPage && ( + + + )} {/*Minimum Approval*/} @@ -77,33 +35,7 @@ const DefineExecutionMultisig: React.FC = () => { label={t('labels.minimumApproval')} helpText={t('createDAO.step4.minimumApprovalSubtitle')} /> - validateMinimumApproval(value), - }} - render={({ - field: {onBlur, onChange, value, name}, - fieldState: {error}, - }) => ( - <> - ) => - minApprovalChanged(e, onChange) - } - error={generateAlert(value, committee.length, t, error)} - /> - - )} - /> + {/* Execution Expiration Time */} diff --git a/src/containers/delegateVotingMenu/delegateVotingForm.tsx b/src/containers/delegateVotingMenu/delegateVotingForm.tsx index 7a6901b9f..c4da2e8c5 100644 --- a/src/containers/delegateVotingMenu/delegateVotingForm.tsx +++ b/src/containers/delegateVotingMenu/delegateVotingForm.tsx @@ -68,7 +68,8 @@ export const DelegateVotingForm: React.FC = props => { const {data: delegateData} = useDelegatee( {tokenAddress: daoToken?.address as string}, - {enabled: daoToken != null && !isOnWrongNetwork} + {enabled: daoToken != null && !isOnWrongNetwork}, + daoDetails ); const currentDelegate = delegateData === null ? address : delegateData; diff --git a/src/containers/delegateVotingMenu/delegateVotingMenu.tsx b/src/containers/delegateVotingMenu/delegateVotingMenu.tsx index 0028831c1..bbc6d6a9d 100644 --- a/src/containers/delegateVotingMenu/delegateVotingMenu.tsx +++ b/src/containers/delegateVotingMenu/delegateVotingMenu.tsx @@ -15,7 +15,7 @@ import {DelegateVotingForm} from './delegateVotingForm'; import {DelegateVotingSuccess} from './delegateVotingSuccess'; import {aragonSdkQueryKeys} from 'services/aragon-sdk/query-keys'; import {useQueryClient} from '@tanstack/react-query'; -import {FormProvider, UseFormProps, useForm, useWatch} from 'react-hook-form'; +import {FormProvider, useForm, UseFormProps, useWatch} from 'react-hook-form'; import { DelegateVotingFormField, IDelegateVotingFormValues, @@ -83,7 +83,8 @@ export const DelegateVotingMenu: React.FC = () => { const {data: delegateData} = useDelegatee( {tokenAddress: daoToken?.address as string}, - {enabled: daoToken != null && !isOnWrongNetwork} + {enabled: daoToken != null && !isOnWrongNetwork}, + daoDetails ); // The useDelegatee hook returns null when current delegate is connected address diff --git a/src/containers/delegationGatingMenu/delegationGatingMenu.tsx b/src/containers/delegationGatingMenu/delegationGatingMenu.tsx index 5fca2cc4e..a8bfdea0e 100644 --- a/src/containers/delegationGatingMenu/delegationGatingMenu.tsx +++ b/src/containers/delegationGatingMenu/delegationGatingMenu.tsx @@ -72,7 +72,8 @@ export const DelegationGatingMenu: React.FC = () => { const {data: delegateData} = useDelegatee( {tokenAddress: daoToken?.address as string}, - {enabled: daoToken != null} + {enabled: daoToken != null}, + daoDetails ); // The useDelegatee hook returns null when current delegate is connected address diff --git a/src/containers/duration/index.tsx b/src/containers/duration/index.tsx index d95814f6c..e444cbcf3 100644 --- a/src/containers/duration/index.tsx +++ b/src/containers/duration/index.tsx @@ -67,6 +67,7 @@ const Duration: React.FC = ({ [daoMinDurationMills] ); + // eslint-disable-next-line @typescript-eslint/no-unused-vars const resetToMinDuration = useCallback(() => { setValue('durationDays', minimums.days); setValue('durationHours', minimums.hours); @@ -97,8 +98,8 @@ const Duration: React.FC = ({ setValue('durationHours', '0'); setValue('durationMinutes', '0'); } else if (value <= minimums.days && durationLTMinimum(formDuration)) { - resetToMinDuration(); - e.target.value = minimums.days.toString(); + // resetToMinDuration(); + // e.target.value = minimums.days.toString(); } trigger(['durationMinutes', 'durationHours', 'durationDays']); onChange(e); @@ -108,7 +109,6 @@ const Duration: React.FC = ({ getValues, maxDurationDays, minimums.days, - resetToMinDuration, setValue, trigger, ] @@ -143,20 +143,13 @@ const Duration: React.FC = ({ ); } } else if (value <= minimums.hours && durationLTMinimum(formDuration)) { - resetToMinDuration(); - e.target.value = minimums.hours.toString(); + // resetToMinDuration(); + // e.target.value = minimums.hours.toString(); } trigger(['durationMinutes', 'durationHours', 'durationDays']); onChange(e); }, - [ - durationLTMinimum, - getValues, - minimums.hours, - resetToMinDuration, - setValue, - trigger, - ] + [durationLTMinimum, getValues, minimums.hours, setValue, trigger] ); const handleMinutesChanged = useCallback( @@ -191,20 +184,13 @@ const Duration: React.FC = ({ setValue('durationHours', hours.toString()); e.target.value = mins.toString(); } else if (value <= minimums.minutes && durationLTMinimum(formDuration)) { - resetToMinDuration(); - e.target.value = minimums.minutes.toString(); + // resetToMinDuration(); + // e.target.value = minimums.minutes.toString(); } trigger(['durationMinutes', 'durationHours', 'durationDays']); onChange(e); }, - [ - durationLTMinimum, - getValues, - minimums.minutes, - resetToMinDuration, - setValue, - trigger, - ] + [durationLTMinimum, getValues, minimums.minutes, setValue, trigger] ); /************************************************* diff --git a/src/containers/editSettings/majorityVoting.tsx b/src/containers/editSettings/majorityVoting.tsx index 38584f185..2dc69384d 100644 --- a/src/containers/editSettings/majorityVoting.tsx +++ b/src/containers/editSettings/majorityVoting.tsx @@ -37,6 +37,8 @@ import {getDHMFromSeconds} from 'utils/date'; import {decodeVotingMode, formatUnits, toDisplayEns} from 'utils/library'; import {ProposeNewSettings} from 'utils/paths'; import {GaslessPluginVotingSettings} from '@vocdoni/gasless-voting'; +import DefineExecutionMultisig from '../defineExecutionMultisig'; +import {MultisigWalletField} from '../../components/multisigWallets/row'; type EditMvSettingsProps = { daoDetails: DaoDetails; @@ -97,7 +99,6 @@ export const EditMvSettings: React.FC = ({daoDetails}) => { executionExpirationMinutes, executionExpirationHours, executionExpirationDays, - committee, committeeMinimumApproval, ] = useWatch({ name: [ @@ -114,12 +115,9 @@ export const EditMvSettings: React.FC = ({daoDetails}) => { 'daoLinks', 'earlyExecution', 'voteReplacement', - - 'votingType', 'executionExpirationMinutes', 'executionExpirationHours', 'executionExpirationDays', - 'committee', 'committeeMinimumApproval', ], control, @@ -130,13 +128,16 @@ export const EditMvSettings: React.FC = ({daoDetails}) => { let approvalDays: number | undefined; let approvalHours: number | undefined; let approvalMinutes: number | undefined; - if (isGasless) { + let executionMultisigMembers: string[] | undefined; + if (isGasless && votingSettings) { const {days, hours, minutes} = getDHMFromSeconds( - (votingSettings as GaslessPluginVotingSettings)?.minTallyDuration ?? 0 + (votingSettings as GaslessPluginVotingSettings).minTallyDuration ?? 0 ); approvalDays = days; approvalHours = hours; approvalMinutes = minutes; + executionMultisigMembers = (votingSettings as GaslessPluginVotingSettings) + .executionMultisigMembers; } const controlledLinks = fields.map((field, index) => { @@ -220,15 +221,28 @@ export const EditMvSettings: React.FC = ({daoDetails}) => { voteReplacement !== daoVotingMode.voteReplacement; } - let isGaslessChanged = false; - if (isGasless && votingSettings) { - isGaslessChanged = - Number(executionExpirationMinutes) !== approvalMinutes || - Number(executionExpirationHours) !== approvalHours || - Number(executionExpirationDays) !== approvalDays || - committeeMinimumApproval !== committee; - // committee !== committeList; // todo(kon): implement it - } + const isGaslessChanged = useMemo(() => { + if (isGasless && votingSettings) { + return ( + Number(executionExpirationMinutes) !== approvalMinutes || + Number(executionExpirationHours) !== approvalHours || + Number(executionExpirationDays) !== approvalDays || + committeeMinimumApproval !== + (votingSettings as GaslessPluginVotingSettings).minTallyApprovals + ); + } + return false; + }, [ + approvalDays, + approvalHours, + approvalMinutes, + committeeMinimumApproval, + executionExpirationDays, + executionExpirationHours, + executionExpirationMinutes, + isGasless, + votingSettings, + ]); // calculate proposer let daoEligibleProposer: TokenVotingProposalEligibility = @@ -326,7 +340,8 @@ export const EditMvSettings: React.FC = ({daoDetails}) => { // TODO: Alerts share will be added later setValue( 'membership', - daoDetails?.plugins[0].id === 'token-voting.plugin.dao.eth' + daoDetails?.plugins[0].id === 'token-voting.plugin.dao.eth' || + daoDetails?.plugins[0].id === GaselessPluginName ? 'token' : 'wallet' ); @@ -346,11 +361,18 @@ export const EditMvSettings: React.FC = ({daoDetails}) => { if (!isGasless) return; setValue('votingType', 'gasless'); - setValue('executionExpirationMinutes', approvalMinutes); - setValue('executionExpirationHours', approvalHours); - setValue('executionExpirationDays', approvalDays); - // todo(kon): implement this - // setValue('committee', []); + setValue('executionExpirationMinutes', approvalMinutes?.toString()); + setValue('executionExpirationHours', approvalHours?.toString()); + setValue('executionExpirationDays', approvalDays?.toString()); + // This is needed to store on form state the actual committee members in order to re-use the DefineExecutionMultisig + setValue( + 'committee', + executionMultisigMembers?.map(wallet => ({ + address: wallet, + ensName: '', + })) as MultisigWalletField[] + ); + setValue( 'committeeMinimumApproval', (votingSettings as GaslessPluginVotingSettings).minTallyApprovals @@ -359,6 +381,7 @@ export const EditMvSettings: React.FC = ({daoDetails}) => { approvalDays, approvalHours, approvalMinutes, + executionMultisigMembers, isGasless, setValue, votingSettings, @@ -396,11 +419,13 @@ export const EditMvSettings: React.FC = ({daoDetails}) => { setCurrentMetadata(); setCurrentCommunity(); setCurrentGovernance(); + setCurrentGasless(); } }, [ dataFetched, isDirty, setCurrentCommunity, + setCurrentGasless, setCurrentGovernance, setCurrentMetadata, ]); @@ -443,19 +468,18 @@ export const EditMvSettings: React.FC = ({daoDetails}) => { }, ]; - /*todo(kon): fix or remove before pr turn to non-draft*/ - // const gaslessAction = [ - // { - // component: ( - // - // ), - // callback: setCurrentGasless, - // }, - // ]; + const gaslessAction = [ + { + component: ( + + ), + callback: setCurrentGasless, + }, + ]; if (isLoading) { return ; @@ -528,20 +552,21 @@ export const EditMvSettings: React.FC = ({daoDetails}) => { - {/*todo(kon): this is not going to work until the executive committe list can be retrieved */} - {/**/} - {/* */} - {/* */} - {/* */} - {/**/} + {isGasless && ( + + + + + + )} {/* Footer */} diff --git a/src/containers/goLive/committee.tsx b/src/containers/goLive/committee.tsx index 85b9c717e..00ea8c9dc 100644 --- a/src/containers/goLive/committee.tsx +++ b/src/containers/goLive/committee.tsx @@ -15,7 +15,6 @@ import CommitteeAddressesModal from '../committeeAddressesModal'; const Committee = () => { const {control, getValues} = useFormContext(); const {setStep} = useFormStep(); - const {open} = useGlobalModalContext(); const {t} = useTranslation(); const { @@ -46,60 +45,91 @@ const Committee = () => { tagLabel={t('labels.changeableVote')} onChecked={() => onChange(!value)} > -
-
{t('labels.review.eligibleMembers')}
-
{t('labels.multisigMembers')}
-
-
-
{t('labels.members')}
-
- open('committeeMembers')} - /> -
-
-
-
{t('labels.minimumApproval')}
-
- {t('labels.review.multisigMinimumApprovals', { - count: committeeMinimumApproval, - total: committee.length, - })} -
-
-
-
{t('createDao.executionMultisig.executionTitle')}
-
-
-
- {t('createDAO.review.days', {days: executionExpirationDays})} -
- {executionExpirationHours > 0 && ( -
- {t('createDAO.review.hours', { - hours: executionExpirationHours, - })} -
- )} - {executionExpirationMinutes > 0 && ( -
- {t('createDAO.review.minutes', { - minutes: executionExpirationMinutes, - })} -
- )} -
-
-
- - + )} /> ); }; +export type ReviewExecutionMultisigProps = { + committee: string[]; + committeeMinimumApproval: number; + executionExpirationMinutes: number; + executionExpirationHours: number; + executionExpirationDays: number; +}; +export const ReviewExecutionMultisig: React.FC< + ReviewExecutionMultisigProps +> = ({ + committee, + committeeMinimumApproval, + executionExpirationMinutes, + executionExpirationHours, + executionExpirationDays, +}) => { + const {t} = useTranslation(); + const {open} = useGlobalModalContext(); + + return ( + <> +
+
{t('labels.review.eligibleMembers')}
+
{t('labels.multisigMembers')}
+
+
+
{t('labels.members')}
+
+ open('committeeMembers')} + /> +
+
+
+
{t('labels.minimumApproval')}
+
+ {committeeMinimumApproval}  + {t('labels.review.multisigMinimumApprovals', { + count: committee?.length || 0, + })} +
+
+
+
{t('createDao.executionMultisig.executionTitle')}
+
+
+
+ {t('createDAO.review.days', {days: executionExpirationDays})} +
+ {executionExpirationHours > 0 && ( +
+ {t('createDAO.review.hours', { + hours: executionExpirationHours, + })} +
+ )} + {executionExpirationMinutes > 0 && ( +
+ {t('createDAO.review.minutes', { + minutes: executionExpirationMinutes, + })} +
+ )} +
+
+
+ + + ); +}; + export default Committee; diff --git a/src/containers/goLive/governance.tsx b/src/containers/goLive/governance.tsx index b137499c2..555eb04a0 100644 --- a/src/containers/goLive/governance.tsx +++ b/src/containers/goLive/governance.tsx @@ -25,6 +25,7 @@ const Governance: React.FC = () => { multisigWallets, isCustomToken, tokenType, + votingType, } = getValues(); const isGovTokenRequiresWrapping = !isCustomToken && tokenType === 'ERC-20'; @@ -103,14 +104,20 @@ const Governance: React.FC = () => { -
-
{t('labels.earlyExecution')}
-
{earlyExecution ? t('labels.yes') : t('labels.no')}
-
-
-
{t('labels.voteReplacement')}
-
{voteReplacement ? t('labels.yes') : t('labels.no')}
-
+ {votingType === 'onChain' && ( + <> +
+
{t('labels.earlyExecution')}
+
{earlyExecution ? t('labels.yes') : t('labels.no')}
+
+
+
{t('labels.voteReplacement')}
+
+ {voteReplacement ? t('labels.yes') : t('labels.no')} +
+
{' '} + + )} )} diff --git a/src/containers/proposeSettingsStepper/proposeSettingsStepper.tsx b/src/containers/proposeSettingsStepper/proposeSettingsStepper.tsx new file mode 100644 index 000000000..d95f3b421 --- /dev/null +++ b/src/containers/proposeSettingsStepper/proposeSettingsStepper.tsx @@ -0,0 +1,297 @@ +import {generatePath} from 'react-router-dom'; +import {Settings} from '../../utils/paths'; +import {toDisplayEns} from '../../utils/library'; +import {FullScreenStepper, Step} from '../../components/fullScreenStepper'; +import CompareSettings from '../compareSettings'; +import { + DefineProposal, + isValid as defineProposalIsValid, +} from '../defineProposal'; +import SetupVotingForm from '../setupVotingForm'; +import ReviewProposal from '../reviewProposal'; +import React, {useCallback} from 'react'; +import {useFormContext, useFormState} from 'react-hook-form'; +import {useDaoDetailsQuery} from '../../hooks/useDaoDetails'; +import { + isGaslessVotingSettings, + isTokenVotingSettings, + useVotingSettings, +} from '../../services/aragon-sdk/queries/use-voting-settings'; +import {PluginTypes} from '../../hooks/usePluginClient'; +import {useDaoToken} from '../../hooks/useDaoToken'; +import {useTokenSupply} from '../../hooks/useTokenSupply'; +import { + Action, + ActionUpdateGaslessSettings, + ActionUpdateMetadata, + ActionUpdateMultisigPluginSettings, + ActionUpdatePluginSettings, +} from '../../utils/types'; +import {MultisigWalletField} from '../../components/multisigWallets/row'; +import {getSecondsFromDHM} from '../../utils/date'; +import {parseUnits} from 'ethers/lib/utils'; +import {VotingMode} from '@aragon/sdk-client'; +import {useTranslation} from 'react-i18next'; +import {useNetwork} from '../../context/network'; +import {Loading} from '../../components/temporary'; + +type ProposalStepperType = { + enableTxModal: () => void; +}; + +export const ProposeSettingsStepper: React.FC = ({ + enableTxModal, +}) => { + const {t} = useTranslation(); + const {network} = useNetwork(); + const {getValues, setValue, control} = useFormContext(); + const {errors, dirtyFields} = useFormState({ + control, + }); + + const {data: daoDetails, isLoading: daoDetailsLoading} = useDaoDetailsQuery(); + const {data: pluginSettings, isLoading: settingsLoading} = useVotingSettings({ + pluginAddress: daoDetails?.plugins[0].instanceAddress as string, + pluginType: daoDetails?.plugins[0].id as PluginTypes, + }); + + const pluginAddress = daoDetails?.plugins?.[0]?.instanceAddress as string; + const {data: daoToken} = useDaoToken(pluginAddress); + const {data: tokenSupply, isLoading: tokenSupplyIsLoading} = useTokenSupply( + daoToken?.address || '' + ); + + // filter actions making sure unchanged information is not bundled + // into the list of actions + const filterActions = useCallback( + (actions: Action[]) => { + const [settingsChanged, metadataChanged] = getValues([ + 'areSettingsChanged', + 'isMetadataChanged', + ]); + + // ignore every action that is not modifying the metadata and voting settings + const filteredActions = (actions as Array).filter(action => { + if (action.name === 'modify_metadata' && metadataChanged) { + return action; + } else if ( + (action.name === 'modify_token_voting_settings' || + action.name === 'modify_multisig_voting_settings' || + action.name === 'modify_gasless_voting_settings') && + settingsChanged + ) { + return action; + } + }); + return filteredActions; + }, + [getValues] + ); + + // Not a fan, but this sets the actions on the form context so that the Action + // Widget can read them + const setFormActions = useCallback(async () => { + const [ + daoName, + daoSummary, + daoLogo, + minimumApproval, + multisigMinimumApprovals, + minimumParticipation, + eligibilityType, + eligibilityTokenAmount, + earlyExecution, + voteReplacement, + durationDays, + durationHours, + durationMinutes, + resourceLinks, + tokenDecimals, + + executionExpirationMinutes, + executionExpirationHours, + executionExpirationDays, + committee, + committeeMinimumApproval, + ] = getValues([ + 'daoName', + 'daoSummary', + 'daoLogo', + 'minimumApproval', + 'multisigMinimumApprovals', + 'minimumParticipation', + 'eligibilityType', + 'eligibilityTokenAmount', + 'earlyExecution', + 'voteReplacement', + 'durationDays', + 'durationHours', + 'durationMinutes', + 'daoLinks', + 'tokenDecimals', + 'executionExpirationMinutes', + 'executionExpirationHours', + 'executionExpirationDays', + 'committee', + 'committeeMinimumApproval', + ]); + + let daoLogoFile = ''; + + if (daoDetails && !daoName) return; + + if (daoLogo?.startsWith?.('blob')) + daoLogoFile = (await fetch(daoLogo).then(r => r.blob())) as string; + else daoLogoFile = daoLogo; + + const metadataAction: ActionUpdateMetadata = { + name: 'modify_metadata', + inputs: { + name: daoName, + description: daoSummary, + avatar: daoLogoFile, + links: resourceLinks, + }, + }; + + let settingsAction: Action; + + if (isGaslessVotingSettings(pluginSettings)) { + const gaslessSettingsAction: ActionUpdateGaslessSettings = { + name: 'modify_gasless_voting_settings', + inputs: { + token: daoToken, + totalVotingWeight: tokenSupply?.raw || BigInt(0), + + executionMultisigMembers: (committee as MultisigWalletField[]).map( + wallet => wallet.address + ), + minTallyApprovals: committeeMinimumApproval, + minDuration: getSecondsFromDHM( + durationDays, + durationHours, + durationMinutes + ), + minTallyDuration: getSecondsFromDHM( + executionExpirationDays, + executionExpirationHours, + executionExpirationMinutes + ), + minParticipation: Number(minimumParticipation) / 100, + supportThreshold: Number(minimumApproval) / 100, + minProposerVotingPower: + eligibilityType === 'token' + ? parseUnits( + eligibilityTokenAmount.toString(), + tokenDecimals + ).toBigInt() + : BigInt(0), + censusStrategy: '', + daoTokenAddress: daoToken?.address, + id: pluginAddress, + }, + }; + settingsAction = gaslessSettingsAction; + } else if (isTokenVotingSettings(pluginSettings)) { + const voteSettingsAction: ActionUpdatePluginSettings = { + name: 'modify_token_voting_settings', + inputs: { + token: daoToken, + totalVotingWeight: tokenSupply?.raw || BigInt(0), + + minDuration: getSecondsFromDHM( + durationDays, + durationHours, + durationMinutes + ), + supportThreshold: Number(minimumApproval) / 100, + minParticipation: Number(minimumParticipation) / 100, + minProposerVotingPower: + eligibilityType === 'token' + ? parseUnits( + eligibilityTokenAmount.toString(), + tokenDecimals + ).toBigInt() + : undefined, + votingMode: earlyExecution + ? VotingMode.EARLY_EXECUTION + : voteReplacement + ? VotingMode.VOTE_REPLACEMENT + : VotingMode.STANDARD, + }, + }; + settingsAction = voteSettingsAction; + } else { + const multisigSettingsAction: ActionUpdateMultisigPluginSettings = { + name: 'modify_multisig_voting_settings', + inputs: { + minApprovals: multisigMinimumApprovals, + onlyListed: eligibilityType === 'multisig', + }, + }; + settingsAction = multisigSettingsAction; + } + setValue('actions', filterActions([metadataAction, settingsAction])); + }, [ + getValues, + daoDetails, + pluginSettings, + daoToken, + pluginAddress, + setValue, + tokenSupply?.raw, + filterActions, + ]); + + if (daoDetailsLoading || settingsLoading || tokenSupplyIsLoading) { + return ; + } + + if (!pluginSettings || !daoDetails) { + return null; + } + + return ( + + { + setFormActions(); + next(); + }} + > + + + + + + + + + + + + + ); +}; diff --git a/src/containers/reviewProposal/index.tsx b/src/containers/reviewProposal/index.tsx index 7ad6f7dde..af587d0e3 100644 --- a/src/containers/reviewProposal/index.tsx +++ b/src/containers/reviewProposal/index.tsx @@ -79,6 +79,7 @@ const ReviewProposal: React.FC = ({ useVotingSettings({pluginAddress, pluginType}); const isMultisig = isMultisigVotingSettings(votingSettings); + const isGasless = isGaslessVotingSettings(votingSettings); // Member list only needed for multisig so first page (1000) is sufficient const { @@ -219,11 +220,12 @@ const ReviewProposal: React.FC = ({ setDisplayedActions( getNonEmptyActions( getValues('actions'), - isMultisig ? votingSettings : undefined + isMultisig ? votingSettings : undefined, + isGasless ? votingSettings : undefined ) ); } - }, [getValues, isMultisig, type, votingSettings]); + }, [isGasless, getValues, isMultisig, type, votingSettings]); useEffect(() => { if (type === ProposalTypes.OSUpdates) { diff --git a/src/containers/settings/gaslessVoting/index.tsx b/src/containers/settings/gaslessVoting/index.tsx index a564dd3b9..2c0f4c208 100644 --- a/src/containers/settings/gaslessVoting/index.tsx +++ b/src/containers/settings/gaslessVoting/index.tsx @@ -14,51 +14,28 @@ import {useVotingSettings} from 'services/aragon-sdk/queries/use-voting-settings import {IPluginSettings} from 'pages/settings'; import {getDHMFromSeconds} from 'utils/date'; import {GaslessPluginVotingSettings} from '@vocdoni/gasless-voting'; +import {useGlobalModalContext} from '../../../context/globalModals'; +import ModalBottomSheetSwitcher from '../../../components/modalBottomSheetSwitcher'; +import {FilteredAddressList} from '../../../components/filteredAddressList'; +import {MultisigWalletField} from '../../../components/multisigWallets/row'; const GaslessVotingSettings: React.FC = ({daoDetails}) => { const {t} = useTranslation(); - // const {network} = useNetwork(); - // const navigate = useNavigate(); const pluginAddress = daoDetails?.plugins?.[0]?.instanceAddress as string; const pluginType = daoDetails?.plugins?.[0]?.id as PluginTypes; + const {open} = useGlobalModalContext(); const {data: pluginVotingSettings, isLoading: votingSettingsLoading} = useVotingSettings({pluginAddress, pluginType}); - // todo(kon): finish to implement this page - // const {data: daoMembers, isLoading: membersLoading} = useDaoMembers( - // pluginAddress, - // pluginType, - // {countOnly: true} - // ); - - // const {data: daoToken, isLoading: daoTokenLoading} = - // useDaoToken(pluginAddress); - // - // const {data: tokenSupply, isLoading: tokenSupplyLoading} = useTokenSupply( - // daoToken?.address ?? '' - // ); - // - // const {isTokenMintable: canMintToken} = useExistingToken({ - // daoToken, - // daoDetails, - // }); - const isLoading = votingSettingsLoading; - // || - // membersLoading || - // daoTokenLoading || - // tokenSupplyLoading; if (isLoading) { return ; } const dataIsFetched = !!daoDetails && !!pluginVotingSettings; - // !!daoMembers && - // !!daoToken && - // !!tokenSupply; if (!dataIsFetched) { return null; @@ -73,15 +50,14 @@ const GaslessVotingSettings: React.FC = ({daoDetails}) => { return ( <> {/* COMMUNITY SECTION */} - + - {t('labels.review.members')} + {t('labels.members')} open('committeeMembers')} /> @@ -91,7 +67,7 @@ const GaslessVotingSettings: React.FC = ({daoDetails}) => { {t('labels.review.multisigMinimumApprovals', { count: votingSettings.minTallyApprovals, - total: 0, // todo(kon): how to retrieve executive committe count + total: votingSettings.executionMultisigMembers?.length, })} @@ -105,96 +81,38 @@ const GaslessVotingSettings: React.FC = ({daoDetails}) => { })} - {/* */} - {/* {t('votingTerminal.token')}*/} - {/* */} - {/*
*/} - {/* }*/} - {/* href={daoTokenBlockUrl}*/} - {/* description={shortenAddress(daoToken.address)}*/} - {/* className="shrink-0"*/} - {/* />*/} - {/* {canMintToken && (*/} - {/* */} - {/* )}*/} - {/*
*/} - {/*
*/} - {/*
*/} - {/* */} - {/* {t('labels.review.distribution')}*/} - {/* */} - {/* }*/} - {/* onClick={() =>*/} - {/* navigate(*/} - {/* generatePath(Community, {network, dao: daoDetails.address})*/} - {/* )*/} - {/* }*/} - {/* />*/} - {/* */} - {/* */} - {/* */} - {/* {t('labels.supply')}*/} - {/* */} - {/* {`${tokenSupply.formatted} ${daoToken.symbol}`}*/} - {/* */} - {/* */} - {/*
*/} - {/*/!* GOVERNANCE SECTION *!/*/} - {/**/} - {/* */} - {/* {t('labels.minimumApprovalThreshold')}*/} - {/* */} - {/* {`>${Math.round(votingSettings.supportThreshold * 100)}%`}*/} - {/* */} - {/* */} - {/* */} - {/* {t('labels.minimumParticipation')}*/} - {/* */} - {/* {`≥${Math.round(votingSettings.minParticipation * 100)}% (≥ ${*/} - {/* votingSettings.minParticipation * (tokenSupply.formatted ?? 0)*/} - {/* } ${daoToken.symbol})`}*/} - {/* */} - {/* */} - {/* */} - {/* {t('labels.minimumDuration')}*/} - {/* */} - {/* {t('governance.settings.preview', {*/} - {/* days,*/} - {/* hours,*/} - {/* minutes,*/} - {/* })}*/} - {/* */} - {/* */} - {/* */} - {/* {t('labels.review.earlyExecution')}*/} - {/* {votingMode.earlyExecution}*/} - {/* */} - {/* */} - {/* {t('labels.review.voteReplacement')}*/} - {/* {votingMode.voteReplacement}*/} - {/* */} - {/* */} - {/* {t('labels.review.proposalThreshold')}*/} - {/* */} - {/* {t('labels.review.tokenHoldersWithTkns', {*/} - {/* tokenAmount: formatUnits(*/} - {/* votingSettings.minProposerVotingPower ?? 0,*/} - {/* daoToken.decimals*/} - {/* ),*/} - {/* tokenSymbol: daoToken.symbol,*/} - {/* })}*/} - {/* */} - {/* */} + ({ + address: wallet, + ensName: '', + })) as MultisigWalletField[] + } + /> ); }; +const CustomCommitteeAddressesModal = ({ + wallets, +}: { + wallets: MultisigWalletField[]; +}) => { + const {isOpen, close} = useGlobalModalContext('committeeMembers'); + + /************************************************* + * Render * + *************************************************/ + return ( + + + + ); +}; + export default GaslessVotingSettings; diff --git a/src/containers/settings/majorityVoting/index.tsx b/src/containers/settings/majorityVoting/index.tsx index 25fa359b7..c54eb38fd 100644 --- a/src/containers/settings/majorityVoting/index.tsx +++ b/src/containers/settings/majorityVoting/index.tsx @@ -15,7 +15,7 @@ import {useNetwork} from 'context/network'; import {useDaoMembers} from 'hooks/useDaoMembers'; import {useDaoToken} from 'hooks/useDaoToken'; import {useExistingToken} from 'hooks/useExistingToken'; -import {PluginTypes} from 'hooks/usePluginClient'; +import {GaselessPluginName, PluginTypes} from 'hooks/usePluginClient'; import {useTokenSupply} from 'hooks/useTokenSupply'; import {useVotingSettings} from 'services/aragon-sdk/queries/use-voting-settings'; import {IPluginSettings} from 'pages/settings'; @@ -31,6 +31,7 @@ const MajorityVotingSettings: React.FC = ({daoDetails}) => { const pluginAddress = daoDetails?.plugins?.[0]?.instanceAddress as string; const pluginType = daoDetails?.plugins?.[0]?.id as PluginTypes; + const isGasless = pluginType === GaselessPluginName; const {data: pluginVotingSettings, isLoading: votingSettingsLoading} = useVotingSettings({pluginAddress, pluginType}); @@ -171,14 +172,18 @@ const MajorityVotingSettings: React.FC = ({daoDetails}) => { })} - - {t('labels.review.earlyExecution')} - {votingMode.earlyExecution} - - - {t('labels.review.voteReplacement')} - {votingMode.voteReplacement} - + {!isGasless && ( + <> + + {t('labels.review.earlyExecution')} + {votingMode.earlyExecution} + + + {t('labels.review.voteReplacement')} + {votingMode.voteReplacement} + + + )} {t('labels.review.proposalThreshold')} diff --git a/src/containers/settings/versionInfoCard/index.tsx b/src/containers/settings/versionInfoCard/index.tsx index db99da422..72f824747 100644 --- a/src/containers/settings/versionInfoCard/index.tsx +++ b/src/containers/settings/versionInfoCard/index.tsx @@ -1,8 +1,8 @@ import {IconLinkExternal, Link} from '@aragon/ods-old'; import { LIVE_CONTRACTS, - SupportedVersion, SupportedNetworksArray, + SupportedVersion, } from '@aragon/sdk-client-common'; import React from 'react'; import {useTranslation} from 'react-i18next'; @@ -17,7 +17,7 @@ import { Term, } from '../settingsCard'; import {useProtocolVersion} from 'services/aragon-sdk/queries/use-protocol-version'; -import {PluginTypes} from 'hooks/usePluginClient'; +import {GaselessPluginName, PluginTypes} from 'hooks/usePluginClient'; export const VersionInfoCard: React.FC<{ pluginAddress: string; @@ -53,7 +53,7 @@ export const VersionInfoCard: React.FC<{ case 'token-voting.plugin.dao.eth': pluginName = 'Token Voting'; break; - case 'vocdoni-gasless-voting-poc.plugin.dao.eth': + case GaselessPluginName: pluginName = 'Vocdoni Gasless Voting'; break; default: diff --git a/src/containers/votingTerminal/gaslessVotingTerminal.tsx b/src/containers/votingTerminal/gaslessVotingTerminal.tsx index 6435c9d54..95581c335 100644 --- a/src/containers/votingTerminal/gaslessVotingTerminal.tsx +++ b/src/containers/votingTerminal/gaslessVotingTerminal.tsx @@ -10,30 +10,25 @@ import {GaslessVotingProposal} from '@vocdoni/gasless-voting'; import {useGaslessCommiteVotes} from '../../context/useGaslessVoting'; import {useWallet} from '../../hooks/useWallet'; import {useProposalTransactionContext} from '../../context/proposalTransaction'; -import {VoteValues} from '@aragon/sdk-client'; import { ExecutionWidget, ExecutionWidgetProps, } from '../../components/executionWidget'; import {getProposalExecutionStatus} from '../../utils/proposals'; -import { - PENDING_PROPOSAL_STATUS_INTERVAL, - // PROPOSAL_STATUS_INTERVAL, -} from '../../pages/proposal'; +import {PENDING_PROPOSAL_STATUS_INTERVAL} from '../../pages/proposal'; import { getApproveStatusLabel, getCommitteVoteButtonLabel, } from '../../utils/committeeVoting'; import {PluginTypes} from '../../hooks/usePluginClient'; -import {BigNumber} from 'ethers'; import {VotingTerminalAccordionItem} from './accordionItem'; -type CommitteeExecutionWidgetProps = Pick< +type GaslessExecutionWidgetProps = Pick< ExecutionWidgetProps, 'actions' | 'onExecuteClicked' >; -type CommitteeVotingTerminalProps = { +type GaslessVotingTerminalProps = { votingStatusLabel: string; proposal: GaslessVotingProposal; pluginAddress: string; @@ -42,13 +37,11 @@ type CommitteeVotingTerminalProps = { wasOnWrongNetwork: boolean; }>; pluginType: PluginTypes; - votingPower: BigNumber; -} & CommitteeExecutionWidgetProps & +} & GaslessExecutionWidgetProps & PropsWithChildren; -export const GaslessVotingTerminal: React.FC = ({ +export const GaslessVotingTerminal: React.FC = ({ votingStatusLabel, - votingPower, proposal, pluginAddress, statusRef, @@ -60,17 +53,16 @@ export const GaslessVotingTerminal: React.FC = ({ const {t, i18n} = useTranslation(); const [terminalTab, setTerminalTab] = useState('breakdown'); const [approvalStatus, setApprovalStatus] = useState(''); - // const [intervalInMills, setIntervalInMills] = useState(0); const {address, isOnWrongNetwork} = useWallet(); const { canApprove, - approved, - isApproved, + isUserApproved, + isProposalApproved, canBeExecuted, + executableWithNextApproval, isApprovalPeriod, - executed, notBegan, } = useGaslessCommiteVotes(pluginAddress, proposal); @@ -117,20 +109,29 @@ export const GaslessVotingTerminal: React.FC = ({ const buttonLabel = useMemo(() => { if (proposal) { return getCommitteVoteButtonLabel( - executed, notBegan, - approved, - canApprove, - isApproved, + isUserApproved, + isProposalApproved, + isApprovalPeriod, + executableWithNextApproval, t ); } - }, [proposal, executed, notBegan, approved, canApprove, isApproved, t]); + }, [ + proposal, + notBegan, + isUserApproved, + isProposalApproved, + isApprovalPeriod, + executableWithNextApproval, + t, + ]); // vote button state and handler + // todo(kon): Should be refactored to use the same logic as the proposal page (using stateRef) const {voteNowDisabled, onClick} = useMemo(() => { // disable voting on non-active proposals or when wallet has voted or can't vote - if (!isApprovalPeriod || !canApprove || approved) { + if (!isApprovalPeriod || !canApprove || isUserApproved) { return {voteNowDisabled: true}; } @@ -160,10 +161,10 @@ export const GaslessVotingTerminal: React.FC = ({ else if (canApprove) { return { voteNowDisabled: false, - onClick: () => { + onClick: (tryExecution: boolean) => { handleExecutionMultisigApprove({ - vote: VoteValues.YES, - votingPower, + proposalId: proposal.id, + tryExecution, }); }, }; @@ -171,12 +172,12 @@ export const GaslessVotingTerminal: React.FC = ({ }, [ isApprovalPeriod, canApprove, - approved, + isUserApproved, address, isOnWrongNetwork, statusRef, handleExecutionMultisigApprove, - votingPower, + proposal.id, ]); /** @@ -187,17 +188,10 @@ export const GaslessVotingTerminal: React.FC = ({ useEffect(() => { if (proposal) { // set the very first time - setApprovalStatus( - getApproveStatusLabel(proposal, isApprovalPeriod, t, i18n.language) - ); + setApprovalStatus(getApproveStatusLabel(proposal, t, i18n.language)); const interval = setInterval(async () => { - const v = getApproveStatusLabel( - proposal, - isApprovalPeriod, - t, - i18n.language - ); + const v = getApproveStatusLabel(proposal, t, i18n.language); // remove interval timer once the proposal has started if (proposal.startDate.valueOf() <= new Date().valueOf()) { @@ -220,20 +214,11 @@ export const GaslessVotingTerminal: React.FC = ({ isApprovalPeriod && // active proposal address && // logged in !isOnWrongNetwork && // on proper network - !canApprove && // cannot vote - !approved // Already voted + !canApprove // cannot vote ) { return t('votingTerminal.status.ineligibleWhitelist'); } - }, [ - isApprovalPeriod, - proposal, - address, - isOnWrongNetwork, - canApprove, - approved, - t, - ]); + }, [isApprovalPeriod, proposal, address, isOnWrongNetwork, canApprove, t]); const ApprovalVotingTerminal = () => { return ( @@ -243,9 +228,10 @@ export const GaslessVotingTerminal: React.FC = ({ selectedTab={terminalTab} alertMessage={alertMessage} onTabSelected={setTerminalTab} - onVoteClicked={onClick} + onApprovalClicked={onClick} voteButtonLabel={buttonLabel} voteNowDisabled={voteNowDisabled} + executableWithNextApproval={executableWithNextApproval} className={ 'border border-t-0 border-neutral-100 bg-neutral-0 px-4 py-5 md:p-6' } diff --git a/src/containers/votingTerminal/index.tsx b/src/containers/votingTerminal/index.tsx index 5160df6e9..1655c2de8 100644 --- a/src/containers/votingTerminal/index.tsx +++ b/src/containers/votingTerminal/index.tsx @@ -29,7 +29,7 @@ import {usePastVotingPowerAsync} from 'services/aragon-sdk/queries/use-past-voti import {Web3Address, shortenAddress} from 'utils/library'; import BreakdownTab from './breakdownTab'; import InfoTab from './infoTab'; -import {PluginTypes} from 'hooks/usePluginClient'; +import {GaselessPluginName, PluginTypes} from 'hooks/usePluginClient'; import {generatePath, useNavigate, useParams} from 'react-router-dom'; import {DaoMember} from 'utils/paths'; import {useNetwork} from 'context/network'; @@ -128,7 +128,10 @@ export const VotingTerminal: React.FC = ({ const fetchPastVotingPower = usePastVotingPowerAsync(); const isMultisigProposal = - pluginType === 'multisig.plugin.dao.eth' && !!approvals && !!minApproval; + (pluginType === 'multisig.plugin.dao.eth' || + pluginType === GaselessPluginName) && // If is gasless and have approvals or min approvals act as multisig voting terminal + !!approvals && + !!minApproval; useEffect(() => { // fetch avatar fpr each voter @@ -252,7 +255,7 @@ export const VotingTerminal: React.FC = ({ {selectedTab === 'breakdown' ? ( diff --git a/src/context/createDao.tsx b/src/context/createDao.tsx index bd3d641c3..531c7ac31 100644 --- a/src/context/createDao.tsx +++ b/src/context/createDao.tsx @@ -224,7 +224,7 @@ const CreateDaoProvider: React.FC<{children: ReactNode}> = ({children}) => { minProposerVotingPower: eligibilityType === 'token' && eligibilityTokenAmount !== undefined ? parseUnits(eligibilityTokenAmount.toString(), decimals).toBigInt() - : eligibilityType === 'multisig' + : eligibilityType === 'multisig' || eligibilityType === 'anyone' ? BigInt(0) : parseUnits('1', decimals).toBigInt(), votingMode, diff --git a/src/context/createGaslessProposal.tsx b/src/context/createGaslessProposal.tsx index b4c95f088..7e5edcc17 100644 --- a/src/context/createGaslessProposal.tsx +++ b/src/context/createGaslessProposal.tsx @@ -4,7 +4,7 @@ import { Erc20WrapperTokenDetails, } from '@aragon/sdk-client'; import {ProposalMetadata} from '@aragon/sdk-client-common'; -import {useCallback} from 'react'; +import {useCallback, useState} from 'react'; import { Census, @@ -14,6 +14,9 @@ import { IElectionParameters, TokenCensus, UnpublishedElection, + AccountData, + ErrNotFoundToken, + ErrFaucetAlreadyFunded, } from '@vocdoni/sdk'; import {VoteValues} from '@aragon/sdk-client'; import {useClient} from '@vocdoni/react-providers'; @@ -22,7 +25,7 @@ import { StepStatus, useFunctionStepper, } from '../hooks/useFunctionStepper'; -import {useCensus3Client} from '../hooks/useCensus3'; +import {useCensus3Client, useCensus3CreateToken} from '../hooks/useCensus3'; export enum GaslessProposalStepId { REGISTER_VOCDONI_ACCOUNT = 'REGISTER_VOCDONI_ACCOUNT', @@ -35,6 +38,7 @@ export type GaslessProposalSteps = StepsMap; type ICreateGaslessProposal = { daoToken: Erc20TokenDetails | Erc20WrapperTokenDetails | undefined; + pluginAddress: string; chainId: number; }; @@ -76,6 +80,7 @@ const proposalToElection = ({ const useCreateGaslessProposal = ({ daoToken, chainId, + pluginAddress, }: ICreateGaslessProposal) => { const {steps, updateStepStatus, doStep, globalState, resetStates} = useFunctionStepper({ @@ -95,19 +100,31 @@ const useCreateGaslessProposal = ({ } as GaslessProposalSteps, }); - const {client: vocdoniClient, account, createAccount, errors} = useClient(); + const {client: vocdoniClient} = useClient(); const census3 = useCensus3Client(); + const {createToken} = useCensus3CreateToken({chainId}); + const [account, setAccount] = useState(undefined); - // todo(kon): check if this is needed somewhere else const collectFaucet = useCallback( async (cost: number) => { - let balance = account!.balance; - + let balance = (await vocdoniClient.fetchAccount()).balance; while (cost > balance) { - balance = (await vocdoniClient.collectFaucetTokens()).balance; + try { + balance = (await vocdoniClient.collectFaucetTokens()).balance; + } catch (e) { + // Wallet already funded + if (e instanceof ErrFaucetAlreadyFunded) { + const dateStr = `(until ${e.untilDate.toLocaleDateString()})`; + throw Error( + `This wallet has reached the maximum allocation of Vocdoni tokens for this period ${dateStr}. ` + + 'For additional tokens, please visit https://onvote.app/faucet and retry after acquiring more.' + ); + } + throw e; + } } }, - [account, vocdoniClient] + [vocdoniClient] ); const createVocdoniElection = useCallback( @@ -119,6 +136,7 @@ const useCreateGaslessProposal = ({ startDate: electionData.startDate, census: electionData.census, maxCensusSize: electionData.census.size ?? undefined, + electionType: {interruptible: false}, }); election.addQuestion( electionData.question, @@ -142,27 +160,42 @@ const useCreateGaslessProposal = ({ [collectFaucet, vocdoniClient] ); - // todo(kon): this is not a callback const checkAccountCreation = useCallback(async () => { // Check if the account is already created, if not, create it - await createAccount()?.finally(() => { - if (errors.account) throw errors.account; - }); - }, [createAccount, errors.account]); + let info; + if (account) return; + try { + info = await vocdoniClient.createAccount(); + } catch (error) { + console.log(error); + throw Error('Error creating Vocdoni account'); + } finally { + setAccount(info); + } + }, [account, vocdoniClient]); const createCensus = useCallback(async (): Promise => { async function getCensus3Token(): Promise { let attempts = 0; - const maxAttempts = 5; + const maxAttempts = 6; while (attempts < maxAttempts) { - const censusToken = await census3.getToken(daoToken!.address, chainId); - if (censusToken.status.synced) { - return censusToken; // early exit if the object has sync set to true + try { + const censusToken = await census3.getToken( + daoToken!.address, + chainId + ); + if (censusToken.status.synced) { + return censusToken; // early exit if the object has sync set to true + } + } catch (e) { + if (e instanceof ErrNotFoundToken) { + await createToken(pluginAddress); + } } attempts++; if (attempts < maxAttempts) { - await new Promise(resolve => setTimeout(resolve, 6000)); + await new Promise(resolve => setTimeout(resolve, 10000)); } } throw Error('Census token is not already calculated, try again later'); @@ -183,8 +216,7 @@ const useCreateGaslessProposal = ({ census3census.size, BigInt(census3census.weight) ); - // return await census3.createTokenCensus(censusToken.id); - }, [census3, chainId, daoToken]); + }, [census3, chainId, createToken, daoToken, pluginAddress]); const createProposal = useCallback( async ( @@ -211,7 +243,6 @@ const useCreateGaslessProposal = ({ GaslessProposalStepId.REGISTER_VOCDONI_ACCOUNT, checkAccountCreation ); - // 2. Create vocdoni election let census: TokenCensus; const electionId = await doStep( diff --git a/src/context/createProposal.tsx b/src/context/createProposal.tsx index d6c2829d4..1605f549f 100644 --- a/src/context/createProposal.tsx +++ b/src/context/createProposal.tsx @@ -50,6 +50,8 @@ import {useDaoDetailsQuery} from 'hooks/useDaoDetails'; import {useDaoToken} from 'hooks/useDaoToken'; import { GaselessPluginName, + isGaslessVotingClient, + isTokenVotingClient, PluginTypes, usePluginClient, } from 'hooks/usePluginClient'; @@ -81,6 +83,7 @@ import { } from 'utils/date'; import { getDefaultPayableAmountInputName, + readFile, toDisplayEns, translateToNetworkishName, } from 'utils/library'; @@ -175,6 +178,7 @@ const CreateProposalWrapper: React.FC = ({ createProposal, } = useCreateGaslessProposal({ daoToken, + pluginAddress, chainId: CHAIN_METADATA[network].id, }); @@ -186,7 +190,8 @@ const CreateProposalWrapper: React.FC = ({ const actions: Array> = []; // return an empty array for undefined clients - if (!pluginClient || !client) return Promise.resolve([] as DaoAction[]); + if (!pluginClient || !client || !daoDetails?.address) + return Promise.resolve([] as DaoAction[]); for await (const action of getNonEmptyActions(actionsFromForm)) { switch (action.name) { @@ -233,7 +238,9 @@ const CreateProposalWrapper: React.FC = ({ ); actions.push( Promise.resolve( - (pluginClient as MultisigClient).encoding.addAddressesAction({ + ( + pluginClient as MultisigClient | GaslessVotingClient + ).encoding.addAddressesAction({ pluginAddress: pluginAddress, members: wallets, }) @@ -248,12 +255,12 @@ const CreateProposalWrapper: React.FC = ({ if (wallets.length > 0) actions.push( Promise.resolve( - (pluginClient as MultisigClient).encoding.removeAddressesAction( - { - pluginAddress: pluginAddress, - members: wallets, - } - ) + ( + pluginClient as MultisigClient | GaslessVotingClient + ).encoding.removeAddressesAction({ + pluginAddress: pluginAddress, + members: wallets, + }) ) ); break; @@ -333,12 +340,12 @@ const CreateProposalWrapper: React.FC = ({ if ( translatedNetwork !== 'unsupported' && SupportedNetworksArray.includes(translatedNetwork) && - daoDetails?.address && + daoDetails.address && versions ) { actions.push( Promise.resolve( - client.encoding.daoUpdateAction(daoDetails?.address, { + client.encoding.daoUpdateAction(daoDetails.address, { previousVersion: versions as [number, number, number], daoFactoryAddress: LIVE_CONTRACTS[action.inputs.version as SupportedVersion][ @@ -354,7 +361,7 @@ const CreateProposalWrapper: React.FC = ({ case 'plugin_update': { const pluginUpdateActions = client.encoding.applyUpdateAndPermissionsActionBlock( - daoDetails?.address as string, + daoDetails.address as string, { ...action.inputs, } @@ -364,13 +371,78 @@ const CreateProposalWrapper: React.FC = ({ }); break; } + + case 'modify_metadata': { + const preparedAction = {...action}; + + if ( + preparedAction.inputs.avatar && + typeof preparedAction.inputs.avatar !== 'string' + ) { + try { + const daoLogoBuffer = await readFile( + preparedAction.inputs.avatar as unknown as Blob + ); + + const logoCID = await client.ipfs.add( + new Uint8Array(daoLogoBuffer) + ); + await client.ipfs.pin(logoCID!); + preparedAction.inputs.avatar = `ipfs://${logoCID}`; + } catch (e) { + preparedAction.inputs.avatar = undefined; + } + } + + try { + const ipfsUri = await client.methods.pinMetadata( + preparedAction.inputs + ); + + actions.push( + client.encoding.updateDaoMetadataAction( + daoDetails.address, + ipfsUri + ) + ); + } catch (error) { + throw Error('Could not pin metadata on IPFS'); + } + break; + } + case 'modify_gasless_voting_settings': { + if (isGaslessVotingClient(pluginClient)) { + actions.push( + Promise.resolve( + pluginClient.encoding.updatePluginSettingsAction( + pluginAddress, + action.inputs + ) + ) + ); + } + break; + } + case 'modify_token_voting_settings': { + if (isTokenVotingClient(pluginClient)) { + actions.push( + Promise.resolve( + pluginClient.encoding.updatePluginSettingsAction( + pluginAddress, + action.inputs + ) + ) + ); + } + break; + } } } return Promise.all(actions); }, [ client, - daoDetails?.address, + daoDetails, getValues, network, pluginAddress, @@ -888,13 +960,21 @@ const CreateProposalWrapper: React.FC = ({ ] ); - const handleOffChainProposal = useCallback(async () => { + const handleGaslessProposal = useCallback(async () => { if (!pluginClient || !daoToken) { return new Error('ERC20 SDK client is not initialized correctly'); } const {params, metadata} = await getProposalCreationParams(); - + if (!params.endDate) { + const startDate = params.startDate || new Date(); + params.endDate = new Date( + startDate.valueOf() + + daysToMills(minDays || 0) + + hoursToMills(minHours || 0) + + minutesToMills(minMinutes || 0) + ); + } await createProposal(metadata, params, handlePublishProposal); }, [ pluginClient, @@ -902,6 +982,9 @@ const CreateProposalWrapper: React.FC = ({ getProposalCreationParams, createProposal, handlePublishProposal, + minDays, + minHours, + minMinutes, ]); /************************************************* @@ -951,7 +1034,7 @@ const CreateProposalWrapper: React.FC = ({ globalState={gaslessGlobalState} isOpen={showTxModal} onClose={handleCloseModal} - callback={handleOffChainProposal} + callback={handleGaslessProposal} closeOnDrag={ creationProcessState !== TransactionState.LOADING || gaslessGlobalState !== StepStatus.LOADING diff --git a/src/context/proposalTransaction.tsx b/src/context/proposalTransaction.tsx index 6e1f4e1e1..dfd521bdf 100644 --- a/src/context/proposalTransaction.tsx +++ b/src/context/proposalTransaction.tsx @@ -62,7 +62,9 @@ type ProposalTransactionContextType = { handlePrepareApproval: (params: ApproveMultisigProposalParams) => void; handlePrepareExecution: () => void; handleGaslessVoting: (params: SubmitVoteParams) => void; - handleExecutionMultisigApprove: (params: SubmitVoteParams) => void; + handleExecutionMultisigApprove: ( + params: ApproveMultisigProposalParams + ) => void; isLoading: boolean; voteOrApprovalSubmitted: boolean; executionSubmitted: boolean; @@ -185,12 +187,12 @@ const ProposalTransactionProvider: React.FC = ({children}) => { ); const handleExecutionMultisigApprove = useCallback( - (params: SubmitVoteParams) => { - setVoteParams({proposalId, vote: params.vote}); + (params: ApproveMultisigProposalParams) => { + setApprovalParams(params); setShowCommitteeApprovalModal(true); setVoteOrApprovalProcessState(TransactionState.WAITING); }, - [proposalId] + [] ); const handlePrepareExecution = useCallback(() => { @@ -202,8 +204,11 @@ const ProposalTransactionProvider: React.FC = ({children}) => { * Estimations * *************************************************/ const estimateVoteOrApprovalFees = useCallback(async () => { - if (isGaslessVotingPluginClient && voteParams) { - return pluginClient?.estimation.approve(voteParams.proposalId); + if (isGaslessVotingPluginClient && approvalParams) { + return pluginClient?.estimation.approve( + approvalParams.proposalId, + approvalParams.tryExecution + ); } if (isTokenVotingPluginClient && voteParams && voteTokenAddress) { @@ -373,7 +378,7 @@ const ProposalTransactionProvider: React.FC = ({children}) => { ); const onGaslessVoteOrApprovalSubmitted = useCallback( - async (proposalId: string, vote: VoteValues, isApproval?: boolean) => { + async (proposalId: string, vote?: VoteValues) => { setVoteParams(undefined); setVoteOrApprovalSubmitted(true); setVoteOrApprovalProcessState(TransactionState.SUCCESS); @@ -382,12 +387,7 @@ const ProposalTransactionProvider: React.FC = ({children}) => { let voteToPersist; if (pluginType === GaselessPluginName) { - if (isApproval) { - voteToPersist = { - type: 'approval', - vote: address.toLowerCase(), - } as GaslessVoteOrApprovalVote; - } else if (voteTokenAddress != null) { + if (vote && voteTokenAddress != null) { const weight = await fetchVotingPower({ tokenAddress: voteTokenAddress, address, @@ -400,6 +400,11 @@ const ProposalTransactionProvider: React.FC = ({children}) => { weight: weight.toBigInt(), }, } as GaslessVoteOrApprovalVote; + } else { + voteToPersist = { + type: 'approval', + vote: address.toLowerCase(), + } as GaslessVoteOrApprovalVote; } } @@ -477,8 +482,8 @@ const ProposalTransactionProvider: React.FC = ({children}) => { if (pluginType === 'multisig.plugin.dao.eth' && approvalParams) { handleMultisigApproval(approvalParams); - } else if (pluginType === GaselessPluginName && voteParams) { - handleExecutionMultisigApproval(voteParams); + } else if (pluginType === GaselessPluginName && approvalParams) { + handleExecutionMultisigApproval(approvalParams); } else if (pluginType === 'token-voting.plugin.dao.eth' && voteParams) { handleTokenVotingVote(voteParams); } @@ -520,11 +525,12 @@ const ProposalTransactionProvider: React.FC = ({children}) => { // For gasless voting const handleExecutionMultisigApproval = useCallback( - async (params: VoteProposalParams) => { + async (params: ApproveMultisigProposalParams) => { if (!isGaslessVotingPluginClient) return; const approveSteps = await pluginClient?.methods.approve( - params.proposalId + params.proposalId, + params.tryExecution ); if (!approveSteps) { @@ -533,21 +539,13 @@ const ProposalTransactionProvider: React.FC = ({children}) => { setVoteOrApprovalSubmitted(false); - // tx hash is necessary for caching when approving and executing - // at the same time - // let txHash = ''; try { for await (const step of approveSteps) { switch (step.key) { case ApproveTallyStep.EXECUTING: - // txHash = step.txHash; break; case ApproveTallyStep.DONE: - onGaslessVoteOrApprovalSubmitted( - params.proposalId, - params.vote, - true - ); + onGaslessVoteOrApprovalSubmitted(params.proposalId); break; } } diff --git a/src/context/update.tsx b/src/context/update.tsx index 52c5fbd01..085fbe111 100644 --- a/src/context/update.tsx +++ b/src/context/update.tsx @@ -13,8 +13,8 @@ import { VersionTag, } from '@aragon/sdk-client-common'; import React, { - ReactElement, createContext, + ReactElement, useCallback, useContext, useEffect, @@ -26,7 +26,11 @@ import {useTranslation} from 'react-i18next'; import PublishModal from 'containers/transactionModals/publishModal'; import {useClient} from 'hooks/useClient'; import {useDaoDetailsQuery} from 'hooks/useDaoDetails'; -import {PluginTypes, usePluginClient} from 'hooks/usePluginClient'; +import { + GaselessPluginName, + PluginTypes, + usePluginClient, +} from 'hooks/usePluginClient'; import {usePollGasFee} from 'hooks/usePollGasfee'; import {useWallet} from 'hooks/useWallet'; import {usePluginVersions} from 'services/aragon-sdk/queries/use-plugin-versions'; @@ -344,10 +348,7 @@ const UpdateProvider: React.FC<{children: ReactElement}> = ({children}) => { // estimate creation fees const estimateCreationFees = useCallback(async () => { if (!state.daoUpdateData) return; - if ( - state.showModal.type === 'plugin' && - pluginType !== 'vocdoni-gasless-voting-poc.plugin.dao.eth' - ) + if (state.showModal.type === 'plugin' && pluginType !== GaselessPluginName) return ( pluginClient as MultisigClient | TokenVotingClient )?.estimation.prepareUpdate(state.daoUpdateData); diff --git a/src/context/useGaslessVoting.tsx b/src/context/useGaslessVoting.tsx index 90692dfe1..a8d9a7831 100644 --- a/src/context/useGaslessVoting.tsx +++ b/src/context/useGaslessVoting.tsx @@ -4,7 +4,7 @@ import { } from '@vocdoni/react-providers'; import {useCallback, useEffect, useMemo, useState} from 'react'; import {VoteProposalParams} from '@aragon/sdk-client'; -import {Vote} from '@vocdoni/sdk'; +import {ErrElectionFinished, Vote} from '@vocdoni/sdk'; import { StepsMap, StepStatus, @@ -65,7 +65,14 @@ const useGaslessVoting = () => { async (vote: VoteProposalParams, electionId: string) => { const vocVote = new Vote([vote.vote - 1]); // See values on the enum, using vocdoni starts on 0 await vocdoniClient.setElectionId(electionId); - return await vocdoniClient.submitVote(vocVote); + try { + return vocdoniClient.submitVote(vocVote); + } catch (e) { + if (e instanceof ErrElectionFinished) { + throw new Error('The election has finished'); + } + throw e; + } }, [vocdoniClient] ); @@ -81,7 +88,7 @@ const useGaslessVoting = () => { const electionId = await doStep( GaslessVotingStepId.CREATE_VOTE_ID, async () => { - const electionId = getElectionId(vote.proposalId); + const electionId = await getElectionId(vote.proposalId); if (!electionId) { throw Error( 'Proposal id has not any associated vocdoni electionId' @@ -113,7 +120,7 @@ export const useGaslessHasAlreadyVote = ({ proposal: DetailedProposal | undefined | null; }) => { const [hasAlreadyVote, setHasAlreadyVote] = useState(false); - const {client} = useClient(); + const {client, signer} = useClient(); const {address} = useWallet(); useEffect(() => { @@ -123,8 +130,12 @@ export const useGaslessHasAlreadyVote = ({ setHasAlreadyVote(true); return; } + if (!signer) return; setHasAlreadyVote( - !!(await client.hasAlreadyVoted(p!.vochainProposalId!)) + !!(await client.hasAlreadyVoted({ + wallet: signer, + electionId: p!.vochainProposalId!, + })) ); }; if ( @@ -135,7 +146,7 @@ export const useGaslessHasAlreadyVote = ({ ) { checkAlreadyVote(); } - }, [address, client, proposal]); + }, [address, client, proposal, signer]); return {hasAlreadyVote}; }; @@ -149,37 +160,41 @@ export const useGaslessCommiteVotes = ( const {address} = useWallet(); const isApprovalPeriod = (proposal => { - if (!proposal) return false; + if (!proposal || proposal.status !== 'Active') return false; return ( - proposal.endDate.valueOf() < new Date().valueOf() && - proposal.tallyEndDate.valueOf() > new Date().valueOf() + (proposal.endDate.valueOf() < new Date().valueOf() && + proposal.tallyEndDate.valueOf() > new Date().valueOf() && + proposal?.canBeApproved) ?? + false ); })(proposal); - const proposalCanBeApproved = - isApprovalPeriod && proposal.status === ProposalStatus.SUCCEEDED; - - const approved = useMemo(() => { - return proposal.approvers?.some(approver => approver === address); + const isUserApproved = useMemo(() => { + return proposal.approvers?.some( + approver => approver.toLowerCase() === address?.toLowerCase() + ); }, [address, proposal.approvers]); - const isApproved = (proposal => { + const isProposalApproved = (proposal => { if (!proposal) return false; return proposal.settings.minTallyApprovals <= proposal.approvers.length; })(proposal); const canBeExecuted = (proposal => { - if (!client || !proposal) return false; - return isApproved && proposalCanBeApproved; + if (!client || !proposal || proposal.status !== 'Active') return false; + return isProposalApproved && isApprovalPeriod; })(proposal); - const nextVoteWillApprove = - proposal.approvers.length + 1 === proposal.settings.minTallyApprovals; - const executed = proposal.executed; const notBegan = proposal.endDate.valueOf() > new Date().valueOf(); + const executableWithNextApproval = + proposal.status === ProposalStatus.ACTIVE && + proposal.actions.length > 0 && + proposal.settings.minTallyApprovals > 1 && + proposal.settings.minTallyApprovals - 1 === proposal.approvers.length; + useEffect(() => { const checkCanVote = async () => { const canApprove = @@ -188,32 +203,24 @@ export const useGaslessCommiteVotes = ( setCanApprove(canApprove); }; - if (!(address && client)) { + if (!address || !client) { return; } - if (approved || !isApprovalPeriod || !proposalCanBeApproved) { + if (isUserApproved || !isApprovalPeriod) { setCanApprove(false); return; } - checkCanVote(); - }, [ - address, - client, - isApprovalPeriod, - pluginAddress, - proposalCanBeApproved, - approved, - ]); + void checkCanVote(); + }, [address, client, isApprovalPeriod, pluginAddress, isUserApproved]); return { isApprovalPeriod, canApprove, - approved, - isApproved, + isUserApproved, + isProposalApproved, canBeExecuted, - nextVoteWillApprove, - proposalCanBeApproved, + executableWithNextApproval, executed, notBegan, }; diff --git a/src/hooks/useCensus3.tsx b/src/hooks/useCensus3.tsx index 0958ead3b..590df86b3 100644 --- a/src/hooks/useCensus3.tsx +++ b/src/hooks/useCensus3.tsx @@ -43,9 +43,10 @@ export const useCensus3CreateToken = ({chainId}: {chainId: number}) => { // Check if the census is already sync try { const token = await client?.methods.getToken(pluginAddress); - if (!token) throw 'Cannot retrieve the token'; + if (!token) throw Error('Cannot retrieve the token'); await census3.createToken(token.address, 'erc20', chainId, undefined, [ - 'aragon/app', + 'aragon', + 'dao', ]); } catch (e) { if (!(e instanceof ErrTokenAlreadyExists)) { diff --git a/src/hooks/useDaoActions.tsx b/src/hooks/useDaoActions.tsx index f849d3a66..9832546e5 100644 --- a/src/hooks/useDaoActions.tsx +++ b/src/hooks/useDaoActions.tsx @@ -7,10 +7,28 @@ import {useDaoToken} from './useDaoToken'; import {useProviders} from 'context/providers'; import {useEffect, useState} from 'react'; import {featureFlags} from 'utils/featureFlags'; +import {PluginTypes} from './usePluginClient'; +import { + isGaslessVotingSettings, + useVotingSettings, +} from '../services/aragon-sdk/queries/use-voting-settings'; export function useDaoActions(dao: string): HookData { - const {data: daoDetails, error, isLoading} = useDaoQuery(dao); - const multisig = daoDetails?.plugins[0].id === 'multisig.plugin.dao.eth'; + const { + data: daoDetails, + error, + isLoading: daoDetailsLoading, + } = useDaoQuery(dao); + const {id: pluginType} = daoDetails?.plugins[0] || {}; + const multisig = pluginType === 'multisig.plugin.dao.eth'; + + const {data: votingSettings, isLoading: settingsLoading} = useVotingSettings({ + pluginAddress: daoDetails?.plugins[0].instanceAddress as string, + pluginType: daoDetails?.plugins[0].id as PluginTypes, + }); + + const isLoading = daoDetailsLoading || settingsLoading; + const [showMintOption, setShowMintOption] = useState(false); const {api: provider} = useProviders(); @@ -25,20 +43,25 @@ export function useDaoActions(dao: string): HookData { daoToken?.address || '', provider ); - setShowMintOption( daoTokenView?.toLocaleLowerCase() === daoDetails?.address ); } - - fetch(); + if (isLoading) return; + if (votingSettings && isGaslessVotingSettings(votingSettings)) { + setShowMintOption(votingSettings.hasGovernanceEnabled!); + return; + } + void fetch(); }, [ dao, daoDetails, daoDetails?.address, daoToken?.address, + isLoading, provider, showMintOption, + votingSettings, ]); const {t} = useTranslation(); diff --git a/src/hooks/useExistingToken.tsx b/src/hooks/useExistingToken.tsx index cc6c88ef4..253a2b0f1 100644 --- a/src/hooks/useExistingToken.tsx +++ b/src/hooks/useExistingToken.tsx @@ -10,7 +10,12 @@ import { TokenVotingClient, } from '@aragon/sdk-client'; import {validateGovernanceTokenAddress} from 'utils/validators'; -import {usePluginClient} from './usePluginClient'; +import { + isGaslessVotingClient, + PluginTypes, + usePluginClient, +} from './usePluginClient'; +import {useGaslessGovernanceEnabled} from './useGaslessGovernanceEnabled'; export const useExistingToken = ({ daoDetails, @@ -21,6 +26,7 @@ export const useExistingToken = ({ } = {}) => { const {api: provider} = useProviders(); const {data: daoDetailsFetched} = useDaoDetailsQuery(); + const {isGovernanceEnabled} = useGaslessGovernanceEnabled(daoDetails); const dao = useMemo( () => daoDetails || daoDetailsFetched, @@ -31,7 +37,8 @@ export const useExistingToken = ({ dao?.plugins?.[0]?.instanceAddress || '' ); - const pluginClient = usePluginClient('token-voting.plugin.dao.eth'); + const {id: pluginType} = daoDetails?.plugins[0] || {}; + const pluginClient = usePluginClient(pluginType as PluginTypes); const token = useMemo( () => daoToken || daoTokenFetched, @@ -42,21 +49,29 @@ export const useExistingToken = ({ const [isTokenMintable, setIsTokenMintable] = useState(false); useEffect(() => { - async function fetchTokenOwner() { - if (!dao || !token) return; + async function isTokenMintable() { + if (!dao || !token || !pluginClient) return; + if (isGaslessVotingClient(pluginClient)) { + setIsTokenMintable(isGovernanceEnabled); + return; + } const tokenDaoOwner = await getDaoTokenOwner(token.address, provider); setIsTokenMintable(tokenDaoOwner?.toLocaleLowerCase() === dao.address); } - fetchTokenOwner(); - }, [dao, provider, token]); + void isTokenMintable(); + }, [dao, isGovernanceEnabled, pluginClient, provider, token]); useEffect(() => { async function detectWhetherGovTokenIsWrapped( token: Erc20WrapperTokenDetails | undefined ) { - if (!token) return; + if (!token || !pluginClient) return; + if (isGaslessVotingClient(pluginClient)) { + setIsTokenMintable(isGovernanceEnabled); + return; + } let tokenType = ''; const isUnderlyingTokenExists = !!token.underlyingToken; @@ -79,7 +94,7 @@ export const useExistingToken = ({ detectWhetherGovTokenIsWrapped( token as Erc20WrapperTokenDetails | undefined ); - }, [pluginClient, provider, token]); + }, [isGovernanceEnabled, pluginClient, provider, token]); return { isTokenMintable, diff --git a/src/hooks/useGaslessGovernanceEnabled.tsx b/src/hooks/useGaslessGovernanceEnabled.tsx new file mode 100644 index 000000000..6eeda46df --- /dev/null +++ b/src/hooks/useGaslessGovernanceEnabled.tsx @@ -0,0 +1,25 @@ +import {GaselessPluginName, PluginTypes} from './usePluginClient'; +import {useVotingSettings} from '../services/aragon-sdk/queries/use-voting-settings'; +import {DaoDetails} from '@aragon/sdk-client'; +import {GaslessPluginVotingSettings} from '@vocdoni/gasless-voting'; + +export const useGaslessGovernanceEnabled = ( + daoDetails?: DaoDetails | null | undefined +) => { + const pluginType = daoDetails?.plugins[0].id as PluginTypes; + const {data: votingSettings} = useVotingSettings({ + pluginAddress: daoDetails?.plugins[0].instanceAddress as string, + pluginType, + }); + + const isGasless = pluginType === GaselessPluginName; + let isGovernanceEnabled = true; + + if (isGasless) { + isGovernanceEnabled = + (votingSettings as GaslessPluginVotingSettings)?.hasGovernanceEnabled ?? + true; + } + + return {isGovernanceEnabled}; +}; diff --git a/src/hooks/usePluginClient.tsx b/src/hooks/usePluginClient.tsx index 6173e08d9..0dc068db1 100644 --- a/src/hooks/usePluginClient.tsx +++ b/src/hooks/usePluginClient.tsx @@ -3,13 +3,14 @@ import { GaslessVotingClient, GaslessVotingContext, } from '@vocdoni/gasless-voting'; -import {EnvOptions} from '@vocdoni/sdk'; import {useEffect, useState} from 'react'; import {useClient} from './useClient'; import {VocdoniEnv} from './useVocdoniSdk'; -export const GaselessPluginName = 'vocdoni-gasless-voting-poc.plugin.dao.eth'; +// todo(kon): fix typo +export const GaselessPluginName = + 'vocdoni-gasless-voting-poc-vanilla-erc20.plugin.dao.eth'; export type GaselessPluginType = typeof GaselessPluginName; export type PluginTypes = @@ -81,8 +82,8 @@ export const usePluginClient = ( case GaselessPluginName: setPluginClient( new GaslessVotingClient( - new GaslessVotingContext(undefined, undefined), - VocdoniEnv as EnvOptions + new GaslessVotingContext(context, context), + VocdoniEnv ) ); break; diff --git a/src/hooks/useWalletCanVote.tsx b/src/hooks/useWalletCanVote.tsx index ca5777a77..9e9645035 100644 --- a/src/hooks/useWalletCanVote.tsx +++ b/src/hooks/useWalletCanVote.tsx @@ -42,7 +42,7 @@ export const useWalletCanVote = ( const isGaslessVoting = pluginType === GaselessPluginName; const client = usePluginClient(pluginType); - const {client: vocdoniClient} = useVocdoniClient(); + const {client: vocdoniClient, signer} = useVocdoniClient(); useEffect(() => { async function fetchOnchainVoting() { @@ -77,9 +77,10 @@ export const useWalletCanVote = ( async function fetchCanVoteGasless() { let canVote = false; if (gaslessProposalId) { - canVote = - (await vocdoniClient.isInCensus(gaslessProposalId)) && - !(await vocdoniClient.hasAlreadyVoted(gaslessProposalId)); + canVote = await vocdoniClient.isInCensus({ + wallet: signer, + electionId: gaslessProposalId, + }); } setData(canVote); } @@ -118,6 +119,7 @@ export const useWalletCanVote = ( proposalId, proposalStatus, vocdoniClient, + signer, ]); return { diff --git a/src/pages/community.tsx b/src/pages/community.tsx index 3821bb6ab..4c9ab84b5 100644 --- a/src/pages/community.tsx +++ b/src/pages/community.tsx @@ -1,15 +1,15 @@ import { + ButtonText, + Dropdown, IconAdd, + IconCheckmark, + IconFailure, IconLinkExternal, - Pagination, - SearchInput, + IconSort, IllustrationHuman, - Dropdown, - ButtonText, ListItemAction, - IconCheckmark, - IconSort, - IconFailure, + Pagination, + SearchInput, } from '@aragon/ods-old'; import React, {useCallback, useState} from 'react'; import {useTranslation} from 'react-i18next'; @@ -24,7 +24,7 @@ import {useNetwork} from 'context/network'; import {useDaoDetailsQuery} from 'hooks/useDaoDetails'; import {useDaoMembers} from 'hooks/useDaoMembers'; import {useDebouncedState} from 'hooks/useDebouncedState'; -import {PluginTypes} from 'hooks/usePluginClient'; +import {GaselessPluginName, PluginTypes} from 'hooks/usePluginClient'; import {CHAIN_METADATA} from 'utils/constants'; import PageEmptyState from 'containers/pageEmptyState'; import {htmlIn} from 'utils/htmlIn'; @@ -90,6 +90,8 @@ export const Community: React.FC = () => { const walletBased = (daoDetails?.plugins[0].id as PluginTypes) === 'multisig.plugin.dao.eth'; + const isGasless = + (daoDetails?.plugins[0].id as PluginTypes) === GaselessPluginName; const enableSearchSort = totalMemberCount <= 1000; const enableDelegation = featureFlags.getValue('VITE_FEATURE_FLAG_DELEGATION') === 'true'; @@ -223,6 +225,14 @@ export const Community: React.FC = () => { onClick: navigateToTokenHoldersChart, }, } + : isGasless && !isDAOTokenWrapped && !isTokenMintable + ? { + secondaryBtnProps: { + label: t('labels.seeAllHolders'), + iconLeft: , + onClick: handleSecondaryButtonClick, + }, + } : { description: t('explore.explorer.tokenBased'), primaryBtnProps: { diff --git a/src/pages/manageMembers.tsx b/src/pages/manageMembers.tsx index c75c2b724..9b062f2e0 100644 --- a/src/pages/manageMembers.tsx +++ b/src/pages/manageMembers.tsx @@ -27,9 +27,12 @@ import {ActionsProvider} from 'context/actions'; import {CreateProposalProvider} from 'context/createProposal'; import {useNetwork} from 'context/network'; import {useDaoDetailsQuery} from 'hooks/useDaoDetails'; -import {useDaoMembers} from 'hooks/useDaoMembers'; +import {DaoMember, MultisigDaoMember, useDaoMembers} from 'hooks/useDaoMembers'; import {PluginTypes} from 'hooks/usePluginClient'; -import {useVotingSettings} from 'services/aragon-sdk/queries/use-voting-settings'; +import { + isGaslessVotingSettings, + useVotingSettings, +} from 'services/aragon-sdk/queries/use-voting-settings'; import { removeUnchangedMinimumApprovalAction, toDisplayEns, @@ -38,9 +41,12 @@ import {Community} from 'utils/paths'; import { ActionAddAddress, ActionRemoveAddress, + ActionUpdateGaslessSettings, ActionUpdateMultisigPluginSettings, ManageMembersFormData, } from 'utils/types'; +import {GaslessPluginVotingSettings} from '@vocdoni/gasless-voting'; +import {GaslessUpdateMinimumApproval} from '../containers/actionBuilder/updateMinimumApproval/gaslessUpdateMinimumApproval'; export const ManageMembers: React.FC = () => { const {t} = useTranslation(); @@ -63,7 +69,7 @@ export const ManageMembers: React.FC = () => { const multisigVotingSettings = pluginSettings as | MultisigVotingSettings | undefined; - + const isGasless = isGaslessVotingSettings(pluginSettings); const isLoading = detailsLoading || membersLoading || votingSettingsLoading; const formMethods = useForm({ @@ -89,18 +95,26 @@ export const ManageMembers: React.FC = () => { const handleGoToSetupVoting = useCallback( (next: () => void) => { - if (multisigVotingSettings) { + if (multisigVotingSettings || isGasless) { formMethods.setValue( 'actions', removeUnchangedMinimumApprovalAction( formActions, - multisigVotingSettings + pluginSettings as + | GaslessPluginVotingSettings + | MultisigVotingSettings ) as ManageMembersFormData['actions'] ); next(); } }, - [formActions, formMethods, multisigVotingSettings] + [ + formActions, + formMethods, + isGasless, + multisigVotingSettings, + pluginSettings, + ] ); /************************************************* @@ -117,6 +131,18 @@ export const ManageMembers: React.FC = () => { // being possibly null. Unfortunately, I don't have a more elegant solution. if (!daoDetails || !multisigVotingSettings || !daoMembers) return null; + // For gasless voting, ignore useDaoMembers result + // We are going to use the execution multisig provided by the sdk + const members: DaoMember[] = isGasless + ? pluginSettings.executionMultisigMembers?.map(a => { + return {address: a} as MultisigDaoMember; + }) ?? [] + : daoMembers.members; + + const minApprovals = isGasless + ? pluginSettings.minTallyApprovals + : multisigVotingSettings.minApprovals; + return ( @@ -137,11 +163,7 @@ export const ManageMembers: React.FC = () => { wizardTitle={t('newProposal.manageWallets.title')} wizardDescription={t('newProposal.manageWallets.description')} isNextButtonDisabled={ - !actionsAreValid( - errors, - formActions, - multisigVotingSettings.minApprovals - ) + !actionsAreValid(errors, formActions, minApprovals) } onNextButtonClicked={handleGoToSetupVoting} onNextButtonDisabledClicked={() => formMethods.trigger('actions')} @@ -150,19 +172,30 @@ export const ManageMembers: React.FC = () => { - + {isGasless ? ( + + ) : ( + + )} { wizardDescription={t('newWithdraw.setupVoting.description')} isNextButtonDisabled={!setupVotingIsValid(errors)} > - + { const {data: daoDetails, isLoading} = useDaoDetailsQuery(); @@ -38,6 +42,7 @@ export const MintToken: React.FC = () => { pluginAddress: daoDetails?.plugins[0].instanceAddress as string, pluginType: daoDetails?.plugins[0].id as PluginTypes, }); + const {isGovernanceEnabled} = useGaslessGovernanceEnabled(daoDetails); const {t} = useTranslation(); const {network} = useNetwork(); @@ -73,7 +78,11 @@ export const MintToken: React.FC = () => { return ; } - if (!daoDetails || !votingSettings) { + if ( + !daoDetails || + !votingSettings || + (isGaslessVotingSettings(votingSettings) && !isGovernanceEnabled) + ) { return null; } diff --git a/src/pages/proposal.tsx b/src/pages/proposal.tsx index 9780fd2cc..21e1aefe0 100644 --- a/src/pages/proposal.tsx +++ b/src/pages/proposal.tsx @@ -19,7 +19,7 @@ import {useEditor} from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {useTranslation} from 'react-i18next'; -import {generatePath, useNavigate, useParams, Link} from 'react-router-dom'; +import {generatePath, Link, useNavigate, useParams} from 'react-router-dom'; import sanitizeHtml from 'sanitize-html'; import styled from 'styled-components'; @@ -56,12 +56,16 @@ import { import {useTokenAsync} from 'services/token/queries/use-token'; import {CHAIN_METADATA} from 'utils/constants'; import {featureFlags} from 'utils/featureFlags'; -import {GaslessVotingProposal} from '@vocdoni/gasless-voting'; +import { + GaslessVotingClient, + GaslessVotingProposal, +} from '@vocdoni/gasless-voting'; import {constants} from 'ethers'; import {usePastVotingPower} from 'services/aragon-sdk/queries/use-past-voting-power'; import { decodeAddMembersToAction, decodeApplyUpdateAction, + decodeGaslessSettingsToAction, decodeMetadataToAction, decodeMintTokensToAction, decodeMultisigSettingsToAction, @@ -292,6 +296,7 @@ export const Proposal: React.FC = () => { const multisigClient = pluginClient as MultisigClient; const tokenVotingClient = pluginClient as TokenVotingClient; + const gaslessVotingClient = pluginClient as GaslessVotingClient; const getAction = async (action: DaoAction, index: number) => { const functionParams = @@ -321,6 +326,10 @@ export const Proposal: React.FC = () => { if (mintTokenActionsData.length === 0) mintTokenActionsIndex = index; mintTokenActionsData.push(action.data); return; + case 'addExecutionMultisigMembers': + return decodeAddMembersToAction(action.data, gaslessVotingClient); + case 'removeExecutionMultisigMembers': + return decodeRemoveMembersToAction(action.data, multisigClient); case 'addAddresses': return decodeAddMembersToAction(action.data, multisigClient); case 'removeAddresses': @@ -329,7 +338,7 @@ export const Proposal: React.FC = () => { return decodePluginSettingsToAction( action.data, tokenVotingClient, - totalVotingWeight as bigint, + totalVotingWeight, proposalErc20Token ); case 'updateMultisigSettings': @@ -338,6 +347,13 @@ export const Proposal: React.FC = () => { return decodeMetadataToAction(action.data, client); case 'upgradeToAndCall': return decodeUpgradeToAndCallAction(action, client); + case 'updatePluginSettings': + return decodeGaslessSettingsToAction( + action.data, + gaslessVotingClient, + (proposal as GaslessVotingProposal).totalVotingWeight, + proposalErc20Token + ); case 'grant': case 'revoke': { return decodeOSUpdateActions( @@ -417,7 +433,7 @@ export const Proposal: React.FC = () => { proposalErc20Token, totalVotingWeight, daoAddress, - proposal?.actions, + proposal, client, network, pluginClient, @@ -605,7 +621,7 @@ export const Proposal: React.FC = () => { // need to check canVote status on Multisig because // the delegation modals are not shown for Multisig ((isMultisigPlugin && (voted || canVote === false)) || - (isGaslessVotingPlugin && voted) || + (isGaslessVotingPlugin && (voted || canVote === false)) || (isTokenVotingPlugin && voted && !canRevote))); const handleApprovalClick = useCallback( @@ -676,6 +692,16 @@ export const Proposal: React.FC = () => { ? t('votingTerminal.status.ineligibleWhitelist') : undefined; + let proposalStateEndate = proposal?.endDate; + // If is gasless proposal and is defeated because is not approved, but the offchain election passed, use the tally end date + if ( + proposal && + isGaslessProposal(proposal) && + proposal.status === ProposalStatus.DEFEATED && + proposal.canBeApproved // Offchain election passed + ) { + proposalStateEndate = proposal.tallyEndDate; + } // status steps for proposal const proposalSteps = proposal ? getProposalStatusSteps( @@ -684,7 +710,7 @@ export const Proposal: React.FC = () => { pluginType, proposal.startDate, // If is gasless the proposal ends after the expiration period - isGaslessProposal(proposal) ? proposal.tallyEndDate : proposal.endDate, + proposalStateEndate!, proposal.creationDate, proposal.creationBlockNumber ? NumberFormatter.format(proposal.creationBlockNumber) @@ -845,7 +871,6 @@ export const Proposal: React.FC = () => { onExecuteClicked={handleExecuteNowClicked} actions={decodedActions} pluginType={pluginType} - votingPower={pastVotingPower} > diff --git a/src/pages/proposeSettings.tsx b/src/pages/proposeSettings.tsx index 80a7de05f..a87f7e386 100644 --- a/src/pages/proposeSettings.tsx +++ b/src/pages/proposeSettings.tsx @@ -1,868 +1,33 @@ -import { - CreateMajorityVotingProposalParams, - CreateMultisigProposalParams, - MajorityVotingProposalSettings, - ProposalCreationSteps, - VoteValues, - VotingMode, - VotingSettings, -} from '@aragon/sdk-client'; -import { - DaoAction, - ProposalMetadata, - ProposalStatus, -} from '@aragon/sdk-client-common'; -import {useQueryClient} from '@tanstack/react-query'; -import differenceInSeconds from 'date-fns/fp/differenceInSeconds'; -import {parseUnits} from 'ethers/lib/utils'; -import React, {ReactNode, useCallback, useEffect, useState} from 'react'; -import {useFormContext, useFormState} from 'react-hook-form'; -import {useTranslation} from 'react-i18next'; -import {generatePath, useNavigate} from 'react-router-dom'; +import React, {useState} from 'react'; -import {FullScreenStepper, Step} from 'components/fullScreenStepper'; import {Loading} from 'components/temporary'; -import CompareSettings from 'containers/compareSettings'; -import {isValid as defineProposalIsValid} from 'containers/defineProposal'; -import {DefineProposal} from 'containers/defineProposal/'; -import ReviewProposal from 'containers/reviewProposal'; -import SetupVotingForm from 'containers/setupVotingForm'; -import PublishModal from 'containers/transactionModals/publishModal'; -import {useGlobalModalContext} from 'context/globalModals'; -import {useNetwork} from 'context/network'; -import {useProviders} from 'context/providers'; -import {useClient} from 'hooks/useClient'; import {useDaoDetailsQuery} from 'hooks/useDaoDetails'; -import {useDaoToken} from 'hooks/useDaoToken'; -import { - PluginTypes, - isGaslessVotingClient, - isMultisigClient, - isTokenVotingClient, - usePluginClient, -} from 'hooks/usePluginClient'; -import {usePollGasFee} from 'hooks/usePollGasfee'; -import {useTokenSupply} from 'hooks/useTokenSupply'; -import {useWallet} from 'hooks/useWallet'; -import {useVotingPower} from 'services/aragon-sdk/queries/use-voting-power'; -import { - isMultisigVotingSettings, - isTokenVotingSettings, - useVotingSettings, -} from 'services/aragon-sdk/queries/use-voting-settings'; -import {AragonSdkQueryItem} from 'services/aragon-sdk/query-keys'; -import {CHAIN_METADATA, TransactionState} from 'utils/constants'; -import { - daysToMills, - getCanonicalDate, - getCanonicalTime, - getCanonicalUtcOffset, - getDHMFromSeconds, - getSecondsFromDHM, - hoursToMills, - minutesToMills, - offsetToMills, -} from 'utils/date'; -import {readFile, toDisplayEns} from 'utils/library'; -import {proposalStorage} from 'utils/localStorage/proposalStorage'; -import {EditSettings, Proposal, Settings} from 'utils/paths'; -import { - Action, - ActionUpdateMetadata, - ActionUpdateMultisigPluginSettings, - ActionUpdatePluginSettings, - ProposalId, - ProposalResource, -} from 'utils/types'; -import {aragonSubgraphQueryKeys} from 'services/aragon-subgraph/query-keys'; +import {CreateProposalProvider} from '../context/createProposal'; +import {ProposeSettingsStepper} from '../containers/proposeSettingsStepper/proposeSettingsStepper'; export const ProposeSettings: React.FC = () => { - const {t} = useTranslation(); - const {network} = useNetwork(); - - const {getValues, setValue, control} = useFormContext(); const [showTxModal, setShowTxModal] = useState(false); - const {errors, dirtyFields} = useFormState({ - control, - }); - const {data: daoDetails, isLoading: daoDetailsLoading} = useDaoDetailsQuery(); - const {data: pluginSettings, isLoading: settingsLoading} = useVotingSettings({ - pluginAddress: daoDetails?.plugins[0].instanceAddress as string, - pluginType: daoDetails?.plugins[0].id as PluginTypes, - }); + const {data: daoDetails, isLoading} = useDaoDetailsQuery(); const enableTxModal = () => { setShowTxModal(true); }; - // filter actions making sure unchanged information is not bundled - // into the list of actions - const filterActions = useCallback(() => { - const [formActions, settingsChanged, metadataChanged] = getValues([ - 'actions', - 'areSettingsChanged', - 'isMetadataChanged', - ]); - - // ignore every action that is not modifying the metadata and voting settings - const filteredActions = (formActions as Array).filter(action => { - if (action.name === 'modify_metadata' && metadataChanged) { - return action; - } else if ( - (action.name === 'modify_token_voting_settings' || - action.name === 'modify_multisig_voting_settings') && - settingsChanged - ) { - return action; - } - }); - - setValue('actions', filteredActions); - }, [getValues, setValue]); - - if (daoDetailsLoading || settingsLoading) { + if (isLoading) { return ; } - if (!pluginSettings || !daoDetails) { + if (!daoDetails) { return null; } return ( - - - { - filterActions(); - next(); - }} - > - - - - - - - - - - - - - - ); -}; - -type Props = { - showTxModal: boolean; - setShowTxModal: (value: boolean) => void; - children: ReactNode; -}; - -// TODO: this is almost identical to CreateProposal wrapper, please merge if possible -const ProposeSettingWrapper: React.FC = ({ - showTxModal, - setShowTxModal, - children, -}) => { - const {t} = useTranslation(); - const {open} = useGlobalModalContext(); - const navigate = useNavigate(); - const queryClient = useQueryClient(); - const {getValues, setValue} = useFormContext(); - const {api: apiProvider} = useProviders(); - - const {network} = useNetwork(); - const {address, isOnWrongNetwork} = useWallet(); - - const {data: daoDetails, isLoading: daoDetailsLoading} = useDaoDetailsQuery(); - const pluginAddress = daoDetails?.plugins?.[0]?.instanceAddress as string; - const pluginType = daoDetails?.plugins?.[0]?.id as PluginTypes; - - const {data: votingSettings} = useVotingSettings({pluginAddress, pluginType}); - const {data: daoToken} = useDaoToken(pluginAddress); - const {data: votingPower} = useVotingPower( - {tokenAddress: daoToken?.address as string, address: address as string}, - {enabled: !!daoToken?.address && !!address} - ); - - const { - days: minDays, - hours: minHours, - minutes: minMinutes, - } = getDHMFromSeconds((votingSettings as VotingSettings)?.minDuration ?? 0); - - const {data: tokenSupply, isLoading: tokenSupplyIsLoading} = useTokenSupply( - daoToken?.address || '' - ); - - const {client} = useClient(); - const pluginClient = usePluginClient(pluginType); - - const [proposalCreationData, setProposalCreationData] = - useState(); - - const [creationProcessState, setCreationProcessState] = - useState(TransactionState.WAITING); - - const [proposalId, setProposalId] = useState(); - - const shouldPoll = - creationProcessState === TransactionState.WAITING && - proposalCreationData !== undefined; - - const disableActionButton = - !proposalCreationData && creationProcessState !== TransactionState.SUCCESS; - - /************************************************* - * Effects * - *************************************************/ - // Not a fan, but this sets the actions on the form context so that the Action - // Widget can read them - useEffect(() => { - async function SetSettingActions() { - { - const [ - daoName, - daoSummary, - daoLogo, - minimumApproval, - multisigMinimumApprovals, - minimumParticipation, - eligibilityType, - eligibilityTokenAmount, - earlyExecution, - voteReplacement, - durationDays, - durationHours, - durationMinutes, - resourceLinks, - tokenDecimals, - ] = getValues([ - 'daoName', - 'daoSummary', - 'daoLogo', - 'minimumApproval', - 'multisigMinimumApprovals', - 'minimumParticipation', - 'eligibilityType', - 'eligibilityTokenAmount', - 'earlyExecution', - 'voteReplacement', - 'durationDays', - 'durationHours', - 'durationMinutes', - 'daoLinks', - 'tokenDecimals', - ]); - - let daoLogoFile = ''; - - if (daoDetails && !daoName) - navigate( - generatePath(EditSettings, {network, dao: daoDetails?.address}) - ); - - if (daoLogo?.startsWith?.('blob')) - daoLogoFile = (await fetch(daoLogo).then(r => r.blob())) as string; - else daoLogoFile = daoLogo; - - const metadataAction: ActionUpdateMetadata = { - name: 'modify_metadata', - inputs: { - name: daoName, - description: daoSummary, - avatar: daoLogoFile, - links: resourceLinks, - }, - }; - - if (isTokenVotingSettings(votingSettings)) { - const voteSettingsAction: ActionUpdatePluginSettings = { - name: 'modify_token_voting_settings', - inputs: { - token: daoToken, - totalVotingWeight: tokenSupply?.raw || BigInt(0), - - minDuration: getSecondsFromDHM( - durationDays, - durationHours, - durationMinutes - ), - supportThreshold: Number(minimumApproval) / 100, - minParticipation: Number(minimumParticipation) / 100, - minProposerVotingPower: - eligibilityType === 'token' - ? parseUnits( - eligibilityTokenAmount.toString(), - tokenDecimals - ).toBigInt() - : undefined, - votingMode: earlyExecution - ? VotingMode.EARLY_EXECUTION - : voteReplacement - ? VotingMode.VOTE_REPLACEMENT - : VotingMode.STANDARD, - }, - }; - setValue('actions', [metadataAction, voteSettingsAction]); - } else { - const multisigSettingsAction: ActionUpdateMultisigPluginSettings = { - name: 'modify_multisig_voting_settings', - inputs: { - minApprovals: multisigMinimumApprovals, - onlyListed: eligibilityType === 'multisig', - }, - }; - - setValue('actions', [metadataAction, multisigSettingsAction]); - } - } - } - - SetSettingActions(); - }, [ - daoToken, - votingSettings, - getValues, - setValue, - tokenSupply?.raw, - daoDetails, - navigate, - network, - ]); - - useEffect(() => { - // encoding actions - const encodeActions = async (): Promise => { - // return an empty array for undefined clients - const actions: Array> = []; - if (!pluginClient || !client || !daoDetails?.address) - return Promise.all(actions); - - for (const action of getValues('actions') as Array) { - if (action.name === 'modify_metadata') { - const preparedAction = {...action}; - - if ( - preparedAction.inputs.avatar && - typeof preparedAction.inputs.avatar !== 'string' - ) { - try { - const daoLogoBuffer = await readFile( - preparedAction.inputs.avatar as unknown as Blob - ); - - const logoCID = await client?.ipfs.add( - new Uint8Array(daoLogoBuffer) - ); - await client?.ipfs.pin(logoCID!); - preparedAction.inputs.avatar = `ipfs://${logoCID}`; - } catch (e) { - preparedAction.inputs.avatar = undefined; - } - } - - try { - const ipfsUri = await client.methods.pinMetadata( - preparedAction.inputs - ); - - actions.push( - client.encoding.updateDaoMetadataAction( - daoDetails.address, - ipfsUri - ) - ); - } catch (error) { - throw Error('Could not pin metadata on IPFS'); - } - } else if ( - action.name === 'modify_token_voting_settings' && - isTokenVotingClient(pluginClient) - ) { - actions.push( - Promise.resolve( - pluginClient.encoding.updatePluginSettingsAction( - pluginAddress, - action.inputs - ) - ) - ); - } else if ( - action.name === 'modify_multisig_voting_settings' && - isMultisigClient(pluginClient) - ) { - actions.push( - Promise.resolve( - pluginClient.encoding.updateMultisigVotingSettings({ - pluginAddress, - votingSettings: { - minApprovals: action.inputs.minApprovals, - onlyListed: action.inputs.onlyListed, - }, - }) - ) - ); - } - } - return Promise.all(actions); - }; - - const getProposalCreationParams = - async (): Promise => { - const [ - title, - summary, - description, - resources, - startDate, - startTime, - startUtc, - endDate, - endTime, - endUtc, - durationSwitch, - startSwitch, - ] = getValues([ - 'proposalTitle', - 'proposalSummary', - 'proposal', - 'links', - 'startDate', - 'startTime', - 'startUtc', - 'endDate', - 'endTime', - 'endUtc', - 'durationSwitch', - 'startSwitch', - ]); - - const actions = await encodeActions(); - - const metadata: ProposalMetadata = { - title, - summary, - description, - resources: resources.filter((r: ProposalResource) => r.name && r.url), - }; - - const ipfsUri = await pluginClient?.methods.pinMetadata(metadata); - - // getting dates - let startDateTime: Date; - - /** - * Here we defined base startDate. - */ - if (startSwitch === 'now') { - // Taking current time, but we won't pass it to SC cuz it's gonna be outdated. Needed for calculations below. - startDateTime = new Date( - `${getCanonicalDate()}T${getCanonicalTime()}:00${getCanonicalUtcOffset()}` - ); - } else { - // Taking time user has set. - startDateTime = new Date( - `${startDate}T${startTime}:00${getCanonicalUtcOffset(startUtc)}` - ); - } - - // Minimum allowed end date (if endDate is lower than that SC call fails) - const minEndDateTimeMills = - startDateTime.valueOf() + - daysToMills(minDays || 0) + - hoursToMills(minHours || 0) + - minutesToMills(minMinutes || 0); - - // End date - let endDateTime; - - // user specifies duration in time/second exact way - if (durationSwitch === 'duration') { - const [days, hours, minutes] = getValues([ - 'durationDays', - 'durationHours', - 'durationMinutes', - ]); - - // Calculate the end date using duration - const endDateTimeMill = - startDateTime.valueOf() + offsetToMills({days, hours, minutes}); - - endDateTime = new Date(endDateTimeMill); - - // In case the endDate is close to being minimum durable, (and we starting immediately) - // to avoid passing late-date possibly, we just rely on SDK to set proper Date - if ( - endDateTime.valueOf() <= minEndDateTimeMills && - startSwitch === 'now' - ) { - /* Pass enddate as undefined to SDK to auto-calculate min endDate */ - endDateTime = undefined; - } - } else { - // In case exact time specified by user - endDateTime = new Date( - `${endDate}T${endTime}:00${getCanonicalUtcOffset(endUtc)}` - ); - } - - if (startSwitch === 'duration' && endDateTime) { - // Making sure we are not in past for further calculation - if (startDateTime.valueOf() < new Date().valueOf()) { - startDateTime = new Date( - `${getCanonicalDate()}T${getCanonicalTime()}:00${getCanonicalUtcOffset()}` - ); - } - - // If provided date is expired - if (endDateTime.valueOf() < minEndDateTimeMills) { - const legacyStartDate = new Date( - `${startDate}T${startTime}:00${getCanonicalUtcOffset(startUtc)}` - ); - const endMills = - endDateTime.valueOf() + - (startDateTime.valueOf() - legacyStartDate.valueOf()); - - endDateTime = new Date(endMills); - } - } - - /** - * In case "now" as start time is selected, we want - * to keep startDate undefined, so it's automatically evaluated. - * If we just provide "Date.now()", than after user still goes through the flow - * it's going to be date from the past. And SC-call evaluation will fail. - */ - const finalStartDate = - startSwitch === 'now' ? undefined : startDateTime; - - // Ignore encoding if the proposal had no actions - return { - pluginAddress, - metadataUri: ipfsUri || '', - startDate: finalStartDate, - endDate: endDateTime, - actions, - }; - }; - - async function setProposalData() { - if (showTxModal && creationProcessState === TransactionState.WAITING) - setProposalCreationData(await getProposalCreationParams()); - } - - if (daoDetails?.address) { - setProposalData(); - } - }, [ - client, - creationProcessState, - daoDetails?.address, - getValues, - minDays, - minHours, - minMinutes, - pluginAddress, - pluginClient, - votingSettings, - showTxModal, - ]); - - /************************************************* - * Callbacks and Handlers * - *************************************************/ - const estimateCreationFees = useCallback(async () => { - if (!pluginClient) { - return Promise.reject( - new Error('ERC20 SDK client is not initialized correctly') - ); - } - if (!proposalCreationData) return; - - // todo(kon): implement this - // The propose settings flow is not currently handled by the gasless voting client - if (!proposalCreationData || isGaslessVotingClient(pluginClient)) return; - - return pluginClient?.estimation.createProposal(proposalCreationData); - }, [pluginClient, proposalCreationData]); - - const { - tokenPrice, - maxFee, - averageFee, - stopPolling, - error: gasEstimationError, - } = usePollGasFee(estimateCreationFees, shouldPoll); - - const handleCloseModal = () => { - switch (creationProcessState) { - case TransactionState.LOADING: - break; - case TransactionState.SUCCESS: - navigate( - generatePath(Proposal, { - network, - dao: toDisplayEns(daoDetails?.ensDomain) || daoDetails?.address, - id: proposalId, - }) - ); - break; - default: { - setCreationProcessState(TransactionState.WAITING); - setShowTxModal(false); - stopPolling(); - } - } - }; - - const invalidateQueries = useCallback(() => { - // invalidating all infinite proposals query regardless of the - // pagination state - queryClient.invalidateQueries([AragonSdkQueryItem.PROPOSALS]); - queryClient.invalidateQueries( - aragonSubgraphQueryKeys.totalProposalCount({pluginAddress, pluginType}) - ); - }, [pluginAddress, pluginType, queryClient]); - - const handlePublishSettings = async () => { - if (!pluginClient) { - return new Error('ERC20 SDK client is not initialized correctly'); - } - - // if no creation data is set, or transaction already running, do nothing. - if ( - !proposalCreationData || - creationProcessState === TransactionState.LOADING - ) { - console.log('Transaction is running'); - return; - } - - // let proposalIterator: AsyncGenerator; - // if (isGaslessVotingClient(pluginClient)) { - // proposalIterator = ( - // pluginClient as GaslessVotingClient - // ).methods.createProposal( - // proposalCreationData as CreateGasslessProposalParams - // ); - // } else { - // proposalIterator = - // pluginClient.methods.createProposal(proposalCreationData); - // } - - // todo(kon): implement this - // The propose settings flow is not currently handled by the gasless voting client - if (isGaslessVotingClient(pluginClient)) { - return; - } - - const proposalIterator = - pluginClient.methods.createProposal(proposalCreationData); - - if (creationProcessState === TransactionState.SUCCESS) { - handleCloseModal(); - return; - } - - if (isOnWrongNetwork) { - open('network'); - handleCloseModal(); - return; - } - - setCreationProcessState(TransactionState.LOADING); - try { - for await (const step of proposalIterator) { - switch (step.key) { - case ProposalCreationSteps.CREATING: - console.log(step.txHash); - break; - case ProposalCreationSteps.DONE: { - //TODO: replace with step.proposal id when SDK returns proper format - const proposalGuid = new ProposalId( - step.proposalId - ).makeGloballyUnique(pluginAddress); - - setProposalId(proposalGuid); - setCreationProcessState(TransactionState.SUCCESS); - - // cache proposal - handleCacheProposal(proposalGuid); - invalidateQueries(); - break; - } - } - } - } catch (error) { - console.error(error); - setCreationProcessState(TransactionState.ERROR); - } - }; - - const handleCacheProposal = useCallback( - async (proposalId: string) => { - if (!address || !daoDetails || !votingSettings || !proposalCreationData) - return; - - const creationBlockNumber = await apiProvider.getBlockNumber(); - - const [title, summary, description, resources] = getValues([ - 'proposalTitle', - 'proposalSummary', - 'proposal', - 'links', - ]); - - const baseParams = { - id: proposalId, - dao: {address: daoDetails.address, name: daoDetails.metadata.name}, - creationDate: new Date(), - creatorAddress: address, - creationBlockNumber, - startDate: proposalCreationData.startDate ?? new Date(), - endDate: proposalCreationData.endDate!, - metadata: { - title, - summary, - description, - resources: resources.filter((r: ProposalResource) => r.name && r.url), - }, - actions: proposalCreationData.actions ?? [], - status: proposalCreationData.startDate - ? ProposalStatus.PENDING - : ProposalStatus.ACTIVE, - }; - - if (isMultisigVotingSettings(votingSettings)) { - const {approve: creatorApproval} = - proposalCreationData as CreateMultisigProposalParams; - - const proposal = { - ...baseParams, - approvals: creatorApproval ? [address] : [], - settings: votingSettings, - }; - proposalStorage.addProposal(CHAIN_METADATA[network].id, proposal); - } - - // token voting - else if (isTokenVotingSettings(votingSettings)) { - const {creatorVote} = - proposalCreationData as CreateMajorityVotingProposalParams; - - const creatorVotingPower = votingPower?.toBigInt() ?? BigInt(0); - - const result = { - yes: creatorVote === VoteValues.YES ? creatorVotingPower : BigInt(0), - no: creatorVote === VoteValues.NO ? creatorVotingPower : BigInt(0), - abstain: - creatorVote === VoteValues.ABSTAIN ? creatorVotingPower : BigInt(0), - }; - - let usedVotingWeight = BigInt(0); - const votes = []; - if (creatorVote) { - usedVotingWeight = creatorVotingPower; - votes.push({ - address, - vote: creatorVote, - voteReplaced: false, - weight: creatorVotingPower, - }); - } - - const settings: MajorityVotingProposalSettings = { - supportThreshold: votingSettings.supportThreshold, - minParticipation: votingSettings.minParticipation, - duration: differenceInSeconds( - baseParams.endDate, - baseParams.startDate - ), - }; - - const proposal = { - ...baseParams, - result, - settings, - usedVotingWeight, - totalVotingWeight: tokenSupply?.raw ?? BigInt(0), - token: daoToken ?? null, - votes, - }; - proposalStorage.addProposal(CHAIN_METADATA[network].id, proposal); - } - }, - [ - address, - daoDetails, - votingSettings, - proposalCreationData, - apiProvider, - getValues, - network, - votingPower, - tokenSupply?.raw, - daoToken, - ] - ); - - /************************************************* - * Render * - *************************************************/ - const buttonLabels = { - [TransactionState.SUCCESS]: t('TransactionModal.goToProposal'), - [TransactionState.WAITING]: t('TransactionModal.createProposalNow'), - }; - - if (daoDetailsLoading || tokenSupplyIsLoading) { - return ; - } - - return ( - <> - {children} - - + + ); }; diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index e9cfcda2a..374870bad 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -35,7 +35,7 @@ import useScreen from 'hooks/useScreen'; import {CHAIN_METADATA} from 'utils/constants'; import {featureFlags} from 'utils/featureFlags'; import {shortenAddress, toDisplayEns} from 'utils/library'; -import {EditSettings} from 'utils/paths'; +import {EditSettings, ManageMembersProposal} from 'utils/paths'; import GaslessVotingSettings from '../containers/settings/gaslessVoting'; import {useIsMember} from 'services/aragon-sdk/queries/use-is-member'; import {useWallet} from 'hooks/useWallet'; @@ -53,6 +53,7 @@ export const Settings: React.FC = () => { const pluginAddress = daoDetails?.plugins?.[0]?.instanceAddress as string; const pluginType = daoDetails?.plugins?.[0]?.id as PluginTypes; + const isGasless = pluginType === GaselessPluginName; const {data: isMember} = useIsMember({ address: address as string, @@ -74,7 +75,7 @@ export const Settings: React.FC = () => { const showUpdatesCard = updateExists && isMember && daoUpdateEnabled; return ( - + {showUpdatesCard && (
@@ -326,7 +327,10 @@ const PluginSettingsWrapper: React.FC = ({daoDetails}) => { } }; -const SettingsWrapper: React.FC<{children: ReactNode}> = ({children}) => { +const SettingsWrapper: React.FC<{children: ReactNode; isGasless: boolean}> = ({ + children, + isGasless, +}) => { const {t} = useTranslation(); const {isMobile} = useScreen(); @@ -342,6 +346,15 @@ const SettingsWrapper: React.FC<{children: ReactNode}> = ({children}) => { iconLeft: isMobile ? : undefined, onClick: () => navigate(generatePath(EditSettings, {network, dao})), }} + secondaryBtnProps={ + isGasless + ? { + label: t('labels.manageMember'), + onClick: () => + navigate(generatePath(ManageMembersProposal, {network, dao})), + } + : undefined + } customBody={<>{children}} /> ); diff --git a/src/services/aragon-sdk/queries/use-creator-proposals.ts b/src/services/aragon-sdk/queries/use-creator-proposals.ts index bc5311437..0dbe20fb7 100644 --- a/src/services/aragon-sdk/queries/use-creator-proposals.ts +++ b/src/services/aragon-sdk/queries/use-creator-proposals.ts @@ -11,7 +11,10 @@ import {invariant} from 'utils/invariant'; import {useNetwork} from 'context/network'; import {ProposalBase, SortDirection} from '@aragon/sdk-client-common'; import {PluginClient, usePluginClient} from 'hooks/usePluginClient'; -import {GaslessVotingProposal} from '@vocdoni/gasless-voting'; +import { + GaslessVotingClient, + GaslessVotingProposal, +} from '@vocdoni/gasless-voting'; type Proposal = MultisigProposal | TokenVotingProposal | GaslessVotingProposal; @@ -51,6 +54,31 @@ export const multisigProposalsQuery = gql` } `; +const fetchCreatorGaslessProposals = async ( + {pluginAddress, address, blockNumber}: IFetchCreatorProposalsParams, + client?: PluginClient +): Promise => { + invariant(client != null, 'fetchCreatorProposals: client is not defined'); + + const resultProposalsIds = await ( + client as GaslessVotingClient + ).methods.getMemberProposals( + pluginAddress, + address, + blockNumber ? blockNumber : 0, + SortDirection.DESC, + ProposalSortBy.CREATED_AT + ); + + const proposalQueriesPromises = resultProposalsIds.map( + (id: string) => client?.methods.getProposal(id) + ); + + const resultsProposals = await Promise.all(proposalQueriesPromises); + + return resultsProposals.filter(item => !!item) as Proposal[]; +}; + const fetchCreatorProposals = async ( { pluginAddress, @@ -118,7 +146,11 @@ export const useCreatorProposals = ( return useQuery( aragonSdkQueryKeys.getCreatorProposals(baseParams, params), - () => fetchCreatorProposals(params, client), + () => + params.pluginType === + 'vocdoni-gasless-voting-poc-vanilla-erc20.plugin.dao.eth' + ? fetchCreatorGaslessProposals(params, client) + : fetchCreatorProposals(params, client), options ); }; diff --git a/src/services/aragon-sdk/queries/use-delegatee.ts b/src/services/aragon-sdk/queries/use-delegatee.ts index cf9dc0c44..77d8566e1 100644 --- a/src/services/aragon-sdk/queries/use-delegatee.ts +++ b/src/services/aragon-sdk/queries/use-delegatee.ts @@ -1,15 +1,21 @@ import {UseQueryOptions, useQuery} from '@tanstack/react-query'; import {aragonSdkQueryKeys} from '../query-keys'; import type {IFetchDelegateeParams} from '../aragon-sdk-service.api'; -import {usePluginClient} from 'hooks/usePluginClient'; +import { + GaselessPluginName, + PluginTypes, + usePluginClient, +} from 'hooks/usePluginClient'; import {useWallet} from 'hooks/useWallet'; import {SupportedNetworks} from 'utils/constants'; -import {TokenVotingClient} from '@aragon/sdk-client'; +import {DaoDetails, TokenVotingClient} from '@aragon/sdk-client'; import {invariant} from 'utils/invariant'; +import {GaslessVotingClient} from '@vocdoni/gasless-voting'; +import {useGaslessGovernanceEnabled} from '../../../hooks/useGaslessGovernanceEnabled'; const fetchDelegatee = async ( params: IFetchDelegateeParams, - client?: TokenVotingClient + client?: TokenVotingClient | GaslessVotingClient ): Promise => { invariant(client != null, 'fetchDelegatee: client is not defined'); const data = await client.methods.getDelegatee(params.tokenAddress); @@ -19,10 +25,19 @@ const fetchDelegatee = async ( export const useDelegatee = ( params: IFetchDelegateeParams, - options: UseQueryOptions = {} + options: UseQueryOptions = {}, + daoDetails: DaoDetails | null | undefined ) => { - const client = usePluginClient('token-voting.plugin.dao.eth'); + const pluginType = daoDetails?.plugins[0].id as PluginTypes; + const {isGovernanceEnabled} = useGaslessGovernanceEnabled(daoDetails); + + const client = usePluginClient( + pluginType === GaselessPluginName + ? GaselessPluginName + : 'token-voting.plugin.dao.eth' + ); const {address, network} = useWallet(); + const baseParams = { address: address as string, network: network as SupportedNetworks, @@ -45,7 +60,13 @@ export const useDelegatee = ( return useQuery( aragonSdkQueryKeys.delegatee(baseParams, params), - () => fetchDelegatee(params, client), + () => { + // If is gasless and governance is not enabled, return + if (pluginType === GaselessPluginName && !isGovernanceEnabled) { + return null; + } + return fetchDelegatee(params, client); + }, options ); }; diff --git a/src/services/aragon-sdk/queries/use-is-member.ts b/src/services/aragon-sdk/queries/use-is-member.ts index 13983e694..abec3a87b 100644 --- a/src/services/aragon-sdk/queries/use-is-member.ts +++ b/src/services/aragon-sdk/queries/use-is-member.ts @@ -1,12 +1,16 @@ import {MajorityVotingSettings} from '@aragon/sdk-client'; -import {UseQueryOptions, useQuery} from '@tanstack/react-query'; +import {useQuery, UseQueryOptions} from '@tanstack/react-query'; import {GaslessVotingClient} from '@vocdoni/gasless-voting'; import {useCallback} from 'react'; import {useNetwork} from 'context/network'; import {useProviders} from 'context/providers'; import {TokenDaoMember, useDaoMembers} from 'hooks/useDaoMembers'; -import {PluginTypes, usePluginClient} from 'hooks/usePluginClient'; +import { + GaselessPluginName, + PluginTypes, + usePluginClient, +} from 'hooks/usePluginClient'; import {CHAIN_METADATA} from 'utils/constants'; import {invariant} from 'utils/invariant'; import {formatUnits} from 'utils/library'; @@ -37,8 +41,7 @@ export const useIsMember = ( const fetchVotingPower = useVotingPowerAsync(); const isTokenVoting = params.pluginType === 'token-voting.plugin.dao.eth'; - const isGaslessVoting = - params.pluginType === 'vocdoni-gasless-voting-poc.plugin.dao.eth'; + const isGaslessVoting = params.pluginType === GaselessPluginName; // fetch voting settings const {data: votingSettings, isLoading: settingsAreLoading} = diff --git a/src/services/aragon-sdk/queries/use-members.ts b/src/services/aragon-sdk/queries/use-members.ts index eaba31c9d..7691a867e 100644 --- a/src/services/aragon-sdk/queries/use-members.ts +++ b/src/services/aragon-sdk/queries/use-members.ts @@ -33,7 +33,7 @@ export const useMembers = ( ) => { const client = usePluginClient(params.pluginType); - if (client == null) { + if (client == null || !params.pluginAddress) { options.enabled = false; } diff --git a/src/services/aragon-sdk/queries/use-proposals.ts b/src/services/aragon-sdk/queries/use-proposals.ts index e2377f613..d513cb269 100644 --- a/src/services/aragon-sdk/queries/use-proposals.ts +++ b/src/services/aragon-sdk/queries/use-proposals.ts @@ -19,7 +19,7 @@ import {proposalStorage} from 'utils/localStorage/proposalStorage'; import {IFetchProposalsParams} from '../aragon-sdk-service.api'; import {aragonSdkQueryKeys} from '../query-keys'; import {transformInfiniteProposals} from '../selectors'; -import {GaslessVotingProposal} from '@vocdoni/gasless-voting'; +import {GaslessVotingProposalListItem} from '@vocdoni/gasless-voting'; export const PROPOSALS_PER_PAGE = 6; @@ -36,7 +36,7 @@ async function fetchProposals( ): Promise< | Array | Array - | Array + | Array > { invariant(!!client, 'fetchProposalsAsync: client is not defined'); const data = await client.methods.getProposals(params); @@ -46,7 +46,9 @@ async function fetchProposals( export const useProposals = ( userParams: Partial & {pluginAddress: string}, options: UseInfiniteQueryOptions< - Array | Array + | Array + | Array + | Array > = {} ) => { const params = {...DEFAULT_PARAMS, ...userParams}; @@ -73,7 +75,9 @@ export const useProposals = ( const defaultSelect = ( data: InfiniteData< - Array | Array + | Array + | Array + | Array > ) => transformInfiniteProposals(chainId, data); @@ -123,7 +127,10 @@ export const useProposals = ( return [...finalStoredProposals, ...serverProposals].slice( 0, params.limit - ) as Array | Array; + ) as + | Array + | Array + | Array; }, // If the length of the last page is equal to the limit from params, diff --git a/src/services/aragon-sdk/selectors/proposal.ts b/src/services/aragon-sdk/selectors/proposal.ts index 4155fa01e..8f330c214 100644 --- a/src/services/aragon-sdk/selectors/proposal.ts +++ b/src/services/aragon-sdk/selectors/proposal.ts @@ -8,7 +8,10 @@ import { } from '@aragon/sdk-client'; import {ensure0x, ProposalStatus} from '@aragon/sdk-client-common'; import {InfiniteData} from '@tanstack/react-query'; -import {GaslessVotingProposal} from '@vocdoni/gasless-voting'; +import { + GaslessVotingProposal, + GaslessVotingProposalListItem, +} from '@vocdoni/gasless-voting'; import {SupportedChainID} from 'utils/constants'; import {executionStorage, voteStorage} from 'utils/localStorage'; @@ -32,7 +35,8 @@ import { export function transformInfiniteProposals< T extends | Array - | Array, + | Array + | Array, >(chainId: SupportedChainID, data: InfiniteData): InfiniteData { return { ...data, @@ -65,6 +69,7 @@ export function transformProposal< | MultisigProposalListItem | TokenVotingProposalListItem | GaslessVotingProposal + | GaslessVotingProposalListItem | null, >(chainId: SupportedChainID, data: T): T { if (data == null) { @@ -86,9 +91,10 @@ export function syncProposalData< T extends | MultisigProposal | TokenVotingProposal + | GaslessVotingProposal | MultisigProposalListItem | TokenVotingProposalListItem - | GaslessVotingProposal, + | GaslessVotingProposalListItem, >(chainId: SupportedChainID, proposalId: string, serverData: T | null) { if (serverData) { proposalStorage.removeProposal(chainId, serverData.id); @@ -113,9 +119,10 @@ function syncExecutionInfo( proposal: | MultisigProposal | TokenVotingProposal + | GaslessVotingProposal | MultisigProposalListItem | TokenVotingProposalListItem - | GaslessVotingProposal + | GaslessVotingProposalListItem ): void { if (proposal.status === ProposalStatus.EXECUTED) { // If the proposal is already executed, remove its detail from storage. @@ -145,9 +152,10 @@ function syncApprovalsOrVotes( proposal: | MultisigProposal | TokenVotingProposal + | GaslessVotingProposal | MultisigProposalListItem | TokenVotingProposalListItem - | GaslessVotingProposal + | GaslessVotingProposalListItem ): void { if (isMultisigProposal(proposal)) { proposal.approvals = syncMultisigVotes(chainId, proposal); @@ -284,8 +292,12 @@ function syncGaslessVotesOrApproves( proposal.id ); - const serverApprovals = new Set(proposal.approvers); - const serverGaslessVoters = new Set(proposal.voters); + const serverApprovals = new Set( + proposal.approvers?.map(approver => approver.toLowerCase()) + ); + const serverGaslessVoters = new Set( + proposal.voters?.map(voter => voter.toLowerCase()) + ); allCachedVotes.forEach(cachedVote => { // remove, from the cache, votes that are returned by the query as well diff --git a/src/utils/committeeVoting.ts b/src/utils/committeeVoting.ts index 73365b032..0c8ed834c 100644 --- a/src/utils/committeeVoting.ts +++ b/src/utils/committeeVoting.ts @@ -4,25 +4,26 @@ import {Locale, formatDistanceToNow} from 'date-fns'; import * as Locales from 'date-fns/locale'; export function getCommitteVoteButtonLabel( - executed: boolean, notBegan: boolean, voted: boolean, - canApprove: boolean, approved: boolean, + isApprovalPeriod: boolean, + executableWithNextApproval: boolean, t: TFunction ) { if (approved || voted) { return t('votingTerminal.status.approved'); } - if (notBegan || canApprove) { - return t('votingTerminal.approve'); + if (notBegan || isApprovalPeriod) { + return executableWithNextApproval + ? t('votingTerminal.approveOnly') + : t('votingTerminal.approve'); } return t('votingTerminal.concluded'); } export function getApproveStatusLabel( proposal: GaslessVotingProposal, - isApprovalPeriod: boolean, t: TFunction, i18nLanguage: string ) { @@ -35,13 +36,21 @@ export function getApproveStatusLabel( ) { const locale = (Locales as Record)[i18nLanguage]; - if (!isApprovalPeriod) { + // If proposal gasless voting is active yet + if (proposal.endDate > new Date()) { const timeUntilNow = formatDistanceToNow(proposal.endDate, { includeSeconds: true, locale, }); label = t('votingTerminal.status.pending', {timeUntilNow}); - } else { + } + // If the status is succeeded but the approval period passed + // So te proposal is nor active/succeeded neither executed + else if (proposal.tallyEndDate < new Date()) { + label = t('votingTerminal.status.succeeded'); + } + // If is approval period + else { const timeUntilEnd = formatDistanceToNow(proposal.tallyEndDate, { includeSeconds: true, locale, diff --git a/src/utils/library.ts b/src/utils/library.ts index fedaafcc6..7076472d0 100644 --- a/src/utils/library.ts +++ b/src/utils/library.ts @@ -52,6 +52,7 @@ import { ActionMintToken, ActionRemoveAddress, ActionSCC, + ActionUpdateGaslessSettings, ActionUpdateMetadata, ActionUpdateMultisigPluginSettings, ActionUpdatePluginSettings, @@ -64,6 +65,10 @@ import {Abi, addABI, decodeMethod} from './abiDecoder'; import {attachEtherNotice} from './contract'; import {getTokenInfo} from './tokens'; import {daoABI} from 'abis/daoABI'; +import { + GaslessPluginVotingSettings, + GaslessVotingClient, +} from '@vocdoni/gasless-voting'; export function formatUnits(amount: BigNumberish, decimals: number) { if (amount.toString().includes('.') || !decimals) { @@ -278,7 +283,7 @@ export async function decodeMintTokensToAction( */ export async function decodeAddMembersToAction( data: Uint8Array | undefined, - client: MultisigClient | undefined + client: MultisigClient | GaslessVotingClient | undefined ): Promise { if (!client || !data) { console.error('SDK client is not initialized correctly'); @@ -304,7 +309,7 @@ export async function decodeAddMembersToAction( */ export async function decodeRemoveMembersToAction( data: Uint8Array | undefined, - client: MultisigClient | undefined + client: MultisigClient | GaslessVotingClient | undefined ): Promise { if (!client || !data) { console.error('SDK client is not initialized correctly'); @@ -365,6 +370,27 @@ export function decodeMultisigSettingsToAction( }; } +export function decodeGaslessSettingsToAction( + data: Uint8Array | undefined, + client: GaslessVotingClient, + totalVotingWeight: bigint, + token?: Erc20TokenDetails +): ActionUpdateGaslessSettings | undefined { + if (!client || !data) { + console.error('SDK client is not initialized correctly'); + return; + } + + return { + name: 'modify_gasless_voting_settings', + inputs: { + ...client.decoding.updatePluginSettingsAction(data), + token, + totalVotingWeight, + }, + }; +} + /** * Decode update DAO metadata settings action * @param data Uint8Array action data @@ -775,12 +801,16 @@ export function readFile(file: Blob): Promise { */ export function removeUnchangedMinimumApprovalAction( actions: Action[], - pluginSettings: MultisigVotingSettings + pluginSettings: MultisigVotingSettings | GaslessPluginVotingSettings ) { return actions.flatMap(action => { if ( - action.name === 'modify_multisig_voting_settings' && - Number(action.inputs.minApprovals) === pluginSettings.minApprovals + (action.name === 'modify_multisig_voting_settings' && + Number(action.inputs.minApprovals) === + (pluginSettings as MultisigVotingSettings).minApprovals) || + (action.name === 'modify_gasless_voting_settings' && + Number(action.inputs.minTallyApprovals) === + (pluginSettings as GaslessPluginVotingSettings).minTallyApprovals) ) return []; else return action; diff --git a/src/utils/proposals.ts b/src/utils/proposals.ts index bc76923e4..82050f8e4 100644 --- a/src/utils/proposals.ts +++ b/src/utils/proposals.ts @@ -119,7 +119,7 @@ export function isGaslessProposal( proposal: SupportedProposals | undefined | null ): proposal is GaslessVotingProposal { if (!proposal) return false; - return 'vochainProposalId' in proposal; + return 'settings' in proposal && 'minTallyApprovals' in proposal.settings; } /** @@ -741,6 +741,11 @@ export function getVoteStatus(proposal: DetailedProposal, t: TFunction) { locale, }); + if (isGaslessProposal(proposal) && proposal.endDate < new Date()) { + label = t('votingTerminal.status.succeeded'); + break; + } + label = t('votingTerminal.status.active', {timeUntilEnd}); } break; @@ -921,12 +926,18 @@ export function getProposalExecutionStatus( */ export function getNonEmptyActions( actions: Array, - msVoteSettings?: MultisigVotingSettings + msVoteSettings?: MultisigVotingSettings, + gaslessVoteSettings?: GaslessPluginVotingSettings ): Action[] { return actions.flatMap(action => { if (action == null) return []; - if (action.name === 'modify_multisig_voting_settings') { + if (action.name === 'modify_gasless_voting_settings') { + return action.inputs.minTallyApprovals !== + gaslessVoteSettings?.minTallyApprovals + ? action + : []; + } else if (action.name === 'modify_multisig_voting_settings') { // minimum approval or onlyListed changed: return action or don't include return action.inputs.minApprovals !== msVoteSettings?.minApprovals || action.inputs.onlyListed !== msVoteSettings.onlyListed diff --git a/src/utils/types.ts b/src/utils/types.ts index eb3952c78..93c82ec47 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -18,6 +18,7 @@ import { import { GaslessPluginVotingSettings, GaslessVotingProposal, + GaslessVotingProposalListItem, } from '@vocdoni/gasless-voting'; import {BigNumber} from 'ethers'; @@ -195,7 +196,7 @@ export type DetailedProposal = export type ProposalListItem = | TokenVotingProposalListItem | MultisigProposalListItem - | GaslessVotingProposal; + | GaslessVotingProposalListItem; export type SupportedProposals = DetailedProposal | ProposalListItem; export type SupportedVotingSettings = @@ -257,7 +258,8 @@ export type ActionsTypes = | 'modify_multisig_voting_settings' | 'update_minimum_approval' | 'os_update' - | 'plugin_update'; + | 'plugin_update' + | 'modify_gasless_voting_settings'; export type ActionWithdraw = { amount: number; @@ -344,12 +346,19 @@ export type ActionUpdateMultisigPluginSettings = { inputs: MultisigVotingSettings; }; +type TokenVotingActionAdditionalInfo = { + token?: Erc20TokenDetails; + totalVotingWeight: bigint; +}; + export type ActionUpdatePluginSettings = { name: 'modify_token_voting_settings'; - inputs: VotingSettings & { - token?: Erc20TokenDetails; - totalVotingWeight: bigint; - }; + inputs: VotingSettings & TokenVotingActionAdditionalInfo; +}; + +export type ActionUpdateGaslessSettings = { + name: 'modify_gasless_voting_settings'; + inputs: GaslessPluginVotingSettings & TokenVotingActionAdditionalInfo; }; export type ActionUpdateMetadata = { @@ -402,7 +411,8 @@ export type Action = | ActionSCC | ActionWC | ActionOSUpdate - | ActionPluginUpdate; + | ActionPluginUpdate + | ActionUpdateGaslessSettings; export type ParamType = { type: string; @@ -609,7 +619,10 @@ export type ProposalSettingsFormData = ProposalFormData & { export interface ManageMembersFormData extends ProposalFormData { actions: Array< - ActionAddAddress | ActionRemoveAddress | ActionUpdateMultisigPluginSettings + | ActionAddAddress + | ActionRemoveAddress + | ActionUpdateMultisigPluginSettings + | ActionUpdateGaslessSettings >; } diff --git a/src/utils/useEffectDebugger.ts b/src/utils/useEffectDebugger.ts index a082c4565..6828b8d39 100644 --- a/src/utils/useEffectDebugger.ts +++ b/src/utils/useEffectDebugger.ts @@ -37,6 +37,9 @@ export const useEffectDebugger = ( console.log('[use-effect-debugger] ', changedDeps); } + // TODO Manos: I actually added the effectHook in the dependencies list + // but apparently the linter does not recognize it, therefore I disable + // the corresponding error to the following line // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(effectHook, dependencies); + useEffect(effectHook, dependencies.concat([effectHook])); }; diff --git a/yarn.lock b/yarn.lock index 21d43dcdb..b835dff48 100644 --- a/yarn.lock +++ b/yarn.lock @@ -81,32 +81,7 @@ dependencies: ethers "^5.6.2" -"@aragon/osx-ethers@^1.3.0-rc0.4": - version "1.3.0-rc0.4" - resolved "https://registry.yarnpkg.com/@aragon/osx-ethers/-/osx-ethers-1.3.0-rc0.4.tgz#878af071e454ef068801104deae8439f0f8f1720" - integrity sha512-FDuF6LC1OLnjFK4C8+P4Wf0sORrrUQ/JtUAxL5ABVtBD8JpyyhtdWGDiv/yWIooLyC2l8aqbDLPuiYWhw1DjEQ== - dependencies: - ethers "^5.6.2" - -"@aragon/sdk-client-common@1.10.0": - version "1.10.0" - resolved "https://registry.yarnpkg.com/@aragon/sdk-client-common/-/sdk-client-common-1.10.0.tgz#603b4e4d8a5079283ba1fb08ef22de6d13b51f1b" - integrity sha512-nWkTPA1Tzk2ULcf0lGbwyalZJkhKUnYN4cd4xQiwnEwvy6traYaw9HRB2sNUlEFgPrLIKIkgjGS9diZjXXW0xQ== - dependencies: - "@aragon/osx-ethers" "^1.3.0-rc0.4" - "@aragon/osx-ethers-v1.0.0" "npm:@aragon/osx-ethers@1.2.1" - "@aragon/sdk-ipfs" "^1.1.0" - "@ethersproject/abstract-signer" "^5.5.0" - "@ethersproject/bignumber" "^5.6.0" - "@ethersproject/constants" "^5.6.0" - "@ethersproject/contracts" "^5.5.0" - "@ethersproject/providers" "^5.5.0" - "@ethersproject/wallet" "^5.6.0" - graphql "^16.5.0" - graphql-request "^4.3.0" - yup "^1.2.0" - -"@aragon/sdk-client-common@^1.13.0": +"@aragon/sdk-client-common@1.13.0", "@aragon/sdk-client-common@^1.13.0": version "1.13.0" resolved "https://registry.yarnpkg.com/@aragon/sdk-client-common/-/sdk-client-common-1.13.0.tgz#3006d14b4b3faab29c5b7430a40759de9b7de96b" integrity sha512-E5GZazXSX0LCVgx4KundbTmdyqhHI2IFEhEd/F0ACYGQwgvjAe073ADAb4WqVlVAVVnOHoU/T6KkgliFRN/Vmw== @@ -124,7 +99,7 @@ graphql-request "^4.3.0" yup "^1.2.0" -"@aragon/sdk-client@^1.21.1": +"@aragon/sdk-client@1.21.1", "@aragon/sdk-client@^1.21.1": version "1.21.1" resolved "https://registry.yarnpkg.com/@aragon/sdk-client/-/sdk-client-1.21.1.tgz#c8d72bca42b95a8205173cf899e2d50ca92e19b8" integrity sha512-frPpT07knwua2VCjzNYPo+dtQfYeG+k5i3HMpk4pK8N5pEweCaeJPLoc4+AZVPPw8LxNdnmkxsKqi7rtUdJN/A== @@ -4654,20 +4629,21 @@ "@babel/plugin-transform-react-jsx-source" "^7.22.5" react-refresh "^0.14.0" -"@vocdoni/gasless-voting-ethers@0.0.1-rc1": - version "0.0.1-rc1" - resolved "https://registry.yarnpkg.com/@vocdoni/gasless-voting-ethers/-/gasless-voting-ethers-0.0.1-rc1.tgz#9958b632abce3e2e5179ecd201c41d3b03a2f047" - integrity sha512-lU58vpJ3AQp2u1OypsVvHgGGzCTvgtDXPLUDChn2yFHJhxc7o2Q79EwCZnD+XV9v8cN94I8AWhocbDDk1E8jMA== +"@vocdoni/gasless-voting-ethers@0.0.1-rc2": + version "0.0.1-rc2" + resolved "https://registry.yarnpkg.com/@vocdoni/gasless-voting-ethers/-/gasless-voting-ethers-0.0.1-rc2.tgz#ce2d3f0457f2504df0f38fa2cb82c555d58512f2" + integrity sha512-n9MpaUwpKyykwaZnMPsm6B298AsCdSGdn8T7PTHMDVesBkKtX9z3/nDcmWyZAgk+bm8odSm0Xze6or4epb0JqA== dependencies: ethers "^5.6.2" -"@vocdoni/gasless-voting@0.0.1-rc2": - version "0.0.1-rc2" - resolved "https://registry.yarnpkg.com/@vocdoni/gasless-voting/-/gasless-voting-0.0.1-rc2.tgz#77301d029d74f78ad1b12a21a4d69176e35db646" - integrity sha512-4RN+yIvo9ki2Qx7v3XXlQ3cxZt13ilnZOpPj9BCGjwtcQUAS2UvNvX2+kOYk7grjLzG2pEwdszq+j9QLzvpcEQ== +"@vocdoni/gasless-voting@0.0.1-rc21": + version "0.0.1-rc21" + resolved "https://registry.yarnpkg.com/@vocdoni/gasless-voting/-/gasless-voting-0.0.1-rc21.tgz#244e18580bb082e43750b3c85425663b217fcc99" + integrity sha512-vyvrLeHdZ+hSkMd5bh40Fk40NoDyf4JdDetXuQ43rgGHxLonom9kr7XR3Kdoju1cLqOrnMvhOwI1xDWuWjZLdg== dependencies: "@aragon/osx-ethers" "1.3.0-rc0.3" - "@aragon/sdk-client-common" "1.10.0" + "@aragon/sdk-client" "1.21.1" + "@aragon/sdk-client-common" "1.13.0" "@ethersproject/abstract-signer" "^5.7.0" "@ethersproject/bignumber" "^5.7.0" "@ethersproject/constants" "^5.7.0" @@ -4675,8 +4651,8 @@ "@ethersproject/providers" "^5.7.2" "@ethersproject/wallet" "^5.7.0" "@types/big.js" "^6.1.5" - "@vocdoni/gasless-voting-ethers" "0.0.1-rc1" - "@vocdoni/sdk" "0.5.0" + "@vocdoni/gasless-voting-ethers" "0.0.1-rc2" + "@vocdoni/sdk" "0.7.1" axios "0.27.2" graphql "^16.6.0" graphql-request "4.3.0" @@ -4689,17 +4665,15 @@ long "^5.2.1" protobufjs "^7.1.2" -"@vocdoni/react-providers@0.1.14": - version "0.1.14" - resolved "https://registry.yarnpkg.com/@vocdoni/react-providers/-/react-providers-0.1.14.tgz#abecb2cb439481b1504a88db33ad62921978ea8f" - integrity sha512-r14l9BJ16FgAcJ+42g8kYmVoQ4r/iyoCYUjLL1pWjLnZBSlJ1OZEgliDFUvS2SlqlV8Ij+l/6+jhmx0jjRM5aQ== - dependencies: - "@vocdoni/sdk" "^0.4.2" +"@vocdoni/react-providers@0.3.1": + version "0.3.1" + resolved "https://registry.yarnpkg.com/@vocdoni/react-providers/-/react-providers-0.3.1.tgz#c4e4416926871d4f1ead7b022ffd1592b298115a" + integrity sha512-Q7Op4wf4Ig/Mf5ceDDxmyHShEhVuioQShW9GOwAcsIiUl7ijeuAuO89uLq6u/PlVuwGJkppHG07O5oUNlxe6UQ== -"@vocdoni/sdk@0.5.0", "@vocdoni/sdk@^0.4.2": - version "0.5.0" - resolved "https://registry.yarnpkg.com/@vocdoni/sdk/-/sdk-0.5.0.tgz#34084e796bb744c68c17e107f80cdf30b9b316e4" - integrity sha512-7MobpmGeCyMyywj8UdpwHdJTE/z28m6jENgwe7UHIYYsaTqzSyWV8Zq/RisRA5vD2jgQbXTVmhzzzV0LXBDwuQ== +"@vocdoni/sdk@0.7.1": + version "0.7.1" + resolved "https://registry.yarnpkg.com/@vocdoni/sdk/-/sdk-0.7.1.tgz#99504b04222c521a65e5bd352bfc04379fb5b568" + integrity sha512-qgHZoFsaJBkwWsrJeu1EZM5iMqqB2DlvQKjtncwAR8PZK/7ueVvavBMoig1Zx5REBhEhY1MKWs1d253lMHjIpw== dependencies: "@ethersproject/abstract-signer" "^5.7.0" "@ethersproject/address" "^5.7.0" @@ -4707,7 +4681,6 @@ "@ethersproject/bytes" "^5.7.0" "@ethersproject/keccak256" "^5.7.0" "@ethersproject/providers" "^5.7.1" - "@ethersproject/sha2" "^5.7.0" "@ethersproject/signing-key" "^5.7.0" "@ethersproject/strings" "^5.7.0" "@ethersproject/units" "^5.7.0" @@ -4717,6 +4690,7 @@ axios "0.27.2" blake2b "^2.1.4" iso-language-codes "^1.1.0" + js-sha256 "^0.10.1" readable-stream "^4.4.2" tiny-invariant "^1.3.1" tweetnacl "^1.0.3" @@ -12540,6 +12514,11 @@ js-levenshtein@^1.1.6: resolved "https://registry.yarnpkg.com/js-levenshtein/-/js-levenshtein-1.1.6.tgz#c6cee58eb3550372df8deb85fad5ce66ce01d59d" integrity sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g== +js-sha256@^0.10.1: + version "0.10.1" + resolved "https://registry.yarnpkg.com/js-sha256/-/js-sha256-0.10.1.tgz#b40104ba1368e823fdd5f41b66b104b15a0da60d" + integrity sha512-5obBtsz9301ULlsgggLg542s/jqtddfOpV5KJc4hajc9JV8GeY2gZHSVpYBn4nWqAUTJ9v+xwtbJ1mIBgIH5Vw== + js-sha3@0.5.7, js-sha3@^0.5.7: version "0.5.7" resolved "https://registry.yarnpkg.com/js-sha3/-/js-sha3-0.5.7.tgz#0d4ffd8002d5333aabaf4a23eed2f6374c9f28e7"