diff --git a/src/api/init.ts b/src/api/init.ts index 84d21429c..4825aec7b 100644 --- a/src/api/init.ts +++ b/src/api/init.ts @@ -47,6 +47,7 @@ import { } from '@hirosystems/api-toolkit'; import { BlockRoutesV2 } from './routes/v2/blocks'; import { BurnBlockRoutesV2 } from './routes/v2/burn-blocks'; +import { TenureHeightRoutesV2 } from './routes/v2/tenure-heights'; import { MempoolRoutesV2 } from './routes/v2/mempool'; import { SmartContractRoutesV2 } from './routes/v2/smart-contracts'; import { AddressRoutesV2 } from './routes/v2/addresses'; @@ -99,6 +100,7 @@ export const StacksApiRoutes: FastifyPluginAsync< async fastify => { await fastify.register(BlockRoutesV2, { prefix: '/blocks' }); await fastify.register(BurnBlockRoutesV2, { prefix: '/burn-blocks' }); + await fastify.register(TenureHeightRoutesV2, { prefix: '/tenure-height' }); await fastify.register(SmartContractRoutesV2, { prefix: '/smart-contracts' }); await fastify.register(MempoolRoutesV2, { prefix: '/mempool' }); await fastify.register(PoxRoutesV2, { prefix: '/pox' }); diff --git a/src/api/pagination.ts b/src/api/pagination.ts index aef16a3d0..888626fde 100644 --- a/src/api/pagination.ts +++ b/src/api/pagination.ts @@ -36,6 +36,7 @@ export enum ResourceType { Pox2Event, Stacker, BurnBlock, + TenureHeight, Signer, PoxCycle, TokenHolders, @@ -51,6 +52,10 @@ export const pagingQueryLimits: Record; +export const TenureParamsSchema = Type.Object( + { + height: Type.Union([ + TenureHeightParamSchema, + ]), + }, + { additionalProperties: false } +); +export type TenureParams = Static; + export const SmartContractStatusParamsSchema = Type.Object( { contract_id: Type.Union([Type.Array(SmartContractIdParamSchema), SmartContractIdParamSchema]), diff --git a/src/api/routes/v2/tenure-heights.ts b/src/api/routes/v2/tenure-heights.ts new file mode 100644 index 000000000..3c7babd43 --- /dev/null +++ b/src/api/routes/v2/tenure-heights.ts @@ -0,0 +1,63 @@ +import { handleChainTipCache } from '../../controllers/cache-controller'; +import { parseDbNakamotoBlock } from './helpers'; +import { TenureParamsSchema } from './schemas'; +import { InvalidRequestError, NotFoundError } from '../../../errors'; +import { FastifyPluginAsync } from 'fastify'; +import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; +import { Server } from 'node:http'; +import { LimitParam, OffsetParam } from '../../schemas/params'; +import { ResourceType } from '../../pagination'; +import { PaginatedResponse } from '../../schemas/util'; +import { NakamotoBlockSchema } from '../../schemas/entities/block'; + +export const TenureHeightRoutesV2: FastifyPluginAsync< + Record, + Server, + TypeBoxTypeProvider +> = async fastify => { + fastify.get( + '/:height/blocks', + { + preHandler: handleChainTipCache, + schema: { + operationId: 'get_blocks_by_tenure_height', + summary: 'Get blocks by tenure height', + description: `Retrieves a list of blocks confirmed within a specific tenure height`, + tags: ['Tenure Height'], + params: TenureParamsSchema, + querystring: Type.Object({ + limit: LimitParam(ResourceType.TenureHeight), + offset: OffsetParam(), + }), + response: { + 200: PaginatedResponse(NakamotoBlockSchema), + }, + }, + }, + async (req, reply) => { + const { height } = req.params; + const query = req.query; + + try { + const { limit, offset, results, total } = await fastify.db.v2.getBlocksByTenureHeight({ + height, + ...query, + }); + const blocks = results.map(r => parseDbNakamotoBlock(r)); + await reply.send({ + limit, + offset, + total, + results: blocks, + }); + } catch (error) { + if (error instanceof InvalidRequestError) { + throw new NotFoundError(error.message); + } + throw error; + } + } + ); + + await Promise.resolve(); +}; diff --git a/src/datastore/pg-store-v2.ts b/src/datastore/pg-store-v2.ts index 236fba9cd..2b4af6797 100644 --- a/src/datastore/pg-store-v2.ts +++ b/src/datastore/pg-store-v2.ts @@ -206,6 +206,52 @@ export class PgStoreV2 extends BasePgStoreModule { }); } + async getBlocksByTenureHeight(args: { + height: number; + limit?: number; + offset?: number; + }): Promise> { + return await this.sqlTransaction(async sql => { + const limit = args.limit ?? BlockLimitParamSchema.default; + const offset = args.offset ?? 0; + const filter = sql`tenure_height = ${args.height}`; + const blockCheck = await sql`SELECT burn_block_hash FROM blocks WHERE ${filter} LIMIT 1`; + if (blockCheck.count === 0) + throw new InvalidRequestError( + `Tenure height not found`, + InvalidRequestErrorType.invalid_param + ); + + const blocksQuery = await sql<(BlockQueryResult & { total: number })[]>` + WITH block_count AS ( + SELECT COUNT(*) AS count FROM blocks WHERE canonical = TRUE AND ${filter} + ) + SELECT + ${sql(BLOCK_COLUMNS)}, + (SELECT count FROM block_count)::int AS total + FROM blocks + WHERE canonical = true AND ${filter} + ORDER BY block_height DESC + LIMIT ${limit} + OFFSET ${offset} + `; + if (blocksQuery.count === 0) + return { + limit, + offset, + results: [], + total: 0, + }; + const blocks = blocksQuery.map(b => parseBlockQueryResult(b)); + return { + limit, + offset, + results: blocks, + total: blocksQuery[0].total, + }; + }); + } + async getBlock(args: BlockIdParam): Promise { return await this.sqlTransaction(async sql => { const filter =