diff --git a/migrations/1747318945115_blocks-tenure-height-index.js b/migrations/1747318945115_blocks-tenure-height-index.js new file mode 100644 index 000000000..6327ea557 --- /dev/null +++ b/migrations/1747318945115_blocks-tenure-height-index.js @@ -0,0 +1,11 @@ +/* eslint-disable camelcase */ + +exports.shorthands = undefined; + +exports.up = pgm => { + pgm.createIndex('blocks', ['tenure_height', { name: 'block_height', sort: 'DESC' }]); +}; + +exports.down = pgm => { + pgm.dropIndex('blocks', ['tenure_height', { name: 'block_height', sort: 'DESC' }]); +}; diff --git a/src/api/init.ts b/src/api/init.ts index b4b82691f..6c57bd763 100644 --- a/src/api/init.ts +++ b/src/api/init.ts @@ -58,6 +58,7 @@ import FastifyCors from '@fastify/cors'; import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; import * as promClient from 'prom-client'; import DeprecationPlugin from './deprecation-plugin'; +import { BlockTenureRoutes } from './routes/v2/block-tenures'; export interface ApiServer { fastifyApp: FastifyInstance; @@ -100,6 +101,7 @@ export const StacksApiRoutes: FastifyPluginAsync< async fastify => { await fastify.register(BlockRoutesV2, { prefix: '/blocks' }); await fastify.register(BurnBlockRoutesV2, { prefix: '/burn-blocks' }); + await fastify.register(BlockTenureRoutes, { prefix: '/block-tenures' }); await fastify.register(SmartContractRoutesV2, { prefix: '/smart-contracts' }); await fastify.register(MempoolRoutesV2, { prefix: '/mempool' }); await fastify.register(PoxRoutesV2, { prefix: '/pox' }); diff --git a/src/api/routes/v2/block-tenures.ts b/src/api/routes/v2/block-tenures.ts new file mode 100644 index 000000000..e5a3bbb26 --- /dev/null +++ b/src/api/routes/v2/block-tenures.ts @@ -0,0 +1,64 @@ +import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; +import { FastifyPluginAsync } from 'fastify'; +import { Server } from 'node:http'; +import { handleBlockCache } from '../../../../src/api/controllers/cache-controller'; +import { getPagingQueryLimit, ResourceType } from '../../../../src/api/pagination'; +import { CursorOffsetParam, LimitParam } from '../../../../src/api/schemas/params'; +import { BlockListV2ResponseSchema } from '../../../../src/api/schemas/responses/responses'; +import { BlockTenureParamsSchema } from './schemas'; +import { NotFoundError } from '../../../../src/errors'; +import { NakamotoBlock } from '../../../../src/api/schemas/entities/block'; +import { parseDbNakamotoBlock } from './helpers'; + +export const BlockTenureRoutes: FastifyPluginAsync< + Record, + Server, + TypeBoxTypeProvider +> = async fastify => { + fastify.get( + '/:tenure_height/blocks', + { + preHandler: handleBlockCache, + schema: { + operationId: 'get_tenure_blocks', + summary: 'Get blocks by tenure', + description: `Retrieves blocks confirmed in a block tenure`, + tags: ['Blocks'], + params: BlockTenureParamsSchema, + querystring: Type.Object({ + limit: LimitParam(ResourceType.Block), + offset: CursorOffsetParam({ resource: ResourceType.Block }), + cursor: Type.Optional(Type.String({ description: 'Cursor for pagination' })), + }), + response: { + 200: BlockListV2ResponseSchema, + }, + }, + }, + async (req, reply) => { + const offset = req.query.offset ?? 0; + const limit = getPagingQueryLimit(ResourceType.Block, req.query.limit); + const blockQuery = await fastify.db.v2.getBlocks({ + offset, + limit, + cursor: req.query.cursor, + tenureHeight: req.params.tenure_height, + }); + if (req.query.cursor && !blockQuery.current_cursor) { + throw new NotFoundError('Cursor not found'); + } + const blocks: NakamotoBlock[] = blockQuery.results.map(r => parseDbNakamotoBlock(r)); + await reply.send({ + limit: blockQuery.limit, + offset: blockQuery.offset, + total: blockQuery.total, + next_cursor: blockQuery.next_cursor, + prev_cursor: blockQuery.prev_cursor, + cursor: blockQuery.current_cursor, + results: blocks, + }); + } + ); + + await Promise.resolve(); +}; diff --git a/src/api/routes/v2/schemas.ts b/src/api/routes/v2/schemas.ts index 198f3b0c5..2671dd912 100644 --- a/src/api/routes/v2/schemas.ts +++ b/src/api/routes/v2/schemas.ts @@ -109,7 +109,6 @@ const BurnBlockHashParamSchema = Type.String({ description: 'Burn block hash', examples: ['0000000000000000000452773967cdd62297137cdaf79950c5e8bb0c62075133'], }); -export const CompiledBurnBlockHashParam = ajv.compile(BurnBlockHashParamSchema); const BurnBlockHeightParamSchema = Type.Integer({ title: 'Burn block height', @@ -153,6 +152,13 @@ const TransactionIdParamSchema = Type.String({ examples: ['0xf6bd5f4a7b26184a3466340b2e99fd003b4962c0e382a7e4b6a13df3dd7a91c6'], }); +const TenureHeightParamSchema = Type.Integer({ + minimum: 0, + title: 'Block tenure height', + description: 'Block tenure height', + examples: [165453], +}); + // ========================== // Query and path params // TODO: Migrate these to each endpoint after switching from Express to Fastify @@ -180,9 +186,6 @@ export type TransactionPaginationQueryParams = Static< const PoxCyclePaginationQueryParamsSchema = PaginationQueryParamsSchema(PoxCycleLimitParamSchema); export type PoxCyclePaginationQueryParams = Static; -const PoxSignerPaginationQueryParamsSchema = PaginationQueryParamsSchema(PoxSignerLimitParamSchema); -export type PoxSignerPaginationQueryParams = Static; - export const BlockParamsSchema = Type.Object( { height_or_hash: Type.Union([ @@ -205,7 +208,13 @@ export const BurnBlockParamsSchema = Type.Object( }, { additionalProperties: false } ); -export type BurnBlockParams = Static; + +export const BlockTenureParamsSchema = Type.Object( + { + tenure_height: TenureHeightParamSchema, + }, + { additionalProperties: false } +); export const SmartContractStatusParamsSchema = Type.Object( { @@ -228,4 +237,3 @@ export const AddressTransactionParamsSchema = Type.Object( }, { additionalProperties: false } ); -export type AddressTransactionParams = Static; diff --git a/src/datastore/pg-store-v2.ts b/src/datastore/pg-store-v2.ts index ce00d0937..17dc51d94 100644 --- a/src/datastore/pg-store-v2.ts +++ b/src/datastore/pg-store-v2.ts @@ -1,18 +1,13 @@ import { BasePgStoreModule, PgSqlClient, has0xPrefix } from '@hirosystems/api-toolkit'; import { BlockLimitParamSchema, - CompiledBurnBlockHashParam, TransactionPaginationQueryParams, TransactionLimitParamSchema, - BlockParams, - BurnBlockParams, BlockPaginationQueryParams, SmartContractStatusParams, AddressParams, - AddressTransactionParams, PoxCyclePaginationQueryParams, PoxCycleLimitParamSchema, - PoxSignerPaginationQueryParams, PoxSignerLimitParamSchema, BlockIdParam, BlockSignerSignatureLimitParamSchema, @@ -51,13 +46,6 @@ import { } from './helpers'; import { SyntheticPoxEventName } from '../pox-helpers'; -async function assertAddressExists(sql: PgSqlClient, address: string) { - const addressCheck = - await sql`SELECT principal FROM principal_stx_txs WHERE principal = ${address} LIMIT 1`; - if (addressCheck.count === 0) - throw new InvalidRequestError(`Address not found`, InvalidRequestErrorType.invalid_param); -} - async function assertTxIdExists(sql: PgSqlClient, tx_id: string) { const txCheck = await sql`SELECT tx_id FROM txs WHERE tx_id = ${tx_id} LIMIT 1`; if (txCheck.count === 0) @@ -69,11 +57,15 @@ export class PgStoreV2 extends BasePgStoreModule { limit: number; offset?: number; cursor?: string; + tenureHeight?: number; }): Promise> { return await this.sqlTransaction(async sql => { const limit = args.limit; const offset = args.offset ?? 0; const cursor = args.cursor ?? null; + const tenureFilter = args.tenureHeight + ? sql`AND tenure_height = ${args.tenureHeight}` + : sql``; const blocksQuery = await sql< (BlockQueryResult & { total: number; next_block_hash: string; prev_block_hash: string })[] @@ -82,7 +74,7 @@ export class PgStoreV2 extends BasePgStoreModule { WITH ordered_blocks AS ( SELECT *, LEAD(block_height, ${offset}) OVER (ORDER BY block_height DESC) offset_block_height FROM blocks - WHERE canonical = true + WHERE canonical = true ${tenureFilter} ORDER BY block_height DESC ) SELECT offset_block_height as block_height @@ -94,7 +86,8 @@ export class PgStoreV2 extends BasePgStoreModule { SELECT ${sql(BLOCK_COLUMNS)} FROM blocks WHERE canonical = true - AND block_height <= (SELECT block_height FROM cursor_block) + ${tenureFilter} + AND block_height <= (SELECT block_height FROM cursor_block) ORDER BY block_height DESC LIMIT ${limit} ), @@ -102,12 +95,13 @@ export class PgStoreV2 extends BasePgStoreModule { SELECT index_block_hash as prev_block_hash FROM blocks WHERE canonical = true - AND block_height < ( - SELECT block_height - FROM selected_blocks - ORDER BY block_height DESC - LIMIT 1 - ) + ${tenureFilter} + AND block_height < ( + SELECT block_height + FROM selected_blocks + ORDER BY block_height DESC + LIMIT 1 + ) ORDER BY block_height DESC OFFSET ${limit - 1} LIMIT 1 @@ -116,18 +110,26 @@ export class PgStoreV2 extends BasePgStoreModule { SELECT index_block_hash as next_block_hash FROM blocks WHERE canonical = true - AND block_height > ( - SELECT block_height - FROM selected_blocks - ORDER BY block_height DESC - LIMIT 1 - ) + ${tenureFilter} + AND block_height > ( + SELECT block_height + FROM selected_blocks + ORDER BY block_height DESC + LIMIT 1 + ) ORDER BY block_height ASC OFFSET ${limit - 1} LIMIT 1 + ), + block_count AS ( + SELECT ${ + args.tenureHeight + ? sql`(SELECT COUNT(*) FROM blocks WHERE tenure_height = ${args.tenureHeight})::int` + : sql`(SELECT block_count FROM chain_tip)::int` + } AS total ) SELECT - (SELECT block_count FROM chain_tip)::int AS total, + (SELECT total FROM block_count) AS total, sb.*, nb.next_block_hash, pb.prev_block_hash diff --git a/tests/api/block.test.ts b/tests/api/block.test.ts index a624dd74b..e00e43520 100644 --- a/tests/api/block.test.ts +++ b/tests/api/block.test.ts @@ -998,6 +998,167 @@ describe('block tests', () => { ); }); + test('blocks by tenure with cursor pagination', async () => { + for (let i = 1; i <= 14; i++) { + const block = new TestBlockBuilder({ + block_height: i, + block_hash: `0x11${i.toString().padStart(62, '0')}`, + index_block_hash: `0x${i.toString().padStart(64, '0')}`, + parent_index_block_hash: `0x${(i - 1).toString().padStart(64, '0')}`, + parent_block_hash: `0x${(i - 1).toString().padStart(64, '0')}`, + burn_block_height: 700000, + burn_block_hash: '0x00000000000000000001e2ee7f0c6bd5361b5e7afd76156ca7d6f524ee5ca3d8', + // Only include 9 in the latest tenure. + tenure_height: i > 5 ? 2 : 1, + }) + .addTx({ tx_id: `0x${i.toString().padStart(64, '0')}` }) + .build(); + await db.update(block); + } + + let body: BlockListV2Response; + + // Fetch latest page + ({ body } = await supertest(api.server).get(`/extended/v2/block-tenures/2/blocks?limit=3`)); + expect(body).toEqual( + expect.objectContaining({ + limit: 3, + offset: 0, + total: 9, + cursor: '0x0000000000000000000000000000000000000000000000000000000000000014', + next_cursor: null, + prev_cursor: '0x0000000000000000000000000000000000000000000000000000000000000011', + results: [ + expect.objectContaining({ height: 14 }), + expect.objectContaining({ height: 13 }), + expect.objectContaining({ height: 12 }), + ], + }) + ); + const latestPageCursor = body.cursor; + const latestBlock = body.results[0]; + + // Can fetch same page using cursor + ({ body } = await supertest(api.server).get( + `/extended/v2/block-tenures/2/blocks?limit=3&cursor=${body.cursor}` + )); + expect(body).toEqual( + expect.objectContaining({ + limit: 3, + offset: 0, + total: 9, + cursor: '0x0000000000000000000000000000000000000000000000000000000000000014', + next_cursor: null, + prev_cursor: '0x0000000000000000000000000000000000000000000000000000000000000011', + results: [ + expect.objectContaining({ height: 14 }), + expect.objectContaining({ height: 13 }), + expect.objectContaining({ height: 12 }), + ], + }) + ); + + // Fetch previous page + ({ body } = await supertest(api.server).get( + `/extended/v2/block-tenures/2/blocks?limit=3&cursor=${body.prev_cursor}` + )); + expect(body).toEqual( + expect.objectContaining({ + limit: 3, + offset: 0, + total: 9, + cursor: '0x0000000000000000000000000000000000000000000000000000000000000011', + next_cursor: '0x0000000000000000000000000000000000000000000000000000000000000014', + prev_cursor: '0x0000000000000000000000000000000000000000000000000000000000000008', + results: [ + expect.objectContaining({ height: 11 }), + expect.objectContaining({ height: 10 }), + expect.objectContaining({ height: 9 }), + ], + }) + ); + + // Oldest page has no prev_cursor + ({ body } = await supertest(api.server).get( + `/extended/v2/block-tenures/2/blocks?limit=3&cursor=0x0000000000000000000000000000000000000000000000000000000000000008` + )); + expect(body).toEqual( + expect.objectContaining({ + limit: 3, + offset: 0, + total: 9, + cursor: '0x0000000000000000000000000000000000000000000000000000000000000008', + next_cursor: '0x0000000000000000000000000000000000000000000000000000000000000011', + prev_cursor: null, + results: [ + expect.objectContaining({ height: 8 }), + expect.objectContaining({ height: 7 }), + expect.objectContaining({ height: 6 }), + ], + }) + ); + + // Offset + cursor works + ({ body } = await supertest(api.server).get( + `/extended/v2/block-tenures/2/blocks?limit=3&cursor=0x0000000000000000000000000000000000000000000000000000000000000011&offset=2` + )); + expect(body).toEqual( + expect.objectContaining({ + limit: 3, + offset: 2, + total: 9, + cursor: '0x0000000000000000000000000000000000000000000000000000000000000009', + next_cursor: '0x0000000000000000000000000000000000000000000000000000000000000012', + prev_cursor: '0x0000000000000000000000000000000000000000000000000000000000000006', + results: [ + expect.objectContaining({ height: 9 }), + expect.objectContaining({ height: 8 }), + expect.objectContaining({ height: 7 }), + ], + }) + ); + + // Negative offset + cursor + ({ body } = await supertest(api.server).get( + `/extended/v2/block-tenures/2/blocks?limit=3&cursor=0x0000000000000000000000000000000000000000000000000000000000000008&offset=-2` + )); + expect(body).toEqual( + expect.objectContaining({ + limit: 3, + offset: -2, + total: 9, + cursor: '0x0000000000000000000000000000000000000000000000000000000000000010', + next_cursor: '0x0000000000000000000000000000000000000000000000000000000000000013', + prev_cursor: '0x0000000000000000000000000000000000000000000000000000000000000007', + results: [ + expect.objectContaining({ height: 10 }), + expect.objectContaining({ height: 9 }), + expect.objectContaining({ height: 8 }), + ], + }) + ); + + // Offset (no cursor) works, has original behavior + ({ body } = await supertest(api.server).get( + `/extended/v2/block-tenures/2/blocks?limit=3&offset=5` + )); + expect(body).toEqual( + expect.objectContaining({ + limit: 3, + offset: 5, + total: 9, + cursor: '0x0000000000000000000000000000000000000000000000000000000000000009', + next_cursor: '0x0000000000000000000000000000000000000000000000000000000000000012', + prev_cursor: '0x0000000000000000000000000000000000000000000000000000000000000006', + results: [ + expect.objectContaining({ height: 9 }), + expect.objectContaining({ height: 8 }), + expect.objectContaining({ height: 7 }), + ], + }) + ); + }); + test('blocks v2 retrieved by hash or height', async () => { const blocks: DataStoreBlockUpdateData[] = []; for (let i = 1; i < 6; i++) {