From 76d6593cea7b0efeb269b9152ee57980be15736f Mon Sep 17 00:00:00 2001 From: Oleksandr Date: Fri, 5 Jan 2024 10:09:11 +0200 Subject: [PATCH] feat: current power visibility (#40) * feat: added current powers to account info modal * feat: added powers by assets to account info modal * fix: powers styles * fix: added impersonated and change to mainnet * fix: payloads explorer details modal * fix: current powers info icon --- public/images/icons/reload.svg | 8 + .../components/PayloadItemDetailsModal.tsx | 96 +++++- .../components/PayloadsExplorerPage.tsx | 40 ++- .../store/payloadsExplorerSelectors.ts | 24 ++ .../store/payloadsExplorerSlice.ts | 58 +++- .../components/TransactionsModalContent.tsx | 27 +- src/ui/store/uiSlice.ts | 8 + src/ui/utils/texts.ts | 9 +- src/utils/localStorage.ts | 9 + .../wallet/AccountInfoModalContent.tsx | 3 + .../components/wallet/ConnectWalletModal.tsx | 30 +- .../wallet/ConnectWalletModalContent.tsx | 36 ++- .../components/wallet/CurrentPowerItem.tsx | 116 +++++++ src/web3/components/wallet/CurrentPowers.tsx | 286 ++++++++++++++++++ .../components/wallet/ImpersonatedForm.tsx | 78 +++++ .../components/wallet/PowersInfoModal.tsx | 59 ++++ .../components/wallet/PowersModalItem.tsx | 154 ++++++++++ src/web3/components/wallet/WalletItem.tsx | 19 +- src/web3/components/wallet/WalletWidget.tsx | 7 + src/web3/providers/Web3HelperProvider.tsx | 10 + src/web3/services/delegationService.ts | 90 +++++- src/web3/store/web3Selectors.ts | 19 ++ src/web3/store/web3Slice.ts | 127 ++++++++ 23 files changed, 1260 insertions(+), 53 deletions(-) create mode 100644 public/images/icons/reload.svg create mode 100644 src/payloadsExplorer/store/payloadsExplorerSelectors.ts create mode 100644 src/web3/components/wallet/CurrentPowerItem.tsx create mode 100644 src/web3/components/wallet/CurrentPowers.tsx create mode 100644 src/web3/components/wallet/ImpersonatedForm.tsx create mode 100644 src/web3/components/wallet/PowersInfoModal.tsx create mode 100644 src/web3/components/wallet/PowersModalItem.tsx create mode 100644 src/web3/store/web3Selectors.ts diff --git a/public/images/icons/reload.svg b/public/images/icons/reload.svg new file mode 100644 index 00000000..c8b16fa2 --- /dev/null +++ b/public/images/icons/reload.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/payloadsExplorer/components/PayloadItemDetailsModal.tsx b/src/payloadsExplorer/components/PayloadItemDetailsModal.tsx index cd0a2bee..5c94181f 100644 --- a/src/payloadsExplorer/components/PayloadItemDetailsModal.tsx +++ b/src/payloadsExplorer/components/PayloadItemDetailsModal.tsx @@ -1,6 +1,10 @@ import { InitialPayload } from '@bgd-labs/aave-governance-ui-helpers'; +import { + selectLastTxByTypeAndPayload, + TransactionStatus, +} from '@bgd-labs/frontend-web3-utils'; import { Box } from '@mui/system'; -import React from 'react'; +import React, { useEffect } from 'react'; import { toHex } from 'viem'; import { PayloadActions } from '../../proposals/components/proposal/PayloadActions'; @@ -10,28 +14,50 @@ import { seatbeltStartLink, } from '../../proposals/utils/formatPayloadData'; import { useStore } from '../../store'; +import { + TransactionUnion, + TxType, +} from '../../transactions/store/transactionsSlice'; import { BasicModal, Link, SmallButton } from '../../ui'; import { NetworkIcon } from '../../ui/components/NetworkIcon'; import { texts } from '../../ui/utils/texts'; +import { selectPayloadExploreById } from '../store/payloadsExplorerSelectors'; import { PayloadStatus } from './PayloadStatus'; interface PayloadItemDetailsModalProps { initialPayload: InitialPayload; + setSelectedPayloadForExecute: ({ + chainId, + payloadsController, + id, + }: InitialPayload) => void; } export function PayloadItemDetailsModal({ initialPayload, + setSelectedPayloadForExecute, }: PayloadItemDetailsModalProps) { + const store = useStore(); const { isPayloadExplorerItemDetailsModalOpen, setIsPayloadExplorerItemDetailsModalOpen, - payloadsExploreData, - } = useStore(); + getPayloadsExploreDataById, + } = store; - const payload = - payloadsExploreData[initialPayload.chainId][ - initialPayload.payloadsController - ][`${initialPayload.payloadsController}_${initialPayload.id}`]; + useEffect(() => { + getPayloadsExploreDataById( + initialPayload.chainId, + initialPayload.payloadsController, + initialPayload.id, + ); + }, []); + + const payload = selectPayloadExploreById( + store, + initialPayload.chainId, + initialPayload.payloadsController, + initialPayload.id, + ); if (!payload) return null; @@ -50,6 +76,20 @@ export function PayloadItemDetailsModal({ withoutProposalData: true, }); + const tx = + store.activeWallet && + selectLastTxByTypeAndPayload( + store, + store.activeWallet.address, + TxType.executePayload, + { + proposalId: 0, + payloadId: payload.id, + chainId: payload.chainId, + payloadController: payload.payloadsController, + }, + ); + return ( - - {texts.proposals.payloadsDetails.seatbelt} - + + + + {texts.proposals.payloadsDetails.seatbelt} + + + + + + {isPayloadReadyForExecution && + !isFinalStatus && + store.activeWallet?.isActive && ( + + { + e.stopPropagation(); + if (!!setSelectedPayloadForExecute) { + setSelectedPayloadForExecute({ + chainId: payload?.chainId, + payloadsController: payload?.payloadsController, + id: payload?.id, + }); + } + store.setIsPayloadExplorerItemDetailsModalOpen(false); + store.setExecutePayloadModalOpen(true); + }}> + {texts.proposals.payloadsDetails.execute} + + + )} + diff --git a/src/payloadsExplorer/components/PayloadsExplorerPage.tsx b/src/payloadsExplorer/components/PayloadsExplorerPage.tsx index 1760e96c..4783a726 100644 --- a/src/payloadsExplorer/components/PayloadsExplorerPage.tsx +++ b/src/payloadsExplorer/components/PayloadsExplorerPage.tsx @@ -93,6 +93,9 @@ export function PayloadsExplorerPage() { // @ts-ignore const params = new URLSearchParams(searchParams); params.set(name, value); + params.delete('payloadId'); + params.delete('payloadChainId'); + params.delete('payloadsControllerAddress'); return params.toString(); }, @@ -109,6 +112,7 @@ export function PayloadsExplorerPage() { isRendered, startDetailedPayloadsExplorerDataPolling, stopDetailedPayloadsExplorerDataPolling, + setIsPayloadExplorerItemDetailsModalOpen, } = useStore(); const [isColumns, setIsColumns] = useState(false); @@ -129,11 +133,42 @@ export function PayloadsExplorerPage() { }, []); useEffect(() => { - if (searchParams && !!searchParams.get('chainId')) { - setChainId(checkChainId(Number(searchParams?.get('chainId')))); + if ( + searchParams && + (!!searchParams.get('payloadChainId') || !!searchParams.get('chainId')) + ) { + setChainId( + checkChainId( + Number( + searchParams?.get('payloadChainId') || searchParams?.get('chainId'), + ), + ), + ); } }, [searchParams]); + useEffect(() => { + if ( + searchParams && + !!searchParams.get('payloadId') && + !!searchParams.get('payloadChainId') && + !!searchParams.get('payloadsControllerAddress') + ) { + const payloadId = Number(searchParams.get('payloadId')); + const payloadChainId = Number(searchParams.get('payloadChainId')); + const payloadsControllerAddress = String( + searchParams.get('payloadsControllerAddress'), + ) as Hex; + + setSelectedPayloadForDetailsModal({ + chainId: payloadChainId, + payloadsController: payloadsControllerAddress, + id: payloadId, + }); + setIsPayloadExplorerItemDetailsModalOpen(true); + } + }, [searchParams?.get('payloadId')]); + useEffect(() => { setControllerAddress( appConfig.payloadsControllerConfig[chainId].contractAddresses[0], @@ -379,6 +414,7 @@ export function PayloadsExplorerPage() { {selectedPayloadForDetailsModal && ( )} diff --git a/src/payloadsExplorer/store/payloadsExplorerSelectors.ts b/src/payloadsExplorer/store/payloadsExplorerSelectors.ts new file mode 100644 index 00000000..6ab75eaf --- /dev/null +++ b/src/payloadsExplorer/store/payloadsExplorerSelectors.ts @@ -0,0 +1,24 @@ +import { Hex } from 'viem'; + +import { RootState } from '../../store'; + +export const selectPayloadExploreById = ( + store: RootState, + chainId: number, + address: Hex, + payloadId: number, +) => { + if (!store.payloadsExploreData[chainId]) { + return; + } else if (!store.payloadsExploreData[chainId][address]) { + return; + } else if ( + !store.payloadsExploreData[chainId][address][`${address}_${payloadId}`] + ) { + return; + } else { + return store.payloadsExploreData[chainId][address][ + `${address}_${payloadId}` + ]; + } +}; diff --git a/src/payloadsExplorer/store/payloadsExplorerSlice.ts b/src/payloadsExplorer/store/payloadsExplorerSlice.ts index 19938fe6..e01bb421 100644 --- a/src/payloadsExplorer/store/payloadsExplorerSlice.ts +++ b/src/payloadsExplorer/store/payloadsExplorerSlice.ts @@ -23,12 +23,26 @@ export interface IPayloadsExplorerSlice { { activePage: number; pageCount: number; currentIds: number[] } >; + setPaginationDetails: ( + chainId: number, + address: Hex, + activePage?: number, + ) => Promise<{ + totalPayloadsCount: number; + idsForRequest: number[]; + }>; + payloadsExploreData: PayloadsData; getPayloadsExploreData: ( chainId: number, address: Hex, activePage?: number, ) => Promise; + getPayloadsExploreDataById: ( + chainId: number, + address: Hex, + payloadId: number, + ) => Promise; isPayloadExplorerItemDetailsModalOpen: boolean; setIsPayloadExplorerItemDetailsModalOpen: (value: boolean) => void; @@ -82,8 +96,7 @@ export const createPayloadsExplorerSlice: StoreSlice< } }, - payloadsExploreData: {}, - getPayloadsExploreData: async (chainId, address, activePage) => { + setPaginationDetails: async (chainId, address, activePage) => { const initialCount = get().totalPayloadsCountByAddress[address]; const totalPayloadsCount = initialCount ? initialCount @@ -155,6 +168,17 @@ export const createPayloadsExplorerSlice: StoreSlice< }), ); + return { + totalPayloadsCount, + idsForRequest, + }; + }, + + payloadsExploreData: {}, + getPayloadsExploreData: async (chainId, address, activePage) => { + const { totalPayloadsCount, idsForRequest } = + await get().setPaginationDetails(chainId, address, activePage); + if (totalPayloadsCount >= 1) { if (!!idsForRequest.length) { const payloadsData: Payload[] = await get().govDataService.getPayloads( @@ -187,6 +211,36 @@ export const createPayloadsExplorerSlice: StoreSlice< } } }, + getPayloadsExploreDataById: async (chainId, address, payloadId) => { + await get().setPaginationDetails(chainId, address); + + const payloadsData: Payload[] = await get().govDataService.getPayloads( + chainId, + address, + [payloadId], + ); + + const formattedPayloadsData: Record = {}; + payloadsData.forEach((payload) => { + if (payload) { + formattedPayloadsData[`${payload.payloadsController}_${payload.id}`] = + payload; + } + }); + + set((state) => + produce(state, (draft) => { + draft.payloadsExploreData[chainId] = { + [address]: { + ...(draft.payloadsExploreData[chainId] + ? draft.payloadsExploreData[chainId][address] + : {}), + ...formattedPayloadsData, + }, + }; + }), + ); + }, isPayloadExplorerItemDetailsModalOpen: false, setIsPayloadExplorerItemDetailsModalOpen: (value) => { diff --git a/src/transactions/components/TransactionsModalContent.tsx b/src/transactions/components/TransactionsModalContent.tsx index 6b1f5092..7d6ebc01 100644 --- a/src/transactions/components/TransactionsModalContent.tsx +++ b/src/transactions/components/TransactionsModalContent.tsx @@ -33,14 +33,14 @@ export function TransactionsModalContent({ ({ - overflowY: 'scroll', - height: forTest ? 191 : 440, - pr: 20, + height: forTest ? 191 : '100%', [theme.breakpoints.up('sm')]: { - height: forTest ? 128 : 440, + overflowY: 'scroll', + pr: 20, + height: forTest ? 128 : 510, }, [theme.breakpoints.up('lg')]: { - height: forTest ? 139 : 440, + height: forTest ? 139 : 580, }, })}> {allTransactions.map((tx, index) => ( @@ -48,14 +48,15 @@ export function TransactionsModalContent({ ))} - + + + ); } diff --git a/src/ui/store/uiSlice.ts b/src/ui/store/uiSlice.ts index c8147760..6c642585 100644 --- a/src/ui/store/uiSlice.ts +++ b/src/ui/store/uiSlice.ts @@ -89,6 +89,9 @@ export interface IUISlice { allTransactionModalOpen: boolean; setAllTransactionModalOpen: (value: boolean) => void; + powersInfoModalOpen: boolean; + setPowersInfoModalOpen: (value: boolean) => void; + isCreatePayloadModalOpen: boolean; setIsCreatePayloadModalOpen: (value: boolean) => void; @@ -422,6 +425,11 @@ export const createUISlice: StoreSlice< set({ isModalOpen: value, allTransactionModalOpen: value }); }, + powersInfoModalOpen: false, + setPowersInfoModalOpen: (value) => { + set({ isModalOpen: value, powersInfoModalOpen: value }); + }, + isActivateVotingModalOpen: false, setIsActivateVotingModalOpen: (value) => { set({ isModalOpen: value, isActivateVotingModalOpen: value }); diff --git a/src/ui/utils/texts.ts b/src/ui/utils/texts.ts index cdf55fbc..88a7f2d3 100644 --- a/src/ui/utils/texts.ts +++ b/src/ui/utils/texts.ts @@ -204,9 +204,9 @@ export const texts = { txSuccess: 'Represented', txTitle: 'Representations', yourWillRepresent: 'You will be represented', - yourRepresented: 'You represented', - yourCancelRepresented: 'You cancel represented', - yourCanceledRepresented: 'You canceled represented', + yourRepresented: 'You are represented', + yourCancelRepresented: 'You are cancel represented', + yourCanceledRepresented: 'You are canceled represented', notRepresented: 'Not represented', representationInfo: 'This is the voting power of the address you are representing. Remember that the representative role is by network, so if not able to vote on other proposals, try to change who you are representing', @@ -272,6 +272,9 @@ export const texts = { impersonatedButtonTitle: 'Connect', representing: 'Representing', representative: 'You are representing', + currentPower: 'Current power', + currentPowerDescription: + 'Current voting power represents how much you would have available for voting if a proposal will be activated just now, and proposition for if you would want to create one.', }, header: { navSnapshots: 'Snapshots', diff --git a/src/utils/localStorage.ts b/src/utils/localStorage.ts index 245531f7..a6dc16b7 100644 --- a/src/utils/localStorage.ts +++ b/src/utils/localStorage.ts @@ -19,6 +19,7 @@ export enum LocalStorageKeys { AppMode = 'appMode', TutorialStartButtonClicked = 'tutorialStartButtonClicked', PayloadsExplorerView = 'payloadsExplorerView', + PowersInfoClicked = 'powersInfoClicked', } // for ENS @@ -128,3 +129,11 @@ export const getLocalStoragePayloadsExplorerView = () => { export const setLocalStoragePayloadsExplorerView = (value: string) => { return localStorage?.setItem(LocalStorageKeys.PayloadsExplorerView, value); }; + +export const getLocalStoragePowersInfoClicked = () => { + return localStorage?.getItem(LocalStorageKeys.PowersInfoClicked); +}; + +export const setLocalStoragePowersInfoClicked = (value: string) => { + return localStorage?.setItem(LocalStorageKeys.PowersInfoClicked, value); +}; diff --git a/src/web3/components/wallet/AccountInfoModalContent.tsx b/src/web3/components/wallet/AccountInfoModalContent.tsx index 366ba91e..8d9fd5dd 100644 --- a/src/web3/components/wallet/AccountInfoModalContent.tsx +++ b/src/web3/components/wallet/AccountInfoModalContent.tsx @@ -20,6 +20,7 @@ import { texts } from '../../../ui/utils/texts'; import { media } from '../../../ui/utils/themeMUI'; import { useMediaQuery } from '../../../ui/utils/useMediaQuery'; import { chainInfoHelper } from '../../../utils/configs'; +import { CurrentPowers } from './CurrentPowers'; import { RepresentingForm } from './RepresentingForm'; interface AccountInfoModalContentProps { @@ -264,6 +265,8 @@ export function AccountInfoModalContent({ /> )} + {!forTest && } + {isActive && !!filteredTransactions.length && ( { + setImpersonatedFormOpen(false); + }, [isOpen]); useEffect(() => { if (!walletActivating && !walletConnectionError) { @@ -58,8 +71,19 @@ export function ConnectWalletModal({ { + if (wallet.walletType === 'Impersonated') { + return { + ...wallet, + isVisible: appMode === 'expert', + }; + } else { + return wallet; + } + })} walletConnectionError={walletConnectionError} + impersonatedFormOpen={impersonatedFormOpen} + setImpersonatedFormOpen={setImpersonatedFormOpen} /> ); diff --git a/src/web3/components/wallet/ConnectWalletModalContent.tsx b/src/web3/components/wallet/ConnectWalletModalContent.tsx index 3ddd5d57..4ecc111f 100644 --- a/src/web3/components/wallet/ConnectWalletModalContent.tsx +++ b/src/web3/components/wallet/ConnectWalletModalContent.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { BoxWith3D } from '../../../ui'; import { RocketLoader } from '../../../ui/components/RocketLoader'; import { texts } from '../../../ui/utils/texts'; +import { ImpersonatedForm } from './ImpersonatedForm'; import { Wallet, WalletItem } from './WalletItem'; interface ConnectWalletModalContentProps { @@ -12,6 +13,8 @@ interface ConnectWalletModalContentProps { onWalletButtonClick?: () => void; walletConnectionError?: string; withoutHelpText?: boolean; + impersonatedFormOpen?: boolean; + setImpersonatedFormOpen?: (value: boolean) => void; } export function ConnectWalletModalContent({ @@ -20,6 +23,8 @@ export function ConnectWalletModalContent({ onWalletButtonClick, walletConnectionError, withoutHelpText, + impersonatedFormOpen, + setImpersonatedFormOpen, }: ConnectWalletModalContentProps) { return ( ) : ( <> - {wallets.map((wallet) => ( - - {wallet.isVisible && ( - - )} - - ))} + {impersonatedFormOpen && !!setImpersonatedFormOpen ? ( + + ) : ( + <> + {wallets.map((wallet) => ( + + {wallet.isVisible && ( + + )} + + ))} + + )} )} diff --git a/src/web3/components/wallet/CurrentPowerItem.tsx b/src/web3/components/wallet/CurrentPowerItem.tsx new file mode 100644 index 00000000..1fd7b9a4 --- /dev/null +++ b/src/web3/components/wallet/CurrentPowerItem.tsx @@ -0,0 +1,116 @@ +import { Box, useTheme } from '@mui/system'; + +import { RepresentationIcon } from '../../../proposals/components/RepresentationIcon'; +import { CustomSkeleton } from '../../../ui/components/CustomSkeleton'; +import { FormattedNumber } from '../../../ui/components/FormattedNumber'; +import { GovernancePowerType } from '../../services/delegationService'; + +interface CurrentPowerItemProps { + type: GovernancePowerType; + totalValue?: number; + yourValue?: number; + delegatedValue?: number; + representativeAddress?: string; +} + +export function CurrentPowerItem({ + type, + totalValue, + yourValue, + delegatedValue, + representativeAddress, +}: CurrentPowerItemProps) { + const theme = useTheme(); + + const isVotingType = type === GovernancePowerType.VOTING; + + return ( + + + Total {isVotingType ? 'Voting' : 'Proposition'} power:{' '} + + {!!representativeAddress && isVotingType && ( + + )} + {!!totalValue || totalValue === 0 ? ( + + ) : ( + + )} + + + + + Power from balance:{' '} + + {!!representativeAddress && isVotingType && ( + + )} + {!!yourValue || yourValue === 0 ? ( + + ) : ( + + )} + + + + + Delegation received:{' '} + + {!!representativeAddress && isVotingType && ( + + )} + {!!delegatedValue || delegatedValue === 0 ? ( + + ) : ( + + )} + + + + ); +} diff --git a/src/web3/components/wallet/CurrentPowers.tsx b/src/web3/components/wallet/CurrentPowers.tsx new file mode 100644 index 00000000..c4d2cdcd --- /dev/null +++ b/src/web3/components/wallet/CurrentPowers.tsx @@ -0,0 +1,286 @@ +import { Popover } from '@headlessui/react'; +import { Box, useTheme } from '@mui/system'; +import dayjs from 'dayjs'; +import React, { useState } from 'react'; +import { zeroAddress } from 'viem'; + +import InfoIcon from '/public/images/icons/info.svg'; +import ReloadIcon from '/public/images/icons/reload.svg'; + +import { useStore } from '../../../store'; +import { Divider, Spinner } from '../../../ui'; +import { IconBox } from '../../../ui/primitives/IconBox'; +import { texts } from '../../../ui/utils/texts'; +import { + getLocalStoragePowersInfoClicked, + setLocalStoragePowersInfoClicked, +} from '../../../utils/localStorage'; +import { GovernancePowerType } from '../../services/delegationService'; +import { + selectCurrentPowers, + selectCurrentPowersForActiveWallet, +} from '../../store/web3Selectors'; +import { CurrentPowerItem } from './CurrentPowerItem'; + +export function CurrentPowers() { + const store = useStore(); + const { + representative, + activeWallet, + setPowersInfoModalOpen, + setAccountInfoModalOpen, + } = store; + const theme = useTheme(); + + const currentPowersAll = selectCurrentPowers(store); + const currentPowersActiveWallet = selectCurrentPowersForActiveWallet(store); + + const [startAnim, setStartAnim] = useState(false); + const [isInfoClicked, setClicked] = useState( + getLocalStoragePowersInfoClicked() === 'true', + ); + + return ( + + + + + {texts.walletConnect.currentPower} + + + {currentPowersAll?.timestamp && ( + + { + setLocalStoragePowersInfoClicked('true'); + setClicked(true); + }} + as={Box} + sx={{ + ml: 4, + transition: 'all 0.2s ease', + lineHeight: 1, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + minHeight: 23, + cursor: 'pointer', + '> *': { + lineHeight: 0, + }, + hover: { opacity: 0.6 }, + }}> + svg': { + width: 14, + height: 14, + path: { + fill: isInfoClicked + ? theme.palette.$text + : theme.palette.$error, + }, + }, + }}> + + + + + + + {texts.walletConnect.currentPowerDescription} + + + + )} + + + + {currentPowersAll?.timestamp ? ( + + + on{' '} + {dayjs + .unix(currentPowersAll.timestamp) + .format('HH:mm DD.MM.YYYY')} + + { + setStartAnim(true); + setTimeout(() => setStartAnim(false), 500); + store.getCurrentPowers( + !!representative.address + ? representative.address + : activeWallet?.address || zeroAddress, + true, + ); + }}> + svg': { + width: 12, + height: 12, + path: { + '&:first-of-type': { + fill: theme.palette.$textDisabled, + }, + '&:last-of-type': { + stroke: theme.palette.$textDisabled, + }, + }, + ellipse: { + fill: theme.palette.$textDisabled, + }, + }, + }}> + + + + + ) : ( + + + + )} + + + + + + + + + + + + { + setAccountInfoModalOpen(false); + setPowersInfoModalOpen(true); + }} + sx={{ + typography: 'descriptorAccent', + mt: 12, + cursor: 'pointer', + transition: 'all 0.2s ease', + hover: { opacity: '0.7' }, + }}> + Details + + + + + ); +} diff --git a/src/web3/components/wallet/ImpersonatedForm.tsx b/src/web3/components/wallet/ImpersonatedForm.tsx new file mode 100644 index 00000000..1b8e978c --- /dev/null +++ b/src/web3/components/wallet/ImpersonatedForm.tsx @@ -0,0 +1,78 @@ +import { Box } from '@mui/system'; +import React from 'react'; +import { Field, Form } from 'react-final-form'; + +import { useStore } from '../../../store'; +import { BackButton3D, BigButton, Input } from '../../../ui'; +import { texts } from '../../../ui/utils/texts'; +import { appConfig } from '../../../utils/appConfig'; + +interface ImpersonatedFormProps { + closeClick: (value: boolean) => void; +} + +export function ImpersonatedForm({ closeClick }: ImpersonatedFormProps) { + const { impersonated, setImpersonated, connectWallet } = useStore(); + + const handleFormSubmit = async ({ + impersonatedAddress, + }: { + impersonatedAddress: string; + }) => { + setImpersonated(impersonatedAddress); + await connectWallet('Impersonated', appConfig.govCoreChainId); + }; + + return ( + + closeClick(false)} + wrapperCss={{ mb: 40 }} + /> + + + onSubmit={handleFormSubmit} + initialValues={{ + impersonatedAddress: impersonated?.address, + }}> + {({ handleSubmit, values }) => ( + + + {(props) => ( + + )} + + + {texts.walletConnect.impersonatedButtonTitle} + + + )} + + + + ); +} diff --git a/src/web3/components/wallet/PowersInfoModal.tsx b/src/web3/components/wallet/PowersInfoModal.tsx new file mode 100644 index 00000000..2a01adf9 --- /dev/null +++ b/src/web3/components/wallet/PowersInfoModal.tsx @@ -0,0 +1,59 @@ +import { Box } from '@mui/system'; +import React from 'react'; + +import { useStore } from '../../../store'; +import { BackButton3D, BasicModal } from '../../../ui'; +import { GovernancePowerType } from '../../services/delegationService'; +import { + selectCurrentPowers, + selectCurrentPowersForActiveWallet, +} from '../../store/web3Selectors'; +import { PowersModalItem } from './PowersModalItem'; + +interface PowersInfoModalProps { + isOpen: boolean; + setIsOpen: (value: boolean) => void; +} + +export function PowersInfoModal({ isOpen, setIsOpen }: PowersInfoModalProps) { + const store = useStore(); + const { setAccountInfoModalOpen, representative } = store; + + const currentPowersAll = selectCurrentPowers(store); + const currentPowersActiveWallet = selectCurrentPowersForActiveWallet(store); + + if (!currentPowersAll || !currentPowersActiveWallet) return null; + + return ( + + + + + + { + setIsOpen(false); + setAccountInfoModalOpen(true); + }} + /> + + + ); +} diff --git a/src/web3/components/wallet/PowersModalItem.tsx b/src/web3/components/wallet/PowersModalItem.tsx new file mode 100644 index 00000000..b2278d46 --- /dev/null +++ b/src/web3/components/wallet/PowersModalItem.tsx @@ -0,0 +1,154 @@ +import { Box } from '@mui/system'; +import React from 'react'; + +import { RepresentationIcon } from '../../../proposals/components/RepresentationIcon'; +import { Divider } from '../../../ui'; +import { FormattedNumber } from '../../../ui/components/FormattedNumber'; +import { TokenIcon } from '../../../ui/components/TokenIcon'; +import { getTokenName, Token } from '../../../utils/getTokenName'; +import { GovernancePowerType } from '../../services/delegationService'; +import { PowersByAssets } from '../../store/web3Slice'; + +interface PowersModalItemProps { + type: GovernancePowerType; + totalValue: number; + representativeAddress?: string; + powersByAssets: PowersByAssets; +} + +export function PowersModalItem({ + type, + totalValue, + representativeAddress, + powersByAssets, +}: PowersModalItemProps) { + const isVotingType = type === GovernancePowerType.VOTING; + + return ( + + + Total {isVotingType ? 'Voting' : 'Proposition'} power:{' '} + + {!!representativeAddress && isVotingType && ( + + )} + + + + + + + Asset + + + From balance + + + Delegation received + + + + + + {Object.values(powersByAssets).map((asset) => { + const assetData = + type === GovernancePowerType.VOTING + ? asset.voting + : asset.proposition; + const symbol = getTokenName(asset.underlyingAsset) as Token; + + return ( + + + + {asset.tokenName} + + + + + + + + + ); + })} + + ); +} diff --git a/src/web3/components/wallet/WalletItem.tsx b/src/web3/components/wallet/WalletItem.tsx index 59c3e940..addd161e 100644 --- a/src/web3/components/wallet/WalletItem.tsx +++ b/src/web3/components/wallet/WalletItem.tsx @@ -11,17 +11,28 @@ export type Wallet = { title: string; onClick?: () => void; isVisible?: boolean; + setOpenImpersonatedForm?: (value: boolean) => void; }; -export function WalletItem({ walletType, title, icon, onClick }: Wallet) { +export function WalletItem({ + walletType, + title, + icon, + onClick, + setOpenImpersonatedForm, +}: Wallet) { const connectWallet = useStore((state) => state.connectWallet); + const iconSize = 28; + const handleWalletClick = async () => { - await connectWallet(walletType); + if (walletType === 'Impersonated' && setOpenImpersonatedForm) { + setOpenImpersonatedForm(true); + } else { + await connectWallet(walletType); + } }; - const iconSize = 28; - return ( + ); } diff --git a/src/web3/providers/Web3HelperProvider.tsx b/src/web3/providers/Web3HelperProvider.tsx index 345ad06d..f8d73d65 100644 --- a/src/web3/providers/Web3HelperProvider.tsx +++ b/src/web3/providers/Web3HelperProvider.tsx @@ -14,6 +14,8 @@ function Child() { getRepresentationData, initEns, initClients, + getCurrentPowers, + representative, } = useStore(); useEffect(() => { @@ -31,6 +33,14 @@ function Child() { getRepresentingAddress(); }, [activeWallet?.address, representationData]); + useEffect(() => { + if (!!representative.address) { + getCurrentPowers(representative.address); + } else if (activeWallet?.address) { + getCurrentPowers(activeWallet?.address); + } + }, [activeWallet?.address, representative.address]); + return null; } diff --git a/src/web3/services/delegationService.ts b/src/web3/services/delegationService.ts index 2bd3ef51..5d9c99ff 100644 --- a/src/web3/services/delegationService.ts +++ b/src/web3/services/delegationService.ts @@ -8,7 +8,7 @@ import { } from '@bgd-labs/aave-governance-ui-helpers'; import { ClientsRecord } from '@bgd-labs/frontend-web3-utils'; import { WalletClient } from '@wagmi/core'; -import { encodeFunctionData, Hex, hexToSignature } from 'viem'; +import { encodeFunctionData, Hex, hexToSignature, zeroAddress } from 'viem'; import { appConfig } from '../../utils/appConfig'; import { getTokenName } from '../../utils/getTokenName'; @@ -55,6 +55,93 @@ export class DelegationService { this.walletClient = walletClient; } + async getUserPowers(userAddress: Hex, underlyingAssets: Hex[]) { + const blockNumber = await this.clients[appConfig.govCoreChainId].getBlock(); + + const contracts = underlyingAssets.map((asset) => { + return { + contract: aaveTokenV3Contract({ + contractAddress: asset, + client: this.clients[appConfig.govCoreChainId], + }), + underlyingAsset: asset, + }; + }); + + return await Promise.all( + contracts.map(async (contract) => { + const userBalance = await contract.contract.read.balanceOf([ + userAddress, + ]); + const delegatee = await contract.contract.read.getDelegates([ + userAddress, + ]); + + const isPropositionPowerDelegated = + delegatee[GovernancePowerType.PROPOSITION] === userAddress || + delegatee[GovernancePowerType.PROPOSITION] === zeroAddress + ? false + : !!delegatee[GovernancePowerType.PROPOSITION]; + const isVotingPowerDelegated = + delegatee[GovernancePowerType.VOTING] === userAddress || + delegatee[GovernancePowerType.VOTING] === zeroAddress + ? false + : !!delegatee[GovernancePowerType.VOTING]; + + const getPower = (totalPower: bigint, type: GovernancePowerType) => { + let formattedUserBalance = userBalance; + if ( + type === GovernancePowerType.PROPOSITION && + isPropositionPowerDelegated + ) { + formattedUserBalance = BigInt(0); + } else if ( + type === GovernancePowerType.VOTING && + isVotingPowerDelegated + ) { + formattedUserBalance = BigInt(0); + } else if (isPropositionPowerDelegated && isVotingPowerDelegated) { + formattedUserBalance = BigInt(0); + } + + return { + userBalance: normalizeBN( + formattedUserBalance.toString(), + 18, + ).toString(), + totalPowerBasic: totalPower.toString(), + totalPower: normalizeBN(totalPower.toString(), 18).toString(), + delegatedPowerBasic: String(totalPower - formattedUserBalance), + delegatedPower: normalizeBN( + String(totalPower - formattedUserBalance), + 18, + ).toString(), + + isWithDelegatedPower: formattedUserBalance !== totalPower, + }; + }; + + const totalPowers = await contract.contract.read.getPowersCurrent([ + userAddress, + ]); + + const proposition = getPower( + totalPowers[1], + GovernancePowerType.PROPOSITION, + ); + const voting = getPower(totalPowers[0], GovernancePowerType.VOTING); + + return { + timestamp: blockNumber.timestamp, + tokenName: getTokenName(contract.underlyingAsset), + underlyingAsset: contract.underlyingAsset, + proposition, + voting, + }; + }), + ); + } + async getDelegates(underlyingAsset: Hex, delegator: Hex) { const assetContract = aaveTokenV3Contract({ contractAddress: underlyingAsset, @@ -107,6 +194,7 @@ export class DelegationService { const blockNumber = ( await this.clients[appConfig.govCoreChainId].getBlock({ blockHash }) ).number; + const contracts = underlyingAssets.map((asset) => { return { contract: aaveTokenV3Contract({ diff --git a/src/web3/store/web3Selectors.ts b/src/web3/store/web3Selectors.ts new file mode 100644 index 00000000..08221120 --- /dev/null +++ b/src/web3/store/web3Selectors.ts @@ -0,0 +1,19 @@ +import { RootState } from '../../store'; + +export const selectCurrentPowers = (store: RootState) => { + if (!!store.representative.address) { + return store.currentPowers[store.representative.address]; + } else if (store.activeWallet?.address) { + return store.currentPowers[store.activeWallet?.address]; + } else { + return; + } +}; + +export const selectCurrentPowersForActiveWallet = (store: RootState) => { + if (store.activeWallet?.address) { + return store.currentPowers[store.activeWallet?.address]; + } else { + return; + } +}; diff --git a/src/web3/store/web3Slice.ts b/src/web3/store/web3Slice.ts index 3d1f3379..49e438d5 100644 --- a/src/web3/store/web3Slice.ts +++ b/src/web3/store/web3Slice.ts @@ -1,9 +1,13 @@ +import { valueToBigNumber } from '@bgd-labs/aave-governance-ui-helpers'; import { ClientsRecord, createWalletSlice, IWalletSlice, StoreSlice, } from '@bgd-labs/frontend-web3-utils'; +import dayjs from 'dayjs'; +import { Draft, produce } from 'immer'; +import { Hex } from 'viem'; import { TransactionsSlice } from '../../transactions/store/transactionsSlice'; import { DelegationService } from '../services/delegationService'; @@ -13,6 +17,37 @@ import { GovDataService } from '../services/govDataService'; * web3Slice is required only to have a better control over providers state i.e * change provider, trigger data refetch if provider changed and have globally available instances of rpcs and data providers */ + +type PowerByAsset = { + userBalance: string; + totalPowerBasic: string; + totalPower: string; + delegatedPowerBasic: string; + delegatedPower: string; + isWithDelegatedPower: boolean; +}; + +export type PowersByAssets = Record< + Hex, + { + tokenName: string; + underlyingAsset: Hex; + proposition: PowerByAsset; + voting: PowerByAsset; + } +>; + +type CurrentPower = { + timestamp: number; + totalPropositionPower: number; + totalVotingPower: number; + yourPropositionPower: number; + yourVotingPower: number; + delegatedPropositionPower: number; + delegatedVotingPower: number; + powersByAssets: PowersByAssets; +}; + export type IWeb3Slice = IWalletSlice & { // need for connect wallet button to not show last tx status always after connected wallet walletConnectedTimeLock: boolean; @@ -22,6 +57,9 @@ export type IWeb3Slice = IWalletSlice & { connectSigner: () => void; initDataServices: (clients: ClientsRecord) => void; + + currentPowers: Record; + getCurrentPowers: (address: Hex, request?: boolean) => Promise; }; export const createWeb3Slice: StoreSlice = ( @@ -51,4 +89,93 @@ export const createWeb3Slice: StoreSlice = ( set({ govDataService: new GovDataService(clients) }); get().connectSigner(); }, + + currentPowers: {}, + getCurrentPowers: async (address, request) => { + const now = dayjs().unix(); + const activeAddress = get().activeWallet?.address; + + const requestAndSetData = async () => { + const votingStrategy = + await get().govDataService.getVotingStrategyContract(); + const underlyingAssets = + (await votingStrategy.read.getVotingAssetList()) as Draft; + + const powers = await get().delegationService.getUserPowers( + address, + underlyingAssets, + ); + + const powersByAssets: PowersByAssets = {}; + powers.forEach((asset) => { + powersByAssets[asset.underlyingAsset] = { + tokenName: asset.tokenName, + underlyingAsset: asset.underlyingAsset, + proposition: asset.proposition, + voting: asset.voting, + }; + }); + + const totalPropositionPower = powers + .map((power) => + valueToBigNumber(power.proposition.totalPower).toNumber(), + ) + .reduce((sum, value) => sum + value, 0); + const totalVotingPower = powers + .map((power) => valueToBigNumber(power.voting.totalPower).toNumber()) + .reduce((sum, value) => sum + value, 0); + + const yourPropositionPower = powers + .map((power) => + valueToBigNumber(power.proposition.userBalance).toNumber(), + ) + .reduce((sum, value) => sum + value, 0); + const yourVotingPower = powers + .map((power) => valueToBigNumber(power.voting.userBalance).toNumber()) + .reduce((sum, value) => sum + value, 0); + + const delegatedPropositionPower = powers + .map((power) => + valueToBigNumber(power.proposition.delegatedPower).toNumber(), + ) + .reduce((sum, value) => sum + value, 0); + const delegatedVotingPower = powers + .map((power) => + valueToBigNumber(power.voting.delegatedPower).toNumber(), + ) + .reduce((sum, value) => sum + value, 0); + + set((state) => + produce(state, (draft) => { + draft.currentPowers[address] = { + timestamp: Number(powers[0].timestamp), + totalPropositionPower, + totalVotingPower, + yourPropositionPower, + yourVotingPower, + delegatedPropositionPower, + delegatedVotingPower, + powersByAssets, + }; + }), + ); + }; + + if (!!get().delegationService && !!get().govDataService) { + if (!!activeAddress && !!get().currentPowers[activeAddress]) { + if (!!get().currentPowers[address]) { + if ( + get().currentPowers[address].timestamp + 3600000 < now || + request + ) { + await requestAndSetData(); + } + } else { + await requestAndSetData(); + } + } else { + await requestAndSetData(); + } + } + }, });