diff --git a/package.json b/package.json index 9d0e6688df..3745651984 100644 --- a/package.json +++ b/package.json @@ -69,9 +69,9 @@ "@web3-onboard/ledger": "2.3.2", "@web3-onboard/trezor": "^2.4.2", "@web3-onboard/walletconnect": "^2.4.5", + "blo": "^1.1.1", "classnames": "^2.3.1", "date-fns": "^2.29.2", - "ethereum-blockies-base64": "^1.0.2", "ethers": "5.7.2", "exponential-backoff": "^3.1.0", "firebase": "^10.3.1", diff --git a/src/components/batch/BatchSidebar/BatchTxItem.tsx b/src/components/batch/BatchSidebar/BatchTxItem.tsx index 3a538ba83c..afd633a1af 100644 --- a/src/components/batch/BatchSidebar/BatchTxItem.tsx +++ b/src/components/batch/BatchSidebar/BatchTxItem.tsx @@ -12,6 +12,9 @@ import { MethodDetails } from '@/components/transactions/TxDetails/TxData/Decode import { TxDataRow } from '@/components/transactions/TxDetails/Summary/TxDataRow' import { dateString } from '@/utils/formatters' import { BATCH_EVENTS, trackEvent } from '@/services/analytics' +import { TransactionInfoType } from '@safe-global/safe-gateway-typescript-sdk' +import useABTesting from '@/services/tracking/useAbTesting' +import { AbTest } from '@/services/tracking/abTesting' type BatchTxItemProps = DraftBatchItem & { id: string @@ -30,6 +33,8 @@ const BatchTxItem = ({ dragging = false, draggable = false, }: BatchTxItemProps) => { + const shouldDisplayHumanDescription = useABTesting(AbTest.HUMAN_DESCRIPTION) + const txSummary = useMemo( () => ({ timestamp, @@ -55,6 +60,9 @@ const BatchTxItem = ({ const handleExpand = () => { trackEvent(BATCH_EVENTS.BATCH_EXPAND_TX) } + const displayInfo = + (!txDetails.txInfo.richDecodedInfo && txDetails.txInfo.type !== TransactionInfoType.TRANSFER) || + !shouldDisplayHumanDescription return ( @@ -75,9 +83,7 @@ const BatchTxItem = ({ - - - + {displayInfo && } {onDelete && ( <> diff --git a/src/components/common/EthHashInfo/index.test.tsx b/src/components/common/EthHashInfo/index.test.tsx index 0fd8825c81..adaf9172c6 100644 --- a/src/components/common/EthHashInfo/index.test.tsx +++ b/src/components/common/EthHashInfo/index.test.tsx @@ -1,4 +1,4 @@ -import makeBlockie from 'ethereum-blockies-base64' +import { blo } from 'blo' import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import { act, fireEvent, render, waitFor } from '@/tests/test-utils' @@ -179,7 +179,7 @@ describe('EthHashInfo', () => { expect(container.querySelector('.icon')).toHaveAttribute( 'style', - `background-image: url(${makeBlockie(MOCK_SAFE_ADDRESS)}); width: 40px; height: 40px;`, + `background-image: url(${blo(MOCK_SAFE_ADDRESS)}); width: 40px; height: 40px;`, ) }) @@ -188,7 +188,7 @@ describe('EthHashInfo', () => { expect(container.querySelector('.icon')).toHaveAttribute( 'style', - `background-image: url(${makeBlockie(MOCK_SAFE_ADDRESS)}); width: 100px; height: 100px;`, + `background-image: url(${blo(MOCK_SAFE_ADDRESS)}); width: 100px; height: 100px;`, ) }) diff --git a/src/components/common/Identicon/index.tsx b/src/components/common/Identicon/index.tsx index fe25818a37..6b3d4d7623 100644 --- a/src/components/common/Identicon/index.tsx +++ b/src/components/common/Identicon/index.tsx @@ -1,6 +1,6 @@ import type { ReactElement, CSSProperties } from 'react' import { useMemo } from 'react' -import makeBlockie from 'ethereum-blockies-base64' +import { blo } from 'blo' import Skeleton from '@mui/material/Skeleton' import css from './styles.module.css' @@ -13,7 +13,7 @@ export interface IdenticonProps { const Identicon = ({ address, size = 40 }: IdenticonProps): ReactElement => { const style = useMemo(() => { try { - const blockie = makeBlockie(address) + const blockie = blo(address as `0x${string}`) return { backgroundImage: `url(${blockie})`, width: `${size}px`, diff --git a/src/components/safe-messages/MsgSummary/index.tsx b/src/components/safe-messages/MsgSummary/index.tsx index 21651cd01b..87fe2b9752 100644 --- a/src/components/safe-messages/MsgSummary/index.tsx +++ b/src/components/safe-messages/MsgSummary/index.tsx @@ -34,7 +34,7 @@ const MsgSummary = ({ msg }: { msg: SafeMessage }): ReactElement => { const isConfirmed = msg.status === SafeMessageStatus.CONFIRMED return ( - + diff --git a/src/components/settings/PushNotifications/GlobalPushNotifications.tsx b/src/components/settings/PushNotifications/GlobalPushNotifications.tsx index 7ef79e8f6f..55feb8c9c7 100644 --- a/src/components/settings/PushNotifications/GlobalPushNotifications.tsx +++ b/src/components/settings/PushNotifications/GlobalPushNotifications.tsx @@ -11,6 +11,7 @@ import { ListItemButton, ListItemIcon, ListItemText, + CircularProgress, } from '@mui/material' import { Fragment, useEffect, useMemo, useState } from 'react' import type { ReactElement } from 'react' @@ -44,7 +45,9 @@ export const transformAddedSafes = (addedSafes: AddedSafesState): NotifiableSafe } // Convert data structure of currently notified Safes -const transformCurrentSubscribedSafes = (allPreferences?: PushNotificationPreferences): NotifiableSafes | undefined => { +export const _transformCurrentSubscribedSafes = ( + allPreferences?: PushNotificationPreferences, +): NotifiableSafes | undefined => { if (!allPreferences) { return } @@ -60,7 +63,10 @@ const transformCurrentSubscribedSafes = (allPreferences?: PushNotificationPrefer } // Merges added Safes and currently notified Safes into a single data structure without duplicates -const mergeNotifiableSafes = (addedSafes: AddedSafesState, currentSubscriptions?: NotifiableSafes): NotifiableSafes => { +export const _mergeNotifiableSafes = ( + addedSafes: AddedSafesState, + currentSubscriptions?: NotifiableSafes, +): NotifiableSafes => { const notifiableSafes = transformAddedSafes(addedSafes) if (!currentSubscriptions) { @@ -78,13 +84,19 @@ const mergeNotifiableSafes = (addedSafes: AddedSafesState, currentSubscriptions? return notifiableSafes } -const getTotalNotifiableSafes = (notifiableSafes: NotifiableSafes): number => { +export const _getTotalNotifiableSafes = (notifiableSafes: NotifiableSafes): number => { return Object.values(notifiableSafes).reduce((acc, safeAddresses) => { return (acc += safeAddresses.length) }, 0) } -const areAllSafesSelected = (notifiableSafes: NotifiableSafes, selectedSafes: NotifiableSafes): boolean => { +export const _areAllSafesSelected = (notifiableSafes: NotifiableSafes, selectedSafes: NotifiableSafes): boolean => { + const entries = Object.entries(notifiableSafes) + + if (entries.length === 0) { + return false + } + return Object.entries(notifiableSafes).every(([chainId, safeAddresses]) => { const hasChain = Object.keys(selectedSafes).includes(chainId) const hasEverySafe = safeAddresses?.every((safeAddress) => selectedSafes[chainId]?.includes(safeAddress)) @@ -93,13 +105,24 @@ const areAllSafesSelected = (notifiableSafes: NotifiableSafes, selectedSafes: No } // Total number of signatures required to register selected Safes -const getTotalSignaturesRequired = (selectedSafes: NotifiableSafes, currentNotifiedSafes?: NotifiableSafes): number => { - return Object.keys(selectedSafes).filter((chainId) => { - return !Object.keys(currentNotifiedSafes || {}).includes(chainId) - }).length +export const _getTotalSignaturesRequired = ( + selectedSafes: NotifiableSafes, + currentNotifiedSafes?: NotifiableSafes, +): number => { + return Object.entries(selectedSafes) + .filter(([, safeAddresses]) => safeAddresses.length > 0) + .reduce((acc, [chainId, safeAddresses]) => { + const isNewChain = !currentNotifiedSafes?.[chainId] + const isNewSafe = safeAddresses.some((safeAddress) => !currentNotifiedSafes?.[chainId]?.includes(safeAddress)) + + if (isNewChain || isNewSafe) { + acc += 1 + } + return acc + }, 0) } -const shouldRegisterSelectedSafes = ( +export const _shouldRegisterSelectedSafes = ( selectedSafes: NotifiableSafes, currentNotifiedSafes?: NotifiableSafes, ): boolean => { @@ -108,7 +131,10 @@ const shouldRegisterSelectedSafes = ( }) } -const shouldUnregsiterSelectedSafes = (selectedSafes: NotifiableSafes, currentNotifiedSafes?: NotifiableSafes) => { +export const _shouldUnregsiterSelectedSafes = ( + selectedSafes: NotifiableSafes, + currentNotifiedSafes?: NotifiableSafes, +) => { return Object.entries(currentNotifiedSafes || {}).some(([chainId, safeAddresses]) => { return safeAddresses.some((safeAddress) => !selectedSafes[chainId]?.includes(safeAddress)) }) @@ -117,7 +143,7 @@ const shouldUnregsiterSelectedSafes = (selectedSafes: NotifiableSafes, currentNo // onSave logic // Safes that need to be registered with the service -const getSafesToRegister = ( +export const _getSafesToRegister = ( selectedSafes: NotifiableSafes, currentNotifiedSafes?: NotifiableSafes, ): NotifiableSafes | undefined => { @@ -141,7 +167,7 @@ const getSafesToRegister = ( } // Safes that need to be unregistered with the service -const getSafesToUnregister = ( +export const _getSafesToUnregister = ( selectedSafes: NotifiableSafes, currentNotifiedSafes?: NotifiableSafes, ): NotifiableSafes | undefined => { @@ -171,7 +197,7 @@ const getSafesToUnregister = ( } // Whether the device needs to be unregistered from the service -const shouldUnregisterDevice = ( +export const _shouldUnregisterDevice = ( chainId: string, safeAddresses: Array, currentNotifiedSafes?: NotifiableSafes, @@ -192,6 +218,7 @@ const shouldUnregisterDevice = ( export const GlobalPushNotifications = (): ReactElement | null => { const chains = useChains() const addedSafes = useAppSelector(selectAllAddedSafes) + const [isLoading, setIsLoading] = useState(false) const { dismissPushNotificationBanner } = useDismissPushNotificationsBanner() const { getAllPreferences } = useNotificationPreferences() @@ -204,7 +231,7 @@ export const GlobalPushNotifications = (): ReactElement | null => { // Current Safes registered for notifications in indexedDB const currentNotifiedSafes = useMemo(() => { const allPreferences = getAllPreferences() - return transformCurrentSubscribedSafes(allPreferences) + return _transformCurrentSubscribedSafes(allPreferences) }, [getAllPreferences]) // `currentNotifiedSafes` is initially undefined until indexedDB resolves @@ -222,15 +249,15 @@ export const GlobalPushNotifications = (): ReactElement | null => { // Merged added Safes and `currentNotifiedSafes` (in case subscriptions aren't added) const notifiableSafes = useMemo(() => { - return mergeNotifiableSafes(addedSafes, currentNotifiedSafes) + return _mergeNotifiableSafes(addedSafes, currentNotifiedSafes) }, [addedSafes, currentNotifiedSafes]) const totalNotifiableSafes = useMemo(() => { - return getTotalNotifiableSafes(notifiableSafes) + return _getTotalNotifiableSafes(notifiableSafes) }, [notifiableSafes]) const isAllSelected = useMemo(() => { - return areAllSafesSelected(notifiableSafes, selectedSafes) + return _areAllSafesSelected(notifiableSafes, selectedSafes) }, [notifiableSafes, selectedSafes]) const onSelectAll = () => { @@ -249,13 +276,13 @@ export const GlobalPushNotifications = (): ReactElement | null => { } const totalSignaturesRequired = useMemo(() => { - return getTotalSignaturesRequired(selectedSafes, currentNotifiedSafes) + return _getTotalSignaturesRequired(selectedSafes, currentNotifiedSafes) }, [currentNotifiedSafes, selectedSafes]) const canSave = useMemo(() => { return ( - shouldRegisterSelectedSafes(selectedSafes, currentNotifiedSafes) || - shouldUnregsiterSelectedSafes(selectedSafes, currentNotifiedSafes) + _shouldRegisterSelectedSafes(selectedSafes, currentNotifiedSafes) || + _shouldUnregsiterSelectedSafes(selectedSafes, currentNotifiedSafes) ) }, [selectedSafes, currentNotifiedSafes]) @@ -264,17 +291,20 @@ export const GlobalPushNotifications = (): ReactElement | null => { return } + setIsLoading(true) + // Although the (un-)registration functions will request permission in getToken we manually // check beforehand to prevent multiple promises in registrationPromises from throwing const isGranted = await requestNotificationPermission() if (!isGranted) { + setIsLoading(false) return } const registrationPromises: Array> = [] - const safesToRegister = getSafesToRegister(selectedSafes, currentNotifiedSafes) + const safesToRegister = _getSafesToRegister(selectedSafes, currentNotifiedSafes) if (safesToRegister) { registrationPromises.push(registerNotifications(safesToRegister)) @@ -284,10 +314,10 @@ export const GlobalPushNotifications = (): ReactElement | null => { }) } - const safesToUnregister = getSafesToUnregister(selectedSafes, currentNotifiedSafes) + const safesToUnregister = _getSafesToUnregister(selectedSafes, currentNotifiedSafes) if (safesToUnregister) { const unregistrationPromises = Object.entries(safesToUnregister).flatMap(([chainId, safeAddresses]) => { - if (shouldUnregisterDevice(chainId, safeAddresses, currentNotifiedSafes)) { + if (_shouldUnregisterDevice(chainId, safeAddresses, currentNotifiedSafes)) { return unregisterDeviceNotifications(chainId) } return safeAddresses.map((safeAddress) => unregisterSafeNotifications(chainId, safeAddress)) @@ -299,6 +329,8 @@ export const GlobalPushNotifications = (): ReactElement | null => { await Promise.all(registrationPromises) trackEvent(PUSH_NOTIFICATION_EVENTS.SAVE_SETTINGS) + + setIsLoading(false) } if (totalNotifiableSafes === 0) { @@ -322,8 +354,8 @@ export const GlobalPushNotifications = (): ReactElement | null => { {(isOk) => ( - )} diff --git a/src/components/settings/PushNotifications/PushNotificationsBanner/index.tsx b/src/components/settings/PushNotifications/PushNotificationsBanner/index.tsx index ea9148d134..31baf48345 100644 --- a/src/components/settings/PushNotifications/PushNotificationsBanner/index.tsx +++ b/src/components/settings/PushNotifications/PushNotificationsBanner/index.tsx @@ -126,7 +126,11 @@ export const PushNotificationsBanner = ({ children }: { children: ReactElement } const allPreferences = getAllPreferences() const safesToRegister = getSafesToRegister(addedSafes, allPreferences) - await assertWalletChain(onboard, safe.chainId) + try { + await assertWalletChain(onboard, safe.chainId) + } catch { + return + } await registerNotifications(safesToRegister) diff --git a/src/components/settings/PushNotifications/__tests__/GlobalPushNotifications.test.ts b/src/components/settings/PushNotifications/__tests__/GlobalPushNotifications.test.ts new file mode 100644 index 0000000000..b6f590b5ee --- /dev/null +++ b/src/components/settings/PushNotifications/__tests__/GlobalPushNotifications.test.ts @@ -0,0 +1,505 @@ +import { + transformAddedSafes, + _mergeNotifiableSafes, + _transformCurrentSubscribedSafes, + _getTotalNotifiableSafes, + _areAllSafesSelected, + _getTotalSignaturesRequired, + _shouldRegisterSelectedSafes, + _shouldUnregsiterSelectedSafes, + _getSafesToRegister, + _getSafesToUnregister, + _shouldUnregisterDevice, +} from '../GlobalPushNotifications' +import type { AddedSafesState } from '@/store/addedSafesSlice' + +describe('GlobalPushNotifications', () => { + describe('transformAddedSafes', () => { + it('should transform added safes into notifiable safes', () => { + const addedSafes = { + '1': { + '0x123': {}, + '0x456': {}, + }, + '4': { + '0x789': {}, + }, + } as unknown as AddedSafesState + + const expectedNotifiableSafes = { + '1': ['0x123', '0x456'], + '4': ['0x789'], + } + + expect(transformAddedSafes(addedSafes)).toEqual(expectedNotifiableSafes) + }) + }) + + describe('mergeNotifiableSafes', () => { + it('should merge added safes and current subscriptions', () => { + const addedSafes = { + '1': { + '0x123': {}, + '0x456': {}, + }, + '4': { + '0x789': {}, + }, + } as unknown as AddedSafesState + + const currentSubscriptions = { + '1': ['0x123', '0x789'], + '4': ['0x789'], + } + + const expectedNotifiableSafes = { + '1': ['0x123', '0x456', '0x789'], + '4': ['0x789'], + } + + expect(_mergeNotifiableSafes(addedSafes, currentSubscriptions)).toEqual(expectedNotifiableSafes) + }) + + it('should return added safes if there are no current subscriptions', () => { + const addedSafes = { + '1': { + '0x123': {}, + '0x456': {}, + }, + '4': { + '0x789': {}, + }, + } as unknown as AddedSafesState + + expect(_mergeNotifiableSafes(addedSafes)).toEqual(transformAddedSafes(addedSafes)) + }) + }) + + describe('transformCurrentSubscribedSafes', () => { + it('should transform current subscriptions into notifiable safes', () => { + const currentSubscriptions = { + '0x123': { + chainId: '1', + safeAddress: '0x123', + }, + '0x456': { + chainId: '1', + safeAddress: '0x456', + }, + '0x789': { + chainId: '4', + safeAddress: '0x789', + }, + } + + const expectedNotifiableSafes = { + '1': ['0x123', '0x456'], + '4': ['0x789'], + } + + expect(_transformCurrentSubscribedSafes(currentSubscriptions)).toEqual(expectedNotifiableSafes) + }) + + it('should return undefined if there are no current subscriptions', () => { + expect(_transformCurrentSubscribedSafes()).toBeUndefined() + }) + }) + + describe('getTotalNotifiableSafes', () => { + it('should return the total number of notifiable safes', () => { + const notifiableSafes = { + '1': ['0x123', '0x456'], + '4': ['0x789'], + } + + expect(_getTotalNotifiableSafes(notifiableSafes)).toEqual(3) + }) + + it('should return 0 if there are no notifiable safes', () => { + expect(_getTotalNotifiableSafes({})).toEqual(0) + }) + }) + + describe('areAllSafesSelected', () => { + it('should return true if all notifiable safes are selected', () => { + const notifiableSafes = { + '1': ['0x123', '0x456'], + '4': ['0x789'], + } + + const selectedSafes = { + '1': ['0x123', '0x456'], + '4': ['0x789'], + } + + expect(_areAllSafesSelected(notifiableSafes, selectedSafes)).toEqual(true) + }) + + it('should return false if not all notifiable safes are selected', () => { + const notifiableSafes = { + '1': ['0x123', '0x456'], + '4': ['0x789'], + } + + const selectedSafes = { + '1': ['0x123', '0x456'], + '4': ['0x123'], + } + + expect(_areAllSafesSelected(notifiableSafes, selectedSafes)).toEqual(false) + }) + + it('should return false if there are no notifiable safes', () => { + const notifiableSafes = {} + + const selectedSafes = { + '1': ['0x123', '0x456'], + '4': ['0x789'], + } + + expect(_areAllSafesSelected(notifiableSafes, selectedSafes)).toEqual(false) + }) + }) + + describe('getTotalSignaturesRequired', () => { + it('should return the total number of signatures required to register a new chain', () => { + const currentNotifiedSafes = { + '1': ['0x123', '0x456'], + '4': ['0x789'], + } + + const selectedSafes = { + ...currentNotifiedSafes, + '5': ['0xabc'], + } + + expect(_getTotalSignaturesRequired(selectedSafes, currentNotifiedSafes)).toEqual(1) + }) + + it('should return the total number of signatures required to register a new Safe', () => { + const currentNotifiedSafes = { + '1': ['0x123'], + '4': ['0x789'], + } + + const selectedSafes = { + '1': ['0x123'], + '4': [...currentNotifiedSafes['4'], '0xabc'], + } + + expect(_getTotalSignaturesRequired(selectedSafes, currentNotifiedSafes)).toEqual(1) + }) + + it('should return the total number of signatures required to register new chains/Safes', () => { + const currentNotifiedSafes = {} + + const selectedSafes = { + '1': ['0x123'], + '4': ['0x789', '0xabc'], + } + + expect(_getTotalSignaturesRequired(selectedSafes, currentNotifiedSafes)).toEqual(2) + }) + + it('should not increase the count if a new chain is empty', () => { + const currentNotifiedSafes = { + '1': ['0x123', '0x456'], + } + + const selectedSafes = { + '1': currentNotifiedSafes['1'], + '5': [], + } + + expect(_getTotalSignaturesRequired(selectedSafes, currentNotifiedSafes)).toEqual(0) + }) + + it('should not increase the count if a chain was removed', () => { + const currentNotifiedSafes = { + '1': ['0x123', '0x456'], + '4': ['0x789'], + } + + const selectedSafes = { + '1': currentNotifiedSafes['1'], + } + + expect(_getTotalSignaturesRequired(selectedSafes, currentNotifiedSafes)).toEqual(0) + }) + + it('should not increase the count if a Safe was removed', () => { + const currentNotifiedSafes = { + '1': ['0x123', '0x456'], + '4': ['0x789'], + } + + const selectedSafes = { + '1': currentNotifiedSafes['1'].slice(0, 1), + '4': ['0x789'], + } + + expect(_getTotalSignaturesRequired(selectedSafes, currentNotifiedSafes)).toEqual(0) + }) + + it('should not increase the count if a chain/Safe was removed', () => { + const currentNotifiedSafes = { + '1': ['0x123'], + '4': ['0x789', '0xabc'], + } + + const selectedSafes = {} + + expect(_getTotalSignaturesRequired(selectedSafes, currentNotifiedSafes)).toEqual(0) + }) + + it('should return 0 if there are no selected safes', () => { + const currentNotifiedSafes = { + '1': ['0x123'], + '4': ['0x789'], + } + + const selectedSafes = {} + + expect(_getTotalSignaturesRequired(selectedSafes, currentNotifiedSafes)).toEqual(0) + }) + }) + + describe('shouldRegisterSelectedSafes', () => { + it('should return true if there are safes to register', () => { + const currentNotifiedSafes = { + '1': ['0x123'], + '4': ['0x789'], + } + + const selectedSafes = { + '1': ['0x123', '0x456'], + '4': ['0x789'], + } + + const result = _shouldRegisterSelectedSafes(selectedSafes, currentNotifiedSafes) + expect(result).toBe(true) + }) + + it('should return true if there are chains to register', () => { + const currentNotifiedSafes = { + '1': ['0x123', '0x456'], + } + + const selectedSafes = { + '1': ['0x123', '0x456'], + '4': ['0x789'], + } + + const result = _shouldRegisterSelectedSafes(selectedSafes, currentNotifiedSafes) + expect(result).toBe(true) + }) + + it('should return true if there are safes/chains to register', () => { + const currentNotifiedSafes = { + '1': ['0x123', '0x456'], + '4': ['0x789'], + } + + const selectedSafes = { + '1': ['0x123', '0x456', '0x789'], + '4': ['0x789'], + } + + const result = _shouldRegisterSelectedSafes(selectedSafes, currentNotifiedSafes) + expect(result).toBe(true) + }) + + it('should return false if there are no safes to register', () => { + const selectedSafes = { + '1': ['0x123'], + '4': ['0x789'], + } + + const currentNotifiedSafes = { + '1': ['0x123'], + '4': ['0x789'], + } + + const result = _shouldRegisterSelectedSafes(selectedSafes, currentNotifiedSafes) + expect(result).toBe(false) + }) + }) + + describe('shouldUnregisterSelectedSafes', () => { + it('should return true if there are safes to unregister', () => { + const currentNotifiedSafes = { + '1': ['0x123', '0x456'], + '4': ['0x789'], + } + + const selectedSafes = { + '1': ['0x123'], + '4': ['0x789'], + } + + const result = _shouldUnregsiterSelectedSafes(selectedSafes, currentNotifiedSafes) + expect(result).toBe(true) + }) + + it('should return true if there are chains to unregister', () => { + const currentNotifiedSafes = { + '1': ['0x123', '0x456'], + '4': ['0x789', '0xabc'], + } + + const selectedSafes = { + '1': ['0x123', '0x456'], + } + + const result = _shouldUnregsiterSelectedSafes(selectedSafes, currentNotifiedSafes) + expect(result).toBe(true) + }) + + it('should return true if there are safes/chains to unregister', () => { + const currentNotifiedSafes = { + '1': ['0x123', '0x456'], + '4': ['0x789', '0xabc'], + } + + const selectedSafes = { + '1': ['0x123'], + } + + const result = _shouldUnregsiterSelectedSafes(selectedSafes, currentNotifiedSafes) + expect(result).toBe(true) + }) + + it('should return false if there are no safes to unregister', () => { + const currentNotifiedSafes = { + '1': ['0x123'], + '4': ['0x789'], + } + + const selectedSafes = { + '1': ['0x123'], + '4': ['0x789'], + } + + const result = _shouldUnregsiterSelectedSafes(selectedSafes, currentNotifiedSafes) + expect(result).toBe(false) + }) + }) + + describe('getSafesToRegister', () => { + it('returns the safes to register', () => { + const currentNotifiedSafes = { + 1: ['0x123'], + 2: ['0xabc'], + 4: ['0x789', '0xdef'], + } + const selectedSafes = { + 1: ['0x123', '0x456'], + 4: ['0x789'], + } + + const result = _getSafesToRegister(selectedSafes, currentNotifiedSafes) + + expect(result).toEqual({ + 1: ['0x456'], + }) + }) + + it('returns undefined if there are no safes to register', () => { + const currentNotifiedSafes = { + 1: ['0x123'], + 2: ['0xabc'], + 4: ['0x789', '0xdef'], + } + const selectedSafes = { + 1: ['0x123'], + 2: ['0xabc'], + 4: ['0x789', '0xdef'], + } + + const result = _getSafesToRegister(selectedSafes, currentNotifiedSafes) + + expect(result).toBeUndefined() + }) + }) + + describe('getSafesToUnregister', () => { + it('returns undefined if there are no current notified safes', () => { + const currentNotifiedSafes = undefined + const selectedSafes = { + 1: ['0x123', '0x456'], + 4: ['0x789'], + } + + const result = _getSafesToUnregister(selectedSafes, currentNotifiedSafes) + + expect(result).toBeUndefined() + }) + + it('returns the safes to unregister', () => { + const currentNotifiedSafes = { + 1: ['0x123'], + 2: ['0xabc'], + 4: ['0x789', '0xdef'], + } + const selectedSafes = { + 1: ['0x123', '0x456'], + 4: ['0x789'], + } + + const result = _getSafesToUnregister(selectedSafes, currentNotifiedSafes) + + expect(result).toEqual({ + 2: ['0xabc'], + 4: ['0xdef'], + }) + }) + + it('returns undefined if there are no safes to unregister', () => { + const currentNotifiedSafes = { + 1: ['0x123'], + 2: ['0xabc'], + 4: ['0x789', '0xdef'], + } + const selectedSafes = { + 1: ['0x123'], + 2: ['0xabc'], + 4: ['0x789', '0xdef'], + } + + const result = _getSafesToUnregister(selectedSafes, currentNotifiedSafes) + + expect(result).toBeUndefined() + }) + }) + + describe('shouldUnregisterDevice', () => { + const chainId = '1' + const safeAddresses = ['0x123', '0x456'] + const currentNotifiedSafes = { + '1': ['0x123', '0x456'], + '4': ['0x789'], + } + + it('returns true if all safe addresses are included in currentNotifiedSafes', () => { + const result = _shouldUnregisterDevice(chainId, safeAddresses, currentNotifiedSafes) + expect(result).toBe(true) + }) + + it('returns false if not all safe addresses are included in currentNotifiedSafes', () => { + const invalidSafeAddresses = ['0x123', '0x789'] + const result = _shouldUnregisterDevice(chainId, invalidSafeAddresses, currentNotifiedSafes) + expect(result).toBe(false) + }) + + it('returns false if currentNotifiedSafes is undefined', () => { + const result = _shouldUnregisterDevice(chainId, safeAddresses) + expect(result).toBe(false) + }) + + it('returns false if the length of safeAddresses is different from the length of currentNotifiedSafes', () => { + const invalidSafeAddresses = ['0x123'] + const result = _shouldUnregisterDevice(chainId, invalidSafeAddresses, currentNotifiedSafes) + expect(result).toBe(false) + }) + }) +}) diff --git a/src/components/settings/PushNotifications/logic.test.ts b/src/components/settings/PushNotifications/__tests__/logic.test.ts similarity index 53% rename from src/components/settings/PushNotifications/logic.test.ts rename to src/components/settings/PushNotifications/__tests__/logic.test.ts index e217ef05cd..f709addb8d 100644 --- a/src/components/settings/PushNotifications/logic.test.ts +++ b/src/components/settings/PushNotifications/__tests__/logic.test.ts @@ -4,8 +4,10 @@ import { hexZeroPad } from 'ethers/lib/utils' import { Web3Provider } from '@ethersproject/providers' import type { JsonRpcSigner } from '@ethersproject/providers' -import * as logic from './logic' -import packageJson from '../../../../package.json' +import * as logic from '../logic' +import * as web3 from '@/hooks/wallets/web3' +import packageJson from '../../../../../package.json' +import type { ConnectedWallet } from '@/services/onboard' jest.mock('firebase/messaging') @@ -29,6 +31,13 @@ Object.defineProperty(globalThis, 'location', { }, }) +const MM_SIGNATURE = + '0x844ba559793a122c5742e9d922ed1f4650d4efd8ea35191105ddaee6a604000165c14f56278bda8d52c9400cdaeaf5cdc38d3596264cc5ccd8f03e5619d5d9d41b' +const LEDGER_SIGNATURE = + '0xb1274687aea0d8b8578a3eb6da57979eee0a64225e04445a0858e6f8d0d1b5870cdff961513992d849e47e9b0a8d432019829f1e4958837fd86e034656766a4e00' +const ADJUSTED_LEDGER_SIGNATURE = + '0xb1274687aea0d8b8578a3eb6da57979eee0a64225e04445a0858e6f8d0d1b5870cdff961513992d849e47e9b0a8d432019829f1e4958837fd86e034656766a4e1b' + describe('Notifications', () => { let alertMock = jest.fn() @@ -88,20 +97,90 @@ describe('Notifications', () => { }) }) + describe('adjustLegerSignature', () => { + it('should return the same signature if not that of a Ledger', () => { + const adjustedSignature = logic._adjustLedgerSignatureV(MM_SIGNATURE) + + expect(adjustedSignature).toBe(MM_SIGNATURE) + }) + + it('should return an adjusted signature if is that of a Ledger and v is 0 or 1', () => { + const adjustedSignature = logic._adjustLedgerSignatureV(LEDGER_SIGNATURE) + + expect(adjustedSignature).toBe(ADJUSTED_LEDGER_SIGNATURE) + }) + + it('should return the same signature if v is 27 or 28', () => { + const adjustedSignature = logic._adjustLedgerSignatureV(MM_SIGNATURE) + + expect(adjustedSignature).toBe(MM_SIGNATURE) + }) + }) + describe('getRegisterDevicePayload', () => { it('should return the payload with signature', async () => { const token = crypto.randomUUID() jest.spyOn(firebase, 'getToken').mockImplementation(() => Promise.resolve(token)) const mockProvider = new Web3Provider(jest.fn()) - const signature = hexZeroPad('0x69420', 65) jest.spyOn(mockProvider, 'getSigner').mockImplementation( () => ({ - signMessage: jest.fn().mockResolvedValueOnce(signature), + signMessage: jest.fn().mockResolvedValueOnce(MM_SIGNATURE), + } as unknown as JsonRpcSigner), + ) + jest.spyOn(web3, 'createWeb3').mockImplementation(() => mockProvider) + + const uuid = crypto.randomUUID() + + const payload = await logic.getRegisterDevicePayload({ + safesToRegister: { + ['1']: [hexZeroPad('0x1', 20), hexZeroPad('0x2', 20)], + ['2']: [hexZeroPad('0x1', 20)], + }, + uuid, + wallet: { + label: 'MetaMask', + } as ConnectedWallet, + }) + + expect(payload).toStrictEqual({ + uuid, + cloudMessagingToken: token, + buildNumber: '0', + bundle: 'safe', + deviceType: DeviceType.WEB, + version: packageJson.version, + timestamp: expect.any(String), + safeRegistrations: [ + { + chainId: '1', + safes: [hexZeroPad('0x1', 20), hexZeroPad('0x2', 20)], + signatures: [MM_SIGNATURE], + }, + { + chainId: '2', + safes: [hexZeroPad('0x1', 20)], + signatures: [MM_SIGNATURE], + }, + ], + }) + }) + + it('should return the payload with a Ledger adjusted signature', async () => { + const token = crypto.randomUUID() + jest.spyOn(firebase, 'getToken').mockImplementation(() => Promise.resolve(token)) + + const mockProvider = new Web3Provider(jest.fn()) + + jest.spyOn(mockProvider, 'getSigner').mockImplementation( + () => + ({ + signMessage: jest.fn().mockResolvedValueOnce(LEDGER_SIGNATURE), } as unknown as JsonRpcSigner), ) + jest.spyOn(web3, 'createWeb3').mockImplementation(() => mockProvider) const uuid = crypto.randomUUID() @@ -111,7 +190,9 @@ describe('Notifications', () => { ['2']: [hexZeroPad('0x1', 20)], }, uuid, - web3: mockProvider, + wallet: { + label: 'Ledger', + } as ConnectedWallet, }) expect(payload).toStrictEqual({ @@ -126,12 +207,12 @@ describe('Notifications', () => { { chainId: '1', safes: [hexZeroPad('0x1', 20), hexZeroPad('0x2', 20)], - signatures: [signature], + signatures: [ADJUSTED_LEDGER_SIGNATURE], }, { chainId: '2', safes: [hexZeroPad('0x1', 20)], - signatures: [signature], + signatures: [ADJUSTED_LEDGER_SIGNATURE], }, ], }) diff --git a/src/components/settings/PushNotifications/hooks/__tests__/useNotificationPreferences.test.ts b/src/components/settings/PushNotifications/hooks/__tests__/useNotificationPreferences.test.ts index 043e610c51..d4f5cba746 100644 --- a/src/components/settings/PushNotifications/hooks/__tests__/useNotificationPreferences.test.ts +++ b/src/components/settings/PushNotifications/hooks/__tests__/useNotificationPreferences.test.ts @@ -58,66 +58,52 @@ describe('useNotificationPreferences', () => { _setPreferences(undefined) }) - it('should return all existing preferences', async () => { - const chainId = '1' - const safeAddress = hexZeroPad('0x1', 20) + describe('_getAllPreferenceEntries', () => { + it('should get all preference entries', async () => { + const chainId1 = '1' + const safeAddress1 = hexZeroPad('0x1', 20) + const safeAddress2 = hexZeroPad('0x1', 20) - const preferences = { - [`${chainId}:${safeAddress}`]: { - chainId, - safeAddress, - preferences: DEFAULT_NOTIFICATION_PREFERENCES, - }, - } + const chainId2 = '2' - await setMany(Object.entries(preferences), createPushNotificationPrefsIndexedDb()) - - const { result } = renderHook(() => useNotificationPreferences()) - - await waitFor(() => { - expect(result.current.getAllPreferences()).toEqual(preferences) - }) - }) - - it('should return existing Safe preferences', async () => { - const chainId = '1' - const safeAddress = hexZeroPad('0x1', 20) - - const preferences = { - [`${chainId}:${safeAddress}`]: { - chainId, - safeAddress, - preferences: DEFAULT_NOTIFICATION_PREFERENCES, - }, - } + const preferences = { + [`${chainId1}:${safeAddress1}`]: { + chainId: chainId1, + safeAddress: safeAddress1, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + [`${chainId1}:${safeAddress2}`]: { + chainId: chainId1, + safeAddress: safeAddress2, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + [`${chainId2}:${safeAddress1}`]: { + chainId: chainId2, + safeAddress: safeAddress1, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + } - await setMany(Object.entries(preferences), createPushNotificationPrefsIndexedDb()) + await setMany(Object.entries(preferences), createPushNotificationPrefsIndexedDb()) - const { result } = renderHook(() => useNotificationPreferences()) + const { result } = renderHook(() => useNotificationPreferences()) - await waitFor(() => { - expect(result.current.getPreferences(chainId, safeAddress)).toEqual( - preferences[`${chainId}:${safeAddress}`].preferences, - ) + await waitFor(async () => { + const _preferences = await result.current._getAllPreferenceEntries() + expect(_preferences).toEqual(Object.entries(preferences)) + }) }) }) - it('should create preferences, then hydrate the preferences state', async () => { - const { result } = renderHook(() => useNotificationPreferences()) - - const chainId1 = '1' - const safeAddress1 = hexZeroPad('0x1', 20) - const safeAddress2 = hexZeroPad('0x1', 20) + describe('_deleteManyPreferenceKeys', () => { + it('should delete many preference keys', async () => { + const chainId1 = '1' + const safeAddress1 = hexZeroPad('0x1', 20) + const safeAddress2 = hexZeroPad('0x1', 20) - const chainId2 = '2' + const chainId2 = '2' - result.current._createPreferences({ - [chainId1]: [safeAddress1, safeAddress2], - [chainId2]: [safeAddress1], - }) - - await waitFor(() => { - expect(result.current.getAllPreferences()).toEqual({ + const preferences = { [`${chainId1}:${safeAddress1}`]: { chainId: chainId1, safeAddress: safeAddress1, @@ -133,182 +119,337 @@ describe('useNotificationPreferences', () => { safeAddress: safeAddress1, preferences: DEFAULT_NOTIFICATION_PREFERENCES, }, + } + + await setMany(Object.entries(preferences), createPushNotificationPrefsIndexedDb()) + + const { result } = renderHook(() => useNotificationPreferences()) + + await waitFor(() => { + expect(result.current.getAllPreferences()).toEqual(preferences) + }) + + const keysToDelete = Object.entries(preferences).map(([key]) => key) + + result.current._deleteManyPreferenceKeys(keysToDelete as `${string}:${string}`[]) + + await waitFor(() => { + expect(result.current.getAllPreferences()).toEqual({}) }) }) }) - it('should not create preferences when passed an empty object', async () => { - const { result } = renderHook(() => useNotificationPreferences()) + describe('getAllPreferences', () => { + it('should return all existing preferences', async () => { + const chainId = '1' + const safeAddress = hexZeroPad('0x1', 20) - result.current._createPreferences({}) + const preferences = { + [`${chainId}:${safeAddress}`]: { + chainId, + safeAddress, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + } - await waitFor(() => { - expect(result.current.getAllPreferences()).toEqual({}) + await setMany(Object.entries(preferences), createPushNotificationPrefsIndexedDb()) + + const { result } = renderHook(() => useNotificationPreferences()) + + await waitFor(() => { + expect(result.current.getAllPreferences()).toEqual(preferences) + }) }) }) - it('should not create preferences when passed an empty array of Safes', async () => { - const { result } = renderHook(() => useNotificationPreferences()) + describe('getPreferences', () => { + it('should return existing Safe preferences', async () => { + const chainId = '1' + const safeAddress = hexZeroPad('0x1', 20) - result.current._createPreferences({ ['1']: [] }) + const preferences = { + [`${chainId}:${safeAddress}`]: { + chainId, + safeAddress, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + } - await waitFor(() => { - expect(result.current.getAllPreferences()).toEqual({}) + await setMany(Object.entries(preferences), createPushNotificationPrefsIndexedDb()) + + const { result } = renderHook(() => useNotificationPreferences()) + + await waitFor(() => { + expect(result.current.getPreferences(chainId, safeAddress)).toEqual( + preferences[`${chainId}:${safeAddress}`].preferences, + ) + }) }) }) - it('should update preferences, then hydrate the preferences state', async () => { - const chainId = '1' - const safeAddress = hexZeroPad('0x1', 20) + describe('createPreferences', () => { + it('should create preferences, then hydrate the preferences state', async () => { + const { result } = renderHook(() => useNotificationPreferences()) - const preferences = { - [`${chainId}:${safeAddress}`]: { - chainId: chainId, - safeAddress: safeAddress, - preferences: DEFAULT_NOTIFICATION_PREFERENCES, - }, - } + const chainId1 = '1' + const safeAddress1 = hexZeroPad('0x1', 20) + const safeAddress2 = hexZeroPad('0x1', 20) - await setMany(Object.entries(preferences), createPushNotificationPrefsIndexedDb()) + const chainId2 = '2' - const { result } = renderHook(() => useNotificationPreferences()) + result.current.createPreferences({ + [chainId1]: [safeAddress1, safeAddress2], + [chainId2]: [safeAddress1], + }) - result.current.updatePreferences(chainId, safeAddress, { - ...DEFAULT_NOTIFICATION_PREFERENCES, - [WebhookType.CONFIRMATION_REQUEST]: false, + await waitFor(() => { + expect(result.current.getAllPreferences()).toEqual({ + [`${chainId1}:${safeAddress1}`]: { + chainId: chainId1, + safeAddress: safeAddress1, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + [`${chainId1}:${safeAddress2}`]: { + chainId: chainId1, + safeAddress: safeAddress2, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + [`${chainId2}:${safeAddress1}`]: { + chainId: chainId2, + safeAddress: safeAddress1, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + }) + }) }) - await waitFor(() => { - expect(result.current.getAllPreferences()).toEqual({ + it('should not create preferences when passed an empty object', async () => { + const { result } = renderHook(() => useNotificationPreferences()) + + result.current.createPreferences({}) + + await waitFor(() => { + expect(result.current.getAllPreferences()).toEqual({}) + }) + }) + + it('should not create preferences when passed an empty array of Safes', async () => { + const { result } = renderHook(() => useNotificationPreferences()) + + result.current.createPreferences({ ['1']: [] }) + + await waitFor(() => { + expect(result.current.getAllPreferences()).toEqual({}) + }) + }) + + it('should hydrate accross instances', async () => { + const chainId1 = '1' + const safeAddress1 = hexZeroPad('0x1', 20) + const safeAddress2 = hexZeroPad('0x1', 20) + + const chainId2 = '2' + const { result: instance1 } = renderHook(() => useNotificationPreferences()) + const { result: instance2 } = renderHook(() => useNotificationPreferences()) + + instance1.current.createPreferences({ + [chainId1]: [safeAddress1, safeAddress2], + [chainId2]: [safeAddress1], + }) + + const expectedPreferences = { + [`${chainId1}:${safeAddress1}`]: { + chainId: chainId1, + safeAddress: safeAddress1, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + [`${chainId1}:${safeAddress2}`]: { + chainId: chainId1, + safeAddress: safeAddress2, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + [`${chainId2}:${safeAddress1}`]: { + chainId: chainId2, + safeAddress: safeAddress1, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + } + + await waitFor(() => { + expect(instance1.current.getAllPreferences()).toEqual(expectedPreferences) + expect(instance2.current.getAllPreferences()).toEqual(expectedPreferences) + }) + }) + }) + + describe('updatePreferences', () => { + it('should update preferences, then hydrate the preferences state', async () => { + const chainId = '1' + const safeAddress = hexZeroPad('0x1', 20) + + const preferences = { [`${chainId}:${safeAddress}`]: { chainId: chainId, safeAddress: safeAddress, - preferences: { - ...DEFAULT_NOTIFICATION_PREFERENCES, - [WebhookType.CONFIRMATION_REQUEST]: false, - }, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, }, + } + + await setMany(Object.entries(preferences), createPushNotificationPrefsIndexedDb()) + + const { result } = renderHook(() => useNotificationPreferences()) + + result.current.updatePreferences(chainId, safeAddress, { + ...DEFAULT_NOTIFICATION_PREFERENCES, + [WebhookType.CONFIRMATION_REQUEST]: false, + }) + + await waitFor(() => { + expect(result.current.getAllPreferences()).toEqual({ + [`${chainId}:${safeAddress}`]: { + chainId: chainId, + safeAddress: safeAddress, + preferences: { + ...DEFAULT_NOTIFICATION_PREFERENCES, + [WebhookType.CONFIRMATION_REQUEST]: false, + }, + }, + }) }) }) }) - it('should delete preferences, then hydrate the preferences state', async () => { - const chainId1 = '1' - const safeAddress1 = hexZeroPad('0x1', 20) - const safeAddress2 = hexZeroPad('0x1', 20) - - const chainId2 = '2' - - const preferences = { - [`${chainId1}:${safeAddress1}`]: { - chainId: chainId1, - safeAddress: safeAddress1, - preferences: DEFAULT_NOTIFICATION_PREFERENCES, - }, - [`${chainId1}:${safeAddress2}`]: { - chainId: chainId1, - safeAddress: safeAddress2, - preferences: DEFAULT_NOTIFICATION_PREFERENCES, - }, - [`${chainId2}:${safeAddress1}`]: { - chainId: chainId2, - safeAddress: safeAddress1, - preferences: DEFAULT_NOTIFICATION_PREFERENCES, - }, - } - - await setMany(Object.entries(preferences), createPushNotificationPrefsIndexedDb()) + describe('deletePreferences', () => { + it('should delete preferences, then hydrate the preferences state', async () => { + const chainId1 = '1' + const safeAddress1 = hexZeroPad('0x1', 20) + const safeAddress2 = hexZeroPad('0x1', 20) - const { result } = renderHook(() => useNotificationPreferences()) - - result.current._deletePreferences({ - [chainId1]: [safeAddress1, safeAddress2], - }) + const chainId2 = '2' - await waitFor(() => { - expect(result.current.getAllPreferences()).toEqual({ + const preferences = { + [`${chainId1}:${safeAddress1}`]: { + chainId: chainId1, + safeAddress: safeAddress1, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + [`${chainId1}:${safeAddress2}`]: { + chainId: chainId1, + safeAddress: safeAddress2, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, [`${chainId2}:${safeAddress1}`]: { chainId: chainId2, safeAddress: safeAddress1, preferences: DEFAULT_NOTIFICATION_PREFERENCES, }, + } + + await setMany(Object.entries(preferences), createPushNotificationPrefsIndexedDb()) + + const { result } = renderHook(() => useNotificationPreferences()) + + result.current.deletePreferences({ + [chainId1]: [safeAddress1, safeAddress2], + }) + + await waitFor(() => { + expect(result.current.getAllPreferences()).toEqual({ + [`${chainId2}:${safeAddress1}`]: { + chainId: chainId2, + safeAddress: safeAddress1, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + }) }) }) - }) - it('should delete all preferences, then hydrate the preferences state', async () => { - const chainId1 = '1' - const safeAddress1 = hexZeroPad('0x1', 20) - const safeAddress2 = hexZeroPad('0x1', 20) - - const chainId2 = '2' - - const preferences = { - [`${chainId1}:${safeAddress1}`]: { - chainId: chainId1, - safeAddress: safeAddress1, - preferences: DEFAULT_NOTIFICATION_PREFERENCES, - }, - [`${chainId1}:${safeAddress2}`]: { - chainId: chainId1, - safeAddress: safeAddress2, - preferences: DEFAULT_NOTIFICATION_PREFERENCES, - }, - [`${chainId2}:${safeAddress1}`]: { - chainId: chainId2, - safeAddress: safeAddress1, - preferences: DEFAULT_NOTIFICATION_PREFERENCES, - }, - } - - await setMany(Object.entries(preferences), createPushNotificationPrefsIndexedDb()) + it('should delete preferences, then hydrate the preferences state', async () => { + const chainId1 = '1' + const safeAddress1 = hexZeroPad('0x1', 20) + const safeAddress2 = hexZeroPad('0x1', 20) - const { result } = renderHook(() => useNotificationPreferences()) + const chainId2 = '2' - result.current._deletePreferences({ - [chainId1]: [safeAddress1, safeAddress2], - }) + const preferences = { + [`${chainId1}:${safeAddress1}`]: { + chainId: chainId1, + safeAddress: safeAddress1, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + [`${chainId1}:${safeAddress2}`]: { + chainId: chainId1, + safeAddress: safeAddress2, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + [`${chainId2}:${safeAddress1}`]: { + chainId: chainId2, + safeAddress: safeAddress1, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + } - await waitFor(() => { - expect(result.current.getAllPreferences()).toEqual(undefined) + await setMany(Object.entries(preferences), createPushNotificationPrefsIndexedDb()) + + const { result } = renderHook(() => useNotificationPreferences()) + + result.current.deletePreferences({ + [chainId1]: [safeAddress1, safeAddress2], + }) + + await waitFor(() => { + expect(result.current.getAllPreferences()).toEqual({ + [`${chainId2}:${safeAddress1}`]: { + chainId: chainId2, + safeAddress: safeAddress1, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + }) + }) }) }) - it('should hydrate accross instances', async () => { - const chainId1 = '1' - const safeAddress1 = hexZeroPad('0x1', 20) - const safeAddress2 = hexZeroPad('0x1', 20) + describe('deleteAllChainPreferences', () => { + it('should delete per chain, then hydrate the preferences state', async () => { + const chainId1 = '1' + const safeAddress1 = hexZeroPad('0x1', 20) + const safeAddress2 = hexZeroPad('0x1', 20) - const chainId2 = '2' - const { result: instance1 } = renderHook(() => useNotificationPreferences()) - const { result: instance2 } = renderHook(() => useNotificationPreferences()) + const chainId2 = '2' - instance1.current._createPreferences({ - [chainId1]: [safeAddress1, safeAddress2], - [chainId2]: [safeAddress1], - }) + const preferences = { + [`${chainId1}:${safeAddress1}`]: { + chainId: chainId1, + safeAddress: safeAddress1, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + [`${chainId1}:${safeAddress2}`]: { + chainId: chainId1, + safeAddress: safeAddress2, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + [`${chainId2}:${safeAddress1}`]: { + chainId: chainId2, + safeAddress: safeAddress1, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + } - const expectedPreferences = { - [`${chainId1}:${safeAddress1}`]: { - chainId: chainId1, - safeAddress: safeAddress1, - preferences: DEFAULT_NOTIFICATION_PREFERENCES, - }, - [`${chainId1}:${safeAddress2}`]: { - chainId: chainId1, - safeAddress: safeAddress2, - preferences: DEFAULT_NOTIFICATION_PREFERENCES, - }, - [`${chainId2}:${safeAddress1}`]: { - chainId: chainId2, - safeAddress: safeAddress1, - preferences: DEFAULT_NOTIFICATION_PREFERENCES, - }, - } + await setMany(Object.entries(preferences), createPushNotificationPrefsIndexedDb()) - await waitFor(() => { - expect(instance1.current.getAllPreferences()).toEqual(expectedPreferences) - expect(instance2.current.getAllPreferences()).toEqual(expectedPreferences) + const { result } = renderHook(() => useNotificationPreferences()) + + result.current.deleteAllChainPreferences(chainId1) + + await waitFor(() => { + expect(result.current.getAllPreferences()).toEqual({ + [`${chainId2}:${safeAddress1}`]: { + chainId: chainId2, + safeAddress: safeAddress1, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + }, + }) + }) }) }) }) diff --git a/src/components/settings/PushNotifications/hooks/__tests__/useNotificationRegistrations.test.ts b/src/components/settings/PushNotifications/hooks/__tests__/useNotificationRegistrations.test.ts index bfff0b0455..7ac47cdedf 100644 --- a/src/components/settings/PushNotifications/hooks/__tests__/useNotificationRegistrations.test.ts +++ b/src/components/settings/PushNotifications/hooks/__tests__/useNotificationRegistrations.test.ts @@ -6,9 +6,11 @@ import * as sdk from '@safe-global/safe-gateway-typescript-sdk' import { renderHook } from '@/tests/test-utils' import { useNotificationRegistrations } from '../useNotificationRegistrations' import * as web3 from '@/hooks/wallets/web3' +import * as wallet from '@/hooks/wallets/useWallet' import * as logic from '../../logic' import * as preferences from '../useNotificationPreferences' import * as notificationsSlice from '@/store/notificationsSlice' +import type { ConnectedWallet } from '@/services/onboard' jest.mock('@safe-global/safe-gateway-typescript-sdk') @@ -28,7 +30,13 @@ describe('useNotificationRegistrations', () => { describe('registerNotifications', () => { beforeEach(() => { const mockProvider = new Web3Provider(jest.fn()) - jest.spyOn(web3, 'useWeb3').mockImplementation(() => mockProvider) + jest.spyOn(web3, 'createWeb3').mockImplementation(() => mockProvider) + jest.spyOn(wallet, 'default').mockImplementation( + () => + ({ + label: 'MetaMask', + } as ConnectedWallet), + ) }) const registerDeviceSpy = jest.spyOn(sdk, 'registerDevice') @@ -96,7 +104,7 @@ describe('useNotificationRegistrations', () => { () => ({ uuid: self.crypto.randomUUID(), - _createPreferences: createPreferencesMock, + createPreferences: createPreferencesMock, } as unknown as ReturnType), ) @@ -128,7 +136,7 @@ describe('useNotificationRegistrations', () => { () => ({ uuid: self.crypto.randomUUID(), - _createPreferences: createPreferencesMock, + createPreferences: createPreferencesMock, } as unknown as ReturnType), ) @@ -159,7 +167,7 @@ describe('useNotificationRegistrations', () => { () => ({ uuid: self.crypto.randomUUID(), - _createPreferences: createPreferencesMock, + createPreferences: createPreferencesMock, } as unknown as ReturnType), ) @@ -210,7 +218,7 @@ describe('useNotificationRegistrations', () => { () => ({ uuid, - _deletePreferences: deletePreferencesMock, + deletePreferences: deletePreferencesMock, } as unknown as ReturnType), ) @@ -236,7 +244,7 @@ describe('useNotificationRegistrations', () => { () => ({ uuid, - _deletePreferences: deletePreferencesMock, + deletePreferences: deletePreferencesMock, } as unknown as ReturnType), ) @@ -262,7 +270,7 @@ describe('useNotificationRegistrations', () => { () => ({ uuid, - _deletePreferences: deletePreferencesMock, + deletePreferences: deletePreferencesMock, } as unknown as ReturnType), ) @@ -302,13 +310,13 @@ describe('useNotificationRegistrations', () => { unregisterDeviceSpy.mockImplementation(() => Promise.resolve('Unregistration could not be completed.')) const uuid = self.crypto.randomUUID() - const deleteAllPreferencesMock = jest.fn() + const deleteAllChainPreferencesMock = jest.fn() ;(preferences.useNotificationPreferences as jest.Mock).mockImplementation( () => ({ uuid, - _deleteAllPreferences: deleteAllPreferencesMock, + deleteAllChainPreferences: deleteAllChainPreferencesMock, } as unknown as ReturnType), ) @@ -318,20 +326,20 @@ describe('useNotificationRegistrations', () => { expect(unregisterDeviceSpy).toHaveBeenCalledWith('1', uuid) - expect(deleteAllPreferencesMock).not.toHaveBeenCalled() + expect(deleteAllChainPreferencesMock).not.toHaveBeenCalled() }) it('does not clear preferences if unregistration throws', async () => { unregisterDeviceSpy.mockImplementation(() => Promise.reject()) const uuid = self.crypto.randomUUID() - const deleteAllPreferencesMock = jest.fn() + const deleteAllChainPreferencesMock = jest.fn() ;(preferences.useNotificationPreferences as jest.Mock).mockImplementation( () => ({ uuid, - _deleteAllPreferences: deleteAllPreferencesMock, + deleteAllChainPreferences: deleteAllChainPreferencesMock, } as unknown as ReturnType), ) @@ -341,20 +349,20 @@ describe('useNotificationRegistrations', () => { expect(unregisterDeviceSpy).toHaveBeenCalledWith('1', uuid) - expect(deleteAllPreferencesMock).not.toHaveBeenCalledWith() + expect(deleteAllChainPreferencesMock).not.toHaveBeenCalledWith() }) - it('clears preferences if unregistration succeeds', async () => { + it('clears chain preferences if unregistration succeeds', async () => { unregisterDeviceSpy.mockImplementation(() => Promise.resolve()) const uuid = self.crypto.randomUUID() - const deleteAllPreferencesMock = jest.fn() + const deleteAllChainPreferencesMock = jest.fn() ;(preferences.useNotificationPreferences as jest.Mock).mockImplementation( () => ({ uuid, - _deleteAllPreferences: deleteAllPreferencesMock, + deleteAllChainPreferences: deleteAllChainPreferencesMock, } as unknown as ReturnType), ) @@ -364,7 +372,7 @@ describe('useNotificationRegistrations', () => { expect(unregisterDeviceSpy).toHaveBeenCalledWith('1', uuid) - expect(deleteAllPreferencesMock).toHaveBeenCalled() + expect(deleteAllChainPreferencesMock).toHaveBeenCalledWith('1') }) }) }) diff --git a/src/components/settings/PushNotifications/hooks/useNotificationPreferences.ts b/src/components/settings/PushNotifications/hooks/useNotificationPreferences.ts index e74d6f4c03..4cc26e7cc7 100644 --- a/src/components/settings/PushNotifications/hooks/useNotificationPreferences.ts +++ b/src/components/settings/PushNotifications/hooks/useNotificationPreferences.ts @@ -3,7 +3,6 @@ import { entries as getEntriesFromIndexedDb, delMany as deleteManyFromIndexedDb, setMany as setManyIndexedDb, - clear as clearIndexedDb, update as updateIndexedDb, } from 'idb-keyval' import { useCallback, useEffect, useMemo } from 'react' @@ -17,7 +16,6 @@ import { } from '@/services/push-notifications/preferences' import { logError } from '@/services/exceptions' import ErrorCodes from '@/services/exceptions/ErrorCodes' -import useIsSafeOwner from '@/hooks/useIsSafeOwner' import type { PushNotificationPreferences, PushNotificationPrefsKey } from '@/services/push-notifications/preferences' import type { NotifiableSafes } from '../logic' @@ -26,7 +24,7 @@ export const DEFAULT_NOTIFICATION_PREFERENCES: PushNotificationPreferences[PushN [WebhookType.INCOMING_ETHER]: true, [WebhookType.INCOMING_TOKEN]: true, [WebhookType.MODULE_TRANSACTION]: true, - [WebhookType.CONFIRMATION_REQUEST]: false, // Requires signature + [WebhookType.CONFIRMATION_REQUEST]: true, // Requires signature [WebhookType.SAFE_CREATED]: false, // We do not preemptively subscribe to Safes before they are created // Disabled on the Transaction Service but kept here for completeness [WebhookType._PENDING_MULTISIG_TRANSACTION]: true, @@ -52,14 +50,17 @@ export const useNotificationPreferences = (): { safeAddress: string, preferences: PushNotificationPreferences[PushNotificationPrefsKey]['preferences'], ) => void - _createPreferences: (safesToRegister: NotifiableSafes) => void - _deletePreferences: (safesToUnregister: NotifiableSafes) => void - _deleteAllPreferences: () => void + createPreferences: (safesToRegister: NotifiableSafes) => void + deletePreferences: (safesToUnregister: NotifiableSafes) => void + deleteAllChainPreferences: (chainId: string) => void + _getAllPreferenceEntries: () => Promise< + [PushNotificationPrefsKey, PushNotificationPreferences[PushNotificationPrefsKey]][] + > + _deleteManyPreferenceKeys: (keysToDelete: PushNotificationPrefsKey[]) => void } => { // State const uuid = useUuid() const preferences = usePreferences() - const isOwner = useIsSafeOwner() // Getters const getPreferences = (chainId: string, safeAddress: string) => { @@ -116,22 +117,38 @@ export const useNotificationPreferences = (): { hydrateUuidStore() }, [hydrateUuidStore, uuidStore]) + const _getAllPreferenceEntries = useCallback(() => { + return getEntriesFromIndexedDb( + preferencesStore, + ) + }, [preferencesStore]) + // Preferences state hydrator const hydratePreferences = useCallback(() => { if (!preferencesStore) { return } - getEntriesFromIndexedDb( - preferencesStore, - ) + _getAllPreferenceEntries() .then((preferencesEntries) => { setPreferences(Object.fromEntries(preferencesEntries)) }) .catch((e) => { logError(ErrorCodes._705, e) }) - }, [preferencesStore]) + }, [_getAllPreferenceEntries, preferencesStore]) + + // Delete array of preferences store keys + const _deleteManyPreferenceKeys = useCallback( + (keysToDelete: PushNotificationPrefsKey[]) => { + deleteManyFromIndexedDb(keysToDelete, preferencesStore) + .then(hydratePreferences) + .catch((e) => { + logError(ErrorCodes._706, e) + }) + }, + [hydratePreferences, preferencesStore], + ) // Hydrate preferences state useEffect(() => { @@ -152,10 +169,7 @@ export const useNotificationPreferences = (): { const defaultPreferences: PushNotificationPreferences[PushNotificationPrefsKey] = { chainId, safeAddress, - preferences: { - ...DEFAULT_NOTIFICATION_PREFERENCES, - [WebhookType.CONFIRMATION_REQUEST]: isOwner, - }, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, } return [key, defaultPreferences] @@ -205,23 +219,27 @@ export const useNotificationPreferences = (): { return safeAddresses.map((safeAddress) => getPushNotificationPrefsKey(chainId, safeAddress)) }) - deleteManyFromIndexedDb(keysToDelete, preferencesStore) - .then(hydratePreferences) - .catch((e) => { - logError(ErrorCodes._706, e) - }) + _deleteManyPreferenceKeys(keysToDelete) } // Delete all preferences store entries - const deleteAllPreferences = () => { + const deleteAllChainPreferences = (chainId: string) => { if (!preferencesStore) { return } - clearIndexedDb(preferencesStore) - .then(hydratePreferences) + _getAllPreferenceEntries() + .then((preferencesEntries) => { + const keysToDelete = preferencesEntries + .filter(([, prefs]) => { + return prefs.chainId === chainId + }) + .map(([key]) => key) + + _deleteManyPreferenceKeys(keysToDelete) + }) .catch((e) => { - logError(ErrorCodes._706, e) + logError(ErrorCodes._705, e) }) } @@ -230,8 +248,10 @@ export const useNotificationPreferences = (): { getAllPreferences, getPreferences, updatePreferences, - _createPreferences: createPreferences, - _deletePreferences: deletePreferences, - _deleteAllPreferences: deleteAllPreferences, + createPreferences, + deletePreferences, + deleteAllChainPreferences, + _getAllPreferenceEntries, + _deleteManyPreferenceKeys, } } diff --git a/src/components/settings/PushNotifications/hooks/useNotificationRegistrations.ts b/src/components/settings/PushNotifications/hooks/useNotificationRegistrations.ts index 30d0fa520d..7bde23e3dc 100644 --- a/src/components/settings/PushNotifications/hooks/useNotificationRegistrations.ts +++ b/src/components/settings/PushNotifications/hooks/useNotificationRegistrations.ts @@ -1,6 +1,5 @@ import { registerDevice, unregisterDevice, unregisterSafe } from '@safe-global/safe-gateway-typescript-sdk' -import { useWeb3 } from '@/hooks/wallets/web3' import { useAppDispatch } from '@/store' import { showNotification } from '@/store/notificationsSlice' import { useNotificationPreferences } from './useNotificationPreferences' @@ -9,6 +8,7 @@ import { PUSH_NOTIFICATION_EVENTS } from '@/services/analytics/events/push-notif import { getRegisterDevicePayload } from '../logic' import { logError } from '@/services/exceptions' import ErrorCodes from '@/services/exceptions/ErrorCodes' +import useWallet from '@/hooks/wallets/useWallet' import type { NotifiableSafes } from '../logic' const registrationFlow = async (registrationFn: Promise, callback: () => void): Promise => { @@ -37,12 +37,12 @@ export const useNotificationRegistrations = (): { unregisterDeviceNotifications: (chainId: string) => Promise } => { const dispatch = useAppDispatch() - const web3 = useWeb3() + const wallet = useWallet() - const { uuid, _createPreferences, _deletePreferences, _deleteAllPreferences } = useNotificationPreferences() + const { uuid, createPreferences, deletePreferences, deleteAllChainPreferences } = useNotificationPreferences() const registerNotifications = async (safesToRegister: NotifiableSafes) => { - if (!uuid || !web3) { + if (!uuid || !wallet) { return } @@ -50,14 +50,14 @@ export const useNotificationRegistrations = (): { const payload = await getRegisterDevicePayload({ uuid, safesToRegister, - web3, + wallet, }) return registerDevice(payload) } return registrationFlow(register(), () => { - _createPreferences(safesToRegister) + createPreferences(safesToRegister) const totalRegistered = Object.values(safesToRegister).reduce( (acc, safeAddresses) => acc + safeAddresses.length, @@ -84,7 +84,7 @@ export const useNotificationRegistrations = (): { const unregisterSafeNotifications = async (chainId: string, safeAddress: string) => { if (uuid) { return registrationFlow(unregisterSafe(chainId, safeAddress, uuid), () => { - _deletePreferences({ [chainId]: [safeAddress] }) + deletePreferences({ [chainId]: [safeAddress] }) trackEvent(PUSH_NOTIFICATION_EVENTS.UNREGISTER_SAFE) }) } @@ -93,7 +93,7 @@ export const useNotificationRegistrations = (): { const unregisterDeviceNotifications = async (chainId: string) => { if (uuid) { return registrationFlow(unregisterDevice(chainId, uuid), () => { - _deleteAllPreferences() + deleteAllChainPreferences(chainId) trackEvent(PUSH_NOTIFICATION_EVENTS.UNREGISTER_DEVICE) }) } diff --git a/src/components/settings/PushNotifications/index.tsx b/src/components/settings/PushNotifications/index.tsx index 7ea459bb60..73aa479c6a 100644 --- a/src/components/settings/PushNotifications/index.tsx +++ b/src/components/settings/PushNotifications/index.tsx @@ -63,7 +63,11 @@ export const PushNotifications = (): ReactElement => { setIsRegistering(true) - await assertWalletChain(onboard, safe.chainId) + try { + await assertWalletChain(onboard, safe.chainId) + } catch { + return + } if (!preferences) { await registerNotifications({ [safe.chainId]: [safe.address.value] }) diff --git a/src/components/settings/PushNotifications/logic.ts b/src/components/settings/PushNotifications/logic.ts index 74c39b12d9..8ee435cf1b 100644 --- a/src/components/settings/PushNotifications/logic.ts +++ b/src/components/settings/PushNotifications/logic.ts @@ -1,4 +1,4 @@ -import { arrayify, keccak256, toUtf8Bytes } from 'ethers/lib/utils' +import { arrayify, joinSignature, keccak256, splitSignature, toUtf8Bytes } from 'ethers/lib/utils' import { getToken, getMessaging } from 'firebase/messaging' import { DeviceType } from '@safe-global/safe-gateway-typescript-sdk' import type { RegisterNotificationsRequest } from '@safe-global/safe-gateway-typescript-sdk' @@ -9,6 +9,9 @@ import packageJson from '../../../../package.json' import { logError } from '@/services/exceptions' import ErrorCodes from '@/services/exceptions/ErrorCodes' import { checksumAddress } from '@/utils/addresses' +import { isLedger } from '@/utils/wallets' +import { createWeb3 } from '@/hooks/wallets/web3' +import type { ConnectedWallet } from '@/services/onboard' type WithRequired = T & { [P in K]-?: T[P] } @@ -31,18 +34,34 @@ export const requestNotificationPermission = async (): Promise => { return permission === 'granted' } -const getSafeRegistrationSignature = ({ +// Ledger produces vrs signatures with a canonical v value of {0,1} +// Ethereum's ecrecover call only accepts a non-standard v value of {27,28}. + +// @see https://github.com/ethereum/go-ethereum/issues/19751 +export const _adjustLedgerSignatureV = (signature: string): string => { + const split = splitSignature(signature) + + if (split.v === 0 || split.v === 1) { + split.v += 27 + } + + return joinSignature(split) +} + +const getSafeRegistrationSignature = async ({ safeAddresses, web3, timestamp, uuid, token, + isLedger, }: { safeAddresses: Array web3: Web3Provider timestamp: string uuid: string token: string + isLedger: boolean }) => { const MESSAGE_PREFIX = 'gnosis-safe' @@ -55,7 +74,13 @@ const getSafeRegistrationSignature = ({ const message = MESSAGE_PREFIX + timestamp + uuid + token + safeAddresses.sort().join('') const hashedMessage = keccak256(toUtf8Bytes(message)) - return web3.getSigner().signMessage(arrayify(hashedMessage)) + const signature = await web3.getSigner().signMessage(arrayify(hashedMessage)) + + if (!isLedger) { + return signature + } + + return _adjustLedgerSignatureV(signature) } export type NotifiableSafes = { [chainId: string]: Array } @@ -63,11 +88,11 @@ export type NotifiableSafes = { [chainId: string]: Array } export const getRegisterDevicePayload = async ({ safesToRegister, uuid, - web3, + wallet, }: { safesToRegister: NotifiableSafes uuid: string - web3: Web3Provider + wallet: ConnectedWallet }): Promise => { const BUILD_NUMBER = '0' // Required value, but does not exist on web const BUNDLE = 'safe' @@ -83,6 +108,9 @@ export const getRegisterDevicePayload = async ({ serviceWorkerRegistration, }) + const web3 = createWeb3(wallet.provider) + const isLedgerWallet = isLedger(wallet) + // If uuid is not provided a new device will be created. // If a uuid for an existing Safe is provided the FirebaseDevice will be updated with all the new data provided. // Safes provided on the request are always added and never removed/replaced @@ -91,25 +119,28 @@ export const getRegisterDevicePayload = async ({ const timestamp = Math.floor(new Date().getTime() / 1000).toString() - const safeRegistrations = await Promise.all( - Object.entries(safesToRegister).map(async ([chainId, safeAddresses]) => { - const checksummedSafeAddresses = safeAddresses.map((address) => checksumAddress(address)) - // We require a signature for confirmation request notifications - const signature = await getSafeRegistrationSignature({ - safeAddresses: checksummedSafeAddresses, - web3, - uuid, - timestamp, - token, - }) - - return { - chainId, - safes: checksummedSafeAddresses, - signatures: [signature], - } - }), - ) + let safeRegistrations: RegisterNotificationsRequest['safeRegistrations'] = [] + + // We cannot `Promise.all` here as Ledger/Trezor return a "busy" error when signing multiple messages at once + for await (const [chainId, safeAddresses] of Object.entries(safesToRegister)) { + const checksummedSafeAddresses = safeAddresses.map((address) => checksumAddress(address)) + + // We require a signature for confirmation request notifications + const signature = await getSafeRegistrationSignature({ + safeAddresses: checksummedSafeAddresses, + web3, + uuid, + timestamp, + token, + isLedger: isLedgerWallet, + }) + + safeRegistrations.push({ + chainId, + safes: checksummedSafeAddresses, + signatures: [signature], + }) + } return { uuid, diff --git a/src/components/tx/SignOrExecuteForm/hooks.ts b/src/components/tx/SignOrExecuteForm/hooks.ts index 2cf77b95e0..a0a4a712c0 100644 --- a/src/components/tx/SignOrExecuteForm/hooks.ts +++ b/src/components/tx/SignOrExecuteForm/hooks.ts @@ -170,7 +170,7 @@ export const useRecommendedNonce = (): number | undefined => { const recommendedNonce = await getRecommendedNonce(safe.chainId, safeAddress) - return recommendedNonce ? Math.max(safe.nonce, recommendedNonce) : undefined + return recommendedNonce !== undefined ? Math.max(safe.nonce, recommendedNonce) : undefined }, // eslint-disable-next-line react-hooks/exhaustive-deps [safeAddress, safe.chainId, safe.txQueuedTag], // update when tx queue changes diff --git a/src/services/exceptions/ErrorCodes.ts b/src/services/exceptions/ErrorCodes.ts index 3e28789c8e..cf24e6b433 100644 --- a/src/services/exceptions/ErrorCodes.ts +++ b/src/services/exceptions/ErrorCodes.ts @@ -45,8 +45,8 @@ enum ErrorCodes { _702 = '702: Failed to remove from local/session storage', _703 = '703: Error importing an address book', _704 = '704: Error importing global data', - _705 = '704: Failed to read from IndexedDB', - _706 = '704: Failed to write to IndexedDB', + _705 = '705: Failed to read from IndexedDB', + _706 = '706: Failed to write to IndexedDB', _800 = '800: Safe creation tx failed', _801 = '801: Failed to send a tx with a spending limit', diff --git a/src/utils/wallets.ts b/src/utils/wallets.ts index cfb527189b..d149609fc8 100644 --- a/src/utils/wallets.ts +++ b/src/utils/wallets.ts @@ -17,6 +17,10 @@ export const isWalletRejection = (err: EthersError | Error): boolean => { return isEthersRejection(err as EthersError) || isWCRejection(err) } +export const isLedger = (wallet: ConnectedWallet): boolean => { + return wallet.label.toUpperCase() === WALLET_KEYS.LEDGER +} + export const isHardwareWallet = (wallet: ConnectedWallet): boolean => { return [WALLET_KEYS.LEDGER, WALLET_KEYS.TREZOR, WALLET_KEYS.KEYSTONE].includes( wallet.label.toUpperCase() as WALLET_KEYS, diff --git a/yarn.lock b/yarn.lock index a087c8ae0e..0c903d6cc0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7288,6 +7288,11 @@ blakejs@^1.1.0, blakejs@^1.2.1: resolved "https://registry.yarnpkg.com/blakejs/-/blakejs-1.2.1.tgz#5057e4206eadb4a97f7c0b6e197a505042fc3814" integrity sha512-QXUSXI3QVc/gJME0dBpXrag1kbzOqCjCX8/b54ntNyW6sjtoqxqRk3LTmXzaJoh71zMsDCjM+47jS7XiwN/+fQ== +blo@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/blo/-/blo-1.1.1.tgz#ed781c5c516fba484ec8ec86105dc27f6c553209" + integrity sha512-1uGZInlRD4X1WQP2G1QjDGwGZ8HdGgFKqnzyRdA2TYYc0MOQCmCi37RTQ8oJuI0UF6DYFKXHwV/t1kZkO/fTaA== + blob-util@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/blob-util/-/blob-util-2.0.2.tgz#3b4e3c281111bb7f11128518006cdc60b403a1eb"