diff --git a/src/components/settings/PushNotifications/hooks/__tests__/useNotificationRegistrations.test.ts b/src/components/settings/PushNotifications/hooks/__tests__/useNotificationRegistrations.test.ts index bfff0b0455..b9ac1646c5 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') @@ -29,6 +31,12 @@ describe('useNotificationRegistrations', () => { beforeEach(() => { const mockProvider = new Web3Provider(jest.fn()) jest.spyOn(web3, 'useWeb3').mockImplementation(() => mockProvider) + jest.spyOn(wallet, 'default').mockImplementation( + () => + ({ + label: 'MetaMask', + } as unknown as ConnectedWallet), + ) }) const registerDeviceSpy = jest.spyOn(sdk, 'registerDevice') diff --git a/src/components/settings/PushNotifications/hooks/useNotificationRegistrations.ts b/src/components/settings/PushNotifications/hooks/useNotificationRegistrations.ts index 30d0fa520d..200006681c 100644 --- a/src/components/settings/PushNotifications/hooks/useNotificationRegistrations.ts +++ b/src/components/settings/PushNotifications/hooks/useNotificationRegistrations.ts @@ -9,6 +9,8 @@ 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 { isLedger } from '@/utils/wallets' import type { NotifiableSafes } from '../logic' const registrationFlow = async (registrationFn: Promise, callback: () => void): Promise => { @@ -38,11 +40,12 @@ export const useNotificationRegistrations = (): { } => { const dispatch = useAppDispatch() const web3 = useWeb3() + const wallet = useWallet() const { uuid, _createPreferences, _deletePreferences, _deleteAllPreferences } = useNotificationPreferences() const registerNotifications = async (safesToRegister: NotifiableSafes) => { - if (!uuid || !web3) { + if (!uuid || !web3 || !wallet) { return } @@ -51,6 +54,7 @@ export const useNotificationRegistrations = (): { uuid, safesToRegister, web3, + isLedger: isLedger(wallet), }) return registerDevice(payload) diff --git a/src/components/settings/PushNotifications/logic.test.ts b/src/components/settings/PushNotifications/logic.test.ts index e217ef05cd..c88f3b1a17 100644 --- a/src/components/settings/PushNotifications/logic.test.ts +++ b/src/components/settings/PushNotifications/logic.test.ts @@ -29,6 +29,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,18 +95,85 @@ 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), + ) + + const uuid = crypto.randomUUID() + + const payload = await logic.getRegisterDevicePayload({ + safesToRegister: { + ['1']: [hexZeroPad('0x1', 20), hexZeroPad('0x2', 20)], + ['2']: [hexZeroPad('0x1', 20)], + }, + uuid, + web3: mockProvider, + isLedger: false, + }) + + 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), ) @@ -112,6 +186,7 @@ describe('Notifications', () => { }, uuid, web3: mockProvider, + isLedger: true, }) expect(payload).toStrictEqual({ @@ -126,12 +201,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/logic.ts b/src/components/settings/PushNotifications/logic.ts index 74c39b12d9..1e1b57bc9d 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' @@ -31,18 +31,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 +71,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 } @@ -64,10 +86,12 @@ export const getRegisterDevicePayload = async ({ safesToRegister, uuid, web3, + isLedger, }: { safesToRegister: NotifiableSafes uuid: string web3: Web3Provider + isLedger: boolean }): Promise => { const BUILD_NUMBER = '0' // Required value, but does not exist on web const BUNDLE = 'safe' @@ -101,6 +125,7 @@ export const getRegisterDevicePayload = async ({ uuid, timestamp, token, + isLedger, }) return { 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,