From e97fd2fa6b9dd26357807e866eb0fc6c00aa1b59 Mon Sep 17 00:00:00 2001 From: hschiau Date: Wed, 11 Dec 2024 19:42:39 +0200 Subject: [PATCH 1/2] MEX-527: add pair memory store service - add service that implements the memory store interface (can be used seamless by the plugin) - add util for creating a model from an array of fields; handles special case of '__typename' being requested - add `@Expose` decorator to all args fields for both the `pairs` and `filteredPairs` queries so that they work with the `plainToInstance` function in class-transformer --- src/modules/common/filters/connection.args.ts | 5 + src/modules/dex.model.ts | 3 + .../memory-store/entities/global.state.ts | 4 + .../memory-store/memory.store.module.ts | 5 +- .../services/pair.memory.store.service.ts | 469 ++++++++++++++++++ .../memory-store/utils/graphql.utils.ts | 47 ++ src/modules/router/models/filter.args.ts | 39 +- 7 files changed, 569 insertions(+), 3 deletions(-) create mode 100644 src/modules/memory-store/services/pair.memory.store.service.ts diff --git a/src/modules/common/filters/connection.args.ts b/src/modules/common/filters/connection.args.ts index 2dc29f388..eee572a99 100644 --- a/src/modules/common/filters/connection.args.ts +++ b/src/modules/common/filters/connection.args.ts @@ -6,6 +6,7 @@ import { import { Field, Int, InputType } from '@nestjs/graphql'; import { IsOptional, Max } from 'class-validator'; import { PaginationArgs } from 'src/modules/dex.model'; +import { Expose } from 'class-transformer'; type PagingMeta = | { pagingType: 'forward'; after?: string; first: number } @@ -83,17 +84,21 @@ export function getPagingParameters(args: ConnectionArgs): PaginationArgs { @InputType() export default class ConnectionArgs implements ConnectionArguments { + @Expose() @Field({ nullable: true, description: 'Paginate before opaque cursor' }) public before?: ConnectionCursor; + @Expose() @Field({ nullable: true, description: 'Paginate after opaque cursor' }) public after?: ConnectionCursor; + @Expose() @IsOptional() @Max(100) @Field(() => Int, { nullable: true, description: 'Paginate first' }) public first?: number; + @Expose() @IsOptional() @Max(100) @Field(() => Int, { nullable: true, description: 'Paginate last' }) diff --git a/src/modules/dex.model.ts b/src/modules/dex.model.ts index 2d57cc078..b6a7b14a1 100644 --- a/src/modules/dex.model.ts +++ b/src/modules/dex.model.ts @@ -1,10 +1,13 @@ import { Field, ArgsType, Int } from '@nestjs/graphql'; +import { Expose } from 'class-transformer'; @ArgsType() export class PaginationArgs { + @Expose() @Field(() => Int) offset = 0; + @Expose() @Field(() => Int) limit = 10; diff --git a/src/modules/memory-store/entities/global.state.ts b/src/modules/memory-store/entities/global.state.ts index 7f17f43b7..f0f26aa2f 100644 --- a/src/modules/memory-store/entities/global.state.ts +++ b/src/modules/memory-store/entities/global.state.ts @@ -25,6 +25,10 @@ export class GlobalStateSingleton { public tokensState: { [key: string]: EsdtToken } = {}; public initStatus: GlobalStateInitStatus = GlobalStateInitStatus.NOT_STARTED; + + public getPairsArray(): PairModel[] { + return Object.values(this.pairsState); + } } export const GlobalState = new GlobalStateSingleton(); diff --git a/src/modules/memory-store/memory.store.module.ts b/src/modules/memory-store/memory.store.module.ts index 6f8a602dc..e5cdbe5c0 100644 --- a/src/modules/memory-store/memory.store.module.ts +++ b/src/modules/memory-store/memory.store.module.ts @@ -1,8 +1,9 @@ import { Module } from '@nestjs/common'; import { MemoryStoreFactoryService } from './services/memory.store.factory.service'; +import { PairMemoryStoreService } from './services/pair.memory.store.service'; @Module({ - providers: [MemoryStoreFactoryService], - exports: [MemoryStoreFactoryService], + providers: [MemoryStoreFactoryService, PairMemoryStoreService], + exports: [MemoryStoreFactoryService, PairMemoryStoreService], }) export class MemoryStoreModule {} diff --git a/src/modules/memory-store/services/pair.memory.store.service.ts b/src/modules/memory-store/services/pair.memory.store.service.ts new file mode 100644 index 000000000..6415ff62b --- /dev/null +++ b/src/modules/memory-store/services/pair.memory.store.service.ts @@ -0,0 +1,469 @@ +import { Injectable } from '@nestjs/common'; +import { PairModel } from '../../pair/models/pair.model'; +import { + GlobalState, + GlobalStateInitStatus, +} from 'src/modules/memory-store/entities/global.state'; +import { + PairFilterArgs, + PairsFilter, + PairSortableFields, + PairSortingArgs, +} from 'src/modules/router/models/filter.args'; +import { PaginationArgs } from 'src/modules/dex.model'; +import { QueryField } from 'src/modules/memory-store/entities/query.field.type'; +import { createModelFromFields } from 'src/modules/memory-store/utils/graphql.utils'; +import { plainToInstance } from 'class-transformer'; +import ConnectionArgs, { + getPagingParameters, +} from 'src/modules/common/filters/connection.args'; +import PageResponse from 'src/modules/common/page.response'; +import BigNumber from 'bignumber.js'; +import { SortingOrder } from 'src/modules/common/page.data'; +import { IMemoryStoreService } from 'src/modules/memory-store/services/interfaces'; +import { PairsResponse } from '../../pair/models/pairs.response'; +import { Connection } from 'graphql-relay'; +import { PairFilteringService } from 'src/modules/pair/services/pair.filtering.service'; + +@Injectable() +export class PairMemoryStoreService extends IMemoryStoreService< + PairModel, + PairsResponse +> { + static typenameMappings: Record> = { + PairModel: { + firstToken: 'EsdtToken', + secondToken: 'EsdtToken', + liquidityPoolToken: 'EsdtToken', + info: 'PairInfoModel', + compoundedAPR: 'PairCompoundedAPRModel', + rewardTokens: 'PairRewardTokensModel', + }, + PairRewardTokensModel: { + poolRewards: 'EsdtToken', + farmReward: 'NftCollection', + dualFarmReward: 'EsdtToken', + }, + EsdtToken: { + assets: 'AssetsModel', + roles: 'RolesModel', + }, + NftCollection: { + assets: 'AssetsModel', + roles: 'RolesModel', + }, + AssetsModel: { + social: 'SocialModel', + }, + }; + + static targetedQueries: Record< + string, + { + isFiltered: boolean; + missingFields: QueryField[]; + identifierField: string; + } + > = { + pairs: { + isFiltered: false, + identifierField: 'address', + missingFields: [ + { name: 'firstTokenVolume24h' }, + { name: 'secondTokenVolume24h' }, + { name: 'previous24hVolumeUSD' }, + { name: 'previous24hFeesUSD' }, + { name: 'lockedTokensInfo' }, + { name: 'whitelistedManagedAddresses' }, + { name: 'initialLiquidityAdder' }, + { name: 'feeDestinations' }, + { name: 'feesCollector' }, + { name: 'feesCollectorCutPercentage' }, + { name: 'trustedSwapPairs' }, + ], + }, + filteredPairs: { + isFiltered: true, + identifierField: 'address', + missingFields: [ + { name: 'firstTokenVolume24h' }, + { name: 'secondTokenVolume24h' }, + { name: 'previous24hVolumeUSD' }, + { name: 'previous24hFeesUSD' }, + { name: 'lockedTokensInfo' }, + { name: 'whitelistedManagedAddresses' }, + { name: 'initialLiquidityAdder' }, + { name: 'feeDestinations' }, + { name: 'feesCollector' }, + { name: 'feesCollectorCutPercentage' }, + { name: 'trustedSwapPairs' }, + ], + }, + }; + + isReady(): boolean { + return GlobalState.initStatus === GlobalStateInitStatus.DONE; + } + + getAllData(): PairModel[] { + return GlobalState.getPairsArray(); + } + + getQueryResponse( + queryName: string, + queryArguments: Record, + requestedFields: QueryField[], + ): PairModel[] | PairsResponse { + if (PairMemoryStoreService.targetedQueries[queryName] === undefined) { + throw new Error( + `Data for query '${queryName}' is not solvable from the memory store.`, + ); + } + + const isFilteredQuery = + PairMemoryStoreService.targetedQueries[queryName].isFiltered; + let pairs = GlobalState.getPairsArray(); + + const pagination = this.getPaginationFromArgs( + queryArguments, + isFilteredQuery, + ); + const filters = this.getFiltersFromArgs( + queryArguments, + isFilteredQuery, + ); + const sorting = this.getSortingFromArgs(queryArguments); + + pairs = this.filterPairs(pairs, filters); + + if (!isFilteredQuery) { + return pairs + .map((pair) => + createModelFromFields( + pair, + requestedFields, + 'PairModel', + this.getTypenameMapping(), + ), + ) + .slice(pagination.offset, pagination.offset + pagination.limit); + } + + if (sorting && sorting.sortField) { + pairs = this.sortPairs(pairs, sorting.sortField, sorting.sortOrder); + } + + const totalCount = pairs.length; + + return PageResponse.mapResponse( + pairs + .map((pair) => + createModelFromFields( + pair, + requestedFields, + 'PairModel', + this.getTypenameMapping(), + ), + ) + .slice(pagination.offset, pagination.offset + pagination.limit), + this.getConnectionFromArgs(queryArguments) ?? new ConnectionArgs(), + totalCount, + pagination.offset, + pagination.limit, + ); + } + + appendFieldsToQueryResponse( + queryName: string, + response: PairModel[] | PairsResponse, + requestedFields: QueryField[], + ): PairModel[] | PairsResponse { + if (PairMemoryStoreService.targetedQueries[queryName] === undefined) { + throw new Error( + `Data for query '${queryName}' is not solvable from the memory store.`, + ); + } + + if (PairMemoryStoreService.targetedQueries[queryName].isFiltered) { + return this.appendFieldsToFilteredQueryResponse( + response, + queryName, + requestedFields, + ); + } + + const responseArray = response as PairModel[]; + + const identifierField = + PairMemoryStoreService.targetedQueries[queryName].identifierField; + + const originalIdentifiers = responseArray.map( + (pair) => pair[identifierField], + ); + + const pairsFromStore = this.getPairsByIDs( + queryName, + requestedFields, + originalIdentifiers, + ); + + return responseArray.map((pair, index) => { + return { + ...pair, + ...pairsFromStore[index], + }; + }); + } + + getTypenameMapping(): Record> { + return PairMemoryStoreService.typenameMappings; + } + + getTargetedQueries(): Record< + string, + { + isFiltered: boolean; + missingFields: QueryField[]; + identifierField: string; + } + > { + return PairMemoryStoreService.targetedQueries; + } + + private appendFieldsToFilteredQueryResponse( + response: Record, + queryName: string, + requestedFields: QueryField[], + ): PairsResponse { + const identifierField = + PairMemoryStoreService.targetedQueries[queryName].identifierField; + + const connectionResponse = response as Connection; + + const originalIdentifiers = connectionResponse.edges.map((edge) => { + return edge.node[identifierField]; + }); + + const pairsFromStore = this.getPairsByIDs( + queryName, + requestedFields, + originalIdentifiers, + ); + + connectionResponse.edges = connectionResponse.edges.map( + (edge, index) => { + edge.node = { + ...edge.node, + ...pairsFromStore[index], + }; + return edge; + }, + ); + + return connectionResponse as PairsResponse; + } + + private getPairsByIDs( + queryName: string, + requestedFields: QueryField[], + identifiers?: string[], + ): PairModel[] { + const identifierField = + PairMemoryStoreService.targetedQueries[queryName].identifierField; + + let pairs = GlobalState.getPairsArray(); + + if (identifiers && identifiers.length > 0) { + pairs = pairs.filter((pair) => + identifiers.includes(pair[identifierField]), + ); + } + + return pairs.map((pair) => + createModelFromFields( + pair, + requestedFields, + 'PairModel', + this.getTypenameMapping(), + ), + ); + } + + private getFiltersFromArgs( + queryArguments: Record, + isFilteredQuery: boolean, + ): PairFilterArgs | PairsFilter { + const filters = isFilteredQuery + ? plainToInstance(PairsFilter, queryArguments.filters, { + excludeExtraneousValues: true, + enableImplicitConversion: true, + exposeUnsetFields: false, + }) + : plainToInstance(PairFilterArgs, queryArguments, { + excludeExtraneousValues: true, + enableImplicitConversion: true, + exposeUnsetFields: false, + }); + + return filters; + } + + private getPaginationFromArgs( + queryArguments: Record, + isFilteredQuery: boolean, + ): PaginationArgs { + if (!isFilteredQuery) { + return plainToInstance(PaginationArgs, queryArguments, { + excludeExtraneousValues: true, + enableImplicitConversion: true, + exposeUnsetFields: false, + }); + } + + const connectionArgs = this.getConnectionFromArgs(queryArguments); + + return getPagingParameters(connectionArgs); + } + + private getConnectionFromArgs( + queryArguments: Record, + ): ConnectionArgs { + return plainToInstance(ConnectionArgs, queryArguments.pagination, { + excludeExtraneousValues: true, + enableImplicitConversion: true, + exposeUnsetFields: false, + }); + } + + private getSortingFromArgs( + queryArguments: Record, + ): PairSortingArgs { + return plainToInstance(PairSortingArgs, queryArguments.sorting, { + excludeExtraneousValues: true, + enableImplicitConversion: true, + exposeUnsetFields: false, + }); + } + + private filterPairs( + pairs: PairModel[], + filters: PairFilterArgs | PairsFilter, + ): PairModel[] { + pairs = PairFilteringService.pairsByIssuedLpToken(filters, pairs); + pairs = PairFilteringService.pairsByAddress(filters, pairs); + pairs = PairFilteringService.pairsByTokens(filters, pairs); + pairs = PairFilteringService.pairsByState(filters, pairs); + pairs = PairFilteringService.pairsByFeeState(filters, pairs); + pairs = PairFilteringService.pairsByVolume(filters, pairs); + pairs = PairFilteringService.pairsByLockedValueUSD(filters, pairs); + + if (!(filters instanceof PairsFilter)) { + return pairs; + } + + pairs = PairFilteringService.pairsByLpTokenIds(filters, pairs); + // TODO: add pair related farm tokens to GlobalState.pairsEsdtTokens + pairs = PairFilteringService.pairsByFarmTokens( + filters, + pairs, + [], // replace with token IDs + ); + pairs = PairFilteringService.pairsByTradesCount(filters, pairs); + pairs = PairFilteringService.pairsByTradesCount24h(filters, pairs); + pairs = PairFilteringService.pairsByHasFarms(filters, pairs); + pairs = PairFilteringService.pairsByHasDualFarms(filters, pairs); + pairs = PairFilteringService.pairsByDeployedAt(filters, pairs); + + return pairs; + } + + private sortPairs( + pairs: PairModel[], + sortField: string, + sortOrder: SortingOrder, + ): PairModel[] { + switch (sortField) { + case PairSortableFields.DEPLOYED_AT: + return pairs.sort((a, b) => { + if (sortOrder === SortingOrder.ASC) { + return new BigNumber(a.deployedAt).comparedTo( + b.deployedAt, + ); + } + return new BigNumber(b.deployedAt).comparedTo(a.deployedAt); + }); + case PairSortableFields.FEES_24: + return pairs.sort((a, b) => { + if (sortOrder === SortingOrder.ASC) { + return new BigNumber(a.feesUSD24h).comparedTo( + b.feesUSD24h, + ); + } + return new BigNumber(b.feesUSD24h).comparedTo(a.feesUSD24h); + }); + case PairSortableFields.TRADES_COUNT: + return pairs.sort((a, b) => { + if (sortOrder === SortingOrder.ASC) { + return new BigNumber(a.tradesCount).comparedTo( + b.tradesCount, + ); + } + return new BigNumber(b.tradesCount).comparedTo( + a.tradesCount, + ); + }); + case PairSortableFields.TRADES_COUNT_24: + return pairs.sort((a, b) => { + if (sortOrder === SortingOrder.ASC) { + return new BigNumber(a.tradesCount24h).comparedTo( + b.tradesCount24h, + ); + } + return new BigNumber(b.tradesCount24h).comparedTo( + a.tradesCount24h, + ); + }); + case PairSortableFields.TVL: + return pairs.sort((a, b) => { + if (sortOrder === SortingOrder.ASC) { + return new BigNumber(a.lockedValueUSD).comparedTo( + b.lockedValueUSD, + ); + } + return new BigNumber(b.lockedValueUSD).comparedTo( + a.lockedValueUSD, + ); + }); + case PairSortableFields.VOLUME_24: + return pairs.sort((a, b) => { + if (sortOrder === SortingOrder.ASC) { + return new BigNumber(a.volumeUSD24h).comparedTo( + b.volumeUSD24h, + ); + } + return new BigNumber(b.volumeUSD24h).comparedTo( + a.volumeUSD24h, + ); + }); + case PairSortableFields.APR: + return pairs.sort((a, b) => { + const aprA = new BigNumber(a.compoundedAPR.feesAPR) + .plus(a.compoundedAPR.farmBaseAPR) + .plus(a.compoundedAPR.farmBoostedAPR) + .plus(a.compoundedAPR.dualFarmBaseAPR) + .plus(a.compoundedAPR.dualFarmBoostedAPR); + const aprB = new BigNumber(b.compoundedAPR.feesAPR) + .plus(b.compoundedAPR.farmBaseAPR) + .plus(b.compoundedAPR.farmBoostedAPR) + .plus(b.compoundedAPR.dualFarmBaseAPR) + .plus(b.compoundedAPR.dualFarmBoostedAPR); + if (sortOrder === SortingOrder.ASC) { + return aprA.comparedTo(aprB); + } + return aprB.comparedTo(aprA); + }); + default: + return pairs; + } + } +} diff --git a/src/modules/memory-store/utils/graphql.utils.ts b/src/modules/memory-store/utils/graphql.utils.ts index 9b859eaf6..d968b3dbb 100644 --- a/src/modules/memory-store/utils/graphql.utils.ts +++ b/src/modules/memory-store/utils/graphql.utils.ts @@ -91,6 +91,53 @@ export function parseArguments( return args; } +export function createModelFromFields( + data: any, + fields: QueryField[], + typeName: string, + typeMapping: Record>, +): any { + const result: Record = {}; + + for (const field of fields) { + if (field.name === '__typename') { + result[field.name] = typeName; + continue; + } + + const fieldData = data?.[field.name]; + const subfields = field.subfields || []; + + if (subfields.length === 0) { + result[field.name] = fieldData ?? null; + continue; + } + + if (Array.isArray(fieldData)) { + result[field.name] = fieldData.map((item) => + createModelFromFields( + item || {}, + subfields, + typeMapping[typeName]?.[field.name] || 'UnknownType', + typeMapping, + ), + ); + } else { + result[field.name] = + fieldData !== undefined + ? createModelFromFields( + fieldData, + subfields, + typeMapping[typeName]?.[field.name] || 'UnknownType', + typeMapping, + ) + : null; + } + } + + return result; +} + function resolveValueNode( valueNode: ValueNode, variables: Record, diff --git a/src/modules/router/models/filter.args.ts b/src/modules/router/models/filter.args.ts index bb5ab5131..d2730d1fc 100644 --- a/src/modules/router/models/filter.args.ts +++ b/src/modules/router/models/filter.args.ts @@ -1,4 +1,5 @@ import { ArgsType, Field, InputType, registerEnumType } from '@nestjs/graphql'; +import { Expose, Transform } from 'class-transformer'; import { SortingOrder } from 'src/modules/common/page.data'; export enum PairSortableFields { @@ -15,65 +16,101 @@ registerEnumType(PairSortableFields, { name: 'PairSortableFields' }); @ArgsType() export class PairFilterArgs { + @Expose() @Field(() => [String], { nullable: true }) addresses: string[]; + @Expose() @Field({ nullable: true }) firstTokenID: string; + @Expose() @Field({ nullable: true }) secondTokenID: string; + @Expose() @Field(() => Boolean) issuedLpToken = true; + @Expose() @Field({ nullable: true }) state: string; + @Expose() @Field({ nullable: true }) minVolume: number; + @Expose() @Field({ nullable: true }) feeState: boolean; + @Expose() @Field({ nullable: true }) minLockedValueUSD: number; } @InputType() export class PairsFilter { + @Expose() @Field(() => [String], { nullable: true }) addresses: string[]; + @Expose() @Field({ nullable: true }) firstTokenID: string; + @Expose() @Field({ nullable: true }) secondTokenID: string; + @Expose() @Field(() => Boolean) issuedLpToken = true; + @Expose() @Field(() => [String], { nullable: true }) state: string[]; + @Expose() @Field({ nullable: true }) minVolume: number; + @Expose() @Field({ nullable: true }) feeState: boolean; + @Expose() @Field({ nullable: true }) minLockedValueUSD: number; + @Expose() @Field({ nullable: true }) minTradesCount: number; + @Expose() @Field({ nullable: true }) minTradesCount24h: number; + @Expose() @Field({ nullable: true }) hasFarms: boolean; + @Expose() @Field({ nullable: true }) hasDualFarms: boolean; + @Expose() @Field({ nullable: true }) minDeployedAt: number; + @Expose() @Field({ nullable: true }) searchToken: string; + @Expose() @Field(() => [String], { nullable: true }) lpTokenIds: string[]; + @Expose() @Field(() => [String], { nullable: true }) farmTokens: string[]; } +export function pairSortableFieldToString(value: PairSortableFields): string { + return PairSortableFields[value]; +} + +export function sortingOrderToString(value: SortingOrder): string { + return SortingOrder[value]; +} + @InputType() export class PairSortingArgs { + @Expose() + @Transform(({ value }) => pairSortableFieldToString(value)) @Field(() => PairSortableFields, { nullable: true }) sortField?: string; + @Expose() + @Transform(({ value }) => sortingOrderToString(value)) @Field(() => SortingOrder, { defaultValue: SortingOrder.ASC }) - sortOrder: string; + sortOrder = SortingOrder.ASC; } From f5fc511bf545ea1102ebd58dbbfbf69b4a80ab82 Mon Sep 17 00:00:00 2001 From: hschiau Date: Wed, 11 Dec 2024 19:43:21 +0200 Subject: [PATCH 2/2] MEX-527: add pairs memory store to factory service --- .../services/memory.store.factory.service.ts | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/modules/memory-store/services/memory.store.factory.service.ts b/src/modules/memory-store/services/memory.store.factory.service.ts index 44f253409..0a72cab41 100644 --- a/src/modules/memory-store/services/memory.store.factory.service.ts +++ b/src/modules/memory-store/services/memory.store.factory.service.ts @@ -1,13 +1,29 @@ import { Injectable } from '@nestjs/common'; import { IMemoryStoreService } from './interfaces'; +import { PairMemoryStoreService } from './pair.memory.store.service'; +import { PairModel } from 'src/modules/pair/models/pair.model'; +import { PairsResponse } from 'src/modules/pair/models/pairs.response'; @Injectable() export class MemoryStoreFactoryService { private queryMapping: Record> = {}; + constructor(private readonly pairMemoryStore: PairMemoryStoreService) { + const pairQueries = Object.keys( + this.pairMemoryStore.getTargetedQueries(), + ); + + for (const query of pairQueries) { + this.queryMapping[query] = this + .pairMemoryStore as IMemoryStoreService< + PairModel[], + PairsResponse + >; + } + } + isReady(): boolean { - // TODO: replace with actual checks on compatible memory store services - return false; + return this.pairMemoryStore.isReady(); } getTargetedQueryNames(): string[] {