diff --git a/_raw/locales/en/messages.json b/_raw/locales/en/messages.json index fddb8d2459e..fd41f44c1cb 100644 --- a/_raw/locales/en/messages.json +++ b/_raw/locales/en/messages.json @@ -200,7 +200,24 @@ "trustValue": "Trust value", "importedDelegatedAddress": "Imported delegated address", "noDelegatedAddress": "No imported delegated address", - "coboSafeNotPermission": "This delegate address does not have permission to initiate this transaction" + "coboSafeNotPermission": "This delegate address does not have permission to initiate this transaction", + "SafeNonceSelector": { + "explain": { + "contractCall": "Contract Call", + "send": "Send Token", + "unknown": "Unknown Transaction" + }, + "optionGroup": { + "recommendTitle": "Recommended nonce", + "replaceTitle": "Replace the transaction in Queue " + }, + "option": { + "new": "New Transaction" + }, + "error": { + "pendingList": "Fail to load pending transactions, <1/><2>Retry2>" + } + } }, "signFooterBar": { "requestFrom": "Request from", @@ -1392,9 +1409,19 @@ "approvalExplain": "Approve {{count}} {{token}} for {{protocol}}", "unlimited": "unlimited", "action": { - "send": "Send" - }, - "viewBtn": "View" + "send": "Send", + "cancel": "Cancel Pending Transaction" + }, + "viewBtn": "View", + "replaceBtn": "Replace", + "ReplacePopup": { + "options": { + "send": "Send Token", + "reject": "Reject Transaction" + }, + "title": "Select how to replace this transaction", + "desc": " A signed transaction cannot be removed but it can be replaced with a new transaction with the same nonce." + } }, "importSuccess": { "title": "Imported Successfully", @@ -1610,7 +1637,8 @@ "Loading": "Loading", "nonce": "nonce", "Balance": "Balance", - "Done": "Done" + "Done": "Done", + "Nonce": "Nonce" }, "background": { "error": { diff --git a/_raw/locales/zh_CN/messages.json b/_raw/locales/zh_CN/messages.json index 23df2825070..0d65d7dd2f1 100644 --- a/_raw/locales/zh_CN/messages.json +++ b/_raw/locales/zh_CN/messages.json @@ -30,7 +30,8 @@ "Loading": "加载中", "Balance": "余额", "Done": "完成", - "nonce": "nonce" + "nonce": "nonce", + "Nonce": "Nonce" }, "page": { "transactions": { @@ -1215,7 +1216,16 @@ "wrapToken": "Wrap 代币", "contractPopularity": "No.{{0}} on {{1}}", "myMarkWithContract": "My mark on {{chainName}} contract", - "coboSafeNotPermission": "This delegate address does not have permission to initiate this transaction" + "coboSafeNotPermission": "This delegate address does not have permission to initiate this transaction", + "SafeNonceSelector": { + "optionGroup": { + "recommendTitle": "推荐的Nonce", + "replaceTitle": "替换Queue中的交易" + }, + "error": { + "pendingList": "Pending列表获取失败, <1/><2>重试2>" + } + } }, "signTypedData": { "buyNFT": { @@ -1417,7 +1427,16 @@ "action": { "send": "发送" }, - "viewBtn": "查看" + "viewBtn": "查看", + "replaceBtn": "替换", + "ReplacePopup": { + "title": "请选择替换交易的方式", + "desc": "已经签名的交易无法被清除,但是可以通过发起一笔相同 Nonce的交易来替换。", + "options": { + "send": "发送代币", + "reject": "拒绝交易" + } + } }, "importSuccess": { "title": "成功导入", diff --git a/src/background/controller/wallet.ts b/src/background/controller/wallet.ts index 1cbdb41008a..493aebb54c8 100644 --- a/src/background/controller/wallet.ts +++ b/src/background/controller/wallet.ts @@ -1521,6 +1521,18 @@ export class WalletController extends BaseController { }; }; + getGnosisPendingTxs = async (address: string, networkId: string) => { + if (!networkId) { + return []; + } + const safe = await createSafeService({ + networkId: networkId, + address, + }); + const { results } = await safe.getPendingTransactions(); + return results; + }; + getGnosisOwners = async ( account: Account, safeAddress: string, diff --git a/src/ui/assets/safe-nonce-select/checked.svg b/src/ui/assets/safe-nonce-select/checked.svg new file mode 100644 index 00000000000..6b6ddae3416 --- /dev/null +++ b/src/ui/assets/safe-nonce-select/checked.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/ui/assets/safe-nonce-select/down.svg b/src/ui/assets/safe-nonce-select/down.svg new file mode 100644 index 00000000000..149cc795009 --- /dev/null +++ b/src/ui/assets/safe-nonce-select/down.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/assets/safe-nonce-select/find.svg b/src/ui/assets/safe-nonce-select/find.svg new file mode 100644 index 00000000000..488b5607209 --- /dev/null +++ b/src/ui/assets/safe-nonce-select/find.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/ui/assets/safe-nonce-select/unchecked.svg b/src/ui/assets/safe-nonce-select/unchecked.svg new file mode 100644 index 00000000000..03cb889c467 --- /dev/null +++ b/src/ui/assets/safe-nonce-select/unchecked.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/views/Approval/components/Actions/utils.tsx b/src/ui/views/Approval/components/Actions/utils.tsx index 17a590a06be..d8a42100c51 100644 --- a/src/ui/views/Approval/components/Actions/utils.tsx +++ b/src/ui/views/Approval/components/Actions/utils.tsx @@ -1389,3 +1389,30 @@ export const getActionTypeText = (data: ParsedActionData) => { } return t('page.signTx.unknownAction'); }; + +export const getActionTypeTextByType = (type: string) => { + const t = i18n.t; + + const dict = { + swap_token: t('page.signTx.swap.title'), + cross_token: t('page.signTx.crossChain.title'), + cross_swap_token: t('page.signTx.swapAndCross.title'), + send_token: t('page.signTx.send.title'), + approve_token: t('page.signTx.tokenApprove.title'), + revoke_token: t('page.signTx.revokeTokenApprove.title'), + permit2_revoke_token: t('page.signTx.revokePermit2.title'), + wrap_token: t('page.signTx.wrapToken'), + unwrap_token: t('page.signTx.unwrap'), + send_nft: t('page.signTx.sendNFT.title'), + approve_nft: t('page.signTx.nftApprove.title'), + revoke_nft: t('page.signTx.revokeNFTApprove.title'), + approve_collection: t('page.signTx.nftCollectionApprove.title'), + revoke_collection: t('page.signTx.revokeNFTCollectionApprove.title'), + deploy_contract: t('page.signTx.deployContract.title'), + cancel_tx: t('page.signTx.cancelTx.title'), + push_multisig: t('page.signTx.submitMultisig.title'), + contract_call: t('page.signTx.contractCall.title'), + }; + + return dict[type] || t('page.signTx.unknownAction'); +}; diff --git a/src/ui/views/Approval/components/FooterBar/FooterBar.tsx b/src/ui/views/Approval/components/FooterBar/FooterBar.tsx index 8a06c4c22a4..9b8115bed94 100644 --- a/src/ui/views/Approval/components/FooterBar/FooterBar.tsx +++ b/src/ui/views/Approval/components/FooterBar/FooterBar.tsx @@ -115,6 +115,7 @@ const Shadow = styled.div` rgba(175, 175, 175, 0.168147) 41.66%, rgba(130, 130, 130, 0.35) 83.44% ); + z-index: 10; `; const ChainLogo = styled.img` diff --git a/src/ui/views/Approval/components/SignTx.tsx b/src/ui/views/Approval/components/SignTx.tsx index 4b6634db6b3..393297af1f4 100644 --- a/src/ui/views/Approval/components/SignTx.tsx +++ b/src/ui/views/Approval/components/SignTx.tsx @@ -71,6 +71,7 @@ import { TokenDetailPopup } from '@/ui/views/Dashboard/components/TokenDetailPop import { useSignPermissionCheck } from '../hooks/useSignPermissionCheck'; import { useTestnetCheck } from '../hooks/useTestnetCheck'; import { CoboDelegatedDrawer } from './TxComponents/CoboDelegatedDrawer'; +import { SafeNonceSelector } from './TxComponents/SafeNonceSelector'; interface BasicCoboArgusInfo { address: string; @@ -821,6 +822,7 @@ const SignTx = ({ params, origin }: SignTxProps) => { isSend, isSwap, isViewGnosisSafe, + safeTxGas, } = normalizeTxParams(params.data[0]); let updateNonce = true; @@ -1124,6 +1126,7 @@ const SignTx = ({ params, origin }: SignTxProps) => { to: tx.to, data: tx.data, value: tx.value, + safeTxGas: safeTxGas, }; params.nonce = realNonce; await wallet.buildGnosisTransaction( @@ -1139,6 +1142,7 @@ const SignTx = ({ params, origin }: SignTxProps) => { data: [account.address, JSON.stringify(typedData)], session: params.session, isGnosis: true, + isSend, account: account, method: 'ethSignTypedDataV4', uiRequestComponent: 'SignTypedData', @@ -1838,45 +1842,58 @@ const SignTx = ({ params, origin }: SignTxProps) => { engineResults={engineResults} /> )} - { - return explainGas({ - gasUsed, - gasPrice: price, - chainId, - nativeTokenPrice: txDetail?.native_token.price || 0, - tx, - wallet, - gasLimit, - }); - }} - recommendGasLimit={recommendGasLimit} - recommendNonce={recommendNonce} - chainId={chainId} - onChange={handleGasChange} - nonce={realNonce || tx.nonce} - disableNonce={isSpeedUp || isCancel} - is1559={support1559} - isHardware={isHardware} - manuallyChangeGasLimit={manuallyChangeGasLimit} - errors={checkErrors} - engineResults={engineResults} - nativeTokenBalance={nativeTokenBalance} - gasPriceMedian={gasPriceMedian} - /> + {isGnosisAccount ? ( + { + setRealNonce(v); + setNonceChanged(true); + }} + /> + ) : ( + { + return explainGas({ + gasUsed, + gasPrice: price, + chainId, + nativeTokenPrice: txDetail?.native_token.price || 0, + tx, + wallet, + gasLimit, + }); + }} + recommendGasLimit={recommendGasLimit} + recommendNonce={recommendNonce} + chainId={chainId} + onChange={handleGasChange} + nonce={realNonce || tx.nonce} + disableNonce={isSpeedUp || isCancel} + is1559={support1559} + isHardware={isHardware} + manuallyChangeGasLimit={manuallyChangeGasLimit} + errors={checkErrors} + engineResults={engineResults} + nativeTokenBalance={nativeTokenBalance} + gasPriceMedian={gasPriceMedian} + /> + )} > )} {isGnosisAccount && safeInfo && ( @@ -1952,7 +1969,9 @@ const SignTx = ({ params, origin }: SignTxProps) => { (isLedger && !useLedgerLive && !hasConnectedLedgerHID) || !canProcess || !!checkErrors.find((item) => item.level === 'forbidden') || - hasUnProcessSecurityResult + hasUnProcessSecurityResult || + (isGnosisAccount && + new BigNumber(realNonce || 0).isLessThan(safeInfo?.nonce || 0)) } /> > diff --git a/src/ui/views/Approval/components/SignTypedData.tsx b/src/ui/views/Approval/components/SignTypedData.tsx index d0b5e956b06..007cfb8ab7c 100644 --- a/src/ui/views/Approval/components/SignTypedData.tsx +++ b/src/ui/views/Approval/components/SignTypedData.tsx @@ -51,6 +51,7 @@ interface SignTypedDataProps { name: string; }; isGnosis?: boolean; + isSend?: boolean; account?: Account; } @@ -145,7 +146,7 @@ const SignTypedData = ({ params }: { params: SignTypedDataProps }) => { } }, [engineResults, currentTx]); - const { data, session, method, isGnosis, account } = params; + const { data, session, method, isGnosis, isSend, account } = params; let parsedMessage = ''; let _message = ''; try { @@ -316,6 +317,9 @@ const SignTypedData = ({ params }: { params: SignTypedDataProps }) => { version: 'V4', } ); + if (isSend) { + wallet.clearPageStateCache(); + } resolveApproval({ uiRequestComponent: WaitingSignMessageComponent[params.account.type], type: params.account.type, @@ -345,6 +349,9 @@ const SignTypedData = ({ params }: { params: SignTypedDataProps }) => { await wallet.gnosisAddSignature(params.account.address, result); await wallet.postGnosisTransaction(); } + if (isSend) { + wallet.clearPageStateCache(); + } resolveApproval(result, false, true); } catch (e) { message.error(e.message); diff --git a/src/ui/views/Approval/components/TxComponents/SafeNonceSelector.tsx b/src/ui/views/Approval/components/TxComponents/SafeNonceSelector.tsx new file mode 100644 index 00000000000..e6ac10c6708 --- /dev/null +++ b/src/ui/views/Approval/components/TxComponents/SafeNonceSelector.tsx @@ -0,0 +1,482 @@ +import { useAccount } from '@/ui/store-hooks'; +import { useWallet } from '@/ui/utils'; +import { BasicSafeInfo } from '@rabby-wallet/gnosis-sdk'; +import { SafeTransactionItem } from '@rabby-wallet/gnosis-sdk/dist/api'; +import { useRequest } from 'ahooks'; +import { Form, Input, Skeleton, Spin } from 'antd'; +import clsx from 'clsx'; +import { INTERNAL_REQUEST_ORIGIN } from 'consts'; +import { maxBy, sortBy, uniqBy } from 'lodash'; +import React, { + MouseEventHandler, + ReactNode, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import styled from 'styled-components'; +import IconChecked from 'ui/assets/safe-nonce-select/checked.svg'; +import IconDown from 'ui/assets/safe-nonce-select/down.svg'; +import IconUnchecked from 'ui/assets/safe-nonce-select/unchecked.svg'; +import IconFind from 'ui/assets/safe-nonce-select/find.svg'; +import { intToHex } from 'ui/utils/number'; +import { findChainByID } from '@/utils/chain'; +import { getActionTypeText, getActionTypeTextByType } from '../Actions/utils'; + +const Wrapper = styled.div` + border-radius: 6px; + background: var(--r-neutral-card-1, #fff); + margin-top: 12px; + + padding: 16px 12px 12px 12px; + + .nonce-select { + &-label { + color: var(--r-neutral-title-1, #192945); + font-size: 15px; + font-weight: 500; + line-height: 18px; + margin-bottom: 8px; + } + .ant-input-affix-wrapper:not(.ant-input-affix-wrapper-disabled):hover { + border-color: var(--r-blue-default, #7084ff); + border-right-width: 1px !important; + z-index: 1; + } + .nonce-input { + height: 52px; + border-radius: 6px; + border: 1px solid var(--r-neutral-line, #d3d8e0); + /* border: 0.5px solid var(--r-neutral-line, #d3d8e0); */ + + background: var(--r-neutral-bg-3, #f7fafc); + + .ant-input { + background: var(--r-neutral-bg-3, #f7fafc); + } + } + + &-option-list { + border-radius: 6px; + background: var(--r-neutral-bg-3, #f7fafc); + margin-top: 8px; + } + + &-option-group { + &-title { + padding: 4px 12px 8px 12px; + color: var(--r-neutral-body, #3e495e); + font-size: 12px; + font-weight: 400; + line-height: 14px; + } + } + + &-option { + display: flex; + align-items: center; + + color: var(--r-neutral-title-1, #192945); + font-size: 13px; + font-weight: 500; + + line-height: 16px; + + padding: 12px; + border-radius: 6px; + position: relative; + border: 1px solid transparent; + cursor: pointer; + + &:hover, + &.is-checked { + border: 1px solid var(--r-blue-default, #7084ff); + background: var(--r-blue-light-1, #eef1ff); + } + + img { + margin-left: auto; + } + + &:not(:last-child)::after { + content: ''; + display: block; + position: absolute; + bottom: -1px; + left: 12px; + right: 12px; + height: 1px; + height: 0.5px; + background: var(--r-neutral-line, #d3d8e0); + } + } + } + + .ant-form-item-has-error .nonce-input { + border-color: var(--r-red-default, #e34935) !important; + } + + .ant-form-item-explain, + .ant-form-item-extra { + min-height: unset; + } + .ant-form-item { + margin-bottom: 0; + } + .ant-form-item-explain.ant-form-item-explain-error { + color: var(--r-red-default, #e34935); + font-size: 12px; + font-weight: 400; + line-height: 14px; + } + + .alert-error { + display: flex; + padding: 40px 40px 36px 40px; + flex-direction: column; + justify-content: flex-end; + align-items: center; + gap: 12px; + border-radius: 6px; + background: var(--r-neutral-bg-3, #f7fafc); + margin-top: 8px; + + &-message { + color: var(--r-neutral-body, #3e495e); + text-align: center; + font-family: SF Pro; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: normal; + } + } +`; + +interface SafeNonceSelectorProps { + value?: string; + onChange?(value: string): void; + isReady?: boolean; + chainId: number; + safeInfo?: BasicSafeInfo | null; +} +export const SafeNonceSelector = ({ + value, + onChange, + isReady, + chainId, + safeInfo, +}: SafeNonceSelectorProps) => { + const { t } = useTranslation(); + const [isShowOptionList, setIsShowOptionList] = useState(false); + + const [form] = Form.useForm(); + + const val = useMemo(() => { + if (value == null || value === '') { + return ''; + } + const res = +value; + if (Number.isNaN(res)) { + return ''; + } + return res; + }, [value]); + + useEffect(() => { + form.setFieldsValue({ + nonce: val, + }); + form.validateFields(); + }, [val]); + + const handleOnChange = (_v: string | number) => { + const v = +_v; + if (Number.isNaN(v)) { + onChange?.(''); + } else { + onChange?.(intToHex(v)); + } + }; + + const optionListRef = useRef(null); + + useEffect(() => { + const parent = document.querySelector('.approval-tx'); + if (optionListRef.current && parent) { + const shouldScroll = + optionListRef.current.getBoundingClientRect().top + 52 > + parent.getBoundingClientRect().height; + if (shouldScroll) { + parent.scrollTo(0, parent.scrollTop + 128); + } + } + }, [isShowOptionList]); + + if (!isReady) { + return ( + + + + + + + {Array(4) + .fill(0) + .map((_e, i) => ( + + ))} + + + + ); + } + return ( + + + {t('global.Nonce')} + + + { + const v = e.target.value; + handleOnChange(v); + }} + // type="number" + suffix={ + setIsShowOptionList((v) => !v)} + className={clsx( + 'cursor-pointer', + isShowOptionList && 'rotate-180' + )} + > + } + > + + + {isShowOptionList ? ( + + + + ) : null} + + + ); +}; + +const OptionList = ({ + chainId, + value, + onChange, + safeInfo, +}: { + chainId: number; + value?: number; + onChange?(value: number): void; + safeInfo?: BasicSafeInfo | null; +}) => { + const wallet = useWallet(); + const [account] = useAccount(); + + const { t } = useTranslation(); + + const { + data: pendingList, + loading: isLoadingPendingList, + refreshAsync, + error, + } = useRequest( + async () => { + if (!account?.address) { + return; + } + return wallet.getGnosisPendingTxs(account?.address, chainId.toString()); + }, + { + cacheKey: `gnosis-pending-txs-${account?.address}-${chainId}`, + } + ); + + const pendingOptionlist = useMemo(() => { + return sortBy(uniqBy(pendingList || [], 'nonce'), 'nonce'); + }, [pendingList]); + + const recommendNonce = useMemo(() => { + const maxNonceTx = pendingList?.length + ? maxBy(pendingList || [], (item) => item.nonce) + : null; + return maxNonceTx != null ? maxNonceTx.nonce + 1 : safeInfo?.nonce; + }, [pendingList, safeInfo]); + + if (isLoadingPendingList && !pendingList) { + return ( + + + + ); + } + + if (error) { + return ( + + + + + Fail to load pending transactions,{' '} + { + refreshAsync(); + }} + className="underline cursor-pointer" + > + Retry + + + + + ); + } + + return ( + + {recommendNonce != null ? ( + + + {t('page.signTx.SafeNonceSelector.optionGroup.recommendTitle')} + + { + onChange?.(recommendNonce); + }} + > + {recommendNonce} - {t('page.signTx.SafeNonceSelector.option.new')} + + + ) : null} + {pendingList?.length ? ( + + + {t('page.signTx.SafeNonceSelector.optionGroup.replaceTitle')} + + {pendingOptionlist?.map((item) => { + return ( + { + onChange?.(item.nonce); + }} + > + + + ); + })} + + ) : null} + + ); +}; + +const PendingOptionContent = ({ + data, + chainId, +}: { + data: SafeTransactionItem; + chainId: number; +}) => { + const wallet = useWallet(); + const { t } = useTranslation(); + const { data: res, loading } = useRequest( + async () => { + const chain = findChainByID(chainId)!; + return wallet.openapi.parseTx({ + chainId: chain.serverId, + tx: { + chainId, + from: data.safe, + to: data.to, + data: data.data || '0x', + value: `0x${Number(data.value).toString(16)}`, + nonce: intToHex(data.nonce), + gasPrice: '0x0', + gas: '0x0', + }, + origin: origin || '', + addr: data.safe, + }); + }, + { + cacheKey: `gnosis-parse-tx-${data.safe}-${data.to}-${data.nonce}-${data?.data}`, + staleTime: 10000, + } + ); + + const content = useMemo(() => { + return getActionTypeTextByType(res?.action?.type || ''); + }, [res?.action?.type]); + + return ( + + {data.nonce} - {loading ? '' : content} + + ); +}; + +const OptionListItem = ({ + children, + checked, + onClick, +}: { + children?: ReactNode; + checked?: boolean; + onClick?: MouseEventHandler; +}) => { + return ( + + {children} + {checked ? ( + + ) : ( + + )} + + ); +}; diff --git a/src/ui/views/GnosisTransactionQueue/GnosisTransactionQueueList.tsx b/src/ui/views/GnosisTransactionQueue/GnosisTransactionQueueList.tsx index 1be664f31ed..c32717c662c 100644 --- a/src/ui/views/GnosisTransactionQueue/GnosisTransactionQueueList.tsx +++ b/src/ui/views/GnosisTransactionQueue/GnosisTransactionQueueList.tsx @@ -1,47 +1,50 @@ -import React, { useEffect, useState } from 'react'; -import { Button, message, Skeleton, Tabs, Tooltip } from 'antd'; -import clsx from 'clsx'; -import Safe, { BasicSafeInfo } from '@rabby-wallet/gnosis-sdk'; +import { BasicSafeInfo } from '@rabby-wallet/gnosis-sdk'; +import { SafeTransactionItem } from '@rabby-wallet/gnosis-sdk/dist/api'; +import { Button, Skeleton, Tooltip, message } from 'antd'; import { - SafeTransactionItem, - SafeInfo, -} from '@rabby-wallet/gnosis-sdk/dist/api'; -import { useTranslation, Trans } from 'react-i18next'; -import { toChecksumAddress, numberToHex } from 'web3-utils'; -import dayjs from 'dayjs'; -import { groupBy, sortBy } from 'lodash'; -import { ExplainTxResponse } from 'background/service/openapi'; + ApproveAction, + ExplainTxResponse, + ParseTxResponse, + RevokeTokenApproveAction, + SendAction, +} from 'background/service/openapi'; import { Account } from 'background/service/preference'; +import clsx from 'clsx'; +import dayjs from 'dayjs'; +import { get, groupBy } from 'lodash'; +import React, { useEffect, useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { numberToHex, toChecksumAddress } from 'web3-utils'; -import { intToHex } from 'ethereumjs-util'; -import { timeago, isSameAddress, useWallet } from 'ui/utils'; -import { - validateEOASign, - validateETHSign, - crossCompareOwners, -} from 'ui/utils/gnosis'; +import { useGnosisSafeInfo } from '@/ui/hooks/useGnosisSafeInfo'; +import { useAccount } from '@/ui/store-hooks'; +import { LoadingOutlined } from '@ant-design/icons'; import { SafeTransactionDataPartial } from '@gnosis.pm/safe-core-sdk-types'; -import { splitNumberByStep } from 'ui/utils/number'; -import { PageHeader, NameAndAddress } from 'ui/component'; -import AccountSelectDrawer from 'ui/component/AccountSelectDrawer'; import { CHAINS, CHAINS_ENUM, INTERNAL_REQUEST_ORIGIN, KEYRING_CLASS, } from 'consts'; -import IconUnknown from 'ui/assets/icon-unknown.svg'; +import { intToHex } from 'ethereumjs-util'; +import { useHistory } from 'react-router-dom'; import IconUser from 'ui/assets/address-management.svg'; import IconChecked from 'ui/assets/checked.svg'; -import IconUnCheck from 'ui/assets/uncheck.svg'; -import { SvgIconLoading } from 'ui/assets'; -import IconTagYou from 'ui/assets/tag-you.svg'; +import IconUnknown from 'ui/assets/icon-unknown.svg'; import IconInformation from 'ui/assets/information.svg'; +import IconTagYou from 'ui/assets/tag-you.svg'; +import IconUnCheck from 'ui/assets/uncheck.svg'; +import { NameAndAddress } from 'ui/component'; +import AccountSelectDrawer from 'ui/component/AccountSelectDrawer'; +import { isSameAddress, timeago, useWallet } from 'ui/utils'; +import { validateEOASign, validateETHSign } from 'ui/utils/gnosis'; +import { splitNumberByStep } from 'ui/utils/number'; +import { ReplacePopup } from './components/ReplacePopup'; import './style.less'; -import { useRabbyDispatch, useRabbySelector } from '@/ui/store'; -import { LoadingOutlined } from '@ant-design/icons'; -import { useGnosisSafeInfo } from '@/ui/hooks/useGnosisSafeInfo'; -import { useAccount } from '@/ui/store-hooks'; +import { findChainByID } from '@/utils/chain'; +import { getTokenSymbol } from '@/ui/utils/token'; +import { useRequest } from 'ahooks'; +import { getProtocol } from '../Approval/components/Actions/utils'; interface TransactionConfirmationsProps { confirmations: SafeTransactionItem['confirmations']; @@ -165,25 +168,47 @@ const TransactionExplain = ({ explain, onView, isViewLoading, + serverId, }: { - explain: ExplainTxResponse; + explain: ParseTxResponse; isViewLoading: boolean; onView(): void; + serverId: string; }) => { const { t } = useTranslation(); let icon: React.ReactNode = ( ); let content: string | React.ReactNode = t('page.safeQueue.unknownTx'); + const wallet = useWallet(); + const { data: spenderProtocol } = useRequest(async () => { + if (explain?.action?.data && 'spender' in explain.action.data) { + const { desc } = await wallet.openapi.addrDesc( + explain?.action?.data?.spender + ); + return getProtocol(desc.protocol, serverId); + } + }); + + const { data: contractProtocol } = useRequest(async () => { + if ( + explain?.action?.data && + !('spender' in (explain?.action?.data || {})) && + explain?.contract_call?.contract?.id + ) { + const { desc } = await wallet.openapi.addrDesc( + explain?.contract_call?.contract?.id + ); + return getProtocol(desc.protocol, serverId); + } + }); if (explain) { - if (explain.type_cancel_token_approval) { + if (explain?.action?.type === 'revoke_token') { + const data = explain.action.data as RevokeTokenApproveAction; icon = ( ); @@ -191,20 +216,17 @@ const TransactionExplain = ({ ); - } - if (explain.type_token_approval) { + } else if (explain?.action?.type === 'approve_token') { + const data = explain.action.data as ApproveAction; icon = ( ); @@ -212,31 +234,32 @@ const TransactionExplain = ({ ); - } - if (explain.type_send) { + } else if (explain?.action?.type === 'send_token') { + const data = explain.action.data as SendAction; icon = ; content = `${t('page.safeQueue.action.send')} ${splitNumberByStep( - explain.type_send.token_amount - )} ${explain.type_send.token_symbol}`; - } - if (explain.type_call) { + data.token.amount + )} ${getTokenSymbol(data.token)}`; + } else if (explain?.action?.type === 'cancel_tx') { + content = t('page.safeQueue.action.cancel'); + } else if (explain?.contract_call) { icon = ( ); - content = explain.type_call.action; + content = explain.contract_call.func; } } @@ -269,11 +292,13 @@ const GnosisTransactionItem = ({ }) => { const wallet = useWallet(); const { t } = useTranslation(); - const [explain, setExplain] = useState(null); + const [explain, setExplain] = useState(null); const [isLoading, setIsLoading] = useState(false); const submitAt = dayjs(data.submissionDate).valueOf(); + + const [isShowReplacePopup, setIsShowReplacePopup] = useState(false); + const now = dayjs().valueOf(); - // todo const ago = timeago(now, submitAt); let agoText = ''; @@ -295,7 +320,9 @@ const GnosisTransactionItem = ({ } const init = async () => { - const res = await wallet.openapi.preExecTx({ + const chain = findChainByID(+networkId)!; + const res = await wallet.openapi.parseTx({ + chainId: chain.serverId, tx: { chainId: Number(networkId), from: data.safe, @@ -307,9 +334,7 @@ const GnosisTransactionItem = ({ gas: '0x0', }, origin: INTERNAL_REQUEST_ORIGIN, - address: data.safe, - updateNonce: false, - pending_tx_list: [], + addr: data.safe, }); setExplain(res); }; @@ -359,67 +384,119 @@ const GnosisTransactionItem = ({ window.close(); }; + const history = useHistory(); + const handleReplace = async (type: string) => { + if (type === 'send') { + history.replace({ + pathname: '/send-token', + state: { + safeInfo: { + nonce: data.nonce, + chainId: Number(networkId), + }, + from: '/gnosis-queue', + }, + }); + } else if (type === 'reject') { + const params = { + chainId: Number(networkId), + from: toChecksumAddress(data.safe), + to: toChecksumAddress(data.safe), + data: '0x', + value: `0x`, + nonce: intToHex(data.nonce), + safeTxGas: 0, + gasPrice: '0', + baseGas: 0, + }; + wallet.sendRequest({ + method: 'eth_sendTransaction', + params: [params], + }); + window.close(); + } + }; + useEffect(() => { init(); }, []); return ( - = safeInfo.threshold && - data.nonce === safeInfo.nonce, - })} - > - - {agoText} - - {t('global.nonce')}: {data.nonce} - - - - {explain ? ( - - ) : ( - - )} - - - - - ) : null - } - > + <> + = safeInfo.threshold && + data.nonce === safeInfo.nonce, + })} + > + + {agoText} + + {t('global.nonce')}: {data.nonce} + + + + {explain ? ( + + ) : ( + + )} + + + + + ) : null + } + > + + onSubmit(data)} + disabled={ + data.confirmations.length < safeInfo.threshold || + data.nonce !== safeInfo.nonce + } + > + {t('page.safeQueue.submitBtn')} + + + onSubmit(data)} - disabled={ - data.confirmations.length < safeInfo.threshold || - data.nonce !== safeInfo.nonce - } + ghost + className="replace-btn" + onClick={() => setIsShowReplacePopup(true)} > - {t('page.safeQueue.submitBtn')} + {t('page.safeQueue.replaceBtn')} - + - + setIsShowReplacePopup(false)} + onSelect={handleReplace} + /> + > ); }; diff --git a/src/ui/views/GnosisTransactionQueue/components/ReplacePopup.tsx b/src/ui/views/GnosisTransactionQueue/components/ReplacePopup.tsx new file mode 100644 index 00000000000..6216d77fd2c --- /dev/null +++ b/src/ui/views/GnosisTransactionQueue/components/ReplacePopup.tsx @@ -0,0 +1,117 @@ +import { Popup } from '@/ui/component'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import styled, { createGlobalStyle } from 'styled-components'; +import IconArrowRight from 'ui/assets/dashboard/settings/icon-right-arrow.svg'; + +const GlobalStyle = createGlobalStyle` + .safe-replace-popup { + .ant-drawer-title { + color: var(--r-neutral-title-1, #192945); + font-size: 17px; + line-height: 20px; + font-weight: 500; + text-align: left; + } + .ant-drawer-body { + padding-top: 12px; + padding-bottom: 22px + } + } +`; + +const Wrapper = styled.div` + .desc { + color: var(--r-neutral-body, #3e495e); + font-size: 14px; + font-weight: 400; + margin-bottom: 20px; + line-height: 17px; + } + .option-list { + &-item { + display: flex; + align-items: center; + border-radius: 6px; + background: var(--r-neutral-card-2, #f2f4f7); + border: 1px solid transparent; + padding: 15px; + cursor: pointer; + + &:not(:last-child) { + margin-bottom: 12px; + } + + &:hover { + border: 1px solid var(--r-blue-default, #7084ff); + background: var(--r-blue-light-1, #eef1ff); + } + + &-content { + color: var(--r-neutral-title-1, #192945); + font-size: 15px; + font-weight: 500; + line-height: 18px; + } + &-right { + margin-left: auto; + } + } + } +`; + +interface ReplacePopupProps { + visible?: boolean; + onClose?: () => void; + onSelect?: (value: string) => void; +} +export const ReplacePopup = ({ + visible, + onClose, + onSelect, +}: ReplacePopupProps) => { + const { t } = useTranslation(); + const options = [ + { + label: t('page.safeQueue.ReplacePopup.options.send'), + value: 'send', + }, + { + label: t('page.safeQueue.ReplacePopup.options.reject'), + value: 'reject', + }, + ]; + return ( + + + + {t('page.safeQueue.ReplacePopup.desc')} + + {options.map((item) => { + return ( + { + onSelect?.(item.value); + }} + > + {item.label} + + + + + ); + })} + + + + ); +}; diff --git a/src/ui/views/GnosisTransactionQueue/style.less b/src/ui/views/GnosisTransactionQueue/style.less index c7ec593e523..1bdf0218abe 100644 --- a/src/ui/views/GnosisTransactionQueue/style.less +++ b/src/ui/views/GnosisTransactionQueue/style.less @@ -92,17 +92,31 @@ } &__footer { display: flex; - justify-content: center; - margin-top: 20px; - .submit-btn { - width: 172px; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 12px; + + .ant-btn { + font-size: 13px; + border-radius: 4px; height: 36px; padding: 0; - .ant-btn { - width: 172px; - height: 36px; - padding: 0; + } + .ant-btn-primary[disabled], + .ant-btn-primary[disabled]:hover, + .ant-btn-primary[disabled]:focus, + .ant-btn-primary[disabled]:active { + background-color: rgba(112, 132, 255, 0.4); + border: none; + &:before { + display: none; } + } + + .submit-btn { + width: 204px; + span { font-weight: 500; font-size: 13px; @@ -110,6 +124,16 @@ text-align: center; } } + .replace-btn { + width: 120px; + &:hover { + &::before { + display: none; + } + background-color: #8697ff1a !important; + box-shadow: none; + } + } } .tx-confirm { background: @color-bg; diff --git a/src/ui/views/SendToken/index.tsx b/src/ui/views/SendToken/index.tsx index 78efbb1770a..14fa3a79dcd 100644 --- a/src/ui/views/SendToken/index.tsx +++ b/src/ui/views/SendToken/index.tsx @@ -48,6 +48,7 @@ import { filterRbiSource, useRbiSource } from '@/ui/utils/ga-event'; import { UIContactBookItem } from '@/background/service/contactBook'; import { findChainByEnum, + findChainByID, findChainByServerID, makeTokenFromChain, } from '@/utils/chain'; @@ -303,10 +304,19 @@ const SendToken = () => { time_at: 0, amount: 0, }); + + const [safeInfo, setSafeInfo] = useState<{ + chainId: number; + nonce: number; + } | null>(null); const persistPageStateCache = useCallback( async (nextStateCache?: { values?: FormSendToken; currentToken?: TokenItem | null; + safeInfo?: { + chainId: number; + nonce: number; + }; }) => { await wallet.setPageStateCache({ path: history.location.pathname, @@ -315,11 +325,12 @@ const SendToken = () => { states: { values: form.getFieldsValue(), currentToken, + safeInfo, ...nextStateCache, }, }); }, - [wallet, history, form, currentToken] + [wallet, history, form, currentToken, safeInfo] ); const [inited, setInited] = useState(false); const [gasList, setGasList] = useState([]); @@ -533,6 +544,9 @@ const SendToken = () => { data: abiCoder.encodeFunctionCall(dataInput[0], dataInput[1]), isSend: true, }; + if (safeInfo?.nonce != null) { + params.nonce = safeInfo.nonce; + } if (isNativeToken) { params.to = to; delete params.data; @@ -908,7 +922,12 @@ const SendToken = () => { }; const handleClickBack = () => { - history.replace('/'); + const from = (history.location.state as any)?.from; + if (from) { + history.replace(from); + } else { + history.replace('/'); + } }; const loadCurrentToken = async ( @@ -938,6 +957,27 @@ const SendToken = () => { } setChain(target.enum); loadCurrentToken(id, tokenChain, account.address); + } else if ((history.location.state as any)?.safeInfo) { + const safeInfo: { + nonce: number; + chainId: number; + } = (history.location.state as any)?.safeInfo; + + const chain = findChainByID(safeInfo.chainId); + let nativeToken: TokenItem | null = null; + if (chain) { + setChain(chain.enum); + nativeToken = await loadCurrentToken( + chain.nativeTokenAddress, + chain.serverId, + account.address + ); + } + setSafeInfo(safeInfo); + persistPageStateCache({ + safeInfo, + currentToken: nativeToken || currentToken, + }); } else { let tokenFromOrder: TokenItem | null = null; @@ -968,6 +1008,9 @@ const SendToken = () => { setCurrentToken(cache.states.currentToken); needLoadToken = cache.states.currentToken; } + if (cache.states.safeInfo) { + setSafeInfo(cache.states.safeInfo); + } } } @@ -1124,6 +1167,7 @@ const SendToken = () => { onChange={handleChainChanged} disabledTips={'Not supported'} supportChains={undefined} + readonly={!!safeInfo} /> {t('page.sendToken.sectionFrom.title')}