diff --git a/packages/neuron-ui/src/components/DepositDialog/hooks.ts b/packages/neuron-ui/src/components/DepositDialog/hooks.ts index 56117708e0..82b93000aa 100644 --- a/packages/neuron-ui/src/components/DepositDialog/hooks.ts +++ b/packages/neuron-ui/src/components/DepositDialog/hooks.ts @@ -3,8 +3,11 @@ import { TFunction } from 'i18next' import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { + MultisigConfig, generateDaoDepositAllTx as generateDaoDepositAllTxAPI, generateDaoDepositTx as generateDaoDepositTxAPI, + generateMultisigDaoDepositTx as generateMultisigDaoDepositTxAPI, + generateMultisigDaoDepositAllTx as generateMultisigDaoDepositAllTxAPI, } from 'services/remote' import { AppActions, useDispatch } from 'states' import { @@ -17,6 +20,7 @@ import { useClearGeneratedTx, validateAmount, } from 'utils' +import getMultisigSignStatus from 'utils/getMultisigSignStatus' import { MAX_DECIMAL_DIGITS, MIN_DEPOSIT_AMOUNT, SHANNON_CKB_RATIO } from 'utils/const' const PERCENT_100 = 100 @@ -45,17 +49,26 @@ function generateDaoDepositTx({ capacity, suggestFeeRate, t, + multisigConfig, }: { walletID: string capacity: string suggestFeeRate: number t: TFunction + multisigConfig?: MultisigConfig }): Promise { - return generateDaoDepositTxAPI({ - feeRate: `${suggestFeeRate}`, - capacity, - walletID, - }).then(res => { + const generateCall = multisigConfig + ? generateMultisigDaoDepositTxAPI({ + feeRate: `${suggestFeeRate}`, + capacity, + multisigConfig, + }) + : generateDaoDepositTxAPI({ + walletID, + feeRate: `${suggestFeeRate}`, + capacity, + }) + return generateCall.then(res => { if (isSuccessResponse(res)) { return res.result } @@ -73,16 +86,25 @@ function generateDaoDepositAllTx({ suggestFeeRate, isBalanceReserved, walletID, + multisigConfig, }: { suggestFeeRate: number isBalanceReserved: boolean walletID: string + multisigConfig?: MultisigConfig }): Promise { - return generateDaoDepositAllTxAPI({ - walletID, - feeRate: `${suggestFeeRate}`, - isBalanceReserved, - }).then(res => { + const generateAllCall = multisigConfig + ? generateMultisigDaoDepositAllTxAPI({ + feeRate: `${suggestFeeRate}`, + isBalanceReserved, + multisigConfig, + }) + : generateDaoDepositAllTxAPI({ + walletID, + feeRate: `${suggestFeeRate}`, + isBalanceReserved, + }) + return generateAllCall.then(res => { if (isSuccessResponse(res)) { return res.result } @@ -97,6 +119,7 @@ export const useGenerateDaoDepositTx = ({ suggestFeeRate, showDepositDialog, slidePercent, + multisigConfig, }: { walletID: string isBalanceReserved: boolean @@ -104,6 +127,7 @@ export const useGenerateDaoDepositTx = ({ suggestFeeRate: number showDepositDialog: boolean slidePercent: number + multisigConfig?: MultisigConfig }) => { const timer = useRef>() const [errorMessage, setErrorMessage] = useState('') @@ -127,8 +151,14 @@ export const useGenerateDaoDepositTx = ({ } const generateDaoDepositResult: Promise = isDepositAll - ? generateDaoDepositAllTx({ walletID, isBalanceReserved, suggestFeeRate }) - : generateDaoDepositTx({ walletID, capacity: CKBToShannonFormatter(depositValue), suggestFeeRate, t }) + ? generateDaoDepositAllTx({ walletID, isBalanceReserved, suggestFeeRate, multisigConfig }) + : generateDaoDepositTx({ + walletID, + capacity: CKBToShannonFormatter(depositValue), + suggestFeeRate, + t, + multisigConfig, + }) generateDaoDepositResult .then(res => { dispatch({ @@ -239,22 +269,38 @@ export const useBalanceReserved = () => { export const useOnDepositDialogSubmit = ({ onDepositSuccess, - walletID, + wallet, + multisigConfig, }: { onDepositSuccess: () => void - walletID: string + wallet: State.Wallet + multisigConfig?: MultisigConfig }) => { const dispatch = useDispatch() return useCallback(() => { - dispatch({ - type: AppActions.RequestPassword, - payload: { - walletID, - actionType: 'send', - onSuccess: onDepositSuccess, - }, - }) - }, [dispatch, walletID, onDepositSuccess]) + if (multisigConfig) { + const { canBroadcastAfterSign } = getMultisigSignStatus({ multisigConfig, addresses: wallet.addresses }) + dispatch({ + type: AppActions.RequestPassword, + payload: { + walletID: wallet.id, + actionType: canBroadcastAfterSign ? 'send-from-multisig-need-one' : 'send-from-multisig', + multisigConfig, + onSuccess: onDepositSuccess, + title: 'password-request.verify-password', + }, + }) + } else { + dispatch({ + type: AppActions.RequestPassword, + payload: { + walletID: wallet.id, + actionType: 'send', + onSuccess: onDepositSuccess, + }, + }) + } + }, [dispatch, wallet.id, onDepositSuccess, multisigConfig]) } export const useOnDepositDialogCancel = ({ diff --git a/packages/neuron-ui/src/components/DepositDialog/index.tsx b/packages/neuron-ui/src/components/DepositDialog/index.tsx index 057fa087b6..7c08111d68 100644 --- a/packages/neuron-ui/src/components/DepositDialog/index.tsx +++ b/packages/neuron-ui/src/components/DepositDialog/index.tsx @@ -1,10 +1,11 @@ -import React, { useCallback } from 'react' +import React, { useCallback, useMemo } from 'react' import { Slider } from 'office-ui-fabric-react' import { Trans, useTranslation } from 'react-i18next' import TextField from 'widgets/TextField' import Spinner, { SpinnerSize } from 'widgets/Spinner' -import { openExternal } from 'services/remote' +import { openExternal, MultisigConfig } from 'services/remote' import { localNumberFormatter, shannonToCKBFormatter } from 'utils' +import getMultisigSignStatus from 'utils/getMultisigSignStatus' import { Attention, Success } from 'widgets/Icons/icon' import Dialog from 'widgets/Dialog' import Tooltip from 'widgets/Tooltip' @@ -30,9 +31,10 @@ interface DepositDialogProps { isDepositing: boolean isTxGenerated: boolean suggestFeeRate: number - walletID: string + wallet: State.Wallet globalAPC: number onDepositSuccess: () => void + multisigConfig?: MultisigConfig } const RfcLink = React.memo(() => ( @@ -50,7 +52,7 @@ const RfcLink = React.memo(() => ( )) const DepositDialog = ({ - walletID, + wallet, balance, show, fee, @@ -60,6 +62,7 @@ const DepositDialog = ({ suggestFeeRate, globalAPC, onDepositSuccess, + multisigConfig, }: DepositDialogProps) => { const [t, { language }] = useTranslation() const disabled = !isTxGenerated @@ -69,14 +72,22 @@ const DepositDialog = ({ show ) const { errorMessage, maxDepositValue } = useGenerateDaoDepositTx({ - walletID, + walletID: wallet.id, isBalanceReserved, depositValue, suggestFeeRate, showDepositDialog: show, slidePercent, + multisigConfig, }) - const onConfirm = useOnDepositDialogSubmit({ onDepositSuccess, walletID }) + + const canSign = useMemo(() => { + if (!multisigConfig) return true + const multisigSignStatus = getMultisigSignStatus({ multisigConfig, addresses: wallet.addresses }) + return multisigSignStatus.canSign + }, [multisigConfig, wallet.addresses]) + + const onConfirm = useOnDepositDialogSubmit({ onDepositSuccess, wallet, multisigConfig }) const onCancel = useOnDepositDialogCancel({ onCloseDepositDialog, resetDepositValue, setIsBalanceReserved }) const onSubmit = useCallback( (e: React.FormEvent) => { @@ -105,7 +116,7 @@ const DepositDialog = ({ onCancel={onCancel} onConfirm={onConfirm} cancelText={t('nervos-dao.cancel')} - confirmText={t('nervos-dao.proceed')} + confirmText={canSign ? t('nervos-dao.proceed') : t('nervos-dao-detail.export')} className={styles.container} > {isDepositing ? ( diff --git a/packages/neuron-ui/src/components/MultisigAddress/hooks.ts b/packages/neuron-ui/src/components/MultisigAddress/hooks.ts index 9c3b55bca4..55dcb2b2d6 100644 --- a/packages/neuron-ui/src/components/MultisigAddress/hooks.ts +++ b/packages/neuron-ui/src/components/MultisigAddress/hooks.ts @@ -298,12 +298,58 @@ const useApproveAction = () => { } } +const useDaoDepositAction = () => { + const [isDialogOpen, setIsDialogOpen] = useState(false) + const [depositFromMultisig, setDepositFromMultisig] = useState() + const onOpenDialog = useCallback( + (option: MultisigConfig) => { + setIsDialogOpen(true) + setDepositFromMultisig(option) + }, + [setIsDialogOpen, setDepositFromMultisig] + ) + const closeDialog = useCallback(() => { + setIsDialogOpen(false) + }, [setIsDialogOpen]) + + return { + action: onOpenDialog, + closeDialog, + depositFromMultisig, + isDialogOpen, + } +} + +const useDaoWithdrawAction = () => { + const [isDialogOpen, setIsDialogOpen] = useState(false) + const [withdrawFromMultisig, setWithdrawFromMultisig] = useState() + const onOpenDialog = useCallback( + (option: MultisigConfig) => { + setIsDialogOpen(true) + setWithdrawFromMultisig(option) + }, + [setIsDialogOpen, setWithdrawFromMultisig] + ) + const closeDialog = useCallback(() => { + setIsDialogOpen(false) + }, [setIsDialogOpen]) + + return { + action: onOpenDialog, + closeDialog, + withdrawFromMultisig, + isDialogOpen, + } +} + export const useActions = ({ deleteConfigById }: { deleteConfigById: (id: number) => void }) => { return { deleteAction: useDeleteAction(deleteConfigById), infoAction: useInfoAction(), sendAction: useSendAction(), approveAction: useApproveAction(), + daoDepositAction: useDaoDepositAction(), + daoWithdrawAction: useDaoWithdrawAction(), } } diff --git a/packages/neuron-ui/src/components/MultisigAddress/index.tsx b/packages/neuron-ui/src/components/MultisigAddress/index.tsx index 50b0c5a485..a9aec87a6e 100644 --- a/packages/neuron-ui/src/components/MultisigAddress/index.tsx +++ b/packages/neuron-ui/src/components/MultisigAddress/index.tsx @@ -6,16 +6,22 @@ import { useExitOnWalletChange, useGoBack, useOnWindowResize, + calculateFee, + useClearGeneratedTx, } from 'utils' -import { useState as useGlobalState } from 'states' +import appState from 'states/init/app' +import { useState as useGlobalState, useDispatch } from 'states' import MultisigAddressCreateDialog from 'components/MultisigAddressCreateDialog' import MultisigAddressInfo from 'components/MultisigAddressInfo' import SendFromMultisigDialog from 'components/SendFromMultisigDialog' import { MultisigConfig, changeMultisigSyncStatus, openExternal } from 'services/remote' import ApproveMultisigTxDialog from 'components/ApproveMultisigTxDialog' +import DepositDialog from 'components/DepositDialog' +import MultisigAddressNervosDAODialog from 'components/MultisigAddressNervosDAODialog' import Dialog from 'widgets/Dialog' import Table from 'widgets/Table' import Tooltip from 'widgets/Tooltip' +import Toast from 'widgets/Toast' import AlertDialog from 'widgets/AlertDialog' import { Download, @@ -30,14 +36,18 @@ import { Confirming, Question, LineDownArrow, + DAODeposit, + DAOWithdrawal, } from 'widgets/Icons/icon' import AttentionCloseDialog from 'widgets/Icons/Attention.png' import { HIDE_BALANCE, NetworkType } from 'utils/const' import { onEnter } from 'utils/inputDevice' import getMultisigSignStatus from 'utils/getMultisigSignStatus' +import useGetCountDownAndFeeRateStats from 'utils/hooks/useGetCountDownAndFeeRateStats' import Button from 'widgets/Button' import SetStartBlockNumberDialog from 'components/SetStartBlockNumberDialog' import { type TFunction } from 'i18next' +import hooks from 'components/NervosDAO/hooks' import { useSearch, useConfigManage, @@ -68,6 +78,14 @@ const tableActions = [ key: ApproveKey, icon: , }, + { + key: 'daoDeposit', + icon: , + }, + { + key: 'daoWithdraw', + icon: , + }, ] const LearnMore = React.memo(({ t }: { t: TFunction }) => ( @@ -87,14 +105,19 @@ const MultisigAddress = () => { const [t] = useTranslation() useExitOnWalletChange() const { - wallet: { id: walletId, addresses }, + app: { + send = appState.send, + loadings: { sending = false }, + }, + wallet, chain: { - syncState: { bestKnownBlockNumber }, + syncState: { bestKnownBlockNumber, bestKnownBlockTimestamp }, networkID, connectionStatus, }, settings: { networks = [] }, } = useGlobalState() + const { id: walletId, addresses } = wallet const isMainnet = isMainnetUtil(networks, networkID) const isLightClient = useMemo( () => networks.find(n => n.id === networkID)?.type === NetworkType.Light, @@ -120,8 +143,16 @@ const MultisigAddress = () => { configs: allConfigs, isLightClient, }) - const { deleteAction, infoAction, sendAction, approveAction } = useActions({ deleteConfigById }) + const { deleteAction, infoAction, sendAction, approveAction, daoDepositAction, daoWithdrawAction } = useActions({ + deleteConfigById, + }) const [showDeleteDialog, setShowDeleteDialog] = useState(false) + const { suggestFeeRate } = useGetCountDownAndFeeRateStats() + const clearGeneratedTx = useClearGeneratedTx() + const dispatch = useDispatch() + const [globalAPC, setGlobalAPC] = useState(0) + const [genesisBlockTimestamp, setGenesisBlockTimestamp] = useState(undefined) + const [notice, setNotice] = useState('') const onClickItem = useCallback( (multisigConfig: MultisigConfig) => (e: React.SyntheticEvent) => { @@ -142,6 +173,12 @@ const MultisigAddress = () => { case 'approve': approveAction.action(multisigConfig) break + case 'daoDeposit': + daoDepositAction.action(multisigConfig) + break + case 'daoWithdraw': + daoWithdrawAction.action(multisigConfig) + break default: break } @@ -219,6 +256,28 @@ const MultisigAddress = () => { }, [updateTipPosition]) useOnWindowResize(updateTipPosition) + const genesisBlockHash = useMemo(() => networks.find(v => v.id === networkID)?.genesisHash, [networkID, networks]) + hooks.useInitData({ + clearGeneratedTx, + dispatch, + wallet, + setGenesisBlockTimestamp, + genesisBlockHash, + }) + hooks.useUpdateGlobalAPC({ bestKnownBlockTimestamp, genesisBlockTimestamp, setGlobalAPC }) + + const fee = `${shannonToCKBFormatter( + send.generatedTx ? send.generatedTx.fee || calculateFee(send.generatedTx) : '0' + )} CKB` + + const onDepositSuccess = useCallback(() => { + daoDepositAction.closeDialog() + setNotice(t('nervos-dao.deposit-submitted')) + if (daoDepositAction.depositFromMultisig) { + daoWithdrawAction.action(daoDepositAction.depositFromMultisig) + } + }, [t, setNotice, daoDepositAction, daoWithdrawAction]) + return (
{ noDataContent={t('multisig-address.no-data')} />
+ + setNotice('')} /> @@ -522,6 +583,29 @@ const MultisigAddress = () => { onCancel={onCancel} /> ) : null} + + {daoDepositAction.depositFromMultisig && daoDepositAction.isDialogOpen ? ( + + ) : null} + + {daoWithdrawAction.withdrawFromMultisig && daoWithdrawAction.isDialogOpen ? ( + + ) : null} ) } diff --git a/packages/neuron-ui/src/components/MultisigAddressNervosDAODialog/hooks.ts b/packages/neuron-ui/src/components/MultisigAddressNervosDAODialog/hooks.ts new file mode 100644 index 0000000000..4eeb755bdc --- /dev/null +++ b/packages/neuron-ui/src/components/MultisigAddressNervosDAODialog/hooks.ts @@ -0,0 +1,307 @@ +import { useEffect, useCallback } from 'react' +import { AppActions, StateAction } from 'states/stateProvider/reducer' +import { showGlobalAlertDialog } from 'states/stateProvider/actionCreators' + +import { type CKBComponents } from '@ckb-lumos/lumos/rpc' +import { isSuccessResponse, getExplorerUrl } from 'utils' + +import { rpc } from 'services/chain' +import { + MultisigConfig, + generateMultisigDaoWithdrawTx, + generateMultisigDaoClaimTx, + openExternal, +} from 'services/remote' +import { calculateMaximumWithdrawCompatible } from '@ckb-lumos/lumos/common-scripts/dao' + +const getRecordKey = ({ depositOutPoint, outPoint }: State.NervosDAORecord) => { + return depositOutPoint ? `${depositOutPoint.txHash}-${depositOutPoint.index}` : `${outPoint.txHash}-${outPoint.index}` +} + +export const useOnWithdrawDialogDismiss = (setActiveRecord: React.Dispatch) => + useCallback(() => { + setActiveRecord(null) + }, [setActiveRecord]) + +export const useGenerateDaoWithdrawTx = ({ + activeRecord, + setActiveRecord, + clearGeneratedTx, + walletID, + dispatch, + suggestFeeRate, + multisigConfig, +}: { + activeRecord: State.NervosDAORecord | null + setActiveRecord: React.Dispatch + clearGeneratedTx: () => void + walletID: string + dispatch: React.Dispatch + suggestFeeRate: number | string + multisigConfig: MultisigConfig +}) => + useCallback(() => { + if (activeRecord) { + generateMultisigDaoWithdrawTx({ + outPoint: activeRecord.outPoint, + feeRate: `${suggestFeeRate}`, + multisigConfig, + }) + .then(res => { + if (isSuccessResponse(res)) { + dispatch({ + type: AppActions.UpdateGeneratedTx, + payload: res.result, + }) + dispatch({ + type: AppActions.RequestPassword, + payload: { + walletID, + actionType: multisigConfig.m === 1 ? 'send-from-multisig-need-one' : 'send-from-multisig', + multisigConfig, + onSuccess: () => {}, + title: 'password-request.verify-password', + }, + }) + } else { + clearGeneratedTx() + throw new Error(`${typeof res.message === 'string' ? res.message : res.message.content}`) + } + }) + .catch((err: Error) => { + showGlobalAlertDialog({ + type: 'failed', + message: err.message, + action: 'ok', + })(dispatch) + }) + } + setActiveRecord(null) + }, [activeRecord, setActiveRecord, clearGeneratedTx, walletID, dispatch, suggestFeeRate]) + +export const useOnActionClick = ({ + records, + clearGeneratedTx, + dispatch, + walletID, + setActiveRecord, + isMainnet, + multisigConfig, + suggestFeeRate, +}: { + records: Readonly + clearGeneratedTx: () => void + dispatch: React.Dispatch + walletID: string + setActiveRecord: React.Dispatch + isMainnet: boolean + multisigConfig: MultisigConfig + suggestFeeRate: number | string +}) => + useCallback( + (e: any) => { + const { dataset } = e.target + const outPoint = { + txHash: dataset.txHash, + index: dataset.index, + } + const record = records.find(r => r.outPoint.txHash === outPoint.txHash && r.outPoint.index === outPoint.index) + if (record) { + if (record.status === 'sent') { + openExternal(`${getExplorerUrl(isMainnet)}/transaction/${record?.depositInfo?.txHash}`) + } else if (record.depositOutPoint) { + generateMultisigDaoClaimTx({ + withdrawingOutPoint: record.outPoint, + depositOutPoint: record.depositOutPoint, + feeRate: `${suggestFeeRate}`, + multisigConfig, + }) + .then(res => { + if (isSuccessResponse(res)) { + dispatch({ + type: AppActions.UpdateGeneratedTx, + payload: res.result, + }) + dispatch({ + type: AppActions.RequestPassword, + payload: { + walletID, + actionType: multisigConfig.m === 1 ? 'send-from-multisig-need-one' : 'send-from-multisig', + multisigConfig, + onSuccess: () => {}, + title: 'password-request.verify-password', + }, + }) + } else { + clearGeneratedTx() + throw new Error(`${typeof res.message === 'string' ? res.message : res.message.content}`) + } + }) + .catch((err: Error) => { + showGlobalAlertDialog({ + type: 'failed', + message: err.message, + action: 'ok', + })(dispatch) + }) + } else { + setActiveRecord(record) + } + } + }, + [records, clearGeneratedTx, dispatch, walletID, setActiveRecord] + ) + +export const useUpdateWithdrawList = ({ + records, + tipDao, + setWithdrawList, +}: { + records: Readonly + tipDao?: string + setWithdrawList: React.Dispatch>> +}) => + useEffect(() => { + if (!tipDao) { + setWithdrawList(new Map()) + return + } + const depositOutPointHashes = records.map(v => v.depositOutPoint?.txHash ?? v.outPoint.txHash) + rpc + .createBatchRequest<'getTransaction', string[], CKBComponents.TransactionWithStatus[]>( + depositOutPointHashes.map(v => ['getTransaction', v]) + ) + .exec() + .then((txs: CKBComponents.TransactionWithStatus[]) => { + const committedTx = txs.filter(v => v.txStatus.status === 'committed') + const blockHashes = [ + ...(committedTx.map(v => v.txStatus.blockHash).filter(v => !!v) as string[]), + ...(records.map(v => (v.depositOutPoint ? v.blockHash : null)).filter(v => !!v) as string[]), + ] + return rpc + .createBatchRequest<'getHeader', string[], CKBComponents.BlockHeader[]>( + blockHashes.map(v => ['getHeader', v]) + ) + .exec() + .then((blockHeaders: CKBComponents.BlockHeader[]) => { + const hashHeaderMap = new Map() + blockHeaders.forEach((header, idx) => { + hashHeaderMap.set(blockHashes[idx], header.dao) + }) + const txMap = new Map() + txs.forEach((tx, idx) => { + if (tx.txStatus.status === 'committed') { + txMap.set(depositOutPointHashes[idx], tx) + } + }) + const withdrawList = new Map() + records.forEach(record => { + const key = getRecordKey(record) + const withdrawBlockHash = record.depositOutPoint ? record.blockHash : undefined + const formattedDepositOutPoint = record.depositOutPoint + ? { + txHash: record.depositOutPoint.txHash, + index: `0x${BigInt(record.depositOutPoint.index).toString(16)}`, + } + : { + txHash: record.outPoint.txHash, + index: `0x${BigInt(record.outPoint.index).toString(16)}`, + } + const tx = txMap.get(formattedDepositOutPoint.txHash) + if (!tx) { + return + } + const depositDAO = hashHeaderMap.get(tx.txStatus.blockHash!) + const withdrawDAO = withdrawBlockHash ? hashHeaderMap.get(withdrawBlockHash) : tipDao + if (!depositDAO || !withdrawDAO) { + return + } + withdrawList.set( + key, + calculateMaximumWithdrawCompatible( + { + cellOutput: tx.transaction.outputs[+formattedDepositOutPoint.index], + data: tx.transaction.outputsData[+formattedDepositOutPoint.index], + }, + depositDAO, + withdrawDAO + ).toHexString() + ) + }) + setWithdrawList(withdrawList) + }) + }) + .catch(() => { + setWithdrawList(new Map()) + }) + }, [records, tipDao, setWithdrawList]) + +const getBlockHashes = (txHashes: string[]) => { + const batchParams: ['getTransaction', string][] = txHashes.map(v => ['getTransaction', v]) + return rpc + .createBatchRequest<'getTransaction', [string], CKBComponents.TransactionWithStatus[]>(batchParams) + .exec() + .then((res: CKBComponents.TransactionWithStatus[]) => { + return res.map((v, idx) => ({ + txHash: txHashes[idx], + blockHash: v.txStatus.blockHash, + })) + }) + .catch(() => { + return [] + }) +} + +export const useUpdateDepositEpochList = ({ + records, + setDepositEpochList, + connectionStatus, +}: { + records: Readonly + setDepositEpochList: React.Dispatch>> + connectionStatus: State.ConnectionStatus +}) => + useEffect(() => { + if (connectionStatus === 'online') { + getBlockHashes(records.map(v => v.depositOutPoint?.txHash).filter(v => !!v) as string[]).then( + (depositBlockHashes: { txHash: string; blockHash: string | undefined }[]) => { + const recordKeyIdx: string[] = [] + const batchParams: ['getHeader', string][] = [] + records.forEach(record => { + if (!record.depositOutPoint && record.blockHash) { + batchParams.push(['getHeader', record.blockHash]) + recordKeyIdx.push(record.outPoint.txHash) + } + }) + depositBlockHashes.forEach(v => { + if (v.blockHash) { + batchParams.push(['getHeader', v.blockHash]) + recordKeyIdx.push(v.txHash) + } + }) + rpc + .createBatchRequest<'getHeader', any, CKBComponents.BlockHeader[]>(batchParams) + .exec() + .then((res: CKBComponents.BlockHeader[]) => { + const epochList = new Map() + records.forEach(record => { + const key = record.depositOutPoint ? record.depositOutPoint.txHash : record.outPoint.txHash + epochList.set(key, res[recordKeyIdx.indexOf(key)]?.epoch) + }) + setDepositEpochList(epochList) + }) + .catch(() => { + setDepositEpochList(new Map()) + }) + } + ) + } + }, [records, setDepositEpochList, connectionStatus]) + +export default { + useOnWithdrawDialogDismiss, + useGenerateDaoWithdrawTx, + useOnActionClick, + useUpdateWithdrawList, + useUpdateDepositEpochList, +} diff --git a/packages/neuron-ui/src/components/MultisigAddressNervosDAODialog/index.tsx b/packages/neuron-ui/src/components/MultisigAddressNervosDAODialog/index.tsx new file mode 100644 index 0000000000..445e3e0f63 --- /dev/null +++ b/packages/neuron-ui/src/components/MultisigAddressNervosDAODialog/index.tsx @@ -0,0 +1,238 @@ +import React, { useState, useMemo, useEffect } from 'react' +import { useState as useGlobalState, useDispatch } from 'states' +import { useTranslation } from 'react-i18next' +import { MultisigConfig, getMultisigDaoData } from 'services/remote' +import { clsx, useClearGeneratedTx, isSuccessResponse, isMainnet as isMainnetUtil } from 'utils' +import Dialog from 'widgets/Dialog' +import WithdrawDialog from 'components/WithdrawDialog' +import DAORecord, { DAORecordProps } from 'components/NervosDAORecord' +import TableNoData from 'widgets/Icons/TableNoData.png' +import { getHeader } from 'services/chain' + +import useGetCountDownAndFeeRateStats from 'utils/hooks/useGetCountDownAndFeeRateStats' +import getMultisigSignStatus from 'utils/getMultisigSignStatus' +import hooks from './hooks' +import styles from './multisigAddressNervosDAODialog.module.scss' + +const MultisigAddressNervosDAODialog = ({ + multisigConfig, + closeDialog, +}: { + multisigConfig: MultisigConfig + closeDialog: () => void +}) => { + const [tabIdx, setTabIdx] = useState('0') + const { + app: { tipDao, tipBlockTimestamp, epoch }, + wallet, + chain: { connectionStatus, networkID }, + settings: { networks }, + } = useGlobalState() + const dispatch = useDispatch() + const [t] = useTranslation() + const { suggestFeeRate } = useGetCountDownAndFeeRateStats() + const [records, setRecords] = useState([]) + const [activeRecord, setActiveRecord] = useState(null) + const [withdrawList, setWithdrawList] = useState>(new Map()) + const [genesisBlockTimestamp, setGenesisBlockTimestamp] = useState(undefined) + const [depositEpochList, setDepositEpochList] = useState>(new Map()) + const clearGeneratedTx = useClearGeneratedTx() + + const { canSign } = getMultisigSignStatus({ multisigConfig, addresses: wallet.addresses }) + + const onWithdrawDialogDismiss = hooks.useOnWithdrawDialogDismiss(setActiveRecord) + + const genesisBlockHash = useMemo(() => networks.find(v => v.id === networkID)?.genesisHash, [networkID, networks]) + + const onWithdrawDialogSubmit = hooks.useGenerateDaoWithdrawTx({ + activeRecord, + setActiveRecord, + clearGeneratedTx, + walletID: wallet.id, + dispatch, + suggestFeeRate, + multisigConfig, + }) + + const isMainnet = isMainnetUtil(networks, networkID) + + const onActionClick = hooks.useOnActionClick({ + records, + clearGeneratedTx, + dispatch, + walletID: wallet.id, + setActiveRecord, + isMainnet, + multisigConfig, + suggestFeeRate, + }) + + hooks.useUpdateDepositEpochList({ records, setDepositEpochList, connectionStatus }) + + useEffect(() => { + getMultisigDaoData({ multisigConfig }).then(res => { + if (isSuccessResponse(res)) { + setRecords(res.result) + } + }) + const intervalId = setInterval(() => { + getMultisigDaoData({ multisigConfig }).then(res => { + if (isSuccessResponse(res)) { + setRecords(res.result) + } + }) + }, 10000) + if (genesisBlockHash) { + getHeader(genesisBlockHash) + .then(header => setGenesisBlockTimestamp(+header.timestamp)) + .catch(err => console.error(err)) + } + return () => { + clearInterval(intervalId) + clearGeneratedTx() + } + }, [multisigConfig]) + + hooks.useUpdateWithdrawList({ + records, + tipDao, + setWithdrawList, + }) + + const MemoizedRecords = useMemo(() => { + const onTabClick = (e: React.SyntheticEvent) => { + const { + dataset: { idx }, + } = e.target as HTMLDivElement + if (idx) { + setTabIdx(idx) + } + } + const filteredRecord = records.filter(record => { + if (record.status === 'failed') { + return false + } + + if (tabIdx === '0') { + return record.status !== 'dead' + } + return record.status === 'dead' + }) + + if (tabIdx === '0') { + filteredRecord.sort((r1, r2) => +r2.depositInfo!.timestamp! - +r1.depositInfo!.timestamp!) + } else if (tabIdx === '1') { + filteredRecord.sort((r1, r2) => +r2.unlockInfo!.timestamp! - +r1.unlockInfo!.timestamp!) + } + + return ( + <> +
+
+
+ + +
+
+ {filteredRecord.length ? ( +
+ {filteredRecord.map(record => { + const key = record.depositOutPoint + ? `${record.depositOutPoint.txHash}-${record.depositOutPoint.index}` + : `${record.outPoint.txHash}-${record.outPoint.index}` + const txHash = record.depositOutPoint ? record.depositOutPoint.txHash : record.outPoint.txHash + + const props: DAORecordProps = { + ...record, + tipBlockTimestamp, + withdrawCapacity: withdrawList.get(key) || null, + onClick: onActionClick, + depositEpoch: depositEpochList.get(txHash) || '', + currentEpoch: epoch, + genesisBlockTimestamp, + connectionStatus, + hasCkbBalance: +wallet.balance > 0, + showDetailInExplorer: true, + isMainnet, + } + return ( +
+ +
+ ) + })} +
+ ) : ( +
+ No Data + {t(`nervos-dao.deposit-record.no-${tabIdx === '0' ? 'deposit' : 'completed'}`)} +
+ )} + + ) + }, [ + records, + withdrawList, + t, + onActionClick, + epoch, + connectionStatus, + genesisBlockTimestamp, + tipBlockTimestamp, + depositEpochList, + tabIdx, + setTabIdx, + ]) + + const MemoizedWithdrawDialog = useMemo(() => { + return activeRecord ? ( + + ) : null + }, [activeRecord, onWithdrawDialogDismiss, onWithdrawDialogSubmit, tipDao, epoch, canSign]) + + return ( + +
+ {MemoizedRecords} + + {MemoizedWithdrawDialog} +
+
+ ) +} + +MultisigAddressNervosDAODialog.displayName = 'MultisigAddressNervosDAODialog' + +export default MultisigAddressNervosDAODialog diff --git a/packages/neuron-ui/src/components/MultisigAddressNervosDAODialog/multisigAddressNervosDAODialog.module.scss b/packages/neuron-ui/src/components/MultisigAddressNervosDAODialog/multisigAddressNervosDAODialog.module.scss new file mode 100644 index 0000000000..f6b76d7148 --- /dev/null +++ b/packages/neuron-ui/src/components/MultisigAddressNervosDAODialog/multisigAddressNervosDAODialog.module.scss @@ -0,0 +1,120 @@ +@import '../../styles/mixin.scss'; + +.container { + width: 900px; + min-height: 50vh; + color: var(--main-text-color); +} + +.tabContainer { + display: flex; + justify-content: space-between; + align-items: center; + .sortBtn { + display: flex; + justify-content: center; + align-items: center; + background: var(--secondary-background-color); + width: 36px; + height: 36px; + border-radius: 100%; + min-width: 0; + cursor: pointer; + &[data-desc='false'] { + svg { + transform: rotateX(-180deg); + } + } + &:hover { + svg { + g, + path { + stroke: var(--primary-color); + } + } + } + } +} + +.recordTab { + // To achieve animation on switching the selected target with pure CSS, a layout with fixed width is required. + $itemWidth: 96px; + $itemOverlapping: 8px; + $padding: 4px; + + --selected-tab: 0; + + position: relative; + width: max-content; + margin: 0; + padding: $padding; + background: var(--fourth-background-color); + border-radius: 40px; + + button { + @include bold-text; + position: relative; + appearance: none; + width: $itemWidth; + height: 40px; + font-weight: 500; + font-size: 14px; + background-color: transparent; + color: var(--primary-color); + padding: 0; + border: none; + margin-left: -$itemOverlapping; + cursor: pointer; + + &:first-of-type { + margin-left: 0; + } + + &.tab { + color: var(--secondary-text-color); + } + + &.active { + cursor: default; + color: var(--primary-color); + } + } + + .underline { + display: block; + position: absolute; + top: $padding; + left: $padding; + height: 40px; + width: $itemWidth; + background: var(--third-background-color); + border-radius: 40px; + transition: transform 0.1s ease-in-out; + transform: translateX(calc(var(--selected-tab) * ($itemWidth - $itemOverlapping))); + } +} + +.records { + display: flex; + flex-direction: column; + gap: 16px; + margin-top: 12px; + + .recordWrap { + border: 1px solid var(--divide-line-color); + border-radius: 16px; + } +} + +.noRecords { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 8px; + margin-top: 12px; + padding: 22px 0 44px 0; + background: var(--secondary-background-color); + border-radius: 16px; + font-size: 14px; +} diff --git a/packages/neuron-ui/src/components/NervosDAO/index.tsx b/packages/neuron-ui/src/components/NervosDAO/index.tsx index c80a7b2a5e..e969fcfd1d 100644 --- a/packages/neuron-ui/src/components/NervosDAO/index.tsx +++ b/packages/neuron-ui/src/components/NervosDAO/index.tsx @@ -256,7 +256,7 @@ const NervosDAO = () => { return ( { onSubmit={onWithdrawDialogSubmit} tipDao={tipDao} currentEpoch={epoch} + canSign /> ) : null }, [activeRecord, onWithdrawDialogDismiss, onWithdrawDialogSubmit, tipDao, epoch]) diff --git a/packages/neuron-ui/src/components/NervosDAORecord/index.tsx b/packages/neuron-ui/src/components/NervosDAORecord/index.tsx index b031f61381..244e1bfe33 100644 --- a/packages/neuron-ui/src/components/NervosDAORecord/index.tsx +++ b/packages/neuron-ui/src/components/NervosDAORecord/index.tsx @@ -13,6 +13,7 @@ import { epochParser, clsx, RoutePath, + getExplorerUrl, } from 'utils' import CompensationPeriodTooltip from 'components/CompensationPeriodTooltip' import { Clock } from 'widgets/Icons/icon' @@ -20,6 +21,7 @@ import { Link } from 'react-router-dom' import { HIDE_BALANCE } from 'utils/const' import Spinner from 'widgets/Spinner' import Tooltip from 'widgets/Tooltip' +import { openExternal } from 'services/remote' import styles from './daoRecordRow.module.scss' import hooks from './hooks' @@ -44,6 +46,8 @@ export interface DAORecordProps extends State.NervosDAORecord { genesisBlockTimestamp: number | undefined // genesis block timestamp, used to calculate apc isPrivacyMode?: boolean hasCkbBalance?: boolean + showDetailInExplorer?: boolean + isMainnet?: boolean } export const DAORecord = ({ @@ -65,6 +69,8 @@ export const DAORecord = ({ unlockInfo, isPrivacyMode, hasCkbBalance, + showDetailInExplorer, + isMainnet, }: DAORecordProps) => { const [t] = useTranslation() const [withdrawEpoch, setWithdrawEpoch] = useState('') @@ -207,9 +213,24 @@ export const DAORecord = ({ )}
- -