diff --git a/.env.example b/.env.example index a0388ae..5b03892 100644 --- a/.env.example +++ b/.env.example @@ -19,4 +19,5 @@ REDIS_HOST=localhost REDIS_PORT=6379 # cache -MY_STAKING_OVERVIEW=120000 #2 mins \ No newline at end of file +MY_STAKING_OVERVIEW=120000 #2 mins +GLOBAL_STAKING_OVERVIEW=120000 #2 mins \ No newline at end of file diff --git a/src/core/config/config.dto.ts b/src/core/config/config.dto.ts index 621aca8..60617a8 100644 --- a/src/core/config/config.dto.ts +++ b/src/core/config/config.dto.ts @@ -29,4 +29,5 @@ export interface RedisConfig { export interface CacheConfig { myStakingOverview: number; + globalStakingOverview: number; } \ No newline at end of file diff --git a/src/core/config/config.schema.ts b/src/core/config/config.schema.ts index 1fb2492..52e9756 100644 --- a/src/core/config/config.schema.ts +++ b/src/core/config/config.schema.ts @@ -12,4 +12,5 @@ export const ConfigSchema = Joi.object({ REDIS_HOST: Joi.string().required(), REDIS_PORT: Joi.string().required(), MY_STAKING_OVERVIEW: Joi.number().required(), + GLOBAL_STAKING_OVERVIEW: Joi.number().required(), }).required(); diff --git a/src/core/config/config.ts b/src/core/config/config.ts index 49e9b82..370a4ab 100644 --- a/src/core/config/config.ts +++ b/src/core/config/config.ts @@ -20,5 +20,6 @@ export const config: ConfigDto = { }, cache: { myStakingOverview: +process.env.MY_STAKING_OVERVIEW!, + globalStakingOverview: +process.env.GLOBAL_STAKING_OVERVIEW!, } }; diff --git a/src/core/lib/okp4/enums/endpoints.enum.ts b/src/core/lib/okp4/enums/endpoints.enum.ts index bc1d957..c653f69 100644 --- a/src/core/lib/okp4/enums/endpoints.enum.ts +++ b/src/core/lib/okp4/enums/endpoints.enum.ts @@ -1,7 +1,9 @@ export enum Endpoints { - SUPPLY_BY_DENOM = '/cosmos/bank/v1beta1/supply/by_denom', - STAKING_DELEGATIONS = '/cosmos/staking/v1beta1/delegations', - DELEGATORS_VALIDATORS = '/cosmos/staking/v1beta1/delegators/:delegator_addr/validators', - DELEGATORS_REWARDS = '/cosmos/distribution/v1beta1/delegators/:delegator_addr/rewards', - SPENDABLE_BALANCE = '/cosmos/bank/v1beta1/spendable_balances', + SUPPLY_BY_DENOM = 'cosmos/bank/v1beta1/supply/by_denom', + STAKING_DELEGATIONS = 'cosmos/staking/v1beta1/delegations', + DELEGATORS_VALIDATORS = 'cosmos/staking/v1beta1/delegators/:delegator_addr/validators', + DELEGATORS_REWARDS = 'cosmos/distribution/v1beta1/delegators/:delegator_addr/rewards', + SPENDABLE_BALANCE = 'cosmos/bank/v1beta1/spendable_balances', + VALIDATORS = 'cosmos/staking/v1beta1/validators', + TOTAL_SUPPLY = 'cosmos/bank/v1beta1/supply', } diff --git a/src/core/lib/okp4/okp4.service.ts b/src/core/lib/okp4/okp4.service.ts index 0ae098f..a067c80 100644 --- a/src/core/lib/okp4/okp4.service.ts +++ b/src/core/lib/okp4/okp4.service.ts @@ -11,10 +11,12 @@ import { RouteParam } from "./enums/route-param.enum"; import { DelegatorValidatorsResponse } from "./responses/delegators-validators.response"; import { DelegatorsRewardsResponse } from "./responses/delegators-rewards.response"; import { SpendableBalancesResponse } from "./responses/spendable-balances.response"; +import { SupplyResponse } from "./responses/supply.response"; @Injectable() export class Okp4Service { private BASE_URL = config.okp4.url; + private VALIDATORS_STATUS = 'BOND_STATUS_BONDED'; constructor(private readonly httpService: HttpService) {} @@ -70,6 +72,21 @@ export class Okp4Service { return this.getWithErrorHandling(this.constructUrl(`${Endpoints.SPENDABLE_BALANCE}/${addr}`)); } + async getValidators(): Promise { + const url = this.constructUrl( + Endpoints.VALIDATORS, + createUrlParams({ status: this.VALIDATORS_STATUS }), + ); + return this.getWithErrorHandling(url); + } + + async getTotalSupply(): Promise { + const url = this.constructUrl( + Endpoints.TOTAL_SUPPLY, + ); + return this.getWithErrorHandling(url); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any private async errorHandleWrapper(fn: any): Promise { try { diff --git a/src/core/lib/okp4/responses/supply.response.ts b/src/core/lib/okp4/responses/supply.response.ts new file mode 100644 index 0000000..b1b9796 --- /dev/null +++ b/src/core/lib/okp4/responses/supply.response.ts @@ -0,0 +1,8 @@ +import { WithPaginationResponse } from "./with-pagination.response"; + +export type SupplyResponse = WithPaginationResponse<{ supply: Supply[] }>; + +export interface Supply { + denom: string; + amount: string; +} \ No newline at end of file diff --git a/src/core/lib/osmosis/enums/endpoints.enum.ts b/src/core/lib/osmosis/enums/endpoints.enum.ts index 3f0d724..a108e07 100644 --- a/src/core/lib/osmosis/enums/endpoints.enum.ts +++ b/src/core/lib/osmosis/enums/endpoints.enum.ts @@ -2,4 +2,5 @@ export enum Endpoints { HISTORICAL_PRICE = 'tokens/v2/historical/:symbol/chart', TOKEN_BY_SYMBOL = 'tokens/v2/:symbol', MARKET_CAP = 'tokens/v2/mcap', + STAKING_APR = 'apr/v2/staking', } diff --git a/src/core/lib/osmosis/osmosis.service.ts b/src/core/lib/osmosis/osmosis.service.ts index f56be98..a1cd8ab 100644 --- a/src/core/lib/osmosis/osmosis.service.ts +++ b/src/core/lib/osmosis/osmosis.service.ts @@ -61,6 +61,12 @@ export class OsmosisService { return this.getWithErrorHandling(this.constructUrl(Endpoints.MARKET_CAP)); } + async getStakingApr(): Promise { + return this.getWithErrorHandling( + this.constructUrl(Endpoints.STAKING_APR), + ) + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any private async errorHandleWrapper(fn: any): Promise { try { diff --git a/src/modules/stacking/dtos/global-staked-overview.dto.ts b/src/modules/stacking/dtos/global-staked-overview.dto.ts new file mode 100644 index 0000000..8632565 --- /dev/null +++ b/src/modules/stacking/dtos/global-staked-overview.dto.ts @@ -0,0 +1,6 @@ +export interface GlobalStakedOverviewDto { + totalValidators: string; + apr: string; + totalStaked: string; + bondedTokens: string; +} \ No newline at end of file diff --git a/src/modules/stacking/enums/stacking-endpoints.enum.ts b/src/modules/stacking/enums/stacking-endpoints.enum.ts index f586917..a22dd78 100644 --- a/src/modules/stacking/enums/stacking-endpoints.enum.ts +++ b/src/modules/stacking/enums/stacking-endpoints.enum.ts @@ -1,3 +1,4 @@ export enum StackingEndpoints { MY_OVERVIEW = '/my/overview', + OVERVIEW = '/overview', } \ No newline at end of file diff --git a/src/modules/stacking/services/stacking.cache.ts b/src/modules/stacking/services/stacking.cache.ts index 19a20de..eba67ed 100644 --- a/src/modules/stacking/services/stacking.cache.ts +++ b/src/modules/stacking/services/stacking.cache.ts @@ -6,6 +6,7 @@ import { Cache } from 'cache-manager'; @Injectable() export class StackingCache { private redisStackingPrefix = 'stacking'; + private globalOverviewPrefix = 'global_overview'; constructor( @Inject(CACHE_MANAGER) private cacheService: Cache, @@ -20,13 +21,28 @@ export class StackingCache { const serialized = await this.cacheService.get(this.createRedisKey(address)); if (!serialized) { - return {}; + return null; } return JSON.parse(serialized as string); } - private createRedisKey(address: string) { - return `${this.redisStackingPrefix}_${address}`; + async setGlobalStakedOverview(data: unknown) { + const serialized = JSON.stringify(data); + await this.cacheService.set(this.createRedisKey(this.globalOverviewPrefix), serialized, config.cache.globalStakingOverview); + } + + async getGlobalStakedOverview() { + const serialized = await this.cacheService.get(this.createRedisKey(this.globalOverviewPrefix)); + + if (!serialized) { + return null; + } + + return JSON.parse(serialized as string); + } + + private createRedisKey(id: string) { + return `${this.redisStackingPrefix}_${id}`; } } \ No newline at end of file diff --git a/src/modules/stacking/services/stacking.service.ts b/src/modules/stacking/services/stacking.service.ts index 0096235..ad8a03e 100644 --- a/src/modules/stacking/services/stacking.service.ts +++ b/src/modules/stacking/services/stacking.service.ts @@ -4,12 +4,17 @@ import { Injectable } from "@nestjs/common"; import { StackingCache } from "./stacking.cache"; import { config } from "@core/config/config"; import { MyStakedOverviewDto } from "../dtos/my-staked-overview.dto"; +import { OsmosisService } from "@core/lib/osmosis/osmosis.service"; +import { Validator } from "@core/lib/okp4/responses/delegators-validators.response"; +import Big from "big.js"; +import { GlobalStakedOverviewDto } from "../dtos/global-staked-overview.dto"; @Injectable() export class StackingService { constructor( private readonly okp4Service: Okp4Service, private readonly cache: StackingCache, + private readonly osmosisService: OsmosisService, ) { } async getMyStakedOverview(address: string) { @@ -47,7 +52,7 @@ export class StackingService { private async fetchDelegatorsValidatorsAmount(address: string) { const res = await this.okp4Service.getDelegatorsValidators(address); - return res.validators.length.toString(); + return res.pagination.total; } private async fetchDelegatorsRewards(address: string) { @@ -59,4 +64,46 @@ export class StackingService { const res = await this.okp4Service.getSpendableBalances(address); return res.balances.find(({ denom }) => config.app.tokenDenom === denom)?.amount || '0'; } + + async getGlobalOverview() { + const cache = await this.cache.getGlobalStakedOverview(); + + if (cache === null) { + return this.fetchAndCacheGlobalStakedOverview(); + } + + return cache; + } + + + private async fetchAndCacheGlobalStakedOverview(): Promise { + const rez = await Promise.all([ + this.okp4Service.getValidators(), + this.osmosisService.getStakingApr(), + this.fetchTotalSupply(), + ]); + + const totalStaked = this.calculateTotalStaked(rez[0].validators); + + const dto: GlobalStakedOverviewDto = { + totalValidators: rez[0].pagination.total, + apr: rez[1].toString(), + totalStaked, + bondedTokens: Big(totalStaked).div(rez[2]!.amount).toString(), + } + + await this.cache.setGlobalStakedOverview(dto); + + return dto; + } + + private calculateTotalStaked(validators: Validator[]): string { + const totalStaked = validators.reduce((acc, val) => acc.add(val.delegator_shares), Big(0)); + return totalStaked.toString(); + } + + private async fetchTotalSupply() { + const res = await this.okp4Service.getTotalSupply(); + return res.supply.find(({ denom }) => denom === config.app.tokenDenom); + } } \ No newline at end of file diff --git a/src/modules/stacking/stacking.controller.ts b/src/modules/stacking/stacking.controller.ts index 75a9116..1bbc239 100644 --- a/src/modules/stacking/stacking.controller.ts +++ b/src/modules/stacking/stacking.controller.ts @@ -19,4 +19,9 @@ export class StackingController { ) { return await this.service.getMyStakedOverview(address); } + + @Get(StackingEndpoints.OVERVIEW) + async getGlobalOverview() { + return this.service.getGlobalOverview(); + } } \ No newline at end of file diff --git a/src/modules/stacking/stacking.module.ts b/src/modules/stacking/stacking.module.ts index 7782b92..f5e9cba 100644 --- a/src/modules/stacking/stacking.module.ts +++ b/src/modules/stacking/stacking.module.ts @@ -4,11 +4,13 @@ import { StackingService } from "./services/stacking.service"; import { StackingController } from "./stacking.controller"; import { HttpService } from "@core/lib/http.service"; import { StackingCache } from "./services/stacking.cache"; +import { OsmosisService } from "@core/lib/osmosis/osmosis.service"; @Module({ imports: [], providers: [ Okp4Service, + OsmosisService, StackingService, StackingCache, HttpService,