diff --git a/src/app.module.ts b/src/app.module.ts index e00dd80..76a6409 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -49,6 +49,8 @@ import { SwethController } from "./sweth/sweth.controller"; import { SwethService } from "./sweth/sweth.service"; import { SwethApiService } from "./sweth/sweth.api.service"; import { User, UserHolding, UserStaked, UserWithdraw } from "./entities/index"; +import { PositionsService } from "./positions/positions.service"; +import { PositionsController } from "./positions/positions.controller"; @Module({ imports: [ @@ -90,6 +92,7 @@ import { User, UserHolding, UserStaked, UserWithdraw } from "./entities/index"; NovaPagingController, CacheController, SwethController, + PositionsController, ], providers: [ { @@ -123,6 +126,7 @@ import { User, UserHolding, UserStaked, UserWithdraw } from "./entities/index"; RedistributeBalanceRepository, SwethService, SwethApiService, + PositionsService, ], }) export class AppModule {} diff --git a/src/common/pagination.util.ts b/src/common/pagination.util.ts index 3ea2235..77a8cf3 100644 --- a/src/common/pagination.util.ts +++ b/src/common/pagination.util.ts @@ -18,4 +18,22 @@ export class PaginationUtil { items: paginatedItems, } as PagingDto; } + + static genPaginateMetaByTotalCount( + total: number, + page: number, + limit: number, + ): PagingMetaDto { + const itemCount = + page * limit > total ? Math.max(total - (page - 1) * limit, 0) : limit; + const pagingMeta = { + currentPage: Number(page), + itemCount: itemCount, + itemsPerPage: Number(limit), + totalItems: total, + totalPages: Math.ceil(total / limit), + }; + + return pagingMeta; + } } diff --git a/src/common/pagingOptionsDto.dto.ts b/src/common/pagingOptionsDto.dto.ts index b1db88d..b84fcbf 100644 --- a/src/common/pagingOptionsDto.dto.ts +++ b/src/common/pagingOptionsDto.dto.ts @@ -14,7 +14,7 @@ export class PagingOptionsDto { @IsInt() @Min(1) @IsOptional() - public readonly page: number = 1; + public readonly page?: number = 1; @ApiPropertyOptional({ type: "integer", @@ -29,5 +29,5 @@ export class PagingOptionsDto { @Min(1) @Max(500) @IsOptional() - public readonly limit: number = 100; + public readonly limit?: number = 100; } diff --git a/src/entities/balanceOfLp.entity.ts b/src/entities/balanceOfLp.entity.ts index aac02fd..b5667ca 100644 --- a/src/entities/balanceOfLp.entity.ts +++ b/src/entities/balanceOfLp.entity.ts @@ -4,7 +4,7 @@ import { bigIntNumberTransformer } from "../transformers/bigIntNumber.transforme import { hexTransformer } from "../transformers/hex.transformer"; @Entity({ name: "balancesOfLp" }) -@Index(["blockNumber", "balance"]) +@Index(["blockNumber", "pairAddress", "tokenAddress", "address"]) export class BalanceOfLp extends BaseEntity { @PrimaryColumn({ type: "bytea", transformer: hexTransformer }) public readonly address: string; diff --git a/src/positions/positions.controller.ts b/src/positions/positions.controller.ts new file mode 100644 index 0000000..17f44a4 --- /dev/null +++ b/src/positions/positions.controller.ts @@ -0,0 +1,60 @@ +import { Controller, Get, Param, Query } from "@nestjs/common"; +import { + ApiBadRequestResponse, + ApiExcludeController, + ApiNotFoundResponse, + ApiParam, + ApiTags, +} from "@nestjs/swagger"; +import { + GetAGXPositionDto, + GetUserPositionsDto, + UserPositionsResponseDto, +} from "./positions.dto"; +import { PositionsService } from "./positions.service"; + +@ApiTags("positions") +@ApiExcludeController(false) +@Controller("positions") +export class PositionsController { + constructor(private positionsService: PositionsService) {} + + @Get(":projectName") + @ApiParam({ + name: "projectName", + required: true, + description: "Project name", + }) + @ApiBadRequestResponse({ + description: '{ "errno": 1, "errmsg": "Service exception" }', + }) + @ApiNotFoundResponse({ + description: '{ "errno": 1, "errmsg": "not found" }', + }) + async getProjectPositions( + @Param("projectName") projectName: string, + @Query() queryParams: GetUserPositionsDto, + ): Promise { + const { data, meta } = + await this.positionsService.getPositionsByProjectAndAddress({ + projectName, + ...queryParams, + }); + + return { + errmsg: "no error", + errno: 0, + meta: meta, + data: data, + }; + } + + @Get("agx/etherfi") + async getAgxUserEtherFiPositions(@Query() queryString: GetAGXPositionDto) { + const { blockNumber } = queryString; + const data = + await this.positionsService.getAgxEtherfiPositionsByBlock(blockNumber); + + return data; + } +} diff --git a/src/positions/positions.dto.ts b/src/positions/positions.dto.ts new file mode 100644 index 0000000..4603ca8 --- /dev/null +++ b/src/positions/positions.dto.ts @@ -0,0 +1,91 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsInt, IsOptional, Min } from "class-validator"; +import { PagingMetaDto } from "src/common/paging.dto"; +import { PagingOptionsDto } from "src/common/pagingOptionsDto.dto"; + +export class GetAGXPositionDto { + @IsInt() + @Min(1) + @Type(() => Number) + blockNumber: number; +} + +export class GetUserPositionsDto extends PagingOptionsDto { + @ApiProperty({ + required: false, + description: "Comma separated list of token addresses", + }) + @IsOptional() + tokenAddresses?: string; + + @ApiProperty({ + required: false, + description: "query positions at the blockNumber", + }) + @IsInt() + @Min(1) + @IsOptional() + @Type(() => Number) + blockNumber?: number; + + @ApiProperty({ + required: false, + description: "user Address", + }) + @IsOptional() + userAddress?: string; +} + +export class UserPositionsDto { + @ApiProperty({ + type: String, + description: "user address", + example: "0xc48F99afe872c2541f530C6c87E3A6427e0C40d5", + }) + userAddress: string; + + @ApiProperty({ + type: String, + description: "token address", + example: "0x8280a4e7D5B3B658ec4580d3Bc30f5e50454F169", + }) + tokenAddress: string; + + @ApiProperty({ + type: String, + description: "token balance", + example: "10000000000000000", + }) + balance: string; +} + +export class UserPositionsResponseDto { + @ApiProperty({ + type: Number, + description: "error code", + example: 0, + }) + public readonly errno: number; + + @ApiProperty({ + type: String, + description: "error message", + example: "no error", + }) + public readonly errmsg: string; + + @ApiProperty({ + type: PagingMetaDto, + description: "page meta", + example: 0, + }) + public readonly meta?: PagingMetaDto; + + @ApiProperty({ + type: UserPositionsDto, + description: "user position list", + nullable: true, + }) + public readonly data?: UserPositionsDto[]; +} diff --git a/src/positions/positions.service.ts b/src/positions/positions.service.ts new file mode 100644 index 0000000..46b7eca --- /dev/null +++ b/src/positions/positions.service.ts @@ -0,0 +1,49 @@ +import { Injectable } from "@nestjs/common"; +import { BalanceOfLpRepository } from "src/repositories/balanceOfLp.repository"; +import { GetUserPositionsDto } from "./positions.dto"; +import { ethers } from "ethers"; +import { PaginationUtil } from "src/common/pagination.util"; + +@Injectable() +export class PositionsService { + constructor(private balanceOfRepository: BalanceOfLpRepository) {} + + async getPositionsByProjectAndAddress( + params: GetUserPositionsDto & { projectName: string }, + ) { + const { tokenAddresses, limit = 100, page = 1 } = params; + const formattedParams = { + ...params, + tokenAddresses: tokenAddresses?.split(",") ?? [], + limit, + page, + }; + const { list, totalCount } = + await this.balanceOfRepository.getProjectPositionsByAddress( + formattedParams, + ); + const meta = PaginationUtil.genPaginateMetaByTotalCount( + totalCount, + page, + limit, + ); + return { data: list, meta: meta }; + } + + async getAgxEtherfiPositionsByBlock(blockNumber: number) { + let result: Array<{ address: string; effective_balance: number }> = []; + + const data = await this.balanceOfRepository.getAgxEtherfiPositions({ + blockNumber, + }); + + result = data.map((i) => ({ + address: i.userAddress, + effective_balance: Number(ethers.formatUnits(i.balance)), + })); + + return { + Result: result, + }; + } +} diff --git a/src/repositories/balanceOfLp.repository.ts b/src/repositories/balanceOfLp.repository.ts index 14beb7f..f87a0f3 100644 --- a/src/repositories/balanceOfLp.repository.ts +++ b/src/repositories/balanceOfLp.repository.ts @@ -2,10 +2,24 @@ import { Injectable } from "@nestjs/common"; import { UnitOfWork } from "../unitOfWork"; import { BaseRepository } from "./base.repository"; import { BalanceOfLp } from "../entities/balanceOfLp.entity"; +import { ProjectRepository } from "./project.repository"; +import { + GetAGXPositionDto, + GetUserPositionsDto, +} from "src/positions/positions.dto"; + +export interface Position { + userAddress: string; + tokenAddress: string; + balance: string; +} @Injectable() export class BalanceOfLpRepository extends BaseRepository { - public constructor(unitOfWork: UnitOfWork) { + public constructor( + unitOfWork: UnitOfWork, + readonly projectRepository: ProjectRepository, + ) { super(BalanceOfLp, unitOfWork); } @@ -71,4 +85,159 @@ export class BalanceOfLpRepository extends BaseRepository { return row; }); } + + private async getClosestBlockNumber( + blockNumber: number, + pairAddressBuffers: Buffer[], + ) { + let result: number | undefined; + const entityManager = this.unitOfWork.getTransactionManager(); + if (blockNumber === undefined) { + const latestBlock = await entityManager + .createQueryBuilder(BalanceOfLp, "b") + .select("MAX(b.blockNumber)", "max") + .where("b.pairAddress IN (:...pairAddresses)", { + pairAddresses: pairAddressBuffers, + }) + .getRawOne(); + + result = latestBlock ? Number(latestBlock.max) : 0; + } else { + const closestBlock = await entityManager + .createQueryBuilder(BalanceOfLp, "b") + .select("b.blockNumber") + .where("b.blockNumber <= :blockNumber", { blockNumber }) + .andWhere("b.pairAddress IN (:...pairAddresses)", { + pairAddresses: pairAddressBuffers, + }) + .orderBy("b.blockNumber", "DESC") + .getOne(); + + result = closestBlock?.blockNumber ?? 0; + } + return result; + } + + private async genPositionsQueryBuilder( + projectName: string, + blockNumber: number, + tokenAddresses: string[], + ) { + const pairAddressBuffers = + await this.projectRepository.getPairAddresses(projectName); + + const entityManager = this.unitOfWork.getTransactionManager(); + + const closestBlockNumber = await this.getClosestBlockNumber( + blockNumber, + pairAddressBuffers, + ); + + let queryBuilder = entityManager + .createQueryBuilder(BalanceOfLp, "b") + .select([ + 'b.address AS "userAddress"', + 'b.tokenAddress AS "tokenAddress"', + 'SUM(CAST(b.balance AS numeric)) AS "balance"', + ]) + .where("b.blockNumber = :blockNumber", { + blockNumber: closestBlockNumber, + }) + .andWhere("b.pairAddress IN (:...pairAddresses)", { + pairAddresses: pairAddressBuffers, + }) + .groupBy("b.address, b.tokenAddress"); + + if (tokenAddresses.length > 0) { + const tokenAddressBuffers = tokenAddresses.map((addr) => + Buffer.from(addr.slice(2), "hex"), + ); + + queryBuilder = queryBuilder.andWhere( + "b.tokenAddress IN (:...tokenAddressList)", + { tokenAddressList: tokenAddressBuffers }, + ); + } + return queryBuilder; + } + + public async getProjectPositionsByAddress({ + projectName, + tokenAddresses, + page, + limit, + blockNumber, + userAddress, + }: Omit & { + projectName: string; + tokenAddresses: string[]; + }): Promise<{ + totalCount: number; + list: Position[]; + }> { + let queryBuilder = await this.genPositionsQueryBuilder( + projectName, + blockNumber, + tokenAddresses, + ); + + const total = await queryBuilder.getCount(); + + if (limit) { + if (page) { + queryBuilder = queryBuilder.skip((page - 1) * limit).take(limit); + } else { + queryBuilder = queryBuilder.take(limit); + } + } + + if (userAddress) { + const userAddressBuffer = Buffer.from(userAddress.slice(2), "hex"); + + queryBuilder = queryBuilder.andWhere("b.address = :userAddress", { + userAddress: userAddressBuffer, + }); + } + + const balances = await queryBuilder.getRawMany<{ + userAddress: Buffer; + tokenAddress: Buffer; + balance: string; + }>(); + + return { + totalCount: total, + list: balances.map((item) => ({ + ...item, + userAddress: "0x" + item.userAddress.toString("hex"), + tokenAddress: "0x" + item.tokenAddress.toString("hex"), + })), + }; + } + + public async getAgxEtherfiPositions({ + blockNumber, + }: GetAGXPositionDto): Promise { + const tokenAddresses = [ + "0x35D5f1b41319e0ebb5a10e55C3BD23f121072da8", + "0xE227155217513f1ACaA2849A872ab933cF2d6a9A", + ]; + const queryBuilder = await this.genPositionsQueryBuilder( + "agx", + blockNumber, + tokenAddresses, + ); + + const balances = await queryBuilder.getRawMany<{ + userAddress: Buffer; + tokenAddress: Buffer; + balance: string; + }>(); + + return balances.map((item) => ({ + ...item, + userAddress: "0x" + item.userAddress.toString("hex"), + tokenAddress: "0x" + item.tokenAddress.toString("hex"), + })); + } }