diff --git a/.env.example b/.env.example index c0e0c1b..925db47 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,7 @@ PROPOSAL_CACHE_TTL=120 #2 mins SUPPLY_CHANGE_CACHE_TTL=120 #2 mins PROPOSAL_VOTERS_TTL=120 #2 mins VALIDATOR_RECENTLY_PROPOSED_BLOCK_TTL=600 #10 min +WALLET_REWARD_HISTORY_TTL=60 #1 min # keybase 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 4d1ccd1..26c0e7d 100644 --- a/src/core/config/config.dto.ts +++ b/src/core/config/config.dto.ts @@ -44,6 +44,7 @@ export interface CacheConfig { supplyChange: number; proposalVoters: number; validatorRecentlyProposedBlock: number; + walletRewardHistory: number; } export interface KeybaseConfig { diff --git a/src/core/config/config.schema.ts b/src/core/config/config.schema.ts index 6d7764f..eaf7767 100644 --- a/src/core/config/config.schema.ts +++ b/src/core/config/config.schema.ts @@ -23,4 +23,5 @@ export const ConfigSchema = Joi.object({ EXCHANGE_RATE_API_KEY: Joi.string().required(), PROPOSAL_VOTERS_TTL: Joi.string().required(), VALIDATOR_RECENTLY_PROPOSED_BLOCK_TTL: Joi.string().required(), + WALLET_REWARD_HISTORY_TTL: Joi.string().required(), }).required(); diff --git a/src/core/config/config.ts b/src/core/config/config.ts index a26d694..9994af9 100644 --- a/src/core/config/config.ts +++ b/src/core/config/config.ts @@ -30,6 +30,7 @@ export const config: ConfigDto = { supplyChange: +process.env.SUPPLY_CHANGE_CACHE_TTL!, proposalVoters: +process.env.PROPOSAL_VOTERS_TTL!, validatorRecentlyProposedBlock: +process.env.VALIDATOR_RECENTLY_PROPOSED_BLOCK_TTL!, + walletRewardHistory: +process.env.WALLET_REWARD_HISTORY_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 8a35bb0..ce42527 100644 --- a/src/core/lib/okp4/enums/endpoints.enum.ts +++ b/src/core/lib/okp4/enums/endpoints.enum.ts @@ -16,5 +16,6 @@ export enum Endpoints { INFLATION = 'cosmos/mint/v1beta1/inflation', DISTRIBUTION_PARAMS = 'cosmos/distribution/v1beta1/params', BALANCES = 'cosmos/bank/v1beta1/balances/:address', - PROPOSAL_VOTES = '/cosmos/gov/v1/proposals/:proposal_id/votes', + PROPOSAL_VOTES = 'cosmos/gov/v1/proposals/:proposal_id/votes', + TXS = 'cosmos/tx/v1beta1/txs', } diff --git a/src/core/lib/okp4/okp4.service.ts b/src/core/lib/okp4/okp4.service.ts index 9d26047..9db1f35 100644 --- a/src/core/lib/okp4/okp4.service.ts +++ b/src/core/lib/okp4/okp4.service.ts @@ -30,6 +30,7 @@ import { DistributionParamsResponse } from "./responses/distribution-params.resp import Big from "big.js"; import { BalancesResponse } from "./responses/balances.response"; import { GetProposalVotesResponse } from "./responses/get-proposal-votes.response"; +import { RewardsHistoryResponse } from "./responses/rewards-history.response"; @Injectable() export class Okp4Service { @@ -308,6 +309,31 @@ export class Okp4Service { ); } + async getWalletRewardsHistory(address: string, limit?: number, offset?: number): Promise { + const wallet = { + "query": `message.sender='${address}'`, + }; + let pagination = undefined; + + if(limit !== undefined && offset !== undefined) { + pagination = { + "pagination.offset": offset.toString(), + "pagination.limit": limit.toString(), + "pagination.count_total": true.toString(), + } + } + + return this.getWithErrorHandling( + this.constructUrl( + Endpoints.TXS, + createUrlParams({ + ...pagination, + ...wallet, + }) + ) + ); + } + private okp4Pagination(limit: number, offset: number) { return createUrlParams({ "pagination.offset": offset.toString(), diff --git a/src/core/lib/okp4/responses/rewards-history.response.ts b/src/core/lib/okp4/responses/rewards-history.response.ts new file mode 100644 index 0000000..6a87903 --- /dev/null +++ b/src/core/lib/okp4/responses/rewards-history.response.ts @@ -0,0 +1,36 @@ +import { WithPaginationResponse } from "./with-pagination.response"; + +export type RewardsHistoryResponse = WithPaginationResponse<{ + tx_responses: Tx[]; +}>; + + +export interface Tx { + txhash: string; + code: number; + timestamp: string; + tx: { + body: { + messages: Array<{ + "@type": string + }>; + } + }; + events: Event[]; +} + +export interface Event { + type: string; + attributes: [ + { + key: string; + value: string; + index: string; + }, + { + key: string; + value: string; + index: string; + } + ] +} \ No newline at end of file diff --git a/src/modules/governance/services/governance.service.ts b/src/modules/governance/services/governance.service.ts index 4adad89..c52871f 100644 --- a/src/modules/governance/services/governance.service.ts +++ b/src/modules/governance/services/governance.service.ts @@ -6,10 +6,10 @@ import { ProposalStatusEnum } from "@core/lib/okp4/enums/proposal-status.enum"; import { GovernanceCache } from "./governance.cache"; import { Log } from "@core/loggers/log"; import { toPercents } from "@utils/to-percents"; -import { createHash } from "crypto"; import { GetProposalVotesDto } from "../dto/get-proposal-votes.dto"; import Big from "big.js"; import { Pagination } from "@core/types/pagination.dto"; +import { hash } from "@utils/create-hash"; @Injectable() export class GovernanceService implements OnModuleInit { @@ -68,7 +68,7 @@ export class GovernanceService implements OnModuleInit { } async getProposals(payload: Pagination) { - const cache = await this.cache.getProposals(this.createParamHash(payload)); + const cache = await this.cache.getProposals(hash(payload)); if (cache === null) { return this.fetchProposals(payload); @@ -98,7 +98,7 @@ export class GovernanceService implements OnModuleInit { proposals: proposalsWithTurnout, }; - await this.cache.setProposals(view, this.createParamHash({ limit, offset })); + await this.cache.setProposals(view, hash({ limit, offset })); return view; } @@ -201,9 +201,7 @@ export class GovernanceService implements OnModuleInit { } async getProposalVotes(payload: GetProposalVotesDto) { - const cache = await this.cache.getProposalVotes( - this.createParamHash(payload) - ); + const cache = await this.cache.getProposalVotes(hash(payload)); if (!cache) { return this.fetchProposalVotes(payload); @@ -230,7 +228,7 @@ export class GovernanceService implements OnModuleInit { option: maxWeightOption.option, }; }); - await this.cache.setProposalVotes(this.createParamHash(payload), voters); + await this.cache.setProposalVotes(hash(payload), voters); return { voters, pagination: { @@ -240,8 +238,4 @@ export class GovernanceService implements OnModuleInit { }, }; } - - private createParamHash(params: unknown): string { - return createHash("sha256").update(JSON.stringify(params)).digest("hex"); - } } diff --git a/src/modules/wallet/get-balances.dto.ts b/src/modules/wallet/dtos/get-balances.dto.ts similarity index 100% rename from src/modules/wallet/get-balances.dto.ts rename to src/modules/wallet/dtos/get-balances.dto.ts diff --git a/src/modules/wallet/dtos/get-wallet-rewards-history.dto.ts b/src/modules/wallet/dtos/get-wallet-rewards-history.dto.ts new file mode 100644 index 0000000..6e3caef --- /dev/null +++ b/src/modules/wallet/dtos/get-wallet-rewards-history.dto.ts @@ -0,0 +1,6 @@ +export interface GetWalletRewardsHistoryDto { + address: string; + limit?: number; + offset?: number; +} + \ No newline at end of file diff --git a/src/modules/wallet/enums/wallet-prefix.enum.ts b/src/modules/wallet/enums/wallet-prefix.enum.ts new file mode 100644 index 0000000..2f4fb19 --- /dev/null +++ b/src/modules/wallet/enums/wallet-prefix.enum.ts @@ -0,0 +1,4 @@ +export enum WalletPrefix { + WALLET = 'wallet', + REWARDS_HISTORY = 'rewards_history', +} \ No newline at end of file diff --git a/src/modules/wallet/get-balances.schema.ts b/src/modules/wallet/schemas/get-balances.schema.ts similarity index 100% rename from src/modules/wallet/get-balances.schema.ts rename to src/modules/wallet/schemas/get-balances.schema.ts diff --git a/src/modules/wallet/schemas/get-wallet-rewards-history.schema.ts b/src/modules/wallet/schemas/get-wallet-rewards-history.schema.ts new file mode 100644 index 0000000..3c7a270 --- /dev/null +++ b/src/modules/wallet/schemas/get-wallet-rewards-history.schema.ts @@ -0,0 +1,5 @@ +import * as Joi from "joi"; + +export const GetWalletRewardsHistorySchema = Joi.object({ + address: Joi.string().required(), +}) diff --git a/src/modules/wallet/wallet-cache.ts b/src/modules/wallet/wallet-cache.ts new file mode 100644 index 0000000..8199e77 --- /dev/null +++ b/src/modules/wallet/wallet-cache.ts @@ -0,0 +1,34 @@ +import { Injectable } from "@nestjs/common"; + +import { RedisService } from "@core/lib/redis.service"; +import { config } from "@core/config/config"; +import { WalletPrefix } from "./enums/wallet-prefix.enum"; + +@Injectable() +export class WalletCache { + constructor( + private readonly redisService: RedisService, + ) { } + + async setWalletRewardHistory(hash: string, history: unknown) { + await this.redisService.setWithTTL(this.createRedisKey(WalletPrefix.REWARDS_HISTORY, hash), JSON.stringify(history), config.cache.walletRewardHistory); + } + + async getWalletRewardHistory(hash: string) { + return this.getObjectFromRedis(this.createRedisKey(WalletPrefix.REWARDS_HISTORY, hash)); + } + + private async getObjectFromRedis(key: string): Promise { + const serialized = await this.redisService.get(key); + + if (!serialized) { + return null; + } + + return JSON.parse(serialized as string); + } + + private createRedisKey(...ids: string[]) { + return ids.reduce((acc, id) => acc + `_${id}`, `${WalletPrefix.WALLET}`); + } +} \ No newline at end of file diff --git a/src/modules/wallet/wallet-routes.enum.ts b/src/modules/wallet/wallet-routes.enum.ts index 36d58de..fad9b80 100644 --- a/src/modules/wallet/wallet-routes.enum.ts +++ b/src/modules/wallet/wallet-routes.enum.ts @@ -1,3 +1,4 @@ export enum WalletRoutesEnum { BALANCES = 'balances', + REWARD_HISTORY = 'reward-history', } \ No newline at end of file diff --git a/src/modules/wallet/wallet.controller.ts b/src/modules/wallet/wallet.controller.ts index ce8fcd3..dc4b0f4 100644 --- a/src/modules/wallet/wallet.controller.ts +++ b/src/modules/wallet/wallet.controller.ts @@ -1,10 +1,12 @@ import { Routes } from "@core/enums/routes.enum"; import { SchemaValidatePipe } from "@core/pipes/schema-validate.pipe"; import { Controller, Get, Query } from "@nestjs/common"; -import { GetBalancesSchema } from "./get-balances.schema"; -import { GetBalancesDto } from "./get-balances.dto"; +import { GetBalancesSchema } from "./schemas/get-balances.schema"; +import { GetBalancesDto } from "./dtos/get-balances.dto"; import { WalletRoutesEnum } from "./wallet-routes.enum"; import { WalletService } from "./wallet.service"; +import { GetWalletRewardsHistoryDto } from "./dtos/get-wallet-rewards-history.dto"; +import { GetWalletRewardsHistorySchema } from "./schemas/get-wallet-rewards-history.schema"; @Controller(Routes.WALLET) export class WalletController { @@ -17,4 +19,12 @@ export class WalletController { ) { return this.service.getBalances(dto); } + + @Get(WalletRoutesEnum.REWARD_HISTORY) + async getWalletRewardsHistory( + @Query(new SchemaValidatePipe(GetWalletRewardsHistorySchema)) + dto: GetWalletRewardsHistoryDto + ) { + return this.service.getWalletRewardsHistory(dto); + } } diff --git a/src/modules/wallet/wallet.module.ts b/src/modules/wallet/wallet.module.ts index 101b307..d14684d 100644 --- a/src/modules/wallet/wallet.module.ts +++ b/src/modules/wallet/wallet.module.ts @@ -3,10 +3,12 @@ import { Okp4Service } from "@core/lib/okp4/okp4.service"; import { Module } from "@nestjs/common"; import { WalletController } from "./wallet.controller"; import { WalletService } from "./wallet.service"; +import { WalletCache } from "./wallet-cache"; +import { RedisService } from "@core/lib/redis.service"; @Module({ imports: [], - providers: [WalletService, Okp4Service, HttpService], + providers: [WalletService, Okp4Service, HttpService, WalletCache, RedisService], controllers: [WalletController], }) export class WalletModule {} diff --git a/src/modules/wallet/wallet.service.ts b/src/modules/wallet/wallet.service.ts index 6b010fb..20efd0c 100644 --- a/src/modules/wallet/wallet.service.ts +++ b/src/modules/wallet/wallet.service.ts @@ -1,10 +1,15 @@ import { Okp4Service } from "@core/lib/okp4/okp4.service"; import { Injectable } from "@nestjs/common"; -import { GetBalancesDto } from "./get-balances.dto"; +import { GetBalancesDto } from "./dtos/get-balances.dto"; +import { GetWalletRewardsHistoryDto } from "./dtos/get-wallet-rewards-history.dto"; +import { Tx } from "@core/lib/okp4/responses/rewards-history.response"; +import { extractNumbers } from "@utils/exctract-numbers"; +import { WalletCache } from "./wallet-cache"; +import { hash } from "@utils/create-hash"; @Injectable() export class WalletService { - constructor(private readonly okp4Service: Okp4Service) {} + constructor(private readonly okp4Service: Okp4Service, private readonly cache: WalletCache) {} async getBalances(payload: GetBalancesDto) { const res = await this.okp4Service.getBalances( @@ -22,4 +27,43 @@ export class WalletService { }, }; } + + async getWalletRewardsHistory(payload: GetWalletRewardsHistoryDto) { + const cache = await this.cache.getWalletRewardHistory(hash(payload)); + + if(!cache) { + return this.fetchAndCacheRewardsHistory(payload); + } + + return cache; + } + + private async fetchAndCacheRewardsHistory({ address, limit, offset }: GetWalletRewardsHistoryDto) { + const res = await this.okp4Service.getWalletRewardsHistory(address, limit, offset); + const historyView = res.tx_responses.map(tx => this.walletRewardHistoryView(tx)); + await this.cache.setWalletRewardHistory(hash({ address, limit, offset }), historyView); + return historyView; + } + + private walletRewardHistoryView(tx: Tx) { + const coinSpendEvent = tx.events.find(event => event.type === 'coin_spent'); + const messages = tx.tx.body.messages.map(message => { + const splitted = message["@type"].split('.'); + return splitted[splitted.length - 1]; + }); + let amount = 0; + + if(coinSpendEvent) { + const amountAttribute = coinSpendEvent.attributes.find(attribute => attribute.key === 'amount'); + amountAttribute && (amount = extractNumbers(amountAttribute?.value)[0]); + } + + return { + txHash: tx.txhash, + result: tx.code ? 'Success' : 'Failed', + messages, + amount, + time: tx.timestamp + } + } } diff --git a/utils/create-hash.ts b/utils/create-hash.ts new file mode 100644 index 0000000..0cedded --- /dev/null +++ b/utils/create-hash.ts @@ -0,0 +1,5 @@ +import { createHash } from "crypto"; + +export function hash(obj: unknown): string { + return createHash("sha256").update(JSON.stringify(obj)).digest("hex"); +} \ No newline at end of file diff --git a/utils/exctract-numbers.ts b/utils/exctract-numbers.ts new file mode 100644 index 0000000..846082b --- /dev/null +++ b/utils/exctract-numbers.ts @@ -0,0 +1,7 @@ +export function extractNumbers(string: string): number[] { + const matches = string.match(/\d+/g); + if (matches) { + return matches.map(Number); + } + return []; +} \ No newline at end of file