diff --git a/project.ts b/project.ts index 6e68995..91cf376 100644 --- a/project.ts +++ b/project.ts @@ -4,8 +4,8 @@ import { CosmosProject, } from "@subql/types-cosmos"; -import * as dotenv from 'dotenv'; -import path from 'path'; +import * as dotenv from "dotenv"; +import path from "path"; const mode = process.env.NODE_ENV || 'production'; diff --git a/schema.graphql b/schema.graphql index da2f70d..5da93c0 100644 --- a/schema.graphql +++ b/schema.graphql @@ -14,6 +14,11 @@ type EventAttribute @entity { event: Event! } +type SupplyDenom @entity { + # denomination indeed + id: ID! +} + type Block @entity { id: ID! # The block header hash chainId: String! @index @@ -23,6 +28,7 @@ type Block @entity { messages: [Message] @derivedFrom(field: "block") events: [Event] @derivedFrom(field: "block") balancesOfAccountByDenom: [Balance] @derivedFrom(field: "lastUpdatedBlock") + supplies: [BlockSupply]! @derivedFrom(field: "block") } type Transaction @entity { @@ -120,3 +126,16 @@ type GenesisFile @entity { id: ID! raw: String! } + +type Supply @entity { + id: ID! + denom: String! @index + amount: BigInt! + blocks: [BlockSupply]! @derivedFrom(field: "supply") +} + +type BlockSupply @entity { + id: ID! + block: Block! + supply: Supply! +} diff --git a/src/mappings/bank/supply.ts b/src/mappings/bank/supply.ts new file mode 100644 index 0000000..0e3cdc9 --- /dev/null +++ b/src/mappings/bank/supply.ts @@ -0,0 +1,88 @@ +import type { + Coin, + CosmosBlock, +} from "@subql/types-cosmos"; +import type { QueryTotalSupplyResponse } from "cosmjs-types/cosmos/bank/v1beta1/query"; +import { + BlockSupply, + Supply, +} from "../../types"; +import { stringify } from "../utils"; + +export const getSupplyId = function(denom: string, height: number): string { + return `${denom}@${height}`; +}; + +export const getSupplyRecord = function(supply: Coin, block: CosmosBlock): Supply { + return Supply.create({ + id: getSupplyId(supply.denom, block.header.height), + denom: supply.denom, + amount: BigInt(supply.amount), + }); +}; + +export async function queryTotalSupply(): Promise { + const finalSupply: Coin[] = []; + let paginationKey: Uint8Array | undefined; + + try { + // Here we force the use of a private property, breaking typescript limitation, due to the need of call a total supply + // rpc query of cosmosjs that is not exposed on the implemented client by SubQuery team. + // To avoid this, we need to move to implement our own rpc client and also use `unsafe` parameter which I prefer to avoid. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const queryClient = api.forceGetQueryClient(); + + // Initial call to get the first set of results + const initialResponse: QueryTotalSupplyResponse = await queryClient.bank.totalSupply() as unknown as QueryTotalSupplyResponse; + logger.debug(`[handleTotalSupply]: initialResponse=${stringify(initialResponse, undefined, 2)}`); + finalSupply.push(...initialResponse.supply); + paginationKey = initialResponse.pagination?.nextKey; + + // Continue fetching if there is a nextKey + while (paginationKey && paginationKey.length > 0) { + logger.debug(`[handleTotalSupply]: loading more supply pages pagination.nextKey=${JSON.stringify(paginationKey, undefined, 2)}`); + const response = await queryClient.bank.totalSupply(paginationKey); + finalSupply.push(...response.supply); + paginationKey = response.pagination?.nextKey; + } + logger.debug(`[handleTotalSupply]: all_total_supply=${JSON.stringify(finalSupply, undefined, 2)}`); + } catch (error) { + logger.error(`[handleTotalSupply] errored: ${error}`); + } + + return finalSupply; +} + +export async function _handleSupply(block: CosmosBlock): Promise { + const totalSupply = await queryTotalSupply(); + if (totalSupply.length === 0) { + logger.warn(`[_handleSupply]: no total supply found`); + return; + } + + for (const supply of totalSupply) { + // get the current blockSupply create on block handler to been able to access the assigned previous supply id + // that will allow us to compare the amount and create a new on if needed. + const blockSupplyId = getSupplyId(supply.denom, block.header.height); + const latestBlockSupply = await BlockSupply.get(blockSupplyId); + if (!latestBlockSupply) { + logger.warn(`[_handleSupply]: no BlockSupply found id=${blockSupplyId}`); + continue; + } + + const latestDenomSupply = await Supply.get(latestBlockSupply.supplyId); + if (!latestDenomSupply) { + logger.warn(`[_handleSupply]: no total supply found id=${latestBlockSupply.supplyId}`); + continue; + } + + if (latestDenomSupply.amount.toString() !== supply.amount) { + const newSupply = getSupplyRecord(supply, block); + await newSupply.save(); + latestBlockSupply.supplyId = newSupply.id; + await latestBlockSupply.save(); + break; + } + } +} diff --git a/src/mappings/primitives.ts b/src/mappings/primitives.ts index 76aa290..ebf57a5 100644 --- a/src/mappings/primitives.ts +++ b/src/mappings/primitives.ts @@ -12,57 +12,76 @@ import { isString, } from "lodash"; import { + Balance, Block, + BlockSupply, Event, EventAttribute, + GenesisBalance, + GenesisFile as GenesisEntity, Message, + NativeBalanceChange, + SupplyDenom, Transaction, TxStatus, - NativeBalanceChange, - GenesisBalance, - Balance, - GenesisFile as GenesisEntity, } from "../types"; +import { + _handleSupply, + getSupplyId, + getSupplyRecord, +} from "./bank/supply"; import { PREFIX } from "./constants"; import type { Genesis } from "./types/genesis"; import { attemptHandling, + getBalanceId, messageId, primitivesFromMsg, primitivesFromTx, stringify, trackUnprocessed, unprocessedEventHandler, - getBalanceId, } from "./utils"; export async function handleGenesis(block: CosmosBlock): Promise { - const genesis: Genesis = require('../../genesis.json'); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const genesis: Genesis = require("../../genesis.json"); // IMPORTANT: Return early if this is not the genesis initial height as this is called for block indexed! if (block.block.header.height !== genesis.initial_height) { - return + return; } logger.info(`[handleGenesis] (block.header.height): indexing genesis block ${block.block.header.height}`); + const supply = genesis.app_state.bank.supply.find(supply => supply.denom === "upokt"); + if (!supply) { + throw new Error("[handleGenesis] Genesis file is missing supply for upokt"); + } + await Promise.all( [ - store.bulkCreate('Account', genesis.app_state.auth.accounts.map(account => { + store.bulkCreate("Account", genesis.app_state.auth.accounts.map(account => { return { id: account.address, chainId: block.block.header.chainId, - } + }; })), + store.bulkCreate("SupplyDenom", genesis.app_state.bank.supply.map(supply => { + return { + id: supply.denom, + }; + })), + getSupplyRecord(supply, block).save(), Event.create({ id: "genesis", type: "genesis", blockId: block.block.id, }).save(), - ] - ) + ], + ); - type EntityToSave = Omit; + type EntityToSave = Omit; const nativeBalanceChanges: Array> = []; const genesisBalances: Array> = []; const balances: Array> = []; @@ -77,26 +96,26 @@ export async function handleGenesis(block: CosmosBlock): Promise { const amountByAccountAndDenom: AmountByAccountAndDenom = genesis.app_state.bank.balances.reduce((acc, balance) => { const amountByDenom: Record = balance.coins.reduce((acc, coin) => ({ ...acc, - [coin.denom]: BigInt(acc[coin.denom] || 0) + BigInt(coin.amount), - }), {} as Record) + [coin.denom]: BigInt(acc[coin.denom] || 0) + BigInt(coin.amount), + }), {} as Record); for (const [denom, amount] of Object.entries(amountByDenom)) { - const id = getBalanceId(balance.address, denom) + const id = getBalanceId(balance.address, denom); if (acc[id]) { - acc[id].amount += amount + acc[id].amount += amount; } else { acc[id] = { amount, denom, accountId: balance.address, - } + }; } } - return acc - }, {} as AmountByAccountAndDenom) + return acc; + }, {} as AmountByAccountAndDenom); - for (const [id, {accountId, amount, denom}] of Object.entries(amountByAccountAndDenom)) { + for (const [id, { accountId, amount, denom }] of Object.entries(amountByAccountAndDenom)) { nativeBalanceChanges.push({ id, balanceOffset: amount.valueOf(), @@ -123,9 +142,9 @@ export async function handleGenesis(block: CosmosBlock): Promise { } await Promise.all([ - store.bulkCreate('GenesisBalance', genesisBalances), - store.bulkCreate('NativeBalanceChange', nativeBalanceChanges), - store.bulkCreate('Balance', balances) + store.bulkCreate("GenesisBalance", genesisBalances), + store.bulkCreate("NativeBalanceChange", nativeBalanceChanges), + store.bulkCreate("Balance", balances), ]); await GenesisEntity.create({ @@ -155,6 +174,41 @@ async function _handleBlock(block: CosmosBlock): Promise { const { header: { chainId, height, time }, id } = block.block; const timestamp = new Date(time.getTime()); + + const supplyDenom = await SupplyDenom.getByFields([]); + const supplyIdHeight = block.header.height === 1 ? block.header.height : block.header.height - 1; + + const blockSupplies: BlockSupply[] = []; + + if (block.header.height > 1) { + // on any block after genesis, we need to look up for the previous BlockSupply to copy the supply id of the + // right one, then the claim/proof settlement or ibc txs will update to the right supply id if a new one + // is created for this denom@block + for (const supplyDenomItem of supplyDenom) { + const blockSupply = await BlockSupply.get(getSupplyId(supplyDenomItem.id, supplyIdHeight)); + if (!blockSupply) { + logger.warn(`[handleBlock] (block.header.height): missing block supply for ${supplyDenomItem.id} at height ${supplyIdHeight}`); + continue; + } + blockSupplies.push(BlockSupply.create({ + id: getSupplyId(supplyDenomItem.id, block.header.height), + blockId: block.block.id, + supplyId: blockSupply.supplyId, + })); + } + // create all the entries + await store.bulkCreate("BlockSupply", blockSupplies); + } else { + // create a base record for each supply denomination because is the first block. + await store.bulkCreate("BlockSupply", supplyDenom.map((supplyDenomItem) => { + return { + id: getSupplyId(supplyDenomItem.id, block.block.header.height), + blockId: block.block.id, + supplyId: getSupplyId(supplyDenomItem.id, supplyIdHeight), + }; + })); + } + const blockEntity = Block.create({ id, chainId, @@ -163,6 +217,10 @@ async function _handleBlock(block: CosmosBlock): Promise { }); await blockEntity.save(); + + // We need to track the supply on every block, and this is the way we can do with the RPC, but on a future + // it will be replaced by handling the claim/proof settle event. + await _handleSupply(block); } async function _handleTransaction(tx: CosmosTransaction): Promise { diff --git a/src/mappings/types/genesis.ts b/src/mappings/types/genesis.ts index cc48302..a299144 100644 --- a/src/mappings/types/genesis.ts +++ b/src/mappings/types/genesis.ts @@ -1,5 +1,6 @@ -import { BaseAccountSDKType } from "../../types/proto-interfaces/cosmos/auth/v1beta1/auth"; -import { Balance } from "../../types/proto-interfaces/cosmos/bank/v1beta1/genesis"; +import type { Coin } from "../../client/cosmos/base/v1beta1/coin"; +import type { BaseAccountSDKType } from "../../types/proto-interfaces/cosmos/auth/v1beta1/auth"; +import type { Balance } from "../../types/proto-interfaces/cosmos/bank/v1beta1/genesis"; export interface Genesis { initial_height: number, @@ -9,6 +10,7 @@ export interface Genesis { } bank: { balances: Array + supply: Array }, } }