Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Notifications #2268

Merged
merged 24 commits into from
Jan 24, 2025
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f33cd28
fix notification recover message address on mobile clients
PooyaRaki Dec 23, 2024
bc86234
REMOVE ME: Run staging pipeline for branch
PooyaRaki Dec 23, 2024
1bedfb2
Register notification subscription for all the signatures in request
PooyaRaki Dec 23, 2024
5a3a9b2
Remove: clean notification data
PooyaRaki Dec 23, 2024
68b23da
Fix tests
PooyaRaki Dec 23, 2024
cdbf10a
Remove: run CI on branch
PooyaRaki Dec 23, 2024
6a96839
Prevent duplicate notifications sent to a device due to multiple sign…
PooyaRaki Dec 24, 2024
a582a6c
Delete all notification subscriptions
PooyaRaki Dec 24, 2024
8d98f42
remove: enable debug logs
PooyaRaki Dec 27, 2024
0645f04
Notification subscription can be registered without any signer address
PooyaRaki Dec 30, 2024
467d423
Remove: Debug Logs
PooyaRaki Dec 30, 2024
7cec885
Remove: more debug logs
PooyaRaki Jan 14, 2025
72a1724
Remove notification hook skip tests
PooyaRaki Jan 14, 2025
492e7d3
Fix IOS push notification Firebase payload
PooyaRaki Jan 15, 2025
1b77ed9
Fix notification deletegate owner
PooyaRaki Jan 15, 2025
c7ffde6
Validates the executed transaction failed status
PooyaRaki Jan 16, 2025
a983e72
Adjust types
PooyaRaki Jan 16, 2025
1188e85
Add to to multisig executed notification
PooyaRaki Jan 16, 2025
91178a6
check confirmation notification signers agaianst delegators and the a…
PooyaRaki Jan 16, 2025
9f449c3
Remove debug logs
PooyaRaki Jan 16, 2025
a6f5108
Remove Me: change ci file
PooyaRaki Jan 20, 2025
1e9c0c7
Fix tests
PooyaRaki Jan 22, 2025
595f162
Remove ci changes
PooyaRaki Jan 23, 2025
92675a4
Address review remarks
PooyaRaki Jan 23, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 0 additions & 13 deletions migrations/1733761866546-cleanNotificationData.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,7 +1,23 @@
export type FirebaseNotification = {
notification?: {
title?: string;
body?: string;
};
notification?: NotificationContent;
data?: Record<string, string>;
};

export type NotificationContent = {
title?: string;
body?: string;
};

export type FireabaseNotificationApn = {
iamacook marked this conversation as resolved.
Show resolved Hide resolved
apns: {
payload: {
aps: {
alert: {
title: string;
body: string;
};
'mutable-content': 1 | 0;
};
};
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,19 @@ describe('FirebaseCloudMessagingApiService', () => {
message: {
token: fcmToken,
...notification,
...{
iamacook marked this conversation as resolved.
Show resolved Hide resolved
apns: {
payload: {
aps: {
alert: {
title: notification.notification?.title,
body: notification.notification?.body,
},
'mutable-content': 1,
},
},
},
},
},
},
networkRequest: {
Expand Down Expand Up @@ -141,6 +154,19 @@ describe('FirebaseCloudMessagingApiService', () => {
message: {
token: fcmToken,
...notification,
...{
iamacook marked this conversation as resolved.
Show resolved Hide resolved
apns: {
payload: {
aps: {
alert: {
title: notification.notification?.title,
body: notification.notification?.body,
},
'mutable-content': 1,
},
},
},
},
},
},
networkRequest: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 =
iamacook marked this conversation as resolved.
Show resolved Hide resolved
'New Activity with your Safe';

private readonly baseUrl: string;
private readonly project: string;
private readonly clientEmail: string;
Expand Down Expand Up @@ -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.
*
Expand All @@ -76,6 +117,7 @@ export class FirebaseCloudMessagingApiService implements IPushNotificationsApi {
message: {
token: fcmToken,
...notification,
...this.getIosNotificationData(notification.notification),
},
},
networkRequest: {
Expand Down
47 changes: 36 additions & 11 deletions src/domain/hooks/helpers/event-notifications.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ 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';

type EventToNotify =
| DeletedMultisigTransactionEvent
Expand Down Expand Up @@ -163,11 +164,13 @@ export class EventNotificationsHelper {
cloudMessagingToken: string;
}>
> {
const subscriptions =
const subscriptions = uniqBy(
iamacook marked this conversation as resolved.
Show resolved Hide resolved
await this.notificationsRepository.getSubscribersBySafe({
chainId: event.chainId,
safeAddress: event.address,
});
}),
'cloudMessagingToken',
);

if (!this.isOwnerOrDelegateOnlyEventToNotify(event)) {
return subscriptions;
Expand Down Expand Up @@ -227,12 +230,13 @@ 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
);
const delegates = await this.delegatesRepository.getDelegates({
iamacook marked this conversation as resolved.
Show resolved Hide resolved
chainId: args.chainId,
delegate: args.subscriber,
});

return delegates?.results.some((delegate) => {
return safe.owners.includes(delegate.delegator);
});
}

Expand Down Expand Up @@ -336,9 +340,19 @@ export class EventNotificationsHelper {
});

// Subscriber has already signed - do not notify
const delegates = await this.delegatesRepository.getDelegates({
iamacook marked this conversation as resolved.
Show resolved Hide resolved
chainId: event.chainId,
delegate: subscriber,
});
const delegators = delegates?.results.map(
(delegate) => delegate?.delegator,
);
const hasSubscriberSigned = transaction.confirmations?.some(
(confirmation) => {
return confirmation.owner === subscriber;
return (
confirmation.owner === subscriber ||
delegators.includes(confirmation.owner)
);
},
);
if (hasSubscriberSigned) {
Expand Down Expand Up @@ -385,8 +399,19 @@ export class EventNotificationsHelper {
});

// Subscriber has already signed - do not notify
const hasSubscriberSigned = message.confirmations.some((confirmation) => {
return confirmation.owner === subscriber;
const delegates = await this.delegatesRepository.getDelegates({
chainId: event.chainId,
delegate: subscriber,
});
const delegators = delegates?.results.map(
(delegate) => delegate?.delegator,
);

const hasSubscriberSigned = message.confirmations?.some((confirmation) => {
return (
confirmation.owner === subscriber ||
delegators.includes(confirmation.owner)
);
iamacook marked this conversation as resolved.
Show resolved Hide resolved
});
if (hasSubscriberSigned) {
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const MockNotificationRepositoryV2: jest.MockedObjectDeep<INotificationsR
upsertSubscriptions: jest.fn(),
getSafeSubscription: jest.fn(),
getSubscribersBySafe: jest.fn(),
deleteDeviceOwners: jest.fn(),
deleteSubscription: jest.fn(),
deleteDevice: jest.fn(),
};
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export interface INotificationsRepositoryV2 {
safeAddress: `0x${string}`;
}): Promise<Array<NotificationType>>;

deleteDeviceOwners(deviceUUid: UUID): Promise<void>;

getSubscribersBySafe(args: {
chainId: string;
safeAddress: `0x${string}`;
Expand Down
17 changes: 17 additions & 0 deletions src/domain/notifications/v2/notifications.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,23 @@ export class NotificationsRepositoryV2 implements INotificationsRepositoryV2 {
return { id: queryResult.identifiers[0].id, device_uuid: deviceUuid };
}

public async deleteDeviceOwners(deviceUuid: UUID): Promise<void> {
iamacook marked this conversation as resolved.
Show resolved Hide resolved
const deviceSubscriptionsRepository =
await this.postgresDatabaseService.getRepository<NotificationSubscription>(
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: {
Expand Down
6 changes: 6 additions & 0 deletions src/routes/hooks/__tests__/event-hooks-queue.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export function executedTransactionEventBuilder(): IBuilder<ExecutedTransaction>
.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() as `0x${string}`)
.with('txHash', faker.string.hexadecimal() as `0x${string}`)
iamacook marked this conversation as resolved.
Show resolved Hide resolved
.with('failed', faker.helpers.arrayElement(['true', 'false']));
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,5 @@ export function pendingTransactionEventBuilder(): IBuilder<PendingTransaction> {
.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() as `0x${string}`);
iamacook marked this conversation as resolved.
Show resolved Hide resolved
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
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({
type: z.literal(TransactionEventType.EXECUTED_MULTISIG_TRANSACTION),
to: AddressSchema,
address: AddressSchema,
chainId: z.string(),
safeTxHash: z.string(),
txHash: z.string(),
safeTxHash: HexSchema,
txHash: HexSchema,
failed: z.union([z.literal('true'), z.literal('false')]),
iamacook marked this conversation as resolved.
Show resolved Hide resolved
});

export type ExecutedTransactionEvent = z.infer<
Expand Down
Original file line number Diff line number Diff line change
@@ -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<
Expand Down
Loading
Loading