diff --git a/migrations/1733761866546-cleanNotificationData.ts b/migrations/1733761866546-cleanNotificationData.ts deleted file mode 100644 index 8d83b263b2..0000000000 --- a/migrations/1733761866546-cleanNotificationData.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { MigrationInterface, QueryRunner } from 'typeorm'; - -export class CleanNotificationData1733761866546 implements MigrationInterface { - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query( - `DELETE FROM notification_subscription_notification_types;`, - ); - await queryRunner.query(`DELETE FROM notification_subscriptions;`); - await queryRunner.query(`DELETE FROM push_notification_devices;`); - } - - public async down(): Promise {} -} diff --git a/src/datasources/push-notifications-api/entities/firebase-notification.entity.ts b/src/datasources/push-notifications-api/entities/firebase-notification.entity.ts index 50d303212f..ad0c07eb92 100644 --- a/src/datasources/push-notifications-api/entities/firebase-notification.entity.ts +++ b/src/datasources/push-notifications-api/entities/firebase-notification.entity.ts @@ -1,7 +1,26 @@ export type FirebaseNotification = { - notification?: { - title?: string; - body?: string; - }; + notification?: NotificationContent; data?: Record; }; + +export type NotificationContent = { + title?: string; + body?: string; +}; + +/** + * @link https://firebase.google.com/docs/cloud-messaging/concept-options + */ +export type FireabaseNotificationApn = { + apns: { + payload: { + aps: { + alert: { + title: string; + body: string; + }; + 'mutable-content': 1 | 0; + }; + }; + }; +}; diff --git a/src/datasources/push-notifications-api/firebase-cloud-messaging-api.service.spec.ts b/src/datasources/push-notifications-api/firebase-cloud-messaging-api.service.spec.ts index 5f16ddbfde..9c3bbaf311 100644 --- a/src/datasources/push-notifications-api/firebase-cloud-messaging-api.service.spec.ts +++ b/src/datasources/push-notifications-api/firebase-cloud-messaging-api.service.spec.ts @@ -103,6 +103,17 @@ describe('FirebaseCloudMessagingApiService', () => { message: { token: fcmToken, ...notification, + apns: { + payload: { + aps: { + alert: { + title: notification.notification?.title, + body: notification.notification?.body, + }, + 'mutable-content': 1, + }, + }, + }, }, }, networkRequest: { @@ -141,6 +152,17 @@ describe('FirebaseCloudMessagingApiService', () => { message: { token: fcmToken, ...notification, + apns: { + payload: { + aps: { + alert: { + title: notification.notification?.title, + body: notification.notification?.body, + }, + 'mutable-content': 1, + }, + }, + }, }, }, networkRequest: { diff --git a/src/datasources/push-notifications-api/firebase-cloud-messaging-api.service.ts b/src/datasources/push-notifications-api/firebase-cloud-messaging-api.service.ts index 6ce8181f4e..6d67cbeb81 100644 --- a/src/datasources/push-notifications-api/firebase-cloud-messaging-api.service.ts +++ b/src/datasources/push-notifications-api/firebase-cloud-messaging-api.service.ts @@ -11,7 +11,11 @@ import { } from '@/datasources/network/network.service.interface'; import { IPushNotificationsApi } from '@/domain/interfaces/push-notifications-api.interface'; import { Inject, Injectable } from '@nestjs/common'; -import { FirebaseNotification } from '@/datasources/push-notifications-api/entities/firebase-notification.entity'; +import { + FireabaseNotificationApn, + FirebaseNotification, + NotificationContent, +} from '@/datasources/push-notifications-api/entities/firebase-notification.entity'; import { IJwtService } from '@/datasources/jwt/jwt.service.interface'; import { FirebaseOauth2Token, @@ -26,6 +30,10 @@ export class FirebaseCloudMessagingApiService implements IPushNotificationsApi { private static readonly Scope = 'https://www.googleapis.com/auth/firebase.messaging'; + private static readonly DefaultIosNotificationTitle = 'New Activity'; + private static readonly DefaultIosNotificationBody = + 'New Activity with your Safe'; + private readonly baseUrl: string; private readonly project: string; private readonly clientEmail: string; @@ -57,6 +65,39 @@ export class FirebaseCloudMessagingApiService implements IPushNotificationsApi { ); } + /** + * Returns the notification data for iOS devices. + * + * On iOS, a title and body are required for a notification to be displayed. + * The `mutable-content` field is set to `1` to allow the notification to be modified by the app. + * This ensures an appropriate title and body are displayed to the user. + * + * @param {NotificationContent} notification - notification payload + * + * @returns {FireabaseNotificationApn} - iOS notification data + **/ + private getIosNotificationData( + notification?: NotificationContent, + ): FireabaseNotificationApn { + return { + apns: { + payload: { + aps: { + alert: { + title: + notification?.title ?? + FirebaseCloudMessagingApiService.DefaultIosNotificationTitle, + body: + notification?.body ?? + FirebaseCloudMessagingApiService.DefaultIosNotificationBody, + }, + 'mutable-content': 1, + }, + }, + }, + }; + } + /** * Enqueues a notification to be sent to a device with given FCM token. * @@ -76,6 +117,7 @@ export class FirebaseCloudMessagingApiService implements IPushNotificationsApi { message: { token: fcmToken, ...notification, + ...this.getIosNotificationData(notification.notification), }, }, networkRequest: { diff --git a/src/domain/hooks/helpers/event-notifications.helper.ts b/src/domain/hooks/helpers/event-notifications.helper.ts index 7d5a0effc5..938b5dad3a 100644 --- a/src/domain/hooks/helpers/event-notifications.helper.ts +++ b/src/domain/hooks/helpers/event-notifications.helper.ts @@ -35,6 +35,9 @@ import { } from '@/domain/delegate/v2/delegates.v2.repository.interface'; import { UUID } from 'crypto'; import { NotificationsRepositoryV2Module } from '@/domain/notifications/v2/notifications.repository.module'; +import uniqBy from 'lodash/uniqBy'; +import { Confirmation } from '@/domain/safe/entities/multisig-transaction.entity'; +import { MessageConfirmation } from '@/domain/messages/entities/message-confirmation.entity'; type EventToNotify = | DeletedMultisigTransactionEvent @@ -163,11 +166,14 @@ export class EventNotificationsHelper { cloudMessagingToken: string; }> > { - const subscriptions = + // If two or more owner keys are registered for the same device we shouldn't send the notification multiple times and therefore we need to group by their cloudMessagingToken + const subscriptions = uniqBy( await this.notificationsRepository.getSubscribersBySafe({ chainId: event.chainId, safeAddress: event.address, - }); + }), + 'cloudMessagingToken', + ); if (!this.isOwnerOrDelegateOnlyEventToNotify(event)) { return subscriptions; @@ -227,12 +233,14 @@ export class EventNotificationsHelper { return true; } - const delegates = await this.delegatesRepository.getDelegates(args); - return !!delegates?.results.some((delegate) => { - return ( - delegate.safe === args.safeAddress && - delegate.delegate === args.subscriber - ); + // Unfortunately, the delegate endpoint does not return any results when querying for the delegators of a safe. Instead, you need to query for the delegators of a delegate key. + const delegates = await this.delegatesRepository.getDelegates({ + chainId: args.chainId, + delegate: args.subscriber, + }); + + return delegates?.results.some((delegate) => { + return safe.owners.includes(delegate.delegator); }); } @@ -336,10 +344,13 @@ export class EventNotificationsHelper { }); // Subscriber has already signed - do not notify - const hasSubscriberSigned = transaction.confirmations?.some( - (confirmation) => { - return confirmation.owner === subscriber; - }, + if (!transaction?.confirmations) { + return null; + } + const hasSubscriberSigned = await this.hasSubscriberSigned( + event.chainId, + subscriber, + transaction.confirmations, ); if (hasSubscriberSigned) { return null; @@ -354,6 +365,27 @@ export class EventNotificationsHelper { }; } + private async hasSubscriberSigned( + chainId: string, + subscriber: `0x${string}`, + confirmations: Array, + ): Promise { + // The owner can be a delegate key so we need to check whether the owner or the delegate key has signed the message. + const delegates = await this.delegatesRepository.getDelegates({ + chainId: chainId, + delegate: subscriber, + }); + const delegators = delegates?.results.map( + (delegate) => delegate?.delegator, + ); + return confirmations?.some((confirmation) => { + return ( + confirmation.owner === subscriber || + delegators.includes(confirmation.owner) + ); + }); + } + /** * Maps {@link MessageCreatedEvent} to {@link MessageConfirmationNotification} if: * @@ -385,9 +417,14 @@ export class EventNotificationsHelper { }); // Subscriber has already signed - do not notify - const hasSubscriberSigned = message.confirmations.some((confirmation) => { - return confirmation.owner === subscriber; - }); + if (!message?.confirmations) { + return null; + } + const hasSubscriberSigned = await this.hasSubscriberSigned( + event.chainId, + subscriber, + message.confirmations, + ); if (hasSubscriberSigned) { return null; } diff --git a/src/domain/notifications/v2/entities/__tests__/notification.repository.mock.ts b/src/domain/notifications/v2/entities/__tests__/notification.repository.mock.ts index 06bdb343fe..8fbb586815 100644 --- a/src/domain/notifications/v2/entities/__tests__/notification.repository.mock.ts +++ b/src/domain/notifications/v2/entities/__tests__/notification.repository.mock.ts @@ -6,6 +6,7 @@ export const MockNotificationRepositoryV2: jest.MockedObjectDeep>; + deleteDeviceAndSubscriptions(deviceUUid: UUID): Promise; + getSubscribersBySafe(args: { chainId: string; safeAddress: `0x${string}`; diff --git a/src/domain/notifications/v2/notifications.repository.ts b/src/domain/notifications/v2/notifications.repository.ts index b24662dbed..79a812ca61 100644 --- a/src/domain/notifications/v2/notifications.repository.ts +++ b/src/domain/notifications/v2/notifications.repository.ts @@ -147,6 +147,23 @@ export class NotificationsRepositoryV2 implements INotificationsRepositoryV2 { return { id: queryResult.identifiers[0].id, device_uuid: deviceUuid }; } + public async deleteDeviceAndSubscriptions(deviceUuid: UUID): Promise { + const deviceSubscriptionsRepository = + await this.postgresDatabaseService.getRepository( + NotificationSubscription, + ); + const deviceSubscriptions = await deviceSubscriptionsRepository.find({ + where: { + push_notification_device: { + device_uuid: deviceUuid, + }, + }, + }); + if (deviceSubscriptions.length) { + await deviceSubscriptionsRepository.remove(deviceSubscriptions); + } + } + private async deletePreviousSubscriptions( entityManager: EntityManager, args: { diff --git a/src/routes/hooks/__tests__/event-hooks-queue.e2e-spec.ts b/src/routes/hooks/__tests__/event-hooks-queue.e2e-spec.ts index f58edd53a9..389dfd8d8c 100644 --- a/src/routes/hooks/__tests__/event-hooks-queue.e2e-spec.ts +++ b/src/routes/hooks/__tests__/event-hooks-queue.e2e-spec.ts @@ -130,6 +130,7 @@ describe('Events queue processing e2e tests', () => { to: faker.finance.ethereumAddress(), safeTxHash: faker.string.hexadecimal({ length: 32 }), txHash: faker.string.hexadecimal({ length: 32 }), + failed: faker.helpers.arrayElement(['true', 'false']), }, { type: 'NEW_CONFIRMATION', @@ -178,6 +179,7 @@ describe('Events queue processing e2e tests', () => { to: faker.finance.ethereumAddress(), safeTxHash: faker.string.hexadecimal({ length: 32 }), txHash: faker.string.hexadecimal({ length: 32 }), + failed: faker.helpers.arrayElement(['true', 'false']), }, { type: 'NEW_CONFIRMATION', @@ -221,6 +223,7 @@ describe('Events queue processing e2e tests', () => { to: faker.finance.ethereumAddress(), safeTxHash: faker.string.hexadecimal({ length: 32 }), txHash: faker.string.hexadecimal({ length: 32 }), + failed: faker.helpers.arrayElement(['true', 'false']), }, { type: 'MODULE_TRANSACTION', @@ -260,6 +263,7 @@ describe('Events queue processing e2e tests', () => { to: faker.finance.ethereumAddress(), safeTxHash: faker.string.hexadecimal({ length: 32 }), txHash: faker.string.hexadecimal({ length: 32 }), + failed: faker.helpers.arrayElement(['true', 'false']), }, { type: 'INCOMING_TOKEN', @@ -304,6 +308,7 @@ describe('Events queue processing e2e tests', () => { to: faker.finance.ethereumAddress(), safeTxHash: faker.string.hexadecimal({ length: 32 }), txHash: faker.string.hexadecimal({ length: 32 }), + failed: faker.helpers.arrayElement(['true', 'false']), }, { type: 'INCOMING_TOKEN', @@ -424,6 +429,7 @@ describe('Events queue processing e2e tests', () => { to: faker.finance.ethereumAddress(), safeTxHash: faker.string.hexadecimal({ length: 32 }), txHash: faker.string.hexadecimal({ length: 32 }), + failed: faker.helpers.arrayElement(['true', 'false']), }, { type: 'INCOMING_TOKEN', diff --git a/src/routes/hooks/entities/__tests__/executed-transaction.builder.ts b/src/routes/hooks/entities/__tests__/executed-transaction.builder.ts index 730b0058de..bf9e80c1b8 100644 --- a/src/routes/hooks/entities/__tests__/executed-transaction.builder.ts +++ b/src/routes/hooks/entities/__tests__/executed-transaction.builder.ts @@ -11,6 +11,10 @@ export function executedTransactionEventBuilder(): IBuilder .with('to', getAddress(faker.finance.ethereumAddress())) .with('address', getAddress(faker.finance.ethereumAddress())) .with('chainId', faker.string.numeric()) - .with('safeTxHash', faker.string.hexadecimal()) - .with('txHash', faker.string.hexadecimal()); + .with( + 'safeTxHash', + faker.string.hexadecimal({ length: 32 }) as `0x${string}`, + ) + .with('txHash', faker.string.hexadecimal({ length: 32 }) as `0x${string}`) + .with('failed', faker.helpers.arrayElement(['true', 'false'])); } diff --git a/src/routes/hooks/entities/__tests__/pending-transaction.builder.ts b/src/routes/hooks/entities/__tests__/pending-transaction.builder.ts index 1046c8cc64..3cf4e0ae92 100644 --- a/src/routes/hooks/entities/__tests__/pending-transaction.builder.ts +++ b/src/routes/hooks/entities/__tests__/pending-transaction.builder.ts @@ -11,5 +11,8 @@ export function pendingTransactionEventBuilder(): IBuilder { .with('to', getAddress(faker.finance.ethereumAddress())) .with('address', getAddress(faker.finance.ethereumAddress())) .with('chainId', faker.string.numeric()) - .with('safeTxHash', faker.string.hexadecimal()); + .with( + 'safeTxHash', + faker.string.hexadecimal({ length: 32 }) as `0x${string}`, + ); } diff --git a/src/routes/hooks/entities/schemas/executed-transaction.schema.ts b/src/routes/hooks/entities/schemas/executed-transaction.schema.ts index bb4b2149a8..7059600a21 100644 --- a/src/routes/hooks/entities/schemas/executed-transaction.schema.ts +++ b/src/routes/hooks/entities/schemas/executed-transaction.schema.ts @@ -1,5 +1,6 @@ import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; import { AddressSchema } from '@/validation/entities/schemas/address.schema'; +import { HexSchema } from '@/validation/entities/schemas/hex.schema'; import { z } from 'zod'; export const ExecutedTransactionEventSchema = z.object({ @@ -7,8 +8,9 @@ export const ExecutedTransactionEventSchema = z.object({ to: AddressSchema, address: AddressSchema, chainId: z.string(), - safeTxHash: z.string(), - txHash: z.string(), + safeTxHash: HexSchema, + txHash: HexSchema, + failed: z.enum(['true', 'false']), }); export type ExecutedTransactionEvent = z.infer< diff --git a/src/routes/hooks/entities/schemas/pending-transaction.schema.ts b/src/routes/hooks/entities/schemas/pending-transaction.schema.ts index e3a45b5b35..180a88e09a 100644 --- a/src/routes/hooks/entities/schemas/pending-transaction.schema.ts +++ b/src/routes/hooks/entities/schemas/pending-transaction.schema.ts @@ -1,13 +1,14 @@ import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; import { z } from 'zod'; import { AddressSchema } from '@/validation/entities/schemas/address.schema'; +import { HexSchema } from '@/validation/entities/schemas/hex.schema'; export const PendingTransactionEventSchema = z.object({ type: z.literal(TransactionEventType.PENDING_MULTISIG_TRANSACTION), to: AddressSchema, address: AddressSchema, chainId: z.string(), - safeTxHash: z.string(), + safeTxHash: HexSchema, }); export type PendingTransactionEvent = z.infer< diff --git a/src/routes/hooks/hooks-cache.spec.ts b/src/routes/hooks/hooks-cache.spec.ts index 4a5da7db91..ca72ee5134 100644 --- a/src/routes/hooks/hooks-cache.spec.ts +++ b/src/routes/hooks/hooks-cache.spec.ts @@ -185,6 +185,7 @@ describe('Hook Events for Cache (Unit)', () => { to: faker.finance.ethereumAddress(), safeTxHash: faker.string.hexadecimal({ length: 32 }), txHash: faker.string.hexadecimal({ length: 32 }), + failed: faker.helpers.arrayElement(['true', 'false']), }, { type: 'NEW_CONFIRMATION', @@ -241,6 +242,7 @@ describe('Hook Events for Cache (Unit)', () => { to: faker.finance.ethereumAddress(), safeTxHash: faker.string.hexadecimal({ length: 32 }), txHash: faker.string.hexadecimal({ length: 32 }), + failed: faker.helpers.arrayElement(['true', 'false']), }, { type: 'NEW_CONFIRMATION', @@ -292,6 +294,7 @@ describe('Hook Events for Cache (Unit)', () => { to: faker.finance.ethereumAddress(), safeTxHash: faker.string.hexadecimal({ length: 32 }), txHash: faker.string.hexadecimal({ length: 32 }), + failed: faker.helpers.arrayElement(['true', 'false']), }, { type: 'MODULE_TRANSACTION', @@ -339,6 +342,7 @@ describe('Hook Events for Cache (Unit)', () => { to: faker.finance.ethereumAddress(), safeTxHash: faker.string.hexadecimal({ length: 32 }), txHash: faker.string.hexadecimal({ length: 32 }), + failed: faker.helpers.arrayElement(['true', 'false']), }, { type: 'MODULE_TRANSACTION', @@ -392,6 +396,7 @@ describe('Hook Events for Cache (Unit)', () => { to: faker.finance.ethereumAddress(), safeTxHash: faker.string.hexadecimal({ length: 32 }), txHash: faker.string.hexadecimal({ length: 32 }), + failed: faker.helpers.arrayElement(['true', 'false']), }, { type: 'INCOMING_TOKEN', @@ -450,6 +455,7 @@ describe('Hook Events for Cache (Unit)', () => { { type: 'EXECUTED_MULTISIG_TRANSACTION', to: faker.finance.ethereumAddress(), + failed: faker.helpers.arrayElement(['true', 'false']), safeTxHash: faker.string.hexadecimal({ length: 32 }), txHash: faker.string.hexadecimal({ length: 32 }), }, @@ -594,6 +600,7 @@ describe('Hook Events for Cache (Unit)', () => { { type: 'EXECUTED_MULTISIG_TRANSACTION', to: faker.finance.ethereumAddress(), + failed: faker.helpers.arrayElement(['true', 'false']), safeTxHash: faker.string.hexadecimal({ length: 32 }), txHash: faker.string.hexadecimal({ length: 32 }), }, diff --git a/src/routes/hooks/hooks-notifications.spec.ts b/src/routes/hooks/hooks-notifications.spec.ts index c1836b66a1..2896d0700e 100644 --- a/src/routes/hooks/hooks-notifications.spec.ts +++ b/src/routes/hooks/hooks-notifications.spec.ts @@ -167,7 +167,7 @@ describe('Hook Events for Notifications (Unit) pt. 1', () => { () => ({ subscriber: getAddress(faker.finance.ethereumAddress()), deviceUuid: faker.string.uuid() as UUID, - cloudMessagingToken: faker.string.alphanumeric(), + cloudMessagingToken: faker.string.alphanumeric({ length: 20 }), }), { count: { min: 1, max: 5 }, @@ -218,7 +218,7 @@ describe('Hook Events for Notifications (Unit) pt. 1', () => { null, ]), deviceUuid: faker.string.uuid() as UUID, - cloudMessagingToken: faker.string.alphanumeric(), + cloudMessagingToken: faker.string.alphanumeric({ length: 20 }), }), { count: { min: 1, max: 5 }, @@ -301,7 +301,7 @@ describe('Hook Events for Notifications (Unit) pt. 1', () => { null, ]), deviceUuid: faker.string.uuid() as UUID, - cloudMessagingToken: faker.string.alphanumeric(), + cloudMessagingToken: faker.string.alphanumeric({ length: 20 }), }), { count: { min: 1, max: 5 }, @@ -373,7 +373,7 @@ describe('Hook Events for Notifications (Unit) pt. 1', () => { () => ({ subscriber: getAddress(faker.finance.ethereumAddress()), deviceUuid: faker.string.uuid() as UUID, - cloudMessagingToken: faker.string.alphanumeric(), + cloudMessagingToken: faker.string.alphanumeric({ length: 20 }), }), { count: { min: 1, max: 5 }, @@ -390,6 +390,12 @@ describe('Hook Events for Notifications (Unit) pt. 1', () => { notificationsRepository.getSubscribersBySafe.mockResolvedValue( subscribers, ); + const delegates = subscribers.map((subscriber) => { + return delegateBuilder() + .with('delegate', subscriber.subscriber) + .with('safe', event.address) + .build(); + }); networkService.get.mockImplementation(({ url }) => { if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { @@ -412,6 +418,11 @@ describe('Hook Events for Notifications (Unit) pt. 1', () => { status: 200, data: rawify(multisigTransaction), }); + } else if (url === `${chain.transactionService}/api/v2/delegates/`) { + return Promise.resolve({ + status: 200, + data: rawify(pageBuilder().with('results', delegates).build()), + }); } else { return Promise.reject(`No matching rule for url: ${url}`); } @@ -443,7 +454,7 @@ describe('Hook Events for Notifications (Unit) pt. 1', () => { () => ({ subscriber: getAddress(faker.finance.ethereumAddress()), deviceUuid: faker.string.uuid() as UUID, - cloudMessagingToken: faker.string.alphanumeric(), + cloudMessagingToken: faker.string.alphanumeric({ length: 20 }), }), { count: { min: 1, max: 5 }, @@ -496,7 +507,7 @@ describe('Hook Events for Notifications (Unit) pt. 1', () => { () => ({ subscriber: getAddress(faker.finance.ethereumAddress()), deviceUuid: faker.string.uuid() as UUID, - cloudMessagingToken: faker.string.alphanumeric(), + cloudMessagingToken: faker.string.alphanumeric({ length: 20 }), }), { count: { min: 1, max: 5 }, @@ -578,7 +589,7 @@ describe('Hook Events for Notifications (Unit) pt. 1', () => { const subscribers = owners.map((owner) => ({ subscriber: owner, deviceUuid: faker.string.uuid() as UUID, - cloudMessagingToken: faker.string.alphanumeric(), + cloudMessagingToken: faker.string.alphanumeric({ length: 20 }), })); notificationsRepository.getSubscribersBySafe.mockResolvedValue( subscribers, @@ -588,6 +599,20 @@ describe('Hook Events for Notifications (Unit) pt. 1', () => { .map((owner) => { return confirmationBuilder().with('owner', owner).build(); }); + + const delegates = owners + .filter((owner) => { + return confirmations.every( + (confirmation) => confirmation.owner !== owner, + ); + }) + .map((owner) => { + return delegateBuilder() + .with('delegate', getAddress(faker.finance.ethereumAddress())) + .with('delegator', owner) + .with('safe', event.address) + .build(); + }); const multisigTransaction = multisigTransactionBuilder() .with('safe', event.address) .with('confirmations', confirmations) @@ -614,6 +639,11 @@ describe('Hook Events for Notifications (Unit) pt. 1', () => { status: 200, data: rawify(multisigTransaction), }); + } else if (url === `${chain.transactionService}/api/v2/delegates/`) { + return Promise.resolve({ + status: 200, + data: rawify(pageBuilder().with('results', delegates).build()), + }); } else { return Promise.reject(`No matching rule for url: ${url}`); } @@ -660,7 +690,7 @@ describe('Hook Events for Notifications (Unit) pt. 1', () => { () => ({ subscriber: getAddress(faker.finance.ethereumAddress()), deviceUuid: faker.string.uuid() as UUID, - cloudMessagingToken: faker.string.alphanumeric(), + cloudMessagingToken: faker.string.alphanumeric({ length: 20 }), }), { count: { min: 1, max: 5 }, @@ -677,6 +707,13 @@ describe('Hook Events for Notifications (Unit) pt. 1', () => { notificationsRepository.getSubscribersBySafe.mockResolvedValue( subscribers, ); + const delegates = safe.owners.map((owner) => { + return delegateBuilder() + .with('delegate', getAddress(faker.finance.ethereumAddress())) + .with('delegator', owner) + .with('safe', event.address) + .build(); + }); networkService.get.mockImplementation(({ url }) => { if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { @@ -699,6 +736,11 @@ describe('Hook Events for Notifications (Unit) pt. 1', () => { status: 200, data: rawify(message), }); + } else if (url === `${chain.transactionService}/api/v2/delegates/`) { + return Promise.resolve({ + status: 200, + data: rawify(pageBuilder().with('results', delegates).build()), + }); } else { return Promise.reject(`No matching rule for url: ${url}`); } @@ -732,7 +774,7 @@ describe('Hook Events for Notifications (Unit) pt. 1', () => { () => ({ subscriber: getAddress(faker.finance.ethereumAddress()), deviceUuid: faker.string.uuid() as UUID, - cloudMessagingToken: faker.string.alphanumeric(), + cloudMessagingToken: faker.string.alphanumeric({ length: 20 }), }), { count: { min: 1, max: 5 }, @@ -785,7 +827,7 @@ describe('Hook Events for Notifications (Unit) pt. 1', () => { () => ({ subscriber: getAddress(faker.finance.ethereumAddress()), deviceUuid: faker.string.uuid() as UUID, - cloudMessagingToken: faker.string.alphanumeric(), + cloudMessagingToken: faker.string.alphanumeric({ length: 20 }), }), { count: { min: 1, max: 5 }, @@ -868,7 +910,7 @@ describe('Hook Events for Notifications (Unit) pt. 1', () => { const subscribers = owners.map((owner) => ({ subscriber: owner, deviceUuid: faker.string.uuid() as UUID, - cloudMessagingToken: faker.string.alphanumeric(), + cloudMessagingToken: faker.string.alphanumeric({ length: 20 }), })); notificationsRepository.getSubscribersBySafe.mockResolvedValue( subscribers, @@ -883,6 +925,20 @@ describe('Hook Events for Notifications (Unit) pt. 1', () => { .with('confirmations', confirmations) .build(); + const delegates = owners + .filter((owner) => { + return confirmations.every( + (confirmation) => confirmation.owner !== owner, + ); + }) + .map((owner) => { + return delegateBuilder() + .with('delegate', getAddress(faker.finance.ethereumAddress())) + .with('delegator', owner) + .with('safe', event.address) + .build(); + }); + networkService.get.mockImplementation(({ url }) => { if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { return Promise.resolve({ @@ -896,6 +952,11 @@ describe('Hook Events for Notifications (Unit) pt. 1', () => { status: 200, data: rawify(safe), }); + } else if (url === `${chain.transactionService}/api/v2/delegates/`) { + return Promise.resolve({ + status: 200, + data: rawify(pageBuilder().with('results', delegates).build()), + }); } else if ( url === `${chain.transactionService}/api/v1/messages/${event.messageHash}` @@ -958,15 +1019,16 @@ describe('Hook Events for Notifications (Unit) pt. 1', () => { () => ({ subscriber: getAddress(faker.finance.ethereumAddress()), deviceUuid: faker.string.uuid() as UUID, - cloudMessagingToken: faker.string.alphanumeric(), + cloudMessagingToken: faker.string.alphanumeric({ length: 20 }), }), { count: { min: 1, max: 5 }, }, ); - const delegates = subscribers.map((subscriber) => { + const delegates = safe.owners.map((owner) => { return delegateBuilder() - .with('delegate', subscriber.subscriber) + .with('delegate', getAddress(faker.finance.ethereumAddress())) + .with('delegator', owner) .with('safe', event.address) .build(); }); @@ -1035,7 +1097,7 @@ describe('Hook Events for Notifications (Unit) pt. 1', () => { () => ({ subscriber: getAddress(faker.finance.ethereumAddress()), deviceUuid: faker.string.uuid() as UUID, - cloudMessagingToken: faker.string.alphanumeric(), + cloudMessagingToken: faker.string.alphanumeric({ length: 20 }), }), { count: { min: 1, max: 5 }, @@ -1095,7 +1157,7 @@ describe('Hook Events for Notifications (Unit) pt. 1', () => { () => ({ subscriber: getAddress(faker.finance.ethereumAddress()), deviceUuid: faker.string.uuid() as UUID, - cloudMessagingToken: faker.string.alphanumeric(), + cloudMessagingToken: faker.string.alphanumeric({ length: 20 }), }), { count: { min: 1, max: 5 }, @@ -1180,22 +1242,29 @@ describe('Hook Events for Notifications (Unit) pt. 1', () => { const subscribers = owners.map((owner) => ({ subscriber: owner, deviceUuid: faker.string.uuid() as UUID, - cloudMessagingToken: faker.string.alphanumeric(), + cloudMessagingToken: faker.string.alphanumeric({ length: 20 }), })); notificationsRepository.getSubscribersBySafe.mockResolvedValue( subscribers, ); - const delegates = subscribers.map((subscriber) => { - return delegateBuilder() - .with('delegate', subscriber.subscriber) - .with('safe', event.address) - .build(); - }); const confirmations = faker.helpers .arrayElements(owners, { min: 1, max: owners.length - 1 }) .map((owner) => { return confirmationBuilder().with('owner', owner).build(); }); + const delegates = owners + .filter((owner) => { + return confirmations.every( + (confirmation) => confirmation.owner !== owner, + ); + }) + .map((owner) => { + return delegateBuilder() + .with('delegate', getAddress(faker.finance.ethereumAddress())) + .with('delegator', owner) + .with('safe', event.address) + .build(); + }); const multisigTransaction = multisigTransactionBuilder() .with('safe', event.address) .with('confirmations', confirmations) @@ -1277,16 +1346,17 @@ describe('Hook Events for Notifications (Unit) pt. 1', () => { () => ({ subscriber: getAddress(faker.finance.ethereumAddress()), deviceUuid: faker.string.uuid() as UUID, - cloudMessagingToken: faker.string.alphanumeric(), + cloudMessagingToken: faker.string.alphanumeric({ length: 20 }), }), { count: { min: 1, max: 5 }, }, ); - const delegates = subscribers.map((subscriber) => { + const delegates = safe.owners.map((owner) => { return delegateBuilder() - .with('delegate', subscriber.subscriber) - .with('safe', event.address) + .with('delegate', getAddress(faker.finance.ethereumAddress())) + .with('delegator', owner) + .with('safe', safe.address) .build(); }); notificationsRepository.getSubscribersBySafe.mockResolvedValue( @@ -1356,7 +1426,7 @@ describe('Hook Events for Notifications (Unit) pt. 1', () => { () => ({ subscriber: getAddress(faker.finance.ethereumAddress()), deviceUuid: faker.string.uuid() as UUID, - cloudMessagingToken: faker.string.alphanumeric(), + cloudMessagingToken: faker.string.alphanumeric({ length: 20 }), }), { count: { min: 1, max: 5 }, @@ -1416,7 +1486,7 @@ describe('Hook Events for Notifications (Unit) pt. 1', () => { () => ({ subscriber: getAddress(faker.finance.ethereumAddress()), deviceUuid: faker.string.uuid() as UUID, - cloudMessagingToken: faker.string.alphanumeric(), + cloudMessagingToken: faker.string.alphanumeric({ length: 20 }), }), { count: { min: 1, max: 5 }, @@ -1502,7 +1572,7 @@ describe('Hook Events for Notifications (Unit) pt. 1', () => { const subscribers = owners.map((owner) => ({ subscriber: owner, deviceUuid: faker.string.uuid() as UUID, - cloudMessagingToken: faker.string.alphanumeric(), + cloudMessagingToken: faker.string.alphanumeric({ length: 20 }), })); notificationsRepository.getSubscribersBySafe.mockResolvedValue( subscribers, @@ -1604,7 +1674,7 @@ describe('Hook Events for Notifications (Unit) pt. 1', () => { null, ]), deviceUuid: faker.string.uuid() as UUID, - cloudMessagingToken: faker.string.alphanumeric(), + cloudMessagingToken: faker.string.alphanumeric({ length: 20 }), }), { count: { min: 1, max: 5 }, @@ -1672,7 +1742,7 @@ describe('Hook Events for Notifications (Unit) pt. 1', () => { null, ]), deviceUuid: faker.string.uuid() as UUID, - cloudMessagingToken: faker.string.alphanumeric(), + cloudMessagingToken: faker.string.alphanumeric({ length: 20 }), }), { count: { min: 1, max: 5 }, @@ -1727,56 +1797,64 @@ describe('Hook Events for Notifications (Unit) pt. 1', () => { it('should enqueue CONFIRMATION_REQUEST event notifications accordingly for a mixture of subscribers: owners, delegates and non-owner/delegates', async () => { const event = pendingTransactionEventBuilder().build(); const chain = chainBuilder().with('chainId', event.chainId).build(); + const safeOwners = [ + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + ]; const ownerSubscriptions = [ { - subscriber: getAddress(faker.finance.ethereumAddress()), + subscriber: safeOwners[0], deviceUuid: faker.string.uuid() as UUID, - cloudMessagingToken: faker.string.alphanumeric(), + cloudMessagingToken: faker.string.alphanumeric({ length: 20 }), }, { - subscriber: getAddress(faker.finance.ethereumAddress()), + subscriber: safeOwners[1], deviceUuid: faker.string.uuid() as UUID, - cloudMessagingToken: faker.string.alphanumeric(), + cloudMessagingToken: faker.string.alphanumeric({ length: 20 }), }, ]; const delegateSubscriptions = [ { subscriber: getAddress(faker.finance.ethereumAddress()), deviceUuid: faker.string.uuid() as UUID, - cloudMessagingToken: faker.string.alphanumeric(), + cloudMessagingToken: faker.string.alphanumeric({ length: 20 }), }, { subscriber: getAddress(faker.finance.ethereumAddress()), deviceUuid: faker.string.uuid() as UUID, - cloudMessagingToken: faker.string.alphanumeric(), + cloudMessagingToken: faker.string.alphanumeric({ length: 20 }), }, ]; + const delegateDelegators = { + [delegateSubscriptions[0].subscriber]: safeOwners[2], + [delegateSubscriptions[1].subscriber]: safeOwners[3], + }; const nonOwnerDelegateSubscriptions = [ { subscriber: getAddress(faker.finance.ethereumAddress()), deviceUuid: faker.string.uuid() as UUID, - cloudMessagingToken: faker.string.alphanumeric(), + cloudMessagingToken: faker.string.alphanumeric({ length: 20 }), }, { subscriber: null, deviceUuid: faker.string.uuid() as UUID, - cloudMessagingToken: faker.string.alphanumeric(), + cloudMessagingToken: faker.string.alphanumeric({ length: 20 }), }, ]; const safe = safeBuilder() .with('address', event.address) - .with('threshold', 2) + .with('threshold', 3) .with( 'owners', - ownerSubscriptions.map((subscription) => subscription.subscriber), + safeOwners.map((owners) => owners), ) .build(); const multisigTransaction = multisigTransactionBuilder() .with('safe', event.address) .with('confirmations', [ - confirmationBuilder() - .with('owner', ownerSubscriptions[0].subscriber) - .build(), + confirmationBuilder().with('owner', safeOwners[0]).build(), ]) .build(); notificationsRepository.getSubscribersBySafe.mockResolvedValue([ @@ -1785,7 +1863,7 @@ describe('Hook Events for Notifications (Unit) pt. 1', () => { ...nonOwnerDelegateSubscriptions, ]); - networkService.get.mockImplementation(({ url }) => { + networkService.get.mockImplementation(({ url, networkRequest }) => { if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { return Promise.resolve({ data: rawify(chain), @@ -1799,21 +1877,21 @@ describe('Hook Events for Notifications (Unit) pt. 1', () => { data: rawify(safe), }); } else if (url === `${chain.transactionService}/api/v2/delegates/`) { + const payloadDelegate = networkRequest?.params + ?.delegate as `0x${string}`; + const delegator = delegateDelegators[payloadDelegate]; + const results = delegator + ? [ + delegateBuilder() + .with('delegate', payloadDelegate) + .with('delegator', delegator) + .with('safe', safe.address) + .build(), + ] + : []; return Promise.resolve({ status: 200, - data: rawify( - pageBuilder() - .with( - 'results', - delegateSubscriptions.map((subscription) => { - return delegateBuilder() - .with('delegate', subscription.subscriber) - .with('safe', safe.address) - .build(); - }), - ) - .build(), - ), + data: rawify(pageBuilder().with('results', results).build()), }); } else if ( url === @@ -1863,56 +1941,64 @@ describe('Hook Events for Notifications (Unit) pt. 1', () => { it('should enqueue MESSAGE_CONFIRMATION_REQUEST event notifications accordingly for a mixture of subscribers: owners, delegates and non-owner/delegates', async () => { const event = messageCreatedEventBuilder().build(); const chain = chainBuilder().with('chainId', event.chainId).build(); + const safeOwners = [ + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + ]; const ownerSubscriptions = [ { - subscriber: getAddress(faker.finance.ethereumAddress()), + subscriber: safeOwners[0], deviceUuid: faker.string.uuid() as UUID, - cloudMessagingToken: faker.string.alphanumeric(), + cloudMessagingToken: faker.string.alphanumeric({ length: 20 }), }, { - subscriber: getAddress(faker.finance.ethereumAddress()), + subscriber: safeOwners[1], deviceUuid: faker.string.uuid() as UUID, - cloudMessagingToken: faker.string.alphanumeric(), + cloudMessagingToken: faker.string.alphanumeric({ length: 20 }), }, ]; const delegateSubscriptions = [ { subscriber: getAddress(faker.finance.ethereumAddress()), deviceUuid: faker.string.uuid() as UUID, - cloudMessagingToken: faker.string.alphanumeric(), + cloudMessagingToken: faker.string.alphanumeric({ length: 20 }), }, { subscriber: getAddress(faker.finance.ethereumAddress()), deviceUuid: faker.string.uuid() as UUID, - cloudMessagingToken: faker.string.alphanumeric(), + cloudMessagingToken: faker.string.alphanumeric({ length: 20 }), }, ]; + const delegateDelegators = { + [delegateSubscriptions[0].subscriber]: safeOwners[2], + [delegateSubscriptions[1].subscriber]: safeOwners[3], + }; const nonOwnerDelegateSubscriptions = [ { subscriber: getAddress(faker.finance.ethereumAddress()), deviceUuid: faker.string.uuid() as UUID, - cloudMessagingToken: faker.string.alphanumeric(), + cloudMessagingToken: faker.string.alphanumeric({ length: 20 }), }, { subscriber: null, deviceUuid: faker.string.uuid() as UUID, - cloudMessagingToken: faker.string.alphanumeric(), + cloudMessagingToken: faker.string.alphanumeric({ length: 20 }), }, ]; const safe = safeBuilder() .with('address', event.address) - .with('threshold', 2) + .with('threshold', 3) .with( 'owners', - ownerSubscriptions.map((subscription) => subscription.subscriber), + safeOwners.map((owner) => owner), ) .build(); const message = messageBuilder() .with('messageHash', event.messageHash as `0x${string}`) .with('confirmations', [ - messageConfirmationBuilder() - .with('owner', ownerSubscriptions[0].subscriber) - .build(), + messageConfirmationBuilder().with('owner', safeOwners[0]).build(), ]) .build(); notificationsRepository.getSubscribersBySafe.mockResolvedValue([ @@ -1921,7 +2007,7 @@ describe('Hook Events for Notifications (Unit) pt. 1', () => { ...nonOwnerDelegateSubscriptions, ]); - networkService.get.mockImplementation(({ url }) => { + networkService.get.mockImplementation(({ url, networkRequest }) => { if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { return Promise.resolve({ data: rawify(chain), @@ -1935,21 +2021,21 @@ describe('Hook Events for Notifications (Unit) pt. 1', () => { data: rawify(safe), }); } else if (url === `${chain.transactionService}/api/v2/delegates/`) { + const payloadDelegate = networkRequest?.params + ?.delegate as `0x${string}`; + const delegator = delegateDelegators[payloadDelegate]; + const results = delegator + ? [ + delegateBuilder() + .with('delegate', payloadDelegate) + .with('delegator', delegator) + .with('safe', safe.address) + .build(), + ] + : []; return Promise.resolve({ status: 200, - data: rawify( - pageBuilder() - .with( - 'results', - delegateSubscriptions.map((subscription) => { - return delegateBuilder() - .with('delegate', subscription.subscriber) - .with('safe', safe.address) - .build(); - }), - ) - .build(), - ), + data: rawify(pageBuilder().with('results', results).build()), }); } else if ( url === @@ -2052,12 +2138,18 @@ describe('Hook Events for Notifications (Unit) pt. 1', () => { (_, i) => ({ subscriber: safe.owners[i], deviceUuid: faker.string.uuid() as UUID, - cloudMessagingToken: faker.string.alphanumeric(), + cloudMessagingToken: faker.string.alphanumeric({ length: 20 }), }), { count: safe.owners.length, }, ); + const delegates = subscribers.map((subscriber) => { + return delegateBuilder() + .with('delegate', subscriber.subscriber) + .with('safe', safe.address) + .build(); + }); notificationsRepository.getSubscribersBySafe.mockResolvedValue(subscribers); networkService.get.mockImplementation(({ url }) => { if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { @@ -2080,6 +2172,11 @@ describe('Hook Events for Notifications (Unit) pt. 1', () => { status: 200, data: rawify(multisigTransaction), }); + } else if (url === `${chain.transactionService}/api/v2/delegates/`) { + return Promise.resolve({ + status: 200, + data: rawify(pageBuilder().with('results', delegates).build()), + }); } else if ( url === `${chain.transactionService}/api/v1/messages/${message.messageHash}` @@ -2186,7 +2283,7 @@ describe('Hook Events for Notifications (Unit) pt. 2', () => { () => ({ subscriber: getAddress(faker.finance.ethereumAddress()), deviceUuid: faker.string.uuid() as UUID, - cloudMessagingToken: faker.string.alphanumeric(), + cloudMessagingToken: faker.string.alphanumeric({ length: 20 }), }), { count: { min: 2, max: 5 }, diff --git a/src/routes/notifications/v1/entities/__tests__/create-registration-v2.dto.builder.ts b/src/routes/notifications/v1/entities/__tests__/create-registration-v2.dto.builder.ts index 41c3a747f1..539f383069 100644 --- a/src/routes/notifications/v1/entities/__tests__/create-registration-v2.dto.builder.ts +++ b/src/routes/notifications/v1/entities/__tests__/create-registration-v2.dto.builder.ts @@ -2,8 +2,14 @@ import type { UpsertSubscriptionsDto } from '@/domain/notifications/v2/entities/ import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity'; import { NotificationType } from '@/domain/notifications/v2/entities/notification.entity'; import type { RegisterDeviceDto } from '@/routes/notifications/v1/entities/register-device.dto.entity'; -import type { UUID } from 'crypto'; -import { getAddress, keccak256, recoverMessageAddress, toBytes } from 'viem'; +import { + getAddress, + keccak256, + recoverAddress, + recoverMessageAddress, + toBytes, +} from 'viem'; +import { DeviceType } from '@/domain/notifications/v1/entities/device.entity'; export const createV2RegisterDtoBuilder = async ( args: RegisterDeviceDto, @@ -28,7 +34,7 @@ export const createV2RegisterDtoBuilder = async ( upsertSubscriptionsDto: { cloudMessagingToken: args.cloudMessagingToken, deviceType: args.deviceType, - deviceUuid: (args.uuid as UUID | undefined) ?? null, + deviceUuid: args.uuid ?? null, safes: [], signature: safeV1Registration.signatures[0] as `0x${string}`, }, @@ -46,18 +52,24 @@ export const createV2RegisterDtoBuilder = async ( } for (const [index, safeV2] of safeV2Array.entries()) { - const safeAddresses = args.safeRegistrations.flatMap((safe) => safe.safes); + const safeAddresses = safeV2.upsertSubscriptionsDto.safes.map( + (safeV2Safes) => safeV2Safes.address, + ); - const recoveredAddress = await recoverMessageAddress({ - message: { - raw: keccak256( - toBytes( - `gnosis-safe${args.timestamp}${args.uuid}${args.cloudMessagingToken}${safeAddresses.sort().join('')}`, - ), - ), - }, - signature: safeV2.upsertSubscriptionsDto.signature, - }); + let recoveredAddress: `0x${string}`; + if (args.deviceType === DeviceType.Web) { + recoveredAddress = await recoverMessageAddress({ + message: { + raw: messageToRecover(args, safeAddresses), + }, + signature: safeV2.upsertSubscriptionsDto.signature, + }); + } else { + recoveredAddress = await recoverAddress({ + hash: messageToRecover(args, safeAddresses), + signature: safeV2.upsertSubscriptionsDto.signature, + }); + } safeV2.authPayload.chain_id = safeV2.upsertSubscriptionsDto.safes[0].chainId; @@ -68,3 +80,14 @@ export const createV2RegisterDtoBuilder = async ( return safeV2Array; }; + +const messageToRecover = ( + args: RegisterDeviceDto, + safeAddresses: Array<`0x${string}`>, +): `0x${string}` => { + return keccak256( + toBytes( + `gnosis-safe${args.timestamp}${args.uuid}${args.cloudMessagingToken}${safeAddresses.sort().join('')}`, + ), + ); +}; diff --git a/src/routes/notifications/v1/entities/register-device.dto.entity.ts b/src/routes/notifications/v1/entities/register-device.dto.entity.ts index 7e3f18661a..f7ca5c3f5a 100644 --- a/src/routes/notifications/v1/entities/register-device.dto.entity.ts +++ b/src/routes/notifications/v1/entities/register-device.dto.entity.ts @@ -6,11 +6,12 @@ import { } from '@nestjs/swagger'; import { DeviceType } from '@/domain/notifications/v1/entities/device.entity'; import { SafeRegistration } from '@/routes/notifications/v1/entities/safe-registration.entity'; +import type { UUID } from 'crypto'; @ApiExtraModels(SafeRegistration) export class RegisterDeviceDto { @ApiPropertyOptional({ type: String, nullable: true }) - uuid?: string; + uuid?: UUID; @ApiProperty() cloudMessagingToken!: string; @ApiProperty() diff --git a/src/routes/notifications/v1/notifications.controller.ts b/src/routes/notifications/v1/notifications.controller.ts index cc46680ddf..20053a2c00 100644 --- a/src/routes/notifications/v1/notifications.controller.ts +++ b/src/routes/notifications/v1/notifications.controller.ts @@ -19,13 +19,19 @@ import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity'; import { NotificationType } from '@/domain/notifications/v2/entities/notification.entity'; import type { UUID } from 'crypto'; import { NotificationsServiceV2 } from '@/routes/notifications/v2/notifications.service'; -import { keccak256, recoverMessageAddress, toBytes } from 'viem'; +import { + keccak256, + recoverAddress, + recoverMessageAddress, + toBytes, +} from 'viem'; import { UuidSchema } from '@/validation/entities/schemas/uuid.schema'; import { IConfigurationService } from '@/config/configuration.service.interface'; import { LoggingService, type ILoggingService, } from '@/logging/logging.interface'; +import { DeviceType } from '@/domain/notifications/v1/entities/device.entity'; @ApiTags('notifications') @Controller({ path: '', version: '1' }) @@ -71,6 +77,18 @@ export class NotificationsController { const v2Requests = []; + const deleteAllDeviceOwners = + registerDeviceDto.deviceType !== DeviceType.Web; + + if (deleteAllDeviceOwners && registerDeviceDto.uuid !== undefined) { + // Some clients, such as the mobile app, do not call the delete endpoint to remove an owner key. + // Instead, they resend the updated list of owners without the key they want to delete. + // In such cases, we need to clear all the previous owners to ensure the update is applied correctly. + await this.notificationServiceV2.deleteDeviceAndSubscriptions( + registerDeviceDto.uuid, + ); + } + for (const compatibleV2Request of compatibleV2Requests) { v2Requests.push( await this.notificationServiceV2.upsertSubscriptions( @@ -91,7 +109,6 @@ export class NotificationsController { }), ); } - await Promise.allSettled(unregistrationRequests).then( (results: Array>) => { for (const result of results) { @@ -126,51 +143,54 @@ export class NotificationsController { const safesV1Registrations = args.safeRegistrations; for (const safeV1Registration of safesV1Registrations) { - if (safeV1Registration.safes.length) { - const safeV2: Parameters< - NotificationsServiceV2['upsertSubscriptions'] - >[0] & { - upsertSubscriptionsDto: { - safes: Array; - signature: `0x${string}`; + const signatureArray = safeV1Registration.signatures.length + ? safeV1Registration.signatures + : [undefined]; // The signature for mobile clients can be empty so we need to pass undefined here + for (const safeV1Signature of signatureArray) { + if (safeV1Registration.safes.length) { + const safeV2: Parameters< + NotificationsServiceV2['upsertSubscriptions'] + >[0] & { + upsertSubscriptionsDto: { + safes: Array; + signature: `0x${string}`; + }; + } = { + upsertSubscriptionsDto: { + cloudMessagingToken: args.cloudMessagingToken, + deviceType: args.deviceType, + deviceUuid: (args.uuid as UUID) || undefined, + safes: [], + signature: (safeV1Signature as `0x${string}`) ?? undefined, + }, + authPayload: new AuthPayload(), }; - } = { - upsertSubscriptionsDto: { - cloudMessagingToken: args.cloudMessagingToken, - deviceType: args.deviceType, - deviceUuid: (args.uuid as UUID) || undefined, - safes: [], - signature: safeV1Registration.signatures[0] as `0x${string}`, - }, - authPayload: new AuthPayload(), - }; - const uniqueSafeAddresses = new Set(safeV1Registration.safes); - for (const safeAddresses of uniqueSafeAddresses) { - safeV2.upsertSubscriptionsDto.safes.push({ - address: safeAddresses as `0x${string}`, - chainId: safeV1Registration.chainId, - notificationTypes: Object.values(NotificationType), - }); + const uniqueSafeAddresses = new Set(safeV1Registration.safes); + for (const safeAddresses of uniqueSafeAddresses) { + safeV2.upsertSubscriptionsDto.safes.push({ + address: safeAddresses as `0x${string}`, + chainId: safeV1Registration.chainId, + notificationTypes: Object.values(NotificationType), + }); + } + safeV2Array.push(safeV2); } - safeV2Array.push(safeV2); } } for (const [index, safeV2] of safeV2Array.entries()) { - const safeAddresses = args.safeRegistrations.flatMap( - (safe) => safe.safes, + const safeAddresses = safeV2.upsertSubscriptionsDto.safes.map( + (safeV2Safes) => safeV2Safes.address, ); - const recoveredAddress = await recoverMessageAddress({ - message: { - raw: keccak256( - toBytes( - `gnosis-safe${args.timestamp}${args.uuid}${args.cloudMessagingToken}${safeAddresses.sort().join('')}`, - ), - ), - }, - signature: safeV2.upsertSubscriptionsDto.signature, - }); + let recoveredAddress: `0x${string}` | undefined = undefined; + if (safeV2.upsertSubscriptionsDto.signature) { + recoveredAddress = await this.recoverAddress({ + registerDeviceDto: args, + safeV2Dto: safeV2, + safeAddresses, + }); + } safeV2.authPayload.chain_id = safeV2.upsertSubscriptionsDto.safes[0].chainId; @@ -196,6 +216,34 @@ export class NotificationsController { } } + private async recoverAddress(args: { + registerDeviceDto: RegisterDeviceDto; + safeV2Dto: Parameters[0] & { + upsertSubscriptionsDto: { + safes: Array; + signature: `0x${string}`; + }; + }; + safeAddresses: Array<`0x${string}`>; + }): Promise<`0x${string}`> { + /** + * @todo Explore the feasibility of using a unified method to recover signatures for both web and other clients. + */ + if (args.registerDeviceDto.deviceType === DeviceType.Web) { + return await recoverMessageAddress({ + message: { + raw: this.messageToRecover(args), + }, + signature: args.safeV2Dto.upsertSubscriptionsDto.signature, + }); + } else { + return await recoverAddress({ + hash: this.messageToRecover(args), + signature: args.safeV2Dto.upsertSubscriptionsDto.signature, + }); + } + } + @Delete('chains/:chainId/notifications/devices/:uuid') async unregisterDevice( @Param('chainId') chainId: string, @@ -289,4 +337,21 @@ export class NotificationsController { } } } + + private messageToRecover(args: { + registerDeviceDto: RegisterDeviceDto; + safeV2Dto: Parameters[0] & { + upsertSubscriptionsDto: { + safes: Array; + signature: `0x${string}`; + }; + }; + safeAddresses: Array<`0x${string}`>; + }): `0x${string}` { + return keccak256( + toBytes( + `gnosis-safe${args.registerDeviceDto.timestamp}${args.registerDeviceDto.uuid}${args.registerDeviceDto.cloudMessagingToken}${args.safeAddresses.sort().join('')}`, + ), + ); + } } diff --git a/src/routes/notifications/v2/notifications.service.ts b/src/routes/notifications/v2/notifications.service.ts index 3449b48f78..c74959c4c8 100644 --- a/src/routes/notifications/v2/notifications.service.ts +++ b/src/routes/notifications/v2/notifications.service.ts @@ -21,6 +21,12 @@ export class NotificationsServiceV2 { return this.notificationsRepository.upsertSubscriptions(args); } + async deleteDeviceAndSubscriptions(deviceUuidd: UUID): Promise { + await this.notificationsRepository.deleteDeviceAndSubscriptions( + deviceUuidd, + ); + } + async getSafeSubscription(args: { authPayload: AuthPayload; deviceUuid: UUID; diff --git a/src/routes/notifications/v2/test.notifications.module.ts b/src/routes/notifications/v2/test.notifications.module.ts index c91592278b..cb0e57d107 100644 --- a/src/routes/notifications/v2/test.notifications.module.ts +++ b/src/routes/notifications/v2/test.notifications.module.ts @@ -6,6 +6,7 @@ const MockedNotificationsServiceV2 = { getSafeSubscription: jest.fn(), deleteSubscription: jest.fn(), deleteDevice: jest.fn(), + deleteDeviceAndSubscriptions: jest.fn(), } as jest.MockedObjectDeep; @Module({