Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
805494f
MEX-748 Implement Claim Rewards notifications in xPortal
EmanuelMiron Mar 28, 2025
bc73efe
MEX-748 Fix issue on failed req
EmanuelMiron Mar 28, 2025
e33d070
MEX-748 Make the module more generic
EmanuelMiron Apr 11, 2025
c726d97
MEX-748 Added push_notifications_aip_url in env.example
EmanuelMiron Apr 11, 2025
5701b74
MEX-748 Moved options to config from env
EmanuelMiron Apr 11, 2025
9daa642
MEX-748 Use ApiService instead of axios directly
EmanuelMiron Apr 11, 2025
c9f750a
MEX-748 Added Fixes after review
EmanuelMiron Apr 16, 2025
7454f00
MEX-748 Fix feesCollector startEpoch logic
EmanuelMiron Apr 16, 2025
49f1ca4
MEX-748 Fix Cron interval for feesCollector Rewards Notifications
EmanuelMiron Apr 17, 2025
ff38557
MEX-748 Added Logs to PushNotification Service
EmanuelMiron Apr 30, 2025
7084897
MEX-748 Added try catch block for the sendPushNotifications
EmanuelMiron Apr 30, 2025
c534c44
MEX-748 Implement RedLock for Push Notifications as a decorator
EmanuelMiron Apr 30, 2025
ff5aee7
Merge branch 'development' into MEX-748-push-notifications
EmanuelMiron Apr 30, 2025
3f19dad
MEX-748 Added Logs back
EmanuelMiron May 6, 2025
5f3c8f1
MEX-748 Added error logging on sendPushNotifications
EmanuelMiron May 6, 2025
6ea6bdd
MEX-748 Moved withLockAndRetry to generic utils
EmanuelMiron May 6, 2025
171b388
MEX-748: Removed redundant try/catch blocks
EmanuelMiron May 6, 2025
47aa1ca
MEX-748 Moved LockAndRetry decorator
EmanuelMiron May 6, 2025
934afbc
MEX-748 Other small fixes
EmanuelMiron May 6, 2025
945d960
MEX-748 Place failedNotifications on the common redis instance
EmanuelMiron May 6, 2025
dc8e57b
MEX-748 Fix srem issue
EmanuelMiron May 6, 2025
9a2131e
MEX-748 Integrate devnet energy snapshot index
EmanuelMiron May 14, 2025
e8d35ca
Merge branch 'development' into MEX-748-push-notifications
EmanuelMiron May 14, 2025
afcd632
MEX-748 Dynamic Cron based on environment
EmanuelMiron May 14, 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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ ENABLE_CACHE_WARMER=true
ENABLE_EVENTS_NOTIFIER=false
ENABLE_TRACER=false
ENABLE_COMPLEXITY=true
ENABLE_PUSH_NOTIFICATIONS=true

#Log filename to use for file logging. Optional
LOG_FILE=
Expand Down Expand Up @@ -83,3 +84,7 @@ OPEN_EXCHANGE_RATES_URL=https://openexchangerates.org/api
# AWS Timestream
AWS_TIMESTREAM_READ=false
AWS_TIMESTREAM_WRITE=false

# Notification Service Configuration
PUSH_NOTIFICATIONS_API_URL=https://devnet-tools.xportal.com
PUSH_NOTIFICATIONS_API_KEY=
18 changes: 18 additions & 0 deletions src/config/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -701,5 +701,23 @@
"nested": 1.5,
"exponentialBase": 10
}
},
"pushNotifications": {
"options": {
"batchSize": 100,
"chainId": 508
},
"feesCollectorRewards": {
"title": "xExchange: Energy rewards",
"body": "You can now claim your fees rewards",
"route": "/portfolio",
"iconUrl": "https://xexchange.com/assets/imgs/mx-logos/xexchange-logo@2x.webp"
},
"negativeEnergy": {
"title": "xExchange: Update energy",
"body": "You have negative energy",
"route": "/energy",
"iconUrl": "https://xexchange.com/assets/imgs/mx-logos/xexchange-logo@2x.webp"
}
}
}
2 changes: 2 additions & 0 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,5 @@ export const dataApiConfig = config.get('dataApi');
export const leaguesConfig = config.get('leagues');

export const complexityConfig = config.get('complexity');

export const pushNotificationsConfig = config.get('pushNotifications');
26 changes: 26 additions & 0 deletions src/helpers/api.config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,22 @@ export class ApiConfigService {
return mongoDBPassword;
}

getNotificationsApiUrl(): string {
const apiUrl = this.configService.get<string>('PUSH_NOTIFICATIONS_API_URL');
if (!apiUrl) {
throw new Error('No push notifications API url present');
}
return apiUrl;
}

getNotificationsApiKey(): string {
const apiKey = this.configService.get<string>('PUSH_NOTIFICATIONS_API_KEY');
if (!apiKey) {
throw new Error('No push notifications API key present');
}
return apiKey;
}

getJwtSecret(): string {
const secret = this.configService.get<string>('JWT_SECRET');
if (!secret) {
Expand Down Expand Up @@ -391,4 +407,14 @@ export class ApiConfigService {
getRateLimiterSecret(): string | undefined {
return this.configService.get<string>('RATE_LIMITER_SECRET');
}

isNotificationsModuleActive(): boolean {
const notificationsModuleActive = this.configService.get<string>(
'ENABLE_PUSH_NOTIFICATIONS',
);
if (!notificationsModuleActive) {
return false;
}
return notificationsModuleActive === 'true';
}
}
6 changes: 6 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import cookieParser from 'cookie-parser';
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';
import { LoggerService } from '@nestjs/common';
import { NestExpressApplication } from '@nestjs/platform-express';
import { PushNotificationsModule } from './modules/push-notifications/push.notifications.module';

async function bootstrap() {
BigNumber.config({ EXPONENTIAL_AT: [-30, 30] });
Expand Down Expand Up @@ -90,5 +91,10 @@ async function bootstrap() {
await rabbitMqService.getFilterAddresses();
await eventsNotifierApp.listen(5673, '0.0.0.0');
}

if (apiConfigService.isNotificationsModuleActive()) {
const pushNotificationsApp = await NestFactory.create(PushNotificationsModule);
await pushNotificationsApp.listen(5674, '0.0.0.0');
}
}
bootstrap();
28 changes: 26 additions & 2 deletions src/modules/energy/services/energy.abi.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ import { MXProxyService } from 'src/services/multiversx-communication/mx.proxy.s
import { GenericAbiService } from 'src/services/generics/generic.abi.service';
import { LockOption } from '../models/simple.lock.energy.model';
import { GetOrSetCache } from 'src/helpers/decorators/caching.decorator';
import { Constants } from '@multiversx/sdk-nestjs-common';
import { AddressUtils, BinaryUtils } from '@multiversx/sdk-nestjs-common';
import { CacheTtlInfo } from 'src/services/caching/cache.ttl.info';
import { MXApiService } from 'src/services/multiversx-communication/mx.api.service';
import { scAddress } from 'src/config';
import { IEnergyAbiService } from './interfaces';
import { ErrorLoggerAsync } from '@multiversx/sdk-nestjs-common';

import { MXGatewayService } from 'src/services/multiversx-communication/mx.gateway.service';
@Injectable()
export class EnergyAbiService
extends GenericAbiService
Expand All @@ -27,6 +27,7 @@ export class EnergyAbiService
constructor(
protected readonly mxProxy: MXProxyService,
private readonly mxAPI: MXApiService,
private readonly mxGateway: MXGatewayService,
) {
super(mxProxy);
}
Expand Down Expand Up @@ -216,4 +217,27 @@ export class EnergyAbiService

return response.firstValue.valueOf();
}

@ErrorLoggerAsync()
async getUsersWithEnergy(): Promise<string[]> {
const contractAddress = scAddress.simpleLockEnergy;

const contractKeysRaw = await this.mxGateway.getSCStorageKeys(
contractAddress,
[],
);

const contractPairs = Object.entries(contractKeysRaw);

const userEnergyKey = BinaryUtils.stringToHex('userEnergy');
const userEnergyKeys = contractPairs
.filter(([key, _]) => key.startsWith(userEnergyKey))
.map(([key, _]) => key.replace(userEnergyKey, ''));

const userEnergyAddresses = userEnergyKeys.map((key) =>
AddressUtils.bech32Encode(key),
);

return userEnergyAddresses;
}
}
133 changes: 133 additions & 0 deletions src/modules/push-notifications/crons/push.notifications.energy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import {
AccountType,
NotificationType,
} from '../models/push.notifications.types';
import { Injectable, Inject } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PushNotificationsService } from '../services/push.notifications.service';
import { ContextGetterService } from 'src/services/context/context.getter.service';
import { ElasticAccountsEnergyService } from 'src/services/elastic-search/services/es.accounts.energy.service';
import { pushNotificationsConfig, scAddress } from 'src/config';
import { WeekTimekeepingAbiService } from 'src/submodules/week-timekeeping/services/week-timekeeping.abi.service';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { Logger } from 'winston';
import { LockAndRetry } from 'src/helpers/decorators/lock.retry.decorator';
import { RedlockService } from '@multiversx/sdk-nestjs-cache';

@Injectable()
export class PushNotificationsEnergyCron {
constructor(
private readonly contextGetter: ContextGetterService,
private readonly pushNotificationsService: PushNotificationsService,
private readonly accountsEnergyElasticService: ElasticAccountsEnergyService,
private readonly weekTimekeepingAbi: WeekTimekeepingAbiService,
private readonly redLockService: RedlockService,
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
) {}

@Cron(
process.env.NODE_ENV === 'mainnet'
? CronExpression.EVERY_DAY_AT_NOON
: CronExpression.EVERY_4_HOURS,
)
@LockAndRetry({
lockKey: 'pushNotifications',
lockName: 'feesCollector',
})
async feesCollectorRewardsCron() {
const currentEpoch = await this.contextGetter.getCurrentEpoch();
const firstWeekStartEpoch =
await this.weekTimekeepingAbi.firstWeekStartEpoch(
String(scAddress.feesCollector),
);

if ((currentEpoch - firstWeekStartEpoch) % 7 !== 0) {
return;
}

let successfulNotifications = 0;
let failedNotifications = 0;

await this.accountsEnergyElasticService.getAccountsByEnergyAmount(
currentEpoch - 1,
'gt',
async (items: AccountType[]) => {
const addresses = items.map(
(item: AccountType) => item.address,
);

const result =
await this.pushNotificationsService.sendNotificationsInBatches(
addresses,
pushNotificationsConfig[
NotificationType.FEES_COLLECTOR_REWARDS
],
NotificationType.FEES_COLLECTOR_REWARDS,
);

successfulNotifications += result.successful.length;
failedNotifications += result.failed.length;
},
);

this.logger.log(
`Fees collector rewards cron completed. Successful: ${successfulNotifications}, Failed: ${failedNotifications}`,
'PushNotificationsEnergyCron',
);
}

@Cron('0 12 */2 * *') // Every 2 days at noon
@LockAndRetry({
lockKey: 'pushNotifications',
lockName: 'negativeEnergy',
})
async negativeEnergyNotificationsCron() {
const currentEpoch = await this.contextGetter.getCurrentEpoch();

let successfulNotifications = 0;
let failedNotifications = 0;

await this.accountsEnergyElasticService.getAccountsByEnergyAmount(
currentEpoch - 1,
'lt',
async (items: AccountType[]) => {
const addresses = items.map(
(item: AccountType) => item.address,
);

const result =
await this.pushNotificationsService.sendNotificationsInBatches(
addresses,
pushNotificationsConfig[
NotificationType.NEGATIVE_ENERGY
],
NotificationType.NEGATIVE_ENERGY,
);

successfulNotifications += result.successful.length;
failedNotifications += result.failed.length;
},
0,
);

this.logger.log(
`Negative energy notifications cron completed. Successful: ${successfulNotifications}, Failed: ${failedNotifications}`,
'PushNotificationsEnergyCron',
);
}

@Cron(CronExpression.EVERY_10_MINUTES)
@LockAndRetry({
lockKey: 'pushNotifications',
lockName: 'retryFailed',
})
async retryFailedNotificationsCron() {
const notificationTypes = Object.values(NotificationType);

for (const notificationType of notificationTypes) {
await this.pushNotificationsService.retryFailedNotifications(
notificationType,
);
}
}
}
38 changes: 38 additions & 0 deletions src/modules/push-notifications/models/push.notifications.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
export interface EnergyDetailsType {
lastUpdateEpoch: number;
amount: string;
totalLockedTokens: string;
}

export interface AccountType {
address: string;
nonce: number;
balance: string;
balanceNum: number;
totalBalanceWithStake: string;
totalBalanceWithStakeNum: number;
timestamp: number;
shardID: number;
totalStake: string;
energy: string;
energyNum: number;
energyDetails: EnergyDetailsType;
totalUnDelegate: string;
}

export interface NotificationResult {
successful: string[];
failed: string[];
}

export interface NotificationConfig {
title: string;
body: string;
route: string;
iconUrl: string;
}

export enum NotificationType {
FEES_COLLECTOR_REWARDS = 'feesCollectorRewards',
NEGATIVE_ENERGY = 'negativeEnergy',
}
35 changes: 35 additions & 0 deletions src/modules/push-notifications/push.notifications.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { PushNotificationsService } from './services/push.notifications.service';
import { PushNotificationsSetterService } from './services/push.notifications.setter.service';
import { CommonAppModule } from '../../common.app.module';
import { MXCommunicationModule } from '../../services/multiversx-communication/mx.communication.module';
import { ElasticSearchModule } from '../../services/elastic-search/elastic.search.module';
import { ContextModule } from '../../services/context/context.module';
import { CacheModule } from '../../services/caching/cache.module';
import { EnergyModule } from '../energy/energy.module';
import { PushNotificationsEnergyCron } from './crons/push.notifications.energy';
import { WeekTimekeepingModule } from 'src/submodules/week-timekeeping/week-timekeeping.module';
import { DynamicModuleUtils } from 'src/utils/dynamic.module.utils';

@Module({
imports: [
CommonAppModule,
ScheduleModule.forRoot(),
MXCommunicationModule,
ElasticSearchModule,
ContextModule,
CacheModule,
EnergyModule,
WeekTimekeepingModule,
DynamicModuleUtils.getRedlockModule(),
DynamicModuleUtils.getCommonRedisModule(),
],
providers: [
PushNotificationsService,
PushNotificationsSetterService,
PushNotificationsEnergyCron,
],
exports: [],
})
export class PushNotificationsModule {}
Loading
Loading