diff --git a/.env.example b/.env.example index 478ea2f..dcb32c1 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,9 @@ MY_STAKING_OVERVIEW=120 #2 mins GLOBAL_STAKING_OVERVIEW=120 #2 mins STAKING_VALIDATORS=120 #2 mins STAKING_VALIDATOR_DELEGATION=120 #2 mins +VALIDATOR_SIGNATURE=120 #2 mins +PROPOSALS_CACHE_TTL=120 #2 mins +PROPOSAL_CACHE_TTL=120 #2 mins #Keybase -KEYBASE_URL=https://keybase.io/_/api/1.0 \ No newline at end of file +KEYBASE_URL=https://keybase.io/_/api/1.0 diff --git a/src/core/config/config.dto.ts b/src/core/config/config.dto.ts index 18ed5e9..c4265d0 100644 --- a/src/core/config/config.dto.ts +++ b/src/core/config/config.dto.ts @@ -35,8 +35,10 @@ export interface CacheConfig { validators: number; validatorDelegation: number; validatorSignature: number; + proposals: number; + proposal: number; } export interface KeybaseConfig { url: string; -} \ No newline at end of file +} diff --git a/src/core/config/config.ts b/src/core/config/config.ts index 7cfdede..94fb652 100644 --- a/src/core/config/config.ts +++ b/src/core/config/config.ts @@ -25,6 +25,8 @@ export const config: ConfigDto = { validators: +process.env.STAKING_VALIDATORS!, validatorDelegation: +process.env.STAKING_VALIDATOR_DELEGATION!, validatorSignature: +process.env.VALIDATOR_SIGNATURE!, + proposals: +process.env.PROPOSALS_CACHE_TTL!, + proposal: +process.env.PROPOSAL_CACHE_TTL!, }, keybase: { url: process.env.KEYBASE_URL!, diff --git a/src/core/lib/okp4/enums/endpoints.enum.ts b/src/core/lib/okp4/enums/endpoints.enum.ts index cacdc42..32c0478 100644 --- a/src/core/lib/okp4/enums/endpoints.enum.ts +++ b/src/core/lib/okp4/enums/endpoints.enum.ts @@ -11,4 +11,5 @@ export enum Endpoints { BLOCKS_BY_HEIGHT = 'cosmos/base/tendermint/v1beta1/blocks/:height', GOV_PARAMS = 'cosmos/gov/v1/params/:params_type', GOV_PROPOSALS = 'cosmos/gov/v1/proposals', + GOV_PROPOSAL = 'cosmos/gov/v1/proposals/:proposal_id', } diff --git a/src/core/lib/okp4/enums/route-param.enum.ts b/src/core/lib/okp4/enums/route-param.enum.ts index 1d270c1..6ede3d5 100644 --- a/src/core/lib/okp4/enums/route-param.enum.ts +++ b/src/core/lib/okp4/enums/route-param.enum.ts @@ -3,4 +3,5 @@ export enum RouteParam { VALIDATOR_ADDRES = ':validator_addr', HEIGHT = ':height', PARAMS_TYPE = ':params_type', -} \ No newline at end of file + PROPOSAL_ID = ':proposal_id', +} diff --git a/src/core/lib/okp4/okp4.service.ts b/src/core/lib/okp4/okp4.service.ts index db02ee4..7b11062 100644 --- a/src/core/lib/okp4/okp4.service.ts +++ b/src/core/lib/okp4/okp4.service.ts @@ -14,75 +14,81 @@ import { SpendableBalancesResponse } from "./responses/spendable-balances.respon import { SupplyResponse } from "./responses/supply.response"; import { ValidatorStatus } from "./enums/validator-status.enum"; import { ValidatorDelegationsResponse } from "./responses/validator-delegations.response"; -import { fromBase64, toBase64, fromHex, toHex } from '@cosmjs/encoding'; -import { sha256 } from '@cosmjs/crypto'; +import { fromBase64, toBase64, fromHex, toHex } from "@cosmjs/encoding"; +import { sha256 } from "@cosmjs/crypto"; import { BlocksResponse } from "./responses/blocks.response"; -import { WebSocket } from 'ws'; +import { WebSocket } from "ws"; import { Log } from "@core/loggers/log"; import { EventEmitter2 } from "@nestjs/event-emitter"; import { GovType } from "./enums/gov-type.enum"; import { GovParamsResponse } from "./responses/gov-params.response"; import { GetProposalsResponse } from "./responses/get-proposals.response"; +import { GetProposalResponse } from "@core/lib/okp4/responses/get-proposal.response"; @Injectable() export class Okp4Service { private BASE_URL = config.okp4.url; - + constructor( - private readonly httpService: HttpService, - private eventEmitter: EventEmitter2, - ) { } + private readonly httpService: HttpService, + private eventEmitter: EventEmitter2 + ) {} private constructUrl(endpoint: string, params?: string): string { - return `${this.BASE_URL}/${endpoint}${params ? `?${params}` : ''}`; + return `${this.BASE_URL}/${endpoint}${params ? `?${params}` : ""}`; } private getWithErrorHandling(url: string): Promise { - return this.errorHandleWrapper( - this.httpService.get.bind( - null, - url, - ), - ); + return this.errorHandleWrapper(this.httpService.get.bind(null, url)); } async getSupplyByDenom(denom: string): Promise { return this.getWithErrorHandling( this.constructUrl( Endpoints.SUPPLY_BY_DENOM, - createUrlParams({ denom }), + createUrlParams({ denom }) ) ); } - + async getDelegations(addr: string): Promise { - return this.getWithErrorHandling(this.constructUrl(`${Endpoints.STAKING_DELEGATIONS}/${addr}`)); + return this.getWithErrorHandling( + this.constructUrl(`${Endpoints.STAKING_DELEGATIONS}/${addr}`) + ); } - async getDelegatorsValidators(addr: string): Promise { + async getDelegatorsValidators( + addr: string + ): Promise { return this.getWithErrorHandling( this.constructUrl( Endpoints.DELEGATORS_VALIDATORS.replace( RouteParam.DELEGATOR_ADDRES, - addr, + addr ) ) ); } - async getDelegatorsRewards(addr: string): Promise { + async getDelegatorsRewards( + addr: string + ): Promise { return this.getWithErrorHandling( this.constructUrl( Endpoints.DELEGATORS_REWARDS.replace( RouteParam.DELEGATOR_ADDRES, - addr, + addr ) ) ); } - async getSpendableBalances(addr: string): Promise { - return this.getWithErrorHandling(this.constructUrl(`${Endpoints.SPENDABLE_BALANCE}/${addr}`)); + async getSpendableBalances( + addr: string + ): Promise { + return this.getWithErrorHandling( + this.constructUrl(`${Endpoints.SPENDABLE_BALANCE}/${addr}`) + ); } async getBondValidators() { @@ -94,34 +100,33 @@ export class Okp4Service { if (status) { params = createUrlParams({ status }); } - const url = this.constructUrl( - Endpoints.VALIDATORS, - params, - ); + const url = this.constructUrl(Endpoints.VALIDATORS, params); return this.getWithErrorHandling(url); } async getTotalSupply(): Promise { - const url = this.constructUrl( - Endpoints.TOTAL_SUPPLY, - ); + const url = this.constructUrl(Endpoints.TOTAL_SUPPLY); return this.getWithErrorHandling(url); } - async getValidatorDelegations(validatorAddr: string, limit?: number, offset?: number): Promise { + async getValidatorDelegations( + validatorAddr: string, + limit?: number, + offset?: number + ): Promise { let params = undefined; if (limit && offset) { params = createUrlParams({ - 'pagination.offset': offset.toString(), - 'pagination.limit': limit.toString(), - 'pagination.count_total': true.toString() - }) + "pagination.offset": offset.toString(), + "pagination.limit": limit.toString(), + "pagination.count_total": true.toString(), + }); } return this.getWithErrorHandling( this.constructUrl( Endpoints.VALIDATOR_DELEGATIONS.replace( RouteParam.VALIDATOR_ADDRES, - validatorAddr, + validatorAddr ), params ) @@ -130,20 +135,25 @@ export class Okp4Service { async getLatestBlocks(): Promise { return this.getWithErrorHandling( - this.constructUrl(Endpoints.BLOCKS_LATEST), - ) + this.constructUrl(Endpoints.BLOCKS_LATEST) + ); } - async getBlocksByHeight(height: number): Promise { + async getBlocksByHeight(height: number): Promise { return this.getWithErrorHandling( this.constructUrl( - Endpoints.BLOCKS_BY_HEIGHT.replace(RouteParam.HEIGHT, height.toString()) + Endpoints.BLOCKS_BY_HEIGHT.replace( + RouteParam.HEIGHT, + height.toString() + ) ) - ) + ); } apiPubkeyToAddr(pubkey: string) { - return toBase64(fromHex(toHex(sha256(fromBase64(pubkey))).slice(0, 40))) + return toBase64( + fromHex(toHex(sha256(fromBase64(pubkey))).slice(0, 40)) + ); } wssPubkeyToAddr(pubkey: string) { @@ -152,24 +162,37 @@ export class Okp4Service { async connectToNewBlockSocket(event: string) { const client = new WebSocket(config.okp4.wss); - client.on('open', () => { - client.send(JSON.stringify({"jsonrpc":"2.0","method":"subscribe","id":0,"params":{"query":"tm.event='NewBlock'"}})); + client.on("open", () => { + client.send( + JSON.stringify({ + jsonrpc: "2.0", + method: "subscribe", + id: 0, + params: { query: "tm.event='NewBlock'" }, + }) + ); }); - client.on('message', (data) => { + client.on("message", (data) => { if (Buffer.isBuffer(data)) { - const message = data.toString('utf-8'); + const message = data.toString("utf-8"); try { const jsonData = JSON.parse(message); if ( jsonData && - jsonData?.result && - jsonData?.result?.query === "tm.event='NewBlock'" + jsonData?.result && + jsonData?.result?.query === "tm.event='NewBlock'" ) { - this.eventEmitter.emit(event, jsonData?.result?.data?.value); + this.eventEmitter.emit( + event, + jsonData?.result?.data?.value + ); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (e: any) { - Log.warn('[OKP4] Problem with parsing data from wss\n' + e.message); + Log.warn( + "[OKP4] Problem with parsing data from wss\n" + + e.message + ); } } }); @@ -189,15 +212,28 @@ export class Okp4Service { ); } + async getProposal( + proposalId: string | number + ): Promise { + return this.getWithErrorHandling( + this.constructUrl( + Endpoints.GOV_PROPOSAL.replace( + RouteParam.PROPOSAL_ID, + String(proposalId) + ) + ) + ); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any private async errorHandleWrapper(fn: any): Promise { try { const response: GSFResponse = await fn(); - + if (this.isFailedResponse(response)) { throw new BadRequestException(response.message); } - + return response as T; // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (e: any) { @@ -205,7 +241,9 @@ export class Okp4Service { } } - private isFailedResponse(response: GSFResponse): response is FailedResponse { + private isFailedResponse( + response: GSFResponse + ): response is FailedResponse { return (response as FailedResponse).message !== undefined; } -} \ No newline at end of file +} diff --git a/src/core/lib/okp4/responses/get-proposal.response.ts b/src/core/lib/okp4/responses/get-proposal.response.ts new file mode 100644 index 0000000..3803ea3 --- /dev/null +++ b/src/core/lib/okp4/responses/get-proposal.response.ts @@ -0,0 +1,4 @@ +import { WithPaginationResponse } from "./with-pagination.response" +import { Proposal } from "@core/lib/okp4/responses/get-proposals.response"; + +export type GetProposalResponse = WithPaginationResponse<{ proposal: Proposal }>; diff --git a/src/modules/staking/enums/query-param.enum.ts b/src/modules/staking/enums/query-param.enum.ts index 69871f6..8b3bba1 100644 --- a/src/modules/staking/enums/query-param.enum.ts +++ b/src/modules/staking/enums/query-param.enum.ts @@ -1,4 +1,5 @@ export enum QueryParam { ADDRESS = 'address', VALIDATOR_ADDRESS = 'validatorAddress', + PROPOSAL_ID = 'proposal_id' } diff --git a/src/modules/staking/enums/staking-cache-prefix.enum.ts b/src/modules/staking/enums/staking-cache-prefix.enum.ts index ffd75e2..471713e 100644 --- a/src/modules/staking/enums/staking-cache-prefix.enum.ts +++ b/src/modules/staking/enums/staking-cache-prefix.enum.ts @@ -5,4 +5,6 @@ export enum StakingCachePrefix { VALIDATOR_IMG = 'validator_img', VALIDATOR_SIGNATURES = 'validator_signatures', VALIDATOR_RECENTLY_PROPOSED_BLOCKS = 'validator_recently_propored_blocks', -} \ No newline at end of file + PROPOSALS = 'proposals', + PROPOSAL = 'proposal', +} diff --git a/src/modules/staking/enums/staking-endpoints.enum.ts b/src/modules/staking/enums/staking-endpoints.enum.ts index c46c1f3..2e3d3b5 100644 --- a/src/modules/staking/enums/staking-endpoints.enum.ts +++ b/src/modules/staking/enums/staking-endpoints.enum.ts @@ -7,4 +7,6 @@ export enum StakingEndpoints { VALIDATORS_BY_ADDRESS = '/validators/:address', VALIDATORS_UPTIME = '/validators/:address/uptime', VALIDATORS_RECENTLY_PROPOSED_BLOCKS = '/validators/:address/recently-proposed-blocks', -} \ No newline at end of file + PROPOSALS = '/proposals', + PROPOSAL = '/proposals/:proposal_id', +} diff --git a/src/modules/staking/services/staking.cache.ts b/src/modules/staking/services/staking.cache.ts index ace13b4..3ed55ba 100644 --- a/src/modules/staking/services/staking.cache.ts +++ b/src/modules/staking/services/staking.cache.ts @@ -4,6 +4,8 @@ import { createHash } from 'crypto'; import { StakingCachePrefix } from "../enums/staking-cache-prefix.enum"; import { RedisService } from "@core/lib/redis.service"; import { v4 } from 'uuid'; +import { GetProposalsResponse } from "@core/lib/okp4/responses/get-proposals.response"; +import { GetProposalResponse } from "@core/lib/okp4/responses/get-proposal.response"; @Injectable() export class StakingCache { @@ -86,7 +88,7 @@ export class StakingCache { const pattern = this.createRedisKey(StakingCachePrefix.VALIDATOR_SIGNATURES, address, '*'); const keys = await this.redisService.keys(pattern); const signatures = await Promise.all(keys.map((key: string) => this.redisService.get(key))); - + return signatures.map(signature => JSON.parse(signature!)); } @@ -99,11 +101,29 @@ export class StakingCache { const pattern = this.createRedisKey(StakingCachePrefix.VALIDATOR_RECENTLY_PROPOSED_BLOCKS, '*'); const keys = await this.redisService.keys(pattern); const recentlyProposedBlocks = await Promise.all(keys.map((key: string) => this.redisService.get(key))); - + return recentlyProposedBlocks.map(block => JSON.parse(block!)); } private createRedisKey(...ids: string[]) { return ids.reduce((acc, id) => acc + `_${id}`, `${StakingCachePrefix.STAKING}`); } -} \ No newline at end of file + + async setProposals(proposals: GetProposalsResponse) { + const serialized = JSON.stringify(proposals); + await this.redisService.setWithTTL(this.createRedisKey(StakingCachePrefix.PROPOSALS), serialized, config.cache.proposals); + } + + async getProposals(): Promise { + return this.getObjByRedisKey(this.createRedisKey(StakingCachePrefix.PROPOSALS)); + } + + async setProposal(proposalId: string | number, proposal: GetProposalResponse) { + const serialized = JSON.stringify(proposal); + await this.redisService.setWithTTL(this.createRedisKey(StakingCachePrefix.PROPOSAL, String(proposalId)), serialized, config.cache.proposal); + } + + async getProposal(proposalId: string | number): Promise { + return this.getObjByRedisKey(this.createRedisKey(StakingCachePrefix.PROPOSAL, String(proposalId))); + } +} diff --git a/src/modules/staking/services/staking.service.ts b/src/modules/staking/services/staking.service.ts index e9fcd77..aa6416a 100644 --- a/src/modules/staking/services/staking.service.ts +++ b/src/modules/staking/services/staking.service.ts @@ -218,7 +218,7 @@ export class StakingService implements OnModuleInit { delegation: rez[0], earnings: rez[1], }; - + await this.cache.setValidatorDelegation(payload.address, payload.validatorAddress, dto); return dto; @@ -288,7 +288,7 @@ export class StakingService implements OnModuleInit { Log.warn("New block error " + e.message); } } - + private async cacheBlock(res: BlocksResponse) { try { await this.cache.setRecentlyProposedBlock({ @@ -366,7 +366,7 @@ export class StakingService implements OnModuleInit { } return Big(blocks.length).div(signed).toNumber(); } - + async getValidatorUptime(address: string) { const signatures = await this.getSortedValidatorSignatures(address); const current = await this.getLastBlockHeight(); @@ -398,7 +398,7 @@ export class StakingService implements OnModuleInit { private async getSortedRecentlyProposedBlocks() { try { const recentlyBlocks: RecentlyProposedBlockDto[] = await this.cache.getRecentlyProposedBlock(); - return recentlyBlocks.sort((a, b) => Number.parseFloat(b.height) - Number.parseFloat(a.height)); + return recentlyBlocks.sort((a, b) => Number.parseFloat(b.height) - Number.parseFloat(a.height)); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch(e: any) { Log.warn("Cache recently proposed blocks deserialization error " + e.message); @@ -420,4 +420,36 @@ export class StakingService implements OnModuleInit { return []; } -} \ No newline at end of file + + private async fetchProposals() { + const proposals = await this.okp4Service.getProposals(); + await this.cache.setProposals(proposals); + return proposals; + } + + async getProposals() { + const cache = await this.cache.getProposals(); + + if (cache === null) { + return this.fetchProposals(); + } + + return cache; + } + + private async fetchProposal(proposalId: string | number) { + const proposal = await this.okp4Service.getProposal(proposalId); + await this.cache.setProposal(proposalId, proposal); + return proposal; + } + + async getProposal(proposalId: string | number) { + const cache = await this.cache.getProposal(proposalId); + + if (cache === null) { + return this.fetchProposal(proposalId); + } + + return cache; + } +} diff --git a/src/modules/staking/staking.controller.ts b/src/modules/staking/staking.controller.ts index 3585b0d..1994794 100644 --- a/src/modules/staking/staking.controller.ts +++ b/src/modules/staking/staking.controller.ts @@ -16,7 +16,7 @@ export class StakingController { constructor( private readonly service: StakingService, ) { } - + @Get(StakingEndpoints.MY_OVERVIEW) async getMyStakedOverview( @Query(QueryParam.ADDRESS, new SchemaValidatePipe(AddressSchema)) @@ -71,4 +71,17 @@ export class StakingController { async getValidatorRecentlyProposedBlocks() { return this.service.getValidatorRecentlyProposedBlocks(); } -} \ No newline at end of file + + @Get(StakingEndpoints.PROPOSALS) + async getProposals() { + return this.service.getProposals(); + } + + @Get(StakingEndpoints.PROPOSAL) + async getProposal( + @Param(QueryParam.PROPOSAL_ID, new SchemaValidatePipe(StringSchema)) + proposalId: string, + ) { + return this.service.getProposal(proposalId); + } +}