• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

safe-global / safe-client-gateway / 10303632879

08 Aug 2024 02:04PM UTC coverage: 46.92% (+0.001%) from 46.919%
10303632879

Pull #1815

github

web-flow
Merge 18e9c741d into ddf55ad2e
Pull Request #1815: Remove database availability hard dependency at boot

476 of 2885 branches covered (16.5%)

Branch coverage included in aggregate %.

1 of 7 new or added lines in 1 file covered. (14.29%)

1 existing line in 1 file now uncovered.

4551 of 7829 relevant lines covered (58.13%)

12.54 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

6.76
/src/domain/hooks/helpers/event-notifications.helper.ts
1
import { Inject, Injectable, Module } from '@nestjs/common';
16✔
2
import {
16✔
3
  IMessagesRepository,
4
  MessagesRepositoryModule,
5
} from '@/domain/messages/messages.repository.interface';
6
import {
16✔
7
  ISafeRepository,
8
  SafeRepositoryModule,
9
} from '@/domain/safe/safe.repository.interface';
10
import {
16✔
11
  TransactionEventType,
12
  ConfigEventType,
13
} from '@/routes/hooks/entities/event-type.entity';
14
import { LoggingService, ILoggingService } from '@/logging/logging.interface';
16✔
15
import { Event } from '@/routes/hooks/entities/event.entity';
16
import {
16✔
17
  INotificationsRepositoryV2,
18
  NotificationsRepositoryV2Module,
19
} from '@/domain/notifications/notifications.repository.v2.interface';
20
import { DeletedMultisigTransactionEvent } from '@/routes/hooks/entities/schemas/deleted-multisig-transaction.schema';
21
import { ExecutedTransactionEvent } from '@/routes/hooks/entities/schemas/executed-transaction.schema';
22
import { IncomingEtherEvent } from '@/routes/hooks/entities/schemas/incoming-ether.schema';
23
import { IncomingTokenEvent } from '@/routes/hooks/entities/schemas/incoming-token.schema';
24
import { ModuleTransactionEvent } from '@/routes/hooks/entities/schemas/module-transaction.schema';
25
import { PendingTransactionEvent } from '@/routes/hooks/entities/schemas/pending-transaction.schema';
26
import { MessageCreatedEvent } from '@/routes/hooks/entities/schemas/message-created.schema';
27
import {
16✔
28
  IncomingEtherNotification,
29
  IncomingTokenNotification,
30
  ConfirmationRequestNotification,
31
  MessageConfirmationNotification,
32
  Notification,
33
  NotificationType,
34
} from '@/domain/notifications/entities-v2/notification.entity';
35
import {
16✔
36
  DelegatesV2RepositoryModule,
37
  IDelegatesV2Repository,
38
} from '@/domain/delegate/v2/delegates.v2.repository.interface';
39
import { UUID } from 'crypto';
40

41
type EventToNotify =
42
  | DeletedMultisigTransactionEvent
43
  | ExecutedTransactionEvent
44
  | IncomingEtherEvent
45
  | IncomingTokenEvent
46
  | ModuleTransactionEvent
47
  | MessageCreatedEvent
48
  | PendingTransactionEvent;
49

50
@Injectable()
51
export class EventNotificationsHelper {
16✔
52
  constructor(
53
    @Inject(IDelegatesV2Repository)
54
    private readonly delegatesRepository: IDelegatesV2Repository,
×
55
    @Inject(IMessagesRepository)
56
    private readonly messagesRepository: IMessagesRepository,
×
57
    @Inject(ISafeRepository)
58
    private readonly safeRepository: ISafeRepository,
×
59
    @Inject(LoggingService)
60
    private readonly loggingService: ILoggingService,
×
61
    @Inject(INotificationsRepositoryV2)
62
    private readonly notificationsRepository: INotificationsRepositoryV2,
×
63
  ) {}
64

65
  /**
66
   * Enqueues notifications for the relevant events to owners/delegates
67
   * and non-owners/delegates of a Safe accordingly.
68
   *
69
   * @param event - {@link Event} to notify about
70
   */
71
  public async onEventEnqueueNotifications(event: Event): Promise<unknown> {
72
    if (!this.isEventToNotify(event)) {
×
73
      return;
×
74
    }
75

76
    const subscriptions = await this.getRelevantSubscribers(event);
×
77

78
    return await Promise.allSettled(
×
79
      subscriptions.map(async (subscription) => {
80
        const data = await this.mapEventNotification(
×
81
          event,
82
          subscription.subscriber,
83
        );
84

85
        if (!data) {
×
86
          return;
×
87
        }
88

89
        return this.notificationsRepository
×
90
          .enqueueNotification({
91
            token: subscription.cloudMessagingToken,
92
            deviceUuid: subscription.deviceUuid,
93
            notification: {
94
              data,
95
            },
96
          })
97
          .then(() => {
98
            this.loggingService.info('Notification sent successfully');
×
99
          })
100
          .catch((e) => {
101
            this.loggingService.error(
×
102
              `Failed to send notification: ${e.reason}`,
103
            );
104
          });
105
      }),
106
    );
107
  }
108

109
  /**
110
   * Checks if the event is to be notified.
111
   *
112
   * @param event - {@link Event} to check
113
   */
114
  private isEventToNotify(event: Event): event is EventToNotify {
115
    return (
×
116
      // Don't notify about Config events
117
      event.type !== ConfigEventType.CHAIN_UPDATE &&
×
118
      event.type !== ConfigEventType.SAFE_APPS_UPDATE &&
119
      // We otherwise notify about executed transactions
120
      event.type !== TransactionEventType.OUTGOING_ETHER &&
121
      event.type !== TransactionEventType.OUTGOING_TOKEN &&
122
      // We only notify required confirmations on creation - see PENDING_MULTISIG_TRANSACTION
123
      event.type !== TransactionEventType.NEW_CONFIRMATION &&
124
      // We only notify required confirmations on required - see MESSAGE_CREATED
125
      event.type !== TransactionEventType.MESSAGE_CONFIRMATION &&
126
      // You cannot subscribe to Safes-to-be-created
127
      event.type !== TransactionEventType.SAFE_CREATED
128
    );
129
  }
130

131
  /**
132
   * Checks if the event an owner/delegate only event.
133
   * @param event - {@link EventToNotify} to check
134
   */
135
  private isOwnerOrDelegateOnlyEventToNotify(
136
    event: EventToNotify,
137
  ): event is PendingTransactionEvent | MessageCreatedEvent {
138
    // We only notify required confirmation events to owners or delegates
139
    // to prevent other subscribers from receiving "private" events
140
    return (
×
141
      event.type === TransactionEventType.PENDING_MULTISIG_TRANSACTION ||
×
142
      event.type === TransactionEventType.MESSAGE_CREATED
143
    );
144
  }
145

146
  /**
147
   * Gets subscribers and their device UUID/cloud messaging tokens for the
148
   * given Safe depending on the event type.
149
   *
150
   * @param event - {@link EventToNotify} to get subscribers for
151
   *
152
   * @returns - List of subscribers/tokens for given Safe
153
   */
154
  private async getRelevantSubscribers(event: EventToNotify): Promise<
155
    Array<{
156
      subscriber: `0x${string}`;
157
      deviceUuid: UUID;
158
      cloudMessagingToken: string;
159
    }>
160
  > {
161
    const subscriptions =
162
      await this.notificationsRepository.getSubscribersBySafe({
×
163
        chainId: event.chainId,
164
        safeAddress: event.address,
165
      });
166

167
    if (!this.isOwnerOrDelegateOnlyEventToNotify(event)) {
×
168
      return subscriptions;
×
169
    }
170

171
    const ownersAndDelegates = await Promise.allSettled(
×
172
      subscriptions.map(async (subscription) => {
173
        const isOwnerOrDelegate = await this.isOwnerOrDelegate({
×
174
          chainId: event.chainId,
175
          safeAddress: event.address,
176
          subscriber: subscription.subscriber,
177
        });
178

179
        if (!isOwnerOrDelegate) {
×
180
          return;
×
181
        }
182

183
        return subscription;
×
184
      }),
185
    );
186

187
    return ownersAndDelegates
×
188
      .filter(
189
        <T>(
190
          item: PromiseSettledResult<T>,
191
        ): item is PromiseFulfilledResult<NonNullable<T>> => {
192
          return item.status === 'fulfilled' && !!item.value;
×
193
        },
194
      )
195
      .map((result) => result.value);
×
196
  }
197

198
  /**
199
   * Checks if the subscriber is an owner or delegate of the Safe.
200
   *
201
   * @param args.chainId - Chain ID
202
   * @param args.safeAddress - Safe address
203
   * @param args.subscriber - Subscriber address
204
   *
205
   * @returns - True if the subscriber is an owner or delegate of the Safe, otherwise false
206
   */
207
  private async isOwnerOrDelegate(args: {
208
    chainId: string;
209
    safeAddress: `0x${string}`;
210
    subscriber: `0x${string}`;
211
  }): Promise<boolean> {
212
    // We don't use Promise.all avoid unnecessary calls for delegates
213
    const safe = await this.safeRepository.getSafe({
×
214
      chainId: args.chainId,
215
      address: args.safeAddress,
216
    });
217
    if (safe?.owners.includes(args.subscriber)) {
×
218
      return true;
×
219
    }
220

221
    const delegates = await this.delegatesRepository.getDelegates(args);
×
222
    return !!delegates?.results.some((delegate) => {
×
223
      return (
×
224
        delegate.safe === args.safeAddress &&
×
225
        delegate.delegate === args.subscriber
226
      );
227
    });
228
  }
229

230
  /**
231
   * Maps an {@link EventToNotify} to a notification.
232
   *
233
   * @param event - {@link EventToNotify} to map
234
   * @param subscriber - Subscriber address
235
   *
236
   * @returns - The {@link Notification} if the conditions are met, otherwise null
237
   */
238
  private async mapEventNotification(
239
    event: EventToNotify,
240
    subscriber: `0x${string}`,
241
  ): Promise<Notification | null> {
242
    if (
×
243
      event.type === TransactionEventType.INCOMING_ETHER ||
×
244
      event.type === TransactionEventType.INCOMING_TOKEN
245
    ) {
246
      return await this.mapIncomingAssetEventNotification(event);
×
247
    } else if (
×
248
      event.type === TransactionEventType.PENDING_MULTISIG_TRANSACTION
249
    ) {
250
      return await this.mapPendingMultisigTransactionEventNotification(
×
251
        event,
252
        subscriber,
253
      );
254
    } else if (event.type === TransactionEventType.MESSAGE_CREATED) {
×
255
      return await this.mapMessageCreatedEventNotification(event, subscriber);
×
256
    } else {
257
      return event;
×
258
    }
259
  }
260

261
  /**
262
   * Maps {@link IncomingEtherEvent} or {@link IncomingTokenEvent} to {@link IncomingEtherNotification} or {@link IncomingTokenNotification} if:
263
   *
264
   * - The asset was sent to the Safe by another address.
265
   *
266
   * @param event - {@link IncomingEtherEvent} or {@link IncomingTokenEvent} to map
267
   *
268
   * @returns - The {@link IncomingEtherNotification} or {@link IncomingTokenNotification} if the conditions are met, otherwise null
269
   */
270
  private async mapIncomingAssetEventNotification(
271
    event: IncomingEtherEvent | IncomingTokenEvent,
272
  ): Promise<IncomingEtherNotification | IncomingTokenNotification | null> {
273
    const incomingTransfers = await this.safeRepository
×
274
      .getIncomingTransfers({
275
        chainId: event.chainId,
276
        safeAddress: event.address,
277
        txHash: event.txHash,
278
      })
279
      .catch(() => null);
×
280

281
    const transfer = incomingTransfers?.results?.find((result) => {
×
282
      return result.transactionHash === event.txHash;
×
283
    });
284

285
    // Asset sent to self - do not notify
286
    if (transfer?.from === event.address) {
×
287
      return null;
×
288
    }
289

290
    return event;
×
291
  }
292

293
  /**
294
   * Maps {@link PendingTransactionEvent} to {@link ConfirmationRequestNotification} if:
295
   *
296
   * - The Safe has a threshold > 1.
297
   * - The subscriber didn't create the transaction.
298
   *
299
   * @param event - {@link PendingTransactionEvent} to map
300
   * @param subscriber - Subscriber address
301
   *
302
   * @returns - The {@link ConfirmationRequestNotification} if the conditions are met, otherwise null
303
   */
304
  private async mapPendingMultisigTransactionEventNotification(
305
    event: PendingTransactionEvent,
306
    subscriber: `0x${string}`,
307
  ): Promise<ConfirmationRequestNotification | null> {
308
    const safe = await this.safeRepository.getSafe({
×
309
      chainId: event.chainId,
310
      address: event.address,
311
    });
312

313
    // Transaction is confirmed and awaiting execution - do not notify
314
    if (safe.threshold === 1) {
×
315
      return null;
×
316
    }
317

318
    const transaction = await this.safeRepository.getMultiSigTransaction({
×
319
      chainId: event.chainId,
320
      safeTransactionHash: event.safeTxHash,
321
    });
322

323
    // Subscriber has already signed - do not notify
324
    const hasSubscriberSigned = transaction.confirmations?.some(
×
325
      (confirmation) => {
326
        return confirmation.owner === subscriber;
×
327
      },
328
    );
329
    if (hasSubscriberSigned) {
×
330
      return null;
×
331
    }
332

333
    return {
×
334
      type: NotificationType.CONFIRMATION_REQUEST,
335
      chainId: event.chainId,
336
      address: event.address,
337
      safeTxHash: event.safeTxHash,
338
    };
339
  }
340

341
  /**
342
   * Maps {@link MessageCreatedEvent} to {@link MessageConfirmationNotification} if:
343
   *
344
   * - The Safe has a threshold > 1.
345
   * - The subscriber didn't create the message.
346
   *
347
   * @param event - {@link MessageCreatedEvent} to map
348
   * @param subscriber - Subscriber address
349
   *
350
   * @returns - The {@link MessageConfirmationNotification} if the conditions are met, otherwise null
351
   */
352
  private async mapMessageCreatedEventNotification(
353
    event: MessageCreatedEvent,
354
    subscriber: `0x${string}`,
355
  ): Promise<MessageConfirmationNotification | null> {
356
    const safe = await this.safeRepository.getSafe({
×
357
      chainId: event.chainId,
358
      address: event.address,
359
    });
360

361
    // Message is confirmed - do not notify
362
    if (safe.threshold === 1) {
×
363
      return null;
×
364
    }
365

366
    const message = await this.messagesRepository.getMessageByHash({
×
367
      chainId: event.chainId,
368
      messageHash: event.messageHash,
369
    });
370

371
    // Subscriber has already signed - do not notify
372
    const hasSubscriberSigned = message.confirmations.some((confirmation) => {
×
373
      return confirmation.owner === subscriber;
×
374
    });
375
    if (hasSubscriberSigned) {
×
376
      return null;
×
377
    }
378

379
    return {
×
380
      type: NotificationType.MESSAGE_CONFIRMATION_REQUEST,
381
      chainId: event.chainId,
382
      address: event.address,
383
      messageHash: event.messageHash,
384
    };
385
  }
386
}
387

388
@Module({
389
  imports: [
390
    DelegatesV2RepositoryModule,
391
    MessagesRepositoryModule,
392
    SafeRepositoryModule,
393
    NotificationsRepositoryV2Module,
394
  ],
395
  providers: [EventNotificationsHelper],
396
  exports: [EventNotificationsHelper],
397
})
398
export class EventNotificationsHelperModule {}
16✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc