Repository navigation
feat(api): add v3 stacks and bitcoin block summary endpoints #2718
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 8 commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
4b63085
v3 block routes
rafa-stacks f9a346a
merge main
rafa-stacks c0b6ef0
estimate future single blocks
rafa-stacks 340feff
feat(api): return block details from the v3 single block endpoint
rafa-stacks 3894dea
feat(api): add tenure height, tx count and bitcoin anchor to v3 block…
rafa-stacks 43868db
feat(api): add stacks block and transaction counts to v3 bitcoin blocks
rafa-stacks 76892bf
fix(api): reject block heights outside the integer range in v3 block …
rafa-stacks a3e760f
feat(api): store bitcoin block header times on burn_blocks
rafa-stacks 7dc01b9
fix(api): fall back to the bitcoin block time for legacy v3 block tim…
rafa-stacks b3b2626
fix(api): fall back to the bitcoin block time for legacy zero block t…
rafa-stacks File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| import type { ColumnDefinitions, MigrationBuilder } from 'node-pg-migrate'; | ||
|
|
||
| export const shorthands: ColumnDefinitions | undefined = undefined; | ||
|
|
||
| /** | ||
| * Materialized count of canonical `burn_blocks` rows on `chain_tip`, so the paginated | ||
| * `/extended/v3/bitcoin-blocks` list reports its `total` from a single-row lookup instead of a | ||
| * `COUNT(*)` over `burn_blocks` on every request. Burn heights are not guaranteed contiguous | ||
| * (raw-event archives can be partially pruned), so the tip height cannot stand in for it. | ||
| * | ||
| * Maintained incrementally on burn block ingestion, delta-corrected on burnchain forks. | ||
| */ | ||
| export function up(pgm: MigrationBuilder): void { | ||
| pgm.addColumn('chain_tip', { | ||
| burn_block_count: { | ||
| type: 'integer', | ||
| notNull: true, | ||
| default: 0, | ||
| }, | ||
| }); | ||
| pgm.sql(` | ||
| UPDATE chain_tip SET | ||
| burn_block_count = (SELECT COUNT(*) FROM burn_blocks WHERE canonical = true) | ||
| `); | ||
| } | ||
|
|
||
| export function down(pgm: MigrationBuilder): void { | ||
| pgm.dropColumn('chain_tip', 'burn_block_count'); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import type { ColumnDefinitions, MigrationBuilder } from 'node-pg-migrate'; | ||
|
|
||
| export const shorthands: ColumnDefinitions | undefined = undefined; | ||
|
|
||
| /** | ||
| * Stores each Bitcoin block's header time on its `burn_blocks` row. The node reports a Bitcoin | ||
| * block's time only on the Stacks blocks anchored to it (`/new_burn_block` carries none), so the | ||
| * time is copied from the first Stacks block ingested for that Bitcoin block hash, whichever of the | ||
| * two arrives first. A header time belongs to the Bitcoin block hash, not to its canonical status, | ||
| * so re-orgs never need to revert it. Bitcoin blocks no Stacks block anchored to (e.g. missed | ||
| * sortitions) stay `NULL`. | ||
| * | ||
| * The backfill reads every `blocks` row once, aggregated per Bitcoin block hash (every Stacks | ||
| * block anchored to one hash reports the same header time). | ||
| */ | ||
| export const BACKFILL_BURN_BLOCK_TIMES_SQL = ` | ||
| UPDATE burn_blocks AS bb | ||
| SET burn_block_time = b.burn_block_time | ||
| FROM ( | ||
| SELECT burn_block_hash, MIN(burn_block_time) AS burn_block_time | ||
| FROM blocks | ||
| GROUP BY burn_block_hash | ||
| ) AS b | ||
| WHERE bb.burn_block_hash = b.burn_block_hash | ||
| `; | ||
|
|
||
| export function up(pgm: MigrationBuilder): void { | ||
| pgm.addColumn('burn_blocks', { | ||
| burn_block_time: { | ||
| type: 'integer', | ||
| }, | ||
| }); | ||
| pgm.sql(BACKFILL_BURN_BLOCK_TIMES_SQL); | ||
| } | ||
|
|
||
| export function down(pgm: MigrationBuilder): void { | ||
| pgm.dropColumn('burn_blocks', 'burn_block_time'); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,190 @@ | ||
| import { FastifyPluginAsync } from 'fastify'; | ||
| import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; | ||
| import { Server } from 'node:http'; | ||
| import { handleChainTipWithBurnchainTipCache } from '../../controllers/cache-controller.js'; | ||
| import { getPagingQueryLimit, ResourceType } from '../../pagination.js'; | ||
| import { | ||
| BitcoinBlockCursorSchema, | ||
| BlockCursorSchema, | ||
| CursorPaginatedResponse, | ||
| CursorPaginationQuerystring, | ||
| } from '../../schemas/v3/cursors.js'; | ||
| import { | ||
| BitcoinBlockSchema, | ||
| BlockHeightOrHashSchema, | ||
| BlockSummarySchema, | ||
| } from '../../schemas/v3/entities/common.js'; | ||
| import { serializeBitcoinBlock, serializeDbBlockSummary } from '../../serializers/v3/blocks.js'; | ||
| import { getBitcoinBlockTimeProjectionConfig } from '../../../datastore/bitcoin-block-time.js'; | ||
| import { parseBlockParam } from '../v2/schemas.js'; | ||
| import { NotFoundError } from '../../../errors.js'; | ||
| import { redirectUnprefixedHashParams } from '../../query-helpers.js'; | ||
|
|
||
| export const BitcoinBlocksRoutes: FastifyPluginAsync< | ||
| Record<never, never>, | ||
| Server, | ||
| TypeBoxTypeProvider | ||
| > = async fastify => { | ||
| const projectionConfig = getBitcoinBlockTimeProjectionConfig(fastify.chainId); | ||
|
|
||
| /** | ||
| * Resolves the interpolated or projected times of the Bitcoin heights without a stored header | ||
| * time, for {@link serializeBitcoinBlock}. Nothing to look up when every block has one. | ||
| */ | ||
| const getTimes = async (heights: number[]) => ({ | ||
| times: | ||
| heights.length > 0 | ||
| ? await fastify.db.v3.getBitcoinBlockTimes(heights, projectionConfig.paceWindowBlocks) | ||
| : { confirmed: new Map(), interpolated: new Map(), tip: null, paceWindowStart: null }, | ||
| config: projectionConfig, | ||
| }); | ||
|
|
||
| fastify.get( | ||
| '/bitcoin-blocks', | ||
| { | ||
| // Times are learned from the Stacks blocks anchored to each Bitcoin block, and projections | ||
| // follow the Stacks tip, so both tips feed the ETag. | ||
| preHandler: handleChainTipWithBurnchainTipCache, | ||
| schema: { | ||
| operationId: 'get_bitcoin_blocks', | ||
| summary: 'Get Bitcoin blocks', | ||
| description: | ||
| 'Retrieves canonical Bitcoin blocks, newest first. Each block carries its header time ' + | ||
| 'once the API has learned it from a Stacks block anchored to it, an interpolated time ' + | ||
| 'for a past block no Stacks block anchored to, and a projected time for the newest ' + | ||
| 'blocks no Stacks block has anchored to yet.', | ||
| tags: ['Bitcoin Blocks'], | ||
| querystring: CursorPaginationQuerystring(BitcoinBlockCursorSchema, ResourceType.BurnBlock), | ||
| response: { | ||
| 200: CursorPaginatedResponse( | ||
| BitcoinBlockSchema, | ||
| BitcoinBlockCursorSchema, | ||
| ResourceType.BurnBlock | ||
| ), | ||
| }, | ||
| }, | ||
| }, | ||
| async (req, reply) => { | ||
| const response = await fastify.db.sqlTransaction(async _sql => { | ||
| const results = await fastify.db.v3.getBitcoinBlockSummaries({ | ||
| limit: req.query.limit ?? getPagingQueryLimit(ResourceType.BurnBlock), | ||
| cursor: req.query.cursor, | ||
| }); | ||
| // Only blocks without a stored header time need one interpolated or projected. | ||
| const times = await getTimes( | ||
| results.results.filter(r => r.burn_block_time === null).map(r => r.burn_block_height) | ||
| ); | ||
| const counts = await fastify.db.v3.getBitcoinBlockStacksBlockCounts(results.results); | ||
| return { | ||
| limit: results.limit, | ||
| total: results.total, | ||
| cursor: { | ||
| next: results.next_cursor, | ||
| previous: results.prev_cursor, | ||
| current: results.current_cursor, | ||
| }, | ||
| results: results.results.map(r => serializeBitcoinBlock(r, times, counts)), | ||
| }; | ||
| }); | ||
| await reply.send(response); | ||
| } | ||
| ); | ||
|
|
||
| fastify.get( | ||
| '/bitcoin-blocks/:height_or_hash', | ||
| { | ||
| onRequest: redirectUnprefixedHashParams({ params: ['height_or_hash'] }), | ||
| preHandler: handleChainTipWithBurnchainTipCache, | ||
| schema: { | ||
| operationId: 'get_bitcoin_block', | ||
| summary: 'Get Bitcoin block', | ||
| description: | ||
| 'Retrieves a single canonical Bitcoin block, by height, hash, or `latest`, with its time ' + | ||
| '(see the Bitcoin blocks list). A height above the Bitcoin tip returns the future block ' + | ||
| 'with no hash and its projected time.', | ||
| tags: ['Bitcoin Blocks'], | ||
| params: Type.Object({ | ||
| height_or_hash: BlockHeightOrHashSchema, | ||
| }), | ||
| response: { | ||
| 200: BitcoinBlockSchema, | ||
| }, | ||
| }, | ||
| }, | ||
| async (req, reply) => { | ||
| const blockId = parseBlockParam(req.params.height_or_hash); | ||
| const response = await fastify.db.sqlTransaction(async _sql => { | ||
| const block = await fastify.db.v3.getBitcoinBlockSummary({ block: blockId }); | ||
| if (block) { | ||
| return serializeBitcoinBlock( | ||
| block, | ||
| await getTimes(block.burn_block_time === null ? [block.burn_block_height] : []), | ||
| await fastify.db.v3.getBitcoinBlockStacksBlockCounts([block]) | ||
| ); | ||
| } | ||
| // A height above the Bitcoin tip is a future block: it has no hash yet, only a projection. | ||
| if (blockId.type === 'height') { | ||
| const tip = await fastify.db.v3.getBitcoinBlockSummary({ | ||
| block: { type: 'latest', latest: true }, | ||
| }); | ||
| if (tip && blockId.height > tip.burn_block_height) { | ||
| return serializeBitcoinBlock( | ||
| { burn_block_height: blockId.height, burn_block_hash: null, burn_block_time: null }, | ||
| await getTimes([blockId.height]), | ||
| new Map() | ||
| ); | ||
| } | ||
| } | ||
| throw new NotFoundError('Bitcoin block not found'); | ||
| }); | ||
| await reply.send(response); | ||
| } | ||
| ); | ||
|
|
||
| fastify.get( | ||
| '/bitcoin-blocks/:height_or_hash/blocks', | ||
| { | ||
| onRequest: redirectUnprefixedHashParams({ params: ['height_or_hash'] }), | ||
| // Stacks blocks keep arriving for the current Bitcoin block, and a Bitcoin fork can replace | ||
| // the block a height or `latest` resolves to, so both tips feed the ETag. | ||
| preHandler: handleChainTipWithBurnchainTipCache, | ||
| schema: { | ||
| operationId: 'get_bitcoin_block_stacks_blocks', | ||
| summary: 'Get Bitcoin block Stacks blocks', | ||
| description: | ||
| 'Retrieves the summaries of the canonical Stacks blocks confirmed by a single canonical ' + | ||
| 'Bitcoin block, newest first', | ||
| tags: ['Bitcoin Blocks'], | ||
| params: Type.Object({ | ||
| height_or_hash: BlockHeightOrHashSchema, | ||
| }), | ||
| querystring: CursorPaginationQuerystring(BlockCursorSchema, ResourceType.Block), | ||
| response: { | ||
| 200: CursorPaginatedResponse(BlockSummarySchema, BlockCursorSchema, ResourceType.Block), | ||
| }, | ||
| }, | ||
| }, | ||
| async (req, reply) => { | ||
| const results = await fastify.db.v3.getBitcoinBlockStacksBlockSummaries({ | ||
| block: parseBlockParam(req.params.height_or_hash), | ||
| limit: req.query.limit ?? getPagingQueryLimit(ResourceType.Block), | ||
| cursor: req.query.cursor, | ||
| }); | ||
| if (!results) { | ||
| throw new NotFoundError('Bitcoin block not found'); | ||
| } | ||
| await reply.send({ | ||
| limit: results.limit, | ||
| total: results.total, | ||
| cursor: { | ||
| next: results.next_cursor, | ||
| previous: results.prev_cursor, | ||
| current: results.current_cursor, | ||
| }, | ||
| results: results.results.map(r => serializeDbBlockSummary(r)), | ||
| }); | ||
| } | ||
| ); | ||
|
|
||
| await Promise.resolve(); | ||
| }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.