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

safe-global / safe-client-gateway / 10684918840

03 Sep 2024 02:24PM CUT coverage: 46.327% (-0.005%) from 46.332%
10684918840

Pull #1892

github

hectorgomezv
Ignore non-relevant folders on coverage reports
Pull Request #1892: Fix coverage reports configuration

499 of 3099 branches covered (16.1%)

Branch coverage included in aggregate %.

4811 of 8363 relevant lines covered (57.53%)

12.24 hits per line

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

6.25
/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}` | null;
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
        if (!subscription.subscriber) {
×
174
          return;
×
175
        }
176

177
        const isOwnerOrDelegate = await this.isOwnerOrDelegate({
×
178
          chainId: event.chainId,
179
          safeAddress: event.address,
180
          subscriber: subscription.subscriber,
181
        });
182

183
        if (!isOwnerOrDelegate) {
×
184
          return;
×
185
        }
186

187
        return subscription;
×
188
      }),
189
    );
190

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

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

225
    const delegates = await this.delegatesRepository.getDelegates(args);
×
226
    return !!delegates?.results.some((delegate) => {
×
227
      return (
×
228
        delegate.safe === args.safeAddress &&
×
229
        delegate.delegate === args.subscriber
230
      );
231
    });
232
  }
233

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

271
  /**
272
   * Maps {@link IncomingEtherEvent} or {@link IncomingTokenEvent} to {@link IncomingEtherNotification} or {@link IncomingTokenNotification} if:
273
   *
274
   * - The asset was sent to the Safe by another address.
275
   *
276
   * @param event - {@link IncomingEtherEvent} or {@link IncomingTokenEvent} to map
277
   *
278
   * @returns - The {@link IncomingEtherNotification} or {@link IncomingTokenNotification} if the conditions are met, otherwise null
279
   */
280
  private async mapIncomingAssetEventNotification(
281
    event: IncomingEtherEvent | IncomingTokenEvent,
282
  ): Promise<IncomingEtherNotification | IncomingTokenNotification | null> {
283
    const incomingTransfers = await this.safeRepository
×
284
      .getIncomingTransfers({
285
        chainId: event.chainId,
286
        safeAddress: event.address,
287
        txHash: event.txHash,
288
      })
289
      .catch(() => null);
×
290

291
    const transfer = incomingTransfers?.results?.find((result) => {
×
292
      return result.transactionHash === event.txHash;
×
293
    });
294

295
    // Asset sent to self - do not notify
296
    if (transfer?.from === event.address) {
×
297
      return null;
×
298
    }
299

300
    return event;
×
301
  }
302

303
  /**
304
   * Maps {@link PendingTransactionEvent} to {@link ConfirmationRequestNotification} if:
305
   *
306
   * - The Safe has a threshold > 1.
307
   * - The subscriber didn't create the transaction.
308
   *
309
   * @param event - {@link PendingTransactionEvent} to map
310
   * @param subscriber - Subscriber address
311
   *
312
   * @returns - The {@link ConfirmationRequestNotification} if the conditions are met, otherwise null
313
   */
314
  private async mapPendingMultisigTransactionEventNotification(
315
    event: PendingTransactionEvent,
316
    subscriber: `0x${string}`,
317
  ): Promise<ConfirmationRequestNotification | null> {
318
    const safe = await this.safeRepository.getSafe({
×
319
      chainId: event.chainId,
320
      address: event.address,
321
    });
322

323
    // Transaction is confirmed and awaiting execution - do not notify
324
    if (safe.threshold === 1) {
×
325
      return null;
×
326
    }
327

328
    const transaction = await this.safeRepository.getMultiSigTransaction({
×
329
      chainId: event.chainId,
330
      safeTransactionHash: event.safeTxHash,
331
    });
332

333
    // Subscriber has already signed - do not notify
334
    const hasSubscriberSigned = transaction.confirmations?.some(
×
335
      (confirmation) => {
336
        return confirmation.owner === subscriber;
×
337
      },
338
    );
339
    if (hasSubscriberSigned) {
×
340
      return null;
×
341
    }
342

343
    return {
×
344
      type: NotificationType.CONFIRMATION_REQUEST,
345
      chainId: event.chainId,
346
      address: event.address,
347
      safeTxHash: event.safeTxHash,
348
    };
349
  }
350

351
  /**
352
   * Maps {@link MessageCreatedEvent} to {@link MessageConfirmationNotification} if:
353
   *
354
   * - The Safe has a threshold > 1.
355
   * - The subscriber didn't create the message.
356
   *
357
   * @param event - {@link MessageCreatedEvent} to map
358
   * @param subscriber - Subscriber address
359
   *
360
   * @returns - The {@link MessageConfirmationNotification} if the conditions are met, otherwise null
361
   */
362
  private async mapMessageCreatedEventNotification(
363
    event: MessageCreatedEvent,
364
    subscriber: `0x${string}`,
365
  ): Promise<MessageConfirmationNotification | null> {
366
    const safe = await this.safeRepository.getSafe({
×
367
      chainId: event.chainId,
368
      address: event.address,
369
    });
370

371
    // Message is confirmed - do not notify
372
    if (safe.threshold === 1) {
×
373
      return null;
×
374
    }
375

376
    const message = await this.messagesRepository.getMessageByHash({
×
377
      chainId: event.chainId,
378
      messageHash: event.messageHash,
379
    });
380

381
    // Subscriber has already signed - do not notify
382
    const hasSubscriberSigned = message.confirmations.some((confirmation) => {
×
383
      return confirmation.owner === subscriber;
×
384
    });
385
    if (hasSubscriberSigned) {
×
386
      return null;
×
387
    }
388

389
    return {
×
390
      type: NotificationType.MESSAGE_CONFIRMATION_REQUEST,
391
      chainId: event.chainId,
392
      address: event.address,
393
      messageHash: event.messageHash,
394
    };
395
  }
396
}
397

398
@Module({
399
  imports: [
400
    DelegatesV2RepositoryModule,
401
    MessagesRepositoryModule,
402
    SafeRepositoryModule,
403
    NotificationsRepositoryV2Module,
404
  ],
405
  providers: [EventNotificationsHelper],
406
  exports: [EventNotificationsHelper],
407
})
408
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