diff --git a/proto/dex_state.proto b/proto/dex_state.proto index 1d1d767ab..d7de2f957 100644 --- a/proto/dex_state.proto +++ b/proto/dex_state.proto @@ -313,6 +313,10 @@ message PairsCountResponse { uint32 count = 1; } +message IsStateInitializedResponse { + bool initialized = 1; +} + service DexStateService { rpc GetPairs (GetPairsRequest) returns (Pairs) {} rpc GetFilteredPairs (GetFilteredPairsRequest) returns (PaginatedPairs) {} @@ -351,4 +355,6 @@ service DexStateService { rpc GetWeeklyTimekeeping (GetWeeklyTimekeepingRequest) returns (WeekTimekeeping) {} rpc UpdateUsdcPrice (UpdateUsdcPriceRequest) returns (UpdateUsdcPriceResponse) {} + + rpc IsStateInitialized (google.protobuf.Empty) returns (IsStateInitializedResponse) {} } diff --git a/src/microservices/dex-state/dex.state.controller.ts b/src/microservices/dex-state/dex.state.controller.ts index 5e720015d..70ad7e7c2 100644 --- a/src/microservices/dex-state/dex.state.controller.ts +++ b/src/microservices/dex-state/dex.state.controller.ts @@ -26,6 +26,7 @@ import { IDexStateService, InitStateRequest, InitStateResponse, + IsStateInitializedResponse, PaginatedPairs, PaginatedStakingFarms, PaginatedTokens, @@ -229,6 +230,13 @@ export class DexStateController implements IDexStateService { return this.dexStateService.getWeeklyTimekeeping(request); } + @GrpcMethod(DEX_STATE_SERVICE_NAME, 'isStateInitialized') + isStateInitialized(): IsStateInitializedResponse { + return { + initialized: this.dexStateService.isReady(), + }; + } + private ensureReady() { if (!this.dexStateService.isReady()) { throw new RpcException({ diff --git a/src/microservices/dex-state/interfaces/dex_state.interfaces.ts b/src/microservices/dex-state/interfaces/dex_state.interfaces.ts index 629a80ca6..872c89df7 100644 --- a/src/microservices/dex-state/interfaces/dex_state.interfaces.ts +++ b/src/microservices/dex-state/interfaces/dex_state.interfaces.ts @@ -308,6 +308,10 @@ export interface PairsCountResponse { count: number; } +export interface IsStateInitializedResponse { + initialized: boolean; +} + export const DEX_STATE_PACKAGE_NAME = 'dex_state'; export interface IDexStateServiceClient { @@ -390,6 +394,8 @@ export interface IDexStateServiceClient { updateUsdcPrice( request: UpdateUsdcPriceRequest, ): Observable; + + isStateInitialized(request: Empty): Observable; } export interface IDexStateService { @@ -527,6 +533,8 @@ export interface IDexStateService { | Promise | Observable | UpdateUsdcPriceResponse; + + isStateInitialized(request: Empty): IsStateInitializedResponse; } export const DEX_STATE_SERVICE_NAME = 'DexStateService'; diff --git a/src/microservices/dex-state/services/bulk.updates.service.ts b/src/microservices/dex-state/services/bulk.updates.service.ts index 3b15bf074..cedf0749a 100644 --- a/src/microservices/dex-state/services/bulk.updates.service.ts +++ b/src/microservices/dex-state/services/bulk.updates.service.ts @@ -199,6 +199,12 @@ export class BulkUpdatesService { } } + pairs = pairs.filter((pair) => { + return pair.firstTokenId === id + ? this.commonTokenIDs.includes(pair.secondTokenId) + : this.commonTokenIDs.includes(pair.firstTokenId); + }); + const tokenPairs: PairModel[] = []; pairs.forEach((pair) => { @@ -211,6 +217,8 @@ export class BulkUpdatesService { return tokenPairs; }; + const minLiquidity = new BigNumber(`1e${mxConfig.EGLDDecimals}`); + const tokenDerivedEGLD = (tokenID: string): string => { if (memo.has(tokenID)) { return memo.get(tokenID); @@ -256,7 +264,10 @@ export class BulkUpdatesService { .times(secondTokenDerivedEGLD) .times(`1e${mxConfig.EGLDDecimals}`) .integerValue(); - if (egldLocked.isGreaterThan(largestLiquidityEGLD)) { + if ( + egldLocked.isGreaterThan(largestLiquidityEGLD) && + egldLocked.gt(minLiquidity) + ) { largestLiquidityEGLD = egldLocked; priceSoFar = new BigNumber(pair.firstTokenPrice).times( secondTokenDerivedEGLD, @@ -273,7 +284,10 @@ export class BulkUpdatesService { .times(firstTokenDerivedEGLD) .times(`1e${mxConfig.EGLDDecimals}`) .integerValue(); - if (egldLocked.isGreaterThan(largestLiquidityEGLD)) { + if ( + egldLocked.isGreaterThan(largestLiquidityEGLD) && + egldLocked.gt(minLiquidity) + ) { largestLiquidityEGLD = egldLocked; priceSoFar = new BigNumber(pair.secondTokenPrice).times( firstTokenDerivedEGLD, @@ -296,9 +310,8 @@ export class BulkUpdatesService { let newLockedValue = new BigNumber(0); for (const pair of tokenPairs) { if ( - pair.state === 'Active' || - (this.commonTokenIDs.includes(pair.firstTokenId) && - this.commonTokenIDs.includes(pair.secondTokenId)) + this.commonTokenIDs.includes(pair.firstTokenId) && + this.commonTokenIDs.includes(pair.secondTokenId) ) { const tokenLockedValueUSD = tokenID === pair.firstTokenId @@ -360,9 +373,8 @@ export class BulkUpdatesService { }; if ( - pair.state === 'Active' || - (this.commonTokenIDs.includes(pair.firstTokenId) && - this.commonTokenIDs.includes(pair.secondTokenId)) + this.commonTokenIDs.includes(pair.firstTokenId) && + this.commonTokenIDs.includes(pair.secondTokenId) ) { result.lockedValueUSD = firstTokenLockedValueUSD .plus(secondTokenLockedValueUSD) diff --git a/src/microservices/dex-state/services/compute/fees-collector.compute.service.ts b/src/microservices/dex-state/services/compute/fees-collector.compute.service.ts index 61a627ada..fadc32e68 100644 --- a/src/microservices/dex-state/services/compute/fees-collector.compute.service.ts +++ b/src/microservices/dex-state/services/compute/fees-collector.compute.service.ts @@ -5,6 +5,7 @@ import { refreshWeekStartAndEndEpochs, } from '../../utils/rewards.compute.utils'; import { StateStore } from '../state.store'; +import { constantsConfig } from 'src/config'; @Injectable() export class FeesCollectorComputeService { @@ -15,6 +16,11 @@ export class FeesCollectorComputeService { ): FeesCollectorModel { refreshWeekStartAndEndEpochs(feesCollector.time); + feesCollector.startWeek = + feesCollector.time.currentWeek - + constantsConfig.USER_MAX_CLAIM_WEEKS; + feesCollector.endWeek = feesCollector.time.currentWeek; + feesCollector.undistributedRewards.forEach((globalInfo) => { if (!globalInfo.totalRewardsForWeek) { globalInfo.totalRewardsForWeek = []; diff --git a/src/microservices/dex-state/services/state.store.ts b/src/microservices/dex-state/services/state.store.ts index c3208023e..c38c3df00 100644 --- a/src/microservices/dex-state/services/state.store.ts +++ b/src/microservices/dex-state/services/state.store.ts @@ -100,6 +100,9 @@ export class StateStore { // Setters for controlled mutation setToken(identifier: string, token: EsdtToken): void { + if (!identifier) { + throw new Error('Token identifier must not be empty'); + } this._tokens.set(identifier, token); } @@ -144,7 +147,10 @@ export class StateStore { if (!this._tokenPairs.has(tokenId)) { this._tokenPairs.set(tokenId, []); } - this._tokenPairs.get(tokenId).push(pairAddress); + const pairs = this._tokenPairs.get(tokenId); + if (!pairs.includes(pairAddress)) { + pairs.push(pairAddress); + } } addTokenByType(type: EsdtTokenType, tokenId: string): void { diff --git a/src/microservices/dex-state/specs/handlers/farms.state.handler.spec.ts b/src/microservices/dex-state/specs/handlers/farms.state.handler.spec.ts new file mode 100644 index 000000000..a0b58706f --- /dev/null +++ b/src/microservices/dex-state/specs/handlers/farms.state.handler.spec.ts @@ -0,0 +1,353 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { FarmsStateHandler } from '../../services/handlers/farms.state.handler'; +import { StateStore } from '../../services/state.store'; +import { FarmComputeService } from '../../services/compute/farm.compute.service'; +import { MockFarmComputeServiceProvider } from '../mocks/compute.services.mock'; +import { + createMockFarm, + createMockPair, + TEST_ADDRESSES, +} from '../test.utils'; +import { PairCompoundedAPRModel } from 'src/modules/pair/models/pair.compounded.apr.model'; + +describe('FarmsStateHandler', () => { + let handler: FarmsStateHandler; + let stateStore: StateStore; + let computeService: FarmComputeService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + FarmsStateHandler, + StateStore, + MockFarmComputeServiceProvider, + ], + }).compile(); + + handler = module.get(FarmsStateHandler); + stateStore = module.get(StateStore); + computeService = module.get(FarmComputeService); + + // Populate state store with test farms + const mockFarm1 = createMockFarm(TEST_ADDRESSES.FARM_1, { + pairAddress: TEST_ADDRESSES.PAIR_EGLD_MEX, + baseApr: '50', + boostedApr: '100', + farmedTokenId: 'MEX-123456', + farmingTokenId: 'EGLDMEX-abcdef', + }); + stateStore.setFarm(TEST_ADDRESSES.FARM_1, mockFarm1); + + const mockFarm2 = createMockFarm(TEST_ADDRESSES.FARM_2, { + pairAddress: TEST_ADDRESSES.PAIR_EGLD_USDC, + baseApr: '30', + boostedApr: '60', + }); + stateStore.setFarm(TEST_ADDRESSES.FARM_2, mockFarm2); + + // Set up farm-pair mapping + stateStore.farmsPairs.set(TEST_ADDRESSES.FARM_1, TEST_ADDRESSES.PAIR_EGLD_MEX); + stateStore.farmsPairs.set(TEST_ADDRESSES.FARM_2, TEST_ADDRESSES.PAIR_EGLD_USDC); + + // Populate with test pair + const mockPair = createMockPair(TEST_ADDRESSES.PAIR_EGLD_MEX, { + compoundedAPR: new PairCompoundedAPRModel({ + address: TEST_ADDRESSES.PAIR_EGLD_MEX, + feesAPR: '10.95', + farmBaseAPR: '0', + farmBoostedAPR: '0', + dualFarmBaseAPR: '0', + dualFarmBoostedAPR: '0', + }), + }); + stateStore.setPair(TEST_ADDRESSES.PAIR_EGLD_MEX, mockPair); + }); + + describe('getFarms', () => { + it('should throw error when farm not found', () => { + const unknownAddress = 'erd1qqqqqqqqqqqqqunknown'; + + expect(() => handler.getFarms([unknownAddress])).toThrow( + `Farm ${unknownAddress} not found`, + ); + }); + + it('should return full farms when fields not specified', () => { + const result = handler.getFarms([TEST_ADDRESSES.FARM_1]); + + expect(result).toBeDefined(); + expect(result.farms).toHaveLength(1); + expect(result.farms[0].address).toBe(TEST_ADDRESSES.FARM_1); + expect(result.farms[0].baseApr).toBe('50'); + expect(result.farms[0].boostedApr).toBe('100'); + expect(result.farms[0].pairAddress).toBeDefined(); + }); + + it('should return partial farms with only requested fields', () => { + const result = handler.getFarms([TEST_ADDRESSES.FARM_1], ['address', 'baseApr']); + + expect(result).toBeDefined(); + expect(result.farms).toHaveLength(1); + expect(result.farms[0].address).toBe(TEST_ADDRESSES.FARM_1); + expect(result.farms[0].baseApr).toBe('50'); + expect(result.farms[0].boostedApr).toBeUndefined(); + }); + + it('should handle multiple farms', () => { + const result = handler.getFarms([ + TEST_ADDRESSES.FARM_1, + TEST_ADDRESSES.FARM_2, + ]); + + expect(result).toBeDefined(); + expect(result.farms).toHaveLength(2); + expect(result.farms[0].address).toBe(TEST_ADDRESSES.FARM_1); + expect(result.farms[1].address).toBe(TEST_ADDRESSES.FARM_2); + }); + }); + + describe('getAllFarms', () => { + it('should return empty array when no farms exist', () => { + stateStore.clearAll(); + + const result = handler.getAllFarms(); + + expect(result).toBeDefined(); + expect(result.farms).toHaveLength(0); + }); + + it('should return all farms when farms exist', () => { + const result = handler.getAllFarms(); + + expect(result).toBeDefined(); + expect(result.farms).toHaveLength(2); + expect(result.farms.some(f => f.address === TEST_ADDRESSES.FARM_1)).toBe(true); + expect(result.farms.some(f => f.address === TEST_ADDRESSES.FARM_2)).toBe(true); + }); + }); + + describe('updateFarms', () => { + it('should update mutable fields correctly', () => { + const request = { + farms: [ + { + address: TEST_ADDRESSES.FARM_1, + perBlockRewards: '2000000000000000000', + } as any, + ], + updateMask: { + paths: ['perBlockRewards'], + }, + }; + + const response = handler.updateFarms(request); + + expect(response.updatedCount).toBe(1); + expect(response.failedAddresses).toHaveLength(0); + + const farm = stateStore.farms.get(TEST_ADDRESSES.FARM_1); + expect(farm.perBlockRewards).toBe('2000000000000000000'); + }); + + it('should skip immutable field updates', () => { + const originalFarmedTokenId = 'MEX-123456'; + const originalFarmingTokenId = 'EGLDMEX-abcdef'; + const originalPairAddress = TEST_ADDRESSES.PAIR_EGLD_MEX; + + const request = { + farms: [ + { + address: TEST_ADDRESSES.FARM_1, + farmedTokenId: 'NEWTOKEN-123456', + farmingTokenId: 'NEWFARMING-abcdef', + pairAddress: 'erd1qqqqqqqqqqqqqnewpair', + perBlockRewards: '2000000000000000000', + } as any, + ], + updateMask: { + paths: ['farmedTokenId', 'farmingTokenId', 'pairAddress', 'perBlockRewards'], + }, + }; + + handler.updateFarms(request); + + const farm = stateStore.farms.get(TEST_ADDRESSES.FARM_1); + expect(farm.farmedTokenId).toBe(originalFarmedTokenId); // Should not change + expect(farm.farmingTokenId).toBe(originalFarmingTokenId); // Should not change + expect(farm.pairAddress).toBe(originalPairAddress); // Should not change + expect(farm.perBlockRewards).toBe('2000000000000000000'); // Should change + }); + + it('should return failedAddresses for non-existent farms', () => { + const unknownAddress = 'erd1qqqqqqqqqqqqqunknown'; + + const request = { + farms: [ + { + address: unknownAddress, + perBlockRewards: '2000000000000000000', + } as any, + ], + updateMask: { + paths: ['perBlockRewards'], + }, + }; + + const response = handler.updateFarms(request); + + expect(response.updatedCount).toBe(0); + expect(response.failedAddresses).toHaveLength(1); + expect(response.failedAddresses[0]).toBe(unknownAddress); + }); + + it('should call compute service for each updated farm', () => { + const computeSpy = jest.spyOn(computeService, 'computeMissingFarmFields'); + + const request = { + farms: [ + { + address: TEST_ADDRESSES.FARM_1, + perBlockRewards: '2000000000000000000', + } as any, + { + address: TEST_ADDRESSES.FARM_2, + perBlockRewards: '3000000000000000000', + } as any, + ], + updateMask: { + paths: ['perBlockRewards'], + }, + }; + + handler.updateFarms(request); + + expect(computeSpy).toHaveBeenCalledTimes(2); + }); + + it('should update linked pair farmBaseAPR and farmBoostedAPR', () => { + // Mock compute service to return specific APR values + jest.spyOn(computeService, 'computeMissingFarmFields').mockImplementation((farm) => ({ + ...farm, + baseApr: '75', + boostedApr: '150', + })); + + const request = { + farms: [ + { + address: TEST_ADDRESSES.FARM_1, + perBlockRewards: '2000000000000000000', + } as any, + ], + updateMask: { + paths: ['perBlockRewards'], + }, + }; + + handler.updateFarms(request); + + const pair = stateStore.pairs.get(TEST_ADDRESSES.PAIR_EGLD_MEX); + expect(pair.compoundedAPR.farmBaseAPR).toBe('75'); + expect(pair.compoundedAPR.farmBoostedAPR).toBe('150'); + }); + + it('should skip pair update when farmsPairs mapping missing', () => { + // Remove farm-pair mapping + stateStore.farmsPairs.delete(TEST_ADDRESSES.FARM_1); + + const originalPair = { ...stateStore.pairs.get(TEST_ADDRESSES.PAIR_EGLD_MEX) }; + + const request = { + farms: [ + { + address: TEST_ADDRESSES.FARM_1, + perBlockRewards: '2000000000000000000', + } as any, + ], + updateMask: { + paths: ['perBlockRewards'], + }, + }; + + handler.updateFarms(request); + + // Pair should not be updated + const pair = stateStore.pairs.get(TEST_ADDRESSES.PAIR_EGLD_MEX); + expect(pair.compoundedAPR.farmBaseAPR).toBe(originalPair.compoundedAPR.farmBaseAPR); + expect(pair.compoundedAPR.farmBoostedAPR).toBe(originalPair.compoundedAPR.farmBoostedAPR); + }); + + it('should skip pair update when pair not found in store', () => { + // Remove pair from store but keep mapping + stateStore.pairs.delete(TEST_ADDRESSES.PAIR_EGLD_MEX); + + const request = { + farms: [ + { + address: TEST_ADDRESSES.FARM_1, + perBlockRewards: '2000000000000000000', + } as any, + ], + updateMask: { + paths: ['perBlockRewards'], + }, + }; + + // Should not throw error + expect(() => handler.updateFarms(request)).not.toThrow(); + + const response = handler.updateFarms(request); + expect(response.updatedCount).toBe(1); + }); + + it('should return correct updatedCount', () => { + const request = { + farms: [ + { + address: TEST_ADDRESSES.FARM_1, + perBlockRewards: '2000000000000000000', + } as any, + { + address: TEST_ADDRESSES.FARM_2, + perBlockRewards: '3000000000000000000', + } as any, + { + address: 'erd1qqqqqqqqqqqqqunknown', + perBlockRewards: '4000000000000000000', + } as any, + ], + updateMask: { + paths: ['perBlockRewards'], + }, + }; + + const response = handler.updateFarms(request); + + expect(response.updatedCount).toBe(2); // Only 2 succeeded + expect(response.failedAddresses).toHaveLength(1); + }); + + it('should skip farms with missing address', () => { + const request = { + farms: [ + { + address: undefined, + perBlockRewards: '2000000000000000000', + } as any, + { + address: TEST_ADDRESSES.FARM_1, + perBlockRewards: '3000000000000000000', + } as any, + ], + updateMask: { + paths: ['perBlockRewards'], + }, + }; + + const response = handler.updateFarms(request); + + expect(response.updatedCount).toBe(1); // Only 1 succeeded + expect(response.failedAddresses).toHaveLength(0); // Missing address not added to failed + }); + }); +}); diff --git a/src/microservices/dex-state/specs/handlers/fees-collector.state.handler.spec.ts b/src/microservices/dex-state/specs/handlers/fees-collector.state.handler.spec.ts new file mode 100644 index 000000000..9fd7e1805 --- /dev/null +++ b/src/microservices/dex-state/specs/handlers/fees-collector.state.handler.spec.ts @@ -0,0 +1,237 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { FeesCollectorStateHandler } from '../../services/handlers/fees-collector.state.handler'; +import { StateStore } from '../../services/state.store'; +import { FeesCollectorComputeService } from '../../services/compute/fees-collector.compute.service'; +import { MockFeesCollectorComputeServiceProvider } from '../mocks/compute.services.mock'; +import { + createMockFeesCollector, + createMockWeekTimekeeping, + TEST_ADDRESSES, +} from '../test.utils'; + +describe('FeesCollectorStateHandler', () => { + let handler: FeesCollectorStateHandler; + let stateStore: StateStore; + let computeService: FeesCollectorComputeService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + FeesCollectorStateHandler, + StateStore, + MockFeesCollectorComputeServiceProvider, + ], + }).compile(); + + handler = module.get( + FeesCollectorStateHandler, + ); + stateStore = module.get(StateStore); + computeService = module.get( + FeesCollectorComputeService, + ); + }); + + describe('getFeesCollector', () => { + it('should throw error when fees collector not initialized', () => { + // State store is empty by default + expect(() => handler.getFeesCollector()).toThrow( + 'Fees collector not initialized', + ); + }); + + it('should return full fees collector when fields not specified', () => { + const mockFeesCollector = createMockFeesCollector({ + address: TEST_ADDRESSES.FEES_COLLECTOR, + lastGlobalUpdateWeek: 99, + startWeek: 1, + endWeek: 100, + }); + stateStore.setFeesCollector(mockFeesCollector); + + const result = handler.getFeesCollector(); + + expect(result).toBeDefined(); + expect(result.address).toBe(TEST_ADDRESSES.FEES_COLLECTOR); + expect(result.lastGlobalUpdateWeek).toBe(99); + expect(result.startWeek).toBe(1); + expect(result.endWeek).toBe(100); + expect(result.time).toBeDefined(); + }); + + it('should return full fees collector when fields is empty array', () => { + const mockFeesCollector = createMockFeesCollector({ + address: TEST_ADDRESSES.FEES_COLLECTOR, + lastGlobalUpdateWeek: 99, + }); + stateStore.setFeesCollector(mockFeesCollector); + + const result = handler.getFeesCollector([]); + + expect(result).toBeDefined(); + expect(result.address).toBe(TEST_ADDRESSES.FEES_COLLECTOR); + expect(result.lastGlobalUpdateWeek).toBe(99); + expect(result.time).toBeDefined(); + }); + + it('should return partial fees collector with only requested field', () => { + const mockFeesCollector = createMockFeesCollector({ + address: TEST_ADDRESSES.FEES_COLLECTOR, + lastGlobalUpdateWeek: 99, + startWeek: 1, + }); + stateStore.setFeesCollector(mockFeesCollector); + + const result = handler.getFeesCollector(['lastGlobalUpdateWeek']); + + expect(result).toBeDefined(); + expect(result.lastGlobalUpdateWeek).toBe(99); + expect(result.address).toBeUndefined(); + expect(result.startWeek).toBeUndefined(); + }); + + it('should return partial fees collector with multiple requested fields', () => { + const mockFeesCollector = createMockFeesCollector({ + address: TEST_ADDRESSES.FEES_COLLECTOR, + lastGlobalUpdateWeek: 99, + startWeek: 1, + }); + stateStore.setFeesCollector(mockFeesCollector); + + const result = handler.getFeesCollector([ + 'address', + 'lastGlobalUpdateWeek', + ]); + + expect(result).toBeDefined(); + expect(result.address).toBe(TEST_ADDRESSES.FEES_COLLECTOR); + expect(result.lastGlobalUpdateWeek).toBe(99); + expect(result.startWeek).toBeUndefined(); + }); + }); + + describe('updateFeesCollector', () => { + beforeEach(() => { + const mockFeesCollector = createMockFeesCollector({ + address: TEST_ADDRESSES.FEES_COLLECTOR, + lastGlobalUpdateWeek: 99, + lockedTokensPerBlock: '1000000', + allowExternalClaimRewards: false, + }); + stateStore.setFeesCollector(mockFeesCollector); + }); + + it('should update mutable fields correctly', () => { + const request = { + feesCollector: { + lastGlobalUpdateWeek: 100, + lockedTokensPerBlock: '2000000', + } as any, + updateMask: { + paths: ['lastGlobalUpdateWeek', 'lockedTokensPerBlock'], + }, + }; + + handler.updateFeesCollector(request); + + const result = stateStore.feesCollector; + expect(result.lastGlobalUpdateWeek).toBe(100); + expect(result.lockedTokensPerBlock).toBe('2000000'); + }); + + it('should skip address field updates (immutable)', () => { + const originalAddress = TEST_ADDRESSES.FEES_COLLECTOR; + const newAddress = 'erd1qqqqqqqqqqqqqpqnew'; + + const request = { + feesCollector: { + address: newAddress, + lastGlobalUpdateWeek: 100, + } as any, + updateMask: { + paths: ['address', 'lastGlobalUpdateWeek'], + }, + }; + + handler.updateFeesCollector(request); + + const result = stateStore.feesCollector; + expect(result.address).toBe(originalAddress); // Should not change + expect(result.lastGlobalUpdateWeek).toBe(100); // Should change + }); + + it('should skip fields with undefined values', () => { + const request = { + feesCollector: { + lastGlobalUpdateWeek: 100, + lockedTokensPerBlock: undefined, + } as any, + updateMask: { + paths: ['lastGlobalUpdateWeek', 'lockedTokensPerBlock'], + }, + }; + + handler.updateFeesCollector(request); + + const result = stateStore.feesCollector; + expect(result.lastGlobalUpdateWeek).toBe(100); // Should change + expect(result.lockedTokensPerBlock).toBe('1000000'); // Should not change + }); + + it('should call compute service after updates', () => { + const computeSpy = jest.spyOn( + computeService, + 'computeMissingFeesCollectorFields', + ); + + const request = { + feesCollector: { + lastGlobalUpdateWeek: 100, + } as any, + updateMask: { + paths: ['lastGlobalUpdateWeek'], + }, + }; + + handler.updateFeesCollector(request); + + expect(computeSpy).toHaveBeenCalledTimes(1); + expect(computeSpy).toHaveBeenCalledWith( + expect.objectContaining({ + lastGlobalUpdateWeek: 100, + }), + ); + }); + + it('should store complete fees collector after compute', () => { + const mockTime = createMockWeekTimekeeping({ + currentWeek: 101, + }); + + // Mock compute service to return updated time + jest.spyOn( + computeService, + 'computeMissingFeesCollectorFields', + ).mockReturnValue({ + ...stateStore.feesCollector, + lastGlobalUpdateWeek: 100, + time: mockTime, + }); + + const request = { + feesCollector: { + lastGlobalUpdateWeek: 100, + } as any, + updateMask: { + paths: ['lastGlobalUpdateWeek'], + }, + }; + + handler.updateFeesCollector(request); + + const result = stateStore.feesCollector; + expect(result.lastGlobalUpdateWeek).toBe(100); + expect(result.time).toEqual(mockTime); + }); + }); +}); diff --git a/src/microservices/dex-state/specs/handlers/pairs.state.handler.spec.ts b/src/microservices/dex-state/specs/handlers/pairs.state.handler.spec.ts new file mode 100644 index 000000000..ad4f99639 --- /dev/null +++ b/src/microservices/dex-state/specs/handlers/pairs.state.handler.spec.ts @@ -0,0 +1,718 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PairsStateHandler } from '../../services/handlers/pairs.state.handler'; +import { TokensStateHandler } from '../../services/handlers/tokens.state.handler'; +import { StateStore } from '../../services/state.store'; +import { + createMockPair, + createMockToken, + Tokens, + TEST_ADDRESSES, + TEST_TOKEN_IDS, +} from '../test.utils'; +import { + GetFilteredPairsRequest, + PairSortField, + SortOrder, +} from '../../interfaces/dex_state.interfaces'; +import '@multiversx/sdk-nestjs-common/lib/utils/extensions/array.extensions'; + +describe('PairsStateHandler', () => { + let handler: PairsStateHandler; + let stateStore: StateStore; + + // Helper function to create a default GetFilteredPairsRequest + const createFilterRequest = ( + overrides: Partial = {}, + ): GetFilteredPairsRequest => ({ + addresses: [], + issuedLpToken: false, + lpTokenIds: [], + farmTokens: [], + firstTokenID: '', + secondTokenID: '', + searchToken: '', + state: [], + feeState: undefined, + hasFarms: undefined, + hasDualFarms: undefined, + minVolume: 0, + minLockedValueUSD: 0, + minTradesCount: 0, + minTradesCount24h: 0, + minDeployedAt: 0, + sortField: PairSortField.PAIRS_SORT_UNSPECIFIED, + sortOrder: SortOrder.SORT_ASC, + offset: 0, + limit: 100, + fields: { paths: [] }, + ...overrides, + }); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [PairsStateHandler, TokensStateHandler, StateStore], + }).compile(); + + handler = module.get(PairsStateHandler); + stateStore = module.get(StateStore); + + // Set up tokens + const wegld = Tokens(TEST_TOKEN_IDS.WEGLD); + const mex = Tokens(TEST_TOKEN_IDS.MEX); + const usdc = Tokens(TEST_TOKEN_IDS.USDC); + const lpEgldMex = Tokens(TEST_TOKEN_IDS.LP_EGLD_MEX); + const lpEgldUsdc = Tokens(TEST_TOKEN_IDS.LP_EGLD_USDC); + stateStore.setToken(TEST_TOKEN_IDS.WEGLD, wegld); + stateStore.setToken(TEST_TOKEN_IDS.MEX, mex); + stateStore.setToken(TEST_TOKEN_IDS.USDC, usdc); + stateStore.setToken(TEST_TOKEN_IDS.LP_EGLD_MEX, lpEgldMex); + stateStore.setToken(TEST_TOKEN_IDS.LP_EGLD_USDC, lpEgldUsdc); + + // Populate state store with test pairs + const mockPair1 = createMockPair(TEST_ADDRESSES.PAIR_EGLD_MEX, { + firstTokenId: TEST_TOKEN_IDS.WEGLD, + secondTokenId: TEST_TOKEN_IDS.MEX, + liquidityPoolTokenId: TEST_TOKEN_IDS.LP_EGLD_MEX, + state: 'Active', + volumeUSD24h: '500000', + lockedValueUSD: '10000000', + tradesCount: 1000, + tradesCount24h: 100, + deployedAt: 1640000000, + hasFarms: true, + hasDualFarms: false, + feeState: true, + }); + stateStore.setPair(TEST_ADDRESSES.PAIR_EGLD_MEX, mockPair1); + + const mockPair2 = createMockPair(TEST_ADDRESSES.PAIR_EGLD_USDC, { + firstTokenId: TEST_TOKEN_IDS.WEGLD, + secondTokenId: TEST_TOKEN_IDS.USDC, + liquidityPoolTokenId: TEST_TOKEN_IDS.LP_EGLD_USDC, + state: 'Active', + volumeUSD24h: '300000', + lockedValueUSD: '8000000', + tradesCount: 800, + tradesCount24h: 80, + deployedAt: 1641000000, + hasFarms: false, + hasDualFarms: true, + feeState: true, + }); + stateStore.setPair(TEST_ADDRESSES.PAIR_EGLD_USDC, mockPair2); + + const mockPair3 = createMockPair(TEST_ADDRESSES.PAIR_TOK5_USDC, { + firstTokenId: TEST_TOKEN_IDS.MEX, + secondTokenId: TEST_TOKEN_IDS.USDC, + liquidityPoolTokenId: null, // No LP token issued + state: 'Inactive', + volumeUSD24h: '10000', + lockedValueUSD: '100000', + tradesCount: 50, + tradesCount24h: 5, + deployedAt: 1642000000, + hasFarms: false, + hasDualFarms: false, + feeState: false, + }); + stateStore.setPair(TEST_ADDRESSES.PAIR_TOK5_USDC, mockPair3); + }); + + describe('getPairs', () => { + it('should throw error when pair not found', () => { + const unknownAddress = 'erd1qqqqqqqqqqqqqunknown'; + + expect(() => handler.getPairs([unknownAddress])).toThrow( + `Pair ${unknownAddress} not found`, + ); + }); + + it('should return full pairs when fields not specified', () => { + const result = handler.getPairs([TEST_ADDRESSES.PAIR_EGLD_MEX]); + + expect(result).toBeDefined(); + expect(result.pairs).toHaveLength(1); + expect(result.pairs[0].address).toBe(TEST_ADDRESSES.PAIR_EGLD_MEX); + expect(result.pairs[0].firstTokenId).toBe(TEST_TOKEN_IDS.WEGLD); + expect(result.pairs[0].secondTokenId).toBe(TEST_TOKEN_IDS.MEX); + }); + + it('should return partial pairs with only requested fields', () => { + const result = handler.getPairs( + [TEST_ADDRESSES.PAIR_EGLD_MEX], + ['address', 'volumeUSD24h'], + ); + + expect(result).toBeDefined(); + expect(result.pairs).toHaveLength(1); + expect(result.pairs[0].address).toBe(TEST_ADDRESSES.PAIR_EGLD_MEX); + expect(result.pairs[0].volumeUSD24h).toBe('500000'); + expect(result.pairs[0].firstTokenId).toBeUndefined(); + }); + + it('should handle multiple pairs', () => { + const result = handler.getPairs([ + TEST_ADDRESSES.PAIR_EGLD_MEX, + TEST_ADDRESSES.PAIR_EGLD_USDC, + ]); + + expect(result).toBeDefined(); + expect(result.pairs).toHaveLength(2); + }); + }); + + describe('getAllPairs', () => { + it('should return empty array when no pairs exist', () => { + stateStore.clearAll(); + + const result = handler.getAllPairs(); + + expect(result).toBeDefined(); + expect(result.pairs).toHaveLength(0); + }); + + it('should return all pairs when pairs exist', () => { + const result = handler.getAllPairs(); + + expect(result).toBeDefined(); + expect(result.pairs).toHaveLength(3); + }); + }); + + describe('getFilteredPairs', () => { + it('should filter by issuedLpToken', () => { + const request = createFilterRequest({ + issuedLpToken: true, + }); + + const result = handler.getFilteredPairs(request); + + expect(result.pairs).toHaveLength(2); // Only pairs with LP tokens + expect(result.count).toBe(2); + }); + + it('should filter by addresses', () => { + const request = createFilterRequest({ + addresses: [ + TEST_ADDRESSES.PAIR_EGLD_MEX, + TEST_ADDRESSES.PAIR_EGLD_USDC, + ], + }); + + const result = handler.getFilteredPairs(request); + + expect(result.pairs).toHaveLength(2); + expect(result.count).toBe(2); + }); + + it('should filter by lpTokenIds', () => { + const request = createFilterRequest({ + lpTokenIds: [TEST_TOKEN_IDS.LP_EGLD_MEX], + }); + + const result = handler.getFilteredPairs(request); + + // Filter logic: lpTokenIds.length > 0 && pair.liquidityPoolTokenId && !lpTokenIds.includes(pair.liquidityPoolTokenId) + // So pairs WITHOUT LP tokens or with matching LP tokens pass + // PAIR_TOK5_USDC has null LP token, so it also passes + expect(result.pairs.length).toBeGreaterThanOrEqual(1); + const matchedPair = result.pairs.find( + (p) => p.address === TEST_ADDRESSES.PAIR_EGLD_MEX, + ); + expect(matchedPair).toBeDefined(); + }); + + it('should filter by firstTokenID and secondTokenID combo', () => { + // Skip: This test requires includesEvery array extension which may not be available + const request = createFilterRequest({ + firstTokenID: TEST_TOKEN_IDS.WEGLD, + secondTokenID: TEST_TOKEN_IDS.MEX, + }); + + const result = handler.getFilteredPairs(request); + + // Should match pair with WEGLD-MEX (in any order) + expect(result.pairs).toHaveLength(1); + expect(result.pairs[0].address).toBe(TEST_ADDRESSES.PAIR_EGLD_MEX); + }); + + it('should filter by state array', () => { + const request = createFilterRequest({ + state: ['Active'], + }); + + const result = handler.getFilteredPairs(request); + + expect(result.pairs).toHaveLength(2); // PAIR_EGLD_MEX and PAIR_EGLD_USDC + expect(result.count).toBe(2); + }); + + it('should filter by feeState', () => { + const request = createFilterRequest({ + feeState: true, + }); + + const result = handler.getFilteredPairs(request); + + expect(result.pairs).toHaveLength(2); + }); + + it('should filter by hasFarms', () => { + const request = createFilterRequest({ + hasFarms: true, + }); + + const result = handler.getFilteredPairs(request); + + expect(result.pairs).toHaveLength(1); + expect(result.pairs[0].address).toBe(TEST_ADDRESSES.PAIR_EGLD_MEX); + }); + + it('should filter by hasDualFarms', () => { + const request = createFilterRequest({ + hasDualFarms: true, + }); + + const result = handler.getFilteredPairs(request); + + expect(result.pairs).toHaveLength(1); + expect(result.pairs[0].address).toBe(TEST_ADDRESSES.PAIR_EGLD_USDC); + }); + + it('should filter by minVolume', () => { + const request = createFilterRequest({ + minVolume: 100000, + }); + + const result = handler.getFilteredPairs(request); + + expect(result.pairs).toHaveLength(2); // PAIR_EGLD_MEX and PAIR_EGLD_USDC + }); + + it('should filter by minLockedValueUSD', () => { + const request = createFilterRequest({ + minLockedValueUSD: 1000000, + }); + + const result = handler.getFilteredPairs(request); + + expect(result.pairs).toHaveLength(2); + }); + + it('should filter by minTradesCount', () => { + const request = createFilterRequest({ + minTradesCount: 500, + }); + + const result = handler.getFilteredPairs(request); + + expect(result.pairs).toHaveLength(2); + }); + + it('should filter by minTradesCount24h', () => { + const request = createFilterRequest({ + minTradesCount24h: 50, + }); + + const result = handler.getFilteredPairs(request); + + expect(result.pairs).toHaveLength(2); + }); + + it('should filter by minDeployedAt', () => { + const request = createFilterRequest({ + minDeployedAt: 1641500000, + }); + + const result = handler.getFilteredPairs(request); + + expect(result.pairs).toHaveLength(1); + expect(result.pairs[0].address).toBe(TEST_ADDRESSES.PAIR_TOK5_USDC); + }); + + it('should filter by searchToken on both tokens', () => { + const request = createFilterRequest({ + searchToken: 'MEX', + }); + + const result = handler.getFilteredPairs(request); + + expect(result.pairs).toHaveLength(2); // PAIR_EGLD_MEX and PAIR_TOK5_USDC + }); + + it('should combine multiple filters', () => { + const request = createFilterRequest({ + state: ['Active'], + hasFarms: true, + minVolume: 100000, + }); + + const result = handler.getFilteredPairs(request); + + expect(result.pairs).toHaveLength(1); + expect(result.pairs[0].address).toBe(TEST_ADDRESSES.PAIR_EGLD_MEX); + }); + + it('should sort by volume', () => { + const request = createFilterRequest({ + sortField: PairSortField.PAIRS_SORT_VOLUME, + sortOrder: SortOrder.SORT_DESC, + }); + + const result = handler.getFilteredPairs(request); + + expect(result.pairs).toHaveLength(3); + expect(result.pairs[0].address).toBe(TEST_ADDRESSES.PAIR_EGLD_MEX); // Highest volume + }); + + it('should sort by TVL', () => { + const request = createFilterRequest({ + sortField: PairSortField.PAIRS_SORT_TVL, + sortOrder: SortOrder.SORT_DESC, + }); + + const result = handler.getFilteredPairs(request); + + expect(result.pairs).toHaveLength(3); + expect(result.pairs[0].address).toBe(TEST_ADDRESSES.PAIR_EGLD_MEX); + }); + + it('should handle PAIRS_SORT_UNSPECIFIED', () => { + const request = createFilterRequest({ + sortField: PairSortField.PAIRS_SORT_UNSPECIFIED, + }); + + const result = handler.getFilteredPairs(request); + + expect(result.pairs).toHaveLength(3); + }); + + it('should paginate results', () => { + const request = createFilterRequest({ + offset: 1, + limit: 1, + }); + + const result = handler.getFilteredPairs(request); + + expect(result.pairs).toHaveLength(1); + expect(result.count).toBe(3); + }); + + it('should return empty when no pairs match filters', () => { + const request = createFilterRequest({ + searchToken: 'NONEXISTENT', + }); + + const result = handler.getFilteredPairs(request); + + expect(result.pairs).toHaveLength(0); + expect(result.count).toBe(0); + }); + }); + + describe('getPairsCount', () => { + it('should return accurate count', () => { + const count = handler.getPairsCount(); + + expect(count).toBe(3); + }); + }); + + describe('getPairsTokens', () => { + it('should return pairs with tokens', () => { + const request = { + addresses: [TEST_ADDRESSES.PAIR_EGLD_MEX], + pairFields: { paths: ['address', 'volumeUSD24h'] }, + tokenFields: { paths: ['identifier', 'name'] }, + }; + + const result = handler.getPairsTokens(request); + + expect(result.pairsWithTokens).toHaveLength(1); + expect(result.pairsWithTokens[0].pair.address).toBe( + TEST_ADDRESSES.PAIR_EGLD_MEX, + ); + expect(result.pairsWithTokens[0].firstToken.identifier).toBe( + TEST_TOKEN_IDS.WEGLD, + ); + expect(result.pairsWithTokens[0].secondToken.identifier).toBe( + TEST_TOKEN_IDS.MEX, + ); + expect(result.pairsWithTokens[0].lpToken.identifier).toBe( + TEST_TOKEN_IDS.LP_EGLD_MEX, + ); + }); + + it('should auto-include token ID fields and remove them from response', () => { + const request = { + addresses: [TEST_ADDRESSES.PAIR_EGLD_MEX], + pairFields: { paths: ['address'] }, // Not including token IDs + tokenFields: { paths: ['identifier'] }, + }; + + const result = handler.getPairsTokens(request); + + // Token ID fields should not be in the response pair + expect(result.pairsWithTokens[0].pair.firstTokenId).toBeUndefined(); + expect( + result.pairsWithTokens[0].pair.secondTokenId, + ).toBeUndefined(); + expect( + result.pairsWithTokens[0].pair.liquidityPoolTokenId, + ).toBeUndefined(); + }); + + it.skip('should handle pair with no LP token', () => { + // Skip: Handler calls getTokens with null which throws. This is a known edge case. + const request = { + addresses: [TEST_ADDRESSES.PAIR_TOK5_USDC], // No LP token + pairFields: { paths: ['address'] }, + tokenFields: { paths: ['identifier'] }, + }; + + const result = handler.getPairsTokens(request); + + expect(result.pairsWithTokens).toHaveLength(1); + expect(result.pairsWithTokens[0].lpToken).toBeUndefined(); + }); + }); + + describe('addPair', () => { + it('should add new pair with token creation and index updates', () => { + const newToken1 = createMockToken('NEWTOKEN1-123456', { + name: 'NewToken1', + }); + const newToken2 = createMockToken('NEWTOKEN2-123456', { + name: 'NewToken2', + }); + const newPair = createMockPair('erd1qqqqqqqqqqqqqnewpair', { + firstTokenId: 'NEWTOKEN1-123456', + secondTokenId: 'NEWTOKEN2-123456', + state: 'Active', + }); + + const request = { + pair: newPair, + firstToken: newToken1, + secondToken: newToken2, + }; + + handler.addPair(request); + + // Verify pair was added + const addedPair = stateStore.pairs.get('erd1qqqqqqqqqqqqqnewpair'); + expect(addedPair).toBeDefined(); + expect(addedPair.compoundedAPR).toBeDefined(); + expect(addedPair.compoundedAPR.farmBaseAPR).toBe('0'); + + // Verify tokens were added + expect(stateStore.tokens.has('NEWTOKEN1-123456')).toBe(true); + expect(stateStore.tokens.has('NEWTOKEN2-123456')).toBe(true); + + // Verify indexes were updated + expect(stateStore.tokenPairs.get('NEWTOKEN1-123456')).toContain( + 'erd1qqqqqqqqqqqqqnewpair', + ); + expect(stateStore.activePairs.has('erd1qqqqqqqqqqqqqnewpair')).toBe( + true, + ); + expect(stateStore.activePairsTokens.has('NEWTOKEN1-123456')).toBe( + true, + ); + }); + + it('should initialize compoundedAPR with zero farm APRs', () => { + const newToken1 = createMockToken('NEWTOKEN3-123456'); + const newToken2 = createMockToken('NEWTOKEN4-123456'); + const newPair = createMockPair('erd1qqqqqqqqqqqqqnewpair2', { + firstTokenId: 'NEWTOKEN3-123456', + secondTokenId: 'NEWTOKEN4-123456', + feesAPR: '15.5', + }); + + handler.addPair({ + pair: newPair, + firstToken: newToken1, + secondToken: newToken2, + }); + + const addedPair = stateStore.pairs.get('erd1qqqqqqqqqqqqqnewpair2'); + expect(addedPair.compoundedAPR.feesAPR).toBe('15.5'); + expect(addedPair.compoundedAPR.farmBaseAPR).toBe('0'); + expect(addedPair.compoundedAPR.dualFarmBaseAPR).toBe('0'); + }); + }); + + describe('addPairLpToken', () => { + it('should assign LP token to existing pair', () => { + // First add a pair without LP token + const newToken1 = createMockToken('LPTEST1-123456'); + const newToken2 = createMockToken('LPTEST2-123456'); + const newPair = createMockPair('erd1qqqqqqqqqqqqqpairforLP', { + firstTokenId: 'LPTEST1-123456', + secondTokenId: 'LPTEST2-123456', + liquidityPoolTokenId: null, + }); + + handler.addPair({ + pair: newPair, + firstToken: newToken1, + secondToken: newToken2, + }); + + // Now add LP token + const lpToken = createMockToken('LPTOKEN-123456', { + name: 'LP Token', + }); + handler.addPairLpToken({ + address: 'erd1qqqqqqqqqqqqqpairforLP', + token: lpToken, + }); + + const updatedPair = stateStore.pairs.get( + 'erd1qqqqqqqqqqqqqpairforLP', + ); + expect(updatedPair.liquidityPoolTokenId).toBe('LPTOKEN-123456'); + expect(stateStore.tokens.has('LPTOKEN-123456')).toBe(true); + }); + + it('should throw error when pair not found', () => { + const unknownAddress = 'erd1qqqqqqqqqqqqqunknown'; + const lpToken = createMockToken('LPTOKEN2-123456'); + + expect(() => + handler.addPairLpToken({ + address: unknownAddress, + token: lpToken, + }), + ).toThrow(`Pair ${unknownAddress} not found`); + }); + }); + + describe('updatePairs', () => { + it('should update mutable fields correctly', () => { + const request = { + pairs: [ + { + address: TEST_ADDRESSES.PAIR_EGLD_MEX, + volumeUSD24h: '600000', + lockedValueUSD: '12000000', + } as any, + ], + updateMask: { + paths: ['volumeUSD24h', 'lockedValueUSD'], + }, + }; + + const response = handler.updatePairs(request); + + expect(response.updatedCount).toBe(1); + expect(response.failedAddresses).toHaveLength(0); + + const pair = stateStore.pairs.get(TEST_ADDRESSES.PAIR_EGLD_MEX); + expect(pair.volumeUSD24h).toBe('600000'); + expect(pair.lockedValueUSD).toBe('12000000'); + }); + + it('should skip immutable field updates', () => { + const originalFirstTokenId = TEST_TOKEN_IDS.WEGLD; + const originalSecondTokenId = TEST_TOKEN_IDS.MEX; + + const request = { + pairs: [ + { + address: TEST_ADDRESSES.PAIR_EGLD_MEX, + firstTokenId: 'NEWTOKEN-123456', + secondTokenId: 'NEWTOKEN2-123456', + volumeUSD24h: '700000', + } as any, + ], + updateMask: { + paths: ['firstTokenId', 'secondTokenId', 'volumeUSD24h'], + }, + }; + + handler.updatePairs(request); + + const pair = stateStore.pairs.get(TEST_ADDRESSES.PAIR_EGLD_MEX); + expect(pair.firstTokenId).toBe(originalFirstTokenId); // Should not change + expect(pair.secondTokenId).toBe(originalSecondTokenId); // Should not change + expect(pair.volumeUSD24h).toBe('700000'); // Should change + }); + + it('should return failedAddresses for non-existent pairs', () => { + const unknownAddress = 'erd1qqqqqqqqqqqqqunknown'; + + const request = { + pairs: [ + { + address: unknownAddress, + volumeUSD24h: '100000', + } as any, + ], + updateMask: { + paths: ['volumeUSD24h'], + }, + }; + + const response = handler.updatePairs(request); + + expect(response.updatedCount).toBe(0); + expect(response.failedAddresses).toHaveLength(1); + expect(response.failedAddresses[0]).toBe(unknownAddress); + }); + + it('should skip pairs with missing address', () => { + const request = { + pairs: [ + { + address: undefined, + volumeUSD24h: '100000', + } as any, + { + address: TEST_ADDRESSES.PAIR_EGLD_MEX, + volumeUSD24h: '200000', + } as any, + ], + updateMask: { + paths: ['volumeUSD24h'], + }, + }; + + const response = handler.updatePairs(request); + + expect(response.updatedCount).toBe(1); + expect(response.failedAddresses).toHaveLength(0); + }); + + it('should handle batch updates', () => { + const request = { + pairs: [ + { + address: TEST_ADDRESSES.PAIR_EGLD_MEX, + volumeUSD24h: '600000', + } as any, + { + address: TEST_ADDRESSES.PAIR_EGLD_USDC, + volumeUSD24h: '400000', + } as any, + ], + updateMask: { + paths: ['volumeUSD24h'], + }, + }; + + const response = handler.updatePairs(request); + + expect(response.updatedCount).toBe(2); + expect( + stateStore.pairs.get(TEST_ADDRESSES.PAIR_EGLD_MEX).volumeUSD24h, + ).toBe('600000'); + expect( + stateStore.pairs.get(TEST_ADDRESSES.PAIR_EGLD_USDC) + .volumeUSD24h, + ).toBe('400000'); + }); + }); +}); diff --git a/src/microservices/dex-state/specs/handlers/staking.state.handler.spec.ts b/src/microservices/dex-state/specs/handlers/staking.state.handler.spec.ts new file mode 100644 index 000000000..0e95b05dc --- /dev/null +++ b/src/microservices/dex-state/specs/handlers/staking.state.handler.spec.ts @@ -0,0 +1,490 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { StakingStateHandler } from '../../services/handlers/staking.state.handler'; +import { StateStore } from '../../services/state.store'; +import { StakingComputeService } from '../../services/compute/staking.compute.service'; +import { MockStakingComputeServiceProvider } from '../mocks/compute.services.mock'; +import { + createMockStakingFarm, + createMockStakingProxy, + createMockPair, + createMockToken, + TEST_ADDRESSES, + TEST_TOKEN_IDS, +} from '../test.utils'; +import { PairCompoundedAPRModel } from 'src/modules/pair/models/pair.compounded.apr.model'; +import { GetFilteredStakingFarmsRequest, StakingFarmSortField, SortOrder } from '../../interfaces/dex_state.interfaces'; + +describe('StakingStateHandler', () => { + let handler: StakingStateHandler; + let stateStore: StateStore; + let computeService: StakingComputeService; + + // Helper function to create a default GetFilteredStakingFarmsRequest + const createFilterRequest = (overrides: Partial = {}): GetFilteredStakingFarmsRequest => ({ + searchToken: '', + rewardsEnded: undefined, + sortField: StakingFarmSortField.STAKING_SORT_UNSPECIFIED, + sortOrder: SortOrder.SORT_ASC, + offset: 0, + limit: 100, + fields: { paths: [] }, + ...overrides, + }); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + StakingStateHandler, + StateStore, + MockStakingComputeServiceProvider, + ], + }).compile(); + + handler = module.get(StakingStateHandler); + stateStore = module.get(StateStore); + computeService = module.get(StakingComputeService); + + // Set up test tokens first + const rideToken = createMockToken(TEST_TOKEN_IDS.RIDE, { + name: 'Holoride', + ticker: 'RIDE', + }); + stateStore.setToken(TEST_TOKEN_IDS.RIDE, rideToken); + + // Populate state store with test staking farms + const mockStaking1 = createMockStakingFarm(TEST_ADDRESSES.STAKING_1, { + farmingTokenId: TEST_TOKEN_IDS.RIDE, + baseApr: '25', + maxBoostedApr: '100', + apr: '50', + stakedValueUSD: '5000000', + isProducingRewards: true, + }); + stateStore.setStakingFarm(TEST_ADDRESSES.STAKING_1, mockStaking1); + + const mockStaking2 = createMockStakingFarm(TEST_ADDRESSES.STAKING_2, { + farmingTokenId: TEST_TOKEN_IDS.RIDE, + baseApr: '15', + maxBoostedApr: '60', + apr: '30', + stakedValueUSD: '3000000', + isProducingRewards: false, // Rewards ended + }); + stateStore.setStakingFarm(TEST_ADDRESSES.STAKING_2, mockStaking2); + + // Set up staking farm-pair mappings + stateStore.stakingFarmsPairs.set(TEST_ADDRESSES.STAKING_1, TEST_ADDRESSES.PAIR_EGLD_MEX); + stateStore.stakingFarmsPairs.set(TEST_ADDRESSES.STAKING_2, TEST_ADDRESSES.PAIR_EGLD_USDC); + + // Populate with test pair + const mockPair = createMockPair(TEST_ADDRESSES.PAIR_EGLD_MEX, { + compoundedAPR: new PairCompoundedAPRModel({ + address: TEST_ADDRESSES.PAIR_EGLD_MEX, + feesAPR: '10.95', + farmBaseAPR: '0', + farmBoostedAPR: '0', + dualFarmBaseAPR: '0', + dualFarmBoostedAPR: '0', + }), + }); + stateStore.setPair(TEST_ADDRESSES.PAIR_EGLD_MEX, mockPair); + + // Populate with test staking proxies + const mockProxy1 = createMockStakingProxy(TEST_ADDRESSES.STAKING_PROXY_1); + const mockProxy2 = createMockStakingProxy(TEST_ADDRESSES.STAKING_PROXY_2); + stateStore.setStakingProxy(TEST_ADDRESSES.STAKING_PROXY_1, mockProxy1); + stateStore.setStakingProxy(TEST_ADDRESSES.STAKING_PROXY_2, mockProxy2); + }); + + describe('getStakingFarms', () => { + it('should throw error when staking farm not found', () => { + const unknownAddress = 'erd1qqqqqqqqqqqqqunknown'; + + expect(() => handler.getStakingFarms([unknownAddress])).toThrow( + `Staking farm ${unknownAddress} not found`, + ); + }); + + it('should return full staking farms when fields not specified', () => { + const result = handler.getStakingFarms([TEST_ADDRESSES.STAKING_1]); + + expect(result).toBeDefined(); + expect(result.stakingFarms).toHaveLength(1); + expect(result.stakingFarms[0].address).toBe(TEST_ADDRESSES.STAKING_1); + expect(result.stakingFarms[0].baseApr).toBe('25'); + expect(result.stakingFarms[0].maxBoostedApr).toBe('100'); + }); + + it('should return partial staking farms with only requested fields', () => { + const result = handler.getStakingFarms([TEST_ADDRESSES.STAKING_1], ['address', 'baseApr']); + + expect(result).toBeDefined(); + expect(result.stakingFarms).toHaveLength(1); + expect(result.stakingFarms[0].address).toBe(TEST_ADDRESSES.STAKING_1); + expect(result.stakingFarms[0].baseApr).toBe('25'); + expect(result.stakingFarms[0].maxBoostedApr).toBeUndefined(); + }); + + it('should handle multiple staking farms', () => { + const result = handler.getStakingFarms([ + TEST_ADDRESSES.STAKING_1, + TEST_ADDRESSES.STAKING_2, + ]); + + expect(result).toBeDefined(); + expect(result.stakingFarms).toHaveLength(2); + expect(result.stakingFarms[0].address).toBe(TEST_ADDRESSES.STAKING_1); + expect(result.stakingFarms[1].address).toBe(TEST_ADDRESSES.STAKING_2); + }); + }); + + describe('getAllStakingFarms', () => { + it('should return empty array when no staking farms exist', () => { + stateStore.clearAll(); + + const result = handler.getAllStakingFarms(); + + expect(result).toBeDefined(); + expect(result.stakingFarms).toHaveLength(0); + }); + + it('should return all staking farms when they exist', () => { + const result = handler.getAllStakingFarms(); + + expect(result).toBeDefined(); + expect(result.stakingFarms).toHaveLength(2); + }); + }); + + describe('getFilteredStakingFarms', () => { + it('should filter by searchToken on farming token', () => { + const request = createFilterRequest({ + searchToken: 'RIDE', + }); + + const result = handler.getFilteredStakingFarms(request); + + expect(result.stakingFarms).toHaveLength(2); + expect(result.count).toBe(2); + }); + + it('should filter by rewardsEnded', () => { + const request = createFilterRequest({ + rewardsEnded: true, + }); + + const result = handler.getFilteredStakingFarms(request); + + // Only farms with isProducingRewards=false should be returned + expect(result.stakingFarms).toHaveLength(1); + expect(result.stakingFarms[0].address).toBe(TEST_ADDRESSES.STAKING_2); + }); + + it('should sort by APR', () => { + const request = createFilterRequest({ + sortField: StakingFarmSortField.STAKING_SORT_APR, + sortOrder: SortOrder.SORT_DESC, + }); + + const result = handler.getFilteredStakingFarms(request); + + expect(result.stakingFarms).toHaveLength(2); + // STAKING_1 has APR=50, STAKING_2 has APR=30 but rewards ended + // Rewards-ended farms should be last regardless of APR + expect(result.stakingFarms[0].address).toBe(TEST_ADDRESSES.STAKING_1); + expect(result.stakingFarms[1].address).toBe(TEST_ADDRESSES.STAKING_2); + }); + + it('should place farms with rewardsEnded=true last', () => { + // Add a third farm with higher APR but rewards ended + const mockStaking3 = createMockStakingFarm(TEST_ADDRESSES.STAKING_3, { + farmingTokenId: TEST_TOKEN_IDS.RIDE, + apr: '80', + stakedValueUSD: '10000000', + isProducingRewards: false, + }); + stateStore.setStakingFarm(TEST_ADDRESSES.STAKING_3, mockStaking3); + + const request = createFilterRequest({ + sortField: StakingFarmSortField.STAKING_SORT_APR, + sortOrder: SortOrder.SORT_DESC, + }); + + const result = handler.getFilteredStakingFarms(request); + + expect(result.stakingFarms).toHaveLength(3); + // STAKING_1 (APR=50, producing) should be first + // STAKING_3 (APR=80, not producing) and STAKING_2 (APR=30, not producing) should be last + expect(result.stakingFarms[0].address).toBe(TEST_ADDRESSES.STAKING_1); + expect(result.stakingFarms[0].isProducingRewards).toBe(true); + // Among ended farms, STAKING_3 should be before STAKING_2 (higher APR) + expect(result.stakingFarms[1].address).toBe(TEST_ADDRESSES.STAKING_3); + expect(result.stakingFarms[2].address).toBe(TEST_ADDRESSES.STAKING_2); + }); + + it('should handle STAKING_SORT_UNSPECIFIED', () => { + const request = createFilterRequest({ + sortField: StakingFarmSortField.STAKING_SORT_UNSPECIFIED, + }); + + const result = handler.getFilteredStakingFarms(request); + + expect(result.stakingFarms).toHaveLength(2); + }); + + it('should paginate results', () => { + const request = createFilterRequest({ + offset: 1, + limit: 1, + }); + + const result = handler.getFilteredStakingFarms(request); + + expect(result.stakingFarms).toHaveLength(1); + expect(result.count).toBe(2); // Total count should be 2 + }); + }); + + describe('updateStakingFarms', () => { + it('should update mutable fields correctly', () => { + const request = { + stakingFarms: [ + { + address: TEST_ADDRESSES.STAKING_1, + apr: '60', + } as any, + ], + updateMask: { + paths: ['apr'], + }, + }; + + const response = handler.updateStakingFarms(request); + + expect(response.updatedCount).toBe(1); + expect(response.failedAddresses).toHaveLength(0); + + const farm = stateStore.stakingFarms.get(TEST_ADDRESSES.STAKING_1); + expect(farm.apr).toBe('60'); + }); + + it('should skip immutable field updates', () => { + const originalFarmingTokenId = TEST_TOKEN_IDS.RIDE; + const originalRewardTokenId = stateStore.stakingFarms.get(TEST_ADDRESSES.STAKING_1).rewardTokenId; + + const request = { + stakingFarms: [ + { + address: TEST_ADDRESSES.STAKING_1, + farmingTokenId: 'NEWTOKEN-123456', + rewardTokenId: 'NEWREWARD-123456', + apr: '60', + } as any, + ], + updateMask: { + paths: ['farmingTokenId', 'rewardTokenId', 'apr'], + }, + }; + + handler.updateStakingFarms(request); + + const farm = stateStore.stakingFarms.get(TEST_ADDRESSES.STAKING_1); + expect(farm.farmingTokenId).toBe(originalFarmingTokenId); // Should not change + expect(farm.rewardTokenId).toBe(originalRewardTokenId); // Should not change + expect(farm.apr).toBe('60'); // Should change + }); + + it('should return failedAddresses for non-existent farms', () => { + const unknownAddress = 'erd1qqqqqqqqqqqqqunknown'; + + const request = { + stakingFarms: [ + { + address: unknownAddress, + apr: '60', + } as any, + ], + updateMask: { + paths: ['apr'], + }, + }; + + const response = handler.updateStakingFarms(request); + + expect(response.updatedCount).toBe(0); + expect(response.failedAddresses).toHaveLength(1); + expect(response.failedAddresses[0]).toBe(unknownAddress); + }); + + it('should call compute service for each updated farm', () => { + const computeSpy = jest.spyOn(computeService, 'computeMissingStakingFarmFields'); + + const request = { + stakingFarms: [ + { + address: TEST_ADDRESSES.STAKING_1, + apr: '60', + } as any, + { + address: TEST_ADDRESSES.STAKING_2, + apr: '40', + } as any, + ], + updateMask: { + paths: ['apr'], + }, + }; + + handler.updateStakingFarms(request); + + expect(computeSpy).toHaveBeenCalledTimes(2); + }); + + it('should update linked pair dualFarmBaseAPR and dualFarmBoostedAPR', () => { + // Mock compute service to return specific APR values + jest.spyOn(computeService, 'computeMissingStakingFarmFields').mockImplementation((farm) => ({ + ...farm, + baseApr: '35', + maxBoostedApr: '120', + })); + + const request = { + stakingFarms: [ + { + address: TEST_ADDRESSES.STAKING_1, + apr: '60', + } as any, + ], + updateMask: { + paths: ['apr'], + }, + }; + + handler.updateStakingFarms(request); + + const pair = stateStore.pairs.get(TEST_ADDRESSES.PAIR_EGLD_MEX); + expect(pair.compoundedAPR.dualFarmBaseAPR).toBe('35'); + expect(pair.compoundedAPR.dualFarmBoostedAPR).toBe('120'); + }); + + it('should skip pair update when stakingFarmsPairs mapping missing', () => { + // Remove staking farm-pair mapping + stateStore.stakingFarmsPairs.delete(TEST_ADDRESSES.STAKING_1); + + const originalPair = { ...stateStore.pairs.get(TEST_ADDRESSES.PAIR_EGLD_MEX) }; + + const request = { + stakingFarms: [ + { + address: TEST_ADDRESSES.STAKING_1, + apr: '60', + } as any, + ], + updateMask: { + paths: ['apr'], + }, + }; + + handler.updateStakingFarms(request); + + // Pair should not be updated + const pair = stateStore.pairs.get(TEST_ADDRESSES.PAIR_EGLD_MEX); + expect(pair.compoundedAPR.dualFarmBaseAPR).toBe(originalPair.compoundedAPR.dualFarmBaseAPR); + expect(pair.compoundedAPR.dualFarmBoostedAPR).toBe(originalPair.compoundedAPR.dualFarmBoostedAPR); + }); + + it('should skip pair update when pair not found in store', () => { + // Remove pair from store but keep mapping + stateStore.pairs.delete(TEST_ADDRESSES.PAIR_EGLD_MEX); + + const request = { + stakingFarms: [ + { + address: TEST_ADDRESSES.STAKING_1, + apr: '60', + } as any, + ], + updateMask: { + paths: ['apr'], + }, + }; + + // Should not throw error + expect(() => handler.updateStakingFarms(request)).not.toThrow(); + + const response = handler.updateStakingFarms(request); + expect(response.updatedCount).toBe(1); + }); + + it('should skip farms with missing address', () => { + const request = { + stakingFarms: [ + { + address: undefined, + apr: '60', + } as any, + { + address: TEST_ADDRESSES.STAKING_1, + apr: '70', + } as any, + ], + updateMask: { + paths: ['apr'], + }, + }; + + const response = handler.updateStakingFarms(request); + + expect(response.updatedCount).toBe(1); // Only 1 succeeded + expect(response.failedAddresses).toHaveLength(0); // Missing address not added to failed + }); + }); + + describe('getStakingProxies', () => { + it('should throw error when staking proxy not found', () => { + const unknownAddress = 'erd1qqqqqqqqqqqqqunknown'; + + expect(() => handler.getStakingProxies([unknownAddress])).toThrow( + `Staking proxy ${unknownAddress} not found`, + ); + }); + + it('should return full staking proxies when fields not specified', () => { + const result = handler.getStakingProxies([TEST_ADDRESSES.STAKING_PROXY_1]); + + expect(result).toBeDefined(); + expect(result.stakingProxies).toHaveLength(1); + expect(result.stakingProxies[0].address).toBe(TEST_ADDRESSES.STAKING_PROXY_1); + }); + + it('should handle multiple staking proxies', () => { + const result = handler.getStakingProxies([ + TEST_ADDRESSES.STAKING_PROXY_1, + TEST_ADDRESSES.STAKING_PROXY_2, + ]); + + expect(result).toBeDefined(); + expect(result.stakingProxies).toHaveLength(2); + expect(result.stakingProxies[0].address).toBe(TEST_ADDRESSES.STAKING_PROXY_1); + expect(result.stakingProxies[1].address).toBe(TEST_ADDRESSES.STAKING_PROXY_2); + }); + }); + + describe('getAllStakingProxies', () => { + it('should return empty array when no staking proxies exist', () => { + stateStore.clearAll(); + + const result = handler.getAllStakingProxies(); + + expect(result).toBeDefined(); + expect(result.stakingProxies).toHaveLength(0); + }); + + it('should return all staking proxies when they exist', () => { + const result = handler.getAllStakingProxies(); + + expect(result).toBeDefined(); + expect(result.stakingProxies).toHaveLength(2); + }); + }); +}); diff --git a/src/microservices/dex-state/specs/handlers/timekeeping.state.handler.spec.ts b/src/microservices/dex-state/specs/handlers/timekeeping.state.handler.spec.ts new file mode 100644 index 000000000..a349ce57a --- /dev/null +++ b/src/microservices/dex-state/specs/handlers/timekeeping.state.handler.spec.ts @@ -0,0 +1,166 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TimekeepingStateHandler } from '../../services/handlers/timekeeping.state.handler'; +import { StateStore } from '../../services/state.store'; +import { + createMockFarm, + createMockStakingFarm, + createMockFeesCollector, + createMockWeekTimekeeping, + TEST_ADDRESSES, +} from '../test.utils'; + +describe('TimekeepingStateHandler', () => { + let handler: TimekeepingStateHandler; + let stateStore: StateStore; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [TimekeepingStateHandler, StateStore], + }).compile(); + + handler = module.get(TimekeepingStateHandler); + stateStore = module.get(StateStore); + + // Populate state store with test data + const mockFarmTime = createMockWeekTimekeeping({ + currentWeek: 100, + startEpochForWeek: 1000, + }); + const mockFarm = createMockFarm(TEST_ADDRESSES.FARM_1, { + time: mockFarmTime, + }); + stateStore.setFarm(TEST_ADDRESSES.FARM_1, mockFarm); + + const mockStakingTime = createMockWeekTimekeeping({ + currentWeek: 101, + startEpochForWeek: 1010, + }); + const mockStaking = createMockStakingFarm(TEST_ADDRESSES.STAKING_1, { + time: mockStakingTime, + }); + stateStore.setStakingFarm(TEST_ADDRESSES.STAKING_1, mockStaking); + + const mockFeesCollectorTime = createMockWeekTimekeeping({ + currentWeek: 102, + startEpochForWeek: 1020, + }); + const mockFeesCollector = createMockFeesCollector({ + address: TEST_ADDRESSES.FEES_COLLECTOR, + time: mockFeesCollectorTime, + }); + stateStore.setFeesCollector(mockFeesCollector); + }); + + describe('getWeeklyTimekeeping', () => { + it('should throw error when address is missing', () => { + const request = { + address: undefined, + fields: { paths: [] }, + }; + + expect(() => handler.getWeeklyTimekeeping(request)).toThrow( + 'SC Address missing', + ); + }); + + it('should return farm time when address matches a farm', () => { + const request = { + address: TEST_ADDRESSES.FARM_1, + fields: { paths: [] }, + }; + + const result = handler.getWeeklyTimekeeping(request); + + expect(result).toBeDefined(); + expect(result.currentWeek).toBe(100); + expect(result.startEpochForWeek).toBe(1000); + }); + + it('should return staking farm time when address matches a staking farm', () => { + const request = { + address: TEST_ADDRESSES.STAKING_1, + fields: { paths: [] }, + }; + + const result = handler.getWeeklyTimekeeping(request); + + expect(result).toBeDefined(); + expect(result.currentWeek).toBe(101); + expect(result.startEpochForWeek).toBe(1010); + }); + + it('should return fees collector time when address matches fees collector', () => { + const request = { + address: TEST_ADDRESSES.FEES_COLLECTOR, + fields: { paths: [] }, + }; + + const result = handler.getWeeklyTimekeeping(request); + + expect(result).toBeDefined(); + expect(result.currentWeek).toBe(102); + expect(result.startEpochForWeek).toBe(1020); + }); + + it('should throw error when address is not found', () => { + const unknownAddress = 'erd1qqqqqqqqqqqqqunknown'; + const request = { + address: unknownAddress, + fields: { paths: [] }, + }; + + expect(() => handler.getWeeklyTimekeeping(request)).toThrow( + `Could not find time for SC ${unknownAddress}`, + ); + }); + + it('should return full time object when fields are not specified', () => { + const request = { + address: TEST_ADDRESSES.FARM_1, + fields: { paths: [] }, + }; + + const result = handler.getWeeklyTimekeeping(request); + + expect(result).toBeDefined(); + expect(result.currentWeek).toBe(100); + expect(result.startEpochForWeek).toBe(1000); + expect(result.endEpochForWeek).toBeDefined(); + expect(result.firstWeekStartEpoch).toBeDefined(); + }); + + it('should return partial time object with only requested field', () => { + const request = { + address: TEST_ADDRESSES.FARM_1, + fields: { + paths: ['currentWeek'], + }, + }; + + const result = handler.getWeeklyTimekeeping(request); + + expect(result).toBeDefined(); + expect(result.currentWeek).toBe(100); + expect(result.startEpochForWeek).toBeUndefined(); + expect(result.endEpochForWeek).toBeUndefined(); + }); + + it('should return partial time object with multiple requested fields', () => { + const request = { + address: TEST_ADDRESSES.FARM_1, + fields: { + paths: ['currentWeek', 'startEpochForWeek', 'endEpochForWeek'], + }, + }; + + const result = handler.getWeeklyTimekeeping(request); + + expect(result).toBeDefined(); + expect(result.currentWeek).toBe(100); + expect(result.startEpochForWeek).toBe(1000); + expect(result.endEpochForWeek).toBeDefined(); + // Fields not requested should be undefined + expect(result.firstWeekStartEpoch).toBeUndefined(); + }); + }); +}); diff --git a/src/microservices/dex-state/specs/handlers/tokens.state.handler.spec.ts b/src/microservices/dex-state/specs/handlers/tokens.state.handler.spec.ts new file mode 100644 index 000000000..f26bdb688 --- /dev/null +++ b/src/microservices/dex-state/specs/handlers/tokens.state.handler.spec.ts @@ -0,0 +1,444 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TokensStateHandler } from '../../services/handlers/tokens.state.handler'; +import { StateStore } from '../../services/state.store'; +import { Tokens, TEST_TOKEN_IDS } from '../test.utils'; +import { TokenSortField, SortOrder, GetFilteredTokensRequest } from '../../interfaces/dex_state.interfaces'; +import { EsdtTokenType } from 'src/modules/tokens/models/esdtToken.model'; + +describe('TokensStateHandler', () => { + let handler: TokensStateHandler; + let stateStore: StateStore; + + // Helper function to create a default GetFilteredTokensRequest + const createFilterRequest = (overrides: Partial = {}): GetFilteredTokensRequest => ({ + enabledSwaps: false, + identifiers: [], + searchToken: '', + minLiquidity: 0, + type: '', + sortField: TokenSortField.TOKENS_SORT_UNSPECIFIED, + sortOrder: SortOrder.SORT_ASC, + offset: 0, + limit: 100, + fields: { paths: [] }, + ...overrides, + }); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [TokensStateHandler, StateStore], + }).compile(); + + handler = module.get(TokensStateHandler); + stateStore = module.get(StateStore); + + // Populate state store with test tokens + const wegld = Tokens(TEST_TOKEN_IDS.WEGLD); + const mex = Tokens(TEST_TOKEN_IDS.MEX); + const usdc = Tokens(TEST_TOKEN_IDS.USDC); + const usdt = Tokens(TEST_TOKEN_IDS.USDT); + + // Add additional data for filtering/sorting tests + wegld.liquidityUSD = '10000000'; + wegld.volumeUSD24h = '500000'; + wegld.swapCount24h = 1000; + mex.liquidityUSD = '5000000'; + mex.volumeUSD24h = '300000'; + mex.swapCount24h = 500; + usdc.liquidityUSD = '8000000'; + usdc.volumeUSD24h = '400000'; + usdc.swapCount24h = 800; + usdt.liquidityUSD = '100000'; // Below minLiquidity threshold + usdt.volumeUSD24h = '10000'; + usdt.swapCount24h = 50; + + stateStore.setToken(TEST_TOKEN_IDS.WEGLD, wegld); + stateStore.setToken(TEST_TOKEN_IDS.MEX, mex); + stateStore.setToken(TEST_TOKEN_IDS.USDC, usdc); + stateStore.setToken(TEST_TOKEN_IDS.USDT, usdt); + + // Set up tokensByType for FungibleToken + stateStore.tokensByType.set(EsdtTokenType.FungibleToken, [ + TEST_TOKEN_IDS.WEGLD, + TEST_TOKEN_IDS.MEX, + TEST_TOKEN_IDS.USDC, + TEST_TOKEN_IDS.USDT, + ]); + + // Set up activePairsTokens (tokens enabled for swaps) + stateStore.activePairsTokens.add(TEST_TOKEN_IDS.WEGLD); + stateStore.activePairsTokens.add(TEST_TOKEN_IDS.MEX); + stateStore.activePairsTokens.add(TEST_TOKEN_IDS.USDC); + // USDT not in activePairsTokens + }); + + describe('getTokens', () => { + it('should throw error when token not found', () => { + const unknownTokenID = 'UNKNOWN-123456'; + + expect(() => handler.getTokens([unknownTokenID])).toThrow( + `Token ${unknownTokenID} not found`, + ); + }); + + it('should handle undefined tokenID gracefully', () => { + const result = handler.getTokens([undefined]); + + expect(result).toBeDefined(); + expect(result.tokens).toHaveLength(1); + expect(result.tokens[0]).toBeUndefined(); + }); + + it('should return full tokens when fields not specified', () => { + const result = handler.getTokens([TEST_TOKEN_IDS.WEGLD]); + + expect(result).toBeDefined(); + expect(result.tokens).toHaveLength(1); + expect(result.tokens[0].identifier).toBe(TEST_TOKEN_IDS.WEGLD); + expect(result.tokens[0].name).toBe('WrappedEgld'); + expect(result.tokens[0].decimals).toBe(18); + }); + + it('should return partial tokens with only requested fields', () => { + const result = handler.getTokens([TEST_TOKEN_IDS.WEGLD], ['identifier', 'name']); + + expect(result).toBeDefined(); + expect(result.tokens).toHaveLength(1); + expect(result.tokens[0].identifier).toBe(TEST_TOKEN_IDS.WEGLD); + expect(result.tokens[0].name).toBe('WrappedEgld'); + expect(result.tokens[0].decimals).toBeUndefined(); + }); + + it('should handle multiple tokens', () => { + const result = handler.getTokens([ + TEST_TOKEN_IDS.WEGLD, + TEST_TOKEN_IDS.MEX, + ]); + + expect(result).toBeDefined(); + expect(result.tokens).toHaveLength(2); + expect(result.tokens[0].identifier).toBe(TEST_TOKEN_IDS.WEGLD); + expect(result.tokens[1].identifier).toBe(TEST_TOKEN_IDS.MEX); + }); + }); + + describe('getAllTokens', () => { + it('should return empty array when no tokens exist', () => { + stateStore.clearAll(); + + const result = handler.getAllTokens(); + + expect(result).toBeDefined(); + expect(result.tokens).toHaveLength(0); + }); + + it('should return all tokens when tokens exist', () => { + const result = handler.getAllTokens(); + + expect(result).toBeDefined(); + expect(result.tokens).toHaveLength(4); + }); + }); + + describe('getFilteredTokens', () => { + it('should filter by enabledSwaps', () => { + const request = createFilterRequest({ + enabledSwaps: true, + }); + + const result = handler.getFilteredTokens(request); + + expect(result.tokens).toHaveLength(3); // WEGLD, MEX, USDC (USDT excluded) + expect(result.count).toBe(3); + expect(result.tokens.some(t => t.identifier === TEST_TOKEN_IDS.USDT)).toBe(false); + }); + + it('should filter by identifiers', () => { + const request = createFilterRequest({ + identifiers: [TEST_TOKEN_IDS.WEGLD, TEST_TOKEN_IDS.MEX], + }); + + const result = handler.getFilteredTokens(request); + + expect(result.tokens).toHaveLength(2); + expect(result.count).toBe(2); + expect(result.tokens[0].identifier).toBe(TEST_TOKEN_IDS.WEGLD); + expect(result.tokens[1].identifier).toBe(TEST_TOKEN_IDS.MEX); + }); + + it('should filter by searchToken - case insensitive identifier match', () => { + const request = createFilterRequest({ + searchToken: 'wegld', + }); + + const result = handler.getFilteredTokens(request); + + expect(result.tokens).toHaveLength(1); + expect(result.tokens[0].identifier).toBe(TEST_TOKEN_IDS.WEGLD); + }); + + it('should filter by searchToken - case insensitive name match', () => { + const request = createFilterRequest({ + searchToken: 'circle', + }); + + const result = handler.getFilteredTokens(request); + + expect(result.tokens).toHaveLength(1); + expect(result.tokens[0].identifier).toBe(TEST_TOKEN_IDS.USDC); + }); + + it('should filter by minLiquidity', () => { + const request = createFilterRequest({ + minLiquidity: 1000000, + }); + + const result = handler.getFilteredTokens(request); + + expect(result.tokens).toHaveLength(3); // WEGLD, MEX, USDC (USDT excluded) + expect(result.count).toBe(3); + expect(result.tokens.some(t => t.identifier === TEST_TOKEN_IDS.USDT)).toBe(false); + }); + + it('should combine multiple filters', () => { + const request = createFilterRequest({ + enabledSwaps: true, + identifiers: [TEST_TOKEN_IDS.WEGLD, TEST_TOKEN_IDS.MEX, TEST_TOKEN_IDS.USDT], + }); + + const result = handler.getFilteredTokens(request); + + // Should match: enabledSwaps (WEGLD, MEX) AND identifiers (WEGLD, MEX, USDT) + // Result: WEGLD, MEX + expect(result.tokens).toHaveLength(2); + expect(result.count).toBe(2); + }); + + it('should sort by liquidity', () => { + const request = createFilterRequest({ + sortField: TokenSortField.TOKENS_SORT_LIQUIDITY, + sortOrder: SortOrder.SORT_DESC, + }); + + const result = handler.getFilteredTokens(request); + + expect(result.tokens).toHaveLength(4); + // Order: WEGLD (10M), USDC (8M), MEX (5M), USDT (100K) + expect(result.tokens[0].identifier).toBe(TEST_TOKEN_IDS.WEGLD); + expect(result.tokens[1].identifier).toBe(TEST_TOKEN_IDS.USDC); + expect(result.tokens[2].identifier).toBe(TEST_TOKEN_IDS.MEX); + expect(result.tokens[3].identifier).toBe(TEST_TOKEN_IDS.USDT); + }); + + it('should sort by volume', () => { + const request = createFilterRequest({ + sortField: TokenSortField.TOKENS_SORT_VOLUME, + sortOrder: SortOrder.SORT_DESC, + }); + + const result = handler.getFilteredTokens(request); + + expect(result.tokens).toHaveLength(4); + // Order: WEGLD (500K), USDC (400K), MEX (300K), USDT (10K) + expect(result.tokens[0].identifier).toBe(TEST_TOKEN_IDS.WEGLD); + expect(result.tokens[1].identifier).toBe(TEST_TOKEN_IDS.USDC); + }); + + it('should handle TOKENS_SORT_UNSPECIFIED', () => { + const request = createFilterRequest({ + sortField: TokenSortField.TOKENS_SORT_UNSPECIFIED, + }); + + const result = handler.getFilteredTokens(request); + + expect(result.tokens).toHaveLength(4); + // Should maintain original order from tokensByType + }); + + it('should paginate results with offset and limit', () => { + const request = createFilterRequest({ + offset: 1, + limit: 2, + }); + + const result = handler.getFilteredTokens(request); + + expect(result.tokens).toHaveLength(2); + expect(result.count).toBe(4); // Total count should be 4 + }); + + it('should return empty when no tokens match filters', () => { + const request = createFilterRequest({ + searchToken: 'NONEXISTENT', + }); + + const result = handler.getFilteredTokens(request); + + expect(result.tokens).toHaveLength(0); + expect(result.count).toBe(0); + }); + + it('should return empty when tokensByType is empty', () => { + stateStore.tokensByType.set(EsdtTokenType.FungibleToken, []); + + const request = createFilterRequest(); + + const result = handler.getFilteredTokens(request); + + expect(result.tokens).toHaveLength(0); + expect(result.count).toBe(0); + }); + }); + + describe('updateTokens', () => { + it('should update mutable fields correctly', () => { + const request = { + tokens: [ + { + identifier: TEST_TOKEN_IDS.WEGLD, + price: '15', + liquidityUSD: '12000000', + } as any, + ], + updateMask: { + paths: ['price', 'liquidityUSD'], + }, + }; + + const response = handler.updateTokens(request); + + expect(response.updatedCount).toBe(1); + expect(response.failedIdentifiers).toHaveLength(0); + + const token = stateStore.tokens.get(TEST_TOKEN_IDS.WEGLD); + expect(token.price).toBe('15'); + expect(token.liquidityUSD).toBe('12000000'); + }); + + it('should skip immutable field updates', () => { + const originalIdentifier = TEST_TOKEN_IDS.WEGLD; + const originalDecimals = 18; + const originalType = 'FungibleESDT'; + + const request = { + tokens: [ + { + identifier: TEST_TOKEN_IDS.WEGLD, + decimals: 6, + type: 'MetaESDT', + price: '15', + } as any, + ], + updateMask: { + paths: ['identifier', 'decimals', 'type', 'price'], + }, + }; + + handler.updateTokens(request); + + const token = stateStore.tokens.get(TEST_TOKEN_IDS.WEGLD); + expect(token.identifier).toBe(originalIdentifier); // Should not change + expect(token.decimals).toBe(originalDecimals); // Should not change + expect(token.type).toBe(originalType); // Should not change + expect(token.price).toBe('15'); // Should change + }); + + it('should return failedIdentifiers for non-existent tokens', () => { + const unknownTokenID = 'UNKNOWN-123456'; + + const request = { + tokens: [ + { + identifier: unknownTokenID, + price: '15', + } as any, + ], + updateMask: { + paths: ['price'], + }, + }; + + const response = handler.updateTokens(request); + + expect(response.updatedCount).toBe(0); + expect(response.failedIdentifiers).toHaveLength(1); + expect(response.failedIdentifiers[0]).toBe(unknownTokenID); + }); + + it('should handle batch updates', () => { + const request = { + tokens: [ + { + identifier: TEST_TOKEN_IDS.WEGLD, + price: '15', + } as any, + { + identifier: TEST_TOKEN_IDS.MEX, + price: '0.02', + } as any, + ], + updateMask: { + paths: ['price'], + }, + }; + + const response = handler.updateTokens(request); + + expect(response.updatedCount).toBe(2); + expect(response.failedIdentifiers).toHaveLength(0); + + expect(stateStore.tokens.get(TEST_TOKEN_IDS.WEGLD).price).toBe('15'); + expect(stateStore.tokens.get(TEST_TOKEN_IDS.MEX).price).toBe('0.02'); + }); + + it('should skip tokens with missing identifier', () => { + const request = { + tokens: [ + { + identifier: undefined, + price: '15', + } as any, + { + identifier: TEST_TOKEN_IDS.WEGLD, + price: '20', + } as any, + ], + updateMask: { + paths: ['price'], + }, + }; + + const response = handler.updateTokens(request); + + expect(response.updatedCount).toBe(1); // Only 1 succeeded + expect(response.failedIdentifiers).toHaveLength(0); // Missing identifier not added to failed + }); + + it('should return correct updatedCount', () => { + const request = { + tokens: [ + { + identifier: TEST_TOKEN_IDS.WEGLD, + price: '15', + } as any, + { + identifier: TEST_TOKEN_IDS.MEX, + price: '0.02', + } as any, + { + identifier: 'UNKNOWN-123456', + price: '100', + } as any, + ], + updateMask: { + paths: ['price'], + }, + }; + + const response = handler.updateTokens(request); + + expect(response.updatedCount).toBe(2); // Only 2 succeeded + expect(response.failedIdentifiers).toHaveLength(1); + }); + }); +}); diff --git a/src/microservices/dex-state/specs/mocks/compute.services.mock.ts b/src/microservices/dex-state/specs/mocks/compute.services.mock.ts new file mode 100644 index 000000000..1bf0e22ad --- /dev/null +++ b/src/microservices/dex-state/specs/mocks/compute.services.mock.ts @@ -0,0 +1,94 @@ +import { FarmModelV2 } from 'src/modules/farm/models/farm.v2.model'; +import { StakingModel } from 'src/modules/staking/models/staking.model'; +import { FeesCollectorModel } from 'src/modules/fees-collector/models/fees-collector.model'; +import { FarmComputeService } from '../../services/compute/farm.compute.service'; +import { StakingComputeService } from '../../services/compute/staking.compute.service'; +import { FeesCollectorComputeService } from '../../services/compute/fees-collector.compute.service'; + +/** + * Mock FarmComputeService for testing state handlers + */ +export class MockFarmComputeService { + computeMissingFarmFields(farm: FarmModelV2): FarmModelV2 { + return { + ...farm, + // Computed price fields + farmedTokenPriceUSD: farm.farmedTokenPriceUSD ?? '0.001', + farmingTokenPriceUSD: farm.farmingTokenPriceUSD ?? '20', + farmTokenPriceUSD: farm.farmTokenPriceUSD ?? '20', + // Computed value fields + totalValueLockedUSD: farm.totalValueLockedUSD ?? '1000000', + // Computed APR fields + baseApr: farm.baseApr ?? '50', + boostedApr: farm.boostedApr ?? '100', + // Computed energy/boost fields + optimalEnergyPerLp: farm.optimalEnergyPerLp ?? '1000000000000000000', + boostedRewardsPerWeek: farm.boostedRewardsPerWeek ?? '1000000000000000000000', + }; + } +} + +/** + * Mock StakingComputeService for testing state handlers + */ +export class MockStakingComputeService { + computeMissingStakingFarmFields(stakingFarm: StakingModel): StakingModel { + return { + ...stakingFarm, + // Computed price fields + farmingTokenPriceUSD: stakingFarm.farmingTokenPriceUSD ?? '1', + // Computed value fields + stakedValueUSD: stakingFarm.stakedValueUSD ?? '1000000', + // Computed APR fields + apr: stakingFarm.apr ?? '25', + aprUncapped: stakingFarm.aprUncapped ?? '25', + baseApr: stakingFarm.baseApr ?? '25', + boostedApr: stakingFarm.boostedApr ?? '50', + maxBoostedApr: stakingFarm.maxBoostedApr ?? '100', + // Computed rewards fields + rewardsRemainingDays: stakingFarm.rewardsRemainingDays ?? 365, + rewardsRemainingDaysUncapped: stakingFarm.rewardsRemainingDaysUncapped ?? 365, + // Computed energy fields + optimalEnergyPerStaking: stakingFarm.optimalEnergyPerStaking ?? '1000000000000000000', + }; + } +} + +/** + * Mock FeesCollectorComputeService for testing state handlers + */ +export class MockFeesCollectorComputeService { + computeMissingFeesCollectorFields(feesCollector: FeesCollectorModel): FeesCollectorModel { + // FeesCollectorComputeService primarily recomputes weekly epochs and distributions + // For testing, we just return the input with minimal changes + return { + ...feesCollector, + // Most fields are already present, compute service refreshes weekly data + lastGlobalUpdateWeek: feesCollector.lastGlobalUpdateWeek ?? feesCollector.time?.currentWeek ?? 1, + }; + } +} + +/** + * Provider for MockFarmComputeService (NestJS DI pattern) + */ +export const MockFarmComputeServiceProvider = { + provide: FarmComputeService, + useClass: MockFarmComputeService, +}; + +/** + * Provider for MockStakingComputeService (NestJS DI pattern) + */ +export const MockStakingComputeServiceProvider = { + provide: StakingComputeService, + useClass: MockStakingComputeService, +}; + +/** + * Provider for MockFeesCollectorComputeService (NestJS DI pattern) + */ +export const MockFeesCollectorComputeServiceProvider = { + provide: FeesCollectorComputeService, + useClass: MockFeesCollectorComputeService, +}; diff --git a/src/microservices/dex-state/specs/services/bulk.updates.service.spec.ts b/src/microservices/dex-state/specs/services/bulk.updates.service.spec.ts new file mode 100644 index 000000000..cabf29d7a --- /dev/null +++ b/src/microservices/dex-state/specs/services/bulk.updates.service.spec.ts @@ -0,0 +1,1331 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BulkUpdatesService } from '../../services/bulk.updates.service'; +import { PairModel } from 'src/modules/pair/models/pair.model'; +import { + EsdtToken, + EsdtTokenType, +} from 'src/modules/tokens/models/esdtToken.model'; +import { PairInfoModel } from 'src/modules/pair/models/pair-info.model'; +import { + Tokens, + createMockPair, + createMockToken, + TEST_TOKEN_IDS, + TEST_ADDRESSES, +} from '../test.utils'; +import BigNumber from 'bignumber.js'; +import '@multiversx/sdk-nestjs-common/lib/utils/extensions/array.extensions'; + +// Mock config - use actual pair address from test constants +jest.mock('src/config', () => ({ + constantsConfig: { + USDC_TOKEN_ID: 'USDC-123456', + BLOCKS_PER_WEEK: 100800, + }, + mxConfig: { + EGLDDecimals: 18, + }, + scAddress: { + // Use the actual EGLD-USDC pair address from test data (pairs[1]) + get WEGLD_USDC() { + return require('src/modules/pair/mocks/pair.constants').pairs[1] + .address; + }, + }, + tokenProviderUSD: 'WEGLD-123456', +})); + +describe('BulkUpdatesService', () => { + let service: BulkUpdatesService; + let pairs: Map; + let tokens: Map; + + // Helper to add LP tokens for all pairs that have liquidityPoolTokenId + const addLpTokensForPairs = () => { + for (const pair of pairs.values()) { + if ( + pair.liquidityPoolTokenId && + !tokens.has(pair.liquidityPoolTokenId) + ) { + tokens.set( + pair.liquidityPoolTokenId, + createMockToken(pair.liquidityPoolTokenId, { + type: EsdtTokenType.FungibleLpToken, + pairAddress: pair.address, + decimals: 18, + }), + ); + } + } + }; + + // Helper to ensure WEGLD-USDC price oracle pair exists + const ensurePriceOraclePair = () => { + if (!pairs.has(TEST_ADDRESSES.PAIR_EGLD_USDC)) { + const wegldUsdcPair = createMockPair( + TEST_ADDRESSES.PAIR_EGLD_USDC, + { + firstTokenId: TEST_TOKEN_IDS.WEGLD, + secondTokenId: TEST_TOKEN_IDS.USDC, + liquidityPoolTokenId: TEST_TOKEN_IDS.LP_EGLD_USDC, + }, + ); + wegldUsdcPair.info = new PairInfoModel({ + reserves0: '100000000000000000000', // 100 WEGLD + reserves1: '1000000000', // 1000 USDC + totalSupply: '10000000000000000000000', + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_USDC, wegldUsdcPair); + + if (!tokens.has(TEST_TOKEN_IDS.WEGLD)) { + tokens.set(TEST_TOKEN_IDS.WEGLD, Tokens(TEST_TOKEN_IDS.WEGLD)); + } + if (!tokens.has(TEST_TOKEN_IDS.USDC)) { + tokens.set(TEST_TOKEN_IDS.USDC, Tokens(TEST_TOKEN_IDS.USDC)); + } + } + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [BulkUpdatesService], + }).compile(); + + service = module.get(BulkUpdatesService); + + // Setup test data + pairs = new Map(); + tokens = new Map(); + }); + + describe('recomputeAllValues', () => { + it('should recompute all values and return updated tokens list', () => { + // Setup WEGLD-USDC pair (price oracle) + const wegldUsdcPair = createMockPair( + TEST_ADDRESSES.PAIR_EGLD_USDC, + { + firstTokenId: TEST_TOKEN_IDS.WEGLD, + secondTokenId: TEST_TOKEN_IDS.USDC, + liquidityPoolTokenId: TEST_TOKEN_IDS.LP_EGLD_USDC, + }, + ); + wegldUsdcPair.info = new PairInfoModel({ + reserves0: '100000000000000000000', // 100 WEGLD + reserves1: '1000000000', // 1000 USDC (6 decimals) + totalSupply: '10000000000000000000000', // 10000 LP tokens + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_USDC, wegldUsdcPair); + + // Setup tokens + const wegld = Tokens(TEST_TOKEN_IDS.WEGLD); + wegld.price = '0'; // Will be recomputed + wegld.derivedEGLD = '0'; + tokens.set(TEST_TOKEN_IDS.WEGLD, wegld); + + const usdc = Tokens(TEST_TOKEN_IDS.USDC); + usdc.price = '0'; + usdc.derivedEGLD = '0'; + tokens.set(TEST_TOKEN_IDS.USDC, usdc); + + const lpToken = createMockToken(TEST_TOKEN_IDS.LP_EGLD_USDC, { + type: EsdtTokenType.FungibleLpToken, + decimals: 18, + pairAddress: TEST_ADDRESSES.PAIR_EGLD_USDC, + }); + tokens.set(TEST_TOKEN_IDS.LP_EGLD_USDC, lpToken); + + addLpTokensForPairs(); + + const updatedTokens = service.recomputeAllValues( + pairs, + tokens, + 1.0, // USDC price = $1 + [TEST_TOKEN_IDS.WEGLD, TEST_TOKEN_IDS.USDC], + ); + + // Verify pair prices were updated (reserves-based) + expect(wegldUsdcPair.firstTokenPrice).not.toBe('0'); + expect(wegldUsdcPair.secondTokenPrice).not.toBe('0'); + + // Verify token prices were updated + expect(tokens.get(TEST_TOKEN_IDS.WEGLD).price).not.toBe('0'); + expect(tokens.get(TEST_TOKEN_IDS.USDC).price).not.toBe('0'); + expect(tokens.get(TEST_TOKEN_IDS.WEGLD).derivedEGLD).toBe('1'); + + // Verify locked values were computed + expect(wegldUsdcPair.lockedValueUSD).not.toBe('0'); + expect(wegldUsdcPair.firstTokenLockedValueUSD).not.toBe('0'); + expect(wegldUsdcPair.secondTokenLockedValueUSD).not.toBe('0'); + + // Verify LP token price was computed + expect(wegldUsdcPair.liquidityPoolTokenPriceUSD).not.toBe('0'); + expect(lpToken.price).toBe( + wegldUsdcPair.liquidityPoolTokenPriceUSD, + ); + + // Verify updated tokens list includes tokens with changed prices + expect(updatedTokens).toContain(TEST_TOKEN_IDS.WEGLD); + expect(updatedTokens).toContain(TEST_TOKEN_IDS.USDC); + }); + + it('should handle multiple pairs and compute derived prices through graph', () => { + // Setup WEGLD-USDC pair (price oracle) + const wegldUsdcPair = createMockPair( + TEST_ADDRESSES.PAIR_EGLD_USDC, + { + firstTokenId: TEST_TOKEN_IDS.WEGLD, + secondTokenId: TEST_TOKEN_IDS.USDC, + liquidityPoolTokenId: TEST_TOKEN_IDS.LP_EGLD_USDC, + }, + ); + wegldUsdcPair.info = new PairInfoModel({ + reserves0: '100000000000000000000', // 100 WEGLD + reserves1: '1000000000', // 1000 USDC + totalSupply: '10000000000000000000000', + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_USDC, wegldUsdcPair); + + // Setup MEX-WEGLD pair (MEX price derived from WEGLD) + const mexWegldPair = createMockPair(TEST_ADDRESSES.PAIR_EGLD_MEX, { + firstTokenId: TEST_TOKEN_IDS.WEGLD, + secondTokenId: TEST_TOKEN_IDS.MEX, + liquidityPoolTokenId: TEST_TOKEN_IDS.LP_EGLD_MEX, + state: 'Active', + }); + mexWegldPair.info = new PairInfoModel({ + reserves0: '10000000000000000000', // 10 WEGLD + reserves1: '100000000000000000000000', // 100000 MEX + totalSupply: '10000000000000000000000', + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_MEX, mexWegldPair); + + // Setup tokens + tokens.set(TEST_TOKEN_IDS.WEGLD, Tokens(TEST_TOKEN_IDS.WEGLD)); + tokens.set(TEST_TOKEN_IDS.USDC, Tokens(TEST_TOKEN_IDS.USDC)); + tokens.set(TEST_TOKEN_IDS.MEX, Tokens(TEST_TOKEN_IDS.MEX)); + + // Add LP tokens for all pairs + addLpTokensForPairs(); + + addLpTokensForPairs(); + + service.recomputeAllValues(pairs, tokens, 1.0, [ + TEST_TOKEN_IDS.WEGLD, + TEST_TOKEN_IDS.USDC, + ]); + + // WEGLD derivedEGLD should be 1 + expect(tokens.get(TEST_TOKEN_IDS.WEGLD).derivedEGLD).toBe('1'); + + // MEX derivedEGLD should be computed through WEGLD pair + const mexDerivedEGLD = new BigNumber( + tokens.get(TEST_TOKEN_IDS.MEX).derivedEGLD, + ); + expect(mexDerivedEGLD.gt(0)).toBe(true); + + // MEX price should be lower than WEGLD price + const mexPrice = new BigNumber( + tokens.get(TEST_TOKEN_IDS.MEX).price, + ); + const wegldPrice = new BigNumber( + tokens.get(TEST_TOKEN_IDS.WEGLD).price, + ); + expect(mexPrice.lt(wegldPrice)).toBe(true); + }); + + it('should handle LP tokens and set their liquidityUSD to zero', () => { + const pair = createMockPair(TEST_ADDRESSES.PAIR_EGLD_USDC, { + firstTokenId: TEST_TOKEN_IDS.WEGLD, + secondTokenId: TEST_TOKEN_IDS.USDC, + liquidityPoolTokenId: TEST_TOKEN_IDS.LP_EGLD_USDC, + }); + pair.info = new PairInfoModel({ + reserves0: '100000000000000000000', + reserves1: '1000000000', + totalSupply: '10000000000000000000000', + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_USDC, pair); + + tokens.set(TEST_TOKEN_IDS.WEGLD, Tokens(TEST_TOKEN_IDS.WEGLD)); + tokens.set(TEST_TOKEN_IDS.USDC, Tokens(TEST_TOKEN_IDS.USDC)); + + const lpToken = createMockToken(TEST_TOKEN_IDS.LP_EGLD_USDC, { + type: EsdtTokenType.FungibleLpToken, + pairAddress: TEST_ADDRESSES.PAIR_EGLD_USDC, + }); + tokens.set(TEST_TOKEN_IDS.LP_EGLD_USDC, lpToken); + + addLpTokensForPairs(); + + service.recomputeAllValues(pairs, tokens, 1.0, [ + TEST_TOKEN_IDS.WEGLD, + TEST_TOKEN_IDS.USDC, + ]); + + // LP tokens should have liquidityUSD = 0 and derivedEGLD = 0 + expect(lpToken.liquidityUSD).toBe('0'); + expect(lpToken.derivedEGLD).toBe('0'); + // LP token price should match pair liquidityPoolTokenPriceUSD + expect(lpToken.price).toBe(pair.liquidityPoolTokenPriceUSD); + }); + }); + + describe('price computation from reserves', () => { + it('should compute token prices based on reserves', () => { + const pair = createMockPair(TEST_ADDRESSES.PAIR_EGLD_USDC, { + firstTokenId: TEST_TOKEN_IDS.WEGLD, + secondTokenId: TEST_TOKEN_IDS.USDC, + liquidityPoolTokenId: TEST_TOKEN_IDS.LP_EGLD_USDC, + }); + pair.info = new PairInfoModel({ + reserves0: '100000000000000000000', // 100 WEGLD (18 decimals) + reserves1: '1000000000', // 1000 USDC (6 decimals) + totalSupply: '10000000000000000000000', + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_USDC, pair); + + tokens.set(TEST_TOKEN_IDS.WEGLD, Tokens(TEST_TOKEN_IDS.WEGLD)); + tokens.set(TEST_TOKEN_IDS.USDC, Tokens(TEST_TOKEN_IDS.USDC)); + addLpTokensForPairs(); + + addLpTokensForPairs(); + + service.recomputeAllValues(pairs, tokens, 1.0, [ + TEST_TOKEN_IDS.WEGLD, + TEST_TOKEN_IDS.USDC, + ]); + + // 1 WEGLD = 1000 USDC / 100 WEGLD = 10 USDC + // Adjusted for decimals: quote(1e18, 100e18, 1000e6) * 1e-6 = 10 + expect(pair.firstTokenPrice).toBe('10'); + + // 1 USDC = 100 WEGLD / 1000 USDC = 0.1 WEGLD + // Adjusted for decimals: quote(1e6, 1000e6, 100e18) * 1e-18 = 0.1 + expect(pair.secondTokenPrice).toBe('0.1'); + }); + + it('should handle pairs with different decimal precisions', () => { + ensurePriceOraclePair(); + + const pair = createMockPair(TEST_ADDRESSES.PAIR_EGLD_MEX, { + firstTokenId: TEST_TOKEN_IDS.WEGLD, + secondTokenId: TEST_TOKEN_IDS.MEX, + liquidityPoolTokenId: TEST_TOKEN_IDS.LP_EGLD_MEX, + }); + pair.info = new PairInfoModel({ + reserves0: '50000000000000000000', // 50 WEGLD (18 decimals) + reserves1: '1000000000000000000000', // 1000 MEX (18 decimals) + totalSupply: '100000000000000000000', + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_MEX, pair); + + tokens.set(TEST_TOKEN_IDS.MEX, Tokens(TEST_TOKEN_IDS.MEX)); + addLpTokensForPairs(); + + addLpTokensForPairs(); + + service.recomputeAllValues(pairs, tokens, 1.0, [ + TEST_TOKEN_IDS.WEGLD, + TEST_TOKEN_IDS.MEX, + ]); + + // 1 WEGLD = 1000 MEX / 50 WEGLD = 20 MEX + expect(pair.firstTokenPrice).toBe('20'); + + // 1 MEX = 50 WEGLD / 1000 MEX = 0.05 WEGLD + expect(pair.secondTokenPrice).toBe('0.05'); + }); + }); + + describe('derived EGLD and USD price computation', () => { + it('should compute WEGLD derivedEGLD as 1', () => { + const pair = createMockPair(TEST_ADDRESSES.PAIR_EGLD_USDC, { + firstTokenId: TEST_TOKEN_IDS.WEGLD, + secondTokenId: TEST_TOKEN_IDS.USDC, + }); + pair.info = new PairInfoModel({ + reserves0: '100000000000000000000', + reserves1: '1000000000', + totalSupply: '10000000000000000000000', + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_USDC, pair); + + tokens.set(TEST_TOKEN_IDS.WEGLD, Tokens(TEST_TOKEN_IDS.WEGLD)); + tokens.set(TEST_TOKEN_IDS.USDC, Tokens(TEST_TOKEN_IDS.USDC)); + + addLpTokensForPairs(); + + service.recomputeAllValues(pairs, tokens, 1.0, [ + TEST_TOKEN_IDS.WEGLD, + TEST_TOKEN_IDS.USDC, + ]); + + // WEGLD is the tokenProviderUSD, so derivedEGLD = 1 + expect(tokens.get(TEST_TOKEN_IDS.WEGLD).derivedEGLD).toBe('1'); + }); + + it('should compute USDC derivedEGLD as 1 / egldPriceUSD', () => { + const pair = createMockPair(TEST_ADDRESSES.PAIR_EGLD_USDC, { + firstTokenId: TEST_TOKEN_IDS.WEGLD, + secondTokenId: TEST_TOKEN_IDS.USDC, + }); + pair.info = new PairInfoModel({ + reserves0: '100000000000000000000', // 100 WEGLD + reserves1: '1000000000', // 1000 USDC + totalSupply: '10000000000000000000000', + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_USDC, pair); + + tokens.set(TEST_TOKEN_IDS.WEGLD, Tokens(TEST_TOKEN_IDS.WEGLD)); + tokens.set(TEST_TOKEN_IDS.USDC, Tokens(TEST_TOKEN_IDS.USDC)); + + addLpTokensForPairs(); + + service.recomputeAllValues(pairs, tokens, 1.0, [ + TEST_TOKEN_IDS.WEGLD, + TEST_TOKEN_IDS.USDC, + ]); + + // EGLD price in USD = firstTokenPrice from WEGLD-USDC pair = 10 USDC + // USDC derivedEGLD = 1 / 10 = 0.1 + const usdcDerivedEGLD = new BigNumber( + tokens.get(TEST_TOKEN_IDS.USDC).derivedEGLD, + ); + expect(usdcDerivedEGLD.toFixed()).toBe('0.1'); + }); + + it('should compute derived EGLD price through multiple hops', () => { + // WEGLD-USDC pair + const wegldUsdcPair = createMockPair( + TEST_ADDRESSES.PAIR_EGLD_USDC, + { + firstTokenId: TEST_TOKEN_IDS.WEGLD, + secondTokenId: TEST_TOKEN_IDS.USDC, + }, + ); + wegldUsdcPair.info = new PairInfoModel({ + reserves0: '100000000000000000000', // 100 WEGLD + reserves1: '1000000000', // 1000 USDC + totalSupply: '10000000000000000000000', + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_USDC, wegldUsdcPair); + + // WEGLD-MEX pair + const wegldMexPair = createMockPair(TEST_ADDRESSES.PAIR_EGLD_MEX, { + firstTokenId: TEST_TOKEN_IDS.WEGLD, + secondTokenId: TEST_TOKEN_IDS.MEX, + state: 'Active', + }); + wegldMexPair.info = new PairInfoModel({ + reserves0: '10000000000000000000', // 10 WEGLD + reserves1: '100000000000000000000000', // 100000 MEX + totalSupply: '10000000000000000000000', + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_MEX, wegldMexPair); + + // MEX-TOK4 pair (TOK4 price derived through MEX) + const mexTok4Pair = createMockPair(TEST_ADDRESSES.PAIR_TOK4_EGLD, { + firstTokenId: TEST_TOKEN_IDS.MEX, + secondTokenId: TEST_TOKEN_IDS.TOK4, + state: 'Active', + }); + mexTok4Pair.info = new PairInfoModel({ + reserves0: '50000000000000000000000', // 50000 MEX + reserves1: '5000000000000000000', // 5 TOK4 + totalSupply: '10000000000000000000000', + }); + pairs.set(TEST_ADDRESSES.PAIR_TOK4_EGLD, mexTok4Pair); + + tokens.set(TEST_TOKEN_IDS.WEGLD, Tokens(TEST_TOKEN_IDS.WEGLD)); + tokens.set(TEST_TOKEN_IDS.USDC, Tokens(TEST_TOKEN_IDS.USDC)); + tokens.set(TEST_TOKEN_IDS.MEX, Tokens(TEST_TOKEN_IDS.MEX)); + tokens.set(TEST_TOKEN_IDS.TOK4, Tokens(TEST_TOKEN_IDS.TOK4)); + + addLpTokensForPairs(); + + service.recomputeAllValues(pairs, tokens, 1.0, [ + TEST_TOKEN_IDS.WEGLD, + TEST_TOKEN_IDS.USDC, + TEST_TOKEN_IDS.MEX, + ]); + + // TOK4 should have derived EGLD computed through MEX -> WEGLD path + const tok4DerivedEGLD = new BigNumber( + tokens.get(TEST_TOKEN_IDS.TOK4).derivedEGLD, + ); + expect(tok4DerivedEGLD.gt(0)).toBe(true); + + // TOK4 price should be positive + const tok4Price = new BigNumber( + tokens.get(TEST_TOKEN_IDS.TOK4).price, + ); + expect(tok4Price.gt(0)).toBe(true); + }); + + it('should choose the pair with largest EGLD liquidity for price derivation', () => { + // WEGLD-USDC pair + const wegldUsdcPair = createMockPair( + TEST_ADDRESSES.PAIR_EGLD_USDC, + { + firstTokenId: TEST_TOKEN_IDS.WEGLD, + secondTokenId: TEST_TOKEN_IDS.USDC, + }, + ); + wegldUsdcPair.info = new PairInfoModel({ + reserves0: '100000000000000000000', + reserves1: '1000000000', + totalSupply: '10000000000000000000000', + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_USDC, wegldUsdcPair); + + // MEX-WEGLD pair (large liquidity) + const mexWegldPair1 = createMockPair(TEST_ADDRESSES.PAIR_EGLD_MEX, { + firstTokenId: TEST_TOKEN_IDS.MEX, + secondTokenId: TEST_TOKEN_IDS.WEGLD, + state: 'Active', + }); + mexWegldPair1.info = new PairInfoModel({ + reserves0: '1000000000000000000000000', // 1M MEX + reserves1: '10000000000000000000', // 10 WEGLD - large liquidity + totalSupply: '100000000000000000000000', + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_MEX, mexWegldPair1); + + // MEX-USDC pair (small liquidity) + const mexUsdcPair = createMockPair(TEST_ADDRESSES.PAIR_TOK4_EGLD, { + firstTokenId: TEST_TOKEN_IDS.MEX, + secondTokenId: TEST_TOKEN_IDS.USDC, + state: 'Active', + }); + mexUsdcPair.info = new PairInfoModel({ + reserves0: '100000000000000000000', // 100 MEX + reserves1: '10000', // 0.01 USDC - small liquidity + totalSupply: '1000000000000000000', + }); + pairs.set(TEST_ADDRESSES.PAIR_TOK4_EGLD, mexUsdcPair); + + tokens.set(TEST_TOKEN_IDS.WEGLD, Tokens(TEST_TOKEN_IDS.WEGLD)); + tokens.set(TEST_TOKEN_IDS.USDC, Tokens(TEST_TOKEN_IDS.USDC)); + tokens.set(TEST_TOKEN_IDS.MEX, Tokens(TEST_TOKEN_IDS.MEX)); + + addLpTokensForPairs(); + + service.recomputeAllValues(pairs, tokens, 1.0, [ + TEST_TOKEN_IDS.WEGLD, + TEST_TOKEN_IDS.USDC, + ]); + + // MEX price should be derived from the WEGLD pair (larger EGLD liquidity) + const mexDerivedEGLD = new BigNumber( + tokens.get(TEST_TOKEN_IDS.MEX).derivedEGLD, + ); + expect(mexDerivedEGLD.gt(0)).toBe(true); + }); + + it('should filter inactive pairs when active pairs exist', () => { + // WEGLD-USDC pair + const wegldUsdcPair = createMockPair( + TEST_ADDRESSES.PAIR_EGLD_USDC, + { + firstTokenId: TEST_TOKEN_IDS.WEGLD, + secondTokenId: TEST_TOKEN_IDS.USDC, + }, + ); + wegldUsdcPair.info = new PairInfoModel({ + reserves0: '100000000000000000000', + reserves1: '1000000000', + totalSupply: '10000000000000000000000', + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_USDC, wegldUsdcPair); + + // MEX-WEGLD pair (inactive with high liquidity) + const mexWegldPairInactive = createMockPair( + TEST_ADDRESSES.PAIR_EGLD_MEX, + { + firstTokenId: TEST_TOKEN_IDS.MEX, + secondTokenId: TEST_TOKEN_IDS.WEGLD, + state: 'Inactive', + }, + ); + mexWegldPairInactive.info = new PairInfoModel({ + reserves0: '10000000000000000000000000', // 10M MEX - huge liquidity but inactive + reserves1: '100000000000000000000', // 100 WEGLD + totalSupply: '1000000000000000000000000', + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_MEX, mexWegldPairInactive); + + // MEX-USDC pair (active with small liquidity) + const mexUsdcPairActive = createMockPair( + TEST_ADDRESSES.PAIR_TOK4_EGLD, + { + firstTokenId: TEST_TOKEN_IDS.MEX, + secondTokenId: TEST_TOKEN_IDS.USDC, + state: 'Active', + }, + ); + mexUsdcPairActive.info = new PairInfoModel({ + reserves0: '1000000000000000000000000', // 1000 MEX - small liquidity but active + reserves1: '100000000', // 0.1 USDC + totalSupply: '1000000000000000000000000', + }); + pairs.set(TEST_ADDRESSES.PAIR_TOK4_EGLD, mexUsdcPairActive); + + tokens.set(TEST_TOKEN_IDS.WEGLD, Tokens(TEST_TOKEN_IDS.WEGLD)); + tokens.set(TEST_TOKEN_IDS.USDC, Tokens(TEST_TOKEN_IDS.USDC)); + tokens.set(TEST_TOKEN_IDS.MEX, Tokens(TEST_TOKEN_IDS.MEX)); + + addLpTokensForPairs(); + + service.recomputeAllValues(pairs, tokens, 1.0, [ + TEST_TOKEN_IDS.WEGLD, + TEST_TOKEN_IDS.USDC, + ]); + + // MEX price should be derived from the active USDC pair, not the inactive WEGLD pair + const mexDerivedEGLD = new BigNumber( + tokens.get(TEST_TOKEN_IDS.MEX).derivedEGLD, + ); + expect(mexDerivedEGLD.gt(0)).toBe(true); + }); + }); + + describe('locked value calculations', () => { + it('should compute locked values for active pairs', () => { + const pair = createMockPair(TEST_ADDRESSES.PAIR_EGLD_USDC, { + firstTokenId: TEST_TOKEN_IDS.WEGLD, + secondTokenId: TEST_TOKEN_IDS.USDC, + state: 'Active', + }); + pair.info = new PairInfoModel({ + reserves0: '100000000000000000000', // 100 WEGLD + reserves1: '1000000000', // 1000 USDC + totalSupply: '10000000000000000000000', + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_USDC, pair); + + tokens.set(TEST_TOKEN_IDS.WEGLD, Tokens(TEST_TOKEN_IDS.WEGLD)); + tokens.set(TEST_TOKEN_IDS.USDC, Tokens(TEST_TOKEN_IDS.USDC)); + + addLpTokensForPairs(); + + service.recomputeAllValues(pairs, tokens, 1.0, [ + TEST_TOKEN_IDS.WEGLD, + TEST_TOKEN_IDS.USDC, + ]); + + // Active pairs should have lockedValueUSD = firstTokenLockedValueUSD + secondTokenLockedValueUSD + const firstLocked = new BigNumber(pair.firstTokenLockedValueUSD); + const secondLocked = new BigNumber(pair.secondTokenLockedValueUSD); + const totalLocked = new BigNumber(pair.lockedValueUSD); + + expect(firstLocked.gt(0)).toBe(true); + expect(secondLocked.gt(0)).toBe(true); + expect(totalLocked.toFixed()).toBe( + firstLocked.plus(secondLocked).toFixed(), + ); + }); + + it('should double common token locked value for inactive pairs with one common token', () => { + const pair = createMockPair(TEST_ADDRESSES.PAIR_EGLD_MEX, { + firstTokenId: TEST_TOKEN_IDS.WEGLD, // common token + secondTokenId: TEST_TOKEN_IDS.MEX, // non-common token + state: 'Inactive', + }); + pair.info = new PairInfoModel({ + reserves0: '50000000000000000000', // 50 WEGLD + reserves1: '500000000000000000000000', // 500000 MEX + totalSupply: '10000000000000000000000', + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_MEX, pair); + + // Need WEGLD-USDC for price computation + const wegldUsdcPair = createMockPair( + TEST_ADDRESSES.PAIR_EGLD_USDC, + { + firstTokenId: TEST_TOKEN_IDS.WEGLD, + secondTokenId: TEST_TOKEN_IDS.USDC, + }, + ); + wegldUsdcPair.info = new PairInfoModel({ + reserves0: '100000000000000000000', + reserves1: '1000000000', + totalSupply: '10000000000000000000000', + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_USDC, wegldUsdcPair); + + tokens.set(TEST_TOKEN_IDS.WEGLD, Tokens(TEST_TOKEN_IDS.WEGLD)); + tokens.set(TEST_TOKEN_IDS.USDC, Tokens(TEST_TOKEN_IDS.USDC)); + tokens.set(TEST_TOKEN_IDS.MEX, Tokens(TEST_TOKEN_IDS.MEX)); + + addLpTokensForPairs(); + + service.recomputeAllValues( + pairs, + tokens, + 1.0, + [TEST_TOKEN_IDS.WEGLD, TEST_TOKEN_IDS.USDC], // Only WEGLD and USDC are common + ); + + // Inactive pair with one common token: lockedValueUSD = 2 * commonTokenLockedValueUSD + const wegldLocked = new BigNumber(pair.firstTokenLockedValueUSD); + const totalLocked = new BigNumber(pair.lockedValueUSD); + + expect(totalLocked.toFixed()).toBe( + wegldLocked.multipliedBy(2).toFixed(), + ); + }); + + it('should set lockedValueUSD to zero for inactive pairs with no common tokens', () => { + // Need WEGLD-USDC for price computation + const wegldUsdcPair = createMockPair( + TEST_ADDRESSES.PAIR_EGLD_USDC, + { + firstTokenId: TEST_TOKEN_IDS.WEGLD, + secondTokenId: TEST_TOKEN_IDS.USDC, + }, + ); + wegldUsdcPair.info = new PairInfoModel({ + reserves0: '100000000000000000000', + reserves1: '1000000000', + totalSupply: '10000000000000000000000', + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_USDC, wegldUsdcPair); + + // MEX-TOK4 pair (both non-common tokens) + const mexTok4Pair = createMockPair(TEST_ADDRESSES.PAIR_EGLD_MEX, { + firstTokenId: TEST_TOKEN_IDS.MEX, + secondTokenId: TEST_TOKEN_IDS.TOK4, + state: 'Inactive', + }); + mexTok4Pair.info = new PairInfoModel({ + reserves0: '100000000000000000000000', // 100000 MEX + reserves1: '50000000000000000000', // 50 TOK4 + totalSupply: '10000000000000000000000', + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_MEX, mexTok4Pair); + + tokens.set(TEST_TOKEN_IDS.WEGLD, Tokens(TEST_TOKEN_IDS.WEGLD)); + tokens.set(TEST_TOKEN_IDS.USDC, Tokens(TEST_TOKEN_IDS.USDC)); + tokens.set(TEST_TOKEN_IDS.MEX, Tokens(TEST_TOKEN_IDS.MEX)); + tokens.set(TEST_TOKEN_IDS.TOK4, Tokens(TEST_TOKEN_IDS.TOK4)); + + addLpTokensForPairs(); + + service.recomputeAllValues( + pairs, + tokens, + 1.0, + [TEST_TOKEN_IDS.WEGLD, TEST_TOKEN_IDS.USDC], // Neither MEX nor TOK4 is common + ); + + // Inactive pair with no common tokens should have lockedValueUSD = 0 + expect(mexTok4Pair.lockedValueUSD).toBe('0'); + + // Individual token locked values should also be 0 because MEX and TOK4 have no price + // (they're not connected to the WEGLD/USDC price oracle through any active pair) + expect(mexTok4Pair.firstTokenLockedValueUSD).toBe('0'); + expect(mexTok4Pair.secondTokenLockedValueUSD).toBe('0'); + }); + + it('should compute locked values for pairs with both common tokens even if inactive', () => { + const wegldUsdcPair = createMockPair( + TEST_ADDRESSES.PAIR_EGLD_USDC, + { + firstTokenId: TEST_TOKEN_IDS.WEGLD, + secondTokenId: TEST_TOKEN_IDS.USDC, + state: 'Inactive', // Inactive but both tokens are common + }, + ); + wegldUsdcPair.info = new PairInfoModel({ + reserves0: '100000000000000000000', + reserves1: '1000000000', + totalSupply: '10000000000000000000000', + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_USDC, wegldUsdcPair); + + tokens.set(TEST_TOKEN_IDS.WEGLD, Tokens(TEST_TOKEN_IDS.WEGLD)); + tokens.set(TEST_TOKEN_IDS.USDC, Tokens(TEST_TOKEN_IDS.USDC)); + + addLpTokensForPairs(); + + service.recomputeAllValues( + pairs, + tokens, + 1.0, + [TEST_TOKEN_IDS.WEGLD, TEST_TOKEN_IDS.USDC], // Both are common + ); + + // Pair with both common tokens should have full locked value even if inactive + const firstLocked = new BigNumber( + wegldUsdcPair.firstTokenLockedValueUSD, + ); + const secondLocked = new BigNumber( + wegldUsdcPair.secondTokenLockedValueUSD, + ); + const totalLocked = new BigNumber(wegldUsdcPair.lockedValueUSD); + + expect(totalLocked.toFixed()).toBe( + firstLocked.plus(secondLocked).toFixed(), + ); + }); + }); + + describe('LP token price calculations', () => { + it('should compute LP token price based on underlying reserves', () => { + const pair = createMockPair(TEST_ADDRESSES.PAIR_EGLD_USDC, { + firstTokenId: TEST_TOKEN_IDS.WEGLD, + secondTokenId: TEST_TOKEN_IDS.USDC, + liquidityPoolTokenId: TEST_TOKEN_IDS.LP_EGLD_USDC, + }); + pair.info = new PairInfoModel({ + reserves0: '100000000000000000000', // 100 WEGLD + reserves1: '1000000000', // 1000 USDC + totalSupply: '10000000000000000000000', // 10000 LP tokens + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_USDC, pair); + + tokens.set(TEST_TOKEN_IDS.WEGLD, Tokens(TEST_TOKEN_IDS.WEGLD)); + tokens.set(TEST_TOKEN_IDS.USDC, Tokens(TEST_TOKEN_IDS.USDC)); + + const lpToken = createMockToken(TEST_TOKEN_IDS.LP_EGLD_USDC, { + type: EsdtTokenType.FungibleLpToken, + decimals: 18, + pairAddress: TEST_ADDRESSES.PAIR_EGLD_USDC, + }); + tokens.set(TEST_TOKEN_IDS.LP_EGLD_USDC, lpToken); + + addLpTokensForPairs(); + + service.recomputeAllValues(pairs, tokens, 1.0, [ + TEST_TOKEN_IDS.WEGLD, + TEST_TOKEN_IDS.USDC, + ]); + + // LP token price should be (value of underlying tokens for 1 LP) in USD + const lpPrice = new BigNumber(pair.liquidityPoolTokenPriceUSD); + expect(lpPrice.gt(0)).toBe(true); + + // LP token price = (reserves0 * token0PriceUSD + reserves1 * token1PriceUSD) / totalSupply + // Approximately: (100 * 10 + 1000 * 1) / 10000 = 2000 / 10000 = 0.2 per LP + expect(lpPrice.toFixed(1)).toBe('0.2'); + }); + + it('should return zero for LP token price when no liquidityPoolTokenId', () => { + const pair = createMockPair(TEST_ADDRESSES.PAIR_EGLD_USDC, { + firstTokenId: TEST_TOKEN_IDS.WEGLD, + secondTokenId: TEST_TOKEN_IDS.USDC, + liquidityPoolTokenId: null, + }); + pair.info = new PairInfoModel({ + reserves0: '100000000000000000000', + reserves1: '1000000000', + totalSupply: '10000000000000000000000', + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_USDC, pair); + + tokens.set(TEST_TOKEN_IDS.WEGLD, Tokens(TEST_TOKEN_IDS.WEGLD)); + tokens.set(TEST_TOKEN_IDS.USDC, Tokens(TEST_TOKEN_IDS.USDC)); + + addLpTokensForPairs(); + + service.recomputeAllValues(pairs, tokens, 1.0, [ + TEST_TOKEN_IDS.WEGLD, + TEST_TOKEN_IDS.USDC, + ]); + + // Pair without LP token should have liquidityPoolTokenPriceUSD = 0 + expect(pair.liquidityPoolTokenPriceUSD).toBe('0'); + }); + }); + + describe('token liquidity calculations', () => { + it('should compute token liquidityUSD from all active pairs', () => { + // WEGLD-USDC pair + const wegldUsdcPair = createMockPair( + TEST_ADDRESSES.PAIR_EGLD_USDC, + { + firstTokenId: TEST_TOKEN_IDS.WEGLD, + secondTokenId: TEST_TOKEN_IDS.USDC, + state: 'Active', + }, + ); + wegldUsdcPair.info = new PairInfoModel({ + reserves0: '100000000000000000000', + reserves1: '1000000000', + totalSupply: '10000000000000000000000', + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_USDC, wegldUsdcPair); + + // WEGLD-MEX pair + const wegldMexPair = createMockPair(TEST_ADDRESSES.PAIR_EGLD_MEX, { + firstTokenId: TEST_TOKEN_IDS.WEGLD, + secondTokenId: TEST_TOKEN_IDS.MEX, + state: 'Active', + }); + wegldMexPair.info = new PairInfoModel({ + reserves0: '50000000000000000000', + reserves1: '500000000000000000000000', + totalSupply: '10000000000000000000000', + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_MEX, wegldMexPair); + + tokens.set(TEST_TOKEN_IDS.WEGLD, Tokens(TEST_TOKEN_IDS.WEGLD)); + tokens.set(TEST_TOKEN_IDS.USDC, Tokens(TEST_TOKEN_IDS.USDC)); + tokens.set(TEST_TOKEN_IDS.MEX, Tokens(TEST_TOKEN_IDS.MEX)); + + addLpTokensForPairs(); + + service.recomputeAllValues(pairs, tokens, 1.0, [ + TEST_TOKEN_IDS.WEGLD, + TEST_TOKEN_IDS.USDC, + ]); + + // WEGLD liquidity should be sum of locked values across both pairs + const wegldLiquidity = new BigNumber( + tokens.get(TEST_TOKEN_IDS.WEGLD).liquidityUSD, + ); + const wegldInUsdc = new BigNumber( + wegldUsdcPair.firstTokenLockedValueUSD, + ); + const wegldInMex = new BigNumber( + wegldMexPair.firstTokenLockedValueUSD, + ); + + expect(wegldLiquidity.toFixed()).toBe( + wegldInUsdc.plus(wegldInMex).toFixed(), + ); + }); + + it('should include inactive pairs with both common tokens in liquidity calculation', () => { + // WEGLD-USDC pair (active) + const wegldUsdcPairActive = createMockPair( + TEST_ADDRESSES.PAIR_EGLD_USDC, + { + firstTokenId: TEST_TOKEN_IDS.WEGLD, + secondTokenId: TEST_TOKEN_IDS.USDC, + state: 'Active', + }, + ); + wegldUsdcPairActive.info = new PairInfoModel({ + reserves0: '100000000000000000000', + reserves1: '1000000000', + totalSupply: '10000000000000000000000', + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_USDC, wegldUsdcPairActive); + + // WEGLD-USDT pair (inactive but both common) + const wegldUsdtPairInactive = createMockPair( + TEST_ADDRESSES.PAIR_EGLD_USDT, + { + firstTokenId: TEST_TOKEN_IDS.WEGLD, + secondTokenId: TEST_TOKEN_IDS.USDT, + state: 'Inactive', + }, + ); + wegldUsdtPairInactive.info = new PairInfoModel({ + reserves0: '50000000000000000000', + reserves1: '500000000', + totalSupply: '5000000000000000000000', + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_USDT, wegldUsdtPairInactive); + + tokens.set(TEST_TOKEN_IDS.WEGLD, Tokens(TEST_TOKEN_IDS.WEGLD)); + tokens.set(TEST_TOKEN_IDS.USDC, Tokens(TEST_TOKEN_IDS.USDC)); + tokens.set(TEST_TOKEN_IDS.USDT, Tokens(TEST_TOKEN_IDS.USDT)); + + addLpTokensForPairs(); + + service.recomputeAllValues( + pairs, + tokens, + 1.0, + [ + TEST_TOKEN_IDS.WEGLD, + TEST_TOKEN_IDS.USDC, + TEST_TOKEN_IDS.USDT, + ], // All common + ); + + // WEGLD liquidity should include both pairs + const wegldLiquidity = new BigNumber( + tokens.get(TEST_TOKEN_IDS.WEGLD).liquidityUSD, + ); + const wegldInUsdc = new BigNumber( + wegldUsdcPairActive.firstTokenLockedValueUSD, + ); + const wegldInUsdt = new BigNumber( + wegldUsdtPairInactive.firstTokenLockedValueUSD, + ); + + expect(wegldLiquidity.toFixed()).toBe( + wegldInUsdc.plus(wegldInUsdt).toFixed(), + ); + }); + + it('should include only common token side for inactive pairs with one common token', () => { + // WEGLD-USDC pair + const wegldUsdcPair = createMockPair( + TEST_ADDRESSES.PAIR_EGLD_USDC, + { + firstTokenId: TEST_TOKEN_IDS.WEGLD, + secondTokenId: TEST_TOKEN_IDS.USDC, + }, + ); + wegldUsdcPair.info = new PairInfoModel({ + reserves0: '100000000000000000000', + reserves1: '1000000000', + totalSupply: '10000000000000000000000', + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_USDC, wegldUsdcPair); + + // WEGLD-MEX pair (inactive, only WEGLD is common) + const wegldMexPair = createMockPair(TEST_ADDRESSES.PAIR_EGLD_MEX, { + firstTokenId: TEST_TOKEN_IDS.WEGLD, + secondTokenId: TEST_TOKEN_IDS.MEX, + state: 'Inactive', + }); + wegldMexPair.info = new PairInfoModel({ + reserves0: '50000000000000000000', + reserves1: '500000000000000000000000', + totalSupply: '10000000000000000000000', + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_MEX, wegldMexPair); + + tokens.set(TEST_TOKEN_IDS.WEGLD, Tokens(TEST_TOKEN_IDS.WEGLD)); + tokens.set(TEST_TOKEN_IDS.USDC, Tokens(TEST_TOKEN_IDS.USDC)); + tokens.set(TEST_TOKEN_IDS.MEX, Tokens(TEST_TOKEN_IDS.MEX)); + + addLpTokensForPairs(); + + service.recomputeAllValues( + pairs, + tokens, + 1.0, + [TEST_TOKEN_IDS.WEGLD, TEST_TOKEN_IDS.USDC], // Only WEGLD and USDC common + ); + + // WEGLD liquidity should include common token side from MEX pair + const wegldLiquidity = new BigNumber( + tokens.get(TEST_TOKEN_IDS.WEGLD).liquidityUSD, + ); + const wegldInUsdc = new BigNumber( + wegldUsdcPair.firstTokenLockedValueUSD, + ); + const wegldInMex = new BigNumber( + wegldMexPair.firstTokenLockedValueUSD, + ); + + expect(wegldLiquidity.toFixed()).toBe( + wegldInUsdc.plus(wegldInMex).toFixed(), + ); + }); + + it('should exclude inactive pairs with no common tokens from liquidity calculation', () => { + // WEGLD-USDC pair + const wegldUsdcPair = createMockPair( + TEST_ADDRESSES.PAIR_EGLD_USDC, + { + firstTokenId: TEST_TOKEN_IDS.WEGLD, + secondTokenId: TEST_TOKEN_IDS.USDC, + }, + ); + wegldUsdcPair.info = new PairInfoModel({ + reserves0: '100000000000000000000', + reserves1: '1000000000', + totalSupply: '10000000000000000000000', + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_USDC, wegldUsdcPair); + + // MEX-TOK4 pair (inactive, no common tokens) + const mexTok4Pair = createMockPair(TEST_ADDRESSES.PAIR_EGLD_MEX, { + firstTokenId: TEST_TOKEN_IDS.MEX, + secondTokenId: TEST_TOKEN_IDS.TOK4, + state: 'Inactive', + }); + mexTok4Pair.info = new PairInfoModel({ + reserves0: '100000000000000000000000', + reserves1: '50000000000000000000', + totalSupply: '10000000000000000000000', + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_MEX, mexTok4Pair); + + tokens.set(TEST_TOKEN_IDS.WEGLD, Tokens(TEST_TOKEN_IDS.WEGLD)); + tokens.set(TEST_TOKEN_IDS.USDC, Tokens(TEST_TOKEN_IDS.USDC)); + tokens.set(TEST_TOKEN_IDS.MEX, Tokens(TEST_TOKEN_IDS.MEX)); + tokens.set(TEST_TOKEN_IDS.TOK4, Tokens(TEST_TOKEN_IDS.TOK4)); + + addLpTokensForPairs(); + + service.recomputeAllValues(pairs, tokens, 1.0, [ + TEST_TOKEN_IDS.WEGLD, + TEST_TOKEN_IDS.USDC, + ]); + + // MEX liquidity should be 0 because its only pair has no common tokens and is inactive + expect(tokens.get(TEST_TOKEN_IDS.MEX).liquidityUSD).toBe('0'); + }); + }); + + describe('edge cases', () => { + it('should handle zero reserves gracefully', () => { + const pair = createMockPair(TEST_ADDRESSES.PAIR_EGLD_USDC, { + firstTokenId: TEST_TOKEN_IDS.WEGLD, + secondTokenId: TEST_TOKEN_IDS.USDC, + }); + pair.info = new PairInfoModel({ + reserves0: '0', + reserves1: '0', + totalSupply: '0', + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_USDC, pair); + + tokens.set(TEST_TOKEN_IDS.WEGLD, Tokens(TEST_TOKEN_IDS.WEGLD)); + tokens.set(TEST_TOKEN_IDS.USDC, Tokens(TEST_TOKEN_IDS.USDC)); + + expect(() => { + addLpTokensForPairs(); + + service.recomputeAllValues(pairs, tokens, 1.0, [ + TEST_TOKEN_IDS.WEGLD, + TEST_TOKEN_IDS.USDC, + ]); + }).not.toThrow(); + + // Prices should be computed even with zero reserves (may be zero or Infinity) + expect(pair.firstTokenPrice).toBeDefined(); + expect(pair.secondTokenPrice).toBeDefined(); + }); + + it('should handle zero total supply in LP price calculation', () => { + const pair = createMockPair(TEST_ADDRESSES.PAIR_EGLD_USDC, { + firstTokenId: TEST_TOKEN_IDS.WEGLD, + secondTokenId: TEST_TOKEN_IDS.USDC, + liquidityPoolTokenId: TEST_TOKEN_IDS.LP_EGLD_USDC, + }); + pair.info = new PairInfoModel({ + reserves0: '100000000000000000000', + reserves1: '1000000000', + totalSupply: '0', // Zero supply + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_USDC, pair); + + tokens.set(TEST_TOKEN_IDS.WEGLD, Tokens(TEST_TOKEN_IDS.WEGLD)); + tokens.set(TEST_TOKEN_IDS.USDC, Tokens(TEST_TOKEN_IDS.USDC)); + + const lpToken = createMockToken(TEST_TOKEN_IDS.LP_EGLD_USDC, { + type: EsdtTokenType.FungibleLpToken, + pairAddress: TEST_ADDRESSES.PAIR_EGLD_USDC, + }); + tokens.set(TEST_TOKEN_IDS.LP_EGLD_USDC, lpToken); + + expect(() => { + addLpTokensForPairs(); + + service.recomputeAllValues(pairs, tokens, 1.0, [ + TEST_TOKEN_IDS.WEGLD, + TEST_TOKEN_IDS.USDC, + ]); + }).not.toThrow(); + + // LP token price may be Infinity or NaN with zero total supply + expect(pair.liquidityPoolTokenPriceUSD).toBeDefined(); + }); + + it('should handle tokens with no pairs gracefully with derivedEGLD = 0', () => { + // WEGLD-USDC pair + const wegldUsdcPair = createMockPair( + TEST_ADDRESSES.PAIR_EGLD_USDC, + { + firstTokenId: TEST_TOKEN_IDS.WEGLD, + secondTokenId: TEST_TOKEN_IDS.USDC, + liquidityPoolTokenId: TEST_TOKEN_IDS.LP_EGLD_USDC, + }, + ); + wegldUsdcPair.info = new PairInfoModel({ + reserves0: '100000000000000000000', + reserves1: '1000000000', + totalSupply: '10000000000000000000000', + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_USDC, wegldUsdcPair); + + // MEX-WEGLD pair so MEX has at least one pair + const mexWegldPair = createMockPair(TEST_ADDRESSES.PAIR_EGLD_MEX, { + firstTokenId: TEST_TOKEN_IDS.WEGLD, + secondTokenId: TEST_TOKEN_IDS.MEX, + liquidityPoolTokenId: TEST_TOKEN_IDS.LP_EGLD_MEX, + state: 'Inactive', + }); + mexWegldPair.info = new PairInfoModel({ + reserves0: '0', // Zero reserves = no liquidity + reserves1: '0', + totalSupply: '0', + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_MEX, mexWegldPair); + + tokens.set(TEST_TOKEN_IDS.WEGLD, Tokens(TEST_TOKEN_IDS.WEGLD)); + tokens.set(TEST_TOKEN_IDS.USDC, Tokens(TEST_TOKEN_IDS.USDC)); + // MEX with an inactive zero-liquidity pair + tokens.set(TEST_TOKEN_IDS.MEX, Tokens(TEST_TOKEN_IDS.MEX)); + + addLpTokensForPairs(); + + service.recomputeAllValues(pairs, tokens, 1.0, [ + TEST_TOKEN_IDS.WEGLD, + TEST_TOKEN_IDS.USDC, + ]); + + // MEX should have derivedEGLD = 0 due to zero total supply pair + expect(tokens.get(TEST_TOKEN_IDS.MEX).derivedEGLD).toBe('0'); + expect(tokens.get(TEST_TOKEN_IDS.MEX).price).toBe('0'); + }); + + it('should not revisit pairs in circular references', () => { + // Create circular reference: WEGLD -> USDC -> MEX -> WEGLD + // This tests the doNotVisit Set + const wegldUsdcPair = createMockPair( + TEST_ADDRESSES.PAIR_EGLD_USDC, + { + firstTokenId: TEST_TOKEN_IDS.WEGLD, + secondTokenId: TEST_TOKEN_IDS.USDC, + state: 'Active', + }, + ); + wegldUsdcPair.info = new PairInfoModel({ + reserves0: '100000000000000000000', + reserves1: '1000000000', + totalSupply: '10000000000000000000000', + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_USDC, wegldUsdcPair); + + const usdcMexPair = createMockPair(TEST_ADDRESSES.PAIR_EGLD_MEX, { + firstTokenId: TEST_TOKEN_IDS.USDC, + secondTokenId: TEST_TOKEN_IDS.MEX, + state: 'Active', + }); + usdcMexPair.info = new PairInfoModel({ + reserves0: '500000000', + reserves1: '5000000000000000000000', + totalSupply: '5000000000000000000000', + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_MEX, usdcMexPair); + + const mexWegldPair = createMockPair(TEST_ADDRESSES.PAIR_TOK4_EGLD, { + firstTokenId: TEST_TOKEN_IDS.MEX, + secondTokenId: TEST_TOKEN_IDS.WEGLD, + state: 'Active', + }); + mexWegldPair.info = new PairInfoModel({ + reserves0: '10000000000000000000000', + reserves1: '10000000000000000000', + totalSupply: '10000000000000000000000', + }); + pairs.set(TEST_ADDRESSES.PAIR_TOK4_EGLD, mexWegldPair); + + tokens.set(TEST_TOKEN_IDS.WEGLD, Tokens(TEST_TOKEN_IDS.WEGLD)); + tokens.set(TEST_TOKEN_IDS.USDC, Tokens(TEST_TOKEN_IDS.USDC)); + tokens.set(TEST_TOKEN_IDS.MEX, Tokens(TEST_TOKEN_IDS.MEX)); + + // Should not hang or crash + expect(() => { + addLpTokensForPairs(); + + service.recomputeAllValues(pairs, tokens, 1.0, [ + TEST_TOKEN_IDS.WEGLD, + TEST_TOKEN_IDS.USDC, + ]); + }).not.toThrow(); + + // All tokens should have valid prices + expect( + new BigNumber(tokens.get(TEST_TOKEN_IDS.WEGLD).price).gte(0), + ).toBe(true); + expect( + new BigNumber(tokens.get(TEST_TOKEN_IDS.USDC).price).gte(0), + ).toBe(true); + expect( + new BigNumber(tokens.get(TEST_TOKEN_IDS.MEX).price).gte(0), + ).toBe(true); + }); + + it('should skip pairs with zero totalSupply in derived EGLD calculation', () => { + const wegldUsdcPair = createMockPair( + TEST_ADDRESSES.PAIR_EGLD_USDC, + { + firstTokenId: TEST_TOKEN_IDS.WEGLD, + secondTokenId: TEST_TOKEN_IDS.USDC, + }, + ); + wegldUsdcPair.info = new PairInfoModel({ + reserves0: '100000000000000000000', + reserves1: '1000000000', + totalSupply: '10000000000000000000000', + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_USDC, wegldUsdcPair); + + // MEX-WEGLD pair with zero totalSupply (should be skipped) + const mexWegldPair = createMockPair(TEST_ADDRESSES.PAIR_EGLD_MEX, { + firstTokenId: TEST_TOKEN_IDS.MEX, + secondTokenId: TEST_TOKEN_IDS.WEGLD, + state: 'Active', + }); + mexWegldPair.info = new PairInfoModel({ + reserves0: '1000000000000000000000000', + reserves1: '100000000000000000000', + totalSupply: '0', // Zero supply - should be skipped + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_MEX, mexWegldPair); + + tokens.set(TEST_TOKEN_IDS.WEGLD, Tokens(TEST_TOKEN_IDS.WEGLD)); + tokens.set(TEST_TOKEN_IDS.USDC, Tokens(TEST_TOKEN_IDS.USDC)); + tokens.set(TEST_TOKEN_IDS.MEX, Tokens(TEST_TOKEN_IDS.MEX)); + + addLpTokensForPairs(); + + service.recomputeAllValues(pairs, tokens, 1.0, [ + TEST_TOKEN_IDS.WEGLD, + TEST_TOKEN_IDS.USDC, + ]); + + // MEX should have derivedEGLD = 0 because its only pair has zero supply + expect(tokens.get(TEST_TOKEN_IDS.MEX).derivedEGLD).toBe('0'); + }); + + it('should track updated tokens correctly', () => { + const pair = createMockPair(TEST_ADDRESSES.PAIR_EGLD_USDC, { + firstTokenId: TEST_TOKEN_IDS.WEGLD, + secondTokenId: TEST_TOKEN_IDS.USDC, + }); + pair.info = new PairInfoModel({ + reserves0: '100000000000000000000', + reserves1: '1000000000', + totalSupply: '10000000000000000000000', + }); + pairs.set(TEST_ADDRESSES.PAIR_EGLD_USDC, pair); + + const wegld = Tokens(TEST_TOKEN_IDS.WEGLD); + wegld.price = '10'; // Set initial price + wegld.derivedEGLD = '1'; + tokens.set(TEST_TOKEN_IDS.WEGLD, wegld); + + const usdc = Tokens(TEST_TOKEN_IDS.USDC); + usdc.price = '0'; // Different price - will be updated + usdc.derivedEGLD = '0'; + tokens.set(TEST_TOKEN_IDS.USDC, usdc); + + addLpTokensForPairs(); + + const updatedTokens = service.recomputeAllValues( + pairs, + tokens, + 1.0, + [TEST_TOKEN_IDS.WEGLD, TEST_TOKEN_IDS.USDC], + ); + + // USDC should be in updated list because price changed + expect(updatedTokens).toContain(TEST_TOKEN_IDS.USDC); + + // If WEGLD price didn't change, it might not be in the list + // (depends on computation result) + }); + }); +}); diff --git a/src/microservices/dex-state/specs/state.store.spec.ts b/src/microservices/dex-state/specs/state.store.spec.ts new file mode 100644 index 000000000..f9e811466 --- /dev/null +++ b/src/microservices/dex-state/specs/state.store.spec.ts @@ -0,0 +1,594 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { StateStore } from '../services/state.store'; +import { EsdtTokenType } from 'src/modules/tokens/models/esdtToken.model'; +import { + createMockToken, + createMockPair, + createMockFarm, + createMockStakingFarm, + createMockStakingProxy, + createMockFeesCollector, + TEST_ADDRESSES, + TEST_TOKEN_IDS, +} from './test.utils'; + +describe('StateStore', () => { + let module: TestingModule; + let stateStore: StateStore; + + beforeEach(async () => { + module = await Test.createTestingModule({ + providers: [StateStore], + }).compile(); + + stateStore = module.get(StateStore); + }); + + afterEach(() => { + stateStore.clearAll(); + }); + + describe('Token Operations', () => { + it('should set and get a token', () => { + const token = createMockToken(TEST_TOKEN_IDS.WEGLD); + stateStore.setToken(TEST_TOKEN_IDS.WEGLD, token); + + const result = stateStore.tokens.get(TEST_TOKEN_IDS.WEGLD); + expect(result).toBeDefined(); + expect(result?.identifier).toBe(TEST_TOKEN_IDS.WEGLD); + }); + + it('should overwrite existing token', () => { + const token1 = createMockToken(TEST_TOKEN_IDS.WEGLD, { price: '10' }); + const token2 = createMockToken(TEST_TOKEN_IDS.WEGLD, { price: '20' }); + + stateStore.setToken(TEST_TOKEN_IDS.WEGLD, token1); + stateStore.setToken(TEST_TOKEN_IDS.WEGLD, token2); + + const result = stateStore.tokens.get(TEST_TOKEN_IDS.WEGLD); + expect(result?.price).toBe('20'); + }); + + it('should return undefined for non-existent token', () => { + const result = stateStore.tokens.get('NON-EXISTENT'); + expect(result).toBeUndefined(); + }); + + it('should get all tokens', () => { + stateStore.setToken(TEST_TOKEN_IDS.WEGLD, createMockToken(TEST_TOKEN_IDS.WEGLD)); + stateStore.setToken(TEST_TOKEN_IDS.USDC, createMockToken(TEST_TOKEN_IDS.USDC)); + stateStore.setToken(TEST_TOKEN_IDS.MEX, createMockToken(TEST_TOKEN_IDS.MEX)); + + expect(stateStore.tokens.size).toBe(3); + }); + + it('should check if token exists using Map.has', () => { + stateStore.setToken(TEST_TOKEN_IDS.WEGLD, createMockToken(TEST_TOKEN_IDS.WEGLD)); + + expect(stateStore.tokens.has(TEST_TOKEN_IDS.WEGLD)).toBe(true); + expect(stateStore.tokens.has('NON-EXISTENT')).toBe(false); + }); + + it('should iterate over all tokens', () => { + const tokenIds = [TEST_TOKEN_IDS.WEGLD, TEST_TOKEN_IDS.USDC, TEST_TOKEN_IDS.MEX]; + tokenIds.forEach(id => stateStore.setToken(id, createMockToken(id))); + + const retrievedIds: string[] = []; + stateStore.tokens.forEach((_, key) => retrievedIds.push(key)); + + expect(retrievedIds).toHaveLength(3); + expect(retrievedIds).toContain(TEST_TOKEN_IDS.WEGLD); + expect(retrievedIds).toContain(TEST_TOKEN_IDS.USDC); + expect(retrievedIds).toContain(TEST_TOKEN_IDS.MEX); + }); + }); + + describe('Pair Operations', () => { + it('should set and get a pair', () => { + const pair = createMockPair(TEST_ADDRESSES.PAIR_EGLD_MEX); + stateStore.setPair(TEST_ADDRESSES.PAIR_EGLD_MEX, pair); + + const result = stateStore.pairs.get(TEST_ADDRESSES.PAIR_EGLD_MEX); + expect(result).toBeDefined(); + expect(result?.address).toBe(TEST_ADDRESSES.PAIR_EGLD_MEX); + }); + + it('should overwrite existing pair', () => { + const pair1 = createMockPair(TEST_ADDRESSES.PAIR_EGLD_MEX, { lockedValueUSD: '1000000' }); + const pair2 = createMockPair(TEST_ADDRESSES.PAIR_EGLD_MEX, { lockedValueUSD: '2000000' }); + + stateStore.setPair(TEST_ADDRESSES.PAIR_EGLD_MEX, pair1); + stateStore.setPair(TEST_ADDRESSES.PAIR_EGLD_MEX, pair2); + + const result = stateStore.pairs.get(TEST_ADDRESSES.PAIR_EGLD_MEX); + expect(result?.lockedValueUSD).toBe('2000000'); + }); + + it('should return undefined for non-existent pair', () => { + const result = stateStore.pairs.get('erd1nonexistent'); + expect(result).toBeUndefined(); + }); + + it('should get all pairs', () => { + stateStore.setPair(TEST_ADDRESSES.PAIR_EGLD_MEX, createMockPair(TEST_ADDRESSES.PAIR_EGLD_MEX)); + stateStore.setPair(TEST_ADDRESSES.PAIR_EGLD_USDC, createMockPair(TEST_ADDRESSES.PAIR_EGLD_USDC)); + + expect(stateStore.pairs.size).toBe(2); + }); + + it('should iterate over all pairs using forEach', () => { + stateStore.setPair(TEST_ADDRESSES.PAIR_EGLD_MEX, createMockPair(TEST_ADDRESSES.PAIR_EGLD_MEX)); + stateStore.setPair(TEST_ADDRESSES.PAIR_EGLD_USDC, createMockPair(TEST_ADDRESSES.PAIR_EGLD_USDC)); + + const addresses: string[] = []; + stateStore.pairs.forEach((_, key) => addresses.push(key)); + + expect(addresses).toHaveLength(2); + expect(addresses).toContain(TEST_ADDRESSES.PAIR_EGLD_MEX); + expect(addresses).toContain(TEST_ADDRESSES.PAIR_EGLD_USDC); + }); + }); + + describe('Farm Operations', () => { + it('should set and get a farm', () => { + const farm = createMockFarm(TEST_ADDRESSES.FARM_1); + stateStore.setFarm(TEST_ADDRESSES.FARM_1, farm); + + const result = stateStore.farms.get(TEST_ADDRESSES.FARM_1); + expect(result).toBeDefined(); + expect(result?.address).toBe(TEST_ADDRESSES.FARM_1); + }); + + it('should overwrite existing farm', () => { + const farm1 = createMockFarm(TEST_ADDRESSES.FARM_1, { baseApr: '50' }); + const farm2 = createMockFarm(TEST_ADDRESSES.FARM_1, { baseApr: '75' }); + + stateStore.setFarm(TEST_ADDRESSES.FARM_1, farm1); + stateStore.setFarm(TEST_ADDRESSES.FARM_1, farm2); + + const result = stateStore.farms.get(TEST_ADDRESSES.FARM_1); + expect(result?.baseApr).toBe('75'); + }); + + it('should return undefined for non-existent farm', () => { + const result = stateStore.farms.get('erd1nonexistent'); + expect(result).toBeUndefined(); + }); + + it('should get all farms', () => { + stateStore.setFarm(TEST_ADDRESSES.FARM_1, createMockFarm(TEST_ADDRESSES.FARM_1)); + stateStore.setFarm(TEST_ADDRESSES.FARM_2, createMockFarm(TEST_ADDRESSES.FARM_2)); + + expect(stateStore.farms.size).toBe(2); + }); + }); + + describe('Staking Farm Operations', () => { + it('should set and get a staking farm', () => { + const stakingFarm = createMockStakingFarm(TEST_ADDRESSES.STAKING_1); + stateStore.setStakingFarm(TEST_ADDRESSES.STAKING_1, stakingFarm); + + const result = stateStore.stakingFarms.get(TEST_ADDRESSES.STAKING_1); + expect(result).toBeDefined(); + expect(result?.address).toBe(TEST_ADDRESSES.STAKING_1); + }); + + it('should overwrite existing staking farm', () => { + const farm1 = createMockStakingFarm(TEST_ADDRESSES.STAKING_1, { apr: '25' }); + const farm2 = createMockStakingFarm(TEST_ADDRESSES.STAKING_1, { apr: '30' }); + + stateStore.setStakingFarm(TEST_ADDRESSES.STAKING_1, farm1); + stateStore.setStakingFarm(TEST_ADDRESSES.STAKING_1, farm2); + + const result = stateStore.stakingFarms.get(TEST_ADDRESSES.STAKING_1); + expect(result?.apr).toBe('30'); + }); + + it('should return undefined for non-existent staking farm', () => { + const result = stateStore.stakingFarms.get('erd1nonexistent'); + expect(result).toBeUndefined(); + }); + + it('should get all staking farms', () => { + stateStore.setStakingFarm(TEST_ADDRESSES.STAKING_1, createMockStakingFarm(TEST_ADDRESSES.STAKING_1)); + stateStore.setStakingFarm(TEST_ADDRESSES.STAKING_2, createMockStakingFarm(TEST_ADDRESSES.STAKING_2)); + + expect(stateStore.stakingFarms.size).toBe(2); + }); + }); + + describe('Staking Proxy Operations', () => { + it('should set and get a staking proxy', () => { + const proxy = createMockStakingProxy(TEST_ADDRESSES.STAKING_PROXY_1); + stateStore.setStakingProxy(TEST_ADDRESSES.STAKING_PROXY_1, proxy); + + const result = stateStore.stakingProxies.get(TEST_ADDRESSES.STAKING_PROXY_1); + expect(result).toBeDefined(); + expect(result?.address).toBe(TEST_ADDRESSES.STAKING_PROXY_1); + }); + + it('should overwrite existing staking proxy', () => { + const proxy1 = createMockStakingProxy(TEST_ADDRESSES.STAKING_PROXY_1, { stakingMinUnboundEpochs: 10 }); + const proxy2 = createMockStakingProxy(TEST_ADDRESSES.STAKING_PROXY_1, { stakingMinUnboundEpochs: 15 }); + + stateStore.setStakingProxy(TEST_ADDRESSES.STAKING_PROXY_1, proxy1); + stateStore.setStakingProxy(TEST_ADDRESSES.STAKING_PROXY_1, proxy2); + + const result = stateStore.stakingProxies.get(TEST_ADDRESSES.STAKING_PROXY_1); + expect(result?.stakingMinUnboundEpochs).toBe(15); + }); + + it('should get all staking proxies', () => { + stateStore.setStakingProxy(TEST_ADDRESSES.STAKING_PROXY_1, createMockStakingProxy(TEST_ADDRESSES.STAKING_PROXY_1)); + + expect(stateStore.stakingProxies.size).toBe(1); + }); + }); + + describe('Fees Collector Operations', () => { + it('should set and get fees collector', () => { + const feesCollector = createMockFeesCollector(); + stateStore.setFeesCollector(feesCollector); + + const result = stateStore.feesCollector; + expect(result).toBeDefined(); + expect(result?.address).toBe(TEST_ADDRESSES.FEES_COLLECTOR); + }); + + it('should overwrite existing fees collector', () => { + const fc1 = createMockFeesCollector({ startWeek: 1 }); + const fc2 = createMockFeesCollector({ startWeek: 5 }); + + stateStore.setFeesCollector(fc1); + stateStore.setFeesCollector(fc2); + + expect(stateStore.feesCollector?.startWeek).toBe(5); + }); + + it('should return undefined when fees collector not set', () => { + expect(stateStore.feesCollector).toBeUndefined(); + }); + }); + + describe('Index Management - Token Pairs', () => { + it('should add token pair mapping', () => { + stateStore.addTokenPair(TEST_TOKEN_IDS.WEGLD, TEST_ADDRESSES.PAIR_EGLD_MEX); + + const pairs = stateStore.tokenPairs.get(TEST_TOKEN_IDS.WEGLD); + expect(pairs).toContain(TEST_ADDRESSES.PAIR_EGLD_MEX); + }); + + it('should add multiple pairs for same token', () => { + stateStore.addTokenPair(TEST_TOKEN_IDS.WEGLD, TEST_ADDRESSES.PAIR_EGLD_MEX); + stateStore.addTokenPair(TEST_TOKEN_IDS.WEGLD, TEST_ADDRESSES.PAIR_EGLD_USDC); + + const pairs = stateStore.tokenPairs.get(TEST_TOKEN_IDS.WEGLD); + expect(pairs).toHaveLength(2); + expect(pairs).toContain(TEST_ADDRESSES.PAIR_EGLD_MEX); + expect(pairs).toContain(TEST_ADDRESSES.PAIR_EGLD_USDC); + }); + + it('should not add duplicate pair addresses', () => { + stateStore.addTokenPair(TEST_TOKEN_IDS.WEGLD, TEST_ADDRESSES.PAIR_EGLD_MEX); + stateStore.addTokenPair(TEST_TOKEN_IDS.WEGLD, TEST_ADDRESSES.PAIR_EGLD_MEX); + + const pairs = stateStore.tokenPairs.get(TEST_TOKEN_IDS.WEGLD); + expect(pairs).toHaveLength(1); + expect(pairs).toContain(TEST_ADDRESSES.PAIR_EGLD_MEX); + }); + + it('should return undefined for token with no pairs', () => { + const pairs = stateStore.tokenPairs.get(TEST_TOKEN_IDS.MEX); + expect(pairs).toBeUndefined(); + }); + }); + + describe('Index Management - Tokens By Type', () => { + it('should add token by type', () => { + stateStore.addTokenByType(EsdtTokenType.FungibleToken, TEST_TOKEN_IDS.WEGLD); + + const tokens = stateStore.tokensByType.get(EsdtTokenType.FungibleToken); + expect(tokens).toContain(TEST_TOKEN_IDS.WEGLD); + }); + + it('should add multiple tokens of same type', () => { + stateStore.addTokenByType(EsdtTokenType.FungibleToken, TEST_TOKEN_IDS.WEGLD); + stateStore.addTokenByType(EsdtTokenType.FungibleToken, TEST_TOKEN_IDS.USDC); + + const tokens = stateStore.tokensByType.get(EsdtTokenType.FungibleToken); + expect(tokens).toHaveLength(2); + }); + + it('should handle LP token type separately', () => { + stateStore.addTokenByType(EsdtTokenType.FungibleToken, TEST_TOKEN_IDS.WEGLD); + stateStore.addTokenByType(EsdtTokenType.FungibleLpToken, TEST_TOKEN_IDS.LP_EGLD_MEX); + + expect(stateStore.tokensByType.get(EsdtTokenType.FungibleToken)).toContain(TEST_TOKEN_IDS.WEGLD); + expect(stateStore.tokensByType.get(EsdtTokenType.FungibleLpToken)).toContain(TEST_TOKEN_IDS.LP_EGLD_MEX); + }); + }); + + describe('Index Management - Active Pairs', () => { + it('should add active pair', () => { + stateStore.addActivePair(TEST_ADDRESSES.PAIR_EGLD_MEX); + + expect(stateStore.activePairs.has(TEST_ADDRESSES.PAIR_EGLD_MEX)).toBe(true); + }); + + it('should deduplicate active pairs (Set behavior)', () => { + stateStore.addActivePair(TEST_ADDRESSES.PAIR_EGLD_MEX); + stateStore.addActivePair(TEST_ADDRESSES.PAIR_EGLD_MEX); + + expect(stateStore.activePairs.size).toBe(1); + }); + + it('should track multiple active pairs', () => { + stateStore.addActivePair(TEST_ADDRESSES.PAIR_EGLD_MEX); + stateStore.addActivePair(TEST_ADDRESSES.PAIR_EGLD_USDC); + + expect(stateStore.activePairs.size).toBe(2); + expect(stateStore.activePairs.has(TEST_ADDRESSES.PAIR_EGLD_MEX)).toBe(true); + expect(stateStore.activePairs.has(TEST_ADDRESSES.PAIR_EGLD_USDC)).toBe(true); + }); + }); + + describe('Index Management - Active Pairs Tokens', () => { + it('should add active pairs token', () => { + stateStore.addActivePairsToken(TEST_TOKEN_IDS.WEGLD); + + expect(stateStore.activePairsTokens.has(TEST_TOKEN_IDS.WEGLD)).toBe(true); + }); + + it('should deduplicate active pairs tokens (Set behavior)', () => { + stateStore.addActivePairsToken(TEST_TOKEN_IDS.WEGLD); + stateStore.addActivePairsToken(TEST_TOKEN_IDS.WEGLD); + + expect(stateStore.activePairsTokens.size).toBe(1); + }); + + it('should track multiple active pairs tokens', () => { + stateStore.addActivePairsToken(TEST_TOKEN_IDS.WEGLD); + stateStore.addActivePairsToken(TEST_TOKEN_IDS.USDC); + + expect(stateStore.activePairsTokens.size).toBe(2); + }); + }); + + describe('Index Management - Farm Pairs', () => { + it('should add farm to pair mapping', () => { + stateStore.addFarmPair(TEST_ADDRESSES.FARM_1, TEST_ADDRESSES.PAIR_EGLD_MEX); + + expect(stateStore.farmsPairs.get(TEST_ADDRESSES.FARM_1)).toBe(TEST_ADDRESSES.PAIR_EGLD_MEX); + }); + + it('should overwrite existing farm pair mapping', () => { + stateStore.addFarmPair(TEST_ADDRESSES.FARM_1, TEST_ADDRESSES.PAIR_EGLD_MEX); + stateStore.addFarmPair(TEST_ADDRESSES.FARM_1, TEST_ADDRESSES.PAIR_EGLD_USDC); + + expect(stateStore.farmsPairs.get(TEST_ADDRESSES.FARM_1)).toBe(TEST_ADDRESSES.PAIR_EGLD_USDC); + }); + + it('should return undefined for farm with no pair mapping', () => { + expect(stateStore.farmsPairs.get(TEST_ADDRESSES.FARM_1)).toBeUndefined(); + }); + }); + + describe('Index Management - Staking Farm Pairs', () => { + it('should add staking farm to pair mapping', () => { + stateStore.addStakingFarmPair(TEST_ADDRESSES.STAKING_1, TEST_ADDRESSES.PAIR_EGLD_MEX); + + expect(stateStore.stakingFarmsPairs.get(TEST_ADDRESSES.STAKING_1)).toBe(TEST_ADDRESSES.PAIR_EGLD_MEX); + }); + + it('should overwrite existing staking farm pair mapping', () => { + stateStore.addStakingFarmPair(TEST_ADDRESSES.STAKING_1, TEST_ADDRESSES.PAIR_EGLD_MEX); + stateStore.addStakingFarmPair(TEST_ADDRESSES.STAKING_1, TEST_ADDRESSES.PAIR_EGLD_USDC); + + expect(stateStore.stakingFarmsPairs.get(TEST_ADDRESSES.STAKING_1)).toBe(TEST_ADDRESSES.PAIR_EGLD_USDC); + }); + }); + + describe('Scalar Values - USDC Price', () => { + it('should set and get USDC price', () => { + stateStore.setUsdcPrice(1.0); + + expect(stateStore.usdcPrice).toBe(1.0); + }); + + it('should default to 0', () => { + expect(stateStore.usdcPrice).toBe(0); + }); + + it('should handle decimal prices', () => { + stateStore.setUsdcPrice(0.9998); + + expect(stateStore.usdcPrice).toBe(0.9998); + }); + }); + + describe('Scalar Values - Common Token IDs', () => { + it('should set and get common token IDs', () => { + const tokenIds = [TEST_TOKEN_IDS.WEGLD, TEST_TOKEN_IDS.USDC]; + stateStore.setCommonTokenIDs(tokenIds); + + expect(stateStore.commonTokenIDs).toEqual(tokenIds); + }); + + it('should default to empty array', () => { + expect(stateStore.commonTokenIDs).toEqual([]); + }); + + it('should overwrite existing common token IDs', () => { + stateStore.setCommonTokenIDs([TEST_TOKEN_IDS.WEGLD]); + stateStore.setCommonTokenIDs([TEST_TOKEN_IDS.USDC, TEST_TOKEN_IDS.MEX]); + + expect(stateStore.commonTokenIDs).toEqual([TEST_TOKEN_IDS.USDC, TEST_TOKEN_IDS.MEX]); + }); + }); + + describe('Scalar Values - Locked Token Collection', () => { + it('should set and get locked token collection', () => { + stateStore.setLockedTokenCollection('XMEX-123456'); + + expect(stateStore.lockedTokenCollection).toBe('XMEX-123456'); + }); + + it('should default to undefined', () => { + expect(stateStore.lockedTokenCollection).toBeUndefined(); + }); + }); + + describe('State Management - Initialization', () => { + it('should set initialized flag', () => { + stateStore.setInitialized(true); + + expect(stateStore.isInitialized()).toBe(true); + }); + + it('should default to not initialized', () => { + expect(stateStore.isInitialized()).toBe(false); + }); + + it('should toggle initialized flag', () => { + stateStore.setInitialized(true); + expect(stateStore.isInitialized()).toBe(true); + + stateStore.setInitialized(false); + expect(stateStore.isInitialized()).toBe(false); + }); + }); + + describe('State Management - clearAll', () => { + it('should clear all primary data stores', () => { + stateStore.setToken(TEST_TOKEN_IDS.WEGLD, createMockToken(TEST_TOKEN_IDS.WEGLD)); + stateStore.setPair(TEST_ADDRESSES.PAIR_EGLD_MEX, createMockPair(TEST_ADDRESSES.PAIR_EGLD_MEX)); + stateStore.setFarm(TEST_ADDRESSES.FARM_1, createMockFarm(TEST_ADDRESSES.FARM_1)); + stateStore.setStakingFarm(TEST_ADDRESSES.STAKING_1, createMockStakingFarm(TEST_ADDRESSES.STAKING_1)); + stateStore.setStakingProxy(TEST_ADDRESSES.STAKING_PROXY_1, createMockStakingProxy(TEST_ADDRESSES.STAKING_PROXY_1)); + stateStore.setFeesCollector(createMockFeesCollector()); + + stateStore.clearAll(); + + expect(stateStore.tokens.size).toBe(0); + expect(stateStore.pairs.size).toBe(0); + expect(stateStore.farms.size).toBe(0); + expect(stateStore.stakingFarms.size).toBe(0); + expect(stateStore.stakingProxies.size).toBe(0); + expect(stateStore.feesCollector).toBeUndefined(); + }); + + it('should clear all derived indexes', () => { + stateStore.addTokenPair(TEST_TOKEN_IDS.WEGLD, TEST_ADDRESSES.PAIR_EGLD_MEX); + stateStore.addTokenByType(EsdtTokenType.FungibleToken, TEST_TOKEN_IDS.MEX); + stateStore.addActivePair(TEST_ADDRESSES.PAIR_EGLD_MEX); + stateStore.addActivePairsToken(TEST_TOKEN_IDS.WEGLD); + stateStore.addFarmPair(TEST_ADDRESSES.FARM_1, TEST_ADDRESSES.PAIR_EGLD_MEX); + stateStore.addStakingFarmPair(TEST_ADDRESSES.STAKING_1, TEST_ADDRESSES.PAIR_EGLD_MEX); + + stateStore.clearAll(); + + expect(stateStore.tokenPairs.size).toBe(0); + expect(stateStore.activePairs.size).toBe(0); + expect(stateStore.activePairsTokens.size).toBe(0); + expect(stateStore.farmsPairs.size).toBe(0); + expect(stateStore.stakingFarmsPairs.size).toBe(0); + }); + + it('should initialize tokensByType with empty arrays for FungibleToken and FungibleLpToken', () => { + stateStore.addTokenByType(EsdtTokenType.FungibleToken, TEST_TOKEN_IDS.WEGLD); + stateStore.addTokenByType(EsdtTokenType.FungibleLpToken, TEST_TOKEN_IDS.LP_EGLD_MEX); + + stateStore.clearAll(); + + expect(stateStore.tokensByType.get(EsdtTokenType.FungibleToken)).toEqual([]); + expect(stateStore.tokensByType.get(EsdtTokenType.FungibleLpToken)).toEqual([]); + expect(stateStore.tokensByType.size).toBe(2); + }); + + it('should set initialized to false', () => { + stateStore.setInitialized(true); + + stateStore.clearAll(); + + expect(stateStore.isInitialized()).toBe(false); + }); + }); + + describe('Edge Cases', () => { + it('should throw when setting a token with empty identifier', () => { + const token = createMockToken(''); + + expect(() => stateStore.setToken('', token)).toThrow( + 'Token identifier must not be empty', + ); + expect(stateStore.tokens.has('')).toBe(false); + }); + + it('should handle very long identifiers', () => { + const longId = 'A'.repeat(100); + const token = createMockToken(longId); + stateStore.setToken(longId, token); + + expect(stateStore.tokens.get(longId)?.identifier).toBe(longId); + }); + + it('should preserve object references (mutable)', () => { + const token = createMockToken(TEST_TOKEN_IDS.WEGLD, { price: '10' }); + stateStore.setToken(TEST_TOKEN_IDS.WEGLD, token); + + const retrieved = stateStore.tokens.get(TEST_TOKEN_IDS.WEGLD); + retrieved!.price = '20'; + + const retrievedAgain = stateStore.tokens.get(TEST_TOKEN_IDS.WEGLD); + expect(retrievedAgain?.price).toBe('20'); + }); + + it('should handle concurrent additions to same index', () => { + const operations = [ + () => stateStore.addTokenPair(TEST_TOKEN_IDS.WEGLD, TEST_ADDRESSES.PAIR_EGLD_MEX), + () => stateStore.addTokenPair(TEST_TOKEN_IDS.WEGLD, TEST_ADDRESSES.PAIR_EGLD_USDC), + ]; + + operations.forEach(op => op()); + + const pairs = stateStore.tokenPairs.get(TEST_TOKEN_IDS.WEGLD); + expect(pairs).toHaveLength(2); + }); + + it('should maintain separate Maps for different entity types', () => { + const sameAddress = TEST_ADDRESSES.PAIR_EGLD_MEX; + const pair = createMockPair(sameAddress); + const farm = createMockFarm(sameAddress); + + stateStore.setPair(sameAddress, pair); + stateStore.setFarm(sameAddress, farm); + + expect(stateStore.pairs.get(sameAddress)).toBeDefined(); + expect(stateStore.farms.get(sameAddress)).toBeDefined(); + expect(stateStore.pairs.get(sameAddress)).not.toBe(stateStore.farms.get(sameAddress)); + }); + }); + + describe('Direct Getter Access', () => { + it('should return direct reference to tokens Map', () => { + const tokens1 = stateStore.tokens; + const tokens2 = stateStore.tokens; + + expect(tokens1).toBe(tokens2); + }); + + it('should return direct reference to pairs Map', () => { + const pairs1 = stateStore.pairs; + const pairs2 = stateStore.pairs; + + expect(pairs1).toBe(pairs2); + }); + + it('should return direct reference to activePairs Set', () => { + const set1 = stateStore.activePairs; + const set2 = stateStore.activePairs; + + expect(set1).toBe(set2); + }); + }); +}); diff --git a/src/microservices/dex-state/specs/test.utils.ts b/src/microservices/dex-state/specs/test.utils.ts new file mode 100644 index 000000000..fd7d423c0 --- /dev/null +++ b/src/microservices/dex-state/specs/test.utils.ts @@ -0,0 +1,303 @@ +import { + EsdtToken, + EsdtTokenType, +} from 'src/modules/tokens/models/esdtToken.model'; +import { PairModel, PairCompoundedAPRModel } from 'src/modules/pair/models/pair.model'; +import { FarmModelV2 } from 'src/modules/farm/models/farm.v2.model'; +import { FarmVersion, FarmRewardType } from 'src/modules/farm/models/farm.model'; +import { StakingModel } from 'src/modules/staking/models/staking.model'; +import { StakingProxyModel } from 'src/modules/staking-proxy/models/staking.proxy.model'; +import { FeesCollectorModel } from 'src/modules/fees-collector/models/fees-collector.model'; +import { WeekTimekeepingModel } from 'src/submodules/week-timekeeping/models/week-timekeeping.model'; +import { + Tokens, + pairs, + MockedTokens, +} from 'src/modules/pair/mocks/pair.constants'; + +export { Tokens, pairs, MockedTokens }; + +export function createMockToken( + identifier: string, + options?: Partial, +): EsdtToken { + return new EsdtToken({ + identifier, + name: options?.name ?? `Token ${identifier}`, + ticker: options?.ticker ?? identifier.split('-')[0], + decimals: options?.decimals ?? 18, + owner: options?.owner ?? 'erd1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq6gq4hu', + derivedEGLD: options?.derivedEGLD ?? '0', + price: options?.price ?? '0', + type: options?.type ?? EsdtTokenType.FungibleToken, + liquidityUSD: options?.liquidityUSD ?? '0', + transactions: options?.transactions ?? 0, + accounts: options?.accounts ?? 0, + isPaused: options?.isPaused ?? false, + canUpgrade: options?.canUpgrade ?? true, + canMint: options?.canMint ?? true, + canBurn: options?.canBurn ?? true, + canChangeOwner: options?.canChangeOwner ?? true, + canPause: options?.canPause ?? true, + canFreeze: options?.canFreeze ?? true, + canWipe: options?.canWipe ?? true, + ...options, + }); +} + +export function createMockWeekTimekeeping( + options?: Partial, +): WeekTimekeepingModel { + return new WeekTimekeepingModel({ + scAddress: options?.scAddress ?? 'erd1qqqqqqqqqqqqqqqpqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqplllst77y4l', + currentWeek: options?.currentWeek ?? 1, + startEpochForWeek: options?.startEpochForWeek ?? 1, + endEpochForWeek: options?.endEpochForWeek ?? 7, + firstWeekStartEpoch: options?.firstWeekStartEpoch ?? 1, + ...options, + }); +} + +export function createMockPair( + address: string, + options?: Partial, +): PairModel { + const firstTokenId = options?.firstTokenId ?? 'WEGLD-123456'; + const secondTokenId = options?.secondTokenId ?? 'USDC-789012'; + const lpTokenId = options?.liquidityPoolTokenId ?? `LP-${address.slice(-6)}`; + + return new PairModel({ + address, + firstTokenId, + secondTokenId, + liquidityPoolTokenId: lpTokenId, + firstTokenPrice: options?.firstTokenPrice ?? '1', + firstTokenPriceUSD: options?.firstTokenPriceUSD ?? '10', + secondTokenPrice: options?.secondTokenPrice ?? '1', + secondTokenPriceUSD: options?.secondTokenPriceUSD ?? '1', + liquidityPoolTokenPriceUSD: options?.liquidityPoolTokenPriceUSD ?? '20', + firstTokenLockedValueUSD: options?.firstTokenLockedValueUSD ?? '1000000', + secondTokenLockedValueUSD: options?.secondTokenLockedValueUSD ?? '1000000', + lockedValueUSD: options?.lockedValueUSD ?? '2000000', + previous24hLockedValueUSD: options?.previous24hLockedValueUSD ?? '1900000', + firstTokenVolume24h: options?.firstTokenVolume24h ?? '100000', + secondTokenVolume24h: options?.secondTokenVolume24h ?? '100000', + volumeUSD24h: options?.volumeUSD24h ?? '200000', + previous24hVolumeUSD: options?.previous24hVolumeUSD ?? '180000', + feesUSD24h: options?.feesUSD24h ?? '600', + previous24hFeesUSD: options?.previous24hFeesUSD ?? '540', + feesAPR: options?.feesAPR ?? '10.95', + totalFeePercent: options?.totalFeePercent ?? 0.003, + specialFeePercent: options?.specialFeePercent ?? 0.0005, + feesCollectorCutPercentage: options?.feesCollectorCutPercentage ?? 50, + trustedSwapPairs: options?.trustedSwapPairs ?? [], + type: options?.type ?? 'Core', + state: options?.state ?? 'Active', + feeState: options?.feeState ?? true, + whitelistedManagedAddresses: options?.whitelistedManagedAddresses ?? [], + initialLiquidityAdder: options?.initialLiquidityAdder ?? '', + feeDestinations: options?.feeDestinations ?? [], + hasFarms: options?.hasFarms ?? false, + hasDualFarms: options?.hasDualFarms ?? false, + tradesCount: options?.tradesCount ?? 1000, + tradesCount24h: options?.tradesCount24h ?? 100, + deployedAt: options?.deployedAt ?? 1640000000, + compoundedAPR: options?.compoundedAPR ?? new PairCompoundedAPRModel({ + address, + feesAPR: '10.95', + farmBaseAPR: '0', + farmBoostedAPR: '0', + dualFarmBaseAPR: '0', + dualFarmBoostedAPR: '0', + }), + ...options, + }); +} + +export function createMockFarm( + address: string, + options?: Partial, +): FarmModelV2 { + return new FarmModelV2({ + address, + farmedTokenId: options?.farmedTokenId ?? 'MEX-123456', + farmingTokenId: options?.farmingTokenId ?? 'EGLDMEX-abcdef', + farmTokenCollection: options?.farmTokenCollection ?? 'FARM-123456', + pairAddress: options?.pairAddress ?? 'erd1qqqqqqqqqqqqqqqpqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqplllst77y4l', + farmedTokenPriceUSD: options?.farmedTokenPriceUSD ?? '0.001', + farmTokenPriceUSD: options?.farmTokenPriceUSD ?? '20', + farmingTokenPriceUSD: options?.farmingTokenPriceUSD ?? '20', + produceRewardsEnabled: options?.produceRewardsEnabled ?? true, + perBlockRewards: options?.perBlockRewards ?? '1000000000000000000', + farmTokenSupply: options?.farmTokenSupply ?? '1000000000000000000000000', + penaltyPercent: options?.penaltyPercent ?? 0, + minimumFarmingEpochs: options?.minimumFarmingEpochs ?? 3, + rewardPerShare: options?.rewardPerShare ?? '0', + rewardReserve: options?.rewardReserve ?? '1000000000000000000000', + lastRewardBlockNonce: options?.lastRewardBlockNonce ?? 1000000, + divisionSafetyConstant: options?.divisionSafetyConstant ?? '1000000000000', + totalValueLockedUSD: options?.totalValueLockedUSD ?? '1000000', + state: options?.state ?? 'Active', + version: options?.version ?? FarmVersion.V2, + boostedYieldsRewardsPercenatage: options?.boostedYieldsRewardsPercenatage ?? 60, + boostedYieldsFactors: options?.boostedYieldsFactors ?? { + maxRewardsFactor: '2.5', + userRewardsEnergy: '3', + userRewardsFarm: '2', + minEnergyAmount: '1000000000000000000', + minFarmAmount: '1000000000000000000', + }, + energyFactoryAddress: options?.energyFactoryAddress ?? 'erd1qqqqqqqqqqqqqqqpqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqylllslmq4y6', + rewardType: options?.rewardType ?? FarmRewardType.LOCKED_REWARDS, + time: options?.time ?? createMockWeekTimekeeping(), + accumulatedRewards: options?.accumulatedRewards ?? '0', + boosterRewards: options?.boosterRewards ?? [], + lastGlobalUpdateWeek: options?.lastGlobalUpdateWeek ?? 1, + farmTokenSupplyCurrentWeek: options?.farmTokenSupplyCurrentWeek ?? '1000000000000000000000000', + baseApr: options?.baseApr ?? '50', + boostedApr: options?.boostedApr ?? '100', + optimalEnergyPerLp: options?.optimalEnergyPerLp ?? '1000000000000000000', + boostedRewardsPerWeek: options?.boostedRewardsPerWeek ?? '1000000000000000000000', + undistributedBoostedRewards: options?.undistributedBoostedRewards ?? '0', + undistributedBoostedRewardsClaimed: options?.undistributedBoostedRewardsClaimed ?? '0', + ...options, + }); +} + +export function createMockStakingFarm( + address: string, + options?: Partial, +): StakingModel { + return new StakingModel({ + address, + farmTokenCollection: options?.farmTokenCollection ?? 'STAKE-123456', + farmTokenDecimals: options?.farmTokenDecimals ?? 18, + farmingTokenId: options?.farmingTokenId ?? 'RIDE-123456', + farmingTokenPriceUSD: options?.farmingTokenPriceUSD ?? '1', + rewardTokenId: options?.rewardTokenId ?? 'RIDE-123456', + farmTokenSupply: options?.farmTokenSupply ?? '1000000000000000000000000', + rewardPerShare: options?.rewardPerShare ?? '0', + accumulatedRewards: options?.accumulatedRewards ?? '0', + rewardCapacity: options?.rewardCapacity ?? '1000000000000000000000000', + annualPercentageRewards: options?.annualPercentageRewards ?? '25', + apr: options?.apr ?? '25', + aprUncapped: options?.aprUncapped ?? '25', + boostedApr: options?.boostedApr ?? '50', + baseApr: options?.baseApr ?? '25', + maxBoostedApr: options?.maxBoostedApr ?? '100', + minUnboundEpochs: options?.minUnboundEpochs ?? 10, + perBlockRewards: options?.perBlockRewards ?? '1000000000000000000', + lastRewardBlockNonce: options?.lastRewardBlockNonce ?? 1000000, + rewardsRemainingDays: options?.rewardsRemainingDays ?? 365, + rewardsRemainingDaysUncapped: options?.rewardsRemainingDaysUncapped ?? 365, + divisionSafetyConstant: options?.divisionSafetyConstant ?? '1000000000000', + produceRewardsEnabled: options?.produceRewardsEnabled ?? true, + state: options?.state ?? 'Active', + boostedYieldsRewardsPercenatage: options?.boostedYieldsRewardsPercenatage ?? 60, + boostedYieldsFactors: options?.boostedYieldsFactors ?? { + maxRewardsFactor: '2.5', + userRewardsEnergy: '3', + userRewardsFarm: '2', + minEnergyAmount: '1000000000000000000', + minFarmAmount: '1000000000000000000', + }, + optimalEnergyPerStaking: options?.optimalEnergyPerStaking ?? '1000000000000000000', + time: options?.time ?? createMockWeekTimekeeping(), + boosterRewards: options?.boosterRewards ?? [], + lastGlobalUpdateWeek: options?.lastGlobalUpdateWeek ?? 1, + farmTokenSupplyCurrentWeek: options?.farmTokenSupplyCurrentWeek ?? '1000000000000000000000000', + energyFactoryAddress: options?.energyFactoryAddress ?? 'erd1qqqqqqqqqqqqqqqpqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqylllslmq4y6', + accumulatedRewardsForWeek: options?.accumulatedRewardsForWeek ?? '0', + undistributedBoostedRewards: options?.undistributedBoostedRewards ?? '0', + undistributedBoostedRewardsClaimed: options?.undistributedBoostedRewardsClaimed ?? '0', + stakingPositionMigrationNonce: options?.stakingPositionMigrationNonce ?? 0, + deployedAt: options?.deployedAt ?? 1640000000, + isProducingRewards: options?.isProducingRewards ?? true, + stakedValueUSD: options?.stakedValueUSD ?? '1000000', + ...options, + }); +} + +export function createMockStakingProxy( + address: string, + options?: Partial, +): StakingProxyModel { + return new StakingProxyModel({ + address, + lpFarmAddress: options?.lpFarmAddress ?? 'erd1qqqqqqqqqqqqqqqpqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqplllst77y4l', + stakingFarmAddress: options?.stakingFarmAddress ?? 'erd1qqqqqqqqqqqqqqqpqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqslllsm5a29a', + stakingMinUnboundEpochs: options?.stakingMinUnboundEpochs ?? 10, + pairAddress: options?.pairAddress ?? 'erd1qqqqqqqqqqqqqqqpqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqplllst77y4l', + stakingTokenId: options?.stakingTokenId ?? 'RIDE-123456', + farmTokenCollection: options?.farmTokenCollection ?? 'FARM-123456', + dualYieldTokenCollection: options?.dualYieldTokenCollection ?? 'DUAL-123456', + lpFarmTokenCollection: options?.lpFarmTokenCollection ?? 'LPFARM-123456', + ...options, + }); +} + +export function createMockFeesCollector( + options?: Partial, +): FeesCollectorModel { + return new FeesCollectorModel({ + address: options?.address ?? 'erd1qqqqqqqqqqqqqqqpqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqflllswdyp88', + time: options?.time ?? createMockWeekTimekeeping(), + startWeek: options?.startWeek ?? 1, + endWeek: options?.endWeek ?? 52, + lastGlobalUpdateWeek: options?.lastGlobalUpdateWeek ?? 1, + undistributedRewards: options?.undistributedRewards ?? [], + allTokens: options?.allTokens ?? [], + knownContracts: options?.knownContracts ?? [], + accumulatedFees: options?.accumulatedFees ?? [], + rewardsClaimed: options?.rewardsClaimed ?? [], + lockedTokenId: options?.lockedTokenId ?? 'XMEX-123456', + lockedTokensPerBlock: options?.lockedTokensPerBlock ?? '1000000000000000000', + lockedTokensPerEpoch: options?.lockedTokensPerEpoch ?? '14400000000000000000000', + allowExternalClaimRewards: options?.allowExternalClaimRewards ?? true, + lastLockedTokensAddWeek: options?.lastLockedTokensAddWeek ?? 1, + ...options, + }); +} + +// Use addresses from pair.constants.ts pairs array +export const TEST_ADDRESSES = { + // Pair addresses from pairs array (Address.fromHex with incrementing hex values) + PAIR_EGLD_MEX: pairs[0].address, // 0x12 + PAIR_EGLD_USDC: pairs[1].address, // 0x13 + PAIR_TOK4_EGLD: pairs[2].address, // 0x14 + PAIR_EGLD_TOK5: pairs[3].address, // 0x15 + PAIR_TOK6_TOK5: pairs[4].address, // 0x16 + PAIR_TOK5_USDC: pairs[5].address, // 0x17 + PAIR_TOK5_USDT: pairs[6].address, // 0x18 + PAIR_EGLD_USDT: pairs[7].address, // 0x19 + // Farm/staking addresses (using different hex ranges) + FARM_1: 'erd1qqqqqqqqqqqqqqqpqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqhllllsajxzat', + FARM_2: 'erd1qqqqqqqqqqqqqqqpqqqqqqqqqqqqqqqqqqqqqqqqqqqqq8hlllls7a6h85', + STAKING_1: 'erd1qqqqqqqqqqqqqqqpqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqslllsm5a29a', + STAKING_2: 'erd1qqqqqqqqqqqqqqqpqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsllllsx0xk85', + STAKING_3: 'erd1qqqqqqqqqqqqqqqpqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqslllls3a9x99', + STAKING_PROXY_1: 'erd1qqqqqqqqqqqqqqqpqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq0llllsj732yz', + STAKING_PROXY_2: 'erd1qqqqqqqqqqqqqqqpqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq0lllls7b3a24', + FEES_COLLECTOR: 'erd1qqqqqqqqqqqqqqqpqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqflllswdyp88', +} as const; + +// Use token IDs from pair.constants.ts MockedTokens array +export const TEST_TOKEN_IDS = { + WEGLD: 'WEGLD-123456', + MEX: 'MEX-123456', + USDC: 'USDC-123456', + USDT: 'USDT-123456', + RIDE: 'RIDE-123456', + TOK4: 'TOK4-123456', + TOK5: 'TOK5-123456', + TOK6: 'TOK6-123456', + // LP tokens + LP_EGLD_USDC: 'EGLDUSDCLP-abcdef', + LP_EGLD_MEX: 'EGLDMEXLP-abcdef', + LP_EGLD_TOK4: 'EGLDTOK4LP-abcdef', + LP_EGLD_TOK5: 'EGLDTOK5LP-abcdef', + LP_TOK5_TOK6: 'TOK5TOK6LP-abcdef', + LP_TOK5_USDC: 'TOK5USDCLP-abcdef', + LP_TOK5_USDT: 'TOK5USDTLP-abcdef', + LP_EGLD_USDT: 'EGLDUSDTLP-abcdef', +} as const; diff --git a/src/modules/staking/services/staking.compute.service.ts b/src/modules/staking/services/staking.compute.service.ts index 257bca3f8..166618ff4 100644 --- a/src/modules/staking/services/staking.compute.service.ts +++ b/src/modules/staking/services/staking.compute.service.ts @@ -503,6 +503,11 @@ export class StakingComputeService { ); } const remainingRewards = await Promise.all(promises); + + if (remainingRewards.length === 0) { + return new BigNumber(0); + } + return remainingRewards.reduce((acc, curr) => { return new BigNumber(acc).plus(curr); }); diff --git a/src/modules/state/services/state.cron.service.ts b/src/modules/state/services/state.cron.service.ts index f09332ef3..31946fafd 100644 --- a/src/modules/state/services/state.cron.service.ts +++ b/src/modules/state/services/state.cron.service.ts @@ -5,11 +5,13 @@ import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; import { Logger } from 'winston'; import { StateTasks, TaskDto } from '../entities/state.tasks.entities'; import { StateTasksService } from './state.tasks.service'; +import { StateService } from './state.service'; @Injectable() export class StateCronService { constructor( private readonly taskService: StateTasksService, + private readonly stateService: StateService, @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger, ) {} @@ -28,6 +30,11 @@ export class StateCronService { @Cron(CronExpression.EVERY_5_MINUTES) @Lock({ name: 'refreshStateAnalytics', verbose: true }) async refreshStateAnalytics(): Promise { + const isStateReady = await this.stateService.isStateInitialized(); + if (!isStateReady) { + return; + } + try { await this.taskService.refreshAnalytics(); } catch (error) { @@ -44,6 +51,11 @@ export class StateCronService { @Cron(CronExpression.EVERY_MINUTE) @Lock({ name: 'updateStateSnapshot', verbose: true }) async updateStateSnapshot(): Promise { + const isStateReady = await this.stateService.isStateInitialized(); + if (!isStateReady) { + return; + } + try { await this.taskService.updateSnapshot(); } catch (error) { @@ -57,6 +69,11 @@ export class StateCronService { @Cron(CronExpression.EVERY_5_MINUTES) @Lock({ name: 'refreshUsdcPrice', verbose: true }) async refreshUsdcPrice(): Promise { + const isStateReady = await this.stateService.isStateInitialized(); + if (!isStateReady) { + return; + } + try { await this.taskService.refreshUsdcPrice(); } catch (error) { @@ -70,6 +87,11 @@ export class StateCronService { @Cron(CronExpression.EVERY_MINUTE) @Lock({ name: 'refreshFeesCollectorFarmsAndStaking', verbose: true }) async refreshFeesCollectorFarmsAndStaking(): Promise { + const isStateReady = await this.stateService.isStateInitialized(); + if (!isStateReady) { + return; + } + try { await this.taskService.queueTasks([ new TaskDto({ diff --git a/src/modules/state/services/state.service.ts b/src/modules/state/services/state.service.ts index 90560ab7a..1fc6f286a 100644 --- a/src/modules/state/services/state.service.ts +++ b/src/modules/state/services/state.service.ts @@ -13,6 +13,15 @@ import { StateGrpcClientService } from './state.grpc.client.service'; export class StateService { constructor(private readonly stateGrpc: StateGrpcClientService) {} + @StateRpcMetrics() + async isStateInitialized(): Promise { + const response = await firstValueFrom( + this.stateGrpc.client.isStateInitialized({}), + ); + + return response.initialized; + } + @StateRpcMetrics() async initState(request: InitStateRequest): Promise { return firstValueFrom(this.stateGrpc.client.initState(request)); diff --git a/src/modules/state/services/state.sync.service.ts b/src/modules/state/services/state.sync.service.ts index b96e88e4c..bd140c17a 100644 --- a/src/modules/state/services/state.sync.service.ts +++ b/src/modules/state/services/state.sync.service.ts @@ -55,7 +55,7 @@ export class StateSyncService { this.logger.info(`Starting ${this.populateState.name}`, { context: StateSyncService.name, }); - const profiler = new PerformanceProfiler(); + let profiler = new PerformanceProfiler(); const tokens = new Map(); const pairs = new Map(); @@ -83,8 +83,13 @@ export class StateSyncService { this.stateSnapshot.getLatestSnapshot(), ]); + profiler.stop(); + const pairsNeedingAnalytics: string[] = []; const tokensNeedingAnalytics: string[] = []; + + profiler = new PerformanceProfiler('Initialize pairs'); + for (const pairMeta of pairsMetadata) { if ( snapshotPairs.has(pairMeta.address) && @@ -169,6 +174,15 @@ export class StateSyncService { tokensNeedingAnalytics.push(pair.firstTokenId, pair.secondTokenId); } + this.logger.info( + `${profiler.description} in ${(profiler.stop() / 1000).toFixed( + 3, + )}s`, + { context: this.populateState.name }, + ); + + profiler = new PerformanceProfiler('Recompute All values'); + this.bulkUpdatesService.recomputeAllValues( pairs, tokens, @@ -176,9 +190,18 @@ export class StateSyncService { commonTokenIDs, ); + this.logger.info( + `${profiler.description} in ${(profiler.stop() / 1000).toFixed( + 3, + )}s`, + { context: this.populateState.name }, + ); + const farms = new Map(); const farmAddresses = farmsAddresses([FarmVersion.V2]); + profiler = new PerformanceProfiler('Initialize farms'); + for (const farmAddress of farmAddresses) { if (snapshotFarms.has(farmAddress)) { const snapshotFarm = snapshotFarms.get(farmAddress); @@ -192,10 +215,19 @@ export class StateSyncService { farms.set(farm.address, { ...farm }); } + this.logger.info( + `${profiler.description} in ${(profiler.stop() / 1000).toFixed( + 3, + )}s`, + { context: this.populateState.name }, + ); + const stakingAddresses = await this.remoteConfigGetter.getStakingAddresses(); const stakingFarms = new Map(); + profiler = new PerformanceProfiler('Initialize staking farms'); + for (const stakingAddress of stakingAddresses) { if (snapshotStakingFarms.has(stakingAddress)) { const snapshotStakingFarm = @@ -212,10 +244,19 @@ export class StateSyncService { stakingFarms.set(stakingAddress, { ...stakingFarm }); } + this.logger.info( + `${profiler.description} in ${(profiler.stop() / 1000).toFixed( + 3, + )}s`, + { context: this.populateState.name }, + ); + const stakingProxyAddresses = await this.remoteConfigGetter.getStakingProxyAddresses(); const stakingProxies = new Map(); + profiler = new PerformanceProfiler('Initialize staking proxies'); + for (const stakingProxyAddress of stakingProxyAddresses) { if (snapshotStakingProxies.has(stakingProxyAddress)) { const snapshotStakingProxy = @@ -234,10 +275,19 @@ export class StateSyncService { stakingProxies.set(stakingProxyAddress, { ...stakingProxy }); } + this.logger.info( + `${profiler.description} in ${(profiler.stop() / 1000).toFixed( + 3, + )}s`, + { context: this.populateState.name }, + ); + const feesCollector = snapshotFeesCollector ?? (await this.feesCollectorSync.populateFeesCollector()); + profiler = new PerformanceProfiler('Update analytics'); + await this.analyticsSync.updatePairsAnalytics( pairs, pairsNeedingAnalytics, @@ -248,13 +298,11 @@ export class StateSyncService { tokensNeedingAnalytics, ); - profiler.stop(); - - this.logger.debug( - `${this.populateState.name} : ${profiler.duration}ms`, - { - context: StateSyncService.name, - }, + this.logger.info( + `${profiler.description} in ${(profiler.stop() / 1000).toFixed( + 3, + )}s`, + { context: this.populateState.name }, ); return { diff --git a/src/modules/state/specs/state.tasks.service.spec.ts b/src/modules/state/specs/state.tasks.service.spec.ts new file mode 100644 index 000000000..3f4039240 --- /dev/null +++ b/src/modules/state/specs/state.tasks.service.spec.ts @@ -0,0 +1,778 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { StateTasksService, STATE_TASKS_CACHE_KEY } from '../services/state.tasks.service'; +import { StateSyncService } from '../services/state.sync.service'; +import { CacheService } from 'src/services/caching/cache.service'; +import { StateService } from '../services/state.service'; +import { PairsStateService } from '../services/pairs.state.service'; +import { TokensStateService } from '../services/tokens.state.service'; +import { FarmsStateService } from '../services/farms.state.service'; +import { StakingStateService } from '../services/staking.state.service'; +import { FeesCollectorStateService } from '../services/fees.collector.state.service'; +import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; +import { PUB_SUB } from 'src/services/redis.pubSub.module'; +import { + StateTasks, + StateTaskPriority, + TaskDto, + PENDING_PRICE_UPDATES_KEY, +} from '../entities/state.tasks.entities'; +import { PairMetadata } from 'src/modules/router/models/pair.metadata.model'; + +describe('StateTasksService', () => { + let service: StateTasksService; + let cacheService: jest.Mocked; + let syncService: jest.Mocked; + let stateService: jest.Mocked; + let pairsState: jest.Mocked; + let tokensState: jest.Mocked; + let farmsState: jest.Mocked; + let stakingState: jest.Mocked; + let feesCollectorState: jest.Mocked; + let logger: any; + let pubSub: any; + + beforeEach(async () => { + const mockCacheService = { + zAdd: jest.fn().mockResolvedValue(undefined), + zPopMin: jest.fn().mockResolvedValue([]), + addToSet: jest.fn().mockResolvedValue(undefined), + getSetMembers: jest.fn().mockResolvedValue([]), + incrementRemote: jest.fn().mockResolvedValue(1), + deleteRemote: jest.fn().mockResolvedValue(undefined), + }; + + const mockLogger = { + info: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const mockPubSub = { + publish: jest.fn().mockResolvedValue(undefined), + }; + + const mockSyncService = { + populateState: jest.fn(), + populatePairAndTokens: jest.fn(), + indexPairLpToken: jest.fn(), + getPairAnalytics: jest.fn(), + updateSnapshot: jest.fn(), + getPairReservesAndState: jest.fn(), + getUsdcPrice: jest.fn(), + getFarmReservesAndWeeklyRewards: jest.fn(), + getStakingFarmReservesAndWeeklyRewards: jest.fn(), + getFeesCollectorFeesAndWeeklyRewards: jest.fn(), + }; + + const mockStateService = { + initState: jest.fn(), + }; + + const mockPairsState = { + getAllPairs: jest.fn(), + getPairs: jest.fn(), + updatePairs: jest.fn(), + addPair: jest.fn(), + addPairLpToken: jest.fn(), + }; + + const mockTokensState = { + getFilteredTokens: jest.fn(), + getTokens: jest.fn(), + getAllTokens: jest.fn(), + updateTokens: jest.fn(), + }; + + const mockFarmsState = { + getAllFarms: jest.fn(), + getFarms: jest.fn(), + updateFarms: jest.fn(), + }; + + const mockStakingState = { + getAllStakingFarms: jest.fn(), + getAllStakingProxies: jest.fn(), + getStakingFarms: jest.fn(), + updateStakingFarms: jest.fn(), + }; + + const mockFeesCollectorState = { + getFeesCollector: jest.fn(), + updateFeesCollector: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + StateTasksService, + { provide: StateSyncService, useValue: mockSyncService }, + { provide: CacheService, useValue: mockCacheService }, + { provide: StateService, useValue: mockStateService }, + { provide: PairsStateService, useValue: mockPairsState }, + { provide: TokensStateService, useValue: mockTokensState }, + { provide: FarmsStateService, useValue: mockFarmsState }, + { provide: StakingStateService, useValue: mockStakingState }, + { provide: FeesCollectorStateService, useValue: mockFeesCollectorState }, + { provide: WINSTON_MODULE_PROVIDER, useValue: mockLogger }, + { provide: PUB_SUB, useValue: mockPubSub }, + ], + }).compile(); + + service = module.get(StateTasksService); + cacheService = module.get(CacheService); + syncService = module.get(StateSyncService); + stateService = module.get(StateService); + pairsState = module.get(PairsStateService); + tokensState = module.get(TokensStateService); + farmsState = module.get(FarmsStateService); + stakingState = module.get(StakingStateService); + feesCollectorState = module.get(FeesCollectorStateService); + logger = module.get(WINSTON_MODULE_PROVIDER); + pubSub = module.get(PUB_SUB); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('queueTasks', () => { + it('should queue a simple task without arguments', async () => { + const tasks = [ + new TaskDto({ + name: StateTasks.INIT_STATE, + args: [], + }), + ]; + + await service.queueTasks(tasks); + + expect(cacheService.zAdd).toHaveBeenCalledWith( + STATE_TASKS_CACHE_KEY, + expect.stringContaining('"name":"initState"'), + StateTaskPriority[StateTasks.INIT_STATE], + ); + expect(logger.info).toHaveBeenCalledWith( + 'State task initState added to queue', + expect.any(Object), + ); + }); + + it('should queue a task with arguments', async () => { + const tasks = [ + new TaskDto({ + name: StateTasks.INDEX_LP_TOKEN, + args: ['erd1qqqqqqqqqqqqqpgqeel2kumf0r8ffyhth7pqdujjat9nx0862jpsg2pqaq'], + }), + ]; + + await service.queueTasks(tasks); + + expect(cacheService.zAdd).toHaveBeenCalledWith( + STATE_TASKS_CACHE_KEY, + expect.stringContaining('"name":"indexLpToken"'), + StateTaskPriority[StateTasks.INDEX_LP_TOKEN], + ); + }); + + it('should throw error if task requires arguments but none provided', async () => { + const tasks = [ + new TaskDto({ + name: StateTasks.INDEX_LP_TOKEN, + args: [], + }), + ]; + + await expect(service.queueTasks(tasks)).rejects.toThrow( + "Task 'indexLpToken' requires an argument", + ); + }); + + it('should queue multiple tasks', async () => { + const tasks = [ + new TaskDto({ name: StateTasks.INIT_STATE, args: [] }), + new TaskDto({ name: StateTasks.REFRESH_ANALYTICS, args: [] }), + new TaskDto({ name: StateTasks.UPDATE_SNAPSHOT, args: [] }), + ]; + + await service.queueTasks(tasks); + + expect(cacheService.zAdd).toHaveBeenCalledTimes(3); + }); + + it('should handle BROADCAST_PRICE_UPDATES special case with token accumulation', async () => { + const tokenIDs = ['WEGLD-123456', 'MEX-123456', 'USDC-123456']; + const tasks = [ + new TaskDto({ + name: StateTasks.BROADCAST_PRICE_UPDATES, + args: [JSON.stringify(tokenIDs)], + }), + ]; + + await service.queueTasks(tasks); + + // Should add token IDs to the pending set + expect(cacheService.addToSet).toHaveBeenCalledWith( + PENDING_PRICE_UPDATES_KEY, + tokenIDs, + ); + + // Should queue the task without args (accumulation pattern) + expect(cacheService.zAdd).toHaveBeenCalledWith( + STATE_TASKS_CACHE_KEY, + JSON.stringify({ name: StateTasks.BROADCAST_PRICE_UPDATES }), + StateTaskPriority[StateTasks.BROADCAST_PRICE_UPDATES], + ); + }); + + it('should queue BROADCAST_PRICE_UPDATES without args if no tokens provided', async () => { + const tasks = [ + new TaskDto({ + name: StateTasks.BROADCAST_PRICE_UPDATES, + args: [], + }), + ]; + + await service.queueTasks(tasks); + + // Should not call addToSet if no args + expect(cacheService.addToSet).not.toHaveBeenCalled(); + + // Should still queue the task + expect(cacheService.zAdd).toHaveBeenCalledWith( + STATE_TASKS_CACHE_KEY, + JSON.stringify({ name: StateTasks.BROADCAST_PRICE_UPDATES }), + StateTaskPriority[StateTasks.BROADCAST_PRICE_UPDATES], + ); + }); + }); + + describe('task priority ordering', () => { + it('should queue tasks with correct priorities', async () => { + const tasks = [ + new TaskDto({ name: StateTasks.INIT_STATE, args: [] }), + new TaskDto({ name: StateTasks.REFRESH_ANALYTICS, args: [] }), + new TaskDto({ name: StateTasks.INDEX_PAIR, args: [JSON.stringify({}), '0'] }), + ]; + + await service.queueTasks(tasks); + + // INIT_STATE should have priority 0 (highest) + expect(cacheService.zAdd).toHaveBeenCalledWith( + STATE_TASKS_CACHE_KEY, + expect.anything(), + 0, + ); + + // INDEX_PAIR should have priority 1 + expect(cacheService.zAdd).toHaveBeenCalledWith( + STATE_TASKS_CACHE_KEY, + expect.anything(), + 1, + ); + + // REFRESH_ANALYTICS should have priority 1000 (lowest) + expect(cacheService.zAdd).toHaveBeenCalledWith( + STATE_TASKS_CACHE_KEY, + expect.anything(), + 1000, + ); + }); + }); + + describe('processQueuedTasks', () => { + it('should return early if no tasks in queue', async () => { + cacheService.zPopMin.mockResolvedValue([]); + + await service.processQueuedTasks(); + + expect(cacheService.zPopMin).toHaveBeenCalledWith(STATE_TASKS_CACHE_KEY); + expect(logger.info).not.toHaveBeenCalled(); + }); + + it('should process INIT_STATE task', async () => { + const task = new TaskDto({ name: StateTasks.INIT_STATE, args: [] }); + cacheService.zPopMin.mockResolvedValue([JSON.stringify(task)]); + + (syncService.populateState as jest.Mock).mockResolvedValue({}); + (stateService.initState as jest.Mock).mockResolvedValue({}); + + await service.processQueuedTasks(); + + expect(syncService.populateState).toHaveBeenCalled(); + expect(stateService.initState).toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith( + 'Processing state task "initState"', + expect.any(Object), + ); + }); + + it('should process INDEX_PAIR task', async () => { + const pairMetadata: PairMetadata = { + address: 'erd1qqqqqqqqqqqqqpgqeel2kumf0r8ffyhth7pqdujjat9nx0862jpsg2pqaq', + firstTokenID: 'WEGLD-123456', + secondTokenID: 'USDC-123456', + }; + const task = new TaskDto({ + name: StateTasks.INDEX_PAIR, + args: [JSON.stringify(pairMetadata), '1640000000'], + }); + cacheService.zPopMin.mockResolvedValue([JSON.stringify(task)]); + + const mockPair = { address: pairMetadata.address }; + const mockFirstToken = { identifier: 'WEGLD-123456' }; + const mockSecondToken = { identifier: 'USDC-123456' }; + + (syncService.populatePairAndTokens as jest.Mock).mockResolvedValue({ + pair: mockPair, + firstToken: mockFirstToken, + secondToken: mockSecondToken, + }); + (pairsState.addPair as jest.Mock).mockResolvedValue(undefined); + + await service.processQueuedTasks(); + + expect(syncService.populatePairAndTokens).toHaveBeenCalledWith( + pairMetadata, + 1640000000, + ); + expect(pairsState.addPair).toHaveBeenCalledWith( + mockPair, + mockFirstToken, + mockSecondToken, + ); + }); + + it('should process INDEX_LP_TOKEN task successfully', async () => { + const pairAddress = 'erd1qqqqqqqqqqqqqpgqeel2kumf0r8ffyhth7pqdujjat9nx0862jpsg2pqaq'; + const task = new TaskDto({ + name: StateTasks.INDEX_LP_TOKEN, + args: [pairAddress], + }); + cacheService.zPopMin.mockResolvedValue([JSON.stringify(task)]); + + const mockPair = { address: pairAddress }; + const mockLpToken = { identifier: 'EGLDUSDC-abcdef' }; + + (pairsState.getPairs as jest.Mock).mockResolvedValue([mockPair]); + (syncService.indexPairLpToken as jest.Mock).mockResolvedValue(mockLpToken); + (pairsState.addPairLpToken as jest.Mock).mockResolvedValue(undefined); + + await service.processQueuedTasks(); + + expect(pairsState.getPairs).toHaveBeenCalledWith([pairAddress], ['address']); + expect(syncService.indexPairLpToken).toHaveBeenCalledWith(pairAddress); + expect(pairsState.addPairLpToken).toHaveBeenCalledWith(pairAddress, mockLpToken); + }); + + it('should process REFRESH_ANALYTICS task', async () => { + const task = new TaskDto({ name: StateTasks.REFRESH_ANALYTICS, args: [] }); + cacheService.zPopMin.mockResolvedValue([JSON.stringify(task)]); + + (pairsState.getAllPairs as jest.Mock).mockResolvedValue([]); + (tokensState.getFilteredTokens as jest.Mock).mockResolvedValue({ tokens: [] }); + (pairsState.updatePairs as jest.Mock).mockResolvedValue(undefined); + (tokensState.updateTokens as jest.Mock).mockResolvedValue(undefined); + + await service.processQueuedTasks(); + + expect(pairsState.getAllPairs).toHaveBeenCalled(); + expect(tokensState.getFilteredTokens).toHaveBeenCalled(); + }); + + it('should process UPDATE_SNAPSHOT task', async () => { + const task = new TaskDto({ name: StateTasks.UPDATE_SNAPSHOT, args: [] }); + cacheService.zPopMin.mockResolvedValue([JSON.stringify(task)]); + + (pairsState.getAllPairs as jest.Mock).mockResolvedValue([]); + (tokensState.getAllTokens as jest.Mock).mockResolvedValue([]); + (farmsState.getAllFarms as jest.Mock).mockResolvedValue([]); + (stakingState.getAllStakingFarms as jest.Mock).mockResolvedValue([]); + (stakingState.getAllStakingProxies as jest.Mock).mockResolvedValue([]); + (feesCollectorState.getFeesCollector as jest.Mock).mockResolvedValue({}); + (syncService.updateSnapshot as jest.Mock).mockResolvedValue(undefined); + + await service.processQueuedTasks(); + + expect(syncService.updateSnapshot).toHaveBeenCalled(); + }); + + it('should process BROADCAST_PRICE_UPDATES task', async () => { + const task = new TaskDto({ name: StateTasks.BROADCAST_PRICE_UPDATES, args: [] }); + cacheService.zPopMin.mockResolvedValue([JSON.stringify(task)]); + + const mockTokenIDs = ['WEGLD-123456', 'MEX-123456']; + cacheService.getSetMembers = jest.fn().mockResolvedValue(mockTokenIDs); + (tokensState.getTokens as jest.Mock).mockResolvedValue([ + { identifier: 'WEGLD-123456', price: '10' }, + { identifier: 'MEX-123456', price: '0.01' }, + ]); + + await service.processQueuedTasks(); + + expect(cacheService.getSetMembers).toHaveBeenCalledWith(PENDING_PRICE_UPDATES_KEY); + expect(tokensState.getTokens).toHaveBeenCalledWith( + mockTokenIDs, + expect.arrayContaining(['identifier', 'price']), + ); + expect(pubSub.publish).toHaveBeenCalled(); + expect(cacheService.deleteRemote).toHaveBeenCalledWith(PENDING_PRICE_UPDATES_KEY); + }); + + it('should process REFRESH_PAIR_RESERVES task', async () => { + const task = new TaskDto({ name: StateTasks.REFRESH_PAIR_RESERVES, args: [] }); + cacheService.zPopMin.mockResolvedValue([JSON.stringify(task)]); + + (pairsState.getAllPairs as jest.Mock).mockResolvedValue([ + { address: 'pair1', firstTokenID: 'WEGLD-123456', secondTokenID: 'USDC-123456' }, + ]); + (syncService.getPairReservesAndState as jest.Mock).mockResolvedValue({}); + (pairsState.updatePairs as jest.Mock).mockResolvedValue(undefined); + + await service.processQueuedTasks(); + + expect(pairsState.getAllPairs).toHaveBeenCalled(); + expect(syncService.getPairReservesAndState).toHaveBeenCalled(); + expect(pairsState.updatePairs).toHaveBeenCalled(); + }); + + it('should process REFRESH_USDC_PRICE task', async () => { + const task = new TaskDto({ name: StateTasks.REFRESH_USDC_PRICE, args: [] }); + cacheService.zPopMin.mockResolvedValue([JSON.stringify(task)]); + + (syncService.getUsdcPrice as jest.Mock).mockResolvedValue(1.0); + + await service.processQueuedTasks(); + + expect(syncService.getUsdcPrice).toHaveBeenCalled(); + }); + + it('should process REFRESH_FARMS task', async () => { + const task = new TaskDto({ name: StateTasks.REFRESH_FARMS, args: [] }); + cacheService.zPopMin.mockResolvedValue([JSON.stringify(task)]); + + (farmsState.getAllFarms as jest.Mock).mockResolvedValue([ + { address: 'farm1' }, + { address: 'farm2' }, + ]); + (syncService.getFarmReservesAndWeeklyRewards as jest.Mock).mockResolvedValue({}); + (farmsState.updateFarms as jest.Mock).mockResolvedValue(undefined); + + await service.processQueuedTasks(); + + expect(farmsState.getAllFarms).toHaveBeenCalled(); + expect(syncService.getFarmReservesAndWeeklyRewards).toHaveBeenCalled(); + }); + + it('should process REFRESH_FARM task with address argument', async () => { + const farmAddress = 'erd1qqqqqqqqqqqqqpgqeel2kumf0r8ffyhth7pqdujjat9nx0862jpsg2pqaq'; + const task = new TaskDto({ + name: StateTasks.REFRESH_FARM, + args: [farmAddress], + }); + cacheService.zPopMin.mockResolvedValue([JSON.stringify(task)]); + + (farmsState.getFarms as jest.Mock).mockResolvedValue([{ address: farmAddress }]); + (syncService.getFarmReservesAndWeeklyRewards as jest.Mock).mockResolvedValue(new Map()); + (farmsState.updateFarms as jest.Mock).mockResolvedValue(undefined); + + await service.processQueuedTasks(); + + expect(farmsState.getFarms).toHaveBeenCalledWith([farmAddress], expect.any(Array)); + }); + + it('should process REFRESH_STAKING_FARMS task', async () => { + const task = new TaskDto({ name: StateTasks.REFRESH_STAKING_FARMS, args: [] }); + cacheService.zPopMin.mockResolvedValue([JSON.stringify(task)]); + + (stakingState.getAllStakingFarms as jest.Mock).mockResolvedValue([ + { address: 'staking1' }, + { address: 'staking2' }, + ]); + (syncService.getStakingFarmReservesAndWeeklyRewards as jest.Mock).mockResolvedValue({}); + (stakingState.updateStakingFarms as jest.Mock).mockResolvedValue(undefined); + + await service.processQueuedTasks(); + + expect(stakingState.getAllStakingFarms).toHaveBeenCalled(); + expect(syncService.getStakingFarmReservesAndWeeklyRewards).toHaveBeenCalled(); + }); + + it('should process REFRESH_STAKING_FARM task with address argument', async () => { + const stakingAddress = 'erd1qqqqqqqqqqqqqpgqeel2kumf0r8ffyhth7pqdujjat9nx0862jpsg2pqaq'; + const task = new TaskDto({ + name: StateTasks.REFRESH_STAKING_FARM, + args: [stakingAddress], + }); + cacheService.zPopMin.mockResolvedValue([JSON.stringify(task)]); + + (stakingState.getStakingFarms as jest.Mock).mockResolvedValue([{ address: stakingAddress }]); + (syncService.getStakingFarmReservesAndWeeklyRewards as jest.Mock).mockResolvedValue(new Map()); + (stakingState.updateStakingFarms as jest.Mock).mockResolvedValue(undefined); + + await service.processQueuedTasks(); + + expect(stakingState.getStakingFarms).toHaveBeenCalledWith([stakingAddress], expect.any(Array)); + }); + + it('should process REFRESH_FEES_COLLECTOR task', async () => { + const task = new TaskDto({ name: StateTasks.REFRESH_FEES_COLLECTOR, args: [] }); + cacheService.zPopMin.mockResolvedValue([JSON.stringify(task)]); + + (feesCollectorState.getFeesCollector as jest.Mock).mockResolvedValue({}); + (syncService.getFeesCollectorFeesAndWeeklyRewards as jest.Mock).mockResolvedValue({}); + (feesCollectorState.updateFeesCollector as jest.Mock).mockResolvedValue(undefined); + + await service.processQueuedTasks(); + + expect(feesCollectorState.getFeesCollector).toHaveBeenCalled(); + expect(syncService.getFeesCollectorFeesAndWeeklyRewards).toHaveBeenCalled(); + }); + }); + + describe('task failure and retry logic', () => { + it('should increment retry counter and re-queue task on failure', async () => { + const task = new TaskDto({ name: StateTasks.INIT_STATE, args: [] }); + cacheService.zPopMin.mockResolvedValue([JSON.stringify(task)]); + cacheService.incrementRemote.mockResolvedValue(1); + + syncService.populateState = jest.fn().mockRejectedValue(new Error('Network error')); + + await service.processQueuedTasks(); + + expect(cacheService.incrementRemote).toHaveBeenCalledWith( + 'dexService.taskRetryCount:initState', + 86400, // 24 hours TTL + ); + + expect(logger.warn).toHaveBeenCalledWith( + 'Re-queuing task "initState" (attempt 1/5)', + expect.any(Object), + ); + + // Should re-queue the task + expect(cacheService.zAdd).toHaveBeenCalledWith( + STATE_TASKS_CACHE_KEY, + expect.stringContaining('"name":"initState"'), + StateTaskPriority[StateTasks.INIT_STATE], + ); + }); + + it('should dead-letter task after MAX_TASK_RETRIES attempts', async () => { + const task = new TaskDto({ name: StateTasks.REFRESH_ANALYTICS, args: [] }); + cacheService.zPopMin.mockResolvedValue([JSON.stringify(task)]); + cacheService.incrementRemote.mockResolvedValue(5); // 5th failure + + (pairsState.getAllPairs as jest.Mock).mockRejectedValue(new Error('Database error')); + + await service.processQueuedTasks(); + + expect(logger.error).toHaveBeenCalledWith( + 'Task "refreshAnalytics" dead-lettered after 5 failed attempts', + expect.any(Object), + ); + + // Should delete retry counter + expect(cacheService.deleteRemote).toHaveBeenCalledWith( + 'dexService.taskRetryCount:refreshAnalytics', + ); + + // Should NOT re-queue the task + expect(cacheService.zAdd).not.toHaveBeenCalled(); + }); + + it('should delete retry counter on successful task completion', async () => { + const task = new TaskDto({ name: StateTasks.INIT_STATE, args: [] }); + cacheService.zPopMin.mockResolvedValue([JSON.stringify(task)]); + + (syncService.populateState as jest.Mock).mockResolvedValue({}); + (stateService.initState as jest.Mock).mockResolvedValue({}); + + await service.processQueuedTasks(); + + expect(cacheService.deleteRemote).toHaveBeenCalledWith( + 'dexService.taskRetryCount:initState', + ); + }); + + it('should include task arguments in retry key', async () => { + const pairAddress = 'erd1qqqqqqqqqqqqqpgqeel2kumf0r8ffyhth7pqdujjat9nx0862jpsg2pqaq'; + const task = new TaskDto({ + name: StateTasks.INDEX_LP_TOKEN, + args: [pairAddress], + }); + cacheService.zPopMin.mockResolvedValue([JSON.stringify(task)]); + cacheService.incrementRemote.mockResolvedValue(2); + + (pairsState.getPairs as jest.Mock).mockRejectedValue(new Error('Service unavailable')); + + await service.processQueuedTasks(); + + expect(cacheService.incrementRemote).toHaveBeenCalledWith( + `dexService.taskRetryCount:indexLpToken:${pairAddress}`, + 86400, + ); + }); + + it('should handle task failure at retry attempt 3', async () => { + const task = new TaskDto({ name: StateTasks.UPDATE_SNAPSHOT, args: [] }); + cacheService.zPopMin.mockResolvedValue([JSON.stringify(task)]); + cacheService.incrementRemote.mockResolvedValue(3); // 3rd attempt + + // Mock one of the state calls to fail + (pairsState.getAllPairs as jest.Mock).mockRejectedValue(new Error('Timeout')); + (tokensState.getAllTokens as jest.Mock).mockResolvedValue([]); + (farmsState.getAllFarms as jest.Mock).mockResolvedValue([]); + (stakingState.getAllStakingFarms as jest.Mock).mockResolvedValue([]); + (stakingState.getAllStakingProxies as jest.Mock).mockResolvedValue([]); + (feesCollectorState.getFeesCollector as jest.Mock).mockResolvedValue({}); + + await service.processQueuedTasks(); + + expect(logger.warn).toHaveBeenCalledWith( + 'Re-queuing task "updateSnapshot" (attempt 3/5)', + expect.any(Object), + ); + + // Should still re-queue since 3 < 5 + expect(cacheService.zAdd).toHaveBeenCalled(); + }); + }); + + describe('INDEX_LP_TOKEN busy-wait behavior', () => { + // Skip this test as it would take 90+ seconds to complete due to busy-wait loop + it.skip('should throw error if LP token not found after max attempts', async () => { + const pairAddress = 'erd1qqqqqqqqqqqqqpgqeel2kumf0r8ffyhth7pqdujjat9nx0862jpsg2pqaq'; + const task = new TaskDto({ + name: StateTasks.INDEX_LP_TOKEN, + args: [pairAddress], + }); + cacheService.zPopMin.mockResolvedValue([JSON.stringify(task)]); + + const mockPair = { address: pairAddress }; + (pairsState.getPairs as jest.Mock).mockResolvedValue([mockPair]); + // Always return null (LP token not found) + (syncService.indexPairLpToken as jest.Mock).mockResolvedValue(null); + + await service.processQueuedTasks(); + + // Should attempt 60 times + expect(syncService.indexPairLpToken).toHaveBeenCalledTimes(60); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed processing task "indexLpToken"'), + expect.any(Error), + ); + }); + + it('should throw error if pair not found', async () => { + const pairAddress = 'erd1qqqqqqqqqqqqqpgqeel2kumf0r8ffyhth7pqdujjat9nx0862jpsg2pqaq'; + const task = new TaskDto({ + name: StateTasks.INDEX_LP_TOKEN, + args: [pairAddress], + }); + cacheService.zPopMin.mockResolvedValue([JSON.stringify(task)]); + + // Return empty array (pair not found) + (pairsState.getPairs as jest.Mock).mockResolvedValue([]); + + await service.processQueuedTasks(); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed processing task "indexLpToken"'), + expect.any(Error), + ); + }); + }); + + describe('task serialization and deserialization', () => { + it('should correctly serialize and deserialize tasks with complex arguments', async () => { + const pairMetadata: PairMetadata = { + address: 'erd1qqqqqqqqqqqqqpgqeel2kumf0r8ffyhth7pqdujjat9nx0862jpsg2pqaq', + firstTokenID: 'WEGLD-123456', + secondTokenID: 'USDC-123456', + }; + + const tasks = [ + new TaskDto({ + name: StateTasks.INDEX_PAIR, + args: [JSON.stringify(pairMetadata), '1640000000'], + }), + ]; + + await service.queueTasks(tasks); + + const serializedCall = cacheService.zAdd.mock.calls[0][1] as string; + const deserialized = JSON.parse(serializedCall); + + expect(deserialized.name).toBe('indexPair'); + expect(deserialized.args).toHaveLength(2); + expect(JSON.parse(deserialized.args[0])).toEqual(pairMetadata); + expect(deserialized.args[1]).toBe('1640000000'); + }); + }); + + describe('BROADCAST_PRICE_UPDATES edge cases', () => { + it('should handle empty token list in pending updates', async () => { + const task = new TaskDto({ name: StateTasks.BROADCAST_PRICE_UPDATES, args: [] }); + cacheService.zPopMin.mockResolvedValue([JSON.stringify(task)]); + cacheService.getSetMembers.mockResolvedValue([]); // No pending tokens + + await service.processQueuedTasks(); + + // Should not call getTokens if no tokens + expect(tokensState.getTokens).not.toHaveBeenCalled(); + // Should return early without deleting the set + expect(pubSub.publish).not.toHaveBeenCalled(); + }); + + it('should accumulate token IDs across multiple queue calls', async () => { + const batch1 = ['WEGLD-123456', 'MEX-123456']; + const batch2 = ['USDC-123456', 'USDT-123456']; + + await service.queueTasks([ + new TaskDto({ + name: StateTasks.BROADCAST_PRICE_UPDATES, + args: [JSON.stringify(batch1)], + }), + ]); + + await service.queueTasks([ + new TaskDto({ + name: StateTasks.BROADCAST_PRICE_UPDATES, + args: [JSON.stringify(batch2)], + }), + ]); + + // Should add both batches to the set + expect(cacheService.addToSet).toHaveBeenCalledWith( + PENDING_PRICE_UPDATES_KEY, + batch1, + ); + expect(cacheService.addToSet).toHaveBeenCalledWith( + PENDING_PRICE_UPDATES_KEY, + batch2, + ); + + // Should only queue once (deduplication) + expect(cacheService.zAdd).toHaveBeenCalledTimes(2); + }); + }); + + describe('populateState chaining', () => { + it('should queue REFRESH_PAIR_RESERVES after successful populateState', async () => { + const task = new TaskDto({ name: StateTasks.INIT_STATE, args: [] }); + cacheService.zPopMin.mockResolvedValue([JSON.stringify(task)]); + + (syncService.populateState as jest.Mock).mockResolvedValue({}); + (stateService.initState as jest.Mock).mockResolvedValue({}); + + await service.processQueuedTasks(); + + // Should queue REFRESH_PAIR_RESERVES after init + expect(cacheService.zAdd).toHaveBeenCalledWith( + STATE_TASKS_CACHE_KEY, + expect.stringContaining('"name":"refreshReserves"'), + StateTaskPriority[StateTasks.REFRESH_PAIR_RESERVES], + ); + }); + }); +}); diff --git a/src/modules/state/state.cron.module.ts b/src/modules/state/state.cron.module.ts index 38a7f6e7c..53b7a0be1 100644 --- a/src/modules/state/state.cron.module.ts +++ b/src/modules/state/state.cron.module.ts @@ -1,9 +1,12 @@ import { Module } from '@nestjs/common'; import { StateCronService } from './services/state.cron.service'; import { StateTasksModule } from './state.tasks.module'; +import { StateService } from './services/state.service'; +import { StateGrpcClientService } from './services/state.grpc.client.service'; +import { StateGrpcClientModule } from './state.grpc.client.module'; @Module({ - imports: [StateTasksModule], - providers: [StateCronService], + imports: [StateTasksModule, StateGrpcClientModule], + providers: [StateCronService, StateService, StateGrpcClientService], }) export class StateCronModule {}