Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions migrations/1779800000029_chain-tip-burn-block-count.ts
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');
}
38 changes: 38 additions & 0 deletions migrations/1779800000030_burn-blocks-time.ts
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');
}
2 changes: 2 additions & 0 deletions src/api/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import { PrincipalsRoutes } from './routes/v3/principals.js';
import { TransactionsRoutes } from './routes/v3/transactions.js';
import { MempoolRoutes } from './routes/v3/mempool.js';
import { SearchRoutes } from './routes/v3/search.js';
import { BitcoinBlocksRoutes } from './routes/v3/bitcoin-blocks.js';
import { BlocksRoutes } from './routes/v3/blocks.js';
import { StakingBondsRoutes } from './routes/v3/staking-bonds.js';
import { StakingCyclesRoutes } from './routes/v3/staking-cycles.js';
Expand Down Expand Up @@ -113,6 +114,7 @@ export const StacksApiRoutes: FastifyPluginAsync<

await fastify.register(
async fastify => {
await fastify.register(BitcoinBlocksRoutes);
await fastify.register(BlocksRoutes);
await fastify.register(FaucetsRoutes);
await fastify.register(MempoolRoutes);
Expand Down
190 changes: 190 additions & 0 deletions src/api/routes/v3/bitcoin-blocks.ts
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,
}),
Comment thread
rafa-stacks marked this conversation as resolved.
Resolved
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();
};
71 changes: 69 additions & 2 deletions src/api/routes/v3/blocks.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import { handleBlockCache } from '../../controllers/cache-controller.js';
import { handleBlockCache, handleChainTipCache } from '../../controllers/cache-controller.js';
import { serializeDbTransactionSummary } from '../../serializers/v3/transactions.js';
import { serializeDbBlock, serializeDbBlockSummary } from '../../serializers/v3/blocks.js';
import { BlockSchema } from '../../schemas/v3/entities/blocks.js';
import { FastifyPluginAsync } from 'fastify';
import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
import { Server } from 'node:http';
import { getPagingQueryLimit, ResourceType } from '../../pagination.js';
import { TransactionSummarySchema } from '../../schemas/v3/entities/transaction-summaries.js';
import {
BlockCursorSchema,
CursorPaginatedResponse,
CursorPaginationQuerystring,
TransactionCursorSchema,
} from '../../schemas/v3/cursors.js';
import { BlockHeightOrHashSchema } from '../../schemas/v3/entities/common.js';
import { BlockHeightOrHashSchema, BlockSummarySchema } from '../../schemas/v3/entities/common.js';
import { parseBlockParam } from '../v2/schemas.js';
import { InvalidRequestError, NotFoundError } from '../../../errors.js';
import { redirectUnprefixedHashParams } from '../../query-helpers.js';
Expand All @@ -20,6 +23,70 @@ export const BlocksRoutes: FastifyPluginAsync<
Server,
TypeBoxTypeProvider
> = async fastify => {
fastify.get(
'/blocks',
{
preHandler: handleChainTipCache,
schema: {
operationId: 'get_stacks_blocks',
summary: 'Get blocks',
description: 'Retrieves the summaries of canonical Stacks blocks, newest first',
tags: ['Blocks'],
querystring: CursorPaginationQuerystring(BlockCursorSchema, ResourceType.Block),
response: {
200: CursorPaginatedResponse(BlockSummarySchema, BlockCursorSchema, ResourceType.Block),
},
},
},
async (req, reply) => {
const results = await fastify.db.v3.getBlockSummaries({
limit: req.query.limit ?? getPagingQueryLimit(ResourceType.Block),
cursor: req.query.cursor,
});
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)),
});
}
);

fastify.get(
'/blocks/:height_or_hash',
{
onRequest: redirectUnprefixedHashParams({ params: ['height_or_hash'] }),
preHandler: handleBlockCache,
schema: {
operationId: 'get_stacks_block',
summary: 'Get block',
description:
'Retrieves a single canonical Stacks block with its details, by height, block hash, ' +
'index block hash, or `latest`',
tags: ['Blocks'],
params: Type.Object({
height_or_hash: BlockHeightOrHashSchema,
}),
Comment thread
rafa-stacks marked this conversation as resolved.
Resolved
response: {
200: BlockSchema,
},
},
},
async (req, reply) => {
const block = await fastify.db.v3.getBlock({
block: parseBlockParam(req.params.height_or_hash),
});
if (!block) {
throw new NotFoundError('Block not found');
}
await reply.send(serializeDbBlock(block));
}
);

fastify.get(
'/blocks/:height_or_hash/transactions',
{
Expand Down
13 changes: 13 additions & 0 deletions src/api/schemas/v3/cursors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,16 @@ export const StakerCursorSchema = Type.String({
"Cursor for paginating a signer's stakers (sorted by staker). Format: staker principal",
});
export type StakerCursor = Static<typeof StakerCursorSchema>;

export const BlockCursorSchema = Type.String({
pattern: '^[0-9]{1,10}$',
description: 'Cursor for paginating Stacks blocks (sorted by height, descending). Format: height',
});
export type BlockCursor = Static<typeof BlockCursorSchema>;

export const BitcoinBlockCursorSchema = Type.String({
pattern: '^[0-9]{1,10}$',
description:
'Cursor for paginating Bitcoin blocks (sorted by height, descending). Format: height',
});
export type BitcoinBlockCursor = Static<typeof BitcoinBlockCursorSchema>;
Loading
Loading