Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 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
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ REDIS_PASSWORD=""
REDIS_PORT=6379
REDIS_DB=0

#Redis common
REDIS_COMMON_URL="localhost"
REDIS_COMMON_PREFIX="development_env"
REDIS_COMMON_PASSWORD=""
REDIS_COMMON_PORT=6384
REDIS_COMMON_DB=0

#MongoDB
MONGODB_URL="mongodb://localhost:27017"
MONGODB_DATABASE="development"
Expand Down Expand Up @@ -46,6 +53,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 @@ -74,3 +82,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=
21 changes: 20 additions & 1 deletion src/config/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -655,7 +655,8 @@
"VOLUME_WEIGHT": 0.25,
"TRADES_COUNT_WEIGHT": 0.25
},
"AWS_QUERY_CACHE_WARMER_DELAY": 50
"AWS_QUERY_CACHE_WARMER_DELAY": 50,
"TIMESCALEDB_INSERT_CHUNK_SIZE": 30
},
"dataApi": {
"tableName": "XEXCHANGE_ANALYTICS"
Expand Down Expand Up @@ -700,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');
49 changes: 49 additions & 0 deletions src/helpers/api.config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,29 @@ export class ApiConfigService {
return password !== '' ? password : undefined;
}

getCommonRedisUrl(): string {
const redisUrl = this.configService.get<string>('REDIS_COMMON_URL');
if (!redisUrl) {
throw new Error('No common redis url present');
}
return redisUrl;
}

getCommonRedisPort(): number {
const redisPort = this.configService.get<number>('REDIS_COMMON_PORT');
if (!redisPort) {
throw new Error('No common redis port present');
}
return redisPort;
}

getCommonRedisPassword(): string | undefined {
const password = this.configService.get<string>(
'REDIS_COMMON_PASSWORD',
);
return password !== '' ? password : undefined;
}

getApiUrl(): string {
const apiUrl = this.configService.get<string>('MX_API_URL');
if (!apiUrl) {
Expand Down Expand Up @@ -196,6 +219,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 @@ -352,4 +391,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;
}
}
124 changes: 124 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,124 @@
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 { EnergyAbiService } from 'src/modules/energy/services/energy.abi.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 { RedlockService } from '@multiversx/sdk-nestjs-cache';
import { LockAndRetry } from '../decorators/lock.retry.decorator';

@Injectable()
export class PushNotificationsEnergyCron {
constructor(
private readonly energyAbiService: EnergyAbiService,
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(CronExpression.EVERY_DAY_AT_NOON)
@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;
}

const isDevnet = process.env.NODE_ENV === 'devnet';

if (isDevnet) {
const addresses = await this.energyAbiService.getUsersWithEnergy();

await this.pushNotificationsService.sendNotificationsInBatches(
addresses,
pushNotificationsConfig[
NotificationType.FEES_COLLECTOR_REWARDS
],
NotificationType.FEES_COLLECTOR_REWARDS,
);
return;
}

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

await this.pushNotificationsService.sendNotificationsInBatches(
addresses,
pushNotificationsConfig[
NotificationType.FEES_COLLECTOR_REWARDS
],
NotificationType.FEES_COLLECTOR_REWARDS,
);
},
);
}

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

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

await this.pushNotificationsService.sendNotificationsInBatches(
addresses,
pushNotificationsConfig[NotificationType.NEGATIVE_ENERGY],
NotificationType.NEGATIVE_ENERGY,
);
this.logger.log(
`Sent ${addresses.length} negative energy notifications`,
'PushNotificationsEnergyCron',
);
},
0,
);
}

@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,
);
}
}
}
43 changes: 43 additions & 0 deletions src/modules/push-notifications/decorators/lock.retry.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { RedlockService } from '@multiversx/sdk-nestjs-cache';
import { Logger } from 'winston';
import { withLockAndRetry } from '../utils/lock.retry.utils';

export function LockAndRetry(options: {
lockKey: string;
lockName: string;
keyExpiration?: number;
maxLockRetries?: number;
lockRetryInterval?: number;
maxOperationRetries?: number;
operationRetryInterval?: number;
}) {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor,
) {
const originalMethod = descriptor.value;

descriptor.value = async function (...args: any[]) {
const redLockService = this.redLockService as RedlockService;
const logger = this.logger as Logger;

if (!redLockService || !logger) {
throw new Error(
'Class must have redLockService and logger properties',
);
}

await withLockAndRetry(
redLockService,
logger,
options,
async () => {
await originalMethod.apply(this, args);
},
);
};

return descriptor;
};
}
Loading
Loading