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 (
-
+
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 (
)
@@ -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"