diff --git a/packages/api/src/modules/notification/services.ts b/packages/api/src/modules/notification/services.ts index 4f5709742..f2631a508 100644 --- a/packages/api/src/modules/notification/services.ts +++ b/packages/api/src/modules/notification/services.ts @@ -194,7 +194,7 @@ export class NotificationService implements INotificationService { sessionId: member.id, to: SocketUsernames.UI, request_id: undefined, - type: SocketEvents.TRANSACTION_UPDATE, + type: SocketEvents.TRANSACTION, data: {}, }); socketClient.disconnect(); @@ -259,7 +259,7 @@ export class NotificationService implements INotificationService { sessionId: member.id, to: SocketUsernames.UI, request_id: undefined, - type: SocketEvents.TRANSACTION_UPDATE, + type: SocketEvents.TRANSACTION, data: {}, }); socketClient.disconnect(); diff --git a/packages/api/src/modules/predicate/controller.ts b/packages/api/src/modules/predicate/controller.ts index 53e718b7f..bd35ad049 100644 --- a/packages/api/src/modules/predicate/controller.ts +++ b/packages/api/src/modules/predicate/controller.ts @@ -21,6 +21,7 @@ import { INotificationService } from '../notification/types'; import { WorkspaceService } from '../workspace/services'; import { + ICheckPredicateBalancesRequest, ICreatePredicateRequest, IDeletePredicateRequest, IFindByHashRequest, @@ -69,12 +70,17 @@ export class PredicateController { // If workspace is not provided, use user's single workspace as default let effectiveWorkspace = workspace; if (!workspace?.id) { - console.log('[PREDICATE_CREATE] No workspace provided, fetching user single workspace'); + console.log( + '[PREDICATE_CREATE] No workspace provided, fetching user single workspace', + ); effectiveWorkspace = await new WorkspaceService() .filter({ user: user.id, single: true }) .list() .then((response: Workspace[]) => response[0]); - console.log('[PREDICATE_CREATE] Using single workspace:', effectiveWorkspace?.id); + console.log( + '[PREDICATE_CREATE] Using single workspace:', + effectiveWorkspace?.id, + ); } const predicateService = new PredicateService(); @@ -153,7 +159,10 @@ export class PredicateController { const predicate = await this.predicateService.findByAddress(address); if (!predicate) { - console.log('[PREDICATE_FIND_BY_ADDRESS] Predicate NOT found for address:', address); + console.log( + '[PREDICATE_FIND_BY_ADDRESS] Predicate NOT found for address:', + address, + ); throw new NotFound({ type: ErrorTypes.NotFound, title: 'Predicate not found', @@ -423,4 +432,21 @@ export class PredicateController { return error(e.error || e, e.statusCode); } } + + async checkPredicateBalances({ + params: { predicateId }, + user, + network, + }: ICheckPredicateBalancesRequest) { + try { + await this.predicateService.checkBalances({ + predicateId, + userId: user.id, + network, + }); + return successful(null, Responses.Ok); + } catch (e) { + return error(e.error || e, e.statusCode); + } + } } diff --git a/packages/api/src/modules/predicate/routes.ts b/packages/api/src/modules/predicate/routes.ts index cc74f4fe0..f14176d92 100644 --- a/packages/api/src/modules/predicate/routes.ts +++ b/packages/api/src/modules/predicate/routes.ts @@ -47,6 +47,7 @@ const { tooglePredicateVisibility, update, allocation, + checkPredicateBalances, } = new PredicateController(predicateService, notificationsService); router.use(authMiddleware); @@ -83,6 +84,16 @@ router.put( validateTooglePredicatePayload, handleResponse(tooglePredicateVisibility), ); -router.get('/:predicateId/allocation', handleResponse(allocation)); +router.get( + '/:predicateId/allocation', + validatePredicateIdParams, + handleResponse(allocation), +); +router.get( + '/check-balances/:predicateId', + validatePredicateIdParams, + permissionMiddlewareById, + handleResponse(checkPredicateBalances), +); export default router; diff --git a/packages/api/src/modules/predicate/services.ts b/packages/api/src/modules/predicate/services.ts index b0d889ce1..81754853e 100644 --- a/packages/api/src/modules/predicate/services.ts +++ b/packages/api/src/modules/predicate/services.ts @@ -19,12 +19,7 @@ import GeneralError, { ErrorTypes } from '@utils/error/GeneralError'; import Internal from '@utils/error/Internal'; import App from '@src/server/app'; -import { - calculateBalanceUSD, - calculateReservedCoins, - FuelProvider, - subCoins, -} from '@src/utils'; +import { calculateReservedCoins, FuelProvider, subCoins } from '@src/utils'; import { IconUtils } from '@src/utils/icons'; import { bn, BN, Network, ZeroBytes32 } from 'fuels'; import { UserService } from '../user/service'; @@ -37,6 +32,12 @@ import { IPredicatePayload, IPredicateService, } from './types'; +import { BalanceCache } from '@src/server/storage/balance'; +import { TransactionCache } from '@src/server/storage/transaction'; +import { compareBalances } from '@src/utils/balance'; +import { emitBalanceOutdatedPredicate } from '@src/socket/events'; +import { SocketUsernames, SocketEvents } from '@src/socket/types'; +import { ProviderWithCache } from '@src/utils/ProviderWithCache'; export class PredicateService implements IPredicateService { private _ordination: IPredicateOrdination = { @@ -871,4 +872,103 @@ export class PredicateService implements IPredicateService { }); } } + + async checkBalances({ + predicateId, + userId, + network, + }: { + predicateId: string; + userId: string; + network: Network; + }): Promise { + try { + const predicate = await Predicate.createQueryBuilder('p') + .leftJoinAndSelect('p.workspace', 'workspace') + .select([ + 'p.id', + 'p.predicateAddress', + 'p.configurable', + 'p.version', + 'workspace.id', + ]) + .where('p.id = :predicateId', { predicateId }) + .getOne(); + + if (!predicate) { + throw new NotFound({ + type: ErrorTypes.NotFound, + title: 'Predicate not found', + detail: `No predicate found with id ${predicateId}`, + }); + } + + const balanceCache = BalanceCache.getInstance(); + const instance = await this.instancePredicate( + predicate.configurable, + network.url, + predicate.version, + ); + + // Only proceed if provider is ProviderWithCache + if (!(instance.provider instanceof ProviderWithCache)) { + return; + } + + // Get cached balance + const cachedBalances = await balanceCache.get( + predicate.predicateAddress, + network.chainId, + ); + + // Get current balance directly from blockchain (bypass cache) + const currentBalances = ( + await (instance.provider as ProviderWithCache).getBalancesFromBlockchain( + predicate.predicateAddress, + ) + ).balances.filter(a => a.amount.gt(0)); + + if (cachedBalances) { + const _cachedBalances = cachedBalances.filter(a => a.amount.gt(0)); + + const hasChanged = compareBalances(_cachedBalances, currentBalances); + + if (hasChanged) { + // Update cache with fresh data + await balanceCache.set( + predicate.predicateAddress, + currentBalances, + network.chainId, + network.url, + ); + + // Invalidate transaction cache + const transactionCache = TransactionCache.getInstance(); + await transactionCache.invalidate( + predicate.predicateAddress, + network.chainId, + ); + + // Emit event to notify balance change + emitBalanceOutdatedPredicate(userId, { + sessionId: userId, + to: SocketUsernames.UI, + type: SocketEvents.BALANCE_OUTDATED_PREDICATE, + predicateId: predicate.id, + workspaceId: predicate.workspace.id, + }); + } + } + } catch (error) { + if (error instanceof NotFound) { + throw error; + } + + throw new Internal({ + type: ErrorTypes.Internal, + title: 'Error on check predicate balance', + detail: error?.message || error, + }); + } + } } diff --git a/packages/api/src/modules/predicate/types.ts b/packages/api/src/modules/predicate/types.ts index bf2593e97..9dabed1d7 100644 --- a/packages/api/src/modules/predicate/types.ts +++ b/packages/api/src/modules/predicate/types.ts @@ -156,6 +156,12 @@ interface IGetAllocationRequestSchema extends ValidatedRequestSchema { }; } +interface ICheckPredicateBalancesRequestSchema extends ValidatedRequestSchema { + [ContainerTypes.Params]: { + predicateId: string; + }; +} + export type ICreatePredicateRequest = AuthValidatedRequest; export type ITooglePredicateRequest = AuthValidatedRequest; export type IUpdatePredicateRequest = AuthValidatedRequest; @@ -179,6 +185,7 @@ export type PredicateWithHidden = Omit< isHidden: boolean; }; export type IGetAllocationRequest = AuthValidatedRequest; +export type ICheckPredicateBalancesRequest = AuthValidatedRequest; export interface IPredicateService { ordination(ordination?: IPredicateOrdination): this; @@ -208,4 +215,9 @@ export interface IPredicateService { authorization: string, ) => Promise; allocation: (params: IPredicateAllocationParams) => Promise; + checkBalances: (params: { + predicateId: string; + userId: string; + network: Network; + }) => Promise; } diff --git a/packages/api/src/modules/user/controller.ts b/packages/api/src/modules/user/controller.ts index 1920f4bc9..2ff74efa8 100644 --- a/packages/api/src/modules/user/controller.ts +++ b/packages/api/src/modules/user/controller.ts @@ -34,6 +34,7 @@ import { IAllocationRequest, ICheckHardwareRequest, ICheckNicknameRequest, + ICheckUserBalancesRequest, ICreateRequest, IDeleteRequest, IFindByNameRequest, @@ -461,4 +462,17 @@ export class UserController { return error(e.error ?? e, e.statusCode); } } + + async checkUserBalances({ user, workspace, network }: ICheckUserBalancesRequest) { + try { + await this.userService.checkBalances({ + userId: user.id, + workspaceId: workspace.id, + network, + }); + return successful(null, Responses.Ok); + } catch (e) { + return error(e.error || e, e.statusCode); + } + } } diff --git a/packages/api/src/modules/user/routes.ts b/packages/api/src/modules/user/routes.ts index dc59dbeac..fba71812a 100644 --- a/packages/api/src/modules/user/routes.ts +++ b/packages/api/src/modules/user/routes.ts @@ -63,6 +63,11 @@ router.get( AllocationQuerySchema, handleResponse(userController.allocation), ); +router.get( + '/check-balances', + authMiddleware, + handleResponse(userController.checkUserBalances), +); router.get('/', authMiddleware, handleResponse(userController.find)); router.get( '/:id', diff --git a/packages/api/src/modules/user/service.ts b/packages/api/src/modules/user/service.ts index 9fb4c94ae..0d0392146 100644 --- a/packages/api/src/modules/user/service.ts +++ b/packages/api/src/modules/user/service.ts @@ -27,14 +27,22 @@ import { import App from '@src/server/app'; import { Address, Network } from 'fuels'; -import { Vault } from 'bakosafe'; import { PredicateService } from '../predicate/services'; import { Maybe } from '@src/utils/types/maybe'; -import { FuelProvider } from '@src/utils'; +import { FuelProvider, processBatch } from '@src/utils'; +import { BalanceCache } from '@src/server/storage/balance'; +import { TransactionCache } from '@src/server/storage/transaction'; +import { compareBalances } from '@src/utils/balance'; +import { emitBalanceOutdatedUser } from '@src/socket/events'; +import { SocketUsernames, SocketEvents } from '@src/socket/types'; +import { ProviderWithCache } from '@src/utils/ProviderWithCache'; const { UI_URL } = process.env; +const MAX_PREDICATES_TO_CHECK_BALANCE = 50; +const PREDICATES_BALANCE_CHECK_BATCH_SIZE = 10; + export class UserService implements IUserService { private _pagination: PaginationParams; private _filter: IFilterParams; @@ -316,4 +324,120 @@ export class UserService implements IUserService { return Pagination.create(queryBuilder).paginate(this._pagination); } + + async checkBalances({ + userId, + workspaceId, + network, + }: { + userId: string; + workspaceId: string; + network: Network; + }): Promise { + try { + // Get all predicates for this user (owner or member) + const predicates = await Predicate.createQueryBuilder('p') + .leftJoinAndSelect('p.members', 'members') + .leftJoinAndSelect('p.workspace', 'predicateWorkspace') + .select([ + 'p.id', + 'p.predicateAddress', + 'p.configurable', + 'p.version', + 'members.id', + 'predicateWorkspace.id', + ]) + .where('predicateWorkspace.id = :workspaceId', { + workspaceId, + }) + .andWhere('(p.owner_id = :userId OR members.id = :userId)', { + userId, + }) + .limit(MAX_PREDICATES_TO_CHECK_BALANCE) + .getMany(); + + const balanceCache = BalanceCache.getInstance(); + const transactionCache = TransactionCache.getInstance(); + const predicateService = new PredicateService(); + const outdatedPredicateIds: string[] = []; + + // Process predicates in batches to control concurrency + await processBatch( + predicates, + PREDICATES_BALANCE_CHECK_BATCH_SIZE, + async predicate => { + try { + const instance = await predicateService.instancePredicate( + predicate.configurable, + network.url, + predicate.version, + ); + + if (!(instance.provider instanceof ProviderWithCache)) { + return; + } + + // Get cached balance + const cachedBalances = await balanceCache.get( + predicate.predicateAddress, + network.chainId, + ); + + // Get current balance directly from blockchain (bypass cache) + const currentBalances = ( + await (instance.provider as ProviderWithCache).getBalancesFromBlockchain( + predicate.predicateAddress, + ) + ).balances.filter(a => a.amount.gt(0)); + + if (cachedBalances) { + const _cachedBalances = cachedBalances.filter(a => a.amount.gt(0)); + + const hasChanged = compareBalances(_cachedBalances, currentBalances); + + if (hasChanged) { + outdatedPredicateIds.push(predicate.id); + + // Update cache with fresh data + await balanceCache.set( + predicate.predicateAddress, + currentBalances, + network.chainId, + network.url, + ); + + // Invalidate transaction cache + await transactionCache.invalidate( + predicate.predicateAddress, + network.chainId, + ); + } + } + } catch (e) { + console.error( + `[CHECK_USER_BALANCES] Error checking predicate ${predicate.id}:`, + e?.message || e, + ); + } + }, + ); + + // Emit event to notify balance change only if there are outdated predicates + if (outdatedPredicateIds.length > 0) { + emitBalanceOutdatedUser(userId, { + sessionId: userId, + to: SocketUsernames.UI, + type: SocketEvents.BALANCE_OUTDATED_USER, + workspaceId, + outdatedPredicateIds, + }); + } + } catch (error) { + throw new Internal({ + type: ErrorTypes.Internal, + title: 'Error on check user balances', + detail: error?.message || error, + }); + } + } } diff --git a/packages/api/src/modules/user/types.ts b/packages/api/src/modules/user/types.ts index 0570c299c..2d1c8bda1 100644 --- a/packages/api/src/modules/user/types.ts +++ b/packages/api/src/modules/user/types.ts @@ -11,6 +11,7 @@ import { IDefaultOrdination, IOrdination } from '@src/utils/ordination'; import { IPagination, PaginationParams } from '@src/utils/pagination'; import { Maybe } from '@src/utils/types/maybe'; import { TypeUser } from 'bakosafe'; +import { Network } from 'fuels'; export interface IWebAuthnSignUp { id: string; @@ -138,6 +139,8 @@ interface IAllocationRequestSchema extends ValidatedRequestSchema { export type IAllocationRequest = AuthValidatedRequest; +export type ICheckUserBalancesRequest = AuthValidatedRequest; + export interface IUserService { filter(filter: IFilterParams): this; paginate(pagination: PaginationParams): this; @@ -156,4 +159,9 @@ export interface IUserService { userId?: string, ): Promise>; listAll(): Promise>; + checkBalances: (params: { + userId: string; + workspaceId: string; + network: Network; + }) => Promise; } diff --git a/packages/api/src/socket/events.ts b/packages/api/src/socket/events.ts index f6fc3a44b..983461529 100644 --- a/packages/api/src/socket/events.ts +++ b/packages/api/src/socket/events.ts @@ -1,6 +1,10 @@ -import { ITransactionResponse, ITransactionHistory } from "@src/modules/transaction/types"; -import { SocketClient } from "./client"; -import { SocketEvents } from "./types"; +import { + ITransactionResponse, + ITransactionHistory, +} from '@src/modules/transaction/types'; +import { SocketClient } from './client'; +import { SocketEvents } from './types'; + const { API_URL } = process.env; export type TransactionEvent = { @@ -9,9 +13,41 @@ export type TransactionEvent = { type: string; transaction: ITransactionResponse; history: ITransactionHistory[]; -} +}; + +export type BalanceOutdatedUserEvent = { + sessionId: string; + to: string; + type: SocketEvents; + workspaceId: string; + outdatedPredicateIds: string[]; +}; + +export type BalanceOutdatedPredicateEvent = { + sessionId: string; + to: string; + type: SocketEvents; + predicateId: string; + workspaceId: string; +}; export function emitTransaction(userId: string, data: TransactionEvent) { const socketClient = new SocketClient(userId, API_URL); socketClient.socket.emit(SocketEvents.TRANSACTION, data); } + +export function emitBalanceOutdatedUser( + userId: string, + data: BalanceOutdatedUserEvent, +) { + const socketClient = new SocketClient(userId, API_URL); + socketClient.socket.emit(SocketEvents.BALANCE_OUTDATED_USER, data); +} + +export function emitBalanceOutdatedPredicate( + userId: string, + data: BalanceOutdatedPredicateEvent, +) { + const socketClient = new SocketClient(userId, API_URL); + socketClient.socket.emit(SocketEvents.BALANCE_OUTDATED_PREDICATE, data); +} diff --git a/packages/api/src/socket/types.ts b/packages/api/src/socket/types.ts index 24add87ce..89f790c44 100644 --- a/packages/api/src/socket/types.ts +++ b/packages/api/src/socket/types.ts @@ -20,7 +20,6 @@ export enum SocketEvents { NOTIFICATION = 'notification', NEW_NOTIFICATION = '[NEW_NOTIFICATION]', - TRANSACTION_UPDATE = '[TRANSACTION]', VAULT_UPDATE = '[VAULT]', TRANSACTION = '[TRANSACTION]', @@ -29,6 +28,8 @@ export enum SocketEvents { TRANSACTION_CANCELED = '[CANCELED]', SWITCH_NETWORK = '[SWITCH_NETWORK]', + BALANCE_OUTDATED_USER = '[BALANCE_OUTDATED_USER]', + BALANCE_OUTDATED_PREDICATE = '[BALANCE_OUTDATED_PREDICATE]', } export enum SocketUsernames { diff --git a/packages/api/src/utils/ProviderWithCache.ts b/packages/api/src/utils/ProviderWithCache.ts index 461eb3d45..33d29701f 100644 --- a/packages/api/src/utils/ProviderWithCache.ts +++ b/packages/api/src/utils/ProviderWithCache.ts @@ -1,6 +1,6 @@ -import { Provider, type CoinQuantity, type ProviderOptions, Address } from 'fuels'; import { BalanceCache } from '@src/server/storage/balance'; import { cacheConfig } from '@src/config/cache'; +import { Address, CoinQuantity, Provider, ProviderOptions } from 'fuels'; // Type for address parameter (matches Fuel SDK's Provider.getBalances signature) type AddressInput = string | Address; @@ -78,9 +78,7 @@ export class ProviderWithCache extends Provider { * Override getBalances to add caching * This is the main method called by Vault.getBalances() */ - async getBalances( - address: AddressInput, - ): Promise<{ balances: CoinQuantity[] }> { + async getBalances(address: AddressInput): Promise<{ balances: CoinQuantity[] }> { const cache = this.getBalanceCache(); // Convert address to string (handles both string and Address objects) @@ -111,7 +109,10 @@ export class ProviderWithCache extends Provider { return result; } catch (error) { - console.error('[ProviderWithCache] Error, falling back to blockchain:', error); + console.error( + '[ProviderWithCache] Error, falling back to blockchain:', + error, + ); // Fallback to blockchain on any cache error return super.getBalances(address); } @@ -143,6 +144,16 @@ export class ProviderWithCache extends Provider { return result; } + /** + * Get balances directly from blockchain without using or modifying cache + * Use this for diagnostic purposes to compare blockchain state with cached state + */ + async getBalancesFromBlockchain( + address: AddressInput, + ): Promise<{ balances: CoinQuantity[] }> { + return super.getBalances(address); + } + /** * Invalidate cache for a specific address */ diff --git a/packages/api/src/utils/balance.ts b/packages/api/src/utils/balance.ts index 926836b51..ac983c102 100644 --- a/packages/api/src/utils/balance.ts +++ b/packages/api/src/utils/balance.ts @@ -76,4 +76,39 @@ const subCoins = ( .filter(balance => balance.amount.gt(bn.parseUnits('0'))); }; -export { calculateReservedCoins, calculateBalanceUSD, subCoins }; +/** + * Compare two arrays of CoinQuantity to detect balance changes + * @param cached - Previously cached balances + * @param current - Current balances from blockchain + * @returns true if balances are different, false if identical + */ +const compareBalances = ( + cached: CoinQuantity[], + current: CoinQuantity[], +): boolean => { + if (cached.length !== current.length) { + return true; + } + + // Sort by assetId for comparison + const sortFn = (a: CoinQuantity, b: CoinQuantity) => + a.assetId.localeCompare(b.assetId); + const cachedSorted = [...cached].sort(sortFn); + const currentSorted = [...current].sort(sortFn); + + for (let i = 0; i < cachedSorted.length; i++) { + const cachedAsset = cachedSorted[i]; + const currentAsset = currentSorted[i]; + + if ( + cachedAsset.assetId !== currentAsset.assetId || + !cachedAsset.amount.eq(currentAsset.amount) + ) { + return true; + } + } + + return false; +}; + +export { calculateReservedCoins, calculateBalanceUSD, subCoins, compareBalances }; diff --git a/packages/api/src/utils/index.ts b/packages/api/src/utils/index.ts index b6696b545..5a0f8ae49 100644 --- a/packages/api/src/utils/index.ts +++ b/packages/api/src/utils/index.ts @@ -17,3 +17,4 @@ export * from './redis/RedisWriteClient'; export * from './FuelProvider'; export * from './ProviderWithCache'; export * from './extractPredicatesFromTransaction'; +export * from './processBatch'; diff --git a/packages/api/src/utils/processBatch.ts b/packages/api/src/utils/processBatch.ts new file mode 100644 index 000000000..110d6ce3c --- /dev/null +++ b/packages/api/src/utils/processBatch.ts @@ -0,0 +1,31 @@ +/** + * Process items in batches with concurrency control + * Prevents overwhelming external services with too many parallel requests + * + * @param items Items to process + * @param batchSize Number of items to process in parallel per batch + * @param processor Async function to process each item + * @returns Array of results maintaining order + * + * @example + * ```ts + * const users = [user1, user2, ..., user20]; + * const results = await processBatch(users, 5, async (user) => { + * return await fetchUserData(user.id); + * }); + * // Processes 5 users at a time, maintains original order + * ``` + */ +export async function processBatch( + items: T[], + batchSize: number, + processor: (item: T) => Promise, +): Promise { + const results: R[] = []; + for (let i = 0; i < items.length; i += batchSize) { + const batch = items.slice(i, i + batchSize); + const batchResults = await Promise.all(batch.map(processor)); + results.push(...batchResults); + } + return results; +} diff --git a/packages/socket-server/src/socket/index.ts b/packages/socket-server/src/socket/index.ts index 4a968e227..1c7dcacd7 100644 --- a/packages/socket-server/src/socket/index.ts +++ b/packages/socket-server/src/socket/index.ts @@ -152,6 +152,24 @@ export const setupSocket = (io: SocketIOServer, database: DatabaseClass, api: Ax } }) + socket.on(SocketEvents.BALANCE_OUTDATED_USER, data => { + const { sessionId, to } = data + const room = `${sessionId}:${to}` + const clientsInRoom = io.sockets.adapter.rooms.get(room) || new Set() + if (clientsInRoom.size > 0) { + socket.to(room).emit(SocketEvents.BALANCE_OUTDATED_USER, data) + } + }) + + socket.on(SocketEvents.BALANCE_OUTDATED_PREDICATE, data => { + const { sessionId, to } = data + const room = `${sessionId}:${to}` + const clientsInRoom = io.sockets.adapter.rooms.get(room) || new Set() + if (clientsInRoom.size > 0) { + socket.to(room).emit(SocketEvents.BALANCE_OUTDATED_PREDICATE, data) + } + }) + socket.on(SocketEvents.NOTIFICATION, data => { const { sessionId, to } = data const room = `${sessionId}:${to}` diff --git a/packages/socket-server/src/types.ts b/packages/socket-server/src/types.ts index 2d36a6b26..6a7f10426 100644 --- a/packages/socket-server/src/types.ts +++ b/packages/socket-server/src/types.ts @@ -30,6 +30,9 @@ export enum SocketEvents { NEW_NOTIFICATION = '[NEW_NOTIFICATION]', TRANSACTION = '[TRANSACTION]', + + BALANCE_OUTDATED_USER = '[BALANCE_OUTDATED_USER]', + BALANCE_OUTDATED_PREDICATE = '[BALANCE_OUTDATED_PREDICATE]', } export enum SocketUsernames {