diff --git a/packages/ui/src/components/CallInfo.tsx b/packages/ui/src/components/CallInfo.tsx index 788b5fe9..eb3c8aa0 100644 --- a/packages/ui/src/components/CallInfo.tsx +++ b/packages/ui/src/components/CallInfo.tsx @@ -1,6 +1,5 @@ import Expander from './Expander' import { styled } from '@mui/material/styles' -import { AggregatedData } from './Transactions/TransactionList' import { AnyJson } from '@polkadot/types/types' import { ReactNode, useMemo } from 'react' import { useApi } from '../contexts/ApiContext' @@ -12,11 +11,11 @@ import { Link } from './library' import { usePjsLinks } from '../hooks/usePjsLinks' import { Alert } from '@mui/material' import { ApiPromise } from '@polkadot/api' -import { isTypeBalance } from '../utils/isTypeBalance' -import { isTypeAccount } from '../utils/isTypeAccount' +import { isTypeBalance, isTypeAccount } from '../utils' +import { CallDataInfoFromChain } from '../hooks/usePendingTx' interface Props { - aggregatedData: Omit + aggregatedData: Omit expanded?: boolean children?: ReactNode className?: string diff --git a/packages/ui/src/components/Transactions/Transaction.tsx b/packages/ui/src/components/Transactions/Transaction.tsx index 61bc6e77..e6f16f8a 100644 --- a/packages/ui/src/components/Transactions/Transaction.tsx +++ b/packages/ui/src/components/Transactions/Transaction.tsx @@ -4,16 +4,16 @@ import { styled } from '@mui/material/styles' import CallInfo from '../CallInfo' import { MdOutlineGesture as GestureIcon } from 'react-icons/md' import { HiOutlineQuestionMarkCircle as QuestionMarkIcon } from 'react-icons/hi2' -import { AggregatedData } from './TransactionList' import { useCallback, useMemo } from 'react' import { isProxyCall } from '../../utils' import { AccountBadge } from '../../types' import TransactionProgress from './TransactionProgress' import { useModals } from '../../contexts/ModalsContext' +import { CallDataInfoFromChain } from '../../hooks/usePendingTx' interface Props { className?: string - aggregatedData: AggregatedData + aggregatedData: CallDataInfoFromChain isProposer: boolean possibleSigners: string[] multisigSignatories: string[] diff --git a/packages/ui/src/components/Transactions/TransactionList.tsx b/packages/ui/src/components/Transactions/TransactionList.tsx index e5f412e2..f39b2feb 100644 --- a/packages/ui/src/components/Transactions/TransactionList.tsx +++ b/packages/ui/src/components/Transactions/TransactionList.tsx @@ -1,195 +1,21 @@ import { Box, CircularProgress, Paper } from '@mui/material' -import { useEffect, useState } from 'react' import { styled } from '@mui/material/styles' -import { PendingTx, usePendingTx } from '../../hooks/usePendingTx' +import { usePendingTx } from '../../hooks/usePendingTx' import { useMultiProxy } from '../../contexts/MultiProxyContext' -import { ApiPromise } from '@polkadot/api' -import { useApi } from '../../contexts/ApiContext' -import { getDifference, getDisplayArgs, getIntersection, isProxyCall } from '../../utils' +import { getDifference, getIntersection } from '../../utils' import { useAccounts } from '../../contexts/AccountsContext' -import { ISanitizedCall, parseGenericCall } from '../../utils' -import { GenericCall } from '@polkadot/types' -import { AnyJson, AnyTuple } from '@polkadot/types/types' import { MdOutlineFlare as FlareIcon } from 'react-icons/md' import Transaction from './Transaction' -import { HexString } from '../../types' -import dayjs from 'dayjs' -import localizedFormat from 'dayjs/plugin/localizedFormat' -dayjs.extend(localizedFormat) - -export interface AggregatedData { - callData?: HexString - hash?: string - name?: string - args?: AnyJson - info?: PendingTx['info'] - from: string - timestamp: Date | undefined -} - -type AggGroupedByDate = { [index: string]: AggregatedData[] } - -const sortByLatest = (a: AggregatedData, b: AggregatedData) => { - if (!a.timestamp || !b.timestamp) return 0 - - return b.timestamp.valueOf() - a.timestamp.valueOf() -} interface Props { className?: string } -export const getMultisigInfo = (c: ISanitizedCall): Partial[] => { - const result: Partial[] = [] - - const getCallResult = (c: ISanitizedCall) => { - if (typeof c.method !== 'string' && c.method.pallet === 'multisig') { - if (c.method.method === 'asMulti' && typeof c.args.call?.method !== 'string') { - result.push({ - name: `${c.args.call?.method?.pallet}.${c.args.call?.method.method}`, - hash: c.args.call?.hash, - callData: c.args.callData as AggregatedData['callData'] - }) - } else { - result.push({ - name: 'Unknown call', - hash: (c.args?.call_hash as Uint8Array).toString() || undefined, - callData: undefined - }) - } - // this is not a multisig call - // try to dig deeper - } else { - if (c.args.calls) { - for (const call of c.args.calls) { - getCallResult(call) - } - } else if (c.args.call) { - getCallResult(c.args.call) - } - } - } - - getCallResult(c) - return result -} - -const getAgregatedDataPromise = (pendingTxData: PendingTx[], api: ApiPromise) => - pendingTxData.map(async (pendingTx) => { - const blockHash = await api.rpc.chain.getBlockHash(pendingTx.info.when.height) - const signedBlock = await api.rpc.chain.getBlock(blockHash) - - let date: Date | undefined - - // get the timestamp which is an unsigned extrinsic set by the validator in each block - // the information for each of the contained extrinsics - signedBlock.block.extrinsics.some(({ method: { args, method, section } }) => { - // check for timestamp.set - if (section === 'timestamp' && method === 'set') { - // extract the Option as Moment - const moment = args.toString() - - // convert to date (unix ms since epoch in Moment - exactly as per the Rust code) - date = new Date(Number(moment)) - return true - } - - return false - }) - - const ext = signedBlock.block.extrinsics[pendingTx.info.when.index] - - const decoded = parseGenericCall(ext.method as GenericCall, ext.registry) - // console.log('pendingTxData', pendingTxData) - // console.log('decoded', decoded) - const multisigInfos = getMultisigInfo(decoded) || {} - - const info = multisigInfos.find(({ name, hash, callData }) => { - if (!!hash && hash === pendingTx.hash) { - return { name, hash, callData } - } - - return false - }) - - if (!info) { - console.log('oops we didnot find the right extrinsic', multisigInfos, pendingTx.hash) - return - } - - const { name, hash, callData } = info - - let call: false | GenericCall = false - try { - call = !!callData && !!hash && ext.registry.createType('Call', callData) - } catch (error) { - console.error('Error in TransactionList') - console.error(error) - } - - return { - callData, - hash: hash || pendingTx.hash, - name, - args: getDisplayArgs(call), - info: pendingTx.info, - from: pendingTx.from, - timestamp: date - } as AggregatedData - }) - const TransactionList = ({ className }: Props) => { - const [aggregatedData, setAggregatedData] = useState({}) - const { selectedMultiProxy, getMultisigByAddress } = useMultiProxy() - const { - data: pendingTxData, - isLoading: isLoadingPendingTxs, - refresh - } = usePendingTx(selectedMultiProxy) - const { api } = useApi() + const { getMultisigByAddress } = useMultiProxy() + const { txWithCallDataByDate, isLoading: isLoadingPendingTxs, refresh } = usePendingTx() const { ownAddressList } = useAccounts() - useEffect(() => { - if (!api) { - return - } - - if (!pendingTxData || !pendingTxData.length) { - setAggregatedData({}) - return - } - - const agregatedDataPromise = getAgregatedDataPromise(pendingTxData, api) - - Promise.all(agregatedDataPromise) - .then((res) => { - const definedTxs = res.filter((agg) => agg !== undefined) as AggregatedData[] - const timestampObj: AggGroupedByDate = {} - - // remove the proxy transaction that aren't for the selected proxy - const relevantTxs = definedTxs.filter((agg) => { - if (!isProxyCall(agg.name) || !agg?.args || !(agg.args as any).real.Id) { - return true - } - - return (agg.args as any).real.Id === selectedMultiProxy?.proxy - }) - - // sort by date, the newest first - const sorted = relevantTxs.sort(sortByLatest) - - // populate the object - sorted.forEach((data) => { - const date = dayjs(data.timestamp).format('LL') - const previousData = timestampObj[date] || [] - timestampObj[date] = [...previousData, data] - }) - - setAggregatedData(timestampObj) - }) - .catch(console.error) - }, [aggregatedData.args, api, pendingTxData, selectedMultiProxy]) - return ( {isLoadingPendingTxs && ( @@ -197,7 +23,7 @@ const TransactionList = ({ className }: Props) => { )} - {Object.entries(aggregatedData).length === 0 && !isLoadingPendingTxs && ( + {Object.entries(txWithCallDataByDate).length === 0 && !isLoadingPendingTxs && ( {
You're all set!
)} - {Object.entries(aggregatedData).length !== 0 && - Object.entries(aggregatedData).map(([date, aggregatedData]) => { + {Object.entries(txWithCallDataByDate).length !== 0 && + Object.entries(txWithCallDataByDate).map(([date, aggregatedData]) => { return ( {date} @@ -215,7 +41,11 @@ const TransactionList = ({ className }: Props) => { const { callData, info, from } = agg const multisig = getMultisigByAddress(from) - if (!info || !multisig?.threshold) return null + // if the "from" is not a multisig from the + // currently selected multiProxy or we have no info + if (!info || !multisig?.threshold) { + return null + } const multisigSignatories = multisig?.signatories || [] // if the threshold is met, but the transaction is still not executed diff --git a/packages/ui/src/components/modals/ProposalSigning.tsx b/packages/ui/src/components/modals/ProposalSigning.tsx index bc0a485f..b8fb8e91 100644 --- a/packages/ui/src/components/modals/ProposalSigning.tsx +++ b/packages/ui/src/components/modals/ProposalSigning.tsx @@ -6,7 +6,6 @@ import { useAccounts } from '../../contexts/AccountsContext' import { useApi } from '../../contexts/ApiContext' import { useMultiProxy } from '../../contexts/MultiProxyContext' import CallInfo from '../CallInfo' -import { AggregatedData } from '../Transactions/TransactionList' import SignerSelection from '../select/SignerSelection' import { SubmittableExtrinsic } from '@polkadot/api/types' import { useToasts } from '../../contexts/ToastContext' @@ -19,12 +18,13 @@ import { ModalCloseButton } from '../library/ModalCloseButton' import { useGetSortAddress } from '../../hooks/useGetSortAddress' import { useCheckBalance } from '../../hooks/useCheckBalance' import BN from 'bn.js' +import { CallDataInfoFromChain } from '../../hooks/usePendingTx' export interface SigningModalProps { onClose: () => void className?: string possibleSigners: string[] - proposalData: AggregatedData + proposalData: CallDataInfoFromChain onSuccess?: () => void } diff --git a/packages/ui/src/hooks/usePendingTx.tsx b/packages/ui/src/hooks/usePendingTx.tsx index 54185fca..04a1cdf4 100644 --- a/packages/ui/src/hooks/usePendingTx.tsx +++ b/packages/ui/src/hooks/usePendingTx.tsx @@ -1,39 +1,169 @@ import { useCallback, useEffect, useMemo, useState } from 'react' -import { useApi } from '../contexts/ApiContext' -import { MultiProxy } from '../contexts/MultiProxyContext' -import { MultisigStorageInfo } from '../types' +import { useMultiProxy } from '../contexts/MultiProxyContext' +import { HexString, MultisigStorageInfo } from '../types' import { useMultisigCallSubscription } from './useMultisigCallsSubscription' import { isEmptyArray } from '../utils' import { useAccountId } from './useAccountId' +import { ApiPromise } from '@polkadot/api' +import { useApi } from '../contexts/ApiContext' +import { ISanitizedCall, getDisplayArgs, isProxyCall, parseGenericCall } from '../utils' +import { GenericCall } from '@polkadot/types' +import { AnyJson, AnyTuple } from '@polkadot/types/types' +import dayjs from 'dayjs' +import localizedFormat from 'dayjs/plugin/localizedFormat' +dayjs.extend(localizedFormat) export interface PendingTx { from: string hash: string info: MultisigStorageInfo } -export const usePendingTx = (multiProxy?: MultiProxy) => { + +export interface CallDataInfoFromChain { + callData?: HexString + hash?: string + name?: string + args?: AnyJson + info?: PendingTx['info'] + from: string + timestamp?: Date + multiProxyAddress?: string +} + +type AggGroupedByDate = { [index: string]: CallDataInfoFromChain[] } + +export const getMultisigInfo = (call: ISanitizedCall): Partial[] => { + const result: Partial[] = [] + + const getCallResult = ({ args, method }: ISanitizedCall) => { + if (typeof method !== 'string' && method.pallet === 'multisig') { + if (method.method === 'asMulti' && typeof args.call?.method !== 'string') { + result.push({ + name: `${args.call?.method?.pallet}.${args.call?.method.method}`, + hash: args.call?.hash, + callData: args.callData as CallDataInfoFromChain['callData'] + }) + } else { + result.push({ + name: 'Unknown call', + hash: (args?.call_hash as Uint8Array).toString() || undefined, + callData: undefined + }) + } + // this is not a multisig call + // try to dig deeper + } else { + if (args.calls) { + for (const call of args.calls) { + getCallResult(call) + } + } else if (args.call) { + getCallResult(args.call) + } + } + } + + getCallResult(call) + return result +} + +const getCallDataFromChainPromise = (pendingTxData: PendingTx[], api: ApiPromise) => + pendingTxData.map(async (pendingTx) => { + const blockHash = await api.rpc.chain.getBlockHash(pendingTx.info.when.height) + const signedBlock = await api.rpc.chain.getBlock(blockHash) + + let date: Date | undefined + + // get the timestamp which is an unsigned extrinsic set by the validator in each block + // the information for each of the contained extrinsics + signedBlock.block.extrinsics.some(({ method: { args, method, section } }) => { + // check for timestamp.set + if (section === 'timestamp' && method === 'set') { + // extract the Option as Moment + const moment = args.toString() + + // convert to date (unix ms since epoch in Moment - exactly as per the Rust code) + date = new Date(Number(moment)) + return true + } + + return false + }) + + const ext = signedBlock.block.extrinsics[pendingTx.info.when.index] + + const decoded = parseGenericCall(ext.method as GenericCall, ext.registry) + // console.log('pendingTxData', pendingTxData) + // console.log('decoded', decoded) + const multisigInfos = getMultisigInfo(decoded) || {} + + const info = multisigInfos.find(({ name, hash, callData }) => { + if (!!hash && hash === pendingTx.hash) { + return { name, hash, callData } + } + + return false + }) + + if (!info) { + console.log('oops we did not find the right extrinsic', multisigInfos, pendingTx.hash) + return + } + + const { name, hash, callData } = info + + let call: false | GenericCall = false + try { + call = !!callData && !!hash && ext.registry.createType('Call', callData) + } catch (error) { + console.error('Error in getCallDataFromChainPromise', error) + } + + return { + callData, + hash: hash || pendingTx.hash, + name, + args: getDisplayArgs(call), + info: pendingTx.info, + from: pendingTx.from, + timestamp: date + } as CallDataInfoFromChain + }) + +const sortByLatest = (a: CallDataInfoFromChain, b: CallDataInfoFromChain) => { + if (!a.timestamp || !b.timestamp) return 0 + + return b.timestamp.valueOf() - a.timestamp.valueOf() +} + +export const usePendingTx = () => { const [isLoading, setIsLoading] = useState(true) const { api, chainInfo } = useApi() - const [data, setData] = useState([]) - const multisigs = useMemo( - () => multiProxy?.multisigs.map(({ address }) => address) || [], - [multiProxy] + const [txWithCallDataByDate, setTxWithCallDataByDate] = useState({}) + const { selectedMultiProxy } = useMultiProxy() + const multisigAddresses = useMemo( + () => selectedMultiProxy?.multisigs.map(({ address }) => address) || [], + [selectedMultiProxy?.multisigs] ) - const refresh = useCallback(() => { + const refresh = useCallback(async () => { + setTxWithCallDataByDate({}) + if (!api) return - if (isEmptyArray(multisigs)) return + if (isEmptyArray(multisigAddresses)) return if (!api?.query?.multisig?.multisigs) return setIsLoading(true) - const newData: typeof data = [] + const pendingMultisigTxs: PendingTx[] = [] - const callsPromises = multisigs.map((address) => api.query.multisig.multisigs.entries(address)) + const callsPromises = multisigAddresses.map((address) => + api.query.multisig.multisigs.entries(address) + ) - Promise.all(callsPromises) + await Promise.all(callsPromises) .then((res1) => { res1.forEach((res, index) => { res.forEach((storage) => { @@ -47,38 +177,73 @@ export const usePendingTx = (multiProxy?: MultiProxy) => { // Fix for ghost proposals for https://github.com/polkadot-js/apps/issues/9103 // These 2 should be the same - if (multisigFromChain.toLowerCase() !== multisigs[index].toLowerCase()) { + if (multisigFromChain.toLowerCase() !== multisigAddresses[index].toLowerCase()) { console.error( 'The onchain call and the one found in the block donot correspond', multisigFromChain, - multisigs[index] + multisigAddresses[index] ) return } - newData.push({ + pendingMultisigTxs.push({ hash, info, - from: multisigs[index] + from: multisigAddresses[index] }) }) }) }) + .catch(console.error) + + if (pendingMultisigTxs.length === 0) { + setTxWithCallDataByDate({}) + setIsLoading(false) + return + } + + const callDataInfoFromChainPromises = getCallDataFromChainPromise(pendingMultisigTxs, api) + + await Promise.all(callDataInfoFromChainPromises) + .then((res) => { + const definedTxs = res.filter((agg) => agg !== undefined) as CallDataInfoFromChain[] + const timestampObj: AggGroupedByDate = {} + + // remove the proxy transaction that aren't for the selected proxy + const relevantTxs = definedTxs.filter((agg) => { + if (!isProxyCall(agg.name) || !agg?.args || !(agg.args as any).real.Id) { + return true + } + + return (agg.args as any).real.Id === selectedMultiProxy?.proxy + }) + + // sort by date, the newest first + const sorted = relevantTxs.sort(sortByLatest) + + // populate the object and sort by data + sorted.forEach((data) => { + const date = dayjs(data.timestamp).format('LL') + const previousData = timestampObj[date] || [] + timestampObj[date] = [...previousData, data] + }) + + setTxWithCallDataByDate(timestampObj) + }) .finally(() => { - setData(newData) setIsLoading(false) }) .catch(console.error) - }, [api, chainInfo, multisigs]) + }, [api, chainInfo?.isEthereum, multisigAddresses, selectedMultiProxy?.proxy]) useEffect(() => { refresh() }, [refresh]) - const multisigIds = useAccountId(multisigs) + const multisigIds = useAccountId(multisigAddresses) // re-fetch the on-chain if some new event appeared for any of the // multisig we are watching useMultisigCallSubscription({ onUpdate: refresh, multisigIds }) - return { isLoading, data, refresh } + return { isLoading, txWithCallDataByDate, refresh } } diff --git a/packages/ui/src/utils/index.ts b/packages/ui/src/utils/index.ts index cc5e4969..38476e04 100644 --- a/packages/ui/src/utils/index.ts +++ b/packages/ui/src/utils/index.ts @@ -1,6 +1,8 @@ export * from './isValidAddress' export * from './arrayUtils' export * from './isProxyCall' +export * from './isTypeBalance' +export * from './isTypeAccount' export * from './decode' export * from './getDisplayAddress' export * from './bnUtils'