diff --git a/.github/workflows/deploy-dockerhub.yml b/.github/workflows/deploy-dockerhub.yml index 87719b0aae..db4c810127 100644 --- a/.github/workflows/deploy-dockerhub.yml +++ b/.github/workflows/deploy-dockerhub.yml @@ -13,7 +13,6 @@ on: jobs: dockerhub-push: runs-on: ubuntu-latest - if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/') steps: - uses: actions/checkout@v3 - name: Dockerhub login diff --git a/Dockerfile b/Dockerfile index 9d34381fbf..c7005e98b4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,10 @@ -FROM node:16-alpine -RUN apk add --no-cache libc6-compat git python3 py3-pip make g++ +FROM node:18-alpine +RUN apk add --no-cache libc6-compat git python3 py3-pip make g++ libusb-dev eudev-dev linux-headers WORKDIR /app COPY . . # install deps -RUN yarn install +RUN yarn install --frozen-lockfile ENV NODE_ENV production diff --git a/cypress/e2e/smoke/address_book.cy.js b/cypress/e2e/smoke/address_book.cy.js index 442c898316..3e365bd69a 100644 --- a/cypress/e2e/smoke/address_book.cy.js +++ b/cypress/e2e/smoke/address_book.cy.js @@ -4,8 +4,7 @@ import { format } from 'date-fns' const NAME = 'Owner1' const EDITED_NAME = 'Edited Owner1' -const ENS_NAME = 'diogo.eth' -const ENS_ADDRESS = '0x6a5602335a878ADDCa4BF63a050E34946B56B5bC' +const ADDRESS = '0x6a5602335a878ADDCa4BF63a050E34946B56B5bC' const GOERLI_TEST_SAFE = 'gor:0x97d314157727D517A706B5D08507A1f9B44AaaE9' const GNO_TEST_SAFE = 'gno:0xB8d760a90a5ed54D3c2b3EFC231277e99188642A' const GOERLI_CSV_ENTRY = { @@ -30,13 +29,14 @@ describe('Address book', () => { // Add a new entry manually cy.contains('Create entry').click() cy.get('input[name="name"]').type(NAME) - cy.get('input[name="address"]').type(ENS_NAME) - // Name was translated - cy.get(ENS_NAME).should('not.exist') + cy.get('input[name="address"]').type(ADDRESS) + + // Save the entry cy.contains('button', 'Save').click() + // The new entry is visible cy.contains(NAME).should('exist') - cy.contains(ENS_ADDRESS).should('exist') + cy.contains(ADDRESS).should('exist') }) it('should save an edited entry name', () => { diff --git a/cypress/e2e/smoke/batch_tx.cy.js b/cypress/e2e/smoke/batch_tx.cy.js index f1c626788d..83ebd9b6d2 100644 --- a/cypress/e2e/smoke/batch_tx.cy.js +++ b/cypress/e2e/smoke/batch_tx.cy.js @@ -13,6 +13,8 @@ describe('Create batch transaction', () => { before(() => { cy.visit(`/home?safe=${SAFE}`) cy.contains('Accept selection').click() + + cy.contains(/E2E Wallet @ G(รถ|oe)rli/, { timeout: 10000 }) }) it('Should open an empty batch list', () => { diff --git a/jest.setup.js b/jest.setup.js index f4a9076f2f..8f8d57b6c7 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -12,7 +12,6 @@ jest.mock('@web3-onboard/keystone/dist/index', () => jest.fn()) jest.mock('@web3-onboard/ledger/dist/index', () => jest.fn()) jest.mock('@web3-onboard/trezor', () => jest.fn()) jest.mock('@web3-onboard/walletconnect', () => jest.fn()) -jest.mock('@web3-onboard/taho', () => jest.fn()) const mockOnboardState = { chains: [], diff --git a/package.json b/package.json index 1c248236b8..eea885d6c4 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "homepage": "https://github.com/safe-global/safe-wallet-web", "license": "GPL-3.0", "type": "module", - "version": "1.17.1", + "version": "1.18.0", "scripts": { "dev": "next dev", "start": "next dev", @@ -28,15 +28,15 @@ "serve": "npx -y serve out -p ${REVERSE_PROXY_UI_PORT:=8080}", "static-serve": "yarn build && yarn serve" }, + "engines": { + "node": ">=16" + }, "pre-commit": [ "lint" ], "resolutions": { "@web3-onboard/trezor/**/protobufjs": "^7.2.4" }, - "engines": { - "node": ">=18" - }, "dependencies": { "@date-io/date-fns": "^2.15.0", "@emotion/cache": "^11.10.1", @@ -63,7 +63,6 @@ "@web3-onboard/injected-wallets": "^2.10.0", "@web3-onboard/keystone": "^2.3.7", "@web3-onboard/ledger": "2.3.2", - "@web3-onboard/taho": "^2.0.5", "@web3-onboard/trezor": "^2.4.2", "@web3-onboard/walletconnect": "^2.4.5", "classnames": "^2.3.1", diff --git a/src/components/batch/BatchSidebar/index.tsx b/src/components/batch/BatchSidebar/index.tsx index 65da6237e6..4edbbca106 100644 --- a/src/components/batch/BatchSidebar/index.tsx +++ b/src/components/batch/BatchSidebar/index.tsx @@ -10,6 +10,7 @@ import ConfirmBatchFlow from '@/components/tx-flow/flows/ConfirmBatch' import Track from '@/components/common/Track' import { BATCH_EVENTS } from '@/services/analytics' import { BatchReorder } from './BatchTxList' +import CheckWallet from '@/components/common/CheckWallet' import PlusIcon from '@/public/images/common/plus.svg' import EmptyBatch from './EmptyBatch' @@ -27,6 +28,15 @@ const BatchSidebar = ({ isOpen, onToggle }: { isOpen: boolean; onToggle: (open: batchTxs.forEach((item) => deleteTx(item.id)) }, [deleteTx, batchTxs]) + // Close confirmation flow when batch is empty + const isConfirmationFlow = txFlow?.type === ConfirmBatchFlow + const shouldExitFlow = isConfirmationFlow && batchTxs.length === 0 + useEffect(() => { + if (shouldExitFlow) { + setTxFlow(undefined) + } + }, [setTxFlow, shouldExitFlow]) + const onAddClick = useCallback( (e: SyntheticEvent) => { e.preventDefault() @@ -65,33 +75,45 @@ const BatchSidebar = ({ isOpen, onToggle }: { isOpen: boolean; onToggle: (open: - - - + + {(isOk) => ( + + + + )} + - - - + + {(isOk) => ( + + + + )} + ) : ( - - - + + {(isOk) => ( + + + + )} + )} diff --git a/src/components/common/Notifications/index.tsx b/src/components/common/Notifications/index.tsx index 53c6eebb46..fc714a8d0b 100644 --- a/src/components/common/Notifications/index.tsx +++ b/src/components/common/Notifications/index.tsx @@ -5,7 +5,7 @@ import { useAppDispatch, useAppSelector } from '@/store' import type { Notification } from '@/store/notificationsSlice' import { closeNotification, readNotification, selectNotifications } from '@/store/notificationsSlice' import type { AlertColor, SnackbarCloseReason } from '@mui/material' -import { Alert, Link, Snackbar } from '@mui/material' +import { Alert, Link, Snackbar, Typography } from '@mui/material' import css from './styles.module.css' import NextLink from 'next/link' import ChevronRightIcon from '@mui/icons-material/ChevronRight' @@ -45,6 +45,7 @@ export const NotificationLink = ({ } const Toast = ({ + title, message, detailedMessage, variant, @@ -73,6 +74,12 @@ const Toast = ({ return ( + {title && ( + + {title} + + )} + {message} {detailedMessage && ( diff --git a/src/components/common/SafeTokenWidget/__tests__/SafeTokenWidget.test.tsx b/src/components/common/SafeTokenWidget/__tests__/SafeTokenWidget.test.tsx index 2c57b9e188..9a873a8178 100644 --- a/src/components/common/SafeTokenWidget/__tests__/SafeTokenWidget.test.tsx +++ b/src/components/common/SafeTokenWidget/__tests__/SafeTokenWidget.test.tsx @@ -6,7 +6,7 @@ import { BigNumber } from 'ethers' import SafeTokenWidget from '..' import { hexZeroPad } from 'ethers/lib/utils' import { AppRoutes } from '@/config/routes' -import useSafeTokenAllocation from '@/hooks/useSafeTokenAllocation' +import useSafeTokenAllocation, { useSafeVotingPower } from '@/hooks/useSafeTokenAllocation' const MOCK_GOVERNANCE_APP_URL = 'https://mock.governance.safe.global' @@ -52,21 +52,24 @@ describe('SafeTokenWidget', () => { it('Should render nothing for unsupported chains', () => { ;(useChainId as jest.Mock).mockImplementationOnce(jest.fn(() => '100')) - ;(useSafeTokenAllocation as jest.Mock).mockImplementation(() => [BigNumber.from(0), false]) + ;(useSafeTokenAllocation as jest.Mock).mockImplementation(() => [[], , false]) + ;(useSafeVotingPower as jest.Mock).mockImplementation(() => [BigNumber.from(0), , false]) const result = render() expect(result.baseElement).toContainHTML('
') }) it('Should display 0 if Safe has no SAFE token', async () => { - ;(useSafeTokenAllocation as jest.Mock).mockImplementation(() => [BigNumber.from(0), false]) + ;(useSafeTokenAllocation as jest.Mock).mockImplementation(() => [[], , false]) + ;(useSafeVotingPower as jest.Mock).mockImplementation(() => [BigNumber.from(0), , false]) const result = render() await waitFor(() => expect(result.baseElement).toHaveTextContent('0')) }) it('Should display the value formatted correctly', async () => { - ;(useSafeTokenAllocation as jest.Mock).mockImplementation(() => [BigNumber.from('472238796133701648384'), false]) + ;(useSafeTokenAllocation as jest.Mock).mockImplementation(() => [[], , false]) + ;(useSafeVotingPower as jest.Mock).mockImplementation(() => [BigNumber.from('472238796133701648384'), , false]) // to avoid failing tests in some environments const NumberFormat = Intl.NumberFormat @@ -82,7 +85,8 @@ describe('SafeTokenWidget', () => { }) it('Should render a link to the governance app', async () => { - ;(useSafeTokenAllocation as jest.Mock).mockImplementation(() => [BigNumber.from(420000), false]) + ;(useSafeTokenAllocation as jest.Mock).mockImplementation(() => [[], , false]) + ;(useSafeVotingPower as jest.Mock).mockImplementation(() => [BigNumber.from(420000), , false]) const result = render() await waitFor(() => { @@ -91,4 +95,14 @@ describe('SafeTokenWidget', () => { ) }) }) + + it('Should render a claim button for SEP5 qualification', async () => { + ;(useSafeTokenAllocation as jest.Mock).mockImplementation(() => [[{ tag: 'user_v2' }], , false]) + ;(useSafeVotingPower as jest.Mock).mockImplementation(() => [BigNumber.from(420000), , false]) + + const result = render() + await waitFor(() => { + expect(result.baseElement).toContainHTML('New allocation') + }) + }) }) diff --git a/src/components/common/SafeTokenWidget/index.tsx b/src/components/common/SafeTokenWidget/index.tsx index 921f792232..3b1b189566 100644 --- a/src/components/common/SafeTokenWidget/index.tsx +++ b/src/components/common/SafeTokenWidget/index.tsx @@ -2,10 +2,10 @@ import { SafeAppsTag, SAFE_TOKEN_ADDRESSES } from '@/config/constants' import { AppRoutes } from '@/config/routes' import { useRemoteSafeApps } from '@/hooks/safe-apps/useRemoteSafeApps' import useChainId from '@/hooks/useChainId' -import useSafeTokenAllocation from '@/hooks/useSafeTokenAllocation' +import useSafeTokenAllocation, { useSafeVotingPower, type Vesting } from '@/hooks/useSafeTokenAllocation' import { OVERVIEW_EVENTS } from '@/services/analytics' import { formatVisualAmount } from '@/utils/formatters' -import { Box, ButtonBase, Skeleton, Tooltip, Typography } from '@mui/material' +import { Box, Button, ButtonBase, Skeleton, Tooltip, Typography } from '@mui/material' import { BigNumber } from 'ethers' import Link from 'next/link' import { useRouter } from 'next/router' @@ -13,6 +13,8 @@ import type { UrlObject } from 'url' import Track from '../Track' import SafeTokenIcon from '@/public/images/common/safe-token.svg' import css from './styles.module.css' +import UnreadBadge from '../UnreadBadge' +import classnames from 'classnames' const TOKEN_DECIMALS = 18 @@ -20,13 +22,26 @@ export const getSafeTokenAddress = (chainId: string): string => { return SAFE_TOKEN_ADDRESSES[chainId] } +const canRedeemSep5Airdrop = (allocation?: Vesting[]): boolean => { + const sep5Allocation = allocation?.find(({ tag }) => tag === 'user_v2') + + if (!sep5Allocation) { + return false + } + + return !sep5Allocation.isRedeemed && !sep5Allocation.isExpired +} + +const SEP5_DEADLINE = '27.10' + const SafeTokenWidget = () => { const chainId = useChainId() const router = useRouter() const [apps] = useRemoteSafeApps(SafeAppsTag.SAFE_GOVERNANCE_APP) const governanceApp = apps?.[0] - const [allocation, allocationLoading] = useSafeTokenAllocation() + const [allocationData, , allocationDataLoading] = useSafeTokenAllocation() + const [allocation, , allocationLoading] = useSafeVotingPower(allocationData) const tokenAddress = getSafeTokenAddress(chainId) if (!tokenAddress) { @@ -40,24 +55,57 @@ const SafeTokenWidget = () => { } : undefined + const canRedeemSep5 = canRedeemSep5Airdrop(allocationData) const flooredSafeBalance = formatVisualAmount(allocation || BigNumber.from(0), TOKEN_DECIMALS, 2) return ( - + - - {allocationLoading ? : flooredSafeBalance} + + + {allocationDataLoading || allocationLoading ? ( + + ) : ( + flooredSafeBalance + )} + + {canRedeemSep5 && ( + + + + )} diff --git a/src/components/common/SafeTokenWidget/styles.module.css b/src/components/common/SafeTokenWidget/styles.module.css index 515d2fcd31..b81af87f3d 100644 --- a/src/components/common/SafeTokenWidget/styles.module.css +++ b/src/components/common/SafeTokenWidget/styles.module.css @@ -19,4 +19,18 @@ gap: var(--space-1); margin-left: 0; margin-right: 0; + align-self: stretch; +} + +.sep5 { + height: 42px; +} + +[data-theme='dark'] .allocationBadge :global .MuiBadge-dot { + background-color: var(--color-primary-main); +} + +.redeemButton { + margin-left: var(--space-1); + padding: calc(var(--space-1) / 2) var(--space-1); } diff --git a/src/components/common/Track/index.tsx b/src/components/common/Track/index.tsx index f2df5be2f0..09a2832940 100644 --- a/src/components/common/Track/index.tsx +++ b/src/components/common/Track/index.tsx @@ -10,6 +10,11 @@ type Props = { label?: EventLabel } +const shouldTrack = (el: HTMLDivElement) => { + const disabledChildren = el.querySelectorAll('*[disabled]') + return disabledChildren.length === 0 +} + const Track = ({ children, as: Wrapper = 'span', ...trackData }: Props): typeof children => { const el = useRef(null) @@ -21,7 +26,9 @@ const Track = ({ children, as: Wrapper = 'span', ...trackData }: Props): typeof const trackEl = el.current const handleClick = () => { - trackEvent(trackData) + if (shouldTrack(trackEl)) { + trackEvent(trackData) + } } // We cannot use onClick as events in children do not always bubble up diff --git a/src/components/licenses/index.tsx b/src/components/licenses/index.tsx index 8061a5e5dd..fe6d496ebb 100644 --- a/src/components/licenses/index.tsx +++ b/src/components/licenses/index.tsx @@ -552,14 +552,6 @@ const SafeLicenses = () => { - - @web3-onboard/taho - - - https://github.com/blocknative/web3-onboard/blob/main/LICENSE - - - @web3-onboard/trezor diff --git a/src/components/new-safe/load/index.tsx b/src/components/new-safe/load/index.tsx index 791557e218..727d39a840 100644 --- a/src/components/new-safe/load/index.tsx +++ b/src/components/new-safe/load/index.tsx @@ -20,8 +20,8 @@ export const LoadSafeSteps: TxStepperProps['steps'] = [ { title: 'Name, address & network', subtitle: 'Paste the address of the Safe Account you want to add, select the network and choose a name.', - render: (_, onSubmit, onBack, setStep) => ( - + render: (data, onSubmit, onBack, setStep) => ( + ), }, { @@ -61,6 +61,8 @@ const LoadSafe = ({ initialData }: { initialData?: TxStepperProps void }): ReactElement => { const requiresAction = !isRead && !!link @@ -45,6 +47,13 @@ const NotificationCenterItem = ({ ) + const primaryText = ( + <> + {title && {title}} + {message} + + ) + return ( @@ -58,7 +67,7 @@ const NotificationCenterItem = ({ {getNotificationIcon(variant)} - + ) } diff --git a/src/components/transactions/TxDetails/TxData/index.tsx b/src/components/transactions/TxDetails/TxData/index.tsx index 8b9faf0d70..81f46f50cc 100644 --- a/src/components/transactions/TxDetails/TxData/index.tsx +++ b/src/components/transactions/TxDetails/TxData/index.tsx @@ -7,7 +7,6 @@ import { isMultisigDetailedExecutionInfo, isSettingsChangeTxInfo, isSpendingLimitMethod, - isSupportedMultiSendAddress, isSupportedSpendingLimitAddress, isTransferTxInfo, } from '@/utils/transaction-guards' @@ -36,7 +35,7 @@ const TxData = ({ txDetails }: { txDetails: TransactionDetails }): ReactElement return } - if (isSupportedMultiSendAddress(txInfo, chainId) && isMultiSendTxInfo(txInfo)) { + if (isMultiSendTxInfo(txInfo)) { return } diff --git a/src/components/transactions/TxDetails/index.tsx b/src/components/transactions/TxDetails/index.tsx index 8f57837278..5506754491 100644 --- a/src/components/transactions/TxDetails/index.tsx +++ b/src/components/transactions/TxDetails/index.tsx @@ -14,7 +14,6 @@ import { isMultiSendTxInfo, isMultisigDetailedExecutionInfo, isMultisigExecutionInfo, - isSupportedMultiSendAddress, isTxQueued, } from '@/utils/transaction-guards' import { InfoDetails } from '@/components/transactions/InfoDetails' @@ -39,7 +38,6 @@ type TxDetailsProps = { } const TxDetailsBlock = ({ txSummary, txDetails }: TxDetailsProps): ReactElement => { - const chainId = useChainId() const isPending = useIsPending(txSummary.id) const isQueue = isTxQueued(txSummary.txStatus) const awaitingExecution = isAwaitingExecution(txSummary.txStatus) @@ -89,7 +87,7 @@ const TxDetailsBlock = ({ txSummary, txDetails }: TxDetailsProps): ReactElement
- {isSupportedMultiSendAddress(txDetails.txInfo, chainId) && isMultiSendTxInfo(txDetails.txInfo) && ( + {isMultiSendTxInfo(txDetails.txInfo) && (
Error parsing data
}> diff --git a/src/components/transactions/TxInfo/index.tsx b/src/components/transactions/TxInfo/index.tsx index eb2be5d6dc..d0f51838bb 100644 --- a/src/components/transactions/TxInfo/index.tsx +++ b/src/components/transactions/TxInfo/index.tsx @@ -17,12 +17,10 @@ import { isMultiSendTxInfo, isNativeTokenTransfer, isSettingsChangeTxInfo, - isSupportedMultiSendAddress, isTransferTxInfo, } from '@/utils/transaction-guards' import { ellipsis, shortenAddress } from '@/utils/formatters' import { useCurrentChain } from '@/hooks/useChains' -import useChainId from '@/hooks/useChainId' export const TransferTx = ({ info, @@ -98,13 +96,11 @@ const SettingsChangeTx = ({ info }: { info: SettingsChange }): ReactElement => { } const TxInfo = ({ info, ...rest }: { info: TransactionInfo; omitSign?: boolean; withLogo?: boolean }): ReactElement => { - const chainId = useChainId() - if (isSettingsChangeTxInfo(info)) { return } - if (isSupportedMultiSendAddress(info, chainId) && isMultiSendTxInfo(info)) { + if (isMultiSendTxInfo(info)) { return } diff --git a/src/components/tx-flow/common/TxNonce/index.tsx b/src/components/tx-flow/common/TxNonce/index.tsx index c472641793..3d7c481eb3 100644 --- a/src/components/tx-flow/common/TxNonce/index.tsx +++ b/src/components/tx-flow/common/TxNonce/index.tsx @@ -59,7 +59,7 @@ const NonceFormOption = memo(function NonceFormOption({ const addressBook = useAddressBook() const transactions = useQueuedTxByNonce(Number(nonce)) - const label = useMemo(() => { + const txLabel = useMemo(() => { const latestTransactions = getLatestTransactions(transactions) if (latestTransactions.length === 0) { @@ -67,13 +67,15 @@ const NonceFormOption = memo(function NonceFormOption({ } const [{ transaction }] = latestTransactions - return getTransactionType(transaction, addressBook).text + return transaction.txInfo.humanDescription || `${getTransactionType(transaction, addressBook).text} transaction` }, [addressBook, transactions]) + const label = txLabel || 'New transaction' + return ( - {nonce} - {`${label || 'New'} transaction`} + {nonce} - {label} ) @@ -168,7 +170,7 @@ const TxNonceForm = ({ nonce, recommendedNonce }: { nonce: string; recommendedNo return ( <> {isRecommendedNonce && Recommended nonce} - {isInitialPreviousNonce && Already in queue} + {isInitialPreviousNonce && Replace existing} ) diff --git a/src/components/tx-flow/index.tsx b/src/components/tx-flow/index.tsx index 28e4bf8267..2a5b7c0223 100644 --- a/src/components/tx-flow/index.tsx +++ b/src/components/tx-flow/index.tsx @@ -1,6 +1,6 @@ import { createContext, type ReactElement, type ReactNode, useState, useEffect, useCallback } from 'react' import TxModalDialog from '@/components/common/TxModalDialog' -import { useRouter } from 'next/router' +import { usePathname } from 'next/navigation' const noop = () => {} @@ -21,7 +21,8 @@ export const TxModalProvider = ({ children }: { children: ReactNode }): ReactEle const [shouldWarn, setShouldWarn] = useState(true) const [, setOnClose] = useState[1]>(noop) const [fullWidth, setFullWidth] = useState(false) - const router = useRouter() + const pathname = usePathname() + const [, setLastPath] = useState(pathname) const handleModalClose = useCallback(() => { setOnClose((prevOnClose) => { @@ -38,13 +39,10 @@ export const TxModalProvider = ({ children }: { children: ReactNode }): ReactEle } const ok = confirm('Closing this window will discard your current progress.') - if (!ok) { - router.events.emit('routeChangeError') - throw 'routeChange aborted. This error can be safely ignored - https://github.com/zeit/next.js/issues/2476.' + if (ok) { + handleModalClose() } - - handleModalClose() - }, [shouldWarn, handleModalClose, router]) + }, [shouldWarn, handleModalClose]) const setTxFlow = useCallback( (txFlow: TxModalContextType['txFlow'], onClose?: () => void, shouldWarn?: boolean) => { @@ -57,13 +55,13 @@ export const TxModalProvider = ({ children }: { children: ReactNode }): ReactEle // Show the confirmation dialog if user navigates useEffect(() => { - if (!txFlow) return - - router.events.on('routeChangeStart', handleShowWarning) - return () => { - router.events.off('routeChangeStart', handleShowWarning) - } - }, [txFlow, handleShowWarning, router]) + setLastPath((prev) => { + if (prev !== pathname && txFlow) { + handleShowWarning() + } + return pathname + }) + }, [txFlow, handleShowWarning, pathname]) return ( diff --git a/src/components/tx/SignOrExecuteForm/ExecuteForm.tsx b/src/components/tx/SignOrExecuteForm/ExecuteForm.tsx index 4038f58e56..4c69eb5c92 100644 --- a/src/components/tx/SignOrExecuteForm/ExecuteForm.tsx +++ b/src/components/tx/SignOrExecuteForm/ExecuteForm.tsx @@ -1,5 +1,5 @@ import { type ReactElement, type SyntheticEvent, useContext, useState } from 'react' -import { Button, CardActions, Divider } from '@mui/material' +import { Box, Button, CardActions, Divider } from '@mui/material' import classNames from 'classnames' import ErrorMessage from '@/components/tx/ErrorMessage' @@ -26,6 +26,8 @@ import commonCss from '@/components/tx-flow/common/styles.module.css' import { TxSecurityContext } from '../security/shared/TxSecurityContext' import useIsSafeOwner from '@/hooks/useIsSafeOwner' import NonOwnerError from '@/components/tx/SignOrExecuteForm/NonOwnerError' +import { useAppSelector } from '@/store' +import { selectQueuedTransactionById } from '@/store/txQueueSlice' const ExecuteForm = ({ safeTx, @@ -50,6 +52,8 @@ const ExecuteForm = ({ const { setTxFlow } = useContext(TxModalContext) const { needsRiskConfirmation, isRiskConfirmed, setIsRiskIgnored } = useContext(TxSecurityContext) + const tx = useAppSelector((state) => selectQueuedTransactionById(state, txId)) + // Check that the transaction is executable const isExecutionLoop = useIsExecutionLoop() @@ -85,7 +89,7 @@ const ExecuteForm = ({ const txOptions = getTxOptions(advancedParams, currentChain) try { - const executedTxId = await executeTx(txOptions, safeTx, txId, origin, willRelay) + const executedTxId = await executeTx(txOptions, safeTx, txId, origin, willRelay, tx) setTxFlow(, undefined, false) } catch (_err) { const err = asError(_err) @@ -133,17 +137,21 @@ const ExecuteForm = ({ Cannot execute a transaction from the Safe Account itself, please connect a different account. - ) : executionValidationError || gasLimitError ? ( - - This transaction will most likely fail. - {` To save gas costs, ${isCreation ? 'avoid creating' : 'reject'} this transaction.`} - ) : ( - submitError && ( - Error submitting the transaction. Please try again. + (executionValidationError || gasLimitError) && ( + + This transaction will most likely fail. + {` To save gas costs, ${isCreation ? 'avoid creating' : 'reject'} this transaction.`} + ) )} + {submitError && ( + + Error submitting the transaction. Please try again. + + )} + diff --git a/src/components/tx/SignOrExecuteForm/SignForm.tsx b/src/components/tx/SignOrExecuteForm/SignForm.tsx index 6dbb3a7f93..37ecffed1f 100644 --- a/src/components/tx/SignOrExecuteForm/SignForm.tsx +++ b/src/components/tx/SignOrExecuteForm/SignForm.tsx @@ -14,6 +14,8 @@ import commonCss from '@/components/tx-flow/common/styles.module.css' import { TxSecurityContext } from '../security/shared/TxSecurityContext' import NonOwnerError from '@/components/tx/SignOrExecuteForm/NonOwnerError' import BatchButton from './BatchButton' +import { useAppSelector } from '@/store' +import { selectQueuedTransactionById } from '@/store/txQueueSlice' const SignForm = ({ safeTx, @@ -38,6 +40,8 @@ const SignForm = ({ const { needsRiskConfirmation, isRiskConfirmed, setIsRiskIgnored } = useContext(TxSecurityContext) const hasSigned = useAlreadySigned(safeTx) + const tx = useAppSelector((state) => selectQueuedTransactionById(state, txId)) + // On modal submit const handleSubmit = async (e: SyntheticEvent, isAddingToBatch = false) => { e.preventDefault() @@ -53,7 +57,7 @@ const SignForm = ({ setSubmitError(undefined) try { - await (isAddingToBatch ? addToBatch(safeTx, origin) : signTx(safeTx, txId, origin)) + await (isAddingToBatch ? addToBatch(safeTx, origin) : signTx(safeTx, txId, origin, tx)) } catch (_err) { const err = asError(_err) logError(Errors._804, err) diff --git a/src/components/tx/SignOrExecuteForm/hooks.ts b/src/components/tx/SignOrExecuteForm/hooks.ts index bd3895e15b..a7ca048452 100644 --- a/src/components/tx/SignOrExecuteForm/hooks.ts +++ b/src/components/tx/SignOrExecuteForm/hooks.ts @@ -18,16 +18,18 @@ import type { OnboardAPI } from '@web3-onboard/core' import { getSafeTxGas, getRecommendedNonce } from '@/services/tx/tx-sender/recommendedNonce' import useAsync from '@/hooks/useAsync' import { useUpdateBatch } from '@/hooks/useDraftBatch' +import { type Transaction, type TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' type TxActions = { addToBatch: (safeTx?: SafeTransaction, origin?: string) => Promise - signTx: (safeTx?: SafeTransaction, txId?: string, origin?: string) => Promise + signTx: (safeTx?: SafeTransaction, txId?: string, origin?: string, transaction?: Transaction) => Promise executeTx: ( txOptions: TransactionOptions, safeTx?: SafeTransaction, txId?: string, origin?: string, isRelayed?: boolean, + transaction?: Transaction, ) => Promise } @@ -83,50 +85,55 @@ export const useTxActions = (): TxActions => { return await dispatchTxSigning(safeTx, version, onboard, chainId, txId) } - const signTx: TxActions['signTx'] = async (safeTx, txId, origin) => { + const signTx: TxActions['signTx'] = async (safeTx, txId, origin, transaction) => { assertTx(safeTx) assertWallet(wallet) assertOnboard(onboard) + const humanDescription = transaction?.transaction?.txInfo?.humanDescription + // Smart contract wallets must sign via an on-chain tx if (await isSmartContractWallet(wallet)) { // If the first signature is a smart contract wallet, we have to propose w/o signatures // Otherwise the backend won't pick up the tx // The signature will be added once the on-chain signature is indexed const id = txId || (await proposeTx(wallet.address, safeTx, txId, origin)).txId - await dispatchOnChainSigning(safeTx, id, onboard, chainId) + await dispatchOnChainSigning(safeTx, id, onboard, chainId, humanDescription) return id } // Otherwise, sign off-chain - const signedTx = await dispatchTxSigning(safeTx, version, onboard, chainId, txId) + const signedTx = await dispatchTxSigning(safeTx, version, onboard, chainId, txId, humanDescription) const tx = await proposeTx(wallet.address, signedTx, txId, origin) return tx.txId } - const executeTx: TxActions['executeTx'] = async (txOptions, safeTx, txId, origin, isRelayed) => { + const executeTx: TxActions['executeTx'] = async (txOptions, safeTx, txId, origin, isRelayed, transaction) => { assertTx(safeTx) assertWallet(wallet) assertOnboard(onboard) + let tx: TransactionDetails | undefined // Relayed transactions must be fully signed, so request a final signature if needed if (isRelayed && safeTx.signatures.size < safe.threshold) { safeTx = await signRelayedTx(safeTx) - const tx = await proposeTx(wallet.address, safeTx, txId, origin) + tx = await proposeTx(wallet.address, safeTx, txId, origin) txId = tx.txId } // Propose the tx if there's no id yet ("immediate execution") if (!txId) { - const tx = await proposeTx(wallet.address, safeTx, txId, origin) + tx = await proposeTx(wallet.address, safeTx, txId, origin) txId = tx.txId } + const humanDescription = tx?.txInfo?.humanDescription || transaction?.transaction?.txInfo?.humanDescription + // Relay or execute the tx via connected wallet if (isRelayed) { - await dispatchTxRelay(safeTx, safe, txId, txOptions.gasLimit) + await dispatchTxRelay(safeTx, safe, txId, txOptions.gasLimit, humanDescription) } else { - await dispatchTxExecution(safeTx, txOptions, txId, onboard, chainId, safeAddress) + await dispatchTxExecution(safeTx, txOptions, txId, onboard, chainId, safeAddress, humanDescription) } return txId diff --git a/src/hooks/useBeamer.ts b/src/hooks/Beamer/useBeamer.ts similarity index 91% rename from src/hooks/useBeamer.ts rename to src/hooks/Beamer/useBeamer.ts index 96c774fd68..b6af7e3cf6 100644 --- a/src/hooks/useBeamer.ts +++ b/src/hooks/Beamer/useBeamer.ts @@ -4,12 +4,15 @@ import { useAppSelector } from '@/store' import { CookieType, selectCookies } from '@/store/cookiesSlice' import { loadBeamer, unloadBeamer, updateBeamer } from '@/services/beamer' import { useCurrentChain } from '@/hooks/useChains' +import { useBeamerNps } from '@/hooks/Beamer/useBeamerNps' const useBeamer = () => { const cookies = useAppSelector(selectCookies) const isBeamerEnabled = cookies[CookieType.UPDATES] const chain = useCurrentChain() + useBeamerNps() + useEffect(() => { if (!chain?.shortName) { return diff --git a/src/hooks/Beamer/useBeamerNps.ts b/src/hooks/Beamer/useBeamerNps.ts new file mode 100644 index 0000000000..e51138aa65 --- /dev/null +++ b/src/hooks/Beamer/useBeamerNps.ts @@ -0,0 +1,31 @@ +import { useEffect } from 'react' + +import { TxEvent, txSubscribe } from '@/services/tx/txEvents' +import { useAppSelector } from '@/store' +import { selectCookies, CookieType } from '@/store/cookiesSlice' +import { shouldShowBeamerNps } from '@/services/beamer' + +export const useBeamerNps = (): void => { + const cookies = useAppSelector(selectCookies) + const isBeamerEnabled = cookies[CookieType.UPDATES] + + useEffect(() => { + if (typeof window === 'undefined' || !isBeamerEnabled) { + return + } + + const unsubscribe = txSubscribe(TxEvent.PROPOSED, () => { + // Cannot check at the top of effect as Beamer may not have loaded yet + if (shouldShowBeamerNps()) { + // We "force" the NPS banner as we have it globally disabled in Beamer to prevent it + // randomly showing on pages that we don't want it to + // Note: this is not documented but confirmed by Beamer support + window.Beamer?.forceShowNPS() + } + + unsubscribe() + }) + + return unsubscribe + }, [isBeamerEnabled]) +} diff --git a/src/hooks/__tests__/useChainId.test.ts b/src/hooks/__tests__/useChainId.test.ts index 48abbf2bbc..9ebf467db8 100644 --- a/src/hooks/__tests__/useChainId.test.ts +++ b/src/hooks/__tests__/useChainId.test.ts @@ -1,4 +1,4 @@ -import { useRouter } from 'next/router' +import { useParams } from 'next/navigation' import useChainId from '@/hooks/useChainId' import { useAppDispatch } from '@/store' import { setLastChainId } from '@/store/sessionSlice' @@ -9,18 +9,14 @@ import type { ConnectedWallet } from '@/services/onboard' import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' // mock useRouter -jest.mock('next/router', () => ({ - useRouter: jest.fn(() => ({ - query: {}, - })), +jest.mock('next/navigation', () => ({ + useParams: jest.fn(() => ({})), })) describe('useChainId hook', () => { // Reset mocks before each test beforeEach(() => { - ;(useRouter as any).mockImplementation(() => ({ - query: {}, - })) + ;(useParams as any).mockImplementation(() => ({})) Object.defineProperty(window, 'location', { writable: true, @@ -29,9 +25,7 @@ describe('useChainId hook', () => { }) it('should read location.pathname if useRouter query.safe is empty', () => { - ;(useRouter as any).mockImplementation(() => ({ - query: {}, - })) + ;(useParams as any).mockImplementation(() => ({})) Object.defineProperty(window, 'location', { writable: true, @@ -47,9 +41,7 @@ describe('useChainId hook', () => { }) it('should read location.search if useRouter query.safe is empty', () => { - ;(useRouter as any).mockImplementation(() => ({ - query: {}, - })) + ;(useParams as any).mockImplementation(() => ({})) Object.defineProperty(window, 'location', { writable: true, @@ -65,9 +57,7 @@ describe('useChainId hook', () => { }) it('should read location.search if useRouter query.chain is empty', () => { - ;(useRouter as any).mockImplementation(() => ({ - query: {}, - })) + ;(useParams as any).mockImplementation(() => ({})) Object.defineProperty(window, 'location', { writable: true, @@ -88,10 +78,8 @@ describe('useChainId hook', () => { }) it('should return the chainId based on the chain query', () => { - ;(useRouter as any).mockImplementation(() => ({ - query: { - chain: 'gno', - }, + ;(useParams as any).mockImplementation(() => ({ + chain: 'gno', })) const { result } = renderHook(() => useChainId()) @@ -99,10 +87,8 @@ describe('useChainId hook', () => { }) it('should return the chainId from the safe address', () => { - ;(useRouter as any).mockImplementation(() => ({ - query: { - safe: 'matic:0x0000000000000000000000000000000000000000', - }, + ;(useParams as any).mockImplementation(() => ({ + safe: 'matic:0x0000000000000000000000000000000000000000', })) const { result } = renderHook(() => useChainId()) @@ -110,9 +96,7 @@ describe('useChainId hook', () => { }) it('should return the wallet chain id if no chain in the URL and it is present in the chain configs', () => { - ;(useRouter as any).mockImplementation(() => ({ - query: {}, - })) + ;(useParams as any).mockImplementation(() => ({})) jest.spyOn(useWalletHook, 'default').mockImplementation( () => @@ -130,9 +114,7 @@ describe('useChainId hook', () => { }) it('should return the last used chain id if no chain in the URL and the connect wallet chain id is not present in the chain configs', () => { - ;(useRouter as any).mockImplementation(() => ({ - query: {}, - })) + ;(useParams as any).mockImplementation(() => ({})) jest.spyOn(useWalletHook, 'default').mockImplementation( () => @@ -150,9 +132,7 @@ describe('useChainId hook', () => { }) it('should return the last used chain id if no wallet is connected and there is no chain in the URL', () => { - ;(useRouter as any).mockImplementation(() => ({ - query: {}, - })) + ;(useParams as any).mockImplementation(() => ({})) renderHook(() => useAppDispatch()(setLastChainId('100'))) diff --git a/src/hooks/__tests__/useSafeTokenAllocation.test.ts b/src/hooks/__tests__/useSafeTokenAllocation.test.ts index c6d3c22dba..1eef4008f6 100644 --- a/src/hooks/__tests__/useSafeTokenAllocation.test.ts +++ b/src/hooks/__tests__/useSafeTokenAllocation.test.ts @@ -1,6 +1,11 @@ import { renderHook, waitFor } from '@/tests/test-utils' import { defaultAbiCoder, hexZeroPad, keccak256, parseEther, toUtf8Bytes } from 'ethers/lib/utils' -import useSafeTokenAllocation, { type VestingData, _getRedeemDeadline } from '../useSafeTokenAllocation' +import useSafeTokenAllocation, { + type VestingData, + _getRedeemDeadline, + useSafeVotingPower, + type Vesting, +} from '../useSafeTokenAllocation' import * as web3 from '../wallets/web3' import * as useSafeInfoHook from '@/hooks/useSafeInfo' import { ZERO_ADDRESS } from '@safe-global/safe-core-sdk/dist/src/utils/constants' @@ -55,8 +60,7 @@ describe('_getRedeemDeadline', () => { }) }) -// TODO: use mockWeb3Provider() -describe('useSafeTokenAllocation', () => { +describe('Allocations', () => { afterEach(() => { //@ts-ignore global.fetch?.mockClear?.() @@ -84,361 +88,302 @@ describe('useSafeTokenAllocation', () => { ) }) - test('return undefined without safe address', async () => { - jest.spyOn(useSafeInfoHook, 'default').mockImplementation( - () => - ({ - safeAddress: undefined, - safe: { - address: undefined, - chainId: '1', - }, - } as any), - ) - - const { result } = renderHook(() => useSafeTokenAllocation()) - - await waitFor(() => { - expect(result.current[1]).toBeFalsy() - expect(result.current[0]).toBeUndefined() + describe('useSafeTokenAllocation', () => { + it('should return undefined without safe address', async () => { + jest.spyOn(useSafeInfoHook, 'default').mockImplementation( + () => + ({ + safeAddress: undefined, + safe: { + address: undefined, + chainId: '1', + }, + } as any), + ) + + const { result } = renderHook(() => useSafeTokenAllocation()) + + await waitFor(() => { + expect(result.current[1]).toBeFalsy() + expect(result.current[0]).toBeUndefined() + }) }) - }) - test('return 0 without web3Provider', async () => { - global.fetch = jest.fn().mockImplementation(setupFetchStub('', 404)) - const { result } = renderHook(() => useSafeTokenAllocation()) + it('should return an empty array without web3Provider', async () => { + global.fetch = jest.fn().mockImplementation(setupFetchStub('', 404)) + const { result } = renderHook(() => useSafeTokenAllocation()) - await waitFor(() => { - expect(result.current[1]).toBeFalsy() - expect(result.current[0]?.toNumber()).toEqual(0) + await waitFor(() => { + expect(result.current[1]).toBeFalsy() + expect(result.current[0]).toStrictEqual([]) + }) }) - }) - test('return 0 if no allocations / balances exist', async () => { - global.fetch = jest.fn().mockImplementation(setupFetchStub('', 404)) - const mockFetch = jest.spyOn(global, 'fetch') - jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation( - () => - ({ - call: (transaction: any, blockTag?: any) => { - const sigHash = keccak256(toUtf8Bytes('balanceOf(address)')).slice(0, 10) - if (transaction.data?.startsWith(sigHash)) { - return Promise.resolve('0x0') - } - return Promise.resolve('0x') - }, - } as any), - ) + it('should return an empty array if no allocations exist', async () => { + global.fetch = jest.fn().mockImplementation(setupFetchStub('', 404)) + const mockFetch = jest.spyOn(global, 'fetch') - const { result } = renderHook(() => useSafeTokenAllocation()) + const { result } = renderHook(() => useSafeTokenAllocation()) - await waitFor(() => { - expect(mockFetch).toHaveBeenCalled() - expect(result.current[0]?.toNumber()).toEqual(0) - expect(result.current[1]).toBeFalsy() + await waitFor(() => { + expect(mockFetch).toHaveBeenCalled() + expect(result.current[0]).toStrictEqual([]) + expect(result.current[1]).toBeFalsy() + }) }) - }) - test('return balance if no allocation exists', async () => { - global.fetch = jest.fn().mockImplementation(setupFetchStub('', 404)) - const mockFetch = jest.spyOn(global, 'fetch') - - jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation( - () => - ({ - call: (transaction: any, blockTag?: any) => { - const sigHash = keccak256(toUtf8Bytes('balanceOf(address)')).slice(0, 10) - if (transaction.data?.startsWith(sigHash)) { - return Promise.resolve(parseEther('100').toHexString()) - } - return Promise.resolve('0x') + it('should calculate expiration', async () => { + const mockAllocations = [ + { + tag: 'user', + account: hexZeroPad('0x2', 20), + chainId: 1, + contract: hexZeroPad('0xabc', 20), + vestingId: hexZeroPad('0x4110', 32), + durationWeeks: 208, + startDate: 1657231200, + amount: '2000', + curve: 0, + proof: [], + }, + ] + + global.fetch = jest.fn().mockImplementation(setupFetchStub(mockAllocations, 200)) + const mockFetch = jest.spyOn(global, 'fetch') + + jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation( + () => + ({ + call: (transaction: any) => { + const vestingsSigHash = keccak256(toUtf8Bytes('vestings(bytes32)')).slice(0, 10) + const redeemDeadlineSigHash = keccak256(toUtf8Bytes('redeemDeadline()')).slice(0, 10) + + if (transaction.data?.startsWith(vestingsSigHash)) { + return Promise.resolve( + defaultAbiCoder.encode( + ['address', 'uint8', 'bool', 'uint16', 'uint64', 'uint128', 'uint128', 'uint64', 'bool'], + [ZERO_ADDRESS, '0x1', false, 208, 1657231200, 2000, 0, 0, false], + ), + ) + } + if (transaction.data?.startsWith(redeemDeadlineSigHash)) { + // 30th Nov 2022 + return Promise.resolve(defaultAbiCoder.encode(['uint64'], [1669766400])) + } + return Promise.resolve('0x') + }, + } as any), + ) + + const { result } = renderHook(() => useSafeTokenAllocation()) + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalled() + expect(result.current[0]).toEqual([ + { + ...mockAllocations[0], + amountClaimed: '0', + isExpired: true, + isRedeemed: false, }, - } as any), - ) - - const { result } = renderHook(() => useSafeTokenAllocation()) - - await waitFor(() => { - expect(mockFetch).toHaveBeenCalled() - expect(result.current[0]?.eq(parseEther('100'))).toBeTruthy() - expect(result.current[1]).toBeFalsy() + ]) + expect(result.current[1]).toBeFalsy() + }) }) - }) - test('always return allocation if it is rededeemed', async () => { - const mockAllocation = [ - { - tag: 'user', - account: hexZeroPad('0x2', 20), - chainId: 1, - contract: hexZeroPad('0xabc', 20), - vestingId: hexZeroPad('0x4110', 32), - durationWeeks: 208, - startDate: 1657231200, - amount: '2000', - curve: 0, - proof: [], - }, - ] - - global.fetch = jest.fn().mockImplementation(setupFetchStub(mockAllocation, 200)) - const mockFetch = jest.spyOn(global, 'fetch') - - jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation( - () => - ({ - call: (transaction: any, blockTag?: any) => { - const balanceOfSigHash = keccak256(toUtf8Bytes('balanceOf(address)')).slice(0, 10) - const vestingsSigHash = keccak256(toUtf8Bytes('vestings(bytes32)')).slice(0, 10) - - if (transaction.data?.startsWith(balanceOfSigHash)) { - return Promise.resolve(parseEther('0').toHexString()) - } - if (transaction.data?.startsWith(vestingsSigHash)) { - return Promise.resolve( - defaultAbiCoder.encode( - ['address', 'uint8', 'bool', 'uint16', 'uint64', 'uint128', 'uint128', 'uint64', 'bool'], - [hexZeroPad('0x2', 20), '0x1', false, 208, 1657231200, 2000, 0, 0, false], - ), - ) - } - return Promise.resolve('0x') + it('should calculate redemption', async () => { + const mockAllocation = [ + { + tag: 'user', + account: hexZeroPad('0x2', 20), + chainId: 1, + contract: hexZeroPad('0xabc', 20), + vestingId: hexZeroPad('0x4110', 32), + durationWeeks: 208, + startDate: 1657231200, + amount: '2000', + curve: 0, + proof: [], + }, + ] + + global.fetch = jest.fn().mockImplementation(setupFetchStub(mockAllocation, 200)) + const mockFetch = jest.spyOn(global, 'fetch') + + jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation( + () => + ({ + call: (transaction: any) => { + const vestingsSigHash = keccak256(toUtf8Bytes('vestings(bytes32)')).slice(0, 10) + const redeemDeadlineSigHash = keccak256(toUtf8Bytes('redeemDeadline()')).slice(0, 10) + + if (transaction.data?.startsWith(vestingsSigHash)) { + return Promise.resolve( + defaultAbiCoder.encode( + ['address', 'uint8', 'bool', 'uint16', 'uint64', 'uint128', 'uint128', 'uint64', 'bool'], + [hexZeroPad('0x2', 20), '0x1', false, 208, 1657231200, 2000, 0, 0, false], + ), + ) + } + if (transaction.data?.startsWith(redeemDeadlineSigHash)) { + // 08.Dec 2200 + return Promise.resolve(defaultAbiCoder.encode(['uint64'], [7287610110])) + } + return Promise.resolve('0x') + }, + } as any), + ) + + const { result } = renderHook(() => useSafeTokenAllocation()) + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalled() + expect(result.current[0]).toEqual([ + { + ...mockAllocation[0], + amountClaimed: BigNumber.from(0), + isExpired: false, + isRedeemed: true, }, - } as any), - ) - - const { result } = renderHook(() => useSafeTokenAllocation()) - - await waitFor(() => { - expect(mockFetch).toHaveBeenCalled() - expect(result.current[0]?.toNumber()).toEqual(2000) - expect(result.current[1]).toBeFalsy() + ]) + expect(result.current[1]).toBeFalsy() + }) }) }) - test('ignore not redeemed allocations if deadline has passed', async () => { - const mockAllocation = [ - { - tag: 'user', - account: hexZeroPad('0x2', 20), - chainId: 1, - contract: hexZeroPad('0xabc', 20), - vestingId: hexZeroPad('0x4110', 32), - durationWeeks: 208, - startDate: 1657231200, - amount: '2000', - curve: 0, - proof: [], - }, - ] - - global.fetch = jest.fn().mockImplementation(setupFetchStub(mockAllocation, 200)) - const mockFetch = jest.spyOn(global, 'fetch') - - jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation( - () => - ({ - call: (transaction: any, blockTag?: any) => { - const balanceOfSigHash = keccak256(toUtf8Bytes('balanceOf(address)')).slice(0, 10) - const vestingsSigHash = keccak256(toUtf8Bytes('vestings(bytes32)')).slice(0, 10) - const redeemDeadlineSigHash = keccak256(toUtf8Bytes('redeemDeadline()')).slice(0, 10) - - if (transaction.data?.startsWith(balanceOfSigHash)) { - return Promise.resolve(parseEther('0').toHexString()) - } - if (transaction.data?.startsWith(vestingsSigHash)) { - return Promise.resolve( - defaultAbiCoder.encode( - ['address', 'uint8', 'bool', 'uint16', 'uint64', 'uint128', 'uint128', 'uint64', 'bool'], - [ZERO_ADDRESS, 0, false, 0, 0, 0, 0, 0, false], - ), - ) - } - if (transaction.data?.startsWith(redeemDeadlineSigHash)) { - // 30th Nov 2022 - return Promise.resolve(defaultAbiCoder.encode(['uint64'], [1669766400])) - } - return Promise.resolve('0x') - }, - } as any), - ) - - const { result } = renderHook(() => useSafeTokenAllocation()) + describe('useSafeTokenBalance', () => { + it('should return undefined without allocation data', async () => { + const { result } = renderHook(() => useSafeVotingPower()) - await waitFor(() => { - expect(mockFetch).toHaveBeenCalled() - expect(result.current[0]?.toNumber()).toEqual(0) - expect(result.current[1]).toBeFalsy() + await waitFor(() => { + expect(result.current[1]).toBeFalsy() + expect(result.current[0]).toBeUndefined() + }) }) - }) - test('add not redeemed allocations if deadline has not passed', async () => { - const mockAllocation = [ - { - tag: 'user', - account: hexZeroPad('0x2', 20), - chainId: 1, - contract: hexZeroPad('0xabc', 20), - vestingId: hexZeroPad('0x4110', 32), - durationWeeks: 208, - startDate: 1657231200, - amount: '2000', - curve: 0, - proof: [], - }, - ] - - global.fetch = jest.fn().mockImplementation(setupFetchStub(mockAllocation, 200)) - const mockFetch = jest.spyOn(global, 'fetch') - - jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation( - () => - ({ - call: (transaction: any, blockTag?: any) => { - const balanceOfSigHash = keccak256(toUtf8Bytes('balanceOf(address)')).slice(0, 10) - const vestingsSigHash = keccak256(toUtf8Bytes('vestings(bytes32)')).slice(0, 10) - const redeemDeadlineSigHash = keccak256(toUtf8Bytes('redeemDeadline()')).slice(0, 10) - - if (transaction.data?.startsWith(balanceOfSigHash)) { - return Promise.resolve(parseEther('0').toHexString()) - } - if (transaction.data?.startsWith(vestingsSigHash)) { - return Promise.resolve( - defaultAbiCoder.encode( - ['address', 'uint8', 'bool', 'uint16', 'uint64', 'uint128', 'uint128', 'uint64', 'bool'], - [ZERO_ADDRESS, 0, false, 0, 0, 0, 0, 0, false], - ), - ) - } - if (transaction.data?.startsWith(redeemDeadlineSigHash)) { - // 08.Dec 2200 - return Promise.resolve(defaultAbiCoder.encode(['uint64'], [7287610110])) - } - return Promise.resolve('0x') - }, - } as any), - ) - - const { result } = renderHook(() => useSafeTokenAllocation()) - - await waitFor(() => { - expect(mockFetch).toHaveBeenCalled() - expect(result.current[0]?.toNumber()).toEqual(2000) - expect(result.current[1]).toBeFalsy() + it('should return undefined without safe address', async () => { + jest.spyOn(useSafeInfoHook, 'default').mockImplementation( + () => + ({ + safeAddress: undefined, + safe: { + address: undefined, + chainId: '1', + }, + } as any), + ) + + const { result } = renderHook(() => useSafeVotingPower([{} as Vesting])) + + await waitFor(() => { + expect(result.current[1]).toBeFalsy() + expect(result.current[0]).toBeUndefined() + }) }) - }) - test('test formula: allocation - claimed + balance', async () => { - const mockAllocation = [ - { - tag: 'user', - account: hexZeroPad('0x2', 20), - chainId: 1, - contract: hexZeroPad('0xabc', 20), - vestingId: hexZeroPad('0x4110', 32), - durationWeeks: 208, - startDate: 1657231200, - amount: '2000', - curve: 0, - proof: [], - }, - ] - - global.fetch = jest.fn().mockImplementation(setupFetchStub(mockAllocation, 200)) - const mockFetch = jest.spyOn(global, 'fetch') - - jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation( - () => - ({ - call: (transaction: any, blockTag?: any) => { - const balanceOfSigHash = keccak256(toUtf8Bytes('balanceOf(address)')).slice(0, 10) - const vestingsSigHash = keccak256(toUtf8Bytes('vestings(bytes32)')).slice(0, 10) - const redeemDeadlineSigHash = keccak256(toUtf8Bytes('redeemDeadline()')).slice(0, 10) - - if (transaction.data?.startsWith(balanceOfSigHash)) { - return Promise.resolve(BigNumber.from('400').toHexString()) - } - if (transaction.data?.startsWith(vestingsSigHash)) { - return Promise.resolve( - defaultAbiCoder.encode( - ['address', 'uint8', 'bool', 'uint16', 'uint64', 'uint128', 'uint128', 'uint64', 'bool'], - // 1000 of 2000 tokens are claimed - [hexZeroPad('0x2', 20), '0x1', false, 208, 1657231200, 2000, 1000, 0, false], - ), - ) - } - if (transaction.data?.startsWith(redeemDeadlineSigHash)) { - // 08.Dec 2200 - return Promise.resolve(defaultAbiCoder.encode(['uint64'], [7287610110])) - } - return Promise.resolve('0x') - }, - } as any), - ) - - const { result } = renderHook(() => useSafeTokenAllocation()) - - await waitFor(() => { - expect(mockFetch).toHaveBeenCalled() - expect(result.current[0]?.toNumber()).toEqual(2000 - 1000 + 400) - expect(result.current[1]).toBeFalsy() + it('should return balance if no allocation exists', async () => { + jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation( + () => + ({ + call: (transaction: any) => { + const sigHash = keccak256(toUtf8Bytes('balanceOf(address)')).slice(0, 10) + + if (transaction.data?.startsWith(sigHash)) { + return Promise.resolve(parseEther('100').toHexString()) + } + return Promise.resolve('0x') + }, + } as any), + ) + + const { result } = renderHook(() => useSafeVotingPower()) + + await waitFor(() => { + expect(result.current[0]?.eq(parseEther('100'))).toBeTruthy() + expect(result.current[1]).toBeFalsy() + }) }) - }) - test('test formula: allocation - claimed + balance, everything claimed and no balance', async () => { - const mockAllocation = [ - { - tag: 'user', - account: hexZeroPad('0x2', 20), - chainId: 1, - contract: hexZeroPad('0xabc', 20), - vestingId: hexZeroPad('0x4110', 32), - durationWeeks: 208, - startDate: 1657231200, - amount: '2000', - curve: 0, - proof: [], - }, - ] - - global.fetch = jest.fn().mockImplementation(setupFetchStub(mockAllocation, 200)) - const mockFetch = jest.spyOn(global, 'fetch') - - jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation( - () => - ({ - call: (transaction: any, blockTag?: any) => { - const balanceOfSigHash = keccak256(toUtf8Bytes('balanceOf(address)')).slice(0, 10) - const vestingsSigHash = keccak256(toUtf8Bytes('vestings(bytes32)')).slice(0, 10) - const redeemDeadlineSigHash = keccak256(toUtf8Bytes('redeemDeadline()')).slice(0, 10) - - if (transaction.data?.startsWith(balanceOfSigHash)) { - return Promise.resolve(BigNumber.from('0').toHexString()) - } - if (transaction.data?.startsWith(vestingsSigHash)) { - return Promise.resolve( - defaultAbiCoder.encode( - ['address', 'uint8', 'bool', 'uint16', 'uint64', 'uint128', 'uint128', 'uint64', 'bool'], - // 1000 of 2000 tokens are claimed - [hexZeroPad('0x2', 20), '0x1', false, 208, 1657231200, 2000, 2000, 0, false], - ), - ) - } - if (transaction.data?.startsWith(redeemDeadlineSigHash)) { - // 08.Dec 2200 - return Promise.resolve(defaultAbiCoder.encode(['uint64'], [7287610110])) - } - return Promise.resolve('0x') - }, - } as any), - ) - - const { result } = renderHook(() => useSafeTokenAllocation()) + test('formula: allocation - claimed + balance', async () => { + jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation( + () => + ({ + call: (transaction: any) => { + const balanceOfSigHash = keccak256(toUtf8Bytes('balanceOf(address)')).slice(0, 10) + + if (transaction.data?.startsWith(balanceOfSigHash)) { + return Promise.resolve(BigNumber.from('400').toHexString()) + } + return Promise.resolve('0x') + }, + } as any), + ) + + const mockAllocation: Vesting[] = [ + { + tag: 'user', + account: hexZeroPad('0x2', 20), + chainId: 1, + contract: hexZeroPad('0xabc', 20), + vestingId: hexZeroPad('0x4110', 32), + durationWeeks: 208, + startDate: 1657231200, + amount: '2000', + curve: 0, + proof: [], + isExpired: false, + isRedeemed: false, + amountClaimed: '1000', + }, + ] + + const { result } = renderHook(() => useSafeVotingPower(mockAllocation)) + + await waitFor(() => { + expect(result.current[0]?.toNumber()).toEqual(2000 - 1000 + 400) + expect(result.current[1]).toBeFalsy() + }) + }) - await waitFor(() => { - expect(mockFetch).toHaveBeenCalled() - expect(result.current[0]?.toNumber()).toEqual(0) - expect(result.current[1]).toBeFalsy() + test('formula: allocation - claimed + balance, everything claimed and no balance', async () => { + jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation( + () => + ({ + call: (transaction: any) => { + const balanceOfSigHash = keccak256(toUtf8Bytes('balanceOf(address)')).slice(0, 10) + + if (transaction.data?.startsWith(balanceOfSigHash)) { + return Promise.resolve(BigNumber.from('0').toHexString()) + } + return Promise.resolve('0x') + }, + } as any), + ) + + const mockAllocation: Vesting[] = [ + { + tag: 'user', + account: hexZeroPad('0x2', 20), + chainId: 1, + contract: hexZeroPad('0xabc', 20), + vestingId: hexZeroPad('0x4110', 32), + durationWeeks: 208, + startDate: 1657231200, + amount: '2000', + curve: 0, + proof: [], + isExpired: false, + isRedeemed: false, + amountClaimed: '2000', + }, + ] + + const { result } = renderHook(() => useSafeVotingPower(mockAllocation)) + + await waitFor(() => { + expect(result.current[0]?.toNumber()).toEqual(0) + expect(result.current[1]).toBeFalsy() + }) }) }) }) diff --git a/src/hooks/useChainId.ts b/src/hooks/useChainId.ts index cb95e7ebd3..8be8d525ff 100644 --- a/src/hooks/useChainId.ts +++ b/src/hooks/useChainId.ts @@ -1,4 +1,4 @@ -import { useRouter } from 'next/router' +import { useParams } from 'next/navigation' import { parse, type ParsedUrlQuery } from 'querystring' import { IS_PRODUCTION } from '@/config/constants' import chains from '@/config/chains' @@ -31,11 +31,11 @@ const getLocationQuery = (): ParsedUrlQuery => { } export const useUrlChainId = (): string | undefined => { - const router = useRouter() + const queryParams = useParams() const { configs } = useChains() // Dynamic query params - const query = router && (router.query.safe || router.query.chain) ? router.query : getLocationQuery() + const query = queryParams && (queryParams.safe || queryParams.chain) ? queryParams : getLocationQuery() const chain = query.chain?.toString() || '' const safe = query.safe?.toString() || '' diff --git a/src/hooks/useSafeTokenAllocation.ts b/src/hooks/useSafeTokenAllocation.ts index 4712cd134c..b78b81ee24 100644 --- a/src/hooks/useSafeTokenAllocation.ts +++ b/src/hooks/useSafeTokenAllocation.ts @@ -6,7 +6,7 @@ import { isPast } from 'date-fns' import { BigNumber } from 'ethers' import { defaultAbiCoder, Interface } from 'ethers/lib/utils' import { useMemo } from 'react' -import useAsync from './useAsync' +import useAsync, { type AsyncResult } from './useAsync' import useSafeInfo from './useSafeInfo' import { getWeb3ReadOnly } from './wallets/web3' import { memoize } from 'lodash' @@ -30,7 +30,7 @@ export type VestingData = { proof: string[] } -type Vesting = VestingData & { +export type Vesting = VestingData & { isExpired: boolean isRedeemed: boolean amountClaimed: string @@ -107,6 +107,22 @@ const fetchAllocation = async (chainId: string, safeAddress: string): Promise => { + const { safe, safeAddress } = useSafeInfo() + const chainId = safe.chainId + + return useAsync(async () => { + if (!safeAddress) return + return Promise.all( + await fetchAllocation(chainId, safeAddress).then((allocations) => + allocations.map((allocation) => completeAllocation(allocation)), + ), + ) + // If the history tag changes we could have claimed / redeemed tokens + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chainId, safeAddress, safe.txHistoryTag]) +} + const fetchTokenBalance = async (chainId: string, safeAddress: string): Promise => { try { const web3ReadOnly = getWeb3ReadOnly() @@ -126,22 +142,11 @@ const fetchTokenBalance = async (chainId: string, safeAddress: string): Promise< * The Safe token allocation is equal to the voting power. * It is computed by adding all vested tokens - claimed tokens + token balance */ -const useSafeTokenAllocation = (): [BigNumber | undefined, boolean] => { +export const useSafeVotingPower = (allocationData?: Vesting[]): AsyncResult => { const { safe, safeAddress } = useSafeInfo() const chainId = safe.chainId - const [allocationData, _, allocationLoading] = useAsync(async () => { - if (!safeAddress) return - return Promise.all( - await fetchAllocation(chainId, safeAddress).then((allocations) => - allocations.map((allocation) => completeAllocation(allocation)), - ), - ) - // If the history tag changes we could have claimed / redeemed tokens - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [chainId, safeAddress, safe.txHistoryTag]) - - const [balance, _error, balanceLoading] = useAsync(() => { + const [balance, balanceError, balanceLoading] = useAsync(() => { if (!safeAddress) return return fetchTokenBalance(chainId, safeAddress) // If the history tag changes we could have claimed / redeemed tokens @@ -149,7 +154,14 @@ const useSafeTokenAllocation = (): [BigNumber | undefined, boolean] => { }, [chainId, safeAddress, safe.txHistoryTag]) const allocation = useMemo(() => { - if (!allocationData || !balance) return + if (!balance) { + return + } + + // Return current balance if no allocation exists + if (!allocationData) { + return BigNumber.from(balance) + } const tokensInVesting = allocationData.reduce( (acc, data) => (data.isExpired ? acc : acc.add(data.amount).sub(data.amountClaimed)), @@ -157,11 +169,11 @@ const useSafeTokenAllocation = (): [BigNumber | undefined, boolean] => { ) // add balance - const totalAllocation = tokensInVesting.add(balance || '0') + const totalAllocation = tokensInVesting.add(BigNumber.from(balance)) return totalAllocation }, [allocationData, balance]) - return [allocation, allocationLoading || balanceLoading] + return [allocation, balanceError, balanceLoading] } export default useSafeTokenAllocation diff --git a/src/hooks/useTxNotifications.ts b/src/hooks/useTxNotifications.ts index cbe777b785..a2f1e12e3e 100644 --- a/src/hooks/useTxNotifications.ts +++ b/src/hooks/useTxNotifications.ts @@ -16,23 +16,20 @@ import useSafeAddress from './useSafeAddress' import { getExplorerLink } from '@/utils/gateway' const TxNotifications = { - [TxEvent.SIGN_FAILED]: 'Signature failed. Please try again.', - [TxEvent.PROPOSED]: 'Your transaction was successfully proposed.', - [TxEvent.PROPOSE_FAILED]: 'Failed proposing the transaction. Please try again.', - [TxEvent.SIGNATURE_PROPOSED]: 'You successfully signed the transaction.', - [TxEvent.SIGNATURE_PROPOSE_FAILED]: 'Failed to send the signature. Please try again.', - [TxEvent.EXECUTING]: 'Please confirm the execution in your wallet.', - [TxEvent.PROCESSING]: 'Your transaction is being processed.', - [TxEvent.PROCESSING_MODULE]: - 'Your transaction has been submitted and will appear in the interface only after it has been successfully processed and indexed.', - [TxEvent.ONCHAIN_SIGNATURE_REQUESTED]: - 'An on-chain signature is required. Please confirm the transaction in your wallet.', - [TxEvent.ONCHAIN_SIGNATURE_SUCCESS]: - "The on-chain signature request was confirmed. Once it's on chain, the transaction will be signed.", - [TxEvent.PROCESSED]: 'Your transaction was successfully processed and is now being indexed.', - [TxEvent.REVERTED]: 'Transaction reverted. Please check your gas settings.', - [TxEvent.SUCCESS]: 'Your transaction was successfully executed.', - [TxEvent.FAILED]: 'Your transaction was unsuccessful.', + [TxEvent.SIGN_FAILED]: 'Failed to sign. Please try again.', + [TxEvent.PROPOSED]: 'Successfully added to queue.', + [TxEvent.PROPOSE_FAILED]: 'Failed to add to queue. Please try again.', + [TxEvent.SIGNATURE_PROPOSED]: 'Successfully signed.', + [TxEvent.SIGNATURE_PROPOSE_FAILED]: 'Failed to send signature. Please try again.', + [TxEvent.EXECUTING]: 'Confirm the execution in your wallet.', + [TxEvent.PROCESSING]: 'Validating...', + [TxEvent.PROCESSING_MODULE]: 'Validating module interaction...', + [TxEvent.ONCHAIN_SIGNATURE_REQUESTED]: 'Confirm on-chain signature in your wallet.', + [TxEvent.ONCHAIN_SIGNATURE_SUCCESS]: 'On-chain signature request confirmed.', + [TxEvent.PROCESSED]: 'Successfully validated. Indexing...', + [TxEvent.REVERTED]: 'Reverted. Please check your gas settings.', + [TxEvent.SUCCESS]: 'Successfully executed.', + [TxEvent.FAILED]: 'Failed.', } enum Variant { @@ -79,9 +76,12 @@ const useTxNotifications = (): void => { const txId = 'txId' in detail ? detail.txId : undefined const txHash = 'txHash' in detail ? detail.txHash : undefined const groupKey = 'groupKey' in detail && detail.groupKey ? detail.groupKey : txId || '' + const humanDescription = + 'humanDescription' in detail && detail.humanDescription ? detail.humanDescription : 'Transaction' dispatch( showNotification({ + title: humanDescription, message, detailedMessage: isError ? detail.error.message : undefined, groupKey, diff --git a/src/hooks/wallets/consts.ts b/src/hooks/wallets/consts.ts index 8150309c3e..10a3bb99fa 100644 --- a/src/hooks/wallets/consts.ts +++ b/src/hooks/wallets/consts.ts @@ -1,23 +1,19 @@ export const enum WALLET_KEYS { - COINBASE = 'COINBASE', INJECTED = 'INJECTED', - KEYSTONE = 'KEYSTONE', - LEDGER = 'LEDGER', + WALLETCONNECT_V2 = 'WALLETCONNECT_V2', + COINBASE = 'COINBASE', PAIRING = 'PAIRING', + LEDGER = 'LEDGER', TREZOR = 'TREZOR', - WALLETCONNECT = 'WALLETCONNECT', - WALLETCONNECT_V2 = 'WALLETCONNECT_V2', - TAHO = 'TAHO', + KEYSTONE = 'KEYSTONE', } export const CGW_NAMES: { [key in WALLET_KEYS]: string | undefined } = { - [WALLET_KEYS.COINBASE]: 'coinbase', [WALLET_KEYS.INJECTED]: 'detectedwallet', - [WALLET_KEYS.KEYSTONE]: 'keystone', - [WALLET_KEYS.LEDGER]: 'ledger', + [WALLET_KEYS.WALLETCONNECT_V2]: 'walletConnect_v2', + [WALLET_KEYS.COINBASE]: 'coinbase', [WALLET_KEYS.PAIRING]: 'safeMobile', + [WALLET_KEYS.LEDGER]: 'ledger', [WALLET_KEYS.TREZOR]: 'trezor', - [WALLET_KEYS.WALLETCONNECT]: 'walletConnect', - [WALLET_KEYS.WALLETCONNECT_V2]: 'walletConnect_v2', - [WALLET_KEYS.TAHO]: 'tally', + [WALLET_KEYS.KEYSTONE]: 'keystone', } diff --git a/src/hooks/wallets/useInitWeb3.ts b/src/hooks/wallets/useInitWeb3.ts index 04dc3da0a6..1015214a39 100644 --- a/src/hooks/wallets/useInitWeb3.ts +++ b/src/hooks/wallets/useInitWeb3.ts @@ -6,33 +6,29 @@ import { createWeb3, createWeb3ReadOnly, setWeb3, setWeb3ReadOnly } from '@/hook import { useAppSelector } from '@/store' import { selectRpc } from '@/store/settingsSlice' -const READONLY_WAIT = 1000 - export const useInitWeb3 = () => { const chain = useCurrentChain() + const chainId = chain?.chainId + const rpcUri = chain?.rpcUri const wallet = useWallet() const customRpc = useAppSelector(selectRpc) const customRpcUrl = chain ? customRpc?.[chain.chainId] : undefined useEffect(() => { - if (!chain) return - - if (wallet) { + if (wallet && wallet.chainId === chainId) { const web3 = createWeb3(wallet.provider) setWeb3(web3) - - if (wallet.chainId === chain.chainId) { - setWeb3ReadOnly(web3) - return - } + } else { + setWeb3(undefined) } + }, [wallet, chainId]) - // Wait for wallet to be connected - const timeout = setTimeout(() => { - const web3ReadOnly = createWeb3ReadOnly(chain.rpcUri, customRpcUrl) - setWeb3ReadOnly(web3ReadOnly) - }, READONLY_WAIT) - - return () => clearTimeout(timeout) - }, [wallet, chain, customRpcUrl]) + useEffect(() => { + if (!rpcUri) { + setWeb3ReadOnly(undefined) + return + } + const web3ReadOnly = createWeb3ReadOnly(rpcUri, customRpcUrl) + setWeb3ReadOnly(web3ReadOnly) + }, [rpcUri, customRpcUrl]) } diff --git a/src/hooks/wallets/useOnboard.ts b/src/hooks/wallets/useOnboard.ts index 5e38bb1ab1..2781d67d86 100644 --- a/src/hooks/wallets/useOnboard.ts +++ b/src/hooks/wallets/useOnboard.ts @@ -68,8 +68,7 @@ const getWalletConnectLabel = async (wallet: ConnectedWallet): Promise { diff --git a/src/hooks/wallets/wallets.ts b/src/hooks/wallets/wallets.ts index e85b7861f3..4a9e27aa38 100644 --- a/src/hooks/wallets/wallets.ts +++ b/src/hooks/wallets/wallets.ts @@ -1,5 +1,5 @@ -import { CYPRESS_MNEMONIC, TREZOR_APP_URL, TREZOR_EMAIL, WC_BRIDGE, WC_PROJECT_ID } from '@/config/constants' -import type { RecommendedInjectedWallets, WalletInit, WalletModule } from '@web3-onboard/common/dist/types.d' +import { CYPRESS_MNEMONIC, TREZOR_APP_URL, TREZOR_EMAIL, WC_PROJECT_ID } from '@/config/constants' +import type { RecommendedInjectedWallets, WalletInit } from '@web3-onboard/common/dist/types.d' import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import coinbaseModule from '@web3-onboard/coinbase' @@ -8,7 +8,6 @@ import keystoneModule from '@web3-onboard/keystone/dist/index' import ledgerModule from '@web3-onboard/ledger/dist/index' import trezorModule from '@web3-onboard/trezor' import walletConnect from '@web3-onboard/walletconnect' -import tahoModule from '@web3-onboard/taho' import pairingModule from '@/services/pairing/module' import e2eWalletModule from '@/tests/e2e-wallet' @@ -18,20 +17,6 @@ const prefersDarkMode = (): boolean => { return window?.matchMedia('(prefers-color-scheme: dark)')?.matches } -// We need to modify the module name as onboard dedupes modules with the same label and the WC v1 and v2 modules have the same -// @see https://github.com/blocknative/web3-onboard/blob/d399e0b76daf7b363d6a74b100b2c96ccb14536c/packages/core/src/store/actions.ts#L419 -// TODO: When removing this, also remove the associated CSS in `onboard.css` -export const WALLET_CONNECT_V1_MODULE_NAME = 'WalletConnect v1' -const walletConnectV1 = (): WalletInit => { - return (helpers) => { - const walletConnectModule = walletConnect({ version: 1, bridge: WC_BRIDGE })(helpers) as WalletModule - - walletConnectModule.label = WALLET_CONNECT_V1_MODULE_NAME - - return walletConnectModule - } -} - const walletConnectV2 = (chain: ChainInfo): WalletInit => { // WalletConnect v2 requires a project ID if (!WC_PROJECT_ID) { @@ -54,14 +39,12 @@ const walletConnectV2 = (chain: ChainInfo): WalletInit => { const WALLET_MODULES: { [key in WALLET_KEYS]: (chain: ChainInfo) => WalletInit } = { [WALLET_KEYS.INJECTED]: () => injectedWalletModule(), - [WALLET_KEYS.PAIRING]: () => pairingModule(), - [WALLET_KEYS.WALLETCONNECT]: () => walletConnectV1(), [WALLET_KEYS.WALLETCONNECT_V2]: (chain) => walletConnectV2(chain), + [WALLET_KEYS.COINBASE]: () => coinbaseModule({ darkMode: prefersDarkMode() }), + [WALLET_KEYS.PAIRING]: () => pairingModule(), [WALLET_KEYS.LEDGER]: () => ledgerModule(), [WALLET_KEYS.TREZOR]: () => trezorModule({ appUrl: TREZOR_APP_URL, email: TREZOR_EMAIL }), [WALLET_KEYS.KEYSTONE]: () => keystoneModule(), - [WALLET_KEYS.TAHO]: () => tahoModule(), - [WALLET_KEYS.COINBASE]: () => coinbaseModule({ darkMode: prefersDarkMode() }), } export const getAllWallets = (chain: ChainInfo): WalletInit[] => { diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index d6612c4179..568fa4f1e5 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -28,7 +28,7 @@ import { cgwDebugStorage } from '@/components/sidebar/DebugToggle' import { useTxTracking } from '@/hooks/useTxTracking' import { useSafeMsgTracking } from '@/hooks/messages/useSafeMsgTracking' import useGtm from '@/services/analytics/useGtm' -import useBeamer from '@/hooks/useBeamer' +import useBeamer from '@/hooks/Beamer/useBeamer' import ErrorBoundary from '@/components/common/ErrorBoundary' import createEmotionCache from '@/utils/createEmotionCache' import MetaTags from '@/components/common/MetaTags' diff --git a/src/services/analytics/events/overview.ts b/src/services/analytics/events/overview.ts index 5cdb7912e6..3fae0cbbf9 100644 --- a/src/services/analytics/events/overview.ts +++ b/src/services/analytics/events/overview.ts @@ -88,4 +88,8 @@ export const OVERVIEW_EVENTS = { action: 'Open relaying help article', category: OVERVIEW_CATEGORY, }, + SEP5_ALLOCATION_BUTTON: { + action: 'Click on SEP5 allocation button', + category: OVERVIEW_CATEGORY, + }, } diff --git a/src/services/beamer/index.ts b/src/services/beamer/index.ts index 6174c5b89e..edcb4afe69 100644 --- a/src/services/beamer/index.ts +++ b/src/services/beamer/index.ts @@ -65,6 +65,7 @@ export const unloadBeamer = (): void => { '_BEAMER_FILTER_BY_URL_', '_BEAMER_LAST_UPDATE_', '_BEAMER_BOOSTED_ANNOUNCEMENT_DATE_', + '_BEAMER_NPS_LAST_SHOWN_', ] if (!window?.Beamer || !scriptRef) { @@ -82,3 +83,16 @@ export const unloadBeamer = (): void => { BEAMER_COOKIES.forEach((name) => Cookies.remove(name, { domain, path: '/' })) }, 100) } + +export const shouldShowBeamerNps = (): boolean => { + if (!isBeamerLoaded() || !window?.Beamer) { + return false + } + + const COOKIE_NAME = `_BEAMER_NPS_LAST_SHOWN_${BEAMER_ID}` + + // Beamer advise using their '/nps/check' endpoint to see if the NPS should be shown + // As we need to check this more than the request limit, we instead check the cookie + // @see https://www.getbeamer.com/api + return !window.Beamer.getCookie(COOKIE_NAME) +} diff --git a/src/services/contracts/safeContracts.ts b/src/services/contracts/safeContracts.ts index a3ef2c4ffb..922584745d 100644 --- a/src/services/contracts/safeContracts.ts +++ b/src/services/contracts/safeContracts.ts @@ -1,7 +1,6 @@ import { getFallbackHandlerDeployment, getMultiSendCallOnlyDeployment, - getMultiSendDeployment, getProxyFactoryDeployment, getSafeL2SingletonDeployment, getSafeSingletonDeployment, @@ -102,28 +101,6 @@ export const getReadOnlyGnosisSafeContract = (chain: ChainInfo, safeVersion: str // MultiSend -const getMultiSendContractDeployment = (chainId: string) => { - return getMultiSendDeployment({ network: chainId }) || getMultiSendDeployment() -} - -export const getMultiSendContractAddress = (chainId: string): string | undefined => { - const deployment = getMultiSendContractDeployment(chainId) - - return deployment?.networkAddresses[chainId] -} - -// MultiSendCallOnly - -const getMultiSendCallOnlyContractDeployment = (chainId: string) => { - return getMultiSendCallOnlyDeployment({ network: chainId }) || getMultiSendCallOnlyDeployment() -} - -export const getMultiSendCallOnlyContractAddress = (chainId: string): string | undefined => { - const deployment = getMultiSendCallOnlyContractDeployment(chainId) - - return deployment?.networkAddresses[chainId] -} - export const getMultiSendCallOnlyContract = ( chainId: string, safeVersion: SafeInfo['version'] = LATEST_SAFE_VERSION, @@ -132,7 +109,7 @@ export const getMultiSendCallOnlyContract = ( const ethAdapter = createEthersAdapter(provider) return ethAdapter.getMultiSendCallOnlyContract({ - singletonDeployment: getMultiSendCallOnlyContractDeployment(chainId), + singletonDeployment: getMultiSendCallOnlyDeployment({ network: chainId, version: safeVersion || undefined }), ..._getValidatedGetContractProps(chainId, safeVersion), }) } @@ -144,7 +121,7 @@ export const getReadOnlyMultiSendCallOnlyContract = ( const ethAdapter = createReadOnlyEthersAdapter() return ethAdapter.getMultiSendCallOnlyContract({ - singletonDeployment: getMultiSendCallOnlyContractDeployment(chainId), + singletonDeployment: getMultiSendCallOnlyDeployment({ network: chainId, version: safeVersion || undefined }), ..._getValidatedGetContractProps(chainId, safeVersion), }) } diff --git a/src/services/tx/tx-sender/__tests__/ts-sender.test.ts b/src/services/tx/tx-sender/__tests__/ts-sender.test.ts index e12c1fa26c..0abf02594f 100644 --- a/src/services/tx/tx-sender/__tests__/ts-sender.test.ts +++ b/src/services/tx/tx-sender/__tests__/ts-sender.test.ts @@ -107,6 +107,9 @@ const mockSafeSDK = { createTransaction: jest.fn(() => ({ signatures: new Map(), addSignature: jest.fn(), + data: { + nonce: '1', + }, })), createRejectionTransaction: jest.fn(() => ({ addSignature: jest.fn(), @@ -399,7 +402,10 @@ describe('txSender', () => { expect((error as Error).message).toBe('rejected') - expect(txEvents.txDispatch).toHaveBeenCalledWith('SIGN_FAILED', { txId: '0x345', error }) + expect(txEvents.txDispatch).toHaveBeenCalledWith('SIGN_FAILED', { + txId: '0x345', + error, + }) expect(txEvents.txDispatch).not.toHaveBeenCalledWith('SIGNED', { txId: '0x345' }) } }) @@ -430,7 +436,10 @@ describe('txSender', () => { expect((error as Error).message).toBe('failure-specific error') - expect(txEvents.txDispatch).toHaveBeenCalledWith('SIGN_FAILED', { txId: '0x345', error }) + expect(txEvents.txDispatch).toHaveBeenCalledWith('SIGN_FAILED', { + txId: '0x345', + error, + }) expect(txEvents.txDispatch).not.toHaveBeenCalledWith('SIGNED', { txId: '0x345' }) } }) diff --git a/src/services/tx/tx-sender/dispatch.ts b/src/services/tx/tx-sender/dispatch.ts index 42e709d6cc..a5a885d0e5 100644 --- a/src/services/tx/tx-sender/dispatch.ts +++ b/src/services/tx/tx-sender/dispatch.ts @@ -63,6 +63,7 @@ export const dispatchTxProposal = async ({ txDispatch(txId ? TxEvent.SIGNATURE_PROPOSED : TxEvent.PROPOSED, { txId: proposedTx.txId, signerAddress: txId ? sender : undefined, + humanDescription: proposedTx?.txInfo?.humanDescription, }) } @@ -78,6 +79,7 @@ export const dispatchTxSigning = async ( onboard: OnboardAPI, chainId: SafeInfo['chainId'], txId?: string, + humanDescription?: string, ): Promise => { const sdk = await getSafeSDKWithSigner(onboard, chainId) @@ -85,7 +87,11 @@ export const dispatchTxSigning = async ( try { signedTx = await tryOffChainTxSigning(safeTx, safeVersion, sdk) } catch (error) { - txDispatch(TxEvent.SIGN_FAILED, { txId, error: asError(error) }) + txDispatch(TxEvent.SIGN_FAILED, { + txId, + error: asError(error), + humanDescription, + }) throw error } @@ -102,10 +108,11 @@ export const dispatchOnChainSigning = async ( txId: string, onboard: OnboardAPI, chainId: SafeInfo['chainId'], + humanDescription?: string, ) => { const sdkUnchecked = await getUncheckedSafeSDK(onboard, chainId) const safeTxHash = await sdkUnchecked.getTransactionHash(safeTx) - const eventParams = { txId } + const eventParams = { txId, humanDescription } try { // With the unchecked signer, the contract call resolves once the tx @@ -133,9 +140,10 @@ export const dispatchTxExecution = async ( onboard: OnboardAPI, chainId: SafeInfo['chainId'], safeAddress: string, + humanDescription?: string, ): Promise => { const sdkUnchecked = await getUncheckedSafeSDK(onboard, chainId) - const eventParams = { txId } + const eventParams = { txId, humanDescription } // Execute the tx let result: TransactionResult | undefined @@ -288,7 +296,10 @@ export const dispatchSpendingLimitTxExecution = async ( ?.wait() .then((receipt) => { if (didRevert(receipt)) { - txDispatch(TxEvent.REVERTED, { groupKey: id, error: new Error('Transaction reverted by EVM') }) + txDispatch(TxEvent.REVERTED, { + groupKey: id, + error: new Error('Transaction reverted by EVM'), + }) } else { txDispatch(TxEvent.PROCESSED, { groupKey: id, safeAddress }) } @@ -316,6 +327,7 @@ export const dispatchTxRelay = async ( safe: SafeInfo, txId: string, gasLimit?: string | number, + humanDescription?: string, ) => { const readOnlySafeContract = getReadOnlyCurrentGnosisSafeContract(safe) @@ -344,9 +356,9 @@ export const dispatchTxRelay = async ( txDispatch(TxEvent.RELAYING, { taskId, txId }) // Monitor relay tx - waitForRelayedTx(taskId, [txId], safe.address.value) + waitForRelayedTx(taskId, [txId], safe.address.value, humanDescription) } catch (error) { - txDispatch(TxEvent.FAILED, { txId, error: asError(error) }) + txDispatch(TxEvent.FAILED, { txId, error: asError(error), humanDescription }) throw error } } diff --git a/src/services/tx/txEvents.ts b/src/services/tx/txEvents.ts index 0adc2102de..6e63e527b1 100644 --- a/src/services/tx/txEvents.ts +++ b/src/services/tx/txEvents.ts @@ -24,25 +24,26 @@ export enum TxEvent { } type Id = { txId: string; groupKey?: string } | { txId?: string; groupKey: string } +type HumanDescription = { humanDescription?: string } interface TxEvents { [TxEvent.SIGNED]: { txId?: string } - [TxEvent.SIGN_FAILED]: { txId?: string; error: Error } - [TxEvent.PROPOSE_FAILED]: { error: Error } - [TxEvent.PROPOSED]: { txId: string } - [TxEvent.SIGNATURE_PROPOSE_FAILED]: { txId: string; error: Error } - [TxEvent.SIGNATURE_PROPOSED]: { txId: string; signerAddress: string } + [TxEvent.SIGN_FAILED]: HumanDescription & { txId?: string; error: Error } + [TxEvent.PROPOSE_FAILED]: HumanDescription & { error: Error } + [TxEvent.PROPOSED]: HumanDescription & { txId: string } + [TxEvent.SIGNATURE_PROPOSE_FAILED]: HumanDescription & { txId: string; error: Error } + [TxEvent.SIGNATURE_PROPOSED]: HumanDescription & { txId: string; signerAddress: string } [TxEvent.SIGNATURE_INDEXED]: { txId: string } - [TxEvent.ONCHAIN_SIGNATURE_REQUESTED]: Id - [TxEvent.ONCHAIN_SIGNATURE_SUCCESS]: Id - [TxEvent.EXECUTING]: Id - [TxEvent.PROCESSING]: Id & { txHash: string } - [TxEvent.PROCESSING_MODULE]: Id & { txHash: string } - [TxEvent.PROCESSED]: Id & { safeAddress: string } - [TxEvent.REVERTED]: Id & { error: Error } + [TxEvent.ONCHAIN_SIGNATURE_REQUESTED]: Id & HumanDescription + [TxEvent.ONCHAIN_SIGNATURE_SUCCESS]: Id & HumanDescription + [TxEvent.EXECUTING]: Id & HumanDescription + [TxEvent.PROCESSING]: Id & HumanDescription & { txHash: string } + [TxEvent.PROCESSING_MODULE]: Id & HumanDescription & { txHash: string } + [TxEvent.PROCESSED]: Id & HumanDescription & { safeAddress: string } + [TxEvent.REVERTED]: Id & HumanDescription & { error: Error } [TxEvent.RELAYING]: Id & { taskId: string } - [TxEvent.FAILED]: Id & { error: Error } - [TxEvent.SUCCESS]: Id + [TxEvent.FAILED]: Id & HumanDescription & { error: Error } + [TxEvent.SUCCESS]: Id & HumanDescription [TxEvent.SAFE_APPS_REQUEST]: { safeAppRequestId: RequestId; safeTxHash: string } [TxEvent.BATCH_ADD]: Id } diff --git a/src/services/tx/txMonitor.ts b/src/services/tx/txMonitor.ts index b75ac03f8d..878b47d61a 100644 --- a/src/services/tx/txMonitor.ts +++ b/src/services/tx/txMonitor.ts @@ -91,7 +91,13 @@ const getRelayTxStatus = async (taskId: string): Promise<{ task: TransactionStat const WAIT_FOR_RELAY_TIMEOUT = 3 * 60_000 // 3 minutes -export const waitForRelayedTx = (taskId: string, txIds: string[], safeAddress: string, groupKey?: string): void => { +export const waitForRelayedTx = ( + taskId: string, + txIds: string[], + safeAddress: string, + groupKey?: string, + humanDescription?: string, +): void => { let intervalId: NodeJS.Timeout let failAfterTimeoutId: NodeJS.Timeout @@ -110,6 +116,7 @@ export const waitForRelayedTx = (taskId: string, txIds: string[], safeAddress: s txId, groupKey, safeAddress, + humanDescription, }), ) break @@ -119,6 +126,7 @@ export const waitForRelayedTx = (taskId: string, txIds: string[], safeAddress: s txId, error: new Error(`Relayed transaction reverted by EVM.`), groupKey, + humanDescription, }), ) break @@ -128,6 +136,7 @@ export const waitForRelayedTx = (taskId: string, txIds: string[], safeAddress: s txId, error: new Error(`Relayed transaction was blacklisted by relay provider.`), groupKey, + humanDescription, }), ) break @@ -137,6 +146,7 @@ export const waitForRelayedTx = (taskId: string, txIds: string[], safeAddress: s txId, error: new Error(`Relayed transaction was cancelled by relay provider.`), groupKey, + humanDescription, }), ) break @@ -146,6 +156,7 @@ export const waitForRelayedTx = (taskId: string, txIds: string[], safeAddress: s txId, error: new Error(`Relayed transaction was not found.`), groupKey, + humanDescription, }), ) break @@ -168,6 +179,7 @@ export const waitForRelayedTx = (taskId: string, txIds: string[], safeAddress: s } minutes. Be aware that it might still be relayed.`, ), groupKey, + humanDescription, }), ) diff --git a/src/store/batchSlice.ts b/src/store/batchSlice.ts index fc93d682f6..31897e623a 100644 --- a/src/store/batchSlice.ts +++ b/src/store/batchSlice.ts @@ -74,7 +74,7 @@ export const batchSlice = createSlice({ export const { setBatch, addTx, removeTx } = batchSlice.actions const selectAllBatches = (state: RootState): BatchTxsState => { - return state[batchSlice.name] || {} + return state[batchSlice.name] || initialState } export const selectBatchBySafe = createSelector( diff --git a/src/store/notificationsSlice.ts b/src/store/notificationsSlice.ts index 0cfe38b788..36f4703877 100644 --- a/src/store/notificationsSlice.ts +++ b/src/store/notificationsSlice.ts @@ -7,6 +7,7 @@ export type Notification = { id: string message: string detailedMessage?: string + title?: string groupKey: string variant: AlertColor timestamp: number diff --git a/src/store/settingsSlice.ts b/src/store/settingsSlice.ts index 691e8d96f3..d31b752622 100644 --- a/src/store/settingsSlice.ts +++ b/src/store/settingsSlice.ts @@ -151,9 +151,12 @@ export const selectTokenList = (state: RootState): SettingsState['tokenList'] => return state[settingsSlice.name].tokenList || initialState.tokenList } -export const selectHiddenTokensPerChain = (state: RootState, chainId: string): string[] => { - return state[settingsSlice.name].hiddenTokens?.[chainId] || [] -} +export const selectHiddenTokensPerChain = createSelector( + [selectSettings, (_, chainId) => chainId], + (settings, chainId) => { + return settings.hiddenTokens?.[chainId] || [] + }, +) export const selectRpc = createSelector(selectSettings, (settings) => settings.env.rpc) diff --git a/src/store/txHistorySlice.ts b/src/store/txHistorySlice.ts index 2561ad2f85..4a771de1d3 100644 --- a/src/store/txHistorySlice.ts +++ b/src/store/txHistorySlice.ts @@ -28,7 +28,13 @@ export const txHistoryListener = (listenerMiddleware: typeof listenerMiddlewareI const txId = result.transaction.id if (pendingTxs[txId]) { - txDispatch(TxEvent.SUCCESS, { txId, groupKey: pendingTxs[txId].groupKey }) + const humanDescription = result.transaction.txInfo?.humanDescription + + txDispatch(TxEvent.SUCCESS, { + txId, + groupKey: pendingTxs[txId].groupKey, + humanDescription, + }) } } }, diff --git a/src/store/txQueueSlice.ts b/src/store/txQueueSlice.ts index 5d84b62437..6ec1a50c27 100644 --- a/src/store/txQueueSlice.ts +++ b/src/store/txQueueSlice.ts @@ -27,6 +27,14 @@ export const selectQueuedTransactionsByNonce = createSelector( }, ) +export const selectQueuedTransactionById = createSelector( + selectQueuedTransactions, + (_: RootState, txId?: string) => txId, + (queuedTransactions, txId?: string) => { + return (queuedTransactions || []).find((item) => item.transaction.id === txId) + }, +) + export const txQueueListener = (listenerMiddleware: typeof listenerMiddlewareInstance) => { listenerMiddleware.startListening({ actionCreator: txQueueSlice.actions.set, diff --git a/src/tests/pages/apps.test.tsx b/src/tests/pages/apps.test.tsx index 3674c745e1..1fa6d6bf65 100644 --- a/src/tests/pages/apps.test.tsx +++ b/src/tests/pages/apps.test.tsx @@ -25,6 +25,11 @@ jest.mock('@safe-global/safe-gateway-typescript-sdk', () => ({ getSafeApps: (chainId: string) => Promise.resolve(mockedSafeApps), })) +jest.mock('next/navigation', () => ({ + ...jest.requireActual('next/navigation'), + useParams: jest.fn(() => ({ safe: 'matic:0x0000000000000000000000000000000000000000' })), +})) + describe('AppsPage', () => { beforeEach(() => { jest.restoreAllMocks() diff --git a/src/utils/__tests__/transactions.test.ts b/src/utils/__tests__/transactions.test.ts index 6d73f5dc38..52c47123ac 100644 --- a/src/utils/__tests__/transactions.test.ts +++ b/src/utils/__tests__/transactions.test.ts @@ -5,6 +5,8 @@ import type { SafeAppData, Transaction, } from '@safe-global/safe-gateway-typescript-sdk' +import { TransactionInfoType } from '@safe-global/safe-gateway-typescript-sdk' +import { isMultiSendTxInfo } from '../transaction-guards' import { getQueuedTransactionCount, getTxOrigin } from '../transactions' describe('transactions', () => { @@ -110,4 +112,77 @@ describe('transactions', () => { ) }) }) + + describe('isMultiSendTxInfo', () => { + it('should return true for a multisend tx', () => { + expect( + isMultiSendTxInfo({ + type: TransactionInfoType.CUSTOM, + to: { + value: '0x40A2aCCbd92BCA938b02010E17A5b8929b49130D', + name: 'Gnosis Safe: MultiSendCallOnly', + logoUri: + 'https://safe-transaction-assets.safe.global/contracts/logos/0x40A2aCCbd92BCA938b02010E17A5b8929b49130D.png', + }, + dataSize: '1188', + value: '0', + methodName: 'multiSend', + actionCount: 3, + isCancellation: false, + }), + ).toBe(true) + }) + + it('should return false for non-multisend txs', () => { + expect( + isMultiSendTxInfo({ + type: TransactionInfoType.CUSTOM, + to: { + value: '0x40A2aCCbd92BCA938b02010E17A5b8929b49130D', + name: 'Gnosis Safe: MultiSendCallOnly', + logoUri: + 'https://safe-transaction-assets.safe.global/contracts/logos/0x40A2aCCbd92BCA938b02010E17A5b8929b49130D.png', + }, + dataSize: '1188', + value: '0', + methodName: 'multiSend', + //actionCount: 3, // missing actionCount + isCancellation: false, + }), + ).toBe(false) + + expect( + isMultiSendTxInfo({ + type: TransactionInfoType.CUSTOM, + to: { + value: '0x40A2aCCbd92BCA938b02010E17A5b8929b49130D', + name: 'Gnosis Safe: MultiSendCallOnly', + logoUri: + 'https://safe-transaction-assets.safe.global/contracts/logos/0x40A2aCCbd92BCA938b02010E17A5b8929b49130D.png', + }, + dataSize: '1188', + value: '0', + methodName: 'notMultiSend', // wrong method + actionCount: 3, + isCancellation: false, + }), + ).toBe(false) + + expect( + isMultiSendTxInfo({ + type: TransactionInfoType.SETTINGS_CHANGE, // wrong type + dataDecoded: { + method: 'changeThreshold', + parameters: [ + { + name: '_threshold', + type: 'uint256', + value: '2', + }, + ], + }, + }), + ).toBe(false) + }) + }) }) diff --git a/src/utils/transaction-guards.ts b/src/utils/transaction-guards.ts index 5622cc1b81..a8f402a8b6 100644 --- a/src/utils/transaction-guards.ts +++ b/src/utils/transaction-guards.ts @@ -35,7 +35,6 @@ import { } from '@safe-global/safe-gateway-typescript-sdk' import { getSpendingLimitModuleAddress } from '@/services/contracts/spendingLimitContracts' import { sameAddress } from '@/utils/addresses' -import { getMultiSendCallOnlyContractAddress, getMultiSendContractAddress } from '@/services/contracts/safeContracts' import type { NamedAddress } from '@/components/new-safe/create/types' export const isTxQueued = (value: TransactionStatus): boolean => { @@ -78,16 +77,12 @@ export const isCustomTxInfo = (value: TransactionInfo): value is Custom => { return value.type === TransactionInfoType.CUSTOM } -export const isSupportedMultiSendAddress = (txInfo: TransactionInfo, chainId: string): boolean => { - const toAddress = isCustomTxInfo(txInfo) ? txInfo.to.value : '' - const multiSendAddress = getMultiSendContractAddress(chainId) - const multiSendCallOnlyAddress = getMultiSendCallOnlyContractAddress(chainId) - - return sameAddress(multiSendAddress, toAddress) || sameAddress(multiSendCallOnlyAddress, toAddress) -} - export const isMultiSendTxInfo = (value: TransactionInfo): value is MultiSend => { - return value.type === TransactionInfoType.CUSTOM && value.methodName === 'multiSend' + return ( + value.type === TransactionInfoType.CUSTOM && + value.methodName === 'multiSend' && + typeof value.actionCount === 'number' + ) } export const isCancellationTxInfo = (value: TransactionInfo): value is Cancellation => { diff --git a/yarn.lock b/yarn.lock index eceda5cd86..22db38c51a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5040,15 +5040,6 @@ buffer "^6.0.3" ethereumjs-util "^7.1.3" -"@web3-onboard/taho@^2.0.5": - version "2.0.5" - resolved "https://registry.yarnpkg.com/@web3-onboard/taho/-/taho-2.0.5.tgz#899d147c234d61e1fb81045fc7339182c230c632" - integrity sha512-Z5n2UMumLNppOlDgYM9MhrM+YGyz8Emouaf7htH8l4B2r/meV4F3Wkgol2xYuwwu5SJyPaJH2GxNeh/EAfyBxg== - dependencies: - "@web3-onboard/common" "^2.3.3" - tallyho-detect-provider "^1.0.0" - tallyho-onboarding "^1.0.2" - "@web3-onboard/trezor@^2.4.2": version "2.4.2" resolved "https://registry.yarnpkg.com/@web3-onboard/trezor/-/trezor-2.4.2.tgz#49a485467d970ae872288c07eccb7adf18782622" @@ -5860,7 +5851,7 @@ borsh@^0.7.0: bs58 "^4.0.0" text-encoding-utf-8 "^1.0.2" -bowser@^2.11.0, bowser@^2.9.0: +bowser@^2.11.0: version "2.11.0" resolved "https://registry.yarnpkg.com/bowser/-/bowser-2.11.0.tgz#5ca3c35757a7aa5771500c70a73a9f91ef420a8f" integrity sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA== @@ -13410,18 +13401,6 @@ table-layout@^1.0.2: typical "^5.2.0" wordwrapjs "^4.0.0" -tallyho-detect-provider@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/tallyho-detect-provider/-/tallyho-detect-provider-1.0.2.tgz#6e462c34494217d39a83e22709dd855488b2d32d" - integrity sha512-VUGZiWUrKJUUjtnkib09tuNO7Kld4UWLs54nnNYP0tewvzmeb1VWDK0UTv88bEUcuRKx2YWGDIuOuK9v270Ewg== - -tallyho-onboarding@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/tallyho-onboarding/-/tallyho-onboarding-1.0.2.tgz#afc7dc4eb05b3a7861ead215e798585e1cbe2e91" - integrity sha512-bdFT/fNrFrq1BYVgjl/JKtwDmeS+z2u0415PoxmGmmYYRfdcKqXtEPImMoEbVwGtOeN0iFVohuS8ESrrAe+w7w== - dependencies: - bowser "^2.9.0" - tapable@^2.2.0: version "2.2.1" resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0"